feat(foundation): offline synthetic scope/permission inspector (FI-FILBERT-8 APPROVED r6)
Rocko-authored, Filbert-reviewed inspector (r6 manifest a4a44930...) with full review/build/verdict evidence under docs/plans/reviews. 43/0 selftests, oracle zero-disagreement, foundation checker PASS. Owner A9 acceptance recorded separately.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* inspector-content-digest/1 — canonical form and digest for MOCK registry
|
||||
* content only (charter candidate 3 §6; feasibility r2 §6.1).
|
||||
*
|
||||
* Domain: objects, arrays, ASCII strings, booleans, null and safe integers.
|
||||
* Object keys are sorted by UTF-16 code unit; separators are compact; array
|
||||
* order is preserved; the digest is "sha256:" + hex(SHA-256(UTF-8 bytes)).
|
||||
* Anything outside the domain refuses with CanonicalError (reason
|
||||
* unsupported-capability): non-ASCII strings, non-integral or unsafe numbers,
|
||||
* negative zero, undefined, functions, symbols, bigints.
|
||||
*
|
||||
* This is NOT the candidate README launch-fingerprint projection and makes no
|
||||
* JCS (RFC 8785) claim. Pure module: imports only node:crypto.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const DIGEST_ALGORITHM = "inspector-content-digest/1";
|
||||
|
||||
export class CanonicalError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.name = "CanonicalError";
|
||||
this.code = code;
|
||||
this.reason = "unsupported-capability";
|
||||
}
|
||||
}
|
||||
|
||||
function isAscii(text) {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text.charCodeAt(i) > 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function encodeString(text) {
|
||||
if (!isAscii(text)) throw new CanonicalError("string-not-ascii");
|
||||
// JSON.stringify on an ASCII string escapes exactly ", \ and U+0000..U+001F
|
||||
// (short forms \b \f \n \r \t, otherwise \u00XX lowercase), matching Python
|
||||
// json.dumps for the ASCII domain.
|
||||
return JSON.stringify(text);
|
||||
}
|
||||
|
||||
function encodeValue(value, depth) {
|
||||
if (depth > 64) throw new CanonicalError("depth-exceeded");
|
||||
if (value === null) return "null";
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
return value ? "true" : "false";
|
||||
case "number":
|
||||
if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
|
||||
throw new CanonicalError("number-not-safe-integer");
|
||||
}
|
||||
return String(value);
|
||||
case "string":
|
||||
return encodeString(value);
|
||||
case "object":
|
||||
break;
|
||||
default:
|
||||
throw new CanonicalError("value-not-json");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = [];
|
||||
for (const item of value) parts.push(encodeValue(item, depth + 1));
|
||||
return `[${parts.join(",")}]`;
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
keys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
const parts = [];
|
||||
for (const key of keys) {
|
||||
const item = value[key];
|
||||
if (item === undefined) throw new CanonicalError("value-not-json");
|
||||
parts.push(`${encodeString(key)}:${encodeValue(item, depth + 1)}`);
|
||||
}
|
||||
return `{${parts.join(",")}}`;
|
||||
}
|
||||
|
||||
/** Canonical compact encoding, or throws CanonicalError. */
|
||||
export function canonicalize(value) {
|
||||
return encodeValue(value, 0);
|
||||
}
|
||||
|
||||
/** "sha256:<hex>" of the canonical encoding, or throws CanonicalError. */
|
||||
export function digestOf(value) {
|
||||
const canonical = canonicalize(value);
|
||||
return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user