PR #1916 rule: a refusal names the field or file, never a config value or a host path. readJsonFile's three refusals and parseJsonFileText now name the file by its `what` label only. Callers whose label alone is ambiguous add a name that already passed a strict pattern (business id, role name, org id, turn id, project key) or a fixed path under the data root. Exit codes, refusal codes and check order are unchanged. organization: util, access, project, config-command, library-seats, org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and organization-config.mjs change only the readJsonFile label. cli: access lock, accounts owner, host state/lock, notifier journal, notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console. discord: turn record label names the turn id. Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each changed refusal and fail on BASE; old path pins updated.
125 lines
4.4 KiB
JavaScript
125 lines
4.4 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}`);
|
|
}
|
|
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`);
|
|
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}`);
|
|
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`);
|
|
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`);
|
|
return value;
|
|
}
|
|
|
|
// Parser diagnostics can quote file bytes. Keep the existing SyntaxError refusal,
|
|
// but never attach the parser's message or cause to an error that callers can log.
|
|
// The message names the file by its `what` label only: a refusal never quotes a
|
|
// host path (PR #1916). `file` stays in the signature so callers don't change;
|
|
// it is not used.
|
|
export function parseJsonFileText(text, file, what = "file") {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new SyntaxError(`${what} is not valid JSON`);
|
|
}
|
|
}
|
|
|
|
// 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. Every refusal names the
|
|
// file by `what` and never by path; a caller whose label alone can't tell
|
|
// two files apart adds a name that already passed a strict pattern.
|
|
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`, 4);
|
|
refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
|
}
|
|
let text;
|
|
try {
|
|
const stat = fstatSync(fd);
|
|
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
|
checkStat?.(stat);
|
|
text = readFileSync(fd, "utf8");
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
try {
|
|
return parseJsonFileText(text, file, what);
|
|
} catch {
|
|
refuse(`${what} is not valid JSON`);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|