From 5edddb8781b5c1f5b7f563bda54e8c7ff05c9c76 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 08:15:00 +0300 Subject: [PATCH] fix: enforce approved media publication closure --- package.json | 4 +- scripts/materialize-empty-asset-set.mjs | 173 ++++++++++++++++-- src/content/schemas.ts | 10 + .../materialize-empty-asset-set.test.mjs | 93 +++++++++- tests/integration/public-content.test.ts | 22 ++- tests/unit/content-schemas.test.ts | 12 ++ 6 files changed, 283 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index efd6e8f..61401fc 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "verify:assets": "node scripts/prepare-assets.mjs --verify", "check:budgets": "node scripts/check-bundle-budgets.mjs dist", "aggregate:performance": "node scripts/aggregate-drive-traces.mjs", - "prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs", - "build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets" + "verify:approved-assets": "node scripts/materialize-empty-asset-set.mjs", + "build:preview": "npm run verify:approved-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets" }, "keywords": [], "author": "", diff --git a/scripts/materialize-empty-asset-set.mjs b/scripts/materialize-empty-asset-set.mjs index 57da791..d6081b1 100644 --- a/scripts/materialize-empty-asset-set.mjs +++ b/scripts/materialize-empty-asset-set.mjs @@ -1,6 +1,7 @@ +import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptPath = fileURLToPath(import.meta.url); @@ -50,16 +51,103 @@ const assertOpenedDirectoryIdentity = async (preparedAssetsPath, expectedIdentit } }; -const readEmptyManifest = async (repositoryRoot) => { +const readJson = async (path, label) => { + let value; + try { + value = JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + throw new Error(`${label} must be valid JSON.`, { cause: error }); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object.`); + } + return value; +}; + +const readManifest = async (repositoryRoot) => { const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json'); - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest) || - Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets) || manifest.assets.length !== 0) { - throw new Error('Phase 1 asset manifest must contain exactly zero assets.'); + const manifest = await readJson(manifestPath, 'Public asset manifest'); + if (Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) { + throw new Error('Public asset manifest must contain exactly one assets array.'); + } + const ids = new Set(); + const fileNames = new Set(); + const assets = manifest.assets.map((asset) => { + if (typeof asset !== 'object' || asset === null || Array.isArray(asset)) { + throw new Error('Every manifest asset must be an object.'); + } + const { id, fileName, sha256 } = asset; + if (typeof id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(id)) { + throw new Error('Manifest asset ID must be a generated safe identifier.'); + } + if (ids.has(id)) throw new Error(`Duplicate manifest asset ID: ${id}`); + ids.add(id); + if (typeof fileName !== 'string' || !/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/u.test(fileName)) { + throw new Error(`Manifest filename must be a generated safe filename: ${id}`); + } + const foldedFileName = fileName.toLocaleLowerCase('en-US'); + if (fileNames.has(foldedFileName)) throw new Error(`Duplicate manifest filename: ${fileName}`); + fileNames.add(foldedFileName); + if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(sha256)) { + throw new Error(`Manifest asset must have a lowercase SHA-256 digest: ${id}`); + } + return { id, fileName, sha256 }; + }); + return assets; +}; + +const assetReferences = (content) => { + const references = []; + const projects = content.projects; + if (typeof projects !== 'object' || projects === null || Array.isArray(projects)) { + throw new Error('Portfolio content must contain project collections.'); + } + for (const collectionName of ['selected', 'additional']) { + const collection = projects[collectionName]; + if (!Array.isArray(collection)) throw new Error(`Portfolio ${collectionName} projects must be an array.`); + for (const project of collection) { + if (typeof project !== 'object' || project === null || Array.isArray(project) || !Array.isArray(project.assetIds)) { + throw new Error('Every public project must contain an assetIds array.'); + } + references.push(...project.assetIds); + } + } + if (!Array.isArray(content.toolkit)) throw new Error('Portfolio toolkit must be an array.'); + for (const group of content.toolkit) { + if (typeof group !== 'object' || group === null || Array.isArray(group) || !Array.isArray(group.technologies)) { + throw new Error('Every toolkit group must contain a technologies array.'); + } + for (const technology of group.technologies) { + if (typeof technology !== 'object' || technology === null || Array.isArray(technology)) { + throw new Error('Every toolkit technology must be an object.'); + } + if (technology.assetId !== undefined) references.push(technology.assetId); + } + } + if (typeof content.metadata !== 'object' || content.metadata === null || Array.isArray(content.metadata)) { + throw new Error('Portfolio content must contain metadata.'); + } + if (content.metadata.socialPreviewAssetId !== undefined) references.push(content.metadata.socialPreviewAssetId); + return references; +}; + +const assertExactReferenceClosure = (assets, content) => { + const manifestIds = new Set(assets.map((asset) => asset.id)); + const referencedIds = new Set(); + for (const assetId of assetReferences(content)) { + if (typeof assetId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(assetId)) { + throw new Error('Public asset reference must be a generated safe identifier.'); + } + if (referencedIds.has(assetId)) throw new Error(`Duplicate public asset reference: ${assetId}`); + referencedIds.add(assetId); + if (!manifestIds.has(assetId)) throw new Error(`Unknown public asset reference: ${assetId}`); + } + for (const assetId of manifestIds) { + if (!referencedIds.has(assetId)) throw new Error(`Unreferenced manifest asset: ${assetId}`); } }; -const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => { +const ensurePreparedAssetDirectory = async (repositoryRoot) => { const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets'); let initialStats; try { @@ -74,19 +162,72 @@ const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => { const initialIdentity = directoryIdentity(initialStats); await assertOpenedDirectoryIdentity(preparedAssetsPath, initialIdentity); await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity); - const members = await readdir(preparedAssetsPath); - await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity); - if (members.length !== 0) { - throw new Error('Prepared asset directory must be empty during Phase 1.'); + return { preparedAssetsPath, initialIdentity }; +}; + +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +const readPreparedRegularFile = async (filePath, fileName) => { + const noFollowFlags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0); + let handle; + try { + handle = await open(filePath, noFollowFlags); + } catch (error) { + if (isErrno(error, 'ELOOP')) { + throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`); + } + throw error; + } + try { + const stats = await handle.stat(); + if (!stats.isFile() || stats.nlink !== 1) { + throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`); + } + return await handle.readFile(); + } finally { + await handle.close(); } }; -export const materializeEmptyAssetSet = async (repositoryRoot) => { - const resolvedRepositoryRoot = resolve(repositoryRoot); - await readEmptyManifest(resolvedRepositoryRoot); - await ensureEmptyPreparedAssetDirectory(resolvedRepositoryRoot); +const verifyPreparedAssetFiles = async (repositoryRoot, assets) => { + const { preparedAssetsPath, initialIdentity } = await ensurePreparedAssetDirectory(repositoryRoot); + const expectedByName = new Map(assets.map((asset) => [asset.fileName, asset])); + const foundNames = new Set(); + const members = await readdir(preparedAssetsPath, { withFileTypes: true }); + for (const member of members) { + const asset = expectedByName.get(member.name); + if (asset === undefined) throw new Error(`Extra prepared asset: ${member.name}`); + if (member.isSymbolicLink() || !member.isFile()) { + throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`); + } + const filePath = join(preparedAssetsPath, member.name); + const stats = await lstat(filePath); + if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) { + throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`); + } + if (sha256(await readPreparedRegularFile(filePath, member.name)) !== asset.sha256) { + throw new Error(`Prepared asset hash does not match manifest: ${member.name}`); + } + foundNames.add(member.name); + } + await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity); + for (const asset of assets) { + if (!foundNames.has(asset.fileName)) throw new Error(`Missing prepared asset: ${asset.fileName}`); + } }; +export const verifyApprovedAssetSet = async (repositoryRoot) => { + const resolvedRepositoryRoot = resolve(repositoryRoot); + const [assets, content] = await Promise.all([ + readManifest(resolvedRepositoryRoot), + readJson(resolve(resolvedRepositoryRoot, 'content/public/portfolio-content.json'), 'Portfolio content'), + ]); + assertExactReferenceClosure(assets, content); + await verifyPreparedAssetFiles(resolvedRepositoryRoot, assets); +}; + +export const materializeEmptyAssetSet = verifyApprovedAssetSet; + if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { - await materializeEmptyAssetSet(defaultRepositoryRoot); + await verifyApprovedAssetSet(defaultRepositoryRoot); } diff --git a/src/content/schemas.ts b/src/content/schemas.ts index b7ee2ae..47c4790 100644 --- a/src/content/schemas.ts +++ b/src/content/schemas.ts @@ -297,11 +297,21 @@ export const parseProductionInputSnapshot = (input: unknown): ProductionInputs = technology.assetId === undefined ? [] : [technology.assetId])), ...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]), ]; + const seenReferences = new Set(); for (const assetId of referencedAssetIds) { + if (seenReferences.has(assetId)) { + throw new Error(`Duplicate public asset reference: ${assetId}`); + } + seenReferences.add(assetId); if (!manifestIds.has(assetId)) { throw new Error(`Unmanifested asset reference: ${assetId}`); } } + for (const assetId of manifestIds) { + if (!seenReferences.has(assetId)) { + throw new Error(`Unreferenced manifest asset: ${assetId}`); + } + } return { content, manifest, site } as ProductionInputs; }; diff --git a/tests/integration/materialize-empty-asset-set.test.mjs b/tests/integration/materialize-empty-asset-set.test.mjs index ab11e00..a525115 100644 --- a/tests/integration/materialize-empty-asset-set.test.mjs +++ b/tests/integration/materialize-empty-asset-set.test.mjs @@ -1,10 +1,11 @@ +import { createHash } from 'node:crypto'; import { lstat, mkdir, mkdtemp, 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 { assertPreparedAssetDirectoryIdentity, - materializeEmptyAssetSet, + verifyApprovedAssetSet, } from '../../scripts/materialize-empty-asset-set.mjs'; const temporaryRoots = []; @@ -14,35 +15,115 @@ const createScratchRepository = async () => { temporaryRoots.push(root); await mkdir(join(root, 'content/public'), { recursive: true }); await writeFile(join(root, 'content/public/asset-manifest.json'), '{"assets":[]}'); + await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ + projects: { selected: [], additional: [] }, + toolkit: [], + metadata: {}, + })); return root; }; +const assetBytes = Buffer.from('approved public asset'); +const assetHash = createHash('sha256').update(assetBytes).digest('hex'); +const writeApprovedSet = async (root) => { + await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: [{ + id: 'approved-image', + fileName: 'approved-image.webp', + sha256: assetHash, + }] })); + await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ + projects: { selected: [{ assetIds: ['approved-image'] }], additional: [] }, + toolkit: [], + metadata: {}, + })); + await mkdir(join(root, 'inputs/prepared-assets'), { recursive: true }); + await writeFile(join(root, 'inputs/prepared-assets/approved-image.webp'), assetBytes); +}; + afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); -describe('empty asset materialization', () => { +describe('approved asset publication closure', () => { it('creates an empty prepared-asset directory in the supplied repository root', async () => { const root = await createScratchRepository(); - await materializeEmptyAssetSet(root); + await verifyApprovedAssetSet(root); expect((await lstat(join(root, 'inputs/prepared-assets'))).isDirectory()).toBe(true); }); + it('accepts an approved manifest, public reference, and prepared file with exact closure', async () => { + const root = await createScratchRepository(); + await writeApprovedSet(root); + await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined(); + }); + it('rejects a symbolic-link prepared-asset directory', async () => { const root = await createScratchRepository(); const target = join(root, 'outside'); await mkdir(target); await mkdir(join(root, 'inputs'), { recursive: true }); await symlink(target, join(root, 'inputs/prepared-assets')); - await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/non-symlink directory/i); + await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/non-symlink directory/i); }); - it('rejects unexpected prepared-asset members in its supplied repository root', async () => { + it('rejects extra prepared files', async () => { const root = await createScratchRepository(); const preparedAssetsPath = join(root, 'inputs/prepared-assets'); await mkdir(preparedAssetsPath, { recursive: true }); await writeFile(join(preparedAssetsPath, 'unexpected.webp'), 'not an asset'); - await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/must be empty/i); + await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/extra prepared asset/i); + }); + + it('rejects missing and path-mismatched prepared files', async () => { + const root = await createScratchRepository(); + await writeApprovedSet(root); + await rm(join(root, 'inputs/prepared-assets/approved-image.webp')); + await writeFile(join(root, 'inputs/prepared-assets/wrong-image.webp'), assetBytes); + await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/extra prepared asset|missing prepared asset/i); + }); + + it('rejects unknown, unreferenced, and duplicate publication IDs', async () => { + const unknown = await createScratchRepository(); + await writeApprovedSet(unknown); + await writeFile(join(unknown, 'content/public/portfolio-content.json'), JSON.stringify({ + projects: { selected: [{ assetIds: ['unknown-image'] }], additional: [] }, toolkit: [], metadata: {}, + })); + await expect(verifyApprovedAssetSet(unknown)).rejects.toThrow(/unknown public asset reference/i); + + const unreferenced = await createScratchRepository(); + await writeApprovedSet(unreferenced); + await writeFile(join(unreferenced, 'content/public/portfolio-content.json'), JSON.stringify({ + projects: { selected: [], additional: [] }, toolkit: [], metadata: {}, + })); + await expect(verifyApprovedAssetSet(unreferenced)).rejects.toThrow(/unreferenced manifest asset/i); + + const duplicate = await createScratchRepository(); + await writeApprovedSet(duplicate); + await writeFile(join(duplicate, 'content/public/portfolio-content.json'), JSON.stringify({ + projects: { selected: [{ assetIds: ['approved-image', 'approved-image'] }], additional: [] }, + toolkit: [], metadata: {}, + })); + await expect(verifyApprovedAssetSet(duplicate)).rejects.toThrow(/duplicate public asset reference/i); + }); + + it('rejects duplicate manifest IDs', async () => { + const root = await createScratchRepository(); + await writeApprovedSet(root); + const duplicateAsset = { id: 'approved-image', fileName: 'approved-image.webp', sha256: assetHash }; + await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ + assets: [duplicateAsset, { ...duplicateAsset, fileName: 'second-image.webp' }], + })); + await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/duplicate manifest asset id/i); + }); + + it('rejects duplicate manifest filenames', async () => { + const root = await createScratchRepository(); + await writeApprovedSet(root); + const duplicateAsset = { id: 'approved-image', fileName: 'approved-image.webp', sha256: assetHash }; + await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ + assets: [duplicateAsset, { ...duplicateAsset, id: 'second-image' }], + })); + await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/duplicate manifest filename/i); }); it('rejects a prepared-asset directory replaced after its identity was captured', async () => { diff --git a/tests/integration/public-content.test.ts b/tests/integration/public-content.test.ts index 6a9212e..2393bdc 100644 --- a/tests/integration/public-content.test.ts +++ b/tests/integration/public-content.test.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest'; -import { parsePortfolioContent, parsePublicAssetManifest, parseSiteConfig } from '@/content/schemas'; +import { validateProductionInputs } from '@/content/schemas'; const repositoryRoot = process.cwd(); const readJson = async (relativePath: string): Promise => @@ -8,9 +8,12 @@ const readJson = async (relativePath: string): Promise => describe('approved public portfolio projection', () => { it('contains approved identity and balanced evidence without prohibited surfaces', async () => { - const content = parsePortfolioContent(await readJson('content/public/portfolio-content.json')); - const manifest = parsePublicAssetManifest(await readJson('content/public/asset-manifest.json')); - const site = parseSiteConfig(await readJson('config/site.preview.json')); + const { content, manifest, site } = validateProductionInputs( + await readJson('content/public/portfolio-content.json'), + await readJson('content/public/asset-manifest.json'), + await readJson('config/site.preview.json'), + new URL('file:///prepared-assets/'), + ); expect(content.identity).toEqual(expect.objectContaining({ name: 'Luca Pîslaru', @@ -25,8 +28,6 @@ describe('approved public portfolio projection', () => { .toBe('Private self-hosted medication tracker'); expect(content.projects.additional.find((project) => project.id === 'private-game-prototype')?.title) .toBe('Private game prototype'); - expect([...content.projects.selected, ...content.projects.additional] - .every((project) => project.assetIds.length === 0)).toBe(true); expect(content.toolkit.flatMap((group) => group.technologies).map((technology) => technology.name)) .toEqual(expect.arrayContaining(['Linux', 'Docker', 'Python', 'Vue.js', 'Java'])); expect(content.contact).toEqual(expect.objectContaining({ email: 'luca.pislaru@gmail.com' })); @@ -35,7 +36,14 @@ describe('approved public portfolio projection', () => { 'https://gitlab.com/luca_pisl', 'https://linkedin.com/in/luca-pisl', ]); - expect(manifest.assets).toEqual([]); + const publishedReferences = [ + ...content.projects.selected.flatMap((project) => project.assetIds), + ...content.projects.additional.flatMap((project) => project.assetIds), + ...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) => + technology.assetId === undefined ? [] : [technology.assetId])), + ...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]), + ]; + expect([...publishedReferences].sort()).toEqual(manifest.assets.map((asset) => asset.id).sort()); expect(site).toEqual({ environment: 'preview', canonicalUrl: 'https://portfolio.localhost/', diff --git a/tests/unit/content-schemas.test.ts b/tests/unit/content-schemas.test.ts index 53211c8..2362103 100644 --- a/tests/unit/content-schemas.test.ts +++ b/tests/unit/content-schemas.test.ts @@ -147,6 +147,18 @@ describe('content schemas', () => { .toThrow(/unmanifested asset/i); }); + it('requires duplicate-free set equality between content references and manifest assets', () => { + const duplicateReference = cloneContent(); + duplicateReference.projects.selected[1]!.assetIds = ['selected-build-one']; + expect(() => validateProductionInputs(duplicateReference, validManifest, validSite, new URL('file:///prepared/'))) + .toThrow(/duplicate public asset reference/i); + + const unreferencedManifestAsset = cloneContent(); + unreferencedManifestAsset.projects.selected[0]!.assetIds = []; + expect(() => validateProductionInputs(unreferencedManifestAsset, validManifest, validSite, new URL('file:///prepared/'))) + .toThrow(/unreferenced manifest asset/i); + }); + it('rejects duplicate manifest asset IDs and unsafe generated filenames', () => { const manifest = structuredClone(validManifest) as { assets: Array<{ id: string; fileName: string }> }; manifest.assets[1]!.id = manifest.assets[0]!.id;