import { randomUUID } from "node:crypto"; import { lstat, mkdir, open, readdir, readlink, rename, rm, stat, writeFile, } from "node:fs/promises"; import { dirname, join, parse, resolve, sep } from "node:path"; import { DomainError } from "./domain-errors.js"; import { errCode } from "./utils.js"; export async function resolveTarget(path: string): Promise { if (path.includes("\0")) throw new DomainError("E_BAD_PAYLOAD", { message: "Path contains null byte. Pass a plain file string and retry.", }); // SAFETY: resolve normalizes ".." and sep; result is absolute and null-byte validated — editing scope intentionally allows any absolute path, OS permissions enforced by valAccess. const absolutePath = resolve(path); const { root } = parse(absolutePath); const parts = absolutePath .slice(root.length) .split(sep) .filter((part) => part.length > 0); const visitedSymlinks = new Set(); async function resParts(currentPath: string, remainingParts: string[]): Promise { if (remainingParts.length === 0) { return currentPath; } const [nextPart, ...tail] = remainingParts; // SAFETY: join of trusted currentPath (root or prior candidate) with nextPart split from resolved absolutePath — components are validated non-empty segments from normalized absolutePath, not raw user concatenation. const candidatePath = join(currentPath, nextPart); try { const candidateStats = await lstat(candidatePath); if (!candidateStats.isSymbolicLink()) { return resParts(candidatePath, tail); } if (visitedSymlinks.has(candidatePath)) { const error = new Error( `Too many symbolic links while resolving ${path}`, ) as NodeJS.ErrnoException; error.code = "ELOOP"; throw error; } visitedSymlinks.add(candidatePath); // SAFETY: resolve of trusted dirname(candidatePath) with symlink target from readlink (filesystem-controlled); result is absolute and normalized, validated via lstat symlink check. const linkTargetPath = resolve(dirname(candidatePath), await readlink(candidatePath)); const targetParts = linkTargetPath .slice(parse(linkTargetPath).root.length) .split(sep) .filter((part) => part.length > 0); return resParts(parse(linkTargetPath).root, [...targetParts, ...tail]); } catch (error: unknown) { if (errCode(error) === "ENOENT") { // SAFETY: join of validated candidatePath with remaining tail parts from resolved absolutePath — all parts are split segments of normalized absolute path, not raw user input. return join(candidatePath, ...tail); } throw error; } } return resParts(root, parts); } const TEMP_PREFIX = ".tmp-"; const TEMP_UUID_RE = /^\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const STALE_TEMP_MS = 60 * 60 * 1000; const sweptDirs = new Set(); async function sweepStaleTemps(dir: string): Promise { if (sweptDirs.has(dir)) return; sweptDirs.add(dir); try { const entries = await readdir(dir, { withFileTypes: true }); const now = Date.now(); for (const entry of entries) { if (!entry.isFile() || !TEMP_UUID_RE.test(entry.name)) continue; // SAFETY: join of trusted dir (dirname of resolved targetPath) with entry.name validated against TEMP_UUID_RE — only .tmp-UUID files, not arbitrary user traversal. const tempPath = join(dir, entry.name); try { const stats = await stat(tempPath); if (now - stats.mtimeMs > STALE_TEMP_MS) { await rm(tempPath, { force: true }); } } catch (error: unknown) { // SAFETY: best-effort stale temp cleanup — inspect failures are ignored; temp files are single-writer UUID names and will be retried on next sweep, no caller depends on removal. console.error("[fs-write] failed to inspect stale temp", error); } } } catch (error: unknown) { // SAFETY: best-effort stale temp sweep — readdir failures are ignored; sweep is opportunistic cleanup, writeAtomic proceeds regardless. console.error("[fs-write] failed to sweep stale temps", error); } } async function syncDir(dir: string): Promise { if (process.platform === "win32") return; try { const handle = await open(dir, "r"); try { await handle.sync(); } finally { await handle.close(); } } catch (error: unknown) { // SAFETY: best-effort directory sync — sync failures after atomic rename are ignored; file is already persisted, sync is durability optimization. console.error("[fs-write] failed to sync directory", error); } } export async function writeAtomic(path: string, content: string): Promise { if (path.includes("\0")) throw new DomainError("E_BAD_PAYLOAD", { message: "Path contains null byte. Pass a plain file string and retry.", }); const targetPath = await resolveTarget(path); let existingStats: Awaited> | null = null; try { existingStats = await stat(targetPath); } catch (error: unknown) { if (errCode(error) !== "ENOENT") { throw error; } } if (existingStats && existingStats.nlink > 1) { await writeFile(targetPath, content, "utf-8"); return; } const dir = dirname(targetPath); await sweepStaleTemps(dir); // SAFETY: join of trusted dir (dirname of resolved targetPath) with TEMP_PREFIX + randomUUID — randomUUID is cryptographically random, not user-controlled, no traversal. const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}`); await mkdir(dir, { recursive: true }); const tempHandle = await open(tempPath, "wx", 0o600); try { await tempHandle.writeFile(content, "utf-8"); if (existingStats) { await tempHandle.chmod(existingStats.mode & 0o7777); } await tempHandle.sync(); } catch (error: unknown) { await tempHandle.close(); try { await rm(tempPath, { force: true }); } catch (error: unknown) { console.error("[fs-write] failed to remove temp file", error); } throw error; } try { await tempHandle.close(); await rename(tempPath, targetPath); await syncDir(dir); } catch (error: unknown) { if (process.platform === "win32" && errCode(error) === "EPERM") { try { await writeFile(targetPath, content, "utf-8"); return; } finally { try { await rm(tempPath, { force: true }); } catch (error: unknown) { console.error("[fs-write] failed to remove temp file", error); } } } try { await rm(tempPath, { force: true }); } catch (error: unknown) { console.error("[fs-write] failed to remove temp file", error); } throw error; } }