fix: harden empty asset materialization

This commit is contained in:
2026-09-02 01:09:21 +03:00
parent 49a4f75c8d
commit bc851e398b
3 changed files with 134 additions and 35 deletions

View File

@@ -1,33 +1,92 @@
import { lstat, mkdir, readFile, readdir } from 'node:fs/promises'; import { constants } from 'node:fs';
import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const scriptPath = fileURLToPath(import.meta.url);
const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json'); const defaultRepositoryRoot = resolve(dirname(scriptPath), '..');
const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets');
const readEmptyManifest = async () => { 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 readEmptyManifest = async (repositoryRoot) => {
const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
if (Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets) || manifest.assets.length !== 0) { 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.'); throw new Error('Phase 1 asset manifest must contain exactly zero assets.');
} }
}; };
const ensureEmptyPreparedAssetDirectory = async () => { const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => {
const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets');
let initialStats;
try { try {
const stats = await lstat(preparedAssetsPath); initialStats = await lstat(preparedAssetsPath);
if (stats.isSymbolicLink() || !stats.isDirectory()) {
throw new Error('Prepared asset path must be a non-symlink directory.');
}
} catch (error) { } catch (error) {
if ((error).code !== 'ENOENT') throw error; if (!isErrno(error, 'ENOENT')) throw error;
await mkdir(preparedAssetsPath, { recursive: true }); await mkdir(preparedAssetsPath, { recursive: true });
initialStats = await lstat(preparedAssetsPath);
} }
if ((await readdir(preparedAssetsPath)).length !== 0) { assertNonSymlinkDirectory(initialStats);
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.'); throw new Error('Prepared asset directory must be empty during Phase 1.');
} }
}; };
await readEmptyManifest(); export const materializeEmptyAssetSet = async (repositoryRoot) => {
await ensureEmptyPreparedAssetDirectory(); const resolvedRepositoryRoot = resolve(repositoryRoot);
await readEmptyManifest(resolvedRepositoryRoot);
await ensureEmptyPreparedAssetDirectory(resolvedRepositoryRoot);
};
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
await materializeEmptyAssetSet(defaultRepositoryRoot);
}

View File

@@ -0,0 +1,58 @@
import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
assertPreparedAssetDirectoryIdentity,
materializeEmptyAssetSet,
} from '../../scripts/materialize-empty-asset-set.mjs';
const temporaryRoots = [];
const createScratchRepository = async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-empty-assets-'));
temporaryRoots.push(root);
await mkdir(join(root, 'content/public'), { recursive: true });
await writeFile(join(root, 'content/public/asset-manifest.json'), '{"assets":[]}');
return root;
};
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('empty asset materialization', () => {
it('creates an empty prepared-asset directory in the supplied repository root', async () => {
const root = await createScratchRepository();
await materializeEmptyAssetSet(root);
expect((await lstat(join(root, 'inputs/prepared-assets'))).isDirectory()).toBe(true);
});
it('rejects a symbolic-link prepared-asset directory', async () => {
const root = await createScratchRepository();
const target = join(root, 'outside');
await mkdir(target);
await mkdir(join(root, 'inputs'), { recursive: true });
await symlink(target, join(root, 'inputs/prepared-assets'));
await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/non-symlink directory/i);
});
it('rejects unexpected prepared-asset members in its supplied repository root', async () => {
const root = await createScratchRepository();
const preparedAssetsPath = join(root, 'inputs/prepared-assets');
await mkdir(preparedAssetsPath, { recursive: true });
await writeFile(join(preparedAssetsPath, 'unexpected.webp'), 'not an asset');
await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/must be empty/i);
});
it('rejects a prepared-asset directory replaced after its identity was captured', async () => {
const root = await createScratchRepository();
const preparedAssetsPath = join(root, 'inputs/prepared-assets');
await mkdir(preparedAssetsPath, { recursive: true });
const original = await lstat(preparedAssetsPath);
await rm(preparedAssetsPath, { recursive: true });
await mkdir(preparedAssetsPath);
await expect(assertPreparedAssetDirectoryIdentity(preparedAssetsPath, original))
.rejects.toThrow(/identity changed/i);
});
});

View File

@@ -1,18 +1,10 @@
import { execFile as execFileCallback } from 'node:child_process'; import { readFile } from 'node:fs/promises';
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import { parsePortfolioContent, parsePublicAssetManifest, parseSiteConfig } from '@/content/schemas'; import { parsePortfolioContent, parsePublicAssetManifest, parseSiteConfig } from '@/content/schemas';
const execFile = promisify(execFileCallback);
const repositoryRoot = process.cwd(); const repositoryRoot = process.cwd();
const readJson = async (relativePath: string): Promise<unknown> => const readJson = async (relativePath: string): Promise<unknown> =>
JSON.parse(await readFile(`${repositoryRoot}/${relativePath}`, 'utf8')) as unknown; JSON.parse(await readFile(`${repositoryRoot}/${relativePath}`, 'utf8')) as unknown;
const preparedAssetsPath = `${repositoryRoot}/inputs/prepared-assets`;
afterEach(async () => {
await rm(preparedAssetsPath, { recursive: true, force: true });
});
describe('approved public portfolio projection', () => { describe('approved public portfolio projection', () => {
it('contains approved identity and balanced evidence without prohibited surfaces', async () => { it('contains approved identity and balanced evidence without prohibited surfaces', async () => {
@@ -51,14 +43,4 @@ describe('approved public portfolio projection', () => {
}); });
expect(JSON.stringify(content)).not.toMatch(/Arch Linux|CV download|heatmap|expert|percent/i); expect(JSON.stringify(content)).not.toMatch(/Arch Linux|CV download|heatmap|expert|percent/i);
}); });
it('materializes an empty prepared-asset directory and rejects unexpected members', async () => {
await execFile(process.execPath, ['scripts/materialize-empty-asset-set.mjs'], { cwd: repositoryRoot });
expect(await readdir(preparedAssetsPath)).toEqual([]);
await mkdir(preparedAssetsPath, { recursive: true });
await writeFile(`${preparedAssetsPath}/unexpected.webp`, 'not an asset');
await expect(execFile(process.execPath, ['scripts/materialize-empty-asset-set.mjs'], { cwd: repositoryRoot }))
.rejects.toThrow(/must be empty/i);
});
}); });