From e32d59ca442c7846bb32055b6ad94cedffd8f796 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 16 Nov 2025 20:52:18 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=86=B5=ED=95=A9(=EA=B6=8C=EC=9E=A5=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/make-verify-json.js: 산출물 폴더 → extras/assets JSON 생성 - scripts/verify.js: JSON 입력으로 무결성 검증(종료코드 반영) - package.json: verify 스크립트 추가 - .gitea/workflows/ci.yml: ./artifacts/export 존재 시 검증 스텝 실행 --- .gitea/workflows/ci.yml | 12 ++++ lib/exporter/verify.serialize.test.ts | 48 ++++++++++++++++ lib/exporter/verify.serialize.ts | 47 +++++++++++++++ package.json | 4 +- scripts/make-verify-json.js | 82 +++++++++++++++++++++++++++ scripts/verify.js | 78 +++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 lib/exporter/verify.serialize.test.ts create mode 100644 lib/exporter/verify.serialize.ts create mode 100644 scripts/make-verify-json.js create mode 100644 scripts/verify.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9e6d35e..9edbbee 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,6 +43,18 @@ jobs: if-no-files-found: ignore retention-days: 3 + - name: Verify bundle integrity (optional) + run: | + set -euo pipefail + if [ -d ./artifacts/export ]; then + echo "Found ./artifacts/export; generating verify inputs" + npm run verify:make-json + echo "Running verify CLI" + npm run verify:run + else + echo "No ./artifacts/export; skipping integrity verification" + fi + - name: Auto-merge PR on green if: ${{ github.event_name == 'pull_request' }} env: diff --git a/lib/exporter/verify.serialize.test.ts b/lib/exporter/verify.serialize.test.ts new file mode 100644 index 0000000..533ef5d --- /dev/null +++ b/lib/exporter/verify.serialize.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { generateVerifyInputs } from '@/lib/exporter/verify.serialize' + +// helper to b64 +function toB64(u8: Uint8Array) { + if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64') + throw new Error('Buffer not available') +} + +describe('verify.serialize (from folder maps)', () => { + it('collects assets.integrity.index.json into extras and assets/* into assets map', async () => { + // virtual FS + const files: Record = { + '/artifacts/export/assets.integrity.index.json': new TextEncoder().encode(JSON.stringify({ entries: [], bundleHash: 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' })), + '/artifacts/export/index.html': new TextEncoder().encode(''), + '/artifacts/export/assets/img/a.png': new Uint8Array([1,2,3]), + '/artifacts/export/assets/fonts/f.woff2': new Uint8Array([4,5,6,7]), + } + const listDir = async (dir: string): Promise => { + // return child paths (recursive listing simulated by filtering) + return Object.keys(files).filter((p) => p.startsWith(dir)).map((p) => p) + } + const readFile = async (p: string) => { + const v = files[p] + if (!v) throw new Error('not found: ' + p) + return v + } + const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile) + // extras must have only the integrity index + expect(Object.keys(extras)).toEqual(['assets.integrity.index.json']) + expect(extras['assets.integrity.index.json']).toBe(toB64(files['/artifacts/export/assets.integrity.index.json'])) + // assets must include assets/* files only + expect(Object.keys(assets).sort()).toEqual(['assets/fonts/f.woff2','assets/img/a.png']) + expect(assets['assets/img/a.png']).toBe(toB64(files['/artifacts/export/assets/img/a.png'])) + }) + + it('ignores non-asset files and missing integrity index', async () => { + const files: Record = { + '/artifacts/export/index.html': new TextEncoder().encode(''), + '/artifacts/export/assets/img/a.png': new Uint8Array([9,9]), + } + const listDir = async (dir: string) => Object.keys(files).filter((p) => p.startsWith(dir)) + const readFile = async (p: string) => files[p]! + const { extras, assets } = await generateVerifyInputs('/artifacts/export', listDir, readFile) + expect(Object.keys(extras)).toEqual([]) + expect(Object.keys(assets)).toEqual(['assets/img/a.png']) + }) +}) diff --git a/lib/exporter/verify.serialize.ts b/lib/exporter/verify.serialize.ts new file mode 100644 index 0000000..73cab33 --- /dev/null +++ b/lib/exporter/verify.serialize.ts @@ -0,0 +1,47 @@ +export type ListDir = (dir: string) => Promise +export type ReadFile = (path: string) => Promise + +function toBase64(u8: Uint8Array): string { + if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64') + throw new Error('Buffer not available') +} + +// Generate inputs for verify CLI from a folder tree. +// - root: export folder (contains assets.integrity.index.json and assets/*) +// - listDir: returns list of file paths under root (can be recursive listing) +// - readFile: reads a file into bytes +export async function generateVerifyInputs( + root: string, + listDir: ListDir, + readFile: ReadFile +): Promise<{ extras: Record; assets: Record }> { + const all = await listDir(root) + const norm = (p: string) => p.replace(/\\/g, '/') + const rootNorm = norm(root).replace(/\/$/, '') + const rel = (p: string) => { + const pn = norm(p) + return pn.startsWith(rootNorm + '/') ? pn.slice(rootNorm.length + 1) : pn + } + + const extras: Record = {} + const assets: Record = {} + + // assets.integrity.index.json at root + const integrityPath = all.find((p) => rel(p) === 'assets.integrity.index.json') + if (integrityPath) { + const bytes = await readFile(integrityPath) + extras['assets.integrity.index.json'] = toBase64(bytes) + } + + // collect assets/**/* + for (const abs of all) { + const r = rel(abs) + if (!r.startsWith('assets/')) continue + // skip directories: best-effort by checking trailing slash + if (r.endsWith('/')) continue + const bytes = await readFile(abs) + assets[r] = toBase64(bytes) + } + + return { extras, assets } +} diff --git a/package.json b/package.json index a8adf9e..f86dac9 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "test": "vitest run", "test:watch": "vitest -w", "coverage": "vitest run --coverage", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "verify:make-json": "node scripts/make-verify-json.js --root ./artifacts/export --extras ./artifacts/extras.json --assets ./artifacts/assets.json", + "verify:run": "node scripts/verify.js --extras ./artifacts/extras.json --assets ./artifacts/assets.json" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/make-verify-json.js b/scripts/make-verify-json.js new file mode 100644 index 0000000..7cdf24d --- /dev/null +++ b/scripts/make-verify-json.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/* + Generate verify inputs from an export folder + Usage: node scripts/make-verify-json.js --root --extras --assets +*/ +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); + +function parseArgs(argv) { + const out = { root: '', extras: '', assets: '' }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--root') out.root = argv[++i]; + else if (a === '--extras') out.extras = argv[++i]; + else if (a === '--assets') out.assets = argv[++i]; + } + return out; +} + +function toB64(buf) { return Buffer.from(buf).toString('base64'); } + +async function readFileB64(p) { const b = await fsp.readFile(p); return toB64(b); } + +async function walk(dir) { + const out = []; + const entries = await fsp.readdir(dir, { withFileTypes: true }); + for (const ent of entries) { + const abs = path.join(dir, ent.name); + if (ent.isDirectory()) { + const sub = await walk(abs); + out.push(...sub); + } else if (ent.isFile()) { + out.push(abs); + } + } + return out; +} + +(async () => { + try { + const { root, extras, assets } = parseArgs(process.argv); + if (!root || !extras || !assets) { + console.log('Usage: node scripts/make-verify-json.js --root --extras --assets '); + process.exit(2); + } + const rootAbs = path.resolve(process.cwd(), root); + const files = await walk(rootAbs); + const norm = (p) => p.split(path.sep).join('/'); + const rootNorm = norm(rootAbs).replace(/\/$/, ''); + const rel = (p) => { + const pn = norm(p); + return pn.startsWith(rootNorm + '/') ? pn.slice(rootNorm.length + 1) : pn; + }; + + const extrasMap = {}; + const assetsMap = {}; + + // integrity index + const idx = files.find((p) => rel(p) === 'assets.integrity.index.json'); + if (idx) { + extrasMap['assets.integrity.index.json'] = await readFileB64(idx); + } + + // assets/**/* + for (const abs of files) { + const r = rel(abs); + if (!r.startsWith('assets/')) continue; + const b64 = await readFileB64(abs); + assetsMap[r] = b64; + } + + await fsp.mkdir(path.dirname(path.resolve(process.cwd(), extras)), { recursive: true }); + await fsp.mkdir(path.dirname(path.resolve(process.cwd(), assets)), { recursive: true }); + await fsp.writeFile(extras, JSON.stringify(extrasMap)); + await fsp.writeFile(assets, JSON.stringify(assetsMap)); + console.log(`Wrote ${extras} and ${assets}`); + } catch (e) { + console.error(e && e.stack || String(e)); + process.exit(1); + } +})(); diff --git a/scripts/verify.js b/scripts/verify.js new file mode 100644 index 0000000..532c00b --- /dev/null +++ b/scripts/verify.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/* + Verify bundle integrity using extras/assets JSON maps. + Usage: node scripts/verify.js --extras --assets +*/ +const fs = require('fs/promises'); +const crypto = require('crypto'); + +function parseArgs(argv) { + const out = { extras: '', assets: '' }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--extras') out.extras = argv[++i]; + else if (a === '--assets') out.assets = argv[++i]; + } + return out; +} + +function fromB64(b64) { return Buffer.from(b64, 'base64'); } +function sha256b64(buf) { return crypto.createHash('sha256').update(buf).digest('base64'); } + +(async () => { + try { + const { extras, assets } = parseArgs(process.argv); + if (!extras || !assets) { + console.log('Usage: node scripts/verify.js --extras --assets '); + process.exit(2); + } + const extrasJson = await fs.readFile(extras, 'utf-8'); + const assetsJson = await fs.readFile(assets, 'utf-8'); + const extrasMap = JSON.parse(extrasJson); + const assetsMap = JSON.parse(assetsJson); + + const result = { ok: true, errors: [], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } }; + + if (!Object.prototype.hasOwnProperty.call(extrasMap, 'assets.integrity.index.json')) { + result.ok = false; result.errors.push('Missing assets.integrity.index.json in extras'); + console.log(JSON.stringify(result, null, 2)); process.exit(1); + } + + let parsed; + try { + parsed = JSON.parse(fromB64(extrasMap['assets.integrity.index.json']).toString('utf-8')); + } catch { + result.ok = false; result.errors.push('Invalid assets.integrity.index.json JSON'); + console.log(JSON.stringify(result, null, 2)); process.exit(1); + } + + const recomputedLines = []; + let missing = 0, integrityMismatches = 0, sizeMismatches = 0; + for (const e of parsed.entries) { + const b64 = assetsMap[e.path]; + if (!b64) { result.errors.push(`Missing asset for path in integrity index: ${e.path}`); missing++; continue; } + const buf = fromB64(b64); + if (typeof e.size === 'number' && e.size !== buf.length) { + result.errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${buf.length}`); sizeMismatches++; } + const integ = 'sha256-' + sha256b64(buf); + if (integ !== e.integrity) { result.errors.push(`Integrity mismatch for ${e.path}`); integrityMismatches++; } + recomputedLines.push(e.path); recomputedLines.push(integ); + } + const bundle = 'sha256-' + sha256b64(Buffer.from(recomputedLines.join('\n'), 'utf-8')); + const bundleHashMismatch = bundle !== parsed.bundleHash; + if (bundleHashMismatch) result.errors.push('BundleHash mismatch'); + + result.summary.total = parsed.entries.length; + result.summary.missing = missing; + result.summary.integrityMismatches = integrityMismatches; + result.summary.sizeMismatches = sizeMismatches; + result.summary.bundleHashMismatch = bundleHashMismatch; + result.ok = result.errors.length === 0; + + console.log(JSON.stringify(result, null, 2)); + process.exit(result.ok ? 0 : 1); + } catch (e) { + console.error(e && e.stack || String(e)); + process.exit(1); + } +})(); -- 2.52.0