From bc851e398b331ff7bef3a5888ce3c04b2736ee44 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 01:09:21 +0300 Subject: [PATCH] fix: harden empty asset materialization --- scripts/materialize-empty-asset-set.mjs | 89 +++++++++++++++---- .../materialize-empty-asset-set.test.mjs | 58 ++++++++++++ tests/integration/public-content.test.ts | 22 +---- 3 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 tests/integration/materialize-empty-asset-set.test.mjs diff --git a/scripts/materialize-empty-asset-set.mjs b/scripts/materialize-empty-asset-set.mjs index 3ee061c..57da791 100644 --- a/scripts/materialize-empty-asset-set.mjs +++ b/scripts/materialize-empty-asset-set.mjs @@ -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 { fileURLToPath } from 'node:url'; -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json'); -const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets'); +const scriptPath = fileURLToPath(import.meta.url); +const defaultRepositoryRoot = resolve(dirname(scriptPath), '..'); -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')); - 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.'); } }; -const ensureEmptyPreparedAssetDirectory = async () => { +const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => { + const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets'); + let initialStats; try { - const stats = await lstat(preparedAssetsPath); - if (stats.isSymbolicLink() || !stats.isDirectory()) { - throw new Error('Prepared asset path must be a non-symlink directory.'); - } + initialStats = await lstat(preparedAssetsPath); } catch (error) { - if ((error).code !== 'ENOENT') throw error; + if (!isErrno(error, 'ENOENT')) throw error; 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.'); } }; -await readEmptyManifest(); -await ensureEmptyPreparedAssetDirectory(); +export const materializeEmptyAssetSet = async (repositoryRoot) => { + const resolvedRepositoryRoot = resolve(repositoryRoot); + await readEmptyManifest(resolvedRepositoryRoot); + await ensureEmptyPreparedAssetDirectory(resolvedRepositoryRoot); +}; + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { + await materializeEmptyAssetSet(defaultRepositoryRoot); +} diff --git a/tests/integration/materialize-empty-asset-set.test.mjs b/tests/integration/materialize-empty-asset-set.test.mjs new file mode 100644 index 0000000..ab11e00 --- /dev/null +++ b/tests/integration/materialize-empty-asset-set.test.mjs @@ -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); + }); +}); diff --git a/tests/integration/public-content.test.ts b/tests/integration/public-content.test.ts index 41258ec..6a9212e 100644 --- a/tests/integration/public-content.test.ts +++ b/tests/integration/public-content.test.ts @@ -1,18 +1,10 @@ -import { execFile as execFileCallback } from 'node:child_process'; -import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { promisify } from 'node:util'; -import { afterEach, describe, expect, it } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; import { parsePortfolioContent, parsePublicAssetManifest, parseSiteConfig } from '@/content/schemas'; -const execFile = promisify(execFileCallback); const repositoryRoot = process.cwd(); const readJson = async (relativePath: string): Promise => 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', () => { 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); }); - - 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); - }); });