chore(repo): initialize WebDrop monorepo
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
commit
53768a195d
39 files changed
+9063
No files matched your search
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { concat, toBase64Url, writeU32, writeU64 } from '../src/bytes.js';
|
||||
import { ChannelSession, collectFrames, itemText, parseLogRecords, type ItemEvent, type ItemPlan, type ReceivedItem } from '../src/index.js';
|
||||
import { encodeMeta, parseEnvelope, sealFrame } from '../src/internal.js';
|
||||
import { ORIGIN, pseudoRandom, toHex } from './helpers.js';
|
||||
|
||||
const GATED = 4;
|
||||
const ENC = 8;
|
||||
const FINAL = 1;
|
||||
const SNAPSHOT = 2;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 一条 frame 日志记录的线格式:seq(8) ‖ kind(1) ‖ 封装。 */
|
||||
const frameRecord = (seq: number, envelope: Uint8Array) => concat(writeU64(seq), new Uint8Array([1]), envelope);
|
||||
|
||||
const only = (events: ItemEvent[]): ItemEvent => {
|
||||
expect(events).toHaveLength(1);
|
||||
return events[0]!;
|
||||
};
|
||||
const itemOf = (events: ItemEvent[]): ReceivedItem => {
|
||||
const ev = only(events);
|
||||
if (ev.type !== 'item') throw new Error(`期望 item,得到 ${JSON.stringify(ev)}`);
|
||||
return ev.item;
|
||||
};
|
||||
|
||||
/** 构造一个多帧 GATED 条目的各帧(小帧,便于测试)。 */
|
||||
async function gatedFrames(session: ChannelSession, size: number, frameData = 100) {
|
||||
const content = pseudoRandom(size, 11);
|
||||
const plan: ItemPlan = session.encryptBytes(content, { kind: 'file', name: 'f' }, { maxFrameData: frameData, ts: 1 });
|
||||
return { content, plan, frames: await collectFrames(plan) };
|
||||
}
|
||||
|
||||
describe('日志记录', () => {
|
||||
it('frame 记录:带 seq,解密出条目', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const [frame] = await collectFrames(owner.encryptText('via log'));
|
||||
const events = await member.createItemReader().push(frameRecord(42, frame!.bytes));
|
||||
const item = itemOf(events);
|
||||
expect(item.seq).toBe(42);
|
||||
expect(itemText(item)).toBe('via log');
|
||||
});
|
||||
|
||||
it('parseLogRecords 解析的记录可直接喂给装配器', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const frames = [
|
||||
...(await collectFrames(owner.encryptText('a'))),
|
||||
...(await collectFrames(owner.encryptText('b'))),
|
||||
];
|
||||
const blob = concat(frameRecord(1, frames[0]!.bytes), frameRecord(2, frames[1]!.bytes));
|
||||
|
||||
const reader = member.createItemReader();
|
||||
const texts: string[] = [];
|
||||
for (const rec of parseLogRecords(blob)) for (const ev of await reader.push(rec)) if (ev.type === 'item') texts.push(itemText(ev.item));
|
||||
expect(texts).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('announce:GATED 条目只通知,不含内容', async () => {
|
||||
const { member } = await pair();
|
||||
const id = new Uint8Array(16).fill(5);
|
||||
const record = concat(writeU64(9), new Uint8Array([3]), id, new Uint8Array([GATED | ENC]), writeU64(1700000000123));
|
||||
expect(only(await member.createItemReader().push(record))).toEqual({
|
||||
type: 'announce',
|
||||
itemId: toBase64Url(id),
|
||||
seq: 9,
|
||||
flags: GATED | ENC,
|
||||
createdAt: 1700000000123,
|
||||
});
|
||||
});
|
||||
|
||||
it('tombstone:删除条目,并清掉装配中的缓冲', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const { frames, plan } = await gatedFrames(owner, 250);
|
||||
const reader = member.createItemReader();
|
||||
await reader.pushFrame(frames[0]!.bytes); // 只收到一部分
|
||||
|
||||
const tomb = concat(writeU64(7), new Uint8Array([2]), plan.idBytes, new Uint8Array([3]));
|
||||
expect(only(await reader.push(tomb))).toEqual({ type: 'removed', itemId: plan.itemId, seq: 7, reason: 'download_limit' });
|
||||
|
||||
// 缓冲已清空:后面的帧从头开始装配,不会和旧状态混在一起
|
||||
const ev = itemOf(await reader.pushFrame(frames[1]!.bytes));
|
||||
expect(ev.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('损坏的日志记录变成 rejected,不抛异常', async () => {
|
||||
const { member } = await pair();
|
||||
const ev = only(await member.createItemReader().push(new Uint8Array([1, 2, 3])));
|
||||
expect(ev).toMatchObject({ type: 'rejected', reason: 'bad_envelope' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('重放与重复', () => {
|
||||
it('同一帧重复到达:不产生新事件(日志回放不会让条目重复出现)', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const [frame] = await collectFrames(owner.encryptText('once'));
|
||||
const reader = member.createItemReader();
|
||||
expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true);
|
||||
expect(await reader.pushFrame(frame!.bytes)).toEqual([]);
|
||||
expect(await reader.pushFrame(frame!.bytes)).toEqual([]);
|
||||
});
|
||||
|
||||
it('装配中的条目收到内容不同的同序号帧:duplicate_mismatch,并丢弃该条目', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const { frames } = await gatedFrames(owner, 250);
|
||||
const reader = member.createItemReader();
|
||||
await reader.pushFrame(frames[0]!.bytes);
|
||||
|
||||
const forged = frames[0]!.bytes.slice();
|
||||
forged[forged.length - 1]! ^= 1;
|
||||
expect(only(await reader.pushFrame(forged))).toMatchObject({ type: 'rejected', reason: 'duplicate_mismatch' });
|
||||
});
|
||||
|
||||
it('条目被删除后,再来的旧帧会重新装配(回放到墓碑之前的数据时不会卡住)', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const [frame] = await collectFrames(owner.encryptText('again'));
|
||||
const reader = member.createItemReader();
|
||||
const { itemId } = parseEnvelopeId(frame!.bytes);
|
||||
await reader.pushFrame(frame!.bytes);
|
||||
await reader.push(concat(writeU64(2), new Uint8Array([2]), itemId, new Uint8Array([0])));
|
||||
expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function parseEnvelopeId(envelope: Uint8Array) {
|
||||
return { itemId: parseEnvelope(envelope).itemId };
|
||||
}
|
||||
|
||||
describe('多帧条目的装配', () => {
|
||||
it('乱序到达也能装配,到齐才 complete', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const { content, frames } = await gatedFrames(owner, 450); // 约 5 帧
|
||||
expect(frames.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const reader = member.createItemReader();
|
||||
const order = [2, 0, 3, 1, ...frames.slice(4).map((_, i) => i + 4)];
|
||||
const states: boolean[] = [];
|
||||
let last: ReceivedItem | undefined;
|
||||
for (const i of order) {
|
||||
last = itemOf(await reader.pushFrame(frames[i]!.bytes));
|
||||
states.push(last.complete);
|
||||
}
|
||||
expect(states.slice(0, -1).every((c) => !c)).toBe(true);
|
||||
expect(last!.complete).toBe(true);
|
||||
expect(toHex(last!.content)).toBe(toHex(content));
|
||||
expect(last!.meta?.name).toBe('f');
|
||||
});
|
||||
|
||||
it('缺一帧时不会 complete,content 只到第一个缺口', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const { frames } = await gatedFrames(owner, 250);
|
||||
expect(frames).toHaveLength(3);
|
||||
|
||||
const reader = member.createItemReader();
|
||||
await reader.pushFrame(frames[0]!.bytes);
|
||||
const item = itemOf(await reader.pushFrame(frames[2]!.bytes)); // 跳过第 1 帧
|
||||
expect(item.complete).toBe(false);
|
||||
|
||||
const filled = itemOf(await reader.pushFrame(frames[1]!.bytes));
|
||||
expect(filled.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('FINAL 之后又出现更大序号的帧:frame_after_final', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const { frames, plan } = await gatedFrames(owner, 250); // 3 帧,序号 2 是 FINAL
|
||||
const reader = member.createItemReader();
|
||||
await reader.pushFrame(frames[0]!.bytes);
|
||||
await reader.pushFrame(frames[2]!.bytes);
|
||||
|
||||
const key = await owner.itemKey(plan.idBytes);
|
||||
const extra = await sealFrame(key, owner.idBytes, { flags: GATED | ENC, itemId: plan.idBytes, index: 3, data: new Uint8Array(5) });
|
||||
expect(only(await reader.pushFrame(extra))).toMatchObject({ type: 'rejected', reason: 'frame_after_final' });
|
||||
});
|
||||
|
||||
it('FINAL 出现在已有更大序号之前:final_conflict', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const { frames, plan } = await gatedFrames(owner, 250);
|
||||
const reader = member.createItemReader();
|
||||
await reader.pushFrame(frames[2]!.bytes); // 序号 2 先到(它带 FINAL)
|
||||
|
||||
// 伪造一个序号 1、也带 FINAL 的帧:FINAL 位置前后矛盾
|
||||
const key = await owner.itemKey(plan.idBytes);
|
||||
const clash = await sealFrame(key, owner.idBytes, { flags: GATED | ENC | FINAL, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) });
|
||||
expect(only(await reader.pushFrame(clash))).toMatchObject({ type: 'rejected', reason: 'final_conflict' });
|
||||
});
|
||||
|
||||
it('同一条目内标志位变化:flags_changed', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const { frames, plan } = await gatedFrames(owner, 250);
|
||||
const reader = member.createItemReader();
|
||||
await reader.pushFrame(frames[0]!.bytes);
|
||||
|
||||
const key = await owner.itemKey(plan.idBytes);
|
||||
// 第 0 帧是 GATED,这里发一个带 SNAPSHOT 的第 1 帧(密文本身是合法认证的)
|
||||
const odd = await sealFrame(key, owner.idBytes, { flags: GATED | SNAPSHOT | ENC, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) });
|
||||
expect(only(await reader.pushFrame(odd))).toMatchObject({ type: 'rejected', reason: 'flags_changed' });
|
||||
});
|
||||
|
||||
it('非 GATED 条目的帧序号不是 0 或没有 FINAL:拒绝(协议规则)', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const id = new Uint8Array(16).fill(1);
|
||||
const key = await owner.itemKey(id);
|
||||
const noFinal = await sealFrame(key, owner.idBytes, { flags: ENC, itemId: id, index: 0, data: concat(encodeMeta({ kind: 'text', ts: 1 }), new Uint8Array(1)) });
|
||||
expect(only(await member.createItemReader().pushFrame(noFinal))).toMatchObject({ type: 'rejected', reason: 'bad_envelope' });
|
||||
});
|
||||
|
||||
it('第 0 帧的元数据不合法:bad_meta', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const id = new Uint8Array(16).fill(2);
|
||||
const key = await owner.itemKey(id);
|
||||
const garbage = new TextEncoder().encode('{"v":1,"kind":123,"ts":"x"}');
|
||||
const frame = await sealFrame(key, owner.idBytes, { flags: ENC | FINAL, itemId: id, index: 0, data: concat(new Uint8Array([0, garbage.length]), garbage) });
|
||||
expect(only(await member.createItemReader().pushFrame(frame))).toMatchObject({ type: 'rejected', reason: 'bad_meta' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('模式隔离(防降级)', () => {
|
||||
it('enc 会话拒绝没有 ENC 标志的帧(恶意服务端注入明文)', async () => {
|
||||
const { member } = await pair('enc');
|
||||
const raw = concat(new Uint8Array([1, FINAL]), new Uint8Array(16), writeU32(0), writeU32(4), new TextEncoder().encode('evil'));
|
||||
expect(only(await member.createItemReader().pushFrame(raw))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' });
|
||||
});
|
||||
|
||||
it('raw 会话拒绝带 ENC 标志的帧', async () => {
|
||||
const { owner: encOwner } = await pair('enc');
|
||||
const { member: rawMember } = await pair('raw');
|
||||
const [frame] = await collectFrames(encOwner.encryptText('x'));
|
||||
expect(only(await rawMember.createItemReader().pushFrame(frame!.bytes))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' });
|
||||
});
|
||||
|
||||
it('一个条目被拒绝不影响其他条目', async () => {
|
||||
const { owner, member } = await pair();
|
||||
const reader = member.createItemReader();
|
||||
const [good1] = await collectFrames(owner.encryptText('one'));
|
||||
const [bad] = await collectFrames(owner.encryptText('bad'));
|
||||
const [good2] = await collectFrames(owner.encryptText('two'));
|
||||
bad!.bytes[bad!.bytes.length - 1]! ^= 1;
|
||||
|
||||
const results = [
|
||||
...(await reader.pushFrame(good1!.bytes)),
|
||||
...(await reader.pushFrame(bad!.bytes)),
|
||||
...(await reader.pushFrame(good2!.bytes)),
|
||||
];
|
||||
expect(results.map((e) => e.type)).toEqual(['item', 'rejected', 'item']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user