Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730). build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and checked 34/34. Integration gate on an export of HEAD plus the patch: business 60/60 on Node 24 and 26, every package test and every scripts/test-*.sh green, test-task 98/98 with the live-provider cases. Conductor, queue, conversation and discord confirmed in git worktrees of HEAD with and without the patch, identical results. Lead decision 63 accepts the vocabulary location, the example path and the business branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
110 lines
3.9 KiB
JavaScript
110 lines
3.9 KiB
JavaScript
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;
|
|
}
|