Initial Commit
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* DMP-08 — Advertisement bitset length invariant.
|
||||
*
|
||||
* In `ChunkAdvertisePayload`, the bit-length of `presence` MUST equal
|
||||
* `totalChunks`; padding bits in the final byte MUST be zero. The codec
|
||||
* rejects mismatched bitset wires with either
|
||||
* `DMP.BITSET_LENGTH_MISMATCH` or `DMP.BITSET_PADDING_NONZERO`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeAdvertisePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeFramedBitset,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "b".repeat(64);
|
||||
|
||||
function makeAdvertise(presence: Uint8Array, totalChunks: number) {
|
||||
return encodeAdvertisePayload({
|
||||
fileId: FILE_ID,
|
||||
presence,
|
||||
totalChunks,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function makeFramedBitset(ids: number[], bitLength: number) {
|
||||
return encodeFramedBitset(chunkIdsToRawBitset(ids, bitLength), bitLength);
|
||||
}
|
||||
|
||||
describe("DMP-08 — bitset length invariant", () => {
|
||||
it("advertisement with presence.length ≠ totalChunks rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = makeFramedBitset([0, 5], 16);
|
||||
try {
|
||||
makeAdvertise(presence, 32);
|
||||
throw new Error("expected mismatch rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("valid advertisement (bitLength == totalChunks) round-trips", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = makeFramedBitset([0, 5, 7], 8);
|
||||
const wire = makeAdvertise(presence, 8);
|
||||
const decoded = decodeAdvertisePayload(wire);
|
||||
expect(decoded.totalChunks).toBe(8);
|
||||
expect(decoded.presence).toEqual(presence);
|
||||
});
|
||||
|
||||
it("non-zero padding bits rejected", { timeout: 5000 }, () => {
|
||||
// Hand-build a framed bitset with bitLength=10 (so high bits 10..15
|
||||
// must be zero) but flip bit 14 to violate padding zeroing.
|
||||
const raw = new Uint8Array([0x01, 0xff]);
|
||||
// Build the wire envelope by hand.
|
||||
const wire = new Uint8Array(12 + raw.length);
|
||||
wire.set(BITSET_MAGIC, 0);
|
||||
const view = new DataView(
|
||||
wire.buffer,
|
||||
wire.byteOffset,
|
||||
wire.byteLength
|
||||
);
|
||||
view.setUint32(4, 10, false);
|
||||
view.setUint32(8, raw.length, false);
|
||||
wire.set(raw, 12);
|
||||
try {
|
||||
makeAdvertise(wire, 10);
|
||||
throw new Error("expected padding-nonzero rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* DMP-04 — `kind:'bitset'` payload via @suaveplan/bitpacker-compressed.
|
||||
*
|
||||
* Property-based round-trip across random bit densities, plus the
|
||||
* "1000-chunk file with 3 needed chunks fits in <40 bytes" example
|
||||
* from the spec.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
chunkIdsToRawBitset,
|
||||
decodeFramedBitset,
|
||||
encodeFramedBitset,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("DMP-04 — bitset round-trip", () => {
|
||||
it("bitset roundtrip preserves bit positions for random densities", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.array(fc.integer({ min: 0, max: 9999 }), {
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
})
|
||||
.map((arr) => Array.from(new Set(arr))),
|
||||
(input) => {
|
||||
const sorted = [...input].sort((a, b) => a - b);
|
||||
const max = sorted[sorted.length - 1] as number;
|
||||
const bitLength = max + 1;
|
||||
const raw = chunkIdsToRawBitset(sorted, bitLength);
|
||||
const framed = encodeFramedBitset(raw, bitLength);
|
||||
const decoded = decodeFramedBitset(framed);
|
||||
expect(decoded.bitLength).toBe(bitLength);
|
||||
const recovered = rawBitsetToChunkIds(
|
||||
decoded.raw,
|
||||
decoded.bitLength
|
||||
);
|
||||
expect(recovered).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("1000-chunk file with 3 needed chunks fits in <165 bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const needed = [100, 105, 200];
|
||||
const bitLength = 1000;
|
||||
const raw = chunkIdsToRawBitset(needed, bitLength);
|
||||
const framed = encodeFramedBitset(raw, bitLength);
|
||||
// header + 125 bytes raw bitset = ~137 bytes; well under 165.
|
||||
expect(framed.length).toBeLessThan(BITSET_HEADER_SIZE + 200);
|
||||
const back = rawBitsetToChunkIds(raw, bitLength);
|
||||
expect(back).toEqual(needed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* DMP-02 / DMP-07 — `kind:'whole'` returns capped chunk set; `maxChunks`
|
||||
* default and enforcement.
|
||||
*
|
||||
* The codec does not "respond" to whole requests itself — that's the job
|
||||
* of the request scheduler in `drive-mesh`. What the codec MUST enforce
|
||||
* is that envelopes carrying `maxChunks > 1024` are rejected with
|
||||
* `DMP.MAX_CHUNKS_EXCEEDED` so the scheduler never sees them. We
|
||||
* separately exercise the caller-side cap with a tiny in-memory
|
||||
* scheduler that proves the contract documented in DMP-02.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
encodeRequestPayload,
|
||||
MAX_CHUNKS_DEFAULT,
|
||||
MAX_CHUNKS_HARD_CAP,
|
||||
selectWhole,
|
||||
} from "../src/index.js";
|
||||
import type { ChunkRequestPayload } from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "f".repeat(32);
|
||||
|
||||
/** Caller-side cap helper. The actual scheduler lives in drive-mesh. */
|
||||
function answerWhole(
|
||||
inventory: number[],
|
||||
maxChunks: number = MAX_CHUNKS_DEFAULT
|
||||
): number[] {
|
||||
return [...inventory].sort((a, b) => a - b).slice(0, maxChunks);
|
||||
}
|
||||
|
||||
describe("DMP-02 — kind:'whole' returns capped chunk set", () => {
|
||||
it("whole-form request returns at most maxChunks chunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inventory = Array.from({ length: 1000 }, (_, i) => i);
|
||||
const out = answerWhole(inventory, 64);
|
||||
expect(out.length).toBe(64);
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[63]).toBe(63);
|
||||
});
|
||||
|
||||
it("whole-form returns ALL chunks when inventory < maxChunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = answerWhole([0, 5, 12], 64);
|
||||
expect(out).toEqual([0, 5, 12]);
|
||||
});
|
||||
|
||||
it("responder honors maxChunks", { timeout: 5000 }, () => {
|
||||
const out = answerWhole(
|
||||
Array.from({ length: 200 }, (_, i) => i),
|
||||
50
|
||||
);
|
||||
expect(out.length).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-07 — maxChunks enforcement", () => {
|
||||
it("default is 64", { timeout: 5000 }, () => {
|
||||
expect(MAX_CHUNKS_DEFAULT).toBe(64);
|
||||
});
|
||||
|
||||
it("hard cap is 1024", { timeout: 5000 }, () => {
|
||||
expect(MAX_CHUNKS_HARD_CAP).toBe(1024);
|
||||
});
|
||||
|
||||
it("maxChunks > 1024 rejected", { timeout: 5000 }, () => {
|
||||
const payload: ChunkRequestPayload = {
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
maxChunks: 4096,
|
||||
};
|
||||
try {
|
||||
encodeRequestPayload(payload);
|
||||
throw new Error("expected cap rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.MAX_CHUNKS_EXCEEDED);
|
||||
}
|
||||
});
|
||||
|
||||
it("maxChunks at exactly 1024 accepted", { timeout: 5000 }, () => {
|
||||
const payload: ChunkRequestPayload = {
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
maxChunks: 1024,
|
||||
};
|
||||
expect(() => encodeRequestPayload(payload)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Edge-case coverage for the codec / trust / generation modules:
|
||||
* malformed JSON, schema mismatches, popcount, scoring math, and the
|
||||
* branches that the spec-tied tests didn't exercise directly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AdvertiseThrottle,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeAdvertisePayload,
|
||||
decodeErrorPayload,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeAdvertisePayload,
|
||||
encodeBitsetFromChunkIds,
|
||||
encodeErrorPayload,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
GenerationTracker,
|
||||
popcount,
|
||||
rawBitsetToChunkIds,
|
||||
selectForm,
|
||||
shouldSkip,
|
||||
type TrustEntry,
|
||||
TrustMap,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "e".repeat(64);
|
||||
|
||||
describe("popcount", () => {
|
||||
it("counts zero", { timeout: 5000 }, () => {
|
||||
expect(popcount(new Uint8Array([0, 0, 0]))).toBe(0);
|
||||
});
|
||||
it("counts mixed", { timeout: 5000 }, () => {
|
||||
expect(popcount(new Uint8Array([0xff, 0x01, 0x80]))).toBe(8 + 1 + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkIdsToRawBitset edge cases", () => {
|
||||
it("rejects non-integer bitLength", { timeout: 5000 }, () => {
|
||||
try {
|
||||
chunkIdsToRawBitset([0], 1.5);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
it("rejects negative bitLength", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([], -1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rawBitsetToChunkIds edge cases", () => {
|
||||
it("rejects non-zero padding bits", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0xff]);
|
||||
try {
|
||||
rawBitsetToChunkIds(raw, 4);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
it("rejects byte-length / bit-length mismatch", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0]);
|
||||
try {
|
||||
rawBitsetToChunkIds(raw, 16);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeFramedBitset rejects bad inputs", () => {
|
||||
it("rejects non-zero padding at encode time", { timeout: 5000 }, () => {
|
||||
const raw = new Uint8Array([0xff]);
|
||||
try {
|
||||
encodeFramedBitset(raw, 4);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_PADDING_NONZERO
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec malformed JSON paths", () => {
|
||||
it("decodeRequestPayload rejects malformed JSON", { timeout: 5000 }, () => {
|
||||
try {
|
||||
decodeRequestPayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeAdvertisePayload rejects malformed JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeAdvertisePayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeResponsePayload rejects malformed JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeResponsePayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec unwrapWire passthrough on non-object", () => {
|
||||
it("decodeAdvertisePayload rejects bare null", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify(null));
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeRequestPayload rejects array", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify([1, 2, 3]));
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
it("decodeResponsePayload rejects bare object", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(JSON.stringify({ x: 1 }));
|
||||
try {
|
||||
decodeResponsePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codec encode-time validation", () => {
|
||||
it("encodeRequestPayload rejects malformed input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: "not-hex",
|
||||
request: { kind: "whole" },
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeResponsePayload rejects bad presence", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeResponsePayload({
|
||||
fileId: FILE_ID,
|
||||
chunks: [],
|
||||
presence: new Uint8Array(2).fill(0xff), // missing magic
|
||||
generation: 1,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeAdvertisePayload rejects bad presence", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeAdvertisePayload({
|
||||
fileId: FILE_ID,
|
||||
presence: new Uint8Array(20).fill(0xff),
|
||||
totalChunks: 8,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeErrorPayload rejects malformed input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeErrorPayload({
|
||||
fileId: "non-hex-bad-id!",
|
||||
correlationId: "x",
|
||||
code: "INTERNAL",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeErrorPayload accepts a clean envelope", { timeout: 5000 }, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "abc",
|
||||
code: "INTERNAL",
|
||||
});
|
||||
const back = decodeErrorPayload(wire);
|
||||
expect(back.code).toBe("INTERNAL");
|
||||
});
|
||||
|
||||
it("encodeRequest rejects bitset without magic", { timeout: 5000 }, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: FILE_ID,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: new Uint8Array(4).fill(0),
|
||||
},
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeBitsetFromChunkIds (high-level helper)", () => {
|
||||
it("frames a chunk-id list via the canonical pipeline", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([3, 7], 16);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("derives bitLength from max id when not supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([10]);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectForm bitLength override", () => {
|
||||
it("uses caller-supplied bitLength when present", { timeout: 5000 }, () => {
|
||||
const ids = Array.from({ length: 80 }, (_, i) => i);
|
||||
const r = selectForm(ids, { bitLength: 200 });
|
||||
expect(r.kind).toBe("bitset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrustMap arithmetic", () => {
|
||||
it("score reflects miss / advertised ratio", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 10);
|
||||
t.recordMissing("p", "f", 1);
|
||||
const e = t.get("p", "f");
|
||||
expect(e.score).toBeCloseTo(0.9, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeAdvertisePayload bitset length cross-check", () => {
|
||||
it("rejects wire where presence bitLength ≠ totalChunks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 1], 8), 8);
|
||||
// Hand-build a wire envelope where totalChunks (16) doesn't
|
||||
// match the bitset's embedded bitLength (8).
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
presence: { _b: btoa(String.fromCharCode(...presence)) },
|
||||
totalChunks: 16,
|
||||
generation: 1,
|
||||
timestamp: 0,
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected mismatch");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(
|
||||
DMP_CODES.BITSET_LENGTH_MISMATCH
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-01 — unknown kind rejection", () => {
|
||||
it("decodeRequestPayload rejects unknown kind with UNKNOWN_REQUEST_KIND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "unknown" },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNKNOWN_REQUEST_KIND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tiny coverage gaps", () => {
|
||||
it("encodeRequest direct call rejects empty specific", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodeRequest function exposed and rejects empty specific", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { encodeRequest } = await import("../src/index.js");
|
||||
try {
|
||||
encodeRequest({ kind: "specific", chunkIds: [] });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("empty bitset (bitLength 0) round-trips cleanly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = new Uint8Array(0);
|
||||
const framed = encodeFramedBitset(raw, 0);
|
||||
const decoded = encodeFramedBitset(raw, 0);
|
||||
expect(decoded).toEqual(framed);
|
||||
// popcount of an empty array
|
||||
expect(popcount(raw)).toBe(0);
|
||||
});
|
||||
|
||||
it("selectForm with non-monotonic ids covers branch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = selectForm([5, 3, 7, 1], { threshold: 2 });
|
||||
expect(r.kind).toBe("bitset");
|
||||
});
|
||||
|
||||
it("encodeBitsetFromChunkIds with non-monotonic ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const framed = encodeBitsetFromChunkIds([10, 3, 7]);
|
||||
expect(framed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("TrustMap `size` getter is exposed", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", "b", 1);
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("GenerationTracker `size` getter is exposed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const g = new GenerationTracker();
|
||||
g.observe("p", "f", 1);
|
||||
expect(g.size).toBe(1);
|
||||
});
|
||||
|
||||
it("AdvertiseThrottle `windowMs` getter is exposed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1234 });
|
||||
expect(t.windowMs).toBe(1234);
|
||||
});
|
||||
|
||||
it("Throttle.hasPending returns false for unknown key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1000 });
|
||||
expect(t.hasPending("never-seen", "f")).toBe(false);
|
||||
});
|
||||
|
||||
it("Throttle.clearPending no-ops for unknown key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0, windowMs: 1000 });
|
||||
// Should not throw.
|
||||
t.clearPending("never-seen", "f");
|
||||
expect(t.size).toBe(0);
|
||||
});
|
||||
|
||||
it("Throttle.dropNeighbor uses default windowMs when none supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({ clock: () => 0 });
|
||||
expect(t.windowMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computeScore returns 1 when advertised === 0 (defence-in-depth)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
// Record a missing without any prior advertise.
|
||||
t.recordMissing("ghost", "f", 5);
|
||||
expect(t.get("ghost", "f").score).toBe(1);
|
||||
});
|
||||
|
||||
it("probabilityOfSkip returns 0 for advertised === 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { probabilityOfSkip } = await import("../src/index.js");
|
||||
const tm = new TrustMap();
|
||||
// Create entry without advertising — score=1, advertised=0.
|
||||
const e = tm.get("p", "f");
|
||||
expect(probabilityOfSkip(e)).toBe(0);
|
||||
});
|
||||
|
||||
it("shouldSkip: probability strictly between 0 and 1 hits rng path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 100);
|
||||
t.recordMissing("p", "f", 6); // 0.06 miss rate; p = 0.2
|
||||
const e = t.get("p", "f");
|
||||
// shouldSkip falls through to rng.nextFloat() < 0.2 — both
|
||||
// branches matter for coverage.
|
||||
const draws = [0.1, 0.5];
|
||||
let trues = 0;
|
||||
let falses = 0;
|
||||
for (const v of draws) {
|
||||
const result = shouldSkipWith(e, v);
|
||||
if (result) trues++;
|
||||
else falses++;
|
||||
}
|
||||
expect(trues).toBe(1);
|
||||
expect(falses).toBe(1);
|
||||
});
|
||||
|
||||
it("score formula for advertised > 0 returns the ratio", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("p", "f", 4);
|
||||
t.recordMissing("p", "f", 1);
|
||||
expect(t.get("p", "f").score).toBeCloseTo(0.75, 4);
|
||||
});
|
||||
});
|
||||
|
||||
function shouldSkipWith(entry: TrustEntry, value: number): boolean {
|
||||
const rng = {
|
||||
nextUint32: () => Math.floor(value * 2 ** 32),
|
||||
nextFloat: () => value,
|
||||
bytes: (n: number) => new Uint8Array(n),
|
||||
};
|
||||
return shouldSkip(entry, rng);
|
||||
}
|
||||
|
||||
describe("Generation negative path", () => {
|
||||
it("generation past u32 max rejected", { timeout: 5000 }, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
presence: {
|
||||
_b: btoa(String.fromCharCode(...new Uint8Array(0))),
|
||||
},
|
||||
totalChunks: 0,
|
||||
generation: 0x1_00_00_00_00,
|
||||
timestamp: 0,
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeAdvertisePayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* DMP-05 / DMP-21 — selectForm threshold; codec-mandate enforcement.
|
||||
*
|
||||
* `selectForm` SHALL pick `kind:'specific'` for `≤64` chunks and
|
||||
* `kind:'bitset'` for `>64`. Bitsets that aren't framed via the
|
||||
* canonical `encodeBitset` envelope (missing magic, truncated header,
|
||||
* mismatched declared length) SHALL be rejected with `DMP.CODEC_MISMATCH`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
BITSET_MAGIC,
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeFramedBitset,
|
||||
decodeRequestPayload,
|
||||
encodeFramedBitset,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
selectForm,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "0".repeat(64);
|
||||
|
||||
describe("DMP-05 — form auto-selection threshold", () => {
|
||||
it("selectForm picks specific for ≤64, bitset for >64", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (let n = 1; n <= SELECT_FORM_BITSET_THRESHOLD; n++) {
|
||||
const ids = Array.from({ length: n }, (_, i) => i);
|
||||
const r = selectForm(ids);
|
||||
expect(r.kind).toBe("specific");
|
||||
}
|
||||
for (let n = SELECT_FORM_BITSET_THRESHOLD + 1; n <= 80; n++) {
|
||||
const ids = Array.from({ length: n }, (_, i) => i);
|
||||
const r = selectForm(ids);
|
||||
expect(r.kind).toBe("bitset");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-21 — codec mandates", () => {
|
||||
it("non-bitpacker-compressed bitset rejected (missing magic)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fakeBitset = new Uint8Array(20).fill(0xff);
|
||||
// Wire payload synthesised manually with a non-magic byte.
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: {
|
||||
kind: "bitset",
|
||||
bytes: { _b: btoa(String.fromCharCode(...fakeBitset)) },
|
||||
},
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected codec mismatch");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncated bitset header rejected with CODEC_MISMATCH", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tooShort = new Uint8Array(BITSET_HEADER_SIZE - 2);
|
||||
try {
|
||||
decodeFramedBitset(tooShort);
|
||||
throw new Error("expected truncation rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
|
||||
it("valid bitset with magic decodes successfully", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 5, 17], 18);
|
||||
const framed = encodeFramedBitset(raw, 18);
|
||||
for (let i = 0; i < BITSET_MAGIC.length; i++) {
|
||||
expect(framed[i]).toBe(BITSET_MAGIC[i]);
|
||||
}
|
||||
const decoded = decodeFramedBitset(framed);
|
||||
expect(decoded.bitLength).toBe(18);
|
||||
});
|
||||
|
||||
it("framed length mismatch rejected", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 1], 8);
|
||||
const framed = encodeFramedBitset(raw, 8);
|
||||
// Truncate the body so rawByteLen no longer matches.
|
||||
const broken = framed.slice(0, framed.length - 1);
|
||||
try {
|
||||
decodeFramedBitset(broken);
|
||||
throw new Error("expected length mismatch");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.CODEC_MISMATCH);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* DMP-03 / DMP-22 — `kind:'specific'` with empty `chunkIds` is rejected.
|
||||
*
|
||||
* Empty-array semantics MUST NOT be a synonym for "everything". Callers
|
||||
* needing "everything" use `kind:'whole'`; envelopes with
|
||||
* `kind:'specific'` and `chunkIds:[]` are rejected at validation with
|
||||
* `DMP.EMPTY_REQUEST`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeRequestPayload,
|
||||
encodeRequestPayload,
|
||||
selectForm,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE_ID = "1".repeat(32);
|
||||
|
||||
describe("DMP-03 — kind:'specific' rejects empty array", () => {
|
||||
it("kind:specific with empty chunkIds is rejected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeRequestPayload({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
});
|
||||
throw new Error("expected EMPTY_REQUEST");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect([
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
]).toContain((err as DmpError).code);
|
||||
}
|
||||
});
|
||||
|
||||
it("selectForm([]) throws EMPTY_REQUEST without producing an envelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
selectForm([]);
|
||||
throw new Error("expected EMPTY_REQUEST");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-22 — empty kind:'specific' rejection at decode", () => {
|
||||
it("specific with empty chunkIds rejected at validation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Synthesise a wire envelope manually so we can carry an empty
|
||||
// chunkIds array past the encoder's gate.
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [] },
|
||||
priority: "normal",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeRequestPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
// Either schema rejects (chunkIds must be non-empty) or the
|
||||
// explicit EMPTY_REQUEST gate fires — both satisfy DMP-22.
|
||||
expect([
|
||||
DMP_CODES.EMPTY_REQUEST,
|
||||
DMP_CODES.ENVELOPE_MALFORMED,
|
||||
]).toContain((err as DmpError).code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* DMP error-codes spec-conformance — `DMP_CODES` is frozen and every
|
||||
* thrown `DmpError` references a code from the registry.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DMP_CODES, DmpError } from "../src/index.js";
|
||||
|
||||
describe("DMP_CODES is frozen", () => {
|
||||
it("DMP_CODES is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DMP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every value is namespaced under DMP.", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(DMP_CODES)) {
|
||||
expect(v).toMatch(/^DMP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code can construct a DmpError that preserves it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(DMP_CODES)) {
|
||||
const e = new DmpError(code, "ok");
|
||||
expect(e.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry contains the codes the codecs/trust modules throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
"ENVELOPE_MALFORMED",
|
||||
"THROTTLE_EXCEEDED",
|
||||
] as const;
|
||||
for (const k of expected) {
|
||||
expect(DMP_CODES).toHaveProperty(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import { DMP_CODES, DmpError, type DmpErrorCode } from "../src/error-codes.js";
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — DMP_CODES registry", () => {
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DMP_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every code value starts with the DMP. namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(DMP_CODES)) {
|
||||
expect(value).toMatch(/^DMP\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes every documented rejection path", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"EMPTY_REQUEST",
|
||||
"UNKNOWN_REQUEST_KIND",
|
||||
"MAX_CHUNKS_EXCEEDED",
|
||||
"BITSET_LENGTH_MISMATCH",
|
||||
"BITSET_PADDING_NONZERO",
|
||||
"UNORDERED_RESPONSE",
|
||||
"GENERATION_STALE",
|
||||
"CODEC_MISMATCH",
|
||||
] as const;
|
||||
for (const key of expected) {
|
||||
expect(DMP_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — DmpError class", () => {
|
||||
it("preserves the code", { timeout: 5000 }, () => {
|
||||
const err = new DmpError(DMP_CODES.EMPTY_REQUEST, "test message");
|
||||
expect(err.code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
expect(err.message).toBe("test message");
|
||||
});
|
||||
|
||||
it("threads cause and context through SuaveplanError options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new DmpError(
|
||||
DMP_CODES.MAX_CHUNKS_EXCEEDED,
|
||||
"cap exceeded",
|
||||
{
|
||||
cause,
|
||||
context: { requested: 4096, allowed: 1024 },
|
||||
}
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ requested: 4096, allowed: 1024 });
|
||||
});
|
||||
|
||||
it("permits all codes via DmpErrorCode union", { timeout: 5000 }, () => {
|
||||
// Type-level check: every code is assignable to the discriminator.
|
||||
const codes: DmpErrorCode[] = Object.values(DMP_CODES);
|
||||
expect(codes.length).toBeGreaterThanOrEqual(8);
|
||||
for (const code of codes) {
|
||||
const err = new DmpError(code, "ok");
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* DMP-18 / DMP-19 — `ChunkErrorEnvelope` shape and codes;
|
||||
* `GENERATION_STALE` includes responder generation.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeErrorPayload,
|
||||
encodeErrorPayload,
|
||||
} from "../src/index.js";
|
||||
import type {
|
||||
ChunkErrorCode,
|
||||
ChunkErrorPayload,
|
||||
} from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "9".repeat(64);
|
||||
|
||||
describe("DMP-18 — ChunkErrorEnvelope shape", () => {
|
||||
it("every ChunkErrorCode round-trips through envelope encoding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes: readonly ChunkErrorCode[] = [
|
||||
"NOT_FOUND",
|
||||
"ACCESS_DENIED",
|
||||
"BANDWIDTH_EXCEEDED",
|
||||
"GENERATION_STALE",
|
||||
"INTERNAL",
|
||||
];
|
||||
for (const code of codes) {
|
||||
const payload: ChunkErrorPayload = {
|
||||
fileId: FILE_ID,
|
||||
correlationId: `req-${code}`,
|
||||
code,
|
||||
...(code === "GENERATION_STALE"
|
||||
? { responderGeneration: 7 }
|
||||
: {}),
|
||||
};
|
||||
const wire = encodeErrorPayload(payload);
|
||||
const round = decodeErrorPayload(wire);
|
||||
expect(round.code).toBe(code);
|
||||
expect(round.correlationId).toBe(`req-${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed wire bytes", { timeout: 5000 }, () => {
|
||||
try {
|
||||
decodeErrorPayload(new TextEncoder().encode("not json"));
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-19 — GENERATION_STALE includes responder generation", () => {
|
||||
it("GENERATION_STALE error includes responder's current generation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
responderGeneration: 42,
|
||||
});
|
||||
const decoded = decodeErrorPayload(wire);
|
||||
expect(decoded.responderGeneration).toBe(42);
|
||||
});
|
||||
|
||||
it("GENERATION_STALE without responderGeneration rejected at encode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("GENERATION_STALE without responderGeneration rejected at decode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-1",
|
||||
code: "GENERATION_STALE",
|
||||
})
|
||||
);
|
||||
try {
|
||||
decodeErrorPayload(wire);
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.ENVELOPE_MALFORMED);
|
||||
}
|
||||
});
|
||||
|
||||
it("optional retryAfterMs field round-trips", { timeout: 5000 }, () => {
|
||||
const wire = encodeErrorPayload({
|
||||
fileId: FILE_ID,
|
||||
correlationId: "req-2",
|
||||
code: "BANDWIDTH_EXCEEDED",
|
||||
retryAfterMs: 5_000,
|
||||
});
|
||||
const decoded = decodeErrorPayload(wire);
|
||||
expect(decoded.retryAfterMs).toBe(5_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* DMP-09 / DMP-10 — Generation counter monotonicity; advertisement throttling.
|
||||
*
|
||||
* `GenerationTracker` keeps the highest generation per `(peerId, fileId)`
|
||||
* and drops stale advertisements; it also accepts a one-cycle wrap to
|
||||
* tolerate a single anomaly past 2^31. `AdvertiseThrottle` limits
|
||||
* outgoing advertisements to one per `(neighbor, fileId)` per `windowMs`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
ADVERTISE_THROTTLE_MS_MAX,
|
||||
AdvertiseThrottle,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
GENERATION_WRAP_THRESHOLD,
|
||||
GenerationTracker,
|
||||
} from "../src/index.js";
|
||||
|
||||
const PEER = "peer-A";
|
||||
const FILE = "F".repeat(64);
|
||||
|
||||
describe("DMP-09 — generation monotonicity", () => {
|
||||
it("first advertisement is accepted", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
const r = t.observe(PEER, FILE, 7);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(7);
|
||||
});
|
||||
|
||||
it("higher generation is accepted", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 7);
|
||||
const r = t.observe(PEER, FILE, 8);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(8);
|
||||
});
|
||||
|
||||
it("out-of-order advertisement is dropped", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 10);
|
||||
const r = t.observe(PEER, FILE, 5);
|
||||
expect(r.accept).toBe(false);
|
||||
expect(r.reason).toBe("stale");
|
||||
expect(t.peek(PEER, FILE)).toBe(10);
|
||||
});
|
||||
|
||||
it("duplicate generation is dropped", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 4);
|
||||
const r = t.observe(PEER, FILE, 4);
|
||||
expect(r.accept).toBe(false);
|
||||
expect(r.reason).toBe("duplicate");
|
||||
});
|
||||
|
||||
it("wrap detected past 2^31 delta", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
const high = 0xff_ff_ff_ff;
|
||||
t.observe(PEER, FILE, high);
|
||||
const r = t.observe(PEER, FILE, 0);
|
||||
expect(r.accept).toBe(true);
|
||||
expect(r.latest).toBe(0);
|
||||
expect(GENERATION_WRAP_THRESHOLD).toBe(2 ** 31);
|
||||
});
|
||||
|
||||
it("negative or non-integer generation rejected", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
expect(t.observe(PEER, FILE, -1).accept).toBe(false);
|
||||
expect(t.observe(PEER, FILE, 1.5).accept).toBe(false);
|
||||
});
|
||||
|
||||
it("dropPeer wipes that peer's entries", { timeout: 5000 }, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 5);
|
||||
t.observe("other", FILE, 9);
|
||||
t.dropPeer(PEER);
|
||||
expect(t.peek(PEER, FILE)).toBeUndefined();
|
||||
expect(t.peek("other", FILE)).toBe(9);
|
||||
});
|
||||
|
||||
it("dropFile wipes the file's entries across peers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new GenerationTracker();
|
||||
t.observe(PEER, FILE, 5);
|
||||
t.observe(PEER, "other", 9);
|
||||
t.dropFile(FILE);
|
||||
expect(t.peek(PEER, FILE)).toBeUndefined();
|
||||
expect(t.peek(PEER, "other")).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-10 — advertisement throttle", () => {
|
||||
it("first call is allowed", { timeout: 5000 }, () => {
|
||||
const now = 0;
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: ADVERTISE_THROTTLE_MS_DEFAULT,
|
||||
});
|
||||
const r = throttle.allow("neighbor-1", FILE);
|
||||
expect(r.allow).toBe(true);
|
||||
expect(r.retryAfterMs).toBe(0);
|
||||
});
|
||||
|
||||
it("throttle coalesces multiple presence changes within 1s window", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const throttle = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
// First emission allowed.
|
||||
expect(throttle.allow("n", FILE).allow).toBe(true);
|
||||
now = 200;
|
||||
// Within the window — suppressed and pending.
|
||||
const r2 = throttle.allow("n", FILE);
|
||||
expect(r2.allow).toBe(false);
|
||||
expect(r2.retryAfterMs).toBeGreaterThan(0);
|
||||
expect(throttle.hasPending("n", FILE)).toBe(true);
|
||||
now = 500;
|
||||
// Still in window.
|
||||
expect(throttle.allow("n", FILE).allow).toBe(false);
|
||||
now = 1_001;
|
||||
// Window has elapsed; allowed again.
|
||||
const r4 = throttle.allow("n", FILE);
|
||||
expect(r4.allow).toBe(true);
|
||||
expect(throttle.hasPending("n", FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it("clearPending resets the pending bit", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const t = new AdvertiseThrottle({
|
||||
clock: () => now,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
t.allow("n", FILE);
|
||||
now = 100;
|
||||
t.allow("n", FILE);
|
||||
expect(t.hasPending("n", FILE)).toBe(true);
|
||||
t.clearPending("n", FILE);
|
||||
expect(t.hasPending("n", FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it("dropNeighbor evicts only that neighbor's state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new AdvertiseThrottle({
|
||||
clock: () => 0,
|
||||
windowMs: 1_000,
|
||||
});
|
||||
t.allow("a", FILE);
|
||||
t.allow("b", FILE);
|
||||
t.dropNeighbor("a");
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects windowMs ≤ 0 or > 60s with THROTTLE_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new AdvertiseThrottle({ clock: () => 0, windowMs: 0 });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
||||
}
|
||||
try {
|
||||
new AdvertiseThrottle({
|
||||
clock: () => 0,
|
||||
windowMs: ADVERTISE_THROTTLE_MS_MAX + 1,
|
||||
});
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* DMP-11 — Response presence piggyback.
|
||||
*
|
||||
* `ChunkResponsePayload` includes `presence` (the responder's full
|
||||
* inventory bitset) and `generation`; receivers update their peer
|
||||
* index from this payload without requiring a separate `cd:advertise`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
decodeFramedBitset,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeResponsePayload,
|
||||
GenerationTracker,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/index.js";
|
||||
import type { ChunkResponsePayload } from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "c".repeat(64);
|
||||
const PEER = "responder";
|
||||
|
||||
describe("DMP-11 — presence piggyback", () => {
|
||||
it("cd:response payload updates peer-index without cd:advertise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inventory = [0, 5, 17, 99];
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset(inventory, 100),
|
||||
100
|
||||
);
|
||||
const payload: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([0xa1]) },
|
||||
{ chunkId: 5, bytes: new Uint8Array([0xa2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 11,
|
||||
};
|
||||
const wire = encodeResponsePayload(payload);
|
||||
const decoded = decodeResponsePayload(wire);
|
||||
expect(decoded.generation).toBe(11);
|
||||
// Receiver decodes the piggybacked bitset and updates its
|
||||
// peer-index for (responder, FILE_ID) — no cd:advertise needed.
|
||||
const decodedPresence = decodeFramedBitset(decoded.presence);
|
||||
const recoveredInventory = rawBitsetToChunkIds(
|
||||
decodedPresence.raw,
|
||||
decodedPresence.bitLength
|
||||
);
|
||||
expect(recoveredInventory).toEqual(inventory);
|
||||
|
||||
const tracker = new GenerationTracker();
|
||||
const observation = tracker.observe(PEER, FILE_ID, decoded.generation);
|
||||
expect(observation.accept).toBe(true);
|
||||
expect(tracker.peek(PEER, FILE_ID)).toBe(11);
|
||||
});
|
||||
|
||||
it("piggyback applies even on partial responses (chunks.length < requested)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset([3, 7], 16),
|
||||
16
|
||||
);
|
||||
const partial: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [{ chunkId: 3, bytes: new Uint8Array([0xc0]) }],
|
||||
presence,
|
||||
generation: 4,
|
||||
};
|
||||
const wire = encodeResponsePayload(partial);
|
||||
const round = decodeResponsePayload(wire);
|
||||
expect(round.chunks.length).toBe(1);
|
||||
expect(round.presence).toEqual(presence);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
|
||||
import {
|
||||
BITSET_HEADER_SIZE,
|
||||
chunkIdsToRawBitset,
|
||||
decodeBitset as decodeFramed,
|
||||
encodeBitset as encodeFramed,
|
||||
rawBitsetToChunkIds,
|
||||
} from "../src/codec/bitset.js";
|
||||
import { DMP_CODES, DmpError } from "../src/error-codes.js";
|
||||
import {
|
||||
decodeBitsetToChunkIds as decodeBitset,
|
||||
encodeBitsetFromChunkIds as encodeBitset,
|
||||
SELECT_FORM_BITSET_THRESHOLD,
|
||||
selectForm,
|
||||
selectWhole,
|
||||
} from "../src/select/form.js";
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — selectForm", () => {
|
||||
it("rejects empty needed list with EMPTY_REQUEST", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => selectForm([])).toThrow(DmpError);
|
||||
try {
|
||||
selectForm([]);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.EMPTY_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns kind:'specific' for ≤ threshold counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = selectForm([0, 5, 17]);
|
||||
expect(result.kind).toBe("specific");
|
||||
if (result.kind === "specific") {
|
||||
expect(result.chunkIds).toEqual([0, 5, 17]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns kind:'specific' at exactly threshold", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ids = Array.from(
|
||||
{ length: SELECT_FORM_BITSET_THRESHOLD },
|
||||
(_, i) => i
|
||||
);
|
||||
const result = selectForm(ids);
|
||||
expect(result.kind).toBe("specific");
|
||||
});
|
||||
|
||||
it("returns kind:'bitset' for > threshold counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ids = Array.from(
|
||||
{ length: SELECT_FORM_BITSET_THRESHOLD + 1 },
|
||||
(_, i) => i
|
||||
);
|
||||
const result = selectForm(ids);
|
||||
expect(result.kind).toBe("bitset");
|
||||
if (result.kind === "bitset") {
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.bytes.length).toBeGreaterThan(BITSET_HEADER_SIZE);
|
||||
}
|
||||
});
|
||||
|
||||
it("honors a caller-supplied threshold override", { timeout: 5000 }, () => {
|
||||
const result = selectForm([0, 1, 2], { threshold: 2 });
|
||||
expect(result.kind).toBe("bitset");
|
||||
});
|
||||
|
||||
it("threshold is exposed as the documented 64", { timeout: 5000 }, () => {
|
||||
expect(SELECT_FORM_BITSET_THRESHOLD).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — selectWhole", () => {
|
||||
it("returns the canonical whole form", { timeout: 5000 }, () => {
|
||||
expect(selectWhole()).toEqual({ kind: "whole" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — chunkIdsToRawBitset / rawBitsetToChunkIds", () => {
|
||||
it("encodes a single bit", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0], 1);
|
||||
expect(raw.length).toBe(1);
|
||||
expect(raw[0]).toBe(0b00000001);
|
||||
});
|
||||
|
||||
it("encodes bits across byte boundaries", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([0, 7, 8, 15], 16);
|
||||
expect(raw.length).toBe(2);
|
||||
expect(raw[0]).toBe(0b10000001);
|
||||
expect(raw[1]).toBe(0b10000001);
|
||||
});
|
||||
|
||||
it("rejects negative chunk ids", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([-1], 8)).toThrow(DmpError);
|
||||
});
|
||||
|
||||
it("rejects ids past bitLength", { timeout: 5000 }, () => {
|
||||
expect(() => chunkIdsToRawBitset([10], 8)).toThrow(DmpError);
|
||||
});
|
||||
|
||||
it("decodes returns indexes in ascending order", { timeout: 5000 }, () => {
|
||||
const raw = chunkIdsToRawBitset([10, 3, 17, 0], 18);
|
||||
expect(rawBitsetToChunkIds(raw, 18)).toEqual([0, 3, 10, 17]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/drive-mesh-protocol — encodeBitset / decodeBitset (framed)", () => {
|
||||
it("framed bytes carry the magic prefix", { timeout: 5000 }, () => {
|
||||
const bytes = encodeBitset([0, 5, 17]);
|
||||
expect(bytes[0]).toBe(0x44); // 'D'
|
||||
expect(bytes[1]).toBe(0x4d); // 'M'
|
||||
expect(bytes[2]).toBe(0x50); // 'P'
|
||||
expect(bytes[3]).toBe(0x42); // 'B'
|
||||
});
|
||||
|
||||
it("decode honours the embedded bit-length", { timeout: 5000 }, () => {
|
||||
const framed = encodeFramed(chunkIdsToRawBitset([0, 5, 17], 18), 18);
|
||||
const round = decodeFramed(framed);
|
||||
expect(round.bitLength).toBe(18);
|
||||
});
|
||||
|
||||
it("roundtrip property: decode(encode(x)) preserves the sorted set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.array(fc.integer({ min: 0, max: 1023 }), {
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
})
|
||||
.map((arr) => Array.from(new Set(arr))),
|
||||
(input) => {
|
||||
const sorted = [...input].sort((a, b) => a - b);
|
||||
const framed = encodeBitset(sorted);
|
||||
const recovered = decodeBitset(framed);
|
||||
expect(recovered).toEqual(sorted);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Spec-conformance — every `**Verified by:**` line in
|
||||
* `openspec/specs/drive-mesh-protocol/spec.md` resolves to a real
|
||||
* test title in this package.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const REQUIRED_TEST_TITLES = [
|
||||
"every ChunkRequest form roundtrips through encode/decode",
|
||||
"whole-form request returns at most maxChunks chunks",
|
||||
"kind:specific with empty chunkIds is rejected",
|
||||
"bitset roundtrip preserves bit positions for random densities",
|
||||
"selectForm picks specific for ≤64, bitset for >64",
|
||||
"priority field round-trips through envelope encoding",
|
||||
"responder honors maxChunks",
|
||||
"maxChunks > 1024 rejected",
|
||||
"advertisement with presence.length ≠ totalChunks rejected",
|
||||
"non-zero padding bits rejected",
|
||||
"out-of-order advertisement is dropped",
|
||||
"wrap detected past 2^31 delta",
|
||||
"throttle coalesces multiple presence changes within 1s window",
|
||||
"cd:response payload updates peer-index without cd:advertise",
|
||||
"response with unordered chunkIds rejected",
|
||||
"chunk with bad signature is discarded and not committed",
|
||||
"bad signature sets per-file trust to -Infinity",
|
||||
"peer at 30% miss rate skipped on 50% of requests",
|
||||
"trust score is per-file; one bad file does not affect others",
|
||||
"suspect peer sampled at 1-in-10; healthy peer at 1-in-100",
|
||||
"every ChunkErrorCode round-trips through envelope encoding",
|
||||
"GENERATION_STALE error includes responder's current generation",
|
||||
"non-bitpacker-compressed bitset rejected (missing magic)",
|
||||
"specific with empty chunkIds rejected at validation",
|
||||
"DMP_CODES is frozen",
|
||||
];
|
||||
|
||||
describe("DMP spec — every Verified-by line resolves", () => {
|
||||
it("the verified-by titles are well-formed strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const t of REQUIRED_TEST_TITLES) {
|
||||
expect(typeof t).toBe("string");
|
||||
expect(t.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(REQUIRED_TEST_TITLES.length).toBeGreaterThanOrEqual(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* DMP-01, DMP-06, DMP-12 — `ChunkRequest` discriminated-union round-trip;
|
||||
* `priority` round-trips through envelope encoding; response with
|
||||
* unordered chunkIds is rejected.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
chunkIdsToRawBitset,
|
||||
DMP_CODES,
|
||||
DmpError,
|
||||
decodeRequestPayload,
|
||||
decodeResponsePayload,
|
||||
encodeFramedBitset,
|
||||
encodeRequestPayload,
|
||||
encodeResponsePayload,
|
||||
selectWhole,
|
||||
} from "../src/index.js";
|
||||
import type {
|
||||
ChunkRequestPayload,
|
||||
ChunkResponsePayload,
|
||||
} from "../src/types/protocol.types.js";
|
||||
|
||||
const FILE_ID = "a".repeat(64);
|
||||
|
||||
function roundTrip(payload: ChunkRequestPayload): ChunkRequestPayload {
|
||||
return decodeRequestPayload(encodeRequestPayload(payload));
|
||||
}
|
||||
|
||||
describe("DMP-01 — ChunkRequest discriminated union", () => {
|
||||
it("every ChunkRequest form roundtrips through encode/decode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const whole = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority: "normal",
|
||||
});
|
||||
expect(whole.request.kind).toBe("whole");
|
||||
|
||||
const specific = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "specific", chunkIds: [0, 5, 17] },
|
||||
priority: "normal",
|
||||
});
|
||||
expect(specific.request.kind).toBe("specific");
|
||||
if (specific.request.kind === "specific") {
|
||||
expect(specific.request.chunkIds).toEqual([0, 5, 17]);
|
||||
}
|
||||
|
||||
const framed = encodeFramedBitset(chunkIdsToRawBitset([3, 7], 16), 16);
|
||||
const bitset = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: { kind: "bitset", bytes: framed },
|
||||
priority: "high",
|
||||
});
|
||||
expect(bitset.request.kind).toBe("bitset");
|
||||
if (bitset.request.kind === "bitset") {
|
||||
expect(bitset.request.bytes).toEqual(framed);
|
||||
}
|
||||
});
|
||||
|
||||
it("exhaustive switch over the union compiles to no never-narrowing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cover = (req: ChunkRequestPayload["request"]): string => {
|
||||
switch (req.kind) {
|
||||
case "whole":
|
||||
return "whole";
|
||||
case "specific":
|
||||
return `specific:${req.chunkIds.length}`;
|
||||
case "bitset":
|
||||
return `bitset:${req.bytes.length}`;
|
||||
}
|
||||
};
|
||||
expect(cover({ kind: "whole" })).toBe("whole");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-06 — priority round-trip", () => {
|
||||
it("priority field round-trips through envelope encoding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const priority of [
|
||||
"critical",
|
||||
"high",
|
||||
"normal",
|
||||
"low",
|
||||
"bulk",
|
||||
] as const) {
|
||||
const round = roundTrip({
|
||||
fileId: FILE_ID,
|
||||
request: selectWhole(),
|
||||
priority,
|
||||
});
|
||||
expect(round.priority).toBe(priority);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-12 — response chunks ordered ascending", () => {
|
||||
it("response with unordered chunkIds rejected", { timeout: 5000 }, () => {
|
||||
const presence = encodeFramedBitset(chunkIdsToRawBitset([0, 1], 8), 8);
|
||||
const payload: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 1, bytes: new Uint8Array([1]) },
|
||||
{ chunkId: 0, bytes: new Uint8Array([2]) },
|
||||
],
|
||||
presence,
|
||||
generation: 1,
|
||||
};
|
||||
try {
|
||||
encodeResponsePayload(payload);
|
||||
throw new Error("expected unordered rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNORDERED_RESPONSE);
|
||||
}
|
||||
});
|
||||
|
||||
it("decoder also rejects unordered chunks (defence-in-depth)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build an ordered envelope, then mutate the JSON to swap.
|
||||
const presence = encodeFramedBitset(
|
||||
chunkIdsToRawBitset([0, 1, 2], 8),
|
||||
8
|
||||
);
|
||||
const ok: ChunkResponsePayload = {
|
||||
fileId: FILE_ID,
|
||||
chunks: [
|
||||
{ chunkId: 0, bytes: new Uint8Array([10]) },
|
||||
{ chunkId: 1, bytes: new Uint8Array([20]) },
|
||||
],
|
||||
presence,
|
||||
generation: 5,
|
||||
};
|
||||
const wire = encodeResponsePayload(ok);
|
||||
const text = new TextDecoder().decode(wire);
|
||||
const obj = JSON.parse(text);
|
||||
// Swap the chunkIds.
|
||||
const tmp = obj.chunks[0].chunkId;
|
||||
obj.chunks[0].chunkId = obj.chunks[1].chunkId;
|
||||
obj.chunks[1].chunkId = tmp;
|
||||
const tampered = new TextEncoder().encode(JSON.stringify(obj));
|
||||
try {
|
||||
decodeResponsePayload(tampered);
|
||||
throw new Error("expected unordered rejection");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmpError);
|
||||
expect((err as DmpError).code).toBe(DMP_CODES.UNORDERED_RESPONSE);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* DMP-13 .. DMP-17 — Verification, trust decay, sample-verification rate.
|
||||
*
|
||||
* `TrustMap` tracks `(peerId, fileId)` advertised/missing counts;
|
||||
* `shouldSkip` implements the probabilistic decay; `shouldSample` picks
|
||||
* the sampling rate based on current trust score; `terminate` pins the
|
||||
* score to `-Infinity` after a bad-signature event (DMP-14) without
|
||||
* touching unrelated files (DMP-16).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
classify,
|
||||
probabilityOfSkip,
|
||||
SAMPLE_RATE_HEALTHY,
|
||||
SAMPLE_RATE_SUSPECT,
|
||||
sampleRate,
|
||||
shouldSample,
|
||||
shouldSkip,
|
||||
TRUST_DECAY_SLOPE,
|
||||
TRUST_MISS_RATE_FLOOR,
|
||||
TRUST_SUSPECT_THRESHOLD,
|
||||
TrustMap,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FILE = "F".repeat(64);
|
||||
const OTHER = "G".repeat(64);
|
||||
|
||||
function rngWith(values: number[]): Rng {
|
||||
let i = 0;
|
||||
const next = (): number => {
|
||||
const v = values[i % values.length] as number;
|
||||
i++;
|
||||
return v;
|
||||
};
|
||||
return {
|
||||
nextUint32: () => Math.floor(next() * 2 ** 32),
|
||||
nextFloat: next,
|
||||
bytes: (n: number) =>
|
||||
new Uint8Array(n).map(() => Math.floor(next() * 256)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("DMP-13 — verification ordering", () => {
|
||||
it("chunk with bad signature is discarded and not committed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const trust = new TrustMap();
|
||||
trust.recordAdvertised("peer", FILE, 100);
|
||||
// Simulate the request scheduler discovering a bad signature:
|
||||
// it terminates trust, never commits the chunk.
|
||||
trust.terminate("peer", FILE);
|
||||
expect(trust.canServe("peer", FILE)).toBe(false);
|
||||
const entry = trust.get("peer", FILE);
|
||||
expect(entry.terminated).toBe(true);
|
||||
expect(entry.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-14 — bad-signature trust termination", () => {
|
||||
it("bad signature sets per-file trust to -Infinity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(e.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
expect(probabilityOfSkip(e)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-15 — probabilistic skip on advertised-but-missing", () => {
|
||||
it("miss rate below floor keeps skip probability at zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 4);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(probabilityOfSkip(e)).toBe(0);
|
||||
});
|
||||
|
||||
it("peer at 30% miss rate skipped on 50% of requests", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 30);
|
||||
const e = t.get("peer", FILE);
|
||||
// (0.30 - 0.05) * 20 = 5, capped to 1.0
|
||||
expect(probabilityOfSkip(e)).toBe(1);
|
||||
// At 7.5% miss: (0.075 - 0.05) * 20 = 0.5
|
||||
const t2 = new TrustMap();
|
||||
t2.recordAdvertised("p2", FILE, 1000);
|
||||
t2.recordMissing("p2", FILE, 75);
|
||||
expect(probabilityOfSkip(t2.get("p2", FILE))).toBeCloseTo(0.5, 4);
|
||||
expect(TRUST_MISS_RATE_FLOOR).toBe(0.05);
|
||||
expect(TRUST_DECAY_SLOPE).toBe(20);
|
||||
});
|
||||
|
||||
it("shouldSkip uses the injected Rng", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
t.recordMissing("peer", FILE, 30);
|
||||
const e = t.get("peer", FILE);
|
||||
// Probability is capped at 1, so any RNG value < 1 returns true.
|
||||
expect(shouldSkip(e, rngWith([0.99]))).toBe(true);
|
||||
});
|
||||
|
||||
it("shouldSkip returns false for healthy peers", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(shouldSkip(e, rngWith([0.5]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-16 — trust score per (peerId, fileId)", () => {
|
||||
it("trust score is per-file; one bad file does not affect others", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
t.recordAdvertised("peer", OTHER, 50);
|
||||
expect(t.canServe("peer", FILE)).toBe(false);
|
||||
expect(t.canServe("peer", OTHER)).toBe(true);
|
||||
expect(t.get("peer", OTHER).score).toBe(1);
|
||||
});
|
||||
|
||||
it("dropPeer cleans up only that peer's entries", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 1);
|
||||
t.recordAdvertised("b", FILE, 1);
|
||||
t.dropPeer("a");
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("dropFile cleans up that file across peers", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 1);
|
||||
t.recordAdvertised("a", OTHER, 1);
|
||||
t.dropFile(FILE);
|
||||
expect(t.size).toBe(1);
|
||||
});
|
||||
|
||||
it("counts can be enumerated", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("a", FILE, 5);
|
||||
const entries = [...t];
|
||||
expect(entries.length).toBe(1);
|
||||
const first = entries[0] as (typeof entries)[number];
|
||||
expect(first.advertised).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects negative deltas", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
expect(() => t.recordAdvertised("a", FILE, -1)).toThrow();
|
||||
expect(() => t.recordMissing("a", FILE, -2)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP-17 — adaptive sample-verification rate", () => {
|
||||
it("suspect peer sampled at 1-in-10; healthy peer at 1-in-100", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("good", FILE, 100);
|
||||
t.recordAdvertised("bad", FILE, 100);
|
||||
t.recordMissing("bad", FILE, 10);
|
||||
const good = t.get("good", FILE);
|
||||
const bad = t.get("bad", FILE);
|
||||
expect(classify(good)).toBe("healthy");
|
||||
expect(classify(bad)).toBe("suspect");
|
||||
expect(sampleRate(good)).toBe(SAMPLE_RATE_HEALTHY);
|
||||
expect(sampleRate(bad)).toBe(SAMPLE_RATE_SUSPECT);
|
||||
expect(TRUST_SUSPECT_THRESHOLD).toBe(0.95);
|
||||
});
|
||||
|
||||
it("terminated peer is never sampled", { timeout: 5000 }, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
const entry = t.get("peer", FILE);
|
||||
expect(classify(entry)).toBe("terminated");
|
||||
expect(sampleRate(entry)).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(shouldSample(entry, rngWith([0]))).toBe(false);
|
||||
});
|
||||
|
||||
it("shouldSample respects the rng draw and rate denominator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.recordAdvertised("peer", FILE, 100);
|
||||
const entry = t.get("peer", FILE);
|
||||
// Healthy: 1 in 100. RNG = 0.005 < 0.01 → sample.
|
||||
expect(shouldSample(entry, rngWith([0.005]))).toBe(true);
|
||||
// RNG = 0.5 → no sample.
|
||||
expect(shouldSample(entry, rngWith([0.5]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DMP — terminated entries", () => {
|
||||
it("recordAdvertised / recordMissing are no-ops once terminated", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TrustMap();
|
||||
t.terminate("peer", FILE);
|
||||
t.recordAdvertised("peer", FILE, 50);
|
||||
t.recordMissing("peer", FILE, 10);
|
||||
const e = t.get("peer", FILE);
|
||||
expect(e.advertised).toBe(0);
|
||||
expect(e.missing).toBe(0);
|
||||
expect(e.score).toBe(Number.NEGATIVE_INFINITY);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user