verifyhash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
package/cli/receipt.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// cli/receipt.js — a versioned, strictly-validated JSON receipt for the verifyhash CLI.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS
|
|
6
|
+
// Two flows need a durable, portable on-disk artifact:
|
|
7
|
+
//
|
|
8
|
+
// 1. CLAIM RECEIPTS (kind "verifyhash.claim-receipt").
|
|
9
|
+
// The commit-reveal attribution flow (`vh claim`) is two transactions separated by a maturation
|
|
10
|
+
// window of MIN_REVEAL_DELAY blocks. On a live testnet that window is minutes. If a single-process
|
|
11
|
+
// `runClaim` (commit + reveal in one go) crashes/Ctrl-C's between the two legs, the secret salt —
|
|
12
|
+
// held only in memory — is lost forever. The contentHash is then committed-but-unrevealable by
|
|
13
|
+
// ANYONE (reveal needs that exact salt), so the attribution is permanently burned. The claim
|
|
14
|
+
// receipt makes the claim DURABLE and RESUMABLE: at commit time the orchestrator persists the
|
|
15
|
+
// salt/commitment (and everything `reveal()` needs) to a JSON receipt on disk; a later, separate
|
|
16
|
+
// `vh reveal --receipt <path>` process loads it and finishes the claim.
|
|
17
|
+
//
|
|
18
|
+
// 2. ANCHOR RECEIPTS (kind "verifyhash.anchor-receipt").
|
|
19
|
+
// A one-shot `vh anchor` records only a contentHash on-chain. For a DIRECTORY that hash is a
|
|
20
|
+
// Merkle root over per-file path-bound leaves, so `vh verify <dir>` can only ever say "the whole
|
|
21
|
+
// tree's root matches / does not match" — it cannot say WHICH file diverged. `vh hash <dir>`
|
|
22
|
+
// already computes every per-file `{ path, contentHash, leaf }` and then throws them away. An
|
|
23
|
+
// anchor receipt records that sorted MANIFEST so a later `vh verify <dir> --receipt <p>` can
|
|
24
|
+
// localize the change to specific files (ADDED / REMOVED / CHANGED).
|
|
25
|
+
//
|
|
26
|
+
// Both kinds, at schemaVersion >= 2, may additionally carry a `manifest` for a directory target.
|
|
27
|
+
// The claim/commit receipts for a directory record it too (so a resumed reveal — and any later
|
|
28
|
+
// verify — has the per-file breakdown).
|
|
29
|
+
//
|
|
30
|
+
// TRUST POSTURE
|
|
31
|
+
// The receipt is an UNTRUSTED local convenience, consistent with docs/TRUST-BOUNDARIES.md: the
|
|
32
|
+
// authoritative result still comes from the on-chain record (and, for verify, from re-deriving the
|
|
33
|
+
// root and comparing it to that record). A receipt's `manifest` only LOCALIZES which file diverged;
|
|
34
|
+
// it can never, by itself, make content "verified". But a receipt that is *corrupt* must never be
|
|
35
|
+
// silently half-accepted — a partial claim receipt could make a user re-derive a wrong commitment
|
|
36
|
+
// or reveal with the wrong salt and waste a transaction (or worse, leak the salt while producing
|
|
37
|
+
// nothing). So `readReceipt` validates strictly and throws on ANY deviation rather than filling
|
|
38
|
+
// defaults.
|
|
39
|
+
|
|
40
|
+
const fs = require("fs");
|
|
41
|
+
|
|
42
|
+
// Current on-disk schema version written by this build. History (all ADDITIVE):
|
|
43
|
+
// 1 -> 2 : added the optional `manifest` (per-file dir leaves, for localized verify diffs).
|
|
44
|
+
// 2 -> 3 : added the optional `git` block { commit, scope } — the resolved commit oid and the
|
|
45
|
+
// repo-relative scope used to enumerate the tracked files for a `--git` anchor/claim.
|
|
46
|
+
// 3 -> 4 : added the optional `parent` field on a CLAIM receipt ONLY (B-10.1) — a 0x 32-byte hex
|
|
47
|
+
// contentHash of an already-anchored predecessor (the lineage edge a `vh commit --parent`
|
|
48
|
+
// will record at REVEAL time via revealWithParent), or absent for a lineage root. It is an
|
|
49
|
+
// UNTRUSTED convenience hint (docs/TRUST-BOUNDARIES.md): the AUTHORITATIVE edge is what
|
|
50
|
+
// revealWithParent records on-chain, not this field. The ANCHOR receipt is unchanged.
|
|
51
|
+
// `readReceipt` still ACCEPTS every prior version (a v1 receipt has no manifest, a v1/v2 receipt has
|
|
52
|
+
// no git block, a v1/v2/v3 receipt has no parent), so older artifacts keep working; it only WRITES
|
|
53
|
+
// version SCHEMA_VERSION. Any version outside the supported set is rejected so a future/foreign file
|
|
54
|
+
// is never misread as a current one.
|
|
55
|
+
const SCHEMA_VERSION = 4;
|
|
56
|
+
const SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2, 3, 4]);
|
|
57
|
+
|
|
58
|
+
// Receipts carry one of these discriminators so a random JSON file (or a different vh artifact) is
|
|
59
|
+
// never mistaken for a verifyhash receipt. A CLAIM receipt is the resumable commit-reveal artifact
|
|
60
|
+
// (carries salt/commitment); an ANCHOR receipt is the one-shot anchor artifact (no secret material).
|
|
61
|
+
const RECEIPT_KIND = "verifyhash.claim-receipt"; // back-compat alias: the claim-receipt kind
|
|
62
|
+
const CLAIM_RECEIPT_KIND = "verifyhash.claim-receipt";
|
|
63
|
+
const ANCHOR_RECEIPT_KIND = "verifyhash.anchor-receipt";
|
|
64
|
+
const RECEIPT_KINDS = Object.freeze([CLAIM_RECEIPT_KIND, ANCHOR_RECEIPT_KIND]);
|
|
65
|
+
|
|
66
|
+
// Fields that must be present and be 0x-prefixed 32-byte (64 hex char) values, by kind.
|
|
67
|
+
// Claim receipts bind a salt + commitment; anchor receipts only ever attest a contentHash.
|
|
68
|
+
const HEX32_FIELDS_CLAIM = ["contentHash", "salt", "commitment"];
|
|
69
|
+
const HEX32_FIELDS_ANCHOR = ["contentHash"];
|
|
70
|
+
// Address fields that must be present, by kind. Anchor receipts have no committer (no signer needed
|
|
71
|
+
// to anchor a hash they already know — and verify needs no signer at all).
|
|
72
|
+
const ADDR_FIELDS_CLAIM = ["committer", "contractAddress"];
|
|
73
|
+
const ADDR_FIELDS_ANCHOR = ["contractAddress"];
|
|
74
|
+
|
|
75
|
+
const HEX32_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
76
|
+
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
77
|
+
// A full git commit object id: 40 lowercase hex chars (what resolveCommit always yields). The `git`
|
|
78
|
+
// block records this BARE (no 0x prefix) to match git's own representation, so a reader can paste it
|
|
79
|
+
// straight into `git show <oid>`.
|
|
80
|
+
const GIT_OID_RE = /^[0-9a-f]{40}$/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a normalized, fully-populated CLAIM-receipt object from raw parts. Throws if any required
|
|
84
|
+
* field is missing or malformed, so we never *write* a partial receipt either.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} parts
|
|
87
|
+
* @param {string} parts.contentHash 0x 32-byte digest being claimed
|
|
88
|
+
* @param {string} parts.committer 0x 20-byte address that committed and will reveal
|
|
89
|
+
* @param {string} parts.salt 0x 32-byte secret salt — `secret: true`: keep this PRIVATE
|
|
90
|
+
* until reveal. Whoever holds it before reveal can front-run
|
|
91
|
+
* the open; it is the one operationally-load-bearing field.
|
|
92
|
+
* @param {string} parts.commitment 0x 32-byte commitment hash
|
|
93
|
+
* @param {string} parts.contractAddress 0x 20-byte ContributionRegistry address
|
|
94
|
+
* @param {number|string|bigint} parts.chainId chain the commit was sent to
|
|
95
|
+
* @param {string} [parts.uri] optional untrusted off-chain pointer hint
|
|
96
|
+
* @param {string} [parts.kind] "file" | "dir" (informational target kind)
|
|
97
|
+
* @param {string} [parts.path] the source path claimed (informational)
|
|
98
|
+
* @param {string} [parts.commitTxHash] the commit() transaction hash (0x 32-byte)
|
|
99
|
+
* @param {number|string|bigint} [parts.commitBlockNumber] block.number the commit mined in
|
|
100
|
+
* @param {number|string|bigint} [parts.minRevealDelay] MIN_REVEAL_DELAY read from the contract
|
|
101
|
+
* @param {Array<{path:string,contentHash:string,leaf:string}>} [parts.manifest]
|
|
102
|
+
* sorted per-file manifest for a directory target (exactly what `vh hash <dir>` produces)
|
|
103
|
+
* @param {{commit:string,scope:string}} [parts.git]
|
|
104
|
+
* optional UNTRUSTED git-provenance hint: the resolved commit oid + repo-relative scope used
|
|
105
|
+
* to enumerate the tracked files for a `--git` claim
|
|
106
|
+
* @param {string} [parts.parent]
|
|
107
|
+
* optional 0x 32-byte contentHash of an ALREADY-ANCHORED predecessor (B-10.1 lineage edge).
|
|
108
|
+
* Recorded ONLY when present; absent means this claim is a lineage root. This is a CLAIM of a
|
|
109
|
+
* predecessor, NOT proof of ancestry or any transfer of the parent's authorship — it is an
|
|
110
|
+
* UNTRUSTED convenience hint. The AUTHORITATIVE edge is what `revealWithParent(contentHash,
|
|
111
|
+
* salt, uri, parent)` records on-chain at reveal time; this field merely lets a resumed
|
|
112
|
+
* `vh reveal` know which reveal leg to route to. Validated as a well-formed non-zero 32-byte
|
|
113
|
+
* hex hash that is NOT equal to `contentHash` (the contract rejects a self-parent as SelfParent).
|
|
114
|
+
* @returns {object} a validated receipt object
|
|
115
|
+
*/
|
|
116
|
+
function buildReceipt(parts) {
|
|
117
|
+
if (!parts || typeof parts !== "object") {
|
|
118
|
+
throw new Error("buildReceipt requires an object of parts");
|
|
119
|
+
}
|
|
120
|
+
const receipt = {
|
|
121
|
+
kind: CLAIM_RECEIPT_KIND,
|
|
122
|
+
schemaVersion: SCHEMA_VERSION,
|
|
123
|
+
contentHash: parts.contentHash,
|
|
124
|
+
committer: parts.committer,
|
|
125
|
+
salt: parts.salt,
|
|
126
|
+
commitment: parts.commitment,
|
|
127
|
+
contractAddress: parts.contractAddress,
|
|
128
|
+
chainId: _normChainId(parts.chainId),
|
|
129
|
+
uri: parts.uri == null ? "" : String(parts.uri),
|
|
130
|
+
};
|
|
131
|
+
_attachOptional(receipt, parts);
|
|
132
|
+
_validate(receipt);
|
|
133
|
+
return receipt;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build a normalized ANCHOR-receipt object: the durable companion to a one-shot `vh anchor`. It has
|
|
138
|
+
* no salt/commitment/committer (anchoring needs none); its reason to exist is the optional directory
|
|
139
|
+
* `manifest` that lets a later `vh verify --receipt` localize which file diverged.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} parts
|
|
142
|
+
* @param {string} parts.contentHash 0x 32-byte digest anchored (file digest or dir Merkle root)
|
|
143
|
+
* @param {string} parts.contractAddress 0x 20-byte ContributionRegistry address
|
|
144
|
+
* @param {number|string|bigint} parts.chainId chain the anchor was/would be sent to
|
|
145
|
+
* @param {string} [parts.uri] optional untrusted off-chain pointer hint
|
|
146
|
+
* @param {string} [parts.kind] "file" | "dir" (informational target kind)
|
|
147
|
+
* @param {string} [parts.path] the source path anchored (informational)
|
|
148
|
+
* @param {string} [parts.anchorTxHash] the anchor() transaction hash (0x 32-byte), when sent
|
|
149
|
+
* @param {number|string|bigint} [parts.anchorBlockNumber] block.number the anchor mined in
|
|
150
|
+
* @param {Array<{path:string,contentHash:string,leaf:string}>} [parts.manifest]
|
|
151
|
+
* sorted per-file manifest for a directory target (exactly what `vh hash <dir>` produces)
|
|
152
|
+
* @param {{commit:string,scope:string}} [parts.git]
|
|
153
|
+
* optional UNTRUSTED git-provenance hint: the resolved commit oid + repo-relative scope used
|
|
154
|
+
* to enumerate the tracked files for a `--git` anchor
|
|
155
|
+
* @returns {object} a validated anchor-receipt object
|
|
156
|
+
*/
|
|
157
|
+
function buildAnchorReceipt(parts) {
|
|
158
|
+
if (!parts || typeof parts !== "object") {
|
|
159
|
+
throw new Error("buildAnchorReceipt requires an object of parts");
|
|
160
|
+
}
|
|
161
|
+
const receipt = {
|
|
162
|
+
kind: ANCHOR_RECEIPT_KIND,
|
|
163
|
+
schemaVersion: SCHEMA_VERSION,
|
|
164
|
+
contentHash: parts.contentHash,
|
|
165
|
+
contractAddress: parts.contractAddress,
|
|
166
|
+
chainId: _normChainId(parts.chainId),
|
|
167
|
+
uri: parts.uri == null ? "" : String(parts.uri),
|
|
168
|
+
};
|
|
169
|
+
if (parts.path != null) receipt.path = String(parts.path);
|
|
170
|
+
if (parts.kind != null) receipt.targetKind = String(parts.kind);
|
|
171
|
+
if (parts.anchorTxHash != null) receipt.anchorTxHash = parts.anchorTxHash;
|
|
172
|
+
if (parts.anchorBlockNumber != null) {
|
|
173
|
+
receipt.anchorBlockNumber = _normIntField("anchorBlockNumber", parts.anchorBlockNumber);
|
|
174
|
+
}
|
|
175
|
+
if (parts.manifest != null) receipt.manifest = _normManifest(parts.manifest);
|
|
176
|
+
if (parts.git != null) receipt.git = _normGit(parts.git);
|
|
177
|
+
_validate(receipt);
|
|
178
|
+
return receipt;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Attach the optional/operational claim-receipt fields (validated for shape) when provided. */
|
|
182
|
+
function _attachOptional(receipt, parts) {
|
|
183
|
+
if (parts.path != null) receipt.path = String(parts.path);
|
|
184
|
+
if (parts.kind != null) receipt.targetKind = String(parts.kind);
|
|
185
|
+
if (parts.commitTxHash != null) receipt.commitTxHash = parts.commitTxHash;
|
|
186
|
+
if (parts.commitBlockNumber != null) {
|
|
187
|
+
receipt.commitBlockNumber = _normIntField("commitBlockNumber", parts.commitBlockNumber);
|
|
188
|
+
}
|
|
189
|
+
if (parts.minRevealDelay != null) {
|
|
190
|
+
receipt.minRevealDelay = _normIntField("minRevealDelay", parts.minRevealDelay);
|
|
191
|
+
}
|
|
192
|
+
if (parts.manifest != null) receipt.manifest = _normManifest(parts.manifest);
|
|
193
|
+
if (parts.git != null) receipt.git = _normGit(parts.git);
|
|
194
|
+
// The lineage edge (B-10.1) is recorded ONLY when present — absent means a lineage root. Mirrors the
|
|
195
|
+
// additive-optional `_normGit` pattern: validated here so a malformed/zero/self-referential parent is
|
|
196
|
+
// rejected at build time and never lands on disk. `receipt.contentHash` is already set above, so the
|
|
197
|
+
// self-reference check can run against it.
|
|
198
|
+
if (parts.parent != null) receipt.parent = _normParent(parts.parent, receipt.contentHash);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Normalize the optional lineage `parent` (B-10.1) into the canonical on-disk shape: a lowercased 0x
|
|
203
|
+
* 32-byte hex hash of an ALREADY-ANCHORED predecessor. Throws on a malformed value, on the all-zero
|
|
204
|
+
* hash (the zero hash is the contract's "no predecessor" sentinel — a receipt that means "root" must
|
|
205
|
+
* OMIT `parent` entirely, never carry the zero hash as a lie), and on a self-reference
|
|
206
|
+
* (`parent === contentHash`; the contract rejects this as SelfParent). The edge is an UNTRUSTED claim
|
|
207
|
+
* of a predecessor (docs/TRUST-BOUNDARIES.md): the authoritative edge is what `revealWithParent`
|
|
208
|
+
* records on-chain — this normalizer only keeps the receipt internally consistent and well-formed.
|
|
209
|
+
* @param {any} parent the candidate predecessor contentHash
|
|
210
|
+
* @param {string} contentHash the receipt's own contentHash (for the self-reference guard)
|
|
211
|
+
* @returns {string} the normalized, lowercased parent hash
|
|
212
|
+
*/
|
|
213
|
+
function _normParent(parent, contentHash) {
|
|
214
|
+
if (typeof parent !== "string" || !HEX32_RE.test(parent)) {
|
|
215
|
+
throw new Error(`receipt parent must be a 0x 32-byte hex contentHash, got: ${String(parent)}`);
|
|
216
|
+
}
|
|
217
|
+
if (/^0x0{64}$/i.test(parent)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
"receipt parent must not be the all-zero hash (a lineage root omits parent entirely)"
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (typeof contentHash === "string" && parent.toLowerCase() === contentHash.toLowerCase()) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
"receipt parent must not equal contentHash (self-reference; the contract rejects it as SelfParent)"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return parent.toLowerCase();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Normalize an optional git-provenance block into the canonical on-disk shape: { commit, scope }.
|
|
232
|
+
* `commit` is a full 40-hex lowercase oid (lowercased here); `scope` is a non-empty repo-relative
|
|
233
|
+
* POSIX path (or "." for the repo root). This is an UNTRUSTED convenience hint (docs/TRUST-BOUNDARIES):
|
|
234
|
+
* it records HOW the tracked set was enumerated, never the authoritative verdict. Throws on a
|
|
235
|
+
* malformed block — a git block either is well-formed or is rejected (the schema must not lie).
|
|
236
|
+
* @param {any} git
|
|
237
|
+
* @returns {{ commit: string, scope: string }}
|
|
238
|
+
*/
|
|
239
|
+
function _normGit(git) {
|
|
240
|
+
if (!git || typeof git !== "object" || Array.isArray(git)) {
|
|
241
|
+
throw new Error("receipt git block must be an object { commit, scope }");
|
|
242
|
+
}
|
|
243
|
+
const commit = typeof git.commit === "string" ? git.commit.toLowerCase() : git.commit;
|
|
244
|
+
if (typeof commit !== "string" || !GIT_OID_RE.test(commit)) {
|
|
245
|
+
throw new Error(`receipt git.commit must be a 40-hex commit oid, got: ${String(git.commit)}`);
|
|
246
|
+
}
|
|
247
|
+
if (typeof git.scope !== "string" || git.scope.length === 0) {
|
|
248
|
+
throw new Error(`receipt git.scope must be a non-empty string, got: ${String(git.scope)}`);
|
|
249
|
+
}
|
|
250
|
+
return { commit, scope: git.scope };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Normalize a chainId (number|string|bigint) to a non-negative integer Number. */
|
|
254
|
+
function _normChainId(v) {
|
|
255
|
+
if (v == null) return v; // let _validate produce the missing-field error
|
|
256
|
+
let n;
|
|
257
|
+
try {
|
|
258
|
+
n = Number(BigInt(v));
|
|
259
|
+
} catch (_) {
|
|
260
|
+
throw new Error(`receipt chainId must be an integer, got: ${String(v)}`);
|
|
261
|
+
}
|
|
262
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
263
|
+
throw new Error(`receipt chainId must be a non-negative integer, got: ${String(v)}`);
|
|
264
|
+
}
|
|
265
|
+
return n;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Normalize an optional integer field (block numbers, delays) to a Number. */
|
|
269
|
+
function _normIntField(name, v) {
|
|
270
|
+
let n;
|
|
271
|
+
try {
|
|
272
|
+
n = Number(BigInt(v));
|
|
273
|
+
} catch (_) {
|
|
274
|
+
throw new Error(`receipt ${name} must be an integer, got: ${String(v)}`);
|
|
275
|
+
}
|
|
276
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
277
|
+
throw new Error(`receipt ${name} must be a non-negative integer, got: ${String(v)}`);
|
|
278
|
+
}
|
|
279
|
+
return n;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Normalize a per-file manifest into the canonical on-disk shape: an array of
|
|
284
|
+
* { path, contentHash, leaf } entries, sorted ascending by `leaf` (the same total order `hashDir`
|
|
285
|
+
* uses to build the tree, so a written manifest is deterministic regardless of input order).
|
|
286
|
+
* Throws on any malformed entry — a manifest either is well-formed or is rejected.
|
|
287
|
+
* @param {any} manifest
|
|
288
|
+
* @returns {Array<{path:string,contentHash:string,leaf:string}>}
|
|
289
|
+
*/
|
|
290
|
+
function _normManifest(manifest) {
|
|
291
|
+
if (!Array.isArray(manifest)) {
|
|
292
|
+
throw new Error("receipt manifest must be an array");
|
|
293
|
+
}
|
|
294
|
+
const out = manifest.map((entry, i) => {
|
|
295
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
296
|
+
throw new Error(`receipt manifest entry ${i} must be an object`);
|
|
297
|
+
}
|
|
298
|
+
if (typeof entry.path !== "string" || entry.path.length === 0) {
|
|
299
|
+
throw new Error(`receipt manifest entry ${i} must have a non-empty string path`);
|
|
300
|
+
}
|
|
301
|
+
if (typeof entry.contentHash !== "string" || !HEX32_RE.test(entry.contentHash)) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`receipt manifest entry ${i} (${entry.path}) contentHash must be a 0x 32-byte hex string`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (typeof entry.leaf !== "string" || !HEX32_RE.test(entry.leaf)) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`receipt manifest entry ${i} (${entry.path}) leaf must be a 0x 32-byte hex string`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return { path: entry.path, contentHash: entry.contentHash, leaf: entry.leaf };
|
|
312
|
+
});
|
|
313
|
+
// Deterministic order: sort by leaf value (matches hashDir's leaf-sorted tree).
|
|
314
|
+
out.sort((a, b) => {
|
|
315
|
+
const x = BigInt(a.leaf);
|
|
316
|
+
const y = BigInt(b.leaf);
|
|
317
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
318
|
+
});
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Strictly validate a parsed receipt object. Throws an Error describing the FIRST problem found.
|
|
324
|
+
* Never mutates the object and never fills defaults — a receipt either is complete and well-formed
|
|
325
|
+
* or it is rejected outright. Accepts every SUPPORTED_SCHEMA_VERSIONS (a v1 receipt has no manifest,
|
|
326
|
+
* a v1/v2 receipt has no git block, a v1/v2/v3 receipt has no parent), and both claim- and anchor-kind
|
|
327
|
+
* receipts. Each optional field is gated to the version that introduced it, so a version never lies.
|
|
328
|
+
* @param {any} obj
|
|
329
|
+
* @returns {object} the same object, if valid
|
|
330
|
+
*/
|
|
331
|
+
function _validate(obj) {
|
|
332
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
333
|
+
throw new Error("receipt must be a JSON object");
|
|
334
|
+
}
|
|
335
|
+
if (!RECEIPT_KINDS.includes(obj.kind)) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`not a verifyhash receipt (kind: ${JSON.stringify(obj.kind)}; expected one of ${JSON.stringify(
|
|
338
|
+
RECEIPT_KINDS
|
|
339
|
+
)})`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (!SUPPORTED_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`unsupported receipt schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
345
|
+
`(this build understands ${JSON.stringify(SUPPORTED_SCHEMA_VERSIONS)})`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const isAnchor = obj.kind === ANCHOR_RECEIPT_KIND;
|
|
350
|
+
const hex32Fields = isAnchor ? HEX32_FIELDS_ANCHOR : HEX32_FIELDS_CLAIM;
|
|
351
|
+
const addrFields = isAnchor ? ADDR_FIELDS_ANCHOR : ADDR_FIELDS_CLAIM;
|
|
352
|
+
|
|
353
|
+
for (const f of hex32Fields) {
|
|
354
|
+
const v = obj[f];
|
|
355
|
+
if (v === undefined || v === null) throw new Error(`receipt missing required field: ${f}`);
|
|
356
|
+
if (typeof v !== "string" || !HEX32_RE.test(v)) {
|
|
357
|
+
throw new Error(`receipt field ${f} must be a 0x-prefixed 32-byte hex string, got: ${String(v)}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const f of addrFields) {
|
|
361
|
+
const v = obj[f];
|
|
362
|
+
if (v === undefined || v === null) throw new Error(`receipt missing required field: ${f}`);
|
|
363
|
+
if (typeof v !== "string" || !ADDR_RE.test(v)) {
|
|
364
|
+
throw new Error(`receipt field ${f} must be a 0x-prefixed 20-byte address, got: ${String(v)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (obj.chainId === undefined || obj.chainId === null) {
|
|
369
|
+
throw new Error("receipt missing required field: chainId");
|
|
370
|
+
}
|
|
371
|
+
if (!Number.isSafeInteger(obj.chainId) || obj.chainId < 0) {
|
|
372
|
+
throw new Error(`receipt field chainId must be a non-negative integer, got: ${String(obj.chainId)}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (obj.uri !== undefined && obj.uri !== null && typeof obj.uri !== "string") {
|
|
376
|
+
throw new Error(`receipt field uri must be a string when present, got: ${typeof obj.uri}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Optional operational fields (claim + anchor): validate shape only when present.
|
|
380
|
+
for (const f of ["commitTxHash", "anchorTxHash"]) {
|
|
381
|
+
if (obj[f] !== undefined && obj[f] !== null) {
|
|
382
|
+
if (typeof obj[f] !== "string" || !HEX32_RE.test(obj[f])) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`receipt field ${f} must be a 0x-prefixed 32-byte hex string when present, got: ${String(obj[f])}`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (const f of ["commitBlockNumber", "minRevealDelay", "anchorBlockNumber"]) {
|
|
390
|
+
if (obj[f] !== undefined && obj[f] !== null) {
|
|
391
|
+
if (!Number.isSafeInteger(obj[f]) || obj[f] < 0) {
|
|
392
|
+
throw new Error(`receipt field ${f} must be a non-negative integer when present, got: ${String(obj[f])}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Optional manifest: only meaningful at schemaVersion >= 2. A v1 receipt that somehow carries a
|
|
398
|
+
// manifest is rejected (the version contract is that v1 has none), so the version is never a lie.
|
|
399
|
+
if (obj.manifest !== undefined && obj.manifest !== null) {
|
|
400
|
+
if (obj.schemaVersion < 2) {
|
|
401
|
+
throw new Error("receipt manifest requires schemaVersion >= 2");
|
|
402
|
+
}
|
|
403
|
+
_validateManifestShape(obj.manifest);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Optional git provenance block: only meaningful at schemaVersion >= 3. A v1/v2 receipt that
|
|
407
|
+
// smuggles in a git block is rejected (those versions are defined to have none), so the version is
|
|
408
|
+
// never a lie. The block is an UNTRUSTED hint — validating its SHAPE here only keeps a written
|
|
409
|
+
// receipt internally consistent; it never elevates the block to an authoritative claim.
|
|
410
|
+
if (obj.git !== undefined && obj.git !== null) {
|
|
411
|
+
if (obj.schemaVersion < 3) {
|
|
412
|
+
throw new Error("receipt git block requires schemaVersion >= 3");
|
|
413
|
+
}
|
|
414
|
+
_validateGitShape(obj.git);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Optional lineage `parent` (B-10.1): only meaningful at schemaVersion >= 4, and ONLY on a CLAIM
|
|
418
|
+
// receipt (the anchor receipt records its lineage edge on-chain at anchor time and carries none on
|
|
419
|
+
// disk). A v1/v2/v3 receipt that smuggles in a parent is rejected (those versions are defined to
|
|
420
|
+
// have none), so the version is never a lie. The edge is an UNTRUSTED claim of a predecessor — this
|
|
421
|
+
// SHAPE check only keeps the receipt internally consistent; the authoritative edge is on-chain.
|
|
422
|
+
if (obj.parent !== undefined && obj.parent !== null) {
|
|
423
|
+
if (isAnchor) {
|
|
424
|
+
throw new Error("an anchor receipt must not carry a parent field (the edge is on-chain only)");
|
|
425
|
+
}
|
|
426
|
+
if (obj.schemaVersion < 4) {
|
|
427
|
+
throw new Error("receipt parent requires schemaVersion >= 4");
|
|
428
|
+
}
|
|
429
|
+
if (typeof obj.parent !== "string" || !HEX32_RE.test(obj.parent)) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`receipt parent must be a 0x 32-byte hex contentHash when present, got: ${String(obj.parent)}`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (/^0x0{64}$/i.test(obj.parent)) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
"receipt parent must not be the all-zero hash (a lineage root omits parent entirely)"
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (
|
|
440
|
+
typeof obj.contentHash === "string" &&
|
|
441
|
+
obj.parent.toLowerCase() === obj.contentHash.toLowerCase()
|
|
442
|
+
) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
"receipt parent must not equal contentHash (self-reference; the contract rejects it as SelfParent)"
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return obj;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Validate a parsed git-provenance block's shape (without mutating). Throws on the first problem. */
|
|
453
|
+
function _validateGitShape(git) {
|
|
454
|
+
if (!git || typeof git !== "object" || Array.isArray(git)) {
|
|
455
|
+
throw new Error("receipt git block must be an object { commit, scope }");
|
|
456
|
+
}
|
|
457
|
+
if (typeof git.commit !== "string" || !GIT_OID_RE.test(git.commit)) {
|
|
458
|
+
throw new Error(`receipt git.commit must be a 40-hex commit oid, got: ${String(git.commit)}`);
|
|
459
|
+
}
|
|
460
|
+
if (typeof git.scope !== "string" || git.scope.length === 0) {
|
|
461
|
+
throw new Error(`receipt git.scope must be a non-empty string, got: ${String(git.scope)}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Validate a parsed manifest's shape (without re-sorting). Throws on the first malformed entry. */
|
|
466
|
+
function _validateManifestShape(manifest) {
|
|
467
|
+
if (!Array.isArray(manifest)) {
|
|
468
|
+
throw new Error("receipt manifest must be an array");
|
|
469
|
+
}
|
|
470
|
+
manifest.forEach((entry, i) => {
|
|
471
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
472
|
+
throw new Error(`receipt manifest entry ${i} must be an object`);
|
|
473
|
+
}
|
|
474
|
+
if (typeof entry.path !== "string" || entry.path.length === 0) {
|
|
475
|
+
throw new Error(`receipt manifest entry ${i} must have a non-empty string path`);
|
|
476
|
+
}
|
|
477
|
+
if (typeof entry.contentHash !== "string" || !HEX32_RE.test(entry.contentHash)) {
|
|
478
|
+
throw new Error(`receipt manifest entry ${i} (${entry.path}) contentHash must be a 0x 32-byte hex string`);
|
|
479
|
+
}
|
|
480
|
+
if (typeof entry.leaf !== "string" || !HEX32_RE.test(entry.leaf)) {
|
|
481
|
+
throw new Error(`receipt manifest entry ${i} (${entry.path}) leaf must be a 0x 32-byte hex string`);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Validate and write a receipt object to `path` as pretty JSON. Pure-ish: the only side effect is
|
|
488
|
+
* the file write. Throws (before writing) if the object is not a valid receipt, so a corrupt object
|
|
489
|
+
* never lands on disk.
|
|
490
|
+
* @param {object} obj a receipt (typically from buildReceipt / buildAnchorReceipt)
|
|
491
|
+
* @param {string} path destination file path
|
|
492
|
+
* @returns {object} the validated object that was written
|
|
493
|
+
*/
|
|
494
|
+
function writeReceipt(obj, path) {
|
|
495
|
+
if (!path || typeof path !== "string") {
|
|
496
|
+
throw new Error("writeReceipt requires a destination path");
|
|
497
|
+
}
|
|
498
|
+
const valid = _validate(obj);
|
|
499
|
+
// Trailing newline so the file is POSIX-clean and diff-friendly.
|
|
500
|
+
fs.writeFileSync(path, JSON.stringify(valid, null, 2) + "\n");
|
|
501
|
+
return valid;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Read, JSON-parse, and strictly validate a receipt from `path`. Throws a clear error if the file is
|
|
506
|
+
* missing, not JSON, or fails validation — it NEVER returns a partial/corrupt receipt. Accepts both
|
|
507
|
+
* claim and anchor receipts at any SUPPORTED_SCHEMA_VERSIONS (older artifacts keep working).
|
|
508
|
+
* @param {string} path
|
|
509
|
+
* @returns {object} the validated receipt
|
|
510
|
+
*/
|
|
511
|
+
function readReceipt(path) {
|
|
512
|
+
if (!path || typeof path !== "string") {
|
|
513
|
+
throw new Error("readReceipt requires a path");
|
|
514
|
+
}
|
|
515
|
+
let raw;
|
|
516
|
+
try {
|
|
517
|
+
raw = fs.readFileSync(path, "utf8");
|
|
518
|
+
} catch (e) {
|
|
519
|
+
throw new Error(`cannot read receipt at ${path}: ${e.message}`);
|
|
520
|
+
}
|
|
521
|
+
let parsed;
|
|
522
|
+
try {
|
|
523
|
+
parsed = JSON.parse(raw);
|
|
524
|
+
} catch (e) {
|
|
525
|
+
throw new Error(`receipt at ${path} is not valid JSON: ${e.message}`);
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
return _validate(parsed);
|
|
529
|
+
} catch (e) {
|
|
530
|
+
throw new Error(`receipt at ${path} is invalid: ${e.message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Compute a precise file-level diff between a receipt's recorded manifest and a freshly-recomputed
|
|
536
|
+
* set of per-file leaves for the SAME directory. This LOCALIZES which file diverged — it does NOT,
|
|
537
|
+
* on its own, decide MATCH/MISMATCH (the authoritative verdict is re-deriving the root and comparing
|
|
538
|
+
* it to the on-chain record; see runVerify). The receipt manifest is an UNTRUSTED hint.
|
|
539
|
+
*
|
|
540
|
+
* @param {Array<{path:string,contentHash:string,leaf:string}>} recordedManifest the receipt's manifest
|
|
541
|
+
* @param {Array<{path:string,contentHash:string,leaf:string}>} currentLeaves `hashDir().leaves` now
|
|
542
|
+
* @returns {{
|
|
543
|
+
* added: Array<{path:string,contentHash:string}>, // present now, not in receipt
|
|
544
|
+
* removed: Array<{path:string,contentHash:string}>, // in receipt, gone now
|
|
545
|
+
* changed: Array<{path:string,oldContentHash:string,newContentHash:string}>, // same path, different content
|
|
546
|
+
* unchanged: Array<{path:string,contentHash:string}>,
|
|
547
|
+
* identical: boolean // no add/remove/change at all
|
|
548
|
+
* }}
|
|
549
|
+
*/
|
|
550
|
+
function diffManifest(recordedManifest, currentLeaves) {
|
|
551
|
+
const recorded = new Map();
|
|
552
|
+
for (const e of recordedManifest || []) recorded.set(e.path, e);
|
|
553
|
+
const current = new Map();
|
|
554
|
+
for (const e of currentLeaves || []) current.set(e.path, e);
|
|
555
|
+
|
|
556
|
+
const added = [];
|
|
557
|
+
const removed = [];
|
|
558
|
+
const changed = [];
|
|
559
|
+
const unchanged = [];
|
|
560
|
+
|
|
561
|
+
for (const [p, cur] of current) {
|
|
562
|
+
const rec = recorded.get(p);
|
|
563
|
+
if (!rec) {
|
|
564
|
+
added.push({ path: p, contentHash: cur.contentHash });
|
|
565
|
+
} else if (rec.leaf.toLowerCase() !== cur.leaf.toLowerCase()) {
|
|
566
|
+
// Path bound into the leaf is identical (same key), so a leaf difference is a content change.
|
|
567
|
+
changed.push({ path: p, oldContentHash: rec.contentHash, newContentHash: cur.contentHash });
|
|
568
|
+
} else {
|
|
569
|
+
unchanged.push({ path: p, contentHash: cur.contentHash });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const [p, rec] of recorded) {
|
|
573
|
+
if (!current.has(p)) removed.push({ path: p, contentHash: rec.contentHash });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const sortByPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
577
|
+
added.sort(sortByPath);
|
|
578
|
+
removed.sort(sortByPath);
|
|
579
|
+
changed.sort(sortByPath);
|
|
580
|
+
unchanged.sort(sortByPath);
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
added,
|
|
584
|
+
removed,
|
|
585
|
+
changed,
|
|
586
|
+
unchanged,
|
|
587
|
+
identical: added.length === 0 && removed.length === 0 && changed.length === 0,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Default receipt FILE NAME for a contentHash: `<first 16 hex chars>.vhclaim.json`. Short enough to
|
|
593
|
+
* be tidy, long enough to be collision-resistant for a human's working set.
|
|
594
|
+
*
|
|
595
|
+
* PURE HELPER — no side effects, no `process.cwd()` lookup. The returned path is RELATIVE
|
|
596
|
+
* (`./<prefix>.vhclaim.json`) and the CALLER is responsible for choosing a SAFE BASE to resolve it
|
|
597
|
+
* against before writing. This helper deliberately does NOT decide where the file lands: a claim
|
|
598
|
+
* receipt holds the SECRET `salt`, so silently dropping it into whatever the current working
|
|
599
|
+
* directory happens to be is a footgun. Callers must resolve against an explicit, user-opted-in base
|
|
600
|
+
* (e.g. `--receipt-dir`, or cwd when the user has been told the exact resolved path) — see
|
|
601
|
+
* `runCommit` and docs/RECEIPTS.md. `runClaim` (the one-shot convenience) writes NOTHING unless an
|
|
602
|
+
* explicit `receiptPath` is passed.
|
|
603
|
+
* @param {string} contentHash 0x 32-byte digest
|
|
604
|
+
* @returns {string} a RELATIVE file name; resolve it against a caller-chosen safe base before writing
|
|
605
|
+
*/
|
|
606
|
+
function defaultReceiptPath(contentHash) {
|
|
607
|
+
if (typeof contentHash !== "string" || !HEX32_RE.test(contentHash)) {
|
|
608
|
+
throw new Error(`defaultReceiptPath needs a 0x 32-byte contentHash, got: ${String(contentHash)}`);
|
|
609
|
+
}
|
|
610
|
+
const prefix = contentHash.slice(2, 2 + 16); // 16 hex chars = 8 bytes
|
|
611
|
+
return `./${prefix}.vhclaim.json`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
module.exports = {
|
|
615
|
+
SCHEMA_VERSION,
|
|
616
|
+
SUPPORTED_SCHEMA_VERSIONS,
|
|
617
|
+
RECEIPT_KIND,
|
|
618
|
+
CLAIM_RECEIPT_KIND,
|
|
619
|
+
ANCHOR_RECEIPT_KIND,
|
|
620
|
+
buildReceipt,
|
|
621
|
+
buildAnchorReceipt,
|
|
622
|
+
writeReceipt,
|
|
623
|
+
readReceipt,
|
|
624
|
+
diffManifest,
|
|
625
|
+
defaultReceiptPath,
|
|
626
|
+
// Exported for unit tests that exercise validation/manifest normalization directly.
|
|
627
|
+
_validate,
|
|
628
|
+
_normManifest,
|
|
629
|
+
_normGit,
|
|
630
|
+
_normParent,
|
|
631
|
+
};
|