Skip to main content

verit_core/
registry.rs

1//! Schema registry + distribution bundle for multi-service deployments.
2//!
3//! In a polyglot backend, a producer may send **hash-only** messages (just the
4//! 128-bit schema id on the wire, no inline schema — the compact, fast form). A
5//! consumer that receives one needs the *writer's* schema to resolve it against
6//! its own reader schema. The registry is where writer schemas live, keyed by
7//! their id.
8//!
9//! The design leans on the one fact that makes a Veritate registry simpler than
10//! a conventional one: **the id is the content hash of the canonical schema**.
11//! So the store is *content-addressed* — registration is idempotent, ids never
12//! collide with different content, and there is no "which version is v3?"
13//! question. A [`Schema`] carries its own authoritative id, so nothing can be
14//! registered under a claimed-but-wrong id; [`from_bundle`](SchemaRegistry::from_bundle)
15//! recomputes every id on import by decoding the canonical bytes.
16//!
17//! [`SchemaRegistry::to_bundle`] / [`from_bundle`](SchemaRegistry::from_bundle)
18//! are the **distribution protocol**: a portable `VRSB` blob carrying a set of
19//! schemas that one service publishes and others load — a schema-set snapshot
20//! for a deployment.
21
22use std::collections::HashMap;
23
24use crate::error::{Error, Result};
25use crate::resolve::Resolver;
26use crate::schema::Schema;
27
28/// Distribution-bundle magic: "VRSB" (Veritate Schema Bundle), version 1.
29pub const BUNDLE_MAGIC: &[u8; 4] = b"VRSB";
30/// Bundle format version this build reads and writes.
31pub const BUNDLE_VERSION: u8 = 1;
32const BUNDLE_HEADER_LEN: usize = 12;
33
34/// A content-addressed store of schemas, keyed by their 128-bit id.
35#[derive(Clone, Debug, Default)]
36pub struct SchemaRegistry {
37    by_id: HashMap<u128, Schema>,
38}
39
40impl SchemaRegistry {
41    pub fn new() -> SchemaRegistry {
42        SchemaRegistry {
43            by_id: HashMap::new(),
44        }
45    }
46
47    /// Register a schema, returning its id. Idempotent: registering the same
48    /// content twice is a no-op (same id, same bytes — content-addressed).
49    pub fn register(&mut self, schema: Schema) -> u128 {
50        let id = schema.id();
51        self.by_id.entry(id).or_insert(schema);
52        id
53    }
54
55    /// Register from canonical bytes (e.g. a message's inline schema, or a
56    /// bundle entry): decodes and validates them first, so a malformed or
57    /// non-canonical blob is rejected rather than stored.
58    pub fn register_canonical(&mut self, bytes: &[u8]) -> Result<u128> {
59        Ok(self.register(Schema::from_canonical(bytes)?))
60    }
61
62    /// The schema with this id, if registered.
63    pub fn get(&self, id: u128) -> Option<&Schema> {
64        self.by_id.get(&id)
65    }
66
67    pub fn contains(&self, id: u128) -> bool {
68        self.by_id.contains_key(&id)
69    }
70
71    pub fn len(&self) -> usize {
72        self.by_id.len()
73    }
74
75    pub fn is_empty(&self) -> bool {
76        self.by_id.is_empty()
77    }
78
79    /// The ids of every registered schema.
80    pub fn ids(&self) -> impl Iterator<Item = u128> + '_ {
81        self.by_id.keys().copied()
82    }
83
84    /// Build a [`Resolver`] to read a message written with `writer_id` into
85    /// `reader` — the multi-service payoff. Look up the writer schema here (it
86    /// must be registered), then resolve it against the consumer's own reader
87    /// schema, so a hash-only message from a peer becomes fully readable with
88    /// schema evolution intact.
89    ///
90    /// `writer_id` is typically `msg.schema_id()`. Errors with
91    /// [`Error::SchemaIdMismatch`] if the writer schema is not registered.
92    pub fn resolver_for(&self, writer_id: u128, reader: &Schema) -> Result<Resolver> {
93        let writer = self.get(writer_id).ok_or(Error::SchemaIdMismatch {
94            message: writer_id,
95            expected: reader.id(),
96        })?;
97        Resolver::new(writer, reader)
98    }
99
100    /// Serialize every registered schema into a portable distribution bundle.
101    /// Entries are ordered by id, so the bytes are deterministic (diff-friendly)
102    /// and independent of insertion order.
103    pub fn to_bundle(&self) -> Vec<u8> {
104        let mut ids: Vec<u128> = self.by_id.keys().copied().collect();
105        ids.sort_unstable();
106
107        let mut buf = Vec::new();
108        buf.extend_from_slice(BUNDLE_MAGIC);
109        buf.push(BUNDLE_VERSION);
110        buf.extend_from_slice(&[0u8; 3]); // reserved
111        buf.extend_from_slice(&(ids.len() as u32).to_le_bytes());
112        for id in ids {
113            let canonical = self.by_id[&id].canonical_bytes();
114            buf.extend_from_slice(&(canonical.len() as u32).to_le_bytes());
115            buf.extend_from_slice(canonical);
116        }
117        buf
118    }
119
120    /// Load a registry from a distribution bundle. Every schema is decoded and
121    /// validated (and thereby its id recomputed from its bytes), so a tampered
122    /// bundle is rejected rather than trusted.
123    pub fn from_bundle(bytes: &[u8]) -> Result<SchemaRegistry> {
124        let mut reg = SchemaRegistry::new();
125        reg.merge_bundle(bytes)?;
126        Ok(reg)
127    }
128
129    /// Merge a distribution bundle into this registry, returning how many
130    /// schemas were newly added (already-present ids are skipped — idempotent).
131    pub fn merge_bundle(&mut self, bytes: &[u8]) -> Result<usize> {
132        if bytes.len() < BUNDLE_HEADER_LEN {
133            return Err(Error::BadSchema("schema bundle truncated".into()));
134        }
135        if &bytes[0..4] != BUNDLE_MAGIC {
136            return Err(Error::BadSchema("bad schema-bundle magic".into()));
137        }
138        if bytes[4] != BUNDLE_VERSION {
139            return Err(Error::BadSchema("unsupported schema-bundle version".into()));
140        }
141        let count = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
142        let mut pos = BUNDLE_HEADER_LEN;
143        let mut added = 0;
144        for _ in 0..count {
145            let end = pos
146                .checked_add(4)
147                .filter(|&e| e <= bytes.len())
148                .ok_or_else(|| Error::BadSchema("schema bundle truncated".into()))?;
149            let len = u32::from_le_bytes(bytes[pos..end].try_into().unwrap()) as usize;
150            pos = end;
151            let entry_end = pos
152                .checked_add(len)
153                .filter(|&e| e <= bytes.len())
154                .ok_or_else(|| Error::BadSchema("schema bundle entry out of bounds".into()))?;
155            let before = self.len();
156            self.register_canonical(&bytes[pos..entry_end])?;
157            if self.len() > before {
158                added += 1;
159            }
160            pos = entry_end;
161        }
162        if pos != bytes.len() {
163            return Err(Error::BadSchema(
164                "trailing bytes after schema bundle".into(),
165            ));
166        }
167        Ok(added)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::schema::{Dt, SchemaBuilder};
175    use crate::{encode, Message, SchemaMode, Value};
176
177    fn v0() -> Schema {
178        SchemaBuilder::new()
179            .add_struct(
180                "LogEvent",
181                vec![(1, "service", Dt::Str), (4, "latency", Dt::U16)],
182            )
183            .build("LogEvent")
184            .unwrap()
185    }
186
187    fn v1() -> Schema {
188        SchemaBuilder::new()
189            .add_struct(
190                "LogEvent",
191                vec![
192                    (1, "service", Dt::Str),
193                    (3, "message", Dt::Str),
194                    (4, "latency", Dt::U32), // widened from u16
195                ],
196            )
197            .build("LogEvent")
198            .unwrap()
199    }
200
201    #[test]
202    fn register_is_content_addressed_and_idempotent() {
203        let mut reg = SchemaRegistry::new();
204        let id_a = reg.register(v0());
205        // Re-registering the same content is a no-op and yields the same id.
206        let id_b = reg.register(v0());
207        assert_eq!(id_a, id_b);
208        assert_eq!(reg.len(), 1);
209        assert!(reg.contains(id_a));
210        assert_eq!(reg.get(id_a).unwrap().id(), id_a);
211    }
212
213    #[test]
214    fn registry_resolves_a_hash_only_peer_message() {
215        // A producer writes v0, hash-only (no inline schema on the wire).
216        let writer = v0();
217        let bytes = encode(
218            &writer,
219            &Value::Struct(vec![(1, Value::str("checkout")), (4, Value::U16(900))]),
220            SchemaMode::HashOnly,
221        )
222        .unwrap();
223        let msg = Message::parse(&bytes).unwrap();
224        assert!(
225            !msg.has_inline_schema(),
226            "hash-only: nothing to read without the registry"
227        );
228
229        // The consumer knows only v1, but has the producer's schema in its registry.
230        let mut reg = SchemaRegistry::new();
231        reg.register(writer);
232        let reader = v1();
233
234        let resolver = reg.resolver_for(msg.schema_id(), &reader).unwrap();
235        let root = msg.root(&resolver).unwrap();
236        assert_eq!(root.get_str(1).unwrap(), Some("checkout"));
237        assert_eq!(root.get_u32(4).unwrap(), Some(900), "u16 widened to u32");
238        assert_eq!(
239            root.get_str(3).unwrap(),
240            None,
241            "field added in v1, absent in v0 data"
242        );
243
244        // An unknown writer id is a typed error, not a panic.
245        assert!(reg.resolver_for(0xdead_beef, &reader).is_err());
246    }
247
248    #[test]
249    fn bundle_round_trips_and_is_deterministic() {
250        let mut reg = SchemaRegistry::new();
251        reg.register(v0());
252        reg.register(v1());
253        let bundle = reg.to_bundle();
254        // Deterministic regardless of insertion order.
255        let mut reg2 = SchemaRegistry::new();
256        reg2.register(v1());
257        reg2.register(v0());
258        assert_eq!(bundle, reg2.to_bundle());
259
260        let loaded = SchemaRegistry::from_bundle(&bundle).unwrap();
261        assert_eq!(loaded.len(), 2);
262        for id in reg.ids() {
263            assert_eq!(loaded.get(id).unwrap().id(), id);
264        }
265    }
266
267    #[test]
268    fn merge_reports_new_additions_and_skips_duplicates() {
269        let mut a = SchemaRegistry::new();
270        a.register(v0());
271        let mut b = SchemaRegistry::new();
272        b.register(v0()); // duplicate
273        b.register(v1()); // new
274        let added = a.merge_bundle(&b.to_bundle()).unwrap();
275        assert_eq!(added, 1, "only v1 is new");
276        assert_eq!(a.len(), 2);
277    }
278
279    #[test]
280    fn rejects_corrupt_bundle() {
281        let mut reg = SchemaRegistry::new();
282        reg.register(v0());
283        let good = reg.to_bundle();
284
285        let mut bad_magic = good.clone();
286        bad_magic[0] = b'X';
287        assert!(SchemaRegistry::from_bundle(&bad_magic).is_err());
288
289        let mut bad_len = good.clone();
290        // Corrupt the first entry's length prefix to point past the buffer.
291        bad_len[8..12].copy_from_slice(&1u32.to_le_bytes()); // count stays 1
292        bad_len[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
293        assert!(SchemaRegistry::from_bundle(&bad_len).is_err());
294
295        assert!(
296            SchemaRegistry::from_bundle(&good[..6]).is_err(),
297            "truncated header"
298        );
299    }
300}