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}
40
41/// Encode `value` as `[version | postcard(value)]`.
42///
43/// The leading byte lets the on-disk/wire format evolve without a silent
44/// misdecode: see [`decode`], which rejects a foreign version. The `version`
45/// is a parameter rather than a constant so each consumer owns its own schema
46/// version and can evolve it independently.
47pub fn encode<T: Serialize>(version: u8, value: &T) -> Result<Vec<u8>, CodecError> {
48    let body = postcard::to_stdvec(value).map_err(CodecError::Encode)?;
49    let mut out = Vec::with_capacity(1 + body.len());
50    out.push(version);
51    out.extend_from_slice(&body);
52    Ok(out)
53}
54
55/// Decode a payload produced by [`encode`], rejecting a version mismatch.
56///
57/// Returns [`CodecError::Version`] when the leading byte differs from
58/// `expected_version` — a stale reader fails loudly instead of parsing old
59/// bytes against a new struct layout.
60pub fn decode<T: DeserializeOwned>(expected_version: u8, bytes: &[u8]) -> Result<T, CodecError> {
61    let (first, rest) = bytes.split_first().ok_or(CodecError::Empty)?;
62    if *first != expected_version {
63        return Err(CodecError::Version {
64            expected: expected_version,
65            actual: *first,
66        });
67    }
68    postcard::from_bytes(rest).map_err(CodecError::Decode)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use serde::{Deserialize, Serialize};
75
76    #[derive(Debug, PartialEq, Serialize, Deserialize)]
77    struct Sample {
78        idx: u64,
79        name: String,
80    }
81
82    #[test]
83    fn encode_decode_roundtrip() {
84        let original = Sample {
85            idx: 42,
86            name: "tsoracle".into(),
87        };
88        let bytes = encode(1, &original).expect("encode");
89        assert_eq!(bytes[0], 1);
90        let decoded: Sample = decode(1, &bytes).expect("decode");
91        assert_eq!(original, decoded);
92    }
93
94    #[test]
95    fn decode_rejects_wrong_version() {
96        let bytes = encode(
97            2,
98            &Sample {
99                idx: 1,
100                name: "x".into(),
101            },
102        )
103        .expect("encode");
104        let err = decode::<Sample>(1, &bytes).expect_err("must reject");
105        assert!(matches!(
106            err,
107            CodecError::Version {
108                expected: 1,
109                actual: 2
110            }
111        ));
112    }
113
114    #[test]
115    fn decode_rejects_empty() {
116        let err = decode::<Sample>(1, &[]).expect_err("must reject");
117        assert!(matches!(err, CodecError::Empty));
118    }
119
120    #[test]
121    fn decode_rejects_truncated_input() {
122        let original = Sample {
123            idx: u64::MAX,
124            name: "hello-world-storage-roundtrip".into(),
125        };
126        let bytes = encode(1, &original).expect("encode");
127        assert!(bytes.len() >= 16, "payload should be non-trivial");
128        let truncated = &bytes[..bytes.len() / 2];
129        assert!(matches!(
130            decode::<Sample>(1, truncated),
131            Err(CodecError::Decode(_))
132        ));
133    }
134
135    use proptest::prelude::*;
136
137    proptest! {
138        // Roundtrip: encode then decode at the same version returns the
139        // original value, for any (version, payload).
140        #[test]
141        fn encode_decode_roundtrip_any(
142            version in any::<u8>(),
143            idx in any::<u64>(),
144            name in any::<String>(),
145        ) {
146            let s = Sample { idx, name };
147            let bytes = encode(version, &s).unwrap();
148            prop_assert_eq!(bytes[0], version);
149            let back: Sample = decode(version, &bytes).unwrap();
150            prop_assert_eq!(s, back);
151        }
152
153        // Version-mismatch detection: decoding with the wrong expected version
154        // returns CodecError::Version carrying the exact values, for any pair
155        // (encoded, expected) where the two differ.
156        #[test]
157        fn decode_rejects_any_version_mismatch(
158            encoded in any::<u8>(),
159            expected in any::<u8>(),
160            idx in any::<u64>(),
161            name in any::<String>(),
162        ) {
163            prop_assume!(encoded != expected);
164            let bytes = encode(encoded, &Sample { idx, name }).unwrap();
165            match decode::<Sample>(expected, &bytes) {
166                Err(CodecError::Version { expected: e, actual: a }) => {
167                    prop_assert_eq!(e, expected);
168                    prop_assert_eq!(a, encoded);
169                }
170                other => prop_assert!(false, "expected Version mismatch; got {other:?}"),
171            }
172        }
173    }
174}