@mikeargento/bitgraph 1.4.0 → 1.6.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/dist/index.d.ts CHANGED
@@ -15,9 +15,9 @@ export { computeProofHash } from "@mikeargento/bitgraph-verify";
15
15
  export { canonicalize, canonicalizeToString, constantTimeEqual } from "@mikeargento/bitgraph-verify";
16
16
  export type { HostCapabilities } from "./host.js";
17
17
  export { Constructor } from "./constructor.js";
18
- export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
18
+ export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
19
19
  export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
20
- export type { FuseSetMember, SetMemberPlacement, FuseSetOptions, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
20
+ export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
21
21
  export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
22
22
  export { parsePolicy, hashPolicy, createPolicyBinding, validateAction, } from "./policy.js";
23
23
  export type { PolicyDocument, PolicyRules, ActionValidationResult, } from "./policy.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAGH,YAAY,EACV,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,GACf,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAC3E,YAAY,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAGrG,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAK/C,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AACtI,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAClH,YAAY,EAAE,aAAa,EAAE,kBAAkB,EAAE,cAAc,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAEvH,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,WAAW,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAG5H,OAAO,EACL,WAAW,EACX,UAAU,EACV,mBAAmB,EACnB,cAAc,GACf,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,cAAc,EACd,WAAW,EACX,sBAAsB,GACvB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAGH,YAAY,EACV,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,GACf,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAC3E,YAAY,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAGrG,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAK/C,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AACvJ,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAClH,YAAY,EAAE,aAAa,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,cAAc,EAAE,eAAe,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAExN,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,WAAW,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAG5H,OAAO,EACL,WAAW,EACX,UAAU,EACV,mBAAmB,EACnB,cAAc,GACf,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,cAAc,EACd,WAAW,EACX,sBAAsB,GACvB,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { Constructor } from "./constructor.js";
7
7
  // The producer profile over the primitive (working name Fuse): allocate a
8
8
  // slot, write a commitment to it into the artifact, hash, commit under the
9
9
  // same slot. The resulting proof is ordinary bitgraph/1.
10
- export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
10
+ export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
11
11
  // Policy parsing, hashing, and validation
12
12
  export { parsePolicy, hashPolicy, createPolicyBinding, validateAction, } from "./policy.js";
13
13
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gEAAgE;AA4BhE,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAE3E,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAKrG,2BAA2B;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,0EAA0E;AAC1E,2EAA2E;AAC3E,yDAAyD;AACzD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAMtI,0CAA0C;AAC1C,OAAO,EACL,WAAW,EACX,UAAU,EACV,mBAAmB,EACnB,cAAc,GACf,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gEAAgE;AA4BhE,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAE3E,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAKrG,2BAA2B;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,0EAA0E;AAC1E,2EAA2E;AAC3E,yDAAyD;AACzD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAMvJ,0CAA0C;AAC1C,OAAO,EACL,WAAW,EACX,UAAU,EACV,mBAAmB,EACnB,cAAc,GACf,MAAM,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikeargento/bitgraph",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "Portable cryptographic proof of construction for digital artifacts.",
6
6
  "type": "module",
@@ -32,7 +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 } 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";
36
37
  import type { FuseSetMember } from "../fuse.js";
37
38
 
38
39
  const FIX = fileURLToPath(new URL("../../src/__tests__/fuse-fixtures/", import.meta.url));
@@ -47,7 +48,7 @@ const unrelated = utf8("a file nobody recorded\n");
47
48
  // The phase-1 fixtures fuse original.txt as trailer/1 and image.png as
48
49
  // container/1, so these members declare those placements; the by-bytes
49
50
  // default (placementForBytes) is asserted on its own in test 3.
50
- 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" }];
51
52
 
52
53
  // ---------------------------------------------------------------------------
53
54
  // Local signer: slots and proofs minted from a received commit body
@@ -159,9 +160,9 @@ const toUrlSafe = (s: string) => s.replace(/\+/g, "-").replace(/\//g, "_").repla
159
160
  interface Oracle { commitment: Uint8Array; fused: Uint8Array[]; rows: SetMember[]; manifest: Uint8Array; digestB64: string; sorted: SetMember[] }
160
161
 
161
162
  /** The commitment from the slot, each member built by its placement, the canonical manifest and its digest. */
162
- function oracle(slot: SlotAllocation, members: readonly FuseSetMember[]): Oracle {
163
+ function oracle(slot: SlotAllocation, members: readonly FuseSetBytesMember[]): Oracle {
163
164
  const commitment = computeSlotCommitment(slot);
164
- const placementOf = (m: FuseSetMember) => m.placement ?? placementForBytes(m.original);
165
+ const placementOf = (m: FuseSetBytesMember) => m.placement ?? placementForBytes(m.original);
165
166
  const fused = members.map((m) => getPlacement(placementOf(m))!.build({ original: m.original, commitment }));
166
167
  const rows = members.map((m, i) => ({ artifact: sha256(fused[i]!), origin: sha256(m.original), placement: placementOf(m) }));
167
168
  const manifest = buildSetManifest(commitment, rows);
@@ -229,11 +230,11 @@ describe("fuseSet(): one slot, N files, the manifest as the artifact", () => {
229
230
  assert.equal(r.proof.attribution?.title, "set/1");
230
231
  });
231
232
 
232
- test("3. members come back in the caller's order, each with its manifest row, its names, and SET_MEMBER_DIRECT against explicit bytes", async () => {
233
+ test("3. members come back in the caller's order, each with its manifest row and its names; with verifyMembers each is SET_MEMBER_DIRECT against explicit bytes", async () => {
233
234
  const members = two();
234
235
  members[0]!.name = "photo.jpg";
235
236
  const o = oracle(slot, members);
236
- const r = await fuseSet(members, { transport: honest(key, slot).transport });
237
+ const r = await fuseSet(members, { transport: honest(key, slot).transport, verifyMembers: true });
237
238
  assert.deepEqual(r.members.map((m) => m.index), [0, 1]);
238
239
  for (const m of r.members) {
239
240
  const row = o.rows[m.index]!;
@@ -241,13 +242,17 @@ describe("fuseSet(): one slot, N files, the manifest as the artifact", () => {
241
242
  assert.equal(m.originDigestB64, bytesToBase64(row.origin));
242
243
  assert.equal(m.artifactDigestB64, bytesToBase64(row.artifact));
243
244
  assert.equal(m.manifestIndex, o.sorted.findIndex((s) => bytesToHex(s.artifact) === bytesToHex(row.artifact)));
244
- assert.equal(m.manifestIndex, m.verification.set!.member!.index);
245
+ assert.equal(m.manifestIndex, m.verification!.set!.member!.index);
245
246
  assert.ok(!("fusedBytes" in m), "not kept by default");
246
- assert.equal(m.verification.category, "SET_MEMBER_DIRECT", m.verification.reason ?? "");
247
- assert.equal(m.verification.set!.manifestSource, "argument");
248
- assert.equal(m.verification.set!.member!.fusedDigestB64, m.artifactDigestB64);
249
- assert.equal(m.verification.set!.memberCount, 2);
247
+ assert.equal(m.verification!.category, "SET_MEMBER_DIRECT", m.verification!.reason ?? "");
248
+ assert.equal(m.verification!.set!.manifestSource, "argument");
249
+ assert.equal(m.verification!.set!.member!.fusedDigestB64, m.artifactDigestB64);
250
+ assert.equal(m.verification!.set!.memberCount, 2);
250
251
  }
252
+ // Without verifyMembers the same rows come back bound by digest, and no verdict is invented.
253
+ const plain = await fuseSet(members, { transport: honest(key, slot).transport });
254
+ assert.deepEqual(plain.members.map((m) => [m.index, m.manifestIndex, m.placement, m.originDigestB64, m.artifactDigestB64]), r.members.map((m) => [m.index, m.manifestIndex, m.placement, m.originDigestB64, m.artifactDigestB64]));
255
+ for (const m of plain.members) assert.ok(!("verification" in m), "no verifier verdict without verifyMembers");
251
256
  assert.deepEqual({ fusedName: r.members[0]!.fusedName, frameName: r.members[0]!.frameName }, fusedNamesFor("photo.jpg", "trailer/1"));
252
257
  assert.equal(r.members[1]!.fusedName, null);
253
258
  assert.equal(r.members[1]!.frameName, null);
@@ -331,7 +336,7 @@ describe("fuseSet(): a bad member burns the slot and commits nothing", () => {
331
336
  test("9. bytes that carry the commitment but embed another member's origin are builder-failed naming the member and the origin", async () => {
332
337
  const { calls, transport } = honest(key, slot);
333
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.
334
- 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" }];
335
340
  members[1]!.builder = ({ commitment }) => getPlacement("trailer/1")!.build({ original, commitment });
336
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);
337
342
  assert.equal(commits(calls).length, 0);
@@ -340,21 +345,21 @@ describe("fuseSet(): a bad member burns the slot and commits nothing", () => {
340
345
 
341
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 () => {
342
347
  const { calls, transport } = honest(key, slot);
343
- 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" }];
344
349
  // The payload names png's digest (so the declared origin matches) while the tar carries other bytes.
345
350
  members[1]!.builder = ({ commitment, originDigest }) => getPlacement("container/1")!.build({ original: utf8("not the member's original\n"), originDigest: originDigest!, commitment });
346
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);
347
352
  assert.equal(commits(calls).length, 0, "nothing was committed");
348
353
  assert.equal(allocates(calls).length, 1);
349
354
  // The mirror: a payload declaring another digest over the member's own bytes is refused the same way.
350
- 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" }];
351
356
  declared[1]!.builder = ({ commitment }) => getPlacement("container/1")!.build({ original: png, originDigest: sha256(unrelated), commitment });
352
357
  const b = honest(key, slot);
353
358
  await assert.rejects(fuseSet(declared, { transport: b.transport }), (e: FuseError) => e.code === "builder-failed" && e.member === 1);
354
359
  assert.equal(commits(b.calls).length, 0);
355
360
  // And the honest container/1 builder still passes both checks.
356
- const ok = await fuseSet([{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }], { transport: honest(key, slot).transport });
357
- assert.equal(ok.members[1]!.verification.category, "SET_MEMBER_DIRECT");
361
+ const ok = await fuseSet([{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }], { transport: honest(key, slot).transport, verifyMembers: true });
362
+ assert.equal(ok.members[1]!.verification!.category, "SET_MEMBER_DIRECT");
358
363
  });
359
364
  });
360
365
 
@@ -370,12 +375,12 @@ describe("fuseSet(): bad input is refused before any request", () => {
370
375
  (e: FuseError) => e.code === "bad-input" && /members 0 and 2/.test(e.message) && e.member === 2,
371
376
  );
372
377
  assert.equal(calls.length, 0, "no slot was burned");
373
- const r = await fuseSet([{ original, placement: "trailer/1" }, { original, placement: "container/1" }], { transport });
378
+ const r = await fuseSet([{ original, placement: "trailer/1" }, { original, placement: "container/1" }], { transport, verifyMembers: true });
374
379
  assert.equal(r.manifest.members.length, 2);
375
380
  assert.deepEqual(r.members.map((m) => m.placement), ["trailer/1", "container/1"]);
376
381
  assert.equal(r.members[0]!.originDigestB64, r.members[1]!.originDigestB64);
377
382
  assert.notEqual(r.members[0]!.artifactDigestB64, r.members[1]!.artifactDigestB64);
378
- for (const m of r.members) assert.equal(m.verification.category, "SET_MEMBER_DIRECT");
383
+ for (const m of r.members) assert.equal(m.verification!.category, "SET_MEMBER_DIRECT");
379
384
  });
380
385
 
381
386
  test("11. an empty set and a set over the cap are refused; the cap runs before the duplicate check", async () => {
@@ -454,14 +459,14 @@ describe("fuseSet(): the boundary", () => {
454
459
  () => { throw new Error("socket hang up"); },
455
460
  () => ({ status: 200, json: { proofs: [{ proof: decoy }, { proof: real }] } }),
456
461
  );
457
- const r = await fuseSet(two(), { transport });
462
+ const r = await fuseSet(two(), { transport, verifyMembers: true });
458
463
  assert.equal(r.recovered, true);
459
464
  assert.deepEqual(r.proof, real);
460
465
  assert.equal(allocates(calls).length, 1);
461
466
  const lookup = calls.find((c) => c.path.startsWith("/api/proofs/"))!;
462
467
  assert.ok(lookup.path.includes(toUrlSafe(o.digestB64)), lookup.path);
463
468
  assert.equal(r.manifestEchoed, true);
464
- for (const m of r.members) assert.equal(m.verification.category, "SET_MEMBER_DIRECT");
469
+ for (const m of r.members) assert.equal(m.verification!.category, "SET_MEMBER_DIRECT");
465
470
  });
466
471
 
467
472
  test("16. 409 slot-unavailable: read back when the ledger has the proof, else reported as slot-unavailable and never retried into a new slot", async () => {
@@ -503,32 +508,40 @@ describe("fuseSet(): the boundary", () => {
503
508
  // ---------------------------------------------------------------------------
504
509
 
505
510
  describe("fuseSet(): the result and the checks before it is returned", () => {
506
- test("19. keepFused returns each member's fused bytes; false and the default do not; the verification is present either way", async () => {
511
+ test("19. keepFused returns each member's fused bytes; false and the default do not; the verifier's verdict only with verifyMembers", async () => {
507
512
  const members = two();
508
513
  const commitment = computeSlotCommitment(slot);
509
- const kept = await fuseSet(members, { transport: honest(key, slot).transport, keepFused: true });
514
+ const kept = await fuseSet(members, { transport: honest(key, slot).transport, keepFused: true, verifyMembers: true });
510
515
  for (const m of kept.members) {
511
516
  assert.deepEqual(m.fusedBytes, getPlacement(m.placement)!.build({ original: members[m.index]!.original, commitment }));
512
- assert.equal(m.verification.category, "SET_MEMBER_DIRECT");
517
+ assert.equal(m.verification!.category, "SET_MEMBER_DIRECT");
518
+ }
519
+ const keptOnly = await fuseSet(members, { transport: honest(key, slot).transport, keepFused: true });
520
+ for (const m of keptOnly.members) {
521
+ assert.deepEqual(m.fusedBytes, getPlacement(m.placement)!.build({ original: members[m.index]!.original, commitment }));
522
+ assert.ok(!("verification" in m));
513
523
  }
514
524
  for (const opts of [{ keepFused: false }, {}]) {
515
525
  const r = await fuseSet(members, { transport: honest(key, slot).transport, ...opts });
516
526
  for (const m of r.members) {
517
527
  assert.ok(!("fusedBytes" in m));
518
- assert.equal(m.verification.category, "SET_MEMBER_DIRECT");
528
+ assert.ok(!("verification" in m));
519
529
  }
520
530
  }
521
531
  });
522
532
 
523
- test("20. a boundary that does not echo the manifest: success, manifestEchoed false, every member verified against the explicit bytes", async () => {
524
- const r = await fuseSet(two(), { transport: honest(key, slot, { withMetadata: false }).transport });
533
+ test("20. a boundary that does not echo the manifest: success, manifestEchoed false, and with verifyMembers every member verified against the explicit bytes", async () => {
534
+ const r = await fuseSet(two(), { transport: honest(key, slot, { withMetadata: false }).transport, verifyMembers: true });
525
535
  assert.equal(r.manifestEchoed, false);
526
536
  assert.equal(r.proof.metadata, undefined);
527
537
  assert.equal(r.verification.category, "FUSED_DIRECT");
528
538
  for (const m of r.members) {
529
- assert.equal(m.verification.category, "SET_MEMBER_DIRECT", m.verification.reason ?? "");
530
- assert.equal(m.verification.set!.manifestSource, "argument");
539
+ assert.equal(m.verification!.category, "SET_MEMBER_DIRECT", m.verification!.reason ?? "");
540
+ assert.equal(m.verification!.set!.manifestSource, "argument");
531
541
  }
542
+ const plain = await fuseSet(two(), { transport: honest(key, slot, { withMetadata: false }).transport });
543
+ assert.equal(plain.manifestEchoed, false);
544
+ assert.equal(plain.members.length, 2);
532
545
  });
533
546
 
534
547
  test("21. an echoed manifest that differs from the committed one is verification-failed; no result", async () => {
@@ -589,7 +602,7 @@ describe("wiring", () => {
589
602
  for (const name of ["SetManifest", "FuseMemberResult", "FuseVerifyResult", "FuseFrame", "PlacementId"]) assert.ok(typeExports.includes(name), `index.ts re-exports type ${name}`);
590
603
  });
591
604
 
592
- test("26. FUSE.md: Sets is its own subsection, the echo is stated as absent on every production boundary, and the return-time cost is stated", () => {
605
+ test("26. FUSE.md: Sets is its own subsection, the echo is stated per boundary, and the return-time binding and its costs are stated", () => {
593
606
  const doc = readFileSync(fileURLToPath(new URL("../../docs/fuse/FUSE.md", import.meta.url)), "utf8");
594
607
  const headings = [...doc.matchAll(/^(##+) (.+)$/gm)].map((m) => `${m[1]} ${m[2]}`);
595
608
  const sets = headings.indexOf("### Sets");
@@ -599,9 +612,15 @@ describe("wiring", () => {
599
612
  const setsText = doc.slice(doc.indexOf("### Sets"), doc.indexOf("### Harness"));
600
613
  assert.ok(!setsText.includes("Bounded copy, verbatim"), "no bounded copy inside Sets");
601
614
  assert.ok(!/the parent and the enclave do echo/.test(doc), "the parent path does not echo the manifest");
602
- assert.match(setsText, /No production boundary returns the echo today/);
603
- assert.match(setsText, /parent-direct returns `manifestEchoed:\s+false`/);
604
- assert.match(setsText, /grows with the square of the member count/);
615
+ assert.match(setsText, /Enclave v6 .*keeps metadata on a held-slot commit/s);
616
+ assert.match(setsText, /returns\s+`manifestEchoed:\s+true`/);
617
+ assert.match(setsText, /drops\s+metadata\s+returns\s+`manifestEchoed:\s+false`/);
618
+ assert.match(setsText, /bound to it by digest/);
619
+ assert.match(setsText, /`verifyMembers: true`/);
620
+ assert.match(setsText, /grows\s+with\s+the\s+square\s+of\s+the\s+member\s+count/);
621
+ assert.match(setsText, /native SHA-256/);
622
+ assert.match(setsText, /one fused copy/);
623
+ assert.match(setsText, /`onProgress`/);
605
624
  assert.match(setsText, /after the commit, so the slot TTL is not\s+at risk/);
606
625
  assert.ok(!doc.includes("\u2014"), "no em dashes");
607
626
  });
@@ -636,3 +655,154 @@ describe("wiring", () => {
636
655
  }
637
656
  });
638
657
  });
658
+
659
+ describe("fuseSet(): hashing and progress", () => {
660
+ test("28. digest(): the native hasher and the library agree at every size, and the library answers when the platform has no hasher or refuses the input", async () => {
661
+ for (const n of [0, 1, 55, 56, 63, 64, 65, 1000, 70_000, 1_000_003]) {
662
+ const b = new Uint8Array(n);
663
+ for (let i = 0; i < n; i++) b[i] = (i * 7 + n) & 0xff;
664
+ assert.equal(bytesToHex(await digest(b)), bytesToHex(sha256(b)), `size ${n}`);
665
+ }
666
+ const sample = utf8("a member hashed without WebCrypto\n");
667
+ const want = bytesToHex(sha256(sample));
668
+ const desc = Object.getOwnPropertyDescriptor(globalThis, "crypto")!;
669
+ try {
670
+ Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true, writable: true });
671
+ assert.equal(bytesToHex(await digest(sample)), want, "no platform hasher: the library");
672
+ Object.defineProperty(globalThis, "crypto", { value: { subtle: { digest: () => Promise.reject(new TypeError("refused")) } }, configurable: true, writable: true });
673
+ assert.equal(bytesToHex(await digest(sample)), want, "a refusing platform hasher: the library");
674
+ } finally {
675
+ Object.defineProperty(globalThis, "crypto", desc);
676
+ }
677
+ assert.equal(bytesToHex(await digest(sample)), want, "restored");
678
+ });
679
+
680
+ test("29. onProgress: hash and fuse per member before and after the slot, commit 0 of 1 then 1 of 1, verify only with verifyMembers; a throwing hook changes nothing", async () => {
681
+ const members: FuseSetMember[] = [...two(), { original: note, placement: "container/1" }];
682
+ const seen: FuseSetProgress[] = [];
683
+ const r = await fuseSet(members, { transport: honest(key, slot).transport, onProgress: (p) => seen.push({ ...p }) });
684
+ assert.equal(r.members.length, 3);
685
+ const of = (phase: FuseSetProgress["phase"]) => seen.filter((p) => p.phase === phase).map((p) => `${p.done}/${p.total}`);
686
+ assert.deepEqual(of("hash"), ["1/3", "2/3", "3/3"]);
687
+ assert.deepEqual(of("fuse"), ["1/3", "2/3", "3/3"]);
688
+ assert.deepEqual(of("commit"), ["0/1", "1/1"]);
689
+ assert.deepEqual(of("verify"), []);
690
+ assert.deepEqual(seen.map((p) => p.phase), ["hash", "hash", "hash", "fuse", "fuse", "fuse", "commit", "commit"], "hash before the slot, fuse after it, then the commit");
691
+ const verified: FuseSetProgress[] = [];
692
+ await fuseSet(members, { transport: honest(key, slot).transport, verifyMembers: true, onProgress: (p) => verified.push({ ...p }) });
693
+ assert.deepEqual(verified.filter((p) => p.phase === "verify").map((p) => `${p.done}/${p.total}`), ["1/3", "2/3", "3/3"]);
694
+ assert.equal(verified.findIndex((p) => p.phase === "verify") > verified.findIndex((p) => p.phase === "commit" && p.done === 1), true, "verify after the commit");
695
+ let calls = 0;
696
+ const thrown = await fuseSet(members, { transport: honest(key, slot).transport, onProgress: () => { calls++; throw new Error("a hook that throws"); } });
697
+ assert.equal(thrown.members.length, 3);
698
+ assert.equal(calls, 8, "every report was attempted");
699
+ });
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
+ });
package/src/fuse-cli.ts CHANGED
@@ -149,7 +149,8 @@ async function runSet(args: Args): Promise<number> {
149
149
  seen.set(fusedName, i);
150
150
  }
151
151
  }
152
- const r = await fuseSet(members, { keepFused: keep, transport: transportFrom(args.flags) });
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) });
153
154
  const outDir = outDirOf(args);
154
155
  await mkdir(outDir, { recursive: true });
155
156
  const written: string[] = [];
@@ -166,7 +167,7 @@ async function runSet(args: Args): Promise<number> {
166
167
  written.push(p);
167
168
  }
168
169
  report(r.proof, r.verification.category, r.recovered, false);
169
- for (const m of r.members) process.stdout.write(`member ${m.index} row ${m.manifestIndex} ${m.placement} ${m.verification.category} ${m.fusedName ?? ""}\n`);
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`);
170
171
  if (!r.manifestEchoed) process.stdout.write(`note the proof does not carry the manifest; keep set.manifest.json beside it\n`);
171
172
  process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
172
173
  return 0;