@mikeargento/bitgraph 1.2.0 → 1.4.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/__tests__/audit-memory-ingest.test.js +15 -0
- package/dist/__tests__/audit-memory-ingest.test.js.map +1 -1
- package/dist/__tests__/fuse-placement.test.d.ts +2 -0
- package/dist/__tests__/fuse-placement.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-placement.test.js +31 -0
- package/dist/__tests__/fuse-placement.test.js.map +1 -0
- package/dist/__tests__/fuse-set-sdk.test.d.ts +2 -0
- package/dist/__tests__/fuse-set-sdk.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-set-sdk.test.js +513 -0
- package/dist/__tests__/fuse-set-sdk.test.js.map +1 -0
- package/dist/__tests__/fuse-set.test.d.ts +2 -0
- package/dist/__tests__/fuse-set.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-set.test.js +863 -0
- package/dist/__tests__/fuse-set.test.js.map +1 -0
- package/dist/canonical.d.ts +54 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +119 -0
- package/dist/canonical.js.map +1 -0
- package/dist/fuse-cli.js +111 -32
- package/dist/fuse-cli.js.map +1 -1
- package/dist/fuse.d.ts +94 -3
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +275 -48
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/proof-hash.d.ts +9 -0
- package/dist/proof-hash.d.ts.map +1 -0
- package/dist/proof-hash.js +75 -0
- package/dist/proof-hash.js.map +1 -0
- package/dist/types.d.ts +705 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/verifier.d.ts +27 -0
- package/dist/verifier.d.ts.map +1 -0
- package/dist/verifier.js +844 -0
- package/dist/verifier.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/audit-memory-ingest.test.ts +16 -0
- package/src/__tests__/fuse-placement.test.ts +32 -0
- package/src/__tests__/fuse-set-sdk.test.ts +638 -0
- package/src/__tests__/fuse-set.test.ts +1000 -0
- package/src/fuse-cli.ts +97 -27
- package/src/fuse.ts +356 -46
- package/src/index.ts +4 -1
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
// Copyright (c) Mike Argento. All rights reserved. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* fuseSet() against a fake transport whose commit answer MINTS the proof from
|
|
5
|
+
* the body it received, under the slot it handed out, with a local signer:
|
|
6
|
+
* the returned proof therefore matches what the SDK sent and verifies for
|
|
7
|
+
* real. Every refusal path runs, every happy path is compared against an
|
|
8
|
+
* independent oracle, and every negative case runs against the real
|
|
9
|
+
* verifier. Nothing here touches a ledger or a network.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, before } from "node:test";
|
|
13
|
+
import * as assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { signAsync } from "@noble/ed25519";
|
|
17
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
18
|
+
import {
|
|
19
|
+
getPlacement,
|
|
20
|
+
canonicalize,
|
|
21
|
+
canonicalSlotBody,
|
|
22
|
+
computeSlotCommitment,
|
|
23
|
+
buildSetManifest,
|
|
24
|
+
parseSetManifest,
|
|
25
|
+
fuseAttribution,
|
|
26
|
+
verifyFuse,
|
|
27
|
+
verifyFuseMember,
|
|
28
|
+
bytesToBase64,
|
|
29
|
+
bytesToHex,
|
|
30
|
+
SET_METADATA_KEY,
|
|
31
|
+
} from "@mikeargento/bitgraph-verify";
|
|
32
|
+
import type { BitGraphProof, SlotAllocation, Attribution, SetMember, SetManifest } from "@mikeargento/bitgraph-verify";
|
|
33
|
+
import { makeKey, signBody, b64, utf8 } from "./audit-fixtures.js";
|
|
34
|
+
import type { ManualKey } from "./audit-fixtures.js";
|
|
35
|
+
import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError, MAX_SET_MEMBERS } from "../fuse.js";
|
|
36
|
+
import type { FuseSetMember } from "../fuse.js";
|
|
37
|
+
|
|
38
|
+
const FIX = fileURLToPath(new URL("../../src/__tests__/fuse-fixtures/", import.meta.url));
|
|
39
|
+
const bytes = (name: string) => new Uint8Array(readFileSync(FIX + name));
|
|
40
|
+
const dec = (b: Uint8Array) => new TextDecoder().decode(b);
|
|
41
|
+
|
|
42
|
+
const original = bytes("original.txt");
|
|
43
|
+
const png = bytes("image.png");
|
|
44
|
+
const note = utf8("a third member, plain text made for the set\n");
|
|
45
|
+
const unrelated = utf8("a file nobody recorded\n");
|
|
46
|
+
|
|
47
|
+
// The phase-1 fixtures fuse original.txt as trailer/1 and image.png as
|
|
48
|
+
// container/1, so these members declare those placements; the by-bytes
|
|
49
|
+
// default (placementForBytes) is asserted on its own in test 3.
|
|
50
|
+
const two = (): FuseSetMember[] => [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Local signer: slots and proofs minted from a received commit body
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const EPOCH = bytesToBase64(new Uint8Array(32).fill(0x5e));
|
|
57
|
+
|
|
58
|
+
/** A slot record signed by `key`, with a 32-byte nonce and the anchored chain. */
|
|
59
|
+
async function allocateSlot(key: ManualKey, counter: string): Promise<SlotAllocation> {
|
|
60
|
+
const body = {
|
|
61
|
+
version: "bitgraph/slot/1" as const,
|
|
62
|
+
nonceB64: b64(crypto.getRandomValues(new Uint8Array(32))),
|
|
63
|
+
counter,
|
|
64
|
+
epochId: EPOCH,
|
|
65
|
+
publicKeyB64: key.publicKeyB64,
|
|
66
|
+
chainId: "bitgraph:main",
|
|
67
|
+
};
|
|
68
|
+
return { ...body, signatureB64: b64(await signAsync(canonicalize(body), key.privateKey)) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The commit body the SDK sends, as the fake boundary receives it. */
|
|
72
|
+
interface CommitBody {
|
|
73
|
+
digests: Array<{ digestB64: string; hashAlg: string }>;
|
|
74
|
+
slotId: string;
|
|
75
|
+
slot: SlotAllocation;
|
|
76
|
+
chainId: string;
|
|
77
|
+
attribution: Attribution;
|
|
78
|
+
metadata?: Record<string, unknown>;
|
|
79
|
+
agency?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface MintOptions {
|
|
83
|
+
key: ManualKey;
|
|
84
|
+
slot: SlotAllocation;
|
|
85
|
+
/** The received body: its digest, attribution and metadata become the proof's. */
|
|
86
|
+
body: CommitBody;
|
|
87
|
+
commitCounter?: string;
|
|
88
|
+
/** Attach the body's metadata under proof.metadata (default true). */
|
|
89
|
+
withMetadata?: boolean;
|
|
90
|
+
/** Override the signed attribution; null for none at all. */
|
|
91
|
+
attribution?: Attribution | null;
|
|
92
|
+
/** Override the artifact digest; the proof stays validly signed. */
|
|
93
|
+
artifactDigestB64?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A proof minted from a received body under `slot`: the digest is the artifact, the attribution is signed, the slot is consumed. */
|
|
97
|
+
async function mintFromBody(o: MintOptions): Promise<BitGraphProof> {
|
|
98
|
+
const commit: BitGraphProof["commit"] = {
|
|
99
|
+
nonceB64: o.slot.nonceB64,
|
|
100
|
+
counter: o.commitCounter ?? (BigInt(o.slot.counter) + 4n).toString(),
|
|
101
|
+
epochId: o.slot.epochId,
|
|
102
|
+
slotCounter: o.slot.counter,
|
|
103
|
+
slotHashB64: b64(sha256(canonicalize(canonicalSlotBody(o.slot)))),
|
|
104
|
+
};
|
|
105
|
+
(commit as unknown as Record<string, unknown>)["chainId"] = "bitgraph:main";
|
|
106
|
+
const attribution = o.attribution === undefined ? o.body.attribution : o.attribution;
|
|
107
|
+
const proof = await signBody(
|
|
108
|
+
o.key,
|
|
109
|
+
{ hashAlg: "sha256", digestB64: o.artifactDigestB64 ?? o.body.digests[0]!.digestB64 },
|
|
110
|
+
commit,
|
|
111
|
+
"test-measurement-set",
|
|
112
|
+
attribution === null ? undefined : { attribution },
|
|
113
|
+
);
|
|
114
|
+
proof.slotAllocation = o.slot;
|
|
115
|
+
if (o.withMetadata !== false && o.body.metadata !== undefined) proof.metadata = structuredClone(o.body.metadata);
|
|
116
|
+
return proof;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Fake transport, in the fuse-sdk style
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
interface Call { path: string; body: unknown }
|
|
124
|
+
interface Answer { status: number; json: unknown }
|
|
125
|
+
type CommitAnswer = (calls: Call[], body: CommitBody) => Answer | Promise<Answer>;
|
|
126
|
+
type LookupAnswer = (calls: Call[]) => Answer | Promise<Answer>;
|
|
127
|
+
type AllocateAnswer = (calls: Call[]) => Answer;
|
|
128
|
+
|
|
129
|
+
/** A transport that hands out `slot` and answers the commit with `commit`; a lookup and an allocate answer are optional. */
|
|
130
|
+
function fakeTransport(slot: SlotAllocation, commit: CommitAnswer, lookup?: LookupAnswer, allocate?: AllocateAnswer) {
|
|
131
|
+
const calls: Call[] = [];
|
|
132
|
+
const f: typeof fetch = async (input, init) => {
|
|
133
|
+
const url = String(input);
|
|
134
|
+
const path = url.replace(/^https?:\/\/[^/]+/, "");
|
|
135
|
+
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
136
|
+
calls.push({ path, body });
|
|
137
|
+
const reply = (r: Answer) => new Response(JSON.stringify(r.json), { status: r.status, headers: { "Content-Type": "application/json" } });
|
|
138
|
+
if (path === "/api/fuse/allocate") return reply(allocate ? allocate(calls) : { status: 200, json: { slotId: slot.nonceB64, slot, chainId: "bitgraph:main" } });
|
|
139
|
+
if (path === "/api/fuse/commit") return reply(await commit(calls, body as CommitBody));
|
|
140
|
+
if (path.startsWith("/api/proofs/")) return reply(lookup ? await lookup(calls) : { status: 200, json: { proofs: [] } });
|
|
141
|
+
return reply({ status: 404, json: { error: "no route" } });
|
|
142
|
+
};
|
|
143
|
+
return { calls, transport: { baseUrl: "https://example.test", fetch: f, recoveryAttempts: 2, recoveryDelayMs: 1 } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The honest boundary: mints the proof from the received body under the slot it handed out. */
|
|
147
|
+
function honest(key: ManualKey, slot: SlotAllocation, mint: Omit<MintOptions, "key" | "slot" | "body"> = {}) {
|
|
148
|
+
return fakeTransport(slot, async (_calls, body) => ({ status: 200, json: { proof: await mintFromBody({ key, slot, body, ...mint }) } }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const allocates = (calls: Call[]) => calls.filter((c) => c.path === "/api/fuse/allocate");
|
|
152
|
+
const commits = (calls: Call[]) => calls.filter((c) => c.path === "/api/fuse/commit");
|
|
153
|
+
const toUrlSafe = (s: string) => s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// The oracle: what the SDK must send, computed independently
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
interface Oracle { commitment: Uint8Array; fused: Uint8Array[]; rows: SetMember[]; manifest: Uint8Array; digestB64: string; sorted: SetMember[] }
|
|
160
|
+
|
|
161
|
+
/** 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
|
+
const commitment = computeSlotCommitment(slot);
|
|
164
|
+
const placementOf = (m: FuseSetMember) => m.placement ?? placementForBytes(m.original);
|
|
165
|
+
const fused = members.map((m) => getPlacement(placementOf(m))!.build({ original: m.original, commitment }));
|
|
166
|
+
const rows = members.map((m, i) => ({ artifact: sha256(fused[i]!), origin: sha256(m.original), placement: placementOf(m) }));
|
|
167
|
+
const manifest = buildSetManifest(commitment, rows);
|
|
168
|
+
return { commitment, fused, rows, manifest, digestB64: bytesToBase64(sha256(manifest)), sorted: parseSetManifest(manifest)!.members };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const expectedBody = (slot: SlotAllocation, o: Oracle): CommitBody => ({
|
|
172
|
+
digests: [{ digestB64: o.digestB64, hashAlg: "sha256" }],
|
|
173
|
+
slotId: slot.nonceB64,
|
|
174
|
+
slot,
|
|
175
|
+
chainId: "bitgraph:main",
|
|
176
|
+
attribution: { name: "bitgraph-fuse/1", title: "set/1" },
|
|
177
|
+
metadata: { [SET_METADATA_KEY]: JSON.parse(dec(o.manifest)) as SetManifest },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
let key!: ManualKey;
|
|
181
|
+
let slot!: SlotAllocation;
|
|
182
|
+
let other!: SlotAllocation;
|
|
183
|
+
|
|
184
|
+
before(async () => {
|
|
185
|
+
key = await makeKey();
|
|
186
|
+
slot = await allocateSlot(key, "10");
|
|
187
|
+
other = await allocateSlot(key, "20");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// The beats
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
describe("fuseSet(): one slot, N files, the manifest as the artifact", () => {
|
|
195
|
+
test("1. a trailer/1 and a container/1 member: one allocate, one commit, the whole body byte for byte against the oracle", async () => {
|
|
196
|
+
const members = two();
|
|
197
|
+
const o = oracle(slot, members);
|
|
198
|
+
const { calls, transport } = honest(key, slot);
|
|
199
|
+
await fuseSet(members, { transport });
|
|
200
|
+
assert.equal(allocates(calls).length, 1);
|
|
201
|
+
assert.equal(commits(calls).length, 1);
|
|
202
|
+
const body = commits(calls)[0]!.body as Record<string, unknown>;
|
|
203
|
+
assert.deepEqual(body, expectedBody(slot, o));
|
|
204
|
+
assert.deepEqual(Object.keys(body), ["digests", "slotId", "slot", "chainId", "attribution", "metadata"]);
|
|
205
|
+
assert.deepEqual(body.attribution, { name: "bitgraph-fuse/1", title: "set/1" });
|
|
206
|
+
assert.ok(!("message" in (body.attribution as object)), "a set has no single origin");
|
|
207
|
+
assert.ok(!("agency" in body));
|
|
208
|
+
assert.deepEqual(body.metadata, { [SET_METADATA_KEY]: JSON.parse(dec(o.manifest)) });
|
|
209
|
+
// The raw nonce rides only where the protocol needs it: the slot record and the slotId that names it.
|
|
210
|
+
const { slot: _slot, slotId: _slotId, ...rest } = body;
|
|
211
|
+
assert.ok(!JSON.stringify(rest).includes(slot.nonceB64));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("2. the result: manifest bytes, digests, commitment, flags, and the manifest's own verification", async () => {
|
|
215
|
+
const members = two();
|
|
216
|
+
const o = oracle(slot, members);
|
|
217
|
+
const r = await fuseSet(members, { transport: honest(key, slot).transport });
|
|
218
|
+
assert.deepEqual(r.manifestBytes, o.manifest);
|
|
219
|
+
assert.equal(r.artifactDigestB64, bytesToBase64(sha256(r.manifestBytes)));
|
|
220
|
+
assert.equal(r.artifactDigestB64, r.proof.artifact.digestB64);
|
|
221
|
+
assert.equal(r.artifactDigestB64, o.digestB64);
|
|
222
|
+
assert.deepEqual(r.manifest, JSON.parse(dec(r.manifestBytes)));
|
|
223
|
+
assert.equal(r.slotCommitmentB64, bytesToBase64(o.commitment));
|
|
224
|
+
assert.equal(r.recovered, false);
|
|
225
|
+
assert.equal(r.manifestEchoed, true);
|
|
226
|
+
assert.equal(r.verification.category, "FUSED_DIRECT", r.verification.reason ?? "");
|
|
227
|
+
assert.equal(r.verification.placement, "set/1");
|
|
228
|
+
assert.equal(r.verification.originDigestB64, null);
|
|
229
|
+
assert.equal(r.proof.attribution?.title, "set/1");
|
|
230
|
+
});
|
|
231
|
+
|
|
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
|
+
const members = two();
|
|
234
|
+
members[0]!.name = "photo.jpg";
|
|
235
|
+
const o = oracle(slot, members);
|
|
236
|
+
const r = await fuseSet(members, { transport: honest(key, slot).transport });
|
|
237
|
+
assert.deepEqual(r.members.map((m) => m.index), [0, 1]);
|
|
238
|
+
for (const m of r.members) {
|
|
239
|
+
const row = o.rows[m.index]!;
|
|
240
|
+
assert.equal(m.placement, row.placement);
|
|
241
|
+
assert.equal(m.originDigestB64, bytesToBase64(row.origin));
|
|
242
|
+
assert.equal(m.artifactDigestB64, bytesToBase64(row.artifact));
|
|
243
|
+
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.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);
|
|
250
|
+
}
|
|
251
|
+
assert.deepEqual({ fusedName: r.members[0]!.fusedName, frameName: r.members[0]!.frameName }, fusedNamesFor("photo.jpg", "trailer/1"));
|
|
252
|
+
assert.equal(r.members[1]!.fusedName, null);
|
|
253
|
+
assert.equal(r.members[1]!.frameName, null);
|
|
254
|
+
// The by-bytes default: PNG is trailer-safe, plain text goes into a container.
|
|
255
|
+
const d = await fuseSet([{ original: png }, { original }], { transport: honest(key, slot).transport });
|
|
256
|
+
assert.deepEqual(d.members.map((m) => m.placement), [placementForBytes(png), placementForBytes(original)]);
|
|
257
|
+
assert.deepEqual(d.members.map((m) => m.placement), ["trailer/1", "container/1"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("4. each original rebuilds its member (SET_MEMBER_FROM_ORIGIN); with the echo stripped and no explicit bytes nothing is bound", async () => {
|
|
261
|
+
const members = two();
|
|
262
|
+
const r = await fuseSet(members, { transport: honest(key, slot).transport });
|
|
263
|
+
for (const m of r.members) {
|
|
264
|
+
const v = await verifyFuseMember({ proof: r.proof, bytes: members[m.index]!.original, manifest: r.manifestBytes });
|
|
265
|
+
assert.equal(v.category, "SET_MEMBER_FROM_ORIGIN", v.reason ?? "");
|
|
266
|
+
assert.equal(v.set!.member!.index, m.manifestIndex);
|
|
267
|
+
assert.equal(v.placement, m.placement);
|
|
268
|
+
}
|
|
269
|
+
const stripped = structuredClone(r.proof);
|
|
270
|
+
delete stripped.metadata;
|
|
271
|
+
const fused = getPlacement("trailer/1")!.build({ original, commitment: computeSlotCommitment(slot) });
|
|
272
|
+
const unbound = await verifyFuseMember({ proof: stripped, bytes: fused });
|
|
273
|
+
assert.equal(unbound.category, "INVALID_SET_MANIFEST");
|
|
274
|
+
assert.equal(unbound.set, null);
|
|
275
|
+
// Which is why manifestBytes is kept beside the proof.
|
|
276
|
+
assert.equal((await verifyFuseMember({ proof: stripped, bytes: fused, manifest: r.manifestBytes })).category, "SET_MEMBER_DIRECT");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("5. verifyFuse answers for the manifest and knows nothing about a member", async () => {
|
|
280
|
+
const r = await fuseSet(two(), { transport: honest(key, slot).transport });
|
|
281
|
+
const m = await verifyFuse({ proof: r.proof, bytes: r.manifestBytes });
|
|
282
|
+
assert.equal(m.category, "FUSED_DIRECT", m.reason ?? "");
|
|
283
|
+
assert.equal(m.placement, "set/1");
|
|
284
|
+
const fused = getPlacement("trailer/1")!.build({ original, commitment: computeSlotCommitment(slot) });
|
|
285
|
+
assert.equal((await verifyFuse({ proof: r.proof, bytes: fused })).category, "NO_MATCH");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("6. an agency envelope passes through untouched, and is absent otherwise", async () => {
|
|
289
|
+
const a = honest(key, slot);
|
|
290
|
+
await fuseSet(two(), { transport: a.transport, agency: { x: 1 } });
|
|
291
|
+
assert.deepEqual((commits(a.calls)[0]!.body as { agency?: unknown }).agency, { x: 1 });
|
|
292
|
+
const b = honest(key, slot);
|
|
293
|
+
await fuseSet(two(), { transport: b.transport });
|
|
294
|
+
assert.ok(!("agency" in (commits(b.calls)[0]!.body as object)));
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Refusals after the slot is held: nothing is committed
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe("fuseSet(): a bad member burns the slot and commits nothing", () => {
|
|
303
|
+
test("7. a member whose builder drops the commitment is commitment-missing, naming the member; no commit, one allocate", async () => {
|
|
304
|
+
const { calls, transport } = honest(key, slot);
|
|
305
|
+
const members = two();
|
|
306
|
+
members[1]!.builder = () => original;
|
|
307
|
+
await assert.rejects(fuseSet(members, { transport }), (e: FuseError) => {
|
|
308
|
+
assert.equal(e.code, "commitment-missing");
|
|
309
|
+
assert.match(e.message, /^member 1:/);
|
|
310
|
+
assert.equal(e.member, 1);
|
|
311
|
+
assert.ok(!e.message.includes(slot.nonceB64));
|
|
312
|
+
return true;
|
|
313
|
+
});
|
|
314
|
+
assert.equal(commits(calls).length, 0, "no commit was attempted");
|
|
315
|
+
assert.equal(allocates(calls).length, 1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("8. a builder that throws, or returns something other than bytes, is builder-failed naming the member", async () => {
|
|
319
|
+
const a = honest(key, slot);
|
|
320
|
+
const throwing = two();
|
|
321
|
+
throwing[0]!.builder = () => { throw new Error("disk full"); };
|
|
322
|
+
await assert.rejects(fuseSet(throwing, { transport: a.transport }), (e: FuseError) => e.code === "builder-failed" && /^member 0: the builder threw: disk full/.test(e.message) && e.member === 0);
|
|
323
|
+
assert.equal(commits(a.calls).length, 0);
|
|
324
|
+
const b = honest(key, slot);
|
|
325
|
+
const stringy = two();
|
|
326
|
+
stringy[1]!.builder = (() => "not bytes") as never;
|
|
327
|
+
await assert.rejects(fuseSet(stringy, { transport: b.transport }), (e: FuseError) => e.code === "builder-failed" && /^member 1: the builder must return a Uint8Array/.test(e.message) && e.member === 1);
|
|
328
|
+
assert.equal(commits(b.calls).length, 0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("9. bytes that carry the commitment but embed another member's origin are builder-failed naming the member and the origin", async () => {
|
|
332
|
+
const { calls, transport } = honest(key, slot);
|
|
333
|
+
// 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" }];
|
|
335
|
+
members[1]!.builder = ({ commitment }) => getPlacement("trailer/1")!.build({ original, commitment });
|
|
336
|
+
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
|
+
assert.equal(commits(calls).length, 0);
|
|
338
|
+
assert.equal(allocates(calls).length, 1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
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
|
+
const { calls, transport } = honest(key, slot);
|
|
343
|
+
const members: FuseSetMember[] = [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
|
|
344
|
+
// The payload names png's digest (so the declared origin matches) while the tar carries other bytes.
|
|
345
|
+
members[1]!.builder = ({ commitment, originDigest }) => getPlacement("container/1")!.build({ original: utf8("not the member's original\n"), originDigest: originDigest!, commitment });
|
|
346
|
+
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
|
+
assert.equal(commits(calls).length, 0, "nothing was committed");
|
|
348
|
+
assert.equal(allocates(calls).length, 1);
|
|
349
|
+
// 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" }];
|
|
351
|
+
declared[1]!.builder = ({ commitment }) => getPlacement("container/1")!.build({ original: png, originDigest: sha256(unrelated), commitment });
|
|
352
|
+
const b = honest(key, slot);
|
|
353
|
+
await assert.rejects(fuseSet(declared, { transport: b.transport }), (e: FuseError) => e.code === "builder-failed" && e.member === 1);
|
|
354
|
+
assert.equal(commits(b.calls).length, 0);
|
|
355
|
+
// 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");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Refusals before any request: no slot is burned
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
describe("fuseSet(): bad input is refused before any request", () => {
|
|
366
|
+
test("10. the same original under the same placement twice names both members; under two placements it is two rows", async () => {
|
|
367
|
+
const { calls, transport } = honest(key, slot);
|
|
368
|
+
await assert.rejects(
|
|
369
|
+
fuseSet([{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }, { original, placement: "trailer/1" }], { transport }),
|
|
370
|
+
(e: FuseError) => e.code === "bad-input" && /members 0 and 2/.test(e.message) && e.member === 2,
|
|
371
|
+
);
|
|
372
|
+
assert.equal(calls.length, 0, "no slot was burned");
|
|
373
|
+
const r = await fuseSet([{ original, placement: "trailer/1" }, { original, placement: "container/1" }], { transport });
|
|
374
|
+
assert.equal(r.manifest.members.length, 2);
|
|
375
|
+
assert.deepEqual(r.members.map((m) => m.placement), ["trailer/1", "container/1"]);
|
|
376
|
+
assert.equal(r.members[0]!.originDigestB64, r.members[1]!.originDigestB64);
|
|
377
|
+
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");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("11. an empty set and a set over the cap are refused; the cap runs before the duplicate check", async () => {
|
|
382
|
+
const { calls, transport } = honest(key, slot);
|
|
383
|
+
await assert.rejects(fuseSet([], { transport }), (e: FuseError) => e.code === "bad-input" && /at least one/.test(e.message));
|
|
384
|
+
const same: FuseSetMember = { original, placement: "trailer/1" };
|
|
385
|
+
await assert.rejects(
|
|
386
|
+
fuseSet(new Array<FuseSetMember>(MAX_SET_MEMBERS + 1).fill(same), { transport }),
|
|
387
|
+
(e: FuseError) => e.code === "bad-input" && /at most 2000 members \(got 2001\)/.test(e.message) && !/members 0 and 1/.test(e.message),
|
|
388
|
+
);
|
|
389
|
+
assert.equal(MAX_SET_MEMBERS, 2000);
|
|
390
|
+
assert.equal(calls.length, 0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("12. a produced/1, set/1, unregistered, or non-bytes member is refused naming the member", async () => {
|
|
394
|
+
const { calls, transport } = honest(key, slot);
|
|
395
|
+
for (const id of ["produced/1", "set/1"]) {
|
|
396
|
+
await assert.rejects(
|
|
397
|
+
fuseSet([{ original, placement: "trailer/1" }, { original: png, placement: id as never }], { transport }),
|
|
398
|
+
(e: FuseError) => e.code === "bad-input" && /^member 1: /.test(e.message) && e.message.includes(id) && e.member === 1,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
await assert.rejects(
|
|
402
|
+
fuseSet([{ original, placement: "xmp/9" as never }], { transport }),
|
|
403
|
+
(e: FuseError) => e.code === "bad-placement" && /^member 0: placement "xmp\/9" is not registered/.test(e.message) && e.member === 0,
|
|
404
|
+
);
|
|
405
|
+
await assert.rejects(
|
|
406
|
+
fuseSet([{ original: "text" as never }], { transport }),
|
|
407
|
+
(e: FuseError) => e.code === "bad-input" && /^member 0: original must be a Uint8Array/.test(e.message) && e.member === 0,
|
|
408
|
+
);
|
|
409
|
+
assert.equal(calls.length, 0);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("12b. a null, undefined or missing member element is bad-input naming the member, never a bare TypeError", async () => {
|
|
413
|
+
const { calls, transport } = honest(key, slot);
|
|
414
|
+
const first: FuseSetMember = { original, placement: "trailer/1" };
|
|
415
|
+
const sparse: FuseSetMember[] = [first];
|
|
416
|
+
sparse.length = 2;
|
|
417
|
+
for (const members of [[first, null], [first, undefined], sparse, [null]] as unknown as FuseSetMember[][]) {
|
|
418
|
+
const expected = members.length === 1 ? 0 : 1;
|
|
419
|
+
await assert.rejects(fuseSet(members, { transport }), (e: unknown) => {
|
|
420
|
+
assert.ok(e instanceof FuseError, `not a FuseError: ${String(e)}`);
|
|
421
|
+
assert.equal(e.code, "bad-input");
|
|
422
|
+
assert.match(e.message, new RegExp(`^member ${expected}: original must be a Uint8Array`));
|
|
423
|
+
assert.equal(e.member, expected);
|
|
424
|
+
return true;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
assert.equal(calls.length, 0, "no slot was burned");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("13. the member cap keeps a full manifest under half the parent's 1 MB body cap", () => {
|
|
431
|
+
const rows: SetMember[] = Array.from({ length: MAX_SET_MEMBERS }, (_, i) => ({ artifact: sha256(utf8(`artifact ${i}`)), origin: sha256(utf8(`origin ${i}`)), placement: "container/1" }));
|
|
432
|
+
const manifest = buildSetManifest(computeSlotCommitment(slot), rows);
|
|
433
|
+
assert.ok(manifest.length + 4096 < 512 * 1024, `${manifest.length} bytes`);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// The boundary: other slots, lost responses, refusals, restarts
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
describe("fuseSet(): the boundary", () => {
|
|
442
|
+
test("14. a proof under a different slot is never labelled a set", async () => {
|
|
443
|
+
const { transport } = fakeTransport(slot, async (_calls, body) => ({ status: 200, json: { proof: await mintFromBody({ key, slot: other, body }) } }));
|
|
444
|
+
await assert.rejects(fuseSet(two(), { transport }), (e: FuseError) => e.code === "slot-mismatch");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("15. a lost commit response is read back by the MANIFEST digest and matched on the held slot; nothing is re-allocated", async () => {
|
|
448
|
+
const o = oracle(slot, two());
|
|
449
|
+
const real = await mintFromBody({ key, slot, body: expectedBody(slot, o) });
|
|
450
|
+
// The decoy commits the same digest under another slot: recovery must match the slot, not the digest alone.
|
|
451
|
+
const decoy = await mintFromBody({ key, slot: other, body: expectedBody(slot, o) });
|
|
452
|
+
const { calls, transport } = fakeTransport(
|
|
453
|
+
slot,
|
|
454
|
+
() => { throw new Error("socket hang up"); },
|
|
455
|
+
() => ({ status: 200, json: { proofs: [{ proof: decoy }, { proof: real }] } }),
|
|
456
|
+
);
|
|
457
|
+
const r = await fuseSet(two(), { transport });
|
|
458
|
+
assert.equal(r.recovered, true);
|
|
459
|
+
assert.deepEqual(r.proof, real);
|
|
460
|
+
assert.equal(allocates(calls).length, 1);
|
|
461
|
+
const lookup = calls.find((c) => c.path.startsWith("/api/proofs/"))!;
|
|
462
|
+
assert.ok(lookup.path.includes(toUrlSafe(o.digestB64)), lookup.path);
|
|
463
|
+
assert.equal(r.manifestEchoed, true);
|
|
464
|
+
for (const m of r.members) assert.equal(m.verification.category, "SET_MEMBER_DIRECT");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
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 () => {
|
|
468
|
+
const o = oracle(slot, two());
|
|
469
|
+
const real = await mintFromBody({ key, slot, body: expectedBody(slot, o) });
|
|
470
|
+
const gone = () => ({ status: 409, json: { error: "gone", code: "slot-unavailable" } });
|
|
471
|
+
const a = fakeTransport(slot, gone, () => ({ status: 200, json: { proofs: [{ proof: real }] } }));
|
|
472
|
+
const r = await fuseSet(two(), { transport: a.transport });
|
|
473
|
+
assert.equal(r.recovered, true);
|
|
474
|
+
assert.deepEqual(r.proof, real);
|
|
475
|
+
const b = fakeTransport(slot, gone);
|
|
476
|
+
await assert.rejects(fuseSet(two(), { transport: b.transport }), (e: FuseError) => e.code === "slot-unavailable" && e.status === 409 && !e.message.includes(slot.nonceB64));
|
|
477
|
+
assert.equal(allocates(b.calls).length, 1);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("17. tee-restarting: on allocate nothing is committed; on commit the ledger is read back first, then reported", async () => {
|
|
481
|
+
const restarting = () => ({ status: 503, json: { error: "restarting", code: "tee-restarting" } });
|
|
482
|
+
const a = fakeTransport(slot, () => ({ status: 200, json: {} }), undefined, restarting);
|
|
483
|
+
await assert.rejects(fuseSet(two(), { transport: a.transport }), (e: FuseError) => e.code === "tee-restarting" && e.status === 503);
|
|
484
|
+
assert.equal(commits(a.calls).length, 0);
|
|
485
|
+
const o = oracle(slot, two());
|
|
486
|
+
const real = await mintFromBody({ key, slot, body: expectedBody(slot, o) });
|
|
487
|
+
const b = fakeTransport(slot, restarting, () => ({ status: 200, json: { proofs: [{ proof: real }] } }));
|
|
488
|
+
assert.equal((await fuseSet(two(), { transport: b.transport })).recovered, true);
|
|
489
|
+
const c = fakeTransport(slot, restarting);
|
|
490
|
+
await assert.rejects(fuseSet(two(), { transport: c.transport }), (e: FuseError) => e.code === "tee-restarting");
|
|
491
|
+
assert.equal(allocates(c.calls).length, 1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("18. a slot off the anchored chain is refused; nothing is built or committed", async () => {
|
|
495
|
+
const { calls, transport } = honest(key, { ...slot, chainId: "global" });
|
|
496
|
+
await assert.rejects(fuseSet(two(), { transport }), (e: FuseError) => e.code === "allocate-failed" && /anchored chain/.test(e.message));
|
|
497
|
+
assert.equal(commits(calls).length, 0);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
// The result and the verification at return
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
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 () => {
|
|
507
|
+
const members = two();
|
|
508
|
+
const commitment = computeSlotCommitment(slot);
|
|
509
|
+
const kept = await fuseSet(members, { transport: honest(key, slot).transport, keepFused: true });
|
|
510
|
+
for (const m of kept.members) {
|
|
511
|
+
assert.deepEqual(m.fusedBytes, getPlacement(m.placement)!.build({ original: members[m.index]!.original, commitment }));
|
|
512
|
+
assert.equal(m.verification.category, "SET_MEMBER_DIRECT");
|
|
513
|
+
}
|
|
514
|
+
for (const opts of [{ keepFused: false }, {}]) {
|
|
515
|
+
const r = await fuseSet(members, { transport: honest(key, slot).transport, ...opts });
|
|
516
|
+
for (const m of r.members) {
|
|
517
|
+
assert.ok(!("fusedBytes" in m));
|
|
518
|
+
assert.equal(m.verification.category, "SET_MEMBER_DIRECT");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
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 });
|
|
525
|
+
assert.equal(r.manifestEchoed, false);
|
|
526
|
+
assert.equal(r.proof.metadata, undefined);
|
|
527
|
+
assert.equal(r.verification.category, "FUSED_DIRECT");
|
|
528
|
+
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");
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("21. an echoed manifest that differs from the committed one is verification-failed; no result", async () => {
|
|
535
|
+
const { transport } = fakeTransport(slot, async (_calls, body) => {
|
|
536
|
+
const proof = await mintFromBody({ key, slot, body });
|
|
537
|
+
const m = proof.metadata![SET_METADATA_KEY] as SetManifest;
|
|
538
|
+
m.members[0]!.placement = m.members[0]!.placement === "trailer/1" ? "container/1" : "trailer/1";
|
|
539
|
+
return { status: 200, json: { proof } };
|
|
540
|
+
});
|
|
541
|
+
await assert.rejects(fuseSet(two(), { transport }), (e: FuseError) => e.code === "verification-failed" && /echoes/.test(e.message));
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("22. a proof committing another digest, or signed under another title, is verification-failed", async () => {
|
|
545
|
+
const a = honest(key, slot, { artifactDigestB64: bytesToBase64(sha256(unrelated)) });
|
|
546
|
+
await assert.rejects(fuseSet(two(), { transport: a.transport }), (e: FuseError) => e.code === "verification-failed" && /NO_MATCH/.test(e.message) && /as a set/.test(e.message));
|
|
547
|
+
const b = honest(key, slot, { attribution: fuseAttribution("trailer/1") });
|
|
548
|
+
await assert.rejects(fuseSet(two(), { transport: b.transport }), (e: FuseError) => e.code === "verification-failed");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("23. the 51st file against the real verifier: bytes carrying the commitment but listed nowhere are SET_NOT_MEMBER; unrelated bytes NO_MATCH", async () => {
|
|
552
|
+
const r = await fuseSet(two(), { transport: honest(key, slot).transport });
|
|
553
|
+
const stray = getPlacement("trailer/1")!.build({ original: unrelated, commitment: computeSlotCommitment(slot) });
|
|
554
|
+
const s = await verifyFuseMember({ proof: r.proof, bytes: stray, manifest: r.manifestBytes });
|
|
555
|
+
assert.equal(s.category, "SET_NOT_MEMBER", s.reason ?? "");
|
|
556
|
+
assert.equal(s.set!.member, null);
|
|
557
|
+
assert.match(s.reason!, /not among the 2 members/);
|
|
558
|
+
const n = await verifyFuseMember({ proof: r.proof, bytes: unrelated, manifest: r.manifestBytes });
|
|
559
|
+
assert.equal(n.category, "NO_MATCH");
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// fuse() regression and wiring
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
describe("wiring", () => {
|
|
568
|
+
test("24. fuse() through the same transport still sends the placement and origin in the attribution and no metadata", async () => {
|
|
569
|
+
const { calls, transport } = honest(key, slot);
|
|
570
|
+
const r = await fuse(builderFor("trailer/1", original), { placement: "trailer/1", original, transport });
|
|
571
|
+
assert.equal(r.verification.category, "FUSED_DIRECT", r.verification.reason ?? "");
|
|
572
|
+
assert.equal(r.frame.type, "bitgraph-fuse/1");
|
|
573
|
+
const body = commits(calls)[0]!.body as Record<string, unknown>;
|
|
574
|
+
assert.deepEqual(body.attribution, { name: "bitgraph-fuse/1", title: "trailer/1", message: bytesToBase64(sha256(original)) });
|
|
575
|
+
assert.ok(!("metadata" in body));
|
|
576
|
+
assert.deepEqual(Object.keys(body), ["digests", "slotId", "slot", "chainId", "attribution"]);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("25. test:core lists this file and the core index exports fuseSet and MAX_SET_MEMBERS", () => {
|
|
580
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8")) as { scripts: Record<string, string> };
|
|
581
|
+
assert.ok(pkg.scripts["test:core"]!.split(" ").includes("dist/__tests__/fuse-set-sdk.test.js"));
|
|
582
|
+
const index = readFileSync(fileURLToPath(new URL("../../src/index.ts", import.meta.url)), "utf8");
|
|
583
|
+
assert.match(index, /export \{[^}]*\bfuseSet\b[^}]*\} from "\.\/fuse\.js"/);
|
|
584
|
+
assert.match(index, /export \{[^}]*\bMAX_SET_MEMBERS\b[^}]*\} from "\.\/fuse\.js"/);
|
|
585
|
+
assert.equal(typeof fuseSet, "function");
|
|
586
|
+
assert.equal(MAX_SET_MEMBERS, 2000);
|
|
587
|
+
// The verify-package types the set results are made of are reachable from the core entry.
|
|
588
|
+
const typeExports = [...index.matchAll(/export type \{([^}]*)\} from "@mikeargento\/bitgraph-verify"/g)].flatMap((m) => m[1]!.split(",").map((s) => s.trim()));
|
|
589
|
+
for (const name of ["SetManifest", "FuseMemberResult", "FuseVerifyResult", "FuseFrame", "PlacementId"]) assert.ok(typeExports.includes(name), `index.ts re-exports type ${name}`);
|
|
590
|
+
});
|
|
591
|
+
|
|
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", () => {
|
|
593
|
+
const doc = readFileSync(fileURLToPath(new URL("../../docs/fuse/FUSE.md", import.meta.url)), "utf8");
|
|
594
|
+
const headings = [...doc.matchAll(/^(##+) (.+)$/gm)].map((m) => `${m[1]} ${m[2]}`);
|
|
595
|
+
const sets = headings.indexOf("### Sets");
|
|
596
|
+
assert.ok(sets >= 0, "a Sets subsection");
|
|
597
|
+
assert.equal(headings[sets + 1], "### Harness", "the single-fuse harness copy sits under its own heading, not under Sets");
|
|
598
|
+
assert.equal(headings[sets + 2], "## Ledger");
|
|
599
|
+
const setsText = doc.slice(doc.indexOf("### Sets"), doc.indexOf("### Harness"));
|
|
600
|
+
assert.ok(!setsText.includes("Bounded copy, verbatim"), "no bounded copy inside Sets");
|
|
601
|
+
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/);
|
|
605
|
+
assert.match(setsText, /after the commit, so the slot TTL is not\s+at risk/);
|
|
606
|
+
assert.ok(!doc.includes("\u2014"), "no em dashes");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("27. bitgraph-fuse set --keep refuses two inputs that would share a fused name before any request; without --keep they proceed", async () => {
|
|
610
|
+
const { mkdtemp, mkdir, writeFile: write, rm } = await import("node:fs/promises");
|
|
611
|
+
const { tmpdir } = await import("node:os");
|
|
612
|
+
const { join } = await import("node:path");
|
|
613
|
+
const { execFile } = await import("node:child_process");
|
|
614
|
+
const { promisify } = await import("node:util");
|
|
615
|
+
const run = promisify(execFile);
|
|
616
|
+
const cli = fileURLToPath(new URL("../fuse-cli.js", import.meta.url));
|
|
617
|
+
const dir = await mkdtemp(join(tmpdir(), "bitgraph-set-cli-"));
|
|
618
|
+
try {
|
|
619
|
+
await mkdir(join(dir, "a"));
|
|
620
|
+
await mkdir(join(dir, "b"));
|
|
621
|
+
await write(join(dir, "a", "x.txt"), "one\n");
|
|
622
|
+
await write(join(dir, "b", "x.txt"), "two\n");
|
|
623
|
+
// Nothing listens on port 1: a request would fail as a network error, so exit 64 with the collision message proves no request was made.
|
|
624
|
+
const flags = ["--out", join(dir, "out"), "--base-url", "http://127.0.0.1:1"];
|
|
625
|
+
type Run = { code: number; stdout: string; stderr: string };
|
|
626
|
+
const attempt = (args: string[]): Promise<Run> => run(process.execPath, [cli, ...args]).then((r) => ({ code: 0, ...r }), (e: Run) => e);
|
|
627
|
+
const kept = await attempt(["set", join(dir, "a", "x.txt"), join(dir, "b", "x.txt"), "--keep", ...flags]);
|
|
628
|
+
assert.equal(kept.code, 64, kept.stderr);
|
|
629
|
+
assert.match(kept.stderr, /members 0 and 1 would both be written as x\.fused\.tar/);
|
|
630
|
+
assert.ok(!/no fused proof was completed/.test(kept.stderr), "refused before fuseSet ran");
|
|
631
|
+
const loose = await attempt(["set", join(dir, "a", "x.txt"), join(dir, "b", "x.txt"), ...flags]);
|
|
632
|
+
assert.equal(loose.code, 1, loose.stderr);
|
|
633
|
+
assert.match(loose.stderr, /no fused proof was completed \(network\)/, "without --keep the set reached the allocate request");
|
|
634
|
+
} finally {
|
|
635
|
+
await rm(dir, { recursive: true, force: true });
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
});
|