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