Files
WebDrop/packages/core/test/stream.test.ts
T
2026-10-02 00:59:22 +08:00

290 lines
14 KiB
TypeScript

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<typeof decryptItemFrames>) {
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<Uint8Array>({
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<Uint8Array>(), {}, { 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);
});
});