Skip to main content

loonfs_api/
envelope.rs

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