fix: harden prerender publication boundary
This commit is contained in:
203
scripts/build-contract.mjs
Normal file
203
scripts/build-contract.mjs
Normal file
@@ -0,0 +1,203 @@
|
||||
const allowedManifestFields = new Set([
|
||||
'file', 'name', 'src', 'isEntry', 'isDynamicEntry', 'imports', 'dynamicImports', 'css', 'assets',
|
||||
]);
|
||||
const safeOutputPath = /^assets\/[a-zA-Z0-9][a-zA-Z0-9._-]*$/u;
|
||||
const safeBuildId = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
|
||||
const sorted = (values) => [...new Set(values)].sort();
|
||||
|
||||
const assertStringArray = (value, label) => {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
||||
throw new Error(`${label} must be an array of strings.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const validateRecord = (key, record) => {
|
||||
if (typeof record !== 'object' || record === null || Array.isArray(record)) {
|
||||
throw new Error(`Vite manifest record must be an object: ${key}`);
|
||||
}
|
||||
for (const field of Object.keys(record)) {
|
||||
if (!allowedManifestFields.has(field)) throw new Error(`Unknown Vite manifest field: ${field}`);
|
||||
}
|
||||
if (typeof record.file !== 'string' || !safeOutputPath.test(record.file)) {
|
||||
throw new Error(`Vite manifest contains an unsafe emitted path: ${String(record.file)}`);
|
||||
}
|
||||
for (const field of ['css', 'assets']) {
|
||||
for (const path of assertStringArray(record[field], `Vite manifest ${field}`)) {
|
||||
if (!safeOutputPath.test(path)) throw new Error(`Vite manifest contains an unsafe emitted path: ${path}`);
|
||||
}
|
||||
}
|
||||
for (const field of ['imports', 'dynamicImports']) {
|
||||
assertStringArray(record[field], `Vite manifest ${field}`);
|
||||
}
|
||||
for (const field of ['src', 'name']) {
|
||||
if (record[field] !== undefined && typeof record[field] !== 'string') {
|
||||
throw new Error(`Vite manifest ${field} must be a string.`);
|
||||
}
|
||||
}
|
||||
for (const field of ['isEntry', 'isDynamicEntry']) {
|
||||
if (record[field] !== undefined && typeof record[field] !== 'boolean') {
|
||||
throw new Error(`Vite manifest ${field} must be boolean.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveViteAssetClosure = (manifest) => {
|
||||
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
|
||||
throw new Error('Vite manifest must be an object.');
|
||||
}
|
||||
const entry = manifest['index.html'];
|
||||
if (entry === undefined || entry.isEntry !== true || entry.src !== 'index.html') {
|
||||
throw new Error('Vite manifest must contain the exact index.html entry.');
|
||||
}
|
||||
for (const [key, record] of Object.entries(manifest)) {
|
||||
validateRecord(key, record);
|
||||
if (key !== 'index.html' && record.isEntry === true) {
|
||||
throw new Error(`Unexpected additional Vite entry: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
const allReachable = new Set();
|
||||
const staticReachable = new Set();
|
||||
const visit = (key, staticImport) => {
|
||||
const record = manifest[key];
|
||||
if (record === undefined) throw new Error(`Missing manifest import: ${key}`);
|
||||
if (staticImport) staticReachable.add(key);
|
||||
if (allReachable.has(key)) return;
|
||||
allReachable.add(key);
|
||||
for (const importedKey of record.imports ?? []) visit(importedKey, staticImport);
|
||||
for (const importedKey of record.dynamicImports ?? []) visit(importedKey, false);
|
||||
};
|
||||
visit('index.html', true);
|
||||
|
||||
const unreachable = Object.keys(manifest).filter((key) => !allReachable.has(key));
|
||||
if (unreachable.length > 0) throw new Error(`Unreachable manifest record: ${unreachable.join(', ')}`);
|
||||
|
||||
const executableModules = [];
|
||||
const modulePreloads = [];
|
||||
const styles = [];
|
||||
const viteAssets = [];
|
||||
for (const key of allReachable) {
|
||||
const record = manifest[key];
|
||||
executableModules.push(record.file);
|
||||
if (staticReachable.has(key)) modulePreloads.push(record.file);
|
||||
styles.push(...(record.css ?? []));
|
||||
viteAssets.push(...(record.assets ?? []));
|
||||
}
|
||||
|
||||
return {
|
||||
entryModule: entry.file,
|
||||
executableModules: sorted(executableModules),
|
||||
modulePreloads: sorted(modulePreloads),
|
||||
styles: sorted(styles),
|
||||
viteAssets: sorted(viteAssets),
|
||||
};
|
||||
};
|
||||
|
||||
const assertSortedUniquePaths = (value, label) => {
|
||||
if (!Array.isArray(value) || value.some((path) => typeof path !== 'string' || !safeOutputPath.test(path))) {
|
||||
throw new Error(`${label} must contain only safe asset paths.`);
|
||||
}
|
||||
if (JSON.stringify(value) !== JSON.stringify(sorted(value))) {
|
||||
throw new Error(`${label} must be sorted and unique.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const parseArtifactContract = (input) => {
|
||||
const fields = [
|
||||
'schema', 'buildId', 'entryModule', 'executableModules', 'modulePreloads', 'styles', 'viteAssets', 'publicAssets', 'files',
|
||||
];
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input) ||
|
||||
JSON.stringify(Object.keys(input).sort()) !== JSON.stringify([...fields].sort())) {
|
||||
throw new Error('Artifact contract has unknown or missing fields.');
|
||||
}
|
||||
if (input.schema !== 'portfolio-artifact-contract/v1' ||
|
||||
typeof input.buildId !== 'string' || !safeBuildId.test(input.buildId) ||
|
||||
typeof input.entryModule !== 'string' || !safeOutputPath.test(input.entryModule)) {
|
||||
throw new Error('Artifact contract identity is invalid.');
|
||||
}
|
||||
const executableModules = assertSortedUniquePaths(input.executableModules, 'Executable modules');
|
||||
const modulePreloads = assertSortedUniquePaths(input.modulePreloads, 'Module preloads');
|
||||
const styles = assertSortedUniquePaths(input.styles, 'Styles');
|
||||
const viteAssets = assertSortedUniquePaths(input.viteAssets, 'Vite assets');
|
||||
const publicAssets = assertSortedUniquePaths(input.publicAssets, 'Public assets');
|
||||
if (!executableModules.includes(input.entryModule) || !modulePreloads.includes(input.entryModule) ||
|
||||
modulePreloads.some((path) => !executableModules.includes(path))) {
|
||||
throw new Error('Artifact contract module closure is inconsistent.');
|
||||
}
|
||||
if (!Array.isArray(input.files)) throw new Error('Artifact contract files must be an array.');
|
||||
const files = input.files.map((record) => {
|
||||
if (typeof record !== 'object' || record === null || Array.isArray(record) ||
|
||||
JSON.stringify(Object.keys(record).sort()) !== JSON.stringify(['path', 'sha256']) ||
|
||||
typeof record.path !== 'string' || !safeOutputPath.test(record.path) ||
|
||||
typeof record.sha256 !== 'string' || !sha256Pattern.test(record.sha256)) {
|
||||
throw new Error('Artifact contract file record is invalid.');
|
||||
}
|
||||
return record;
|
||||
});
|
||||
if (JSON.stringify(files.map((record) => record.path)) !== JSON.stringify(sorted(files.map((record) => record.path)))) {
|
||||
throw new Error('Artifact contract files must be sorted and unique.');
|
||||
}
|
||||
const expectedFiles = sorted([...executableModules, ...styles, ...viteAssets, ...publicAssets]);
|
||||
if (JSON.stringify(files.map((record) => record.path)) !== JSON.stringify(expectedFiles)) {
|
||||
throw new Error('Artifact contract file inventory does not equal its trusted closure.');
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
export const createArtifactContract = (closure, publicManifest, digestByPath, buildId) => {
|
||||
const publicAssets = sorted(publicManifest.assets.map((asset) => `assets/${asset.fileName}`));
|
||||
const expectedPaths = sorted([
|
||||
...closure.executableModules,
|
||||
...closure.styles,
|
||||
...closure.viteAssets,
|
||||
...publicAssets,
|
||||
]);
|
||||
const digestPaths = Object.keys(digestByPath).sort();
|
||||
if (JSON.stringify(digestPaths) !== JSON.stringify(expectedPaths)) {
|
||||
throw new Error('Asset digests must exactly cover the trusted artifact closure.');
|
||||
}
|
||||
return parseArtifactContract({
|
||||
schema: 'portfolio-artifact-contract/v1',
|
||||
buildId,
|
||||
entryModule: closure.entryModule,
|
||||
executableModules: sorted(closure.executableModules),
|
||||
modulePreloads: sorted(closure.modulePreloads),
|
||||
styles: sorted(closure.styles),
|
||||
viteAssets: sorted(closure.viteAssets),
|
||||
publicAssets,
|
||||
files: expectedPaths.map((path) => ({ path, sha256: digestByPath[path] })),
|
||||
});
|
||||
};
|
||||
|
||||
export const serializeArtifactContract = (contract) => `${JSON.stringify(parseArtifactContract(contract), null, 2)}\n`;
|
||||
|
||||
export const createBuildAuthority = (viteManifest, artifactContract) => ({
|
||||
schema: 'portfolio-build-authority/v1',
|
||||
viteManifest,
|
||||
artifactContract: parseArtifactContract(artifactContract),
|
||||
});
|
||||
|
||||
export const parseBuildAuthority = (input) => {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input) ||
|
||||
JSON.stringify(Object.keys(input).sort()) !==
|
||||
JSON.stringify(['artifactContract', 'schema', 'viteManifest'])) {
|
||||
throw new Error('Build authority has unknown or missing fields.');
|
||||
}
|
||||
if (input.schema !== 'portfolio-build-authority/v1') throw new Error('Build authority schema is invalid.');
|
||||
const closure = resolveViteAssetClosure(input.viteManifest);
|
||||
const contract = parseArtifactContract(input.artifactContract);
|
||||
for (const field of ['entryModule', 'executableModules', 'modulePreloads', 'styles', 'viteAssets']) {
|
||||
if (JSON.stringify(contract[field]) !== JSON.stringify(closure[field])) {
|
||||
throw new Error(`Build authority manifest disagrees with artifact contract: ${field}`);
|
||||
}
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
export const serializeBuildAuthority = (authority) =>
|
||||
`${JSON.stringify(parseBuildAuthority(authority), null, 2)}\n`;
|
||||
Reference in New Issue
Block a user