Files
clean-architecture-frontend…/scripts/lib/ci-gate-log.ts
T

188 lines
5.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
import path from "node:path";
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
export async function writeCiGateLogAtomic(input: Readonly<{
root: string;
relativePath: string;
content: string;
maxBytes?: number;
}>): Promise<void> {
const root = path.resolve(input.root);
const relative = normalizeRepositoryRelativePath(input.relativePath, "CI gate log path");
const target = path.join(root, relative);
const maxBytes = input.maxBytes ?? 67_108_864;
const contentBytes = Buffer.byteLength(input.content, "utf8");
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || contentBytes < 1 || contentBytes > maxBytes) {
throw new RangeError(`CI gate log size is outside 1..${maxBytes}: ${relative}`);
}
const parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target));
await assertSafePublishLeaf(target, relative);
const temporary = path.join(
path.dirname(target),
`.${path.basename(target)}.${randomUUID()}.tmp`,
);
let ownsTemporary = false;
try {
const handle = await open(
temporary,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o644,
);
ownsTemporary = true;
let failure: unknown;
try {
await handle.writeFile(input.content, "utf8");
await handle.sync();
} catch (error) {
failure = error;
}
try {
await handle.close();
} catch (error) {
failure ??= error;
}
if (failure) throw failure;
await assertDirectoryIdentity(path.dirname(target), parentIdentity, relative);
await assertSafePublishLeaf(target, relative);
await rename(temporary, target);
ownsTemporary = false;
const directory = await open(path.dirname(target), constants.O_RDONLY);
try {
try {
await directory.sync();
} catch (error) {
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
}
} finally {
await directory.close();
}
} catch (error) {
if (ownsTemporary) {
try {
await rm(temporary, { force: true });
} catch {
// Preserve the publication failure and clean only the owned sibling temp.
}
}
throw error;
}
}
export async function ensureSafePublishDirectory(
rootInput: string,
directoryInput: string,
): Promise<Stats> {
const root = path.resolve(rootInput);
const directory = path.resolve(directoryInput);
const relativeDirectory = path.relative(root, directory);
if (
relativeDirectory === ".." ||
relativeDirectory.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeDirectory)
) {
throw new TypeError("CI publish directory escapes root");
}
const rootMetadata = await lstat(root);
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
throw new TypeError("CI gate log root is unsafe");
}
let ancestor = root;
for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) {
ancestor = path.join(ancestor, segment);
let metadata;
try {
metadata = await lstat(ancestor);
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
try {
await mkdir(ancestor);
} catch (mkdirError) {
if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError;
}
metadata = await lstat(ancestor);
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new TypeError(`CI publish ancestor is unsafe: ${relativeDirectory}`);
}
}
return lstat(directory);
}
export async function assertSafePublishLeaf(
target: string,
label = target,
): Promise<void> {
try {
const metadata = await lstat(target);
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new TypeError(`CI publish leaf is unsafe: ${label}`);
}
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error;
}
}
export async function assertSafeExistingPublishPath(
rootInput: string,
targetInput: string,
): Promise<boolean> {
const root = path.resolve(rootInput);
const target = path.resolve(targetInput);
const relative = path.relative(root, target);
if (
relative === "" ||
relative === ".." ||
relative.startsWith(`..${path.sep}`) ||
path.isAbsolute(relative)
) {
throw new TypeError("CI publish target escapes root");
}
const rootMetadata = await lstat(root);
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
throw new TypeError("CI publish root is unsafe");
}
const segments = relative.split(path.sep).filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
current = path.join(current, segment);
let metadata: Stats;
try {
metadata = await lstat(current);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
const leaf = index === segments.length - 1;
if (metadata.isSymbolicLink() || (leaf ? !metadata.isFile() : !metadata.isDirectory())) {
throw new TypeError(`CI publish path is unsafe: ${relative}`);
}
}
return true;
}
async function assertDirectoryIdentity(
directory: string,
expected: Stats,
label: string,
): Promise<void> {
const actual = await lstat(directory);
if (
actual.isSymbolicLink() ||
!actual.isDirectory() ||
expected.dev <= 0 ||
expected.ino <= 0 ||
actual.dev !== expected.dev ||
actual.ino !== expected.ino
) {
throw new TypeError(`CI publish directory identity changed: ${label}`);
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}