234 lines
9.8 KiB
JavaScript
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);
|
|
}
|