Files
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
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.
2026-11-11 01:04:15 -06:00

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;
}