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 std::io;
16
17use serde::{Serialize, de::DeserializeOwned};
18
19/// Failure modes of the version-prefixed postcard codec.
20///
21/// `Encode` and `Decode` are kept distinct so a caller can tell which
22/// direction failed; both carry the underlying [`postcard::Error`] as the
23/// error source rather than via a `From` conversion, so a stray `?` on a
24/// `postcard` result never silently becomes a `CodecError`.
25#[derive(Debug, thiserror::Error)]
26pub enum CodecError {
27    /// The payload had no leading version byte (it was empty).
28    #[error("payload empty")]
29    Empty,
30    /// The leading version byte did not match the version the reader expected.
31    /// A stale reader hits this instead of misdecoding old bytes against a new
32    /// struct layout.
33    #[error("version mismatch: expected {expected}, got {actual}")]
34    Version { expected: u8, actual: u8 },
35    /// `postcard` failed to serialize the value.
36    #[error("encode failed: {0}")]
37    Encode(#[source] postcard::Error),
38    /// `postcard` failed to deserialize the framed body.
39    #[error("decode failed: {0}")]
40    Decode(#[source] postcard::Error),
41    /// The body decoded successfully but `extra` bytes remained unconsumed.
42    /// For a versioned on-disk format this signals corruption — e.g. a partial
43    /// overwrite that left stale tail bytes — rather than a clean record.
44    #[error("trailing bytes: {extra} unconsumed after a valid body")]
45    TrailingBytes { extra: usize },
46}
47
48/// Encode `value` as `[version | postcard(value)]`.
49///
50/// The leading byte lets the on-disk/wire format evolve without a silent
51/// misdecode: see [`decode`], which rejects a foreign version. The `version`
52/// is a parameter rather than a constant so each consumer owns its own schema
53/// version and can evolve it independently.
54pub fn encode<T: Serialize>(version: u8, value: &T) -> Result<Vec<u8>, CodecError> {
55    let body = postcard::to_stdvec(value).map_err(CodecError::Encode)?;
56    let mut out = Vec::with_capacity(1 + body.len());
57    out.push(version);
58    out.extend_from_slice(&body);
59    Ok(out)
60}
61
62/// Decode a payload produced by [`encode`], rejecting a version mismatch.
63///
64/// Returns [`CodecError::Version`] when the leading byte differs from
65/// `expected_version` — a stale reader fails loudly instead of parsing old
66/// bytes against a new struct layout. Returns [`CodecError::TrailingBytes`]
67/// when the body decodes but leaves surplus bytes unconsumed: for a versioned
68/// format that exists to catch drift, garbage appended to a valid record is a
69/// corruption signal, not something to silently discard.
70pub fn decode<T: DeserializeOwned>(expected_version: u8, bytes: &[u8]) -> Result<T, CodecError> {
71    let (first, rest) = bytes.split_first().ok_or(CodecError::Empty)?;
72    if *first != expected_version {
73        return Err(CodecError::Version {
74            expected: expected_version,
75            actual: *first,
76        });
77    }
78    let (value, remainder) = postcard::take_from_bytes(rest).map_err(CodecError::Decode)?;
79    if !remainder.is_empty() {
80        return Err(CodecError::TrailingBytes {
81            extra: remainder.len(),
82        });
83    }
84    Ok(value)
85}
86
87/// Map a [`CodecError`] to an [`io::Error`], tagging the message with `context`
88/// so the failing boundary is identifiable.
89///
90/// A [`CodecError::Version`] mismatch becomes [`io::ErrorKind::InvalidData`]: a
91/// foreign on-disk/wire format is a structured, distinguishable condition — a
92/// caller can react to "this record is from another schema version" specifically
93/// rather than treating it as a generic decode failure. Every other variant maps
94/// through [`io::Error::other`].
95///
96/// Consumers whose trait surface speaks `io::Error` — the openraft
97/// `RaftLogStorage` / `RaftStateMachine` implementations — share this one mapping
98/// so the version-mismatch-to-`InvalidData` contract can't drift between them.
99/// (The paxos toolkit surfaces [`CodecError`] through its own `thiserror` error
100/// enum and so does not use this.)
101pub fn codec_io_error(context: &str, err: CodecError) -> io::Error {
102    match err {
103        version_mismatch @ CodecError::Version { .. } => io::Error::new(
104            io::ErrorKind::InvalidData,
105            format!("{context}: {version_mismatch}"),
106        ),
107        other => io::Error::other(format!("{context}: {other}")),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use serde::{Deserialize, Serialize};
115
116    #[derive(Debug, PartialEq, Serialize, Deserialize)]
117    struct Sample {
118        idx: u64,
119        name: String,
120    }
121
122    #[test]
123    fn encode_decode_roundtrip() {
124        let original = Sample {
125            idx: 42,
126            name: "tsoracle".into(),
127        };
128        let bytes = encode(1, &original).expect("encode");
129        assert_eq!(bytes[0], 1);
130        let decoded: Sample = decode(1, &bytes).expect("decode");
131        assert_eq!(original, decoded);
132    }
133
134    #[test]
135    fn decode_rejects_wrong_version() {
136        let bytes = encode(
137            2,
138            &Sample {
139                idx: 1,
140                name: "x".into(),
141            },
142        )
143        .expect("encode");
144        let err = decode::<Sample>(1, &bytes).expect_err("must reject");
145        assert!(matches!(
146            err,
147            CodecError::Version {
148                expected: 1,
149                actual: 2
150            }
151        ));
152    }
153
154    #[test]
155    fn decode_rejects_empty() {
156        let err = decode::<Sample>(1, &[]).expect_err("must reject");
157        assert!(matches!(err, CodecError::Empty));
158    }
159
160    #[test]
161    fn decode_rejects_truncated_input() {
162        let original = Sample {
163            idx: u64::MAX,
164            name: "hello-world-storage-roundtrip".into(),
165        };
166        let bytes = encode(1, &original).expect("encode");
167        assert!(bytes.len() >= 16, "payload should be non-trivial");
168        let truncated = &bytes[..bytes.len() / 2];
169        assert!(matches!(
170            decode::<Sample>(1, truncated),
171            Err(CodecError::Decode(_))
172        ));
173    }
174
175    #[test]
176    fn decode_rejects_trailing_bytes() {
177        let original = Sample {
178            idx: 7,
179            name: "trailing".into(),
180        };
181        let mut bytes = encode(1, &original).expect("encode");
182        // Simulate a partial overwrite that left stale tail bytes behind: a
183        // valid body followed by garbage postcard never consumes.
184        bytes.extend_from_slice(&[0xAB, 0xCD, 0xEF]);
185        assert!(matches!(
186            decode::<Sample>(1, &bytes),
187            Err(CodecError::TrailingBytes { extra: 3 })
188        ));
189    }
190
191    #[test]
192    fn codec_io_error_maps_version_mismatch_to_invalid_data() {
193        // A record stamped at version 2 read against version 1 is a version
194        // mismatch — the boundary `codec_io_error` must surface as `InvalidData`.
195        let v2_bytes = encode(
196            2,
197            &Sample {
198                idx: 1,
199                name: "x".into(),
200            },
201        )
202        .unwrap();
203        let err = decode::<Sample>(1, &v2_bytes).expect_err("must reject");
204        assert!(matches!(err, CodecError::Version { .. }));
205        let io_err = codec_io_error("vote decode", err);
206        assert_eq!(io_err.kind(), io::ErrorKind::InvalidData);
207        assert!(
208            io_err.to_string().starts_with("vote decode: "),
209            "context must prefix the message, got {io_err}"
210        );
211    }
212
213    #[test]
214    fn codec_io_error_maps_other_variants_to_other_kind() {
215        // `Empty` is not a version mismatch, so it must not masquerade as the
216        // `InvalidData` reserved for a foreign schema version.
217        let io_err = codec_io_error("vote decode", CodecError::Empty);
218        assert_ne!(io_err.kind(), io::ErrorKind::InvalidData);
219        assert!(io_err.to_string().starts_with("vote decode: "));
220    }
221
222    use proptest::prelude::*;
223
224    proptest! {
225        // Roundtrip: encode then decode at the same version returns the
226        // original value, for any (version, payload).
227        #[test]
228        fn encode_decode_roundtrip_any(
229            version in any::<u8>(),
230            idx in any::<u64>(),
231            name in any::<String>(),
232        ) {
233            let s = Sample { idx, name };
234            let bytes = encode(version, &s).unwrap();
235            prop_assert_eq!(bytes[0], version);
236            let back: Sample = decode(version, &bytes).unwrap();
237            prop_assert_eq!(s, back);
238        }
239
240        // Version-mismatch detection: decoding with the wrong expected version
241        // returns CodecError::Version carrying the exact values, for any pair
242        // (encoded, expected) where the two differ.
243        #[test]
244        fn decode_rejects_any_version_mismatch(
245            encoded in any::<u8>(),
246            expected in any::<u8>(),
247            idx in any::<u64>(),
248            name in any::<String>(),
249        ) {
250            prop_assume!(encoded != expected);
251            let bytes = encode(encoded, &Sample { idx, name }).unwrap();
252            match decode::<Sample>(expected, &bytes) {
253                Err(CodecError::Version { expected: e, actual: a }) => {
254                    prop_assert_eq!(e, expected);
255                    prop_assert_eq!(a, encoded);
256                }
257                other => prop_assert!(false, "expected Version mismatch; got {other:?}"),
258            }
259        }
260    }
261}