import { describe, expect, it } from 'vitest'; import { ChannelSession, IntegrityError, collectFrames, decryptItemFrames, splitEnvelopes } from '../src/index.js'; import { sealFrame } from '../src/internal.js'; import type { Bytes } from '../src/bytes.js'; import { concatAll, ORIGIN, pseudoRandom, readAll, splitAt, asyncChunks, toHex } from './helpers.js'; const GATED = 4; const ENC = 8; const FINAL = 1; async function pair(mode: 'enc' | 'raw' = 'enc') { const { session: owner } = await ChannelSession.create({ mode }); const member = await ChannelSession.open(owner.link(ORIGIN)); return { owner, member }; } async function encode(session: ChannelSession, content: Bytes, maxFrameData?: number) { const plan = session.encryptFile(new Blob([content]), { name: 'doc.bin', mime: 'application/octet-stream' }, { ts: 7, ...(maxFrameData && { maxFrameData }) }); const frames = await collectFrames(plan); return { plan, frames, wire: concatAll(frames.map((f) => f.bytes)) }; } /** 迭代解密生成器,收集内容并拿到返回值。 */ async function drain(gen: ReturnType) { const parts: Uint8Array[] = []; for (;;) { const r = await gen.next(); if (r.done) return { content: concatAll(parts), result: r.value }; parts.push(r.value); } } const ctx = (s: ChannelSession) => ({ channelId: s.idBytes, mode: s.mode, itemKey: (id: Uint8Array) => s.itemKey(id) }); describe('splitEnvelopes', () => { it('任意切分的字节流都能还原出原来的封装', async () => { const { owner } = await pair(); const { frames, wire } = await encode(owner, pseudoRandom(3000, 1), 700); expect(frames.length).toBeGreaterThan(3); for (const seed of [1, 2, 3, 4, 5]) { const out: Uint8Array[] = []; for await (const env of splitEnvelopes(asyncChunks(splitAt(wire, seed)))) out.push(env); expect(out.map(toHex)).toEqual(frames.map((f) => toHex(f.bytes))); } }); it('逐字节喂入也行', async () => { const { owner } = await pair(); const { frames, wire } = await encode(owner, pseudoRandom(500, 2), 300); const out: Uint8Array[] = []; for await (const env of splitEnvelopes(asyncChunks(Array.from(wire, (b) => new Uint8Array([b]))))) out.push(env); expect(out).toHaveLength(frames.length); }); it('末尾被截断的半个封装直接丢弃(这是断线,不是篡改)', async () => { const { owner } = await pair(); const { frames, wire } = await encode(owner, pseudoRandom(1500, 3), 500); const cut = wire.slice(0, wire.length - 10); const out: Uint8Array[] = []; for await (const env of splitEnvelopes(asyncChunks([cut]))) out.push(env); expect(out).toHaveLength(frames.length - 1); }); it('声明了离谱的负载长度:拒绝,而不是等待永远不会到的数据', async () => { const header = new Uint8Array(26); header[0] = 1; header.set([0xff, 0xff, 0xff, 0xff], 22); const gen = splitEnvelopes(asyncChunks([header])); await expect(gen.next()).rejects.toThrowError(IntegrityError); }); it('接受 ReadableStream', async () => { const { owner } = await pair(); const { frames, wire } = await encode(owner, pseudoRandom(900, 4), 400); const stream = new ReadableStream({ start(c) { for (const chunk of splitAt(wire, 9)) c.enqueue(chunk); c.close(); }, }); const out: Uint8Array[] = []; for await (const env of splitEnvelopes(stream)) out.push(env); expect(out).toHaveLength(frames.length); }); }); describe('流式解密', () => { it('大文件(多个 1 MiB 帧)往返一致,元数据正确,标记 GATED', async () => { const { owner, member } = await pair(); const content = pseudoRandom(2_600_000, 21); const { plan, frames, wire } = await encode(owner, content); expect(plan.gated).toBe(true); expect(frames).toHaveLength(3); // 2.6 MB / 约 1 MiB 每帧 const { content: out, result } = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire, 5)))); expect(result.complete).toBe(true); expect(result.nextIndex).toBe(frames.length); expect(result.meta).toMatchObject({ kind: 'file', name: 'doc.bin', mime: 'application/octet-stream', size: content.length }); expect(out.length).toBe(content.length); expect(toHex(out.slice(0, 64))).toBe(toHex(content.slice(0, 64))); expect(toHex(out.slice(-64))).toBe(toHex(content.slice(-64))); expect(Buffer.compare(out, content)).toBe(0); }); it('包成 ReadableStream 使用,并给出 result', async () => { const { owner, member } = await pair(); const content = pseudoRandom(5000, 22); const { wire } = await encode(owner, content, 1500); const { content: stream, result } = member.decryptItemStream(asyncChunks(splitAt(wire, 6))); expect(toHex(await readAll(stream))).toBe(toHex(content)); expect(await result).toMatchObject({ complete: true, nextIndex: 4 }); }); it('中间帧被篡改:流报 auth_failed,result 同样失败', async () => { const { owner, member } = await pair(); const { frames } = await encode(owner, pseudoRandom(4000, 23), 1000); const tampered = frames.map((f) => f.bytes.slice()); tampered[2]![tampered[2]!.length - 3]! ^= 1; const { content, result } = member.decryptItemStream(asyncChunks([concatAll(tampered)])); await expect(readAll(content)).rejects.toMatchObject({ reason: 'auth_failed' }); await expect(result).rejects.toThrowError(IntegrityError); }); it('混入别的条目的帧(服务端拼接):item_mismatch', async () => { const { owner, member } = await pair(); const a = await encode(owner, pseudoRandom(2500, 24), 1000); const b = await encode(owner, pseudoRandom(2500, 25), 1000); // 前两帧来自 A,第三帧换成 B 的第三帧(两者都是合法认证过的帧) const spliced = concatAll([a.frames[0]!.bytes, a.frames[1]!.bytes, b.frames[2]!.bytes]); await expect(drain(decryptItemFrames(ctx(member), asyncChunks([spliced])))).rejects.toMatchObject({ reason: 'item_mismatch' }); }); it('指定了期望的条目 id 时,第一帧就不匹配也会被拒绝', async () => { const { owner, member } = await pair(); const a = await encode(owner, pseudoRandom(100, 26)); const b = await encode(owner, pseudoRandom(100, 27)); await expect( drain(decryptItemFrames(ctx(member), asyncChunks([a.wire]), { itemId: b.plan.idBytes })), ).rejects.toMatchObject({ reason: 'item_mismatch' }); }); it('跳过一帧(服务端丢帧):gap', async () => { const { owner, member } = await pair(); const { frames } = await encode(owner, pseudoRandom(4000, 28), 1000); const dropped = concatAll([frames[0]!.bytes, frames[2]!.bytes, frames[3]!.bytes]); await expect(drain(decryptItemFrames(ctx(member), asyncChunks([dropped])))).rejects.toMatchObject({ reason: 'gap' }); }); it('重复的帧:同样被当作 gap 拒绝', async () => { const { owner, member } = await pair(); const { frames } = await encode(owner, pseudoRandom(3000, 29), 1000); const dup = concatAll([frames[0]!.bytes, frames[0]!.bytes, frames[1]!.bytes]); await expect(drain(decryptItemFrames(ctx(member), asyncChunks([dup])))).rejects.toMatchObject({ reason: 'gap' }); }); it('FINAL 之后还有帧:frame_after_final', async () => { const { owner, member } = await pair(); const { frames, plan } = await encode(owner, pseudoRandom(2500, 30), 1000); const key = await owner.itemKey(plan.idBytes); const extra = await sealFrame(key, owner.idBytes, { flags: GATED | ENC, itemId: plan.idBytes, index: frames.length, data: new Uint8Array(3) }); await expect( drain(decryptItemFrames(ctx(member), asyncChunks([concatAll([...frames.map((f) => f.bytes), extra])]))), ).rejects.toMatchObject({ reason: 'frame_after_final' }); }); it('被截断的内容不会被当成完整:没有 FINAL 时 complete 为 false', async () => { const { owner, member } = await pair(); const content = pseudoRandom(4000, 31); const { frames } = await encode(owner, content, 1000); const withoutLast = concatAll(frames.slice(0, -1).map((f) => f.bytes)); // 服务端丢掉最后一帧 const { result } = await drain(decryptItemFrames(ctx(member), asyncChunks([withoutLast]))); expect(result.complete).toBe(false); expect(result.nextIndex).toBe(frames.length - 1); }); it('enc 会话里出现没有 ENC 标志的帧:wrong_mode', async () => { const { member } = await pair(); const evil = concatAll([new Uint8Array([1, FINAL]), new Uint8Array(16), new Uint8Array(4), new Uint8Array([0, 0, 0, 4]), new Uint8Array(4)]); await expect(drain(decryptItemFrames(ctx(member), asyncChunks([evil])))).rejects.toMatchObject({ reason: 'wrong_mode' }); }); }); describe('断点续传', () => { it('接收端:连接中途断开,用 nextIndex 续传,拼起来与原文一致', async () => { const { owner, member } = await pair(); const content = pseudoRandom(9000, 40); const { frames } = await encode(owner, content, 1000); expect(frames.length).toBeGreaterThanOrEqual(9); const wire = concatAll(frames.map((f) => f.bytes)); // 第一次:在第 5 帧中间断开 const firstLen = frames.slice(0, 4).reduce((n, f) => n + f.bytes.length, 0) + 100; const first = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire.slice(0, firstLen), 3)))); expect(first.result.complete).toBe(false); expect(first.result.nextIndex).toBe(4); expect(first.result.meta?.name).toBe('doc.bin'); // 第二次:从 nextIndex 开始(服务端 GET ...?from=4,不重复计下载) const rest = concatAll(frames.slice(4).map((f) => f.bytes)); const second = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(rest, 4)), { fromIndex: first.result.nextIndex })); expect(second.result.complete).toBe(true); expect(second.result.meta).toBeUndefined(); // 续传的响应里没有第 0 帧 expect(toHex(concatAll([first.content, second.content]))).toBe(toHex(content)); }); it('从中间开始时,帧序号必须从 fromIndex 起连续', async () => { const { owner, member } = await pair(); const { frames } = await encode(owner, pseudoRandom(4000, 41), 1000); const wrongStart = concatAll(frames.slice(2).map((f) => f.bytes)); await expect(drain(decryptItemFrames(ctx(member), asyncChunks([wrongStart]), { fromIndex: 1 }))).rejects.toMatchObject({ reason: 'gap' }); }); it('发送端:从第 k 帧开始重新加密,得到与完整加密完全相同的字节', async () => { const { owner } = await pair(); const content = pseudoRandom(5500, 42); const blob = new Blob([content]); const full = owner.encryptFile(blob, { name: 'x' }, { ts: 3, maxFrameData: 1000 }); const fullFrames = await collectFrames(full); expect(fullFrames.length).toBeGreaterThanOrEqual(6); for (const from of [1, 2, fullFrames.length - 1]) { const resumed = await collectFrames( owner.encryptFile(blob, { name: 'x' }, { ts: 3, maxFrameData: 1000, itemId: full.idBytes, fromFrame: from }), ); expect(resumed.map((f) => f.index)).toEqual(fullFrames.slice(from).map((f) => f.index)); expect(resumed.map((f) => toHex(f.bytes))).toEqual(fullFrames.slice(from).map((f) => toHex(f.bytes))); } }); it('发送端续传:Uint8Array 来源同样适用', async () => { const { owner } = await pair(); const content = pseudoRandom(3200, 43); const full = owner.encryptBytes(content, { kind: 'file' }, { ts: 1, maxFrameData: 800 }); const fullFrames = await collectFrames(full); const resumed = await collectFrames(owner.encryptBytes(content, { kind: 'file' }, { ts: 1, maxFrameData: 800, itemId: full.idBytes, fromFrame: 2 })); expect(resumed.map((f) => toHex(f.bytes))).toEqual(fullFrames.slice(2).map((f) => toHex(f.bytes))); }); it('发送端续传的边界错误:单帧条目、越界、流式来源', async () => { const { owner } = await pair(); const content = pseudoRandom(2500, 44); const frames = await collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 1000 })); await expect(collectFrames(owner.encryptBytes(new Uint8Array(5), {}, { fromFrame: 1 }))).rejects.toThrow(RangeError); await expect(collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 1000, fromFrame: frames.length }))).rejects.toThrow(RangeError); // 长度未知的来源无法定位第 k 帧的起点,在创建计划时就拒绝 expect(() => owner.encryptFile(new ReadableStream(), {}, { fromFrame: 1 })).toThrow(RangeError); }); }); describe('raw 模式的流', () => { it('透传内容,不做解密', async () => { const { owner, member } = await pair('raw'); const content = pseudoRandom(700, 50); const frames = await collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 300 })); const wire = concatAll(frames.map((f) => f.bytes)); const { content: out, result } = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire, 2)))); expect(result.complete).toBe(true); expect(toHex(out)).toBe(toHex(content)); }); }); describe('取消', () => { it('取消流不会抛异常,也不会继续消费来源', async () => { const { owner, member } = await pair(); const { wire } = await encode(owner, pseudoRandom(6000, 60), 1000); let pulled = 0; async function* source() { for (const chunk of splitAt(wire, 8)) { pulled++; yield chunk; } } const { content } = member.decryptItemStream(source()); const reader = content.getReader(); await reader.read(); await reader.cancel(); const after = pulled; await new Promise((r) => setTimeout(r, 20)); expect(pulled).toBe(after); }); });