chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user