@mikeargento/bitgraph 1.5.0 → 1.7.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.
@@ -32,8 +32,8 @@ import {
32
32
  import type { BitGraphProof, SlotAllocation, Attribution, SetMember, SetManifest } from "@mikeargento/bitgraph-verify";
33
33
  import { makeKey, signBody, b64, utf8 } from "./audit-fixtures.js";
34
34
  import type { ManualKey } from "./audit-fixtures.js";
35
- import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError, MAX_SET_MEMBERS, digest } from "../fuse.js";
36
- import type { FuseSetProgress } from "../fuse.js";
35
+ import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError, MAX_SET_MEMBERS, digest, trailerBytesFor } from "../fuse.js";
36
+ import type { FuseSetProgress, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput } from "../fuse.js";
37
37
  import type { FuseSetMember } from "../fuse.js";
38
38
 
39
39
  const FIX = fileURLToPath(new URL("../../src/__tests__/fuse-fixtures/", import.meta.url));
@@ -48,7 +48,7 @@ const unrelated = utf8("a file nobody recorded\n");
48
48
  // The phase-1 fixtures fuse original.txt as trailer/1 and image.png as
49
49
  // container/1, so these members declare those placements; the by-bytes
50
50
  // default (placementForBytes) is asserted on its own in test 3.
51
- const two = (): FuseSetMember[] => [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
51
+ const two = (): FuseSetBytesMember[] => [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
52
52
 
53
53
  // ---------------------------------------------------------------------------
54
54
  // Local signer: slots and proofs minted from a received commit body
@@ -160,9 +160,9 @@ const toUrlSafe = (s: string) => s.replace(/\+/g, "-").replace(/\//g, "_").repla
160
160
  interface Oracle { commitment: Uint8Array; fused: Uint8Array[]; rows: SetMember[]; manifest: Uint8Array; digestB64: string; sorted: SetMember[] }
161
161
 
162
162
  /** The commitment from the slot, each member built by its placement, the canonical manifest and its digest. */
163
- function oracle(slot: SlotAllocation, members: readonly FuseSetMember[]): Oracle {
163
+ function oracle(slot: SlotAllocation, members: readonly FuseSetBytesMember[]): Oracle {
164
164
  const commitment = computeSlotCommitment(slot);
165
- const placementOf = (m: FuseSetMember) => m.placement ?? placementForBytes(m.original);
165
+ const placementOf = (m: FuseSetBytesMember) => m.placement ?? placementForBytes(m.original);
166
166
  const fused = members.map((m) => getPlacement(placementOf(m))!.build({ original: m.original, commitment }));
167
167
  const rows = members.map((m, i) => ({ artifact: sha256(fused[i]!), origin: sha256(m.original), placement: placementOf(m) }));
168
168
  const manifest = buildSetManifest(commitment, rows);
@@ -259,7 +259,7 @@ describe("fuseSet(): one slot, N files, the manifest as the artifact", () => {
259
259
  // The by-bytes default: PNG is trailer-safe, plain text goes into a container.
260
260
  const d = await fuseSet([{ original: png }, { original }], { transport: honest(key, slot).transport });
261
261
  assert.deepEqual(d.members.map((m) => m.placement), [placementForBytes(png), placementForBytes(original)]);
262
- assert.deepEqual(d.members.map((m) => m.placement), ["trailer/1", "container/1"]);
262
+ assert.deepEqual(d.members.map((m) => m.placement), ["trailer/1", "container/2"]);
263
263
  });
264
264
 
265
265
  test("4. each original rebuilds its member (SET_MEMBER_FROM_ORIGIN); with the echo stripped and no explicit bytes nothing is bound", async () => {
@@ -336,7 +336,7 @@ describe("fuseSet(): a bad member burns the slot and commits nothing", () => {
336
336
  test("9. bytes that carry the commitment but embed another member's origin are builder-failed naming the member and the origin", async () => {
337
337
  const { calls, transport } = honest(key, slot);
338
338
  // Two trailer/1 members; member 1's builder hands back member 0's fused bytes, which carry c but embed member 0's original.
339
- const members: FuseSetMember[] = [{ original, placement: "trailer/1" }, { original: note, placement: "trailer/1" }];
339
+ const members: FuseSetBytesMember[] = [{ original, placement: "trailer/1" }, { original: note, placement: "trailer/1" }];
340
340
  members[1]!.builder = ({ commitment }) => getPlacement("trailer/1")!.build({ original, commitment });
341
341
  await assert.rejects(fuseSet(members, { transport }), (e: FuseError) => e.code === "builder-failed" && /member 1/.test(e.message) && /origin/.test(e.message) && e.member === 1);
342
342
  assert.equal(commits(calls).length, 0);
@@ -345,14 +345,14 @@ describe("fuseSet(): a bad member burns the slot and commits nothing", () => {
345
345
 
346
346
  test("9b. container/1: bytes that declare the member's origin digest but carry other bytes are builder-failed naming the member; no commit", async () => {
347
347
  const { calls, transport } = honest(key, slot);
348
- const members: FuseSetMember[] = [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
348
+ const members: FuseSetBytesMember[] = [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
349
349
  // The payload names png's digest (so the declared origin matches) while the tar carries other bytes.
350
350
  members[1]!.builder = ({ commitment, originDigest }) => getPlacement("container/1")!.build({ original: utf8("not the member's original\n"), originDigest: originDigest!, commitment });
351
351
  await assert.rejects(fuseSet(members, { transport }), (e: FuseError) => e.code === "builder-failed" && /^member 1/.test(e.message) && /origin/.test(e.message) && e.member === 1);
352
352
  assert.equal(commits(calls).length, 0, "nothing was committed");
353
353
  assert.equal(allocates(calls).length, 1);
354
354
  // The mirror: a payload declaring another digest over the member's own bytes is refused the same way.
355
- const declared: FuseSetMember[] = [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
355
+ const declared: FuseSetBytesMember[] = [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
356
356
  declared[1]!.builder = ({ commitment }) => getPlacement("container/1")!.build({ original: png, originDigest: sha256(unrelated), commitment });
357
357
  const b = honest(key, slot);
358
358
  await assert.rejects(fuseSet(declared, { transport: b.transport }), (e: FuseError) => e.code === "builder-failed" && e.member === 1);
@@ -698,3 +698,111 @@ describe("fuseSet(): hashing and progress", () => {
698
698
  assert.equal(calls, 8, "every report was attempted");
699
699
  });
700
700
  });
701
+
702
+ describe("fuseSet(): loaded and hashed members", () => {
703
+ /** The trailer/1 member finished from a hasher state, as a scanner does: hash the original once, keep the state, add the trailer later. */
704
+ const savedState = (bytes: Uint8Array) => { const h = sha256.create(); h.update(bytes); return h; };
705
+ const finish = (h: ReturnType<typeof sha256.create>, trailer: Uint8Array) => { const c = h.clone(); c.update(trailer); return c.digest(); };
706
+
707
+ test("30. trailerBytesFor is the placement's own suffix: original followed by it hashes to the trailer/1 build, and a saved state finished with it agrees", () => {
708
+ const commitment = computeSlotCommitment(slot);
709
+ const t = trailerBytesFor(commitment);
710
+ assert.equal(t.length, 48);
711
+ assert.deepEqual(t.subarray(0, 8), utf8("BGFUSE01"));
712
+ assert.deepEqual(t.subarray(8, 16), new Uint8Array(8));
713
+ assert.deepEqual(t.subarray(16), commitment);
714
+ for (const o of [original, png, note, new Uint8Array(0), new Uint8Array(55).fill(1), new Uint8Array(64).fill(2), new Uint8Array(1_000_003).fill(3)]) {
715
+ const built = getPlacement("trailer/1")!.build({ original: o, commitment });
716
+ assert.deepEqual(built.subarray(o.length), t, `suffix for ${o.length} bytes`);
717
+ assert.deepEqual(finish(savedState(o), t), sha256(built), `finished state for ${o.length} bytes`);
718
+ }
719
+ assert.throws(() => trailerBytesFor(new Uint8Array(31)), (e: FuseError) => e.code === "bad-input");
720
+ });
721
+
722
+ test("31. a loaded member is read once, after the slot is held, checked against its named digest, and released unless kept", async () => {
723
+ const { calls, transport } = honest(key, slot);
724
+ const order: string[] = [];
725
+ const wrapped = { ...transport, fetch: async (url: string, init?: RequestInit) => { order.push(new URL(url).pathname); return (transport.fetch as (u: string, i?: RequestInit) => Promise<Response>)(url, init); } } as unknown as typeof transport;
726
+ let loads = 0;
727
+ const loaded: FuseSetLoadedMember = { load: async () => { loads++; order.push("load"); return original; }, originDigest: sha256(original), placement: "trailer/1", name: "photo.jpg" };
728
+ const r = await fuseSet([loaded, { original: png, placement: "container/1" }], { transport: wrapped });
729
+ assert.equal(loads, 1);
730
+ assert.ok(order.indexOf("load") > order.indexOf("/api/fuse/allocate"), "read after the slot is held");
731
+ assert.ok(order.indexOf("load") < order.indexOf("/api/fuse/commit"), "read before the commit");
732
+ const o = oracle(slot, [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }]);
733
+ assert.equal(r.members[0]!.artifactDigestB64, bytesToBase64(o.rows[0]!.artifact));
734
+ assert.equal(r.members[0]!.originDigestB64, bytesToBase64(sha256(original)));
735
+ assert.equal(r.members[0]!.fusedName, fusedNamesFor("photo.jpg", "trailer/1").fusedName);
736
+ assert.ok(!("fusedBytes" in r.members[0]!));
737
+ assert.equal(commits(calls).length, 1);
738
+ const kept = await fuseSet([{ ...loaded }], { transport: honest(key, slot).transport, keepFused: true });
739
+ assert.deepEqual(kept.members[0]!.fusedBytes, getPlacement("trailer/1")!.build({ original, commitment: computeSlotCommitment(slot) }));
740
+ // A wrong named digest burns the slot and commits nothing.
741
+ const bad = honest(key, slot);
742
+ await assert.rejects(fuseSet([{ load: () => original, originDigest: sha256(unrelated), placement: "trailer/1" }], { transport: bad.transport }), (e: FuseError) => e.code === "bad-input" && /^member 0: originDigest is not the SHA-256 of the loaded bytes/.test(e.message) && e.member === 0);
743
+ assert.equal(commits(bad.calls).length, 0);
744
+ assert.equal(allocates(bad.calls).length, 1);
745
+ // A loader that throws, or returns something other than bytes, is load-failed naming the member.
746
+ const thrown = honest(key, slot);
747
+ await assert.rejects(fuseSet([{ load: () => { throw new Error("gone"); }, originDigest: sha256(original), placement: "trailer/1" }], { transport: thrown.transport }), (e: FuseError) => e.code === "load-failed" && /^member 0: load threw: gone/.test(e.message) && e.member === 0);
748
+ assert.equal(commits(thrown.calls).length, 0);
749
+ await assert.rejects(fuseSet([{ original, placement: "trailer/1" }, { load: (() => "text") as never, originDigest: sha256(original), placement: "container/1" }], { transport: honest(key, slot).transport }), (e: FuseError) => e.code === "load-failed" && /^member 1: load must return a Uint8Array/.test(e.message) && e.member === 1);
750
+ // Without a placement or a digest nothing is requested.
751
+ const quiet = honest(key, slot);
752
+ await assert.rejects(fuseSet([{ load: () => original, originDigest: sha256(original) } as never], { transport: quiet.transport }), (e: FuseError) => e.code === "bad-input" && /^member 0: a loaded member names its placement/.test(e.message));
753
+ await assert.rejects(fuseSet([{ load: () => original, placement: "trailer/1" } as never], { transport: quiet.transport }), (e: FuseError) => e.code === "bad-input" && /^member 0: a loaded member names its originDigest/.test(e.message));
754
+ assert.equal(quiet.calls.length, 0);
755
+ });
756
+
757
+ test("32. a hashed member answers its fused digest for the held commitment and never shows its bytes; the row is what the real verifier finds", async () => {
758
+ const { calls, transport } = honest(key, slot);
759
+ const inputs: FusedDigestInput[] = [];
760
+ const state = savedState(original);
761
+ const hashed: FuseSetHashedMember = {
762
+ originDigest: sha256(original),
763
+ placement: "trailer/1",
764
+ name: "photo.jpg",
765
+ fusedDigest: (input) => { inputs.push(input); return finish(state, trailerBytesFor(input.commitment)); },
766
+ };
767
+ const r = await fuseSet([hashed, { original: png, placement: "container/1" }, { load: () => note, originDigest: sha256(note), placement: "container/1" }], { transport, keepFused: true });
768
+ assert.equal(inputs.length, 1);
769
+ const commitment = computeSlotCommitment(slot);
770
+ assert.deepEqual(inputs[0]!.commitment, commitment);
771
+ assert.equal(inputs[0]!.commitmentHex, bytesToHex(commitment));
772
+ assert.equal(inputs[0]!.slot.nonceB64, slot.nonceB64);
773
+ const built = getPlacement("trailer/1")!.build({ original, commitment });
774
+ assert.equal(r.members[0]!.artifactDigestB64, bytesToBase64(sha256(built)), "the row is the hash of the placement's build");
775
+ assert.ok(!("fusedBytes" in r.members[0]!), "nothing to keep for a hashed member");
776
+ assert.ok("fusedBytes" in r.members[1]! && "fusedBytes" in r.members[2]!, "bytes and loaded members are kept");
777
+ assert.equal(r.members.length, 3);
778
+ assert.equal(r.manifest.members.length, 3);
779
+ assert.equal(commits(calls).length, 1);
780
+ // The real verifier reads the member from the bytes a reader would build, and from its original.
781
+ const direct = await verifyFuseMember({ proof: r.proof, bytes: built, manifest: r.manifestBytes });
782
+ assert.equal(direct.category, "SET_MEMBER_DIRECT", direct.reason ?? "");
783
+ assert.equal(direct.set!.member!.index, r.members[0]!.manifestIndex);
784
+ const fromOrigin = await verifyFuseMember({ proof: r.proof, bytes: original, manifest: r.manifestBytes });
785
+ assert.equal(fromOrigin.category, "SET_MEMBER_FROM_ORIGIN", fromOrigin.reason ?? "");
786
+ // verifyMembers refuses a hashed member before any request; a throwing or short answer is builder-failed naming the member.
787
+ const quiet = honest(key, slot);
788
+ await assert.rejects(fuseSet([hashed], { transport: quiet.transport, verifyMembers: true }), (e: FuseError) => e.code === "bad-input" && /^member 0: a hashed member cannot be verified in full/.test(e.message) && e.member === 0);
789
+ assert.equal(quiet.calls.length, 0);
790
+ const throwing = honest(key, slot);
791
+ await assert.rejects(fuseSet([{ ...hashed, fusedDigest: () => { throw new Error("no state"); } }], { transport: throwing.transport }), (e: FuseError) => e.code === "builder-failed" && /^member 0: fusedDigest threw: no state/.test(e.message) && e.member === 0);
792
+ assert.equal(commits(throwing.calls).length, 0);
793
+ await assert.rejects(fuseSet([{ ...hashed, fusedDigest: () => new Uint8Array(31) }], { transport: honest(key, slot).transport }), (e: FuseError) => e.code === "builder-failed" && /^member 0: fusedDigest must return a 32-byte digest/.test(e.message));
794
+ // The same original as a hashed member and as a bytes member under one placement is the duplicate it always was.
795
+ await assert.rejects(fuseSet([hashed, { original, placement: "trailer/1" }], { transport: honest(key, slot).transport }), (e: FuseError) => e.code === "bad-input" && /^members 0 and 1 are the same original/.test(e.message));
796
+ });
797
+
798
+ test("33. progress counts every shape: hash before the slot for all three, fuse after it, one commit", async () => {
799
+ const seen: FuseSetProgress[] = [];
800
+ const state = savedState(original);
801
+ await fuseSet([
802
+ { originDigest: sha256(original), placement: "trailer/1", fusedDigest: ({ commitment }) => finish(state, trailerBytesFor(commitment)) },
803
+ { load: () => note, originDigest: sha256(note), placement: "container/1" },
804
+ { original: png, placement: "container/1" },
805
+ ], { transport: honest(key, slot).transport, onProgress: (p) => seen.push({ ...p }) });
806
+ assert.deepEqual(seen.map((p) => `${p.phase} ${p.done}/${p.total}`), ["hash 1/3", "hash 2/3", "hash 3/3", "fuse 1/3", "fuse 2/3", "fuse 3/3", "commit 0/1", "commit 1/1"]);
807
+ });
808
+ });
@@ -352,7 +352,7 @@ describe("codec: parseSetManifest", () => {
352
352
 
353
353
  describe("registry and metadata", () => {
354
354
  test("13. PLACEMENTS is unchanged; set/1 resolves through getPlacement as Form C, not byte-exact, locate-only", () => {
355
- assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "produced/1"]);
355
+ assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "container/2", "produced/1"]);
356
356
  const set1 = getPlacement("set/1")!;
357
357
  assert.ok(set1);
358
358
  assert.equal(set1.id, SET_PLACEMENT_ID);
package/src/fuse-cli.ts CHANGED
@@ -28,7 +28,7 @@ import type { FuseTransport } from "./fuse.js";
28
28
 
29
29
  const USAGE = `bitgraph-fuse: BitGraph producer harness (profile bitgraph-fuse/1, working name)
30
30
 
31
- bitgraph-fuse fuse <file> --placement trailer/1|container/1 [options]
31
+ bitgraph-fuse fuse <file> --placement trailer/1|container/1|container/2 [options]
32
32
  Form A or B over an existing file. The file is never modified.
33
33
  bitgraph-fuse produce [--origin <file>] [options]
34
34
  Form C: a canonical payload naming an optional source.
@@ -104,7 +104,7 @@ async function writeOutputs(outDir: string, label: string, frame: unknown, fused
104
104
  async function runFuse(args: Args): Promise<number> {
105
105
  const file = args.positional[0];
106
106
  const placement = args.flags.get("placement");
107
- if (file === undefined || (placement !== "trailer/1" && placement !== "container/1")) { process.stderr.write(USAGE); return 64; }
107
+ if (file === undefined || (placement !== "trailer/1" && placement !== "container/1" && placement !== "container/2")) { process.stderr.write(USAGE); return 64; }
108
108
  const original = new Uint8Array(await readFile(resolve(file)));
109
109
  const label = sanitize(basename(file));
110
110
  const ext = placement === "trailer/1" ? extname(label) : ".tar";
package/src/fuse.ts CHANGED
@@ -49,6 +49,8 @@ import {
49
49
  parseSetManifest,
50
50
  readSetMetadata,
51
51
  SET_METADATA_KEY,
52
+ TRAILER_LENGTH,
53
+ TRAILER_MAGIC,
52
54
  verifyFuse,
53
55
  verifyFuseMember,
54
56
  base64ToBytes,
@@ -153,6 +155,7 @@ export type FuseErrorCode =
153
155
  | "bad-input"
154
156
  | "allocate-failed"
155
157
  | "builder-failed"
158
+ | "load-failed"
156
159
  | "commitment-missing"
157
160
  | "commit-refused"
158
161
  | "slot-unavailable"
@@ -183,12 +186,14 @@ export class FuseError extends Error {
183
186
  * safe only where decoders stop at an end marker or read by declared sizes:
184
187
  * JPEG (EOI), PNG (IEND), GIF (0x3B), TIFF and the TIFF-based raws such as
185
188
  * DNG, CR2, NEF, ARW (offset tables), BMP and RIFF containers such as WebP,
186
- * WAV, AVI (declared sizes). Everything else goes into `container/1`, a tar
187
- * that carries the original untouched: PDF, ZIP-based documents, ISO base
189
+ * WAV, AVI (declared sizes). Everything else goes into `container/2`, a tar
190
+ * that carries the original untouched and FIRST, so a scanner can hash it
191
+ * once and finish the fused digest later: PDF, ZIP-based documents, ISO base
188
192
  * media video and images, Matroska, MP3, structured and plain text, and any
189
- * format not recognised here.
193
+ * format not recognised here. Artifacts made under `container/1` (the
194
+ * manifest first) stay readable; nothing new is made under it.
190
195
  */
191
- export function placementForBytes(bytes: Uint8Array): "trailer/1" | "container/1" {
196
+ export function placementForBytes(bytes: Uint8Array): "trailer/1" | "container/2" {
192
197
  const at = (sig: number[], offset = 0): boolean => bytes.length >= offset + sig.length && sig.every((v, i) => bytes[offset + i] === v);
193
198
  const trailerSafe =
194
199
  at([0xff, 0xd8, 0xff]) || // JPEG
@@ -197,7 +202,7 @@ export function placementForBytes(bytes: Uint8Array): "trailer/1" | "container/1
197
202
  at([0x49, 0x49, 0x2a, 0x00]) || at([0x4d, 0x4d, 0x00, 0x2a]) || // TIFF, DNG, CR2, NEF, ARW
198
203
  (at([0x42, 0x4d]) && bytes.length >= 14) || // BMP
199
204
  (at([0x52, 0x49, 0x46, 0x46]) && bytes.length >= 12); // RIFF: WebP, WAV, AVI
200
- return trailerSafe ? "trailer/1" : "container/1";
205
+ return trailerSafe ? "trailer/1" : "container/2";
201
206
  }
202
207
 
203
208
  /** Names for a fused artifact and its Frame, from the original's name. */
@@ -206,7 +211,7 @@ export function fusedNamesFor(originalName: string, placement: PlacementId): { f
206
211
  const stem = dot > 0 ? originalName.slice(0, dot) : originalName;
207
212
  const ext = dot > 0 ? originalName.slice(dot) : "";
208
213
  return {
209
- fusedName: placement === "trailer/1" ? `${stem}.fused${ext}` : placement === "container/1" ? `${stem}.fused.tar` : `${stem}.produced.json`,
214
+ fusedName: placement === "trailer/1" ? `${stem}.fused${ext}` : placement.startsWith("container/") ? `${stem}.fused.tar` : `${stem}.produced.json`,
210
215
  frameName: `${originalName}.bitgraph-fuse.json`,
211
216
  };
212
217
  }
@@ -476,9 +481,20 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
476
481
  export const MAX_SET_MEMBERS = 2000;
477
482
 
478
483
  /** The placements a set member takes: Forms A and B, one original per member. */
479
- export type SetMemberPlacement = "trailer/1" | "container/1";
484
+ export type SetMemberPlacement = "trailer/1" | "container/1" | "container/2";
480
485
 
481
- export interface FuseSetMember {
486
+ /** What a hashed member's fused digest is computed for: the held slot and its commitment. */
487
+ export interface FusedDigestInput {
488
+ commitment: Uint8Array;
489
+ commitmentHex: string;
490
+ slot: SlotAllocation;
491
+ }
492
+
493
+ /**
494
+ * A member given as bytes. The core hashes the original, builds the fused
495
+ * bytes under the slot's commitment, checks them, and hashes them.
496
+ */
497
+ export interface FuseSetBytesMember {
482
498
  /** The original bytes. Never modified. */
483
499
  original: Uint8Array;
484
500
  /** Default: placementForBytes(original). */
@@ -489,10 +505,62 @@ export interface FuseSetMember {
489
505
  builder?: FuseBuilder;
490
506
  }
491
507
 
508
+ /**
509
+ * A member whose bytes are read only when it is that member's turn, after
510
+ * the slot is held, and released once hashed: one member's bytes in memory
511
+ * at a time, however large the set. Nothing can be read before allocation
512
+ * without reading twice, so the caller names the placement and the origin
513
+ * digest up front; the digest is checked against the loaded bytes, and the
514
+ * byte guards run as for a bytes member.
515
+ */
516
+ export interface FuseSetLoadedMember {
517
+ load: () => Promise<Uint8Array> | Uint8Array;
518
+ originDigest: Uint8Array;
519
+ placement: SetMemberPlacement;
520
+ /** Advisory; feeds fusedNamesFor. */
521
+ name?: string;
522
+ /** Default: builderFor(placement, bytes). The locate and origin guards run regardless. */
523
+ builder?: FuseBuilder;
524
+ }
525
+
526
+ /**
527
+ * A member the caller hashes itself: it answers the fused digest for the
528
+ * held slot's commitment. For trailer/1 that is a hash state saved after
529
+ * the original and finished with trailerBytesFor(commitment), so the bytes
530
+ * are read once, when they are scanned, and never again. The core never
531
+ * sees this member's bytes: no byte guard runs, keepFused returns nothing
532
+ * for it, and verifyMembers refuses it before any request. Its row is bound
533
+ * to the committed manifest by digest like every other.
534
+ */
535
+ export interface FuseSetHashedMember {
536
+ originDigest: Uint8Array;
537
+ placement: SetMemberPlacement;
538
+ fusedDigest: (input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array;
539
+ /** Advisory; feeds fusedNamesFor. */
540
+ name?: string;
541
+ }
542
+
543
+ export type FuseSetMember = FuseSetBytesMember | FuseSetLoadedMember | FuseSetHashedMember;
544
+
545
+ /**
546
+ * The 48 bytes trailer/1 appends after the original: the magic, eight
547
+ * reserved zero bytes, the commitment. A hasher whose state was saved after
548
+ * the original finishes with these and holds the member's fused digest
549
+ * without reading the original again. A test pins them against the
550
+ * placement's own build.
551
+ */
552
+ export function trailerBytesFor(commitment: Uint8Array): Uint8Array {
553
+ if (!(commitment instanceof Uint8Array) || commitment.length !== 32) throw new FuseError("bad-input", "a slot commitment is 32 bytes");
554
+ const out = new Uint8Array(TRAILER_LENGTH);
555
+ out.set(new TextEncoder().encode(TRAILER_MAGIC), 0);
556
+ out.set(commitment, TRAILER_LENGTH - 32);
557
+ return out;
558
+ }
559
+
492
560
  export interface FuseSetProgress {
493
561
  /**
494
- * "hash": origin digests, one per member, before any request.
495
- * "fuse": each member built and its fused digest taken, after the slot is held.
562
+ * "hash": each member checked before any request (a bytes member's origin digest is taken here).
563
+ * "fuse": each member's fused digest taken, after the slot is held.
496
564
  * "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
497
565
  * "verify": only with verifyMembers, one per member.
498
566
  */
@@ -502,7 +570,7 @@ export interface FuseSetProgress {
502
570
  }
503
571
 
504
572
  export interface FuseSetOptions {
505
- /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
573
+ /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. A hashed member has none to return. */
506
574
  keepFused?: boolean;
507
575
  /**
508
576
  * Run the full verifier (verifyFuseMember) over every member's fused bytes
@@ -510,7 +578,8 @@ export interface FuseSetOptions {
510
578
  * false: every member is bound to the returned proof by digest, its row in
511
579
  * the committed manifest, which is itself verified FUSED_DIRECT; that is
512
580
  * linear and reads no bytes. The full pass re-hashes every member with the
513
- * verifier's own hasher and grows with the square of the member count.
581
+ * verifier's own hasher and grows with the square of the member count. A
582
+ * set with a hashed member refuses it before any request.
514
583
  */
515
584
  verifyMembers?: boolean;
516
585
  /** Called as the set advances. A throw inside it is ignored: a progress hook never changes the outcome. */
@@ -533,7 +602,7 @@ export interface FuseSetMemberResult {
533
602
  fusedName: string | null;
534
603
  /** Advisory; no Frame is written for a set member this phase. */
535
604
  frameName: string | null;
536
- /** Present only when keepFused is true. */
605
+ /** Present only when keepFused is true and the member's bytes passed through the core (never for a hashed member). */
537
606
  fusedBytes?: Uint8Array;
538
607
  /** Present only with verifyMembers: the verifier's own verdict against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
539
608
  verification?: FuseMemberResult;
@@ -562,13 +631,28 @@ export interface FuseSetResult {
562
631
  * Allocate once, fuse every member with the one commitment, hash the set
563
632
  * manifest, fill the slot with it. Returns the proof with the manifest bytes
564
633
  * beside it, or throws a FuseError; it never commits a partial set and never
565
- * allocates a second slot.
634
+ * allocates a second slot. Members may be given as bytes, as a loader read
635
+ * one at a time after the slot is held, or as a digest the caller finishes
636
+ * itself; one set may mix them.
566
637
  */
567
638
  export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
568
639
  // 0. validate, before any request. A refusal here burns nothing.
569
640
  if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
570
641
  if (members.length > MAX_SET_MEMBERS) throw new FuseError("bad-input", `a set lists at most ${MAX_SET_MEMBERS} members (got ${members.length})`);
571
- interface Checked { placement: Placement; id: SetMemberPlacement; original: Uint8Array; originDigest: Uint8Array; name: string | null; builder: FuseBuilder }
642
+ const keep = options.keepFused === true;
643
+ const verifyMembers = options.verifyMembers === true;
644
+ type Kind = "bytes" | "loaded" | "hashed";
645
+ interface Checked {
646
+ kind: Kind;
647
+ placement: Placement;
648
+ id: SetMemberPlacement;
649
+ originDigest: Uint8Array;
650
+ name: string | null;
651
+ original: Uint8Array | null;
652
+ load: (() => Promise<Uint8Array> | Uint8Array) | null;
653
+ builder: FuseBuilder | null;
654
+ fusedDigest: ((input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array) | null;
655
+ }
572
656
  const checked: Checked[] = [];
573
657
  const seen = new Map<string, number>();
574
658
  const report = (phase: FuseSetProgress["phase"], done: number, total: number) => {
@@ -579,17 +663,23 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
579
663
  // a progress hook never changes the outcome
580
664
  }
581
665
  };
666
+ const bad = (i: number, message: string) => new FuseError("bad-input", `member ${i}: ${message}`, null, i);
582
667
  for (let i = 0; i < members.length; i++) {
583
- const m = members[i];
584
- // A null, undefined or missing element is refused like any other member without original bytes.
585
- if (m === null || typeof m !== "object" || !(m.original instanceof Uint8Array)) throw new FuseError("bad-input", `member ${i}: original must be a Uint8Array`, null, i);
586
- const id = m.placement ?? placementForBytes(m.original);
668
+ const m = members[i] as Partial<FuseSetBytesMember & FuseSetLoadedMember & FuseSetHashedMember> | null | undefined;
669
+ // A null, undefined or missing element is refused like any other member without bytes, a loader or a digest.
670
+ if (m === null || typeof m !== "object") throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
671
+ const kind: Kind | null = m.original instanceof Uint8Array ? "bytes" : typeof m.load === "function" ? "loaded" : typeof m.fusedDigest === "function" ? "hashed" : null;
672
+ if (kind === null) throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
673
+ if (kind !== "bytes" && m.placement === undefined) throw bad(i, `a ${kind} member names its placement`);
674
+ const id = m.placement ?? placementForBytes(m.original as Uint8Array);
587
675
  const placement = getPlacement(id);
588
676
  if (placement === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
589
- if (placement.form === "C") throw new FuseError("bad-input", `member ${i}: ${id} takes no original; a set holds trailer/1 and container/1 members only`, null, i);
590
- if (m.name !== undefined && typeof m.name !== "string") throw new FuseError("bad-input", `member ${i}: name must be a string`, null, i);
591
- if (m.builder !== undefined && typeof m.builder !== "function") throw new FuseError("bad-input", `member ${i}: builder must be a function`, null, i);
592
- const originDigest = await digest(m.original);
677
+ if (placement.form === "C") throw bad(i, `${id} takes no original; a set holds trailer/1, container/1 and container/2 members only`);
678
+ if (m.name !== undefined && typeof m.name !== "string") throw bad(i, "name must be a string");
679
+ if (m.builder !== undefined && typeof m.builder !== "function") throw bad(i, "builder must be a function");
680
+ if (kind !== "bytes" && !(m.originDigest instanceof Uint8Array && m.originDigest.length === 32)) throw bad(i, `a ${kind} member names its originDigest, 32 bytes`);
681
+ if (kind === "hashed" && verifyMembers) throw bad(i, "a hashed member cannot be verified in full; pass its bytes or drop verifyMembers");
682
+ const originDigest = kind === "bytes" ? await digest(m.original as Uint8Array) : (m.originDigest as Uint8Array);
593
683
  // The same original under the same placement fuses to the same bytes, which one manifest lists once.
594
684
  const key = `${id}:${bytesToHex(originDigest)}`;
595
685
  const j = seen.get(key);
@@ -597,7 +687,17 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
597
687
  throw new FuseError("bad-input", `members ${j} and ${i} are the same original under the same placement (${id}) and would fuse to the same bytes; a set lists each fused artifact once`, null, i);
598
688
  }
599
689
  seen.set(key, i);
600
- checked.push({ placement, id, original: m.original, originDigest, name: m.name ?? null, builder: m.builder ?? builderFor(id, m.original) });
690
+ checked.push({
691
+ kind,
692
+ placement,
693
+ id,
694
+ originDigest,
695
+ name: m.name ?? null,
696
+ original: kind === "bytes" ? (m.original as Uint8Array) : null,
697
+ load: kind === "loaded" ? (m.load as Checked["load"]) : null,
698
+ builder: kind !== "hashed" && m.builder !== undefined ? (m.builder as FuseBuilder) : null,
699
+ fusedDigest: kind === "hashed" ? (m.fusedDigest as Checked["fusedDigest"]) : null,
700
+ });
601
701
  report("hash", i + 1, members.length);
602
702
  }
603
703
  const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
@@ -605,41 +705,72 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
605
705
  // 1. nonce: one slot for the whole set
606
706
  const slot = await allocateSlot(t);
607
707
 
608
- // 2. fuse: the commitment once, every member's bytes carrying it. The slot
609
- // is held and its TTL is running; a throw here burns it but commits nothing.
708
+ // 2. fuse: the commitment once, every member's digest under it. The slot
709
+ // is held and its TTL is running; a throw here burns it but commits
710
+ // nothing. A member's fused bytes are virtual: each is built, hashed
711
+ // and released in turn, so memory holds one member's bytes at a time.
712
+ // They are held only for a caller who keeps them or asks the full
713
+ // verifier to read them.
610
714
  const commitment = computeSlotCommitment(slot);
611
715
  const commitmentHex = bytesToHex(commitment);
612
- const keep = options.keepFused === true;
613
- const verifyMembers = options.verifyMembers === true;
614
- // A member's fused bytes are virtual: each is built, hashed and released in
615
- // turn, so memory holds the originals and one fused copy. They are held only
616
- // for a caller who keeps them or asks the full verifier to read them.
617
716
  const fusedBytes: (Uint8Array | null)[] = [];
618
717
  const rows: SetMember[] = [];
718
+ const expiring = "nothing was committed and the slot will expire";
619
719
  for (let i = 0; i < checked.length; i++) {
620
720
  const c = checked[i]!;
621
- let fused: Uint8Array;
622
- try {
623
- fused = await c.builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
624
- } catch (err) {
625
- throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
626
- }
627
- if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
628
- const located = requireCommitment(c.placement, fused, commitment, i);
629
- // The row's origin must be the origin the bytes embed, else the member
630
- // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
631
- // facts are checked when both are present: the digest the bytes declare
632
- // (container/1's payload) and the bytes they carry, compared byte for
633
- // byte with the member's original rather than hashed again, so a builder
634
- // cannot pack other bytes under the member's digest and leave a member no
635
- // original rebuilds.
636
- const declared = located.originDigest;
637
- const carried = located.originalBytes;
638
- if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, c.original))) {
639
- throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; nothing was committed and the slot will expire`, null, i);
721
+ let artifact: Uint8Array;
722
+ let held: Uint8Array | null = null;
723
+ if (c.kind === "hashed") {
724
+ let d: unknown;
725
+ try {
726
+ d = await c.fusedDigest!({ commitment, commitmentHex, slot });
727
+ } catch (err) {
728
+ throw new FuseError("builder-failed", `member ${i}: fusedDigest threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
729
+ }
730
+ if (!(d instanceof Uint8Array) || d.length !== 32) throw new FuseError("builder-failed", `member ${i}: fusedDigest must return a 32-byte digest; ${expiring}`, null, i);
731
+ artifact = d;
732
+ } else {
733
+ let original: Uint8Array;
734
+ if (c.kind === "loaded") {
735
+ let loaded: unknown;
736
+ try {
737
+ loaded = await c.load!();
738
+ } catch (err) {
739
+ throw new FuseError("load-failed", `member ${i}: load threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
740
+ }
741
+ if (!(loaded instanceof Uint8Array)) throw new FuseError("load-failed", `member ${i}: load must return a Uint8Array; ${expiring}`, null, i);
742
+ original = loaded;
743
+ // The digest the caller named is the row's origin; it must be these bytes' own.
744
+ if (!bytesEqual(await digest(original), c.originDigest)) throw new FuseError("bad-input", `member ${i}: originDigest is not the SHA-256 of the loaded bytes; ${expiring}`, null, i);
745
+ } else {
746
+ original = c.original!;
747
+ }
748
+ const builder = c.builder ?? builderFor(c.id, original);
749
+ let fused: Uint8Array;
750
+ try {
751
+ fused = await builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
752
+ } catch (err) {
753
+ throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
754
+ }
755
+ if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
756
+ const located = requireCommitment(c.placement, fused, commitment, i);
757
+ // The row's origin must be the origin the bytes embed, else the member
758
+ // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
759
+ // facts are checked when both are present: the digest the bytes declare
760
+ // (container/1's payload) and the bytes they carry, compared byte for
761
+ // byte with the member's original rather than hashed again, so a builder
762
+ // cannot pack other bytes under the member's digest and leave a member no
763
+ // original rebuilds.
764
+ const declared = located.originDigest;
765
+ const carried = located.originalBytes;
766
+ if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, original))) {
767
+ throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; ${expiring}`, null, i);
768
+ }
769
+ artifact = await digest(fused);
770
+ if (keep || verifyMembers) held = fused;
640
771
  }
641
- rows.push({ artifact: await digest(fused), origin: c.originDigest, placement: c.id });
642
- fusedBytes.push(keep || verifyMembers ? fused : null);
772
+ rows.push({ artifact, origin: c.originDigest, placement: c.id });
773
+ fusedBytes.push(held);
643
774
  report("fuse", i + 1, checked.length);
644
775
  }
645
776
 
@@ -648,7 +779,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
648
779
  try {
649
780
  manifestBytes = buildSetManifest(commitment, rows);
650
781
  } catch (err) {
651
- throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; nothing was committed and the slot will expire`);
782
+ throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
652
783
  }
653
784
  const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
654
785
  const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
@@ -712,6 +843,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
712
843
  report("verify", i + 1, checked.length);
713
844
  }
714
845
  const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
846
+ const held = fusedBytes[i];
715
847
  results.push({
716
848
  index: i,
717
849
  manifestIndex: k,
@@ -720,7 +852,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
720
852
  artifactDigestB64: memberArtifactB64,
721
853
  fusedName: names?.fusedName ?? null,
722
854
  frameName: names?.frameName ?? null,
723
- ...(keep ? { fusedBytes: fusedBytes[i]! } : {}),
855
+ ...(keep && held !== null ? { fusedBytes: held } : {}),
724
856
  ...(verification !== undefined ? { verification } : {}),
725
857
  });
726
858
  }
package/src/index.ts CHANGED
@@ -40,9 +40,9 @@ export { Constructor } from "./constructor.js";
40
40
  // The producer profile over the primitive (working name Fuse): allocate a
41
41
  // slot, write a commitment to it into the artifact, hash, commit under the
42
42
  // same slot. The resulting proof is ordinary bitgraph/1.
43
- export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
43
+ export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
44
44
  export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
45
- export type { FuseSetMember, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
45
+ export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
46
46
  // The verify-package types those results are made of, so the core entry names everything it returns.
47
47
  export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
48
48