222 lines
9.5 KiB
JavaScript
222 lines
9.5 KiB
JavaScript
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 reachableAssetFiles = new Set(
|
|
[...allReachable].flatMap((key) => manifest[key].assets ?? []),
|
|
);
|
|
const emittedAssetRecords = new Set(
|
|
Object.entries(manifest).flatMap(([key, record]) => {
|
|
const isEmittedAssetRecord =
|
|
reachableAssetFiles.has(record.file) &&
|
|
record.isEntry === undefined &&
|
|
record.isDynamicEntry === undefined &&
|
|
(record.imports?.length ?? 0) === 0 &&
|
|
(record.dynamicImports?.length ?? 0) === 0 &&
|
|
(record.css?.length ?? 0) === 0 &&
|
|
(record.assets?.length ?? 0) === 0;
|
|
return isEmittedAssetRecord ? [key] : [];
|
|
}),
|
|
);
|
|
const unreachable = Object.keys(manifest).filter(
|
|
(key) => !allReachable.has(key) && !emittedAssetRecords.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`;
|