diff --git a/src/cli/index.ts b/src/cli/index.ts index 3fc7c6c..4672f10 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,6 +16,7 @@ import { runVendorKit } from "./kit-runner"; import { runIssueKey } from "./issue-key"; import { formatArtifactScanReport, resolveAssetInventoryWaivers, scanPublishedArtifact } from "../artifact-scan"; import { runSafeReadCli } from "./read"; +import { runVerifyWriteCli } from "./verify-write"; function collectJsonFiles(root: string): string[] { const stat = statSync(root); @@ -68,7 +69,7 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } - if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store", "read"].includes(command)) { + if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store", "read", "verify-write"].includes(command)) { return reportParserJsonError("commander.unknownCommand", `unknown command '${command}'`); } @@ -82,7 +83,7 @@ function preflightJsonUsageErrors(argv: string[]) { // command's own flags. Preflighting those against this program's option set // would reject `contracts read -- todos list --limit 5` for an option that is // not ours to validate. Commander handles it. - if (command === "read") { + if (command === "read" || command === "verify-write") { return false; } @@ -509,6 +510,21 @@ export function createContractsProgram() { process.exitCode = runSafeReadCli(command ?? [], options as never); }); + program + .command("verify-write") + .description( + "Cheaper than rendering a stored body: compare byte length and SHA-256; prevents appended capability content from reaching output" + ) + .argument("", "Exact object ID requested from the fetch command") + .argument("[command...]", "The fetch command to run after --; it must return one JSON object") + .requiredOption("--authored ", "File containing the exact payload the caller authored") + .option("--id-path ", "Dotted path to the fetched object's ID", "id") + .option("--content-path ", "Dotted path to the fetched stored content", "body") + .option("-j, --json", "Output metadata-only JSON") + .action((target: string, command: string[], options: Record) => { + process.exitCode = runVerifyWriteCli(target, command ?? [], options as never); + }); + program .command("issue-key") .description("Mint an API key (prefix hasna__): stores the hashed record and prints the secret ONCE") diff --git a/src/cli/verify-write.ts b/src/cli/verify-write.ts new file mode 100644 index 0000000..1cd25b1 --- /dev/null +++ b/src/cli/verify-write.ts @@ -0,0 +1,112 @@ +import { readFileSync } from "node:fs"; +import type { CapturedRead } from "../safe-read"; +import { runCaptured } from "../safe-read-exec"; +import { verifyFetchedWrite, type VerifyWriteResult } from "../verify-write"; + +export interface VerifyWriteCliOptions { + authored: string; + idPath?: string; + contentPath?: string; + json?: boolean; +} + +interface VerifyWriteCliIo { + log: (line: string) => void; + err: (line: string) => void; +} + +const defaultIo: VerifyWriteCliIo = { + log: (line) => console.log(line), + err: (line) => console.error(line) +}; + +function refusal(code: string, message: string) { + return { ok: false as const, status: "refused" as const, code, message }; +} + +function writeResult(result: VerifyWriteResult | ReturnType, json: boolean, io: VerifyWriteCliIo): number { + if (json) { + io.log(JSON.stringify(result)); + } else if (result.status === "match") { + io.log(`MATCH — ${result.message}`); + } else if (result.status === "refused") { + io.err(`REFUSED [${result.code}] — ${result.message}`); + } else if (result.status === "grew") { + io.err(`GREW BY ${result.deltaBytes} BYTES — ${result.message}`); + } else if (result.status === "shrunk") { + io.err(`SHRANK BY ${Math.abs(result.deltaBytes)} BYTES — ${result.message}`); + } else { + io.err(`MISMATCH — ${result.message}`); + } + + if (result.status === "match") return 0; + if (result.status === "refused") return 2; + return 1; +} + +export function runVerifyWriteCli( + targetId: string, + argv: string[], + options: VerifyWriteCliOptions, + io: VerifyWriteCliIo = defaultIo, + run: (argv: string[]) => CapturedRead = runCaptured +): number { + if (!targetId || !options.authored || argv.length === 0) { + writeResult( + refusal("usage", "target, --authored, and a fetch command after -- are required; stored body NOT rendered"), + Boolean(options.json), + io + ); + return 3; + } + + let authored: Buffer; + try { + authored = readFileSync(options.authored); + } catch { + return writeResult( + refusal("authored_read_failed", "authored payload could not be read; stored body NOT rendered"), + Boolean(options.json), + io + ); + } + + let captured: CapturedRead; + try { + captured = run(argv); + } catch { + return writeResult( + refusal("fetch_failed", "fetch command could not be executed; captured output NOT rendered"), + Boolean(options.json), + io + ); + } + + if (captured.code !== 0) { + return writeResult( + refusal("fetch_failed", "fetch command did not succeed; captured output NOT rendered"), + Boolean(options.json), + io + ); + } + + let fetched: unknown; + try { + fetched = JSON.parse(captured.stdout); + } catch { + return writeResult( + refusal("fetch_invalid_json", "fetch command did not return one JSON object; captured output NOT rendered"), + Boolean(options.json), + io + ); + } + + const result = verifyFetchedWrite({ + targetId, + authored, + fetched, + idPath: options.idPath ?? "id", + contentPath: options.contentPath ?? "body" + }); + return writeResult(result, Boolean(options.json), io); +} diff --git a/src/verify-write.ts b/src/verify-write.ts new file mode 100644 index 0000000..fcbe208 --- /dev/null +++ b/src/verify-write.ts @@ -0,0 +1,154 @@ +import { createHash } from "node:crypto"; + +export type VerifyWriteStatus = "match" | "grew" | "shrunk" | "mismatch" | "refused"; + +export interface VerifyWriteMatch { + ok: true; + status: "match"; + authoredBytes: number; + storedBytes: number; + deltaBytes: 0; + hashesEqual: true; + message: string; +} + +export interface VerifyWriteDifference { + ok: false; + status: "grew" | "shrunk" | "mismatch"; + authoredBytes: number; + storedBytes: number; + deltaBytes: number; + hashesEqual: false; + message: string; +} + +export interface VerifyWriteRefusal { + ok: false; + status: "refused"; + code: + | "object_id_missing" + | "object_id_invalid" + | "object_id_mismatch" + | "content_missing" + | "content_invalid"; + message: string; +} + +export type VerifyWriteResult = VerifyWriteMatch | VerifyWriteDifference | VerifyWriteRefusal; + +export interface VerifyFetchedWriteRequest { + targetId: string; + authored: Uint8Array; + fetched: unknown; + idPath?: string; + contentPath?: string; +} + +interface PathRead { + found: boolean; + value?: unknown; +} + +function readPath(value: unknown, path: string): PathRead { + let current = value; + for (const segment of path.split(".")) { + if (!segment || current === null || typeof current !== "object") { + return { found: false }; + } + if (!Object.prototype.hasOwnProperty.call(current, segment)) { + return { found: false }; + } + current = (current as Record)[segment]; + } + return { found: true, value: current }; +} + +function sha256(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function refused(code: VerifyWriteRefusal["code"], message: string): VerifyWriteRefusal { + return { ok: false, status: "refused", code, message }; +} + +/** + * Compare one fetched object with the caller-authored bytes without returning + * either body or either digest. Object identity is checked before the stored + * content path is accessed. + */ +export function verifyFetchedWrite(request: VerifyFetchedWriteRequest): VerifyWriteResult { + const idRead = readPath(request.fetched, request.idPath ?? "id"); + if (!idRead.found) { + return refused("object_id_missing", "fetched object ID was missing; stored body NOT rendered"); + } + if (typeof idRead.value !== "string") { + return refused("object_id_invalid", "fetched object ID was not a string; stored body NOT rendered"); + } + if (idRead.value !== request.targetId) { + return refused( + "object_id_mismatch", + "fetched object ID did not equal requested ID; stored body NOT rendered" + ); + } + + const contentRead = readPath(request.fetched, request.contentPath ?? "body"); + if (!contentRead.found) { + return refused("content_missing", "stored content field was missing; stored body NOT rendered"); + } + if (typeof contentRead.value !== "string") { + return refused("content_invalid", "stored content field was not a string; stored body NOT rendered"); + } + + const authored = Buffer.from(request.authored); + const stored = Buffer.from(contentRead.value, "utf8"); + const authoredBytes = authored.byteLength; + const storedBytes = stored.byteLength; + const deltaBytes = storedBytes - authoredBytes; + const hashesEqual = sha256(authored) === sha256(stored); + + if (hashesEqual) { + return { + ok: true, + status: "match", + authoredBytes, + storedBytes, + deltaBytes: 0, + hashesEqual: true, + message: `fetched object ID equals requested ID; ${authoredBytes} bytes; SHA-256 equal; stored body NOT rendered` + }; + } + + if (deltaBytes > 0) { + return { + ok: false, + status: "grew", + authoredBytes, + storedBytes, + deltaBytes, + hashesEqual: false, + message: `third-party content appended, ${deltaBytes} bytes, NOT rendered` + }; + } + + if (deltaBytes < 0) { + return { + ok: false, + status: "shrunk", + authoredBytes, + storedBytes, + deltaBytes, + hashesEqual: false, + message: "stored content is shorter, NOT rendered" + }; + } + + return { + ok: false, + status: "mismatch", + authoredBytes, + storedBytes, + deltaBytes: 0, + hashesEqual: false, + message: "byte length equal but SHA-256 differs; stored body NOT rendered" + }; +} diff --git a/tests/verify-write.test.ts b/tests/verify-write.test.ts new file mode 100644 index 0000000..04407e0 --- /dev/null +++ b/tests/verify-write.test.ts @@ -0,0 +1,202 @@ +/** + * Two-sided fixtures for verify-write. + * + * Every fetched payload is an in-memory fixture. Nothing here reaches a forge, + * API, credential store, or shared data surface. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createContractsProgram } from "../src/cli/index"; +import { runVerifyWriteCli } from "../src/cli/verify-write"; +import type { CapturedRead } from "../src/safe-read"; +import { verifyFetchedWrite } from "../src/verify-write"; + +const scratch = mkdtempSync(join(tmpdir(), "contracts-verify-write-test-")); + +function authored(name: string, content: string): string { + const path = join(scratch, name); + writeFileSync(path, content); + return path; +} + +function captured(value: unknown, stderr = "", code = 0): CapturedRead { + return { stdout: JSON.stringify(value), stderr, code }; +} + +function invoke( + target: string, + authoredPath: string, + fetch: CapturedRead, + options: { idPath?: string; contentPath?: string; json?: boolean } = {} +) { + const stdout: string[] = []; + const stderr: string[] = []; + const code = runVerifyWriteCli( + target, + ["fixture", "fetch"], + { authored: authoredPath, ...options }, + { log: (line) => stdout.push(line), err: (line) => stderr.push(line) }, + () => fetch + ); + return { code, stdout: stdout.join("\n"), stderr: stderr.join("\n") }; +} + +describe("verify-write", () => { + test("byte-identical stored content reports MATCH", () => { + const body = "authored payload\n"; + const result = invoke( + "target-0001", + authored("match.txt", body), + captured({ id: "target-0001", body }) + ); + + expect(result.code).toBe(0); + expect(result.stdout).toBe( + "MATCH — fetched object ID equals requested ID; 17 bytes; SHA-256 equal; stored body NOT rendered" + ); + expect(result.stderr).toBe(""); + }); + + test("an appended footer reports growth without rendering the footer on either stream", () => { + const body = "authored payload\n"; + const footer = "SIGNED-CAPABILITY-FOOTER"; + const result = invoke( + "target-0001", + authored("growth.txt", body), + captured({ id: "target-0001", body: `${body}${footer}` }) + ); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + `GREW BY ${Buffer.byteLength(footer)} BYTES — third-party content appended, ${Buffer.byteLength(footer)} bytes, NOT rendered` + ); + expect(result.stdout).not.toContain(footer); + expect(result.stderr).not.toContain(footer); + }); + + test("a same-length wrong object refuses before MATCH or GROWTH", () => { + const body = "same-length-body"; + const result = invoke( + "target-0001", + authored("wrong-object.txt", body), + captured({ id: "target-0002", body }) + ); + + expect(result.code).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("REFUSED [object_id_mismatch]"); + expect(result.stderr).not.toContain("MATCH"); + expect(result.stderr).not.toContain("GREW BY"); + expect(result.stderr).not.toContain(body); + }); + + test("object identity is checked before stored content is accessed", () => { + let contentReads = 0; + const fetched = { + id: "target-0002", + get body() { + contentReads += 1; + throw new Error("stored content must not be touched for the wrong object"); + } + }; + + const result = verifyFetchedWrite({ + targetId: "target-0001", + authored: Buffer.from("same-length-body"), + fetched, + idPath: "id", + contentPath: "body" + }); + + expect(result).toEqual({ + ok: false, + status: "refused", + code: "object_id_mismatch", + message: "fetched object ID did not equal requested ID; stored body NOT rendered" + }); + expect(contentReads).toBe(0); + }); + + test("an unfetchable target refuses and never renders captured output", () => { + const body = "authored payload"; + const capability = "SIGNED-CAPABILITY-IN-FETCH-ERROR"; + const result = invoke( + "target-0001", + authored("unfetchable.txt", body), + { stdout: capability, stderr: capability, code: 1 } + ); + + expect(result.code).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("REFUSED [fetch_failed]"); + expect(result.stderr).not.toContain(capability); + expect(result.stderr).not.toContain(body); + }); + + test("shorter and same-length-different content remain distinguishable without rendering content", () => { + const body = "abcdefgh"; + const shrink = invoke( + "target-0001", + authored("shrink.txt", body), + captured({ id: "target-0001", body: "abc" }) + ); + const changed = invoke( + "target-0001", + authored("changed.txt", body), + captured({ id: "target-0001", body: "ijklmnop" }) + ); + + expect(shrink.code).toBe(1); + expect(shrink.stderr).toBe("SHRANK BY 5 BYTES — stored content is shorter, NOT rendered"); + expect(changed.code).toBe(1); + expect(changed.stderr).toBe( + "MISMATCH — byte length equal but SHA-256 differs; stored body NOT rendered" + ); + for (const stream of [shrink.stdout, shrink.stderr, changed.stdout, changed.stderr]) { + expect(stream).not.toContain(body); + expect(stream).not.toContain("ijklmnop"); + } + }); + + test("JSON output is metadata-only", () => { + const body = "PAYLOAD-CONTENT-SECRET"; + const footer = "APPENDED-CAPABILITY"; + const result = invoke( + "target-0001", + authored("json.txt", body), + captured({ result: { object_id: "target-0001", content: `${body}${footer}` } }), + { idPath: "result.object_id", contentPath: "result.content", json: true } + ); + + expect(result.code).toBe(1); + const payload = JSON.parse(result.stdout); + expect(payload).toEqual({ + ok: false, + status: "grew", + authoredBytes: Buffer.byteLength(body), + storedBytes: Buffer.byteLength(body + footer), + deltaBytes: Buffer.byteLength(footer), + hashesEqual: false, + message: `third-party content appended, ${Buffer.byteLength(footer)} bytes, NOT rendered` + }); + expect(result.stdout).not.toContain(body); + expect(result.stdout).not.toContain(footer); + expect(result.stderr).toBe(""); + }); + + test("the command help leads with the cheaper workflow and exposes no verbose mode", () => { + const command = createContractsProgram().commands.find((candidate) => candidate.name() === "verify-write"); + expect(command).toBeDefined(); + expect(command!.description()).toStartWith("Cheaper than rendering a stored body"); + expect(command!.description()).toContain("capability"); + expect(command!.options.map((option) => option.long)).not.toContain("--verbose"); + }); +}); + +afterAll(() => { + rmSync(scratch, { recursive: true, force: true }); +});