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 { 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 { 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 { 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 { 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 { 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); }