140 lines
5.7 KiB
TypeScript
140 lines
5.7 KiB
TypeScript
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);
|
|
});
|
|
});
|