From 5bdeeaa433f0f402089cd0830f436ebbe2cea1fd Mon Sep 17 00:00:00 2001 From: LucaP Date: Fri, 4 Sep 2026 22:22:28 +0300 Subject: [PATCH] fix: harden additive asset publication --- scripts/lib/merge-assets.mjs | 4 +- scripts/prepare-assets.mjs | 225 +++++++++++++++++++---- tests/integration/prepare-assets.test.ts | 224 +++++++++++++++++++++- 3 files changed, 410 insertions(+), 43 deletions(-) diff --git a/scripts/lib/merge-assets.mjs b/scripts/lib/merge-assets.mjs index a05a0c0..8f3aae1 100644 --- a/scripts/lib/merge-assets.mjs +++ b/scripts/lib/merge-assets.mjs @@ -1,5 +1,6 @@ const idPattern = /^[a-z][a-z0-9-]{0,63}$/u; const fileNamePattern = /^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm|opus|mp3)$/u; +const managedFileNamePattern = /^(?:project|social)-[a-z0-9][a-z0-9-]*\.(?:avif|jpg|png|svg|webp|mp4|webm)$/u; const sha256Pattern = /^[a-f0-9]{64}$/u; const assertSafeRecord = (asset) => { @@ -21,7 +22,8 @@ export const mergeAssetRecords = (baseAssets, generatedAssets, managedIds) => { throw new Error('ASSET-MERGE-RECORD-001'); } } - const retained = baseAssets.filter(({ id }) => !managedIds.has(id)); + const retained = baseAssets.filter(({ id, fileName }) => + !managedIds.has(id) || !managedFileNamePattern.test(fileName)); const combined = [...retained, ...generatedAssets]; const ids = new Set(); const names = new Set(); diff --git a/scripts/prepare-assets.mjs b/scripts/prepare-assets.mjs index f52c866..e99cf8c 100644 --- a/scripts/prepare-assets.mjs +++ b/scripts/prepare-assets.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; -import { copyFile, lstat, mkdir, open, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises'; -import { basename, dirname, extname, join, resolve } from 'node:path'; +import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, extname, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { fileTypeFromFile } from 'file-type'; @@ -15,6 +15,44 @@ import { encodeVideoDerivatives, extractVideoFrames, probeVideo } from './lib/vi const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const fail = (rule) => { throw new Error(rule); }; +const isErrno = (error, code) => typeof error === 'object' && error !== null && 'code' in error && error.code === code; + +const canonicalizePathRole = async (path) => { + let cursor = resolve(path); + const suffix = []; + while (true) { + try { + return resolve(await realpath(cursor), ...suffix); + } catch (error) { + if (!isErrno(error, 'ENOENT')) fail('ASSET-PATH-001'); + const parent = dirname(cursor); + if (parent === cursor) fail('ASSET-PATH-001'); + suffix.unshift(basename(cursor)); + cursor = parent; + } + } +}; + +const pathsOverlap = (left, right) => + left === right || left.startsWith(`${right}${sep}`) || right.startsWith(`${left}${sep}`); + +const validatePathRoles = async (options) => { + const roleNames = [ + 'sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir', + ...(options.approvalRegisterPath === undefined ? [] : ['approvalRegisterPath', 'publishRoot', 'manifestPath']), + ]; + const roles = Object.fromEntries(await Promise.all(roleNames.map(async (name) => [ + name, + await canonicalizePathRole(options[name]), + ]))); + for (const resetRole of ['stagingRoot', 'reportDir']) { + if (!roles[resetRole].startsWith(`/tmp${sep}`)) fail('ASSET-PATH-001'); + for (const otherRole of roleNames) { + if (resetRole !== otherRole && pathsOverlap(roles[resetRole], roles[otherRole])) fail('ASSET-PATH-001'); + } + } + return roles; +}; const assertSafeOutputRoot = (path, rule) => { const resolved = resolve(path); @@ -210,8 +248,12 @@ const validateManifestRecord = (asset) => { } }; -const loadManifestAssets = async (manifestPath) => { - const manifest = await readJson(manifestPath, 'ASSET-MANIFEST-001'); +const fileIdentity = (details) => ({ dev: details.dev, ino: details.ino }); +const sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino; + +const manifestAssetsFromBytes = (bytes) => { + let manifest; + try { manifest = JSON.parse(bytes.toString('utf8')); } catch { fail('ASSET-MANIFEST-001'); } if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001'); for (const asset of manifest.assets) validateManifestRecord(asset); @@ -231,7 +273,7 @@ const readVerifiedRegularFile = async (path, expectedHash, rule) => { if (!details.isFile() || details.nlink !== 1) fail(rule); const bytes = await handle.readFile(); if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) fail('ASSET-HASH-001'); - return bytes; + return { bytes, identity: fileIdentity(details) }; } catch (error) { if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error; fail(rule); @@ -240,61 +282,167 @@ const readVerifiedRegularFile = async (path, expectedHash, rule) => { } }; -const verifyCurrentPublication = async (publishRoot, assets) => { - const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001'); +const readManifestSnapshot = async (manifestPath) => { + let handle; + try { + handle = await open(manifestPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const details = await handle.stat(); + if (!details.isFile() || details.nlink !== 1) fail('ASSET-MANIFEST-001'); + const bytes = await handle.readFile(); + return { assets: manifestAssetsFromBytes(bytes), bytes, identity: fileIdentity(details) }; + } catch (error) { + if (error instanceof Error && error.message === 'ASSET-MANIFEST-001') throw error; + fail('ASSET-MANIFEST-001'); + } finally { + await handle?.close(); + } +}; + +const verifyPublicationDirectory = async (publishRoot, assets) => { let rootStats; - try { rootStats = await lstat(safePublishRoot); } catch { fail('ASSET-EXACT-001'); } + try { rootStats = await lstat(publishRoot); } catch { fail('ASSET-EXACT-001'); } if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) fail('ASSET-EXACT-001'); const expectedNames = new Set(assets.map(({ fileName }) => fileName)); - const entries = await readdir(safePublishRoot, { withFileTypes: true }); + const entries = await readdir(publishRoot, { withFileTypes: true }); if (entries.length !== expectedNames.size) fail('ASSET-EXACT-001'); for (const entry of entries) { if (!expectedNames.has(entry.name) || entry.isSymbolicLink() || !entry.isFile()) fail('ASSET-EXACT-001'); } + const files = new Map(); for (const asset of assets) { - await readVerifiedRegularFile(join(safePublishRoot, asset.fileName), asset.sha256, 'ASSET-EXACT-001'); + const snapshot = await readVerifiedRegularFile(join(publishRoot, asset.fileName), asset.sha256, 'ASSET-EXACT-001'); + files.set(asset.fileName, snapshot); } - return safePublishRoot; + return { rootIdentity: fileIdentity(rootStats), files }; }; -const publish = async ({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) => { +const verifyCurrentPublication = async (publishRoot, assets) => { + const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001'); + return { safePublishRoot, ...await verifyPublicationDirectory(safePublishRoot, assets) }; +}; + +const managedCoordinatesForRecipes = (recipes) => recipes.flatMap((recipe) => { + if (recipe.kind === 'image') return [{ id: recipe.id, fileName: recipe.output }]; + if (recipe.kind !== 'video' || !recipe.output.endsWith('.webm')) fail('ASSET-OWNERSHIP-001'); + const base = recipe.output.slice(0, -'.webm'.length); + return [ + { id: recipe.id, fileName: `${base}.webm` }, + { id: `${recipe.id}-mp4`, fileName: `${base}.mp4` }, + { id: `${recipe.id}-poster`, fileName: `${base}-poster.webp` }, + ]; +}); + +const trustedManagedIds = (recipes, candidates) => { + const expected = managedCoordinatesForRecipes(recipes); + const expectedCoordinates = expected.map(({ id, fileName }) => `${id}\u0000${fileName}`).sort(); + const actualCoordinates = candidates.map(({ id, fileName }) => `${id}\u0000${fileName}`).sort(); + if (JSON.stringify(actualCoordinates) !== JSON.stringify(expectedCoordinates)) fail('ASSET-OWNERSHIP-001'); + return new Set(expected.map(({ id }) => id)); +}; + +export const publishPreparedAssets = async ({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) => { if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) { fail('ASSET-RIGHTS-001'); } - const baseAssets = await loadManifestAssets(manifestPath); - const safePublishRoot = await verifyCurrentPublication(publishRoot, baseAssets); - const managedIds = new Set(recipes.map(({ id }) => id)); + const manifestSnapshot = await readManifestSnapshot(manifestPath); + const baseAssets = manifestSnapshot.assets; + const currentPublication = await verifyCurrentPublication(publishRoot, baseAssets); + const { safePublishRoot } = currentPublication; + const managedIds = trustedManagedIds(recipes, candidates); const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds); + const candidateSnapshots = new Map(); for (const item of candidates) { validateManifestRecord(item); - await readVerifiedRegularFile(join(stagingRoot, item.fileName), item.sha256, 'ASSET-EXACT-001'); + const path = join(stagingRoot, item.fileName); + candidateSnapshots.set(item.fileName, { + path, + expectedHash: item.sha256, + ...await readVerifiedRegularFile(path, item.sha256, 'ASSET-EXACT-001'), + }); } - const mergedNames = new Set(mergedAssets.map(({ fileName }) => fileName)); - const obsoleteNames = baseAssets - .filter(({ id, fileName }) => managedIds.has(id) && !mergedNames.has(fileName)) - .map(({ fileName }) => fileName); - for (const item of candidates) await copyFile(join(stagingRoot, item.fileName), join(safePublishRoot, item.fileName)); - for (const item of candidates) { - await readVerifiedRegularFile(join(safePublishRoot, item.fileName), item.sha256, 'ASSET-EXACT-001'); + let publicationTransactionRoot; + let manifestTransactionRoot; + let preserveRecoveryState = false; + try { + publicationTransactionRoot = await mkdtemp(join(dirname(safePublishRoot), '.asset-publication-')); + const nextPublication = join(publicationTransactionRoot, 'next'); + const previousPublication = join(publicationTransactionRoot, 'previous'); + await mkdir(nextPublication); + for (const asset of mergedAssets) { + const snapshot = candidateSnapshots.get(asset.fileName) ?? currentPublication.files.get(asset.fileName); + if (snapshot === undefined) fail('ASSET-PUBLISH-001'); + await writeFile(join(nextPublication, asset.fileName), snapshot.bytes, { flag: 'wx', mode: 0o644 }); + } + await verifyPublicationDirectory(nextPublication, mergedAssets); + + manifestTransactionRoot = await mkdtemp(join(dirname(manifestPath), '.asset-manifest-')); + const nextManifest = join(manifestTransactionRoot, 'next-manifest.json'); + const previousManifest = join(manifestTransactionRoot, 'previous-manifest.json'); + await writeFile(nextManifest, `${JSON.stringify({ assets: mergedAssets }, null, 2)}\n`, { flag: 'wx', mode: 0o644 }); + + for (const snapshot of candidateSnapshots.values()) { + const current = await readVerifiedRegularFile(snapshot.path, snapshot.expectedHash, 'ASSET-EXACT-001'); + if (!sameIdentity(current.identity, snapshot.identity)) fail('ASSET-EXACT-001'); + } + const protectedPublication = await verifyCurrentPublication(safePublishRoot, baseAssets); + if (!sameIdentity(protectedPublication.rootIdentity, currentPublication.rootIdentity)) fail('ASSET-EXACT-001'); + const protectedManifest = await readManifestSnapshot(manifestPath); + if (!sameIdentity(protectedManifest.identity, manifestSnapshot.identity) || + !protectedManifest.bytes.equals(manifestSnapshot.bytes)) fail('ASSET-MANIFEST-001'); + + let publicationBackedUp = false; + let publicationInstalled = false; + let manifestBackedUp = false; + let manifestInstalled = false; + try { + await rename(safePublishRoot, previousPublication); + publicationBackedUp = true; + await rename(nextPublication, safePublishRoot); + publicationInstalled = true; + await rename(manifestPath, previousManifest); + manifestBackedUp = true; + await rename(nextManifest, manifestPath); + manifestInstalled = true; + const installedManifest = await readManifestSnapshot(manifestPath); + if (JSON.stringify(installedManifest.assets) !== JSON.stringify(mergedAssets)) fail('ASSET-PUBLISH-001'); + await verifyCurrentPublication(safePublishRoot, mergedAssets); + } catch { + try { + if (manifestInstalled) await rename(manifestPath, nextManifest); + if (manifestBackedUp) await rename(previousManifest, manifestPath); + if (publicationInstalled) await rename(safePublishRoot, nextPublication); + if (publicationBackedUp) await rename(previousPublication, safePublishRoot); + } catch { + preserveRecoveryState = true; + fail('ASSET-PUBLISH-ROLLBACK-001'); + } + fail('ASSET-PUBLISH-001'); + } + } finally { + if (!preserveRecoveryState) { + if (manifestTransactionRoot !== undefined) await rm(manifestTransactionRoot, { recursive: true, force: true }); + if (publicationTransactionRoot !== undefined) await rm(publicationTransactionRoot, { recursive: true, force: true }); + } } - for (const fileName of obsoleteNames) await unlink(join(safePublishRoot, fileName)); - await verifyCurrentPublication(safePublishRoot, mergedAssets); - await mkdir(dirname(manifestPath), { recursive: true }); - await writeFile(manifestPath, `${JSON.stringify({ assets: mergedAssets }, null, 2)}\n`, 'utf8'); }; export const prepareAssets = async (options) => { const required = ['sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir']; if (!options || required.some((key) => typeof options[key] !== 'string')) fail('ASSET-ARGUMENT-001'); - const recipes = await loadRecipes(options.recipesPath); - const mapping = await loadPrivateMapping(options.mappingPath, recipes); - const denylist = await loadDenylist(options.denylistPath); - const stagingRoot = await resetDirectory(options.stagingRoot, 'ASSET-STAGING-001'); - const reportDir = await resetDirectory(options.reportDir, 'ASSET-REPORT-001'); + if (options.approvalRegisterPath !== undefined && + !['approvalRegisterPath', 'publishRoot', 'manifestPath'].every((key) => typeof options[key] === 'string')) { + fail('ASSET-ARGUMENT-001'); + } + const paths = await validatePathRoles(options); + const recipes = await loadRecipes(paths.recipesPath); + const mapping = await loadPrivateMapping(paths.mappingPath, recipes); + const denylist = await loadDenylist(paths.denylistPath); + const stagingRoot = await resetDirectory(paths.stagingRoot, 'ASSET-STAGING-001'); + const reportDir = await resetDirectory(paths.reportDir, 'ASSET-REPORT-001'); const candidates = []; for (const recipe of recipes) { - const source = await resolvePrivateSource(options.sourceRoot, mapping[recipe.id]); + const source = await resolvePrivateSource(paths.sourceRoot, mapping[recipe.id]); const prepared = recipe.kind === 'image' ? await prepareImage({ source, recipe, stagingRoot, denylist }) : await prepareVideo({ source, recipe, stagingRoot, reportDir, denylist }); @@ -310,11 +458,10 @@ export const prepareAssets = async (options) => { await createContactSheet(finalCandidates, stagingRoot, reportDir); let approvalStatus = 'pending'; if (options.approvalRegisterPath !== undefined) { - if (typeof options.publishRoot !== 'string' || typeof options.manifestPath !== 'string') fail('ASSET-ARGUMENT-001'); - await approvalsMatch(options.approvalRegisterPath, approval); - await publish({ + await approvalsMatch(paths.approvalRegisterPath, approval); + await publishPreparedAssets({ candidates: finalCandidates, recipes, stagingRoot, - publishRoot: options.publishRoot, manifestPath: options.manifestPath, + publishRoot: paths.publishRoot, manifestPath: paths.manifestPath, }); approvalStatus = 'approved'; } @@ -323,8 +470,8 @@ export const prepareAssets = async (options) => { export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => { if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001'); - const assets = await loadManifestAssets(manifestPath); - const safePublishRoot = await verifyCurrentPublication(publishRoot, assets); + const { assets } = await readManifestSnapshot(manifestPath); + const { safePublishRoot } = await verifyCurrentPublication(publishRoot, assets); const denylist = await loadDenylist(denylistPath); for (const item of assets) { const path = join(safePublishRoot, item.fileName); diff --git a/tests/integration/prepare-assets.test.ts b/tests/integration/prepare-assets.test.ts index 27c4e6e..4f2602d 100644 --- a/tests/integration/prepare-assets.test.ts +++ b/tests/integration/prepare-assets.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node -import { link, mkdtemp, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { link, mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, watch, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import sharp from 'sharp'; @@ -15,7 +15,7 @@ import { scanImage, scanText } from '../../scripts/lib/privacy-scan.mjs'; // @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper. import { mergeAssetRecords } from '../../scripts/lib/merge-assets.mjs'; // @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper. -import { prepareAssets, verifyPreparedAssets } from '../../scripts/prepare-assets.mjs'; +import { prepareAssets, publishPreparedAssets, verifyPreparedAssets } from '../../scripts/prepare-assets.mjs'; const execFileAsync = promisify(execFile); interface Candidate { id: string; fileName: string; sha256: string; } @@ -26,6 +26,26 @@ const writeJson = async (path: string, value: unknown): Promise => { await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); }; +const onTransactionDirectory = async ( + root: string, + prefix: string, + action: (path: string) => Promise, +): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + for await (const event of watch(root, { signal: controller.signal })) { + const name = event.filename?.toString(); + if (name?.startsWith(prefix)) { + await action(join(root, name)); + return; + } + } + } finally { + clearTimeout(timeout); + } +}; + const baseRecipe = (overrides: Record = {}) => ({ id: 'project-interface', kind: 'image', @@ -143,6 +163,135 @@ describe('approved asset record merging', () => { }); }); +describe('prepared candidate publication', () => { + it('re-publishes the complete base, MP4, and poster record set for a video recipe', async () => { + const root = await createRoot(); + const stagingRoot = join(root, 'staging'); + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + await mkdir(stagingRoot); + await mkdir(publishRoot); + await writeJson(manifestPath, { assets: [] }); + const definitions = [ + ['private-game-loop', 'project-private-game-loop.webm', 'video/webm', 640, 360, 1_500], + ['private-game-loop-mp4', 'project-private-game-loop.mp4', 'video/mp4', 640, 360, 1_500], + ['private-game-loop-poster', 'project-private-game-loop-poster.webp', 'image/webp', 640, 360, null], + ] as const; + const candidates = []; + for (const [id, fileName, mediaType, width, height, durationMs] of definitions) { + const path = join(stagingRoot, fileName); + await writeFile(path, Buffer.from(`approved bytes for ${id}`)); + candidates.push({ + id, fileName, sha256: await hashFile(path), mediaType, width, height, durationMs, + rightsHolder: 'Portfolio owner', license: 'All rights reserved', attribution: 'Portfolio owner', + }); + } + const recipes = [{ id: 'private-game-loop', kind: 'video', output: 'project-private-game-loop.webm' }]; + + await publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath }); + await publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath }); + + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { assets: Candidate[] }; + expect(manifest.assets.map(({ id }) => id).sort()).toEqual([ + 'private-game-loop', 'private-game-loop-mp4', 'private-game-loop-poster', + ]); + expect((await readdir(publishRoot)).sort()).toEqual([ + 'project-private-game-loop-poster.webp', 'project-private-game-loop.mp4', 'project-private-game-loop.webm', + ]); + }); + + it('rejects a candidate changed after validation and rolls back the complete publication', async () => { + const root = await createRoot(); + const stagingRoot = join(root, 'staging'); + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const candidatePath = join(stagingRoot, 'project-interface.webp'); + const soundtrackPath = join(publishRoot, 'nightdrive-main.opus'); + const soundtrackBytes = Buffer.from('transaction-protected soundtrack bytes'); + await mkdir(stagingRoot); + await mkdir(publishRoot); + await writeFile(candidatePath, 'approved candidate bytes'); + await writeFile(soundtrackPath, soundtrackBytes); + await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(soundtrackPath))] }); + const initialManifest = await readFile(manifestPath, 'utf8'); + const candidates = [{ + id: 'project-interface', + fileName: 'project-interface.webp', + sha256: await hashFile(candidatePath), + mediaType: 'image/webp', + width: 640, + height: 360, + durationMs: null, + rightsHolder: 'Portfolio owner', + license: 'All rights reserved', + attribution: 'Portfolio owner', + }]; + const recipes = [{ id: 'project-interface', kind: 'image', output: 'project-interface.webp' }]; + + const mutation = onTransactionDirectory(root, '.asset-publication-', async () => { + await writeFile(candidatePath, 'changed after validation'); + }); + const outcome = publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) + .then(() => ({ error: undefined }), (error: unknown) => ({ error })); + await mutation; + const { error } = await outcome; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/ASSET-(HASH|PUBLISH)-001/u); + expect(await readFile(soundtrackPath)).toEqual(soundtrackBytes); + expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); + expect(await readdir(publishRoot)).toEqual(['nightdrive-main.opus']); + }); + + it('restores both protected paths when manifest installation fails after the directory swap', async () => { + const root = await createRoot(); + const stagingRoot = join(root, 'staging'); + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const candidatePath = join(stagingRoot, 'project-interface.webp'); + const soundtrackPath = join(publishRoot, 'nightdrive-main.opus'); + const soundtrackBytes = Buffer.alloc(16 * 1024 * 1024, 0x5a); + await mkdir(stagingRoot); + await mkdir(publishRoot); + await writeFile(candidatePath, 'approved rollback candidate'); + await writeFile(soundtrackPath, soundtrackBytes); + const soundtrackHash = await hashFile(soundtrackPath); + await writeJson(manifestPath, { assets: [soundtrackRecord(soundtrackHash)] }); + const initialManifest = await readFile(manifestPath, 'utf8'); + const candidates = [{ + id: 'project-interface', fileName: 'project-interface.webp', sha256: await hashFile(candidatePath), + mediaType: 'image/webp', width: 640, height: 360, durationMs: null, + rightsHolder: 'Portfolio owner', license: 'All rights reserved', attribution: 'Portfolio owner', + }]; + const recipes = [{ id: 'project-interface', kind: 'image', output: 'project-interface.webp' }]; + + const sabotage = onTransactionDirectory(root, '.asset-manifest-', async (transactionRoot) => { + const nextManifest = join(transactionRoot, 'next-manifest.json'); + for (let attempt = 0; attempt < 500; attempt += 1) { + try { + await rm(nextManifest); + return; + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 1)); + } + } + throw new Error('next manifest was not observable'); + }); + const outcome = publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) + .then(() => ({ error: undefined }), (error: unknown) => ({ error })); + await sabotage; + const { error } = await outcome; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/ASSET-PUBLISH-001/u); + expect(await hashFile(soundtrackPath)).toBe(soundtrackHash); + expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); + expect(await readdir(publishRoot)).toEqual(['nightdrive-main.opus']); + expect((await readdir(root)).filter((name) => name.startsWith('.asset-'))).toEqual([]); + }); +}); + describe('deterministic asset preparation', () => { it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => { const root = await createRoot(); @@ -389,4 +538,73 @@ describe('deterministic asset preparation', () => { expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' }); }, 30_000); + + it('rejects a recipe that impersonates an unmanaged soundtrack ID without changing its closure', async () => { + const root = await createRoot(); + const recipe = baseRecipe({ id: 'soundtrack-main-opus', output: 'project-soundtrack-impostor.webp' }); + const inputs = await makeInputs(root, recipe); + await makePng(join(inputs.sourceRoot, 'source.png')); + const pending = await prepareAssets(inputs); + const approvalPath = join(root, 'approval.json'); + await writeJson(approvalPath, { + schema: 'portfolio-asset-approvals/v1', + assets: [{ id: 'soundtrack-main-opus', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }], + }); + + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const soundtrackBytes = Buffer.from('protected retained soundtrack bytes'); + const soundtrackPath = join(publishRoot, 'nightdrive-main.opus'); + await mkdir(publishRoot); + await writeFile(soundtrackPath, soundtrackBytes); + await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(soundtrackPath))] }); + const initialManifest = await readFile(manifestPath, 'utf8'); + + await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath })) + .rejects.toThrow(/ASSET-MERGE-ID-001|ASSET-OWNERSHIP-001/u); + expect(await readFile(soundtrackPath)).toEqual(soundtrackBytes); + expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); + await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' }); + }, 30_000); + + it.each([ + ['staging root equals publish root', async (publishRoot: string) => ({ stagingRoot: publishRoot })], + ['report root equals publish root', async (publishRoot: string) => ({ reportDir: publishRoot })], + ['staging root descends from publish root', async (publishRoot: string) => ({ stagingRoot: join(publishRoot, 'scratch') })], + ['staging root is a symlink alias of publish root', async (publishRoot: string) => { + const alias = join(dirname(publishRoot), 'staging-alias'); + await symlink(publishRoot, alias, 'dir'); + return { stagingRoot: alias }; + }], + ])('rejects destructive path overlap when %s and preserves the soundtrack closure', async (_label, overlap) => { + const root = await createRoot(); + const inputs = await makeInputs(root, baseRecipe()); + await makePng(join(inputs.sourceRoot, 'source.png')); + const pending = await prepareAssets(inputs); + const approvalPath = join(root, 'approval.json'); + await writeJson(approvalPath, { + schema: 'portfolio-asset-approvals/v1', + assets: [{ id: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }], + }); + + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const soundtrackBytes = Buffer.from('path-protected soundtrack bytes'); + const soundtrackPath = join(publishRoot, 'nightdrive-main.opus'); + await mkdir(publishRoot); + await writeFile(soundtrackPath, soundtrackBytes); + await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(soundtrackPath))] }); + const initialManifest = await readFile(manifestPath, 'utf8'); + + await expect(prepareAssets({ + ...inputs, + ...await overlap(publishRoot), + approvalRegisterPath: approvalPath, + publishRoot, + manifestPath, + })).rejects.toThrow(/ASSET-PATH-001/u); + expect(await readFile(soundtrackPath)).toEqual(soundtrackBytes); + expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); + expect(await readdir(publishRoot)).toEqual(['nightdrive-main.opus']); + }, 30_000); });