From 0aa8bd04414a95a9e313e9d3c13f797627bd8b4c Mon Sep 17 00:00:00 2001 From: finch Date: Wed, 19 Aug 2026 15:56:13 -0400 Subject: [PATCH 01/42] Add the CBOR tag table and fold the issue-35 rulings into the wire design doc The design doc now records today's rulings: the signal keeps its redundant stream component; listings are spelled as maps; the greeting map carries a protocol-magic entry; the observation hook is three-level (peer, session, directed stream) with no library-imposed cross-stream ordering; both consumers (the CBOR reflection snapshot extractor and the tracing adapter) are in scope; and the provisional atom-tag block is based at 0xD255 in the IANA first-come-first-served range, pinned in the new public tags module pending registration. Foundation commit for the CBOR-legible wire lane (rumors#35). --- design/cbor-legible-wire.md | 179 +++++++++++++++++++++++------------- src/lib.rs | 1 + src/tags.rs | 37 ++++++++ 3 files changed, 151 insertions(+), 66 deletions(-) create mode 100644 src/tags.rs diff --git a/design/cbor-legible-wire.md b/design/cbor-legible-wire.md index 4c64d8ac..f776f40c 100644 --- a/design/cbor-legible-wire.md +++ b/design/cbor-legible-wire.md @@ -1,7 +1,10 @@ # A CBOR-legible wire protocol, and the observation hook -Status: proposal, pre-implementation. Owner: Finch. Origin: design -conversation, 2026-08-19. Builds on the version-keying migration's +Status: accepted, in implementation; tracked as +[rumors#35](https://github.com/oxidecomputer/rumors/issues/35), and +where that issue and this document differed, the rulings below resolve +toward the issue. Owner: Finch. Origin: design conversation, +2026-08-19. Builds on the version-keying migration's uniform-CBOR rulings (payloads and the wire's version atom are already CBOR; each supply-record body is already a two-item CBOR sequence). @@ -35,9 +38,10 @@ sequences are the right choice.) | Record framing | u32 BE record header | **tag 63** ("embedded CBOR sequence in a byte string"): `63(bstr(version ‖ payload))` | ≈ 0 (tag 2B + bstr header 1–5B vs flat 4B; often equal, −1 for small records) | | Record body | CBOR bstr(version) ‖ CBOR(payload) — already a sequence | unchanged, now inside the tag-63 bstr | 0 | | Run length | u32 BE | bstr header arithmetic | ≈ 0 | -| Query child listing | raw `(radix ‖ 24-byte hash)*` | alternating array `[radix, h'…', …]` (+2 B/child) or map `{radix: hash}` (+3 B/child) | the one hot cost: ballpark +3–4% on digest-dominated dispute traffic — **measure at the calibration cells before pinning** | -| Greeting | fixed-offset block + frames | text-keyed map (`{"network": …, "version": …, "listing": …, "set_len": …, "max_version_bytes": …}`) | few dozen bytes, once per session | +| Query child listing | raw `(radix ‖ 24-byte hash)*` | map `{radix: hash}` (+3 B/child; ruled over the +2 B/child alternating array for the canonicality coincidence below) | the one hot cost: ballpark +3–4% on digest-dominated dispute traffic — **measure at the calibration cells before pinning** | +| Greeting | fixed-offset block + frames | text-keyed map (`{"protocol": "rumors", "network": …, "version": …, "listing": …, "set_len": …, "max_version_bytes": …}`); the `protocol` entry is the rumors magic — tag 55799 announces only "CBOR", never whose | few dozen bytes, once per session | | Preamble magic | 6 raw bytes | CBOR self-described **tag 55799** (`0xd9d9f7`) opening the control stream, then version/intent as ints, network as bstr | once per session | +| Party hand-off | one length-framed frame carrying the party's canonical bytes on the control stream | tagged bstr (the party atom, tagged per the table below) | few bytes, once per hand-off | | Stream open label | epoch byte ‖ index byte | two leading int items | +0–2 bytes per stream | | Epilogue marker | one byte | int item | 0 | @@ -52,8 +56,9 @@ Notes on the spellings: ledger's charge-before-custody ordering is untouched. - **The listing map's key order coincides with canonicality.** CBOR deterministic encoding mandates ascending keys; the wire's canonical - form mandates strictly ascending radixes. If the map spelling is - chosen, the two disciplines are one discipline. + form mandates strictly ascending radixes. Under the map spelling the + two disciplines are one discipline — that coincidence is why the map + is ruled in over the byte-cheaper alternating array. - **The wire is deterministic-encoding CBOR, as a stated contract**: shortest-form headers everywhere, one spelling per value. This is what keeps the byte-pinning snapshot discipline meaningful after the change. @@ -126,7 +131,8 @@ for free.) Tag / don't-tag: - **Tagged**: version atoms and party atoms wherever the protocol spells - them (supply records, the greeting, the bookmark's stored clocks). + them (supply records, the greeting, the party hand-off, the + bookmark's stored clocks). Their contexts are diverse, and their per-instance cost (+3 bytes for a first-come-first-served-range tag) lands on payload-dominated paths or once-per-session surfaces. @@ -135,29 +141,34 @@ Tag / don't-tag: dispute-heavy path, the one place bytes are dear. Structure already names them. Signals and counts likewise: position suffices. -Tag numbers come from the IANA first-come-first-served range (256+; -3-byte encodings — the 1- and 2-byte ranges are assigned or -specification-required). The honest path is registering a small -contiguous block (FCFS registration is lightweight); squatting risks a -generic tool someday rendering these atoms with someone else's -semantics. Until registration lands, the numbers live in one pinned -constant table, and the capture renderer learns their names (the -sanctioned renderer-vocabulary re-accept class). - -## The one open wire ruling: signal redundancy +Tag numbers come from the IANA first-come-first-served range (32768+ +per RFC 8949 §9.2 — everything below is standards-action or +specification-required; numbers through 65535 still encode in 3 +bytes). The honest path is registering a small contiguous block (FCFS +registration is lightweight); squatting risks a generic tool someday +rendering these atoms with someone else's semantics. The provisional +block is based at 53845 (hex D255, the ASCII bytes "RU" with the FCFS +high bit set), chosen against the live registry's unassigned space: +party, version, then clock (the bookmark's stored party ‖ version +concatenation, tagged whole rather than split), with a small reserve. +Until registration lands, the numbers live in one pinned constant +table, and the capture renderer learns their names (the sanctioned +renderer-vocabulary re-accept class). + +## The signal-redundancy ruling Within one recorded directed stream the signal's stream component is constant, so signals *could* re-base to state-only codes (≤ 9, always one CBOR byte). But the dense code's redundant stream component is what the `Mislabeled` check validates against the transport label — a conformance -bug detector with committed fault-matrix coverage. Recommendation: keep -the redundancy and pay the byte (codes ≥ 24 cost two). The ruling is the -charter's first decision. +bug detector with committed fault-matrix coverage. Ruling: keep the +redundancy and pay the byte (codes ≥ 24 cost two). ## The observation hook -The capture path is a public hook, installed at `Peer` construction -(ruled 2026-08-19, shape below refined with the implementer's latitude): +The capture path is a public hook, installed at `Peer` construction, +scoped in three levels — peer, session, directed stream (ruled +2026-08-19): - **A handler attaches to the `Peer` when it is created.** For each session the peer runs (gossip, and equally bootstrap and retire — a @@ -166,44 +177,63 @@ The capture path is a public hook, installed at `Peer` construction creation call carries what identifies the session (intent, protocol, role election, an ordinal): each captured session is uniquely identifiable, and the sub-handler's lifetime is the session's. -- **The sub-handler is invoked once per protocol message on every - directed stream of that session, in observation order.** One serialized - invocation stream per session is what captures inter-stream ordering: - the call order *is* the observed interleaving. (Design note: the - streams pump concurrently, so this is a synchronization point — the - hook must never block on protocol progress, and a slow handler - back-pressures its session. That is acceptable for a debugger and must - be documented at the hook. If contention ever matters, the recorded - alternative is per-stream invocation plus a session-level atomic - ordinal, reconstructing total order without a lock.) -- **The per-message payload is the frame's wire bytes with minimal - identity, not parsed values.** Something of the shape - `fn message(&mut self, frame: Observed<'_>)` where `Observed` carries - the directed-stream identity (speaker + stream), the direction - (sent/received — both directions are captured), and `bytes: &[u8]` - which is **exactly one CBOR item**. Two deliberate choices here: - borrowing keeps the hot path zero-copy, and bytes-not-types keeps the - hook *itself* rumors-blind — no protocol type appears in its - signature, so the hook's API is stable across wire evolution and its - consumers parse with any CBOR library (or none). A bare - `FnMut(&[u8])` is the degenerate form; the small struct earns its - keep the moment a consumer wants to know which stream spoke. +- **The per-session handler yields a per-directed-stream handler** for + each directed stream of the session as it opens (control and data, + both directions — sent and received are both captured). The creation + call carries the directed-stream identity (speaker + stream); the + handler's lifetime is the stream's. +- **The per-stream handler is invoked once per protocol message, in + stream order.** Invocation order within one directed stream is that + stream's message order; **the hook imposes no cross-stream + synchronization** — streams pump concurrently, and the library does + not make every observed frame contend on a session-wide point just to + serialize observation. A consumer that wants the observed + interleaving reconstructs it without a lock from a session-scoped + atomic ordinal in its own per-session handler, stamping each message + as it arrives. A slow handler back-pressures only its own stream; + the hook must still never block on protocol progress, documented at + the hook. +- **The per-message payload is the frame's wire bytes, not parsed + values**: `bytes: &[u8]`, **exactly one CBOR item** per invocation. + Two deliberate choices here: borrowing keeps the hot path zero-copy, + and bytes-not-types keeps the hook *itself* rumors-blind — no + protocol type appears in its signature, so the hook's API is stable + across wire evolution and its consumers parse with any CBOR library + (or none). Stream identity lives at the per-stream handler's creation + rather than on every message, so the message call carries only what + varies per message. - Attachment is dynamic (`Arc` held as an `Option`), not a generic parameter on `Peer`: one branch per frame when unattached, and the public type stays unparameterized. An observability surface does not warrant monomorphization. -## First consumer: the tracing adapter - -A separate crate (or feature-gated module) so the core keeps its -dependency surface: sessions open `tracing` spans (session identity as -span fields), every observed frame is an event within its span, and the -CBOR structure maps to structured fields — ints and text directly, maps -by key, atoms as lengths-plus-hex. Because the wire is CBOR all the way -down, the adapter is a *generic* CBOR-to-tracing bridge plus a thin -naming layer; deep inspection of application payloads comes free, since -they are the application's own CBOR. The adapter is also the dogfood -proof that the hook's bytes-only signature suffices. +## The two consumers + +Both consumers are in scope for the implementation lane (ruled +2026-08-19); each is the dogfood proof, from a different angle, that +the hook's bytes-only signature suffices. + +**The snapshot extractor.** A CBOR reflection renderer that supplants +the opaque hexdump wire snapshots with human-readable introspection: +the snapshot suites capture sessions through the public hook and pin a +deterministic rendering of every observed item — structure unfolded +(ints, text, arrays, maps), tagged atoms named from the tag table, +opaque byte strings as length-plus-hex. The byte-pinning discipline +survives the legibility: because the wire is deterministic-encoding +CBOR as a stated contract, a rendering that shows every item's complete +content is injective on wire bytes — two different byte streams cannot +render identically — so the legible snapshot still pins the wire, while +a reviewer can finally *audit* what moved and why. + +**The tracing adapter.** A separate crate (or feature-gated module) so +the core keeps its dependency surface: sessions open `tracing` spans +(session identity as span fields), every observed frame is an event +within its span, and the CBOR structure maps to structured fields — +ints and text directly, maps by key, atoms as lengths-plus-hex. Because +the wire is CBOR all the way down, the adapter is a *generic* +CBOR-to-tracing bridge plus a thin naming layer; deep inspection of +application payloads comes free, since they are the application's own +CBOR. ## The committed contract @@ -238,9 +268,10 @@ and every validation property, re-denominated. After the version-keying branch merges: same review season, one format era. Pre-release is the cheap moment for a wire change; once a release -ships, this is a new protocol version by the hard rules. The tracing -adapter can trail the codec lane as its own small unit; the render test -lands with the codec lane itself. +ships, this is a new protocol version by the hard rules. The render +test lands with the codec lane itself; the two consumers (the snapshot +extractor and the tracing adapter) land in the same lane, downstream of +the codec and the hook. ## Decision record @@ -253,14 +284,30 @@ lands with the codec lane itself. - 2026-08-19 (Finch): the on-disk bookmark format becomes fully CBOR-parseable under the same property. - 2026-08-19 (Finch, shape; Claude, refinements): the observation hook — - Peer-attached handler, per-session sub-handler capturing inter-stream - ordering and session identity, per-message bytes-level invocation; the - tracing adapter is the first consumer. + Peer-attached handler, per-session sub-handler carrying session + identity, per-message bytes-level invocation; the tracing adapter is + the first consumer. - 2026-08-19 (Finch): the opaque atoms gain CBOR tags, placed in the transport codecs (never the serde impls), per the tag/don't-tag table above. -- Open rulings for the charter: the signal-redundancy byte (recommend - paying it); listing spelling (alternating array vs map — recommend the - map, for the canonicality coincidence, unless the extra byte per child - reads as too dear at the calibration cells); the hook's final - signature; the tag-number block and its IANA registration. +- 2026-08-19 (Finch): keep the signal's redundant stream component and + pay the byte; the `Mislabeled` conformance check keeps its wire-side + witness. +- 2026-08-19 (Finch): listings are spelled as the map, for the + canonicality coincidence; cost still measured at the calibration + cells before pinning. +- 2026-08-19 (Finch): the hook is three-level (peer → session → + directed stream), per the issue's framing; the library imposes no + cross-stream ordering cost — a consumer wanting the interleaving + reconstructs it from its own session-scoped atomic ordinal. +- 2026-08-19 (Finch): the greeting map carries a `protocol` entry as + the rumors magic, since the self-described CBOR tag alone does not + name the protocol. +- 2026-08-19 (Finch): the implementer picks a provisional contiguous + tag block from the FCFS range, pinned in one constant table; the + IANA registration (or renumbering to the registered block) is + Finch's, before first release. +- 2026-08-19 (Finch): both consumers are in scope for the + implementation lane — the CBOR reflection snapshot extractor + supplants the opaque hexdump wire snapshots, and the tracing adapter + lands alongside rather than trailing. diff --git a/src/lib.rs b/src/lib.rs index ed5d45e3..750d869a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -287,6 +287,7 @@ mod protocol; pub mod reconciliation; mod rumors; mod snapshot; +pub mod tags; #[cfg(any(test, feature = "test-internals"))] #[doc(hidden)] pub mod testing; diff --git a/src/tags.rs b/src/tags.rs new file mode 100644 index 00000000..64812e91 --- /dev/null +++ b/src/tags.rs @@ -0,0 +1,37 @@ +//! CBOR tag numbers identifying the crate's opaque atoms on its +//! serialized surfaces. +//! +//! The wire protocol and the stored bookmark spell party and version +//! atoms as CBOR byte strings wrapping their canonical bit-level +//! codings. Each such byte string is preceded by one of the tags +//! below, so the atom's identity travels with it: a generic CBOR tool +//! holding nothing but this table can pick the atoms out of a capture, +//! a bookmark, or a pasted snippet, with no knowledge of where in the +//! protocol they appeared. +//! +//! Two rules govern the tags: +//! +//! - **They are protocol vocabulary, written and read only by the +//! transport and bookmark codecs.** The serde implementations of the +//! underlying types stay untagged and format-agnostic: an +//! application payload containing a version serializes identically +//! to JSON, CBOR, or any other backend, and never carries a +//! CBOR-specific concept. +//! - **The numbers are provisional, pending IANA registration.** They +//! are drawn from the first-come-first-served range (32768 and up +//! per RFC 8949 §9.2) and based at `0xD255` — the ASCII bytes `RU` +//! with the range's high bit set — in currently unassigned space. +//! Should registration assign a different block, the constants here +//! move in a deliberate, versioned format change; nothing else in +//! the crate hard-codes them. + +/// Tags a byte string holding a party atom's canonical encoding. +pub const PARTY_TAG: u64 = 0xD255; + +/// Tags a byte string holding a version atom's canonical encoding. +pub const VERSION_TAG: u64 = 0xD256; + +/// Tags a byte string holding a clock's canonical encoding: a party +/// atom's bytes immediately followed by a version atom's bytes, as the +/// bookmark stores them. +pub const CLOCK_TAG: u64 = 0xD257; From 4dd2053c9ef5de7936fdc76e9a3fc2f8e35202cc Mon Sep 17 00:00:00 2001 From: finch Date: Wed, 19 Aug 2026 17:54:31 -0400 Subject: [PATCH 02/42] wire: spell the whole V2 wire as deterministic-encoding CBOR (rumors#35) The owner-ruled, pre-release wire format change of rumors#35: every directed stream of a V2 session now parses as an RFC 8742 CBOR sequence under standard tag unwrapping, per design/cbor-legible-wire.md. The spellings: frames are arrays carrying the dense signal code as a uint item; query listings are {radix: hash} maps, whose deterministic key order and the wire's canonical child order are now one rule; supply runs and their records are embedded-sequence byte strings (tag 63) with the version atom carrying its provisional tag; the greeting is one embedded item (tag 24) wrapping a text-keyed map with a "protocol": "rumors" magic entry; the preamble is a 30-byte self-described item, 55799(["rumors", version, network, intent]); the party hand-off is a tagged byte string; stream labels are two uint items; the epilogue is the text item ".". Decoders reject non-shortest-form and indefinite heads, so the byte-pinning snapshot discipline stays meaningful. The V1 wire, codec, and snapshots are untouched (the preamble and party codecs branch on the selected dialect). Re-derived pins, old -> new, each read from its instrument: the calibrated dispute intercept 35 -> 43 B (the calibration cells; +3.9% per disputed message at the design record); the default crossover 60 -> 52 B and the u64 BDP-scale window 65,404 -> 91,941 scopes (the exact solve); DEFAULT_TARGET_MESSAGE_SIZE 1,638,912 -> 1,830,400 B (const-evaluated from the new listing arithmetic, with a new construct-and-encode adequacy pin); the disputed-reply transient ceiling 3,380,000 -> 3,570,000 B (measured 3,468,800); the trade-off table regenerated by its own renderer. The full wire snapshot corpus re-accepts as this one deliberate format change; the renderer keeps its hexdump vocabulary, decoding the new wire through the codec's own functions. The stale "~4.6x" note in the window solve's assert message (pre-existing drift: a figure that never matched the quoted prose) is corrected by the re-pinned message it lives in. --- design/cbor-legible-wire.md | 54 +- src/error.rs | 12 +- src/peer.rs | 30 +- src/peer/gossip.rs | 48 +- src/peer/gossip/tests.rs | 14 +- src/testing.rs | 24 +- src/tests.rs | 32 +- src/tree/mirror.rs | 1 + src/tree/mirror/alternating/tests.rs | 4 +- src/tree/mirror/cbor.rs | 227 ++++++ src/tree/mirror/cbor/tests.rs | 105 +++ src/tree/mirror/framing.rs | 61 +- src/tree/mirror/handshake.rs | 253 +++++-- src/tree/mirror/handshake/tests.rs | 347 ++++++---- src/tree/mirror/party.rs | 103 ++- src/tree/mirror/party/tests.rs | 77 ++- src/tree/mirror/streaming.rs | 2 +- src/tree/mirror/streaming/message.rs | 4 +- src/tree/mirror/streaming/remote.rs | 4 +- .../remote/adapter/tests/malformed.rs | 16 +- .../streaming/remote/adapter/tests/parking.rs | 2 +- src/tree/mirror/streaming/remote/codec.rs | 112 ++- .../mirror/streaming/remote/codec/budget.rs | 80 ++- .../streaming/remote/codec/budget/tests.rs | 40 +- .../mirror/streaming/remote/codec/capture.rs | 320 ++++----- .../streaming/remote/codec/capture/tests.rs | 112 ++- .../mirror/streaming/remote/codec/decode.rs | 287 ++++++-- .../streaming/remote/codec/decode/async_io.rs | 155 +++-- .../streaming/remote/codec/decode/tests.rs | 445 ++++++++---- .../mirror/streaming/remote/codec/encode.rs | 70 +- .../streaming/remote/codec/encode/async_io.rs | 14 +- .../streaming/remote/codec/encode/tests.rs | 63 +- .../mirror/streaming/remote/codec/error.rs | 25 +- .../mirror/streaming/remote/codec/frame.rs | 460 +++++++++---- .../streaming/remote/codec/frame/tests.rs | 175 +++-- .../mirror/streaming/remote/codec/greeting.rs | 313 +++++++++ .../streaming/remote/codec/greeting/tests.rs | 121 ++++ .../mirror/streaming/remote/codec/signal.rs | 27 +- ...sts__bounded_corpus_manifest_snapshot.snap | 650 +++++++++--------- ...tests__canonical_frame_atlas_snapshot.snap | 650 +++++++++--------- .../mirror/streaming/remote/codec/tests.rs | 25 +- .../remote/codec/tests/error_atlas.rs | 165 +++-- ...ror_atlas__codec_error_atlas_snapshot.snap | 206 +++--- .../mirror/streaming/remote/proxy/start.rs | 121 +--- .../streaming/remote/proxy/start/tests.rs | 259 ++++--- .../mirror/streaming/remote/proxy/tests.rs | 4 +- .../streaming/remote/proxy/tests/harness.rs | 222 ++++-- src/tree/mirror/streaming/remote/streams.rs | 81 ++- .../mirror/streaming/remote/streams/tests.rs | 4 +- src/tree/mirror/streaming/window.rs | 7 +- src/tree/mirror/streaming/window/tests.rs | 6 +- src/tree/mirror/streaming/window/tradeoff.md | 16 +- tests/decode_alloc.rs | 27 +- tests/handshake.rs | 101 +-- .../bootstrap_snapshot__empty_provider.snap | 70 +- ...trap_snapshot__mutual_bootstrap_bails.snap | 68 +- ...ootstrap_snapshot__populated_provider.snap | 104 +-- .../bootstrap_snapshot__string_payload.snap | 97 +-- ...etric_message_targets_unbatch_the_run.snap | 91 ++- .../gossip_snapshot__batched_supply_run.snap | 90 ++- ...napshot__both_redact_the_same_message.snap | 90 ++- ...bulk_initiator_ships_opening_supplies.snap | 131 ++-- ...gossip_snapshot__converged_forks_noop.snap | 98 +-- ...gossip_snapshot__deep_trie_divergence.snap | 490 +++++++------ ...shot__early_supplies_honor_redactions.snap | 132 ++-- ...hot__empty_pair_converges_immediately.snap | 72 +- .../gossip_snapshot__fork_insert_redact.snap | 122 ++-- .../gossip_snapshot__one_sided_transfer.snap | 96 +-- .../gossip_snapshot__redaction_only.snap | 96 +-- ..._same_live_content_divergent_versions.snap | 90 ++- .../gossip_snapshot__string_payload.snap | 108 +-- .../retire_snapshot__divergent_retire.snap | 104 +-- .../retire_snapshot__empty_retire.snap | 72 +- ...tire_snapshot__mutual_retire_declines.snap | 28 +- ...re_snapshot__retire_into_bootstrapper.snap | 94 +-- 75 files changed, 5758 insertions(+), 3368 deletions(-) create mode 100644 src/tree/mirror/cbor.rs create mode 100644 src/tree/mirror/cbor/tests.rs create mode 100644 src/tree/mirror/streaming/remote/codec/greeting.rs create mode 100644 src/tree/mirror/streaming/remote/codec/greeting/tests.rs diff --git a/design/cbor-legible-wire.md b/design/cbor-legible-wire.md index f776f40c..f442f685 100644 --- a/design/cbor-legible-wire.md +++ b/design/cbor-legible-wire.md @@ -33,17 +33,17 @@ sequences are the right choice.) | Layer | Today | CBOR spelling | Recurring cost | |---|---|---|---| -| Frame signal | one dense byte (stream × state, 17 × 10 codes) | unsigned int item | +1 byte for codes ≥ 24 (most); see the signal ruling below | -| Frame | signal ‖ raw body | small array `[signal, body…]` | +1 byte array header | -| Record framing | u32 BE record header | **tag 63** ("embedded CBOR sequence in a byte string"): `63(bstr(version ‖ payload))` | ≈ 0 (tag 2B + bstr header 1–5B vs flat 4B; often equal, −1 for small records) | -| Record body | CBOR bstr(version) ‖ CBOR(payload) — already a sequence | unchanged, now inside the tag-63 bstr | 0 | -| Run length | u32 BE | bstr header arithmetic | ≈ 0 | -| Query child listing | raw `(radix ‖ 24-byte hash)*` | map `{radix: hash}` (+3 B/child; ruled over the +2 B/child alternating array for the canonicality coincidence below) | the one hot cost: ballpark +3–4% on digest-dominated dispute traffic — **measure at the calibration cells before pinning** | -| Greeting | fixed-offset block + frames | text-keyed map (`{"protocol": "rumors", "network": …, "version": …, "listing": …, "set_len": …, "max_version_bytes": …}`); the `protocol` entry is the rumors magic — tag 55799 announces only "CBOR", never whose | few dozen bytes, once per session | -| Preamble magic | 6 raw bytes | CBOR self-described **tag 55799** (`0xd9d9f7`) opening the control stream, then version/intent as ints, network as bstr | once per session | +| Frame signal | one dense byte (stream × state, 17 × 10 codes) | unsigned int item carrying the same dense code | +1 byte for codes ≥ 24 (most); see the signal ruling below | +| Frame | signal ‖ raw body | array `[signal]` or `[signal, body]` | +1 byte array head | +| Record framing | u32 BE record header | **tag 63** ("embedded CBOR sequence in a byte string"): each record is `63(bstr(tagged version ‖ payload))` | ≈ 0 (tag 2B + bstr head 1–5B vs flat 4B; often equal, −1 for small records) | +| Record body | CBOR bstr(version) ‖ CBOR(payload) — already a sequence | the version atom gains its tag; payload unchanged | +3 bytes (the version tag) | +| Run length | u32 BE | the whole run is `63(bstr(record*))`; its byte-string head is the run length | ≈ 0 (tag 2B + head 1–5B vs flat 4B) | +| Query child listing | raw `(radix ‖ 24-byte hash)*` | map `{radix: hash}` (+3 B/child; ruled over the +2 B/child alternating array for the canonicality coincidence below) | the one hot cost, **measured** at the calibration cells: the whole change moved the calibrated per-disputed-message intercept 35 → 43 B — +3.9% at the design record (207 → 215 B/message), +8% at mid-size records, +14% at minimal `u64` records | +| Greeting | fixed-offset block + frames | one item, `24(bstr(map))`: a text-keyed map (`{"listing": {radix: hash}, "set_len": …, "version": , "protocol": "rumors", "max_version_bytes": …, "target_message_size": …}`, keys in deterministic order) behind the embedded-item tag, so the control-stream reader gets the item's length up front. The `protocol` entry is the rumors magic — tag 55799 announces only "CBOR", never whose — and the version atom rides tagged, dissolving its old bare-canonical spelling | few dozen bytes, once per session | +| Preamble magic | 6 raw bytes opening a 25-byte fixed block | one 30-byte self-described item: `55799(["rumors", version: uint, network: bstr, intent: uint])` — the magic survives as the text item, so "not rumors" still diagnoses at the preamble | once per session | | Party hand-off | one length-framed frame carrying the party's canonical bytes on the control stream | tagged bstr (the party atom, tagged per the table below) | few bytes, once per hand-off | -| Stream open label | epoch byte ‖ index byte | two leading int items | +0–2 bytes per stream | -| Epilogue marker | one byte | int item | 0 | +| Stream open label | epoch byte ‖ index byte | two leading uint items | +0–1 bytes per stream | +| Epilogue marker | one byte (`.`) | the text item `"."` — one byte dearer than a small int, and a generic tool renders the dot | +1 byte, once per session | Notes on the spellings: @@ -237,13 +237,17 @@ CBOR. ## The committed contract -- **The rumors-blind render test**, built on the public hook (the - instrument enters through the public door): capture a full session in - tests, parse every directed stream with a generic RFC 8742 parser plus - standard tag unwrapping (55799, 63, 24), and assert everything parses - with no bytes outside CBOR items. This is the tamper-evident form of +- **The rumors-blind render test, stated as a property over arbitrary + sessions**: randomized peer contents and payloads drive real sessions + of every kind the capture harness can pair, every directed stream's + capture is parsed with a generic RFC 8742 walk plus standard tag + unwrapping (55799, 63, 24; unknown tags tolerated), and the proptest + asserts everything parses with no bytes outside CBOR items. The + parser knows nothing of rumors. This is the tamper-evident form of the legibility promise; prose claims of legibility are decoration - without it. + without it. (The test enters through the transport capture today; the + observation hook's own capture-validity differential is its + complement.) - The full snapshot corpus re-accepts as one deliberate, owner-ruled pre-release format change, named in the re-accepting commit. - Re-derived (never transcribed) readings: the dispute-wire closed form @@ -257,12 +261,18 @@ codec layer (signal/frame/streams/greeting/bookmark format) rewritten, hand-parsed as today (delegating to ciborium is *not* required — the structural validation and exact pricing stay first-class), plus the hook threading through the session drivers, plus the re-accept and re-pin -wave. Recurring wire cost: +1–2 bytes per frame and +2–3 bytes per listed -child on dispute-heavy traffic (low single-digit percent, measured before -pinning); essentially zero relative cost on bulk supply. What does not -change: session semantics, the deadlock-freedom argument (framing- -independent; the hook adds observation, never a protocol dependency), -and every validation property, re-denominated. +wave. Recurring wire cost, measured at the calibration cells: +8 B per +disputed message end to end (the calibrated intercept moved 35 → 43 B), +which is +3.9% at the design record size and grows toward +14% only for +minimal `u64`-record corpora; essentially zero relative cost on bulk +supply (+3 B per record for the version tag, ≈0 for the framing). The +committed snapshot corpus — toy sessions dominated by their per-session +constants — grew 62% (5,273 → 8,567 B), a denominator that overweights +the once-per-session surfaces by design; the per-message figures above +are the hot-path claim. What does not change: session semantics, the +deadlock-freedom argument (framing-independent; the hook adds +observation, never a protocol dependency), and every validation +property, re-denominated. ## Sequencing diff --git a/src/error.rs b/src/error.rs index 76fade6f..f1ccc6c9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -57,15 +57,17 @@ pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), - /// The peer's preamble did not begin with [`PROTOCOL_MAGIC`](crate::PROTOCOL_MAGIC). - #[error("peer is not a rumors stream (remote magic: {remote_magic:x?})")] + /// The peer is not speaking the rumors protocol: its preamble began + /// with neither the self-described CBOR opening of a + /// [`Protocol::V2`] session nor the legacy raw magic of a V1 one. + #[error("peer is not a rumors stream (leading bytes: {remote_magic:x?})")] MagicMismatch { remote_magic: [u8; 6] }, /// The peer speaks a different wire dialect. #[error("peer speaks rumors protocol version {remote_version}, we selected {local_protocol:?}")] VersionMismatch { local_protocol: Protocol, - remote_version: u16, + remote_version: u64, }, /// Both peers were gossiping but belong to unrelated causal universes. @@ -221,6 +223,10 @@ impl From for Error { local_protocol, remote_version, }, + handshake::Error::Malformed { detail } => Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("peer preamble is malformed: {detail}"), + )), handshake::Error::IntentInvalid { byte } => Error::IntentInvalid { byte }, handshake::Error::BootstrapRetireConflict => Error::BootstrapRetireConflict, } diff --git a/src/peer.rs b/src/peer.rs index f5270c49..90edf255 100644 --- a/src/peer.rs +++ b/src/peer.rs @@ -400,7 +400,7 @@ impl Peer { /// pinned: each in-flight dispute (one disputed subtree, the unit /// the table below counts as a disputed scope) charges the budget /// a 5431 B envelope (recomputed exactly by test), and each disputed - /// message costs 35 B of wire overhead on top of its record + /// message costs 43 B of wire overhead on top of its record /// (calibrated by deterministic byte counts, /// `tests/dispute_wire.rs`). /// @@ -408,10 +408,10 @@ impl Peer { /// trade. A session's worst-case slowdown, relative to a session /// limited only by wire time, is about /// - /// > `slowdown ≈ max(1, BDP × 5431 / (budget × (35 + m)))` + /// > `slowdown ≈ max(1, BDP × 5431 / (budget × (43 + m)))` /// /// Read it as a ratio of two message counts: how many disputed - /// messages the wire holds, `BDP / (35 + m)`, against how many the + /// messages the wire holds, `BDP / (43 + m)`, against how many the /// budget keeps in flight, `budget / 5431`. Slowdown 1 is /// wire-time-optimal: bandwidth-bound stays bandwidth-bound. /// @@ -430,28 +430,28 @@ impl Peer { /// The ballpark answers, at the specification BDP: /// /// - **Is the default enough?** For any corpus whose mean encoded - /// record size is at least 60 B, yes: the default imposes no + /// record size is at least 52 B, yes: the default imposes no /// window-induced serialization at all, because the in-flight /// disputes' own transfer time covers the round trip. That - /// 60 B crossover comes from the exact solve, evaluated + /// 52 B crossover comes from the exact solve, evaluated /// self-consistently (each record size at its own BDP-scale /// corpus: the specification BDP in `m`-sized records, per side) /// and pinned by `default_crossover_matches_the_solve`; - /// the closed form's safe-side estimate is ~91 B. + /// the closed form's safe-side estimate is ~84 B. /// - **What budget removes the wait entirely?** About - /// `BDP × 5431 / (35 + m)` bytes. The design record (`m = 172`) - /// needs ~330 MB, where the solve agrees with the form to three + /// `BDP × 5431 / (43 + m)` bytes. The design record (`m = 172`) + /// needs ~316 MB, where the solve agrees with the form to three /// digits (this is the design point the envelope is pinned at). - /// A minimal `u64`-record corpus (9 B encoded) needs ~1.5 GB by - /// the form, ~1.1 GB by the solve: population caps thin the deep + /// A minimal `u64`-record corpus (9 B encoded) needs ~1.3 GB by + /// the form, ~0.8 GB by the solve: population caps thin the deep /// charge at BDP-scale corpora, so the estimate is conservative /// there. /// - **What does a smaller budget cost?** Smooth latency, never /// memory, and only on the interleaved dispute walk (bulk supply /// runs stream outside the window). `u64` records at the default - /// run at ~4.3× wire time for a BDP-scale corpus, and the factor + /// run at ~2.6× wire time for a BDP-scale corpus, and the factor /// grows slowly with set size as the derived window narrows: - /// ~13.6× at 10⁷ messages, ~25.3× at 10¹⁰ (all derived from the + /// ~11.5× at 10⁷ messages, ~21.4× at 10¹⁰ (all derived from the /// solve). `tests/window_operator.rs` holds the wave model /// against measured sessions on a bandwidth-limited link. /// @@ -463,7 +463,7 @@ impl Peer { /// session of 62500-message corpora a side; larger corpora derive /// narrower windows. Each cell then applies the measured wave form /// `slowdown = max(1, BDP_messages / K)`, with - /// `BDP_messages = BDP / (35 + m)` evaluated at the specification + /// `BDP_messages = BDP / (43 + m)` evaluated at the specification /// BDP of 12.5 MB (the wave form is measured: /// `tests/window_knee.rs`, `tests/window_operator.rs`). One /// caution when reading it: in rows whose window reaches the @@ -527,9 +527,9 @@ impl Peer { /// the wire's maximally disputed reply (the decode side's documented /// per-reply memory unit), so default batching never raises the wire's /// established memory ceiling. Any value is safe: zero degrades to one - /// leaf per message, and values above the wire's framing ceiling + /// leaf per message, and values above the wire's run byte cap /// (`u32::MAX` less the frame envelope) saturate to it, so a run built - /// within the target always fits its length header. + /// within the target always fits the cap. /// /// Like [`protocol`](Self::protocol), the choice follows the peer /// through [`into_rumors`](Self::into_rumors), cloning and reunion, diff --git a/src/peer/gossip.rs b/src/peer/gossip.rs index 1194055e..9881072d 100644 --- a/src/peer/gossip.rs +++ b/src/peer/gossip.rs @@ -45,18 +45,24 @@ use crate::{ use super::{Inner, Peer, bootstrap::Bootstrap}; use serde::de::DeserializeOwned; -/// Magic bytes that open every `rumors` gossip session's preamble frame. +/// Magic bytes that open a V1 gossip session's preamble frame. +/// +/// A [`Protocol::V2`] session opens with the self-described CBOR tag +/// instead, and carries its protocol magic as the greeting map's +/// `"protocol"` entry; this raw marker belongs to the V1 wire dialect +/// alone. pub const PROTOCOL_MAGIC: [u8; 6] = *b"RUMORS"; -/// The one epilogue marker byte each side writes on the control stream after -/// all of its session work, under [`Protocol::V2`]. +/// The epilogue marker each side writes on the control stream after all +/// of its session work, under [`Protocol::V2`]: the CBOR text item `"."`. /// /// Reading the peer's marker is what lets `Ok` certify that the peer -/// completed and committed too. Deliberately distinct from -/// [`PROTOCOL_MAGIC`]'s first byte (`b'R'`): a desynchronized peer that -/// starts its next preamble where an epilogue belongs is diagnosed as a -/// protocol violation, not mistaken for completion. -const EPILOGUE_MARKER: u8 = b'.'; +/// completed and committed too. As an item, the marker keeps the control +/// stream a pure CBOR sequence; its leading byte is deliberately distinct +/// from the self-described tag opening a V2 preamble, so a desynchronized +/// peer that starts its next preamble where an epilogue belongs is +/// diagnosed as a protocol violation, not mistaken for completion. +const EPILOGUE_MARKER: [u8; 2] = [0x61, b'.']; /// A session's control read half with its concrete transport type erased. /// @@ -252,7 +258,7 @@ impl Peer { let deserializer = Message::deserializer::(); // Magic/version/network/intent preamble first, before either protocol // is allowed to trust peer-declared frame lengths. - let mut staged = handshake::Staged::new(); + let mut staged = handshake::Staged::new(config.protocol); let remote = handshake::preamble( config.protocol, Network::BOOTSTRAP, @@ -290,7 +296,7 @@ impl Peer { let Some((root, mut read, mut write)) = reconcile.await? else { return Ok(None); }; - let party = party::receive(&mut read).await?; + let party = party::receive(config.protocol, &mut read).await?; // Our absorption of the received identity completes with the // in-memory `Peer` construction below, which cannot fail: certify // completion now, and require the provider's certificate so `Ok` @@ -403,7 +409,7 @@ impl Peer { C: Connector, A: Acceptor, { - let mut staged = handshake::Staged::new(); + let mut staged = handshake::Staged::new(self.protocol); let parts = match erase(link) { Ok(parts) => parts, // The fail-fast happened before any wire traffic: nothing of @@ -442,7 +448,7 @@ impl Peer { C: Connector, A: Acceptor, { - let mut staged = handshake::Staged::new(); + let mut staged = handshake::Staged::new(self.protocol); let parts = erase(link).map_err(Error::widen)?; let (_intent, result) = self.gossip_inner(Intent::Remain, &mut staged, parts).await; // Un-poison on clean completion: the session's own `Ok` under V2 is @@ -732,7 +738,7 @@ impl Peer { // The preamble rejects a peer that claims to both bootstrap and // retire, and we bailed early if we were retiring too, so no // party of ours is in flight here: `guarded.party` is `None`. - absorbed = match party::receive(read).await { + absorbed = match party::receive(self.protocol, read).await { Err(e) => return (Intent::Remain, Err(e.widen())), Ok(donated_party) => Some(donated_party), }; @@ -753,7 +759,7 @@ impl Peer { // the peer may hold the party even if the send errors, so it can // never be safely re-joined. let donated = guarded.party.take().expect("is_some"); - match party::send(donated, write).await { + match party::send(self.protocol, donated, write).await { Err(e) => { // A retiring donation in limbo must be assumed received: // report `Intent::Retire` alongside the error so that the @@ -905,7 +911,7 @@ impl Peer { acceptor: &mut link.acceptor as DynAcceptor<'a>, state: &mut link.session, when: Box::pin(when), - staged: handshake::Staged::new(), + staged: handshake::Staged::new(self.protocol), converged: None, done: false, }; @@ -1010,7 +1016,7 @@ impl Peer { // fresh staging buffer (this preamble is // consumed), and the new suppression token. drive.state.finish(); - drive.staged = handshake::Staged::new(); + drive.staged = handshake::Staged::new(drive.peer.protocol); drive.converged = Some(converged.clone()); Some(( Ok(Gossiped { @@ -1320,18 +1326,18 @@ async fn epilogue( write: &mut (dyn AsyncWrite + Unpin + Send + '_), ) -> Result<(), Error> { let send = async { - write.write_all(&[EPILOGUE_MARKER]).await?; + write.write_all(&EPILOGUE_MARKER).await?; write.flush().await }; let receive = async { - let mut marker = [0u8; 1]; + let mut marker = [0u8; EPILOGUE_MARKER.len()]; read.read_exact(&mut marker).await?; - if marker[0] != EPILOGUE_MARKER { + if marker != EPILOGUE_MARKER { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( - "peer wrote {:#04x} where the epilogue marker belongs", - marker[0] + "peer wrote {:#04x} {:#04x} where the epilogue marker belongs", + marker[0], marker[1] ), )); } diff --git a/src/peer/gossip/tests.rs b/src/peer/gossip/tests.rs index 5cc72fed..e3f6f1fa 100644 --- a/src/peer/gossip/tests.rs +++ b/src/peer/gossip/tests.rs @@ -86,11 +86,11 @@ fn concurrent_exchange_is_symmetric() { #[test] fn marker_byte_space_is_exhaustive() { for byte in u8::MIN..=u8::MAX { - let bytes = [byte]; + let bytes = [EPILOGUE_MARKER[0], byte]; let mut reader = &bytes[..]; let mut writer = tokio::io::sink(); let result = pollster::block_on(epilogue(&mut reader, &mut writer)); - if byte == EPILOGUE_MARKER { + if byte == EPILOGUE_MARKER[1] { result.expect("the marker byte completes the epilogue"); } else { let error = epilogue_error(result); @@ -126,7 +126,7 @@ fn close_before_the_marker_is_a_typed_eof() { /// following bytes remain unread in the transport. #[test] fn bytes_after_the_marker_stay_untouched() { - let bytes = [EPILOGUE_MARKER, b'R', b'U']; + let bytes = [EPILOGUE_MARKER[0], EPILOGUE_MARKER[1], b'R', b'U']; let mut reader = &bytes[..]; let mut writer = tokio::io::sink(); pollster::block_on(epilogue(&mut reader, &mut writer)).expect("the marker completes"); @@ -184,7 +184,7 @@ async fn claim_bootstrap_v2( root: tree::Root, ) -> Result<(Party, Tree), Error> { let (read, write, connector, acceptor, epoch) = erase(link)?; - let mut staged = handshake::Staged::new(); + let mut staged = handshake::Staged::new(Protocol::V2); handshake::preamble( Protocol::V2, Network::BOOTSTRAP, @@ -205,7 +205,7 @@ async fn claim_bootstrap_v2( .map_err(streaming_error)?; let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); let (root, (mut read, mut write)) = descent.await.map_err(streaming_error)?; - let party = party::receive(&mut read).await?; + let party = party::receive(Protocol::V2, &mut read).await?; epilogue(&mut read, &mut write).await?; Ok((party, Tree::from_root(root.into()))) } @@ -220,7 +220,7 @@ async fn claim_bootstrap_v1( root: tree::Root, ) -> Result<(Party, Tree), Error> { let (read, write, _connector, _acceptor, _epoch) = erase(link)?; - let mut staged = handshake::Staged::new(); + let mut staged = handshake::Staged::new(Protocol::V1); handshake::preamble( Protocol::V1, Network::BOOTSTRAP, @@ -243,7 +243,7 @@ async fn claim_bootstrap_v1( let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); let (root, (read, _write)) = descent.await.map_err(alternating_error)?; let mut read = read.into_inner(); - let party = party::receive(&mut read).await?; + let party = party::receive(Protocol::V1, &mut read).await?; Ok((party, Tree::from_root(root))) } diff --git a/src/testing.rs b/src/testing.rs index 048724d7..bb4bb42d 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -103,20 +103,24 @@ pub fn frame_payload_chunk_len() -> usize { crate::tree::mirror::framing::PAYLOAD_CHUNK_LEN } -/// Bytes of the length header ahead of each leaf record in a supply run. +/// The wire prefix of one streaming-codec supply frame declaring a +/// `declared`-byte run. /// -/// Exposed so the allocator meter (`tests/decode_alloc.rs`) builds run -/// bodies from the wire's own width rather than a transcribed copy. -pub fn run_record_header_len() -> usize { - crate::tree::mirror::framing::LENGTH_HEADER_LEN +/// Prepend it to a run body to hand [`decode_supply_frame`] a decodable +/// byte stream; the prefix is built by the codec's own head writers, so +/// the meter cannot drift from the wire. +pub fn supply_frame_head(declared: usize) -> Vec { + crate::tree::mirror::streaming::remote::supply_frame_head(declared) } -/// The signal byte opening one streaming-codec supply frame. +/// A structurally valid lone-record run of exactly `len` bytes, with +/// arbitrary record content. /// -/// Prepend it to a length-headed supply body to hand -/// [`decode_supply_frame`] a decodable byte stream. -pub fn supply_signal_byte() -> u8 { - crate::tree::mirror::streaming::remote::supply_signal_byte() +/// Exposed so the allocator meter (`tests/decode_alloc.rs`) builds run +/// bodies from the wire's own record heads rather than a transcribed +/// copy. +pub fn lone_record_run(len: usize) -> Vec { + crate::tree::mirror::streaming::remote::lone_record_run(len) } /// Decode one streaming-codec supply frame, discarding the decoded run. diff --git a/src/tests.rs b/src/tests.rs index 6b241999..68d72c23 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -23,7 +23,7 @@ use crate::{Error, Inner, Peer, Retire}; /// The preamble's wire length: magic(6) + proto_version(2) + network(16) + /// intent(1). The fault-injection budgets /// below land cuts on exact protocol boundaries relative to this. -const PREAMBLE_LEN: usize = 25; +const PREAMBLE_LEN: usize = crate::tree::mirror::handshake::V2_PREAMBLE_LEN; /// Insert each of `vals` into `k` as one committed batch. fn with_messages(k: Peer, vals: &[u64]) -> Peer { @@ -264,27 +264,31 @@ impl AsyncWrite for Fuse { /// frame plus the root-fan listing frame — so a [`Fuse`] budget can land on /// an exact protocol boundary. fn greeting_frame_len(retiree: &Peer) -> usize { - use crate::tree::mirror::streaming::{self, Local, materialized}; + use crate::tree::mirror::streaming::{self, Local, materialized, message::Greeting}; let root: streaming::Root = retiree.inner.borrow().tree.clone().root.into(); - let fan = pollster::block_on(materialized::greeting_fan(&Local, root.root)) + let fan = pollster::block_on(materialized::greeting_fan(&Local, root.root.clone())) .unwrap_or_else(|never| match never {}); - // The listing frame is raw radix-hash records: one byte plus a Merkle - // hash per child, the frame length carrying the count. - let listing_len = - materialized::fan_listing(&fan).len() * (1 + crate::tree::typed::hash::MERKLE_HASH_LEN); - crate::tree::mirror::framing::LENGTH_HEADER_LEN - + crate::tree::mirror::framing::GREETING_SIZE_WORDS_LEN - + retiree.snapshot().latest().as_bytes().len() - + crate::tree::mirror::framing::LENGTH_HEADER_LEN - + listing_len + // Reassemble the exact greeting the session sends — the same field + // sources the handshake draws from — and measure its one wire item. + let greeting = Greeting { + version: retiree.snapshot().latest().clone(), + set_len: root.len(), + max_version_bytes: root.max_version_bytes(), + target_message_size: retiree.run_budget.bytes() as u64, + listing: materialized::fan_listing(&fan), + }; + crate::tree::mirror::streaming::remote::codec::greeting::encode_greeting(&greeting).len() } /// The wire length of `retiree`'s trailing party frame, so a [`Fuse`] budget /// can land on an exact protocol boundary. fn party_frame_len(retiree: &Peer) -> usize { - // The party frame's body is the canonical party encoding, bare. - crate::tree::mirror::framing::LENGTH_HEADER_LEN + party_of(retiree).as_bytes().len() + // The hand-off is the party-atom tag wrapping a byte string of the + // canonical party encoding. + use crate::tree::mirror::cbor::head_len; + let party = party_of(retiree).as_bytes().len(); + head_len(crate::tags::PARTY_TAG) + head_len(party as u64) + party } /// A connector whose opened streams draw on the link's shared fuse budget. diff --git a/src/tree/mirror.rs b/src/tree/mirror.rs index ed9ca073..f93f6dd6 100644 --- a/src/tree/mirror.rs +++ b/src/tree/mirror.rs @@ -25,6 +25,7 @@ pub mod streaming; #[cfg(test)] mod tests; +pub(crate) mod cbor; pub(crate) mod framing; pub(crate) mod handshake; pub(crate) mod party; diff --git a/src/tree/mirror/alternating/tests.rs b/src/tree/mirror/alternating/tests.rs index 22e5f6f3..73da329c 100644 --- a/src/tree/mirror/alternating/tests.rs +++ b/src/tree/mirror/alternating/tests.rs @@ -442,8 +442,8 @@ fn handshake_flushes_over_buffering_transport() { let mut b_r = b_r; let mut a_w = HoldUntilFlush::new(a_w); let mut b_w = HoldUntilFlush::new(b_w); - let mut a_staged = handshake::Staged::new(); - let mut b_staged = handshake::Staged::new(); + let mut a_staged = handshake::Staged::new(crate::Protocol::V1); + let mut b_staged = handshake::Staged::new(crate::Protocol::V1); // The preamble carries only magic + version + network + intent, so // this exercises purely the flush/deadlock behavior of the framed diff --git a/src/tree/mirror/cbor.rs b/src/tree/mirror/cbor.rs new file mode 100644 index 00000000..719d8e1e --- /dev/null +++ b/src/tree/mirror/cbor.rs @@ -0,0 +1,227 @@ +//! Canonical CBOR head primitives shared by the hand-written wire codecs. +//! +//! The V2 wire and the session surfaces around it (preamble, greeting, +//! party hand-off, stream labels, epilogue) spell every structure as +//! deterministic-encoding CBOR: shortest-form heads everywhere, definite +//! lengths only, one spelling per value. That contract is what keeps the +//! byte-pinning snapshot discipline meaningful — a value has exactly one +//! encoding, so a snapshot pins semantics, not an encoder's whim — and it +//! is enforced on ingress: [`read_head`] and [`read_head_async`] reject a +//! head that is indefinite, reserved, or wider than its value requires. +//! +//! This module owns only the *head* grammar (RFC 8949 §3: the initial +//! byte's major type and its argument). What follows a head — payload +//! bytes, nested items, tag content — belongs to the codec reading it; +//! each codec validates the majors and values it expects and prices its +//! own lengths. Writers here emit exactly what the readers accept, and +//! the round-trip property tests in this module hold the two together. + +use tokio::io::{AsyncRead, AsyncReadExt}; + +/// Major type of an unsigned integer item. +pub(crate) const MAJOR_UINT: u8 = 0; + +/// Major type of a definite-length byte string. +pub(crate) const MAJOR_BSTR: u8 = 2; + +/// Major type of a definite-length text string. +pub(crate) const MAJOR_TEXT: u8 = 3; + +/// Major type of a definite-length array. +pub(crate) const MAJOR_ARRAY: u8 = 4; + +/// Major type of a definite-length map. +pub(crate) const MAJOR_MAP: u8 = 5; + +/// Major type of a tag. +pub(crate) const MAJOR_TAG: u8 = 6; + +/// Tag number for an embedded CBOR sequence in a byte string (RFC 9277). +pub(crate) const TAG_CBOR_SEQUENCE: u64 = 63; + +/// Tag number for an embedded CBOR data item in a byte string (RFC 8949). +pub(crate) const TAG_EMBEDDED_ITEM: u64 = 24; + +/// Bytes the shortest-form head for `value` occupies, any major type. +pub(crate) const fn head_len(value: u64) -> usize { + match value { + 0..=23 => 1, + 24..=0xff => 2, + 0x100..=0xffff => 3, + 0x1_0000..=0xffff_ffff => 5, + _ => 9, + } +} + +/// Append the shortest-form head `(major, value)` to `out`. +pub(crate) fn write_head(out: &mut Vec, major: u8, value: u64) { + debug_assert!(major < 8, "a CBOR major type is three bits"); + let major = major << 5; + match value { + 0..=23 => out.push(major | value as u8), + 24..=0xff => out.extend_from_slice(&[major | 24, value as u8]), + 0x100..=0xffff => { + out.push(major | 25); + out.extend_from_slice(&(value as u16).to_be_bytes()); + } + 0x1_0000..=0xffff_ffff => { + out.push(major | 26); + out.extend_from_slice(&(value as u32).to_be_bytes()); + } + _ => { + out.push(major | 27); + out.extend_from_slice(&value.to_be_bytes()); + } + } +} + +/// Append the head of a tag item to `out`. +pub(crate) fn write_tag(out: &mut Vec, tag: u64) { + write_head(out, MAJOR_TAG, tag); +} + +/// One decoded head: the item's major type and its argument (a value, +/// length, count, or tag number, by major type). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Head { + pub major: u8, + pub value: u64, +} + +/// A head violating the wire's deterministic-encoding contract, or no +/// head at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum HeadError { + /// The input ended inside the head. + #[error("input ends inside a CBOR head")] + Truncated, + /// An indefinite-length head; the wire is definite-length only. + #[error("indefinite-length CBOR is not canonical")] + Indefinite, + /// A reserved additional-information value (28 through 30). + #[error("CBOR head uses a reserved additional-information value")] + Reserved, + /// A wider argument encoding than the value requires. + #[error("CBOR head is not in shortest form")] + NotShortest, +} + +/// Read one canonical head off the front of `input`, advancing past it. +pub(crate) fn read_head(input: &mut &[u8]) -> Result { + let (&initial, rest) = input.split_first().ok_or(HeadError::Truncated)?; + let major = initial >> 5; + let info = initial & 0x1f; + let (value, rest) = match info { + 0..=23 => (u64::from(info), rest), + 24 => { + let (&byte, rest) = rest.split_first().ok_or(HeadError::Truncated)?; + (u64::from(byte), rest) + } + 25 => { + let (bytes, rest) = split_argument::<2>(rest)?; + (u64::from(u16::from_be_bytes(bytes)), rest) + } + 26 => { + let (bytes, rest) = split_argument::<4>(rest)?; + (u64::from(u32::from_be_bytes(bytes)), rest) + } + 27 => { + let (bytes, rest) = split_argument::<8>(rest)?; + (u64::from_be_bytes(bytes), rest) + } + 28..=30 => return Err(HeadError::Reserved), + _ => return Err(HeadError::Indefinite), + }; + let width = 1 + (input.len() - rest.len() - 1); + if width != head_len(value) { + return Err(HeadError::NotShortest); + } + *input = rest; + Ok(Head { major, value }) +} + +/// Split a fixed-width head argument off `rest`. +fn split_argument(rest: &[u8]) -> Result<([u8; N], &[u8]), HeadError> { + if rest.len() < N { + return Err(HeadError::Truncated); + } + let (bytes, rest) = rest.split_at(N); + Ok((bytes.try_into().expect("split at the argument width"), rest)) +} + +/// How reading a head from a live transport failed. +#[derive(Debug, thiserror::Error)] +pub(crate) enum HeadReadError { + /// The transport failed (end-of-stream inside the head included). + #[error(transparent)] + Io(#[from] std::io::Error), + /// The head arrived whole but violates the deterministic contract. + #[error(transparent)] + Malformed(HeadError), +} + +/// Read one canonical head from `read`. +/// +/// A clean end-of-stream *before the first byte* returns `Ok(None)`; an +/// end-of-stream inside the head is an +/// [`UnexpectedEof`](std::io::ErrorKind::UnexpectedEof) I/O error. Not +/// cancel safe: a dropped future may have consumed part of the head. +pub(crate) async fn read_head_async( + read: &mut R, +) -> Result, HeadReadError> { + let mut initial = 0u8; + match read.read(std::slice::from_mut(&mut initial)).await? { + 0 => return Ok(None), + 1 => {} + _ => unreachable!("a one-byte read returns at most one byte"), + } + let extension = extension_len(initial)?; + let mut bytes = [0u8; 9]; + bytes[0] = initial; + read.read_exact(&mut bytes[1..1 + extension]).await?; + let mut input = &bytes[..1 + extension]; + read_head(&mut input) + .map(Some) + .map_err(HeadReadError::Malformed) +} + +/// Read one canonical head from a synchronous reader. +/// +/// The blocking twin of [`read_head_async`], with the same clean-close +/// and error contract; the sync codec oracle reads through this so the +/// two ingress paths share one head grammar. +#[cfg(test)] +pub(crate) fn read_head_io( + read: &mut R, +) -> Result, HeadReadError> { + let mut initial = 0u8; + match read.read(std::slice::from_mut(&mut initial))? { + 0 => return Ok(None), + 1 => {} + _ => unreachable!("a one-byte read returns at most one byte"), + } + let extension = extension_len(initial)?; + let mut bytes = [0u8; 9]; + bytes[0] = initial; + read.read_exact(&mut bytes[1..1 + extension])?; + let mut input = &bytes[..1 + extension]; + read_head(&mut input) + .map(Some) + .map_err(HeadReadError::Malformed) +} + +/// Bytes of head argument following an initial byte, before reading them. +fn extension_len(initial: u8) -> Result { + match initial & 0x1f { + 0..=23 => Ok(0), + 24 => Ok(1), + 25 => Ok(2), + 26 => Ok(4), + 27 => Ok(8), + 28..=30 => Err(HeadReadError::Malformed(HeadError::Reserved)), + _ => Err(HeadReadError::Malformed(HeadError::Indefinite)), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/tree/mirror/cbor/tests.rs b/src/tree/mirror/cbor/tests.rs new file mode 100644 index 00000000..5b8bdba4 --- /dev/null +++ b/src/tree/mirror/cbor/tests.rs @@ -0,0 +1,105 @@ +use proptest::prelude::*; + +use super::*; + +/// Every head a writer emits reads back as the same `(major, value)` pair, +/// occupies exactly `head_len` bytes, and leaves trailing input untouched: +/// the writer and the canonical reader are inverses. +#[test] +fn heads_round_trip_at_their_stated_width() { + proptest!(|(major in 0u8..7, value: u64, trailing: Vec)| { + let mut bytes = Vec::new(); + write_head(&mut bytes, major, value); + prop_assert_eq!(bytes.len(), head_len(value)); + bytes.extend_from_slice(&trailing); + let mut input = bytes.as_slice(); + let head = read_head(&mut input).expect("a written head is canonical"); + prop_assert_eq!(head, Head { major, value }); + prop_assert_eq!(input, trailing.as_slice()); + }); +} + +/// A head whose argument is wider than its value requires is rejected as +/// non-shortest-form: the deterministic contract admits one spelling per +/// value. +#[test] +fn widened_heads_are_rejected() { + proptest!(|(major in 0u8..7, value: u64)| { + let widths: &[(u8, usize)] = &[(24, 1), (25, 2), (26, 4), (27, 8)]; + for &(info, width) in widths { + // Only widths strictly larger than the shortest form are + // non-canonical spellings of this value. + if width < head_len(value) || value >= 1u64 << (8 * width as u32).min(63) { + continue; + } + let mut bytes = vec![(major << 5) | info]; + bytes.extend_from_slice(&value.to_be_bytes()[8 - width..]); + let mut input = bytes.as_slice(); + prop_assert_eq!(read_head(&mut input), Err(HeadError::NotShortest)); + } + }); +} + +/// Indefinite-length and reserved additional-information heads are +/// rejected: the wire is definite-length, deterministic CBOR only. +#[test] +fn indefinite_and_reserved_heads_are_rejected() { + for major in 0u8..8 { + for (info, expected) in [ + (28, HeadError::Reserved), + (29, HeadError::Reserved), + (30, HeadError::Reserved), + (31, HeadError::Indefinite), + ] { + let bytes = [(major << 5) | info]; + let mut input = bytes.as_slice(); + assert_eq!(read_head(&mut input), Err(expected)); + } + } +} + +/// A head cut anywhere before its final byte is a truncation, and the +/// input is left unconsumed. +#[test] +fn truncated_heads_are_rejected() { + proptest!(|(major in 0u8..7, value: u64)| { + let mut bytes = Vec::new(); + write_head(&mut bytes, major, value); + for cut in 0..bytes.len() { + let mut input = &bytes[..cut]; + let before = input; + prop_assert_eq!(read_head(&mut input), Err(HeadError::Truncated)); + prop_assert_eq!(input, before); + } + }); +} + +/// The async head reader agrees with the slice reader on every written +/// head — the two ingress paths cannot drift — and reports a clean +/// end-of-stream before the first byte as `None`. +#[test] +fn async_heads_match_the_slice_reader() { + proptest!(|(major in 0u8..7, value: u64)| { + let mut bytes = Vec::new(); + write_head(&mut bytes, major, value); + let head = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime builds") + .block_on(async { + let mut read = bytes.as_slice(); + read_head_async(&mut read).await + }) + .expect("a written head is canonical") + .expect("a nonempty stream yields a head"); + prop_assert_eq!(head, Head { major, value }); + }); + let none = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime builds") + .block_on(async { + let mut read: &[u8] = &[]; + read_head_async(&mut read).await + }) + .expect("an empty stream is a clean close"); + assert!(none.is_none()); +} diff --git a/src/tree/mirror/framing.rs b/src/tree/mirror/framing.rs index ccbf75b1..52056542 100644 --- a/src/tree/mirror/framing.rs +++ b/src/tree/mirror/framing.rs @@ -1,11 +1,14 @@ -//! Exact-read length-delimited framing shared by the mirror wire protocols. +//! Exact-read payload buffering, and the V1 wire's length-delimited +//! framing. //! -//! A framed body is a 4-byte big-endian length followed by exactly that many -//! payload bytes. The streaming protocol uses it for its greeting (the -//! causal-version and root-fan listing frames), variable-width supply runs -//! and their leaf records, and the trailing identity hand-off; -//! signal-delimited fixed bodies remain bare. The reader never consumes a byte -//! beyond the frame requested. +//! Two things live here. [`read_payload`] and [`resume_payload`] grow a +//! buffer only as bytes arrive — the memory policy every variable-length +//! body read in either protocol shares, and the one the allocator meters +//! price. Around them, [`FrameRead`] and [`FrameWrite`] carry the V1 +//! wire's frames: a 4-byte big-endian length followed by exactly that +//! many payload bytes. (The V2 wire's bodies are self-delimiting CBOR +//! items; only their payload reads come through here.) The reader never +//! consumes a byte beyond the frame requested. //! //! That guarantee makes a session boundary a stream position. A buffering //! reader can slurp leading bytes of traffic belonging after the current @@ -20,9 +23,12 @@ //! payload read outsizes the buffer and bypasses it. Caller-owned buffering //! is safe because it outlives a session and rides into the next one. -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt}; +#[cfg(any(test, feature = "protocol-v1"))] +use tokio::io::{AsyncWrite, AsyncWriteExt}; /// Bytes occupied by the big-endian `u32` payload-length header. +#[cfg(any(test, feature = "protocol-v1", feature = "test-internals"))] pub(crate) const LENGTH_HEADER_LEN: usize = std::mem::size_of::(); /// The initial reservation granule for framed payload buffers. @@ -52,36 +58,6 @@ pub(crate) fn chunk_boundary_cuts(total: usize) -> Vec { cuts } -/// Bytes of one negotiated size word in the greeting's version frame: a -/// little-endian `u64`. -pub(crate) const GREETING_WORD_LEN: usize = std::mem::size_of::(); - -/// The greeting version frame's fixed prefix: three size words (the -/// sender's set size, version-size bound, and message-size target) ahead -/// of the version encoding. -/// -/// Sender, receiver, and every fixture measuring greeting frames must -/// agree on this width; it is defined once here so the layout can only -/// change in one place. -pub(crate) const GREETING_SIZE_WORDS_LEN: usize = 3 * GREETING_WORD_LEN; - -/// Split a greeting version frame's body into its three leading size -/// words: the sender's set size, version-size bound, and target message -/// size, in wire order (the version encoding follows the fixed prefix). -/// -/// `None` when the body is shorter than the prefix. Defined beside the -/// width constants so every reader of the layout — the handshake and the -/// capture renderer — decodes it through one function. -pub(crate) fn greeting_words(body: &[u8]) -> Option<(u64, u64, u64)> { - let word = |index: usize| { - let at = index * GREETING_WORD_LEN; - body.get(at..at + GREETING_WORD_LEN) - .and_then(|prefix| <[u8; GREETING_WORD_LEN]>::try_from(prefix).ok()) - .map(u64::from_le_bytes) - }; - Some((word(0)?, word(1)?, word(2)?)) -} - /// A payload length which cannot be represented by the framing header. #[derive(Debug, thiserror::Error)] #[error("payload length {len} exceeds the u32 framing limit")] @@ -93,7 +69,8 @@ pub struct LengthOverflow { pub source: std::num::TryFromIntError, } -/// Encode the checked big-endian length header shared by both wire codecs. +/// Encode the checked big-endian length header of the V1 wire codec. +#[cfg(any(test, feature = "protocol-v1"))] pub(crate) fn length_header(len: usize) -> Result<[u8; LENGTH_HEADER_LEN], LengthOverflow> { let len = u32::try_from(len).map_err(|source| LengthOverflow { len, source })?; Ok(len.to_be_bytes()) @@ -144,6 +121,7 @@ pub(crate) async fn resume_payload( Ok(payload) } +#[cfg(any(test, feature = "protocol-v1", feature = "test-internals"))] /// The read half of a session's transport, yielding one exact frame at a time. /// /// Stateless beyond the reader it wraps: it buffers nothing, so dropping it @@ -152,6 +130,7 @@ pub struct FrameRead { read: R, } +#[cfg(any(test, feature = "protocol-v1", feature = "test-internals"))] impl FrameRead { /// Wrap `read` for frame-at-a-time reading. pub fn new(read: R) -> Self { @@ -168,6 +147,7 @@ impl FrameRead { } } +#[cfg(any(test, feature = "protocol-v1", feature = "test-internals"))] impl FrameRead { /// Read one frame, growing the payload buffer as its bytes arrive. /// @@ -195,6 +175,7 @@ impl FrameRead { } } +#[cfg(any(test, feature = "protocol-v1"))] /// The write half of a session's transport, shipping one frame at a time. /// /// Every frame is flushed before [`frame`](Self::frame) returns, so dropping @@ -203,6 +184,7 @@ pub struct FrameWrite { write: W, } +#[cfg(any(test, feature = "protocol-v1"))] impl FrameWrite { /// Wrap `write` for frame-at-a-time writing. pub fn new(write: W) -> Self { @@ -219,6 +201,7 @@ impl FrameWrite { } } +#[cfg(any(test, feature = "protocol-v1"))] impl FrameWrite { /// Write `payload` as one frame — length header, then bytes — and flush. /// diff --git a/src/tree/mirror/handshake.rs b/src/tree/mirror/handshake.rs index 771677db..9d620df0 100644 --- a/src/tree/mirror/handshake.rs +++ b/src/tree/mirror/handshake.rs @@ -10,43 +10,86 @@ //! Keeping these phases separate permits a provider to learn that its peer is //! bootstrapping before it atomically snapshots the tree and forks its party. //! -//! ```text -//! [ magic = b"RUMORS": 6B | version: 2B (big-endian) -//! | network: 16B | intent: 1B ] -//! ``` +//! The preamble's spelling is the selected dialect's own: //! -//! Its 25-byte size is part of the wire dialect, so no redundant frame length -//! precedes it. Validation diagnoses magic, then protocol version, followed by -//! the semantic network/intent combination. Only after that validation may a -//! protocol trust peer-declared lengths. +//! - **V2**: one self-described CBOR item, so a V2 control stream is a +//! CBOR sequence from its very first byte — +//! `55799(["rumors", version: uint, network: bstr, intent: uint])`. +//! Every field's head is one byte at the values the dialect admits, so +//! the item is 30 bytes, fixed; that width is part of the dialect, so +//! no redundant frame length precedes it. +//! - **V1**: the legacy fixed frame, +//! `[ magic = b"RUMORS": 6B | version: 2B (big-endian) | network: 16B | +//! intent: 1B ]`, 25 bytes. +//! +//! Validation diagnoses magic, then protocol version, followed by the +//! semantic network/intent combination. Only after that validation may a +//! protocol trust peer-declared lengths. A V2 endpoint additionally +//! recognizes the legacy magic and diagnoses it as a version mismatch +//! rather than a foreign protocol, so a cross-dialect pairing reports +//! what it is. use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use crate::{Network, Protocol}; +use crate::{ + Network, Protocol, + tree::mirror::cbor::{self, MAJOR_BSTR, MAJOR_UINT}, +}; -/// Bytes occupied by the fixed protocol marker. +/// Bytes occupied by the legacy fixed protocol marker. const MAGIC_LEN: usize = crate::PROTOCOL_MAGIC.len(); -/// Bytes occupied by the big-endian wire-version field. +/// Bytes occupied by the legacy big-endian wire-version field. const VERSION_LEN: usize = std::mem::size_of::(); /// Canonical width of one network identifier. const NETWORK_LEN: usize = 16; -/// Bytes occupied by the intent discriminant. +/// Bytes occupied by the legacy intent discriminant. const INTENT_LEN: usize = std::mem::size_of::(); -/// Offset at which the wire version begins. +/// Offset at which the legacy wire version begins. const VERSION_AT: usize = MAGIC_LEN; -/// Offset at which the network identifier begins. +/// Offset at which the legacy network identifier begins. const NETWORK_AT: usize = VERSION_AT + VERSION_LEN; -/// Offset at which the intent discriminant sits. +/// Offset at which the legacy intent discriminant sits. const INTENT_AT: usize = NETWORK_AT + NETWORK_LEN; -/// Length of the complete fixed preamble. -const PREAMBLE_LEN: usize = INTENT_AT + INTENT_LEN; +/// Length of the complete legacy fixed preamble. +const LEGACY_PREAMBLE_LEN: usize = INTENT_AT + INTENT_LEN; + +/// The V2 preamble's fixed prefix: the self-described CBOR tag, the +/// four-item array head, and the text item `"rumors"`. +/// +/// A literal so validation is one comparison; `prefix_matches_the_writers` +/// pins it against the head writers' own rendering. +const V2_PREFIX: [u8; 11] = [ + 0xd9, 0xd9, 0xf7, 0x84, 0x66, b'r', b'u', b'm', b'o', b'r', b's', +]; + +/// Length of the complete V2 preamble item. +pub(crate) const V2_PREAMBLE_LEN: usize = V2_PREFIX.len() + 1 + (1 + NETWORK_LEN) + INTENT_LEN; + +/// The widest preamble either dialect reads. +const PREAMBLE_MAX: usize = { + // The buffer must hold whichever dialect is selected. + if V2_PREAMBLE_LEN > LEGACY_PREAMBLE_LEN { + V2_PREAMBLE_LEN + } else { + LEGACY_PREAMBLE_LEN + } +}; + +/// The exact preamble width of one dialect. +fn preamble_len(protocol: Protocol) -> usize { + match protocol { + #[cfg(any(test, feature = "protocol-v1"))] + Protocol::V1 => LEGACY_PREAMBLE_LEN, + Protocol::V2 => V2_PREAMBLE_LEN, + } +} /// A peer's declared purpose for one reconciliation session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -63,7 +106,8 @@ impl Intent { self == Intent::Retire } - /// Render the intent to its one-byte wire discriminant. + /// Render the intent to its wire discriminant, shared by both + /// dialects (V1 spells it as a raw byte, V2 as a one-byte uint item). fn to_byte(self) -> u8 { match self { Intent::Remain => 0, @@ -91,20 +135,53 @@ pub(crate) struct Preamble { } impl Preamble { - /// Render one complete fixed-width preamble. - fn encode(self, protocol: Protocol) -> [u8; PREAMBLE_LEN] { - let mut bytes = [0; PREAMBLE_LEN]; - bytes[..MAGIC_LEN].copy_from_slice(&crate::PROTOCOL_MAGIC); - bytes[VERSION_AT..NETWORK_AT].copy_from_slice(&(protocol as u16).to_be_bytes()); - bytes[NETWORK_AT..INTENT_AT].copy_from_slice(&self.network.to_bytes()); - bytes[INTENT_AT] = self.intent.to_byte(); - bytes + /// Render one complete preamble in the selected dialect. + fn encode(self, protocol: Protocol) -> Vec { + match protocol { + #[cfg(any(test, feature = "protocol-v1"))] + Protocol::V1 => { + let mut bytes = [0; LEGACY_PREAMBLE_LEN]; + bytes[..MAGIC_LEN].copy_from_slice(&crate::PROTOCOL_MAGIC); + bytes[VERSION_AT..NETWORK_AT].copy_from_slice(&(protocol as u16).to_be_bytes()); + bytes[NETWORK_AT..INTENT_AT].copy_from_slice(&self.network.to_bytes()); + bytes[INTENT_AT] = self.intent.to_byte(); + bytes.to_vec() + } + Protocol::V2 => { + let mut bytes = Vec::with_capacity(V2_PREAMBLE_LEN); + bytes.extend_from_slice(&V2_PREFIX); + cbor::write_head(&mut bytes, MAJOR_UINT, protocol as u64); + cbor::write_head(&mut bytes, MAJOR_BSTR, NETWORK_LEN as u64); + bytes.extend_from_slice(&self.network.to_bytes()); + cbor::write_head(&mut bytes, MAJOR_UINT, u64::from(self.intent.to_byte())); + debug_assert_eq!(bytes.len(), V2_PREAMBLE_LEN, "the dialect width is fixed"); + bytes + } + } } /// Parse and validate one complete peer-controlled preamble. - fn decode(bytes: &[u8; PREAMBLE_LEN], protocol: Protocol) -> Result { + fn decode(bytes: &[u8], protocol: Protocol) -> Result { + match protocol { + #[cfg(any(test, feature = "protocol-v1"))] + Protocol::V1 => Self::decode_legacy(bytes, protocol), + Protocol::V2 => Self::decode_v2(bytes, protocol), + } + } + + /// Parse the legacy fixed frame. + #[cfg(any(test, feature = "protocol-v1"))] + fn decode_legacy(bytes: &[u8], protocol: Protocol) -> Result { let remote_magic = bytes[..MAGIC_LEN].try_into().expect("magic width"); if remote_magic != crate::PROTOCOL_MAGIC { + // A V2-opening peer is a version mismatch, not a foreign + // protocol — the mirror of the V2 decoder's legacy detection. + if bytes[..V2_PREFIX.len()] == V2_PREFIX && bytes[V2_PREFIX.len()] < 24 { + return Err(Error::VersionMismatch { + local_protocol: protocol, + remote_version: u64::from(bytes[V2_PREFIX.len()]), + }); + } return Err(Error::MagicMismatch { remote_magic }); } let remote_version = u16::from_be_bytes( @@ -115,7 +192,7 @@ impl Preamble { if remote_version != protocol as u16 { return Err(Error::VersionMismatch { local_protocol: protocol, - remote_version, + remote_version: u64::from(remote_version), }); } @@ -125,6 +202,66 @@ impl Preamble { .expect("network width"), ); let intent = Intent::from_byte(bytes[INTENT_AT])?; + Self::admit(network, intent) + } + + /// Parse the V2 self-described item. + fn decode_v2(bytes: &[u8], protocol: Protocol) -> Result { + if bytes[..V2_PREFIX.len()] != V2_PREFIX { + // A legacy-magic peer is a version mismatch, not a foreign + // protocol: report what it is. + if bytes[..MAGIC_LEN] == crate::PROTOCOL_MAGIC { + let remote_version = u16::from_be_bytes( + bytes[VERSION_AT..NETWORK_AT] + .try_into() + .expect("version width"), + ); + return Err(Error::VersionMismatch { + local_protocol: protocol, + remote_version: u64::from(remote_version), + }); + } + return Err(Error::MagicMismatch { + remote_magic: bytes[..MAGIC_LEN].try_into().expect("magic width"), + }); + } + let mut input = &bytes[V2_PREFIX.len()..]; + let malformed = |detail| Error::Malformed { detail }; + let version = cbor::read_head(&mut input) + .ok() + .filter(|head| head.major == MAJOR_UINT) + .ok_or(malformed("preamble version is not an unsigned int"))?; + if version.value != protocol as u64 { + return Err(Error::VersionMismatch { + local_protocol: protocol, + remote_version: version.value, + }); + } + cbor::read_head(&mut input) + .ok() + .filter(|head| head.major == MAJOR_BSTR && head.value == NETWORK_LEN as u64) + .ok_or(malformed("preamble network is not a 16-byte string"))?; + if input.len() < NETWORK_LEN { + return Err(malformed("preamble network is truncated")); + } + let (network, rest) = input.split_at(NETWORK_LEN); + input = rest; + let network = Network::from_bytes(network.try_into().expect("network width")); + let intent = cbor::read_head(&mut input) + .ok() + .filter(|head| head.major == MAJOR_UINT) + .ok_or(malformed("preamble intent is not an unsigned int"))?; + if !input.is_empty() { + return Err(malformed("preamble carries trailing bytes")); + } + let intent = u8::try_from(intent.value) + .map_err(|_| Error::IntentInvalid { byte: u8::MAX }) + .and_then(Intent::from_byte)?; + Self::admit(network, intent) + } + + /// Enforce the semantic network/intent combination. + fn admit(network: Network, intent: Intent) -> Result { if network.is_bootstrap() && intent.retiring() { return Err(Error::BootstrapRetireConflict); } @@ -139,16 +276,19 @@ pub(crate) enum Error { #[error(transparent)] Io(#[from] std::io::Error), /// The peer is not speaking the rumors protocol. - #[error("peer is not a rumors stream (remote magic: {remote_magic:x?})")] + #[error("peer is not a rumors stream (leading bytes: {remote_magic:x?})")] MagicMismatch { remote_magic: [u8; 6] }, /// The peer speaks a different wire dialect. #[error("peer speaks rumors protocol version {remote_version}, we selected {local_protocol:?}")] VersionMismatch { local_protocol: Protocol, - remote_version: u16, + remote_version: u64, }, - /// The peer's intent byte has no defined meaning. - #[error("peer sent an invalid intent byte ({byte:#04x})")] + /// The preamble opened correctly but a field was not canonical. + #[error("peer preamble is malformed: {detail}")] + Malformed { detail: &'static str }, + /// The peer's intent has no defined meaning. + #[error("peer sent an invalid intent ({byte:#04x})")] IntentInvalid { byte: u8 }, /// A peer cannot simultaneously receive and donate an identity. #[error("peer claimed to bootstrap and retire in the same session")] @@ -157,15 +297,20 @@ pub(crate) enum Error { /// A cancel-safe, partially received fixed preamble. pub(crate) struct Staged { - buf: [u8; PREAMBLE_LEN], + buf: [u8; PREAMBLE_MAX], + /// The selected dialect's exact width, filled before validation. + want: usize, + protocol: Protocol, filled: usize, } impl Staged { - /// Start with no received preamble bytes. - pub(crate) fn new() -> Self { + /// Start with no received preamble bytes, sized for one dialect. + pub(crate) fn new(protocol: Protocol) -> Self { Self { - buf: [0; PREAMBLE_LEN], + buf: [0; PREAMBLE_MAX], + want: preamble_len(protocol), + protocol, filled: 0, } } @@ -180,10 +325,32 @@ impl Staged { where R: AsyncRead + Unpin + ?Sized, { - while self.filled < self.buf.len() { - match reader.read(&mut self.buf[self.filled..]).await? { + while self.filled < self.want { + match reader.read(&mut self.buf[self.filled..self.want]).await? { 0 if self.filled == 0 => return Ok(Fill::Closed), 0 => { + // A V2 endpoint reading a legacy 25-byte preamble sees + // the close five bytes early; diagnose the dialect + // rather than reporting a bare cut. (Never under V1, + // whose own preamble legitimately opens with the + // magic, and never when the claimed version matches — + // that is not a dialect skew.) + if self.want == V2_PREAMBLE_LEN + && self.filled >= NETWORK_AT + && self.buf[..MAGIC_LEN] == crate::PROTOCOL_MAGIC + { + let remote_version = u64::from(u16::from_be_bytes( + self.buf[VERSION_AT..NETWORK_AT] + .try_into() + .expect("version width"), + )); + if remote_version != self.protocol as u64 { + return Err(Error::VersionMismatch { + local_protocol: self.protocol, + remote_version, + }); + } + } return Err(Error::Io(std::io::Error::new( std::io::ErrorKind::UnexpectedEof, "peer closed mid-preamble", @@ -196,9 +363,9 @@ impl Staged { } /// Validate a completely received frame in diagnostic order. - fn validate(&self, protocol: Protocol) -> Result { - debug_assert_eq!(self.filled, PREAMBLE_LEN, "validate before full"); - Preamble::decode(&self.buf, protocol) + fn validate(&self) -> Result { + debug_assert_eq!(self.filled, self.want, "validate before full"); + Preamble::decode(&self.buf[..self.want], self.protocol) } } @@ -231,13 +398,13 @@ where } }; futures_util::future::try_join(write, read).await?; - staged.validate(protocol) + staged.validate() } /// Progress of a cancel-safe preamble arrival. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Fill { - /// All 25 bytes have arrived. + /// The dialect's full preamble has arrived. Filled, /// The peer closed before sending any preamble byte. Closed, diff --git a/src/tree/mirror/handshake/tests.rs b/src/tree/mirror/handshake/tests.rs index 370ceaac..486530af 100644 --- a/src/tree/mirror/handshake/tests.rs +++ b/src/tree/mirror/handshake/tests.rs @@ -1,142 +1,234 @@ use proptest::prelude::*; use tokio::io::{duplex, split}; -use super::{Error, Intent, PREAMBLE_LEN, Preamble, Staged, preamble}; +use super::{Error, Intent, Preamble, Staged, V2_PREAMBLE_LEN, V2_PREFIX, preamble}; use crate::{Network, Protocol}; -/// Construct a fully received preamble with one caller-selected intent byte. +/// Construct a fully received V2 preamble with one caller-selected raw +/// byte in the intent item's place. fn staged(network: Network, intent: u8) -> Staged { - let mut staged = Staged::new(); - staged.buf[..6].copy_from_slice(&crate::PROTOCOL_MAGIC); - staged.buf[6..8].copy_from_slice(&(Protocol::V2 as u16).to_be_bytes()); - staged.buf[8..24].copy_from_slice(&network.to_bytes()); - staged.buf[24] = intent; - staged.filled = PREAMBLE_LEN; + let encoded = Preamble { + network, + intent: Intent::Remain, + } + .encode(Protocol::V2); + let mut staged = Staged::new(Protocol::V2); + staged.buf[..encoded.len()].copy_from_slice(&encoded); + staged.buf[V2_PREAMBLE_LEN - 1] = intent; + staged.filled = V2_PREAMBLE_LEN; staged } +/// The V2 preamble's pinned prefix literal is exactly what the head +/// writers render for the self-described tag, the four-item array, and +/// the text `"rumors"`: the validation constant cannot drift from the +/// encoder. +#[test] +fn prefix_matches_the_writers() { + use crate::tree::mirror::cbor::{self, MAJOR_ARRAY, MAJOR_TEXT}; + let mut prefix = Vec::new(); + cbor::write_tag(&mut prefix, 55799); + cbor::write_head(&mut prefix, MAJOR_ARRAY, 4); + cbor::write_head(&mut prefix, MAJOR_TEXT, "rumors".len() as u64); + prefix.extend_from_slice(b"rumors"); + assert_eq!(prefix, V2_PREFIX); +} + /// Both sides exchange the shared preamble over a one-byte transport without /// deadlock, preserving each peer's network and intent exactly. #[test] fn fragmented_exchange_is_symmetric() { - let left = Network::from_bytes([1; 16]); - let right = Network::from_bytes([2; 16]); - let (left_io, right_io) = duplex(1); - let (left_read, left_write) = split(left_io); - let (right_read, right_write) = split(right_io); - let mut left_read = left_read; - let mut left_write = left_write; - let mut right_read = right_read; - let mut right_write = right_write; - let mut left_staged = Staged::new(); - let mut right_staged = Staged::new(); - - let (seen_by_left, seen_by_right) = pollster::block_on(async { - tokio::join!( - preamble( - Protocol::V2, - left, - Intent::Remain, - &mut left_staged, - &mut left_read, - &mut left_write, - ), - preamble( - Protocol::V2, - right, - Intent::Retire, - &mut right_staged, - &mut right_read, - &mut right_write, - ), - ) - }); + for protocol in [Protocol::V1, Protocol::V2] { + let left = Network::from_bytes([1; 16]); + let right = Network::from_bytes([2; 16]); + let (left_io, right_io) = duplex(1); + let (left_read, left_write) = split(left_io); + let (right_read, right_write) = split(right_io); + let mut left_read = left_read; + let mut left_write = left_write; + let mut right_read = right_read; + let mut right_write = right_write; + let mut left_staged = Staged::new(protocol); + let mut right_staged = Staged::new(protocol); - assert_eq!( - seen_by_left.unwrap(), - Preamble { - network: right, - intent: Intent::Retire, - } - ); - assert_eq!( - seen_by_right.unwrap(), - Preamble { - network: left, - intent: Intent::Remain, - } - ); + let (seen_by_left, seen_by_right) = pollster::block_on(async { + tokio::join!( + preamble( + protocol, + left, + Intent::Remain, + &mut left_staged, + &mut left_read, + &mut left_write, + ), + preamble( + protocol, + right, + Intent::Retire, + &mut right_staged, + &mut right_read, + &mut right_write, + ), + ) + }); + + assert_eq!( + seen_by_left.unwrap(), + Preamble { + network: right, + intent: Intent::Retire, + } + ); + assert_eq!( + seen_by_right.unwrap(), + Preamble { + network: left, + intent: Intent::Remain, + } + ); + } } -/// Intent decoding is exhaustive: exactly the two defined bytes are accepted -/// for an established network and every other byte retains its typed value. +/// Intent decoding is exhaustive over the raw byte in the intent item's +/// place. +/// +/// The two defined values are accepted, other small uint items are the +/// typed intent rejection, and bytes that are no one-byte uint item at +/// all are the malformed-preamble class. #[test] fn intent_byte_space_is_exhaustive() { let network = Network::from_bytes([1; 16]); for byte in u8::MIN..=u8::MAX { - match (byte, staged(network, byte).validate(Protocol::V2)) { + match (byte, staged(network, byte).validate()) { (0, Ok(preamble)) => assert_eq!(preamble.intent, Intent::Remain), (1, Ok(preamble)) => assert_eq!(preamble.intent, Intent::Retire), (0 | 1, other) => panic!("defined intent {byte} was rejected: {other:?}"), - (byte, Err(Error::IntentInvalid { byte: rejected })) => assert_eq!(rejected, byte), - (_, other) => panic!("invalid intent produced the wrong result: {other:?}"), + (2..=0x17, Err(Error::IntentInvalid { byte: rejected })) => { + assert_eq!(rejected, byte); + } + (0x18.., Err(Error::Malformed { .. })) => {} + (_, other) => panic!("invalid intent {byte} produced the wrong result: {other:?}"), } } } /// A peer that closes the connection at any point inside the preamble -/// surfaces a typed I/O error, never a hang and never a partial decode. +/// surfaces a typed error, never a hang and never a partial decode. /// -/// Every strict prefix of the 25-byte frame is a structurally distinct -/// truncation (the boundaries between magic, version, network, and intent -/// included), so the whole prefix space is swept: zero bytes is the -/// clean-goodbye close, every longer prefix a mid-preamble cut, and both -/// must resolve to [`Error::Io`] with `UnexpectedEof`. +/// Every strict prefix of the fixed item is a structurally distinct +/// truncation, so the whole prefix space is swept in both dialects: zero +/// bytes is the clean-goodbye close, every longer prefix a mid-preamble +/// cut resolving to [`Error::Io`] with `UnexpectedEof` — except a V2 +/// endpoint cut exactly where a whole legacy preamble ends, which is +/// diagnosed as the version mismatch it is. #[test] fn every_truncation_boundary_is_a_typed_eof() { + for protocol in [Protocol::V1, Protocol::V2] { + let network = Network::from_bytes([1; 16]); + let full = Preamble { + network, + intent: Intent::Remain, + } + .encode(protocol); + + for cut in 0..full.len() { + let mut staged = Staged::new(protocol); + let mut reader = &full[..cut]; + let mut writer = tokio::io::sink(); + let result = pollster::block_on(preamble( + protocol, + network, + Intent::Remain, + &mut staged, + &mut reader, + &mut writer, + )); + match result { + Err(Error::Io(error)) => assert_eq!( + error.kind(), + std::io::ErrorKind::UnexpectedEof, + "cut after {cut} bytes must be an unexpected EOF", + ), + other => panic!("cut after {cut} bytes must be a typed I/O error, got {other:?}"), + } + } + } +} + +/// A V2 endpoint whose peer speaks the legacy dialect diagnoses the +/// version mismatch, not a bare cut or a foreign protocol. +/// +/// The legacy 25-byte preamble ends five bytes short of the V2 item, and +/// its magic names the rumors protocol at version 1. +#[test] +fn legacy_peer_is_a_version_mismatch() { let network = Network::from_bytes([1; 16]); - let full = Preamble { + let legacy = Preamble { network, intent: Intent::Remain, } - .encode(Protocol::V2); + .encode(Protocol::V1); - for cut in 0..full.len() { - let mut staged = Staged::new(); - let mut reader = &full[..cut]; - let mut writer = tokio::io::sink(); - let result = pollster::block_on(preamble( - Protocol::V2, - network, - Intent::Remain, - &mut staged, - &mut reader, - &mut writer, - )); - match result { - Err(Error::Io(error)) => assert_eq!( - error.kind(), - std::io::ErrorKind::UnexpectedEof, - "cut after {cut} bytes must be an unexpected EOF", - ), - other => panic!("cut after {cut} bytes must be a typed I/O error, got {other:?}"), - } - } + // The peer sent its whole legacy preamble and closed. + let mut staged = Staged::new(Protocol::V2); + let mut reader = legacy.as_slice(); + let mut writer = tokio::io::sink(); + let result = pollster::block_on(preamble( + Protocol::V2, + network, + Intent::Remain, + &mut staged, + &mut reader, + &mut writer, + )); + assert!( + matches!( + result, + Err(Error::VersionMismatch { + local_protocol: Protocol::V2, + remote_version: 1, + }) + ), + "expected the dialect diagnosis, got {result:?}", + ); + + // The peer's next five bytes (its greeting) arrived too: the full + // 30-byte read then validates, and the magic check diagnoses the + // dialect ahead of any structural complaint. + let mut padded = legacy; + padded.extend_from_slice(&[0; 5]); + let mut staged = Staged::new(Protocol::V2); + let mut reader = padded.as_slice(); + let mut writer = tokio::io::sink(); + let result = pollster::block_on(preamble( + Protocol::V2, + network, + Intent::Remain, + &mut staged, + &mut reader, + &mut writer, + )); + assert!(matches!( + result, + Err(Error::VersionMismatch { + local_protocol: Protocol::V2, + remote_version: 1, + }) + )); } /// A wrong magic is diagnosed first, before any other field is judged. /// -/// The frame here is wrong in every field — magic, version, and intent — -/// and must still surface [`Error::MagicMismatch`] carrying the exact -/// remote bytes: the diagnostic order promised by the module docs puts -/// "not a rumors stream" ahead of "wrong dialect". +/// The item here is wrong in every field and must still surface +/// [`Error::MagicMismatch`] carrying the leading remote bytes: the +/// diagnostic order promised by the module docs puts "not a rumors +/// stream" ahead of "wrong dialect". #[test] fn magic_mismatch_is_diagnosed_first() { let mut wrong = staged(Network::from_bytes([1; 16]), 0xFF); wrong.buf[..6].copy_from_slice(b"SROMUR"); - wrong.buf[6..8].copy_from_slice(&0xFFFF_u16.to_be_bytes()); - let result = wrong.validate(Protocol::V2); + let result = wrong.validate(); assert!( matches!( &result, @@ -148,16 +240,16 @@ fn magic_mismatch_is_diagnosed_first() { /// A wrong wire version is diagnosed before the semantic fields. /// -/// With a correct magic but a foreign version, the frame's (invalid) -/// intent byte must never be reached: the typed rejection is +/// With a correct opening but a foreign version, the item's (invalid) +/// intent must never be reached: the typed rejection is /// [`Error::VersionMismatch`] carrying the remote's declared version, so a /// dialect skew is reported as such rather than as a garbled body. #[test] fn version_mismatch_is_diagnosed_before_intent() { let mut wrong = staged(Network::from_bytes([1; 16]), 0xFF); - wrong.buf[6..8].copy_from_slice(&7_u16.to_be_bytes()); + wrong.buf[V2_PREFIX.len()] = 0x07; - let result = wrong.validate(Protocol::V2); + let result = wrong.validate(); assert!( matches!( result, @@ -171,38 +263,37 @@ fn version_mismatch_is_diagnosed_before_intent() { } proptest! { - /// Any complete 25-byte preamble decodes exactly as the field-by-field - /// oracle predicts: a typed error naming the first invalid field in - /// diagnostic order, or the valid preamble — never a panic. + /// Any complete V2 preamble whose fields are canonically spelled + /// decodes exactly as the field-by-field oracle predicts. /// - /// The strategy weights the magic and version toward their valid values - /// so the deeper fields' arms are actually reached; the oracle - /// recomputes the documented diagnosis order (magic, then version, then - /// intent, then the network/intent combination) independently of the - /// decoder. + /// The prediction: a typed error naming the first invalid field in + /// diagnostic order, or the valid preamble — never a panic. #[test] fn arbitrary_preamble_decodes_by_the_oracle( - magic in prop_oneof![Just(crate::PROTOCOL_MAGIC), any::<[u8; 6]>()], - version in prop_oneof![Just(Protocol::V2 as u16), any::()], + magic_valid in prop_oneof![Just(true), any::()], + version in prop_oneof![Just(Protocol::V2 as u8), 0_u8..=0x17], network in any::<[u8; 16]>(), - intent in prop_oneof![0_u8..=3, any::()], + intent in prop_oneof![0_u8..=3, 0_u8..=0x17], ) { - let mut bytes = [0u8; PREAMBLE_LEN]; - bytes[..6].copy_from_slice(&magic); - bytes[6..8].copy_from_slice(&version.to_be_bytes()); - bytes[8..24].copy_from_slice(&network); - bytes[24] = intent; + let mut bytes = Vec::with_capacity(V2_PREAMBLE_LEN); + if magic_valid { + bytes.extend_from_slice(&V2_PREFIX); + } else { + bytes.extend_from_slice(b"SROMURxxxxx"); + } + bytes.push(version); + bytes.push(0x50); + bytes.extend_from_slice(&network); + bytes.push(intent); let result = Preamble::decode(&bytes, Protocol::V2); - let as_oracle = if magic != crate::PROTOCOL_MAGIC { - matches!( - &result, - Err(Error::MagicMismatch { remote_magic }) if *remote_magic == magic, - ) - } else if version != Protocol::V2 as u16 { + let as_oracle = if !magic_valid { + matches!(&result, Err(Error::MagicMismatch { remote_magic }) if remote_magic == b"SROMUR") + } else if version != Protocol::V2 as u8 { matches!( &result, - Err(Error::VersionMismatch { remote_version, .. }) if *remote_version == version, + Err(Error::VersionMismatch { remote_version, .. }) + if *remote_version == u64::from(version), ) } else if intent > 1 { matches!(&result, Err(Error::IntentInvalid { byte }) if *byte == intent) @@ -217,6 +308,14 @@ proptest! { }; prop_assert!(as_oracle, "decode disagreed with the oracle: {:?}", result); } + + /// Arbitrary bytes in the preamble's place decode to a typed error or + /// a valid preamble, never a panic: the parser is total over its + /// fixed-width input. + #[test] + fn arbitrary_bytes_never_panic(bytes in any::<[u8; V2_PREAMBLE_LEN]>()) { + let _ = Preamble::decode(&bytes, Protocol::V2); + } } /// The bootstrap placeholder composes only with remain intent; retirement @@ -224,16 +323,14 @@ proptest! { #[test] fn bootstrap_intent_matrix_is_exhaustive() { assert_eq!( - staged(Network::BOOTSTRAP, 0) - .validate(Protocol::V2) - .unwrap(), + staged(Network::BOOTSTRAP, 0).validate().unwrap(), Preamble { network: Network::BOOTSTRAP, intent: Intent::Remain, } ); assert!(matches!( - staged(Network::BOOTSTRAP, 1).validate(Protocol::V2), + staged(Network::BOOTSTRAP, 1).validate(), Err(Error::BootstrapRetireConflict) )); } diff --git a/src/tree/mirror/party.rs b/src/tree/mirror/party.rs index 9358b829..460dd029 100644 --- a/src/tree/mirror/party.rs +++ b/src/tree/mirror/party.rs @@ -1,37 +1,92 @@ //! Trailing identity hand-off after content reconciliation. use before::Party; -use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use crate::{ - Error, - tree::mirror::framing::{FrameRead, FrameWrite}, + Error, Protocol, + tree::mirror::cbor::{self, MAJOR_BSTR}, }; /// Ship a donated party after reconciliation has transferred all content. /// /// Bootstrapping sends a freshly forked party from provider to newcomer; -/// retirement sends the retiree's whole party toward its absorber. The exact -/// frame boundary leaves a following session preamble untouched. -pub(crate) async fn send(party: Party, writer: &mut W) -> Result<(), Error> +/// retirement sends the retiree's whole party toward its absorber. The +/// hand-off's spelling is the selected dialect's: under V2, one +/// self-delimiting item — the party-atom tag wrapping a byte string of +/// the party's canonical encoding — and under the frozen V1 wire, one +/// length-delimited frame of the bare encoding. Either way its exact +/// boundary leaves a following session preamble untouched. +pub(crate) async fn send(protocol: Protocol, party: Party, writer: &mut W) -> Result<(), Error> where W: AsyncWrite + Unpin + ?Sized, { - // The frame delimits, so the body is the party's canonical encoding, - // bare. - FrameWrite::new(writer).frame(party.as_bytes()).await?; - Ok(()) + #[cfg(any(test, feature = "protocol-v1"))] + if protocol == Protocol::V1 { + // The frame delimits, so the body is the party's canonical + // encoding, bare. + crate::tree::mirror::framing::FrameWrite::new(writer) + .frame(party.as_bytes()) + .await?; + return Ok(()); + } + let _ = protocol; + let bytes = party.as_bytes(); + let mut item = Vec::with_capacity( + cbor::head_len(crate::tags::PARTY_TAG) + cbor::head_len(bytes.len() as u64) + bytes.len(), + ); + cbor::write_tag(&mut item, crate::tags::PARTY_TAG); + cbor::write_head(&mut item, MAJOR_BSTR, bytes.len() as u64); + item.extend_from_slice(bytes); + writer.write_all(&item).await.map_err(Error::Io)?; + writer.flush().await.map_err(Error::Io) } /// Receive the identity donation promised by the peer's preamble intent. -pub(crate) async fn receive(reader: &mut R) -> Result +pub(crate) async fn receive(protocol: Protocol, reader: &mut R) -> Result where R: AsyncRead + Unpin + ?Sized, { - let bytes = FrameRead::new(reader).frame().await?; - Party::decode(&bytes[..]) + #[cfg(any(test, feature = "protocol-v1"))] + if protocol == Protocol::V1 { + let bytes = crate::tree::mirror::framing::FrameRead::new(reader) + .frame() + .await?; + return decode_party(&bytes); + } + let _ = protocol; + let invalid = |message: &'static str| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + message, + )) + }; + let head = read_head(reader).await?; + if head.major != cbor::MAJOR_TAG || head.value != crate::tags::PARTY_TAG { + return Err(invalid( + "identity hand-off does not carry the party-atom tag", + )); + } + let head = read_head(reader).await?; + if head.major != MAJOR_BSTR { + return Err(invalid("party-atom tag does not wrap a byte string")); + } + let Ok(len) = usize::try_from(head.value) else { + return Err(invalid( + "identity hand-off declares an unaddressable length", + )); + }; + let bytes = crate::tree::mirror::framing::read_payload(&mut &mut *reader, len) + .await + .map_err(Error::Io)?; + decode_party(&bytes) +} + +/// Decode one exact donation body into its canonical party. +fn decode_party(bytes: &[u8]) -> Result { + Party::decode(bytes) .map_err(|e| match e { - // A frame that ends inside the encoding is a truncation, not + // An item that ends inside the encoding is a truncation, not // corruption; the reader's own failures pass through. before::error::Decode::Truncated => { std::io::Error::new(std::io::ErrorKind::UnexpectedEof, e) @@ -42,5 +97,25 @@ where .map_err(Error::Io) } +/// Read one canonical head, treating any close as an unexpected cut: the +/// hand-off was promised by the peer's preamble intent. +async fn read_head(reader: &mut R) -> Result +where + R: AsyncRead + Unpin + ?Sized, +{ + match cbor::read_head_async(reader).await { + Ok(Some(head)) => Ok(head), + Ok(None) => Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "peer closed before its promised identity hand-off", + ))), + Err(cbor::HeadReadError::Io(io)) => Err(Error::Io(io)), + Err(cbor::HeadReadError::Malformed(head)) => Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + head.to_string(), + ))), + } +} + #[cfg(test)] mod tests; diff --git a/src/tree/mirror/party/tests.rs b/src/tree/mirror/party/tests.rs index 746749af..3fd878d4 100644 --- a/src/tree/mirror/party/tests.rs +++ b/src/tree/mirror/party/tests.rs @@ -1,13 +1,13 @@ //! Ingress validation of the trailing party-donation frame. //! //! The donated identity is the last peer-controlled payload of a bootstrap -//! or retire session: one length-delimited frame whose body must be exactly -//! one canonical party encoding. This suite feeds [`receive`] crafted frames -//! — truncations at each structural boundary, length lies in both -//! directions, trailing and arbitrary bodies — and pins that each surfaces -//! as the typed [`Error::Io`], never a panic, never a hang, and never a -//! partial identity; and that a clean receive leaves the next session's -//! bytes untouched in the transport. +//! or retire session: one party-atom-tagged byte string whose content must +//! be exactly one canonical party encoding. This suite feeds [`receive`] +//! crafted items — truncations at each structural boundary, length lies in +//! both directions, wrong tags, trailing and arbitrary bodies — and pins +//! that each surfaces as the typed [`Error::Io`], never a panic, never a +//! hang, and never a partial identity; and that a clean receive leaves the +//! next session's bytes untouched in the transport. use before::Party; use proptest::collection::vec; @@ -15,20 +15,23 @@ use proptest::prelude::*; use super::{receive, send}; use crate::Error; +use crate::Protocol; use crate::tree::arb::nth_party; -use crate::tree::mirror::framing::LENGTH_HEADER_LEN; +use crate::tree::mirror::cbor::{self, MAJOR_BSTR}; -/// Length-delimit one frame body exactly as [`send`] does. +/// Wrap one item content exactly as [`send`] does: the party-atom tag, +/// then a byte string of the content. fn frame(body: &[u8]) -> Vec { - let len = u32::try_from(body.len()).expect("test frame bodies fit in u32"); - let mut bytes = len.to_be_bytes().to_vec(); + let mut bytes = Vec::new(); + cbor::write_tag(&mut bytes, crate::tags::PARTY_TAG); + cbor::write_head(&mut bytes, MAJOR_BSTR, body.len() as u64); bytes.extend_from_slice(body); bytes } /// Receive a donation from crafted wire bytes through the production ingress. fn receive_party(bytes: &[u8]) -> Result { - pollster::block_on(async { receive(&mut &bytes[..]).await }) + pollster::block_on(async { receive(Protocol::V2, &mut &bytes[..]).await }) } /// Unwrap the sole error variant this ingress can produce. @@ -50,31 +53,53 @@ fn io_error(result: Result) -> std::io::Error { fn a_donated_party_round_trips() { pollster::block_on(async { let mut wire = Vec::new(); - send(nth_party(3), &mut wire).await.expect("donation sends"); - let received = receive(&mut &wire[..]) + send(Protocol::V2, nth_party(3), &mut wire) + .await + .expect("donation sends"); + let received = receive(Protocol::V2, &mut &wire[..]) .await .expect("a canonical donation decodes"); assert_eq!(received, nth_party(3)); }); } -/// A peer that closes before or inside the frame header is a typed EOF. +/// A peer that closes before or inside the item's heads is a typed EOF. /// -/// Every strict prefix of the four-byte length header — the close at the -/// boundary included — must resolve to [`Error::Io`] with `UnexpectedEof`, -/// never a hang on bytes that cannot arrive. +/// Every strict prefix of the tag and byte-string heads — the close at +/// each boundary included — must resolve to [`Error::Io`] with +/// `UnexpectedEof`, never a hang on bytes that cannot arrive. #[test] fn truncated_frame_header_is_a_typed_eof() { - for cut in 0..LENGTH_HEADER_LEN { - let error = io_error(receive_party(&vec![0; cut])); + let heads = frame(&[0]); + let heads = &heads[..heads.len() - 1]; + for cut in 0..heads.len() { + let error = io_error(receive_party(&heads[..cut])); assert_eq!( error.kind(), std::io::ErrorKind::UnexpectedEof, - "cut after {cut} header bytes must be an unexpected EOF", + "cut after {cut} head bytes must be an unexpected EOF", ); } } +/// An item that is not the party-atom tag is a typed protocol violation. +/// +/// The tag is the hand-off's identity on the wire; a different tag (or a +/// bare byte string) must surface as `InvalidData`, never decode. +#[test] +fn wrong_tag_is_a_typed_error() { + let mut wrong = Vec::new(); + cbor::write_tag(&mut wrong, crate::tags::VERSION_TAG); + cbor::write_head(&mut wrong, MAJOR_BSTR, 0); + let error = io_error(receive_party(&wrong)); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + + let mut bare = Vec::new(); + cbor::write_head(&mut bare, MAJOR_BSTR, 0); + let error = io_error(receive_party(&bare)); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + /// A frame declaring more bytes than the peer sends is a typed EOF. /// /// The over-declared length makes the exact body read run off the end of @@ -82,7 +107,9 @@ fn truncated_frame_header_is_a_typed_eof() { /// never as a partially filled body handed to the party decoder. #[test] fn over_declared_frame_is_a_typed_eof() { - let mut bytes = 16_u32.to_be_bytes().to_vec(); + let mut bytes = Vec::new(); + cbor::write_tag(&mut bytes, crate::tags::PARTY_TAG); + cbor::write_head(&mut bytes, MAJOR_BSTR, 16); bytes.extend_from_slice(&[1, 2, 3, 4]); let error = io_error(receive_party(&bytes)); @@ -140,11 +167,13 @@ fn trailing_frame_bytes_are_rejected() { fn bytes_after_the_frame_stay_untouched() { pollster::block_on(async { let mut wire = Vec::new(); - send(nth_party(3), &mut wire).await.expect("donation sends"); + send(Protocol::V2, nth_party(3), &mut wire) + .await + .expect("donation sends"); wire.extend_from_slice(b".RUMORS"); let mut cursor = &wire[..]; - receive(&mut cursor) + receive(Protocol::V2, &mut cursor) .await .expect("a canonical donation decodes"); assert_eq!(cursor, b".RUMORS", "bytes after the donation were consumed"); diff --git a/src/tree/mirror/streaming.rs b/src/tree/mirror/streaming.rs index 88baf712..04a59d6b 100644 --- a/src/tree/mirror/streaming.rs +++ b/src/tree/mirror/streaming.rs @@ -50,7 +50,7 @@ pub(crate) mod convert; mod driver; mod erased; pub mod materialized; -mod message; +pub(crate) mod message; mod protocol; pub mod remote; pub mod stats; diff --git a/src/tree/mirror/streaming/message.rs b/src/tree/mirror/streaming/message.rs index 5f752802..6848e2eb 100644 --- a/src/tree/mirror/streaming/message.rs +++ b/src/tree/mirror/streaming/message.rs @@ -12,8 +12,8 @@ //! [`materialized`](crate::tree::mirror::streaming::materialized) for the ordering argument). //! //! The memory unit is one reply: a maximally disputed reply is 256 -//! reactions × a 256-entry listing ≈ fan² hashes ≈ 1.6 MB encoded -//! (≈ 3.3 MB while an encoded and a decoded copy coexist), transient, at +//! reactions × a 256-entry listing ≈ fan² hashes ≈ 1.8 MB encoded +//! (≈ 3.5 MB while an encoded and a decoded copy coexist), transient, at //! most one in flight per stage. use std::cmp::Ordering; diff --git a/src/tree/mirror/streaming/remote.rs b/src/tree/mirror/streaming/remote.rs index aaf6470c..eae07c9f 100644 --- a/src/tree/mirror/streaming/remote.rs +++ b/src/tree/mirror/streaming/remote.rs @@ -67,7 +67,7 @@ //! stream is invisible to every other by the link contract. mod adapter; -mod codec; +pub(crate) mod codec; mod error; mod proxy; mod streams; @@ -77,7 +77,7 @@ pub use codec::LinkCapture; #[cfg(any(test, feature = "test-internals"))] pub(crate) use codec::render_v2_capture; #[cfg(any(test, feature = "test-internals"))] -pub(crate) use codec::{decode_frame_discarded, supply_signal_byte}; +pub(crate) use codec::{decode_frame_discarded, lone_record_run, supply_frame_head}; pub use error::*; /// The codec's logical stream count, for cross-layer constant assertions. diff --git a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs index 2337f049..d1de4c8d 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs @@ -474,15 +474,17 @@ fn leaf_scope_is_enforced_within_one_run() { assert_eq!(actual, <[u8; 32]>::from(outside.path())); } -/// The run body of a single zero-length record: one bare record header. -const ZERO_LENGTH_RECORD_RUN: [u8; 4] = [0, 0, 0, 0]; +/// The run body of a single empty-content record: the embedded-sequence +/// tag wrapping an empty byte string. +const ZERO_LENGTH_RECORD_RUN: [u8; 3] = [0xd8, 0x3f, 0x40]; -/// A zero-length record passes structural validation but fails canonically. +/// An empty-content record passes structural validation but fails +/// canonically. /// -/// A `00000000` record header inside a run chains exactly, so the wire -/// accepts the run's structure; the record's empty body cannot hold a -/// version, so the reply decoder reports `DecodeError::Record` carrying the -/// version decoder's `UnexpectedEof`. +/// A record whose byte string is empty chains exactly, so the wire +/// accepts the run's structure; the empty content cannot hold a tagged +/// version, so the reply decoder reports `DecodeError::Record` carrying +/// the version decoder's `UnexpectedEof`. #[test] fn a_zero_length_record_fails_as_a_version_decode_error() { let run = LeafRun::from_encoded(ZERO_LENGTH_RECORD_RUN.to_vec()) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs index ec94abcd..ebeb9a6f 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs @@ -55,7 +55,7 @@ const LEAVES: u64 = 512; /// heavier framing — fails the pin and forces the module doc's charged /// figure (and `streaming/message.rs`, which states it) to be /// re-derived rather than silently going stale. -const DISPUTED_REPLY_TRANSIENT_CEILING: usize = 3_380_000; +const DISPUTED_REPLY_TRANSIENT_CEILING: usize = 3_570_000; /// A parked decoded reply holds one pointer-sized node handle per supplied /// node — O(fan) handles independent of how many leaves streamed through diff --git a/src/tree/mirror/streaming/remote/codec.rs b/src/tree/mirror/streaming/remote/codec.rs index 69a4dd5b..cd079def 100644 --- a/src/tree/mirror/streaming/remote/codec.rs +++ b/src/tree/mirror/streaming/remote/codec.rs @@ -1,12 +1,24 @@ //! The self-delimiting frame grammar shared by every logical wire stream. //! -//! A signal byte densely encodes `(frame state, stream)` rather than imposing a -//! bit-field boundary. There are ten frame states — four reaction forms, each +//! Every frame is one CBOR array item, so a directed stream's frames form +//! an RFC 8742 CBOR sequence a generic tool can walk: `[signal]` for a +//! body-free frame, `[signal, body]` otherwise. The wire is +//! deterministic-encoding CBOR as a stated contract — shortest-form heads +//! everywhere, definite lengths only, one spelling per value +//! ([`cbor`](crate::tree::mirror::cbor)) — and decoding rejects any other +//! spelling, which is what keeps the byte-pinning snapshot discipline +//! meaningful. +//! +//! The signal is an unsigned int carrying the dense `(frame state, +//! stream)` code. There are ten frame states — four reaction forms, each //! continuing or ending its reply, plus a bare empty-reply end and a bare -//! stream-end control — and 17 streams. `state * 17 + stream` occupies values 0 -//! through 169; the other 86 byte values are reserved. Speaker and stream then -//! select a phase-specific subset: the initiator admits 162 placements and the -//! responder 163, rejecting the rest before their frame body is read. +//! stream-end control — and 17 streams. `state * 17 + stream` occupies +//! values 0 through 169; the rest of the byte-ranged code space is +//! reserved, and the signal's stream component deliberately restates the +//! transport label so a mislabeled stream is its own diagnosis. Speaker +//! and stream then select a phase-specific subset: the initiator admits +//! 162 placements and the responder 163, rejecting the rest before their +//! frame body is read. //! //! Reply and stream lifetimes are deliberately orthogonal. Every nonempty //! reply ends on its final reaction; an empty reply is one bare reply-end @@ -16,20 +28,26 @@ //! lets a lazy reply stream flush each item immediately without looking ahead //! to discover whether that item is also the stream's last. //! -//! An empty query is wholly represented by its signal. A nonempty query carries -//! `count - 1` in one byte, covering 1 through 256. A supply body is a -//! [`LeafRun`] behind an exact `u32` body length: one or more -//! backend-neutral `(Version, Message)` leaf records, each behind its own -//! exact `u32` record length. The codec validates the run's record framing -//! once its whole body arrives but leaves the records encoded; the adapter -//! decodes them one at a time, constructs its backend-specific leaves, and -//! validates their version-derived paths. How many records share one run -//! is the sender's choice within the session's [`RunBudget`], and the -//! decoder holds arriving frames to that same budget: any within-budget -//! batching decodes, a single record of any size decodes (the encoder's -//! minimum-one-record overhang), and a frame batching multiple records past -//! the budget is rejected typed ([`DecodeErrorKind::OverbatchedRun`]) -//! before its body is buffered. +//! An empty query is wholly represented by its signal. A nonempty query's +//! body is a `{radix: hash}` map of one to 256 children: CBOR +//! deterministic encoding mandates ascending keys and the wire's canonical +//! form mandates strictly ascending radixes, so the two disciplines are +//! one rule, enforced once at ingress. A supply body is a [`LeafRun`] +//! behind the embedded-CBOR-sequence tag (63) wrapping a byte string — +//! the byte-string head is the run's exact length, preserving O(1) skip +//! and up-front pricing — and the run's records are each the same shape +//! in miniature: tag 63 wrapping a byte string whose content is the +//! tagged version atom followed by the message's own CBOR payload. The +//! codec validates the run's record framing once its whole body arrives +//! but leaves the records encoded; the adapter decodes them one at a +//! time, constructs its backend-specific leaves, and validates their +//! version-derived paths. How many records share one run is the sender's +//! choice within the session's [`RunBudget`], and the decoder holds +//! arriving frames to that same budget: any within-budget batching +//! decodes, a single record of any size decodes (the encoder's +//! minimum-one-record overhang), and a frame batching multiple records +//! past the budget is rejected typed +//! ([`DecodeErrorKind::OverbatchedRun`]) before its body is buffered. //! //! Encoding trusts the protocol and adapter to produce phase-correct, //! canonically ordered frames; it performs no redundant semantic validation. @@ -45,6 +63,7 @@ mod decode; mod encode; mod error; mod frame; +pub(crate) mod greeting; mod signal; #[cfg(test)] @@ -65,23 +84,60 @@ pub use error::{ }; #[cfg(test)] pub use frame::WireFrame; -pub use frame::{Frame, LeafRun, LeafRunError, Reaction, validate_children}; +pub use frame::{Frame, LeafRun, LeafRunError, Reaction}; +#[cfg(test)] +pub(crate) use frame::{parse_listing_map, write_listing}; pub use signal::{ DecodeSignalError, End, Flow, InvalidSignalPlacement, InvalidWireSignal, Speaker, Stream, StreamClass, }; -/// The signal byte opening one initiator-spoken, reply-ending supply frame. +/// The whole wire prefix of one initiator-spoken, reply-ending supply +/// frame declaring a `declared`-byte run: the frame's array head, its +/// signal head, and the run's embedded-sequence tag and byte-string head. /// -/// The allocator meter (`tests/decode_alloc.rs`) prepends it to a hand-built -/// supply body so the codec's supply read path is drivable from outside the -/// crate. +/// The allocator meter (`tests/decode_alloc.rs`) prepends it to a +/// hand-built run body so the codec's supply read path is drivable from +/// outside the crate. #[cfg(any(test, feature = "test-internals"))] -pub(crate) fn supply_signal_byte() -> u8 { - signal::WireSignal::encode( +pub(crate) fn supply_frame_head(declared: usize) -> Vec { + use crate::tree::mirror::cbor; + let code = signal::WireSignal::encode( Stream::new(0).expect("stream 0 is within the stream range"), signal::Signal::Supply(Flow::End), - ) + ); + let mut bytes = Vec::new(); + cbor::write_head(&mut bytes, cbor::MAJOR_ARRAY, 2); + cbor::write_head(&mut bytes, cbor::MAJOR_UINT, u64::from(code)); + cbor::write_tag(&mut bytes, cbor::TAG_CBOR_SEQUENCE); + cbor::write_head(&mut bytes, cbor::MAJOR_BSTR, declared as u64); + bytes +} + +/// A structurally valid lone-record run of exactly `len` bytes: one +/// record whose heads plus arbitrary content span the run. +/// +/// Record content decodes lazily, so the meter's bodies need only pass +/// run-record framing. Panics when no single record's head widths can +/// reach `len` exactly (the head-width gaps); the meters' lengths are +/// chosen away from those gaps. +#[cfg(any(test, feature = "test-internals"))] +pub(crate) fn lone_record_run(len: usize) -> Vec { + use crate::tree::mirror::cbor; + for width in [1usize, 2, 3, 5, 9] { + let Some(content) = len.checked_sub(cbor::head_len(cbor::TAG_CBOR_SEQUENCE) + width) else { + continue; + }; + if cbor::head_len(content as u64) != width { + continue; + } + let mut run = Vec::with_capacity(len); + cbor::write_tag(&mut run, cbor::TAG_CBOR_SEQUENCE); + cbor::write_head(&mut run, cbor::MAJOR_BSTR, content as u64); + run.extend((0..content).map(|i| i as u8)); + return run; + } + panic!("no lone record spans exactly {len} bytes"); } /// Decode one initiator-spoken frame from `read` under `budget`, dropping diff --git a/src/tree/mirror/streaming/remote/codec/budget.rs b/src/tree/mirror/streaming/remote/codec/budget.rs index 256b36d7..e37170ca 100644 --- a/src/tree/mirror/streaming/remote/codec/budget.rs +++ b/src/tree/mirror/streaming/remote/codec/budget.rs @@ -6,7 +6,7 @@ //! chunked by **bytes**, not record count: the encoder accumulates records //! into the current run and flushes it when appending the next record would //! push the frame's full wire size — its [`SUPPLY_FRAME_OVERHEAD`]-byte -//! signal-and-length envelope plus the run body — past the budget. A run +//! head envelope plus the run body — past the budget. A run //! always carries at least one record, so a single record larger than the //! budget ships alone in its own frame, exceeding the budget by exactly //! that record's overhang. Runs never span protocol reactions: the batching @@ -29,54 +29,76 @@ //! session minimum, not only by counterparty courtesy. The public //! knob is [`Peer::target_message_size`](crate::Peer::target_message_size). //! -//! Framing headroom: runs ride the wire's `u32` length header -//! ([`framing`](crate::tree::mirror::framing)), so +//! Framing headroom: the wire caps a run body at `u32::MAX` bytes, so //! [`from_bytes`](RunBudget::from_bytes) saturates every budget at //! [`MAX_RUN_BUDGET_BYTES`] — a run flushed within budget always fits the -//! header. The one frame that can still outgrow it is a *single record* -//! larger than the header's ceiling (the minimum-one-record rule ships it -//! alone): that is a record-size limit of the wire, which no budget -//! setting can lift, and the encoder rejects it at the header before -//! writing anything. +//! cap. The one frame that can still outgrow it is a *single record* +//! larger than the cap (the minimum-one-record rule ships it alone): that +//! is a record-size limit of the wire, which no budget setting can lift, +//! and the encoder rejects it at record level before writing anything. -use crate::tree::mirror::framing::LENGTH_HEADER_LEN; +use crate::tree::mirror::cbor; use crate::tree::mirror::streaming::window::FAN; -use super::frame::{MAX_QUERY_CHILDREN, QUERY_CHILD_LEN, QUERY_COUNT_LEN}; +use super::frame::{MAX_QUERY_CHILDREN, listing_entry_len}; use super::signal::WireSignal; +/// The exact wire size of one full-fan query frame. +/// +/// Its array head, its signal head (every query code takes the two-byte +/// head), the listing map's head at the full fan, and one entry per radix +/// value — the map spelling's per-entry cost varies with the key's head +/// width, so the sum walks the radix space rather than multiplying. +const FULL_FAN_QUERY_FRAME_LEN: usize = { + let mut total = cbor::head_len(2) // the frame's two-item array head + + WireSignal::MAX_ENCODED_LEN + + cbor::head_len(MAX_QUERY_CHILDREN as u64); + let mut radix = 0usize; + while radix < MAX_QUERY_CHILDREN { + total += listing_entry_len(radix as u8); + radix += 1; + } + total +}; + /// Default supply-run byte budget: the size of the maximally disputed reply. /// /// Derived from the wire constants, not measured: the decode side's /// documented memory unit is one decoded *reply* (the streaming `message` /// module docs), and the largest non-supply reply is maximally disputed — -/// `FAN` reactions, each a full-fan query frame of one signal byte, -/// one count byte, and `MAX_QUERY_CHILDREN` children of -/// `QUERY_CHILD_LEN` bytes each. Batching at this default therefore -/// never raises the wire's established per-reply memory ceiling. -pub const DEFAULT_TARGET_MESSAGE_SIZE: usize = - FAN * (WireSignal::ENCODED_LEN + QUERY_COUNT_LEN + MAX_QUERY_CHILDREN * QUERY_CHILD_LEN); +/// `FAN` reactions, each a full-fan query frame. Batching at this default +/// therefore never raises the wire's established per-reply memory ceiling. +pub const DEFAULT_TARGET_MESSAGE_SIZE: usize = FAN * FULL_FAN_QUERY_FRAME_LEN; -/// Wire bytes a supply frame wraps around its run body: the signal byte and -/// the body's `u32` length header. +/// Wire bytes a supply frame wraps around its run body, charged at their +/// widest. +/// +/// The envelope: the frame's array head, the signal's widest head, and +/// the run's embedded-sequence tag with the widest byte-string head the +/// run cap admits. The heads narrow for small runs; charging the envelope +/// constant keeps the flush algebra exact-or-conservative, never +/// optimistic. /// /// The budget prices whole wire frames, so the encoder's flush accounting /// charges this envelope alongside the accumulated records — a frame's full /// wire size stays within the budget except when a single record alone /// exceeds it. -pub const SUPPLY_FRAME_OVERHEAD: usize = WireSignal::ENCODED_LEN + LENGTH_HEADER_LEN; +pub const SUPPLY_FRAME_OVERHEAD: usize = cbor::head_len(2) + + WireSignal::MAX_ENCODED_LEN + + cbor::head_len(cbor::TAG_CBOR_SEQUENCE) + + cbor::head_len(u32::MAX as u64); -/// The largest supply-run budget the wire's framing can honor: budgets -/// saturate here at construction. +/// The largest supply-run budget the wire can honor: budgets saturate +/// here at construction. /// /// A frame's full wire size is its [`SUPPLY_FRAME_OVERHEAD`] envelope -/// plus the run body, and the body's length must encode in the `u32` -/// header ([`framing`](crate::tree::mirror::framing)). Capping the -/// whole-frame budget at `u32::MAX` less the envelope keeps every -/// within-budget flush under the header's ceiling with the envelope -/// already paid; without the cap, an over-ceiling budget lets a run -/// grow past 4 GiB in RAM and then deterministically fail at the length -/// header, re-failing every retry while the divergence persists. +/// plus the run body, and the wire caps a run body at `u32::MAX` bytes +/// (the cap every pricing closed form is denominated in). Capping the +/// whole-frame budget at that ceiling less the envelope keeps every +/// within-budget flush under the cap with the envelope already paid; +/// without it, an over-ceiling budget lets a run grow past 4 GiB in RAM +/// and then deterministically fail at the run head, re-failing every +/// retry while the divergence persists. pub const MAX_RUN_BUDGET_BYTES: usize = u32::MAX as usize - SUPPLY_FRAME_OVERHEAD; /// The byte budget one supply frame may grow to before the encoder flushes it. @@ -87,7 +109,7 @@ pub const MAX_RUN_BUDGET_BYTES: usize = u32::MAX as usize - SUPPLY_FRAME_OVERHEA /// minimum-one-record rule keeps every leaf shippable, degrading a zero /// budget to the pre-batching one-leaf-per-frame wire traffic, and the /// constructor's [`MAX_RUN_BUDGET_BYTES`] ceiling keeps every -/// within-budget flush inside the wire's length header. +/// within-budget flush inside the wire's run byte cap. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RunBudget { /// Wire-frame bytes admitted before the next record forces a flush. diff --git a/src/tree/mirror/streaming/remote/codec/budget/tests.rs b/src/tree/mirror/streaming/remote/codec/budget/tests.rs index 2ec4d1cf..76491198 100644 --- a/src/tree/mirror/streaming/remote/codec/budget/tests.rs +++ b/src/tree/mirror/streaming/remote/codec/budget/tests.rs @@ -6,13 +6,37 @@ use super::*; /// assert loudly. #[test] fn default_budget_matches_its_derivation() { - assert_eq!(DEFAULT_TARGET_MESSAGE_SIZE, 1_638_912); + assert_eq!(DEFAULT_TARGET_MESSAGE_SIZE, 1_830_400); assert_eq!( RunBudget::default(), RunBudget::from_bytes(DEFAULT_TARGET_MESSAGE_SIZE) ); } +/// The full-fan frame constant prices exactly what the encoder writes. +/// +/// One query frame carrying every radix, constructed and encoded, is +/// byte-for-byte the closed form's value — so the default budget's +/// derivation cannot drift from the wire. +#[test] +fn full_fan_frame_len_matches_an_actual_encode() { + use crate::tree::typed::{Hash, hash::MERKLE_HASH_LEN}; + + use super::super::signal::{Flow, Speaker, Stream}; + use super::super::{Frame, Reaction, WireFrame, encode}; + + let children: Vec<(u8, Hash)> = (0..=u8::MAX) + .map(|radix| (radix, Hash([radix; MERKLE_HASH_LEN]))) + .collect(); + let frame: WireFrame = ( + Stream::new(8).expect("an interior stream exists"), + Frame::Reaction(Reaction::Query(children), Flow::Continue), + ); + let mut encoded = Vec::new(); + encode(Speaker::Initiator, &frame, &mut encoded).expect("a full-fan query encodes"); + assert_eq!(encoded.len(), FULL_FAN_QUERY_FRAME_LEN); +} + /// The budget's admission boundary charges the whole wire frame. /// /// A record is admitted exactly while the frame — the signal-and-length @@ -32,20 +56,20 @@ fn admission_charges_the_frame_envelope() { assert!(!body_only.admits(body, record)); } -/// The default budget stays within the `u32` framing header, so a +/// The default budget stays within the wire's run byte cap, so a /// default-sized run is always representable on the wire. #[test] fn default_budget_fits_the_framing_header() { assert!(u32::try_from(DEFAULT_TARGET_MESSAGE_SIZE).is_ok()); } -/// Budgets above the wire's framing ceiling saturate to it, so a run the -/// budget admits always flushes within the `u32` length header instead -/// of buffering past 4 GiB and deterministically failing the flush. +/// Budgets above the wire's run byte cap saturate to it, so a run the +/// budget admits always stays within the cap instead of buffering past +/// 4 GiB and deterministically failing at the run head. /// /// The boundary is checked at the admitted maximum: the largest /// body-plus-record the saturated budget accepts still leaves the -/// flushed frame's body encodable in the header, envelope included. The +/// flushed frame's body within the cap, envelope included. The /// negative control shows the ceiling binds: one byte past the admitted /// maximum is refused, so the saturated budget is a real bound, not a /// pass-through. @@ -59,8 +83,8 @@ fn over_ceiling_budgets_saturate_to_the_framing_ceiling() { let body = MAX_RUN_BUDGET_BYTES - SUPPLY_FRAME_OVERHEAD - 1; assert!(budget.admits(body, 1)); assert!( - crate::tree::mirror::framing::length_header(body + 1).is_ok(), - "an admitted flush must encode in the u32 length header", + super::super::frame::checked_run_len(body + 1).is_ok(), + "an admitted flush must stay within the run byte cap", ); // Negative control: the ceiling genuinely binds. assert!(!budget.admits(body + 1, 1)); diff --git a/src/tree/mirror/streaming/remote/codec/capture.rs b/src/tree/mirror/streaming/remote/codec/capture.rs index 503f288f..f3c5db3a 100644 --- a/src/tree/mirror/streaming/remote/codec/capture.rs +++ b/src/tree/mirror/streaming/remote/codec/capture.rs @@ -15,38 +15,30 @@ use std::{collections::BTreeMap, fmt::Write as _}; use crate::Version; -use crate::tree::mirror::framing::{GREETING_SIZE_WORDS_LEN, LENGTH_HEADER_LEN, greeting_words}; -use crate::tree::mirror::streaming::message::initiates; -use crate::tree::typed::Hash; +use crate::tree::mirror::cbor::{self, MAJOR_BSTR, MAJOR_TAG, MAJOR_UINT, TAG_EMBEDDED_ITEM}; +use crate::tree::mirror::handshake::V2_PREAMBLE_LEN; +use crate::tree::mirror::streaming::message::{Greeting, initiates}; use super::{ End, Speaker, Stream, - decode::parse_query, - frame::{LeafRun, QUERY_CHILD_LEN, QUERY_COUNT_BIAS, QUERY_COUNT_LEN, validate_children}, + frame::{LeafRun, parse_listing_map}, + greeting::parse_greeting, signal::{Signal, WireSignal}, }; #[cfg(test)] mod tests; -/// Bytes occupied by the fixed session preamble. -const PREAMBLE_LEN: usize = 25; - -// The label's width is defined canonically beside the sender that writes -// it; captures parse with the same constant. -use super::super::streams::LABEL_LEN; - /// Everything one endpoint sent during a captured session. /// /// The link keeps logical streams physically separate, so a capture is /// already demultiplexed: the control stream's exact bytes, plus each opened /// data stream's exact bytes (label included), in open order. pub struct LinkCapture { - /// The control stream's outgoing bytes: preamble, the greeting's - /// causal-version and root-fan listing frames, and any trailing party - /// hand-off, in order. + /// The control stream's outgoing bytes: preamble, the greeting item, + /// and any trailing party hand-off and epilogue, in order. pub control: Vec, - /// Each opened data stream's outgoing bytes: its two-byte label, then + /// Each opened data stream's outgoing bytes: its label items, then /// its frames through the explicit end control. pub streams: Vec>, } @@ -61,25 +53,26 @@ pub fn render_v2_capture(a: &LinkCapture, b: &LinkCapture) -> String { let a_control = Control::parse(&a.control); let b_control = Control::parse(&b.control); - let (a_streams, b_streams) = match (&a_control.version, &b_control.version) { + let (a_streams, b_streams) = match (&a_control.greeting, &b_control.greeting) { (None, None) => (None, None), - (Some(a_version), Some(b_version)) if a_version == b_version => { + (Some((_, a_greeting)), Some((_, b_greeting))) + if a_greeting.version == b_greeting.version => + { assert!( a.streams.is_empty() && b.streams.is_empty(), "equal versions open no data streams", ); (None, None) } - (Some(a_version), Some(b_version)) => { + (Some((_, a_greeting)), Some((_, b_greeting))) => { // Mirror the session's role election: the smaller advertised // set initiates, canonical version bytes break ties. - let a_len = a_control - .set_len - .expect("a version frame carries its set size"); - let b_len = b_control - .set_len - .expect("a version frame carries its set size"); - let a_speaker = if initiates(a_len, a_version, b_len, b_version) { + let a_speaker = if initiates( + a_greeting.set_len, + &a_greeting.version, + b_greeting.set_len, + &b_greeting.version, + ) { Speaker::Initiator } else { Speaker::Responder @@ -89,7 +82,7 @@ pub fn render_v2_capture(a: &LinkCapture, b: &LinkCapture) -> String { Some(Streams::parse(a_speaker.other(), &b.streams)), ) } - _ => panic!("both directions must either carry or omit a version frame"), + _ => panic!("both directions must either carry or omit a greeting"), }; let mut rendered = String::new(); @@ -99,97 +92,60 @@ pub fn render_v2_capture(a: &LinkCapture, b: &LinkCapture) -> String { rendered } -/// The control stream's fixed prefix, optional greeting frames, and trailing +/// The control stream's fixed prefix, optional greeting item, and trailing /// session bytes. struct Control { preamble: Vec, - version_frame: Option>, - version: Option, - /// The version frame's leading word: the sender's advertised set size, - /// the role election's primary key. - set_len: Option, - /// The version frame's remaining size words: the sender's - /// version-size bound and target message size. - max_version_bytes: Option, - target_message_size: Option, - /// The greeting's second frame: the sender's root-fan listing. - listing_frame: Option>, + /// The greeting item's exact bytes and its decoded form. + greeting: Option<(Vec, Greeting)>, trailing: Vec, } impl Control { - /// Split one captured control direction at its exact fixed boundaries. + /// Split one captured control direction at its exact item boundaries. fn parse(bytes: &[u8]) -> Self { - assert!(bytes.len() >= PREAMBLE_LEN, "capture omitted the preamble"); - let (preamble, rest) = bytes.split_at(PREAMBLE_LEN); - if rest.is_empty() { - return Self { - preamble: preamble.to_vec(), - version_frame: None, - version: None, - set_len: None, - max_version_bytes: None, - target_message_size: None, - listing_frame: None, - trailing: Vec::new(), - }; - } - - // A session that ends before its causal greeting (a mutual retire - // declining at the preamble) still closes with the one-byte session - // epilogue marker: control bytes too short to be a version frame - // header are that trailing marker, not a truncated frame. - if rest.len() < LENGTH_HEADER_LEN { + assert!( + bytes.len() >= V2_PREAMBLE_LEN, + "capture omitted the preamble" + ); + let (preamble, rest) = bytes.split_at(V2_PREAMBLE_LEN); + // A greeting item opens with the embedded-item tag; anything else + // after the preamble (a session ending before its greeting — a + // mutual retire declining — closes with just the epilogue item) is + // trailing. + let mut probe = rest; + let is_greeting = matches!( + cbor::read_head(&mut probe), + Ok(cbor::Head { + major: MAJOR_TAG, + value: TAG_EMBEDDED_ITEM, + }) + ); + if !is_greeting { return Self { preamble: preamble.to_vec(), - version_frame: None, - version: None, - set_len: None, - max_version_bytes: None, - target_message_size: None, - listing_frame: None, + greeting: None, trailing: rest.to_vec(), }; } - let (version_frame, rest) = split_frame(rest, "version"); - // The version frame's body leads with its three size words, - // decoded through the same framing helper the handshake reads - // them with; the version encoding follows them. - let (set_len, max_version_bytes, target_message_size) = - greeting_words(&version_frame[LENGTH_HEADER_LEN..]) - .expect("captured version frame carries its three size words"); - let version = - Version::decode(&version_frame[LENGTH_HEADER_LEN + GREETING_SIZE_WORDS_LEN..]) - .expect("captured version frame is canonical"); - // The greeting always carries its listing frame directly behind the - // version frame (empty tree = empty listing, still framed). - let (listing_frame, rest) = split_frame(rest, "listing"); + let head = cbor::read_head(&mut probe).expect("captured greeting has a byte-string head"); + assert_eq!( + head.major, MAJOR_BSTR, + "captured greeting wraps a byte string" + ); + let len = usize::try_from(head.value).expect("captured greeting fits in memory"); + let consumed = rest.len() - probe.len() + len; + assert!(rest.len() >= consumed, "truncated greeting item"); + let (item, trailing) = rest.split_at(consumed); + let greeting = parse_greeting(&probe[..len]).expect("captured greeting is canonical"); Self { preamble: preamble.to_vec(), - version_frame: Some(version_frame), - version: Some(version), - set_len: Some(set_len), - max_version_bytes: Some(max_version_bytes), - target_message_size: Some(target_message_size), - listing_frame: Some(listing_frame), - trailing: rest.to_vec(), + greeting: Some((item.to_vec(), greeting)), + trailing: trailing.to_vec(), } } } -/// Split one exact length-delimited frame (header included) off `bytes`. -fn split_frame<'a>(bytes: &'a [u8], what: &str) -> (Vec, &'a [u8]) { - assert!( - bytes.len() >= LENGTH_HEADER_LEN, - "truncated {what} frame header" - ); - let len = - u32::from_be_bytes(bytes[..LENGTH_HEADER_LEN].try_into().expect("header width")) as usize; - let frame_end = LENGTH_HEADER_LEN + len; - assert!(bytes.len() >= frame_end, "truncated {what} frame"); - (bytes[..frame_end].to_vec(), &bytes[frame_end..]) -} - /// One direction's exact data streams, keyed by their labeled stream index. struct Streams { speaker: Speaker, @@ -207,13 +163,12 @@ impl Streams { fn parse(speaker: Speaker, streams: &[Vec]) -> Self { let mut parsed = BTreeMap::new(); for bytes in streams { - assert!( - bytes.len() >= LABEL_LEN, - "captured stream omitted its label" - ); - let (label, mut rest) = bytes.split_at(LABEL_LEN); - let [epoch, index] = label.try_into().expect("label width"); - let labeled = Stream::new(index).expect("captured label names a logical stream"); + let mut rest = bytes.as_slice(); + let epoch = label_item(&mut rest, "epoch"); + let epoch = u8::try_from(epoch).expect("captured epoch is a byte-ranged counter"); + let index = label_item(&mut rest, "stream index"); + let labeled = Stream::new(u8::try_from(index).expect("captured label is byte-ranged")) + .expect("captured label names a logical stream"); let mut frames = Vec::new(); let mut ended = false; @@ -223,7 +178,7 @@ impl Streams { ended = matches!(signal, Signal::End(End::Stream)); frames.push(CapturedFrame { semantic: format!("{signal:?}"), - payload: payload_lines(&signal, &rest[..consumed]), + payload: payload_lines(speaker, &rest[..consumed]), bytes: rest[..consumed].to_vec(), }); rest = &rest[consumed..]; @@ -239,30 +194,50 @@ impl Streams { } } +/// Read one label item: a canonical unsigned int. +fn label_item(rest: &mut &[u8], what: &str) -> u64 { + let head = cbor::read_head(rest) + .unwrap_or_else(|e| panic!("captured stream label {what} is canonical: {e}")); + assert_eq!( + head.major, MAJOR_UINT, + "captured label {what} is an unsigned int" + ); + head.value +} + /// Parse one honest frame's boundary without decoding its supplied payload. fn raw_frame(speaker: Speaker, bytes: &[u8]) -> (Stream, Signal, usize) { - let (&byte, body) = bytes.split_first().expect("captured stream ended early"); - let (stream, signal) = WireSignal::from_byte(speaker, byte) + let mut rest = bytes; + let head = cbor::read_head(&mut rest).expect("captured frame head is canonical"); + assert_eq!(head.major, cbor::MAJOR_ARRAY, "captured frame is an array"); + let head = cbor::read_head(&mut rest).expect("captured signal is canonical"); + assert_eq!(head.major, MAJOR_UINT, "captured signal is an unsigned int"); + let code = u8::try_from(head.value).expect("captured signal is in the dense code space"); + let (stream, signal) = WireSignal::from_byte(speaker, code) .expect("captured signal is valid") .into_parts(); - let body_len = match signal { - Signal::Match(_) | Signal::QueryEmpty(_) | Signal::End(_) => 0, + match signal { + Signal::Match(_) | Signal::QueryEmpty(_) | Signal::End(_) => {} Signal::Query(_) => { - let (&count, _) = body.split_first().expect("captured query has a count"); - QUERY_COUNT_LEN + (usize::from(count) + QUERY_COUNT_BIAS) * QUERY_CHILD_LEN + // Walking the listing map through the codec's own parser both + // finds the frame boundary and validates canonical form. + parse_listing_map(&mut rest).expect("captured query listing is canonical"); } Signal::Supply(_) => { - assert!( - body.len() >= LENGTH_HEADER_LEN, - "captured supply has a length" + let head = cbor::read_head(&mut rest).expect("captured run tag is canonical"); + assert_eq!( + (head.major, head.value), + (MAJOR_TAG, cbor::TAG_CBOR_SEQUENCE), + "captured supply opens with the embedded-sequence tag" ); - let len = - u32::from_be_bytes(body[..LENGTH_HEADER_LEN].try_into().expect("header width")); - LENGTH_HEADER_LEN + len as usize + let head = cbor::read_head(&mut rest).expect("captured run head is canonical"); + assert_eq!(head.major, MAJOR_BSTR, "captured run is a byte string"); + let len = usize::try_from(head.value).expect("captured run fits in memory"); + assert!(rest.len() >= len, "captured frame is truncated"); + rest = &rest[len..]; } - }; - let consumed = WireSignal::ENCODED_LEN + body_len; - assert!(bytes.len() >= consumed, "captured frame is truncated"); + } + let consumed = bytes.len() - rest.len(); (stream, signal, consumed) } @@ -283,25 +258,34 @@ struct CapturedFrame { /// payload bytes that do not decode render as an explicit failure line — /// the hex below them then stands as the witness, never as the only /// account. -fn payload_lines(signal: &Signal, frame: &[u8]) -> Vec { +fn payload_lines(speaker: Speaker, frame: &[u8]) -> Vec { + // Skip the frame's array and signal heads, re-reading them through + // the head grammar so the offsets cannot drift from `raw_frame`. + let mut rest = frame; + cbor::read_head(&mut rest).expect("frame head validated by raw_frame"); + let signal = cbor::read_head(&mut rest).expect("signal validated by raw_frame"); + let code = u8::try_from(signal.value).expect("signal range validated by raw_frame"); + let (_, signal) = WireSignal::from_byte(speaker, code) + .expect("signal validated by raw_frame") + .into_parts(); match signal { Signal::Match(_) | Signal::QueryEmpty(_) | Signal::End(_) => Vec::new(), - // Signal byte, count byte, then the exact children — the frame - // boundary already validated the arithmetic. - Signal::Query(_) => query_lines(&frame[WireSignal::ENCODED_LEN + QUERY_COUNT_LEN..]), - // Signal byte and length header, then the run body. + Signal::Query(_) => query_lines(rest), Signal::Supply(_) => { - supply_lines(frame[WireSignal::ENCODED_LEN + LENGTH_HEADER_LEN..].to_vec()) + let mut heads = rest; + cbor::read_head(&mut heads).expect("run tag validated by raw_frame"); + cbor::read_head(&mut heads).expect("run head validated by raw_frame"); + supply_lines(heads.to_vec()) } } } /// Render a nonempty query's children: each child's radix and hash, -/// decoded through the codec's own `parse_query` (canonical child order +/// decoded through the codec's own listing parser (canonical child order /// included), so the renderer cannot drift from what the decoder /// accepts. -fn query_lines(children: &[u8]) -> Vec { - let children = match parse_query(children) { +fn query_lines(mut children: &[u8]) -> Vec { + let children = match parse_listing_map(&mut children) { Ok(children) => children, Err(err) => { return vec![format!( @@ -335,6 +319,19 @@ fn supply_lines(run: Vec) -> Vec { let mut lines = vec![format!("supply run: {} record(s)", run.record_count())]; for (index, record) in run.record_slices().enumerate() { let mut input = record; + let tagged = matches!( + cbor::read_head(&mut input), + Ok(cbor::Head { + major: MAJOR_TAG, + value, + }) if value == crate::tags::VERSION_TAG + ); + if !tagged { + lines.push(format!( + " record {index} does not open with the version-atom tag; the exact bytes stand below" + )); + continue; + } match ciborium::de::from_reader::(&mut input) { Ok(version) => lines.push(format!( " record {index}: version {version}, message {} byte(s)", @@ -348,38 +345,13 @@ fn supply_lines(run: Vec) -> Vec { lines } -/// Render one root-fan listing frame's children, or its explicit decode -/// failure: the listing is peer-controlled bytes, so the renderer must -/// never present undecodable bytes as a quietly hex-only frame. +/// Render one root-fan listing's children. /// -/// The canonical child order is held by the codec's own -/// `validate_children`, the same rule the handshake applies before -/// building scope from a received listing. -fn listing_lines(body: &[u8]) -> Vec { - const RECORD: usize = 1 + crate::tree::typed::hash::MERKLE_HASH_LEN; - if !body.len().is_multiple_of(RECORD) { - return vec![format!( - "listing undecodable ({} bytes is not a whole number of radix-hash records); \ - the exact bytes stand below", - body.len() - )]; - } - let children: Vec<(u8, Hash)> = body - .chunks_exact(RECORD) - .map(|record| { - let (&radix, hash) = record.split_first().expect("a record has a radix byte"); - let mut bytes = [0u8; crate::tree::typed::hash::MERKLE_HASH_LEN]; - bytes.copy_from_slice(hash); - (radix, Hash(bytes)) - }) - .collect(); - if let Err(err) = validate_children(&children) { - return vec![format!( - "listing not canonical ({err}); the exact bytes stand below" - )]; - } - let mut lines = vec![format!("listing: {} child(ren)", children.len())]; - for (radix, hash) in &children { +/// The canonical child order was already held by the codec's own listing +/// parser when the greeting decoded; these lines render the result. +fn listing_lines(listing: &[(u8, crate::tree::typed::Hash)]) -> Vec { + let mut lines = vec![format!("listing: {} child(ren)", listing.len())]; + for (radix, hash) in listing { lines.push(format!(" child 0x{radix:x}: {}", hex::encode(hash.0))); } lines @@ -389,32 +361,18 @@ fn listing_lines(body: &[u8]) -> Vec { fn render_direction(label: &str, control: &Control, streams: Option<&Streams>, out: &mut String) { writeln!(out, "direction {label}").unwrap(); render_block("preamble", &control.preamble, out); - if let Some(version) = &control.version { - writeln!(out, "version: {version}").unwrap(); + if let Some((item, greeting)) = &control.greeting { + writeln!(out, "version: {}", greeting.version).unwrap(); writeln!( out, "greeting words: set len {}, version-size bound {}, message-size target {}", - control - .set_len - .expect("a version frame carries its set size"), - control - .max_version_bytes - .expect("a version frame carries its version-size bound"), - control - .target_message_size - .expect("a version frame carries its message-size target"), + greeting.set_len, greeting.max_version_bytes, greeting.target_message_size, ) .unwrap(); - render_block( - "version frame", - control.version_frame.as_deref().expect("version frame"), - out, - ); - let listing_frame = control.listing_frame.as_deref().expect("listing frame"); - for line in listing_lines(&listing_frame[LENGTH_HEADER_LEN..]) { + for line in listing_lines(&greeting.listing) { writeln!(out, "{line}").unwrap(); } - render_block("listing frame", listing_frame, out); + render_block("greeting frame", item, out); } if let Some(streams) = streams { diff --git a/src/tree/mirror/streaming/remote/codec/capture/tests.rs b/src/tree/mirror/streaming/remote/codec/capture/tests.rs index 35b2fb32..6b44007d 100644 --- a/src/tree/mirror/streaming/remote/codec/capture/tests.rs +++ b/src/tree/mirror/streaming/remote/codec/capture/tests.rs @@ -2,25 +2,28 @@ //! //! Two commitments: the decoded parse tree names the semantic field a //! snapshot re-accept moved (the committed fixture pair below differs in -//! exactly one field and exactly one rendered line), and payload bytes -//! that do not decode render as an explicit failure line, never as -//! silent hex. +//! exactly one field and exactly one rendered line), and data-stream +//! payload bytes that do not decode render as an explicit failure line, +//! never as silent hex. (The greeting is different: a session cannot +//! proceed past a non-canonical greeting, so a capture holding one means +//! the harness itself is broken, and its parse stays a panic.) use super::*; use crate::message::Message; +use crate::tree::mirror::cbor::{MAJOR_BSTR, TAG_CBOR_SEQUENCE}; +use crate::tree::typed::Hash; use crate::tree::typed::hash::MERKLE_HASH_LEN; -use super::super::frame::LeafRun; +use super::super::frame::write_listing; -/// Encode a listing as its wire form: raw radix-hash records. -fn encode_listing(children: &[(u8, Hash)]) -> Vec { - let mut body = Vec::new(); - for (radix, hash) in children { - body.push(*radix); - body.extend_from_slice(hash.as_bytes()); - } - body +/// A record item wrapping raw content bytes. +fn raw_record(content: &[u8]) -> Vec { + let mut record = Vec::new(); + cbor::write_head(&mut record, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut record, MAJOR_BSTR, content.len() as u64); + record.extend_from_slice(content); + record } /// The capture renderer decodes each supply record structurally. @@ -59,13 +62,13 @@ fn supply_decode_names_the_field_that_moved() { /// Unparseable supply payloads render an explicit decode failure, never /// silent hex. /// -/// A run with broken record framing convicts the whole run, and a -/// structurally framed record whose version bytes do not decode -/// convicts that record by index. +/// A run with broken record framing convicts the whole run; a +/// structurally framed record without the version-atom tag, or whose +/// version bytes do not decode, convicts that record by index. #[test] fn undecodable_supply_renders_failure_not_silent_hex() { - // A record header promising more bytes than the run carries. - let torn = vec![0, 0, 0, 9, 1, 2, 3]; + // A record whose byte string promises more bytes than the run carries. + let torn = raw_record(&[1, 2, 3])[..5].to_vec(); let lines = supply_lines(torn); assert_eq!(lines.len(), 1); assert!( @@ -73,10 +76,19 @@ fn undecodable_supply_renders_failure_not_silent_hex() { "torn framing must convict the run: {lines:?}" ); - // Valid record framing around bytes that are no version encoding. - let mut framed = vec![0, 0, 0, 3]; - framed.extend_from_slice(&[0xff, 0xff, 0xff]); - let lines = supply_lines(framed); + // Valid record framing around content missing the version-atom tag. + let lines = supply_lines(raw_record(&[0xff, 0xff, 0xff])); + assert_eq!(lines[0], "supply run: 1 record(s)"); + assert!( + lines[1].contains("record 0 does not open with the version-atom tag"), + "an untagged version must convict its record: {lines:?}" + ); + + // A tagged version whose atom bytes are no version encoding. + let mut content = Vec::new(); + cbor::write_head(&mut content, MAJOR_TAG, crate::tags::VERSION_TAG); + content.extend_from_slice(&[0xff, 0xff, 0xff]); + let lines = supply_lines(raw_record(&content)); assert_eq!(lines[0], "supply run: 1 record(s)"); assert!( lines[1].contains("record 0 undecodable"), @@ -84,17 +96,15 @@ fn undecodable_supply_renders_failure_not_silent_hex() { ); } -/// The greeting's root-fan listing decodes to one line per child naming -/// its radix and full hash; bytes that are not a canonical listing -/// render the explicit failure instead. +/// The greeting's root-fan listing renders one line per child naming its +/// radix and full hash. #[test] -fn listing_decodes_children_and_convicts_garbage() { +fn listing_renders_children() { let children = vec![ (0x3_u8, Hash([0xab; MERKLE_HASH_LEN])), (0xc_u8, Hash([0x01; MERKLE_HASH_LEN])), ]; - let body = encode_listing(&children); - let lines = listing_lines(&body); + let lines = listing_lines(&children); assert_eq!(lines[0], "listing: 2 child(ren)"); assert_eq!( lines[1], @@ -104,14 +114,6 @@ fn listing_decodes_children_and_convicts_garbage() { lines[2], format!(" child 0xc: {}", "01".repeat(MERKLE_HASH_LEN)) ); - - let truncated = &body[..body.len() - 1]; - let lines = listing_lines(truncated); - assert_eq!(lines.len(), 1); - assert!( - lines[0].contains("listing undecodable"), - "a truncated listing must convict itself: {lines:?}" - ); } /// A nonempty query's children decode to one line per child naming its @@ -119,10 +121,13 @@ fn listing_decodes_children_and_convicts_garbage() { #[test] fn query_children_decode_to_radix_and_hash() { let mut children = Vec::new(); - children.push(0x0_u8); - children.extend_from_slice(&[0x22; MERKLE_HASH_LEN]); - children.push(0xf_u8); - children.extend_from_slice(&[0x9d; MERKLE_HASH_LEN]); + write_listing( + &mut children, + &[ + (0x0_u8, Hash([0x22; MERKLE_HASH_LEN])), + (0xf_u8, Hash([0x9d; MERKLE_HASH_LEN])), + ], + ); let lines = query_lines(&children); assert_eq!(lines[0], "query: 2 child(ren)"); assert_eq!( @@ -141,10 +146,13 @@ fn query_children_decode_to_radix_and_hash() { #[test] fn non_canonical_query_renders_failure_not_silent_hex() { let mut children = Vec::new(); - children.push(0xf_u8); - children.extend_from_slice(&[0x9d; MERKLE_HASH_LEN]); - children.push(0x0_u8); - children.extend_from_slice(&[0x22; MERKLE_HASH_LEN]); + write_listing( + &mut children, + &[ + (0xf_u8, Hash([0x9d; MERKLE_HASH_LEN])), + (0x0_u8, Hash([0x22; MERKLE_HASH_LEN])), + ], + ); let lines = query_lines(&children); assert_eq!(lines.len(), 1); assert!( @@ -152,21 +160,3 @@ fn non_canonical_query_renders_failure_not_silent_hex() { "descending children must convict the query: {lines:?}" ); } - -/// The listing is held to the same canonical child order the handshake -/// enforces before building scope from it: out-of-order children render -/// an explicit conviction, never a quietly decoded tree. -#[test] -fn non_canonical_listing_renders_failure_not_silent_hex() { - let children = vec![ - (0xc_u8, Hash([0x01; MERKLE_HASH_LEN])), - (0x3_u8, Hash([0xab; MERKLE_HASH_LEN])), - ]; - let body = encode_listing(&children); - let lines = listing_lines(&body); - assert_eq!(lines.len(), 1); - assert!( - lines[0].contains("listing not canonical"), - "descending children must convict the listing: {lines:?}" - ); -} diff --git a/src/tree/mirror/streaming/remote/codec/decode.rs b/src/tree/mirror/streaming/remote/codec/decode.rs index 9b1b94fc..037ba98f 100644 --- a/src/tree/mirror/streaming/remote/codec/decode.rs +++ b/src/tree/mirror/streaming/remote/codec/decode.rs @@ -1,13 +1,9 @@ //! Self-delimiting frame decoding. -#[cfg(test)] -use std::slice; - #[cfg(test)] use std::io::{ErrorKind, Read}; -use crate::tree::mirror::framing::LENGTH_HEADER_LEN; -use crate::tree::typed::{Hash, hash::MERKLE_HASH_LEN}; +use crate::tree::mirror::cbor::{self, HeadReadError, MAJOR_ARRAY, MAJOR_UINT}; mod async_io; @@ -16,15 +12,14 @@ pub use async_io::FrameRead; #[cfg(test)] use super::budget::RunBudget; #[cfg(test)] +use super::frame::{Frame, LeafRun, Reaction, WireFrame}; use super::{ - error::FramePart, - frame::{Frame, LeafRun, QUERY_COUNT_BIAS, Reaction, WireFrame}, -}; -use super::{ - error::{DecodeError, DecodeErrorKind}, - frame::{QUERY_CHILD_LEN, validate_children}, + error::{DecodeError, DecodeErrorKind, FramePart}, + frame::ListingIssue, signal::{Signal, Speaker, Stream, WireSignal}, }; +#[cfg(test)] +use crate::tree::typed::{Hash, hash::MERKLE_HASH_LEN}; /// Decode one frame from `read`, leaving subsequent bytes untouched. #[cfg(test)] @@ -76,18 +71,44 @@ impl<'a, R: Read> FrameDecoder<'a, R> { } fn decode(mut self) -> Result { + let arity = self + .arity() + .map_err(|kind| DecodeError::direction(self.speaker, kind))?; let (stream, signal) = self.signal()?; - let frame = self - .body(signal) + let frame = check_arity(signal, arity) + .and_then(|()| self.body(signal)) .map_err(|kind| DecodeError::stream(self.speaker, stream, kind))?; Ok((stream, frame)) } + /// Read the frame's array head; this oracle treats a clean close as a + /// truncation, since its callers always expect a frame. + fn arity(&mut self) -> Result { + let head = cbor::read_head_io(self.read) + .map_err(|e| head_error(FramePart::FrameHead, e))? + .ok_or_else(|| { + head_error( + FramePart::FrameHead, + HeadReadError::Io(ErrorKind::UnexpectedEof.into()), + ) + })?; + frame_arity(head) + } + fn signal(&mut self) -> Result<(Stream, Signal), DecodeError> { - let byte = self - .byte(FramePart::Signal) + let head = cbor::read_head_io(self.read) + .map_err(|e| head_error(FramePart::Signal, e)) + .and_then(|head| { + head.ok_or_else(|| { + head_error( + FramePart::Signal, + HeadReadError::Io(ErrorKind::UnexpectedEof.into()), + ) + }) + }) .map_err(|kind| DecodeError::direction(self.speaker, kind))?; - decode_signal(self.speaker, byte) + let code = signal_code(head).map_err(|kind| DecodeError::direction(self.speaker, kind))?; + decode_signal(self.speaker, code) } fn body(&mut self, signal: Signal) -> Result { @@ -102,41 +123,50 @@ impl<'a, R: Read> FrameDecoder<'a, R> { } fn query(&mut self) -> Result, DecodeErrorKind> { - let count = usize::from(self.byte(FramePart::QueryCount)?) + QUERY_COUNT_BIAS; - // One bulk read for the whole listing rather than one call per child. - let mut listing = vec![0; count * QUERY_CHILD_LEN]; - self.read_exact(&mut listing, FramePart::QueryChildren)?; - - parse_query(&listing) + let head = self.head(FramePart::QueryChildren)?; + let mut listing = query_listing(head)?; + let count = head.value; + for _ in 0..count { + let key = self.head(FramePart::QueryChildren)?; + let radix = listing.key(key).map_err(listing_issue)?; + let value = self.head(FramePart::QueryChildren)?; + super::frame::ListingBuilder::value_head(value).map_err(listing_issue)?; + let mut digest = [0; MERKLE_HASH_LEN]; + self.read_exact(&mut digest, FramePart::QueryChildren)?; + listing.entry(radix, digest); + } + Ok(listing.finish()) } fn supply(&mut self) -> Result { - let mut header = [0; LENGTH_HEADER_LEN]; - self.read_exact(&mut header, FramePart::SupplyLength)?; - let len = u32::from_be_bytes(header) as usize; + let tag = self.head(FramePart::SupplyLength)?; + let body = self.head(FramePart::SupplyLength)?; + let len = run_head(tag, body)?; // The run-budget ingress check, mirroring the async reader's exactly // (see `AsyncFrameDecoder::supply` for the memory argument this // oracle does not need): an over-budget frame is legal only as one // lone record spanning the whole body, decided from the first - // record's length header alone. + // record's heads alone. if !self.budget.covers(len) { let budget = self.budget; let overbatched = move || DecodeErrorKind::OverbatchedRun { declared: super::budget::SUPPLY_FRAME_OVERHEAD.saturating_add(len), budget: budget.bytes(), }; - if len < LENGTH_HEADER_LEN { + // A body too short to hold a record's heads cannot be a lone + // record: rejected on the declared length alone. + if len < super::frame::RECORD_TAG_LEN + 1 { return Err(overbatched()); } - let mut first = [0; LENGTH_HEADER_LEN]; - self.read_exact(&mut first, FramePart::SupplyRun)?; - let record = u32::from_be_bytes(first) as usize; - if !lone_record_spans(len, record) { + let Some((prefix, record)) = self.record_prefix()? else { + return Err(overbatched()); + }; + if !super::frame::lone_record_spans(len, record) { return Err(overbatched()); } let mut run = vec![0; len]; - run[..LENGTH_HEADER_LEN].copy_from_slice(&first); - self.read_exact(&mut run[LENGTH_HEADER_LEN..], FramePart::SupplyRun)?; + run[..prefix.len()].copy_from_slice(&prefix); + self.read_exact(&mut run[prefix.len()..], FramePart::SupplyRun)?; return Ok(LeafRun::from_encoded(run)?); } // This oracle deliberately reads the whole declared body at once so @@ -149,10 +179,29 @@ impl<'a, R: Read> FrameDecoder<'a, R> { Ok(LeafRun::from_encoded(run)?) } - fn byte(&mut self, part: FramePart) -> Result { - let mut byte = 0; - self.read_exact(slice::from_mut(&mut byte), part)?; - Ok(byte) + /// Read the first record's heads inside an over-budget run, returning + /// the exact bytes consumed and the record content length; `None` when + /// they are not a record's heads (over budget, the distinction from + /// malformed is moot). + fn record_prefix(&mut self) -> Result, u64)>, DecodeErrorKind> { + let mut prefix = Vec::new(); + let tag = self.head(FramePart::SupplyRun)?; + cbor::write_head(&mut prefix, tag.major, tag.value); + if tag.major != cbor::MAJOR_TAG || tag.value != cbor::TAG_CBOR_SEQUENCE { + return Ok(None); + } + let body = self.head(FramePart::SupplyRun)?; + cbor::write_head(&mut prefix, body.major, body.value); + if body.major != cbor::MAJOR_BSTR { + return Ok(None); + } + Ok(Some((prefix, body.value))) + } + + fn head(&mut self, part: FramePart) -> Result { + cbor::read_head_io(self.read) + .map_err(|e| head_error(part, e))? + .ok_or_else(|| head_error(part, HeadReadError::Io(ErrorKind::UnexpectedEof.into()))) } fn read_exact(&mut self, bytes: &mut [u8], part: FramePart) -> Result<(), DecodeErrorKind> { @@ -168,39 +217,145 @@ impl<'a, R: Read> FrameDecoder<'a, R> { } } -/// Whether a run body of `len` bytes is exactly one record: the first -/// record's header plus the record it declares span the body. -/// -/// The lone-record test of the run-budget ingress check, shared by the -/// async reader and the sync oracle so the two decoders draw the -/// over-budget legality boundary identically. A body this predicate -/// rejects may also be structurally malformed; over budget, that -/// distinction is moot — either way the frame is not the one legal -/// overhang — so the check does not refine it further. -fn lone_record_spans(len: usize, first_record_len: usize) -> bool { - LENGTH_HEADER_LEN.saturating_add(first_record_len) == len +/// Validate a frame's array head: a definite array of one or two items. +pub(super) fn frame_arity(head: cbor::Head) -> Result { + if head.major != MAJOR_ARRAY { + return Err(DecodeErrorKind::FrameShape { + detail: "frame item is not an array", + }); + } + if !(1..=2).contains(&head.value) { + return Err(DecodeErrorKind::FrameShape { + detail: "frame array is not one or two items", + }); + } + Ok(head.value) } -fn decode_signal(speaker: Speaker, byte: u8) -> Result<(Stream, Signal), DecodeError> { - let wire = WireSignal::from_byte(speaker, byte) - .map_err(|invalid| DecodeError::stream(speaker, invalid.stream(), invalid.into()))?; - Ok(wire.into_parts()) +/// Validate a signal head: an unsigned int within the dense code space's +/// byte range. Codes above the dense space but within the byte range keep +/// their reserved-value taxonomy downstream. +pub(super) fn signal_code(head: cbor::Head) -> Result { + if head.major != MAJOR_UINT { + return Err(DecodeErrorKind::Malformed { + part: FramePart::Signal, + detail: "signal is not an unsigned int", + }); + } + u8::try_from(head.value).map_err(|_| DecodeErrorKind::Malformed { + part: FramePart::Signal, + detail: "signal is outside the dense code space", + }) +} + +/// Enforce the frame array's length against its signal's body arity. +pub(super) fn check_arity(signal: Signal, arity: u64) -> Result<(), DecodeErrorKind> { + let expected = match signal { + Signal::Match(_) | Signal::QueryEmpty(_) | Signal::End(_) => 1, + Signal::Query(_) | Signal::Supply(_) => 2, + }; + if arity != expected { + return Err(DecodeErrorKind::FrameArity { + expected, + found: arity, + }); + } + Ok(()) +} + +/// Open a query body: its head must be a nonempty listing map (an empty +/// query travels as its own signal), within the radix space. +pub(super) fn query_listing( + head: cbor::Head, +) -> Result { + if head.major != cbor::MAJOR_MAP { + return Err(DecodeErrorKind::Malformed { + part: FramePart::QueryChildren, + detail: "query body is not a listing map", + }); + } + if head.value == 0 { + return Err(DecodeErrorKind::Malformed { + part: FramePart::QueryChildren, + detail: "a nonempty query's listing is empty", + }); + } + super::frame::ListingBuilder::new(head.value).map_err(listing_issue) +} + +/// Open a supply body: the run's embedded-sequence tag and byte-string +/// head, held to the wire's run byte cap. +pub(super) fn run_head(tag: cbor::Head, body: cbor::Head) -> Result { + if tag.major != cbor::MAJOR_TAG || tag.value != cbor::TAG_CBOR_SEQUENCE { + return Err(DecodeErrorKind::Malformed { + part: FramePart::SupplyLength, + detail: "supply body does not open with the embedded-sequence tag", + }); + } + if body.major != cbor::MAJOR_BSTR { + return Err(DecodeErrorKind::Malformed { + part: FramePart::SupplyLength, + detail: "supply tag does not wrap a byte string", + }); + } + u32::try_from(body.value) + .map(|len| len as usize) + .map_err(|_| DecodeErrorKind::Malformed { + part: FramePart::SupplyLength, + detail: "supply run exceeds the run byte cap", + }) } -/// `pub(super)` for the capture renderer, which decodes captured query -/// children through the same canonical path (order validation included). -pub(super) fn parse_query(listing: &[u8]) -> Result, DecodeErrorKind> { - let mut children = Vec::with_capacity(listing.len() / QUERY_CHILD_LEN); - for record in listing.chunks_exact(QUERY_CHILD_LEN) { - let (&radix, encoded_hash) = record - .split_first() - .expect("a query child record contains its radix"); - let mut hash = [0; MERKLE_HASH_LEN]; - hash.copy_from_slice(encoded_hash); - children.push((radix, Hash(hash))); - } - validate_children(&children)?; - Ok(children) +/// Type a listing-map violation into the frame error taxonomy. +pub(super) fn listing_issue(issue: ListingIssue) -> DecodeErrorKind { + match issue { + ListingIssue::Order(order) => DecodeErrorKind::QueryOutOfOrder(order), + ListingIssue::Head(_) => DecodeErrorKind::Malformed { + part: FramePart::QueryChildren, + detail: "listing head is not canonical", + }, + ListingIssue::Shape(detail) => DecodeErrorKind::Malformed { + part: FramePart::QueryChildren, + detail, + }, + ListingIssue::Truncated => DecodeErrorKind::Malformed { + part: FramePart::QueryChildren, + detail: "listing hash bytes are truncated", + }, + } +} + +/// Type a head-read failure by the frame part it interrupted. +pub(super) fn head_error(part: FramePart, error: HeadReadError) -> DecodeErrorKind { + match error { + HeadReadError::Io(source) => match source.kind() { + std::io::ErrorKind::UnexpectedEof => DecodeErrorKind::Truncated { + missing: part, + source, + }, + _ => DecodeErrorKind::Read { part, source }, + }, + HeadReadError::Malformed(head) => DecodeErrorKind::Malformed { + part, + detail: head_detail(head), + }, + } +} + +/// Name a deterministic-contract violation for the error taxonomy. +fn head_detail(error: cbor::HeadError) -> &'static str { + match error { + cbor::HeadError::Truncated => "truncated head", + cbor::HeadError::Indefinite => "indefinite-length head", + cbor::HeadError::Reserved => "reserved head", + cbor::HeadError::NotShortest => "head not in shortest form", + } +} + +pub(super) fn decode_signal(speaker: Speaker, code: u8) -> Result<(Stream, Signal), DecodeError> { + let wire = WireSignal::from_byte(speaker, code) + .map_err(|invalid| DecodeError::stream(speaker, invalid.stream(), invalid.into()))?; + Ok(wire.into_parts()) } #[cfg(test)] diff --git a/src/tree/mirror/streaming/remote/codec/decode/async_io.rs b/src/tree/mirror/streaming/remote/codec/decode/async_io.rs index a490a002..148c11e7 100644 --- a/src/tree/mirror/streaming/remote/codec/decode/async_io.rs +++ b/src/tree/mirror/streaming/remote/codec/decode/async_io.rs @@ -1,29 +1,32 @@ //! Exact asynchronous input for the self-delimiting frame grammar. -use std::slice; - use std::io::ErrorKind; use tokio::io::{AsyncRead, AsyncReadExt}; use super::super::{ budget::RunBudget, error::{DecodeError, DecodeErrorKind, FramePart}, - frame::{Frame, LeafRun, QUERY_CHILD_LEN, QUERY_COUNT_BIAS, Reaction, WireFrame}, - signal::{Signal, Speaker, Stream}, + frame::{Frame, LeafRun, ListingBuilder, Reaction, WireFrame}, + signal::{Signal, Speaker}, +}; +use super::{ + check_arity, decode_signal, frame_arity, head_error, listing_issue, query_listing, run_head, + signal_code, }; -use super::{decode_signal, lone_record_spans, parse_query}; use crate::tree::{ - mirror::framing::{LENGTH_HEADER_LEN, read_payload, resume_payload}, - typed::Hash, + mirror::cbor, + mirror::framing::{read_payload, resume_payload}, + typed::{Hash, hash::MERKLE_HASH_LEN}, }; /// Async frame reader over one speaker's transport direction. /// -/// EOF before a signal is a clean direction close and returns `None`. Once a -/// signal arrives, a missing component is a contextual truncation. Variable -/// bodies are read at their declared size and validated exactly once, with -/// supply bodies additionally held to the session's run budget before they -/// are buffered ([`DecodeErrorKind::OverbatchedRun`]). +/// EOF before a frame's array head is a clean direction close and returns +/// `None`. Once that head arrives, a missing component is a contextual +/// truncation. Variable bodies are read at their declared size and +/// validated exactly once, with supply bodies additionally held to the +/// session's run budget before they are buffered +/// ([`DecodeErrorKind::OverbatchedRun`]). pub struct FrameRead { speaker: Speaker, /// The session's negotiated run budget, enforced on every supply frame @@ -59,40 +62,36 @@ impl FrameRead { /// Not cancel safe. A dropped `frame` future may already have consumed /// part of a frame — the exact reads do not give bytes back — leaving /// the direction mid-frame, where the next call would parse body bytes - /// as a signal. Either retain the in-flight future across polls until - /// it resolves, or read nothing further from this direction after a - /// cancellation. + /// as a frame head. Either retain the in-flight future across polls + /// until it resolves, or read nothing further from this direction after + /// a cancellation. pub async fn frame(&mut self) -> Result, DecodeError> { - let Some((stream, signal)) = read_signal(self.speaker, &mut self.read).await? else { + let Some(head) = cbor::read_head_async(&mut self.read) + .await + .map_err(|e| head_error(FramePart::FrameHead, e)) + .map_err(|kind| DecodeError::direction(self.speaker, kind))? + else { return Ok(None); }; - let frame = AsyncFrameDecoder::new(&mut self.read, self.budget) - .body(signal) + let mut decoder = AsyncFrameDecoder::new(&mut self.read, self.budget); + let arity = frame_arity(head).map_err(|kind| DecodeError::direction(self.speaker, kind))?; + let signal_head = decoder + .head(FramePart::Signal) .await - .map_err(|kind| DecodeError::stream(self.speaker, stream, kind))?; + .map_err(|kind| DecodeError::direction(self.speaker, kind))?; + let code = + signal_code(signal_head).map_err(|kind| DecodeError::direction(self.speaker, kind))?; + let (stream, signal) = decode_signal(self.speaker, code)?; + let frame = async { + check_arity(signal, arity)?; + decoder.body(signal).await + } + .await + .map_err(|kind| DecodeError::stream(self.speaker, stream, kind))?; Ok(Some((stream, frame))) } } -async fn read_signal( - speaker: Speaker, - read: &mut (impl AsyncRead + Unpin), -) -> Result, DecodeError> { - let mut byte = 0; - match read.read(slice::from_mut(&mut byte)).await { - Ok(0) => Ok(None), - Ok(1) => decode_signal(speaker, byte).map(Some), - Ok(_) => unreachable!("a one-byte async read returns at most one byte"), - Err(source) => Err(DecodeError::direction( - speaker, - DecodeErrorKind::Read { - part: FramePart::Signal, - source, - }, - )), - } -} - /// Reads a body after its signal has established the frame grammar. struct AsyncFrameDecoder<'a, R> { read: &'a mut R, @@ -117,18 +116,25 @@ impl<'a, R: AsyncRead + Unpin> AsyncFrameDecoder<'a, R> { } async fn query(&mut self) -> Result, DecodeErrorKind> { - let count = usize::from(self.byte(FramePart::QueryCount).await?) + QUERY_COUNT_BIAS; - let mut listing = vec![0; count * QUERY_CHILD_LEN]; - self.read_exact(&mut listing, FramePart::QueryChildren) - .await?; - parse_query(&listing) + let head = self.head(FramePart::QueryChildren).await?; + let mut listing = query_listing(head)?; + for _ in 0..head.value { + let key = self.head(FramePart::QueryChildren).await?; + let radix = listing.key(key).map_err(listing_issue)?; + let value = self.head(FramePart::QueryChildren).await?; + ListingBuilder::value_head(value).map_err(listing_issue)?; + let mut digest = [0; MERKLE_HASH_LEN]; + self.read_exact(&mut digest, FramePart::QueryChildren) + .await?; + listing.entry(radix, digest); + } + Ok(listing.finish()) } async fn supply(&mut self) -> Result { - let mut header = [0; LENGTH_HEADER_LEN]; - self.read_exact(&mut header, FramePart::SupplyLength) - .await?; - let len = u32::from_be_bytes(header) as usize; + let tag = self.head(FramePart::SupplyLength).await?; + let body = self.head(FramePart::SupplyLength).await?; + let len = run_head(tag, body)?; let run = if self.budget.covers(len) { read_payload(self.read, len) .await @@ -138,39 +144,66 @@ impl<'a, R: AsyncRead + Unpin> AsyncFrameDecoder<'a, R> { // within, so the one shape an honest encoder can still have // produced is a single record spanning the whole body (the // minimum-one-record overhang). That is decidable from the - // first record's length header alone, so nothing beyond it is + // first record's heads alone, so nothing beyond them is // read until the frame is known legal: a violating frame is // rejected before its body is buffered, keeping the decode // inside the memory envelope the budget priced. A body too - // short to hold a record header cannot be a lone record and is - // rejected on the declared length alone. + // short to hold a record's heads cannot be a lone record and + // is rejected on the declared length alone. let budget = self.budget; let overbatched = move || DecodeErrorKind::OverbatchedRun { declared: super::super::budget::SUPPLY_FRAME_OVERHEAD.saturating_add(len), budget: budget.bytes(), }; - if len < LENGTH_HEADER_LEN { + if len < super::super::frame::RECORD_TAG_LEN + 1 { return Err(overbatched()); } - let mut first = [0; LENGTH_HEADER_LEN]; - self.read_exact(&mut first, FramePart::SupplyRun).await?; - let record = u32::from_be_bytes(first) as usize; - if !lone_record_spans(len, record) { + let Some((prefix, record)) = self.record_prefix().await? else { + return Err(overbatched()); + }; + if !super::super::frame::lone_record_spans(len, record) { return Err(overbatched()); } - // Legal lone record: resume the body read behind the header + // Legal lone record: resume the body read behind the heads // already consumed, in the same single buffer. - resume_payload(self.read, first.to_vec(), len) + resume_payload(self.read, prefix, len) .await .map_err(|source| classify(FramePart::SupplyRun, source))? }; Ok(LeafRun::from_encoded(run)?) } - async fn byte(&mut self, part: FramePart) -> Result { - let mut byte = 0; - self.read_exact(slice::from_mut(&mut byte), part).await?; - Ok(byte) + /// Read the first record's heads inside an over-budget run, returning + /// the exact bytes consumed and the record content length. + /// + /// `None` when they are not a record's heads: over budget, the + /// distinction from malformed is moot — either way the frame is not + /// the legal overhang. + async fn record_prefix(&mut self) -> Result, u64)>, DecodeErrorKind> { + let mut prefix = Vec::new(); + let tag = self.head(FramePart::SupplyRun).await?; + cbor::write_head(&mut prefix, tag.major, tag.value); + if tag.major != cbor::MAJOR_TAG || tag.value != cbor::TAG_CBOR_SEQUENCE { + return Ok(None); + } + let body = self.head(FramePart::SupplyRun).await?; + cbor::write_head(&mut prefix, body.major, body.value); + if body.major != cbor::MAJOR_BSTR { + return Ok(None); + } + Ok(Some((prefix, body.value))) + } + + async fn head(&mut self, part: FramePart) -> Result { + cbor::read_head_async(self.read) + .await + .map_err(|e| head_error(part, e))? + .ok_or_else(|| { + head_error( + part, + cbor::HeadReadError::Io(ErrorKind::UnexpectedEof.into()), + ) + }) } async fn read_exact( diff --git a/src/tree/mirror/streaming/remote/codec/decode/tests.rs b/src/tree/mirror/streaming/remote/codec/decode/tests.rs index e5df2a9a..121e76b3 100644 --- a/src/tree/mirror/streaming/remote/codec/decode/tests.rs +++ b/src/tree/mirror/streaming/remote/codec/decode/tests.rs @@ -4,19 +4,17 @@ use super::*; use crate::Version; use crate::message::Message; use crate::tree::arb::arb_version; +use crate::tree::mirror::cbor::{MAJOR_BSTR, MAJOR_MAP, MAJOR_TAG, TAG_CBOR_SEQUENCE}; +use crate::tree::typed::{Hash, hash::MERKLE_HASH_LEN}; use super::super::{ error::{DecodeLeafError, Origin, QueryOrderError}, - frame::{QUERY_COUNT_BIAS, QUERY_COUNT_LEN}, + frame::{LeafRunError, RECORD_TAG_LEN}, signal::{DecodeSignalError, End, Flow, Speaker, Stream, StreamError}, }; const SPEAKERS: [Speaker; 2] = [Speaker::Initiator, Speaker::Responder]; -/// A CBOR byte-string header promising two version bytes, cut short after -/// one: the version field ends inside its own framing. -const TRUNCATED_VERSION: &[u8] = &[0x42, 0x01]; - fn stream(index: u8) -> Stream { Stream::new(index).unwrap() } @@ -27,24 +25,77 @@ fn signal(stream: Stream, signal: Signal) -> u8 { .to_byte() } +/// The frame head of a `arity`-item frame carrying `code`: the array head +/// then the signal's unsigned-int head. +fn frame_head(arity: u64, code: u8) -> Vec { + let mut head = Vec::new(); + cbor::write_head(&mut head, cbor::MAJOR_ARRAY, arity); + cbor::write_head(&mut head, MAJOR_UINT, u64::from(code)); + head +} + +/// A whole body-free frame. +fn bare_frame(stream: Stream, s: Signal) -> Vec { + frame_head(1, signal(stream, s)) +} + +/// A whole supply frame declaring `body.len()` run bytes and carrying +/// `body`. fn supply(stream: Stream, flow: Flow, body: &[u8]) -> Vec { - let mut encoded = vec![signal(stream, Signal::Supply(flow))]; - encoded.extend_from_slice(&(body.len() as u32).to_be_bytes()); + supply_declaring(stream, flow, body.len(), body) +} + +/// A supply frame declaring `declared` run bytes while carrying `body`. +fn supply_declaring(stream: Stream, flow: Flow, declared: usize, body: &[u8]) -> Vec { + let mut encoded = frame_head(2, signal(stream, Signal::Supply(flow))); + cbor::write_head(&mut encoded, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut encoded, MAJOR_BSTR, declared as u64); encoded.extend_from_slice(body); encoded } -/// One length-prefixed leaf record as it appears inside a run body: the -/// version as one CBOR value, then the payload's CBOR bytes bare. +/// A whole query frame carrying `children` as its listing map, written +/// raw (no canonical-order validation) so tests can synthesize +/// violations. +fn query(stream: Stream, flow: Flow, children: &[(u8, Hash)]) -> Vec { + let mut encoded = frame_head(2, signal(stream, Signal::Query(flow))); + super::super::frame::write_listing(&mut encoded, children); + encoded +} + +/// One leaf record as it appears inside a run body: the embedded-sequence +/// tag and byte-string head, then the tagged version atom, then the +/// payload's CBOR bytes bare. fn record(version: &Version, message: &Message) -> Vec { - let mut body = Vec::new(); - ciborium::ser::into_writer(version, &mut body).unwrap(); - body.extend_from_slice(message.as_slice()); - let mut record = (body.len() as u32).to_be_bytes().to_vec(); - record.extend_from_slice(&body); + let mut content = Vec::new(); + cbor::write_head(&mut content, MAJOR_TAG, crate::tags::VERSION_TAG); + ciborium::ser::into_writer(version, &mut content).unwrap(); + content.extend_from_slice(message.as_slice()); + let mut record = Vec::new(); + cbor::write_head(&mut record, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut record, MAJOR_BSTR, content.len() as u64); + record.extend_from_slice(&content); + record +} + +/// A record item wrapping raw content bytes, for malformed-content cases. +fn raw_record(content: &[u8]) -> Vec { + let mut record = Vec::new(); + cbor::write_head(&mut record, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut record, MAJOR_BSTR, content.len() as u64); + record.extend_from_slice(content); record } +/// A record's content for `version` and `message`, without its item heads. +fn record_content(version: &Version, message: &Message) -> Vec { + let mut content = Vec::new(); + cbor::write_head(&mut content, MAJOR_TAG, crate::tags::VERSION_TAG); + ciborium::ser::into_writer(version, &mut content).unwrap(); + content.extend_from_slice(message.as_slice()); + content +} + fn arb_speaker() -> impl Strategy { prop_oneof![Just(Speaker::Initiator), Just(Speaker::Responder)] } @@ -53,7 +104,9 @@ fn arb_flow() -> impl Strategy { prop_oneof![Just(Flow::Continue), Just(Flow::End)] } -/// Reserved signal states retain the stream encoded alongside them. +/// Reserved signal codes within the byte range retain the stream encoded +/// alongside them; codes past the byte range and non-int signal items are +/// malformed signals. #[test] fn invalid_signals_are_rejected() { assert_eq!( @@ -68,7 +121,8 @@ fn invalid_signals_are_rejected() { let DecodeSignalError::Reserved(reserved) = invalid else { panic!("unexpected signal error") }; - let error = decode_exact(speaker, RunBudget::default(), &[byte]).unwrap_err(); + let encoded = frame_head(1, byte); + let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); assert_eq!(error.origin, Origin::stream(speaker, reserved.stream())); let DecodeErrorKind::InvalidSignal(DecodeSignalError::Reserved(source)) = error.kind else { @@ -80,6 +134,89 @@ fn invalid_signals_are_rejected() { assert!(std::error::Error::source(&source).is_some()); } } + // Past the byte range, and a non-int item where the signal belongs. + for speaker in SPEAKERS { + let mut encoded = Vec::new(); + cbor::write_head(&mut encoded, cbor::MAJOR_ARRAY, 1); + cbor::write_head(&mut encoded, MAJOR_UINT, 256); + let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); + assert_eq!(error.origin, Origin::direction(speaker)); + assert!(matches!( + error.kind, + DecodeErrorKind::Malformed { + part: FramePart::Signal, + .. + } + )); + + let mut encoded = Vec::new(); + cbor::write_head(&mut encoded, cbor::MAJOR_ARRAY, 1); + cbor::write_head(&mut encoded, MAJOR_BSTR, 0); + let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); + assert!(matches!( + error.kind, + DecodeErrorKind::Malformed { + part: FramePart::Signal, + .. + } + )); + } +} + +/// A frame item that is not a one- or two-element array, or whose array +/// length contradicts its signal's body arity, is rejected typed. +#[test] +fn frame_shape_is_enforced() { + let stream = stream(4); + for speaker in SPEAKERS { + // Not an array at all. + let error = decode_exact(speaker, RunBudget::default(), &[0x00]).unwrap_err(); + assert!(matches!(error.kind, DecodeErrorKind::FrameShape { .. })); + // A three-item array. + let error = decode_exact(speaker, RunBudget::default(), &[0x83]).unwrap_err(); + assert!(matches!(error.kind, DecodeErrorKind::FrameShape { .. })); + // A body-free signal inside a two-item array. + let encoded = frame_head(2, signal(stream, Signal::Match(Flow::Continue))); + let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); + assert_eq!(error.origin, Origin::stream(speaker, stream)); + assert!(matches!( + error.kind, + DecodeErrorKind::FrameArity { + expected: 1, + found: 2 + } + )); + // A body-bearing signal inside a one-item array. + let encoded = frame_head(1, signal(stream, Signal::Query(Flow::Continue))); + let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); + assert!(matches!( + error.kind, + DecodeErrorKind::FrameArity { + expected: 2, + found: 1 + } + )); + } +} + +/// A widened (non-shortest-form) signal head is rejected: the wire admits +/// one spelling per value. +#[test] +fn widened_signal_heads_are_rejected() { + let stream = stream(3); + let code = signal(stream, Signal::Match(Flow::Continue)); + for speaker in SPEAKERS { + // The code spelled with a needlessly wide argument. + let encoded = [0x81, 0x19, 0x00, code]; + let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); + assert!(matches!( + error.kind, + DecodeErrorKind::Malformed { + part: FramePart::Signal, + .. + } + )); + } } /// Truncation identifies both the absent component and its known origin. @@ -87,27 +224,33 @@ fn invalid_signals_are_rejected() { fn truncated_bodies_are_rejected() { let stream = stream(4); for speaker in SPEAKERS { + let query_head = frame_head(2, signal(stream, Signal::Query(Flow::Continue))); + let mut half_listing = query_head.clone(); + cbor::write_head(&mut half_listing, MAJOR_MAP, 1); + let supply_head = frame_head(2, signal(stream, Signal::Supply(Flow::Continue))); let cases = [ - (Vec::new(), FramePart::Signal, Origin::direction(speaker)), + (Vec::new(), FramePart::FrameHead, Origin::direction(speaker)), + (vec![0x81], FramePart::Signal, Origin::direction(speaker)), ( - vec![signal(stream, Signal::Query(Flow::Continue))], - FramePart::QueryCount, + query_head, + FramePart::QueryChildren, Origin::stream(speaker, stream), ), ( - vec![signal(stream, Signal::Query(Flow::Continue)), u8::MIN], + half_listing, FramePart::QueryChildren, Origin::stream(speaker, stream), ), ( - vec![signal(stream, Signal::Supply(Flow::Continue))], + supply_head.clone(), FramePart::SupplyLength, Origin::stream(speaker, stream), ), ( { - let mut frame = vec![signal(stream, Signal::Supply(Flow::Continue))]; - frame.extend_from_slice(&1_u32.to_be_bytes()); + let mut frame = supply_head; + cbor::write_head(&mut frame, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut frame, MAJOR_BSTR, 4); frame }, FramePart::SupplyRun, @@ -116,13 +259,13 @@ fn truncated_bodies_are_rejected() { ]; for (encoded, missing, origin) in cases { let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); - assert_eq!(error.origin, origin); + assert_eq!(error.origin, origin, "case {missing:?}"); let DecodeErrorKind::Truncated { missing: actual, source, } = error.kind else { - panic!("unexpected error kind"); + panic!("unexpected error kind for {missing:?}: {:?}", error.kind); }; assert_eq!(actual, missing); assert_eq!(source.kind(), std::io::ErrorKind::UnexpectedEof); @@ -155,13 +298,11 @@ proptest! { } } -/// Structurally invalid runs are rejected at the wire with their exact cause: -/// an empty run, a record header past the run's end, or a record body past -/// the run's end. +/// Structurally invalid runs are rejected at the wire with their exact +/// cause: an empty run, bytes that are no record item, or a record's +/// content past the run's end. #[test] fn malformed_run_structure_is_typed() { - use super::super::frame::LeafRunError; - let stream = stream(8); for speaker in SPEAKERS { let empty = decode_exact( @@ -176,20 +317,21 @@ fn malformed_run_structure_is_typed() { DecodeErrorKind::InvalidRun(LeafRunError::Empty) )); - let short_header = decode_exact( + // Bytes where a record item belongs that are not one. + let not_a_record = decode_exact( speaker, RunBudget::default(), - &supply(stream, Flow::Continue, &[0, 0]), + &supply(stream, Flow::Continue, &[0x00, 0x00]), ) .unwrap_err(); - assert_eq!(short_header.origin, Origin::stream(speaker, stream)); + assert_eq!(not_a_record.origin, Origin::stream(speaker, stream)); assert!(matches!( - short_header.kind, - DecodeErrorKind::InvalidRun(LeafRunError::TruncatedHeader { remaining: 2 }) + not_a_record.kind, + DecodeErrorKind::InvalidRun(LeafRunError::NotARecord { remaining: 2, .. }) )); - let mut overrun = 2_u32.to_be_bytes().to_vec(); - overrun.push(0); + // A record declaring more content than the run holds. + let overrun = raw_record(&[0, 0])[..RECORD_TAG_LEN + 2].to_vec(); let short_record = decode_exact( speaker, RunBudget::default(), @@ -207,16 +349,16 @@ fn malformed_run_structure_is_typed() { } } -/// A zero-length record header inside a run body is structurally valid. +/// An empty-content record inside a run body is structurally valid. /// -/// From raw wire bytes, a run body of one bare `00000000` header chains -/// exactly, so the codec accepts the frame and defers the record's failure -/// to its record iterator: the empty body cannot hold a version, and the -/// iterator reports the version decoder's `UnexpectedEof`. +/// From raw wire bytes, a run body of one record whose byte string is +/// empty chains exactly, so the codec accepts the frame and defers the +/// record's failure to its record iterator: the empty content cannot hold +/// a tagged version, and the iterator reports the version decode failure. #[test] fn a_zero_length_record_is_structurally_valid() { let stream = stream(8); - let encoded = supply(stream, Flow::End, &[0, 0, 0, 0]); + let encoded = supply(stream, Flow::End, &raw_record(&[])); for speaker in SPEAKERS { let (decoded_stream, frame) = decode_exact(speaker, RunBudget::default(), &encoded).unwrap(); @@ -241,9 +383,11 @@ fn a_zero_length_record_is_structurally_valid() { /// which types each failure and retains the source error. #[test] fn supplied_record_errors_are_typed() { - let mut truncated_version = (TRUNCATED_VERSION.len() as u32).to_be_bytes().to_vec(); - truncated_version.extend_from_slice(TRUNCATED_VERSION); - let run = LeafRun::from_encoded(truncated_version).unwrap(); + // A version byte string promising two bytes, cut short after one. + let mut content = Vec::new(); + cbor::write_head(&mut content, MAJOR_TAG, crate::tags::VERSION_TAG); + content.extend_from_slice(&[0x42, 0x01]); + let run = LeafRun::from_encoded(raw_record(&content)).unwrap(); let error = run .records(Message::deserializer::()) .next() @@ -254,11 +398,21 @@ fn supplied_record_errors_are_typed() { }; assert_eq!(source.kind(), std::io::ErrorKind::UnexpectedEof); - let mut version = Vec::new(); - ciborium::ser::into_writer(&Version::new(), &mut version).unwrap(); - let mut missing_message = (version.len() as u32).to_be_bytes().to_vec(); - missing_message.extend_from_slice(&version); - let run = LeafRun::from_encoded(missing_message).unwrap(); + // An untagged version where the tagged atom belongs. + let mut content = Vec::new(); + ciborium::ser::into_writer(&Version::new(), &mut content).unwrap(); + let run = LeafRun::from_encoded(raw_record(&content)).unwrap(); + let error = run + .records(Message::deserializer::()) + .next() + .unwrap() + .unwrap_err(); + assert!(matches!(error, DecodeLeafError::Version(_))); + + // A tagged version with no message behind it. + let content = record_content(&Version::new(), &Message::new(0u64)); + let missing_message = &content[..content.len() - Message::new(0u64).as_slice().len()]; + let run = LeafRun::from_encoded(raw_record(missing_message)).unwrap(); let error = run .records(Message::deserializer::()) .next() @@ -272,11 +426,9 @@ fn supplied_record_errors_are_typed() { // Bytes past the canonical pair make the payload malformed: the // payload runs to the record's end, so the deserializer's // exactly-one-value check is what rejects the excess. - ciborium::ser::into_writer(&0_u64, &mut version).unwrap(); - version.push(u8::MIN); - let mut trailing = (version.len() as u32).to_be_bytes().to_vec(); - trailing.extend_from_slice(&version); - let run = LeafRun::from_encoded(trailing).unwrap(); + let mut content = record_content(&Version::new(), &Message::new(0u64)); + content.push(u8::MIN); + let run = LeafRun::from_encoded(raw_record(&content)).unwrap(); let error = run .records(Message::deserializer::()) .next() @@ -300,16 +452,7 @@ proptest! { prop_assume!(previous >= radix); let stream = stream(index); let children = vec![(previous, Hash::default()), (radix, Hash::default())]; - let encoded_count = u8::try_from(children.len() - QUERY_COUNT_BIAS).unwrap(); - let mut encoded = Vec::with_capacity(WireSignal::ENCODED_LEN + QUERY_COUNT_LEN); - encoded.extend_from_slice(&[ - signal(stream, Signal::Query(Flow::Continue)), - encoded_count, - ]); - for (radix, hash) in &children { - encoded.push(*radix); - encoded.extend_from_slice(hash.as_bytes()); - } + let encoded = query(stream, Flow::Continue, &children); let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); prop_assert_eq!(error.origin, Origin::stream(speaker, stream)); let correct = matches!( @@ -321,23 +464,61 @@ proptest! { ); prop_assert!(correct); } + + /// An arbitrary canonical query round-trips through the decoder. + #[test] + fn canonical_queries_decode( + index in 1_u8..Stream::MAX, + speaker in arb_speaker(), + flow in arb_flow(), + radixes in proptest::collection::btree_set(any::(), 1..=32), + ) { + let stream = stream(index); + let children: Vec<(u8, Hash)> = radixes + .iter() + .map(|&radix| (radix, Hash([radix; MERKLE_HASH_LEN]))) + .collect(); + let encoded = query(stream, flow, &children); + prop_assert_eq!( + decode_exact(speaker, RunBudget::default(), &encoded).unwrap(), + (stream, Frame::Reaction(Reaction::Query(children), flow)) + ); + } +} + +/// A query body whose listing map is empty is rejected: an empty query +/// travels as its own signal, so the map spelling admits one to 256 +/// children only. +#[test] +fn empty_query_listing_is_rejected() { + let stream = stream(5); + let encoded = query(stream, Flow::Continue, &[]); + for speaker in SPEAKERS { + let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); + assert!(matches!( + error.kind, + DecodeErrorKind::Malformed { + part: FramePart::QueryChildren, + .. + } + )); + } } /// Exact decoding rejects a trailing frame while incremental decoding preserves it. #[test] fn exact_decode_rejects_trailing_frame() { let stream = stream(10); - let first = signal(stream, Signal::Match(Flow::Continue)); - let second = signal(stream, Signal::End(End::Reply)); - let encoded = [first, second]; + let first = bare_frame(stream, Signal::Match(Flow::Continue)); + let second = bare_frame(stream, Signal::End(End::Reply)); + let mut encoded = first.clone(); + encoded.extend_from_slice(&second); for speaker in SPEAKERS { let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err(); assert_eq!(error.origin, Origin::stream(speaker, stream)); assert!(matches!( error.kind, - DecodeErrorKind::TrailingBytes { - count: WireSignal::ENCODED_LEN - } + DecodeErrorKind::TrailingBytes { count } if count == second.len() )); let mut rest = encoded.as_slice(); @@ -346,12 +527,12 @@ fn exact_decode_rejects_trailing_frame() { frame, (stream, Frame::Reaction(Reaction::Match, Flow::Continue)) ); - assert_eq!(rest, &[second]); + assert_eq!(rest, second.as_slice()); } } -/// Async EOF is clean only before a signal; every partial body reports the -/// same missing part and stream context as synchronous decoding. +/// Async EOF is clean only before a frame head; every partial body reports +/// the same missing part and stream context as synchronous decoding. #[test] fn async_eof_distinguishes_close_from_truncation() { let stream = stream(4); @@ -359,23 +540,26 @@ fn async_eof_distinguishes_close_from_truncation() { let mut closed = FrameRead::new(speaker, RunBudget::default(), &[][..]); assert_eq!(pollster::block_on(closed.frame()).unwrap(), None); + let supply_head = frame_head(2, signal(stream, Signal::Supply(Flow::Continue))); let cases = [ ( - vec![signal(stream, Signal::Query(Flow::Continue))], - FramePart::QueryCount, - ), - ( - vec![signal(stream, Signal::Query(Flow::Continue)), u8::MIN], + frame_head(2, signal(stream, Signal::Query(Flow::Continue))), FramePart::QueryChildren, ), ( - vec![signal(stream, Signal::Supply(Flow::Continue))], - FramePart::SupplyLength, + { + let mut frame = frame_head(2, signal(stream, Signal::Query(Flow::Continue))); + cbor::write_head(&mut frame, MAJOR_MAP, 1); + frame + }, + FramePart::QueryChildren, ), + (supply_head.clone(), FramePart::SupplyLength), ( { - let mut frame = vec![signal(stream, Signal::Supply(Flow::Continue))]; - frame.extend_from_slice(&1_u32.to_be_bytes()); + let mut frame = supply_head; + cbor::write_head(&mut frame, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut frame, MAJOR_BSTR, 4); frame }, FramePart::SupplyRun, @@ -396,8 +580,8 @@ fn async_eof_distinguishes_close_from_truncation() { } } -/// An invalid async signal consumes only itself, leaving the following valid -/// frame at the next exact boundary. +/// An invalid async signal consumes only its own frame, leaving the +/// following valid frame at the next exact boundary. #[test] fn async_invalid_signal_does_not_consume_a_body() { for speaker in SPEAKERS { @@ -423,7 +607,8 @@ fn async_invalid_signal_does_not_consume_a_body() { let valid = WireSignal::new(speaker, stream, valid_signal) .unwrap() .to_byte(); - let bytes = [invalid, valid]; + let mut bytes = frame_head(1, invalid); + bytes.extend_from_slice(&frame_head(1, valid)); let mut reader = FrameRead::new(speaker, RunBudget::default(), bytes.as_slice()); let error = pollster::block_on(reader.frame()).unwrap_err(); @@ -456,7 +641,7 @@ fn reader_errors_are_contextual() { assert!(matches!( error.kind, DecodeErrorKind::Read { - part: FramePart::Signal, + part: FramePart::FrameHead, source, } if source.kind() == std::io::ErrorKind::Other )); @@ -477,9 +662,8 @@ fn supply_truncation_at_chunk_boundaries_is_typed() { let stream = stream(6); for speaker in SPEAKERS { for delivered in chunk_boundary_cuts(declared) { - let mut encoded = vec![signal(stream, Signal::Supply(Flow::Continue))]; - encoded.extend_from_slice(&u32::try_from(declared).unwrap().to_be_bytes()); - encoded.extend(vec![0xA5; delivered]); + let body = vec![0xA5; delivered]; + let encoded = supply_declaring(stream, Flow::Continue, declared, &body); let mut reader = FrameRead::new(speaker, RunBudget::default(), encoded.as_slice()); let error = pollster::block_on(reader.frame()).unwrap_err(); assert_eq!(error.origin, Origin::stream(speaker, stream)); @@ -497,7 +681,9 @@ fn supply_truncation_at_chunk_boundaries_is_typed() { } } -/// The full wire size of a supply frame carrying `body` run bytes. +/// The charged wire size of a supply frame carrying `body` run bytes: the +/// budget envelope constant plus the body, the exact quantity `covers` +/// prices and `OverbatchedRun` reports. fn frame_wire_size(body: &[u8]) -> usize { super::super::SUPPLY_FRAME_OVERHEAD + body.len() } @@ -548,13 +734,13 @@ proptest! { /// Ingress enforces the run budget as the exact complement of the /// encoder's flush rule, deciding before any body byte is read. /// - /// A multi-record supply frame decodes when its full wire size is + /// A multi-record supply frame decodes when its charged wire size is /// within the budget and fails typed as `OverbatchedRun` — carrying /// that wire size and the budget — when it is past it. The rejection /// is decided ahead of the body: a stream ending right after the - /// first record's length header still classifies as the budget - /// violation, never as a truncation. Both decoders (the async reader - /// and the sync oracle) agree throughout. + /// first record's heads still classifies as the budget violation, + /// never as a truncation. Both decoders (the async reader and the + /// sync oracle) agree throughout. #[test] fn multi_record_frames_are_held_to_the_run_budget( index in 1_u8..Stream::MAX, @@ -566,8 +752,19 @@ proptest! { ) { let stream = stream(index); let mut body = Vec::new(); - for (version, value) in &records { - body.extend_from_slice(&record(version, &Message::new(*value))); + let mut first_record_heads = 0; + for (at, (version, value)) in records.iter().enumerate() { + let record = record(version, &Message::new(*value)); + if at == 0 { + let content = { + let mut input = record.as_slice(); + super::super::frame::record_head(&mut input) + .expect("a built record has record heads"); + record.len() - input.len() + }; + first_record_heads = content; + } + body.extend_from_slice(&record); } let encoded = supply(stream, flow, &body); let wire_size = frame_wire_size(&body); @@ -594,11 +791,11 @@ proptest! { ); prop_assert!(typed, "mistyped over-budget batching: {:?}", error.kind); - // Before the body read: the same rejection from only the signal, - // the run length header, and the first record's length header — no - // body byte exists to read, so a decoder that buffered the body - // first would classify this as a truncation instead. - let prefix = &encoded[..1 + LENGTH_HEADER_LEN + LENGTH_HEADER_LEN]; + // Before the body read: the same rejection from only the frame + // head, the run head, and the first record's heads — no body byte + // exists to read, so a decoder that buffered the body first would + // classify this as a truncation instead. + let prefix = &encoded[..encoded.len() - body.len() + first_record_heads]; let error = decode_both(speaker, over, prefix).expect_err( "undetected over-budget batching: the violation must be decided \ ahead of the body", @@ -645,23 +842,22 @@ proptest! { /// Corner classifications of the run-budget ingress check, under a zero /// budget so every frame overhangs. /// -/// An over-budget body too short to hold a record header is the +/// An over-budget body too short to hold a record's heads is the /// violation, decided on the declared length alone (no body byte follows, -/// yet the error is not a truncation); a first record header that falls -/// short of the body or overruns it is the violation; a stream ending -/// inside the first record header, or inside an admitted lone record's -/// body, is a truncated supply run. +/// yet the error is not a truncation); a first record that falls short of +/// the body or overruns it is the violation; a stream ending inside the +/// first record's heads, or inside an admitted lone record's body, is a +/// truncated supply run. #[test] fn overbatched_corners_classify_exactly() { let stream = stream(9); let zero = RunBudget::from_bytes(0); for speaker in SPEAKERS { - // Declared bodies too short for a record header, none delivered. - for declared in 0..LENGTH_HEADER_LEN { - let mut encoded = vec![signal(stream, Signal::Supply(Flow::End))]; - encoded.extend_from_slice(&(declared as u32).to_be_bytes()); + // Declared bodies too short for a record's heads, none delivered. + for declared in 0..RECORD_TAG_LEN + 1 { + let encoded = supply_declaring(stream, Flow::End, declared, &[]); let error = decode_both(speaker, zero, &encoded) - .expect_err("a headerless over-budget body cannot decode"); + .expect_err("a headless over-budget body cannot decode"); assert!( matches!(error.kind, DecodeErrorKind::OverbatchedRun { .. }), "declared {declared}: {:?}", @@ -669,13 +865,16 @@ fn overbatched_corners_classify_exactly() { ); } - // A first record header falling short of the body (two records' - // shapes) and one overrunning it: both are the violation. - let two_records = [record(&Version::new(), &Message::new(1))] - .concat() - .repeat(2); - let mut overrun = (200_u32).to_be_bytes().to_vec(); - overrun.extend_from_slice(&[0; 8]); + // A first record falling short of the body (two records' shapes) + // and one overrunning it: both are the violation. + let two_records = record(&Version::new(), &Message::new(1)).repeat(2); + let overrun = { + let mut record = Vec::new(); + cbor::write_head(&mut record, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut record, MAJOR_BSTR, 200); + record.extend_from_slice(&[0; 8]); + record + }; for body in [two_records, overrun] { let error = decode_both(speaker, zero, &supply(stream, Flow::End, &body)) .expect_err("a non-spanning first record cannot decode over budget"); @@ -686,12 +885,12 @@ fn overbatched_corners_classify_exactly() { ); } - // Ends inside the first record header, and inside an admitted lone + // Ends inside the first record's heads, and inside an admitted lone // record's body: truncations of the supply run, not violations. let lone = record(&Version::new(), &Message::new(1)); let encoded = supply(stream, Flow::End, &lone); - let header_end = 1 + LENGTH_HEADER_LEN; - for cut in [header_end + 2, encoded.len() - 1] { + let heads_end = encoded.len() - lone.len() + 1; + for cut in [heads_end, encoded.len() - 1] { let error = decode_both(speaker, zero, &encoded[..cut]) .expect_err("a truncated frame cannot decode"); assert!( diff --git a/src/tree/mirror/streaming/remote/codec/encode.rs b/src/tree/mirror/streaming/remote/codec/encode.rs index f62bf732..2f361ffa 100644 --- a/src/tree/mirror/streaming/remote/codec/encode.rs +++ b/src/tree/mirror/streaming/remote/codec/encode.rs @@ -3,10 +3,7 @@ #[cfg(test)] use std::io::Write; -use crate::tree::{ - mirror::framing::{LENGTH_HEADER_LEN, length_header}, - typed::Hash, -}; +use crate::tree::mirror::cbor::{self, MAJOR_ARRAY, MAJOR_BSTR, MAJOR_UINT, TAG_CBOR_SEQUENCE}; mod async_io; @@ -14,7 +11,7 @@ pub use async_io::FrameWrite; use super::{ error::EncodeErrorKind, - frame::{Frame, LeafRun, QUERY_COUNT_BIAS, Reaction}, + frame::{Frame, LeafRun, Reaction, write_listing}, signal::{Signal, Stream, WireSignal}, }; #[cfg(test)] @@ -42,20 +39,22 @@ pub fn encode( /// The encoder is not a trust boundary: phase placement, query ordering, and /// run record framing are guaranteed by its callers and checked only when /// bytes enter from the wire. Construction performs only the -/// representational checks needed before any byte can be emitted. +/// representational checks needed before any byte can be emitted, and +/// renders every head — so the write paths move bytes without measuring +/// anything. struct FrameEncoding<'a> { - signal: [u8; WireSignal::ENCODED_LEN], + /// The frame's array head and signal head. + head: Vec, body: BodyEncoding<'a>, } enum BodyEncoding<'a> { Empty, - Query { - count: [u8; 1], - children: &'a [(u8, Hash)], - }, + /// A nonempty query's child-listing map, fully rendered. + Listing(Vec), + /// A supply run behind its rendered embedded-sequence head. Supply { - header: [u8; LENGTH_HEADER_LEN], + head: Vec, run: &'a LeafRun, }, } @@ -68,40 +67,43 @@ impl<'a> FrameEncoding<'a> { (Signal::QueryEmpty(*flow), BodyEncoding::Empty) } Frame::Reaction(Reaction::Query(children), flow) => { - let count = u8::try_from(children.len() - QUERY_COUNT_BIAS) - .expect("a protocol query never exceeds the radix fan"); - ( - Signal::Query(*flow), - BodyEncoding::Query { - count: [count], - children, - }, - ) + let mut listing = Vec::new(); + write_listing(&mut listing, children); + (Signal::Query(*flow), BodyEncoding::Listing(listing)) } Frame::Reaction(Reaction::Supply(run), flow) => { - let header = length_header(run.encoded_len())?; - (Signal::Supply(*flow), BodyEncoding::Supply { header, run }) + let len = super::frame::checked_run_len(run.encoded_len())?; + let mut head = Vec::new(); + cbor::write_tag(&mut head, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut head, MAJOR_BSTR, len); + (Signal::Supply(*flow), BodyEncoding::Supply { head, run }) } Frame::End(end) => (Signal::End(*end), BodyEncoding::Empty), }; - let signal = [WireSignal::encode(stream, signal)]; - Ok(Self { signal, body }) + let arity = match &body { + BodyEncoding::Empty => 1, + BodyEncoding::Listing(_) | BodyEncoding::Supply { .. } => 2, + }; + let mut head = Vec::new(); + cbor::write_head(&mut head, MAJOR_ARRAY, arity); + cbor::write_head( + &mut head, + MAJOR_UINT, + u64::from(WireSignal::encode(stream, signal)), + ); + Ok(Self { head, body }) } #[cfg(test)] fn write(&self, out: &mut impl Write) -> Result<(), EncodeErrorKind> { - write(out, FramePart::Signal, &self.signal)?; + write(out, FramePart::FrameHead, &self.head)?; match &self.body { BodyEncoding::Empty => {} - BodyEncoding::Query { count, children } => { - write(out, FramePart::QueryCount, count)?; - for (radix, hash) in *children { - write(out, FramePart::QueryChildren, std::slice::from_ref(radix))?; - write(out, FramePart::QueryChildren, hash.as_bytes())?; - } + BodyEncoding::Listing(listing) => { + write(out, FramePart::QueryChildren, listing)?; } - BodyEncoding::Supply { header, run } => { - write(out, FramePart::SupplyLength, header)?; + BodyEncoding::Supply { head, run } => { + write(out, FramePart::SupplyLength, head)?; write(out, FramePart::SupplyRun, run.as_bytes())?; } } diff --git a/src/tree/mirror/streaming/remote/codec/encode/async_io.rs b/src/tree/mirror/streaming/remote/codec/encode/async_io.rs index fa834f2b..9283dc9d 100644 --- a/src/tree/mirror/streaming/remote/codec/encode/async_io.rs +++ b/src/tree/mirror/streaming/remote/codec/encode/async_io.rs @@ -59,18 +59,14 @@ async fn write_encoding( out: &mut (impl AsyncWrite + Unpin), encoding: &FrameEncoding<'_>, ) -> Result<(), EncodeErrorKind> { - write(out, FramePart::Signal, &encoding.signal).await?; + write(out, FramePart::FrameHead, &encoding.head).await?; match &encoding.body { BodyEncoding::Empty => {} - BodyEncoding::Query { count, children } => { - write(out, FramePart::QueryCount, count).await?; - for (radix, hash) in *children { - write(out, FramePart::QueryChildren, std::slice::from_ref(radix)).await?; - write(out, FramePart::QueryChildren, hash.as_bytes()).await?; - } + BodyEncoding::Listing(listing) => { + write(out, FramePart::QueryChildren, listing).await?; } - BodyEncoding::Supply { header, run } => { - write(out, FramePart::SupplyLength, header).await?; + BodyEncoding::Supply { head, run } => { + write(out, FramePart::SupplyLength, head).await?; write(out, FramePart::SupplyRun, run.as_bytes()).await?; } } diff --git a/src/tree/mirror/streaming/remote/codec/encode/tests.rs b/src/tree/mirror/streaming/remote/codec/encode/tests.rs index 4be2237e..0f20b744 100644 --- a/src/tree/mirror/streaming/remote/codec/encode/tests.rs +++ b/src/tree/mirror/streaming/remote/codec/encode/tests.rs @@ -16,9 +16,10 @@ use crate::{ use super::super::{ error::Origin, - frame::{MAX_QUERY_CHILDREN, QUERY_CHILD_LEN, QUERY_COUNT_BIAS, QUERY_COUNT_LEN}, + frame::{MAX_QUERY_CHILDREN, listing_len}, signal::{End, Flow, Speaker, Stream}, }; +use crate::tree::mirror::cbor::{MAJOR_TAG, MAJOR_UINT, TAG_CBOR_SEQUENCE}; const SPEAKERS: [Speaker; 2] = [Speaker::Initiator, Speaker::Responder]; const FLOWS: [Flow; 2] = [Flow::Continue, Flow::End]; @@ -33,6 +34,14 @@ fn signal(stream: Stream, signal: Signal) -> u8 { .to_byte() } +/// The frame head of a `arity`-item frame carrying `code`. +fn frame_head(arity: u64, code: u8) -> Vec { + let mut head = Vec::new(); + cbor::write_head(&mut head, MAJOR_ARRAY, arity); + cbor::write_head(&mut head, MAJOR_UINT, u64::from(code)); + head +} + fn arb_speaker() -> impl Strategy { prop_oneof![Just(Speaker::Initiator), Just(Speaker::Responder)] } @@ -41,7 +50,8 @@ fn arb_flow() -> impl Strategy { prop_oneof![Just(Flow::Continue), Just(Flow::End)] } -/// Every query fan and flow state has one canonical count representation. +/// Every query fan and flow state has one canonical map representation, +/// its length priced exactly by the listing closed form. #[test] fn query_count_covers_every_fan_and_flow() { let stream = stream(7); @@ -61,23 +71,24 @@ fn query_count_covers_every_fan_and_flow() { let mut encoded = Vec::new(); encode(speaker, &frame, &mut encoded).unwrap(); if count == 0 { - assert_eq!(encoded, [signal(stream, Signal::QueryEmpty(flow))]); - } else { - assert_eq!(encoded[0], signal(stream, Signal::Query(flow))); - assert_eq!(encoded[1], (count - QUERY_COUNT_BIAS) as u8); assert_eq!( - encoded.len(), - WireSignal::ENCODED_LEN + QUERY_COUNT_LEN + count * QUERY_CHILD_LEN + encoded, + frame_head(1, signal(stream, Signal::QueryEmpty(flow))) ); + } else { + let head = frame_head(2, signal(stream, Signal::Query(flow))); + assert_eq!(&encoded[..head.len()], head.as_slice()); + assert_eq!(encoded.len(), head.len() + listing_len(&children)); } } } } } -/// Match flow and both bare ends exhaust their one-byte representations. +/// Match flow and both bare ends exhaust the body-free representations: +/// each is exactly its one-item array head and signal. #[test] -fn one_byte_frames_are_exhaustive() { +fn body_free_frames_are_exhaustive() { let stream = stream(4); let cases: Vec<(WireFrame, u8)> = vec![ ( @@ -101,15 +112,18 @@ fn one_byte_frames_are_exhaustive() { for (frame, expected) in &cases { let mut encoded = Vec::new(); encode(speaker, frame, &mut encoded).unwrap(); - assert_eq!(encoded, [*expected]); + assert_eq!(encoded, frame_head(1, *expected)); } } } proptest! { - /// Supply framing is exact for an arbitrary run of backend-neutral leaf - /// records: one run length header, then one length-prefixed record per - /// leaf, in push order. + /// Supply framing is exact for an arbitrary run of backend-neutral + /// leaf records. + /// + /// The layout: the run's embedded-sequence heads, then one record item + /// per leaf, in push order — each record the tagged version atom and + /// bare payload behind its own embedded-sequence heads. #[test] fn supplied_run_is_framed_exactly( index in 1_u8..Stream::MAX, @@ -123,18 +137,21 @@ proptest! { for (version, value) in &records { let message = Message::new(*value); run.push(version, &message).unwrap(); - let mut record = Vec::new(); - ciborium::ser::into_writer(version, &mut record).unwrap(); - record.extend_from_slice(message.as_slice()); - body.extend_from_slice(&(record.len() as u32).to_be_bytes()); - body.extend_from_slice(&record); + let mut content = Vec::new(); + cbor::write_head(&mut content, MAJOR_TAG, crate::tags::VERSION_TAG); + ciborium::ser::into_writer(version, &mut content).unwrap(); + content.extend_from_slice(message.as_slice()); + cbor::write_head(&mut body, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut body, MAJOR_BSTR, content.len() as u64); + body.extend_from_slice(&content); } let frame = (stream, Frame::Reaction(Reaction::Supply(run), flow)); let mut encoded = Vec::new(); encode(speaker, &frame, &mut encoded).unwrap(); - let mut expected = vec![signal(stream, Signal::Supply(flow))]; - expected.extend_from_slice(&(body.len() as u32).to_be_bytes()); + let mut expected = frame_head(2, signal(stream, Signal::Supply(flow))); + cbor::write_head(&mut expected, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut expected, MAJOR_BSTR, body.len() as u64); expected.extend_from_slice(&body); prop_assert_eq!(encoded, expected); } @@ -164,7 +181,7 @@ fn writer_errors_are_contextual() { assert!(matches!( error.kind, EncodeErrorKind::Write { - part: FramePart::Signal, + part: FramePart::FrameHead, source, } if source.kind() == std::io::ErrorKind::Other )); @@ -215,7 +232,7 @@ fn async_writer_errors_are_contextual() { assert!(matches!( error.kind, EncodeErrorKind::Write { - part: FramePart::Signal, + part: FramePart::FrameHead, source, } if source.kind() == std::io::ErrorKind::Other )); diff --git a/src/tree/mirror/streaming/remote/codec/error.rs b/src/tree/mirror/streaming/remote/codec/error.rs index 68405f94..72024bac 100644 --- a/src/tree/mirror/streaming/remote/codec/error.rs +++ b/src/tree/mirror/streaming/remote/codec/error.rs @@ -10,7 +10,7 @@ use super::signal::{DecodeSignalError, Speaker, Stream}; /// The speaker and, when known, logical stream which produced an error. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Origin { - /// The direction is known, but no signal byte supplied a stream yet. + /// The direction is known, but no signal supplied a stream yet. Direction(Speaker), /// Both the direction and logical stream are known. Stream { speaker: Speaker, stream: Stream }, @@ -37,16 +37,16 @@ impl fmt::Display for Origin { } } -/// The absent component of a truncated frame. +/// The absent or malformed component of a frame. #[derive(Debug, Clone, Copy, thiserror::Error, PartialEq, Eq)] pub enum FramePart { - #[error("signal byte")] + #[error("frame head")] + FrameHead, + #[error("signal")] Signal, - #[error("query count")] - QueryCount, #[error("query child listing")] QueryChildren, - #[error("supply run length")] + #[error("supply run head")] SupplyLength, #[error("supply run")] SupplyRun, @@ -128,6 +128,19 @@ pub enum DecodeErrorKind { }, #[error(transparent)] QueryOutOfOrder(#[from] QueryOrderError), + /// The frame item is not a one- or two-element CBOR array. + #[error("frame is not a CBOR reaction array: {detail}")] + FrameShape { detail: &'static str }, + /// The frame array's length contradicts its signal's body arity. + #[error("frame array carries {found} item(s) where its signal takes {expected}")] + FrameArity { expected: u64, found: u64 }, + /// A frame component was present but not canonical CBOR of the + /// expected shape. + #[error("frame's {part} is malformed: {detail}")] + Malformed { + part: FramePart, + detail: &'static str, + }, #[error(transparent)] InvalidRun(#[from] LeafRunError), #[error( diff --git a/src/tree/mirror/streaming/remote/codec/frame.rs b/src/tree/mirror/streaming/remote/codec/frame.rs index 9b21f0b2..2e175fec 100644 --- a/src/tree/mirror/streaming/remote/codec/frame.rs +++ b/src/tree/mirror/streaming/remote/codec/frame.rs @@ -4,7 +4,10 @@ use crate::{ Version, message::{Message, PayloadDeserializer}, tree::{ - mirror::framing::{LENGTH_HEADER_LEN, LengthOverflow, length_header}, + mirror::cbor::{ + self, HeadError, MAJOR_BSTR, MAJOR_MAP, MAJOR_TAG, MAJOR_UINT, TAG_CBOR_SEQUENCE, + }, + mirror::framing::LengthOverflow, typed::{Hash, hash::MERKLE_HASH_LEN}, }, }; @@ -12,20 +15,25 @@ use crate::{ use super::error::{DecodeLeafError, QueryOrderError}; use super::signal::{End, Flow, Stream}; -/// The count byte stores one less than the nonempty query's actual fan. -pub const QUERY_COUNT_BIAS: usize = 1; +/// Largest query fan a listing map can carry: one child per radix value. +pub const MAX_QUERY_CHILDREN: usize = 256; -/// Largest query fan representable by a count-minus-one byte. -pub const MAX_QUERY_CHILDREN: usize = u8::MAX as usize + QUERY_COUNT_BIAS; +/// Bytes of the byte-string head ahead of one listed Merkle hash. +pub const HASH_HEAD_LEN: usize = cbor::head_len(MERKLE_HASH_LEN as u64); -/// Bytes occupied by one query child: its radix followed by its Merkle hash. -pub const QUERY_CHILD_LEN: usize = std::mem::size_of::() + MERKLE_HASH_LEN; +/// Bytes one listed child occupies as a map entry: its radix key's head, +/// then its hash value's head and digest bytes. Radixes of 24 and above +/// take a two-byte key head; smaller radixes take one. +pub const fn listing_entry_len(radix: u8) -> usize { + cbor::head_len(radix as u64) + HASH_HEAD_LEN + MERKLE_HASH_LEN +} -/// Bytes occupied by the count-minus-one field of a nonempty query. -pub const QUERY_COUNT_LEN: usize = std::mem::size_of::(); +/// Head bytes of the embedded-CBOR-sequence tag (63) opening every supply +/// run and every record within one. +pub(super) const RECORD_TAG_LEN: usize = cbor::head_len(TAG_CBOR_SEQUENCE); -/// Items in the adjacent-child window used to validate strict ordering. -const ADJACENT_CHILD_COUNT: usize = 2; +/// Head bytes of the version-atom tag ahead of a record's version. +const VERSION_TAG_LEN: usize = cbor::head_len(crate::tags::VERSION_TAG); /// The body of one complete reaction frame. #[derive(Debug, Clone, PartialEq, Eq)] @@ -44,28 +52,30 @@ pub enum Frame { End(End), } -/// A frame paired with the logical stream named by its signal byte. +/// A frame paired with the logical stream named by its signal. pub type WireFrame = (Stream, Frame); /// One supply frame's run of leaf records, held in encoded form. /// -/// A run is a delimited sequence of one or more `(Version, Message)` -/// records: each record is a [`LENGTH_HEADER_LEN`]-byte big-endian length -/// followed by one CBOR value (a byte string wrapping the version's -/// canonical encoding) and then the message's CBOR payload, back to back — -/// the record header delimits the payload, so it travels bare, and the -/// version's CBOR framing is what lets the decoder split the two without -/// re-measuring. The run stays encoded on both sides of the wire — the encoder -/// appends records copied from borrowed leaf data ([`push`](Self::push)) and -/// the decoder yields them one at a time ([`records`](Self::records)) — so -/// neither side materializes a decoded vector of leaves per frame; the bound -/// is one run's bytes. +/// A run is a CBOR sequence of one or more records. Each record is an +/// embedded-sequence item — tag 63 wrapping a byte string — whose content +/// is itself a two-item CBOR sequence: the version atom (its own tag +/// wrapping a byte string of the version's canonical encoding) followed by +/// the message's CBOR payload. The record's byte-string head delimits the +/// payload, so the payload travels bare, and the version's framing is what +/// lets the decoder split the two without re-measuring. The run stays +/// encoded on both sides of the wire — the encoder appends records copied +/// from borrowed leaf data ([`push`](Self::push)) and the decoder yields +/// them one at a time ([`records`](Self::records)) — so neither side +/// materializes a decoded vector of leaves per frame; the bound is one +/// run's bytes. /// -/// Construction guarantees record framing: [`push`] rejects a record no run -/// body can carry within the wire's `u32` frame header, and -/// [`from_encoded`](Self::from_encoded) rejects wire bytes whose headers do -/// not chain exactly to the end. A [`records`] iterator therefore never -/// fails structurally, only on a record's canonical content. +/// Construction guarantees record framing: [`push`] rejects a record no +/// run body can carry within the wire's run byte cap, and +/// [`from_encoded`](Self::from_encoded) rejects wire bytes whose record +/// items do not chain exactly to the end in canonical form. A [`records`] +/// iterator therefore never fails structurally, only on a record's +/// canonical content. /// /// [`push`]: Self::push /// [`records`]: Self::records @@ -116,7 +126,8 @@ impl LeafRun { self.bytes.is_empty() } - /// Bytes this run occupies on the wire, excluding signal and run length. + /// Bytes this run occupies on the wire, excluding the frame head and + /// the run's own embedded-sequence head. pub fn encoded_len(&self) -> usize { self.bytes.len() } @@ -128,16 +139,25 @@ impl LeafRun { /// Bytes one record with these components will occupy in a run. /// - /// Exactly what [`push`](Self::push) writes — the record header, the - /// version's CBOR byte-string framing plus its canonical bytes, and - /// the payload — pinned against an actual push by - /// `record_len_matches_an_actual_push`. Saturating: a sum past - /// `usize::MAX` cannot occur for in-memory slices, and an over-large - /// record is rejected by [`push`](Self::push) regardless. + /// Exactly what [`push`](Self::push) writes — the record's + /// embedded-sequence tag and byte-string head, the version atom's tag + /// and byte-string framing plus its canonical bytes, and the payload — + /// pinned against an actual push by `record_len_matches_an_actual_push`. + /// Saturating: a sum past `usize::MAX` cannot occur for in-memory + /// slices, and an over-large record is rejected by [`push`](Self::push) + /// regardless. pub fn record_len(version: &Version, message: &Message) -> usize { + let body = Self::record_body_len(version, message); + RECORD_TAG_LEN + .saturating_add(cbor::head_len(body as u64)) + .saturating_add(body) + } + + /// Bytes of a record's content behind its embedded-sequence head. + fn record_body_len(version: &Version, message: &Message) -> usize { let version = version.as_bytes().len(); - LENGTH_HEADER_LEN - .saturating_add(cbor_bytes_header_len(version)) + VERSION_TAG_LEN + .saturating_add(cbor::head_len(version as u64)) .saturating_add(version) .saturating_add(message.as_slice().len()) } @@ -146,45 +166,57 @@ impl LeafRun { /// /// # Errors /// - /// Rejects a record no run can carry — one whose combined encoding plus - /// its own record header exceeds the `u32` run-body limit — leaving the - /// run untouched. + /// Rejects a record no run can carry — one whose whole record item + /// exceeds the wire's run byte cap — leaving the run untouched. pub fn push(&mut self, version: &Version, message: &Message) -> Result<(), LengthOverflow> { + let body = Self::record_body_len(version, message); + let item = RECORD_TAG_LEN + .saturating_add(cbor::head_len(body as u64)) + .saturating_add(body); + checked_run_len(item)?; let version = version.as_bytes(); let message = message.as_slice(); - let len = cbor_bytes_header_len(version.len()) - .saturating_add(version.len()) - .saturating_add(message.len()); - let header = checked_record_header(len)?; - self.bytes.reserve(LENGTH_HEADER_LEN + len); - self.bytes.extend_from_slice(&header); - write_cbor_bytes_header(&mut self.bytes, version.len()); + self.bytes.reserve(item); + cbor::write_tag(&mut self.bytes, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut self.bytes, MAJOR_BSTR, body as u64); + cbor::write_tag(&mut self.bytes, crate::tags::VERSION_TAG); + cbor::write_head(&mut self.bytes, MAJOR_BSTR, version.len() as u64); self.bytes.extend_from_slice(version); self.bytes.extend_from_slice(message); Ok(()) } - /// Validate wire bytes as a run: nonempty, headers chaining exactly. + /// Validate wire bytes as a run: nonempty, canonical record items + /// chaining exactly to the end. pub fn from_encoded(bytes: Vec) -> Result { if bytes.is_empty() { return Err(LeafRunError::Empty); } let mut rest = bytes.as_slice(); while !rest.is_empty() { - if rest.len() < LENGTH_HEADER_LEN { - return Err(LeafRunError::TruncatedHeader { - remaining: rest.len(), + let remaining = rest.len(); + let len = match record_head(&mut rest) { + Ok(len) => len, + Err(RecordHeadError::Head(source)) => { + return Err(LeafRunError::Head { remaining, source }); + } + Err(RecordHeadError::NotARecord(detail)) => { + return Err(LeafRunError::NotARecord { remaining, detail }); + } + }; + let Ok(len) = usize::try_from(len) else { + return Err(LeafRunError::NotARecord { + remaining, + detail: "record exceeds the run byte cap", }); - } - let (header, body) = rest.split_at(LENGTH_HEADER_LEN); - let len = record_header(header); - if body.len() < len { + }; + if rest.len() < len { return Err(LeafRunError::TruncatedRecord { len, - remaining: body.len(), + remaining: rest.len(), }); } - rest = &body[len..]; + rest = &rest[len..]; } Ok(Self { bytes }) } @@ -203,7 +235,7 @@ impl LeafRun { .map(move |record| parse_record(record, deserializer)) } - /// Split the validated run back into its exact record slices. + /// Split the validated run back into its exact record contents. /// /// `pub(super)` for the capture renderer, which decodes each /// record's version structurally without knowing the leaf type. @@ -212,7 +244,7 @@ impl LeafRun { } } -/// Iterator over the exact record bodies of a structurally valid run. +/// Iterator over the exact record contents of a structurally valid run. pub(super) struct RecordSlices<'a> { rest: &'a [u8], } @@ -224,49 +256,108 @@ impl<'a> Iterator for RecordSlices<'a> { if self.rest.is_empty() { return None; } - let (header, body) = self.rest.split_at(LENGTH_HEADER_LEN); - let (record, rest) = body.split_at(record_header(header)); + let len = record_head(&mut self.rest).expect("a validated run chains canonical records"); + let (record, rest) = self + .rest + .split_at(usize::try_from(len).expect("a validated record fits in memory")); self.rest = rest; Some(record) } } -/// The record header for a `len`-byte record, checked against the outer frame. +/// A record's leading heads were not a canonical embedded-sequence item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RecordHeadError { + Head(HeadError), + NotARecord(&'static str), +} + +/// Parse one record's leading heads — the embedded-sequence tag and its +/// byte-string head — off the front of `input`, returning the record's +/// content length. +pub(super) fn record_head(input: &mut &[u8]) -> Result { + let head = cbor::read_head(input).map_err(RecordHeadError::Head)?; + if head.major != MAJOR_TAG || head.value != TAG_CBOR_SEQUENCE { + return Err(RecordHeadError::NotARecord( + "record does not open with the embedded-sequence tag", + )); + } + let head = cbor::read_head(input).map_err(RecordHeadError::Head)?; + if head.major != MAJOR_BSTR { + return Err(RecordHeadError::NotARecord( + "record tag does not wrap a byte string", + )); + } + Ok(head.value) +} + +/// Check a run body length against the wire's run byte cap. /// -/// A record is only encodable if the smallest run body holding it — the -/// record's bytes plus its own [`LENGTH_HEADER_LEN`]-byte header — fits the -/// wire's `u32` frame header, so the check charges the record header too. -/// [`LeafRun::push`] rejects on this boundary eagerly: an unshippable record -/// fails at record level rather than later at the outer frame. -fn checked_record_header(len: usize) -> Result<[u8; LENGTH_HEADER_LEN], LengthOverflow> { - length_header(len.saturating_add(LENGTH_HEADER_LEN))?; - Ok(length_header(len).expect("bounded by the header-charged check above")) +/// The encoder's boundary: a run the cap rejects was necessarily a single +/// record (the budget saturates below the cap, so a multi-record run never +/// grows here), and [`LeafRun::push`] already rejected any such record — +/// this check is the belt to that suspender, priced identically. +pub(super) fn checked_run_len(len: usize) -> Result { + // The cap is exactly the u32 range, so the failed conversion is the + // overflow witness. + match u32::try_from(len) { + Ok(len) => Ok(u64::from(len)), + Err(source) => Err(LengthOverflow { len, source }), + } } -/// Read one record header; construction guarantees its width. -fn record_header(header: &[u8]) -> usize { - u32::from_be_bytes( - header - .try_into() - .expect("a validated run chunks exact record headers"), - ) as usize +/// Whether a run body of `len` bytes is exactly one record: the first +/// record's heads plus the content they declare span the body. +/// +/// The lone-record test of the run-budget ingress check, shared by the +/// async reader and the sync oracle so the two decoders draw the +/// over-budget legality boundary identically. A body this predicate +/// rejects may also be structurally malformed; over budget, that +/// distinction is moot — either way the frame is not the one legal +/// overhang — so the check does not refine it further. +pub(super) fn lone_record_spans(len: usize, record_content: u64) -> bool { + (RECORD_TAG_LEN as u64) + .saturating_add(cbor::head_len(record_content) as u64) + .saturating_add(record_content) + == len as u64 } -/// Decode one exact record body into its canonical pair. +/// Decode one exact record content into its canonical pair. fn parse_record( record: &[u8], deserializer: PayloadDeserializer, ) -> Result<(Version, Message), DecodeLeafError> { - // Both fields are self-delimiting CBOR values, so the exact record - // body parses without retrying, and whatever the payload's parse does - // not consume is trailing. + // The version atom's tag is protocol vocabulary, read here by hand; + // the byte string behind it and the payload are self-delimiting CBOR + // values, so the exact record content parses without retrying, and + // whatever the payload's parse does not consume is trailing. fn de_error(e: ciborium::de::Error) -> std::io::Error { match e { ciborium::de::Error::Io(e) => e, e => std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()), } } + fn invalid(message: &str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, message.to_string()) + } let mut input = record; + match cbor::read_head(&mut input) { + Ok(head) if head.major == MAJOR_TAG && head.value == crate::tags::VERSION_TAG => {} + Ok(_) => { + return Err(DecodeLeafError::Version(invalid( + "supplied version does not carry the version-atom tag", + ))); + } + // A record too short to hold the version's tag ran out of bytes, + // the same class as a version cut mid-encoding. + Err(HeadError::Truncated) => { + return Err(DecodeLeafError::Version(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "record ends inside the version atom's tag", + ))); + } + Err(e) => return Err(DecodeLeafError::Version(invalid(&e.to_string()))), + } let version: Version = ciborium::de::from_reader(&mut input).map_err(|e| DecodeLeafError::Version(de_error(e)))?; // The deserializer owns the payload parse, including the @@ -278,83 +369,162 @@ fn parse_record( Ok((version, message)) } -/// Bytes of the CBOR definite-length byte-string header for a `len`-byte -/// payload: the major-type-2 initial byte, plus the argument's width. -/// -/// The dual of [`write_cbor_bytes_header`]; `record_len` prices with one -/// and `push` writes with the other, and the -/// `record_len_matches_an_actual_push` pin holds them together. -fn cbor_bytes_header_len(len: usize) -> usize { - match len { - 0..=23 => 1, - 24..=0xff => 2, - 0x100..=0xffff => 3, - 0x1_0000..=0xffff_ffff => 5, - _ => 9, - } -} - -/// Append the CBOR definite-length byte-string header for a `len`-byte -/// payload: exactly what [`ciborium`] emits for `serialize_bytes`. -fn write_cbor_bytes_header(out: &mut Vec, len: usize) { - const MAJOR_BYTES: u8 = 2 << 5; - match len { - 0..=23 => out.push(MAJOR_BYTES | len as u8), - 24..=0xff => out.extend_from_slice(&[MAJOR_BYTES | 24, len as u8]), - 0x100..=0xffff => { - out.push(MAJOR_BYTES | 25); - out.extend_from_slice(&(len as u16).to_be_bytes()); - } - 0x1_0000..=0xffff_ffff => { - out.push(MAJOR_BYTES | 26); - out.extend_from_slice(&(len as u32).to_be_bytes()); - } - _ => { - out.push(MAJOR_BYTES | 27); - out.extend_from_slice(&(len as u64).to_be_bytes()); - } - } -} - /// A supply run whose record framing is structurally invalid. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum LeafRunError { /// Every supply frame carries at least one record. #[error("a supply run carries no leaf records")] Empty, - /// A record header overruns the run's declared length. - #[error("a leaf record header overruns the {remaining} bytes left in its run")] - TruncatedHeader { remaining: usize }, - /// A record body overruns the run's declared length. + /// A record's leading heads are truncated or non-canonical. + #[error("a leaf record's heads are invalid in the {remaining} bytes left in its run: {source}")] + Head { + remaining: usize, + #[source] + source: HeadError, + }, + /// The bytes where a record belongs are some other CBOR item. + #[error("a {remaining}-byte run tail is not a leaf record: {detail}")] + NotARecord { + remaining: usize, + detail: &'static str, + }, + /// A record's content overruns the run's declared length. #[error("a leaf record of {len} bytes overruns the {remaining} bytes left in its run")] TruncatedRecord { len: usize, remaining: usize }, } -/// Validate that a radix listing is in canonical order: strictly ascending. -/// -/// This is the one gate every child listing entering from the wire passes, -/// whichever surface carries it — a query frame's body or the greeting's -/// root-fan listing. Strictness is the whole invariant: the canonical form -/// admits each radix at most once, so an equal adjacent pair is rejected -/// exactly like a descent. -/// -/// # Errors +/// One structural problem in a child-listing map. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ListingIssue { + /// A head was truncated, indefinite, reserved, or widened. + Head(HeadError), + /// An item had the wrong major type, value range, or count. + Shape(&'static str), + /// The digest bytes behind a value head were cut short. + Truncated, + /// Adjacent keys were not strictly ascending. + Order(QueryOrderError), +} + +impl std::fmt::Display for ListingIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ListingIssue::Head(e) => write!(f, "{e}"), + ListingIssue::Shape(detail) => f.write_str(detail), + ListingIssue::Truncated => f.write_str("listing hash bytes are truncated"), + ListingIssue::Order(e) => write!(f, "{e}"), + } + } +} + +/// Incrementally validated state of one child-listing map's entries. /// -/// The first adjacent non-ascending pair reports both radices as a -/// [`QueryOrderError`]. -pub fn validate_children(children: &[(u8, Hash)]) -> Result<(), QueryOrderError> { - for pair in children.windows(ADJACENT_CHILD_COUNT) { - let [previous, current] = pair else { - unreachable!("an adjacent-child window contains exactly two items") - }; - if previous.0 >= current.0 { - return Err(QueryOrderError { - previous: previous.0, - radix: current.0, - }); +/// This is the one gate every child listing entering from the wire +/// passes, whichever surface carries it — a query frame's body or the +/// greeting's root-fan listing — and whichever reader drives it (the +/// async decoder, the sync oracle, or the slice parser). The map's +/// deterministic-encoding key order and the wire's canonical child order +/// are one discipline: keys must be strictly ascending radixes, so an +/// equal adjacent pair is rejected exactly like a descent +/// ([`ListingIssue::Order`]). +pub(super) struct ListingBuilder { + children: Vec<(u8, Hash)>, + previous: Option, +} + +impl ListingBuilder { + /// Accept a map head of `count` entries within the radix space. + pub(super) fn new(count: u64) -> Result { + if count > MAX_QUERY_CHILDREN as u64 { + return Err(ListingIssue::Shape("listing exceeds the radix space")); } + Ok(Self { + children: Vec::with_capacity(count as usize), + previous: None, + }) + } + + /// Accept one entry's key head: an unsigned radix, strictly above the + /// previous key. + pub(super) fn key(&mut self, head: cbor::Head) -> Result { + if head.major != MAJOR_UINT || head.value > u64::from(u8::MAX) { + return Err(ListingIssue::Shape("listing key is not a radix")); + } + let radix = head.value as u8; + if let Some(previous) = self.previous + && previous >= radix + { + return Err(ListingIssue::Order(QueryOrderError { previous, radix })); + } + self.previous = Some(radix); + Ok(radix) + } + + /// Accept one entry's value head: a byte string of exactly one digest. + pub(super) fn value_head(head: cbor::Head) -> Result<(), ListingIssue> { + if head.major != MAJOR_BSTR || head.value != MERKLE_HASH_LEN as u64 { + return Err(ListingIssue::Shape("listing value is not a Merkle hash")); + } + Ok(()) + } + + /// Record one entry whose key and value heads were accepted. + pub(super) fn entry(&mut self, radix: u8, hash: [u8; MERKLE_HASH_LEN]) { + self.children.push((radix, Hash(hash))); + } + + /// Yield the validated children. + pub(super) fn finish(self) -> Vec<(u8, Hash)> { + self.children + } +} + +/// Parse one complete child-listing map off the front of `input`, +/// advancing past it. +pub(crate) fn parse_listing_map(input: &mut &[u8]) -> Result, ListingIssue> { + let head = cbor::read_head(input).map_err(ListingIssue::Head)?; + if head.major != MAJOR_MAP { + return Err(ListingIssue::Shape("listing is not a map")); + } + let count = head.value; + let mut listing = ListingBuilder::new(count)?; + for _ in 0..count { + let key = cbor::read_head(input).map_err(ListingIssue::Head)?; + let radix = listing.key(key)?; + let value = cbor::read_head(input).map_err(ListingIssue::Head)?; + ListingBuilder::value_head(value)?; + if input.len() < MERKLE_HASH_LEN { + return Err(ListingIssue::Truncated); + } + let (digest, rest) = input.split_at(MERKLE_HASH_LEN); + *input = rest; + listing.entry(radix, digest.try_into().expect("split at the digest width")); + } + Ok(listing.finish()) +} + +/// Append one child listing as a canonical map: ascending radix keys, +/// each hash a definite-length byte string. +/// +/// The encoder is not a trust boundary — callers guarantee canonical +/// child order — so this writes without revalidating it. +pub(crate) fn write_listing(out: &mut Vec, children: &[(u8, Hash)]) { + cbor::write_head(out, MAJOR_MAP, children.len() as u64); + for (radix, hash) in children { + cbor::write_head(out, MAJOR_UINT, u64::from(*radix)); + cbor::write_head(out, MAJOR_BSTR, MERKLE_HASH_LEN as u64); + out.extend_from_slice(hash.as_bytes()); + } +} + +/// Bytes a whole child listing occupies as a map: its head plus entries. +#[cfg(test)] +pub fn listing_len(children: &[(u8, Hash)]) -> usize { + let mut total = cbor::head_len(children.len() as u64); + for (radix, _) in children { + total += listing_entry_len(*radix); } - Ok(()) + total } #[cfg(test)] diff --git a/src/tree/mirror/streaming/remote/codec/frame/tests.rs b/src/tree/mirror/streaming/remote/codec/frame/tests.rs index f21de334..2b66c657 100644 --- a/src/tree/mirror/streaming/remote/codec/frame/tests.rs +++ b/src/tree/mirror/streaming/remote/codec/frame/tests.rs @@ -1,48 +1,35 @@ use super::*; -/// The largest record `push` admits: with its own record header charged, the -/// smallest run body holding it exactly fills the outer `u32` frame header. -const LARGEST_ENCODABLE_RECORD: usize = u32::MAX as usize - LENGTH_HEADER_LEN; +use crate::tree::mirror::cbor::HeadError; -/// Push's capacity check is eager and charges the record header. -/// -/// A record is admitted exactly when its bytes plus its own header fit the -/// outer `u32` frame header, so a record with length in -/// `(u32::MAX - 4, u32::MAX]` fails at record level rather than later at -/// the outer frame. -#[test] -fn record_capacity_charges_the_record_header() { - assert!(checked_record_header(LARGEST_ENCODABLE_RECORD).is_ok()); - for unshippable in [ - LARGEST_ENCODABLE_RECORD + 1, - u32::MAX as usize, - u32::MAX as usize + 1, - ] { - let error = checked_record_header(unshippable) - .expect_err("a record past the header-charged boundary must fail"); - assert_eq!(error.len, unshippable.saturating_add(LENGTH_HEADER_LEN)); - } +/// The record heads ahead of a record's content: the embedded-sequence +/// tag plus the byte-string head for `content` bytes. +fn record_heads(content: usize) -> usize { + RECORD_TAG_LEN + cbor::head_len(content as u64) } -/// The checked header encodes the record's own length, not the charged sum: -/// the header-charged boundary changes only admission, never the wire bytes -/// of an admitted record. +/// Push's capacity check is eager and charges the record's whole item. +/// +/// A record is admitted exactly when its heads plus its content fit the +/// wire's run byte cap, so a record item with length past `u32::MAX` +/// fails at record level rather than later at the run head. #[test] -fn checked_header_encodes_the_bare_record_length() { - let len = 7; - assert_eq!( - checked_record_header(len).expect("a small record is admitted"), - (len as u32).to_be_bytes(), - ); +fn record_capacity_charges_the_whole_item() { + assert!(checked_run_len(u32::MAX as usize).is_ok()); + for unshippable in [u32::MAX as usize + 1, u32::MAX as usize + 2] { + let error = checked_run_len(unshippable) + .expect_err("a record item past the run byte cap must fail"); + assert_eq!(error.len, unshippable); + } } /// `record_len` prices exactly what `push` writes, at every CBOR -/// byte-string header width a version can occupy. +/// byte-string head width a version can occupy. /// /// The two are the same quantity computed two ways — arithmetic against /// actual encoding — so the run-budget math can trust the closed form. /// Deep version chains grow the canonical encoding through the 1-byte -/// (< 24), 2-byte (< 256), and 3-byte (< 65536) CBOR header regimes; the +/// (< 24), 2-byte (< 256), and 3-byte (< 65536) CBOR head regimes; the /// chain lengths below land encodings in the first two and the message /// sizes sweep the payload term. #[test] @@ -52,12 +39,12 @@ fn record_len_matches_an_actual_push() { for parties in 1..=128u32 { // One tick on a fresh disjoint party per step: each new party's // event widens the canonical encoding, marching it through the - // CBOR header-width regimes. + // CBOR head-width regimes. version.tick(&crate::tree::arb::nth_party(parties as usize)); if !(parties == 1 || parties % 17 == 0) { continue; } - checked_regimes.insert(super::cbor_bytes_header_len(version.as_bytes().len())); + checked_regimes.insert(cbor::head_len(version.as_bytes().len() as u64)); for message in [Message::new(0u64), Message::new(u64::MAX)] { let mut run = LeafRun::new(); run.push(&version, &message).expect("test records fit"); @@ -66,19 +53,129 @@ fn record_len_matches_an_actual_push() { LeafRun::record_len(&version, &message), "record_len must price exactly one pushed record", ); - // The version atom `push` writes is byte-identical to the - // serde form the decoder parses (ciborium's byte string). + // Behind the record's heads and the version-atom tag, the + // version `push` writes is byte-identical to the serde form + // the decoder parses (ciborium's byte string). let mut serde_form = Vec::new(); ciborium::ser::into_writer(&version, &mut serde_form).unwrap(); + let content = LeafRun::record_body_len(&version, &message); + let at = record_heads(content) + VERSION_TAG_LEN; assert_eq!( - &run.as_bytes()[LENGTH_HEADER_LEN..LENGTH_HEADER_LEN + serde_form.len()], + &run.as_bytes()[at..at + serde_form.len()], serde_form.as_slice(), - "push's hand-written CBOR header must match ciborium's", + "push's hand-written version framing must match ciborium's", ); } } assert!( checked_regimes.len() >= 2, - "the sweep must cross at least two CBOR header-width regimes, got {checked_regimes:?}", + "the sweep must cross at least two CBOR head-width regimes, got {checked_regimes:?}", ); } + +/// A pushed run round-trips through `from_encoded` and yields the same +/// records: the writer's record heads are exactly what the validator +/// chains over, one record at a time. +#[test] +fn pushed_runs_validate_and_iterate() { + let mut version = crate::Version::new(); + version.tick(&crate::tree::arb::nth_party(1)); + let mut run = LeafRun::new(); + for payload in [1u64, 2, 3] { + run.push(&version, &Message::new(payload)) + .expect("test records fit"); + } + let decoded = + LeafRun::from_encoded(run.as_bytes().to_vec()).expect("a pushed run is structurally valid"); + let payloads: Vec = decoded + .records(Message::deserializer::()) + .map(|record| *record.expect("a pushed record decodes").1.arc::()) + .collect(); + assert_eq!(payloads, vec![1, 2, 3]); + assert_eq!(decoded.record_count(), 3); +} + +/// A run whose record opens with anything but the embedded-sequence tag, +/// or whose head is widened past shortest form, is rejected typed: the +/// deterministic contract holds inside runs too. +#[test] +fn malformed_record_heads_are_typed() { + // A bare byte string where a tagged record belongs. + let mut bytes = Vec::new(); + cbor::write_head(&mut bytes, MAJOR_BSTR, 1); + bytes.push(0); + assert!(matches!( + LeafRun::from_encoded(bytes), + Err(LeafRunError::NotARecord { .. }) + )); + // A widened (non-shortest) byte-string head behind a valid tag. + let mut bytes = Vec::new(); + cbor::write_tag(&mut bytes, TAG_CBOR_SEQUENCE); + bytes.extend_from_slice(&[0x58, 0x01, 0x00]); // 1 spelled wide + assert!(matches!( + LeafRun::from_encoded(bytes), + Err(LeafRunError::Head { + source: HeadError::NotShortest, + .. + }) + )); + // A record whose declared content overruns the run. + let mut bytes = Vec::new(); + cbor::write_tag(&mut bytes, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut bytes, MAJOR_BSTR, 4); + bytes.push(0); + assert!(matches!( + LeafRun::from_encoded(bytes), + Err(LeafRunError::TruncatedRecord { + len: 4, + remaining: 1 + }) + )); + // The empty run. + assert!(matches!( + LeafRun::from_encoded(Vec::new()), + Err(LeafRunError::Empty) + )); +} + +/// The listing writer and the listing parser are inverses on every +/// canonical listing, and the parser holds keys strictly ascending: +/// the map's deterministic key order and the wire's canonical child +/// order are one rule. +#[test] +fn listings_round_trip_and_hold_canonical_order() { + use proptest::prelude::*; + proptest!(|(radixes in proptest::collection::btree_set(any::(), 0..=64))| { + let children: Vec<(u8, Hash)> = radixes + .iter() + .map(|&radix| (radix, Hash([radix; MERKLE_HASH_LEN]))) + .collect(); + let mut bytes = Vec::new(); + write_listing(&mut bytes, &children); + prop_assert_eq!(bytes.len(), listing_len(&children)); + let mut input = bytes.as_slice(); + let parsed = parse_listing_map(&mut input).expect("a written listing is canonical"); + prop_assert_eq!(parsed, children); + prop_assert!(input.is_empty()); + }); +} + +/// A listing with a descending or repeated key is rejected with the +/// order violation, exactly like a wire query: an equal adjacent pair is +/// as non-canonical as a descent. +#[test] +fn unordered_listings_are_rejected() { + for (previous, radix) in [(3u8, 3u8), (5, 2)] { + let children = [ + (previous, Hash([0; MERKLE_HASH_LEN])), + (radix, Hash([1; MERKLE_HASH_LEN])), + ]; + let mut bytes = Vec::new(); + write_listing(&mut bytes, &children); + let mut input = bytes.as_slice(); + assert_eq!( + parse_listing_map(&mut input), + Err(ListingIssue::Order(QueryOrderError { previous, radix })), + ); + } +} diff --git a/src/tree/mirror/streaming/remote/codec/greeting.rs b/src/tree/mirror/streaming/remote/codec/greeting.rs new file mode 100644 index 00000000..d29db012 --- /dev/null +++ b/src/tree/mirror/streaming/remote/codec/greeting.rs @@ -0,0 +1,313 @@ +//! The V2 greeting's wire spelling. +//! +//! One control-stream item: an embedded-CBOR-item tag (24) wrapping a +//! byte string whose content is a text-keyed map. The embedding is what +//! keeps the control stream's reader trivial — the byte string's head +//! declares the whole greeting's length up front, so no incremental map +//! walk happens against the transport — while a generic tool unwraps +//! tag 24 as part of the standard vocabulary and sees the map. +//! +//! The map's keys ride in CBOR deterministic order (bytewise +//! lexicographic over their encodings), and the decoder requires exactly +//! this key set in exactly that order: one spelling per greeting. +//! +//! - `"listing"`: the sender's root-fan listing, the same +//! `{radix: hash}` map spelling a query frame carries. +//! - `"set_len"`: the sender's declared set size. +//! - `"version"`: the sender's causal version — the version-atom tag +//! wrapping a byte string of the version's canonical encoding. +//! - `"protocol"`: the text `"rumors"` — the protocol magic. The +//! preamble's self-described CBOR tag announces only "this is CBOR"; +//! this entry is what marks the session as a rumors stream to a +//! reader holding nothing but the bytes. +//! - `"max_version_bytes"`: the sender's version-size bound. +//! - `"target_message_size"`: the sender's supply-run byte target. + +use crate::{ + Version, + tree::mirror::cbor::{ + self, HeadError, MAJOR_BSTR, MAJOR_TAG, MAJOR_TEXT, MAJOR_UINT, TAG_EMBEDDED_ITEM, + }, + tree::mirror::streaming::message::Greeting, +}; + +use super::error::QueryOrderError; +use super::frame::{ListingIssue, parse_listing_map, write_listing}; + +/// The greeting map's keys, in the deterministic (bytewise lexicographic) +/// order the wire requires. +const KEYS: [&str; 6] = [ + "listing", + "set_len", + "version", + "protocol", + "max_version_bytes", + "target_message_size", +]; + +/// The protocol magic carried by the greeting's `"protocol"` entry. +const PROTOCOL_NAME: &str = "rumors"; + +/// Render one greeting as its complete control-stream item: +/// tag 24 wrapping a byte string of the greeting map. +pub(crate) fn encode_greeting(greeting: &Greeting) -> Vec { + let map = greeting_map(greeting); + let mut item = Vec::with_capacity( + cbor::head_len(TAG_EMBEDDED_ITEM) + cbor::head_len(map.len() as u64) + map.len(), + ); + cbor::write_tag(&mut item, TAG_EMBEDDED_ITEM); + cbor::write_head(&mut item, MAJOR_BSTR, map.len() as u64); + item.extend_from_slice(&map); + item +} + +/// Render the greeting map alone. +fn greeting_map(greeting: &Greeting) -> Vec { + let mut map = Vec::new(); + cbor::write_head(&mut map, cbor::MAJOR_MAP, KEYS.len() as u64); + for key in KEYS { + cbor::write_head(&mut map, MAJOR_TEXT, key.len() as u64); + map.extend_from_slice(key.as_bytes()); + match key { + "listing" => write_listing(&mut map, &greeting.listing), + "set_len" => cbor::write_head(&mut map, MAJOR_UINT, greeting.set_len), + "version" => { + let version = greeting.version.as_bytes(); + cbor::write_tag(&mut map, crate::tags::VERSION_TAG); + cbor::write_head(&mut map, MAJOR_BSTR, version.len() as u64); + map.extend_from_slice(version); + } + "protocol" => { + cbor::write_head(&mut map, MAJOR_TEXT, PROTOCOL_NAME.len() as u64); + map.extend_from_slice(PROTOCOL_NAME.as_bytes()); + } + "max_version_bytes" => { + cbor::write_head(&mut map, MAJOR_UINT, greeting.max_version_bytes); + } + "target_message_size" => { + cbor::write_head(&mut map, MAJOR_UINT, greeting.target_message_size); + } + _ => unreachable!("the key roster is exhaustive"), + } + } + map +} + +/// A greeting that is not canonical rumors CBOR. +#[derive(Debug, thiserror::Error)] +pub(crate) enum GreetingError { + /// A head was truncated, indefinite, reserved, or widened. + #[error("greeting head is not canonical: {0}")] + Head(HeadError), + /// An item had the wrong major type, value, or position. + #[error("greeting is malformed: {0}")] + Shape(&'static str), + /// The listing map violated a structural rule. + #[error("greeting listing is malformed: {0}")] + Listing(ListingIssue), + /// The listing's keys were not in canonical strictly ascending order. + #[error(transparent)] + Order(QueryOrderError), + /// The version atom's canonical bytes did not decode. + #[error("greeting version does not decode")] + Version(#[source] std::io::Error), +} + +/// Parse a greeting map from the embedded byte string's exact content. +pub(crate) fn parse_greeting(bytes: &[u8]) -> Result { + let mut input = bytes; + let head = cbor::read_head(&mut input).map_err(GreetingError::Head)?; + if head.major != cbor::MAJOR_MAP || head.value != KEYS.len() as u64 { + return Err(GreetingError::Shape("greeting is not the six-entry map")); + } + let mut version = None; + let mut set_len = None; + let mut max_version_bytes = None; + let mut target_message_size = None; + let mut listing = None; + for key in KEYS { + let head = cbor::read_head(&mut input).map_err(GreetingError::Head)?; + if head.major != MAJOR_TEXT || head.value != key.len() as u64 { + return Err(GreetingError::Shape( + "greeting keys are not the deterministic roster", + )); + } + let Some((text, rest)) = split(input, key.len()) else { + return Err(GreetingError::Shape("greeting key is truncated")); + }; + input = rest; + if text != key.as_bytes() { + return Err(GreetingError::Shape( + "greeting keys are not the deterministic roster", + )); + } + match key { + "listing" => { + listing = Some(parse_listing_map(&mut input).map_err(|issue| match issue { + ListingIssue::Order(order) => GreetingError::Order(order), + issue => GreetingError::Listing(issue), + })?); + } + "set_len" => set_len = Some(uint(&mut input, "set_len")?), + "version" => { + let head = cbor::read_head(&mut input).map_err(GreetingError::Head)?; + if head.major != MAJOR_TAG || head.value != crate::tags::VERSION_TAG { + return Err(GreetingError::Shape( + "greeting version does not carry the version-atom tag", + )); + } + let head = cbor::read_head(&mut input).map_err(GreetingError::Head)?; + if head.major != MAJOR_BSTR { + return Err(GreetingError::Shape( + "greeting version tag does not wrap a byte string", + )); + } + let Ok(len) = usize::try_from(head.value) else { + return Err(GreetingError::Shape("greeting version outsizes memory")); + }; + let Some((atom, rest)) = split(input, len) else { + return Err(GreetingError::Shape("greeting version is truncated")); + }; + input = rest; + version = Some(Version::decode(atom).map_err(|e| { + GreetingError::Version(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + })?); + } + "protocol" => { + let head = cbor::read_head(&mut input).map_err(GreetingError::Head)?; + if head.major != MAJOR_TEXT || head.value != PROTOCOL_NAME.len() as u64 { + return Err(GreetingError::Shape("greeting protocol magic is absent")); + } + let Some((name, rest)) = split(input, PROTOCOL_NAME.len()) else { + return Err(GreetingError::Shape("greeting protocol magic is truncated")); + }; + input = rest; + if name != PROTOCOL_NAME.as_bytes() { + return Err(GreetingError::Shape( + "greeting protocol magic is not \"rumors\"", + )); + } + } + "max_version_bytes" => { + max_version_bytes = Some(uint(&mut input, "max_version_bytes")?); + } + "target_message_size" => { + target_message_size = Some(uint(&mut input, "target_message_size")?); + } + _ => unreachable!("the key roster is exhaustive"), + } + } + if !input.is_empty() { + return Err(GreetingError::Shape("greeting carries trailing bytes")); + } + Ok(Greeting { + version: version.expect("the roster visits version"), + set_len: set_len.expect("the roster visits set_len"), + max_version_bytes: max_version_bytes.expect("the roster visits max_version_bytes"), + target_message_size: target_message_size.expect("the roster visits target_message_size"), + listing: listing.expect("the roster visits listing"), + }) +} + +/// Read one unsigned-int value. +fn uint(input: &mut &[u8], _key: &'static str) -> Result { + let head = cbor::read_head(input).map_err(GreetingError::Head)?; + if head.major != MAJOR_UINT { + return Err(GreetingError::Shape( + "greeting size entry is not an unsigned int", + )); + } + Ok(head.value) +} + +/// Split `len` leading bytes off `input`, or `None` when it is shorter. +fn split(input: &[u8], len: usize) -> Option<(&[u8], &[u8])> { + (input.len() >= len).then(|| input.split_at(len)) +} + +/// Read one complete greeting item from the control stream. +/// +/// Transport failures pass through as `Err(Ok-side io)`; a malformed or +/// non-canonical greeting is an [`InvalidData`](std::io::ErrorKind) +/// error, except a non-canonical listing order, surfaced typed so the +/// handshake can report it as the codec's own violation class. +pub(crate) async fn read_greeting(read: &mut R) -> Result +where + R: tokio::io::AsyncRead + Unpin, +{ + use crate::tree::mirror::framing::read_payload; + let head = cbor::read_head_async(read) + .await + .map_err(head_read_error)? + .ok_or_else(|| { + ReadGreetingError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "peer closed before its greeting", + )) + })?; + if head.major != MAJOR_TAG || head.value != TAG_EMBEDDED_ITEM { + return Err(ReadGreetingError::Decode(invalid( + "greeting does not open with the embedded-item tag", + ))); + } + let head = cbor::read_head_async(read) + .await + .map_err(head_read_error)? + .ok_or_else(|| { + ReadGreetingError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "peer closed inside its greeting", + )) + })?; + if head.major != MAJOR_BSTR { + return Err(ReadGreetingError::Decode(invalid( + "greeting tag does not wrap a byte string", + ))); + } + let Ok(len) = usize::try_from(head.value) else { + return Err(ReadGreetingError::Decode(invalid( + "greeting declares an unaddressable length", + ))); + }; + let bytes = read_payload(read, len) + .await + .map_err(ReadGreetingError::Io)?; + parse_greeting(&bytes).map_err(|e| match e { + GreetingError::Order(order) => ReadGreetingError::Listing(order), + e => ReadGreetingError::Decode(std::io::Error::new( + std::io::ErrorKind::InvalidData, + e.to_string(), + )), + }) +} + +/// How reading a greeting from the control stream failed. +#[derive(Debug, thiserror::Error)] +pub(crate) enum ReadGreetingError { + /// The transport failed before the greeting arrived whole. + #[error(transparent)] + Io(std::io::Error), + /// The greeting arrived but is not canonical rumors CBOR. + #[error(transparent)] + Decode(std::io::Error), + /// The greeting's listing violated canonical child order. + #[error(transparent)] + Listing(QueryOrderError), +} + +fn invalid(message: &'static str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, message) +} + +fn head_read_error(e: cbor::HeadReadError) -> ReadGreetingError { + match e { + cbor::HeadReadError::Io(io) => ReadGreetingError::Io(io), + cbor::HeadReadError::Malformed(head) => ReadGreetingError::Decode(std::io::Error::new( + std::io::ErrorKind::InvalidData, + head.to_string(), + )), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/tree/mirror/streaming/remote/codec/greeting/tests.rs b/src/tree/mirror/streaming/remote/codec/greeting/tests.rs new file mode 100644 index 00000000..4261070a --- /dev/null +++ b/src/tree/mirror/streaming/remote/codec/greeting/tests.rs @@ -0,0 +1,121 @@ +use super::*; + +use crate::tree::typed::{Hash, hash::MERKLE_HASH_LEN}; + +fn sample(listing: Vec<(u8, Hash)>) -> Greeting { + let mut version = crate::Version::new(); + version.tick(&crate::tree::arb::nth_party(1)); + Greeting { + version, + set_len: 7, + max_version_bytes: 4096, + target_message_size: 1 << 20, + listing, + } +} + +/// Greeting encode and parse are inverses, listing shapes included: +/// empty, small-radix, and large-radix listings all round-trip through +/// the one wire spelling. +#[test] +fn greetings_round_trip() { + for listing in [ + Vec::new(), + vec![(0, Hash([1; MERKLE_HASH_LEN]))], + vec![ + (3, Hash([1; MERKLE_HASH_LEN])), + (24, Hash([2; MERKLE_HASH_LEN])), + (255, Hash([3; MERKLE_HASH_LEN])), + ], + ] { + let greeting = sample(listing); + let item = encode_greeting(&greeting); + // Strip the embedded-item tag and byte-string head, the layer the + // async reader consumes. + let mut input = item.as_slice(); + let head = cbor::read_head(&mut input).expect("the item opens with a head"); + assert_eq!((head.major, head.value), (MAJOR_TAG, TAG_EMBEDDED_ITEM)); + let head = cbor::read_head(&mut input).expect("the tag wraps a byte string"); + assert_eq!(head.major, MAJOR_BSTR); + assert_eq!(head.value as usize, input.len()); + let parsed = parse_greeting(input).expect("a written greeting parses"); + assert_eq!(parsed.version, greeting.version); + assert_eq!(parsed.set_len, greeting.set_len); + assert_eq!(parsed.max_version_bytes, greeting.max_version_bytes); + assert_eq!(parsed.target_message_size, greeting.target_message_size); + assert_eq!(parsed.listing, greeting.listing); + } +} + +/// The greeting's map admits exactly one spelling: a missing or +/// out-of-order key, a wrong protocol magic, or trailing bytes are each +/// rejected — one spelling per greeting is the deterministic contract. +#[test] +fn greeting_key_roster_is_exact() { + let greeting = sample(Vec::new()); + let item = encode_greeting(&greeting); + let mut input = item.as_slice(); + cbor::read_head(&mut input).expect("tag head"); + cbor::read_head(&mut input).expect("bstr head"); + let map = input.to_vec(); + + // Renaming the protocol magic's value breaks the greeting. + let mut wrong_magic = map.clone(); + let at = find(&wrong_magic, b"rumors", 0).expect("the magic value is present"); + wrong_magic[at] = b'x'; + assert!(matches!( + parse_greeting(&wrong_magic), + Err(GreetingError::Shape(_)) + )); + + // Renaming a key breaks the roster. + let mut wrong_key = map.clone(); + let at = find(&wrong_key, b"set_len", 0).expect("the key is present"); + wrong_key[at] = b'x'; + assert!(matches!( + parse_greeting(&wrong_key), + Err(GreetingError::Shape(_)) + )); + + // Trailing bytes are rejected. + let mut trailing = map.clone(); + trailing.push(0); + assert!(matches!( + parse_greeting(&trailing), + Err(GreetingError::Shape(_)) + )); +} + +/// A greeting listing violating strictly ascending radix order is +/// rejected as the codec's own order violation, the same class a wire +/// query reports. +#[test] +fn greeting_listing_order_is_enforced() { + // The encoder trusts its caller, so an unsorted listing synthesizes + // the wire violation directly. + let greeting = sample(vec![ + (9, Hash([1; MERKLE_HASH_LEN])), + (5, Hash([2; MERKLE_HASH_LEN])), + ]); + let item = encode_greeting(&greeting); + let mut input = item.as_slice(); + cbor::read_head(&mut input).expect("tag head"); + cbor::read_head(&mut input).expect("bstr head"); + assert!(matches!( + parse_greeting(input), + Err(GreetingError::Order(QueryOrderError { + previous: 9, + radix: 5 + })) + )); +} + +/// Find the `skip`-th occurrence of `needle` in `haystack`. +fn find(haystack: &[u8], needle: &[u8], skip: usize) -> Option { + haystack + .windows(needle.len()) + .enumerate() + .filter(|(_, window)| *window == needle) + .map(|(at, _)| at) + .nth(skip) +} diff --git a/src/tree/mirror/streaming/remote/codec/signal.rs b/src/tree/mirror/streaming/remote/codec/signal.rs index cd93bb6b..19552219 100644 --- a/src/tree/mirror/streaming/remote/codec/signal.rs +++ b/src/tree/mirror/streaming/remote/codec/signal.rs @@ -1,4 +1,4 @@ -//! The dense signal byte and its semantic components. +//! The dense signal code and its semantic components. use crate::tree::typed::height::{Height, Root, UnderRoot, Z}; @@ -178,7 +178,7 @@ impl Flow { } } -/// The semantic state carried alongside a stream id in one signal byte. +/// The semantic state carried alongside a stream id in one signal code. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Signal { Match(Flow), @@ -271,8 +271,11 @@ pub struct WireSignal { } impl WireSignal { - /// Bytes occupied by a densely encoded signal. - pub const ENCODED_LEN: usize = std::mem::size_of::(); + /// Widest head a dense signal code occupies as a CBOR unsigned int + /// item: every code of 24 and above takes a two-byte head, and the + /// code space tops out at 169. + pub const MAX_ENCODED_LEN: usize = + crate::tree::mirror::cbor::head_len((Signal::STATE_COUNT * Stream::COUNT - 1) as u64); /// Byte values occupied by the syntactic `(signal state, stream)` product. #[cfg(test)] @@ -288,7 +291,7 @@ impl WireSignal { Self::pair(stream, signal).validate(speaker) } - /// Parse and validate a dense wire byte for its speaker's protocol phase. + /// Parse and validate a dense code for its speaker's protocol phase. pub fn from_byte(speaker: Speaker, byte: u8) -> Result { Self::parse(byte)?.validate(speaker).map_err(Into::into) } @@ -342,7 +345,7 @@ impl WireSignal { } } - /// Render the paired stream and semantic signal as one dense wire byte. + /// Render the paired stream and semantic signal as the dense code. pub fn to_byte(self) -> u8 { self.signal.state() * Stream::COUNT + self.stream.index() } @@ -355,14 +358,14 @@ impl WireSignal { /// A valid signal state placed on a stream where the protocol forbids it. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -#[error("signal byte {byte:#04x} is invalid for {class}")] +#[error("signal code {byte:#04x} is invalid for {class}")] pub struct InvalidSignalPlacement { byte: u8, class: StreamClass, } impl InvalidSignalPlacement { - /// Return the rejected dense wire byte. + /// Return the rejected dense code. pub fn byte(self) -> u8 { self.byte } @@ -373,7 +376,7 @@ impl InvalidSignalPlacement { } } -/// A syntactically invalid signal byte or a valid state in an invalid phase. +/// A syntactically invalid signal code or a valid state in an invalid phase. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum DecodeSignalError { #[error(transparent)] @@ -392,9 +395,9 @@ impl DecodeSignalError { } } -/// A reserved dense signal byte and the stream encoded within it. +/// A reserved dense signal code and the stream encoded within it. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -#[error("signal byte {byte:#04x} encodes an invalid semantic state")] +#[error("signal code {byte:#04x} encodes an invalid semantic state")] pub struct InvalidWireSignal { byte: u8, stream: Stream, @@ -403,7 +406,7 @@ pub struct InvalidWireSignal { } impl InvalidWireSignal { - /// Return the rejected dense wire byte. + /// Return the rejected dense code. pub fn byte(self) -> u8 { self.byte } diff --git a/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__bounded_corpus_manifest_snapshot.snap b/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__bounded_corpus_manifest_snapshot.snap index 501e88d9..5ee59f0b 100644 --- a/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__bounded_corpus_manifest_snapshot.snap +++ b/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__bounded_corpus_manifest_snapshot.snap @@ -10,363 +10,363 @@ Initiator QueryEmpty(End): cases 1 accepted 0 rejected 1 rejection Some(OpeningSupplies) digest bed1553e944c1f60caad77749acd1505d7b760208777c714f35ffdc0f2de766d Query(Continue): cases 32896 accepted 0 rejected 32896 rejection Some(OpeningSupplies) digest 9ba562d04ead543df788328565bd2f0dc9d4471e6e2bf7be09eec7d061dee9af Query(End): cases 32896 accepted 0 rejected 32896 rejection Some(OpeningSupplies) digest 387698c6a8f6cd20537f989b769a3033ffa03c71aaf4854dc14f2010d5673555 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 59b5fd7fcfce8862da5a311e322502cc8fdcd5b1ead61bd8e6b6e532bf774ede - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e84ed624d4c85c71009ba4d06f28a9b3c251d88711e3ee37f9c5877b511222ad - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest e6c8657a09bbd68e3a4410183521d0dc564776c29489da7eef314c391632057b - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest c6137a2a9996157c93f59583278135d535fbd3d5439da4de7af076a1a699dc2d + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest ef42a1f4f55d67a16c4425c0b892fc4d8ee7ef28b2745d077cd58f0bb5814980 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e4409433cc158bfb054b2001e551041927ed7c45e10012eb5d3309c841a78ba9 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8f78ad754b008c62ef8f7e0346ece60c634bf8cc48e00d8d8ef118aa8faf4f5a + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 122aa7dc19fb16cfede3a7b7b70eff5651b16ae1f2aba2acd02b4820bfd67d95 stream 01 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 9bbd1097cc86719184aa34340aa3f2c06a63dafb37b86914d45f36563186235d - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 45ee114c5b1f0ed4b48840ede520d9c80af340fd119de086f63b8a976a1ec742 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0bb26c70fe8f5bf17d6645b50caba9dbea86edef3b92e14f585637a64ed0f548 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 066871f0d0267b1b42edbb3835aed267fd755c6f7a819c1420def0671f62628d - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest af9a3ba555a3bcdfa2108b28f6539d8620aeb2eda089bfb7896820f98ba0a827 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest f29eaef5dc2c87a94692e1687365ee51a341ad822204604da619148f4f742247 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 319b28c9c064036c6f2b1e30f665b32d6061b2f03a826d934a56845924030837 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 4dfd3a14bb679a3f3c72a3bac874d2ecb167299fcf03f1543fcdd49d76144db8 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 7efb04b3cd8ae0a43ffaaf027a7d1ae31f033da458ccf85ed6339227bdfce8c2 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest d210087cbc67d20f09c0fda1159b051ad38c629fe9783dfaf4e0cbbe985ecdcc + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 822e221183582cc39ec93269c2f05600f45f6257060a6d7ae3fa807ffdab3bfe + Match(End): cases 1 accepted 1 rejected 0 rejection None digest c7b891e63ff7694bf97ddae67d2d58f763785ab525dc164923540e714a136b94 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b514e4add70536373f31eb2ee2be3e2955c5c830f6e9c2d88368303d08c50c2f + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest dcd78220f73334462fc05c0092732081ce054f8390d6deae9478413ebc51c4b0 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 492c659c7e07c17f674abb88954a176fcf35038e0c8c2c1552a895a21806986b + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 154a3788c85d4a50eba9328b8351ad401a987d7d6bc2986f937f2afcd880285f + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest f812816df7b5bcc7158910f89f3493fed4e09e28a39fa7ae6e6986e1b6e877a6 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 6bb66af11167bfe6b41ff77a4eba9a8dc8b77a6079c0818e84c0d99c1f9b5295 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 3f7595c05aa461b3adfeb2720b3ea00bba3bf16236e5dc8dea498ef648d64d11 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 5a6f8915e99b97a83b5a0b30ae413f0ebfa61fdfa177f656be1a95ebed9385c6 stream 02 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8f3624873d4b068a073bda3cf2e280a5ca195bd9066d3b2e0b9c6156a33a5e6a - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 1f286c89dd40d9dd0a323fc7a4aa9222aeeff0d82b0cdc1e337859c7fa57249b - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 7ceb016d4177164a41f5315046fb7a104e7b402b8544076656fba53cdc7c5b8a - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest b232233fad61fdfe489b32005295b43142c3da73df863366f532cf4e04dc0960 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 6c6a82a4201c2095453ffcc81b644af49180d33c49a958cbbd221bd259a931a5 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1d2091676cd8510ae281d0959ba783251c31b696d764c878213a67cdddb2fe00 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 97b2682d961d4e0fbb9ca8093663428d6c5852e37baa1905f652fde30e31b0b3 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest fc1d59d32a9266c1a77486e6d76a9bee51d1bb4cd943d380038230f42b9dcf4b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 5aaec826e8f1aa4bb1dc7f60095baf57a5b3cb9adcb0a4cf688407b0e7b7347e - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 2221d8b7741c597c23a781ae3a4315325660af324d10d667036b1c22c70fd4ff + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8da023b966a9819fd558c2276909fc87966cced0c324a13db00489c2a1d4db1e + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 3a166443f96064928189f46632d85d880cf414706aff03099c3e5fb7ef706047 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0eeb109cb452c2417fe4c9d280f3e435bbe8f7a0ade330d494415fdce47724a8 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 71d47af427367e34a0420af2c849bb06c38db6ca107c725a2f525038485387a4 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 00ed3d0aae4f3ec8d63e223e67834db5dfe001390e6fd2db5bf76ad288fb18fb + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 58bd9c9778eff881c835a53fb270cc781bae4331ca767e54b9ddb6c4a629176d + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 217373b0d58da1136bcce08bdc1ed633f939c84ea1d4fa5bc56d69406cb6b26d + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 97688245e619012a49a5043b1f4f45983a0ccf57481e071867d37dd9921ec24d + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest aa8428f399e0244514b082aa56c9fda2510488a978a320028b219050c8e4c7bf + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 8d5ad0ff596aebc75fd7a0435dac0d8e7f521278f3489faccbe2b5bdb7de7be1 stream 03 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 66fad94ed67e1ffe5a3c03b47e195ecfd7fe08e96e933146ab068802c62711de - Match(End): cases 1 accepted 1 rejected 0 rejection None digest c85c9376cd1443e95f4a955c78ce5bc0886a3886744b6c641db8640fa471e6fd - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest ac1261c496af02319a1772ff71e9813a3b4658b27bab4d1610fba06196b9ef51 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 157b7b3c36e30debeb2364b3e9c29267d936ae5b68ac616aee5fc533a24b52fb - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest c51172601ee20ac3a04e5a32720d521af1af3a90d29b71d6337acbafbe5fc115 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest f427d3759ade3b024bfc7f57189200fbe0e220ecc832df29864cfccb0283a740 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest e3525955c5573149fbb36710cf3cca454db1eef8fc2d7dfbfbadd08096b6b14a - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 9f920581eb1636ed6e52b37f50109b03b6010e431e7173a0af29bfda972f1f64 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8dc4e0a82ef1dfa3953132cd6cbe54650221b73a98162927793d488162a38fac - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest edc61faa00b781b3d047bab5809faf0b1a74bdb20f202712d9e5dbbf805525b8 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 18362a39b132ae13a3ef04803937260cd185f761b2147d2506202f206c05a611 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 2796a215960684c415193f031b414fafd5054a0876fc885d45b2becd4b2333ac + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 93d46dba804ced90724fe3100b67406bb81cb40b17aa5a8e846de9f9723b6a76 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest a372666b42bde5f3679a47b32d5846a9aa427dd3d3a3b11571c8c5c9bb74159d + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 181528afe22b0e7986dfeb8ff683e1b0c039b03804fb5da51e4a0d0128dde6db + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest b428487914e610ac8d2385e50a29bfe26a08a12bb71c812fedbffd824610103b + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest e3d0d3c889b924fc0d81f7c8cca6ee888baf3d0a9578c79eeedc46938a028d76 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest a2d2469022d06234ced10ab8ee31795662aed518588c7af109a8f4e41764e968 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest b9031379ac21c36c75ce35461c068591fa0acfc37dc59e0bdb5c198a51cc2436 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 624110158fafc1bc489ba666d6f3cfcb1c42e88948baf16de09a942382a9ed00 stream 04 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0aabcb6009a818cb51cd563f227fc5100ad87c2cc07e8f95ee952d3fd840c3eb - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 43511d45dc52dd6d8f66c218e6bcb8272bdbd57a53a228670dc8e999cb99b8e9 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b435ac27a6a88d353d269371500517496cf8f3cc386895cc8ac3059abd1151f5 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest bafaa10bfebbfd58e6dd9555c38d68f074a88cc7714d5cda3a8d4932990cd87f - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest aa3e0335828851588f14358567427c9634d7463286a2264198060a3d9c188e11 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest dfc58272001597c14dfa88433653257a8a3b12091c74fc18f40c6fe3c74a588d - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 9d263dbb054de831ed5346a3301e63c7e338b02a834d0db1bcd11d1eb77105ad - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 4d2250bf01b9cc3ef793712d67669ccc61ef5c42984841d578bfd2a848b5331b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 964e4c82824016b610d97562de5eb0d355b4d17cf16acb220581550c2cb08201 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 09f3d01b5a48fee382807fb80ccfae7e9a2aa167b28ed706e8cd6e536752e14f + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 163d3634fc1418cc68a6632a5bf272b4e4d13752e0ebd8add7928e88d8fe3c84 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 116b0f61934959f642b115f3a0695318b8097c7e1cfff1ace24c8a94f32fab0a + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b418d391ea72a381e693c8c7501adaf436976974b307f000547de7f0a4eb056f + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 7b428726940fb374efc9da2c7f1ec5eb99a1eef0d4873b886e9e02e5718b0c24 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 4bdecfce7e2f1db9dc54b3f203871bfc44be54a7b9a73523b5026e4554377a65 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest b17a10c5fab248a0a35dc69aee60d3941af501d2b73377be8c9b4212777d917f + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 112ba83fa34002a0d82c9e67586f92a1ee7ed2c801738c666d212549609399cf + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest fbe0e0af3533eb75f617af3aa735816a7a88a3b4043f365dcc771605a1829282 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 0745e6e4b9d144d37c7dfcb1fd8c3b20bbfb772ee9d7d9524896c836bb7a2c07 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest c336003c19e3d38c544445fa47a491cc0c0bf38267c67a53205bd0cdefe69bff stream 05 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 775ed8cb20fd4d5405bb3d83b4c81a29738bc999c1341d2c4ec38a96e9c2a478 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 0d6f8784c353242b50acc993f6af93394b09564469f4976a8ecf3809a4b7c340 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 716e53ce1b990161233ac6562d3672e38cce0a91b108ebe915e2362216af4d79 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 41fc3692f19ac323b3b2d7c8bd0307425676297f8e0bed6b221753c173461fd0 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 75abb3accb666e6145d70c2d102fdd6ce5f7ee38cfb7d4a33696051837bedbfd - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest fe90890210e48d3fac30d24e2fb66ad2a6273e6d8b67cff578a161b3062680c0 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b528e65d2440e583aead617a94d194f56699e05e71235a9229f907dced0a9b79 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest df3b29ef01094a59fa27126fbc554570a1b89e4be9cbd9f525279aefa812c774 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest f42bf364254585a59063ca719e3e466fabdcf8bb4de407f05c44bf8c1b75b0f4 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 37008b172b7470e27c3df9f915d753b7a1c501bdab556c600eddff42c51e5df1 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8b2bcb0aa72efc91a4308a50158458b40c34a5e6298420da3cc4436a1a7438c8 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest fa929e4b8d272af567fcda22b7c09e8cca387a9b4e5eb6cf108d05224e568a54 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest a8feda23c84986de29557cf75e55cca962a9f421a81bb2fd611d73f272b5e411 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest e36e489f824ce1a98b5efc940e70fd3d736138760f2bba5f0b69127edbdf77b8 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 57160278519ca5794d4b9d5d6cc5c4c83112973411c5eadbb4e306de0a578925 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1312217b358ae3a9a3ecc386ab11f025d074112cf2bce302422ce08836689d75 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 4091eab1ac41b4c8a07bcb9daa72e9d0dbfb679978902f531b3ffcde71ee56b9 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 4798a3f3ed5923953ed9c4bde3eb03ad5cdb6302c1259783695acab7afb17e79 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 674d77ceb6d82027d98029a77aca2fc7b864800d20c83bffb0e1ffc67c303f6a + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest d2de9dc2f96e7d6b3d5fc7946b7038862714c55cdf872e100e18c7687a460b50 stream 06 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 19dc8b6508fcb25bd215ff98745b10a0226d0b7802317ff23693170f6b69e2bf - Match(End): cases 1 accepted 1 rejected 0 rejection None digest b3f847c2acab7165aed0ab12dbf8095ddedff4da4f27b75719e598bd7a1bc9d5 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 05319d1eba637b4941839be91b827a4e17bbb9ea997a9de62021e8c51d44f28d - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest e11362eed2d9395eae0b5e66d4adf8e7b26d5660c549658c61c500fc61921a97 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 14a04b8700e66157eb28db77fc19075aec4fb93ac3b4cf7ac8f4a7dc6d195548 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 9095d15c02ae8055927f39ada986d42a974f1621d7a97a48f22619dab7f51eb3 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest a2d7ee70ef3c9562a15d7077586fd0d24b4d8f81db5a4fb9869782458de72fb2 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest a4561e3b2a42f881801ad9ab5d79618254f5157ec1a50043c81d7a53f3245f72 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 29f62ebb0b44a04c6c29c315771602bf97d0e948fdfc9e889a3ec27572876e1f - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 127c84b8f7a667189b8accc3ead7f9d759231b6d5e9f4d521bf8a27acffd3e20 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest e4f7848df6ca1afcccab12651d9356f43ace95ed972a996669aec12de64eb8e2 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest a86d5f8b56f9221c2d5f944ee03ae4f5d1cf058ba32b2db7353cc01b2510fe4d + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest a51b552a13d95e1c413876140bc217d5dfb95132fdc83134ff19be18d1329999 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest ec94b9af7046d9cf87c300043e51fbdaeb46af865e97c0fab3cb09dea22f6cde + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 275db08be66aa5ef32a6747f2ec7bdcb9ef867f8f726ecb3e852dbe2cd9598cc + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 9697014f4cee53799c3890119160281f811c65287adb31221289bf3f184259ce + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 784f02431c5bdb551b5c2185f787bc5f6832212602ef363fd48910a3dcda312f + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 16c13cdfbb93ca755d7304932c5035a1575e7c82c67acc6cfdc377009109daaf + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 3a313910a93bb9c6fce179115ac038716b01ba7e924f61ea4032287c26da7c25 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest fc52adcaab5665aac8ffc0c4b20decec58547c5cf358cf009917f3dd21c445f5 stream 07 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest dafa9fae71d3877a3a621fcd8bf9f75723bab8f02d0dab6a6bb5ea6d608c2786 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 75d5f73f826776473c9d2a2560c371f3e7d0092b123648b34240d4f82c4acc36 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 074aedf4174f5bc56a17f56d49dda4403c9bfdf0ab9f7bff581fd3757af72129 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 46acb5c7c4557b55a31329576477e76c7a9f9a41fb45b8c5907ed7b9ec11a433 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 66a2f0ee0a7799997880a907305e992f4ac33bf602d96761235310a2149c9bc8 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 5d04b5006b60a70a1413b657e481c3ed6d0a75da18ecc410b72d19872a2b17eb - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 5e300b4cb071daa8574f20507a7da4472111351f1e740723f9b5bf8097b8e41b - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 0a562f5dd6f838c161f4f48188a8b8783acedd8f3938c965213ef59fb0e6d6e3 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 0abc151c61d1bf0abdbf8de84f9a373da4dd54f63d5ddde0d3963e68e7a1bf8e - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest ef47eedfe6e578be84ed170e1f5cfb0978893dda7ff8ea95c5e56c76947e5064 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest c09a0b577898409cda79efcfb32ef0892830769ed6ca0cf360350e8bc2f53704 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest dfd0ae5b28c32c8e65b61656edc45398602de1a83762a2094e07e2aa4c3314bd + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 506b9458de2178528097b78795cb9ccf8099c865801df55cb8be36a28dfdb419 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 472449d11e02b2cd60077313c13c45acea914d48ce9dcb247f09922494b86bc4 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 98e3af9d722bedd4b6b2e79a380033b1133fe0653450d2c4cc6734c92f8d0e6d + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest ba112066ab293fb2cfda6a508bbb5962ad7e3de98a38f4e0bfb9c466dd00f6fd + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 4c0fac1a9e9adcb0a6e0702b55c704405e5dbccd11e44d28358c508439272a41 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest b8a1477fb43bf17a7af0dd27866f013120ef5c4062811ac507d23886e399f945 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest b9848cca526ae9ad112553ccbf357526d94e7eb024067cd758d3d74d88d25282 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest a86c8046f0029b1ad34120d3f84167bbde755f794d9955f6af0b29a1aa6dfef7 stream 08 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest bd306ad277db2515cc49b306b36b9c4c499e6a192339b74c44fd61f598215e78 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 780efde07897d67399fa95cb91d9e08698bb571eb13c07f1dc7c808bea43ded7 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0e3fbd1149bcf46ed868882fb0fff74fcdd959d671835f6909e71b1ed95845eb - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 17f68790f5b708d77429611d19cade3367733a261a50c4b41037fd88026cbaa3 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 5851952e464d7e55c7f95efb554bc5613a5bdce4ccec542581674532a90ec418 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest e286e082de98f67d8a5e12fa2ce5d46de10d524b70764cd9de29d59be3b54c2c - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest f0b52c1605a67276d58f1baeb1abb4b1ec603d3f5d037ce404b8052298b13828 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 130042645e4d5f9eac83eaa1271585f1147b8dcce58bffd94aecc4cac6e96b96 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8bb8f6555c5bdf08019175230fe7e440d3ab688fa49909e75da4ac8c3e091809 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest f96f9bf58b82edb0d80376acd21f4f8b5458aeed6f63c82f79da08f83eb73601 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 76a538374c611ae0f915298a269e642bc343015d2c2200076d8fcbd61479dc99 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest a04abe29794ef9764b323839ecf5f7b82025da1b1b981cdadd59096cb619e5fc + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest f2d8b2e7629631594e1e896a108847a2bb8f30b61dfb36c965db44cb089c7fcb + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest a1e8eab09925fabebaa69dae25c80fad8878c464666782d3d06997659ed01b20 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest e78c3ea97c25284e115d9877aff71a71b456788785542e0e0999d8ffa5476340 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 879dd07d45e47be9a3e5165a351810fa677600872c012104c228229d913d6a8f + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b46dc4e98f83be2b7be8186e6acc499da2fd27016fc4a76fca24746d9fb036d3 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 92a7e230ad7aec2ef7ebd968f4b66855e789b1cddb155783cc626dabd4a1956d + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 50361a2982a505da1553321c4bf5ef892c86047553a5709af75be5ba68acd891 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 802d3cccb9510a00fff36ea14b439c1585e5a7b548f5492d159f032966dc0594 stream 09 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 6ff2d54cfbad7b12599d13898bcab4184ff9708ff7d135e6c86b86c51a6998bf - Match(End): cases 1 accepted 1 rejected 0 rejection None digest edceddeeeb01fa91218b35507effc6b6eab4ae1fa20d641d07724144ed9613b5 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 17edbc4a9820227254a08fbad0a5d88cc4aed289087687c1abea050b9aec6aa4 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest f730acb629eef6f410a2464a59b555340285b5a9f4fa7c3c726353ff38c8e538 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 7bfb131a76928337a39d00599979ddacea1cc88fbea1f251eabc7ce2130a16d1 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 137950e7ae9ab307972b518682a1a6529819980301e0c6b61869477829624ce4 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 138fd3f03e645cf6dbce8b55d67dfa3bee2d319611ccbd87fc5b323747ea818b - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest b19854ff51cedbf8a4e761891538cdd8a2466a8111f5a321f530c1c23c685b5b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest b1b6b3a0adeaf7bdf761e9a23a79f238199e020dd452c21e9f5aab6a1da5d82b - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 27cf3d734488f83f038cb377078fe6cc24925393b66c6c31fe2960a6107a7415 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest aa3ae45ca390d05aea43866b30f3c9ca3455b1f1b05a99c2547d06ae15874e3a + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 8f85614bfdf9ce7fb9d42500f8c89ae650d80ceb9400e5c5931530be1035ce30 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 17b32292a1292f52f825bb6dff8f66e938a85f8b123e1e2e423b16d8bea86094 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest b00698287e44e9fbcbba00c223a58bdacaabaf4b11def029b4decefab932889a + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 09a6d4d83dcae40376a255a845c3d47468f095e621c50122512c1d7bdb8a1271 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 99b3695a660ddec193b6ea2a212e44f3551115cc49d10f2dce461aed5bd92a02 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 85bd41349fcd3affdd14f6d8f854e7c8824ddc7cb1d9eac780a4050e53d38543 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest edb4ebfa31892e0874d8798829a6f5059853820d9988620268fbe41658f01627 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest ff93c31134942756e47063b116c8f2081f1f6605c90214f6f46b7798774ba604 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest ca69134ef23f6197177d7e761738644763e386ae540238e7ac2a6adc2b295e63 stream 10 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest ca44c3bce73a5610a872a1d0303b56a23cf24ca37777c13e6a24a87d1cfc24c7 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest b54ad413a5a73d9c58159d8ba395a38e0f10aa9a228850241c4b0a86b3f7c060 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest d74014c3b3088e17e6d053c05d819d7cc2167122a4ae5f892635440d69384884 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest e74e1793309ec1319e8638cf40ea30ecda33dac3ac34e039080e2c6b0fd9f4cc - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 1a0630fb34fbcf3cd72eb700271d55cb72d1396cf043d7594b4d011e06de3d65 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 16d0104cd8cc53d64ee80959c406a0b544dcf97efc2a8d4883b8372bdb8107dc - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 09a2b373f6a05b7868f0b62badd9a7b70485c3887d07f3704e17287a495bdd0e - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest cf53b4f42b6df5f38a07ecb6dbae883816b89d0776d27fd781f8fa08105c0409 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 63c674a610aa789bcab890eb7b995a513ea52f2cb4d35a3e773836850070c932 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 299a727b9d68956daf0449e6984cb16c4b2164d84eb1458c06226902204b6609 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 104bdf253fdd338b6dca9ab6c5e85eff63b2d89dcab038fa3b51037b50241dd8 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 1252adbe0adcb6d80d6c49e5f42328cffca047b1051f0db4156b495a7ab09c64 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 159d7594519afacb1983e0e1342f0c1d9a1690df506f242f10f325ab913ae6a5 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 737ac4e0e149b3e6035639cd463ee58b93665b608ded034b0bc6fce2fad7b22c + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest ca92e8a0028ceb29d923afb1756e1b3a2cd2d6612a1b94275505343fde2c9ba6 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 38e0024a61e3ae9e82824f8cb39f7306170bf83b35ee25735bceb74c3a370f1c + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest d7f15e05b6b502dfc48e1b450586c1a59f45cc752db762871a0f25b60e80aff4 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 1c86ba62acded2176304804fade5d64040d0bb166865de53ee1be8c782dd5ba3 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 1980296bd259e73290087b9a647d9fb23fdd89126b703e5a0b787e409172c979 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 9e61f33b566ce703dafbe5bb907defdd2b953e4fec670e1953541f43a70f4f08 stream 11 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest b686ece8f076664ffa0165733e0500ee836edf985acf0b484edbdfb613e6563b - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 4b32a58c8dcba7682ce0b04152d5184e48f255bcbdde08d728ab1dc6ef5aa719 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest dd82e4cf4daf7c19991aa4c7bd1bb24e55eb7b73cfdaff3444b3b1bbf73a2694 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 851a1229d333e606ec2aeb7fffe0f817662f5f010010af9b62ae437d2e60f4d2 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest c930467f2aaeee953347042808ebb417d92cd89f56335f085d0e468f33d61ffa - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 20b37b9884de14193cca46bda29983412944b68c277fd6930dd5f60df04f782d - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 5611b1ec26af613d14ebb77b8a17f0efc88b9c5da0c29776e9692aa12ee2af76 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e504ec6c7b648cd9e1a489be7954ed0208ff6cfb264e366ba0a300da9c1820f7 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest d8157eee870ca226593b505a153b951f657311ca5471860ee29e67aa548dd58b - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest a1351b92d9e69aaaee98b9ecf17a33f6e3a9e0270b149290f2d45cba7c6d6149 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 672eae71bdd18bb1ed4805dc282787ce7a167b895f0134c9df4e29dad42d0e0e + Match(End): cases 1 accepted 1 rejected 0 rejection None digest f70ab3dfb26e0ff7b11a6fa548081c047fb8c4c7dbdecd07b2eb3997483883be + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 99838e8b827f8033b1aedcbd3350fb3bded51219357fbb6c574a8c39684034fe + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 77127117955c46c913e999393886d24787a4870cb61fe3055fb8f4f2713e40c1 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 546ccc9d71ca5a4561828595de3f8454fe91877e56eb132f34f421b760ab8961 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 411c31511a1c718afe1f86682afff7fb59ccd88b1bfdd01ef8fcfd0bc284694e + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 1d28a0b5f2e76df4b27e36706fd7077a5c7d1ff0ea500788e0cfde7051dfebf3 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 67642881fc7ef3be06590df5430b90511638f1aac15aa74c924e5b92ed80d7a9 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest a04832f9a32bb370e59ef7bcf4ab2e97b0b621b06af0c84b8f68f37358aad309 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 609d5452aa7e0a88cb9b8a5534681c062b92167792e1fb4521937b6ce3f3e7be stream 12 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8d2fb9fe08d4398ba2bdf41a1bfbd61f8a623a94a0997cc110425e2a32089fd4 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest cadfaa6a713d548a63d259d1aeda96768bff2eae82fd1d399370bffc03137613 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest cecec5d2cb55bfcccd6f0d48be9dc21064ff5e0f63d7649e7f26df156d536f2f - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest a31da823b3c1bb429765cc210a258f220aaa8c92df9673eb382fe12821b111ac - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 254975c65f08456de3e7be51da5139736a55a1b5e7e9a6defbaa7a6dd95a0523 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest c54fb8b09ef9fea57ae284009d05a41b8f993e06114b4ff918d8565b2bb43d3f - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0d05f27f04bd9868452c564d84baea9c6ce14927eb4ce469ab9a6246fa26d926 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 638ae22a8f314323aa7353af461430680e1fafa580f11ac1f73f21fc1e8d9d95 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 5a3f1429c3d154082905a1e51ffd43a59cc80a48fb38e85abd61598fdca8dedc - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 0e99767e69250ac6fcbfb7b4d9c993c83ecf9077dc80bd989d10d8e16687c1a7 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 6c298f19bca2e8078524cedae611cd2673c62ee97ad06e62243d4db52358f7cb + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 65803d6519e8009a73c56b339a9f7ba46f1285b11c09f5ab4450354c39bf8490 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b92a07935202be27bf25b5c9bd51735c2060ebbd6c67c61fc119e8f8acaaf986 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 8d1ef8402eb7a9aae43d4334f297341e8de2efa2662a53c309fac0053d04b365 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 4b506718dbf3dbdc49cd1cc003390ae1363ec0d71a7825d979045f44068e9ca5 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest e6aaff4e35949e4a7ff0a967d344799619b6e170295950c1bda9b267748ade96 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 7f648e2f9a661f83059182d36099f8c071675e727ba7b9b8d7d0336188695173 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 229a363b7bde06285d9df455c6eb65af90935aeb893613a98c6f2e485f57c3ae + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest bd9cbd15f7105c97ed69e1c3325d12268e1bc85725d3f944ec6de84fa8270aad + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 32af3bd5bb0b69e0ce179a770269a7418177fa563e8edc44df1c01f7cb2204dd stream 13 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 059088ab453f9a4ea6c1bda93d62172ec77f9e2e27dbf1aa355ebce4064ecfed - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 87a00b397cf046dc00eb333155428d922229a4dbe8c97422902374c399cf0a40 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest f9ac3b457d07b5344335572c99e15dfd8b4ce1dbf74d20cdf45ff9d132f8ec62 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 4fd9741405c8cc1354022519f755d08d511ea2455235de82b292d0bac0f2da9d - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 3c5d1011b48719d8d6b69ecbf983be0b3fb6757f0c4eea62740babdf03888f0a - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest e5abecd847278d559eb1e90add494e476aafb8b8a7b6da3adc46314c003c3d91 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest bed90570bb0963ab5526ce6e55abda3be957893a5177c318819af3a7f46e29b0 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 7d73dd645bbf5331ce6e68dcdde05bf0dd9057592b8d5d02c651e8855abbdd6b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 785f77fdc47845b843501b702d0260af3ba977a343a8763a874321e7769160f7 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest b1b179df568a7c34c8d7bf87203c161ef88e1ab38ca0d75e6557fd3527dc11a7 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest fec35e1cf5737ab3dcd034e66551e32b381cb672291b64f6b0140d616190ce83 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 4a1861f34feb55ddb21f549739bd5666dd8fc27552f3a3173baaab2eb34df19d + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 7190a38fff28dca89d3aadb7d4f07ab67349fd3448877b469ef32bcf9af7c8ed + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 1406117ea9fa975564ae00ee94b0c99cc479fd72616f417d1b5901b525529cd4 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 215bf8bb454b8bb072d8e980f85570b12f49a7e3bf889bee8773b54b1aff5184 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest eca762246a893da6ade59d27aafc0da76e88cd7d83586ca687a0ee6fc689dcef + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b2a369f93b2e2f1aa801ecedbf76c018cd5e9e54dffdfa368618b7e23ad6099a + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e4d7c0752bde967a6a6c229128f2473713cac168302411d2068692ed867a4f8b + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8febe5d88db3eb980d967075e77a4eacd709fadf23104dce8fbdaa4e771f9094 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 68b3b4adc4d89d691374d0296f39d9667fd4b6206f1a203e20d19a37fa73e93d stream 14 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 412f432fbfc37aa305017091196a1cf08cdcfb58286c5d67a5879a7adfe41a98 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest b926b2fa40df45f59a10eb48153efaba5c5aa388262e69137f571acab8678ad5 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 2d87757635342d0fd27aff56df18325535b7886318a1508f3dadd6a540a30e9f - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 5d537842e1e44c2fb2663485c69818515a5d724e84ab275eeeda1534a66b326f - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest b0adb97cf6e8fd75a3d6eca5d1ba6a81b4e9cfbce99793d9374bf35e6a53f756 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1a598804994f12a0bf7db52814ab606b664bb716c6077797d25ca28777067d00 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b8637f87ab12074b33dd6ef0cf82da1a1e576167190c225c72f9578963208c00 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 28a6bede5bd63c5a42a5f30e4f7dbcc3628634af2962a8e8839ae4b9c29f604d - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 2f182802ac4c754db7b1e709ec1e2c62fed5a070e2c068faba47bb7964d3f4e2 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest f5c41bf5e61ad5831f4929f863cfa6bfe71fa3b6ee9b27a21e7c0a7595e357b7 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 16131963006b632de280a07deb46ea192c15d2dc8e25ddf274461261ae0ebca3 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 21a867cfc349c464b9c79771ff5ce115a7fb6c77c69eab4333c654d1617ec112 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest cf8f33059852e53a2e2e01d7106399ece848983621f1dc666fb7d0c7ea6561e9 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest f9e69834a2d2b1a86a0bc36b29a8cdd8d6af1c4d59e93c2fab12517ceb7ceb6f + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest b160156964892a2f2f7a872f1c2702fc6a8ed893ea3eade80f4a74931f4043f1 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 22185f43a8a2836d412f0a7498ecc7dd9194720e2056c8b59bf95334de525cc9 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 3c85c62b9b1dab4c45bb9c9af63ca1de990e12d5402e2341e92d22703ed27095 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 67070ac2787a5aec30323b7b1fd7e45c7d018878de75503d80e5e42ffb7f52a4 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest d95cc1a17f764327b1a5269a0ade95b190f22f0d68b801023394934525457971 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 71e233055c53dfe2ac77e4d9cf92dee19af492e82c3e32d7eb93dbd28d4eac8c stream 15 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 857b425150218962c0a7548ad7867d0223631dbca974fdcc84cb94019355c7ad - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 95287e0900ba61bf3efa683215b6b74a25af987141de2f338f74b9e9089c9694 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 98956d902b7f9ded95fe6659d503239a457ff8e0f9557ca5e48c5f3f73cafeb9 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 627c3e7aff204f684040fcf3fd123b5c9cfb945c9aae4167b12eab4723b953f0 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest b8bef3ff0962d6a257250d4bb4876d9f1d9aab401cd818b3e9a2c9475e1ec67e - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 97fec0191ef2a75b3d6440739172679e85e8c853f502199048fa9c34fe479ad9 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest e2a690dbe15e9f6276788dee265fa948b186a5904698d55aa72c1ebf49bd7b9f - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest b10ff7406dbceaf57f9edfd790369eda2149677347a3bc5a15226dbe0928d0ca - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 033a9d6c6cb059c17270280ac6121bc6cff59ff0b452c49cb65dce6f55e992a7 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest d4fc5053bcf8daa493d15dfb6750582b5efc3690d8eb3260ed4645e012789c4b + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest baa489d341a4a2700fda70bf6886f5b9dab805820201010b108c47ee303eba41 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest e01b98e21bd5c63e254e0bffd894935132c4d19139611e842ece4c4b57365538 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 07248d0e233debd4957a759f5a206c0e19208e530dd0ae0b289433e0e4efdf89 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 350c9e7214b4d19dda13cc2a40f5b0cdcba493056962d9973e2197039614545c + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 15a6a6f14af85c5f40b3395c445330d1cc20676dad8257390e5e5e92a500b094 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1ea6e03cf037327f78a691daa80c95514ed71fa4475d0bb7e840af5f13cabf87 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest d1a34d662eeaf09b1094de8f6ffc636311b783883ff9218288ea81f66e685650 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest c90d18111ee0768484b386281a560fad062ba6f8f11871256f4fc3dfb2830e76 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest c80c79a90c901d855e127dc81690b9357fd87a290cf7a856d4618c1a54cfe7fb + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest a76debc09aff00b2f5343ea22224544c69ec8793774afc18a19783fdeb5cdf64 stream 16 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest dd140b0d516a82a58732fd1284bd4f26da3912938fd1296195717826ca5ce6fb - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 79b37443fe6cbbb9f1e685ee0232170bd3cc3fcca8edbf104d9c304bdff15276 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 380f648b6b5a1cc408b08c18ec16e74342a0c565aeb4455485c6572a68178fdd - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest ba0d629849bd7c219c3ecb424e75a17049097500732e7887fd7b951eaaac4fbe + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0f38e4a6ee59a0d81330375a4753651b84d4bde276f933e9c62b7bacc21d5f81 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 214a0977eb69bc66da2e0e8b2271fddfc90dc522672e4b874c7ae782a5520286 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 22556a47a31956eb62f084419d74738ef09f8bdec3487bf53086ed20d06fca99 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest cbb70092f731bbb2184d4a3f34a809f1450bc9df0a9a6956d25ea4c71d350358 Query(Continue): cases 32896 accepted 0 rejected 32896 rejection Some(LeafParentReplies) digest d678b609a984aec0e82f6704ec78d2c1312a319c00aef09315cb6d5a97afd3b3 Query(End): cases 32896 accepted 0 rejected 32896 rejection Some(LeafParentReplies) digest 20206e46e9569954fa7409adc1466295dd78517ee9b1f7a6dcb931af522b09f2 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 9f508aa9bb69f2b3742ce4cd27f52fa10dc7526ce8a0f529d4ab3e922ca0c6c2 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 29c688db20ab6a6a85b8a64ea17a0f0c9e3380c16c02a687588891c443cd6664 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 7a250d61fe85b397ff21c6c0d2883c934615f0c59d3f7fa92090f2bbc974cb74 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 69e0a19208d6edd30e31916235b05fcd9f3072a9f93668a49e7cd2d971621588 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 1960aabcbf735cde670759145a15dfe46bd9f6593c32031ad4254a60ff292be0 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest c7350f8f4be155503abf5f41d5d6ebb144b3c4068d4d41c66fb2d8aefb31c346 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 7257d21c0f045fbdaae74ddd1f9c1ac52ac58d85f6b2efc24a92e2c2c7b48d61 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest c19c0586156caa168dd0e5703a80d90674d0554d1712c535f49d55172049b0b4 Responder stream 00 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 964e37b3c509df37c0b8877fca3051bae3efbe83abc4924c862811e703cc4623 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest e10f9ad1721b9db582acfaf071974eb6d3c339c7d83fd66f287e195c3e59e00b - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8e41452eb051511766f0adbff2c4a2e082df0e9fb452497e549fef8928461e2e - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest b90c5effa96b16776cbf6e9949635b52682c2c65c7f797ff18573310cd77339c - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 7c974c0efd6be446db31c057cb8a5c8ab3be81b87a9c0dbeb9c9465088e2a743 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest c1aa9df70be5616b950459c84c1da97879b7228dd5221b651b098cc4728d9ad8 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 59b5fd7fcfce8862da5a311e322502cc8fdcd5b1ead61bd8e6b6e532bf774ede - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e84ed624d4c85c71009ba4d06f28a9b3c251d88711e3ee37f9c5877b511222ad - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest e6c8657a09bbd68e3a4410183521d0dc564776c29489da7eef314c391632057b - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest c6137a2a9996157c93f59583278135d535fbd3d5439da4de7af076a1a699dc2d + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 979a4115ce0daaaf110b6885121b967b0a2d36b45807762760e5f62efd4a2a5a + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 1afb54dcbc2b3eebc3055161cb60e8fecda1ca917acf4b688aae43582e9fb058 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 2703d5de54045e1ca616761daea6e11f9b653d763ddfb8b8553c91fb0801c0d5 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest f5d3416b14d3741a3267b55d576108c33cbd64e7423f6ff3b1cae726f1eeaffd + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 4705ca15cb20079b34532900513f8f19310054e1ad65af8844c31c81592e2c8c + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest acd6ab89a95a54cbc3a3cbdf91a950eab963eca3eb27bc02d1792fc3b5c09ea7 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest ef42a1f4f55d67a16c4425c0b892fc4d8ee7ef28b2745d077cd58f0bb5814980 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e4409433cc158bfb054b2001e551041927ed7c45e10012eb5d3309c841a78ba9 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8f78ad754b008c62ef8f7e0346ece60c634bf8cc48e00d8d8ef118aa8faf4f5a + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 122aa7dc19fb16cfede3a7b7b70eff5651b16ae1f2aba2acd02b4820bfd67d95 stream 01 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 9bbd1097cc86719184aa34340aa3f2c06a63dafb37b86914d45f36563186235d - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 45ee114c5b1f0ed4b48840ede520d9c80af340fd119de086f63b8a976a1ec742 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0bb26c70fe8f5bf17d6645b50caba9dbea86edef3b92e14f585637a64ed0f548 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 066871f0d0267b1b42edbb3835aed267fd755c6f7a819c1420def0671f62628d - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest af9a3ba555a3bcdfa2108b28f6539d8620aeb2eda089bfb7896820f98ba0a827 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest f29eaef5dc2c87a94692e1687365ee51a341ad822204604da619148f4f742247 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 319b28c9c064036c6f2b1e30f665b32d6061b2f03a826d934a56845924030837 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 4dfd3a14bb679a3f3c72a3bac874d2ecb167299fcf03f1543fcdd49d76144db8 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 7efb04b3cd8ae0a43ffaaf027a7d1ae31f033da458ccf85ed6339227bdfce8c2 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest d210087cbc67d20f09c0fda1159b051ad38c629fe9783dfaf4e0cbbe985ecdcc + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 822e221183582cc39ec93269c2f05600f45f6257060a6d7ae3fa807ffdab3bfe + Match(End): cases 1 accepted 1 rejected 0 rejection None digest c7b891e63ff7694bf97ddae67d2d58f763785ab525dc164923540e714a136b94 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b514e4add70536373f31eb2ee2be3e2955c5c830f6e9c2d88368303d08c50c2f + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest dcd78220f73334462fc05c0092732081ce054f8390d6deae9478413ebc51c4b0 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 492c659c7e07c17f674abb88954a176fcf35038e0c8c2c1552a895a21806986b + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 154a3788c85d4a50eba9328b8351ad401a987d7d6bc2986f937f2afcd880285f + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest f812816df7b5bcc7158910f89f3493fed4e09e28a39fa7ae6e6986e1b6e877a6 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 6bb66af11167bfe6b41ff77a4eba9a8dc8b77a6079c0818e84c0d99c1f9b5295 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 3f7595c05aa461b3adfeb2720b3ea00bba3bf16236e5dc8dea498ef648d64d11 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 5a6f8915e99b97a83b5a0b30ae413f0ebfa61fdfa177f656be1a95ebed9385c6 stream 02 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8f3624873d4b068a073bda3cf2e280a5ca195bd9066d3b2e0b9c6156a33a5e6a - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 1f286c89dd40d9dd0a323fc7a4aa9222aeeff0d82b0cdc1e337859c7fa57249b - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 7ceb016d4177164a41f5315046fb7a104e7b402b8544076656fba53cdc7c5b8a - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest b232233fad61fdfe489b32005295b43142c3da73df863366f532cf4e04dc0960 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 6c6a82a4201c2095453ffcc81b644af49180d33c49a958cbbd221bd259a931a5 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1d2091676cd8510ae281d0959ba783251c31b696d764c878213a67cdddb2fe00 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 97b2682d961d4e0fbb9ca8093663428d6c5852e37baa1905f652fde30e31b0b3 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest fc1d59d32a9266c1a77486e6d76a9bee51d1bb4cd943d380038230f42b9dcf4b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 5aaec826e8f1aa4bb1dc7f60095baf57a5b3cb9adcb0a4cf688407b0e7b7347e - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 2221d8b7741c597c23a781ae3a4315325660af324d10d667036b1c22c70fd4ff + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8da023b966a9819fd558c2276909fc87966cced0c324a13db00489c2a1d4db1e + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 3a166443f96064928189f46632d85d880cf414706aff03099c3e5fb7ef706047 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0eeb109cb452c2417fe4c9d280f3e435bbe8f7a0ade330d494415fdce47724a8 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 71d47af427367e34a0420af2c849bb06c38db6ca107c725a2f525038485387a4 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 00ed3d0aae4f3ec8d63e223e67834db5dfe001390e6fd2db5bf76ad288fb18fb + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 58bd9c9778eff881c835a53fb270cc781bae4331ca767e54b9ddb6c4a629176d + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 217373b0d58da1136bcce08bdc1ed633f939c84ea1d4fa5bc56d69406cb6b26d + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 97688245e619012a49a5043b1f4f45983a0ccf57481e071867d37dd9921ec24d + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest aa8428f399e0244514b082aa56c9fda2510488a978a320028b219050c8e4c7bf + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 8d5ad0ff596aebc75fd7a0435dac0d8e7f521278f3489faccbe2b5bdb7de7be1 stream 03 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 66fad94ed67e1ffe5a3c03b47e195ecfd7fe08e96e933146ab068802c62711de - Match(End): cases 1 accepted 1 rejected 0 rejection None digest c85c9376cd1443e95f4a955c78ce5bc0886a3886744b6c641db8640fa471e6fd - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest ac1261c496af02319a1772ff71e9813a3b4658b27bab4d1610fba06196b9ef51 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 157b7b3c36e30debeb2364b3e9c29267d936ae5b68ac616aee5fc533a24b52fb - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest c51172601ee20ac3a04e5a32720d521af1af3a90d29b71d6337acbafbe5fc115 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest f427d3759ade3b024bfc7f57189200fbe0e220ecc832df29864cfccb0283a740 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest e3525955c5573149fbb36710cf3cca454db1eef8fc2d7dfbfbadd08096b6b14a - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 9f920581eb1636ed6e52b37f50109b03b6010e431e7173a0af29bfda972f1f64 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8dc4e0a82ef1dfa3953132cd6cbe54650221b73a98162927793d488162a38fac - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest edc61faa00b781b3d047bab5809faf0b1a74bdb20f202712d9e5dbbf805525b8 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 18362a39b132ae13a3ef04803937260cd185f761b2147d2506202f206c05a611 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 2796a215960684c415193f031b414fafd5054a0876fc885d45b2becd4b2333ac + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 93d46dba804ced90724fe3100b67406bb81cb40b17aa5a8e846de9f9723b6a76 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest a372666b42bde5f3679a47b32d5846a9aa427dd3d3a3b11571c8c5c9bb74159d + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 181528afe22b0e7986dfeb8ff683e1b0c039b03804fb5da51e4a0d0128dde6db + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest b428487914e610ac8d2385e50a29bfe26a08a12bb71c812fedbffd824610103b + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest e3d0d3c889b924fc0d81f7c8cca6ee888baf3d0a9578c79eeedc46938a028d76 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest a2d2469022d06234ced10ab8ee31795662aed518588c7af109a8f4e41764e968 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest b9031379ac21c36c75ce35461c068591fa0acfc37dc59e0bdb5c198a51cc2436 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 624110158fafc1bc489ba666d6f3cfcb1c42e88948baf16de09a942382a9ed00 stream 04 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0aabcb6009a818cb51cd563f227fc5100ad87c2cc07e8f95ee952d3fd840c3eb - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 43511d45dc52dd6d8f66c218e6bcb8272bdbd57a53a228670dc8e999cb99b8e9 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b435ac27a6a88d353d269371500517496cf8f3cc386895cc8ac3059abd1151f5 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest bafaa10bfebbfd58e6dd9555c38d68f074a88cc7714d5cda3a8d4932990cd87f - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest aa3e0335828851588f14358567427c9634d7463286a2264198060a3d9c188e11 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest dfc58272001597c14dfa88433653257a8a3b12091c74fc18f40c6fe3c74a588d - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 9d263dbb054de831ed5346a3301e63c7e338b02a834d0db1bcd11d1eb77105ad - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 4d2250bf01b9cc3ef793712d67669ccc61ef5c42984841d578bfd2a848b5331b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 964e4c82824016b610d97562de5eb0d355b4d17cf16acb220581550c2cb08201 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 09f3d01b5a48fee382807fb80ccfae7e9a2aa167b28ed706e8cd6e536752e14f + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 163d3634fc1418cc68a6632a5bf272b4e4d13752e0ebd8add7928e88d8fe3c84 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 116b0f61934959f642b115f3a0695318b8097c7e1cfff1ace24c8a94f32fab0a + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b418d391ea72a381e693c8c7501adaf436976974b307f000547de7f0a4eb056f + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 7b428726940fb374efc9da2c7f1ec5eb99a1eef0d4873b886e9e02e5718b0c24 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 4bdecfce7e2f1db9dc54b3f203871bfc44be54a7b9a73523b5026e4554377a65 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest b17a10c5fab248a0a35dc69aee60d3941af501d2b73377be8c9b4212777d917f + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 112ba83fa34002a0d82c9e67586f92a1ee7ed2c801738c666d212549609399cf + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest fbe0e0af3533eb75f617af3aa735816a7a88a3b4043f365dcc771605a1829282 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 0745e6e4b9d144d37c7dfcb1fd8c3b20bbfb772ee9d7d9524896c836bb7a2c07 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest c336003c19e3d38c544445fa47a491cc0c0bf38267c67a53205bd0cdefe69bff stream 05 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 775ed8cb20fd4d5405bb3d83b4c81a29738bc999c1341d2c4ec38a96e9c2a478 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 0d6f8784c353242b50acc993f6af93394b09564469f4976a8ecf3809a4b7c340 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 716e53ce1b990161233ac6562d3672e38cce0a91b108ebe915e2362216af4d79 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 41fc3692f19ac323b3b2d7c8bd0307425676297f8e0bed6b221753c173461fd0 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 75abb3accb666e6145d70c2d102fdd6ce5f7ee38cfb7d4a33696051837bedbfd - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest fe90890210e48d3fac30d24e2fb66ad2a6273e6d8b67cff578a161b3062680c0 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b528e65d2440e583aead617a94d194f56699e05e71235a9229f907dced0a9b79 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest df3b29ef01094a59fa27126fbc554570a1b89e4be9cbd9f525279aefa812c774 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest f42bf364254585a59063ca719e3e466fabdcf8bb4de407f05c44bf8c1b75b0f4 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 37008b172b7470e27c3df9f915d753b7a1c501bdab556c600eddff42c51e5df1 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8b2bcb0aa72efc91a4308a50158458b40c34a5e6298420da3cc4436a1a7438c8 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest fa929e4b8d272af567fcda22b7c09e8cca387a9b4e5eb6cf108d05224e568a54 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest a8feda23c84986de29557cf75e55cca962a9f421a81bb2fd611d73f272b5e411 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest e36e489f824ce1a98b5efc940e70fd3d736138760f2bba5f0b69127edbdf77b8 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 57160278519ca5794d4b9d5d6cc5c4c83112973411c5eadbb4e306de0a578925 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1312217b358ae3a9a3ecc386ab11f025d074112cf2bce302422ce08836689d75 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 4091eab1ac41b4c8a07bcb9daa72e9d0dbfb679978902f531b3ffcde71ee56b9 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 4798a3f3ed5923953ed9c4bde3eb03ad5cdb6302c1259783695acab7afb17e79 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 674d77ceb6d82027d98029a77aca2fc7b864800d20c83bffb0e1ffc67c303f6a + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest d2de9dc2f96e7d6b3d5fc7946b7038862714c55cdf872e100e18c7687a460b50 stream 06 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 19dc8b6508fcb25bd215ff98745b10a0226d0b7802317ff23693170f6b69e2bf - Match(End): cases 1 accepted 1 rejected 0 rejection None digest b3f847c2acab7165aed0ab12dbf8095ddedff4da4f27b75719e598bd7a1bc9d5 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 05319d1eba637b4941839be91b827a4e17bbb9ea997a9de62021e8c51d44f28d - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest e11362eed2d9395eae0b5e66d4adf8e7b26d5660c549658c61c500fc61921a97 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 14a04b8700e66157eb28db77fc19075aec4fb93ac3b4cf7ac8f4a7dc6d195548 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 9095d15c02ae8055927f39ada986d42a974f1621d7a97a48f22619dab7f51eb3 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest a2d7ee70ef3c9562a15d7077586fd0d24b4d8f81db5a4fb9869782458de72fb2 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest a4561e3b2a42f881801ad9ab5d79618254f5157ec1a50043c81d7a53f3245f72 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 29f62ebb0b44a04c6c29c315771602bf97d0e948fdfc9e889a3ec27572876e1f - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 127c84b8f7a667189b8accc3ead7f9d759231b6d5e9f4d521bf8a27acffd3e20 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest e4f7848df6ca1afcccab12651d9356f43ace95ed972a996669aec12de64eb8e2 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest a86d5f8b56f9221c2d5f944ee03ae4f5d1cf058ba32b2db7353cc01b2510fe4d + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest a51b552a13d95e1c413876140bc217d5dfb95132fdc83134ff19be18d1329999 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest ec94b9af7046d9cf87c300043e51fbdaeb46af865e97c0fab3cb09dea22f6cde + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 275db08be66aa5ef32a6747f2ec7bdcb9ef867f8f726ecb3e852dbe2cd9598cc + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 9697014f4cee53799c3890119160281f811c65287adb31221289bf3f184259ce + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 784f02431c5bdb551b5c2185f787bc5f6832212602ef363fd48910a3dcda312f + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 16c13cdfbb93ca755d7304932c5035a1575e7c82c67acc6cfdc377009109daaf + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 3a313910a93bb9c6fce179115ac038716b01ba7e924f61ea4032287c26da7c25 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest fc52adcaab5665aac8ffc0c4b20decec58547c5cf358cf009917f3dd21c445f5 stream 07 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest dafa9fae71d3877a3a621fcd8bf9f75723bab8f02d0dab6a6bb5ea6d608c2786 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 75d5f73f826776473c9d2a2560c371f3e7d0092b123648b34240d4f82c4acc36 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 074aedf4174f5bc56a17f56d49dda4403c9bfdf0ab9f7bff581fd3757af72129 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 46acb5c7c4557b55a31329576477e76c7a9f9a41fb45b8c5907ed7b9ec11a433 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 66a2f0ee0a7799997880a907305e992f4ac33bf602d96761235310a2149c9bc8 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 5d04b5006b60a70a1413b657e481c3ed6d0a75da18ecc410b72d19872a2b17eb - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 5e300b4cb071daa8574f20507a7da4472111351f1e740723f9b5bf8097b8e41b - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 0a562f5dd6f838c161f4f48188a8b8783acedd8f3938c965213ef59fb0e6d6e3 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 0abc151c61d1bf0abdbf8de84f9a373da4dd54f63d5ddde0d3963e68e7a1bf8e - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest ef47eedfe6e578be84ed170e1f5cfb0978893dda7ff8ea95c5e56c76947e5064 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest c09a0b577898409cda79efcfb32ef0892830769ed6ca0cf360350e8bc2f53704 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest dfd0ae5b28c32c8e65b61656edc45398602de1a83762a2094e07e2aa4c3314bd + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 506b9458de2178528097b78795cb9ccf8099c865801df55cb8be36a28dfdb419 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 472449d11e02b2cd60077313c13c45acea914d48ce9dcb247f09922494b86bc4 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 98e3af9d722bedd4b6b2e79a380033b1133fe0653450d2c4cc6734c92f8d0e6d + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest ba112066ab293fb2cfda6a508bbb5962ad7e3de98a38f4e0bfb9c466dd00f6fd + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 4c0fac1a9e9adcb0a6e0702b55c704405e5dbccd11e44d28358c508439272a41 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest b8a1477fb43bf17a7af0dd27866f013120ef5c4062811ac507d23886e399f945 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest b9848cca526ae9ad112553ccbf357526d94e7eb024067cd758d3d74d88d25282 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest a86c8046f0029b1ad34120d3f84167bbde755f794d9955f6af0b29a1aa6dfef7 stream 08 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest bd306ad277db2515cc49b306b36b9c4c499e6a192339b74c44fd61f598215e78 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 780efde07897d67399fa95cb91d9e08698bb571eb13c07f1dc7c808bea43ded7 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0e3fbd1149bcf46ed868882fb0fff74fcdd959d671835f6909e71b1ed95845eb - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 17f68790f5b708d77429611d19cade3367733a261a50c4b41037fd88026cbaa3 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 5851952e464d7e55c7f95efb554bc5613a5bdce4ccec542581674532a90ec418 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest e286e082de98f67d8a5e12fa2ce5d46de10d524b70764cd9de29d59be3b54c2c - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest f0b52c1605a67276d58f1baeb1abb4b1ec603d3f5d037ce404b8052298b13828 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 130042645e4d5f9eac83eaa1271585f1147b8dcce58bffd94aecc4cac6e96b96 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8bb8f6555c5bdf08019175230fe7e440d3ab688fa49909e75da4ac8c3e091809 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest f96f9bf58b82edb0d80376acd21f4f8b5458aeed6f63c82f79da08f83eb73601 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 76a538374c611ae0f915298a269e642bc343015d2c2200076d8fcbd61479dc99 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest a04abe29794ef9764b323839ecf5f7b82025da1b1b981cdadd59096cb619e5fc + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest f2d8b2e7629631594e1e896a108847a2bb8f30b61dfb36c965db44cb089c7fcb + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest a1e8eab09925fabebaa69dae25c80fad8878c464666782d3d06997659ed01b20 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest e78c3ea97c25284e115d9877aff71a71b456788785542e0e0999d8ffa5476340 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 879dd07d45e47be9a3e5165a351810fa677600872c012104c228229d913d6a8f + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b46dc4e98f83be2b7be8186e6acc499da2fd27016fc4a76fca24746d9fb036d3 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 92a7e230ad7aec2ef7ebd968f4b66855e789b1cddb155783cc626dabd4a1956d + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 50361a2982a505da1553321c4bf5ef892c86047553a5709af75be5ba68acd891 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 802d3cccb9510a00fff36ea14b439c1585e5a7b548f5492d159f032966dc0594 stream 09 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 6ff2d54cfbad7b12599d13898bcab4184ff9708ff7d135e6c86b86c51a6998bf - Match(End): cases 1 accepted 1 rejected 0 rejection None digest edceddeeeb01fa91218b35507effc6b6eab4ae1fa20d641d07724144ed9613b5 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 17edbc4a9820227254a08fbad0a5d88cc4aed289087687c1abea050b9aec6aa4 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest f730acb629eef6f410a2464a59b555340285b5a9f4fa7c3c726353ff38c8e538 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 7bfb131a76928337a39d00599979ddacea1cc88fbea1f251eabc7ce2130a16d1 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 137950e7ae9ab307972b518682a1a6529819980301e0c6b61869477829624ce4 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 138fd3f03e645cf6dbce8b55d67dfa3bee2d319611ccbd87fc5b323747ea818b - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest b19854ff51cedbf8a4e761891538cdd8a2466a8111f5a321f530c1c23c685b5b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest b1b6b3a0adeaf7bdf761e9a23a79f238199e020dd452c21e9f5aab6a1da5d82b - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 27cf3d734488f83f038cb377078fe6cc24925393b66c6c31fe2960a6107a7415 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest aa3ae45ca390d05aea43866b30f3c9ca3455b1f1b05a99c2547d06ae15874e3a + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 8f85614bfdf9ce7fb9d42500f8c89ae650d80ceb9400e5c5931530be1035ce30 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 17b32292a1292f52f825bb6dff8f66e938a85f8b123e1e2e423b16d8bea86094 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest b00698287e44e9fbcbba00c223a58bdacaabaf4b11def029b4decefab932889a + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 09a6d4d83dcae40376a255a845c3d47468f095e621c50122512c1d7bdb8a1271 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 99b3695a660ddec193b6ea2a212e44f3551115cc49d10f2dce461aed5bd92a02 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 85bd41349fcd3affdd14f6d8f854e7c8824ddc7cb1d9eac780a4050e53d38543 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest edb4ebfa31892e0874d8798829a6f5059853820d9988620268fbe41658f01627 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest ff93c31134942756e47063b116c8f2081f1f6605c90214f6f46b7798774ba604 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest ca69134ef23f6197177d7e761738644763e386ae540238e7ac2a6adc2b295e63 stream 10 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest ca44c3bce73a5610a872a1d0303b56a23cf24ca37777c13e6a24a87d1cfc24c7 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest b54ad413a5a73d9c58159d8ba395a38e0f10aa9a228850241c4b0a86b3f7c060 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest d74014c3b3088e17e6d053c05d819d7cc2167122a4ae5f892635440d69384884 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest e74e1793309ec1319e8638cf40ea30ecda33dac3ac34e039080e2c6b0fd9f4cc - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 1a0630fb34fbcf3cd72eb700271d55cb72d1396cf043d7594b4d011e06de3d65 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 16d0104cd8cc53d64ee80959c406a0b544dcf97efc2a8d4883b8372bdb8107dc - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 09a2b373f6a05b7868f0b62badd9a7b70485c3887d07f3704e17287a495bdd0e - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest cf53b4f42b6df5f38a07ecb6dbae883816b89d0776d27fd781f8fa08105c0409 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 63c674a610aa789bcab890eb7b995a513ea52f2cb4d35a3e773836850070c932 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 299a727b9d68956daf0449e6984cb16c4b2164d84eb1458c06226902204b6609 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 104bdf253fdd338b6dca9ab6c5e85eff63b2d89dcab038fa3b51037b50241dd8 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 1252adbe0adcb6d80d6c49e5f42328cffca047b1051f0db4156b495a7ab09c64 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 159d7594519afacb1983e0e1342f0c1d9a1690df506f242f10f325ab913ae6a5 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 737ac4e0e149b3e6035639cd463ee58b93665b608ded034b0bc6fce2fad7b22c + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest ca92e8a0028ceb29d923afb1756e1b3a2cd2d6612a1b94275505343fde2c9ba6 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 38e0024a61e3ae9e82824f8cb39f7306170bf83b35ee25735bceb74c3a370f1c + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest d7f15e05b6b502dfc48e1b450586c1a59f45cc752db762871a0f25b60e80aff4 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 1c86ba62acded2176304804fade5d64040d0bb166865de53ee1be8c782dd5ba3 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 1980296bd259e73290087b9a647d9fb23fdd89126b703e5a0b787e409172c979 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 9e61f33b566ce703dafbe5bb907defdd2b953e4fec670e1953541f43a70f4f08 stream 11 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest b686ece8f076664ffa0165733e0500ee836edf985acf0b484edbdfb613e6563b - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 4b32a58c8dcba7682ce0b04152d5184e48f255bcbdde08d728ab1dc6ef5aa719 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest dd82e4cf4daf7c19991aa4c7bd1bb24e55eb7b73cfdaff3444b3b1bbf73a2694 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 851a1229d333e606ec2aeb7fffe0f817662f5f010010af9b62ae437d2e60f4d2 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest c930467f2aaeee953347042808ebb417d92cd89f56335f085d0e468f33d61ffa - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 20b37b9884de14193cca46bda29983412944b68c277fd6930dd5f60df04f782d - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 5611b1ec26af613d14ebb77b8a17f0efc88b9c5da0c29776e9692aa12ee2af76 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e504ec6c7b648cd9e1a489be7954ed0208ff6cfb264e366ba0a300da9c1820f7 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest d8157eee870ca226593b505a153b951f657311ca5471860ee29e67aa548dd58b - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest a1351b92d9e69aaaee98b9ecf17a33f6e3a9e0270b149290f2d45cba7c6d6149 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 672eae71bdd18bb1ed4805dc282787ce7a167b895f0134c9df4e29dad42d0e0e + Match(End): cases 1 accepted 1 rejected 0 rejection None digest f70ab3dfb26e0ff7b11a6fa548081c047fb8c4c7dbdecd07b2eb3997483883be + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 99838e8b827f8033b1aedcbd3350fb3bded51219357fbb6c574a8c39684034fe + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 77127117955c46c913e999393886d24787a4870cb61fe3055fb8f4f2713e40c1 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 546ccc9d71ca5a4561828595de3f8454fe91877e56eb132f34f421b760ab8961 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 411c31511a1c718afe1f86682afff7fb59ccd88b1bfdd01ef8fcfd0bc284694e + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 1d28a0b5f2e76df4b27e36706fd7077a5c7d1ff0ea500788e0cfde7051dfebf3 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 67642881fc7ef3be06590df5430b90511638f1aac15aa74c924e5b92ed80d7a9 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest a04832f9a32bb370e59ef7bcf4ab2e97b0b621b06af0c84b8f68f37358aad309 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 609d5452aa7e0a88cb9b8a5534681c062b92167792e1fb4521937b6ce3f3e7be stream 12 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 8d2fb9fe08d4398ba2bdf41a1bfbd61f8a623a94a0997cc110425e2a32089fd4 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest cadfaa6a713d548a63d259d1aeda96768bff2eae82fd1d399370bffc03137613 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest cecec5d2cb55bfcccd6f0d48be9dc21064ff5e0f63d7649e7f26df156d536f2f - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest a31da823b3c1bb429765cc210a258f220aaa8c92df9673eb382fe12821b111ac - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 254975c65f08456de3e7be51da5139736a55a1b5e7e9a6defbaa7a6dd95a0523 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest c54fb8b09ef9fea57ae284009d05a41b8f993e06114b4ff918d8565b2bb43d3f - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 0d05f27f04bd9868452c564d84baea9c6ce14927eb4ce469ab9a6246fa26d926 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 638ae22a8f314323aa7353af461430680e1fafa580f11ac1f73f21fc1e8d9d95 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 5a3f1429c3d154082905a1e51ffd43a59cc80a48fb38e85abd61598fdca8dedc - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 0e99767e69250ac6fcbfb7b4d9c993c83ecf9077dc80bd989d10d8e16687c1a7 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 6c298f19bca2e8078524cedae611cd2673c62ee97ad06e62243d4db52358f7cb + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 65803d6519e8009a73c56b339a9f7ba46f1285b11c09f5ab4450354c39bf8490 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest b92a07935202be27bf25b5c9bd51735c2060ebbd6c67c61fc119e8f8acaaf986 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 8d1ef8402eb7a9aae43d4334f297341e8de2efa2662a53c309fac0053d04b365 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 4b506718dbf3dbdc49cd1cc003390ae1363ec0d71a7825d979045f44068e9ca5 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest e6aaff4e35949e4a7ff0a967d344799619b6e170295950c1bda9b267748ade96 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 7f648e2f9a661f83059182d36099f8c071675e727ba7b9b8d7d0336188695173 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 229a363b7bde06285d9df455c6eb65af90935aeb893613a98c6f2e485f57c3ae + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest bd9cbd15f7105c97ed69e1c3325d12268e1bc85725d3f944ec6de84fa8270aad + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 32af3bd5bb0b69e0ce179a770269a7418177fa563e8edc44df1c01f7cb2204dd stream 13 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 059088ab453f9a4ea6c1bda93d62172ec77f9e2e27dbf1aa355ebce4064ecfed - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 87a00b397cf046dc00eb333155428d922229a4dbe8c97422902374c399cf0a40 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest f9ac3b457d07b5344335572c99e15dfd8b4ce1dbf74d20cdf45ff9d132f8ec62 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 4fd9741405c8cc1354022519f755d08d511ea2455235de82b292d0bac0f2da9d - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 3c5d1011b48719d8d6b69ecbf983be0b3fb6757f0c4eea62740babdf03888f0a - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest e5abecd847278d559eb1e90add494e476aafb8b8a7b6da3adc46314c003c3d91 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest bed90570bb0963ab5526ce6e55abda3be957893a5177c318819af3a7f46e29b0 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 7d73dd645bbf5331ce6e68dcdde05bf0dd9057592b8d5d02c651e8855abbdd6b - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 785f77fdc47845b843501b702d0260af3ba977a343a8763a874321e7769160f7 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest b1b179df568a7c34c8d7bf87203c161ef88e1ab38ca0d75e6557fd3527dc11a7 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest fec35e1cf5737ab3dcd034e66551e32b381cb672291b64f6b0140d616190ce83 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 4a1861f34feb55ddb21f549739bd5666dd8fc27552f3a3173baaab2eb34df19d + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 7190a38fff28dca89d3aadb7d4f07ab67349fd3448877b469ef32bcf9af7c8ed + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 1406117ea9fa975564ae00ee94b0c99cc479fd72616f417d1b5901b525529cd4 + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 215bf8bb454b8bb072d8e980f85570b12f49a7e3bf889bee8773b54b1aff5184 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest eca762246a893da6ade59d27aafc0da76e88cd7d83586ca687a0ee6fc689dcef + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b2a369f93b2e2f1aa801ecedbf76c018cd5e9e54dffdfa368618b7e23ad6099a + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest e4d7c0752bde967a6a6c229128f2473713cac168302411d2068692ed867a4f8b + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 8febe5d88db3eb980d967075e77a4eacd709fadf23104dce8fbdaa4e771f9094 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 68b3b4adc4d89d691374d0296f39d9667fd4b6206f1a203e20d19a37fa73e93d stream 14 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 412f432fbfc37aa305017091196a1cf08cdcfb58286c5d67a5879a7adfe41a98 - Match(End): cases 1 accepted 1 rejected 0 rejection None digest b926b2fa40df45f59a10eb48153efaba5c5aa388262e69137f571acab8678ad5 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 2d87757635342d0fd27aff56df18325535b7886318a1508f3dadd6a540a30e9f - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 5d537842e1e44c2fb2663485c69818515a5d724e84ab275eeeda1534a66b326f - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest b0adb97cf6e8fd75a3d6eca5d1ba6a81b4e9cfbce99793d9374bf35e6a53f756 - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1a598804994f12a0bf7db52814ab606b664bb716c6077797d25ca28777067d00 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest b8637f87ab12074b33dd6ef0cf82da1a1e576167190c225c72f9578963208c00 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 28a6bede5bd63c5a42a5f30e4f7dbcc3628634af2962a8e8839ae4b9c29f604d - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 2f182802ac4c754db7b1e709ec1e2c62fed5a070e2c068faba47bb7964d3f4e2 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest f5c41bf5e61ad5831f4929f863cfa6bfe71fa3b6ee9b27a21e7c0a7595e357b7 + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 16131963006b632de280a07deb46ea192c15d2dc8e25ddf274461261ae0ebca3 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest 21a867cfc349c464b9c79771ff5ce115a7fb6c77c69eab4333c654d1617ec112 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest cf8f33059852e53a2e2e01d7106399ece848983621f1dc666fb7d0c7ea6561e9 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest f9e69834a2d2b1a86a0bc36b29a8cdd8d6af1c4d59e93c2fab12517ceb7ceb6f + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest b160156964892a2f2f7a872f1c2702fc6a8ed893ea3eade80f4a74931f4043f1 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 22185f43a8a2836d412f0a7498ecc7dd9194720e2056c8b59bf95334de525cc9 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest 3c85c62b9b1dab4c45bb9c9af63ca1de990e12d5402e2341e92d22703ed27095 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 67070ac2787a5aec30323b7b1fd7e45c7d018878de75503d80e5e42ffb7f52a4 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest d95cc1a17f764327b1a5269a0ade95b190f22f0d68b801023394934525457971 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 71e233055c53dfe2ac77e4d9cf92dee19af492e82c3e32d7eb93dbd28d4eac8c stream 15 - Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest 857b425150218962c0a7548ad7867d0223631dbca974fdcc84cb94019355c7ad - Match(End): cases 1 accepted 1 rejected 0 rejection None digest 95287e0900ba61bf3efa683215b6b74a25af987141de2f338f74b9e9089c9694 - QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 98956d902b7f9ded95fe6659d503239a457ff8e0f9557ca5e48c5f3f73cafeb9 - QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 627c3e7aff204f684040fcf3fd123b5c9cfb945c9aae4167b12eab4723b953f0 - Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest b8bef3ff0962d6a257250d4bb4876d9f1d9aab401cd818b3e9a2c9475e1ec67e - Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 97fec0191ef2a75b3d6440739172679e85e8c853f502199048fa9c34fe479ad9 - Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest e2a690dbe15e9f6276788dee265fa948b186a5904698d55aa72c1ebf49bd7b9f - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest b10ff7406dbceaf57f9edfd790369eda2149677347a3bc5a15226dbe0928d0ca - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 033a9d6c6cb059c17270280ac6121bc6cff59ff0b452c49cb65dce6f55e992a7 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest d4fc5053bcf8daa493d15dfb6750582b5efc3690d8eb3260ed4645e012789c4b + Match(Continue): cases 1 accepted 1 rejected 0 rejection None digest baa489d341a4a2700fda70bf6886f5b9dab805820201010b108c47ee303eba41 + Match(End): cases 1 accepted 1 rejected 0 rejection None digest e01b98e21bd5c63e254e0bffd894935132c4d19139611e842ece4c4b57365538 + QueryEmpty(Continue): cases 1 accepted 1 rejected 0 rejection None digest 07248d0e233debd4957a759f5a206c0e19208e530dd0ae0b289433e0e4efdf89 + QueryEmpty(End): cases 1 accepted 1 rejected 0 rejection None digest 350c9e7214b4d19dda13cc2a40f5b0cdcba493056962d9973e2197039614545c + Query(Continue): cases 32896 accepted 32896 rejected 0 rejection None digest 15a6a6f14af85c5f40b3395c445330d1cc20676dad8257390e5e5e92a500b094 + Query(End): cases 32896 accepted 32896 rejected 0 rejection None digest 1ea6e03cf037327f78a691daa80c95514ed71fa4475d0bb7e840af5f13cabf87 + Supply(Continue): cases 1 accepted 1 rejected 0 rejection None digest d1a34d662eeaf09b1094de8f6ffc636311b783883ff9218288ea81f66e685650 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest c90d18111ee0768484b386281a560fad062ba6f8f11871256f4fc3dfb2830e76 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest c80c79a90c901d855e127dc81690b9357fd87a290cf7a856d4618c1a54cfe7fb + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest a76debc09aff00b2f5343ea22224544c69ec8793774afc18a19783fdeb5cdf64 stream 16 Match(Continue): cases 1 accepted 0 rejected 1 rejection Some(TerminalLeafReplies) digest af35b4bbaa746c77cbfb5f3e4badd404be07dfbdb84fd40c73823d2cc7e1b054 Match(End): cases 1 accepted 0 rejected 1 rejection Some(TerminalLeafReplies) digest 64dcda0a643b88998ba505638306db5435d5cfb3c30c4b71b8633f4074288a13 @@ -375,6 +375,6 @@ Responder Query(Continue): cases 32896 accepted 0 rejected 32896 rejection Some(TerminalLeafReplies) digest d678b609a984aec0e82f6704ec78d2c1312a319c00aef09315cb6d5a97afd3b3 Query(End): cases 32896 accepted 0 rejected 32896 rejection Some(TerminalLeafReplies) digest 20206e46e9569954fa7409adc1466295dd78517ee9b1f7a6dcb931af522b09f2 Supply(Continue): cases 1 accepted 0 rejected 1 rejection Some(TerminalLeafReplies) digest 8a98948334763852e22cefc20322ea74bfef9f5b97b0c1ee2b11ccf8281288b3 - Supply(End): cases 1 accepted 1 rejected 0 rejection None digest 29c688db20ab6a6a85b8a64ea17a0f0c9e3380c16c02a687588891c443cd6664 - End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 7a250d61fe85b397ff21c6c0d2883c934615f0c59d3f7fa92090f2bbc974cb74 - End(Stream): cases 1 accepted 1 rejected 0 rejection None digest 69e0a19208d6edd30e31916235b05fcd9f3072a9f93668a49e7cd2d971621588 + Supply(End): cases 1 accepted 1 rejected 0 rejection None digest c7350f8f4be155503abf5f41d5d6ebb144b3c4068d4d41c66fb2d8aefb31c346 + End(Reply): cases 1 accepted 1 rejected 0 rejection None digest 7257d21c0f045fbdaae74ddd1f9c1ac52ac58d85f6b2efc24a92e2c2c7b48d61 + End(Stream): cases 1 accepted 1 rejected 0 rejection None digest c19c0586156caa168dd0e5703a80d90674d0554d1712c535f49d55172049b0b4 diff --git a/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__canonical_frame_atlas_snapshot.snap b/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__canonical_frame_atlas_snapshot.snap index 620c353a..e9262834 100644 --- a/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__canonical_frame_atlas_snapshot.snap +++ b/src/tree/mirror/streaming/remote/codec/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__canonical_frame_atlas_snapshot.snap @@ -10,363 +10,363 @@ Initiator QueryEmpty(End): rejected byte 33 class OpeningSupplies Query(Continue): rejected byte 44 class OpeningSupplies Query(End): rejected byte 55 class OpeningSupplies - Supply(Continue): accepted len 12 hex 66000000070000000341e0f6 - Supply(End): accepted len 12 hex 77000000070000000341e0f6 - End(Reply): accepted len 1 hex 88 - End(Stream): accepted len 1 hex 99 + Supply(Continue): accepted len 15 hex 821866d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821877d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811888 + End(Stream): accepted len 3 hex 811899 stream 01 - Match(Continue): accepted len 1 hex 01 - Match(End): accepted len 1 hex 12 - QueryEmpty(Continue): accepted len 1 hex 23 - QueryEmpty(End): accepted len 1 hex 34 - Query(Continue): accepted len 27 hex 450000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 560000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 67000000070000000341e0f6 - Supply(End): accepted len 12 hex 78000000070000000341e0f6 - End(Reply): accepted len 1 hex 89 - End(Stream): accepted len 1 hex 9a + Match(Continue): accepted len 2 hex 8101 + Match(End): accepted len 2 hex 8112 + QueryEmpty(Continue): accepted len 3 hex 811823 + QueryEmpty(End): accepted len 3 hex 811834 + Query(Continue): accepted len 31 hex 821845a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821856a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821867d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821878d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811889 + End(Stream): accepted len 3 hex 81189a stream 02 - Match(Continue): accepted len 1 hex 02 - Match(End): accepted len 1 hex 13 - QueryEmpty(Continue): accepted len 1 hex 24 - QueryEmpty(End): accepted len 1 hex 35 - Query(Continue): accepted len 27 hex 460000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 570000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 68000000070000000341e0f6 - Supply(End): accepted len 12 hex 79000000070000000341e0f6 - End(Reply): accepted len 1 hex 8a - End(Stream): accepted len 1 hex 9b + Match(Continue): accepted len 2 hex 8102 + Match(End): accepted len 2 hex 8113 + QueryEmpty(Continue): accepted len 3 hex 811824 + QueryEmpty(End): accepted len 3 hex 811835 + Query(Continue): accepted len 31 hex 821846a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821857a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821868d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821879d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188a + End(Stream): accepted len 3 hex 81189b stream 03 - Match(Continue): accepted len 1 hex 03 - Match(End): accepted len 1 hex 14 - QueryEmpty(Continue): accepted len 1 hex 25 - QueryEmpty(End): accepted len 1 hex 36 - Query(Continue): accepted len 27 hex 470000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 580000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 69000000070000000341e0f6 - Supply(End): accepted len 12 hex 7a000000070000000341e0f6 - End(Reply): accepted len 1 hex 8b - End(Stream): accepted len 1 hex 9c + Match(Continue): accepted len 2 hex 8103 + Match(End): accepted len 2 hex 8114 + QueryEmpty(Continue): accepted len 3 hex 811825 + QueryEmpty(End): accepted len 3 hex 811836 + Query(Continue): accepted len 31 hex 821847a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821858a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821869d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187ad83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188b + End(Stream): accepted len 3 hex 81189c stream 04 - Match(Continue): accepted len 1 hex 04 - Match(End): accepted len 1 hex 15 - QueryEmpty(Continue): accepted len 1 hex 26 - QueryEmpty(End): accepted len 1 hex 37 - Query(Continue): accepted len 27 hex 480000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 590000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6a000000070000000341e0f6 - Supply(End): accepted len 12 hex 7b000000070000000341e0f6 - End(Reply): accepted len 1 hex 8c - End(Stream): accepted len 1 hex 9d + Match(Continue): accepted len 2 hex 8104 + Match(End): accepted len 2 hex 8115 + QueryEmpty(Continue): accepted len 3 hex 811826 + QueryEmpty(End): accepted len 3 hex 811837 + Query(Continue): accepted len 31 hex 821848a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821859a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186ad83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187bd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188c + End(Stream): accepted len 3 hex 81189d stream 05 - Match(Continue): accepted len 1 hex 05 - Match(End): accepted len 1 hex 16 - QueryEmpty(Continue): accepted len 1 hex 27 - QueryEmpty(End): accepted len 1 hex 38 - Query(Continue): accepted len 27 hex 490000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5a0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6b000000070000000341e0f6 - Supply(End): accepted len 12 hex 7c000000070000000341e0f6 - End(Reply): accepted len 1 hex 8d - End(Stream): accepted len 1 hex 9e + Match(Continue): accepted len 2 hex 8105 + Match(End): accepted len 2 hex 8116 + QueryEmpty(Continue): accepted len 3 hex 811827 + QueryEmpty(End): accepted len 3 hex 811838 + Query(Continue): accepted len 31 hex 821849a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185aa1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186bd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187cd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188d + End(Stream): accepted len 3 hex 81189e stream 06 - Match(Continue): accepted len 1 hex 06 - Match(End): accepted len 1 hex 17 - QueryEmpty(Continue): accepted len 1 hex 28 - QueryEmpty(End): accepted len 1 hex 39 - Query(Continue): accepted len 27 hex 4a0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5b0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6c000000070000000341e0f6 - Supply(End): accepted len 12 hex 7d000000070000000341e0f6 - End(Reply): accepted len 1 hex 8e - End(Stream): accepted len 1 hex 9f + Match(Continue): accepted len 2 hex 8106 + Match(End): accepted len 2 hex 8117 + QueryEmpty(Continue): accepted len 3 hex 811828 + QueryEmpty(End): accepted len 3 hex 811839 + Query(Continue): accepted len 31 hex 82184aa1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185ba1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186cd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187dd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188e + End(Stream): accepted len 3 hex 81189f stream 07 - Match(Continue): accepted len 1 hex 07 - Match(End): accepted len 1 hex 18 - QueryEmpty(Continue): accepted len 1 hex 29 - QueryEmpty(End): accepted len 1 hex 3a - Query(Continue): accepted len 27 hex 4b0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5c0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6d000000070000000341e0f6 - Supply(End): accepted len 12 hex 7e000000070000000341e0f6 - End(Reply): accepted len 1 hex 8f - End(Stream): accepted len 1 hex a0 + Match(Continue): accepted len 2 hex 8107 + Match(End): accepted len 3 hex 811818 + QueryEmpty(Continue): accepted len 3 hex 811829 + QueryEmpty(End): accepted len 3 hex 81183a + Query(Continue): accepted len 31 hex 82184ba1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185ca1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186dd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187ed83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188f + End(Stream): accepted len 3 hex 8118a0 stream 08 - Match(Continue): accepted len 1 hex 08 - Match(End): accepted len 1 hex 19 - QueryEmpty(Continue): accepted len 1 hex 2a - QueryEmpty(End): accepted len 1 hex 3b - Query(Continue): accepted len 27 hex 4c0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5d0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6e000000070000000341e0f6 - Supply(End): accepted len 12 hex 7f000000070000000341e0f6 - End(Reply): accepted len 1 hex 90 - End(Stream): accepted len 1 hex a1 + Match(Continue): accepted len 2 hex 8108 + Match(End): accepted len 3 hex 811819 + QueryEmpty(Continue): accepted len 3 hex 81182a + QueryEmpty(End): accepted len 3 hex 81183b + Query(Continue): accepted len 31 hex 82184ca1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185da1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186ed83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187fd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811890 + End(Stream): accepted len 3 hex 8118a1 stream 09 - Match(Continue): accepted len 1 hex 09 - Match(End): accepted len 1 hex 1a - QueryEmpty(Continue): accepted len 1 hex 2b - QueryEmpty(End): accepted len 1 hex 3c - Query(Continue): accepted len 27 hex 4d0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5e0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6f000000070000000341e0f6 - Supply(End): accepted len 12 hex 80000000070000000341e0f6 - End(Reply): accepted len 1 hex 91 - End(Stream): accepted len 1 hex a2 + Match(Continue): accepted len 2 hex 8109 + Match(End): accepted len 3 hex 81181a + QueryEmpty(Continue): accepted len 3 hex 81182b + QueryEmpty(End): accepted len 3 hex 81183c + Query(Continue): accepted len 31 hex 82184da1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185ea1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186fd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821880d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811891 + End(Stream): accepted len 3 hex 8118a2 stream 10 - Match(Continue): accepted len 1 hex 0a - Match(End): accepted len 1 hex 1b - QueryEmpty(Continue): accepted len 1 hex 2c - QueryEmpty(End): accepted len 1 hex 3d - Query(Continue): accepted len 27 hex 4e0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5f0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 70000000070000000341e0f6 - Supply(End): accepted len 12 hex 81000000070000000341e0f6 - End(Reply): accepted len 1 hex 92 - End(Stream): accepted len 1 hex a3 + Match(Continue): accepted len 2 hex 810a + Match(End): accepted len 3 hex 81181b + QueryEmpty(Continue): accepted len 3 hex 81182c + QueryEmpty(End): accepted len 3 hex 81183d + Query(Continue): accepted len 31 hex 82184ea1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185fa1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821870d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821881d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811892 + End(Stream): accepted len 3 hex 8118a3 stream 11 - Match(Continue): accepted len 1 hex 0b - Match(End): accepted len 1 hex 1c - QueryEmpty(Continue): accepted len 1 hex 2d - QueryEmpty(End): accepted len 1 hex 3e - Query(Continue): accepted len 27 hex 4f0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 600000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 71000000070000000341e0f6 - Supply(End): accepted len 12 hex 82000000070000000341e0f6 - End(Reply): accepted len 1 hex 93 - End(Stream): accepted len 1 hex a4 + Match(Continue): accepted len 2 hex 810b + Match(End): accepted len 3 hex 81181c + QueryEmpty(Continue): accepted len 3 hex 81182d + QueryEmpty(End): accepted len 3 hex 81183e + Query(Continue): accepted len 31 hex 82184fa1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821860a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821871d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821882d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811893 + End(Stream): accepted len 3 hex 8118a4 stream 12 - Match(Continue): accepted len 1 hex 0c - Match(End): accepted len 1 hex 1d - QueryEmpty(Continue): accepted len 1 hex 2e - QueryEmpty(End): accepted len 1 hex 3f - Query(Continue): accepted len 27 hex 500000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 610000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 72000000070000000341e0f6 - Supply(End): accepted len 12 hex 83000000070000000341e0f6 - End(Reply): accepted len 1 hex 94 - End(Stream): accepted len 1 hex a5 + Match(Continue): accepted len 2 hex 810c + Match(End): accepted len 3 hex 81181d + QueryEmpty(Continue): accepted len 3 hex 81182e + QueryEmpty(End): accepted len 3 hex 81183f + Query(Continue): accepted len 31 hex 821850a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821861a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821872d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821883d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811894 + End(Stream): accepted len 3 hex 8118a5 stream 13 - Match(Continue): accepted len 1 hex 0d - Match(End): accepted len 1 hex 1e - QueryEmpty(Continue): accepted len 1 hex 2f - QueryEmpty(End): accepted len 1 hex 40 - Query(Continue): accepted len 27 hex 510000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 620000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 73000000070000000341e0f6 - Supply(End): accepted len 12 hex 84000000070000000341e0f6 - End(Reply): accepted len 1 hex 95 - End(Stream): accepted len 1 hex a6 + Match(Continue): accepted len 2 hex 810d + Match(End): accepted len 3 hex 81181e + QueryEmpty(Continue): accepted len 3 hex 81182f + QueryEmpty(End): accepted len 3 hex 811840 + Query(Continue): accepted len 31 hex 821851a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821862a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821873d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821884d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811895 + End(Stream): accepted len 3 hex 8118a6 stream 14 - Match(Continue): accepted len 1 hex 0e - Match(End): accepted len 1 hex 1f - QueryEmpty(Continue): accepted len 1 hex 30 - QueryEmpty(End): accepted len 1 hex 41 - Query(Continue): accepted len 27 hex 520000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 630000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 74000000070000000341e0f6 - Supply(End): accepted len 12 hex 85000000070000000341e0f6 - End(Reply): accepted len 1 hex 96 - End(Stream): accepted len 1 hex a7 + Match(Continue): accepted len 2 hex 810e + Match(End): accepted len 3 hex 81181f + QueryEmpty(Continue): accepted len 3 hex 811830 + QueryEmpty(End): accepted len 3 hex 811841 + Query(Continue): accepted len 31 hex 821852a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821863a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821874d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821885d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811896 + End(Stream): accepted len 3 hex 8118a7 stream 15 - Match(Continue): accepted len 1 hex 0f - Match(End): accepted len 1 hex 20 - QueryEmpty(Continue): accepted len 1 hex 31 - QueryEmpty(End): accepted len 1 hex 42 - Query(Continue): accepted len 27 hex 530000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 640000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 75000000070000000341e0f6 - Supply(End): accepted len 12 hex 86000000070000000341e0f6 - End(Reply): accepted len 1 hex 97 - End(Stream): accepted len 1 hex a8 + Match(Continue): accepted len 2 hex 810f + Match(End): accepted len 3 hex 811820 + QueryEmpty(Continue): accepted len 3 hex 811831 + QueryEmpty(End): accepted len 3 hex 811842 + Query(Continue): accepted len 31 hex 821853a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821864a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821875d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821886d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811897 + End(Stream): accepted len 3 hex 8118a8 stream 16 - Match(Continue): accepted len 1 hex 10 - Match(End): accepted len 1 hex 21 - QueryEmpty(Continue): accepted len 1 hex 32 - QueryEmpty(End): accepted len 1 hex 43 + Match(Continue): accepted len 2 hex 8110 + Match(End): accepted len 3 hex 811821 + QueryEmpty(Continue): accepted len 3 hex 811832 + QueryEmpty(End): accepted len 3 hex 811843 Query(Continue): rejected byte 54 class LeafParentReplies Query(End): rejected byte 65 class LeafParentReplies - Supply(Continue): accepted len 12 hex 76000000070000000341e0f6 - Supply(End): accepted len 12 hex 87000000070000000341e0f6 - End(Reply): accepted len 1 hex 98 - End(Stream): accepted len 1 hex a9 + Supply(Continue): accepted len 15 hex 821876d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821887d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811898 + End(Stream): accepted len 3 hex 8118a9 Responder stream 00 - Match(Continue): accepted len 1 hex 00 - Match(End): accepted len 1 hex 11 - QueryEmpty(Continue): accepted len 1 hex 22 - QueryEmpty(End): accepted len 1 hex 33 - Query(Continue): accepted len 27 hex 440000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 550000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 66000000070000000341e0f6 - Supply(End): accepted len 12 hex 77000000070000000341e0f6 - End(Reply): accepted len 1 hex 88 - End(Stream): accepted len 1 hex 99 + Match(Continue): accepted len 2 hex 8100 + Match(End): accepted len 2 hex 8111 + QueryEmpty(Continue): accepted len 3 hex 811822 + QueryEmpty(End): accepted len 3 hex 811833 + Query(Continue): accepted len 31 hex 821844a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821855a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821866d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821877d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811888 + End(Stream): accepted len 3 hex 811899 stream 01 - Match(Continue): accepted len 1 hex 01 - Match(End): accepted len 1 hex 12 - QueryEmpty(Continue): accepted len 1 hex 23 - QueryEmpty(End): accepted len 1 hex 34 - Query(Continue): accepted len 27 hex 450000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 560000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 67000000070000000341e0f6 - Supply(End): accepted len 12 hex 78000000070000000341e0f6 - End(Reply): accepted len 1 hex 89 - End(Stream): accepted len 1 hex 9a + Match(Continue): accepted len 2 hex 8101 + Match(End): accepted len 2 hex 8112 + QueryEmpty(Continue): accepted len 3 hex 811823 + QueryEmpty(End): accepted len 3 hex 811834 + Query(Continue): accepted len 31 hex 821845a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821856a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821867d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821878d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811889 + End(Stream): accepted len 3 hex 81189a stream 02 - Match(Continue): accepted len 1 hex 02 - Match(End): accepted len 1 hex 13 - QueryEmpty(Continue): accepted len 1 hex 24 - QueryEmpty(End): accepted len 1 hex 35 - Query(Continue): accepted len 27 hex 460000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 570000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 68000000070000000341e0f6 - Supply(End): accepted len 12 hex 79000000070000000341e0f6 - End(Reply): accepted len 1 hex 8a - End(Stream): accepted len 1 hex 9b + Match(Continue): accepted len 2 hex 8102 + Match(End): accepted len 2 hex 8113 + QueryEmpty(Continue): accepted len 3 hex 811824 + QueryEmpty(End): accepted len 3 hex 811835 + Query(Continue): accepted len 31 hex 821846a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821857a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821868d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821879d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188a + End(Stream): accepted len 3 hex 81189b stream 03 - Match(Continue): accepted len 1 hex 03 - Match(End): accepted len 1 hex 14 - QueryEmpty(Continue): accepted len 1 hex 25 - QueryEmpty(End): accepted len 1 hex 36 - Query(Continue): accepted len 27 hex 470000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 580000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 69000000070000000341e0f6 - Supply(End): accepted len 12 hex 7a000000070000000341e0f6 - End(Reply): accepted len 1 hex 8b - End(Stream): accepted len 1 hex 9c + Match(Continue): accepted len 2 hex 8103 + Match(End): accepted len 2 hex 8114 + QueryEmpty(Continue): accepted len 3 hex 811825 + QueryEmpty(End): accepted len 3 hex 811836 + Query(Continue): accepted len 31 hex 821847a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821858a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821869d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187ad83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188b + End(Stream): accepted len 3 hex 81189c stream 04 - Match(Continue): accepted len 1 hex 04 - Match(End): accepted len 1 hex 15 - QueryEmpty(Continue): accepted len 1 hex 26 - QueryEmpty(End): accepted len 1 hex 37 - Query(Continue): accepted len 27 hex 480000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 590000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6a000000070000000341e0f6 - Supply(End): accepted len 12 hex 7b000000070000000341e0f6 - End(Reply): accepted len 1 hex 8c - End(Stream): accepted len 1 hex 9d + Match(Continue): accepted len 2 hex 8104 + Match(End): accepted len 2 hex 8115 + QueryEmpty(Continue): accepted len 3 hex 811826 + QueryEmpty(End): accepted len 3 hex 811837 + Query(Continue): accepted len 31 hex 821848a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821859a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186ad83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187bd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188c + End(Stream): accepted len 3 hex 81189d stream 05 - Match(Continue): accepted len 1 hex 05 - Match(End): accepted len 1 hex 16 - QueryEmpty(Continue): accepted len 1 hex 27 - QueryEmpty(End): accepted len 1 hex 38 - Query(Continue): accepted len 27 hex 490000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5a0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6b000000070000000341e0f6 - Supply(End): accepted len 12 hex 7c000000070000000341e0f6 - End(Reply): accepted len 1 hex 8d - End(Stream): accepted len 1 hex 9e + Match(Continue): accepted len 2 hex 8105 + Match(End): accepted len 2 hex 8116 + QueryEmpty(Continue): accepted len 3 hex 811827 + QueryEmpty(End): accepted len 3 hex 811838 + Query(Continue): accepted len 31 hex 821849a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185aa1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186bd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187cd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188d + End(Stream): accepted len 3 hex 81189e stream 06 - Match(Continue): accepted len 1 hex 06 - Match(End): accepted len 1 hex 17 - QueryEmpty(Continue): accepted len 1 hex 28 - QueryEmpty(End): accepted len 1 hex 39 - Query(Continue): accepted len 27 hex 4a0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5b0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6c000000070000000341e0f6 - Supply(End): accepted len 12 hex 7d000000070000000341e0f6 - End(Reply): accepted len 1 hex 8e - End(Stream): accepted len 1 hex 9f + Match(Continue): accepted len 2 hex 8106 + Match(End): accepted len 2 hex 8117 + QueryEmpty(Continue): accepted len 3 hex 811828 + QueryEmpty(End): accepted len 3 hex 811839 + Query(Continue): accepted len 31 hex 82184aa1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185ba1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186cd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187dd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188e + End(Stream): accepted len 3 hex 81189f stream 07 - Match(Continue): accepted len 1 hex 07 - Match(End): accepted len 1 hex 18 - QueryEmpty(Continue): accepted len 1 hex 29 - QueryEmpty(End): accepted len 1 hex 3a - Query(Continue): accepted len 27 hex 4b0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5c0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6d000000070000000341e0f6 - Supply(End): accepted len 12 hex 7e000000070000000341e0f6 - End(Reply): accepted len 1 hex 8f - End(Stream): accepted len 1 hex a0 + Match(Continue): accepted len 2 hex 8107 + Match(End): accepted len 3 hex 811818 + QueryEmpty(Continue): accepted len 3 hex 811829 + QueryEmpty(End): accepted len 3 hex 81183a + Query(Continue): accepted len 31 hex 82184ba1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185ca1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186dd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187ed83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 81188f + End(Stream): accepted len 3 hex 8118a0 stream 08 - Match(Continue): accepted len 1 hex 08 - Match(End): accepted len 1 hex 19 - QueryEmpty(Continue): accepted len 1 hex 2a - QueryEmpty(End): accepted len 1 hex 3b - Query(Continue): accepted len 27 hex 4c0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5d0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6e000000070000000341e0f6 - Supply(End): accepted len 12 hex 7f000000070000000341e0f6 - End(Reply): accepted len 1 hex 90 - End(Stream): accepted len 1 hex a1 + Match(Continue): accepted len 2 hex 8108 + Match(End): accepted len 3 hex 811819 + QueryEmpty(Continue): accepted len 3 hex 81182a + QueryEmpty(End): accepted len 3 hex 81183b + Query(Continue): accepted len 31 hex 82184ca1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185da1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186ed83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 82187fd83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811890 + End(Stream): accepted len 3 hex 8118a1 stream 09 - Match(Continue): accepted len 1 hex 09 - Match(End): accepted len 1 hex 1a - QueryEmpty(Continue): accepted len 1 hex 2b - QueryEmpty(End): accepted len 1 hex 3c - Query(Continue): accepted len 27 hex 4d0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5e0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 6f000000070000000341e0f6 - Supply(End): accepted len 12 hex 80000000070000000341e0f6 - End(Reply): accepted len 1 hex 91 - End(Stream): accepted len 1 hex a2 + Match(Continue): accepted len 2 hex 8109 + Match(End): accepted len 3 hex 81181a + QueryEmpty(Continue): accepted len 3 hex 81182b + QueryEmpty(End): accepted len 3 hex 81183c + Query(Continue): accepted len 31 hex 82184da1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185ea1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 82186fd83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821880d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811891 + End(Stream): accepted len 3 hex 8118a2 stream 10 - Match(Continue): accepted len 1 hex 0a - Match(End): accepted len 1 hex 1b - QueryEmpty(Continue): accepted len 1 hex 2c - QueryEmpty(End): accepted len 1 hex 3d - Query(Continue): accepted len 27 hex 4e0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 5f0000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 70000000070000000341e0f6 - Supply(End): accepted len 12 hex 81000000070000000341e0f6 - End(Reply): accepted len 1 hex 92 - End(Stream): accepted len 1 hex a3 + Match(Continue): accepted len 2 hex 810a + Match(End): accepted len 3 hex 81181b + QueryEmpty(Continue): accepted len 3 hex 81182c + QueryEmpty(End): accepted len 3 hex 81183d + Query(Continue): accepted len 31 hex 82184ea1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 82185fa1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821870d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821881d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811892 + End(Stream): accepted len 3 hex 8118a3 stream 11 - Match(Continue): accepted len 1 hex 0b - Match(End): accepted len 1 hex 1c - QueryEmpty(Continue): accepted len 1 hex 2d - QueryEmpty(End): accepted len 1 hex 3e - Query(Continue): accepted len 27 hex 4f0000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 600000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 71000000070000000341e0f6 - Supply(End): accepted len 12 hex 82000000070000000341e0f6 - End(Reply): accepted len 1 hex 93 - End(Stream): accepted len 1 hex a4 + Match(Continue): accepted len 2 hex 810b + Match(End): accepted len 3 hex 81181c + QueryEmpty(Continue): accepted len 3 hex 81182d + QueryEmpty(End): accepted len 3 hex 81183e + Query(Continue): accepted len 31 hex 82184fa1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821860a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821871d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821882d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811893 + End(Stream): accepted len 3 hex 8118a4 stream 12 - Match(Continue): accepted len 1 hex 0c - Match(End): accepted len 1 hex 1d - QueryEmpty(Continue): accepted len 1 hex 2e - QueryEmpty(End): accepted len 1 hex 3f - Query(Continue): accepted len 27 hex 500000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 610000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 72000000070000000341e0f6 - Supply(End): accepted len 12 hex 83000000070000000341e0f6 - End(Reply): accepted len 1 hex 94 - End(Stream): accepted len 1 hex a5 + Match(Continue): accepted len 2 hex 810c + Match(End): accepted len 3 hex 81181d + QueryEmpty(Continue): accepted len 3 hex 81182e + QueryEmpty(End): accepted len 3 hex 81183f + Query(Continue): accepted len 31 hex 821850a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821861a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821872d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821883d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811894 + End(Stream): accepted len 3 hex 8118a5 stream 13 - Match(Continue): accepted len 1 hex 0d - Match(End): accepted len 1 hex 1e - QueryEmpty(Continue): accepted len 1 hex 2f - QueryEmpty(End): accepted len 1 hex 40 - Query(Continue): accepted len 27 hex 510000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 620000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 73000000070000000341e0f6 - Supply(End): accepted len 12 hex 84000000070000000341e0f6 - End(Reply): accepted len 1 hex 95 - End(Stream): accepted len 1 hex a6 + Match(Continue): accepted len 2 hex 810d + Match(End): accepted len 3 hex 81181e + QueryEmpty(Continue): accepted len 3 hex 81182f + QueryEmpty(End): accepted len 3 hex 811840 + Query(Continue): accepted len 31 hex 821851a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821862a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821873d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821884d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811895 + End(Stream): accepted len 3 hex 8118a6 stream 14 - Match(Continue): accepted len 1 hex 0e - Match(End): accepted len 1 hex 1f - QueryEmpty(Continue): accepted len 1 hex 30 - QueryEmpty(End): accepted len 1 hex 41 - Query(Continue): accepted len 27 hex 520000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 630000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 74000000070000000341e0f6 - Supply(End): accepted len 12 hex 85000000070000000341e0f6 - End(Reply): accepted len 1 hex 96 - End(Stream): accepted len 1 hex a7 + Match(Continue): accepted len 2 hex 810e + Match(End): accepted len 3 hex 81181f + QueryEmpty(Continue): accepted len 3 hex 811830 + QueryEmpty(End): accepted len 3 hex 811841 + Query(Continue): accepted len 31 hex 821852a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821863a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821874d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821885d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811896 + End(Stream): accepted len 3 hex 8118a7 stream 15 - Match(Continue): accepted len 1 hex 0f - Match(End): accepted len 1 hex 20 - QueryEmpty(Continue): accepted len 1 hex 31 - QueryEmpty(End): accepted len 1 hex 42 - Query(Continue): accepted len 27 hex 530000000000000000000000000000000000000000000000000000 - Query(End): accepted len 27 hex 640000000000000000000000000000000000000000000000000000 - Supply(Continue): accepted len 12 hex 75000000070000000341e0f6 - Supply(End): accepted len 12 hex 86000000070000000341e0f6 - End(Reply): accepted len 1 hex 97 - End(Stream): accepted len 1 hex a8 + Match(Continue): accepted len 2 hex 810f + Match(End): accepted len 3 hex 811820 + QueryEmpty(Continue): accepted len 3 hex 811831 + QueryEmpty(End): accepted len 3 hex 811842 + Query(Continue): accepted len 31 hex 821853a1005818000000000000000000000000000000000000000000000000 + Query(End): accepted len 31 hex 821864a1005818000000000000000000000000000000000000000000000000 + Supply(Continue): accepted len 15 hex 821875d83f49d83f46d9d25641e0f6 + Supply(End): accepted len 15 hex 821886d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811897 + End(Stream): accepted len 3 hex 8118a8 stream 16 Match(Continue): rejected byte 10 class TerminalLeafReplies Match(End): rejected byte 21 class TerminalLeafReplies @@ -375,6 +375,6 @@ Responder Query(Continue): rejected byte 54 class TerminalLeafReplies Query(End): rejected byte 65 class TerminalLeafReplies Supply(Continue): rejected byte 76 class TerminalLeafReplies - Supply(End): accepted len 12 hex 87000000070000000341e0f6 - End(Reply): accepted len 1 hex 98 - End(Stream): accepted len 1 hex a9 + Supply(End): accepted len 15 hex 821887d83f49d83f46d9d25641e0f6 + End(Reply): accepted len 3 hex 811898 + End(Stream): accepted len 3 hex 8118a9 diff --git a/src/tree/mirror/streaming/remote/codec/tests.rs b/src/tree/mirror/streaming/remote/codec/tests.rs index cfda1246..0551193d 100644 --- a/src/tree/mirror/streaming/remote/codec/tests.rs +++ b/src/tree/mirror/streaming/remote/codec/tests.rs @@ -256,7 +256,15 @@ fn canonical_frame_atlas_snapshot() { Ok(wire) => { let mut encoded = Vec::new(); encode(speaker, &(stream, frame.clone()), &mut encoded).unwrap(); - assert_eq!(encoded.first(), Some(&wire.to_byte())); + // The frame head carries the dense code as a uint + // item right behind the array head. + let mut expected_signal = Vec::new(); + crate::tree::mirror::cbor::write_head( + &mut expected_signal, + crate::tree::mirror::cbor::MAJOR_UINT, + u64::from(wire.to_byte()), + ); + assert_eq!(&encoded[1..1 + expected_signal.len()], expected_signal); assert_eq!( decode_exact(speaker, RunBudget::default(), &encoded).unwrap(), (stream, frame) @@ -267,8 +275,19 @@ fn canonical_frame_atlas_snapshot() { atlas.push('\n'); } Err(invalid) => { - let error = decode_exact(speaker, RunBudget::default(), &[invalid.byte()]) - .unwrap_err(); + let mut rejected = Vec::new(); + crate::tree::mirror::cbor::write_head( + &mut rejected, + crate::tree::mirror::cbor::MAJOR_ARRAY, + 1, + ); + crate::tree::mirror::cbor::write_head( + &mut rejected, + crate::tree::mirror::cbor::MAJOR_UINT, + u64::from(invalid.byte()), + ); + let error = + decode_exact(speaker, RunBudget::default(), &rejected).unwrap_err(); assert_eq!(error.origin, Origin::stream(speaker, stream)); assert!(matches!( error.kind, diff --git a/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs b/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs index eaf44cc3..df4035ab 100644 --- a/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs +++ b/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs @@ -25,9 +25,10 @@ use super::super::{ DecodeError, DecodeErrorKind, DecodeLeafError, DecodeSignalError, EncodeError, EncodeErrorKind, Flow, Frame, FrameWrite, LeafRunError, Reaction, RunBudget, Speaker, Stream, WireFrame, decode, decode_exact, encode, - frame::{LeafRun, QUERY_CHILD_LEN}, + frame::LeafRun, signal::{Signal, WireSignal}, }; +use crate::tree::mirror::cbor::{self, MAJOR_BSTR, MAJOR_TAG, TAG_CBOR_SEQUENCE}; use crate::{Version, message::Message, tree::typed::Hash}; use serde::Serialize; @@ -48,18 +49,22 @@ const WITNESS_MARKERS: &[&str] = &[ "kind: Truncated(missing=", "kind: QueryOutOfOrder(previous=", "kind: InvalidRun::Empty", - "kind: InvalidRun::TruncatedHeader(", + "kind: InvalidRun::Head(", + "kind: InvalidRun::NotARecord(", "kind: InvalidRun::TruncatedRecord(", "kind: OverbatchedRun(declared=", "kind: TrailingBytes(count=", + "kind: FrameShape(", + "kind: FrameArity(", + "kind: Malformed(part=", // DecodeLeafError (describe_leaf_kind). "kind: Record::Version(io=", "kind: Record::Message(io=", // FramePart: every frame component must fail somewhere. These ride the // encode Write witnesses; FramePart has no exhaustive match here, so a // new component's marker must be added by hand alongside its witnesses. + "part=FrameHead", "part=Signal", - "part=QueryCount", "part=QueryChildren", "part=SupplyLength", "part=SupplyRun", @@ -69,8 +74,8 @@ const WITNESS_MARKERS: &[&str] = &[ /// unreachable without resource exhaustion. const EXEMPT_MARKERS: &[(&str, &str)] = &[( "kind: SupplyTooLarge(", - "requires a run body past the u32 frame ceiling: a >4 GiB in-memory run \ - is resource exhaustion by construction; the ceiling itself is pinned at \ + "requires a run body past the wire's run byte cap: a >4 GiB in-memory \ + run is resource exhaustion by construction; the cap itself is pinned at \ its exact boundary in frame/tests.rs", )]; @@ -142,12 +147,15 @@ fn encode_errors(atlas: &mut String) { ); for speaker in [Speaker::Initiator, Speaker::Responder] { + // Offsets in whole delivered bytes: the interior-stream query and + // supply frames open with a three-byte frame head (one array byte, + // a two-byte signal head), and the small supply run's own heads + // take three more. for (label, frame, offset) in [ - ("write/signal", &query, 0), - ("write/query-count", &query, 1), - ("write/query-children", &query, 2), - ("write/supply-length", &supply, 1), - ("write/supply-run", &supply, 5), + ("write/frame-head", &query, 0), + ("write/query-children", &query, 3), + ("write/supply-length", &supply, 3), + ("write/supply-run", &supply, 6), ] { let error = encode(speaker, frame, &mut FailAfterWriter::new(offset)).unwrap_err(); record_encode(atlas, &format!("{speaker:?}/{label}"), &error); @@ -188,24 +196,32 @@ fn decode_errors(atlas: &mut String) { ); for speaker in [Speaker::Initiator, Speaker::Responder] { + // The three-byte frame head and the small supply run's heads + // locate every read failure below. let error = decode( speaker, RunBudget::default(), &mut FailAfterReader::new(matched.clone(), 0), ) .unwrap_err(); + record_decode(atlas, &format!("{speaker:?}/read/frame-head"), &error); + + let error = decode( + speaker, + RunBudget::default(), + &mut FailAfterReader::new(matched.clone(), 1), + ) + .unwrap_err(); record_decode(atlas, &format!("{speaker:?}/read/signal"), &error); - for (label, offset) in [("query-count", 1), ("query-children", 2)] { - let error = decode( - speaker, - RunBudget::default(), - &mut FailAfterReader::new(query.clone(), offset), - ) - .unwrap_err(); - record_decode(atlas, &format!("{speaker:?}/read/{label}"), &error); - } - for (label, offset) in [("supply-length", 1), ("supply-run", 5)] { + let error = decode( + speaker, + RunBudget::default(), + &mut FailAfterReader::new(query.clone(), 3), + ) + .unwrap_err(); + record_decode(atlas, &format!("{speaker:?}/read/query-children"), &error); + for (label, offset) in [("supply-length", 3), ("supply-run", 6)] { let error = decode( speaker, RunBudget::default(), @@ -216,23 +232,53 @@ fn decode_errors(atlas: &mut String) { } for (label, bytes) in [ - ("signal", &[][..]), - ("query-count", &query[..1]), - ("query-children", &query[..2]), - ("supply-length", &supply[..1]), - ("supply-run", &supply[..5]), + ("frame-head", &[][..]), + ("signal", &matched[..1]), + ("query-children", &query[..3]), + ("supply-length", &supply[..4]), + ("supply-run", &supply[..6]), ] { let error = decode_exact(speaker, RunBudget::default(), bytes).unwrap_err(); record_decode(atlas, &format!("{speaker:?}/truncated/{label}"), &error); } - let error = - decode_exact(speaker, RunBudget::default(), &[FIRST_RESERVED_SIGNAL]).unwrap_err(); + let mut reserved = Vec::new(); + cbor::write_head(&mut reserved, cbor::MAJOR_ARRAY, 1); + cbor::write_head( + &mut reserved, + cbor::MAJOR_UINT, + u64::from(FIRST_RESERVED_SIGNAL), + ); + let error = decode_exact(speaker, RunBudget::default(), &reserved).unwrap_err(); record_decode(atlas, &format!("{speaker:?}/reserved-signal"), &error); - let mut unordered = query.clone(); - unordered[2] = 2; - unordered[2 + QUERY_CHILD_LEN] = 1; + // The frame item's own shape violations: a non-array item, an + // arity contradicting the signal, and non-canonical heads. + let error = decode_exact(speaker, RunBudget::default(), &[0x00]).unwrap_err(); + record_decode(atlas, &format!("{speaker:?}/frame/not-an-array"), &error); + + let mut mismatched = Vec::new(); + cbor::write_head(&mut mismatched, cbor::MAJOR_ARRAY, 2); + mismatched.extend_from_slice(&matched[1..]); + let error = decode_exact(speaker, RunBudget::default(), &mismatched).unwrap_err(); + record_decode(atlas, &format!("{speaker:?}/frame/arity"), &error); + + // The matched frame's signal is a one-byte head (a small code), + // so its code byte is the head itself; respell it widened. + let widened = [0x81, 0x19, 0x00, matched[1]]; + let error = decode_exact(speaker, RunBudget::default(), &widened).unwrap_err(); + record_decode(atlas, &format!("{speaker:?}/frame/widened-signal"), &error); + + let unordered = encoded( + speaker, + ( + stream, + Frame::Reaction( + Reaction::Query(vec![(2, Hash::default()), (1, Hash::default())]), + Flow::Continue, + ), + ), + ); let error = decode_exact(speaker, RunBudget::default(), &unordered).unwrap_err(); record_decode(atlas, &format!("{speaker:?}/query-out-of-order"), &error); @@ -250,9 +296,19 @@ fn decode_errors(atlas: &mut String) { &raw_supply(stream, Flow::Continue, &[0, 0]), ) .unwrap_err(); - record_decode(atlas, &format!("{speaker:?}/run/truncated-header"), &error); + record_decode(atlas, &format!("{speaker:?}/run/not-a-record"), &error); - let mut overrun = 2_u32.to_be_bytes().to_vec(); + let error = decode_exact( + speaker, + RunBudget::default(), + &raw_supply(stream, Flow::Continue, &[0xd8, 0x3f, 0x58, 0x01, 0x00]), + ) + .unwrap_err(); + record_decode(atlas, &format!("{speaker:?}/run/widened-head"), &error); + + let mut overrun = Vec::new(); + cbor::write_head(&mut overrun, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut overrun, MAJOR_BSTR, 2); overrun.push(0); let error = decode_exact( speaker, @@ -289,7 +345,10 @@ fn decode_errors(atlas: &mut String) { for (label, speaker, stream, frame) in placement_witnesses() { let signal = frame_signal(&frame); let invalid = WireSignal::new(speaker, stream, signal).unwrap_err(); - let error = decode_exact(speaker, RunBudget::default(), &[invalid.byte()]).unwrap_err(); + let mut bytes = Vec::new(); + cbor::write_head(&mut bytes, cbor::MAJOR_ARRAY, 1); + cbor::write_head(&mut bytes, cbor::MAJOR_UINT, u64::from(invalid.byte())); + let error = decode_exact(speaker, RunBudget::default(), &bytes).unwrap_err(); record_decode(atlas, &format!("{label}/decode"), &error); } } @@ -302,13 +361,15 @@ fn decode_errors(atlas: &mut String) { fn record_errors(atlas: &mut String) { writeln!(atlas, "RECORD").unwrap(); - // A zero-length record is structurally valid; its empty body fails - // at the version decoder. + // An empty-content record is structurally valid; its missing + // version-atom tag fails at the version decoder. let run = LeafRun::from_encoded(framed_record(&[])).unwrap(); record_leaf(atlas, "record/version", &next_record_error(&run)); - // A record ending after its version fails at the message decoder. + // A record ending after its tagged version fails at the message + // decoder. let mut version = Vec::new(); + cbor::write_head(&mut version, MAJOR_TAG, crate::tags::VERSION_TAG); ciborium::ser::into_writer(&Version::new(), &mut version).unwrap(); let run = LeafRun::from_encoded(framed_record(&version)).unwrap(); record_leaf(atlas, "record/message", &next_record_error(&run)); @@ -322,9 +383,12 @@ fn record_errors(atlas: &mut String) { record_leaf(atlas, "record/trailing", &next_record_error(&run)); } -/// Frame one record body with its length header, as a run body. +/// Frame one record content behind its embedded-sequence heads, as a run +/// body. fn framed_record(record: &[u8]) -> Vec { - let mut body = (record.len() as u32).to_be_bytes().to_vec(); + let mut body = Vec::new(); + cbor::write_head(&mut body, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut body, MAJOR_BSTR, record.len() as u64); body.extend_from_slice(record); body } @@ -393,8 +457,11 @@ fn raw_supply(stream: Stream, flow: Flow, body: &[u8]) -> Vec { let signal = WireSignal::new(Speaker::Initiator, stream, Signal::Supply(flow)) .unwrap() .to_byte(); - let mut encoded = vec![signal]; - encoded.extend_from_slice(&(body.len() as u32).to_be_bytes()); + let mut encoded = Vec::new(); + cbor::write_head(&mut encoded, cbor::MAJOR_ARRAY, 2); + cbor::write_head(&mut encoded, cbor::MAJOR_UINT, u64::from(signal)); + cbor::write_head(&mut encoded, MAJOR_TAG, TAG_CBOR_SEQUENCE); + cbor::write_head(&mut encoded, MAJOR_BSTR, body.len() as u64); encoded.extend_from_slice(body); encoded } @@ -482,8 +549,22 @@ fn describe_decode_kind(out: &mut String, kind: &DecodeErrorKind) { DecodeErrorKind::InvalidRun(LeafRunError::Empty) => { write!(out, "InvalidRun::Empty").unwrap() } - DecodeErrorKind::InvalidRun(LeafRunError::TruncatedHeader { remaining }) => { - write!(out, "InvalidRun::TruncatedHeader(remaining={remaining})").unwrap() + DecodeErrorKind::InvalidRun(LeafRunError::Head { remaining, source }) => write!( + out, + "InvalidRun::Head(remaining={remaining}, source={source})" + ) + .unwrap(), + DecodeErrorKind::InvalidRun(LeafRunError::NotARecord { remaining, detail }) => write!( + out, + "InvalidRun::NotARecord(remaining={remaining}, {detail})" + ) + .unwrap(), + DecodeErrorKind::FrameShape { detail } => write!(out, "FrameShape({detail})").unwrap(), + DecodeErrorKind::FrameArity { expected, found } => { + write!(out, "FrameArity(expected={expected}, found={found})").unwrap() + } + DecodeErrorKind::Malformed { part, detail } => { + write!(out, "Malformed(part={part:?}, {detail})").unwrap() } DecodeErrorKind::InvalidRun(LeafRunError::TruncatedRecord { len, remaining }) => write!( out, diff --git a/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap b/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap index 299c9212..f24f4c5a 100644 --- a/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap +++ b/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap @@ -3,17 +3,11 @@ source: src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs expression: build_atlas() --- ENCODE - Initiator/write/signal - display: Initiator stream 8: could not write the frame's signal byte + Initiator/write/frame-head + display: Initiator stream 8: could not write the frame's frame head origin: Initiator stream 8 - kind: Write(part=Signal, io=Other) - source[0]: could not write the frame's signal byte - source[1]: Io(Other) - Initiator/write/query-count - display: Initiator stream 8: could not write the frame's query count - origin: Initiator stream 8 - kind: Write(part=QueryCount, io=Other) - source[0]: could not write the frame's query count + kind: Write(part=FrameHead, io=Other) + source[0]: could not write the frame's frame head source[1]: Io(Other) Initiator/write/query-children display: Initiator stream 8: could not write the frame's query child listing @@ -22,10 +16,10 @@ ENCODE source[0]: could not write the frame's query child listing source[1]: Io(Other) Initiator/write/supply-length - display: Initiator stream 8: could not write the frame's supply run length + display: Initiator stream 8: could not write the frame's supply run head origin: Initiator stream 8 kind: Write(part=SupplyLength, io=Other) - source[0]: could not write the frame's supply run length + source[0]: could not write the frame's supply run head source[1]: Io(Other) Initiator/write/supply-run display: Initiator stream 8: could not write the frame's supply run @@ -39,17 +33,11 @@ ENCODE kind: Flush(io=Other) source[0]: could not flush the completed frame source[1]: Io(Other) - Responder/write/signal - display: Responder stream 8: could not write the frame's signal byte - origin: Responder stream 8 - kind: Write(part=Signal, io=Other) - source[0]: could not write the frame's signal byte - source[1]: Io(Other) - Responder/write/query-count - display: Responder stream 8: could not write the frame's query count + Responder/write/frame-head + display: Responder stream 8: could not write the frame's frame head origin: Responder stream 8 - kind: Write(part=QueryCount, io=Other) - source[0]: could not write the frame's query count + kind: Write(part=FrameHead, io=Other) + source[0]: could not write the frame's frame head source[1]: Io(Other) Responder/write/query-children display: Responder stream 8: could not write the frame's query child listing @@ -58,10 +46,10 @@ ENCODE source[0]: could not write the frame's query child listing source[1]: Io(Other) Responder/write/supply-length - display: Responder stream 8: could not write the frame's supply run length + display: Responder stream 8: could not write the frame's supply run head origin: Responder stream 8 kind: Write(part=SupplyLength, io=Other) - source[0]: could not write the frame's supply run length + source[0]: could not write the frame's supply run head source[1]: Io(Other) Responder/write/supply-run display: Responder stream 8: could not write the frame's supply run @@ -76,17 +64,17 @@ ENCODE source[0]: could not flush the completed frame source[1]: Io(Other) DECODE + Initiator/read/frame-head + display: Initiator direction: could not read the frame's frame head + origin: Initiator direction + kind: Read(part=FrameHead, io=Other) + source[0]: could not read the frame's frame head + source[1]: Io(Other) Initiator/read/signal - display: Initiator direction: could not read the frame's signal byte + display: Initiator direction: could not read the frame's signal origin: Initiator direction kind: Read(part=Signal, io=Other) - source[0]: could not read the frame's signal byte - source[1]: Io(Other) - Initiator/read/query-count - display: Initiator stream 8: could not read the frame's query count - origin: Initiator stream 8 - kind: Read(part=QueryCount, io=Other) - source[0]: could not read the frame's query count + source[0]: could not read the frame's signal source[1]: Io(Other) Initiator/read/query-children display: Initiator stream 8: could not read the frame's query child listing @@ -95,10 +83,10 @@ DECODE source[0]: could not read the frame's query child listing source[1]: Io(Other) Initiator/read/supply-length - display: Initiator stream 8: could not read the frame's supply run length + display: Initiator stream 8: could not read the frame's supply run head origin: Initiator stream 8 kind: Read(part=SupplyLength, io=Other) - source[0]: could not read the frame's supply run length + source[0]: could not read the frame's supply run head source[1]: Io(Other) Initiator/read/supply-run display: Initiator stream 8: could not read the frame's supply run @@ -106,17 +94,17 @@ DECODE kind: Read(part=SupplyRun, io=Other) source[0]: could not read the frame's supply run source[1]: Io(Other) + Initiator/truncated/frame-head + display: Initiator direction: frame ended before its frame head + origin: Initiator direction + kind: Truncated(missing=FrameHead, io=UnexpectedEof) + source[0]: frame ended before its frame head + source[1]: Io(UnexpectedEof) Initiator/truncated/signal - display: Initiator direction: frame ended before its signal byte + display: Initiator direction: frame ended before its signal origin: Initiator direction kind: Truncated(missing=Signal, io=UnexpectedEof) - source[0]: frame ended before its signal byte - source[1]: Io(UnexpectedEof) - Initiator/truncated/query-count - display: Initiator stream 8: frame ended before its query count - origin: Initiator stream 8 - kind: Truncated(missing=QueryCount, io=UnexpectedEof) - source[0]: frame ended before its query count + source[0]: frame ended before its signal source[1]: Io(UnexpectedEof) Initiator/truncated/query-children display: Initiator stream 8: frame ended before its query child listing @@ -125,10 +113,10 @@ DECODE source[0]: frame ended before its query child listing source[1]: Io(UnexpectedEof) Initiator/truncated/supply-length - display: Initiator stream 8: frame ended before its supply run length + display: Initiator stream 8: frame ended before its supply run head origin: Initiator stream 8 kind: Truncated(missing=SupplyLength, io=UnexpectedEof) - source[0]: frame ended before its supply run length + source[0]: frame ended before its supply run head source[1]: Io(UnexpectedEof) Initiator/truncated/supply-run display: Initiator stream 8: frame ended before its supply run @@ -137,11 +125,26 @@ DECODE source[0]: frame ended before its supply run source[1]: Io(UnexpectedEof) Initiator/reserved-signal - display: Initiator stream 0: signal byte 0xaa encodes an invalid semantic state + display: Initiator stream 0: signal code 0xaa encodes an invalid semantic state origin: Initiator stream 0 kind: InvalidSignal::Reserved(byte=aa, state=10) - source[0]: signal byte 0xaa encodes an invalid semantic state + source[0]: signal code 0xaa encodes an invalid semantic state source[1]: semantic signal state 10 is outside the valid range + Initiator/frame/not-an-array + display: Initiator direction: frame is not a CBOR reaction array: frame item is not an array + origin: Initiator direction + kind: FrameShape(frame item is not an array) + source[0]: frame is not a CBOR reaction array: frame item is not an array + Initiator/frame/arity + display: Initiator stream 8: frame array carries 2 item(s) where its signal takes 1 + origin: Initiator stream 8 + kind: FrameArity(expected=1, found=2) + source[0]: frame array carries 2 item(s) where its signal takes 1 + Initiator/frame/widened-signal + display: Initiator direction: frame's signal is malformed: head not in shortest form + origin: Initiator direction + kind: Malformed(part=Signal, head not in shortest form) + source[0]: frame's signal is malformed: head not in shortest form Initiator/query-out-of-order display: Initiator stream 8: query child radix 1 does not follow 2 in ascending order origin: Initiator stream 8 @@ -152,37 +155,43 @@ DECODE origin: Initiator stream 8 kind: InvalidRun::Empty source[0]: a supply run carries no leaf records - Initiator/run/truncated-header - display: Initiator stream 8: a leaf record header overruns the 2 bytes left in its run + Initiator/run/not-a-record + display: Initiator stream 8: a 2-byte run tail is not a leaf record: record does not open with the embedded-sequence tag + origin: Initiator stream 8 + kind: InvalidRun::NotARecord(remaining=2, record does not open with the embedded-sequence tag) + source[0]: a 2-byte run tail is not a leaf record: record does not open with the embedded-sequence tag + Initiator/run/widened-head + display: Initiator stream 8: a leaf record's heads are invalid in the 5 bytes left in its run: CBOR head is not in shortest form origin: Initiator stream 8 - kind: InvalidRun::TruncatedHeader(remaining=2) - source[0]: a leaf record header overruns the 2 bytes left in its run + kind: InvalidRun::Head(remaining=5, source=CBOR head is not in shortest form) + source[0]: a leaf record's heads are invalid in the 5 bytes left in its run: CBOR head is not in shortest form + source[1]: CBOR head is not in shortest form Initiator/run/truncated-record display: Initiator stream 8: a leaf record of 2 bytes overruns the 1 bytes left in its run origin: Initiator stream 8 kind: InvalidRun::TruncatedRecord(len=2, remaining=1) source[0]: a leaf record of 2 bytes overruns the 1 bytes left in its run Initiator/run/overbatched - display: Initiator stream 8: supply frame occupies 19 wire bytes, batching records past the 0-byte run budget + display: Initiator stream 8: supply frame occupies 28 wire bytes, batching records past the 0-byte run budget origin: Initiator stream 8 - kind: OverbatchedRun(declared=19, budget=0) - source[0]: supply frame occupies 19 wire bytes, batching records past the 0-byte run budget + kind: OverbatchedRun(declared=28, budget=0) + source[0]: supply frame occupies 28 wire bytes, batching records past the 0-byte run budget Initiator/frame/trailing display: Initiator stream 8: 1 trailing bytes follow the frame origin: Initiator stream 8 kind: TrailingBytes(count=1) source[0]: 1 trailing bytes follow the frame + Responder/read/frame-head + display: Responder direction: could not read the frame's frame head + origin: Responder direction + kind: Read(part=FrameHead, io=Other) + source[0]: could not read the frame's frame head + source[1]: Io(Other) Responder/read/signal - display: Responder direction: could not read the frame's signal byte + display: Responder direction: could not read the frame's signal origin: Responder direction kind: Read(part=Signal, io=Other) - source[0]: could not read the frame's signal byte - source[1]: Io(Other) - Responder/read/query-count - display: Responder stream 8: could not read the frame's query count - origin: Responder stream 8 - kind: Read(part=QueryCount, io=Other) - source[0]: could not read the frame's query count + source[0]: could not read the frame's signal source[1]: Io(Other) Responder/read/query-children display: Responder stream 8: could not read the frame's query child listing @@ -191,10 +200,10 @@ DECODE source[0]: could not read the frame's query child listing source[1]: Io(Other) Responder/read/supply-length - display: Responder stream 8: could not read the frame's supply run length + display: Responder stream 8: could not read the frame's supply run head origin: Responder stream 8 kind: Read(part=SupplyLength, io=Other) - source[0]: could not read the frame's supply run length + source[0]: could not read the frame's supply run head source[1]: Io(Other) Responder/read/supply-run display: Responder stream 8: could not read the frame's supply run @@ -202,17 +211,17 @@ DECODE kind: Read(part=SupplyRun, io=Other) source[0]: could not read the frame's supply run source[1]: Io(Other) + Responder/truncated/frame-head + display: Responder direction: frame ended before its frame head + origin: Responder direction + kind: Truncated(missing=FrameHead, io=UnexpectedEof) + source[0]: frame ended before its frame head + source[1]: Io(UnexpectedEof) Responder/truncated/signal - display: Responder direction: frame ended before its signal byte + display: Responder direction: frame ended before its signal origin: Responder direction kind: Truncated(missing=Signal, io=UnexpectedEof) - source[0]: frame ended before its signal byte - source[1]: Io(UnexpectedEof) - Responder/truncated/query-count - display: Responder stream 8: frame ended before its query count - origin: Responder stream 8 - kind: Truncated(missing=QueryCount, io=UnexpectedEof) - source[0]: frame ended before its query count + source[0]: frame ended before its signal source[1]: Io(UnexpectedEof) Responder/truncated/query-children display: Responder stream 8: frame ended before its query child listing @@ -221,10 +230,10 @@ DECODE source[0]: frame ended before its query child listing source[1]: Io(UnexpectedEof) Responder/truncated/supply-length - display: Responder stream 8: frame ended before its supply run length + display: Responder stream 8: frame ended before its supply run head origin: Responder stream 8 kind: Truncated(missing=SupplyLength, io=UnexpectedEof) - source[0]: frame ended before its supply run length + source[0]: frame ended before its supply run head source[1]: Io(UnexpectedEof) Responder/truncated/supply-run display: Responder stream 8: frame ended before its supply run @@ -233,11 +242,26 @@ DECODE source[0]: frame ended before its supply run source[1]: Io(UnexpectedEof) Responder/reserved-signal - display: Responder stream 0: signal byte 0xaa encodes an invalid semantic state + display: Responder stream 0: signal code 0xaa encodes an invalid semantic state origin: Responder stream 0 kind: InvalidSignal::Reserved(byte=aa, state=10) - source[0]: signal byte 0xaa encodes an invalid semantic state + source[0]: signal code 0xaa encodes an invalid semantic state source[1]: semantic signal state 10 is outside the valid range + Responder/frame/not-an-array + display: Responder direction: frame is not a CBOR reaction array: frame item is not an array + origin: Responder direction + kind: FrameShape(frame item is not an array) + source[0]: frame is not a CBOR reaction array: frame item is not an array + Responder/frame/arity + display: Responder stream 8: frame array carries 2 item(s) where its signal takes 1 + origin: Responder stream 8 + kind: FrameArity(expected=1, found=2) + source[0]: frame array carries 2 item(s) where its signal takes 1 + Responder/frame/widened-signal + display: Responder direction: frame's signal is malformed: head not in shortest form + origin: Responder direction + kind: Malformed(part=Signal, head not in shortest form) + source[0]: frame's signal is malformed: head not in shortest form Responder/query-out-of-order display: Responder stream 8: query child radix 1 does not follow 2 in ascending order origin: Responder stream 8 @@ -248,41 +272,47 @@ DECODE origin: Responder stream 8 kind: InvalidRun::Empty source[0]: a supply run carries no leaf records - Responder/run/truncated-header - display: Responder stream 8: a leaf record header overruns the 2 bytes left in its run + Responder/run/not-a-record + display: Responder stream 8: a 2-byte run tail is not a leaf record: record does not open with the embedded-sequence tag + origin: Responder stream 8 + kind: InvalidRun::NotARecord(remaining=2, record does not open with the embedded-sequence tag) + source[0]: a 2-byte run tail is not a leaf record: record does not open with the embedded-sequence tag + Responder/run/widened-head + display: Responder stream 8: a leaf record's heads are invalid in the 5 bytes left in its run: CBOR head is not in shortest form origin: Responder stream 8 - kind: InvalidRun::TruncatedHeader(remaining=2) - source[0]: a leaf record header overruns the 2 bytes left in its run + kind: InvalidRun::Head(remaining=5, source=CBOR head is not in shortest form) + source[0]: a leaf record's heads are invalid in the 5 bytes left in its run: CBOR head is not in shortest form + source[1]: CBOR head is not in shortest form Responder/run/truncated-record display: Responder stream 8: a leaf record of 2 bytes overruns the 1 bytes left in its run origin: Responder stream 8 kind: InvalidRun::TruncatedRecord(len=2, remaining=1) source[0]: a leaf record of 2 bytes overruns the 1 bytes left in its run Responder/run/overbatched - display: Responder stream 8: supply frame occupies 19 wire bytes, batching records past the 0-byte run budget + display: Responder stream 8: supply frame occupies 28 wire bytes, batching records past the 0-byte run budget origin: Responder stream 8 - kind: OverbatchedRun(declared=19, budget=0) - source[0]: supply frame occupies 19 wire bytes, batching records past the 0-byte run budget + kind: OverbatchedRun(declared=28, budget=0) + source[0]: supply frame occupies 28 wire bytes, batching records past the 0-byte run budget Responder/frame/trailing display: Responder stream 8: 1 trailing bytes follow the frame origin: Responder stream 8 kind: TrailingBytes(count=1) source[0]: 1 trailing bytes follow the frame placement/opening-supplies/decode - display: Initiator stream 0: signal byte 0x00 is invalid for the initiator's opening supplies + display: Initiator stream 0: signal code 0x00 is invalid for the initiator's opening supplies origin: Initiator stream 0 kind: InvalidSignal::Placement(byte=00, class=OpeningSupplies) - source[0]: signal byte 0x00 is invalid for the initiator's opening supplies + source[0]: signal code 0x00 is invalid for the initiator's opening supplies placement/leaf-parent/decode - display: Initiator stream 16: signal byte 0x54 is invalid for the initiator's leaf-parent replies + display: Initiator stream 16: signal code 0x54 is invalid for the initiator's leaf-parent replies origin: Initiator stream 16 kind: InvalidSignal::Placement(byte=54, class=LeafParentReplies) - source[0]: signal byte 0x54 is invalid for the initiator's leaf-parent replies + source[0]: signal code 0x54 is invalid for the initiator's leaf-parent replies placement/terminal-leaf/decode - display: Responder stream 16: signal byte 0x10 is invalid for the responder's terminal leaf replies + display: Responder stream 16: signal code 0x10 is invalid for the responder's terminal leaf replies origin: Responder stream 16 kind: InvalidSignal::Placement(byte=10, class=TerminalLeafReplies) - source[0]: signal byte 0x10 is invalid for the responder's terminal leaf replies + source[0]: signal code 0x10 is invalid for the responder's terminal leaf replies RECORD record/version display: supplied Version could not be decoded diff --git a/src/tree/mirror/streaming/remote/proxy/start.rs b/src/tree/mirror/streaming/remote/proxy/start.rs index bf5b4f2b..c3e34cb2 100644 --- a/src/tree/mirror/streaming/remote/proxy/start.rs +++ b/src/tree/mirror/streaming/remote/proxy/start.rs @@ -1,35 +1,29 @@ //! The wire participant's protocol handshake states. use crate::message::PayloadDeserializer; -use std::io; use tokio::io::{AsyncRead, AsyncWrite}; use crate::{ - Version, link::{Acceptor, Connector, Link}, tree::{ - mirror::{ - framing, - streaming::{ - Backend, Leaf, - message::{Greeting, initiates}, - protocol::{self, Accept, CompleteConnect, Connect}, - remote::{ - codec::{RunBudget, Speaker, validate_children}, - proxy::{ - Connected, Error, - work::{Physical, Work}, - }, - streams::{AcceptDriver, claims, error_route}, + mirror::streaming::{ + Backend, Leaf, + message::{Greeting, initiates}, + protocol::{self, Accept, CompleteConnect, Connect}, + remote::{ + codec::{RunBudget, Speaker, greeting as greeting_codec}, + proxy::{ + Connected, Error, + work::{Physical, Work}, }, - stats::Recorder, - window::{Window, WindowConfig}, + streams::{AcceptDriver, claims, error_route}, }, + stats::Recorder, + window::{Window, WindowConfig}, }, typed::{ Hash, - hash::MERKLE_HASH_LEN, height::{Root, Z}, }, }, @@ -228,85 +222,44 @@ fn run_budget(ours: &Greeting, theirs: &Greeting) -> RunBudget { RunBudget::from_bytes(usize::try_from(bytes).unwrap_or(usize::MAX)) } -/// Send one greeting: the size-prefixed causal-version frame, then the -/// root-fan listing frame. +/// Send one greeting: a single self-delimiting control-stream item, +/// flushed in one hop. /// -/// The first frame's body is `set_len (8 B LE) ‖ max_version_bytes -/// (8 B LE) ‖ target_message_size (8 B LE) ‖ version`. Both frames flush -/// on the same hop; the listing frame is the wire carriage of the -/// opening question's content (see [`Greeting`] for the always-carry -/// trade). +/// The spelling lives in +/// [`codec::greeting`](crate::tree::mirror::streaming::remote::codec::greeting). +/// The listing rides inside the item — the wire carriage of the opening +/// question's content (see [`Greeting`] for the always-carry trade). async fn send(greeting: &Greeting, write: &mut W) -> Result<(), Error> where W: AsyncWrite + Unpin, { - let mut write = framing::FrameWrite::new(write); - let mut first = - Vec::with_capacity(framing::GREETING_SIZE_WORDS_LEN + greeting.version.as_bytes().len()); - first.extend_from_slice(&greeting.set_len.to_le_bytes()); - first.extend_from_slice(&greeting.max_version_bytes.to_le_bytes()); - first.extend_from_slice(&greeting.target_message_size.to_le_bytes()); - first.extend_from_slice(greeting.version.as_bytes()); - write.frame(&first).await.map_err(Error::HandshakeWrite)?; - // The listing frame is raw fixed-width records — radix byte, then the - // Merkle hash — with the frame length carrying the count, exactly the - // codec's query-listing shape. - let mut listing = Vec::with_capacity(greeting.listing.len() * (1 + MERKLE_HASH_LEN)); - for (radix, hash) in &greeting.listing { - listing.push(*radix); - listing.extend_from_slice(hash.as_bytes()); - } - write.frame(&listing).await.map_err(Error::HandshakeWrite) + use tokio::io::AsyncWriteExt as _; + let item = greeting_codec::encode_greeting(greeting); + write + .write_all(&item) + .await + .map_err(Error::HandshakeWrite)?; + write.flush().await.map_err(Error::HandshakeWrite) } -/// Receive and canonically decode one greeting: the size-prefixed -/// causal-version frame, then the root-fan listing frame. +/// Receive and canonically decode one greeting item. /// -/// The listing is peer-controlled, so its canonical strictly-ascending radix -/// order is enforced here — the same rule the frame codec applies to a wire -/// query — before any scope is built from it. +/// The greeting is peer-controlled, so its whole spelling is enforced +/// on ingress — deterministic heads, the exact key roster, and the +/// listing's canonical strictly-ascending radix order, the same rule the +/// frame codec applies to a wire query — before any scope is built +/// from it. async fn receive(read: &mut R) -> Result> where R: AsyncRead + Unpin, { - let mut read = framing::FrameRead::new(read); - let bytes = read.frame().await.map_err(Error::HandshakeRead)?; - let short = || { - Error::HandshakeDecode(io::Error::new( - io::ErrorKind::InvalidData, - "greeting version frame is shorter than its size prefixes", - )) - }; - let (set_len, max_version_bytes, target_message_size) = - framing::greeting_words(&bytes).ok_or_else(short)?; - let version = Version::decode(&bytes[framing::GREETING_SIZE_WORDS_LEN..]) - .map_err(|e| Error::HandshakeDecode(io::Error::new(io::ErrorKind::InvalidData, e)))?; - let bytes = read.frame().await.map_err(Error::HandshakeRead)?; - // The frame length carries the record count; a remainder is a - // malformed listing, not a short read. - if !bytes.len().is_multiple_of(1 + MERKLE_HASH_LEN) { - return Err(Error::HandshakeDecode(io::Error::new( - io::ErrorKind::InvalidData, - "listing frame is not a whole number of radix-hash records", - ))); - } - let listing: Vec<(u8, Hash)> = bytes - .chunks_exact(1 + MERKLE_HASH_LEN) - .map(|record| { - let (&radix, hash) = record.split_first().expect("a record has a radix byte"); - let mut bytes = [0u8; MERKLE_HASH_LEN]; - bytes.copy_from_slice(hash); - (radix, Hash(bytes)) + greeting_codec::read_greeting(read) + .await + .map_err(|e| match e { + greeting_codec::ReadGreetingError::Io(io) => Error::HandshakeRead(io), + greeting_codec::ReadGreetingError::Decode(io) => Error::HandshakeDecode(io), + greeting_codec::ReadGreetingError::Listing(order) => Error::HandshakeListing(order), }) - .collect(); - validate_children(&listing).map_err(Error::HandshakeListing)?; - Ok(Greeting { - version, - set_len, - max_version_bytes, - target_message_size, - listing, - }) } /// Return untouched control halves on equality, otherwise open the session. diff --git a/src/tree/mirror/streaming/remote/proxy/start/tests.rs b/src/tree/mirror/streaming/remote/proxy/start/tests.rs index 8e3e0211..dd7bdc13 100644 --- a/src/tree/mirror/streaming/remote/proxy/start/tests.rs +++ b/src/tree/mirror/streaming/remote/proxy/start/tests.rs @@ -1,16 +1,16 @@ //! Ingress validation of the control-stream greeting decoder. //! -//! The greeting's frames are peer-controlled bytes arriving on the control -//! stream — first the causal-version frame, then the root-fan listing frame, -//! whose structural validation lives in [`receive`]: the same canonical-order -//! rule the frame codec applies to a wire query, applied at the greeting -//! ingress. +//! The greeting is one peer-controlled item arriving on the control +//! stream — the embedded-item tag wrapping a byte string of the greeting +//! map — whose structural validation lives in [`receive`]: deterministic +//! heads, the exact key roster, and the same canonical-order rule the +//! frame codec applies to a wire query, applied at the greeting ingress. //! //! The scripted-fault harness wraps only data streams, so this ingress is //! exercised here directly: crafted control-stream bytes must surface the //! typed greeting errors ([`Error::HandshakeRead`] for truncation and //! length lies, [`Error::HandshakeListing`] for canonical-order violations, -//! [`Error::HandshakeDecode`] for malformed bodies), never a panic, and a +//! [`Error::HandshakeDecode`] for malformed items), never a panic, and a //! canonical greeting must decode intact. use std::convert::Infallible; @@ -21,32 +21,32 @@ use proptest::prelude::*; use super::{Error, Greeting, receive}; use crate::Version; use crate::tree::arb::nth_party; +use crate::tree::mirror::cbor::{self, MAJOR_BSTR, TAG_EMBEDDED_ITEM}; use crate::tree::mirror::streaming::remote::codec::QueryOrderError; +use crate::tree::mirror::streaming::remote::codec::greeting::encode_greeting; use crate::tree::typed::Hash; -/// Length-delimit one frame body exactly as [`super::send`] does. -fn frame(body: &[u8]) -> Vec { - let len = u32::try_from(body.len()).expect("test frame bodies fit in u32"); - let mut bytes = len.to_be_bytes().to_vec(); - bytes.extend_from_slice(body); +/// Wrap raw content exactly as the greeting item does: the embedded-item +/// tag, then a byte string of the content. +fn raw_item(content: &[u8]) -> Vec { + let mut bytes = Vec::new(); + cbor::write_tag(&mut bytes, TAG_EMBEDDED_ITEM); + cbor::write_head(&mut bytes, MAJOR_BSTR, content.len() as u64); + bytes.extend_from_slice(content); bytes } -/// A version frame's body: the sender's set-size, version-size-bound, -/// and message-size-target prefixes, then the version. -fn version_body(version: &Version) -> Vec { - let mut body = 0_u64.to_le_bytes().to_vec(); - body.extend_from_slice(&0_u64.to_le_bytes()); - body.extend_from_slice(&0_u64.to_le_bytes()); - body.extend_from_slice(version.as_bytes()); - body -} - -/// A full greeting: the identity-version frame, then `listing_body` framed. -fn greeting(listing_body: &[u8]) -> Vec { - let mut bytes = frame(&version_body(&Version::new())); - bytes.extend_from_slice(&frame(listing_body)); - bytes +/// A greeting whose sizes are zero and whose listing is caller-selected; +/// the encoder trusts its caller, so a non-canonical listing synthesizes +/// wire violations directly. +fn greeting(listing: Vec<(u8, Hash)>) -> Vec { + encode_greeting(&Greeting { + version: Version::new(), + set_len: 0, + max_version_bytes: 0, + target_message_size: 0, + listing, + }) } /// Decode crafted greeting bytes through the production ingress. @@ -54,53 +54,43 @@ async fn receive_greeting(bytes: &[u8]) -> Result> { receive(&mut &bytes[..]).await } -/// A nonempty causal version, so truncating its encoding leaves bytes to cut. -fn ticked_version() -> Version { - let party = nth_party(0); - let mut version = Version::new(); - version.tick(&party); - version -} - -/// Encode a root-fan listing as its wire form: raw radix-hash records, -/// the frame length carrying the count. -fn encode_listing(children: &[(u8, Hash)]) -> Vec { - let mut body = Vec::new(); - for (radix, hash) in children { - body.push(*radix); - body.extend_from_slice(hash.as_bytes()); - } - body +/// The map content behind a greeting item's heads. +fn content_of(item: &[u8]) -> Vec { + let mut input = item; + cbor::read_head(&mut input).expect("the item's tag head"); + cbor::read_head(&mut input).expect("the item's string head"); + input.to_vec() } -/// A greeting cut inside the version frame's length header fails as a typed -/// read error. +/// A greeting cut inside the item's heads fails as a typed read error. /// -/// The four header bytes are the first peer-controlled bytes of the -/// greeting; a peer that closes mid-header must surface +/// The tag and byte-string heads are the first peer-controlled bytes of +/// the greeting; a peer that closes mid-head must surface /// [`Error::HandshakeRead`] with `UnexpectedEof` — never a hang waiting on /// bytes that cannot arrive. #[pollster::test] async fn truncated_version_header_is_a_typed_read_error() { - let result = receive_greeting(&[0, 0]).await.map(|_| ()); + let result = receive_greeting(&[0xd8]).await.map(|_| ()); match result { Err(Error::HandshakeRead(error)) => { assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof) } - other => panic!("expected the truncated header's typed rejection, got {other:?}"), + other => panic!("expected the truncated head's typed rejection, got {other:?}"), } } -/// A version frame declaring more bytes than the stream carries fails as a +/// A greeting item declaring more bytes than the stream carries fails as a /// typed read error. /// -/// An over-declared length header makes the frame's exact read run off the -/// end of the peer's bytes; the lie must surface [`Error::HandshakeRead`] -/// with `UnexpectedEof`, never a partially filled frame handed to the -/// decoder. +/// An over-declared byte-string head makes the item's exact read run off +/// the end of the peer's bytes; the lie must surface +/// [`Error::HandshakeRead`] with `UnexpectedEof`, never a partially filled +/// item handed to the decoder. #[pollster::test] async fn over_declared_version_frame_is_a_typed_read_error() { - let mut bytes = 8_u32.to_be_bytes().to_vec(); + let mut bytes = Vec::new(); + cbor::write_tag(&mut bytes, TAG_EMBEDDED_ITEM); + cbor::write_head(&mut bytes, MAJOR_BSTR, 8); bytes.extend_from_slice(&[1, 2, 3]); let result = receive_greeting(&bytes).await.map(|_| ()); @@ -108,99 +98,93 @@ async fn over_declared_version_frame_is_a_typed_read_error() { Err(Error::HandshakeRead(error)) => { assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof) } - other => panic!("expected the over-declared frame's typed rejection, got {other:?}"), + other => panic!("expected the over-declared item's typed rejection, got {other:?}"), } } -/// A zero-length version frame fails as a typed decode error. +/// An empty greeting item fails as a typed decode error. /// -/// A frame whose declared length is zero carries neither the set-size -/// prefix nor a version; the empty body must surface -/// [`Error::HandshakeDecode`] — the under-declared degenerate case, -/// distinct from the transport-level truncations above. +/// An item whose byte string is empty carries no map at all; it must +/// surface [`Error::HandshakeDecode`] — the under-declared degenerate +/// case, distinct from the transport-level truncations above. #[pollster::test] async fn empty_version_frame_is_a_typed_decode_error() { - let result = receive_greeting(&frame(&[])).await.map(|_| ()); + let result = receive_greeting(&raw_item(&[])).await.map(|_| ()); assert!( matches!(result, Err(Error::HandshakeDecode(_))), - "expected the empty version body's typed rejection, got {result:?}", + "expected the empty item's typed rejection, got {result:?}", ); } -/// A version body truncated inside an honestly sized frame fails as a typed -/// decode error. +/// A control stream opening with anything but the embedded-item tag fails +/// as a typed decode error. /// -/// The frame is well-formed — its header matches its body — but the body is -/// a strict prefix of a canonical version encoding, so the decoder runs out -/// of bits: [`Error::HandshakeDecode`], never a panic and never a shorter -/// version silently accepted. +/// The tag is the greeting's identity on the wire: a bare map (however +/// well-formed inside) is not the greeting's one spelling. #[pollster::test] -async fn truncated_version_body_is_a_typed_decode_error() { - let mut body = version_body(&ticked_version()); - body.truncate(body.len() - 1); +async fn untagged_greeting_is_a_typed_decode_error() { + let item = greeting(Vec::new()); + let content = content_of(&item); - let result = receive_greeting(&frame(&body)).await.map(|_| ()); + let result = receive_greeting(&content).await.map(|_| ()); assert!( matches!(result, Err(Error::HandshakeDecode(_))), - "expected the truncated version body's typed rejection, got {result:?}", + "expected the untagged item's typed rejection, got {result:?}", ); } -/// A version frame with bytes after the version fails as a typed decode -/// error. +/// A greeting item with bytes after its map fails as a typed decode error. /// -/// The version encoding is prefix-free and the greeting decode is -/// canonical: the frame must contain exactly one version, so trailing bytes -/// surface [`Error::HandshakeDecode`] rather than being silently dropped -/// (which would let two encodings name one greeting). +/// The greeting decode is canonical: the item must contain exactly one +/// map, so trailing bytes surface [`Error::HandshakeDecode`] rather than +/// being silently dropped (which would let two encodings name one +/// greeting). #[pollster::test] async fn trailing_version_bytes_are_rejected() { - let mut body = version_body(&ticked_version()); - body.push(0xFF); + let item = greeting(Vec::new()); + let mut content = content_of(&item); + content.push(0xFF); - let result = receive_greeting(&frame(&body)).await.map(|_| ()); + let result = receive_greeting(&raw_item(&content)).await.map(|_| ()); assert!( matches!(result, Err(Error::HandshakeDecode(_))), "expected the trailing bytes' typed rejection, got {result:?}", ); } -/// A greeting that ends after the version frame fails as a typed read error. +/// A greeting whose stream ends inside the item's content fails as a +/// typed read error. /// -/// The listing frame is not optional: a peer that sends its version and -/// closes must surface [`Error::HandshakeRead`] with `UnexpectedEof` on the -/// missing listing, never a greeting with a defaulted listing. +/// The byte-string head promised more content than arrived: the exact +/// read runs off the stream's end, a transport-level truncation. #[pollster::test] async fn missing_listing_frame_is_a_typed_read_error() { - let bytes = frame(&version_body(&Version::new())); + let item = greeting(Vec::new()); + let bytes = &item[..item.len() - 1]; - let result = receive_greeting(&bytes).await.map(|_| ()); + let result = receive_greeting(bytes).await.map(|_| ()); match result { Err(Error::HandshakeRead(error)) => { assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof) } - other => panic!("expected the missing listing's typed rejection, got {other:?}"), + other => panic!("expected the cut content's typed rejection, got {other:?}"), } } proptest! { - /// Arbitrary greeting bodies decode to a greeting or a typed error, - /// never a panic. + /// Arbitrary greeting item contents decode to a greeting or a typed + /// error, never a panic. /// - /// Both frames are honestly sized around arbitrary bodies, so the fuzz - /// lands on the body decoders (the version's bit codec, the listing's - /// record shape and order check) rather than on the allocator via a lied - /// length header — the header lies are pinned deterministically above. - /// Every outcome must be `Ok` or one of the three typed greeting - /// errors. + /// The item is honestly sized around arbitrary content, so the fuzz + /// lands on the map decoder (heads, key roster, version atom, listing + /// shape and order) rather than on the allocator via a lied length — + /// the head lies are pinned deterministically above. Every outcome + /// must be `Ok` or one of the three typed greeting errors. #[test] fn arbitrary_greeting_bodies_never_panic( - version_body in vec(any::(), 0..64), - listing_body in vec(any::(), 0..64), + content in vec(any::(), 0..96), ) { - let mut bytes = frame(&version_body); - bytes.extend_from_slice(&frame(&listing_body)); - + let bytes = raw_item(&content); let result = pollster::block_on(receive_greeting(&bytes)).map(|_| ()); prop_assert!(matches!( result, @@ -219,10 +203,9 @@ proptest! { /// with the exact violating pair, before any scope is built from it. #[pollster::test] async fn unordered_listing_is_rejected() { - let listing = vec![(2_u8, Hash::default()), (1_u8, Hash::default())]; - let body = encode_listing(&listing); + let item = greeting(vec![(2_u8, Hash::default()), (1_u8, Hash::default())]); - let result = receive_greeting(&greeting(&body)).await.map(|_| ()); + let result = receive_greeting(&item).await.map(|_| ()); assert!( matches!( result, @@ -242,10 +225,9 @@ async fn unordered_listing_is_rejected() { /// with both offending radixes reported. #[pollster::test] async fn duplicate_listing_radix_is_rejected() { - let listing = vec![(3_u8, Hash::default()), (3_u8, Hash::default())]; - let body = encode_listing(&listing); + let item = greeting(vec![(3_u8, Hash::default()), (3_u8, Hash::default())]); - let result = receive_greeting(&greeting(&body)).await.map(|_| ()); + let result = receive_greeting(&item).await.map(|_| ()); assert!( matches!( result, @@ -258,42 +240,21 @@ async fn duplicate_listing_radix_is_rejected() { ); } -/// A listing frame whose record body is truncated fails as a typed decode -/// error. +/// A listing map whose content is truncated fails as a typed decode error. /// -/// A frame declaring more listing entries than its body carries must surface -/// [`Error::HandshakeDecode`] — a typed greeting failure, never a panic and -/// never a partial listing. +/// The greeting's map declared more listing entries than its content +/// carries; the cut must surface [`Error::HandshakeDecode`] — a typed +/// greeting failure, never a panic and never a partial listing. #[pollster::test] async fn truncated_listing_body_is_rejected() { - let listing = vec![(0_u8, Hash::default()), (1_u8, Hash::default())]; - let mut body = encode_listing(&listing); - body.truncate(body.len() - 1); + let item = greeting(vec![(0_u8, Hash::default()), (1_u8, Hash::default())]); + let mut content = content_of(&item); + content.truncate(content.len() - 1); - let result = receive_greeting(&greeting(&body)).await.map(|_| ()); + let result = receive_greeting(&raw_item(&content)).await.map(|_| ()); assert!( matches!(result, Err(Error::HandshakeDecode(_))), - "expected the truncated body's typed rejection, got {result:?}", - ); -} - -/// A listing frame with bytes after the listing fails as a typed decode -/// error. -/// -/// The greeting decode is canonical: the frame must be a whole number of -/// radix-hash records, so trailing garbage surfaces -/// [`Error::HandshakeDecode`] rather than being silently ignored (which -/// would let two encodings name one greeting). -#[pollster::test] -async fn trailing_listing_bytes_are_rejected() { - let listing: Vec<(u8, Hash)> = Vec::new(); - let mut body = encode_listing(&listing); - body.push(0xFF); - - let result = receive_greeting(&greeting(&body)).await.map(|_| ()); - assert!( - matches!(result, Err(Error::HandshakeDecode(_))), - "expected the trailing bytes' typed rejection, got {result:?}", + "expected the truncated listing's typed rejection, got {result:?}", ); } @@ -301,16 +262,26 @@ async fn trailing_listing_bytes_are_rejected() { /// /// The empty listing is a legal greeting — an empty tree's root fan — and /// the validation path must pass it through: the decoded handshake carries -/// the sent version and the empty listing, exercising the success arm of the +/// the sent fields and the empty listing, exercising the success arm of the /// same ingress the rejection tests pin. #[pollster::test] async fn empty_listing_greeting_decodes() { - let listing: Vec<(u8, Hash)> = Vec::new(); - let body = encode_listing(&listing); + let mut version = Version::new(); + version.tick(&nth_party(0)); + let item = encode_greeting(&Greeting { + version: version.clone(), + set_len: 7, + max_version_bytes: 512, + target_message_size: 1 << 16, + listing: Vec::new(), + }); - let handshake = receive_greeting(&greeting(&body)) + let handshake = receive_greeting(&item) .await .expect("a canonical empty-listing greeting decodes"); - assert_eq!(handshake.version, Version::new()); + assert_eq!(handshake.version, version); + assert_eq!(handshake.set_len, 7); + assert_eq!(handshake.max_version_bytes, 512); + assert_eq!(handshake.target_message_size, 1 << 16); assert!(handshake.listing.is_empty()); } diff --git a/src/tree/mirror/streaming/remote/proxy/tests.rs b/src/tree/mirror/streaming/remote/proxy/tests.rs index ec4dc5f0..77abd612 100644 --- a/src/tree/mirror/streaming/remote/proxy/tests.rs +++ b/src/tree/mirror/streaming/remote/proxy/tests.rs @@ -136,8 +136,8 @@ where let b = Handshaking::start(Local, Root::::from(b)).window(WindowConfig::FLOOR); let (mut a_link, mut b_link) = memory_with_capacity(64 * 1024); let network = crate::Network::from_bytes([1; 16]); - let mut a_staged = handshake::Staged::new(); - let mut b_staged = handshake::Staged::new(); + let mut a_staged = handshake::Staged::new(crate::Protocol::V2); + let mut b_staged = handshake::Staged::new(crate::Protocol::V2); let (seen_a, seen_b) = join!( handshake::preamble( crate::Protocol::V2, diff --git a/src/tree/mirror/streaming/remote/proxy/tests/harness.rs b/src/tree/mirror/streaming/remote/proxy/tests/harness.rs index b77a3a61..bf2a2983 100644 --- a/src/tree/mirror/streaming/remote/proxy/tests/harness.rs +++ b/src/tree/mirror/streaming/remote/proxy/tests/harness.rs @@ -17,7 +17,7 @@ use tokio::io::ReadBuf; use crate::link::{Acceptor, Connector, Done, Link, MemoryLink, memory_with_capacity}; use crate::testing::{IoPlan, IoReportHandle, IoSide, wrap_link}; -use crate::tree::mirror::framing::{GREETING_WORD_LEN, LENGTH_HEADER_LEN}; +use crate::tree::mirror::cbor; use crate::tree::mirror::streaming::window::WindowConfig; use crate::tree::{ Root as TreeRoot, @@ -41,10 +41,6 @@ const QUERY_STATES: RangeInclusive = 4..=5; /// Dense states below this boundary carry reactions rather than bare ends. const REACTION_STATE_COUNT: u8 = 8; -// The label's width is defined canonically beside the sender that writes -// it; the harness scripts frames with the same constant. -use super::super::super::streams::LABEL_LEN; - /// Failure returned by the materialized-left/proxy-right driver. pub type LeftError = MirrorError, RemoteError>; @@ -121,15 +117,16 @@ impl Script { /// /// Every flush below the [`StreamSender`] carries exactly one frame, so the /// flush boundary is the frame boundary. The stream's first flush carries -/// the two-byte label ahead of its frame; mutations offset past it and leave -/// it intact. +/// the label items ahead of its frame; mutations parse past them and leave +/// them intact. /// /// [`StreamSender`]: crate::tree::mirror::streaming::remote::streams::StreamSender pub struct ScriptedWrite { inner: W, script: Option