mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
fix(read-state): structured decode for NIP-RS override layer
Replace the flat-map decode path with a parseContexts() function that
returns {frontiers, overrides} — separate namespaces keyed by raw
context ID. This fixes three correctness defects in the previous
implementation:
- Frontier keys were stored with their esc: prefix intact, so
isOverrideActive evaluated against the wrong frontier for any raw
context ID starting with ov_ or esc: (e.g. raw ov_s:evil had its
frontier stored as esc:ov_s:evil while the override register was
keyed by ov_s:evil).
- The 256-byte UTF-8 key-length check was applied only to frontier
entries; ov_* sibling keys were written back unchecked, allowing
wire keys up to 5 + suffix bytes through.
- extractOverrideRegisters accepted an arbitrary flat map and zero-filled
missing components, reconstructing partial groups that group-first
validation had already rejected.
Changes:
- readStateFormat.ts: add ParsedContexts interface and parseContexts();
make partitionOverrideGroups internal; add 256-byte enforcement to
every wire key (frontier and override siblings alike); fix the
sanitizeContexts doc comment.
- readStateSnapshot.ts: add contexts: ParsedContexts to
ParsedReadStateEvent; parseReadStateEvent populates both blob.contexts
(flat, for readStateManager backward compat) and contexts (structured);
mergeReadStateEvents iterates parsed.contexts.frontiers so callers
receive unescaped raw ctx IDs; remove extractOverrideRegisters; update
mergeOverrideRegisterMaps to accept ReadonlyMap inputs.
- readStateOverride.test.mjs: rewrite around parseContexts(); add the
full partial-group rejection matrix (S-only, B-only, S+B, C+B, S+C),
invalid sibling at each position, uint32 boundary values, 256/257-byte
key-length cases for live and floor shapes, multibyte UTF-8 boundary,
and Thufir's exact collision witness (raw ctx ov_s:evil alongside ctx
evil with its own ov_s:evil counter). 3968/3968 pass.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
72b1d4f368
commit
d979427a7b
@@ -97,8 +97,8 @@ export function unescapeFrontierKey(wireKey: string): string {
|
||||
*
|
||||
* Slice 2 (manager) and slice 3 (UI) use this type as the wire-in/wire-out
|
||||
* contract. Slices MUST NOT construct registers directly — use
|
||||
* `encodeOverrideGroup` for encoding and rely on `extractOverrideRegisters`
|
||||
* (snapshot.ts) for decoding.
|
||||
* `encodeOverrideGroup` for encoding and read `ParsedReadStateEvent.contexts.overrides`
|
||||
* (via `parseContexts`) for decoding.
|
||||
*/
|
||||
export interface OverrideRegister {
|
||||
/** Set counter S — incremented on each mark-unread. */
|
||||
@@ -193,90 +193,224 @@ export function encodeOverrideGroup(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override group validation — used inside sanitizeContexts
|
||||
//
|
||||
// The spec requires that override entries be validated as a complete logical
|
||||
// group BEFORE any per-entry processing. This function partitions the raw
|
||||
// contexts map into (a) validated override groups and (b) everything else,
|
||||
// so that sanitizeContexts can apply group-first rules without mixing the two
|
||||
// validation paths.
|
||||
// Structured parse result — separates decoded frontier IDs from override keys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Structured result of parsing a `contexts` map.
|
||||
*
|
||||
* `frontiers` — Map from **raw** context ID (unescaped) to merged frontier
|
||||
* value. Keys are never `ov_*` or `esc:` prefixed: all escaping is stripped
|
||||
* at decode time so the same raw ID is used in both maps.
|
||||
*
|
||||
* `overrides` — Map from **raw** context ID to validated `OverrideRegister`.
|
||||
* Keyed by the same raw IDs as `frontiers`, so `isOverrideActive` can safely
|
||||
* evaluate `overrides.get(rawCtx)` against `frontiers.get(rawCtx)`.
|
||||
*/
|
||||
export interface ParsedContexts {
|
||||
readonly frontiers: ReadonlyMap<string, number>;
|
||||
readonly overrides: ReadonlyMap<string, OverrideRegister>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A validated override group ready for merge.
|
||||
* `kind === "live"` → three-key group with all counters present.
|
||||
* `kind === "floor"` → tombstone, only `c` is meaningful (s=0, b=0).
|
||||
*
|
||||
* Used internally by `parseContexts` and `sanitizeContexts`.
|
||||
*/
|
||||
export type ValidatedOverrideGroup =
|
||||
| { kind: "live"; reg: OverrideRegister }
|
||||
| { kind: "floor"; c: number };
|
||||
|
||||
/**
|
||||
* Parse and validate a raw `contexts` object from a decoded blob into
|
||||
* separate frontier and override namespaces.
|
||||
*
|
||||
* This is the single structured decode path for the override layer. It
|
||||
* applies group-first validation (spec `:114`) before any per-entry
|
||||
* processing:
|
||||
*
|
||||
* 1. Collect all `ov_*` entries by suffix into candidate groups.
|
||||
* 2. Validate each group as a whole: complete live (S+C+B) or tombstone
|
||||
* floor (C-only). Any other shape (partial group, invalid value in any
|
||||
* sibling) silently drops the whole group; the frontier entry for `<ctx>`
|
||||
* is retained.
|
||||
* 3. Apply the 256-byte UTF-8 key-length check to EVERY wire key — both
|
||||
* frontier keys and override sibling keys. An invalid sibling rejects
|
||||
* its entire group; an invalid frontier key discards only that entry.
|
||||
* 4. Unescape frontier wire keys (`esc:…` → raw ctx) so both maps share
|
||||
* the same key domain.
|
||||
*
|
||||
* The result contains no `ov_*` or `esc:*` keys in `frontiers`.
|
||||
* Slices 2–3 use this type; existing callers use `sanitizeContexts` which
|
||||
* wraps this function.
|
||||
*/
|
||||
export function parseContexts(raw: Record<string, unknown>): ParsedContexts {
|
||||
const enc = new TextEncoder();
|
||||
const isUint32 = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295;
|
||||
|
||||
// ── Pass 1: bucket override candidates by suffix ─────────────────────────
|
||||
const sWire = new Map<string, string>(); // suffix → wire key (for byte-length check)
|
||||
const cWire = new Map<string, string>();
|
||||
const bWire = new Map<string, string>();
|
||||
const sVal = new Map<string, unknown>();
|
||||
const cVal = new Map<string, unknown>();
|
||||
const bVal = new Map<string, unknown>();
|
||||
const frontierEntries: Array<[string, unknown]> = [];
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key.startsWith(OV_S_PREFIX)) {
|
||||
const suffix = key.slice(OV_S_PREFIX.length);
|
||||
sWire.set(suffix, key);
|
||||
sVal.set(suffix, value);
|
||||
} else if (key.startsWith(OV_C_PREFIX)) {
|
||||
const suffix = key.slice(OV_C_PREFIX.length);
|
||||
cWire.set(suffix, key);
|
||||
cVal.set(suffix, value);
|
||||
} else if (key.startsWith(OV_B_PREFIX)) {
|
||||
const suffix = key.slice(OV_B_PREFIX.length);
|
||||
bWire.set(suffix, key);
|
||||
bVal.set(suffix, value);
|
||||
} else {
|
||||
frontierEntries.push([key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: validate each suffix as a group ───────────────────────────────
|
||||
const overrides = new Map<string, OverrideRegister>();
|
||||
const suffixes = new Set([...sWire.keys(), ...cWire.keys(), ...bWire.keys()]);
|
||||
|
||||
for (const suffix of suffixes) {
|
||||
const hasS = sWire.has(suffix);
|
||||
const hasC = cWire.has(suffix);
|
||||
const hasB = bWire.has(suffix);
|
||||
const sv = sVal.get(suffix);
|
||||
const cv = cVal.get(suffix);
|
||||
const bv = bVal.get(suffix);
|
||||
|
||||
if (!hasS && hasC && !hasB) {
|
||||
// Tombstone floor: only ov_c: present.
|
||||
const wireKey = cWire.get(suffix) ?? "";
|
||||
if (enc.encode(wireKey).length > 256) continue; // key too long → drop group
|
||||
if (!isUint32(cv)) continue;
|
||||
overrides.set(suffix, { s: 0, c: cv, b: 0 });
|
||||
} else if (hasS && hasC && hasB) {
|
||||
// Complete live group: all three sibling wire keys must be ≤ 256 bytes.
|
||||
const swk = sWire.get(suffix) ?? "";
|
||||
const cwk = cWire.get(suffix) ?? "";
|
||||
const bwk = bWire.get(suffix) ?? "";
|
||||
if (
|
||||
enc.encode(swk).length > 256 ||
|
||||
enc.encode(cwk).length > 256 ||
|
||||
enc.encode(bwk).length > 256
|
||||
)
|
||||
continue; // any sibling key too long → drop group
|
||||
if (!isUint32(sv) || !isUint32(cv) || !isUint32(bv)) continue;
|
||||
overrides.set(suffix, { s: sv, c: cv, b: bv });
|
||||
}
|
||||
// Any other shape (B-only, S-only, S+B, C+B, S+C): silently dropped.
|
||||
// The frontier entry for `suffix` may still appear in frontierEntries.
|
||||
}
|
||||
|
||||
// ── Pass 3: validate and unescape frontier entries ────────────────────────
|
||||
const frontiers = new Map<string, number>();
|
||||
for (const [wireKey, value] of frontierEntries) {
|
||||
if (enc.encode(wireKey).length > 256) continue;
|
||||
if (!isUint32(value)) continue;
|
||||
// Strip exactly one leading `esc:` to recover the raw context ID.
|
||||
const rawCtx = unescapeFrontierKey(wireKey);
|
||||
// max-merge in case a malformed blob has both escaped and unescaped forms.
|
||||
const existing = frontiers.get(rawCtx);
|
||||
if (existing === undefined || value > existing) {
|
||||
frontiers.set(rawCtx, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { frontiers, overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition the raw contexts map into validated override groups and
|
||||
* non-override entries.
|
||||
*
|
||||
* Override entries that fail group validation are silently dropped (per spec:
|
||||
* "the entire override group is rejected"); the corresponding frontier entry
|
||||
* for `<ctx>` is retained in `nonOverride`.
|
||||
* This internal helper is used by `sanitizeContexts` to produce the flat
|
||||
* `Record<string, number>` stored in `ReadStateBlob.contexts` for backward
|
||||
* compatibility with `readStateManager`. New callers (slices 2–3) should
|
||||
* use `parseContexts` directly for the structured result.
|
||||
*
|
||||
* Override key-length enforcement mirrors `parseContexts`: an invalid sibling
|
||||
* wire key rejects the entire group while retaining the frontier entry.
|
||||
*
|
||||
* @returns
|
||||
* `overrides` — Map from raw context ID to validated group.
|
||||
* `nonOverride` — Raw contexts map with all `ov_*` keys removed (frontier
|
||||
* entries and escaped keys remain for standard processing).
|
||||
* `overrides` — Map from raw context suffix to `ValidatedOverrideGroup`.
|
||||
* `nonOverride` — Raw contexts map with all `ov_*` keys removed.
|
||||
*/
|
||||
export function partitionOverrideGroups(contexts: Record<string, unknown>): {
|
||||
function partitionOverrideGroups(contexts: Record<string, unknown>): {
|
||||
overrides: Map<string, ValidatedOverrideGroup>;
|
||||
nonOverride: Record<string, unknown>;
|
||||
} {
|
||||
// First pass: collect all ov_* entries by context suffix.
|
||||
const sMap = new Map<string, unknown>();
|
||||
const cMap = new Map<string, unknown>();
|
||||
const bMap = new Map<string, unknown>();
|
||||
const enc = new TextEncoder();
|
||||
const isUint32 = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295;
|
||||
|
||||
const sWire = new Map<string, string>();
|
||||
const cWire = new Map<string, string>();
|
||||
const bWire = new Map<string, string>();
|
||||
const sVal = new Map<string, unknown>();
|
||||
const cVal = new Map<string, unknown>();
|
||||
const bVal = new Map<string, unknown>();
|
||||
const nonOverride: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(contexts)) {
|
||||
if (key.startsWith(OV_S_PREFIX)) {
|
||||
sMap.set(key.slice(OV_S_PREFIX.length), value);
|
||||
const suffix = key.slice(OV_S_PREFIX.length);
|
||||
sWire.set(suffix, key);
|
||||
sVal.set(suffix, value);
|
||||
} else if (key.startsWith(OV_C_PREFIX)) {
|
||||
cMap.set(key.slice(OV_C_PREFIX.length), value);
|
||||
const suffix = key.slice(OV_C_PREFIX.length);
|
||||
cWire.set(suffix, key);
|
||||
cVal.set(suffix, value);
|
||||
} else if (key.startsWith(OV_B_PREFIX)) {
|
||||
bMap.set(key.slice(OV_B_PREFIX.length), value);
|
||||
const suffix = key.slice(OV_B_PREFIX.length);
|
||||
bWire.set(suffix, key);
|
||||
bVal.set(suffix, value);
|
||||
} else {
|
||||
nonOverride[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: validate each unique ctx suffix as a group.
|
||||
const ctxSuffixes = new Set([...sMap.keys(), ...cMap.keys(), ...bMap.keys()]);
|
||||
const suffixes = new Set([...sWire.keys(), ...cWire.keys(), ...bWire.keys()]);
|
||||
const overrides = new Map<string, ValidatedOverrideGroup>();
|
||||
|
||||
for (const ctx of ctxSuffixes) {
|
||||
const hasS = sMap.has(ctx);
|
||||
const hasC = cMap.has(ctx);
|
||||
const hasB = bMap.has(ctx);
|
||||
|
||||
const sVal = sMap.get(ctx);
|
||||
const cVal = cMap.get(ctx);
|
||||
const bVal = bMap.get(ctx);
|
||||
|
||||
const isUint32 = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 4294967295;
|
||||
for (const suffix of suffixes) {
|
||||
const hasS = sWire.has(suffix);
|
||||
const hasC = cWire.has(suffix);
|
||||
const hasB = bWire.has(suffix);
|
||||
const sv = sVal.get(suffix);
|
||||
const cv = cVal.get(suffix);
|
||||
const bv = bVal.get(suffix);
|
||||
|
||||
if (!hasS && hasC && !hasB) {
|
||||
// Tombstone floor: only ov_c: present — must be a valid uint32.
|
||||
if (!isUint32(cVal)) continue; // invalid — drop group silently
|
||||
overrides.set(ctx, { kind: "floor", c: cVal });
|
||||
const wireKey = cWire.get(suffix) ?? "";
|
||||
if (enc.encode(wireKey).length > 256) continue;
|
||||
if (!isUint32(cv)) continue;
|
||||
overrides.set(suffix, { kind: "floor", c: cv });
|
||||
} else if (hasS && hasC && hasB) {
|
||||
// Complete live group: all three keys must have valid uint32 values.
|
||||
if (!isUint32(sVal) || !isUint32(cVal) || !isUint32(bVal)) continue;
|
||||
overrides.set(ctx, {
|
||||
kind: "live",
|
||||
reg: { s: sVal, c: cVal, b: bVal },
|
||||
});
|
||||
} else {
|
||||
// Partial group or any other shape — reject the whole group (spec).
|
||||
// The frontier entry for this ctx remains in nonOverride; do nothing here.
|
||||
const swk = sWire.get(suffix) ?? "";
|
||||
const cwk = cWire.get(suffix) ?? "";
|
||||
const bwk = bWire.get(suffix) ?? "";
|
||||
if (
|
||||
enc.encode(swk).length > 256 ||
|
||||
enc.encode(cwk).length > 256 ||
|
||||
enc.encode(bwk).length > 256
|
||||
)
|
||||
continue;
|
||||
if (!isUint32(sv) || !isUint32(cv) || !isUint32(bv)) continue;
|
||||
overrides.set(suffix, { kind: "live", reg: { s: sv, c: cv, b: bv } });
|
||||
}
|
||||
// Any other shape: silently dropped; frontier entry retained in nonOverride.
|
||||
}
|
||||
|
||||
return { overrides, nonOverride };
|
||||
@@ -346,28 +480,29 @@ export function isValidBlob(obj: unknown): obj is ReadStateBlob {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate a raw contexts object from a decoded blob.
|
||||
* Sanitize and validate a raw contexts object from a decoded blob into the
|
||||
* flat `Record<string, number>` stored in `ReadStateBlob.contexts`.
|
||||
*
|
||||
* Override entries (`ov_*` keys) are validated as complete logical groups
|
||||
* BEFORE any per-entry processing, per the spec's Content Validation section.
|
||||
* Partial groups are rejected (dropped) while their corresponding frontier
|
||||
* entry is retained. Validated override groups are re-encoded into the result
|
||||
* as raw entries so that the rest of the pipeline (merge, liveness) operates
|
||||
* on a flat integer map — the same type as frontier entries.
|
||||
*
|
||||
* Escaped frontier keys (`esc:…`) pass through unchanged; unescaping happens
|
||||
* at read time in the snapshot layer.
|
||||
* This is the backward-compatible path for `readStateManager`, which reads
|
||||
* the flat blob directly. Override entries (`ov_*` keys) are validated as
|
||||
* complete logical groups BEFORE any per-entry processing; partial groups are
|
||||
* dropped while their frontier entry is retained. The 256-byte UTF-8
|
||||
* key-length check is applied to every wire key — including override siblings.
|
||||
* Escaped frontier keys (`esc:…`) are stored as-is in the flat map (the
|
||||
* structured path via `parseContexts` applies unescaping; this flat path
|
||||
* preserves the wire form for the manager).
|
||||
*/
|
||||
export function sanitizeContexts(
|
||||
contexts: Record<string, unknown>,
|
||||
): Record<string, number> {
|
||||
const enc = new TextEncoder();
|
||||
// Step 1: partition override groups from the rest (group-first validation).
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(contexts);
|
||||
|
||||
// Step 2: sanitize non-override entries with the standard per-entry rules.
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(nonOverride)) {
|
||||
if (new TextEncoder().encode(key).length > 256) continue;
|
||||
if (enc.encode(key).length > 256) continue;
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) continue;
|
||||
if (value < 0 || value > 4294967295) continue;
|
||||
result[key] = value;
|
||||
|
||||
@@ -10,15 +10,12 @@ import {
|
||||
OV_B_PREFIX,
|
||||
OV_C_PREFIX,
|
||||
OV_S_PREFIX,
|
||||
partitionOverrideGroups,
|
||||
parseContexts,
|
||||
sanitizeContexts,
|
||||
unescapeFrontierKey,
|
||||
} from "./readStateFormat.ts";
|
||||
|
||||
import {
|
||||
extractOverrideRegisters,
|
||||
mergeOverrideRegisterMaps,
|
||||
} from "./readStateSnapshot.ts";
|
||||
import { mergeOverrideRegisterMaps } from "./readStateSnapshot.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// escapeFrontierKey / unescapeFrontierKey
|
||||
@@ -130,6 +127,24 @@ test("isOverrideActive_tombstoneFloor_returnsFalse", () => {
|
||||
assert.equal(isOverrideActive({ s: 0, c: 5, b: 0 }, 0), false);
|
||||
});
|
||||
|
||||
test("isOverrideActive_uint32Max_liveOverride_returnsTrue", () => {
|
||||
// Boundary: S at uint32 max, C one less, B at max, F at zero
|
||||
const UINT32_MAX = 4294967295;
|
||||
assert.equal(
|
||||
isOverrideActive({ s: UINT32_MAX, c: UINT32_MAX - 1, b: UINT32_MAX }, 0),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isOverrideActive_uint32Max_zeroFrontier_tile_returnsFalse", () => {
|
||||
// S == C at max → clear-wins
|
||||
const UINT32_MAX = 4294967295;
|
||||
assert.equal(
|
||||
isOverrideActive({ s: UINT32_MAX, c: UINT32_MAX, b: 1 }, 0),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeOverrideRegisters — componentwise max()
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -161,6 +176,29 @@ test("mergeOverrideRegisters_associative", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisters_zeroBoundary_idempotent", () => {
|
||||
const zero = { s: 0, c: 0, b: 0 };
|
||||
assert.deepEqual(mergeOverrideRegisters(zero, zero), zero);
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisters_uint32Max_idempotent", () => {
|
||||
const UINT32_MAX = 4294967295;
|
||||
const max = { s: UINT32_MAX, c: UINT32_MAX, b: UINT32_MAX };
|
||||
assert.deepEqual(mergeOverrideRegisters(max, max), max);
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisters_uint32Max_commutative", () => {
|
||||
const UINT32_MAX = 4294967295;
|
||||
const a = { s: UINT32_MAX, c: 0, b: UINT32_MAX };
|
||||
const b = { s: 0, c: UINT32_MAX, b: 0 };
|
||||
assert.deepEqual(mergeOverrideRegisters(a, b), mergeOverrideRegisters(b, a));
|
||||
assert.deepEqual(mergeOverrideRegisters(a, b), {
|
||||
s: UINT32_MAX,
|
||||
c: UINT32_MAX,
|
||||
b: UINT32_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encodeOverrideGroup — canonical wire encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,116 +242,373 @@ test("encodeOverrideGroup_tombstoneFloorShape_returnsOnlyOvC", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// partitionOverrideGroups — group-first validation
|
||||
// parseContexts — structured decode (group-first validation, unescape, 256-byte)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("partitionOverrideGroups_liveGroup_validates", () => {
|
||||
// ── accepted shapes ─────────────────────────────────────────────────────────
|
||||
|
||||
test("parseContexts_liveGroup_populatesBothMaps", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
"ov_c:channel:x": 0,
|
||||
"ov_b:channel:x": 900,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("channel:x"), 1000);
|
||||
assert.deepEqual(overrides.get("channel:x"), { s: 1, c: 0, b: 900 });
|
||||
assert.equal(overrides.size, 1);
|
||||
const group = overrides.get("channel:x");
|
||||
assert.equal(group?.kind, "live");
|
||||
assert.deepEqual(group?.reg, { s: 1, c: 0, b: 900 });
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
// No ov_* keys in frontiers
|
||||
assert.equal(frontiers.has("ov_s:channel:x"), false);
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_tombstoneFloor_validates", () => {
|
||||
test("parseContexts_tombstoneFloor_populatesBothMaps", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_c:channel:x": 5,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("channel:x"), 1000);
|
||||
assert.deepEqual(overrides.get("channel:x"), { s: 0, c: 5, b: 0 });
|
||||
assert.equal(overrides.size, 1);
|
||||
const group = overrides.get("channel:x");
|
||||
assert.equal(group?.kind, "floor");
|
||||
assert.equal(group?.c, 5);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_partialGroup_droppedFrontierRetained", () => {
|
||||
// Only ov_s without ov_c and ov_b → partial group → rejected
|
||||
test("parseContexts_normalFrontierOnly_noOverrides", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
"ch:normal": 5000,
|
||||
"msg:abc": 3000,
|
||||
"thread:def": 2000,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0); // group rejected
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 }); // frontier retained
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_sConly_droppedFrontierRetained", () => {
|
||||
// ov_s + ov_c but no ov_b → partial group
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": 1,
|
||||
"ov_c:channel:x": 0,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:normal"), 5000);
|
||||
assert.equal(frontiers.get("msg:abc"), 3000);
|
||||
assert.equal(frontiers.get("thread:def"), 2000);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_invalidValue_groupDropped", () => {
|
||||
// ov_s has non-integer value → invalid → whole group dropped
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": "not-a-number",
|
||||
"ov_c:channel:x": 0,
|
||||
"ov_b:channel:x": 900,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
test("parseContexts_escapedFrontierKey_unescapedInFrontiers", () => {
|
||||
// Wire key esc:ov_s:evil → raw ctx ov_s:evil
|
||||
const raw = { "esc:ov_s:evil": 777 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ov_s:evil"), 777);
|
||||
assert.equal(frontiers.has("esc:ov_s:evil"), false);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_negativeValue_groupDropped", () => {
|
||||
test("parseContexts_doubleEscapedFrontierKey_stripsOneEsc", () => {
|
||||
// Wire key esc:esc:foo → raw ctx esc:foo
|
||||
const raw = { "esc:esc:foo": 888 };
|
||||
const { frontiers } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("esc:foo"), 888);
|
||||
assert.equal(frontiers.has("esc:esc:foo"), false);
|
||||
});
|
||||
|
||||
// ── partial-group rejection matrix (every illegal subset) ───────────────────
|
||||
|
||||
test("parseContexts_sOnly_droppedFrontierRetained", () => {
|
||||
const raw = { "ch:x": 100, "ov_s:ch:x": 1 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("parseContexts_bOnly_droppedFrontierRetained", () => {
|
||||
const raw = { "ch:x": 100, "ov_b:ch:x": 50 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("parseContexts_sConly_droppedFrontierRetained", () => {
|
||||
const raw = { "ch:x": 100, "ov_s:ch:x": 1, "ov_c:ch:x": 0 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("parseContexts_sBonly_droppedFrontierRetained", () => {
|
||||
const raw = { "ch:x": 100, "ov_s:ch:x": 1, "ov_b:ch:x": 50 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("parseContexts_cBonly_droppedFrontierRetained", () => {
|
||||
const raw = { "ch:x": 100, "ov_c:ch:x": 2, "ov_b:ch:x": 50 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
// ── invalid sibling at each position ────────────────────────────────────────
|
||||
|
||||
test("parseContexts_invalidS_liveGroupDropped", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_s:channel:x": -1,
|
||||
"ov_c:channel:x": 0,
|
||||
"ov_b:channel:x": 900,
|
||||
"ch:x": 100,
|
||||
"ov_s:ch:x": "not-a-number",
|
||||
"ov_c:ch:x": 0,
|
||||
"ov_b:ch:x": 50,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_ovCInvalidValue_tombstoneDropped", () => {
|
||||
// Tombstone floor with non-integer c
|
||||
test("parseContexts_invalidC_liveGroupDropped", () => {
|
||||
const raw = {
|
||||
"channel:x": 1000,
|
||||
"ov_c:channel:x": 1.5,
|
||||
"ch:x": 100,
|
||||
"ov_s:ch:x": 1,
|
||||
"ov_c:ch:x": 1.5,
|
||||
"ov_b:ch:x": 50,
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 0);
|
||||
assert.deepEqual(nonOverride, { "channel:x": 1000 });
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("partitionOverrideGroups_multipleContexts_allValidated", () => {
|
||||
test("parseContexts_invalidB_liveGroupDropped", () => {
|
||||
const raw = { "ch:x": 100, "ov_s:ch:x": 1, "ov_c:ch:x": 0, "ov_b:ch:x": -1 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("parseContexts_uint32Overflow_liveGroupDropped", () => {
|
||||
const raw = {
|
||||
"ch:x": 100,
|
||||
"ov_s:ch:x": 4294967296,
|
||||
"ov_c:ch:x": 0,
|
||||
"ov_b:ch:x": 50,
|
||||
};
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
test("parseContexts_invalidC_tombstoneDropped", () => {
|
||||
const raw = { "ch:x": 100, "ov_c:ch:x": -5 };
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), 100);
|
||||
assert.equal(overrides.has("ch:x"), false);
|
||||
});
|
||||
|
||||
// ── uint32 boundary values accepted ─────────────────────────────────────────
|
||||
|
||||
test("parseContexts_uint32Zero_acceptedInLiveGroup", () => {
|
||||
const raw = { "ch:x": 0, "ov_s:ch:x": 0, "ov_c:ch:x": 0, "ov_b:ch:x": 0 };
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.deepEqual(overrides.get("ch:x"), { s: 0, c: 0, b: 0 });
|
||||
});
|
||||
|
||||
test("parseContexts_uint32Max_acceptedInLiveGroup", () => {
|
||||
const UINT32_MAX = 4294967295;
|
||||
const raw = {
|
||||
"ch:x": UINT32_MAX,
|
||||
"ov_s:ch:x": UINT32_MAX,
|
||||
"ov_c:ch:x": UINT32_MAX,
|
||||
"ov_b:ch:x": UINT32_MAX,
|
||||
};
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(frontiers.get("ch:x"), UINT32_MAX);
|
||||
assert.deepEqual(overrides.get("ch:x"), {
|
||||
s: UINT32_MAX,
|
||||
c: UINT32_MAX,
|
||||
b: UINT32_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseContexts_uint32Max_acceptedInTombstone", () => {
|
||||
const UINT32_MAX = 4294967295;
|
||||
const raw = { "ch:x": 0, "ov_c:ch:x": UINT32_MAX };
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.deepEqual(overrides.get("ch:x"), { s: 0, c: UINT32_MAX, b: 0 });
|
||||
});
|
||||
|
||||
// ── 256-byte key-length enforcement ─────────────────────────────────────────
|
||||
|
||||
// Build a string of exactly N UTF-8 bytes (ASCII, so length == byte count).
|
||||
function asciiOfBytes(n) {
|
||||
return "a".repeat(n);
|
||||
}
|
||||
|
||||
test("parseContexts_frontierKey256Bytes_accepted", () => {
|
||||
// ov_s: prefix is 5 bytes; a 251-byte suffix yields a 256-byte wire key.
|
||||
const suffix251 = asciiOfBytes(251);
|
||||
const raw = { [suffix251]: 42 };
|
||||
const { frontiers } = parseContexts(raw);
|
||||
assert.equal(frontiers.get(suffix251), 42);
|
||||
});
|
||||
|
||||
test("parseContexts_frontierKey257Bytes_dropped", () => {
|
||||
const suffix257 = asciiOfBytes(257);
|
||||
const raw = { [suffix257]: 42 }; // 257 bytes — exceeds 256
|
||||
const { frontiers } = parseContexts(raw);
|
||||
assert.equal(frontiers.has(suffix257), false);
|
||||
});
|
||||
|
||||
test("parseContexts_liveGroupSiblingKey256Bytes_accepted", () => {
|
||||
// ov_s: is 5 bytes; suffix of 251 bytes → wire key exactly 256 bytes.
|
||||
const suffix251 = asciiOfBytes(251);
|
||||
const raw = {
|
||||
[suffix251]: 10,
|
||||
[`${OV_S_PREFIX}${suffix251}`]: 1,
|
||||
[`${OV_C_PREFIX}${suffix251}`]: 0,
|
||||
[`${OV_B_PREFIX}${suffix251}`]: 9,
|
||||
};
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.deepEqual(overrides.get(suffix251), { s: 1, c: 0, b: 9 });
|
||||
});
|
||||
|
||||
test("parseContexts_liveGroupSiblingKey257Bytes_groupDropped", () => {
|
||||
// ov_s: is 5 bytes; suffix of 252 bytes → wire key 257 bytes — drops group.
|
||||
const suffix252 = asciiOfBytes(252);
|
||||
const raw = {
|
||||
[suffix252]: 10, // frontier key also 252 bytes — also dropped
|
||||
[`${OV_S_PREFIX}${suffix252}`]: 1,
|
||||
[`${OV_C_PREFIX}${suffix252}`]: 0,
|
||||
[`${OV_B_PREFIX}${suffix252}`]: 9,
|
||||
};
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.equal(overrides.has(suffix252), false);
|
||||
});
|
||||
|
||||
test("parseContexts_tombstoneFloorKey256Bytes_accepted", () => {
|
||||
// ov_c: is 5 bytes; suffix of 251 bytes → wire key exactly 256 bytes.
|
||||
const suffix251 = asciiOfBytes(251);
|
||||
const raw = {
|
||||
[suffix251]: 0,
|
||||
[`${OV_C_PREFIX}${suffix251}`]: 3,
|
||||
};
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.deepEqual(overrides.get(suffix251), { s: 0, c: 3, b: 0 });
|
||||
});
|
||||
|
||||
test("parseContexts_tombstoneFloorKey257Bytes_groupDropped", () => {
|
||||
// ov_c: is 5 bytes; suffix of 252 bytes → wire key 257 bytes — drops group.
|
||||
const suffix252 = asciiOfBytes(252);
|
||||
const raw = {
|
||||
[suffix252]: 0,
|
||||
[`${OV_C_PREFIX}${suffix252}`]: 3,
|
||||
};
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.equal(overrides.has(suffix252), false);
|
||||
});
|
||||
|
||||
test("parseContexts_multibyteSuffix_keyLengthCheckedInBytes", () => {
|
||||
// A 2-byte UTF-8 char (e.g. 'é') counts as 2 bytes toward the 256 limit.
|
||||
// suffix of 127 × 'é' = 254 bytes; ov_c: (5) + 254 = 259 → over limit.
|
||||
const multiSuffix = "\u00e9".repeat(127); // 127 × 2 bytes = 254 bytes
|
||||
const raw = {
|
||||
[multiSuffix]: 0,
|
||||
[`${OV_C_PREFIX}${multiSuffix}`]: 7,
|
||||
};
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.equal(overrides.has(multiSuffix), false);
|
||||
});
|
||||
|
||||
test("parseContexts_multibyteSuffix_justUnder256_accepted", () => {
|
||||
// 'é' × 125 = 250 bytes; ov_c: (5) + 250 = 255 → under limit.
|
||||
const multiSuffix = "\u00e9".repeat(125); // 125 × 2 = 250 bytes
|
||||
const raw = {
|
||||
[multiSuffix]: 0,
|
||||
[`${OV_C_PREFIX}${multiSuffix}`]: 7,
|
||||
};
|
||||
const { overrides } = parseContexts(raw);
|
||||
assert.deepEqual(overrides.get(multiSuffix), { s: 0, c: 7, b: 0 });
|
||||
});
|
||||
|
||||
// ── reserved-ID collision witness (Thufir finding 1) ────────────────────────
|
||||
|
||||
test("parseContexts_reservedIdCollision_frontierAndOverrideUseDistinctNamespaces", () => {
|
||||
// Raw context `ov_s:evil` has its frontier published under `esc:ov_s:evil`
|
||||
// (as a publisher-aware client would do). It also has a live register for
|
||||
// raw context `ov_s:evil` (suffix = `evil` for the ov_* keys).
|
||||
// Raw context `evil` independently has its own frontier and override.
|
||||
const raw = {
|
||||
"esc:ov_s:evil": 7, // frontier for raw ctx `ov_s:evil`
|
||||
"ov_s:evil": 3, // set counter for raw ctx `evil`
|
||||
"ov_c:evil": 1,
|
||||
"ov_b:evil": 6,
|
||||
evil: 100, // frontier for raw ctx `evil`
|
||||
"ov_s:regular": 2, // set counter for raw ctx `regular`
|
||||
"ov_c:regular": 0,
|
||||
"ov_b:regular": 50,
|
||||
regular: 40,
|
||||
};
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
|
||||
// frontier for raw ctx `ov_s:evil` must be 7 (unescaped from `esc:ov_s:evil`)
|
||||
assert.equal(
|
||||
frontiers.get("ov_s:evil"),
|
||||
7,
|
||||
"frontier for ctx ov_s:evil must be 7",
|
||||
);
|
||||
// frontier for raw ctx `evil` must be 100
|
||||
assert.equal(frontiers.get("evil"), 100, "frontier for ctx evil must be 100");
|
||||
// override for raw ctx `evil` comes from `ov_s:evil`/`ov_c:evil`/`ov_b:evil` (suffix=`evil`)
|
||||
assert.deepEqual(
|
||||
overrides.get("evil"),
|
||||
{ s: 3, c: 1, b: 6 },
|
||||
"override for ctx evil",
|
||||
);
|
||||
// override for raw ctx `regular`
|
||||
assert.deepEqual(
|
||||
overrides.get("regular"),
|
||||
{ s: 2, c: 0, b: 50 },
|
||||
"override for ctx regular",
|
||||
);
|
||||
// No `ov_s:evil` in frontiers as an override key — but `ov_s:evil` IS a raw ctx ID with frontier 7
|
||||
assert.equal(
|
||||
frontiers.get("ov_s:evil"),
|
||||
7,
|
||||
"ov_s:evil raw ctx has frontier 7",
|
||||
);
|
||||
// No `esc:ov_s:evil` in frontiers (was unescaped)
|
||||
assert.equal(
|
||||
frontiers.has("esc:ov_s:evil"),
|
||||
false,
|
||||
"escaped wire key must not appear in frontiers",
|
||||
);
|
||||
// liveness evaluation uses consistent namespaces
|
||||
const evilReg = overrides.get("evil");
|
||||
const evilFrontier = frontiers.get("evil") ?? 0;
|
||||
// s=3, c=1, b=6, f=100 → F(100) > B(6) → inactive
|
||||
assert.equal(
|
||||
isOverrideActive(evilReg, evilFrontier),
|
||||
false,
|
||||
"evil override inactive because frontier(100) > baseline(6)",
|
||||
);
|
||||
|
||||
const regularReg = overrides.get("regular");
|
||||
const regularFrontier = frontiers.get("regular") ?? 0;
|
||||
// s=2, c=0, b=50, f=40 → S>0 ∧ F(40)<=B(50) ∧ S(2)>C(0) → active
|
||||
assert.equal(
|
||||
isOverrideActive(regularReg, regularFrontier),
|
||||
true,
|
||||
"regular override active: F(40) <= B(50), S(2) > C(0)",
|
||||
);
|
||||
});
|
||||
|
||||
// ── multiple contexts in one blob ────────────────────────────────────────────
|
||||
|
||||
test("parseContexts_multipleContexts_allProcessedCorrectly", () => {
|
||||
const raw = {
|
||||
"ch:a": 100,
|
||||
"ov_s:ch:a": 1,
|
||||
"ov_c:ch:a": 0,
|
||||
"ov_b:ch:a": 90,
|
||||
"ch:b": 200,
|
||||
"ov_c:ch:b": 3, // tombstone floor for ch:b
|
||||
"ov_c:ch:b": 3, // tombstone floor
|
||||
"ch:c": 300,
|
||||
"ov_s:ch:c": 1, // partial — only s, no c/b → rejected
|
||||
};
|
||||
const { overrides, nonOverride } = partitionOverrideGroups(raw);
|
||||
assert.equal(overrides.size, 2); // ch:a and ch:b, ch:c rejected
|
||||
assert.equal(overrides.get("ch:a")?.kind, "live");
|
||||
assert.equal(overrides.get("ch:b")?.kind, "floor");
|
||||
const { frontiers, overrides } = parseContexts(raw);
|
||||
assert.equal(overrides.get("ch:a")?.s, 1);
|
||||
assert.deepEqual(overrides.get("ch:b"), { s: 0, c: 3, b: 0 });
|
||||
assert.equal(overrides.has("ch:c"), false);
|
||||
// Frontier entries all retained
|
||||
assert.equal(nonOverride["ch:a"], 100);
|
||||
assert.equal(nonOverride["ch:b"], 200);
|
||||
assert.equal(nonOverride["ch:c"], 300);
|
||||
assert.equal(frontiers.get("ch:a"), 100);
|
||||
assert.equal(frontiers.get("ch:b"), 200);
|
||||
assert.equal(frontiers.get("ch:c"), 300);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -357,6 +652,27 @@ test("sanitizeContexts_partialGroup_droppedFrontierRetained", () => {
|
||||
assert.equal(result[`${OV_S_PREFIX}channel:x`], undefined);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_liveGroupSiblingKeyTooLong_groupDropped", () => {
|
||||
// ov_s: (5 bytes) + suffix of 252 bytes = 257 bytes wire key → drop group
|
||||
const suffix252 = asciiOfBytes(252);
|
||||
const raw = {
|
||||
[suffix252]: 10,
|
||||
[`${OV_S_PREFIX}${suffix252}`]: 1,
|
||||
[`${OV_C_PREFIX}${suffix252}`]: 0,
|
||||
[`${OV_B_PREFIX}${suffix252}`]: 9,
|
||||
};
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result[`${OV_S_PREFIX}${suffix252}`], undefined);
|
||||
assert.equal(result[`${OV_C_PREFIX}${suffix252}`], undefined);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_tombstoneKeyTooLong_groupDropped", () => {
|
||||
const suffix252 = asciiOfBytes(252);
|
||||
const raw = { [suffix252]: 0, [`${OV_C_PREFIX}${suffix252}`]: 3 };
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result[`${OV_C_PREFIX}${suffix252}`], undefined);
|
||||
});
|
||||
|
||||
test("sanitizeContexts_normalEntriesUnaffected", () => {
|
||||
const raw = {
|
||||
"ch:normal": 5000,
|
||||
@@ -380,56 +696,12 @@ test("sanitizeContexts_invalidFrontierEntry_dropped", () => {
|
||||
});
|
||||
|
||||
test("sanitizeContexts_escapedFrontierKey_passesThrough", () => {
|
||||
// esc: prefixed frontier keys must pass through unchanged
|
||||
// esc: prefixed frontier keys must pass through unchanged in the flat map
|
||||
const raw = { "esc:ov_s:suspicious-ctx": 1000 };
|
||||
const result = sanitizeContexts(raw);
|
||||
assert.equal(result["esc:ov_s:suspicious-ctx"], 1000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractOverrideRegisters — from sanitized flat map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("extractOverrideRegisters_liveGroup_reconstructsRegister", () => {
|
||||
const contexts = new Map([
|
||||
["channel:x", 1000],
|
||||
[`${OV_S_PREFIX}channel:x`, 2],
|
||||
[`${OV_C_PREFIX}channel:x`, 1],
|
||||
[`${OV_B_PREFIX}channel:x`, 900],
|
||||
]);
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.equal(regs.size, 1);
|
||||
assert.deepEqual(regs.get("channel:x"), { s: 2, c: 1, b: 900 });
|
||||
});
|
||||
|
||||
test("extractOverrideRegisters_tombstoneFloor_reconstructsRegister", () => {
|
||||
const contexts = new Map([
|
||||
["channel:x", 1000],
|
||||
[`${OV_C_PREFIX}channel:x`, 5],
|
||||
]);
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.equal(regs.size, 1);
|
||||
// Floor shape: s=0, c=5, b=0
|
||||
assert.deepEqual(regs.get("channel:x"), { s: 0, c: 5, b: 0 });
|
||||
});
|
||||
|
||||
test("extractOverrideRegisters_noOverrideKeys_returnsEmptyMap", () => {
|
||||
const contexts = new Map([["channel:x", 1000]]);
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.equal(regs.size, 0);
|
||||
});
|
||||
|
||||
test("extractOverrideRegisters_acceptsPlainRecord", () => {
|
||||
const contexts = {
|
||||
"channel:x": 1000,
|
||||
[`${OV_S_PREFIX}channel:x`]: 1,
|
||||
[`${OV_C_PREFIX}channel:x`]: 0,
|
||||
[`${OV_B_PREFIX}channel:x`]: 900,
|
||||
};
|
||||
const regs = extractOverrideRegisters(contexts);
|
||||
assert.deepEqual(regs.get("channel:x"), { s: 1, c: 0, b: 900 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeOverrideRegisterMaps — across multiple blobs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -469,51 +741,87 @@ test("mergeOverrideRegisterMaps_threeWayMerge_takesGlobalMax", () => {
|
||||
assert.deepEqual(merged.get("ctx"), { s: 5, c: 3, b: 30 });
|
||||
});
|
||||
|
||||
test("mergeOverrideRegisterMaps_uint32Max_commutative", () => {
|
||||
const UINT32_MAX = 4294967295;
|
||||
const a = new Map([["ctx", { s: UINT32_MAX, c: 0, b: UINT32_MAX }]]);
|
||||
const b = new Map([["ctx", { s: 0, c: UINT32_MAX, b: 0 }]]);
|
||||
const ab = mergeOverrideRegisterMaps(a, b);
|
||||
const ba = mergeOverrideRegisterMaps(b, a);
|
||||
assert.deepEqual(ab.get("ctx"), ba.get("ctx"));
|
||||
assert.deepEqual(ab.get("ctx"), {
|
||||
s: UINT32_MAX,
|
||||
c: UINT32_MAX,
|
||||
b: UINT32_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip: encode → sanitize → extract produces original register
|
||||
// Round-trip: encodeOverrideGroup → parseContexts produces original register
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("roundTrip_liveRegister_survivesEncodeAndExtract", () => {
|
||||
test("roundTrip_liveRegister_survivesEncodeAndParse", () => {
|
||||
const reg = { s: 2, c: 1, b: 100 };
|
||||
const frontier = 50;
|
||||
const rawCtx = "channel-uuid-1234";
|
||||
|
||||
// Encode (as a publisher would)
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, frontier);
|
||||
// Simulate inclusion in a blob's contexts map (add frontier entry too)
|
||||
const rawContexts = { [rawCtx]: frontier, ...patch };
|
||||
// Sanitize (as a receiver would)
|
||||
const sanitized = sanitizeContexts(rawContexts);
|
||||
// Extract registers
|
||||
const regs = extractOverrideRegisters(sanitized);
|
||||
const { frontiers, overrides } = parseContexts(rawContexts);
|
||||
|
||||
assert.deepEqual(regs.get(rawCtx), reg);
|
||||
assert.equal(frontiers.get(rawCtx), frontier);
|
||||
assert.deepEqual(overrides.get(rawCtx), reg);
|
||||
});
|
||||
|
||||
test("roundTrip_tombstoneFloor_survivesEncodeAndExtract", () => {
|
||||
// A dead register compacts to tombstone floor on encode
|
||||
const reg = { s: 0, c: 5, b: 0 }; // already a floor shape
|
||||
test("roundTrip_tombstoneFloor_survivesEncodeAndParse", () => {
|
||||
const reg = { s: 0, c: 5, b: 0 };
|
||||
const frontier = 0;
|
||||
const rawCtx = "channel-uuid-5678";
|
||||
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, frontier);
|
||||
const rawContexts = { [rawCtx]: frontier, ...patch };
|
||||
const sanitized = sanitizeContexts(rawContexts);
|
||||
const regs = extractOverrideRegisters(sanitized);
|
||||
const { frontiers, overrides } = parseContexts(rawContexts);
|
||||
|
||||
// Floor is s=0, c=5, b=0
|
||||
assert.deepEqual(regs.get(rawCtx), { s: 0, c: 5, b: 0 });
|
||||
assert.equal(frontiers.get(rawCtx), frontier);
|
||||
assert.deepEqual(overrides.get(rawCtx), { s: 0, c: 5, b: 0 });
|
||||
});
|
||||
|
||||
test("roundTrip_virginRegister_omittedFromWire", () => {
|
||||
const reg = { s: 0, c: 0, b: 0 };
|
||||
const frontier = 0;
|
||||
const rawCtx = "channel-uuid-virgin";
|
||||
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, frontier);
|
||||
assert.deepEqual(patch, {}); // nothing emitted
|
||||
const rawContexts = { [rawCtx]: frontier };
|
||||
const sanitized = sanitizeContexts(rawContexts);
|
||||
const regs = extractOverrideRegisters(sanitized);
|
||||
assert.equal(regs.has(rawCtx), false);
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, 0);
|
||||
assert.deepEqual(patch, {});
|
||||
const rawContexts = { [rawCtx]: 0 };
|
||||
const { frontiers, overrides } = parseContexts(rawContexts);
|
||||
assert.equal(frontiers.get(rawCtx), 0);
|
||||
assert.equal(overrides.has(rawCtx), false);
|
||||
});
|
||||
|
||||
test("roundTrip_escapedRawCtx_survivesEncodeAndParse", () => {
|
||||
// A raw context ID that starts with ov_: publisher escapes frontier key.
|
||||
// encodeOverrideGroup does NOT escape the raw ctx suffix in ov_* keys —
|
||||
// the spec says ov_* keys use the raw suffix. The frontier key is
|
||||
// published escaped (esc:ov_s:raw) by the publisher — simulate that here.
|
||||
const rawCtx = "ov_s:tricky";
|
||||
const reg = { s: 1, c: 0, b: 10 };
|
||||
const frontier = 8;
|
||||
|
||||
// Publisher encodes: override keys use raw suffix, frontier key is escaped.
|
||||
const patch = encodeOverrideGroup(rawCtx, reg, frontier);
|
||||
// patch has ov_s:ov_s:tricky, ov_c:ov_s:tricky, ov_b:ov_s:tricky
|
||||
const rawContexts = {
|
||||
[`esc:${rawCtx}`]: frontier, // frontier published escaped
|
||||
...patch,
|
||||
};
|
||||
const { frontiers, overrides } = parseContexts(rawContexts);
|
||||
|
||||
// After unescape: frontier keyed by raw ctx `ov_s:tricky`
|
||||
assert.equal(frontiers.get(rawCtx), frontier);
|
||||
// Override keyed by suffix after `ov_s:` → `ov_s:tricky`
|
||||
assert.deepEqual(overrides.get(rawCtx), reg);
|
||||
// liveness evaluation uses the same raw key for both
|
||||
const ov = overrides.get(rawCtx);
|
||||
const f = frontiers.get(rawCtx) ?? 0;
|
||||
// s=1, c=0, b=10, f=8 → S>0 ∧ F(8)<=B(10) ∧ S(1)>C(0) → active
|
||||
assert.equal(isOverrideActive(ov, f), true);
|
||||
});
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
isValidBlob,
|
||||
isValidReadStateDTag,
|
||||
mergeOverrideRegisters,
|
||||
OV_B_PREFIX,
|
||||
OV_C_PREFIX,
|
||||
OV_S_PREFIX,
|
||||
parseContexts,
|
||||
sanitizeContexts,
|
||||
type OverrideRegister,
|
||||
type ParsedContexts,
|
||||
type ReadStateBlob,
|
||||
} from "@/features/channels/readState/readStateFormat";
|
||||
|
||||
@@ -17,6 +16,8 @@ export type ReadStateDecrypt = (ciphertext: string) => Promise<string>;
|
||||
export type ParsedReadStateEvent = {
|
||||
dTag: string;
|
||||
blob: ReadStateBlob;
|
||||
/** Structured decode: frontiers keyed by raw ctx ID, overrides by raw ctx ID. */
|
||||
contexts: ParsedContexts;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
@@ -41,13 +42,15 @@ export async function parseReadStateEvent(
|
||||
const plaintext = await decrypt(event.content);
|
||||
const parsed = JSON.parse(plaintext);
|
||||
if (!isValidBlob(parsed)) return null;
|
||||
const sanitized = sanitizeContexts(parsed.contexts);
|
||||
return {
|
||||
dTag,
|
||||
blob: {
|
||||
v: 1,
|
||||
client_id: parsed.client_id,
|
||||
contexts: sanitizeContexts(parsed.contexts),
|
||||
contexts: sanitized,
|
||||
},
|
||||
contexts: parseContexts(parsed.contexts),
|
||||
createdAt: event.created_at,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -60,61 +63,15 @@ export async function parseReadStateEvent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all override registers from a sanitized contexts map.
|
||||
*
|
||||
* Returns a Map from raw context ID to `OverrideRegister`. A tombstone floor
|
||||
* (`ov_c:` only) is returned as `{ s: 0, c: <floor>, b: 0 }` — the same
|
||||
* shape as a dead register, which is correct: the floor's sole purpose is to
|
||||
* block counter reuse, and the liveness predicate evaluates to false for it.
|
||||
*
|
||||
* This is the read-side counterpart of `encodeOverrideGroup`: it reconstructs
|
||||
* the register from the flat integer map that `sanitizeContexts` produced.
|
||||
* The input MUST already have been through `sanitizeContexts` (so partial
|
||||
* groups are absent — they were rejected at validation time).
|
||||
*/
|
||||
export function extractOverrideRegisters(
|
||||
contexts: ReadonlyMap<string, number> | Record<string, number>,
|
||||
): Map<string, OverrideRegister> {
|
||||
// Collect s/c/b values per context suffix.
|
||||
const sVals = new Map<string, number>();
|
||||
const cVals = new Map<string, number>();
|
||||
const bVals = new Map<string, number>();
|
||||
|
||||
const iterate =
|
||||
contexts instanceof Map
|
||||
? [...contexts.entries()]
|
||||
: Object.entries(contexts);
|
||||
|
||||
for (const [key, value] of iterate) {
|
||||
if (key.startsWith(OV_S_PREFIX))
|
||||
sVals.set(key.slice(OV_S_PREFIX.length), value);
|
||||
else if (key.startsWith(OV_C_PREFIX))
|
||||
cVals.set(key.slice(OV_C_PREFIX.length), value);
|
||||
else if (key.startsWith(OV_B_PREFIX))
|
||||
bVals.set(key.slice(OV_B_PREFIX.length), value);
|
||||
}
|
||||
|
||||
const result = new Map<string, OverrideRegister>();
|
||||
const ctxSet = new Set([...sVals.keys(), ...cVals.keys(), ...bVals.keys()]);
|
||||
for (const ctx of ctxSet) {
|
||||
result.set(ctx, {
|
||||
s: sVals.get(ctx) ?? 0,
|
||||
c: cVals.get(ctx) ?? 0,
|
||||
b: bVals.get(ctx) ?? 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge override registers from multiple sanitized contexts maps.
|
||||
* Merge override registers from multiple register maps.
|
||||
*
|
||||
* Applies componentwise `max()` across all sources for each context suffix,
|
||||
* returning a single Map from raw context ID to merged `OverrideRegister`.
|
||||
* This is the register-level analogue of the frontier `max()` merge.
|
||||
* Slices 2–3 call this after collecting `ParsedReadStateEvent.contexts.overrides`
|
||||
* maps from each parsed event.
|
||||
*/
|
||||
export function mergeOverrideRegisterMaps(
|
||||
...maps: Array<Map<string, OverrideRegister>>
|
||||
...maps: Array<ReadonlyMap<string, OverrideRegister>>
|
||||
): Map<string, OverrideRegister> {
|
||||
const result = new Map<string, OverrideRegister>();
|
||||
for (const source of maps) {
|
||||
@@ -131,21 +88,23 @@ export async function mergeReadStateEvents(
|
||||
pubkey: string,
|
||||
decrypt?: ReadStateDecrypt,
|
||||
): Promise<Map<string, number>> {
|
||||
const contexts = new Map<string, number>();
|
||||
const frontiers = new Map<string, number>();
|
||||
|
||||
for (const event of events) {
|
||||
const parsed = await parseReadStateEvent(event, pubkey, decrypt);
|
||||
if (!parsed) continue;
|
||||
|
||||
for (const [contextId, timestamp] of Object.entries(parsed.blob.contexts)) {
|
||||
const current = contexts.get(contextId) ?? 0;
|
||||
if (timestamp > current) {
|
||||
contexts.set(contextId, timestamp);
|
||||
// Use the structured frontiers (keyed by raw ctx ID, unescaped) rather
|
||||
// than the flat blob.contexts, which retains wire keys for readStateManager.
|
||||
for (const [rawCtx, ts] of parsed.contexts.frontiers) {
|
||||
const current = frontiers.get(rawCtx) ?? 0;
|
||||
if (ts > current) {
|
||||
frontiers.set(rawCtx, ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contexts;
|
||||
return frontiers;
|
||||
}
|
||||
|
||||
export function getSnapshotReadTimestamp(
|
||||
|
||||
Reference in New Issue
Block a user