@mikeargento/bitgraph 1.3.0 → 1.5.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/src/fuse-cli.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * bitgraph-fuse: the internal harness (spec 9.2), as a command rather than a
6
6
  * page. Exercises Forms A, B and C end to end through fuse(), writes the
7
7
  * Frame (and the fused bytes when they must be kept), and prints the bounded
8
- * copy of spec 9.3. `check` runs verifyFuse over a Frame (or bare proof)
9
- * and a file, so both verification paths can be exercised from a shell.
8
+ * copy of spec 9.3. `set` runs fuseSet() over N files under one slot and
9
+ * writes the proof beside the manifest bytes. `check` runs verifyFuse over a
10
+ * Frame (or bare proof) and a file, or verifyFuseMember when the proof is a
11
+ * set proof and the file is not its manifest, so every verification path can
12
+ * be exercised from a shell.
10
13
  *
11
14
  * Not a product surface. The site has no /fuse page: a page would make the
12
15
  * website build depend on packages that are not published, which is a deploy
@@ -18,9 +21,9 @@
18
21
  import { readFile, writeFile, mkdir } from "node:fs/promises";
19
22
  import { basename, extname, join, resolve } from "node:path";
20
23
  import { sha256 } from "@noble/hashes/sha256";
21
- import { parseFrame, verifyFuse, bytesToBase64 } from "@mikeargento/bitgraph-verify";
22
- import type { BitGraphProof, PlacementId } from "@mikeargento/bitgraph-verify";
23
- import { fuse, builderFor, FuseError } from "./fuse.js";
24
+ import { parseFrame, verifyFuse, verifyFuseMember, bytesToBase64 } from "@mikeargento/bitgraph-verify";
25
+ import type { BitGraphProof, PlacementId, FuseVerifyResult, FuseMemberResult } from "@mikeargento/bitgraph-verify";
26
+ import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError } from "./fuse.js";
24
27
  import type { FuseTransport } from "./fuse.js";
25
28
 
26
29
  const USAGE = `bitgraph-fuse: BitGraph producer harness (profile bitgraph-fuse/1, working name)
@@ -29,12 +32,14 @@ const USAGE = `bitgraph-fuse: BitGraph producer harness (profile bitgraph-fuse/1
29
32
  Form A or B over an existing file. The file is never modified.
30
33
  bitgraph-fuse produce [--origin <file>] [options]
31
34
  Form C: a canonical payload naming an optional source.
32
- bitgraph-fuse check <frame-or-proof.json> <file> [--max-positions N]
33
- Verify a Frame (or bare proof) against the fused bytes or the original.
35
+ bitgraph-fuse set <file>... [options]
36
+ N files under one slot; the committed artifact is the set manifest. The files are never modified.
37
+ bitgraph-fuse check <frame-or-proof.json> <file> [--manifest <set.manifest.json>] [--max-positions N]
38
+ Verify a Frame (or bare proof) against the fused bytes or the original; a set proof against a member or its original.
34
39
 
35
- Options for fuse and produce:
36
- --out <dir> where to write the Frame and fused bytes (default: .)
37
- --keep also write the fused bytes for byte-exact placements
40
+ Options for fuse, produce and set:
41
+ --out <dir> where to write the Frame (for set: set.proof.json and set.manifest.json) and fused bytes (default: .)
42
+ --keep also write the fused bytes for byte-exact placements (set: inputs need distinct names)
38
43
  --base-url <url> commit surface (default https://bitgraph.ing)
39
44
  --allocate-path <p> default /api/fuse/allocate (parent-direct: /allocate-slot)
40
45
  --commit-path <p> default /api/fuse/commit (parent-direct: /commit)
@@ -78,6 +83,10 @@ function transportFrom(flags: Map<string, string | true>): FuseTransport {
78
83
  return t;
79
84
  }
80
85
 
86
+ function outDirOf(args: Args): string {
87
+ return resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
88
+ }
89
+
81
90
  async function writeOutputs(outDir: string, label: string, frame: unknown, fusedName: string | null, fused: Uint8Array | undefined): Promise<string[]> {
82
91
  await mkdir(outDir, { recursive: true });
83
92
  const written: string[] = [];
@@ -102,8 +111,7 @@ async function runFuse(args: Args): Promise<number> {
102
111
  const fusedName = `${label.slice(0, label.length - extname(label).length)}.fused${ext}`;
103
112
  const keep = args.flags.get("keep") === true;
104
113
  const r = await fuse(builderFor(placement as PlacementId, original), { placement: placement as PlacementId, original, fusedFile: fusedName, keepFused: keep, transport: transportFrom(args.flags) });
105
- const outDir = resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
106
- const written = await writeOutputs(outDir, label, r.frame, keep ? fusedName : null, r.fusedBytes);
114
+ const written = await writeOutputs(outDirOf(args), label, r.frame, keep ? fusedName : null, r.fusedBytes);
107
115
  report(r.proof, r.verification.category, r.recovered, true);
108
116
  process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
109
117
  if (!keep) process.stdout.write(`\nThe fused bytes were not kept (${placement} is byte-exact: any verifier rebuilds them from the original and the proof). Pass --keep to write them.\n`);
@@ -114,13 +122,57 @@ async function runProduce(args: Args): Promise<number> {
114
122
  const originPath = args.flags.get("origin");
115
123
  const originDigest = typeof originPath === "string" ? sha256(new Uint8Array(await readFile(resolve(originPath)))) : undefined;
116
124
  const r = await fuse(builderFor("produced/1"), { placement: "produced/1", ...(originDigest !== undefined ? { originDigest } : {}), fusedFile: "produced.json", transport: transportFrom(args.flags) });
117
- const outDir = resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
118
- const written = await writeOutputs(outDir, "produced", r.frame, "produced.json", r.fusedBytes);
125
+ const written = await writeOutputs(outDirOf(args), "produced", r.frame, "produced.json", r.fusedBytes);
119
126
  report(r.proof, r.verification.category, r.recovered, originDigest !== undefined);
120
127
  process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
121
128
  return 0;
122
129
  }
123
130
 
131
+ /** N files under one slot. Writes the proof and the manifest bytes exactly (they are the committed artifact, so `check` can hash them as it). */
132
+ async function runSet(args: Args): Promise<number> {
133
+ if (args.positional.length === 0) { process.stderr.write(USAGE); return 64; }
134
+ const keep = args.flags.get("keep") === true;
135
+ const members = [];
136
+ for (const file of args.positional) {
137
+ const original = new Uint8Array(await readFile(resolve(file)));
138
+ members.push({ original, placement: placementForBytes(original), name: sanitize(basename(file)) });
139
+ }
140
+ // Under --keep every member's fused bytes go to a file named from its own
141
+ // name and placement; two inputs that would share that name are refused
142
+ // here, before any allocation, rather than one silently overwriting the other.
143
+ if (keep) {
144
+ const seen = new Map<string, number>();
145
+ for (const [i, m] of members.entries()) {
146
+ const { fusedName } = fusedNamesFor(m.name, m.placement);
147
+ const j = seen.get(fusedName);
148
+ if (j !== undefined) { process.stderr.write(`set --keep: members ${j} and ${i} would both be written as ${fusedName}; give the files distinct names\n`); return 64; }
149
+ seen.set(fusedName, i);
150
+ }
151
+ }
152
+ // The harness reads every member with the full verifier; the site binds by digest.
153
+ const r = await fuseSet(members, { keepFused: keep, verifyMembers: true, transport: transportFrom(args.flags) });
154
+ const outDir = outDirOf(args);
155
+ await mkdir(outDir, { recursive: true });
156
+ const written: string[] = [];
157
+ const proofPath = join(outDir, "set.proof.json");
158
+ await writeFile(proofPath, JSON.stringify(r.proof, null, 2) + "\n");
159
+ written.push(proofPath);
160
+ const manifestPath = join(outDir, "set.manifest.json");
161
+ await writeFile(manifestPath, r.manifestBytes);
162
+ written.push(manifestPath);
163
+ for (const m of r.members) {
164
+ if (m.fusedBytes === undefined || m.fusedName === null) continue;
165
+ const p = join(outDir, m.fusedName);
166
+ await writeFile(p, m.fusedBytes);
167
+ written.push(p);
168
+ }
169
+ report(r.proof, r.verification.category, r.recovered, false);
170
+ for (const m of r.members) process.stdout.write(`member ${m.index} row ${m.manifestIndex} ${m.placement} ${m.verification?.category ?? "listed"} ${m.fusedName ?? ""}\n`);
171
+ if (!r.manifestEchoed) process.stdout.write(`note the proof does not carry the manifest; keep set.manifest.json beside it\n`);
172
+ process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
173
+ return 0;
174
+ }
175
+
124
176
  function report(proof: BitGraphProof, category: string, recovered: boolean, hasOrigin: boolean): void {
125
177
  const c = proof.commit;
126
178
  if (hasOrigin) process.stdout.write(COPY_ORIGINAL + "\n\n");
@@ -134,6 +186,22 @@ function report(proof: BitGraphProof, category: string, recovered: boolean, hasO
134
186
  if (recovered) process.stdout.write(`note the commit response was lost; the proof was read back by digest and matched on the held slot\n`);
135
187
  }
136
188
 
189
+ /** The verdict lines shared by both verifiers; the `set` line appears only for a member verdict. */
190
+ function printVerdict(r: FuseVerifyResult | FuseMemberResult): void {
191
+ process.stdout.write(`category ${r.category}\n`);
192
+ process.stdout.write(`proof ${r.proof.valid ? "valid" : `invalid: ${r.proof.reason ?? ""}`}\n`);
193
+ process.stdout.write(`file digest ${r.fileDigestB64}\n`);
194
+ process.stdout.write(`artifact ${r.artifactDigestB64}\n`);
195
+ if (r.originDigestB64) process.stdout.write(`origin ${r.originDigestB64}\n`);
196
+ if (r.placement) process.stdout.write(`placement ${r.placement}\n`);
197
+ if (r.span) process.stdout.write(`span slot ${r.span.slotCounter} to commit ${r.span.commitCounter} (${r.span.positions} positions)\n`);
198
+ if (r.policy.maxPositions !== null) process.stdout.write(`span policy ${r.policy.spanExceeded ? "EXCEEDED" : "within"} ${r.policy.maxPositions} positions\n`);
199
+ if ("set" in r && r.set?.member) process.stdout.write(`set member ${r.set.member.index + 1} of ${r.set.memberCount}\n`);
200
+ if (r.reason) process.stdout.write(`reason ${r.reason}\n`);
201
+ for (const s of r.statements) process.stdout.write(`\n${s}\n`);
202
+ process.stdout.write(`\nfloor computed by the Player from anchors in a bundle (bitgraph-play check); not available here\n`);
203
+ }
204
+
137
205
  async function runCheck(args: Args): Promise<number> {
138
206
  const [frameArg, fileArg] = args.positional;
139
207
  if (frameArg === undefined || fileArg === undefined) { process.stderr.write(USAGE); return 64; }
@@ -148,18 +216,20 @@ async function runCheck(args: Args): Promise<number> {
148
216
  }
149
217
  const bytes = new Uint8Array(await readFile(resolve(fileArg)));
150
218
  const max = args.flags.get("max-positions");
151
- const r = await verifyFuse({ proof, bytes, frame, ...(typeof max === "string" ? { maxPositions: BigInt(max) } : {}) });
152
- process.stdout.write(`category ${r.category}\n`);
153
- process.stdout.write(`proof ${r.proof.valid ? "valid" : `invalid: ${r.proof.reason ?? ""}`}\n`);
154
- process.stdout.write(`file digest ${r.fileDigestB64}\n`);
155
- process.stdout.write(`artifact ${r.artifactDigestB64}\n`);
156
- if (r.originDigestB64) process.stdout.write(`origin ${r.originDigestB64}\n`);
157
- if (r.placement) process.stdout.write(`placement ${r.placement}\n`);
158
- if (r.span) process.stdout.write(`span slot ${r.span.slotCounter} to commit ${r.span.commitCounter} (${r.span.positions} positions)\n`);
159
- if (r.policy.maxPositions !== null) process.stdout.write(`span policy ${r.policy.spanExceeded ? "EXCEEDED" : "within"} ${r.policy.maxPositions} positions\n`);
160
- if (r.reason) process.stdout.write(`reason ${r.reason}\n`);
161
- for (const s of r.statements) process.stdout.write(`\n${s}\n`);
162
- process.stdout.write(`\nfloor computed by the Player from anchors in a bundle (bitgraph-play check); not available here\n`);
219
+ const policy = typeof max === "string" ? { maxPositions: BigInt(max) } : {};
220
+ // A set proof and a file that is not its manifest: the member verifier answers.
221
+ const a = proof.attribution;
222
+ if (a?.name === "bitgraph-fuse/1" && a.title === "set/1" && bytesToBase64(sha256(bytes)) !== proof.artifact.digestB64) {
223
+ const manifestArg = args.flags.get("manifest");
224
+ const manifest = typeof manifestArg === "string" ? new Uint8Array(await readFile(resolve(manifestArg))) : undefined;
225
+ const r = await verifyFuseMember({ proof, bytes, ...(manifest !== undefined ? { manifest } : {}), ...policy });
226
+ printVerdict(r);
227
+ if (r.category === "SET_MEMBER_DIRECT" || r.category === "SET_MEMBER_FROM_ORIGIN") return r.policy.spanExceeded ? 1 : 0;
228
+ if (r.category === "UNDETERMINED_PLACEMENT" || r.category === "NO_MATCH") return 2;
229
+ return 1;
230
+ }
231
+ const r = await verifyFuse({ proof, bytes, frame, ...policy });
232
+ printVerdict(r);
163
233
  if (r.category === "RECORDED" || r.category === "FUSED_DIRECT" || r.category === "FUSED_FROM_ORIGIN") return r.policy.spanExceeded ? 1 : 0;
164
234
  if (r.category === "UNDETERMINED_PLACEMENT" || r.category === "NO_MATCH") return 2;
165
235
  return 1;
@@ -171,12 +241,13 @@ async function main(): Promise<number> {
171
241
  switch (args.command) {
172
242
  case "fuse": return await runFuse(args);
173
243
  case "produce": return await runProduce(args);
244
+ case "set": return await runSet(args);
174
245
  case "check": return await runCheck(args);
175
246
  default: process.stderr.write(USAGE); return 64;
176
247
  }
177
248
  } catch (err) {
178
249
  if (err instanceof FuseError) {
179
- process.stderr.write(`no fused proof was completed (${err.code}${err.status !== null ? `, ${err.status}` : ""}): ${err.message}\n`);
250
+ process.stderr.write(`no fused proof was completed (${err.code}${err.status !== null ? `, ${err.status}` : ""}${err.member !== null ? `, member ${err.member}` : ""}): ${err.message}\n`);
180
251
  return err.code === "tee-restarting" ? 2 : 1;
181
252
  }
182
253
  process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);