Skip to main content

tsoracle_codec/
lib.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//
8//  Copyright (c) 2026 Prisma Risk
9//  Licensed under the Apache License, Version 2.0
10//  https://github.com/prisma-risk/tsoracle
11//
12
13#![doc = include_str!("../README.md")]
14
15use serde::{Serialize, de::DeserializeOwned};
16
17/// Failure modes of the version-prefixed postcard codec.
18///
19/// `Encode` and `Decode` are kept distinct so a caller can tell which
20/// direction failed; both carry the underlying [`postcard::Error`] as the
21/// error source rather than via a `From` conversion, so a stray `?` on a
22/// `postcard` result never silently becomes a `CodecError`.
23#[derive(Debug, thiserror::Error)]
24pub enum CodecError {
25    /// The payload had no leading version byte (it was empty).
26    #[error("payload empty")]
27    Empty,
28    /// The leading version byte did not match the version the reader expected.
29    /// A stale reader hits this instead of misdecoding old bytes against a new
30    /// struct layout.
31    #[error("version mismatch: expected {expected}, got {actual}")]
32    Version { expected: u8, actual: u8 },
33    /// `postcard` failed to serialize the value.
34    #[error("encode failed: {0}")]
35    Encode(#[source] postcard::Error),
36    /// `postcard` failed to deserialize the framed body.
37    #[error("decode failed: {0}")]
38    Decode(#[source] postcard::Error),
39    /// The body decoded successfully but `extra` bytes remained unconsumed.
40    /// For a versioned on-disk format this signals corruption — e.g. a partial
41    /// overwrite that left stale tail bytes — rather than a clean record.
42    #[error("trailing bytes: {extra} unconsumed after a valid body")]
43    TrailingBytes { extra: usize },
44}
45
46/// Encode `value` as `[version | postcard(value)]`.
47///
48/// The leading byte lets the on-disk/wire format evolve without a silent
49/// misdecode: see [`decode`], which rejects a foreign version. The `version`
50/// is a parameter rather than a constant so each consumer owns its own schema
51/// version and can evolve it independently.
52pub fn encode<T: Serialize>(version: u8, value: &T) -> Result<Vec<u8>, CodecError> {
53    let body = postcard::to_stdvec(value).map_err(CodecError::Encode)?;
54    let mut out = Vec::with_capacity(1 + body.len());
55    out.push(version);
56    out.extend_from_slice(&body);
57    Ok(out)
58}
59
60/// Decode a payload produced by [`encode`], rejecting a version mismatch.
61///
62/// Returns [`CodecError::Version`] when the leading byte differs from
63/// `expected_version` — a stale reader fails loudly instead of parsing old
64/// bytes against a new struct layout. Returns [`CodecError::TrailingBytes`]
65/// when the body decodes but leaves surplus bytes unconsumed: for a versioned
66/// format that exists to catch drift, garbage appended to a valid record is a
67/// corruption signal, not something to silently discard.
68pub fn decode<T: DeserializeOwned>(expected_version: u8, bytes: &[u8]) -> Result<T, CodecError> {
69    let (first, rest) = bytes.split_first().ok_or(CodecError::Empty)?;
70    if *first != expected_version {
71        return Err(CodecError::Version {
72            expected: expected_version,
73            actual: *first,
74        });
75    }
76    let (value, remainder) = postcard::take_from_bytes(rest).map_err(CodecError::Decode)?;
77    if !remainder.is_empty() {
78        return Err(CodecError::TrailingBytes {
79            extra: remainder.len(),
80        });
81    }
82    Ok(value)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use serde::{Deserialize, Serialize};
89
90    #[derive(Debug, PartialEq, Serialize, Deserialize)]
91    struct Sample {
92        idx: u64,
93        name: String,
94    }
95
96    #[test]
97    fn encode_decode_roundtrip() {
98        let original = Sample {
99            idx: 42,
100            name: "tsoracle".into(),
101        };
102        let bytes = encode(1, &original).expect("encode");
103        assert_eq!(bytes[0], 1);
104        let decoded: Sample = decode(1, &bytes).expect("decode");
105        assert_eq!(original, decoded);
106    }
107
108    #[test]
109    fn decode_rejects_wrong_version() {
110        let bytes = encode(
111            2,
112            &Sample {
113                idx: 1,
114                name: "x".into(),
115            },
116        )
117        .expect("encode");
118        let err = decode::<Sample>(1, &bytes).expect_err("must reject");
119        assert!(matches!(
120            err,
121            CodecError::Version {
122                expected: 1,
123                actual: 2
124            }
125        ));
126    }
127
128    #[test]
129    fn decode_rejects_empty() {
130        let err = decode::<Sample>(1, &[]).expect_err("must reject");
131        assert!(matches!(err, CodecError::Empty));
132    }
133
134    #[test]
135    fn decode_rejects_truncated_input() {
136        let original = Sample {
137            idx: u64::MAX,
138            name: "hello-world-storage-roundtrip".into(),
139        };
140        let bytes = encode(1, &original).expect("encode");
141        assert!(bytes.len() >= 16, "payload should be non-trivial");
142        let truncated = &bytes[..bytes.len() / 2];
143        assert!(matches!(
144            decode::<Sample>(1, truncated),
145            Err(CodecError::Decode(_))
146        ));
147    }
148
149    #[test]
150    fn decode_rejects_trailing_bytes() {
151        let original = Sample {
152            idx: 7,
153            name: "trailing".into(),
154        };
155        let mut bytes = encode(1, &original).expect("encode");
156        // Simulate a partial overwrite that left stale tail bytes behind: a
157        // valid body followed by garbage postcard never consumes.
158        bytes.extend_from_slice(&[0xAB, 0xCD, 0xEF]);
159        assert!(matches!(
160            decode::<Sample>(1, &bytes),
161            Err(CodecError::TrailingBytes { extra: 3 })
162        ));
163    }
164
165    use proptest::prelude::*;
166
167    proptest! {
168        // Roundtrip: encode then decode at the same version returns the
169        // original value, for any (version, payload).
170        #[test]
171        fn encode_decode_roundtrip_any(
172            version in any::<u8>(),
173            idx in any::<u64>(),
174            name in any::<String>(),
175        ) {
176            let s = Sample { idx, name };
177            let bytes = encode(version, &s).unwrap();
178            prop_assert_eq!(bytes[0], version);
179            let back: Sample = decode(version, &bytes).unwrap();
180            prop_assert_eq!(s, back);
181        }
182
183        // Version-mismatch detection: decoding with the wrong expected version
184        // returns CodecError::Version carrying the exact values, for any pair
185        // (encoded, expected) where the two differ.
186        #[test]
187        fn decode_rejects_any_version_mismatch(
188            encoded in any::<u8>(),
189            expected in any::<u8>(),
190            idx in any::<u64>(),
191            name in any::<String>(),
192        ) {
193            prop_assume!(encoded != expected);
194            let bytes = encode(encoded, &Sample { idx, name }).unwrap();
195            match decode::<Sample>(expected, &bytes) {
196                Err(CodecError::Version { expected: e, actual: a }) => {
197                    prop_assert_eq!(e, expected);
198                    prop_assert_eq!(a, encoded);
199                }
200                other => prop_assert!(false, "expected Version mismatch; got {other:?}"),
201            }
202        }
203    }
204}