Skip to main content

tsoracle_codec/
versioned.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24//! Versioned codec seam. A [`VersionedCodec`] type can be decoded from any
25//! supported version (up-converting older layouts into the current in-memory
26//! type) and encoded at any active version (down-converting the current type
27//! into an older layout). [`encode_framed`] / [`decode_framed`] add and validate
28//! the leading version byte; [`encode_postcard`] / [`decode_postcard_exact`] are
29//! the DRY per-version body building blocks implementors call.
30
31use serde::Serialize;
32use serde::de::DeserializeOwned;
33
34use crate::CodecError;
35
36/// A type whose persisted and on-wire representation is versioned.
37///
38/// `decode_version` up-converts an older layout into the current in-memory type;
39/// `encode_version` down-converts the current type into the layout of a given
40/// (possibly older) version. Implementors typically `match` on the version,
41/// routing to a per-version struct via [`decode_postcard_exact`] /
42/// [`encode_postcard`] and a `From` conversion. Both methods operate on the
43/// bare body (the bytes after the version prefix); framing is handled by
44/// [`encode_framed`] / [`decode_framed`].
45pub trait VersionedCodec: Sized {
46    /// Decode a `body` known to be `version` into the current type.
47    fn decode_version(version: u8, body: &[u8]) -> Result<Self, CodecError>;
48
49    /// Encode `self` as the body layout of `version`.
50    fn encode_version(&self, version: u8) -> Result<Vec<u8>, CodecError>;
51}
52
53/// Encode `value` as `[version | body]`, where `body` is `value`'s layout at
54/// `version` (see [`VersionedCodec::encode_version`]).
55pub fn encode_framed<T: VersionedCodec>(version: u8, value: &T) -> Result<Vec<u8>, CodecError> {
56    let body = value.encode_version(version)?;
57    let mut out = Vec::with_capacity(1 + body.len());
58    out.push(version);
59    out.extend_from_slice(&body);
60    Ok(out)
61}
62
63/// Decode a `[version | body]` payload, rejecting a version outside the reader's
64/// supported range `[min_version, max_version]` with [`CodecError::VersionUnsupported`]
65/// before any body parse. An in-range version is dispatched to
66/// [`VersionedCodec::decode_version`], up-converting older layouts into the
67/// current type.
68pub fn decode_framed<T: VersionedCodec>(
69    min_version: u8,
70    max_version: u8,
71    bytes: &[u8],
72) -> Result<T, CodecError> {
73    let (first, body) = bytes.split_first().ok_or(CodecError::Empty)?;
74    let version = *first;
75    if version < min_version || version > max_version {
76        return Err(CodecError::VersionUnsupported {
77            min: min_version,
78            max: max_version,
79            actual: version,
80        });
81    }
82    T::decode_version(version, body)
83}
84
85/// Serialize `value` to a bare postcard body (no version prefix). Used by
86/// [`VersionedCodec::encode_version`] implementations per version.
87pub fn encode_postcard<T: Serialize>(value: &T) -> Result<Vec<u8>, CodecError> {
88    postcard::to_stdvec(value).map_err(CodecError::Encode)
89}
90
91/// Deserialize a bare postcard body, rejecting surplus trailing bytes. A
92/// versioned format that exists to catch drift treats trailing garbage as
93/// corruption (e.g. a partial overwrite), not a clean record. Used by
94/// [`VersionedCodec::decode_version`] implementations per version.
95pub fn decode_postcard_exact<T: DeserializeOwned>(body: &[u8]) -> Result<T, CodecError> {
96    let (value, remainder) = postcard::take_from_bytes(body).map_err(CodecError::Decode)?;
97    if !remainder.is_empty() {
98        return Err(CodecError::TrailingBytes {
99            extra: remainder.len(),
100        });
101    }
102    Ok(value)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde::Deserialize;
109
110    #[derive(Debug, PartialEq, Serialize, Deserialize)]
111    struct Body {
112        idx: u64,
113        name: String,
114    }
115
116    // Current in-memory type. `color` is added at v2 and, per the design's
117    // representability rule, is feature-gated off (None) until the active write
118    // version reaches 2, so down-conversion to v1 is always lossless.
119    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120    struct Widget {
121        id: u64,
122        label: String,
123        color: Option<String>,
124    }
125
126    // v1 on-disk/wire layout: no `color`.
127    #[derive(Serialize, Deserialize)]
128    struct WidgetV1 {
129        id: u64,
130        label: String,
131    }
132
133    // v2 layout: includes `color`.
134    #[derive(Serialize, Deserialize)]
135    struct WidgetV2 {
136        id: u64,
137        label: String,
138        color: Option<String>,
139    }
140
141    impl From<WidgetV1> for Widget {
142        fn from(widget: WidgetV1) -> Self {
143            Widget {
144                id: widget.id,
145                label: widget.label,
146                color: None,
147            }
148        }
149    }
150
151    impl From<WidgetV2> for Widget {
152        fn from(widget: WidgetV2) -> Self {
153            Widget {
154                id: widget.id,
155                label: widget.label,
156                color: widget.color,
157            }
158        }
159    }
160
161    const WIDGET_MIN: u8 = 1;
162    const WIDGET_MAX: u8 = 2;
163
164    impl VersionedCodec for Widget {
165        fn decode_version(version: u8, body: &[u8]) -> Result<Self, CodecError> {
166            match version {
167                1 => decode_postcard_exact::<WidgetV1>(body).map(Into::into),
168                2 => decode_postcard_exact::<WidgetV2>(body).map(Into::into),
169                other => Err(CodecError::VersionUnsupported {
170                    min: WIDGET_MIN,
171                    max: WIDGET_MAX,
172                    actual: other,
173                }),
174            }
175        }
176
177        fn encode_version(&self, version: u8) -> Result<Vec<u8>, CodecError> {
178            match version {
179                1 => {
180                    // v1 has no `color`. If it is set (a feature-gate violation —
181                    // `color` belongs to v2+), fail loudly rather than silently
182                    // drop it. encode_version returns Result precisely for this.
183                    if self.color.is_some() {
184                        return Err(CodecError::NotRepresentable { version: 1 });
185                    }
186                    encode_postcard(&WidgetV1 {
187                        id: self.id,
188                        label: self.label.clone(),
189                    })
190                }
191                2 => encode_postcard(&WidgetV2 {
192                    id: self.id,
193                    label: self.label.clone(),
194                    color: self.color.clone(),
195                }),
196                other => Err(CodecError::VersionUnsupported {
197                    min: WIDGET_MIN,
198                    max: WIDGET_MAX,
199                    actual: other,
200                }),
201            }
202        }
203    }
204
205    #[test]
206    fn postcard_body_roundtrips() {
207        let original = Body {
208            idx: 7,
209            name: "ts".into(),
210        };
211        let bytes = encode_postcard(&original).expect("encode");
212        let decoded: Body = decode_postcard_exact(&bytes).expect("decode");
213        assert_eq!(original, decoded);
214    }
215
216    #[test]
217    fn postcard_body_rejects_trailing_bytes() {
218        let mut bytes = encode_postcard(&Body {
219            idx: 1,
220            name: "x".into(),
221        })
222        .expect("encode");
223        bytes.extend_from_slice(&[0xAB, 0xCD]);
224        assert!(matches!(
225            decode_postcard_exact::<Body>(&bytes),
226            Err(CodecError::TrailingBytes { extra: 2 })
227        ));
228    }
229
230    #[test]
231    fn version_unsupported_carries_range_and_actual() {
232        let err = CodecError::VersionUnsupported {
233            min: 1,
234            max: 2,
235            actual: 9,
236        };
237        assert_eq!(
238            err.to_string(),
239            "version unsupported: 9 outside readable range [1, 2]"
240        );
241    }
242
243    #[test]
244    fn not_representable_names_the_version() {
245        let err = CodecError::NotRepresentable { version: 1 };
246        assert_eq!(err.to_string(), "value not representable at version 1");
247    }
248
249    #[test]
250    fn trait_encode_decode_roundtrips_each_version() {
251        let widget = Widget {
252            id: 42,
253            label: "tso".into(),
254            color: Some("amber".into()),
255        };
256        let body_v2 = widget.encode_version(2).expect("encode v2");
257        let decoded_v2 = Widget::decode_version(2, &body_v2).expect("decode v2");
258        assert_eq!(widget, decoded_v2);
259
260        let gated = Widget {
261            id: 7,
262            label: "old".into(),
263            color: None,
264        };
265        let body_v1 = gated.encode_version(1).expect("encode v1");
266        let decoded_v1 = Widget::decode_version(1, &body_v1).expect("decode v1");
267        assert_eq!(gated, decoded_v1);
268    }
269
270    #[test]
271    fn down_convert_rejects_non_representable_state() {
272        // A v2-only field (color) set while encoding the v1 layout must fail
273        // loud, not silently drop — in release builds too.
274        let widget = Widget {
275            id: 1,
276            label: "x".into(),
277            color: Some("blue".into()),
278        };
279        assert!(matches!(
280            widget.encode_version(1),
281            Err(CodecError::NotRepresentable { version: 1 })
282        ));
283    }
284
285    #[test]
286    fn encode_framed_prepends_version_and_matches_body() {
287        let widget = Widget {
288            id: 5,
289            label: "z".into(),
290            color: None,
291        };
292        let framed = encode_framed(1, &widget).expect("encode_framed v1");
293        assert_eq!(framed[0], 1, "leading byte is the version");
294        // The remainder must equal the bare v1 body — proving no `color` byte
295        // leaks into the v1 layout (down-conversion is lossless).
296        let expected_body = widget.encode_version(1).expect("encode v1 body");
297        assert_eq!(&framed[1..], expected_body.as_slice());
298    }
299
300    #[test]
301    fn decode_framed_dispatches_and_up_converts() {
302        // A record written at v1 (no color) decodes into the current type with
303        // color defaulted to None.
304        let v1_body = encode_postcard(&WidgetV1 {
305            id: 9,
306            label: "old".into(),
307        })
308        .expect("body");
309        let mut framed = vec![1u8];
310        framed.extend_from_slice(&v1_body);
311        let widget: Widget = decode_framed(WIDGET_MIN, WIDGET_MAX, &framed).expect("decode v1");
312        assert_eq!(
313            widget,
314            Widget {
315                id: 9,
316                label: "old".into(),
317                color: None
318            }
319        );
320    }
321
322    #[test]
323    fn decode_framed_roundtrips_current_version() {
324        let widget = Widget {
325            id: 1,
326            label: "now".into(),
327            color: Some("red".into()),
328        };
329        let framed = encode_framed(2, &widget).expect("encode");
330        let back: Widget = decode_framed(WIDGET_MIN, WIDGET_MAX, &framed).expect("decode");
331        assert_eq!(widget, back);
332    }
333
334    #[test]
335    fn decode_framed_rejects_out_of_range_version() {
336        let framed = [3u8, 0x00];
337        let err = decode_framed::<Widget>(WIDGET_MIN, WIDGET_MAX, &framed).expect_err("reject");
338        assert!(matches!(
339            err,
340            CodecError::VersionUnsupported {
341                min: 1,
342                max: 2,
343                actual: 3
344            }
345        ));
346    }
347
348    #[test]
349    fn decode_framed_rejects_below_range_version() {
350        let framed = [0u8, 0x00];
351        let err = decode_framed::<Widget>(WIDGET_MIN, WIDGET_MAX, &framed).expect_err("reject");
352        assert!(matches!(
353            err,
354            CodecError::VersionUnsupported {
355                min: 1,
356                max: 2,
357                actual: 0
358            }
359        ));
360    }
361
362    #[test]
363    fn decode_framed_rejects_empty() {
364        let err = decode_framed::<Widget>(WIDGET_MIN, WIDGET_MAX, &[]).expect_err("reject");
365        assert!(matches!(err, CodecError::Empty));
366    }
367
368    use proptest::prelude::*;
369
370    proptest! {
371        // For any widget with color gated off, framing at v1 then decoding
372        // returns the original; framing at v2 with any color round-trips too.
373        #[test]
374        fn framed_roundtrips_any(id in any::<u64>(), label in any::<String>(), color in any::<Option<String>>()) {
375            let gated = Widget { id, label: label.clone(), color: None };
376            let framed_v1 = encode_framed(1, &gated).unwrap();
377            let back_v1: Widget = decode_framed(WIDGET_MIN, WIDGET_MAX, &framed_v1).unwrap();
378            prop_assert_eq!(gated, back_v1);
379
380            let full = Widget { id, label, color };
381            let framed_v2 = encode_framed(2, &full).unwrap();
382            let back_v2: Widget = decode_framed(WIDGET_MIN, WIDGET_MAX, &framed_v2).unwrap();
383            prop_assert_eq!(full, back_v2);
384        }
385    }
386}