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