Skip to main content

zenkey_fleet/
decode.rs

1//! The schema-aware decode seam (issues #11/#15): wire key → type name →
2//! served schema → named-field JSON, with the honest fallbacks a generic
3//! tool owes its user.
4//!
5//! [`SchemaStore`] caches each producer's served `describe` reply (RFC 08
6//! §7) and fetches on first miss through a declared
7//! [`crate::query::RepeatingQuery`] (the RFC 05 §2.1 discipline, kept warm
8//! across the negative-TTL re-asks — #37). [`decode_sample`] is the whole
9//! pipeline in one call; encoding resolution is **sample > registry > sniff**
10//! and the sniff never goes away.
11
12use std::collections::HashMap;
13use std::sync::Mutex;
14use std::time::Duration;
15
16use anyhow::Result;
17use zenkey::schema::decode::{DecodeError, DecodedPayload, DecoderRegistry};
18use zenkey::schema::{SchemaSet, TypeSchema, WireEncoding};
19use zenoh::Session;
20
21use crate::registry::SliceSet;
22
23/// Per-producer schema sets, fetched lazily and cached for the process.
24pub struct SchemaStore {
25    base: String,
26    timeout: Duration,
27    /// producer → what we know about its `describe` (see [`Cached`]).
28    ///
29    /// A served set is behind an `Arc` because it is read **per sample**:
30    /// handing out a deep clone of every type's document to answer "what is
31    /// the schema for this one type" was the other half of issue #100's cost,
32    /// and the quieter half — a descriptor pool rebuild at least looks
33    /// expensive.
34    sets: Mutex<HashMap<String, Cached>>,
35    /// One declared querier per producer's describe key (#37), reused across
36    /// the negative-TTL re-asks. Bounded by fleet producer count; entries
37    /// live for the store's lifetime (no eviction — a fleet's producer set
38    /// is small and a stale querier is only idle routing state).
39    queriers: Mutex<HashMap<String, std::sync::Arc<crate::query::RepeatingQuery>>>,
40    decoders: DecoderRegistry,
41}
42
43/// How long "asked, and answered with nothing usable" stays authoritative
44/// before re-asking. A producer that genuinely serves no `describe` must not
45/// be re-asked per sample, and 60s is the bound for that.
46const NOT_SERVED_TTL: Duration = Duration::from_secs(60);
47
48/// The first backoff after a GET that drew **zero replies** (issue #101).
49///
50/// Zero replies is the RFC 05 §3.1 non-verdict this codebase refuses to treat
51/// as an answer anywhere else, and it is what an explorer started before its
52/// fleet sees. Doubling from here, capped at [`NOT_SERVED_TTL`], means a
53/// routing race resolves in well under a second while a producer that is
54/// simply absent still converges on the same 60s bound.
55const NO_REPLY_BACKOFF: Duration = Duration::from_millis(250);
56
57/// Why a producer has no cached set, which decides how soon we re-ask.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum MissReason {
60    /// The GET returned no replies at all. Nobody said anything — including
61    /// "no". Could be a producer that does not exist, or a connector whose
62    /// GET went out before the producer's queryable was routable.
63    NoReplies,
64    /// Somebody replied, and nothing in the replies parsed as a `SchemaSet`.
65    /// That *is* an answer about this producer, and it earns the full TTL.
66    AnsweredUnusable,
67}
68
69/// A producer we asked and got nothing usable from.
70#[derive(Debug, Clone, Copy)]
71struct Missing {
72    reason: MissReason,
73    asked: std::time::Instant,
74    /// Consecutive zero-reply asks, driving the backoff.
75    attempts: u32,
76}
77
78impl Missing {
79    /// How long this miss stays authoritative before the next ask.
80    fn backoff(&self) -> Duration {
81        match self.reason {
82            MissReason::AnsweredUnusable => NOT_SERVED_TTL,
83            MissReason::NoReplies => NO_REPLY_BACKOFF
84                .saturating_mul(1u32 << self.attempts.saturating_sub(1).min(16))
85                .min(NOT_SERVED_TTL),
86        }
87    }
88
89    fn may_reask(&self) -> bool {
90        self.asked.elapsed() >= self.backoff()
91    }
92}
93
94/// What the store knows about one producer's `describe`.
95enum Cached {
96    Served(std::sync::Arc<SchemaSet>),
97    Missing(Missing),
98}
99
100/// What one `describe` GET produced — the distinction issue #101 exists for.
101enum Fetched {
102    Served(SchemaSet),
103    NoReplies,
104    AnsweredUnusable,
105}
106
107impl SchemaStore {
108    pub fn new(base: impl Into<String>, timeout: Duration) -> Self {
109        SchemaStore {
110            base: base.into(),
111            timeout,
112            sets: Mutex::new(HashMap::new()),
113            queriers: Mutex::new(HashMap::new()),
114            decoders: DecoderRegistry::new(),
115        }
116    }
117
118    /// The decoder table (register custom kinds through this).
119    pub fn decoders_mut(&mut self) -> &mut DecoderRegistry {
120        &mut self.decoders
121    }
122
123    /// The schema for `type_name` as served by `producer`, fetching
124    /// `@rpc/<producer>/describe` on first miss. `None` = the producer does
125    /// not serve describe or does not describe this type — render
126    /// structurally (never an error; RFC 08 §7 is a SHOULD for
127    /// self-describing encodings).
128    pub async fn schema_for(
129        &self,
130        session: &Session,
131        producer: &str,
132        type_name: &str,
133    ) -> Option<TypeSchema> {
134        self.set_for(session, producer)
135            .await
136            .and_then(|set| set.get(type_name).cloned())
137    }
138
139    /// The producer's **whole** served set, on the same fetch-and-cache path
140    /// as [`schema_for`](Self::schema_for) (issue #51: `zenctl schema
141    /// <producer>` dumps the inventory, and asking type-by-type would be a
142    /// different question than the one `describe` answers).
143    ///
144    /// `None` = the producer does not serve `describe` — an honest
145    /// degradation, never an error.
146    pub async fn set_for(
147        &self,
148        session: &Session,
149        producer: &str,
150    ) -> Option<std::sync::Arc<SchemaSet>> {
151        // How many consecutive zero-reply asks precede this one — carried
152        // across so the backoff actually grows.
153        let attempts = {
154            let sets = self.sets.lock().expect("store lock");
155            match sets.get(producer) {
156                Some(Cached::Served(set)) => return Some(std::sync::Arc::clone(set)),
157                Some(Cached::Missing(m)) if !m.may_reask() => return None,
158                Some(Cached::Missing(m)) => m.attempts,
159                None => 0,
160            }
161        };
162        let entry = match self.fetch(session, producer).await {
163            Fetched::Served(set) => Cached::Served(std::sync::Arc::new(set)),
164            Fetched::NoReplies => Cached::Missing(Missing {
165                reason: MissReason::NoReplies,
166                asked: std::time::Instant::now(),
167                attempts: attempts.saturating_add(1),
168            }),
169            // An answer resets the streak: this is a verdict about the
170            // producer, not a routing race.
171            Fetched::AnsweredUnusable => Cached::Missing(Missing {
172                reason: MissReason::AnsweredUnusable,
173                asked: std::time::Instant::now(),
174                attempts: 0,
175            }),
176        };
177        let mut sets = self.sets.lock().expect("store lock");
178        let served = match &entry {
179            Cached::Served(set) => Some(std::sync::Arc::clone(set)),
180            Cached::Missing(_) => None,
181        };
182        sets.insert(producer.to_string(), entry);
183        served
184    }
185
186    /// Forget what we learned about one producer, so the next question goes
187    /// to the bus (issue #101).
188    ///
189    /// The queriers are kept: they are idle routing state, and re-declaring
190    /// them is exactly the cost #37 removed.
191    pub fn forget(&self, producer: &str) {
192        self.sets.lock().expect("store lock").remove(producer);
193    }
194
195    /// Forget every producer — the "re-ask schemas" action a frontend offers.
196    ///
197    /// Covers the case the backoff cannot: a *positive* entry never expires,
198    /// so a producer that changes its served set mid-session is otherwise
199    /// read with the schemas it had at first contact.
200    pub fn forget_all(&self) {
201        self.sets.lock().expect("store lock").clear();
202    }
203
204    /// Producers currently answered-for, and whether each served a set —
205    /// what a frontend shows next to its re-ask button.
206    pub fn known(&self) -> Vec<(String, bool)> {
207        let sets = self.sets.lock().expect("store lock");
208        let mut out: Vec<(String, bool)> = sets
209            .iter()
210            .map(|(p, c)| (p.clone(), matches!(c, Cached::Served(_))))
211            .collect();
212        out.sort();
213        out
214    }
215
216    async fn fetch(&self, session: &Session, producer: &str) -> Fetched {
217        let cached = {
218            let queriers = self.queriers.lock().expect("querier lock");
219            queriers.get(producer).cloned()
220        };
221        let querier = match cached {
222            Some(q) => q,
223            None => {
224                let key = zenkey::grammar::with_base(
225                    &self.base,
226                    zenkey::selector::fleet_rpc(producer, &["describe"]),
227                );
228                let declared =
229                    match crate::query::declare_repeating(session, &self.base, &key, self.timeout)
230                        .await
231                    {
232                        Ok(q) => std::sync::Arc::new(q),
233                        // We could not even ask. Nobody said anything about
234                        // this producer, so this is the non-verdict case, not
235                        // a 60s verdict.
236                        Err(_) => return Fetched::NoReplies,
237                    };
238                // A concurrent miss may have declared first; keep whichever
239                // landed (the loser undeclares itself on drop — idle state,
240                // not a leak).
241                let mut queriers = self.queriers.lock().expect("querier lock");
242                queriers
243                    .entry(producer.to_string())
244                    .or_insert(declared)
245                    .clone()
246            }
247        };
248        let Ok(answers) = querier.fetch().await else {
249            return Fetched::NoReplies;
250        };
251        if answers.is_empty() {
252            return Fetched::NoReplies;
253        }
254        // Any well-formed reply will do; hashes make same-name drift a
255        // doctor finding, not a decode concern.
256        for a in answers {
257            if let crate::query::Answer::Value(bytes) = a.answer {
258                let cow = bytes.to_bytes();
259                if let Ok(text) = std::str::from_utf8(&cow)
260                    && let Ok(set) = SchemaSet::parse(text)
261                {
262                    return Fetched::Served(set);
263                }
264            }
265        }
266        // Somebody answered — with an error, or with something that is not a
267        // SchemaSet. That is a statement about this producer.
268        Fetched::AnsweredUnusable
269    }
270
271    /// Decode `bytes` under a schema, if one resolves.
272    pub fn decode(
273        &self,
274        schema: &TypeSchema,
275        encoding: &WireEncoding,
276        bytes: &[u8],
277    ) -> Result<DecodedPayload, DecodeError> {
278        self.decoders.decode(schema, encoding, bytes)
279    }
280
281    /// The other direction (issue #97): a JSON value framed for the wire.
282    /// The store owns the decoder table, so the write path resolves its codec
283    /// exactly where the read path does — one registration, both directions.
284    pub fn encode(
285        &self,
286        schema: &TypeSchema,
287        value: &serde_json::Value,
288        target: &WireEncoding,
289    ) -> Result<Vec<u8>, DecodeError> {
290        self.decoders.encode(schema, value, target)
291    }
292}
293
294/// The registry type names one producer's slice references — RFC 08 §7's
295/// totality set for that producer.
296fn referenced_types(slice: &zenkey::slice::RegistrySlice) -> Vec<String> {
297    let mut names: Vec<&str> = slice
298        .subjects
299        .iter()
300        .map(|s| s.type_name.as_str())
301        .filter(|t| !t.is_empty())
302        .collect();
303    for p in &slice.procedures {
304        names.extend(p.request.as_deref());
305        names.extend(p.reply.as_deref());
306    }
307    for b in &slice.blob {
308        names.extend(b.reference.as_deref());
309    }
310    names.sort_unstable();
311    names.dedup();
312    names.into_iter().map(str::to_string).collect()
313}
314
315/// One type's schema, as a report row.
316fn row(
317    producer: &str,
318    type_name: &str,
319    schema: &TypeSchema,
320    full: bool,
321) -> crate::report::SchemaRow {
322    crate::report::SchemaRow {
323        producer: producer.to_string(),
324        type_name: type_name.to_string(),
325        kind: schema.kind().as_str().to_string(),
326        hash: schema.hash().to_string(),
327        document: full.then(|| schema_document(schema)),
328    }
329}
330
331/// A schema's document in a renderable form. `json-schema` has one natively;
332/// every other kind is summarised structurally rather than faked — a codec
333/// this build cannot read still gets to say what it is.
334fn schema_document(schema: &TypeSchema) -> serde_json::Value {
335    if let Some(doc) = schema.json_document() {
336        return doc.clone();
337    }
338    let mut obj = serde_json::Map::new();
339    obj.insert(
340        "kind".into(),
341        serde_json::Value::String(schema.kind().as_str().to_string()),
342    );
343    if let Some(m) = schema.protobuf_message() {
344        obj.insert("message".into(), serde_json::Value::String(m.to_string()));
345    }
346    if let Some(bytes) = schema.protobuf_descriptor_set() {
347        obj.insert(
348            "descriptor_set_bytes".into(),
349            serde_json::Value::from(bytes.len()),
350        );
351    }
352    if let Some(fields) = schema.cdr_fields() {
353        obj.insert("fields".into(), fields.clone());
354    }
355    if let Some(types) = schema.cdr_types() {
356        obj.insert("types".into(), serde_json::Value::Object(types.clone()));
357    }
358    serde_json::Value::Object(obj)
359}
360
361/// Dump one producer's served `describe` reply (issue #51), joined against
362/// its registry slice so the RFC 08 §7 totality gap is visible where the user
363/// is already looking.
364///
365/// A producer serving no `describe` yields `served: false` — the honest
366/// degradation, never an error: §7 is a SHOULD, and silence about a type is
367/// not a claim about it.
368pub async fn schema_dump(
369    store: &SchemaStore,
370    session: &Session,
371    slices: &SliceSet,
372    producer: &str,
373    type_filter: Option<&str>,
374    full: bool,
375) -> crate::report::SchemaDump {
376    let set = store.set_for(session, producer).await;
377    let Some(set) = set else {
378        return crate::report::SchemaDump {
379            producer: producer.to_string(),
380            served: false,
381            app: None,
382            types: Vec::new(),
383            missing: Vec::new(),
384        };
385    };
386    let types: Vec<crate::report::SchemaRow> = set
387        .iter()
388        .filter(|(name, _)| type_filter.is_none_or(|f| f == *name))
389        .map(|(name, schema)| row(producer, name, schema, full || type_filter.is_some()))
390        .collect();
391    let missing = slices
392        .get(producer)
393        .map(|slice| {
394            referenced_types(slice)
395                .into_iter()
396                .filter(|n| set.get(n).is_none())
397                .collect()
398        })
399        .unwrap_or_default();
400    crate::report::SchemaDump {
401        producer: producer.to_string(),
402        served: true,
403        app: Some(set.app().to_string()),
404        types,
405        missing,
406    }
407}
408
409/// Every producer's schema for one type name (issue #51's `interface show
410/// --schema`). Asking all of them is the point: same name, different hash is
411/// RFC 08 §7's drift finding, and the type's own page is where it is worth
412/// seeing.
413pub async fn schemas_for_type(
414    store: &SchemaStore,
415    session: &Session,
416    producers: &[String],
417    type_name: &str,
418    full: bool,
419) -> Vec<crate::report::SchemaRow> {
420    let mut out = Vec::new();
421    for producer in producers {
422        if let Some(schema) = store.schema_for(session, producer, type_name).await {
423            out.push(row(producer, type_name, &schema, full));
424        }
425    }
426    out
427}
428
429/// Two producers serving one type name with different hashes — "a `doctor`
430/// finding" by RFC 08 §7's own words (issue #41).
431#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
432pub struct SchemaDrift {
433    pub type_name: String,
434    /// Every (producer, hash) pair observed for the name.
435    pub servers: Vec<(String, String)>,
436}
437
438/// A type the producer's slice references that its served describe set does
439/// not cover — a violation of RFC 08 §7's totality clause.
440#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
441pub struct TotalityGap {
442    pub producer: String,
443    pub missing: Vec<String>,
444}
445
446/// Compute drift across a described fleet. Pure — feed it whatever describe
447/// replies were gathered (the store's cache, or a fresh sweep).
448pub fn schema_drift(described: &[(String, SchemaSet)]) -> Vec<SchemaDrift> {
449    use std::collections::BTreeMap;
450    let mut by_name: BTreeMap<&str, Vec<(String, String)>> = BTreeMap::new();
451    for (producer, set) in described {
452        for (name, schema) in set.iter() {
453            by_name
454                .entry(name)
455                .or_default()
456                .push((producer.clone(), schema.hash().to_string()));
457        }
458    }
459    by_name
460        .into_iter()
461        .filter(|(_, servers)| servers.iter().any(|(_, h)| h != &servers[0].1))
462        .map(|(name, servers)| SchemaDrift {
463            type_name: name.to_string(),
464            servers,
465        })
466        .collect()
467}
468
469/// Totality per producer: every type name the slice references (subjects,
470/// procedure request/reply, blob references) must appear in the served set
471/// (RFC 08 §7). A producer that served no describe at all is NOT a gap here —
472/// that is "describe absent", a different finding with a different fix.
473pub fn totality_gaps(described: &[(String, SchemaSet)], slices: &SliceSet) -> Vec<TotalityGap> {
474    let mut gaps = Vec::new();
475    for (producer, set) in described {
476        let Some(slice) = slices.get(producer) else {
477            continue;
478        };
479        let mut names: Vec<&str> = Vec::new();
480        // An untyped subject (empty `type`) references nothing — without this
481        // filter it would demand a schema for "" and report a phantom gap.
482        names.extend(
483            slice
484                .subjects
485                .iter()
486                .map(|s| s.type_name.as_str())
487                .filter(|t| !t.is_empty()),
488        );
489        for p in &slice.procedures {
490            names.extend(p.request.as_deref());
491            names.extend(p.reply.as_deref());
492        }
493        for b in &slice.blob {
494            names.extend(b.reference.as_deref());
495        }
496        names.sort();
497        names.dedup();
498        let missing: Vec<String> = names
499            .into_iter()
500            .filter(|n| set.get(n).is_none())
501            .map(str::to_string)
502            .collect();
503        if !missing.is_empty() {
504            gaps.push(TotalityGap {
505                producer: producer.clone(),
506                missing,
507            });
508        }
509    }
510    gaps
511}
512
513/// How a rendered payload was produced — a tool surfaces this honestly
514/// instead of letting decoded and sniffed output look alike.
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub enum Rendering {
517    /// Schema-decoded into named fields.
518    Typed(DecodedPayload),
519    /// No schema (or an undecodable kind): structural sniff — JSON if it
520    /// parses, CBOR diagnostic, UTF-8 text, else a byte count.
521    Structural(String),
522}
523
524/// Resolve the wire encoding: sample `Encoding` > registry `encoding` > sniff
525/// (RFC 08 §7).
526pub fn resolve_encoding(
527    sample_encoding: Option<&str>,
528    registry_encoding: Option<&str>,
529    bytes: &[u8],
530) -> WireEncoding {
531    // Zenoh's default when a publisher sets nothing is the opaque
532    // `zenoh/bytes` — that is "unsaid", not "bytes on purpose".
533    if let Some(e) = sample_encoding
534        && e != "zenoh/bytes"
535    {
536        return WireEncoding::from_encoding_str(e);
537    }
538    if let Some(e) = registry_encoding {
539        return WireEncoding::from_encoding_str(e);
540    }
541    // The sniff: JSON text starts with a JSON-ish byte; otherwise call it
542    // CBOR (the reference profile default) and let the decoder's error path
543    // fall through to structural rendering.
544    match bytes.first() {
545        Some(b'{' | b'[' | b'"') => WireEncoding::Json,
546        _ => WireEncoding::Cbor,
547    }
548}
549
550/// The structural sniff as a **value** rather than as text — the same ladder
551/// [`structural`] renders, stopped one step earlier.
552///
553/// `Some` means the bytes carry a self-describing document (JSON, or CBOR that
554/// accounts for every byte and is not the text-vs-scalar ambiguity below).
555/// `None` means they do not: plain text, or opaque bytes. That distinction is
556/// what lets a caller diff two payloads field-by-field when it can, and say so
557/// honestly — a byte comparison — when it cannot.
558///
559/// Deliberately sync and schema-free: this runs on render paths, where the
560/// async [`decode_sample`] (which may GET a `describe` on a miss) must never
561/// sit.
562pub fn structural_value(bytes: &[u8]) -> Option<serde_json::Value> {
563    let looks_json = bytes.first().is_some_and(|b| {
564        matches!(
565            b,
566            b'{' | b'[' | b'"' | b'-' | b'0'..=b'9' | b't' | b'f' | b'n'
567        )
568    });
569    if looks_json && let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
570        return Some(v);
571    }
572    let is_text = std::str::from_utf8(bytes).is_ok_and(|t| !t.is_empty());
573    if let Some(v) = cbor_whole(bytes)
574        // A bare CBOR scalar over bytes that are *also* valid text is the
575        // ambiguous case, and plain text is the likelier reading on a bus that
576        // carries anything. Structured CBOR (a map, an array) is unambiguous
577        // and still wins.
578        && !(is_text && is_scalar(&v))
579        // A CBOR map keyed by anything but strings has no JSON form; that is a
580        // failure of the *rendering*, not of the payload, so it degrades to
581        // text like any other unreadable shape rather than being invented.
582        && let Ok(value) = serde_json::to_value(&v)
583    {
584        return Some(value);
585    }
586    None
587}
588
589/// Structural fallback rendering — what the wire honestly says when no
590/// schema resolves.
591pub fn structural(bytes: &[u8]) -> String {
592    if let Some(v) = structural_value(bytes) {
593        return serde_json::to_string(&v).unwrap_or_default();
594    }
595    match std::str::from_utf8(bytes).ok().filter(|t| !t.is_empty()) {
596        Some(text) => text.to_string(),
597        None => format!("<{} bytes>", bytes.len()),
598    }
599}
600
601/// Decode CBOR only if it accounts for **every** byte.
602///
603/// `ciborium::from_reader` decodes one value from the front and ignores the
604/// rest, which makes it a false-positive machine on plain text: `j` is `0x6A`,
605/// "text string of length 10", so `just a plain string` decodes as the CBOR
606/// text `"ust a plai"` with eight bytes left over — and an explorer that shows
607/// that has silently corrupted the payload it was asked to display. Any
608/// lowercase-initial ASCII text is a candidate. Requiring total consumption is
609/// what makes the sniff honest (RFC 08 §7 — sniffing is the last resort, so it
610/// must at least be self-consistent).
611fn cbor_whole(bytes: &[u8]) -> Option<ciborium::Value> {
612    let mut cursor = std::io::Cursor::new(bytes);
613    let value = ciborium::from_reader::<ciborium::Value, _>(&mut cursor).ok()?;
614    (cursor.position() as usize == bytes.len()).then_some(value)
615}
616
617/// A single scalar, as opposed to a map or array.
618fn is_scalar(v: &ciborium::Value) -> bool {
619    !matches!(v, ciborium::Value::Map(_) | ciborium::Value::Array(_))
620}
621
622/// The whole decode pipeline for one sample: refine the key against the
623/// slices, resolve the schema through the store, decode — or fall back
624/// structurally, tagged with whatever we did learn.
625pub async fn decode_sample(
626    store: &SchemaStore,
627    session: &Session,
628    slices: &SliceSet,
629    base: &str,
630    wire_key: &str,
631    sample_encoding: Option<&str>,
632    bytes: &[u8],
633) -> (Option<String>, Rendering) {
634    use zenkey::grammar::ClassOrPlane;
635    let refined = zenkey::grammar::parse_full(base, wire_key).and_then(|parsed| {
636        let producer = match (&parsed.producer, &parsed.origin) {
637            (Some(p), _) => p.name().to_string(),
638            (None, zenkey::grammar::Origin::Service(s)) => {
639                slices.by_service_origin(s)?.name.clone()
640            }
641            _ => return None,
642        };
643        let ClassOrPlane::Class(class) = parsed.class else {
644            return None;
645        };
646        let (subject, _) = slices.refine(&producer, class.chunk(), &parsed.subject)?;
647        Some((
648            producer,
649            subject.type_name.clone(),
650            subject.encoding.clone(),
651        ))
652    });
653    let Some((producer, type_name, registry_encoding)) = refined else {
654        return (None, Rendering::Structural(structural(bytes)));
655    };
656    let encoding = resolve_encoding(sample_encoding, registry_encoding.as_deref(), bytes);
657    match store.schema_for(session, &producer, &type_name).await {
658        Some(schema) => match store.decode(&schema, &encoding, bytes) {
659            Ok(decoded) => (Some(type_name), Rendering::Typed(decoded)),
660            // Wrong schema/encoding is a finding for the *user*, not a crash:
661            // fall back to structure, keep the type tag.
662            Err(_) => (Some(type_name), Rendering::Structural(structural(bytes))),
663        },
664        None => (Some(type_name), Rendering::Structural(structural(bytes))),
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn encoding_resolution_order() {
674        // Sample wins…
675        assert_eq!(
676            resolve_encoding(Some("application/json"), Some("application/cbor"), b"x"),
677            WireEncoding::Json
678        );
679        // …but the opaque default is "unsaid", so the registry speaks…
680        assert_eq!(
681            resolve_encoding(Some("zenoh/bytes"), Some("application/cbor"), b"{"),
682            WireEncoding::Cbor
683        );
684        // …and with neither, the sniff.
685        assert_eq!(
686            resolve_encoding(None, None, b"{\"a\":1}"),
687            WireEncoding::Json
688        );
689        assert_eq!(resolve_encoding(None, None, &[0xa1]), WireEncoding::Cbor);
690    }
691
692    #[test]
693    fn structural_rendering_is_honest() {
694        assert_eq!(structural(b"{\"a\":1}"), "{\"a\":1}");
695        // CBOR map {1: 2} renders as structure.
696        let mut cbor = Vec::new();
697        ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
698        assert!(structural(&cbor).contains("\"x\""));
699        assert_eq!(structural(&[0xff, 0xfe, 0x00]), "<3 bytes>");
700    }
701
702    /// The value form answers the question a diff actually asks: is there a
703    /// document here to compare field by field, or only bytes?
704    #[test]
705    fn structural_value_yields_documents_and_nothing_else() {
706        assert_eq!(
707            structural_value(br#"{"value":42.0}"#),
708            Some(serde_json::json!({"value": 42.0}))
709        );
710        let mut cbor = Vec::new();
711        ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
712        assert_eq!(structural_value(&cbor), Some(serde_json::json!({"x": 1})));
713        // Plain text and opaque bytes are not documents — the caller falls
714        // back to a byte comparison rather than being handed a fake one.
715        assert_eq!(structural_value(b"just a plain string"), None);
716        assert_eq!(structural_value(&[0xff, 0xfe, 0x00]), None);
717        assert_eq!(structural_value(b""), None);
718    }
719
720    /// The two must not drift: `structural` is the rendering of
721    /// `structural_value` wherever one exists.
722    #[test]
723    fn the_rendering_agrees_with_the_value() {
724        for payload in [
725            &br#"{"a":1}"#[..],
726            &b"[1,2,3]"[..],
727            &b"just a plain string"[..],
728            &[0xff, 0xfe, 0x00][..],
729        ] {
730            if let Some(v) = structural_value(payload) {
731                assert_eq!(structural(payload), serde_json::to_string(&v).unwrap());
732            }
733        }
734    }
735
736    /// Regression: plain text must not be eaten by the CBOR sniff.
737    ///
738    /// `ciborium` decodes one value from the front and ignores trailing bytes,
739    /// so `just a plain string` used to render as `"ust a plai"` — `j` is
740    /// `0x6A`, "text string of length 10". Every lowercase-initial ASCII
741    /// payload was a candidate, which on an arbitrary bus is most of them.
742    #[test]
743    fn plain_text_is_not_mistaken_for_cbor() {
744        assert_eq!(structural(b"just a plain string"), "just a plain string");
745        assert_eq!(
746            structural(b"a v2 key: not this convention"),
747            "a v2 key: not this convention"
748        );
749        // The whole lowercase range is the danger zone (0x60..=0x7b).
750        for first in b'a'..=b'z' {
751            let mut payload = vec![first];
752            payload.extend_from_slice(b" some trailing words here");
753            let text = String::from_utf8(payload.clone()).unwrap();
754            assert_eq!(structural(&payload), text, "mangled {text:?}");
755        }
756    }
757
758    /// The ambiguous case: bytes that are *both* a complete CBOR text string
759    /// and valid UTF-8. Plain text is the likelier reading on a bus that
760    /// carries anything, and it is the lossless one.
761    #[test]
762    fn an_exact_cbor_text_string_still_reads_as_text() {
763        // 0x6A = text(10), followed by exactly 10 bytes: fully consumed CBOR.
764        let payload = b"just a plai";
765        assert!(cbor_whole(payload).is_some(), "setup: this is valid CBOR");
766        assert_eq!(structural(payload), "just a plai");
767    }
768
769    /// …but structured CBOR is unambiguous and must still win, even when the
770    /// bytes happen to be valid UTF-8.
771    #[test]
772    fn structured_cbor_still_wins_over_text() {
773        let mut cbor = Vec::new();
774        ciborium::into_writer(&serde_json::json!({"ok": true}), &mut cbor).unwrap();
775        let rendered = structural(&cbor);
776        assert!(rendered.contains("\"ok\""), "{rendered}");
777        assert!(rendered.starts_with('{'), "{rendered}");
778    }
779
780    /// Trailing bytes mean the buffer is not one CBOR value, whatever the
781    /// front of it looks like.
782    #[test]
783    fn cbor_must_account_for_every_byte() {
784        let mut cbor = Vec::new();
785        ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
786        assert!(cbor_whole(&cbor).is_some());
787        cbor.push(0x00);
788        assert!(cbor_whole(&cbor).is_none(), "trailing byte must reject");
789    }
790
791    fn set_with(name: &str, schema: serde_json::Value) -> SchemaSet {
792        SchemaSet::builder("app")
793            .entry(name, zenkey::schema::TypeSchema::json_schema(schema))
794            .build()
795    }
796
797    /// RFC 08 §7: same name, different hash, across producers — one finding
798    /// listing every server; agreement is silent.
799    #[test]
800    fn drift_findings_name_every_server() {
801        let a = SchemaSet::builder("app")
802            .entry(
803                "T",
804                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
805            )
806            .build();
807        let b = SchemaSet::builder("app")
808            .entry(
809                "T",
810                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"string"})),
811            )
812            .build();
813        let c = SchemaSet::builder("app")
814            .entry(
815                "T",
816                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
817            )
818            .build();
819        let described = vec![
820            ("p1".to_string(), a),
821            ("p2".to_string(), b),
822            ("p3".to_string(), c),
823        ];
824        let drift = schema_drift(&described);
825        assert_eq!(drift.len(), 1);
826        assert_eq!(drift[0].type_name, "T");
827        assert_eq!(drift[0].servers.len(), 3, "every server is named");
828        // p1 and p3 agree; p2 is the odd one out — the caller can see which.
829        assert_eq!(drift[0].servers[0].1, drift[0].servers[2].1);
830        assert_ne!(drift[0].servers[0].1, drift[0].servers[1].1);
831
832        // All agreeing: no finding.
833        let described = vec![
834            (
835                "p1".to_string(),
836                set_with("T", serde_json::json!({"type":"object"})),
837            ),
838            (
839                "p3".to_string(),
840                set_with("T", serde_json::json!({"type":"object"})),
841            ),
842        ];
843        assert!(schema_drift(&described).is_empty());
844    }
845
846    /// Totality: a slice-referenced type absent from the served describe is a
847    /// gap; a producer that served no describe is not judged here.
848    #[test]
849    fn totality_gaps_check_only_served_producers() {
850        use zenkey::slice::{RegistrySlice, SubjectDecl};
851        let slice = RegistrySlice {
852            version: "1".into(),
853            app: "a".into(),
854            convention: 1,
855            name: "sysinfo".into(),
856            service_origin: None,
857            description: None,
858            subjects: vec![SubjectDecl {
859                path: "cpu".into(),
860                class: "telemetry".into(),
861                type_name: "TelemetryPoint".into(),
862                common: None,
863                since: None,
864                description: None,
865                qos: None,
866                ttl_s: None,
867                unit: None,
868                rate: None,
869                cardinality: None,
870                encoding: None,
871            }],
872            procedures: vec![],
873            blob: vec![],
874            media: vec![],
875            deprecated: vec![],
876        };
877        let slices = crate::registry::SliceSet::from_slices(vec![slice]);
878
879        // Served describe missing the referenced type: one gap.
880        let incomplete = SchemaSet::builder("a")
881            .entry(
882                "Other",
883                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
884            )
885            .build();
886        let gaps = totality_gaps(&[("sysinfo".to_string(), incomplete)], &slices);
887        assert_eq!(gaps.len(), 1);
888        assert_eq!(gaps[0].missing, ["TelemetryPoint"]);
889
890        // No describe served at all: not judged by totality.
891        assert!(totality_gaps(&[], &slices).is_empty());
892    }
893
894    /// An untyped subject (empty `type`) references nothing — it must not
895    /// demand a schema for `""` (regression: phantom gap found while
896    /// consolidating doctor's totality check onto this function, #55).
897    #[test]
898    fn an_untyped_subject_is_not_a_totality_gap() {
899        use zenkey::slice::{RegistrySlice, SubjectDecl};
900        let slice = RegistrySlice {
901            version: "1".into(),
902            app: "a".into(),
903            convention: 1,
904            name: "sysinfo".into(),
905            service_origin: None,
906            description: None,
907            subjects: vec![SubjectDecl {
908                path: "raw".into(),
909                class: "telemetry".into(),
910                type_name: String::new(),
911                common: None,
912                since: None,
913                description: None,
914                qos: None,
915                ttl_s: None,
916                unit: None,
917                rate: None,
918                cardinality: None,
919                encoding: None,
920            }],
921            procedures: vec![],
922            blob: vec![],
923            media: vec![],
924            deprecated: vec![],
925        };
926        let slices = crate::registry::SliceSet::from_slices(vec![slice]);
927        let served = SchemaSet::builder("a").build();
928        assert!(
929            totality_gaps(&[("sysinfo".to_string(), served)], &slices).is_empty(),
930            "empty type names must be filtered, not reported as gaps"
931        );
932    }
933
934    /// Issue #101: the two ways of learning nothing are different facts and
935    /// must not share a bound. Zero replies is the RFC 05 §3.1 non-verdict —
936    /// it backs off in milliseconds and grows; an answer that served nothing
937    /// usable keeps the full 60s.
938    #[test]
939    fn a_zero_reply_ask_backs_off_fast_and_an_answered_one_does_not() {
940        let now = std::time::Instant::now();
941        let no_reply = |attempts| Missing {
942            reason: MissReason::NoReplies,
943            asked: now,
944            attempts,
945        };
946        assert_eq!(no_reply(1).backoff(), NO_REPLY_BACKOFF);
947        assert_eq!(no_reply(2).backoff(), NO_REPLY_BACKOFF * 2);
948        assert_eq!(no_reply(3).backoff(), NO_REPLY_BACKOFF * 4);
949        // …and it converges on the same bound a genuinely absent producer
950        // deserves, rather than re-asking forever.
951        assert_eq!(no_reply(30).backoff(), NOT_SERVED_TTL);
952
953        let answered = Missing {
954            reason: MissReason::AnsweredUnusable,
955            asked: now,
956            attempts: 0,
957        };
958        assert_eq!(
959            answered.backoff(),
960            NOT_SERVED_TTL,
961            "a producer that answered and served nothing is asked once per TTL"
962        );
963    }
964
965    /// The first zero-reply backoff must be short enough that an explorer
966    /// started before its fleet is not blind for a human-noticeable time.
967    #[test]
968    fn the_first_reask_is_sub_second() {
969        let m = Missing {
970            reason: MissReason::NoReplies,
971            asked: std::time::Instant::now(),
972            attempts: 1,
973        };
974        assert!(m.backoff() < Duration::from_secs(1));
975        assert!(!m.may_reask(), "and not before it elapses");
976    }
977}