188 lines
5.8 KiB
TypeScript
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);
|
|
}
|