diff --git a/.gitignore b/.gitignore index 52352f4..1c65cae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-client/ dist-ssr/ coverage/ playwright-report/ diff --git a/package.json b/package.json index 79f1a75..7bae8fe 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "vue-tsc --noEmit", "test:unit": "vitest run", "test:browser": "playwright test", - "build:client": "vite build --outDir dist/client --manifest", + "build:client": "vite build --outDir dist-client --manifest", "build:ssr": "vite build --ssr src/entry-server.ts --outDir dist-ssr", "prerender": "node scripts/prerender.mjs", "verify:artifacts": "node scripts/verify-artifacts.mjs", diff --git a/scripts/build-contract.d.mts b/scripts/build-contract.d.mts new file mode 100644 index 0000000..995126e --- /dev/null +++ b/scripts/build-contract.d.mts @@ -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>, + 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; diff --git a/scripts/build-contract.mjs b/scripts/build-contract.mjs new file mode 100644 index 0000000..8aeaef2 --- /dev/null +++ b/scripts/build-contract.mjs @@ -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`; diff --git a/scripts/config-argument.d.mts b/scripts/config-argument.d.mts new file mode 100644 index 0000000..bdbd4ef --- /dev/null +++ b/scripts/config-argument.d.mts @@ -0,0 +1 @@ +export function parseExactConfigArgument(arguments_: string[]): string; diff --git a/scripts/config-argument.mjs b/scripts/config-argument.mjs new file mode 100644 index 0000000..2589b21 --- /dev/null +++ b/scripts/config-argument.mjs @@ -0,0 +1,16 @@ +export const parseExactConfigArgument = (arguments_) => { + if (arguments_.length !== 2 || arguments_[0] !== '--config') { + throw new Error('Prerender accepts exactly --config .'); + } + 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; +}; diff --git a/scripts/prerender.d.mts b/scripts/prerender.d.mts index 8563e7b..40a794c 100644 --- a/scripts/prerender.d.mts +++ b/scripts/prerender.d.mts @@ -6,12 +6,13 @@ export interface ViteManifestEntry { isEntry?: boolean; css?: string[]; imports?: string[]; + dynamicImports?: string[]; + assets?: string[]; } export type ViteManifest = Record; export function parseExactConfigArgument(arguments_: string[]): string; -export function assertExpectedDistInputs(distRoot: string): Promise; export function assemblePrerenderedHtml( template: string, manifest: ViteManifest, diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 1d158f0..f1e0752 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -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 .'); - } - 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>/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) { diff --git a/scripts/safe-publication.d.mts b/scripts/safe-publication.d.mts new file mode 100644 index 0000000..9ea58af --- /dev/null +++ b/scripts/safe-publication.d.mts @@ -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>; diff --git a/scripts/safe-publication.mjs b/scripts/safe-publication.mjs new file mode 100644 index 0000000..757d54d --- /dev/null +++ b/scripts/safe-publication.mjs @@ -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); +}; diff --git a/scripts/verify-artifacts.d.mts b/scripts/verify-artifacts.d.mts index 19f882a..a5a07df 100644 --- a/scripts/verify-artifacts.d.mts +++ b/scripts/verify-artifacts.d.mts @@ -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>; diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs index 7b84200..c7d7659 100644 --- a/scripts/verify-artifacts.mjs +++ b/scripts/verify-artifacts.mjs @@ -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) { diff --git a/src/content/metadata.ts b/src/content/metadata.ts index d55557e..66c96e3 100644 --- a/src/content/metadata.ts +++ b/src/content/metadata.ts @@ -160,6 +160,9 @@ export const createStaticHostArtifacts = ( '', ].join('\n'), headers: [ + '/assets/*', + ' Cache-Control: public, max-age=31536000, immutable', + '', '/*', ...commonHeaders(metadata), ' Cache-Control: public, max-age=0, must-revalidate', diff --git a/src/content/production-input-snapshot.ts b/src/content/production-input-snapshot.ts new file mode 100644 index 0000000..a7aed8a --- /dev/null +++ b/src/content/production-input-snapshot.ts @@ -0,0 +1,19 @@ +import { parseProductionInputSnapshot } from './schemas'; +import type { ProductionInputs } from './types'; + +const deepFreeze = <T>(value: T, seen = new WeakSet<object>()): Readonly<T> => { + if (value !== null && typeof value === 'object') { + const object = value as object; + if (!seen.has(object)) { + seen.add(object); + for (const key of Reflect.ownKeys(object)) { + deepFreeze(Reflect.get(object, key), seen); + } + Object.freeze(object); + } + } + return value; +}; + +export const validateAndFreezeProductionInputSnapshot = (input: unknown): Readonly<ProductionInputs> => + deepFreeze(parseProductionInputSnapshot(input)); diff --git a/src/content/schemas.ts b/src/content/schemas.ts index 0596409..d35975d 100644 --- a/src/content/schemas.ts +++ b/src/content/schemas.ts @@ -225,15 +225,14 @@ export const parsePortfolioContent = (input: unknown): PortfolioContent => portf export const parsePublicAssetManifest = (input: unknown): PublicAssetManifest => publicAssetManifestSchema.parse(input) as PublicAssetManifest; export const parseSiteConfig = (input: unknown): SiteConfig => siteConfigSchema.parse(input) as SiteConfig; -export const validateProductionInputs = ( - contentInput: unknown, - manifestInput: unknown, - siteInput: unknown, - _preparedAssetRoot: URL, -): ProductionInputs => { - const content = parsePortfolioContent(contentInput); - const manifest = parsePublicAssetManifest(manifestInput); - const site = parseSiteConfig(siteInput); +const productionInputsSchema = z.object({ + content: portfolioContentSchema, + manifest: publicAssetManifestSchema, + site: siteConfigSchema, +}).strict(); + +export const parseProductionInputSnapshot = (input: unknown): ProductionInputs => { + const { content, manifest, site } = productionInputsSchema.parse(input); const manifestIds = new Set(manifest.assets.map((asset) => asset.id)); const referencedAssetIds = [ ...content.projects.selected.flatMap((project) => project.assetIds), @@ -247,5 +246,16 @@ export const validateProductionInputs = ( throw new Error(`Unmanifested asset reference: ${assetId}`); } } - return { content, manifest, site }; + return { content, manifest, site } as ProductionInputs; }; + +export const validateProductionInputs = ( + contentInput: unknown, + manifestInput: unknown, + siteInput: unknown, + _preparedAssetRoot: URL, +): ProductionInputs => parseProductionInputSnapshot({ + content: contentInput, + manifest: manifestInput, + site: siteInput, +}); diff --git a/src/entry-client.ts b/src/entry-client.ts index f40600b..8f16a2e 100644 --- a/src/entry-client.ts +++ b/src/entry-client.ts @@ -1,4 +1,5 @@ import { createApp } from './app'; +import { validateAndFreezeProductionInputSnapshot } from './content/production-input-snapshot'; import type { ProductionInputs } from './content/types'; export const readProductionInputsSnapshot = (source: Document): Readonly<ProductionInputs> => { @@ -16,7 +17,7 @@ export const readProductionInputsSnapshot = (source: Document): Readonly<Product throw new Error('The inert portfolio input snapshot is empty.'); } - return JSON.parse(serializedInputs) as Readonly<ProductionInputs>; + return validateAndFreezeProductionInputSnapshot(JSON.parse(serializedInputs) as unknown); }; const appRoot = document.getElementById('app'); diff --git a/tests/integration/prerender.test.ts b/tests/integration/prerender.test.ts index e15130e..63d4d5b 100644 --- a/tests/integration/prerender.test.ts +++ b/tests/integration/prerender.test.ts @@ -1,23 +1,17 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { loadProductionInputs } from '@/content/load-production-inputs'; import { readProductionInputsSnapshot } from '@/entry-client'; import { render } from '@/entry-server'; import { assemblePrerenderedHtml, - assertExpectedDistInputs, parseExactConfigArgument, type ViteManifest, } from '../../scripts/prerender.mjs'; const repositoryRoot = process.cwd(); -const temporaryDirectories: string[] = []; -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); -}); const locations = { contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')), assetManifestJson: pathToFileURL(resolve(repositoryRoot, 'content/public/asset-manifest.json')), @@ -138,26 +132,3 @@ describe('approved build template', () => { expect(template).not.toContain('window.__PORTFOLIO_INPUTS__'); }); }); - -describe('deterministic staging inventory', () => { - it.each([ - { location: 'client/unknown.txt' }, - { location: 'assets/unknown.txt' }, - ])('rejects an unknown staged output at $location', async ({ location }) => { - const directory = await mkdtemp(join(tmpdir(), 'portfolio-dist-')); - temporaryDirectories.push(directory); - await mkdir(join(directory, 'client/.vite'), { recursive: true }); - await mkdir(join(directory, 'client/assets'), { recursive: true }); - await mkdir(join(directory, 'assets'), { recursive: true }); - await writeFile(join(directory, 'client/index.html'), builtTemplate); - await writeFile(join(directory, 'client/.vite/manifest.json'), JSON.stringify(manifest)); - await writeFile(join(directory, 'client/assets/index-test.js'), 'export {};'); - await writeFile(join(directory, 'client/assets/index-test.css'), 'body {}'); - await writeFile(join(directory, 'index.html'), builtTemplate); - await writeFile(join(directory, 'assets/index-test.js'), 'export {};'); - await writeFile(join(directory, 'assets/index-test.css'), 'body {}'); - await writeFile(join(directory, location), 'unknown'); - - await expect(assertExpectedDistInputs(directory)).rejects.toThrow(/unexpected build output/i); - }); -}); diff --git a/tests/integration/publication.test.ts b/tests/integration/publication.test.ts new file mode 100644 index 0000000..952cdb9 --- /dev/null +++ b/tests/integration/publication.test.ts @@ -0,0 +1,139 @@ +import { mkdtemp, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + captureDirectoryIdentity, + publishStagedDirectory, +} from '../../scripts/safe-publication.mjs'; + +const temporaryDirectories: string[] = []; +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +const repository = async () => { + const root = await mkdtemp(join(tmpdir(), 'portfolio-publication-')); + temporaryDirectories.push(root); + await mkdir(join(root, 'dist')); + await mkdir(join(root, '.dist-stage-test')); + await writeFile(join(root, 'dist/old.txt'), 'old'); + await writeFile(join(root, '.dist-stage-test/new.txt'), 'new'); + return { + root, + finalRoot: join(root, 'dist'), + stagingRoot: join(root, '.dist-stage-test'), + }; +}; + +describe('safe artifact publication', () => { + it('rejects a symlinked final output before mutating its target', async () => { + const { root, finalRoot, stagingRoot } = await repository(); + const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-')); + temporaryDirectories.push(outside); + await writeFile(join(outside, 'protected.txt'), 'protected'); + await rm(finalRoot, { recursive: true }); + await symlink(outside, finalRoot); + + await expect(publishStagedDirectory({ + repositoryRoot: root, + finalRoot, + stagingRoot, + expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), + })).rejects.toThrow(/symlink|safe directory/i); + expect(await readFile(join(outside, 'protected.txt'), 'utf8')).toBe('protected'); + }); + + it('rejects an output path outside the repository', async () => { + const { root, stagingRoot } = await repository(); + const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-')); + temporaryDirectories.push(outside); + + await expect(publishStagedDirectory({ + repositoryRoot: root, + finalRoot: outside, + stagingRoot, + expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), + })).rejects.toThrow(/outside.*repository/i); + }); + + it('rejects a repository path with a symlinked ancestor', async () => { + const { root, stagingRoot } = await repository(); + const parent = await mkdtemp(join(tmpdir(), 'portfolio-publication-link-')); + temporaryDirectories.push(parent); + const linkedRoot = join(parent, 'linked-root'); + await symlink(root, linkedRoot); + + await expect(publishStagedDirectory({ + repositoryRoot: linkedRoot, + finalRoot: join(linkedRoot, 'dist'), + stagingRoot, + expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), + })).rejects.toThrow(/ancestor|canonical|symlink/i); + }); + + it('rejects a staging directory replaced after verification without touching the final output', async () => { + const { root, finalRoot, stagingRoot } = await repository(); + const expectedStagingIdentity = await captureDirectoryIdentity(stagingRoot); + await rm(stagingRoot, { recursive: true }); + await mkdir(stagingRoot); + await writeFile(join(stagingRoot, 'attacker.txt'), 'attacker'); + + await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity })) + .rejects.toThrow(/identity changed/i); + expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old'); + }); + + it('rolls back the previous output when the staged rename fails mid-publication', async () => { + const { root, finalRoot, stagingRoot } = await repository(); + let renameCount = 0; + const failSecondRename = async (from: string, to: string) => { + renameCount += 1; + if (renameCount === 2) throw new Error('injected staged rename failure'); + await rename(from, to); + }; + + await expect(publishStagedDirectory({ + repositoryRoot: root, + finalRoot, + stagingRoot, + expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), + renameDirectory: failSecondRename, + })).rejects.toThrow(/injected staged rename failure/i); + expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old'); + await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow(); + expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false); + }); + + it('rolls back the previous output when the pre-commit authority update fails', async () => { + const { root, finalRoot, stagingRoot } = await repository(); + + await expect(publishStagedDirectory({ + repositoryRoot: root, + finalRoot, + stagingRoot, + expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), + beforeCommit: async () => { + throw new Error('injected authority update failure'); + }, + })).rejects.toThrow(/injected authority update failure/i); + expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old'); + await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow(); + expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false); + }); + + it('publishes the verified staging directory and removes the superseded output', async () => { + const { root, finalRoot, stagingRoot } = await repository(); + + await publishStagedDirectory({ + repositoryRoot: root, + finalRoot, + stagingRoot, + expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), + }); + + expect(await readFile(join(finalRoot, 'new.txt'), 'utf8')).toBe('new'); + await expect(readFile(join(finalRoot, 'old.txt'), 'utf8')).rejects.toThrow(); + expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false); + }); +}); diff --git a/tests/integration/verify-artifacts.test.ts b/tests/integration/verify-artifacts.test.ts index d3c4462..ef5495d 100644 --- a/tests/integration/verify-artifacts.test.ts +++ b/tests/integration/verify-artifacts.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -31,6 +32,9 @@ const template = `<!doctype html><html lang="en"><head><meta charset="UTF-8" /> <script type="module" crossorigin src="/assets/index-test.js"></script> <link rel="stylesheet" href="/assets/index-test.css"></head><body><div id="app"></div> <!--portfolio-body--></body></html>`; +const clientJavaScript = 'export {};\n'; +const clientStylesheet = 'body { margin: 0; }\n'; +const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); const temporaryDirectories: string[] = []; afterEach(async () => { @@ -45,25 +49,39 @@ const writeValidArtifacts = async (site?: SiteConfig) => { await mkdir(join(directory, 'assets')); const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs); const hostArtifacts = createStaticHostArtifacts(createPageMetadata(inputs.content, inputs.manifest, inputs.site), inputs.site); + const contract = { + schema: 'portfolio-artifact-contract/v1', + buildId: inputs.site.buildId, + entryModule: 'assets/index-test.js', + executableModules: ['assets/index-test.js'], + modulePreloads: ['assets/index-test.js'], + styles: ['assets/index-test.css'], + viteAssets: [], + publicAssets: [], + files: [ + { path: 'assets/index-test.css', sha256: sha256(clientStylesheet) }, + { path: 'assets/index-test.js', sha256: sha256(clientJavaScript) }, + ], + } as const; await Promise.all([ writeFile(join(directory, 'index.html'), html), writeFile(join(directory, 'robots.txt'), hostArtifacts.robots), writeFile(join(directory, '_headers.example'), hostArtifacts.headers), writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects), - writeFile(join(directory, 'assets/index-test.js'), 'export {};\n'), - writeFile(join(directory, 'assets/index-test.css'), 'body { margin: 0; }\n'), + writeFile(join(directory, 'assets/index-test.js'), clientJavaScript), + writeFile(join(directory, 'assets/index-test.css'), clientStylesheet), ...(hostArtifacts.sitemap === undefined ? [] : [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]), ]); - return { directory, html, inputs }; + return { directory, html, inputs, contract }; }; describe('artifact verification', () => { it('accepts the complete private preview artifact set', async () => { - const { directory, inputs } = await writeValidArtifacts(); + const { directory, inputs, contract } = await writeValidArtifacts(); - await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined(); + await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined(); }); it('accepts the pure production artifact path with canonical sitemap and allow policy', async () => { @@ -72,9 +90,9 @@ describe('artifact verification', () => { canonicalUrl: 'https://portfolio.example.test/', buildId: 'production-test', }; - const { directory, inputs } = await writeValidArtifacts(productionSite); + const { directory, inputs, contract } = await writeValidArtifacts(productionSite); - await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined(); + await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined(); }); it.each([ @@ -96,13 +114,13 @@ describe('artifact verification', () => { { name: 'a prohibited private surface', mutate: async (directory: string) => writeFile(join(directory, 'assets/index-test.js'), 'const evidenceId = "fixture";'), - error: /prohibited surface/i, + error: /prohibited surface|digest does not match/i, }, ])('rejects $name', async ({ mutate, error }) => { - const { directory, inputs } = await writeValidArtifacts(); + const { directory, inputs, contract } = await writeValidArtifacts(); await mutate(directory); - await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error); + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error); }); it.each([ @@ -114,7 +132,7 @@ describe('artifact verification', () => { { name: 'duplicate H1 identity', mutate: (html: string) => html.replace('</main>', '<h1>Luca Pîslaru</h1></main>'), - error: /exactly one h1/i, + error: /exactly one (?:visible )?h1/i, }, { name: 'malformed JSON-LD', @@ -124,7 +142,7 @@ describe('artifact verification', () => { { name: 'missing infrastructure evidence', mutate: (html: string) => html.replace('id="infrastructure"', 'id="removed-infrastructure"'), - error: /section.*infrastructure/i, + error: /section.*infrastructure|visible sections/i, }, { name: 'executable snapshot assignment', @@ -132,25 +150,145 @@ describe('artifact verification', () => { error: /inert input snapshot|prohibited surface/i, }, ])('rejects raw HTML with $name', async ({ mutate, error }) => { - const { directory, html, inputs } = await writeValidArtifacts(); + const { directory, html, inputs, contract } = await writeValidArtifacts(); await writeFile(join(directory, 'index.html'), mutate(html)); - await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error); + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error); }); it('rejects a missing crawl artifact', async () => { - const { directory, inputs } = await writeValidArtifacts(); + const { directory, inputs, contract } = await writeValidArtifacts(); await rm(join(directory, 'robots.txt')); - await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/robots/i); + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/robots/i); }); it('checks the CSP hash against the actual serialized JSON-LD bytes', async () => { - const { directory, inputs } = await writeValidArtifacts(); + const { directory, inputs, contract } = await writeValidArtifacts(); const headersPath = join(directory, '_headers.example'); const headers = await readFile(headersPath, 'utf8'); await writeFile(headersPath, headers.replace(/sha256-[A-Za-z0-9+/=]+/u, 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=')); - await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/csp.*json-ld|json-ld.*csp/i); + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/csp.*json-ld|json-ld.*csp|exact.*headers/i); + }); + + it.each([ + { + name: 'a final-HTML-authorized substitute module', + mutate: async (directory: string, html: string) => { + await rm(join(directory, 'assets/index-test.js')); + await writeFile(join(directory, 'assets/substitute.js'), clientJavaScript); + return html.replaceAll('assets/index-test.js', 'assets/substitute.js'); + }, + error: /trusted.*module|artifact contract|unexpected artifact/i, + }, + { + name: 'an extra inline executable script', + mutate: async (_directory: string, html: string) => html.replace('</body>', '<script>void 0</script></body>'), + error: /unexpected executable script/i, + }, + { + name: 'an extra external executable script', + mutate: async (_directory: string, html: string) => html.replace('</body>', '<script src="https://example.test/evil.js"></script></body>'), + error: /unexpected executable script|external executable/i, + }, + { + name: 'a final-HTML-authorized extra stylesheet', + mutate: async (directory: string, html: string) => { + await writeFile(join(directory, 'assets/extra.css'), 'body {}'); + return html.replace('</head>', '<link rel="stylesheet" href="/assets/extra.css" /></head>'); + }, + error: /trusted.*style|artifact contract|unexpected artifact/i, + }, + { + name: 'a missing trusted module preload', + mutate: async (_directory: string, html: string) => html.replace(/<link rel="modulepreload"[^>]+>\n?/u, ''), + error: /trusted.*preload|module preload/i, + }, + ])('rejects $name instead of trusting final HTML', async ({ mutate, error }) => { + const { directory, html, inputs, contract } = await writeValidArtifacts(); + await writeFile(join(directory, 'index.html'), await mutate(directory, html)); + + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error); + }); + + it.each([ + { + name: 'an appended CSP directive', + mutate: (headers: string) => `${headers} script-src https://example.test\n`, + }, + { + name: 'an external script-src relaxation', + mutate: (headers: string) => headers.replace("script-src 'self'", "script-src 'self' https://example.test"), + }, + { + name: 'a duplicate partial CSP policy', + mutate: (headers: string) => `${headers} Content-Security-Policy: default-src 'self'\n`, + }, + { + name: 'commented header impostors', + mutate: (headers: string) => headers.split('\n').map((line) => `# ${line}`).join('\n'), + }, + ])('rejects $name by exact header bytes', async ({ mutate }) => { + const { directory, inputs, contract } = await writeValidArtifacts(); + const path = join(directory, '_headers.example'); + await writeFile(path, mutate(await readFile(path, 'utf8'))); + + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*headers|headers.*exact/i); + }); + + it('rejects modified redirect example bytes', async () => { + const { directory, inputs, contract } = await writeValidArtifacts(); + const path = join(directory, '_redirects.example'); + await writeFile(path, `${await readFile(path, 'utf8')}# appended\n`); + + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*redirect|redirect.*exact/i); + }); + + it('rejects modified production sitemap bytes', async () => { + const productionSite: SiteConfig = { + environment: 'production', + canonicalUrl: 'https://portfolio.example.test/', + buildId: 'production-test', + }; + const { directory, inputs, contract } = await writeValidArtifacts(productionSite); + const path = join(directory, 'sitemap.xml'); + await writeFile(path, `${await readFile(path, 'utf8')}<!-- appended -->\n`); + + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*sitemap|sitemap.*exact/i); + }); + + it.each([ + { + name: 'a hidden application root', + mutate: (html: string) => html.replace('<div id="app">', '<div id="app" hidden>'), + }, + { + name: 'identity rendered only inside a template', + mutate: (html: string) => html.replace( + '<h1>Luca Pîslaru</h1><p>System Administrator & Full-Stack Developer</p>', + '<template><h1>Luca Pîslaru</h1><p>System Administrator & Full-Stack Developer</p></template>', + ), + }, + { + name: 'an aria-hidden native navigation', + mutate: (html: string) => html.replace('<nav class="site-navigation"', '<nav aria-hidden="true" class="site-navigation"'), + }, + { + name: 'an inert canonical section', + mutate: (html: string) => html.replace('<section id="infrastructure"', '<section id="infrastructure" inert'), + }, + { + name: 'infrastructure evidence present only in hydration JSON', + mutate: (html: string) => { + const snapshotAt = html.indexOf('<script id="portfolio-inputs"'); + return `${html.slice(0, snapshotAt).replaceAll('Secure self-hosted platform and lifecycle', 'Removed evidence')}${html.slice(snapshotAt)}`; + }, + }, + ])('rejects $name as non-visible semantic evidence', async ({ mutate }) => { + const { directory, html, inputs, contract } = await writeValidArtifacts(); + await writeFile(join(directory, 'index.html'), mutate(html)); + + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i); }); }); diff --git a/tests/unit/build-contract.test.ts b/tests/unit/build-contract.test.ts new file mode 100644 index 0000000..ea64b7b --- /dev/null +++ b/tests/unit/build-contract.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { resolveViteAssetClosure } from '../../scripts/build-contract.mjs'; +import type { ViteManifest } from '../../scripts/prerender.mjs'; + +const recursiveManifest = (): ViteManifest => ({ + 'index.html': { + file: 'assets/entry-a1.js', + src: 'index.html', + isEntry: true, + css: ['assets/entry-a1.css'], + assets: ['assets/entry-logo-a1.webp'], + imports: ['src/shared.ts'], + dynamicImports: ['src/lazy.ts'], + }, + 'src/shared.ts': { + file: 'assets/shared-b2.js', + src: 'src/shared.ts', + css: ['assets/shared-b2.css'], + imports: ['src/deep.ts'], + }, + 'src/deep.ts': { + file: 'assets/deep-c3.js', + src: 'src/deep.ts', + }, + 'src/lazy.ts': { + file: 'assets/lazy-d4.js', + src: 'src/lazy.ts', + css: ['assets/lazy-d4.css'], + }, +}); + +describe('trusted Vite asset closure', () => { + it('resolves the exact recursive executable, preload, style, and Vite asset sets', () => { + expect(resolveViteAssetClosure(recursiveManifest())).toEqual({ + entryModule: 'assets/entry-a1.js', + executableModules: [ + 'assets/deep-c3.js', + 'assets/entry-a1.js', + 'assets/lazy-d4.js', + 'assets/shared-b2.js', + ], + modulePreloads: [ + 'assets/deep-c3.js', + 'assets/entry-a1.js', + 'assets/shared-b2.js', + ], + styles: [ + 'assets/entry-a1.css', + 'assets/lazy-d4.css', + 'assets/shared-b2.css', + ], + viteAssets: ['assets/entry-logo-a1.webp'], + }); + }); + + it.each([ + { + name: 'a missing exact index.html entry', + mutate: (manifest: ViteManifest) => { + manifest['src/entry-client.ts'] = manifest['index.html']!; + delete manifest['index.html']; + }, + error: /exact index\.html/i, + }, + { + name: 'an unreachable manifest record', + mutate: (manifest: ViteManifest) => { + manifest['src/orphan.ts'] = { file: 'assets/orphan.js', src: 'src/orphan.ts' }; + }, + error: /unreachable manifest/i, + }, + { + name: 'an unsafe emitted path', + mutate: (manifest: ViteManifest) => { + manifest['src/shared.ts']!.file = '../outside.js'; + }, + error: /unsafe.*path/i, + }, + { + name: 'a missing recursive import', + mutate: (manifest: ViteManifest) => { + manifest['src/shared.ts']!.imports = ['src/missing.ts']; + }, + error: /missing manifest import/i, + }, + ])('rejects $name', ({ mutate, error }) => { + const manifest = recursiveManifest(); + mutate(manifest); + + expect(() => resolveViteAssetClosure(manifest)).toThrow(error); + }); +}); diff --git a/tests/unit/entry-client.test.ts b/tests/unit/entry-client.test.ts new file mode 100644 index 0000000..4e42c88 --- /dev/null +++ b/tests/unit/entry-client.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { readProductionInputsSnapshot } from '@/entry-client'; +import { serializeJsonLd } from '@/security/serialize-json-ld'; +import type { ProductionInputs } from '@/content/types'; + +const validInputs = (): ProductionInputs => ({ + content: JSON.parse(readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8')), + manifest: JSON.parse(readFileSync(resolve(process.cwd(), 'content/public/asset-manifest.json'), 'utf8')), + site: JSON.parse(readFileSync(resolve(process.cwd(), 'config/site.preview.json'), 'utf8')), +}) as ProductionInputs; + +const snapshotDocument = (value: unknown): Document => new DOMParser().parseFromString( + `<script id="portfolio-inputs" type="application/json">${serializeJsonLd(value)}</script>`, + 'text/html', +); + +describe('client production-input snapshot', () => { + it('schema-validates and recursively freezes the complete browser snapshot', () => { + const inputs = readProductionInputsSnapshot(snapshotDocument(validInputs())); + + expect(Object.isFrozen(inputs)).toBe(true); + expect(Object.isFrozen(inputs.content.projects.selected)).toBe(true); + expect(Object.isFrozen(inputs.content.projects.selected[0]?.technicalDecisions)).toBe(true); + expect(Object.isFrozen(inputs.manifest.assets)).toBe(true); + expect(Object.isFrozen(inputs.site)).toBe(true); + expect(() => { + (inputs.content.identity as { role: string }).role = 'mutated'; + }).toThrow(); + }); + + it('rejects JSON that does not satisfy the production-input schema', () => { + const invalid = validInputs() as ProductionInputs & { unexpected?: string }; + invalid.unexpected = 'not allowed'; + + expect(() => readProductionInputsSnapshot(snapshotDocument(invalid))).toThrow(/unrecognized|invalid|expected/i); + }); + + it('rejects a supplied asset reference absent from the supplied public manifest', () => { + const invalid = validInputs(); + invalid.content.projects.selected[0]!.assetIds = ['unmanifested-snapshot-asset']; + + expect(() => readProductionInputsSnapshot(snapshotDocument(invalid))).toThrow(/unmanifested asset reference/i); + }); +}); diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts index 650f2e5..d3babc9 100644 --- a/tests/unit/metadata.test.ts +++ b/tests/unit/metadata.test.ts @@ -95,7 +95,8 @@ describe('environment-aware crawl and response artifacts', () => { }); it('generates production-only sitemap, allow policy, HSTS, and no preview directives', () => { - const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, productionSite), productionSite); + const metadata = createPageMetadata(content, manifest, productionSite); + const artifacts = createStaticHostArtifacts(metadata, productionSite); expect(artifacts.robots).toBe( 'User-agent: *\nAllow: /\nSitemap: https://portfolio.example.test/sitemap.xml\n', @@ -104,6 +105,20 @@ describe('environment-aware crawl and response artifacts', () => { expect(artifacts.headers).toContain('Strict-Transport-Security: max-age=31536000'); expect(artifacts.headers).not.toContain('X-Robots-Tag'); expect(artifacts.headers).not.toContain('private, no-store'); + expect(artifacts.headers).toBe([ + '/assets/*', + ' Cache-Control: public, max-age=31536000, immutable', + '', + '/*', + ` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; style-src 'self'; img-src 'self'; media-src 'self'; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests`, + ' X-Content-Type-Options: nosniff', + ' Referrer-Policy: no-referrer', + ' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()', + ' X-Frame-Options: DENY', + ' Cache-Control: public, max-age=0, must-revalidate', + ' Strict-Transport-Security: max-age=31536000', + '', + ].join('\n')); }); it('emits the least-privilege browser security baseline', () => {