fix: harden additive asset publication
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
const idPattern = /^[a-z][a-z0-9-]{0,63}$/u;
|
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 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 sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||||
|
|
||||||
const assertSafeRecord = (asset) => {
|
const assertSafeRecord = (asset) => {
|
||||||
@@ -21,7 +22,8 @@ export const mergeAssetRecords = (baseAssets, generatedAssets, managedIds) => {
|
|||||||
throw new Error('ASSET-MERGE-RECORD-001');
|
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 combined = [...retained, ...generatedAssets];
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
const names = new Set();
|
const names = new Set();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import { copyFile, lstat, mkdir, open, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises';
|
import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname, extname, join, resolve } from 'node:path';
|
import { basename, dirname, extname, join, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { fileTypeFromFile } from 'file-type';
|
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 repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const fail = (rule) => { throw new Error(rule); };
|
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 assertSafeOutputRoot = (path, rule) => {
|
||||||
const resolved = resolve(path);
|
const resolved = resolve(path);
|
||||||
@@ -210,8 +248,12 @@ const validateManifestRecord = (asset) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadManifestAssets = async (manifestPath) => {
|
const fileIdentity = (details) => ({ dev: details.dev, ino: details.ino });
|
||||||
const manifest = await readJson(manifestPath, 'ASSET-MANIFEST-001');
|
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) ||
|
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) ||
|
||||||
Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001');
|
Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001');
|
||||||
for (const asset of manifest.assets) validateManifestRecord(asset);
|
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);
|
if (!details.isFile() || details.nlink !== 1) fail(rule);
|
||||||
const bytes = await handle.readFile();
|
const bytes = await handle.readFile();
|
||||||
if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) fail('ASSET-HASH-001');
|
if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) fail('ASSET-HASH-001');
|
||||||
return bytes;
|
return { bytes, identity: fileIdentity(details) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error;
|
if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error;
|
||||||
fail(rule);
|
fail(rule);
|
||||||
@@ -240,61 +282,167 @@ const readVerifiedRegularFile = async (path, expectedHash, rule) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyCurrentPublication = async (publishRoot, assets) => {
|
const readManifestSnapshot = async (manifestPath) => {
|
||||||
const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001');
|
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;
|
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');
|
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) fail('ASSET-EXACT-001');
|
||||||
const expectedNames = new Set(assets.map(({ fileName }) => fileName));
|
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');
|
if (entries.length !== expectedNames.size) fail('ASSET-EXACT-001');
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!expectedNames.has(entry.name) || entry.isSymbolicLink() || !entry.isFile()) fail('ASSET-EXACT-001');
|
if (!expectedNames.has(entry.name) || entry.isSymbolicLink() || !entry.isFile()) fail('ASSET-EXACT-001');
|
||||||
}
|
}
|
||||||
|
const files = new Map();
|
||||||
for (const asset of assets) {
|
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}`))) {
|
if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) {
|
||||||
fail('ASSET-RIGHTS-001');
|
fail('ASSET-RIGHTS-001');
|
||||||
}
|
}
|
||||||
const baseAssets = await loadManifestAssets(manifestPath);
|
const manifestSnapshot = await readManifestSnapshot(manifestPath);
|
||||||
const safePublishRoot = await verifyCurrentPublication(publishRoot, baseAssets);
|
const baseAssets = manifestSnapshot.assets;
|
||||||
const managedIds = new Set(recipes.map(({ id }) => id));
|
const currentPublication = await verifyCurrentPublication(publishRoot, baseAssets);
|
||||||
|
const { safePublishRoot } = currentPublication;
|
||||||
|
const managedIds = trustedManagedIds(recipes, candidates);
|
||||||
const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds);
|
const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds);
|
||||||
|
const candidateSnapshots = new Map();
|
||||||
for (const item of candidates) {
|
for (const item of candidates) {
|
||||||
validateManifestRecord(item);
|
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));
|
let publicationTransactionRoot;
|
||||||
const obsoleteNames = baseAssets
|
let manifestTransactionRoot;
|
||||||
.filter(({ id, fileName }) => managedIds.has(id) && !mergedNames.has(fileName))
|
let preserveRecoveryState = false;
|
||||||
.map(({ fileName }) => fileName);
|
try {
|
||||||
for (const item of candidates) await copyFile(join(stagingRoot, item.fileName), join(safePublishRoot, item.fileName));
|
publicationTransactionRoot = await mkdtemp(join(dirname(safePublishRoot), '.asset-publication-'));
|
||||||
for (const item of candidates) {
|
const nextPublication = join(publicationTransactionRoot, 'next');
|
||||||
await readVerifiedRegularFile(join(safePublishRoot, item.fileName), item.sha256, 'ASSET-EXACT-001');
|
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) => {
|
export const prepareAssets = async (options) => {
|
||||||
const required = ['sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir'];
|
const required = ['sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir'];
|
||||||
if (!options || required.some((key) => typeof options[key] !== 'string')) fail('ASSET-ARGUMENT-001');
|
if (!options || required.some((key) => typeof options[key] !== 'string')) fail('ASSET-ARGUMENT-001');
|
||||||
const recipes = await loadRecipes(options.recipesPath);
|
if (options.approvalRegisterPath !== undefined &&
|
||||||
const mapping = await loadPrivateMapping(options.mappingPath, recipes);
|
!['approvalRegisterPath', 'publishRoot', 'manifestPath'].every((key) => typeof options[key] === 'string')) {
|
||||||
const denylist = await loadDenylist(options.denylistPath);
|
fail('ASSET-ARGUMENT-001');
|
||||||
const stagingRoot = await resetDirectory(options.stagingRoot, 'ASSET-STAGING-001');
|
}
|
||||||
const reportDir = await resetDirectory(options.reportDir, 'ASSET-REPORT-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 = [];
|
const candidates = [];
|
||||||
for (const recipe of recipes) {
|
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'
|
const prepared = recipe.kind === 'image'
|
||||||
? await prepareImage({ source, recipe, stagingRoot, denylist })
|
? await prepareImage({ source, recipe, stagingRoot, denylist })
|
||||||
: await prepareVideo({ source, recipe, stagingRoot, reportDir, denylist });
|
: await prepareVideo({ source, recipe, stagingRoot, reportDir, denylist });
|
||||||
@@ -310,11 +458,10 @@ export const prepareAssets = async (options) => {
|
|||||||
await createContactSheet(finalCandidates, stagingRoot, reportDir);
|
await createContactSheet(finalCandidates, stagingRoot, reportDir);
|
||||||
let approvalStatus = 'pending';
|
let approvalStatus = 'pending';
|
||||||
if (options.approvalRegisterPath !== undefined) {
|
if (options.approvalRegisterPath !== undefined) {
|
||||||
if (typeof options.publishRoot !== 'string' || typeof options.manifestPath !== 'string') fail('ASSET-ARGUMENT-001');
|
await approvalsMatch(paths.approvalRegisterPath, approval);
|
||||||
await approvalsMatch(options.approvalRegisterPath, approval);
|
await publishPreparedAssets({
|
||||||
await publish({
|
|
||||||
candidates: finalCandidates, recipes, stagingRoot,
|
candidates: finalCandidates, recipes, stagingRoot,
|
||||||
publishRoot: options.publishRoot, manifestPath: options.manifestPath,
|
publishRoot: paths.publishRoot, manifestPath: paths.manifestPath,
|
||||||
});
|
});
|
||||||
approvalStatus = 'approved';
|
approvalStatus = 'approved';
|
||||||
}
|
}
|
||||||
@@ -323,8 +470,8 @@ export const prepareAssets = async (options) => {
|
|||||||
|
|
||||||
export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => {
|
export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => {
|
||||||
if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001');
|
if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001');
|
||||||
const assets = await loadManifestAssets(manifestPath);
|
const { assets } = await readManifestSnapshot(manifestPath);
|
||||||
const safePublishRoot = await verifyCurrentPublication(publishRoot, assets);
|
const { safePublishRoot } = await verifyCurrentPublication(publishRoot, assets);
|
||||||
const denylist = await loadDenylist(denylistPath);
|
const denylist = await loadDenylist(denylistPath);
|
||||||
for (const item of assets) {
|
for (const item of assets) {
|
||||||
const path = join(safePublishRoot, item.fileName);
|
const path = join(safePublishRoot, item.fileName);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment node
|
// @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 { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import sharp from 'sharp';
|
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.
|
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
|
||||||
import { mergeAssetRecords } from '../../scripts/lib/merge-assets.mjs';
|
import { mergeAssetRecords } from '../../scripts/lib/merge-assets.mjs';
|
||||||
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
|
// @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);
|
const execFileAsync = promisify(execFile);
|
||||||
interface Candidate { id: string; fileName: string; sha256: string; }
|
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');
|
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> = {}) => ({
|
const baseRecipe = (overrides: Record<string, unknown> = {}) => ({
|
||||||
id: 'project-interface',
|
id: 'project-interface',
|
||||||
kind: 'image',
|
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', () => {
|
describe('deterministic asset preparation', () => {
|
||||||
it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => {
|
it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => {
|
||||||
const root = await createRoot();
|
const root = await createRoot();
|
||||||
@@ -389,4 +538,73 @@ describe('deterministic asset preparation', () => {
|
|||||||
expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest);
|
expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest);
|
||||||
await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' });
|
await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
}, 30_000);
|
}, 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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user