Skip to main content

loonfs_api/
envelope.rs

1//! The durable envelope layout, decided once.
2//!
3//! Every durable LoonFS object is an envelope document with the same leading
4//! fields — `kind`, `format_version`, `payload_checksum` —
5//! followed by the payload as an opaque sub-document. This module owns the
6//! whole contract: the probe, the one error vocabulary, the kind/version/
7//! checksum validation rules, and the generic JSON codec that control
8//! objects and namespace manifests parameterize. The WAL segment codec keeps
9//! its CBOR-plus-zstd transport but validates through the same rules and
10//! reports through the same errors, so no envelope family can drift from
11//! the others.
12//!
13//! `payload_checksum` is always computed over the exact payload bytes as
14//! stored, never over a re-encoding, so checksum failures mean corruption
15//! and version skew surfaces as a version error.
16
17use crate::digest::sha256_digest;
18use serde::de::DeserializeOwned;
19use serde::{Deserialize, Serialize};
20use serde_json::value::RawValue;
21use thiserror::Error;
22
23/// Identifying prefix of a durable envelope document.
24///
25/// Readers decode this probe before the full document so that objects written
26/// with an unknown kind or an unsupported format version fail with a precise
27/// error instead of a generic decode error. `kind` is deliberately a string
28/// (not an enum) so future kinds remain reportable.
29#[derive(Debug, Deserialize)]
30pub struct EnvelopeProbe {
31    /// Durable family declared by the stored object.
32    pub kind: String,
33    /// Family-independent version gate declared by the stored object.
34    pub format_version: u32,
35}
36
37/// Failure vocabulary shared by every envelope codec. Messages are
38/// envelope-generic; the wrapping error names the object (and its key) the
39/// bytes came from.
40#[derive(Debug, Error)]
41#[non_exhaustive]
42pub enum EnvelopeCodecError {
43    /// Reports a payload that cannot be serialized into its family's durable encoding.
44    #[error("failed to encode envelope payload: {0}")]
45    PayloadEncode(String),
46    /// Reports an envelope document that cannot be serialized around an encoded payload.
47    #[error("failed to encode envelope document: {0}")]
48    EnvelopeEncode(String),
49    /// Reports bytes that do not decode as the shared durable envelope layout.
50    #[error("failed to decode envelope document: {0}")]
51    EnvelopeDecode(String),
52    /// Reports a verified envelope whose opaque payload does not decode as its declared family.
53    #[error("failed to decode envelope payload: {0}")]
54    PayloadDecode(String),
55    /// Reports an envelope that the configured transport codec could not compress.
56    #[error("failed to compress envelope: {0}")]
57    Compress(String),
58    /// Reports stored bytes that the configured transport codec could not decompress.
59    #[error("failed to decompress envelope: {0}")]
60    Decompress(String),
61    /// Reports an unrecognized durable-family discriminator found during the envelope probe.
62    #[error("unknown envelope kind `{found}`")]
63    UnknownKind {
64        /// Untrusted `kind` spelling decoded from the stored object.
65        found: String,
66    },
67    /// Reports a valid discriminator that does not belong to the decoder the caller selected.
68    #[error("envelope kind mismatch: expected `{expected}`, found `{found}`")]
69    KindMismatch {
70        /// Durable family the selected decoder accepts.
71        expected: String,
72        /// Durable family declared by the stored object.
73        found: String,
74    },
75    /// Reports a known durable family whose format version this build cannot read.
76    #[error(
77        "unsupported `{kind}` envelope format version `{found}`: \
78         this build supports `{supported}`"
79    )]
80    UnsupportedFormatVersion {
81        /// Durable family whose independent version gate rejected the object.
82        kind: String,
83        /// Version declared by the stored object.
84        found: u32,
85        /// Sole version this build reads and writes for `kind`.
86        supported: u32,
87    },
88    /// Reports stored payload bytes that do not match the checksum recorded beside them.
89    #[error("envelope payload checksum mismatch: expected `{expected}`, actual `{actual}`")]
90    ChecksumMismatch {
91        /// Digest recorded in the durable envelope.
92        expected: String,
93        /// Digest recomputed over the exact stored payload bytes.
94        actual: String,
95    },
96    /// Reports an in-memory payload changed without rebuilding its envelope checksum.
97    #[error(
98        "envelope checksum `{checksum}` does not match its payload `{actual}`: \
99         rebuild the envelope from its payload"
100    )]
101    StalePayloadChecksum {
102        /// Digest retained by the stale in-memory envelope.
103        checksum: String,
104        /// Digest recomputed from the payload about to be encoded.
105        actual: String,
106    },
107}
108
109/// Requires the probed kind to be exactly `expected`.
110pub fn verify_kind(expected: &str, found: &str) -> Result<(), EnvelopeCodecError> {
111    if found != expected {
112        return Err(EnvelopeCodecError::KindMismatch {
113            expected: expected.to_owned(),
114            found: found.to_owned(),
115        });
116    }
117    Ok(())
118}
119
120/// Requires the probed format version to be exactly what this build writes
121/// for `kind` — no envelope family tolerates version skew.
122pub fn verify_version(kind: &str, found: u32, supported: u32) -> Result<(), EnvelopeCodecError> {
123    if found != supported {
124        return Err(EnvelopeCodecError::UnsupportedFormatVersion {
125            kind: kind.to_owned(),
126            found,
127            supported,
128        });
129    }
130    Ok(())
131}
132
133/// Requires the stored checksum to match the payload bytes as stored.
134pub fn verify_payload_checksum(
135    expected: &str,
136    payload_bytes: &[u8],
137) -> Result<(), EnvelopeCodecError> {
138    let actual = sha256_digest(payload_bytes);
139    if actual != expected {
140        return Err(EnvelopeCodecError::ChecksumMismatch {
141            expected: expected.to_owned(),
142            actual,
143        });
144    }
145    Ok(())
146}
147
148/// Requires an in-memory envelope's recorded checksum to still match its
149/// payload before encoding — a stale checksum means the caller mutated the
150/// payload without rebuilding the envelope.
151pub fn verify_checksum_fresh(
152    checksum: &str,
153    payload_bytes: &[u8],
154) -> Result<(), EnvelopeCodecError> {
155    let actual = sha256_digest(payload_bytes);
156    if actual != checksum {
157        return Err(EnvelopeCodecError::StalePayloadChecksum {
158            checksum: checksum.to_owned(),
159            actual,
160        });
161    }
162    Ok(())
163}
164
165/// Durable layout of a JSON-bodied envelope: the shared fields plus the
166/// payload as a raw JSON fragment, kept inline so the object remains
167/// directly readable JSON while `payload_checksum` covers the exact
168/// fragment bytes as stored.
169#[derive(Serialize, Deserialize)]
170struct JsonEnvelopeDocument {
171    kind: String,
172    format_version: u32,
173    payload_checksum: String,
174    payload: Box<RawValue>,
175}
176
177#[derive(Deserialize)]
178#[serde(deny_unknown_fields)]
179struct StrictJsonEnvelopeDocument {
180    kind: String,
181    format_version: u32,
182    payload_checksum: String,
183    payload: Box<RawValue>,
184}
185
186impl From<StrictJsonEnvelopeDocument> for JsonEnvelopeDocument {
187    fn from(document: StrictJsonEnvelopeDocument) -> Self {
188        Self {
189            kind: document.kind,
190            format_version: document.format_version,
191            payload_checksum: document.payload_checksum,
192            payload: document.payload,
193        }
194    }
195}
196
197/// The `sha256:<hex>` checksum a JSON payload will carry, computed over its
198/// canonical serialization.
199pub fn json_payload_checksum<T: Serialize>(payload: &T) -> Result<String, EnvelopeCodecError> {
200    let bytes = serde_json::to_vec(payload)
201        .map_err(|err| EnvelopeCodecError::PayloadEncode(err.to_string()))?;
202    Ok(sha256_digest(&bytes))
203}
204
205/// Encodes one JSON-bodied envelope, validating that the recorded version is
206/// what this build writes for `kind` and that the recorded checksum still
207/// matches the payload.
208pub fn encode_json_envelope<T: Serialize>(
209    kind: &str,
210    format_version: u32,
211    supported_version: u32,
212    payload_checksum: &str,
213    payload: &T,
214) -> Result<Vec<u8>, EnvelopeCodecError> {
215    verify_version(kind, format_version, supported_version)?;
216    let payload_json = serde_json::to_string(payload)
217        .map_err(|err| EnvelopeCodecError::PayloadEncode(err.to_string()))?;
218    verify_checksum_fresh(payload_checksum, payload_json.as_bytes())?;
219    let document = JsonEnvelopeDocument {
220        kind: kind.to_owned(),
221        format_version,
222        payload_checksum: payload_checksum.to_owned(),
223        payload: RawValue::from_string(payload_json)
224            .map_err(|err| EnvelopeCodecError::PayloadEncode(err.to_string()))?,
225    };
226    serde_json::to_vec(&document).map_err(|err| EnvelopeCodecError::EnvelopeEncode(err.to_string()))
227}
228
229/// A decoded JSON-bodied envelope's shared fields plus its parsed payload.
230pub struct DecodedJsonEnvelope<T> {
231    /// Version gate the stored object declared and this build accepted.
232    pub format_version: u32,
233    /// Digest verified against the payload fragment exactly as stored.
234    pub payload_checksum: String,
235    /// The family payload decoded from that verified fragment.
236    pub payload: T,
237}
238
239/// Decodes one JSON-bodied envelope: probe first (kind through
240/// `classify_kind`, then version), then the checksum over the stored
241/// payload fragment, then the payload itself.
242///
243/// `classify_kind` lets a family with a kind registry report unknown kinds
244/// distinctly from mismatched ones; families with one kind pass
245/// [`verify_kind`] directly.
246pub fn decode_json_envelope<T: DeserializeOwned>(
247    bytes: &[u8],
248    supported_version: u32,
249    classify_kind: impl FnOnce(&str) -> Result<(), EnvelopeCodecError>,
250) -> Result<DecodedJsonEnvelope<T>, EnvelopeCodecError> {
251    decode_json_envelope_probe(bytes, supported_version, classify_kind)?;
252    let document: JsonEnvelopeDocument = serde_json::from_slice(bytes)
253        .map_err(|err| EnvelopeCodecError::EnvelopeDecode(err.to_string()))?;
254    decode_json_envelope_payload(document)
255}
256
257/// Immutable envelope families tolerate unknown fields, while mutable control-object envelopes
258/// reject them. A tolerant read followed by rewrite would erase fields the current binary does
259/// not understand.
260pub fn decode_strict_json_envelope<T: DeserializeOwned>(
261    bytes: &[u8],
262    supported_version: u32,
263    classify_kind: impl FnOnce(&str) -> Result<(), EnvelopeCodecError>,
264) -> Result<DecodedJsonEnvelope<T>, EnvelopeCodecError> {
265    decode_json_envelope_probe(bytes, supported_version, classify_kind)?;
266    let document: StrictJsonEnvelopeDocument = serde_json::from_slice(bytes)
267        .map_err(|err| EnvelopeCodecError::EnvelopeDecode(err.to_string()))?;
268    decode_json_envelope_payload(document.into())
269}
270
271fn decode_json_envelope_probe(
272    bytes: &[u8],
273    supported_version: u32,
274    classify_kind: impl FnOnce(&str) -> Result<(), EnvelopeCodecError>,
275) -> Result<(), EnvelopeCodecError> {
276    let probe: EnvelopeProbe = serde_json::from_slice(bytes)
277        .map_err(|err| EnvelopeCodecError::EnvelopeDecode(err.to_string()))?;
278    classify_kind(&probe.kind)?;
279    verify_version(&probe.kind, probe.format_version, supported_version)?;
280    Ok(())
281}
282
283fn decode_json_envelope_payload<T: DeserializeOwned>(
284    document: JsonEnvelopeDocument,
285) -> Result<DecodedJsonEnvelope<T>, EnvelopeCodecError> {
286    verify_payload_checksum(
287        &document.payload_checksum,
288        document.payload.get().as_bytes(),
289    )?;
290    let payload: T = serde_json::from_str(document.payload.get())
291        .map_err(|err| EnvelopeCodecError::PayloadDecode(err.to_string()))?;
292
293    Ok(DecodedJsonEnvelope {
294        format_version: document.format_version,
295        payload_checksum: document.payload_checksum,
296        payload,
297    })
298}