Merge pull request 'auto: PR for feat/verify-integrity-cli' (#69) from feat/verify-integrity-cli into main
CI / test (push) Successful in 55s
CI / test (push) Successful in 55s
This commit was merged in pull request #69.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<string, Uint8Array> = {
|
||||
'/artifacts/export/assets.integrity.index.json': new TextEncoder().encode(JSON.stringify({ entries: [], bundleHash: 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' })),
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/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<string[]> => {
|
||||
// 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<string, Uint8Array> = {
|
||||
'/artifacts/export/index.html': new TextEncoder().encode('<!doctype html>'),
|
||||
'/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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export type ListDir = (dir: string) => Promise<string[]>
|
||||
export type ReadFile = (path: string) => Promise<Uint8Array>
|
||||
|
||||
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<string, string>; assets: Record<string, string> }> {
|
||||
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<string, string> = {}
|
||||
const assets: Record<string, string> = {}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Generate verify inputs from an export folder
|
||||
Usage: node scripts/make-verify-json.js --root <export_folder> --extras <out_extras.json> --assets <out_assets.json>
|
||||
*/
|
||||
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 <export_folder> --extras <out_extras.json> --assets <out_assets.json>');
|
||||
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);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
Verify bundle integrity using extras/assets JSON maps.
|
||||
Usage: node scripts/verify.js --extras <extras.json> --assets <assets.json>
|
||||
*/
|
||||
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 <extras.json> --assets <assets.json>');
|
||||
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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user