fix: harden additive asset publication

This commit is contained in:
2026-09-04 22:22:28 +03:00
parent c06fc18cad
commit 5bdeeaa433
3 changed files with 410 additions and 43 deletions

View File

@@ -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<void> => {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
};
const onTransactionDirectory = async (
root: string,
prefix: string,
action: (path: string) => Promise<void>,
): Promise<void> => {
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<string, unknown> = {}) => ({
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);
});