Skip to main content

reddb_wire/
topology.rs

1//! Canonical `Topology` payload — shared by both transports.
2//!
3//! ADR 0008 (`.red/adr/0008-topology-advertisement-security.md`)
4//! settled the security model and the schema-evolution rule:
5//!
6//!   * New optional fields land under a versioned envelope.
7//!   * Old parsers ignore unknown fields cleanly. Unknown version
8//!     tags are dropped (no panic), the consumer falls back to
9//!     URI-only routing.
10//!   * Schema-version bumps are reserved for changes a naive
11//!     optional field cannot express (a removed field, a renegotiated
12//!     meaning, a framing change). They are not the default move.
13//!
14//! Wire encoding (single shape across RedWire HelloAck + gRPC
15//! `Topology` RPC):
16//!
17//! ```text
18//! ┌────────────────────────────────────────────────────────────┐
19//! │ u8   version_tag        currently 0x01                     │
20//! │ u32  body_length (LE)   bytes that follow                  │
21//! │ ... body ...            version-specific payload encoding  │
22//! └────────────────────────────────────────────────────────────┘
23//! ```
24//!
25//! The header (tag + length) is fixed across versions so a parser
26//! that does not recognise the tag can still skip the entire blob
27//! cleanly.
28//!
29//! Body for `0x01`: a flat little-endian struct dump where every
30//! string is `u32 len` + utf-8 bytes:
31//!
32//! ```text
33//! u64  epoch
34//! str  primary.addr
35//! str  primary.region
36//! u32  replicas.len
37//! foreach replica:
38//!   str  addr
39//!   str  region
40//!   u8   healthy   (0 / 1)
41//!   u32  lag_ms
42//!   u64  last_applied_lsn
43//! ```
44//!
45//! No serde dependency: a hex dump stays readable, no extra crate
46//! pulled into `reddb-wire`, and the format matches the rest of the
47//! RedWire codec discipline.
48
49use reddb_types::encoding::base64_encode;
50
51/// Wire version tag for the initial schema.
52///
53/// Bumping this is reserved for genuinely breaking changes (removed
54/// field, renegotiated meaning, framing change). Additive evolution
55/// (new optional fields) keeps this byte stable — see ADR 0008 §4.
56pub const TOPOLOGY_WIRE_VERSION_V1: u8 = 0x01;
57
58/// Highest tag the current parser understands. A `Topology` blob
59/// stamped with anything else is ignored cleanly so an older client
60/// can keep its URI-only routing fallback.
61pub const MAX_KNOWN_TOPOLOGY_VERSION: u8 = TOPOLOGY_WIRE_VERSION_V1;
62
63/// Header size (version tag + u32 body length).
64pub const TOPOLOGY_HEADER_SIZE: usize = 1 + 4;
65
66/// Canonical topology payload — the same shape both transports
67/// carry. New optional fields go in here as `Option<…>` /
68/// `Vec<…>` and ride the existing version tag; only a genuinely
69/// breaking change earns a new tag.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Topology {
72    pub epoch: u64,
73    pub primary: Endpoint,
74    pub replicas: Vec<ReplicaInfo>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Endpoint {
79    pub addr: String,
80    pub region: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ReplicaInfo {
85    pub addr: String,
86    pub region: String,
87    pub healthy: bool,
88    pub lag_ms: u32,
89    pub last_applied_lsn: u64,
90    /// `true` while this replica is re-bootstrapping (loading a fresh
91    /// snapshot). Its `last_applied_lsn` describes data it is about to
92    /// discard, so a causal reader must treat it as ineligible for
93    /// bookmark reads even when the advertised frontier covers the
94    /// bookmark (issue #837). Non-causal reads are unaffected.
95    ///
96    /// Additive field (ADR 0008 §4): it rides the existing `0x01`
97    /// version tag as a trailing per-replica flag block appended after
98    /// the replica records. An old decoder stops after the records and
99    /// ignores the trailing bytes (defaulting every replica to
100    /// `false`); a new decoder reading old bytes finds no trailing
101    /// block and likewise defaults to `false`. No version bump.
102    pub rebootstrapping: bool,
103}
104
105/// Decode-side errors. Distinct from "unknown version tag", which
106/// is reported as `Ok(None)` on `decode_topology` so the consumer
107/// can fall back to URI-only routing without branching on an
108/// error variant.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum TopologyError {
111    Truncated,
112    BodyLengthMismatch { declared: u32, available: usize },
113    InvalidUtf8,
114    StringTooLong { declared: u32, remaining: usize },
115}
116
117impl std::fmt::Display for TopologyError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::Truncated => write!(f, "topology blob truncated (< 5-byte header)"),
121            Self::BodyLengthMismatch {
122                declared,
123                available,
124            } => write!(
125                f,
126                "topology body length mismatch: declared {declared}, available {available}"
127            ),
128            Self::InvalidUtf8 => write!(f, "topology string field is not valid UTF-8"),
129            Self::StringTooLong {
130                declared,
131                remaining,
132            } => write!(
133                f,
134                "topology string length {declared} exceeds remaining body bytes {remaining}"
135            ),
136        }
137    }
138}
139
140impl std::error::Error for TopologyError {}
141
142/// Encode `topology` to the canonical version-tagged byte string.
143/// Same bytes consumed by both RedWire HelloAck (after base64
144/// embedding in the JSON envelope) and the gRPC `TopologyReply`
145/// (carried directly as a `bytes` field).
146pub fn encode_topology(topology: &Topology) -> Vec<u8> {
147    let mut body = Vec::with_capacity(estimate_body_size(topology));
148    body.extend_from_slice(&topology.epoch.to_le_bytes());
149    write_str(&mut body, &topology.primary.addr);
150    write_str(&mut body, &topology.primary.region);
151    body.extend_from_slice(&(topology.replicas.len() as u32).to_le_bytes());
152    for r in &topology.replicas {
153        write_str(&mut body, &r.addr);
154        write_str(&mut body, &r.region);
155        body.push(if r.healthy { 1 } else { 0 });
156        body.extend_from_slice(&r.lag_ms.to_le_bytes());
157        body.extend_from_slice(&r.last_applied_lsn.to_le_bytes());
158    }
159    // Additive trailing block (ADR 0008 §4): one `rebootstrapping`
160    // byte per replica, in advertised order, appended after the
161    // records. Placed at the tail rather than interleaved into each
162    // record so an old decoder — which stops after `replicas.len`
163    // records — skips it cleanly instead of mis-framing every field.
164    for r in &topology.replicas {
165        body.push(if r.rebootstrapping { 1 } else { 0 });
166    }
167
168    let mut out = Vec::with_capacity(TOPOLOGY_HEADER_SIZE + body.len());
169    out.push(TOPOLOGY_WIRE_VERSION_V1);
170    out.extend_from_slice(&(body.len() as u32).to_le_bytes());
171    out.extend_from_slice(&body);
172    out
173}
174
175/// Decode a topology blob.
176///
177/// Returns:
178/// * `Ok(Some(Topology))` — recognised version tag, body parsed.
179/// * `Ok(None)` — unknown version tag. The consumer is expected to
180///   fall back to URI-only routing rather than treat this as an
181///   error (ADR 0008 §4: unknown fields are dropped, not rejected).
182/// * `Err(TopologyError)` — recognised tag, but the body was
183///   structurally malformed (truncated, invalid UTF-8, …).
184pub fn decode_topology(bytes: &[u8]) -> Result<Option<Topology>, TopologyError> {
185    if bytes.len() < TOPOLOGY_HEADER_SIZE {
186        return Err(TopologyError::Truncated);
187    }
188    let version = bytes[0];
189    let declared_len = u32::from_le_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]);
190    let body = &bytes[TOPOLOGY_HEADER_SIZE..];
191    if (body.len() as u64) < declared_len as u64 {
192        return Err(TopologyError::BodyLengthMismatch {
193            declared: declared_len,
194            available: body.len(),
195        });
196    }
197    let body = &body[..declared_len as usize];
198
199    if version > MAX_KNOWN_TOPOLOGY_VERSION {
200        // Forward-compat: unknown version tag, drop cleanly.
201        return Ok(None);
202    }
203
204    // version == 0x01
205    let mut cur = Cursor::new(body);
206    let epoch = cur.read_u64()?;
207    let primary_addr = cur.read_str()?;
208    let primary_region = cur.read_str()?;
209    let replica_count = cur.read_u32()? as usize;
210    let mut replicas = Vec::with_capacity(replica_count);
211    for _ in 0..replica_count {
212        let addr = cur.read_str()?;
213        let region = cur.read_str()?;
214        let healthy = cur.read_u8()? != 0;
215        let lag_ms = cur.read_u32()?;
216        let last_applied_lsn = cur.read_u64()?;
217        replicas.push(ReplicaInfo {
218            addr,
219            region,
220            healthy,
221            lag_ms,
222            last_applied_lsn,
223            // Default until the trailing block is read below. A
224            // pre-#837 advertisement carries no trailing block, so
225            // every replica stays `false`.
226            rebootstrapping: false,
227        });
228    }
229    // Additive trailing block: a `rebootstrapping` byte per replica.
230    // Present only when the producer is post-#837. When the producer
231    // is older the cursor has no bytes left here and every flag keeps
232    // its `false` default. A partial/truncated block (fewer bytes than
233    // replicas) leaves the unread tail `false` rather than erroring —
234    // forward-compat posture per ADR 0008 §4.
235    for r in replicas.iter_mut() {
236        if cur.remaining() == 0 {
237            break;
238        }
239        r.rebootstrapping = cur.read_u8()? != 0;
240    }
241    Ok(Some(Topology {
242        epoch,
243        primary: Endpoint {
244            addr: primary_addr,
245            region: primary_region,
246        },
247        replicas,
248    }))
249}
250
251fn estimate_body_size(t: &Topology) -> usize {
252    let endpoint = |e: &Endpoint| 4 + e.addr.len() + 4 + e.region.len();
253    let mut n = 8 + endpoint(&t.primary) + 4;
254    for r in &t.replicas {
255        n += 4 + r.addr.len() + 4 + r.region.len() + 1 + 4 + 8;
256    }
257    // Trailing `rebootstrapping` flag block: one byte per replica.
258    n += t.replicas.len();
259    n
260}
261
262fn write_str(buf: &mut Vec<u8>, s: &str) {
263    buf.extend_from_slice(&(s.len() as u32).to_le_bytes());
264    buf.extend_from_slice(s.as_bytes());
265}
266
267struct Cursor<'a> {
268    buf: &'a [u8],
269    pos: usize,
270}
271
272impl<'a> Cursor<'a> {
273    fn new(buf: &'a [u8]) -> Self {
274        Self { buf, pos: 0 }
275    }
276
277    fn remaining(&self) -> usize {
278        self.buf.len() - self.pos
279    }
280
281    fn read_u8(&mut self) -> Result<u8, TopologyError> {
282        if self.remaining() < 1 {
283            return Err(TopologyError::Truncated);
284        }
285        let v = self.buf[self.pos];
286        self.pos += 1;
287        Ok(v)
288    }
289
290    fn read_u32(&mut self) -> Result<u32, TopologyError> {
291        if self.remaining() < 4 {
292            return Err(TopologyError::Truncated);
293        }
294        let bytes = &self.buf[self.pos..self.pos + 4];
295        self.pos += 4;
296        Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
297    }
298
299    fn read_u64(&mut self) -> Result<u64, TopologyError> {
300        if self.remaining() < 8 {
301            return Err(TopologyError::Truncated);
302        }
303        let bytes = &self.buf[self.pos..self.pos + 8];
304        self.pos += 8;
305        Ok(u64::from_le_bytes([
306            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
307        ]))
308    }
309
310    fn read_str(&mut self) -> Result<String, TopologyError> {
311        let len = self.read_u32()?;
312        if (len as usize) > self.remaining() {
313            return Err(TopologyError::StringTooLong {
314                declared: len,
315                remaining: self.remaining(),
316            });
317        }
318        let bytes = &self.buf[self.pos..self.pos + len as usize];
319        self.pos += len as usize;
320        let s = std::str::from_utf8(bytes)
321            .map_err(|_| TopologyError::InvalidUtf8)?
322            .to_string();
323        Ok(s)
324    }
325}
326
327// ---------------------------------------------------------------
328// HelloAck embedding.
329//
330// The HelloAck payload is a JSON object. To carry the binary
331// topology blob inside it without breaking the existing JSON-only
332// parser (which deserialises the whole payload with `serde_json`),
333// the canonical bytes are base64-encoded under a new `topology`
334// key. Old parsers ignore unknown keys cleanly; new parsers extract
335// the field and run `decode_topology` on the decoded bytes.
336// ---------------------------------------------------------------
337
338/// Base64-encode the canonical topology bytes for embedding inside
339/// a HelloAck JSON payload as a string field.
340///
341/// The caller (server-side HelloAck builder) is expected to insert
342/// the resulting string under the JSON key `"topology"`; the client
343/// extracts that key, base64-decodes it, and runs `decode_topology`.
344pub fn encode_topology_for_hello_ack(topology: &Topology) -> String {
345    base64_encode(&encode_topology(topology))
346}
347
348/// Decode the base64 string carried in HelloAck JSON `topology`
349/// field back into a `Topology`.
350///
351/// Mirrors `decode_topology`'s three-state contract:
352/// * `Ok(Some(_))` — recognised version tag, body parsed.
353/// * `Ok(None)` — base64 decode failed *or* the version tag is
354///   unknown. Both cases collapse to "fall back to URI-only
355///   routing"; the consumer does not branch on which one it was.
356/// * `Err(_)` — recognised version tag with a malformed body.
357pub fn decode_topology_from_hello_ack(field: &str) -> Result<Option<Topology>, TopologyError> {
358    let Some(bytes) = base64_decode(field) else {
359        // Malformed base64 is treated as "unknown encoding, drop
360        // cleanly" so the client falls back to URI-only routing —
361        // same posture as an unknown version tag (ADR 0008 §4).
362        return Ok(None);
363    };
364    decode_topology(&bytes)
365}
366
367fn base64_decode(input: &str) -> Option<Vec<u8>> {
368    let trimmed = input.trim_end_matches('=');
369    let mut out = Vec::with_capacity(trimmed.len() * 3 / 4);
370    let mut buf = 0u32;
371    let mut bits = 0u8;
372    for ch in trimmed.bytes() {
373        let v: u32 = match ch {
374            b'A'..=b'Z' => (ch - b'A') as u32,
375            b'a'..=b'z' => (ch - b'a' + 26) as u32,
376            b'0'..=b'9' => (ch - b'0' + 52) as u32,
377            b'+' => 62,
378            b'/' => 63,
379            _ => return None,
380        };
381        buf = (buf << 6) | v;
382        bits += 6;
383        if bits >= 8 {
384            bits -= 8;
385            out.push(((buf >> bits) & 0xFF) as u8);
386        }
387    }
388    Some(out)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn fixture() -> Topology {
396        Topology {
397            epoch: 0xDEAD_BEEF_CAFE_BABE,
398            primary: Endpoint {
399                addr: "primary.example.com:5050".into(),
400                region: "us-east-1".into(),
401            },
402            replicas: vec![
403                ReplicaInfo {
404                    addr: "replica-a.example.com:5050".into(),
405                    region: "us-east-1".into(),
406                    healthy: true,
407                    lag_ms: 12,
408                    last_applied_lsn: 4242,
409                    rebootstrapping: false,
410                },
411                ReplicaInfo {
412                    addr: "replica-b.example.com:5050".into(),
413                    region: "us-west-2".into(),
414                    healthy: false,
415                    lag_ms: 999,
416                    last_applied_lsn: 4100,
417                    rebootstrapping: true,
418                },
419            ],
420        }
421    }
422
423    #[test]
424    fn round_trip_v1() {
425        let t = fixture();
426        let bytes = encode_topology(&t);
427        let decoded = decode_topology(&bytes).expect("decode").expect("v1 known");
428        assert_eq!(decoded, t);
429    }
430
431    #[test]
432    fn empty_replicas_round_trip() {
433        let t = Topology {
434            epoch: 1,
435            primary: Endpoint {
436                addr: "p:5050".into(),
437                region: "r".into(),
438            },
439            replicas: vec![],
440        };
441        let bytes = encode_topology(&t);
442        let decoded = decode_topology(&bytes).expect("decode").expect("v1");
443        assert_eq!(decoded, t);
444    }
445
446    #[test]
447    fn unknown_version_tag_returns_none() {
448        // Forward-compat invariant from ADR 0008 §4: an unknown
449        // version tag must be ignored, not rejected. The consumer
450        // falls back to URI-only routing.
451        let mut bytes = encode_topology(&fixture());
452        bytes[0] = 0xFE; // bumped past MAX_KNOWN_TOPOLOGY_VERSION
453        let decoded = decode_topology(&bytes).expect("decode");
454        assert!(
455            decoded.is_none(),
456            "unknown version tag must drop cleanly, got {decoded:?}"
457        );
458    }
459
460    #[test]
461    fn truncated_header_errors() {
462        assert!(matches!(
463            decode_topology(&[0x01, 0x00]),
464            Err(TopologyError::Truncated)
465        ));
466    }
467
468    #[test]
469    fn body_length_mismatch_errors() {
470        // Declared body length larger than buffer.
471        let bytes = vec![0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00];
472        assert!(matches!(
473            decode_topology(&bytes),
474            Err(TopologyError::BodyLengthMismatch { .. })
475        ));
476    }
477
478    #[test]
479    fn version_tag_is_pinned_to_0x01() {
480        // Sentinel against an accidental schema bump. ADR 0008 §4
481        // reserves the bump for genuinely breaking changes; a PR
482        // adding an optional field must NOT touch this value.
483        assert_eq!(TOPOLOGY_WIRE_VERSION_V1, 0x01);
484    }
485
486    #[test]
487    fn rebootstrapping_flag_round_trips_per_replica() {
488        // The additive #837 flag survives a full encode/decode and is
489        // carried independently per replica.
490        let t = fixture();
491        let decoded = decode_topology(&encode_topology(&t))
492            .expect("decode")
493            .expect("v1 known");
494        assert!(!decoded.replicas[0].rebootstrapping);
495        assert!(decoded.replicas[1].rebootstrapping);
496    }
497
498    #[test]
499    fn legacy_blob_without_trailing_block_defaults_rebootstrapping_false() {
500        // Backward-compat (ADR 0008 §4): a pre-#837 producer emits no
501        // trailing flag block. Synthesise that blob by encoding, then
502        // truncating the trailing per-replica flag bytes off the body
503        // and fixing the declared length. The decoder must still parse
504        // and default every replica to `false`.
505        let t = fixture();
506        let full = encode_topology(&t);
507        let trailing = t.replicas.len();
508        let body_len = (full.len() - TOPOLOGY_HEADER_SIZE - trailing) as u32;
509        let mut legacy = Vec::new();
510        legacy.push(TOPOLOGY_WIRE_VERSION_V1);
511        legacy.extend_from_slice(&body_len.to_le_bytes());
512        legacy.extend_from_slice(&full[TOPOLOGY_HEADER_SIZE..full.len() - trailing]);
513        let decoded = decode_topology(&legacy).expect("decode").expect("v1 known");
514        assert_eq!(decoded.replicas.len(), 2);
515        assert!(decoded.replicas.iter().all(|r| !r.rebootstrapping));
516        // Every other field is preserved verbatim — only the new flag
517        // is absent.
518        assert_eq!(decoded.replicas[0].last_applied_lsn, 4242);
519        assert_eq!(decoded.replicas[1].lag_ms, 999);
520    }
521
522    #[test]
523    fn hello_ack_round_trip_via_base64() {
524        // The HelloAck embedding shape: encode → base64-string →
525        // decode. Same canonical bytes both transports carry, just
526        // base64-wrapped so the JSON envelope stays valid.
527        let t = fixture();
528        let field = encode_topology_for_hello_ack(&t);
529        let decoded = decode_topology_from_hello_ack(&field)
530            .expect("decode")
531            .expect("v1 known");
532        assert_eq!(decoded, t);
533    }
534
535    #[test]
536    fn hello_ack_inner_bytes_match_grpc_bytes() {
537        // The acceptance criterion (#166 §4): same bytes consumed
538        // by both transports. Round-trip via the HelloAck base64
539        // wrapper and assert the decoded inner payload is byte-for-
540        // byte equivalent to the canonical encoding.
541        let t = fixture();
542        let canonical = encode_topology(&t);
543        let field = encode_topology_for_hello_ack(&t);
544        let recovered = base64_decode(&field).expect("base64");
545        assert_eq!(recovered, canonical);
546    }
547
548    #[test]
549    fn hello_ack_unknown_version_tag_drops_cleanly() {
550        // A HelloAck whose topology field carries a future version
551        // tag must not panic — the client falls back to URI-only
552        // routing.
553        let mut bytes = encode_topology(&fixture());
554        bytes[0] = 0x99;
555        let field = base64_encode(&bytes);
556        let decoded = decode_topology_from_hello_ack(&field).expect("decode");
557        assert!(decoded.is_none());
558    }
559
560    #[test]
561    fn hello_ack_malformed_base64_drops_cleanly() {
562        // A garbled base64 field is treated like an unknown version
563        // tag: drop, fall back to URI-only routing, never panic.
564        let decoded = decode_topology_from_hello_ack("@not base64@").expect("decode");
565        assert!(decoded.is_none());
566    }
567
568    #[test]
569    fn old_hello_ack_without_topology_field_is_backwards_compat() {
570        // Backwards-compat (#166 acceptance criterion §6): an
571        // old-style HelloAck JSON payload — no `topology` key —
572        // still parses cleanly into a Topology slot of `None` on
573        // the consumer side. We model that here by extracting the
574        // JSON value the way the client will (look for the key,
575        // run our decoder if present), and checking the absence
576        // path resolves to "no topology, fall back to URI-only".
577        let json = br#"{"version":1,"auth":"bearer","features":3,"server":"reddb/0.2.9"}"#;
578        let v: serde_json_check::Value = serde_json_check::from_slice(json).expect("valid JSON");
579        let topo_field = v.find_string("topology");
580        let topology = match topo_field {
581            None => None,
582            Some(s) => decode_topology_from_hello_ack(&s).expect("decode"),
583        };
584        assert!(
585            topology.is_none(),
586            "an old HelloAck without `topology` must produce None"
587        );
588    }
589
590    /// Tiny JSON probe used only by the backwards-compat test.
591    /// `reddb-wire` has no JSON dep — keep this scoped to test
592    /// code so we do not pull serde into the production build.
593    mod serde_json_check {
594        pub enum Value {
595            Object(Vec<(String, Value)>),
596            String(String),
597            Other,
598        }
599
600        impl Value {
601            pub fn find_string(&self, key: &str) -> Option<String> {
602                match self {
603                    Value::Object(map) => map.iter().find_map(|(k, v)| {
604                        if k == key {
605                            if let Value::String(s) = v {
606                                Some(s.clone())
607                            } else {
608                                None
609                            }
610                        } else {
611                            None
612                        }
613                    }),
614                    _ => None,
615                }
616            }
617        }
618
619        pub fn from_slice(bytes: &[u8]) -> Result<Value, &'static str> {
620            let s = std::str::from_utf8(bytes).map_err(|_| "utf8")?;
621            let mut p = Parser { src: s, pos: 0 };
622            p.skip_ws();
623            let v = p.parse_value()?;
624            Ok(v)
625        }
626
627        struct Parser<'a> {
628            src: &'a str,
629            pos: usize,
630        }
631
632        impl<'a> Parser<'a> {
633            fn rest(&self) -> &'a str {
634                &self.src[self.pos..]
635            }
636            fn bump(&mut self, n: usize) {
637                self.pos += n;
638            }
639            fn skip_ws(&mut self) {
640                while let Some(c) = self.rest().chars().next() {
641                    if c.is_whitespace() {
642                        self.bump(c.len_utf8());
643                    } else {
644                        break;
645                    }
646                }
647            }
648            fn parse_value(&mut self) -> Result<Value, &'static str> {
649                self.skip_ws();
650                let head = self.rest().chars().next().ok_or("eof")?;
651                match head {
652                    '{' => self.parse_object(),
653                    '"' => self.parse_string().map(Value::String),
654                    _ => {
655                        // Skip primitives (numbers, true/false/null,
656                        // arrays). The probe only cares about object
657                        // keys at the top level, so coarse skipping
658                        // is enough for our fixture.
659                        self.skip_until_top_level_comma_or_close();
660                        Ok(Value::Other)
661                    }
662                }
663            }
664            fn skip_until_top_level_comma_or_close(&mut self) {
665                let mut depth = 0i32;
666                while let Some(c) = self.rest().chars().next() {
667                    match c {
668                        '"' => {
669                            let _ = self.parse_string();
670                            continue;
671                        }
672                        '{' | '[' => {
673                            depth += 1;
674                            self.bump(1);
675                        }
676                        '}' | ']' => {
677                            if depth == 0 {
678                                return;
679                            }
680                            depth -= 1;
681                            self.bump(1);
682                        }
683                        ',' if depth == 0 => return,
684                        _ => self.bump(c.len_utf8()),
685                    }
686                }
687            }
688            fn parse_object(&mut self) -> Result<Value, &'static str> {
689                self.bump(1); // '{'
690                let mut map = Vec::new();
691                loop {
692                    self.skip_ws();
693                    if self.rest().starts_with('}') {
694                        self.bump(1);
695                        return Ok(Value::Object(map));
696                    }
697                    let key = self.parse_string()?;
698                    self.skip_ws();
699                    if !self.rest().starts_with(':') {
700                        return Err("expected ':'");
701                    }
702                    self.bump(1);
703                    let val = self.parse_value()?;
704                    map.push((key, val));
705                    self.skip_ws();
706                    match self.rest().chars().next() {
707                        Some(',') => {
708                            self.bump(1);
709                            continue;
710                        }
711                        Some('}') => {
712                            self.bump(1);
713                            return Ok(Value::Object(map));
714                        }
715                        _ => return Err("expected ',' or '}'"),
716                    }
717                }
718            }
719            fn parse_string(&mut self) -> Result<String, &'static str> {
720                if !self.rest().starts_with('"') {
721                    return Err("expected '\"'");
722                }
723                self.bump(1);
724                let start = self.pos;
725                while let Some(c) = self.rest().chars().next() {
726                    if c == '"' {
727                        let s = self.src[start..self.pos].to_string();
728                        self.bump(1);
729                        return Ok(s);
730                    }
731                    if c == '\\' {
732                        self.bump(c.len_utf8());
733                    }
734                    self.bump(c.len_utf8());
735                }
736                Err("unterminated string")
737            }
738        }
739    }
740
741    #[test]
742    fn header_layout_first_byte_is_version_then_le_length() {
743        // Pinning the layout: byte 0 is the tag, bytes 1..5 are the
744        // little-endian body length. A consumer that sees an unknown
745        // tag still knows how many bytes to skip — that is the only
746        // forward-compat invariant the header has to carry.
747        let t = fixture();
748        let bytes = encode_topology(&t);
749        assert_eq!(bytes[0], TOPOLOGY_WIRE_VERSION_V1);
750        let declared = u32::from_le_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]);
751        assert_eq!(declared as usize, bytes.len() - TOPOLOGY_HEADER_SIZE);
752    }
753}