Files
Portfolio/scripts/materialize-empty-asset-set.mjs

234 lines
9.8 KiB
JavaScript

import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptPath = fileURLToPath(import.meta.url);
const defaultRepositoryRoot = resolve(dirname(scriptPath), '..');
const isErrno = (error, code) =>
typeof error === 'object' && error !== null && 'code' in error && error.code === code;
const directoryIdentity = (stats) => ({ dev: stats.dev, ino: stats.ino });
const hasSameDirectoryIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
const assertNonSymlinkDirectory = (stats) => {
if (stats.isSymbolicLink() || !stats.isDirectory()) {
throw new Error('Prepared asset path must be a non-symlink directory.');
}
};
export const assertPreparedAssetDirectoryIdentity = async (preparedAssetsPath, expectedIdentity) => {
const current = await lstat(preparedAssetsPath);
assertNonSymlinkDirectory(current);
if (!hasSameDirectoryIdentity(directoryIdentity(current), expectedIdentity)) {
throw new Error('Prepared asset directory identity changed during validation.');
}
};
const assertOpenedDirectoryIdentity = async (preparedAssetsPath, expectedIdentity) => {
const noFollowDirectoryFlags = constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0);
let handle;
try {
handle = await open(preparedAssetsPath, noFollowDirectoryFlags);
} catch (error) {
if (isErrno(error, 'ELOOP')) {
throw new Error('Prepared asset path must be a non-symlink directory.');
}
if (isErrno(error, 'EINVAL') || isErrno(error, 'ENOTSUP') || isErrno(error, 'ENOSYS')) return;
throw error;
}
try {
const opened = await handle.stat();
assertNonSymlinkDirectory(opened);
if (!hasSameDirectoryIdentity(directoryIdentity(opened), expectedIdentity)) {
throw new Error('Prepared asset directory identity changed during validation.');
}
} finally {
await handle.close();
}
};
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 = 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 ensurePreparedAssetDirectory = async (repositoryRoot) => {
const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets');
let initialStats;
try {
initialStats = await lstat(preparedAssetsPath);
} catch (error) {
if (!isErrno(error, 'ENOENT')) throw error;
await mkdir(preparedAssetsPath, { recursive: true });
initialStats = await lstat(preparedAssetsPath);
}
assertNonSymlinkDirectory(initialStats);
const initialIdentity = directoryIdentity(initialStats);
await assertOpenedDirectoryIdentity(preparedAssetsPath, initialIdentity);
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
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();
}
};
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 verifyApprovedAssetSet(defaultRepositoryRoot);
}