fix: enforce approved media publication closure
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
@@ -50,16 +51,103 @@ const assertOpenedDirectoryIdentity = async (preparedAssetsPath, expectedIdentit
|
||||
}
|
||||
};
|
||||
|
||||
const readEmptyManifest = async (repositoryRoot) => {
|
||||
const readJson = async (path, label) => {
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(await readFile(path, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`${label} must be valid JSON.`, { cause: error });
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be a JSON object.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const readManifest = async (repositoryRoot) => {
|
||||
const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest) ||
|
||||
Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets) || manifest.assets.length !== 0) {
|
||||
throw new Error('Phase 1 asset manifest must contain exactly zero assets.');
|
||||
const manifest = await readJson(manifestPath, 'Public asset manifest');
|
||||
if (Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) {
|
||||
throw new Error('Public asset manifest must contain exactly one assets array.');
|
||||
}
|
||||
const ids = new Set();
|
||||
const fileNames = new Set();
|
||||
const assets = manifest.assets.map((asset) => {
|
||||
if (typeof asset !== 'object' || asset === null || Array.isArray(asset)) {
|
||||
throw new Error('Every manifest asset must be an object.');
|
||||
}
|
||||
const { id, fileName, sha256 } = asset;
|
||||
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(id)) {
|
||||
throw new Error('Manifest asset ID must be a generated safe identifier.');
|
||||
}
|
||||
if (ids.has(id)) throw new Error(`Duplicate manifest asset ID: ${id}`);
|
||||
ids.add(id);
|
||||
if (typeof fileName !== 'string' || !/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/u.test(fileName)) {
|
||||
throw new Error(`Manifest filename must be a generated safe filename: ${id}`);
|
||||
}
|
||||
const foldedFileName = fileName.toLocaleLowerCase('en-US');
|
||||
if (fileNames.has(foldedFileName)) throw new Error(`Duplicate manifest filename: ${fileName}`);
|
||||
fileNames.add(foldedFileName);
|
||||
if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(sha256)) {
|
||||
throw new Error(`Manifest asset must have a lowercase SHA-256 digest: ${id}`);
|
||||
}
|
||||
return { id, fileName, sha256 };
|
||||
});
|
||||
return assets;
|
||||
};
|
||||
|
||||
const assetReferences = (content) => {
|
||||
const references = [];
|
||||
const projects = content.projects;
|
||||
if (typeof projects !== 'object' || projects === null || Array.isArray(projects)) {
|
||||
throw new Error('Portfolio content must contain project collections.');
|
||||
}
|
||||
for (const collectionName of ['selected', 'additional']) {
|
||||
const collection = projects[collectionName];
|
||||
if (!Array.isArray(collection)) throw new Error(`Portfolio ${collectionName} projects must be an array.`);
|
||||
for (const project of collection) {
|
||||
if (typeof project !== 'object' || project === null || Array.isArray(project) || !Array.isArray(project.assetIds)) {
|
||||
throw new Error('Every public project must contain an assetIds array.');
|
||||
}
|
||||
references.push(...project.assetIds);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(content.toolkit)) throw new Error('Portfolio toolkit must be an array.');
|
||||
for (const group of content.toolkit) {
|
||||
if (typeof group !== 'object' || group === null || Array.isArray(group) || !Array.isArray(group.technologies)) {
|
||||
throw new Error('Every toolkit group must contain a technologies array.');
|
||||
}
|
||||
for (const technology of group.technologies) {
|
||||
if (typeof technology !== 'object' || technology === null || Array.isArray(technology)) {
|
||||
throw new Error('Every toolkit technology must be an object.');
|
||||
}
|
||||
if (technology.assetId !== undefined) references.push(technology.assetId);
|
||||
}
|
||||
}
|
||||
if (typeof content.metadata !== 'object' || content.metadata === null || Array.isArray(content.metadata)) {
|
||||
throw new Error('Portfolio content must contain metadata.');
|
||||
}
|
||||
if (content.metadata.socialPreviewAssetId !== undefined) references.push(content.metadata.socialPreviewAssetId);
|
||||
return references;
|
||||
};
|
||||
|
||||
const assertExactReferenceClosure = (assets, content) => {
|
||||
const manifestIds = new Set(assets.map((asset) => asset.id));
|
||||
const referencedIds = new Set();
|
||||
for (const assetId of assetReferences(content)) {
|
||||
if (typeof assetId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(assetId)) {
|
||||
throw new Error('Public asset reference must be a generated safe identifier.');
|
||||
}
|
||||
if (referencedIds.has(assetId)) throw new Error(`Duplicate public asset reference: ${assetId}`);
|
||||
referencedIds.add(assetId);
|
||||
if (!manifestIds.has(assetId)) throw new Error(`Unknown public asset reference: ${assetId}`);
|
||||
}
|
||||
for (const assetId of manifestIds) {
|
||||
if (!referencedIds.has(assetId)) throw new Error(`Unreferenced manifest asset: ${assetId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => {
|
||||
const ensurePreparedAssetDirectory = async (repositoryRoot) => {
|
||||
const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets');
|
||||
let initialStats;
|
||||
try {
|
||||
@@ -74,19 +162,72 @@ const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => {
|
||||
const initialIdentity = directoryIdentity(initialStats);
|
||||
await assertOpenedDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
||||
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
||||
const members = await readdir(preparedAssetsPath);
|
||||
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
||||
if (members.length !== 0) {
|
||||
throw new Error('Prepared asset directory must be empty during Phase 1.');
|
||||
return { preparedAssetsPath, initialIdentity };
|
||||
};
|
||||
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
const readPreparedRegularFile = async (filePath, fileName) => {
|
||||
const noFollowFlags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(filePath, noFollowFlags);
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ELOOP')) {
|
||||
throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.nlink !== 1) {
|
||||
throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`);
|
||||
}
|
||||
return await handle.readFile();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const materializeEmptyAssetSet = async (repositoryRoot) => {
|
||||
const resolvedRepositoryRoot = resolve(repositoryRoot);
|
||||
await readEmptyManifest(resolvedRepositoryRoot);
|
||||
await ensureEmptyPreparedAssetDirectory(resolvedRepositoryRoot);
|
||||
const verifyPreparedAssetFiles = async (repositoryRoot, assets) => {
|
||||
const { preparedAssetsPath, initialIdentity } = await ensurePreparedAssetDirectory(repositoryRoot);
|
||||
const expectedByName = new Map(assets.map((asset) => [asset.fileName, asset]));
|
||||
const foundNames = new Set();
|
||||
const members = await readdir(preparedAssetsPath, { withFileTypes: true });
|
||||
for (const member of members) {
|
||||
const asset = expectedByName.get(member.name);
|
||||
if (asset === undefined) throw new Error(`Extra prepared asset: ${member.name}`);
|
||||
if (member.isSymbolicLink() || !member.isFile()) {
|
||||
throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`);
|
||||
}
|
||||
const filePath = join(preparedAssetsPath, member.name);
|
||||
const stats = await lstat(filePath);
|
||||
if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) {
|
||||
throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`);
|
||||
}
|
||||
if (sha256(await readPreparedRegularFile(filePath, member.name)) !== asset.sha256) {
|
||||
throw new Error(`Prepared asset hash does not match manifest: ${member.name}`);
|
||||
}
|
||||
foundNames.add(member.name);
|
||||
}
|
||||
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
||||
for (const asset of assets) {
|
||||
if (!foundNames.has(asset.fileName)) throw new Error(`Missing prepared asset: ${asset.fileName}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyApprovedAssetSet = async (repositoryRoot) => {
|
||||
const resolvedRepositoryRoot = resolve(repositoryRoot);
|
||||
const [assets, content] = await Promise.all([
|
||||
readManifest(resolvedRepositoryRoot),
|
||||
readJson(resolve(resolvedRepositoryRoot, 'content/public/portfolio-content.json'), 'Portfolio content'),
|
||||
]);
|
||||
assertExactReferenceClosure(assets, content);
|
||||
await verifyPreparedAssetFiles(resolvedRepositoryRoot, assets);
|
||||
};
|
||||
|
||||
export const materializeEmptyAssetSet = verifyApprovedAssetSet;
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||
await materializeEmptyAssetSet(defaultRepositoryRoot);
|
||||
await verifyApprovedAssetSet(defaultRepositoryRoot);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user