fix: preserve approved assets when adding project media

This commit is contained in:
2026-09-04 22:01:52 +03:00
parent 8f358c2e0d
commit c06fc18cad
3 changed files with 372 additions and 21 deletions

View File

@@ -0,0 +1,37 @@
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 sha256Pattern = /^[a-f0-9]{64}$/u;
const assertSafeRecord = (asset) => {
if (!asset || typeof asset !== 'object' || Array.isArray(asset) ||
typeof asset.id !== 'string' || !idPattern.test(asset.id) ||
typeof asset.fileName !== 'string' || !fileNamePattern.test(asset.fileName) ||
typeof asset.sha256 !== 'string' || !sha256Pattern.test(asset.sha256)) {
throw new Error('ASSET-MERGE-RECORD-001');
}
};
export const mergeAssetRecords = (baseAssets, generatedAssets, managedIds) => {
if (!Array.isArray(baseAssets) || !Array.isArray(generatedAssets) || !(managedIds instanceof Set)) {
throw new Error('ASSET-MERGE-RECORD-001');
}
for (const asset of [...baseAssets, ...generatedAssets]) {
if (!asset || typeof asset !== 'object' || Array.isArray(asset) ||
typeof asset.id !== 'string' || typeof asset.fileName !== 'string') {
throw new Error('ASSET-MERGE-RECORD-001');
}
}
const retained = baseAssets.filter(({ id }) => !managedIds.has(id));
const combined = [...retained, ...generatedAssets];
const ids = new Set();
const names = new Set();
for (const asset of combined) {
const folded = asset.fileName.toLocaleLowerCase('en-US');
if (ids.has(asset.id)) throw new Error('ASSET-MERGE-ID-001');
if (names.has(folded)) throw new Error('ASSET-MERGE-NAME-001');
ids.add(asset.id);
names.add(folded);
}
for (const asset of [...baseAssets, ...generatedAssets]) assertSafeRecord(asset);
return combined.sort((left, right) => left.fileName.localeCompare(right.fileName, 'en-US'));
};

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
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 { fileURLToPath } from 'node:url';
import sharp from 'sharp';
@@ -7,6 +9,7 @@ import { fileTypeFromFile } from 'file-type';
import { hashFile } from './lib/hash-file.mjs';
import { loadPrivateMapping, loadRecipes, readJson, resolvePrivateSource } from './lib/asset-recipes.mjs';
import { mergeAssetRecords } from './lib/merge-assets.mjs';
import { loadDenylist, scanBinary, scanImage } from './lib/privacy-scan.mjs';
import { encodeVideoDerivatives, extractVideoFrames, probeVideo } from './lib/video-frames.mjs';
@@ -161,14 +164,124 @@ const approvalsMatch = async (path, expected) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-APPROVAL-002');
};
const publish = async ({ candidates, stagingRoot, publishRoot, manifestPath }) => {
const manifestKeys = [
'id', 'fileName', 'sha256', 'mediaType', 'width', 'height', 'durationMs',
'rightsHolder', 'license', 'attribution',
];
const mediaExtension = new Map([
['image/avif', 'avif'], ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/svg+xml', 'svg'],
['image/webp', 'webp'], ['video/mp4', 'mp4'], ['video/webm', 'webm'],
['audio/ogg', 'opus'], ['audio/mpeg', 'mp3'],
]);
const isPositiveIntegerOrNull = (value) => value === null || (Number.isInteger(value) && value > 0);
const hasUnpairedSurrogate = (value) => {
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index);
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (next < 0xdc00 || next > 0xdfff) return true;
index += 1;
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
return true;
}
}
return false;
};
const isVisibleString = (value) => typeof value === 'string' && value.trim().length > 0 &&
value === value.normalize('NFC') && !hasUnpairedSurrogate(value) && !/[\u0000-\u001f\u007f]/u.test(value);
const validateManifestRecord = (asset) => {
if (!asset || typeof asset !== 'object' || Array.isArray(asset) ||
Object.keys(asset).length !== manifestKeys.length || manifestKeys.some((key) => !(key in asset)) ||
typeof asset.id !== 'string' || typeof asset.fileName !== 'string' || typeof asset.sha256 !== 'string' ||
!mediaExtension.has(asset.mediaType) || !asset.fileName.endsWith(`.${mediaExtension.get(asset.mediaType)}`) ||
!isPositiveIntegerOrNull(asset.width) || !isPositiveIntegerOrNull(asset.height) ||
!isPositiveIntegerOrNull(asset.durationMs) ||
!isVisibleString(asset.rightsHolder) || !isVisibleString(asset.license) || !isVisibleString(asset.attribution)) {
fail('ASSET-MANIFEST-001');
}
const isImage = asset.mediaType.startsWith('image/');
const isVideo = asset.mediaType.startsWith('video/');
const isAudio = asset.mediaType.startsWith('audio/');
if ((isImage && (asset.width === null || asset.height === null || asset.durationMs !== null)) ||
(isVideo && (asset.width === null || asset.height === null || asset.durationMs === null)) ||
(isAudio && (asset.width !== null || asset.height !== null || asset.durationMs === null))) {
fail('ASSET-MANIFEST-001');
}
};
const loadManifestAssets = async (manifestPath) => {
const manifest = await readJson(manifestPath, '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);
try {
mergeAssetRecords(manifest.assets, [], new Set());
} catch {
fail('ASSET-MANIFEST-001');
}
return manifest.assets;
};
const readVerifiedRegularFile = async (path, expectedHash, rule) => {
let handle;
try {
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
const details = await handle.stat();
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;
} catch (error) {
if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error;
fail(rule);
} finally {
await handle?.close();
}
};
const verifyCurrentPublication = async (publishRoot, assets) => {
const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001');
let rootStats;
try { rootStats = await lstat(safePublishRoot); } 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 });
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');
}
for (const asset of assets) {
await readVerifiedRegularFile(join(safePublishRoot, asset.fileName), asset.sha256, 'ASSET-EXACT-001');
}
return safePublishRoot;
};
const publish = async ({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) => {
if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) {
fail('ASSET-RIGHTS-001');
}
const safePublishRoot = await resetDirectory(publishRoot, 'ASSET-PUBLISH-001');
const baseAssets = await loadManifestAssets(manifestPath);
const safePublishRoot = await verifyCurrentPublication(publishRoot, baseAssets);
const managedIds = new Set(recipes.map(({ id }) => id));
const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds);
for (const item of candidates) {
validateManifestRecord(item);
await readVerifiedRegularFile(join(stagingRoot, item.fileName), 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');
}
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: candidates }, null, 2)}\n`, 'utf8');
await writeFile(manifestPath, `${JSON.stringify({ assets: mergedAssets }, null, 2)}\n`, 'utf8');
};
export const prepareAssets = async (options) => {
@@ -199,7 +312,10 @@ export const prepareAssets = async (options) => {
if (options.approvalRegisterPath !== undefined) {
if (typeof options.publishRoot !== 'string' || typeof options.manifestPath !== 'string') fail('ASSET-ARGUMENT-001');
await approvalsMatch(options.approvalRegisterPath, approval);
await publish({ candidates: finalCandidates, stagingRoot, publishRoot: options.publishRoot, manifestPath: options.manifestPath });
await publish({
candidates: finalCandidates, recipes, stagingRoot,
publishRoot: options.publishRoot, manifestPath: options.manifestPath,
});
approvalStatus = 'approved';
}
return { candidates: finalCandidates, approvalStatus, reportDir };
@@ -207,23 +323,13 @@ 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 manifest = await readJson(manifestPath, 'ASSET-MANIFEST-001');
if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001');
const expected = manifest.assets.map(({ fileName }) => fileName).sort();
const actual = (await readdir(publishRoot, { withFileTypes: true }))
.map((entry) => {
if (!entry.isFile()) fail('ASSET-EXACT-001');
return entry.name;
}).sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-EXACT-001');
const assets = await loadManifestAssets(manifestPath);
const safePublishRoot = await verifyCurrentPublication(publishRoot, assets);
const denylist = await loadDenylist(denylistPath);
for (const item of manifest.assets) {
if (!item || typeof item.fileName !== 'string' || !/^project-[a-z0-9-]+\.(webp|png|webm|mp4)$/u.test(item.fileName)) fail('ASSET-MANIFEST-001');
const path = join(publishRoot, item.fileName);
if ((await stat(path)).nlink !== 1) fail('ASSET-EXACT-001');
if (await hashFile(path) !== item.sha256) fail('ASSET-HASH-001');
for (const item of assets) {
const path = join(safePublishRoot, item.fileName);
if (item.mediaType.startsWith('image/')) await scanImage(path, denylist, { runOcr: true });
else await scanBinary(path, denylist);
else if (item.mediaType.startsWith('video/')) await scanBinary(path, denylist);
}
return true;
};

View File

@@ -1,5 +1,5 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { link, mkdtemp, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
@@ -13,6 +13,8 @@ import { hashFile } from '../../scripts/lib/hash-file.mjs';
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
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';
const execFileAsync = promisify(execFile);
@@ -84,6 +86,63 @@ const makeInputs = async (root: string, recipe: Record<string, unknown>, sourceN
return { sourceRoot, reportDir, stagingRoot, recipesPath, mappingPath, denylistPath };
};
const soundtrackRecord = (sha256: string) => ({
id: 'soundtrack-main-opus',
fileName: 'nightdrive-main.opus',
sha256,
mediaType: 'audio/ogg',
width: null,
height: null,
durationMs: 178_605,
rightsHolder: 'Portfolio owner',
license: 'All rights reserved',
attribution: 'Portfolio owner',
});
describe('approved asset record merging', () => {
const retained = [{ id: 'soundtrack-main-opus', fileName: 'nightdrive-main.opus', sha256: 'a'.repeat(64) }];
const generated = [{ id: 'medication-mobile-logs', fileName: 'project-medication-logs.webp', sha256: 'b'.repeat(64) }];
it('preserves unmanaged records while adding generated managed records', () => {
expect(mergeAssetRecords(retained, generated, new Set(['medication-mobile-logs']))).toEqual([
generated[0]!,
retained[0]!,
].sort((a, b) => a.fileName.localeCompare(b.fileName, 'en-US')));
});
it('rejects duplicate IDs in the resulting manifest', () => {
expect(() => mergeAssetRecords(retained, [generated[0]!, generated[0]!], new Set(['medication-mobile-logs'])))
.toThrowError(/ASSET-MERGE-ID-001/u);
});
it('rejects case-folded filename collisions in the resulting manifest', () => {
const colliding = [{ ...generated[0]!, fileName: 'NIGHTDRIVE-MAIN.OPUS' }];
expect(() => mergeAssetRecords(retained, colliding, new Set(['medication-mobile-logs'])))
.toThrowError(/ASSET-MERGE-NAME-001/u);
});
it('does not let generated records replace unmanaged soundtrack IDs', () => {
const impersonating = [{ ...generated[0]!, id: 'soundtrack-main-opus' }];
expect(() => mergeAssetRecords(retained, impersonating, new Set(['medication-mobile-logs'])))
.toThrowError(/ASSET-MERGE-ID-001/u);
});
it('replaces an old managed recipe record with its newly generated record', () => {
const oldManaged = [{ ...generated[0]!, sha256: 'c'.repeat(64) }];
expect(mergeAssetRecords([...retained, ...oldManaged], generated, new Set(['medication-mobile-logs'])))
.toEqual([generated[0]!, retained[0]!].sort((a, b) => a.fileName.localeCompare(b.fileName, 'en-US')));
});
it.each([
[{ ...generated[0]!, id: '../unsafe' }],
[{ ...generated[0]!, fileName: '../unsafe.webp' }],
[{ ...generated[0]!, sha256: 'ABC' }],
])('rejects records whose safe manifest fields are invalid', (invalidGenerated) => {
expect(() => mergeAssetRecords(retained, invalidGenerated, new Set(['medication-mobile-logs'])))
.toThrowError(/ASSET-MERGE-RECORD-001/u);
});
});
describe('deterministic asset preparation', () => {
it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => {
const root = await createRoot();
@@ -175,10 +234,159 @@ describe('deterministic asset preparation', () => {
});
const publishRoot = join(root, 'published');
const manifestPath = join(root, 'asset-manifest.json');
await mkdir(publishRoot);
await writeJson(manifestPath, { assets: [] });
const approved = await prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath });
expect(approved.approvalStatus).toBe('approved');
expect(JSON.parse(await readFile(manifestPath, 'utf8')).assets).toHaveLength(1);
await writeFile(join(publishRoot, 'project-unexpected.webp'), 'extra', 'utf8');
await expect(verifyPreparedAssets({ publishRoot, manifestPath, denylistPath: inputs.denylistPath })).rejects.toThrow(/ASSET-EXACT-001/u);
}, 30_000);
it('publishes approved project assets additively without changing manifest-bound soundtrack bytes', async () => {
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('retained soundtrack bytes');
const soundtrackPath = join(publishRoot, 'nightdrive-main.opus');
await mkdir(publishRoot);
await writeFile(soundtrackPath, soundtrackBytes);
const soundtrackHash = await hashFile(soundtrackPath);
await writeJson(manifestPath, { assets: [soundtrackRecord(soundtrackHash)] });
const approved = await prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath });
const project = pending.candidates[0]!;
const publishedManifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { assets: Candidate[] };
expect(approved.approvalStatus).toBe('approved');
expect(await readFile(join(publishRoot, 'nightdrive-main.opus'))).toEqual(soundtrackBytes);
expect(await hashFile(join(publishRoot, 'nightdrive-main.opus'))).toBe(soundtrackHash);
expect(await hashFile(join(publishRoot, project.fileName))).toBe(project.sha256);
expect(publishedManifest.assets.map(({ id }) => id)).toEqual(['soundtrack-main-opus', 'project-interface']);
await expect(verifyPreparedAssets({ publishRoot, manifestPath, denylistPath: inputs.denylistPath })).resolves.toBe(true);
}, 30_000);
it('rejects unmanifested publish-root files before changing publication state', async () => {
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('retained soundtrack bytes');
await mkdir(publishRoot);
await writeFile(join(publishRoot, 'nightdrive-main.opus'), soundtrackBytes);
await writeFile(join(publishRoot, 'unmanifested.webp'), 'must block publication');
const soundtrackHash = await hashFile(join(publishRoot, 'nightdrive-main.opus'));
await writeJson(manifestPath, { assets: [soundtrackRecord(soundtrackHash)] });
const initialManifest = await readFile(manifestPath, 'utf8');
await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath }))
.rejects.toThrow(/ASSET-EXACT-001/u);
expect(await readFile(join(publishRoot, 'nightdrive-main.opus'))).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('rejects changed retained bytes before changing publication state', async () => {
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');
await mkdir(publishRoot);
await writeFile(join(publishRoot, 'nightdrive-main.opus'), 'altered soundtrack bytes');
await writeJson(manifestPath, { assets: [soundtrackRecord('a'.repeat(64))] });
const initialManifest = await readFile(manifestPath, 'utf8');
await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath }))
.rejects.toThrow(/ASSET-HASH-001/u);
expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest);
await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' });
}, 30_000);
it('removes only the validated prior filename when a managed recipe output is renamed', async () => {
const root = await createRoot();
const recipe = baseRecipe({ output: 'project-interface-new.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: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }],
});
const publishRoot = join(root, 'published');
const manifestPath = join(root, 'asset-manifest.json');
const oldBytes = Buffer.from('previously approved project image');
const oldPath = join(publishRoot, 'project-interface-old.webp');
await mkdir(publishRoot);
await writeFile(oldPath, oldBytes);
await writeJson(manifestPath, { assets: [{
id: 'project-interface',
fileName: 'project-interface-old.webp',
sha256: await hashFile(oldPath),
mediaType: 'image/webp',
width: 1,
height: 1,
durationMs: null,
rightsHolder: 'Portfolio owner',
license: 'All rights reserved',
attribution: 'Portfolio owner',
}] });
await prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath });
await expect(stat(oldPath)).rejects.toMatchObject({ code: 'ENOENT' });
expect(await hashFile(join(publishRoot, pending.candidates[0]!.fileName))).toBe(pending.candidates[0]!.sha256);
}, 30_000);
it('rejects a linked retained file before changing publication state', async () => {
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 originalPath = join(root, 'retained-original.opus');
const soundtrackBytes = Buffer.from('linked soundtrack bytes');
await mkdir(publishRoot);
await writeFile(originalPath, soundtrackBytes);
await link(originalPath, join(publishRoot, 'nightdrive-main.opus'));
await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(originalPath))] });
const initialManifest = await readFile(manifestPath, 'utf8');
await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath }))
.rejects.toThrow(/ASSET-EXACT-001/u);
expect(await readFile(originalPath)).toEqual(soundtrackBytes);
expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest);
await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' });
}, 30_000);
});