Skip to main content

noxu_bind/serial/
serde_binding.rs

1//! Serde-based entry binding for Noxu DB.
2//!
3//! Replaces Java serialization with Rust's serde framework using a
4//! compact binary format implemented in [`super::simple_serial`].
5//!
6//! ## Wire format (v1.5)
7//!
8//! Every payload produced by `SerdeBinding::object_to_entry` begins
9//! with a 2-byte header followed by the [`super::simple_serial`] body:
10//!
11//! ```text
12//! +--------+---------+----------------+
13//! | 0xCB   |   0x01  |  simple_serial |
14//! | magic  | version |    payload     |
15//! +--------+---------+----------------+
16//! ```
17//!
18//! On decode, `entry_to_object` validates both bytes and returns
19//! [`crate::BindError::VersionMismatch`] if either is wrong, rather
20//! than silently producing a wrong-shaped value.  This is **not** full
21//! schema evolution (it cannot tolerate added/removed/reordered struct
22//! fields), but it stops silent corruption and gives an unambiguous,
23//! typed error when on-disk data and the running binary disagree.
24//!
25//! ## Breaking change vs. earlier 1.5 release candidates
26//!
27//! Data written by `SerdeBinding` in pre-3C builds did **not** carry
28//! the 2-byte header.  Records produced by older builds will fail to
29//! decode under v1.5 with `BindError::VersionMismatch { found_magic:
30//! <whatever the first byte happened to be>, ... }`.  See
31//! `docs/src/getting-started/bindings.md` for the migration guidance.
32//!
33//! ## Required dependencies (to be added to Cargo.toml)
34//!
35//! ```toml
36//! serde = { version = "1", features = ["derive"] }
37//! ```
38
39use std::marker::PhantomData;
40
41use serde::Serialize;
42use serde::de::DeserializeOwned;
43
44use noxu_db::DatabaseEntry;
45
46use crate::Result;
47use crate::entry_binding::EntryBinding;
48use crate::serial::simple_serial;
49
50/// Magic byte identifying a `SerdeBinding`-encoded payload.  Picked to
51/// be stable across releases; bumping it would be a breaking on-disk
52/// change.
53pub const SERDE_BINDING_MAGIC: u8 = 0xCB;
54
55/// Wire-format version emitted by this build.  Bump in lock-step with
56/// any incompatible change to the [`super::simple_serial`] format.
57pub const SERDE_BINDING_VERSION: u8 = 0x01;
58
59/// Length of the version header prefixed to every encoded entry.
60pub const SERDE_BINDING_HEADER_LEN: usize = 2;
61
62/// Binding that uses a compact binary format via serde for serialization.
63///
64/// Any type implementing `Serialize + DeserializeOwned` can be stored in and
65/// retrieved from database entries using this binding.  Each entry is
66/// prefixed with a 2-byte header; see the module docs for the format.
67///
68/// # Schema management caveat
69///
70/// `SerdeBinding` does **not** carry a per-record schema descriptor.
71/// The 2-byte header guards against decoding records produced by an
72/// incompatible *wire format* (`BindError::VersionMismatch`), but it
73/// cannot detect changes in the *Rust struct* being serialised:
74///
75/// * Adding a struct field, removing a struct field, or reordering
76///   fields **silently corrupts** records written by an earlier
77///   build of the same binary — the deserializer walks the same
78///   field list, in the same order, with no field tags to anchor it.
79///
80/// JE's `SerialBinding` solved the same problem with a
81/// `StoredClassCatalog` keyed off a per-record class id; this crate
82/// has no equivalent today.  Two concrete mitigations:
83///
84/// 1. Keep the on-disk struct stable and add fields only via
85///    `Option<T>`-typed wrappers under a new top-level enum variant
86///    that you can match on at the application layer.
87/// 2. For schemas that need to evolve, use the DPL
88///    (`noxu-persist`) which has explicit `@KeyField` /
89///    `@SecondaryKey` annotation-driven evolution; see
90///    `docs/src/collections/entity-persistence.md`.
91///
92/// # Examples
93///
94/// ```ignore
95/// use serde::{Serialize, Deserialize};
96/// use noxu_bind::serial::serde_binding::SerdeBinding;
97/// use noxu_bind::entry_binding::EntryBinding;
98/// use noxu_db::DatabaseEntry;
99///
100/// #[derive(Serialize, Deserialize, Debug, PartialEq)]
101/// struct Person {
102///     name: String,
103///     age: u32,
104/// }
105///
106/// let binding = SerdeBinding::<Person>::new();
107/// let person = Person { name: "Alice".into(), age: 30 };
108///
109/// let mut entry = DatabaseEntry::new();
110/// binding.object_to_entry(&person, &mut entry).unwrap();
111///
112/// let decoded = binding.entry_to_object(&entry).unwrap();
113/// assert_eq!(decoded, person);
114/// ```
115pub struct SerdeBinding<T> {
116    _phantom: PhantomData<T>,
117}
118
119impl<T> SerdeBinding<T> {
120    /// Creates a new serde-based binding.
121    pub fn new() -> Self {
122        Self { _phantom: PhantomData }
123    }
124}
125
126impl<T> Default for SerdeBinding<T> {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl<T> Clone for SerdeBinding<T> {
133    fn clone(&self) -> Self {
134        Self::new()
135    }
136}
137
138impl<T> std::fmt::Debug for SerdeBinding<T> {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("SerdeBinding")
141            .field("type", &std::any::type_name::<T>())
142            .finish()
143    }
144}
145
146impl<T: Serialize + DeserializeOwned> EntryBinding<T> for SerdeBinding<T> {
147    fn entry_to_object(&self, entry: &DatabaseEntry) -> Result<T> {
148        let data = entry.data();
149        if data.len() < SERDE_BINDING_HEADER_LEN {
150            return Err(crate::BindError::VersionMismatch {
151                expected_magic: SERDE_BINDING_MAGIC,
152                expected_version: SERDE_BINDING_VERSION,
153                found_magic: data.first().copied().unwrap_or(0),
154                found_version: data.get(1).copied().unwrap_or(0),
155            });
156        }
157        if data[0] != SERDE_BINDING_MAGIC || data[1] != SERDE_BINDING_VERSION {
158            return Err(crate::BindError::VersionMismatch {
159                expected_magic: SERDE_BINDING_MAGIC,
160                expected_version: SERDE_BINDING_VERSION,
161                found_magic: data[0],
162                found_version: data[1],
163            });
164        }
165        simple_serial::from_bytes(&data[SERDE_BINDING_HEADER_LEN..])
166    }
167
168    fn object_to_entry(
169        &self,
170        object: &T,
171        entry: &mut DatabaseEntry,
172    ) -> Result<()> {
173        let body = simple_serial::to_bytes(object)?;
174        let mut bytes =
175            Vec::with_capacity(body.len() + SERDE_BINDING_HEADER_LEN);
176        bytes.push(SERDE_BINDING_MAGIC);
177        bytes.push(SERDE_BINDING_VERSION);
178        bytes.extend_from_slice(&body);
179        entry.set_data_vec(bytes);
180        Ok(())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use serde::{Deserialize, Serialize};
188
189    #[test]
190    fn test_u32_round_trip() {
191        let binding = SerdeBinding::<u32>::new();
192        let mut entry = DatabaseEntry::new();
193        binding.object_to_entry(&42u32, &mut entry).unwrap();
194        assert_eq!(binding.entry_to_object(&entry).unwrap(), 42u32);
195    }
196
197    #[test]
198    fn test_string_round_trip() {
199        let binding = SerdeBinding::<String>::new();
200        let mut entry = DatabaseEntry::new();
201        let s = "hello world".to_string();
202        binding.object_to_entry(&s, &mut entry).unwrap();
203        assert_eq!(binding.entry_to_object(&entry).unwrap(), s);
204    }
205
206    #[derive(Debug, PartialEq, Serialize, Deserialize)]
207    struct TestRecord {
208        id: u64,
209        name: String,
210        active: bool,
211    }
212
213    #[test]
214    fn test_struct_round_trip() {
215        let binding = SerdeBinding::<TestRecord>::new();
216        let record =
217            TestRecord { id: 12345, name: "test".to_string(), active: true };
218        let mut entry = DatabaseEntry::new();
219        binding.object_to_entry(&record, &mut entry).unwrap();
220        assert_eq!(binding.entry_to_object(&entry).unwrap(), record);
221    }
222
223    #[test]
224    fn test_vec_round_trip() {
225        let binding = SerdeBinding::<Vec<u32>>::new();
226        let v = vec![1, 2, 3, 4, 5];
227        let mut entry = DatabaseEntry::new();
228        binding.object_to_entry(&v, &mut entry).unwrap();
229        assert_eq!(binding.entry_to_object(&entry).unwrap(), v);
230    }
231
232    #[test]
233    fn test_option_round_trip() {
234        let binding = SerdeBinding::<Option<String>>::new();
235        let mut entry = DatabaseEntry::new();
236
237        binding.object_to_entry(&Some("yes".to_string()), &mut entry).unwrap();
238        assert_eq!(
239            binding.entry_to_object(&entry).unwrap(),
240            Some("yes".to_string())
241        );
242
243        binding.object_to_entry(&None, &mut entry).unwrap();
244        assert_eq!(binding.entry_to_object(&entry).unwrap(), None);
245    }
246
247    #[derive(Debug, PartialEq, Serialize, Deserialize)]
248    enum Status {
249        Active,
250        Inactive,
251        Pending(String),
252    }
253
254    #[test]
255    fn test_enum_round_trip() {
256        let binding = SerdeBinding::<Status>::new();
257        let mut entry = DatabaseEntry::new();
258
259        binding.object_to_entry(&Status::Active, &mut entry).unwrap();
260        assert_eq!(binding.entry_to_object(&entry).unwrap(), Status::Active);
261
262        binding
263            .object_to_entry(&Status::Pending("review".to_string()), &mut entry)
264            .unwrap();
265        assert_eq!(
266            binding.entry_to_object(&entry).unwrap(),
267            Status::Pending("review".to_string())
268        );
269    }
270
271    #[test]
272    fn test_default() {
273        let binding = SerdeBinding::<u32>::default();
274        let mut entry = DatabaseEntry::new();
275        binding.object_to_entry(&7u32, &mut entry).unwrap();
276        assert_eq!(binding.entry_to_object(&entry).unwrap(), 7u32);
277    }
278
279    #[test]
280    fn test_clone() {
281        let binding = SerdeBinding::<u32>::new();
282        let cloned = binding.clone();
283        let mut entry = DatabaseEntry::new();
284        binding.object_to_entry(&99u32, &mut entry).unwrap();
285        assert_eq!(cloned.entry_to_object(&entry).unwrap(), 99u32);
286    }
287
288    #[test]
289    fn test_debug() {
290        let binding = SerdeBinding::<u32>::new();
291        let debug = format!("{:?}", binding);
292        assert!(debug.contains("SerdeBinding"));
293    }
294
295    #[test]
296    fn test_empty_entry_error() {
297        let binding = SerdeBinding::<u32>::new();
298        let entry = DatabaseEntry::new();
299        // Empty entry has no data  -  should fail on deserialization
300        assert!(binding.entry_to_object(&entry).is_err());
301    }
302
303    #[derive(Debug, PartialEq, Serialize, Deserialize)]
304    struct Nested {
305        inner: TestRecord,
306        tags: Vec<String>,
307    }
308
309    #[test]
310    fn test_nested_struct_round_trip() {
311        let binding = SerdeBinding::<Nested>::new();
312        let nested = Nested {
313            inner: TestRecord {
314                id: 1,
315                name: "nested".to_string(),
316                active: false,
317            },
318            tags: vec!["a".to_string(), "b".to_string()],
319        };
320        let mut entry = DatabaseEntry::new();
321        binding.object_to_entry(&nested, &mut entry).unwrap();
322        assert_eq!(binding.entry_to_object(&entry).unwrap(), nested);
323    }
324
325    #[test]
326    fn test_tuple_round_trip() {
327        let binding = SerdeBinding::<(u32, String, bool)>::new();
328        let val = (42u32, "hello".to_string(), true);
329        let mut entry = DatabaseEntry::new();
330        binding.object_to_entry(&val, &mut entry).unwrap();
331        assert_eq!(binding.entry_to_object(&entry).unwrap(), val);
332    }
333
334    #[test]
335    fn test_entry_data_is_set() {
336        let binding = SerdeBinding::<u32>::new();
337        let mut entry = DatabaseEntry::new();
338        assert!(entry.is_empty());
339        binding.object_to_entry(&42u32, &mut entry).unwrap();
340        assert!(!entry.is_empty());
341        assert!(entry.get_data().is_some());
342    }
343
344    // ----- Sprint 3C version-prefix tests (audit finding #19) ----------------
345
346    /// The wire format must begin with the documented 2-byte header.
347    /// If this assertion fails the on-disk format has drifted and the
348    /// version constant must be bumped.
349    #[test]
350    fn test_encoded_payload_starts_with_version_header() {
351        let binding = SerdeBinding::<u32>::new();
352        let mut entry = DatabaseEntry::new();
353        binding.object_to_entry(&42u32, &mut entry).unwrap();
354
355        let bytes = entry.get_data().unwrap();
356        assert!(
357            bytes.len() >= SERDE_BINDING_HEADER_LEN,
358            "encoded entry must include the 2-byte header",
359        );
360        assert_eq!(bytes[0], SERDE_BINDING_MAGIC);
361        assert_eq!(bytes[1], SERDE_BINDING_VERSION);
362        // body is a 4-byte big-endian u32 (= 42).
363        assert_eq!(&bytes[2..], &[0, 0, 0, 42]);
364    }
365
366    /// An entry written by a pre-3C build (no header) must surface as
367    /// `BindError::VersionMismatch` rather than panicking, returning
368    /// `InvalidData`, or producing a wrong-shaped value.
369    #[test]
370    fn test_decode_unprefixed_payload_returns_version_mismatch() {
371        // Pre-3C bytes: a bare big-endian u32 with no header.
372        let entry = DatabaseEntry::from_bytes(&[0, 0, 0, 42]);
373        let binding = SerdeBinding::<u32>::new();
374
375        let err = binding
376            .entry_to_object(&entry)
377            .expect_err("unprefixed payload must fail to decode");
378        match err {
379            crate::BindError::VersionMismatch {
380                expected_magic,
381                expected_version,
382                found_magic,
383                found_version,
384            } => {
385                assert_eq!(expected_magic, SERDE_BINDING_MAGIC);
386                assert_eq!(expected_version, SERDE_BINDING_VERSION);
387                // The pre-3C u32 starts with 0x00 0x00 — neither matches
388                // the magic, so the error reports those bytes verbatim.
389                assert_eq!(found_magic, 0x00);
390                assert_eq!(found_version, 0x00);
391            }
392            other => panic!("expected VersionMismatch, got {:?}", other),
393        }
394    }
395
396    /// A short entry (less than 2 bytes total) must also surface as
397    /// `VersionMismatch` rather than `BufferUnderflow`.
398    #[test]
399    fn test_decode_short_payload_returns_version_mismatch() {
400        let binding = SerdeBinding::<u32>::new();
401
402        for short in &[&[][..], &[SERDE_BINDING_MAGIC][..]] {
403            let entry = DatabaseEntry::from_bytes(short);
404            let err = binding
405                .entry_to_object(&entry)
406                .expect_err("short payload must fail to decode");
407            assert!(
408                matches!(err, crate::BindError::VersionMismatch { .. }),
409                "short payload (len={}) must fail with VersionMismatch, got {:?}",
410                short.len(),
411                err,
412            );
413        }
414    }
415
416    /// A header with the right magic but the wrong version must be
417    /// rejected with `VersionMismatch`, even if the trailing body
418    /// would otherwise round-trip cleanly.
419    #[test]
420    fn test_decode_wrong_version_returns_version_mismatch() {
421        let mut bytes = vec![SERDE_BINDING_MAGIC, 0xFF];
422        bytes.extend_from_slice(&42u32.to_be_bytes());
423        let entry = DatabaseEntry::from_bytes(&bytes);
424        let binding = SerdeBinding::<u32>::new();
425
426        let err = binding
427            .entry_to_object(&entry)
428            .expect_err("wrong-version payload must fail to decode");
429        match err {
430            crate::BindError::VersionMismatch {
431                found_magic,
432                found_version,
433                ..
434            } => {
435                assert_eq!(found_magic, SERDE_BINDING_MAGIC);
436                assert_eq!(found_version, 0xFF);
437            }
438            other => panic!("expected VersionMismatch, got {:?}", other),
439        }
440    }
441
442    /// `VersionMismatch` formats with both expected and found bytes so
443    /// users see exactly what is wrong.
444    #[test]
445    fn test_version_mismatch_display() {
446        let err = crate::BindError::VersionMismatch {
447            expected_magic: 0xCB,
448            expected_version: 0x01,
449            found_magic: 0x00,
450            found_version: 0x00,
451        };
452        let s = err.to_string();
453        assert!(s.contains("0xCB"), "display must include expected magic: {s}");
454        assert!(
455            s.contains("0x01"),
456            "display must include expected version: {s}"
457        );
458        assert!(
459            s.contains("version mismatch"),
460            "display must name the failure: {s}"
461        );
462    }
463}