| 1 | import assert from "node:assert/strict" ; |
| 2 | import {readFile} from "node:fs/promises" ; |
| 3 | import test from "node:test" ; |
| 4 | import vm from "node:vm" ; |
| 5 | |
| 6 | const source = await readFile(new URL("../tinyclient/blossom-manifests.js" , import.meta.url), "utf8" ); |
| 7 | const shared = await readFile(new URL("../tinyclient/tiny.js" , import.meta.url), "utf8" ); |
| 8 | const sandbox = {TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, crypto: (await import("node:crypto" )).webcrypto}; |
| 9 | sandbox.globalThis = sandbox; |
| 10 | vm.runInNewContext(shared + source, sandbox); |
| 11 | const api = sandbox.tiny.blossom.manifests; |
| 12 | const hash = value => value.repeat(64); |
| 13 | const bytes = hex => Uint8Array.from(hex.match(/../g), value => parseInt(value, 16)); |
| 14 | |
| 15 | test("rejects duplicate names split across directory fanout children" , async () => { |
| 16 | const entries = Array.from({length: 173}, (_, i) => ({hash: hash("a" ), name: `a${String(i).padStart(3, "0")}` , size: 1})); |
| 17 | entries.push({hash: hash("b" ), name: "dup" , size: 1}, {hash: hash("c" ), name: "dup" , size: 1}); |
| 18 | await assert.rejects(api.buildDirectory(entries), /duplicate directory entry/); |
| 19 | }); |
| 20 | |
| 21 | test("decrypts encrypted file-manifest children before decoding" , async () => { |
| 22 | const leaf = new TextEncoder().encode("ok" ); |
| 23 | const leafHash = api.hex(await api.sha256(leaf)); |
| 24 | const child = {l: [{h: bytes(leafHash), s: leaf.length, t: 0}], t: 1}; |
| 25 | const childBytes = api.encodeManifest(child); |
| 26 | const encrypted = Uint8Array.from(childBytes, value => value ^ 0xff); |
| 27 | const childHash = await api.hex(await api.sha256(encrypted)); |
| 28 | const root = {l: [{h: bytes(childHash), k: new Uint8Array(32), s: 2, t: 1}], t: 1}; |
| 29 | const result = await api.readFile(root, async (wantedHash) => { |
| 30 | if (wantedHash === childHash) return encrypted; |
| 31 | assert.equal(wantedHash, leafHash); |
| 32 | return leaf; |
| 33 | }, {decrypt: async value => { |
| 34 | assert.equal(api.hex(value), api.hex(encrypted)); |
| 35 | return childBytes; |
| 36 | }}); |
| 37 | assert.equal(result.length, 2); |
| 38 | }); |
| 39 | |
| 40 | test("decrypts encrypted directory-manifest children before decoding" , async () => { |
| 41 | const leaf = new TextEncoder().encode("ok" ); |
| 42 | const leafHash = api.hex(await api.sha256(leaf)); |
| 43 | const child = {l: [{h: bytes(leafHash), n: "a.txt" , s: leaf.length, t: 0}], t: 2}; |
| 44 | const childBytes = api.encodeManifest(child); |
| 45 | const encrypted = Uint8Array.from(childBytes, value => value ^ 0xff); |
| 46 | const childHash = await api.hex(await api.sha256(encrypted)); |
| 47 | const root = {l: [{h: bytes(childHash), k: new Uint8Array(32), m: {count: 1, first: "a.txt" , last: "a.txt" }, s: leaf.length, t: 2}], t: 3}; |
| 48 | const result = await api.resolveDirectory(root, async wantedHash => { |
| 49 | assert.equal(wantedHash, childHash); |
| 50 | return encrypted; |
| 51 | }, {decrypt: async value => { |
| 52 | assert.equal(api.hex(value), api.hex(encrypted)); |
| 53 | return childBytes; |
| 54 | }}); |
| 55 | assert.equal(result[0].n, "a.txt" ); |
| 56 | }); |
| 57 | |
| 58 | test("matches the BUD-17 directory fanout vector" , async () => { |
| 59 | const node = {l: [ |
| 60 | {h: bytes("11" .repeat(32)), m: {count: 2, first: "a.txt" , last: "b.txt" }, s: 30, t: 2}, |
| 61 | {h: bytes("22" .repeat(32)), m: {count: 1, first: "c.txt" , last: "c.txt" }, s: 40, t: 2} |
| 62 | ], t: 3}; |
| 63 | assert.equal(await api.manifestHash(node), "6626ab03b5468f417d888fa25fa22b48f5bcb7dfafb88eef34c638d167afc0a3" ); |
| 64 | }); |
| 65 | |
| 66 | test("sorts and preserves non-ASCII names by UTF-8 byte order" , async () => { |
| 67 | const node = await api.buildDirectory([ |
| 68 | {hash: hash("a" ), name: "é" , size: 1}, |
| 69 | {hash: hash("b" ), name: "e\u0301" , size: 1}, |
| 70 | {hash: hash("c" ), name: "😀" , size: 1} |
| 71 | ]); |
| 72 | assert.deepEqual(node.l.map(link => link.n), ["e\u0301" , "é" , "😀" ]); |
| 73 | }); |
| 74 | |
| 75 | test("honors zero traversal budgets" , async () => { |
| 76 | const one = new TextEncoder().encode("x" ); |
| 77 | const oneHash = api.hex(await api.sha256(one)); |
| 78 | const file = {l: [{h: bytes(oneHash), s: 1, t: 0}], t: 1}; |
| 79 | await assert.rejects(api.readFile(file, async () => one, {maxManifests: 0}), /manifest count limit exceeded/); |
| 80 | await assert.rejects(api.readFile(file, async () => one, {maxBytes: 0}), /file byte limit exceeded/); |
| 81 | await assert.rejects(api.resolveDirectory({l: [], t: 3}, async () => new Uint8Array(), {maxManifests: 0}), /manifest count limit exceeded/); |
| 82 | }); |
| 83 | |
| 84 | test("bounds total directory fetches and permits encryption overhead separately" , async () => { |
| 85 | const child = {l: [{h: bytes("aa" .repeat(32)), n: "a" , s: 2, t: 0}], t: 2}; |
| 86 | const raw = api.encodeManifest(child), digest = api.hex(await api.sha256(raw)); |
| 87 | const root = {l: [{h: bytes(digest), m: {count: 1, first: "a" , last: "a" }, s: 2, t: 2}], t: 3}; |
| 88 | await assert.rejects(api.resolveDirectory(root, async () => raw, {maxBytes: 1}), /byte limit/); |
| 89 | const ciphertext = new Uint8Array(18), hash = api.hex(await api.sha256(ciphertext)); |
| 90 | const file = {l: [{h: bytes(hash), k: new Uint8Array(32), s: 2, t: 0}], t: 1}; |
| 91 | assert.equal((await api.readFile(file, async () => ciphertext, {maxBytes: 2, decrypt: async () => new Uint8Array(2)})).length, 2); |
| 92 | await assert.rejects(api.readFile(file, async () => ciphertext, {maxBytes: 2, maxFetchedBytes: 1}), /byte limit/); |
| 93 | }); |