fix: harden prerender publication boundary
This commit is contained in:
40
scripts/build-contract.d.mts
Normal file
40
scripts/build-contract.d.mts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ViteManifest } from './prerender.mjs';
|
||||
|
||||
export interface ViteAssetClosure {
|
||||
entryModule: string;
|
||||
executableModules: string[];
|
||||
modulePreloads: string[];
|
||||
styles: string[];
|
||||
viteAssets: string[];
|
||||
}
|
||||
|
||||
export interface ArtifactContractFile {
|
||||
path: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ArtifactContract extends ViteAssetClosure {
|
||||
schema: 'portfolio-artifact-contract/v1';
|
||||
buildId: string;
|
||||
publicAssets: string[];
|
||||
files: ArtifactContractFile[];
|
||||
}
|
||||
|
||||
export interface BuildAuthority {
|
||||
schema: 'portfolio-build-authority/v1';
|
||||
viteManifest: ViteManifest;
|
||||
artifactContract: ArtifactContract;
|
||||
}
|
||||
|
||||
export function resolveViteAssetClosure(manifest: ViteManifest): ViteAssetClosure;
|
||||
export function parseArtifactContract(input: unknown): ArtifactContract;
|
||||
export function createArtifactContract(
|
||||
closure: ViteAssetClosure,
|
||||
publicManifest: { assets: ReadonlyArray<{ fileName: string }> },
|
||||
digestByPath: Readonly<Record<string, string>>,
|
||||
buildId: string,
|
||||
): ArtifactContract;
|
||||
export function serializeArtifactContract(contract: ArtifactContract): string;
|
||||
export function createBuildAuthority(viteManifest: ViteManifest, artifactContract: ArtifactContract): BuildAuthority;
|
||||
export function parseBuildAuthority(input: unknown): BuildAuthority;
|
||||
export function serializeBuildAuthority(authority: BuildAuthority): string;
|
||||
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`;
|
||||
1
scripts/config-argument.d.mts
Normal file
1
scripts/config-argument.d.mts
Normal file
@@ -0,0 +1 @@
|
||||
export function parseExactConfigArgument(arguments_: string[]): string;
|
||||
16
scripts/config-argument.mjs
Normal file
16
scripts/config-argument.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
export const parseExactConfigArgument = (arguments_) => {
|
||||
if (arguments_.length !== 2 || arguments_[0] !== '--config') {
|
||||
throw new Error('Prerender accepts exactly --config <path>.');
|
||||
}
|
||||
const configPath = arguments_[1];
|
||||
if (
|
||||
configPath.length === 0 ||
|
||||
configPath.startsWith('/') ||
|
||||
configPath.includes('\\') ||
|
||||
configPath.split('/').some((part) => part === '' || part === '.' || part === '..') ||
|
||||
!/^config\/[a-z0-9][a-z0-9._-]*\.json$/u.test(configPath)
|
||||
) {
|
||||
throw new Error('Config must be a safe repository-relative JSON path inside config/.');
|
||||
}
|
||||
return configPath;
|
||||
};
|
||||
@@ -6,12 +6,13 @@ export interface ViteManifestEntry {
|
||||
isEntry?: boolean;
|
||||
css?: string[];
|
||||
imports?: string[];
|
||||
dynamicImports?: string[];
|
||||
assets?: string[];
|
||||
}
|
||||
|
||||
export type ViteManifest = Record<string, ViteManifestEntry>;
|
||||
|
||||
export function parseExactConfigArgument(arguments_: string[]): string;
|
||||
export function assertExpectedDistInputs(distRoot: string): Promise<void>;
|
||||
export function assemblePrerenderedHtml(
|
||||
template: string,
|
||||
manifest: ViteManifest,
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { cp, lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadProductionInputs } from '../src/content/load-production-inputs.ts';
|
||||
import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts';
|
||||
import { serializeJsonLd } from '../src/security/serialize-json-ld.ts';
|
||||
import {
|
||||
createArtifactContract,
|
||||
createBuildAuthority,
|
||||
resolveViteAssetClosure,
|
||||
serializeBuildAuthority,
|
||||
} from './build-contract.mjs';
|
||||
import { parseExactConfigArgument } from './config-argument.mjs';
|
||||
import { captureDirectoryIdentity, publishStagedDirectory } from './safe-publication.mjs';
|
||||
import { verifyArtifactDirectory } from './verify-artifacts.mjs';
|
||||
|
||||
export { parseExactConfigArgument } from './config-argument.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repositoryRoot = resolve(dirname(scriptPath), '..');
|
||||
@@ -19,33 +32,6 @@ const exactlyOne = (source, expression, label) => {
|
||||
if (matches.length !== 1) throw new Error(`Expected exactly one ${label}.`);
|
||||
};
|
||||
|
||||
export const parseExactConfigArgument = (arguments_) => {
|
||||
if (arguments_.length !== 2 || arguments_[0] !== '--config') {
|
||||
throw new Error('Prerender accepts exactly --config <path>.');
|
||||
}
|
||||
const configPath = arguments_[1];
|
||||
if (
|
||||
configPath.length === 0 ||
|
||||
configPath.startsWith('/') ||
|
||||
configPath.includes('\\') ||
|
||||
configPath.split('/').some((part) => part === '' || part === '.' || part === '..') ||
|
||||
!/^config\/[a-z0-9][a-z0-9._-]*\.json$/u.test(configPath)
|
||||
) {
|
||||
throw new Error('Config must be a safe repository-relative JSON path inside config/.');
|
||||
}
|
||||
return configPath;
|
||||
};
|
||||
|
||||
const manifestEntry = (manifest) => {
|
||||
const entries = Object.values(manifest).filter((entry) => entry.isEntry === true);
|
||||
if (entries.length !== 1) throw new Error('Vite manifest must contain exactly one client entry.');
|
||||
const entry = entries[0];
|
||||
if (typeof entry.file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+\.js$/u.test(entry.file)) {
|
||||
throw new Error('Vite manifest client entry is unsafe.');
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const metadataMarkup = (metadata) => {
|
||||
const image = metadata.openGraph.image === undefined
|
||||
? ''
|
||||
@@ -63,8 +49,8 @@ const metadataMarkup = (metadata) => {
|
||||
};
|
||||
|
||||
export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
|
||||
const entry = manifestEntry(manifest);
|
||||
const entryUrl = `/${entry.file}`;
|
||||
const closure = resolveViteAssetClosure(manifest);
|
||||
const entryUrl = `/${closure.entryModule}`;
|
||||
exactlyOne(template, /<title>[^<]*<\/title>/gu, 'title');
|
||||
exactlyOne(template, /<div id="app"><\/div>/gu, 'empty app root');
|
||||
exactlyOne(template, /<script type="module"[^>]*><\/script>/gu, 'client module script');
|
||||
@@ -73,8 +59,7 @@ export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
|
||||
|
||||
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
|
||||
const snapshot = serializeJsonLd(inputs);
|
||||
const preloads = [entry.file, ...(entry.imports ?? []).map((key) => manifest[key]?.file)]
|
||||
.filter((file, index, files) => typeof file === 'string' && files.indexOf(file) === index)
|
||||
const preloads = closure.modulePreloads
|
||||
.map((file) => ` <link rel="modulepreload" href="/${file}" />`)
|
||||
.join('\n');
|
||||
const withoutModule = template.replace(/\s*<script type="module"[^>]*><\/script>/u, '');
|
||||
@@ -89,62 +74,6 @@ export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
|
||||
return withHead.replace('<!--portfolio-body-->', clientBootstrap);
|
||||
};
|
||||
|
||||
const assertDirectoryMembers = async (root, expected, label) => {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!expected.has(entry.name)) throw new Error(`Unexpected build output in ${label}: ${entry.name}`);
|
||||
if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output in ${label}: ${entry.name}`);
|
||||
}
|
||||
for (const name of expected) {
|
||||
if (!entries.some((entry) => entry.name === name)) throw new Error(`Missing build output in ${label}: ${name}`);
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const manifestAssetFiles = (manifest) => {
|
||||
const files = new Set();
|
||||
for (const entry of Object.values(manifest)) {
|
||||
for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) {
|
||||
if (typeof file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+$/u.test(file)) {
|
||||
throw new Error('Vite manifest contains an unsafe output path.');
|
||||
}
|
||||
files.add(file.slice('assets/'.length));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
export const assertExpectedDistInputs = async (distRoot) => {
|
||||
const allowed = new Set([
|
||||
'client', 'assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example',
|
||||
]);
|
||||
const rootEntries = await readdir(distRoot, { withFileTypes: true });
|
||||
for (const entry of rootEntries) {
|
||||
if (!allowed.has(entry.name)) throw new Error(`Unexpected build output: ${entry.name}`);
|
||||
if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output: ${entry.name}`);
|
||||
}
|
||||
const clientStats = await lstat(resolve(distRoot, 'client'));
|
||||
if (clientStats.isSymbolicLink() || !clientStats.isDirectory()) {
|
||||
throw new Error('Client output must be a non-symlink directory.');
|
||||
}
|
||||
|
||||
await assertDirectoryMembers(resolve(distRoot, 'client'), new Set(['.vite', 'assets', 'index.html']), 'client');
|
||||
await assertDirectoryMembers(resolve(distRoot, 'client/.vite'), new Set(['manifest.json']), 'client/.vite');
|
||||
const manifest = JSON.parse(await readFile(resolve(distRoot, 'client/.vite/manifest.json'), 'utf8'));
|
||||
await assertDirectoryMembers(resolve(distRoot, 'client/assets'), manifestAssetFiles(manifest), 'client/assets');
|
||||
|
||||
if (rootEntries.some((entry) => entry.name === 'assets')) {
|
||||
if (!rootEntries.some((entry) => entry.name === 'index.html')) {
|
||||
throw new Error('Existing root assets require their generated index.html inventory.');
|
||||
}
|
||||
const existingHtml = await readFile(resolve(distRoot, 'index.html'), 'utf8');
|
||||
const referencedAssets = new Set(
|
||||
[...existingHtml.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => match[1]),
|
||||
);
|
||||
await assertDirectoryMembers(resolve(distRoot, 'assets'), referencedAssets, 'assets');
|
||||
}
|
||||
};
|
||||
|
||||
const assertInsideRepository = (path) => {
|
||||
const pathRelativeToRoot = relative(repositoryRoot, path);
|
||||
if (pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) {
|
||||
@@ -152,12 +81,152 @@ const assertInsideRepository = (path) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isMissing = (error) =>
|
||||
typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
|
||||
const fileIdentity = (stats) => ({
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
});
|
||||
const sameFileIdentity = (left, right) =>
|
||||
left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
||||
|
||||
const assertRegularFile = async (path, label) => {
|
||||
const stats = await lstat(path);
|
||||
if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) {
|
||||
throw new Error(`${label} must be one regular, non-linked file.`);
|
||||
}
|
||||
return fileIdentity(stats);
|
||||
};
|
||||
|
||||
const readSafeRegularFile = async (path, label) => {
|
||||
const initialIdentity = await assertRegularFile(path, label);
|
||||
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const openedStats = await handle.stat();
|
||||
if (!openedStats.isFile() || openedStats.nlink !== 1 ||
|
||||
!sameFileIdentity(initialIdentity, fileIdentity(openedStats))) {
|
||||
throw new Error(`${label} identity changed while opening.`);
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (!sameFileIdentity(initialIdentity, await assertRegularFile(path, label))) {
|
||||
throw new Error(`${label} identity changed while reading.`);
|
||||
}
|
||||
return bytes;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const assertExactDirectoryMembers = async (root, expectedNames, label) => {
|
||||
await captureDirectoryIdentity(root);
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const actualNames = entries.map((entry) => entry.name).sort();
|
||||
const expected = [...expectedNames].sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expected)) {
|
||||
throw new Error(`${label} inventory is not exact.`);
|
||||
}
|
||||
if (entries.some((entry) => entry.isSymbolicLink())) throw new Error(`${label} contains a symlink.`);
|
||||
return entries;
|
||||
};
|
||||
|
||||
const loadTrustedClientBuild = async (clientRoot) => {
|
||||
await assertExactDirectoryMembers(clientRoot, ['.vite', 'assets', 'index.html'], 'Client build');
|
||||
const viteRoot = resolve(clientRoot, '.vite');
|
||||
const assetsRoot = resolve(clientRoot, 'assets');
|
||||
await assertExactDirectoryMembers(viteRoot, ['manifest.json'], 'Client Vite metadata');
|
||||
await captureDirectoryIdentity(assetsRoot);
|
||||
const template = (await readSafeRegularFile(resolve(clientRoot, 'index.html'), 'Built client template')).toString('utf8');
|
||||
const manifestSource = (await readSafeRegularFile(resolve(viteRoot, 'manifest.json'), 'Vite manifest')).toString('utf8');
|
||||
const manifest = JSON.parse(manifestSource);
|
||||
const closure = resolveViteAssetClosure(manifest);
|
||||
const clientPaths = [...new Set([
|
||||
...closure.executableModules,
|
||||
...closure.styles,
|
||||
...closure.viteAssets,
|
||||
])].sort();
|
||||
await assertExactDirectoryMembers(
|
||||
assetsRoot,
|
||||
clientPaths.map((path) => path.slice('assets/'.length)),
|
||||
'Client assets',
|
||||
);
|
||||
const bytesByPath = {};
|
||||
for (const path of clientPaths) {
|
||||
bytesByPath[path] = await readSafeRegularFile(resolve(clientRoot, path), `Client asset ${path}`);
|
||||
}
|
||||
return { template, manifest, closure, bytesByPath };
|
||||
};
|
||||
|
||||
const loadPreparedPublicAssets = async (inputs) => {
|
||||
const preparedRoot = resolve(repositoryRoot, 'inputs/prepared-assets');
|
||||
await captureDirectoryIdentity(preparedRoot);
|
||||
const bytesByPath = {};
|
||||
for (const asset of inputs.manifest.assets) {
|
||||
bytesByPath[`assets/${asset.fileName}`] = await readSafeRegularFile(
|
||||
resolve(preparedRoot, asset.fileName),
|
||||
`Prepared public asset ${asset.fileName}`,
|
||||
);
|
||||
}
|
||||
return bytesByPath;
|
||||
};
|
||||
|
||||
const writeFreshStagingArtifacts = async (stagingRoot, html, artifacts, bytesByPath) => {
|
||||
await mkdir(resolve(stagingRoot, 'assets'));
|
||||
for (const [path, bytes] of Object.entries(bytesByPath)) {
|
||||
await writeFile(resolve(stagingRoot, path), bytes, { flag: 'wx' });
|
||||
}
|
||||
await Promise.all([
|
||||
writeFile(resolve(stagingRoot, 'index.html'), html, { flag: 'wx' }),
|
||||
writeFile(resolve(stagingRoot, 'robots.txt'), artifacts.robots, { flag: 'wx' }),
|
||||
writeFile(resolve(stagingRoot, '_headers.example'), artifacts.headers, { flag: 'wx' }),
|
||||
writeFile(resolve(stagingRoot, '_redirects.example'), artifacts.redirects, { flag: 'wx' }),
|
||||
...(artifacts.sitemap === undefined
|
||||
? []
|
||||
: [writeFile(resolve(stagingRoot, 'sitemap.xml'), artifacts.sitemap, { flag: 'wx' })]),
|
||||
]);
|
||||
};
|
||||
|
||||
const removeOwnedStaging = async (stagingRoot) => {
|
||||
if (dirname(stagingRoot) !== repositoryRoot || !stagingRoot.startsWith(resolve(repositoryRoot, '.dist-stage-'))) {
|
||||
throw new Error('Refusing to remove an unowned staging path.');
|
||||
}
|
||||
try {
|
||||
await captureDirectoryIdentity(stagingRoot);
|
||||
} catch (error) {
|
||||
if (isMissing(error) || /existing safe directory/iu.test(String(error))) return;
|
||||
throw error;
|
||||
}
|
||||
await rm(stagingRoot, { recursive: true });
|
||||
};
|
||||
|
||||
const writeAuthorityAtomically = async (authority) => {
|
||||
const serverRoot = resolve(repositoryRoot, 'dist-ssr');
|
||||
await captureDirectoryIdentity(serverRoot);
|
||||
const authorityPath = resolve(serverRoot, 'artifact-authority.json');
|
||||
try {
|
||||
await assertRegularFile(authorityPath, 'Existing artifact authority');
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
const temporaryPath = resolve(serverRoot, `.artifact-authority-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await writeFile(temporaryPath, serializeBuildAuthority(authority), { flag: 'wx' });
|
||||
await assertRegularFile(temporaryPath, 'Temporary artifact authority');
|
||||
await rename(temporaryPath, authorityPath);
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const prerender = async (configPath) => {
|
||||
const distRoot = resolve(repositoryRoot, 'dist');
|
||||
const clientRoot = resolve(distRoot, 'client');
|
||||
const clientRoot = resolve(repositoryRoot, 'dist-client');
|
||||
const configAbsolutePath = resolve(repositoryRoot, configPath);
|
||||
assertInsideRepository(configAbsolutePath);
|
||||
await assertExpectedDistInputs(distRoot);
|
||||
if (await realpath(repositoryRoot) !== repositoryRoot) throw new Error('Repository root has a symlinked ancestor.');
|
||||
|
||||
const inputs = await loadProductionInputs({
|
||||
contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')),
|
||||
@@ -165,32 +234,47 @@ export const prerender = async (configPath) => {
|
||||
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}${sep}`),
|
||||
siteConfigJson: pathToFileURL(configAbsolutePath),
|
||||
});
|
||||
const [{ render }, template, manifestSource] = await Promise.all([
|
||||
import(pathToFileURL(resolve(repositoryRoot, 'dist-ssr/entry-server.js')).href),
|
||||
readFile(resolve(clientRoot, 'index.html'), 'utf8'),
|
||||
readFile(resolve(clientRoot, '.vite/manifest.json'), 'utf8'),
|
||||
]);
|
||||
const clientBuild = await loadTrustedClientBuild(clientRoot);
|
||||
const serverEntryPath = resolve(repositoryRoot, 'dist-ssr/entry-server.js');
|
||||
const serverEntryIdentity = await assertRegularFile(serverEntryPath, 'SSR entry');
|
||||
const { render } = await import(`${pathToFileURL(serverEntryPath).href}?build=${encodeURIComponent(inputs.site.buildId)}`);
|
||||
if (typeof render !== 'function') throw new Error('SSR entry does not export render().');
|
||||
const manifest = JSON.parse(manifestSource);
|
||||
if (!sameFileIdentity(serverEntryIdentity, await assertRegularFile(serverEntryPath, 'SSR entry'))) {
|
||||
throw new Error('SSR entry identity changed during import.');
|
||||
}
|
||||
const markup = await render('/', inputs);
|
||||
const html = assemblePrerenderedHtml(template, manifest, markup, inputs);
|
||||
const html = assemblePrerenderedHtml(clientBuild.template, clientBuild.manifest, markup, inputs);
|
||||
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
|
||||
const artifacts = createStaticHostArtifacts(metadata, inputs.site);
|
||||
|
||||
for (const path of ['assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example']) {
|
||||
await rm(resolve(distRoot, path), { recursive: true, force: true });
|
||||
const publicBytes = await loadPreparedPublicAssets(inputs);
|
||||
const bytesByPath = { ...clientBuild.bytesByPath, ...publicBytes };
|
||||
const digestByPath = Object.fromEntries(Object.entries(bytesByPath).map(([path, bytes]) => [
|
||||
path,
|
||||
createHash('sha256').update(bytes).digest('hex'),
|
||||
]));
|
||||
const contract = createArtifactContract(
|
||||
clientBuild.closure,
|
||||
inputs.manifest,
|
||||
digestByPath,
|
||||
inputs.site.buildId,
|
||||
);
|
||||
const authority = createBuildAuthority(clientBuild.manifest, contract);
|
||||
const stagingRoot = await mkdtemp(resolve(repositoryRoot, '.dist-stage-'));
|
||||
try {
|
||||
await writeFreshStagingArtifacts(stagingRoot, html, artifacts, bytesByPath);
|
||||
await verifyArtifactDirectory(stagingRoot, inputs, contract);
|
||||
const stagingIdentity = await captureDirectoryIdentity(stagingRoot);
|
||||
await publishStagedDirectory({
|
||||
repositoryRoot,
|
||||
finalRoot: distRoot,
|
||||
stagingRoot,
|
||||
expectedStagingIdentity: stagingIdentity,
|
||||
beforeCommit: async () => writeAuthorityAtomically(authority),
|
||||
});
|
||||
} catch (error) {
|
||||
await removeOwnedStaging(stagingRoot);
|
||||
throw error;
|
||||
}
|
||||
await cp(resolve(clientRoot, 'assets'), resolve(distRoot, 'assets'), { recursive: true, errorOnExist: true });
|
||||
await Promise.all([
|
||||
writeFile(resolve(distRoot, 'index.html'), html),
|
||||
writeFile(resolve(distRoot, 'robots.txt'), artifacts.robots),
|
||||
writeFile(resolve(distRoot, '_headers.example'), artifacts.headers),
|
||||
writeFile(resolve(distRoot, '_redirects.example'), artifacts.redirects),
|
||||
...(artifacts.sitemap === undefined
|
||||
? []
|
||||
: [writeFile(resolve(distRoot, 'sitemap.xml'), artifacts.sitemap)]),
|
||||
]);
|
||||
await rm(clientRoot, { recursive: true });
|
||||
};
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||
|
||||
14
scripts/safe-publication.d.mts
Normal file
14
scripts/safe-publication.d.mts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface DirectoryIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
}
|
||||
|
||||
export function captureDirectoryIdentity(path: string): Promise<DirectoryIdentity>;
|
||||
export function publishStagedDirectory(options: {
|
||||
repositoryRoot: string;
|
||||
finalRoot: string;
|
||||
stagingRoot: string;
|
||||
expectedStagingIdentity: DirectoryIdentity;
|
||||
renameDirectory?: (from: string, to: string) => Promise<void>;
|
||||
beforeCommit?: () => Promise<void>;
|
||||
}): Promise<void>;
|
||||
125
scripts/safe-publication.mjs
Normal file
125
scripts/safe-publication.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { lstat, realpath, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const isMissing = (error) =>
|
||||
typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
|
||||
const assertInsideRepository = (repositoryRoot, path, label) => {
|
||||
const pathRelativeToRoot = relative(repositoryRoot, path);
|
||||
if (pathRelativeToRoot === '' || pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) {
|
||||
throw new Error(`${label} is outside the repository.`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertCanonicalDirectory = async (path, label) => {
|
||||
const resolvedPath = resolve(path);
|
||||
let canonicalPath;
|
||||
try {
|
||||
canonicalPath = await realpath(resolvedPath);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) throw new Error(`${label} must be an existing safe directory.`);
|
||||
throw error;
|
||||
}
|
||||
if (canonicalPath !== resolvedPath) throw new Error(`${label} or an ancestor is symlinked or non-canonical.`);
|
||||
const stats = await lstat(resolvedPath);
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error(`${label} must be a safe directory.`);
|
||||
return stats;
|
||||
};
|
||||
|
||||
export const captureDirectoryIdentity = async (path) => {
|
||||
const stats = await assertCanonicalDirectory(path, 'Directory');
|
||||
return { dev: stats.dev, ino: stats.ino };
|
||||
};
|
||||
|
||||
const sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
||||
|
||||
const assertOptionalFinalDirectory = async (repositoryRoot, finalRoot) => {
|
||||
assertInsideRepository(repositoryRoot, finalRoot, 'Final output');
|
||||
if (dirname(finalRoot) !== repositoryRoot || basename(finalRoot) !== 'dist') {
|
||||
throw new Error('Final output must be the repository-contained dist sibling.');
|
||||
}
|
||||
try {
|
||||
const stats = await lstat(finalRoot);
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory() || await realpath(finalRoot) !== finalRoot) {
|
||||
throw new Error('Final output must be an existing safe directory.');
|
||||
}
|
||||
return { exists: true, identity: { dev: stats.dev, ino: stats.ino } };
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return { exists: false, identity: undefined };
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const removeOwnedBackup = async (repositoryRoot, backupRoot) => {
|
||||
assertInsideRepository(repositoryRoot, backupRoot, 'Backup');
|
||||
if (dirname(backupRoot) !== repositoryRoot || !basename(backupRoot).startsWith('.dist-backup-')) {
|
||||
throw new Error('Refusing to remove an unowned backup path.');
|
||||
}
|
||||
const stats = await lstat(backupRoot);
|
||||
if (stats.isSymbolicLink() || !stats.isDirectory() || await realpath(backupRoot) !== backupRoot) {
|
||||
throw new Error('Refusing to remove an unsafe backup path.');
|
||||
}
|
||||
await rm(backupRoot, { recursive: true });
|
||||
};
|
||||
|
||||
export const publishStagedDirectory = async ({
|
||||
repositoryRoot: repositoryRootInput,
|
||||
finalRoot: finalRootInput,
|
||||
stagingRoot: stagingRootInput,
|
||||
expectedStagingIdentity,
|
||||
renameDirectory = rename,
|
||||
beforeCommit = async () => {},
|
||||
}) => {
|
||||
const repositoryRoot = resolve(repositoryRootInput);
|
||||
const finalRoot = resolve(finalRootInput);
|
||||
const stagingRoot = resolve(stagingRootInput);
|
||||
await assertCanonicalDirectory(repositoryRoot, 'Repository root');
|
||||
assertInsideRepository(repositoryRoot, stagingRoot, 'Staging output');
|
||||
if (dirname(stagingRoot) !== repositoryRoot || !basename(stagingRoot).startsWith('.dist-stage-')) {
|
||||
throw new Error('Staging output must be a repository-contained owned sibling.');
|
||||
}
|
||||
const stagingIdentity = await captureDirectoryIdentity(stagingRoot);
|
||||
if (!sameIdentity(stagingIdentity, expectedStagingIdentity)) {
|
||||
throw new Error('Staging directory identity changed after verification.');
|
||||
}
|
||||
const previous = await assertOptionalFinalDirectory(repositoryRoot, finalRoot);
|
||||
|
||||
await assertCanonicalDirectory(repositoryRoot, 'Repository root');
|
||||
if (!sameIdentity(await captureDirectoryIdentity(stagingRoot), expectedStagingIdentity)) {
|
||||
throw new Error('Staging directory identity changed before publication.');
|
||||
}
|
||||
if (previous.exists) {
|
||||
const currentFinal = await captureDirectoryIdentity(finalRoot);
|
||||
if (!sameIdentity(currentFinal, previous.identity)) throw new Error('Final output identity changed before publication.');
|
||||
}
|
||||
|
||||
const backupRoot = resolve(repositoryRoot, `.dist-backup-${randomUUID()}`);
|
||||
let backupCreated = false;
|
||||
let stagedPublished = false;
|
||||
try {
|
||||
if (previous.exists) {
|
||||
await renameDirectory(finalRoot, backupRoot);
|
||||
backupCreated = true;
|
||||
}
|
||||
await renameDirectory(stagingRoot, finalRoot);
|
||||
stagedPublished = true;
|
||||
await beforeCommit();
|
||||
} catch (error) {
|
||||
try {
|
||||
if (stagedPublished) {
|
||||
await renameDirectory(finalRoot, stagingRoot);
|
||||
stagedPublished = false;
|
||||
}
|
||||
if (backupCreated) {
|
||||
await renameDirectory(backupRoot, finalRoot);
|
||||
backupCreated = false;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([error, rollbackError], 'Publication failed and rollback could not restore the previous output.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (backupCreated) await removeOwnedBackup(repositoryRoot, backupRoot);
|
||||
};
|
||||
@@ -3,5 +3,6 @@ import type { ProductionInputs } from '../src/content/types.ts';
|
||||
export function verifyArtifactDirectory(
|
||||
root: string,
|
||||
inputs: Readonly<ProductionInputs>,
|
||||
contract: unknown,
|
||||
): Promise<void>;
|
||||
export function verifyArtifacts(configPath: string): Promise<void>;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { lstat, readFile, readdir } from 'node:fs/promises';
|
||||
import { dirname, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadProductionInputs } from '../src/content/load-production-inputs.ts';
|
||||
import { parseExactConfigArgument } from './prerender.mjs';
|
||||
import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts';
|
||||
import { Window } from 'happy-dom';
|
||||
import { parseArtifactContract, parseBuildAuthority } from './build-contract.mjs';
|
||||
import { parseExactConfigArgument } from './config-argument.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repositoryRoot = resolve(dirname(scriptPath), '..');
|
||||
@@ -59,18 +62,20 @@ const listArtifacts = async (root) => {
|
||||
return files.sort();
|
||||
};
|
||||
|
||||
const assertArtifactInventory = (files, html, inputs) => {
|
||||
const assertArtifactInventory = (files, inputs, contract) => {
|
||||
const expectedRootFiles = new Set(['index.html', 'robots.txt', '_headers.example', '_redirects.example']);
|
||||
if (inputs.site.environment === 'production') expectedRootFiles.add('sitemap.xml');
|
||||
const referencedAssets = new Set(
|
||||
[...html.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => `assets/${match[1]}`),
|
||||
);
|
||||
for (const asset of inputs.manifest.assets) referencedAssets.add(`assets/${asset.fileName}`);
|
||||
const expectedAssets = new Set(contract.files.map((record) => record.path));
|
||||
const expectedPublicAssets = inputs.manifest.assets.map((asset) => `assets/${asset.fileName}`).sort();
|
||||
if (contract.buildId !== inputs.site.buildId ||
|
||||
JSON.stringify(contract.publicAssets) !== JSON.stringify(expectedPublicAssets)) {
|
||||
throw new Error('Artifact contract does not match the validated production inputs.');
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.map')) throw new Error(`Source map artifact is prohibited: ${file}`);
|
||||
if (file.startsWith('assets/')) {
|
||||
if (!referencedAssets.has(file)) throw new Error(`Unexpected artifact: ${file}`);
|
||||
if (!expectedAssets.has(file)) throw new Error(`Unexpected artifact outside trusted contract: ${file}`);
|
||||
} else if (!expectedRootFiles.has(file)) {
|
||||
if (inputs.site.environment === 'preview' && file === 'sitemap.xml') {
|
||||
throw new Error('Preview artifacts must not publish a sitemap.');
|
||||
@@ -78,11 +83,48 @@ const assertArtifactInventory = (files, html, inputs) => {
|
||||
throw new Error(`Unexpected artifact: ${file}`);
|
||||
}
|
||||
}
|
||||
for (const required of [...expectedRootFiles, ...referencedAssets]) {
|
||||
for (const required of [...expectedRootFiles, ...expectedAssets]) {
|
||||
if (!files.includes(required)) throw new Error(`Missing required artifact: ${required}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertArtifactDigests = async (root, contract) => {
|
||||
for (const record of contract.files) {
|
||||
const digest = createHash('sha256').update(await readFile(resolve(root, record.path))).digest('hex');
|
||||
if (digest !== record.sha256) throw new Error(`Artifact digest does not match trusted contract: ${record.path}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedAttributeValues = (elements, attribute) =>
|
||||
[...elements].map((element) => element.getAttribute(attribute)).filter((value) => value !== null).sort();
|
||||
|
||||
const assertTrustedHtmlAssets = (html, contract) => {
|
||||
const window = new Window();
|
||||
try {
|
||||
window.document.write(html);
|
||||
const scripts = [...window.document.querySelectorAll('script')];
|
||||
const jsonLd = scripts.filter((script) => script.type === 'application/ld+json' && !script.hasAttribute('src'));
|
||||
const snapshot = scripts.filter((script) =>
|
||||
script.id === 'portfolio-inputs' && script.type === 'application/json' && !script.hasAttribute('src'));
|
||||
const modules = scripts.filter((script) => script.type === 'module' && script.getAttribute('src') === `/${contract.entryModule}`);
|
||||
if (scripts.length !== 3 || jsonLd.length !== 1 || snapshot.length !== 1 || modules.length !== 1) {
|
||||
throw new Error('Unexpected executable script outside the exact JSON-LD, inert snapshot, and trusted client module set.');
|
||||
}
|
||||
const preloads = sortedAttributeValues(window.document.querySelectorAll('link[rel="modulepreload"]'), 'href');
|
||||
const expectedPreloads = contract.modulePreloads.map((path) => `/${path}`).sort();
|
||||
if (JSON.stringify(preloads) !== JSON.stringify(expectedPreloads)) {
|
||||
throw new Error('Final HTML module preload set does not match the trusted contract.');
|
||||
}
|
||||
const styles = sortedAttributeValues(window.document.querySelectorAll('link[rel="stylesheet"]'), 'href');
|
||||
const expectedStyles = contract.styles.map((path) => `/${path}`).sort();
|
||||
if (JSON.stringify(styles) !== JSON.stringify(expectedStyles)) {
|
||||
throw new Error('Final HTML style set does not match the trusted contract.');
|
||||
}
|
||||
} finally {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
const assertNoProhibitedSurfaces = async (root, files) => {
|
||||
for (const file of files) {
|
||||
const bytes = await readFile(resolve(root, file));
|
||||
@@ -99,56 +141,110 @@ const exactlyOneMatch = (text, expression, label) => {
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
const assertSemanticHtml = (html, inputs) => {
|
||||
if (!/<html lang="en">/u.test(html)) throw new Error('Final HTML must declare lang=en.');
|
||||
const h1 = exactlyOneMatch(html, /<h1\b[^>]*>([\s\S]*?)<\/h1>/gu, 'H1');
|
||||
if (h1[1] !== escapeHtml(inputs.content.identity.name)) throw new Error('H1 identity does not match public content.');
|
||||
const adjacentIdentity = `<h1>${escapeHtml(inputs.content.identity.name)}</h1><p>${escapeHtml(inputs.content.identity.role)}</p>`;
|
||||
if (!html.includes(adjacentIdentity)) throw new Error('The canonical role must be immediately adjacent to the H1.');
|
||||
const excludedSemanticContainers = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT']);
|
||||
|
||||
for (const id of canonicalSectionIds) {
|
||||
const sectionMatches = html.match(new RegExp(`<section id="${escapeRegularExpression(id)}"(?:\\s|>)`, 'gu')) ?? [];
|
||||
if (sectionMatches.length !== 1) throw new Error(`Expected exactly one section: ${id}`);
|
||||
const navigationMatches = html.match(new RegExp(`href="#${escapeRegularExpression(id)}"`, 'gu')) ?? [];
|
||||
if (navigationMatches.length !== 1) throw new Error(`Expected one native navigation link for section: ${id}`);
|
||||
const label = inputs.content.navigation.find((item) => item.id === id)?.label;
|
||||
if (label === undefined || !html.includes(`<h2 id="${id}-heading">${escapeHtml(label.toUpperCase())}</h2>`)) {
|
||||
throw new Error(`Missing canonical section heading: ${id}`);
|
||||
const isVisibleSemanticNode = (node, appRoot) => {
|
||||
if (!node.isConnected || !appRoot.contains(node)) return false;
|
||||
let element = node.nodeType === 1 ? node : node.parentElement;
|
||||
while (element !== null) {
|
||||
if (excludedSemanticContainers.has(element.tagName) || element.hasAttribute('hidden') ||
|
||||
element.hasAttribute('inert') || element.getAttribute('aria-hidden') === 'true' ||
|
||||
element.style?.display === 'none' || element.style?.visibility === 'hidden') {
|
||||
return false;
|
||||
}
|
||||
if (element === appRoot) return true;
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const requiredVisibleCopy = [
|
||||
inputs.content.systemsOperations.title,
|
||||
inputs.content.infrastructure.title,
|
||||
...inputs.content.infrastructure.bands.flatMap((band) => [band.title, band.summary]),
|
||||
...inputs.content.projects.selected.flatMap((project) => [project.title, project.summary]),
|
||||
...inputs.content.toolkit.flatMap((group) => [group.title, ...group.technologies.map((technology) => technology.name)]),
|
||||
inputs.content.contact.email,
|
||||
];
|
||||
for (const copy of requiredVisibleCopy) {
|
||||
if (!html.includes(escapeHtml(copy))) throw new Error(`Missing required semantic evidence: ${copy}`);
|
||||
}
|
||||
if (!html.includes(`href="mailto:${escapeHtml(inputs.content.contact.email)}"`)) {
|
||||
throw new Error('Missing visible mailto contact link.');
|
||||
}
|
||||
const visibleText = (node, appRoot) => {
|
||||
if (node.nodeType === 3) return isVisibleSemanticNode(node, appRoot) ? node.textContent ?? '' : '';
|
||||
if (node.nodeType === 1 && !isVisibleSemanticNode(node, appRoot)) return '';
|
||||
return [...node.childNodes].map((child) => visibleText(child, appRoot)).join('');
|
||||
};
|
||||
|
||||
const moduleScript = exactlyOneMatch(html, /<script type="module"[^>]*src="\/assets\/[a-zA-Z0-9._-]+\.js"[^>]*><\/script>/gu, 'client module script');
|
||||
const snapshot = exactlyOneMatch(
|
||||
html,
|
||||
/<script id="portfolio-inputs" type="application\/json">([\s\S]*?)<\/script>/gu,
|
||||
'inert input snapshot',
|
||||
);
|
||||
let parsedSnapshot;
|
||||
const assertSemanticHtml = (html, inputs, contract) => {
|
||||
const window = new Window();
|
||||
try {
|
||||
parsedSnapshot = JSON.parse(snapshot[1]);
|
||||
} catch {
|
||||
throw new Error('The inert input snapshot is malformed.');
|
||||
}
|
||||
if (JSON.stringify(parsedSnapshot) !== JSON.stringify(inputs)) {
|
||||
throw new Error('Hydration snapshot does not match the server input bytes and meaning.');
|
||||
}
|
||||
if (snapshot.index >= moduleScript.index || h1.index >= moduleScript.index) {
|
||||
throw new Error('Semantic HTML and inert snapshot must precede the client module script.');
|
||||
window.document.write(html);
|
||||
const document = window.document;
|
||||
if (document.documentElement.lang !== 'en') throw new Error('Final HTML must declare lang=en.');
|
||||
const appRoots = [...document.querySelectorAll('#app')];
|
||||
if (appRoots.length !== 1 || !isVisibleSemanticNode(appRoots[0], appRoots[0])) {
|
||||
throw new Error('Expected one connected visible semantic #app root.');
|
||||
}
|
||||
const appRoot = appRoots[0];
|
||||
const h1s = [...appRoot.querySelectorAll('h1')].filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (h1s.length !== 1 || h1s[0].textContent !== inputs.content.identity.name) {
|
||||
throw new Error('Expected exactly one visible H1 with the canonical identity.');
|
||||
}
|
||||
const role = h1s[0].nextElementSibling;
|
||||
if (role === null || !isVisibleSemanticNode(role, appRoot) || role.textContent !== inputs.content.identity.role) {
|
||||
throw new Error('Expected the visible canonical role immediately after the H1.');
|
||||
}
|
||||
|
||||
const navigations = [...appRoot.querySelectorAll('nav[aria-label="Portfolio sections"]')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (navigations.length !== 1) throw new Error('Expected exactly one visible navigation.');
|
||||
const navigationLinks = [...navigations[0].querySelectorAll('a')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (JSON.stringify(navigationLinks.map((link) => link.getAttribute('href'))) !==
|
||||
JSON.stringify(canonicalSectionIds.map((id) => `#${id}`))) {
|
||||
throw new Error('Visible navigation does not contain the exact canonical native links.');
|
||||
}
|
||||
|
||||
const sections = [...appRoot.querySelectorAll('main > section')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (JSON.stringify(sections.map((section) => section.id)) !== JSON.stringify(canonicalSectionIds)) {
|
||||
throw new Error('Visible sections do not match the eight canonical sections.');
|
||||
}
|
||||
for (const section of sections) {
|
||||
const label = inputs.content.navigation.find((item) => item.id === section.id)?.label;
|
||||
const headings = [...section.querySelectorAll(':scope > h2')]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (label === undefined || headings.length !== 1 || headings[0].id !== `${section.id}-heading` ||
|
||||
headings[0].textContent !== label.toUpperCase()) {
|
||||
throw new Error(`Missing visible section heading: ${section.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const semanticText = visibleText(appRoot, appRoot);
|
||||
const requiredVisibleCopy = [
|
||||
inputs.content.systemsOperations.title,
|
||||
inputs.content.infrastructure.title,
|
||||
...inputs.content.infrastructure.bands.flatMap((band) => [band.title, band.summary]),
|
||||
...inputs.content.projects.selected.flatMap((project) => [project.title, project.summary]),
|
||||
...inputs.content.toolkit.flatMap((group) => [group.title, ...group.technologies.map((technology) => technology.name)]),
|
||||
inputs.content.contact.email,
|
||||
];
|
||||
for (const copy of requiredVisibleCopy) {
|
||||
if (!semanticText.includes(copy)) throw new Error(`Missing visible semantic evidence: ${copy}`);
|
||||
}
|
||||
const mailLinks = [...appRoot.querySelectorAll(`a[href="mailto:${inputs.content.contact.email}"]`)]
|
||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||
if (mailLinks.length !== 1 || mailLinks[0].textContent !== inputs.content.contact.email) {
|
||||
throw new Error('Missing visible semantic mailto contact link.');
|
||||
}
|
||||
|
||||
const moduleScript = document.querySelector(`script[type="module"][src="/${contract.entryModule}"]`);
|
||||
const snapshot = document.querySelector('script#portfolio-inputs[type="application/json"]');
|
||||
if (moduleScript === null || snapshot === null ||
|
||||
(appRoot.compareDocumentPosition(moduleScript) & window.Node.DOCUMENT_POSITION_FOLLOWING) === 0 ||
|
||||
(snapshot.compareDocumentPosition(moduleScript) & window.Node.DOCUMENT_POSITION_FOLLOWING) === 0) {
|
||||
throw new Error('Connected visible semantic content and snapshot must precede the trusted module script.');
|
||||
}
|
||||
let parsedSnapshot;
|
||||
try {
|
||||
parsedSnapshot = JSON.parse(snapshot.textContent ?? '');
|
||||
} catch {
|
||||
throw new Error('The inert input snapshot is malformed.');
|
||||
}
|
||||
if (JSON.stringify(parsedSnapshot) !== JSON.stringify(inputs)) {
|
||||
throw new Error('Hydration snapshot does not match the server input bytes and meaning.');
|
||||
}
|
||||
} finally {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,13 +352,34 @@ const assertCrawlArtifacts = async (root, inputs, headers) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyArtifactDirectory = async (root, inputs) => {
|
||||
const assertExactHostArtifacts = async (root, inputs) => {
|
||||
const expected = createStaticHostArtifacts(
|
||||
createPageMetadata(inputs.content, inputs.manifest, inputs.site),
|
||||
inputs.site,
|
||||
);
|
||||
const actualHeaders = await readRequiredText(root, '_headers.example');
|
||||
if (actualHeaders !== expected.headers) throw new Error('Headers must exactly equal the generated expected headers.');
|
||||
const actualRobots = await readRequiredText(root, 'robots.txt');
|
||||
if (actualRobots !== expected.robots) throw new Error('Robots must exactly equal the generated expected robots policy.');
|
||||
const actualRedirects = await readRequiredText(root, '_redirects.example');
|
||||
if (actualRedirects !== expected.redirects) throw new Error('Redirects must exactly equal the generated expected redirect example.');
|
||||
if (expected.sitemap !== undefined) {
|
||||
const actualSitemap = await readRequiredText(root, 'sitemap.xml');
|
||||
if (actualSitemap !== expected.sitemap) throw new Error('Sitemap must exactly equal the generated expected sitemap.');
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyArtifactDirectory = async (root, inputs, contractInput) => {
|
||||
const contract = parseArtifactContract(contractInput);
|
||||
const html = await readRequiredText(root, 'index.html');
|
||||
const files = await listArtifacts(root);
|
||||
assertArtifactInventory(files, html, inputs);
|
||||
assertArtifactInventory(files, inputs, contract);
|
||||
await assertArtifactDigests(root, contract);
|
||||
await assertNoProhibitedSurfaces(root, files);
|
||||
assertSemanticHtml(html, inputs);
|
||||
assertTrustedHtmlAssets(html, contract);
|
||||
assertSemanticHtml(html, inputs, contract);
|
||||
const serializedJsonLd = assertMetadata(html, inputs);
|
||||
await assertExactHostArtifacts(root, inputs);
|
||||
const headers = await readRequiredText(root, '_headers.example');
|
||||
assertSecurityHeaders(headers, inputs, serializedJsonLd);
|
||||
await assertCrawlArtifacts(root, inputs, headers);
|
||||
@@ -284,7 +401,14 @@ export const verifyArtifacts = async (configPath) => {
|
||||
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}${sep}`),
|
||||
siteConfigJson: pathToFileURL(configAbsolutePath),
|
||||
});
|
||||
await verifyArtifactDirectory(resolve(repositoryRoot, 'dist'), inputs);
|
||||
const authoritySource = await readRequiredText(resolve(repositoryRoot, 'dist-ssr'), 'artifact-authority.json');
|
||||
let authority;
|
||||
try {
|
||||
authority = parseBuildAuthority(JSON.parse(authoritySource));
|
||||
} catch (error) {
|
||||
throw new Error('Persisted build authority is invalid.', { cause: error });
|
||||
}
|
||||
await verifyArtifactDirectory(resolve(repositoryRoot, 'dist'), inputs, authority.artifactContract);
|
||||
};
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||
|
||||
Reference in New Issue
Block a user