import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { refuse } from "./errors.mjs"; import { ID_PATTERN } from "./vocabulary.mjs"; export function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } export function requireObject(value, where) { if (!isPlainObject(value)) refuse(`${where} must be a JSON object`); return value; } export function rejectUnknownKeys(object, allowed, where) { for (const key of Object.keys(object)) { if (!allowed.includes(key)) refuse(`unsupported ${where} key: ${JSON.stringify(key)}`); } } export function requireId(value, where) { if (typeof value !== "string" || !ID_PATTERN.test(value)) { refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`); } return value; } export function requireString(value, where, { max = 200 } = {}) { if (typeof value !== "string" || value.trim().length === 0 || value.length > max || value.includes("\0")) { refuse(`${where} must be a non-empty string of at most ${max} characters`); } return value; } export function requirePositiveInt(value, where) { if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`); return value; } // A list of distinct strings, each checked by `check`. export function requireDistinctList(value, where, check, { nonEmpty = false } = {}) { if (!Array.isArray(value)) refuse(`${where} must be an array`); if (nonEmpty && value.length === 0) refuse(`${where} must not be empty`); const seen = new Set(); for (const item of value) { check(item); if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`); seen.add(item); } return [...seen]; } // Calendar date YYYY-MM-DD that exists (2026-02-30 refuses). export function requireDate(value, where) { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`); const d = new Date(`${value}T00:00:00Z`); if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`); return value; } // Read a JSON file that must be a regular file, not a symbolic link. // Missing or not a regular file is 4; unparseable is 2. One descriptor, // opened without following a link, serves every check and the read. // `checkStat` sees that descriptor's stat before the read, so the file // can't be swapped between the check and the read. export function readJsonFile(file, what, checkStat) { let fd; try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (error) { if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4); refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); } let text; try { const stat = fstatSync(fd); if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); checkStat?.(stat); text = readFileSync(fd, "utf8"); } finally { closeSync(fd); } try { return JSON.parse(text); } catch (error) { refuse(`${what} is not valid JSON (${file}): ${error.message}`); } } // JSON with object keys sorted at every level, for digests. export function canonicalJson(value) { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (isPlainObject(value)) { return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`; } return JSON.stringify(value); } export function sha256(text) { return createHash("sha256").update(text).digest("hex"); } export function deepFreeze(value) { if (typeof value === "object" && value !== null && !Object.isFrozen(value)) { Object.freeze(value); for (const v of Object.values(value)) deepFreeze(v); } return value; }