import fs from "node:fs"; import path from "node:path"; import { RunsError } from "./errors.mjs"; export const RUNS_DIRNAME = "runs"; export const STATE_DIRNAME = "state"; export function isMissing(error) { return error?.code === "ENOENT" || error?.code === "ENOTDIR"; } function isInside(root, target) { const relative = path.relative(root, target); return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); } // Resolves / through any symbolic links and returns the // real path, or null when it doesn't exist. The data root is trusted as // configured; a path below it that resolves outside it refuses, so no reader // here follows a link out of the data root. export function resolveInside(dataRoot, ...parts) { const target = path.join(dataRoot, ...parts); let root; let real; try { root = fs.realpathSync(dataRoot); real = fs.realpathSync(target); } catch (error) { if (isMissing(error)) return null; throw new RunsError(`cannot resolve ${target}: ${error.code ?? error.message}`); } if (!isInside(root, real)) { throw new RunsError(`${target} resolves outside the data root (${root})`); } return real; } // A JSON object read from /, or null when the file is // missing, unreadable, not JSON, not an object or outside the data root. export function readJsonObject(dataRoot, ...parts) { try { const file = resolveInside(dataRoot, ...parts); if (file === null) return null; const value = JSON.parse(fs.readFileSync(file, "utf8")); return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null; } catch { return null; } }