126 lines
5.0 KiB
JavaScript
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);
|
|
};
|