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}