import { mkdtemp, mkdir, readFile, readdir, rename, 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 { captureDirectoryIdentity, publishStagedDirectory, } from '../../scripts/safe-publication.mjs'; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); const repository = async () => { const root = await mkdtemp(join(tmpdir(), 'portfolio-publication-')); temporaryDirectories.push(root); await mkdir(join(root, 'dist')); await mkdir(join(root, '.dist-stage-test')); await writeFile(join(root, 'dist/old.txt'), 'old'); await writeFile(join(root, '.dist-stage-test/new.txt'), 'new'); return { root, finalRoot: join(root, 'dist'), stagingRoot: join(root, '.dist-stage-test'), }; }; describe('safe artifact publication', () => { it('rejects a symlinked final output before mutating its target', async () => { const { root, finalRoot, stagingRoot } = await repository(); const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-')); temporaryDirectories.push(outside); await writeFile(join(outside, 'protected.txt'), 'protected'); await rm(finalRoot, { recursive: true }); await symlink(outside, finalRoot); await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), })).rejects.toThrow(/symlink|safe directory/i); expect(await readFile(join(outside, 'protected.txt'), 'utf8')).toBe('protected'); }); it('rejects an output path outside the repository', async () => { const { root, stagingRoot } = await repository(); const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-')); temporaryDirectories.push(outside); await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot: outside, stagingRoot, expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), })).rejects.toThrow(/outside.*repository/i); }); it('rejects a repository path with a symlinked ancestor', async () => { const { root, stagingRoot } = await repository(); const parent = await mkdtemp(join(tmpdir(), 'portfolio-publication-link-')); temporaryDirectories.push(parent); const linkedRoot = join(parent, 'linked-root'); await symlink(root, linkedRoot); await expect(publishStagedDirectory({ repositoryRoot: linkedRoot, finalRoot: join(linkedRoot, 'dist'), stagingRoot, expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), })).rejects.toThrow(/ancestor|canonical|symlink/i); }); it('rejects a staging directory replaced after verification without touching the final output', async () => { const { root, finalRoot, stagingRoot } = await repository(); const expectedStagingIdentity = await captureDirectoryIdentity(stagingRoot); await rm(stagingRoot, { recursive: true }); await mkdir(stagingRoot); await writeFile(join(stagingRoot, 'attacker.txt'), 'attacker'); await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity })) .rejects.toThrow(/identity changed/i); expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old'); }); it('rolls back the previous output when the staged rename fails mid-publication', async () => { const { root, finalRoot, stagingRoot } = await repository(); let renameCount = 0; const failSecondRename = async (from: string, to: string) => { renameCount += 1; if (renameCount === 2) throw new Error('injected staged rename failure'); await rename(from, to); }; await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), renameDirectory: failSecondRename, })).rejects.toThrow(/injected staged rename failure/i); expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old'); await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow(); expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false); }); it('rolls back the previous output when the pre-commit authority update fails', async () => { const { root, finalRoot, stagingRoot } = await repository(); await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), beforeCommit: async () => { throw new Error('injected authority update failure'); }, })).rejects.toThrow(/injected authority update failure/i); expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old'); await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow(); expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false); }); it('publishes the verified staging directory and removes the superseded output', async () => { const { root, finalRoot, stagingRoot } = await repository(); await publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot), }); expect(await readFile(join(finalRoot, 'new.txt'), 'utf8')).toBe('new'); await expect(readFile(join(finalRoot, 'old.txt'), 'utf8')).rejects.toThrow(); expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false); }); });