Skip to main content

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