Files
Portfolio/scripts/safe-publication.mjs

126 lines
5.0 KiB
JavaScript

import { randomUUID } from 'node:crypto';
import { lstat, realpath, rename, rm } from 'node:fs/promises';
import { basename, dirname, relative, resolve, sep } from 'node:path';
const isMissing = (error) =>
typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
const assertInsideRepository = (repositoryRoot, path, label) => {
const pathRelativeToRoot = relative(repositoryRoot, path);
if (pathRelativeToRoot === '' || pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) {
throw new Error(`${label} is outside the repository.`);
}
};
const assertCanonicalDirectory = async (path, label) => {
const resolvedPath = resolve(path);
let canonicalPath;
try {
canonicalPath = await realpath(resolvedPath);
} catch (error) {
if (isMissing(error)) throw new Error(`${label} must be an existing safe directory.`);
throw error;
}
if (canonicalPath !== resolvedPath) throw new Error(`${label} or an ancestor is symlinked or non-canonical.`);
const stats = await lstat(resolvedPath);
if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error(`${label} must be a safe directory.`);
return stats;
};
export const captureDirectoryIdentity = async (path) => {
const stats = await assertCanonicalDirectory(path, 'Directory');
return { dev: stats.dev, ino: stats.ino };
};
const sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
const assertOptionalFinalDirectory = async (repositoryRoot, finalRoot) => {
assertInsideRepository(repositoryRoot, finalRoot, 'Final output');
if (dirname(finalRoot) !== repositoryRoot || basename(finalRoot) !== 'dist') {
throw new Error('Final output must be the repository-contained dist sibling.');
}
try {
const stats = await lstat(finalRoot);
if (stats.isSymbolicLink() || !stats.isDirectory() || await realpath(finalRoot) !== finalRoot) {
throw new Error('Final output must be an existing safe directory.');
}
return { exists: true, identity: { dev: stats.dev, ino: stats.ino } };
} catch (error) {
if (isMissing(error)) return { exists: false, identity: undefined };
throw error;
}
};
const removeOwnedBackup = async (repositoryRoot, backupRoot) => {
assertInsideRepository(repositoryRoot, backupRoot, 'Backup');
if (dirname(backupRoot) !== repositoryRoot || !basename(backupRoot).startsWith('.dist-backup-')) {
throw new Error('Refusing to remove an unowned backup path.');
}
const stats = await lstat(backupRoot);
if (stats.isSymbolicLink() || !stats.isDirectory() || await realpath(backupRoot) !== backupRoot) {
throw new Error('Refusing to remove an unsafe backup path.');
}
await rm(backupRoot, { recursive: true });
};
export const publishStagedDirectory = async ({
repositoryRoot: repositoryRootInput,
finalRoot: finalRootInput,
stagingRoot: stagingRootInput,
expectedStagingIdentity,
renameDirectory = rename,
beforeCommit = async () => {},
}) => {
const repositoryRoot = resolve(repositoryRootInput);
const finalRoot = resolve(finalRootInput);
const stagingRoot = resolve(stagingRootInput);
await assertCanonicalDirectory(repositoryRoot, 'Repository root');
assertInsideRepository(repositoryRoot, stagingRoot, 'Staging output');
if (dirname(stagingRoot) !== repositoryRoot || !basename(stagingRoot).startsWith('.dist-stage-')) {
throw new Error('Staging output must be a repository-contained owned sibling.');
}
const stagingIdentity = await captureDirectoryIdentity(stagingRoot);
if (!sameIdentity(stagingIdentity, expectedStagingIdentity)) {
throw new Error('Staging directory identity changed after verification.');
}
const previous = await assertOptionalFinalDirectory(repositoryRoot, finalRoot);
await assertCanonicalDirectory(repositoryRoot, 'Repository root');
if (!sameIdentity(await captureDirectoryIdentity(stagingRoot), expectedStagingIdentity)) {
throw new Error('Staging directory identity changed before publication.');
}
if (previous.exists) {
const currentFinal = await captureDirectoryIdentity(finalRoot);
if (!sameIdentity(currentFinal, previous.identity)) throw new Error('Final output identity changed before publication.');
}
const backupRoot = resolve(repositoryRoot, `.dist-backup-${randomUUID()}`);
let backupCreated = false;
let stagedPublished = false;
try {
if (previous.exists) {
await renameDirectory(finalRoot, backupRoot);
backupCreated = true;
}
await renameDirectory(stagingRoot, finalRoot);
stagedPublished = true;
await beforeCommit();
} catch (error) {
try {
if (stagedPublished) {
await renameDirectory(finalRoot, stagingRoot);
stagedPublished = false;
}
if (backupCreated) {
await renameDirectory(backupRoot, finalRoot);
backupCreated = false;
}
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'Publication failed and rollback could not restore the previous output.');
}
throw error;
}
if (backupCreated) await removeOwnedBackup(repositoryRoot, backupRoot);
};