Skip to main content

liminal/protocol/
envelope.rs

1use super::causal::CausalContext;
2use super::error::ProtocolError;
3
4const U32_LEN: usize = 4;
5
6/// Content schema hash that identifies the opaque payload's type.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
8pub struct SchemaId([u8; Self::WIRE_LEN]);
9
10impl SchemaId {
11    /// Number of bytes used by the schema id hash on the wire.
12    pub const WIRE_LEN: usize = 32;
13
14    /// Wrap a 32-byte content schema hash.
15    #[must_use]
16    pub const fn new(bytes: [u8; Self::WIRE_LEN]) -> Self {
17        Self(bytes)
18    }
19
20    /// Return the wrapped hash bytes.
21    #[must_use]
22    pub const fn as_bytes(&self) -> &[u8; Self::WIRE_LEN] {
23        &self.0
24    }
25
26    /// Consume this schema id and return its hash bytes.
27    #[must_use]
28    pub const fn into_bytes(self) -> [u8; Self::WIRE_LEN] {
29        self.0
30    }
31
32    /// Derives the stable 32-byte schema id liminal uses for raw schema bytes.
33    ///
34    /// This is THE derivation: the id a `liminal-server` channel advertises at
35    /// subscribe time is computed from the channel's RAW schema bytes by this
36    /// function, and a publisher deriving an id from the same bytes converges
37    /// on it. It is FNV-1a 64 over the bytes, the 8-byte digest spread across
38    /// the 32-byte id and re-hashed after every full 8-byte block so the id is
39    /// not just the digest repeated. Byte-identical to the private
40    /// `schema_id_from_bytes` the server and the SDK transports carried before
41    /// it was made public here (pinned by `tests/schema_id_derivation.rs`
42    /// against a verbatim copy of that private path and a known answer), so
43    /// an embedder derives ids with liminal's own bytes instead of a copy that
44    /// can drift silently.
45    ///
46    /// Two byte sequences that parse to the same document but differ in bytes
47    /// derive different ids; the derivation is over bytes, never a document.
48    #[must_use]
49    pub fn from_schema_bytes(schema_bytes: &[u8]) -> Self {
50        let mut id = [0_u8; Self::WIRE_LEN];
51        let mut hash = fnv1a(schema_bytes).to_be_bytes();
52        for (index, slot) in id.iter_mut().enumerate() {
53            *slot = hash[index % hash.len()];
54            if index % hash.len() == hash.len() - 1 {
55                hash = fnv1a(&hash).to_be_bytes();
56            }
57        }
58        Self(id)
59    }
60}
61
62/// FNV-1a 64-bit hash, used only by [`SchemaId::from_schema_bytes`].
63fn fnv1a(bytes: &[u8]) -> u64 {
64    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
65    const PRIME: u64 = 0x0000_0100_0000_01b3;
66    let mut hash = OFFSET_BASIS;
67    for byte in bytes {
68        hash ^= u64::from(*byte);
69        hash = hash.wrapping_mul(PRIME);
70    }
71    hash
72}
73
74/// Protocol message envelope carried by publish and conversation-message frames.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct MessageEnvelope {
77    /// Content type hash identifying the payload schema.
78    pub schema_id: SchemaId,
79    /// Structured causal metadata used by the bus for ordering decisions.
80    pub causal_context: CausalContext,
81    /// Opaque application payload bytes.
82    pub payload: Vec<u8>,
83}
84
85impl MessageEnvelope {
86    /// Create a protocol message envelope from all of its fields.
87    #[must_use]
88    pub const fn new(schema_id: SchemaId, causal_context: CausalContext, payload: Vec<u8>) -> Self {
89        Self {
90            schema_id,
91            causal_context,
92            payload,
93        }
94    }
95
96    /// Return the deterministic encoded length of this envelope.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`ProtocolError::CodecError`] when the causal-context or payload
101    /// length cannot fit in the protocol's `u32` length fields, or when the total
102    /// length overflows `usize`.
103    pub fn encoded_len(&self) -> Result<usize, ProtocolError> {
104        let causal_len = self.causal_context.encoded_len()?;
105        checked_u32_len(causal_len, "causal context")?;
106        checked_u32_len(self.payload.len(), "payload")?;
107        sum_lengths(&[
108            SchemaId::WIRE_LEN,
109            U32_LEN,
110            causal_len,
111            U32_LEN,
112            self.payload.len(),
113        ])
114    }
115
116    /// Serialize this envelope with deterministic big-endian field encoding.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`ProtocolError::CodecError`] when a length field cannot represent
121    /// the encoded envelope.
122    pub fn serialize(&self) -> Result<Vec<u8>, ProtocolError> {
123        let causal_bytes = self.causal_context.serialize()?;
124        checked_u32_len(causal_bytes.len(), "causal context")?;
125        checked_u32_len(self.payload.len(), "payload")?;
126        let len = sum_lengths(&[
127            SchemaId::WIRE_LEN,
128            U32_LEN,
129            causal_bytes.len(),
130            U32_LEN,
131            self.payload.len(),
132        ])?;
133        let mut bytes = Vec::with_capacity(len);
134
135        bytes.extend_from_slice(self.schema_id.as_bytes());
136        write_u32(&mut bytes, causal_bytes.len(), "causal context")?;
137        bytes.extend_from_slice(&causal_bytes);
138        write_u32(&mut bytes, self.payload.len(), "payload")?;
139        bytes.extend_from_slice(&self.payload);
140
141        if bytes.len() == len {
142            Ok(bytes)
143        } else {
144            Err(ProtocolError::codec(
145                "message envelope encoder produced an unexpected length",
146            ))
147        }
148    }
149
150    /// Serialize this envelope for wire transport.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`ProtocolError::CodecError`] when a length field cannot represent
155    /// the encoded envelope.
156    pub fn to_wire_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
157        self.serialize()
158    }
159
160    /// Deserialize an envelope from deterministic wire bytes.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`ProtocolError::CodecError`] when the bytes are truncated,
165    /// contain malformed causal context bytes, or contain trailing bytes.
166    pub fn deserialize(bytes: &[u8]) -> Result<Self, ProtocolError> {
167        Self::from_wire_bytes(bytes)
168    }
169
170    /// Deserialize an envelope from deterministic wire bytes.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`ProtocolError::CodecError`] when the bytes are truncated,
175    /// contain malformed causal context bytes, or contain trailing bytes.
176    pub fn from_wire_bytes(bytes: &[u8]) -> Result<Self, ProtocolError> {
177        let mut offset = 0;
178        let schema_id = SchemaId::new(read_schema_id(bytes, &mut offset)?);
179        let causal_len = read_u32_as_usize(bytes, &mut offset, "causal context length")?;
180        let causal_bytes = read_slice(bytes, &mut offset, causal_len, "causal context bytes")?;
181        let causal_context = CausalContext::deserialize(causal_bytes)?;
182        let payload_len = read_u32_as_usize(bytes, &mut offset, "payload length")?;
183        let payload = read_slice(bytes, &mut offset, payload_len, "payload bytes")?.to_vec();
184
185        if offset == bytes.len() {
186            Ok(Self {
187                schema_id,
188                causal_context,
189                payload,
190            })
191        } else {
192            Err(ProtocolError::codec(
193                "message envelope contained trailing bytes",
194            ))
195        }
196    }
197}
198
199fn checked_u32_len(len: usize, field: &str) -> Result<(), ProtocolError> {
200    u32::try_from(len)
201        .map(|_| ())
202        .map_err(|_| ProtocolError::codec(format!("{field} length exceeded u32::MAX")))
203}
204
205fn sum_lengths(parts: &[usize]) -> Result<usize, ProtocolError> {
206    let mut total = 0_usize;
207    for part in parts {
208        total = total
209            .checked_add(*part)
210            .ok_or_else(|| ProtocolError::codec("message envelope length overflowed usize"))?;
211    }
212    Ok(total)
213}
214
215fn write_u32(buffer: &mut Vec<u8>, value: usize, field: &str) -> Result<(), ProtocolError> {
216    let value = u32::try_from(value)
217        .map_err(|_| ProtocolError::codec(format!("{field} length exceeded u32::MAX")))?;
218    buffer.extend_from_slice(&value.to_be_bytes());
219    Ok(())
220}
221
222fn read_schema_id(
223    bytes: &[u8],
224    offset: &mut usize,
225) -> Result<[u8; SchemaId::WIRE_LEN], ProtocolError> {
226    let schema_bytes = read_slice(bytes, offset, SchemaId::WIRE_LEN, "schema id")?;
227    let mut schema_id = [0_u8; SchemaId::WIRE_LEN];
228    schema_id.copy_from_slice(schema_bytes);
229    Ok(schema_id)
230}
231
232fn read_u32_as_usize(
233    bytes: &[u8],
234    offset: &mut usize,
235    field: &str,
236) -> Result<usize, ProtocolError> {
237    let bytes = read_slice(bytes, offset, U32_LEN, field)?;
238    let [b0, b1, b2, b3] = bytes else {
239        return Err(ProtocolError::codec(format!("{field} was truncated")));
240    };
241    usize::try_from(u32::from_be_bytes([*b0, *b1, *b2, *b3]))
242        .map_err(|_| ProtocolError::codec(format!("{field} cannot fit usize")))
243}
244
245fn read_slice<'a>(
246    bytes: &'a [u8],
247    offset: &mut usize,
248    len: usize,
249    field: &str,
250) -> Result<&'a [u8], ProtocolError> {
251    let end = offset
252        .checked_add(len)
253        .ok_or_else(|| ProtocolError::codec(format!("{field} offset overflowed usize")))?;
254    let Some(slice) = bytes.get(*offset..end) else {
255        return Err(ProtocolError::codec(format!(
256            "{field} exceeded available bytes"
257        )));
258    };
259    *offset = end;
260    Ok(slice)
261}
262
263#[cfg(test)]
264mod tests {
265    use std::fmt::Debug;
266
267    use super::{MessageEnvelope, SchemaId};
268    use crate::protocol::{CausalContext, MessageId, ProtocolError, extract_causal_context};
269
270    #[test]
271    fn envelope_trait_bounds_are_available() {
272        fn assert_schema_traits<T: Debug + Clone + Copy + PartialEq + Eq>() {}
273        fn assert_envelope_traits<T: Debug + Clone + PartialEq + Eq>() {}
274
275        assert_schema_traits::<SchemaId>();
276        assert_envelope_traits::<MessageEnvelope>();
277    }
278
279    #[test]
280    fn schema_id_wraps_exactly_thirty_two_bytes() {
281        let bytes = [0xAB; SchemaId::WIRE_LEN];
282        let schema_id = SchemaId::new(bytes);
283
284        assert_eq!(SchemaId::WIRE_LEN, 32);
285        assert_eq!(schema_id.as_bytes(), &bytes);
286        assert_eq!(schema_id.into_bytes(), bytes);
287    }
288
289    #[test]
290    fn constructor_sets_all_fields() {
291        let schema_id = SchemaId::new([1; 32]);
292        let causal_context = CausalContext::with_parent(MessageId::from("parent"));
293        let payload = vec![1, 2, 3];
294
295        let envelope = MessageEnvelope::new(schema_id, causal_context.clone(), payload.clone());
296
297        assert_eq!(envelope.schema_id, schema_id);
298        assert_eq!(envelope.causal_context, causal_context);
299        assert_eq!(envelope.payload, payload);
300    }
301
302    #[test]
303    fn identical_fields_produce_identical_bytes() -> Result<(), ProtocolError> {
304        let first = sample_envelope(vec![5, 6, 7]);
305        let second = sample_envelope(vec![5, 6, 7]);
306
307        assert_eq!(first.serialize()?, second.serialize()?);
308        Ok(())
309    }
310
311    #[test]
312    fn serialization_round_trips_losslessly() -> Result<(), ProtocolError> {
313        let envelope = sample_envelope(vec![9, 8, 7]);
314        let encoded = envelope.serialize()?;
315        let decoded = MessageEnvelope::deserialize(&encoded)?;
316
317        assert_eq!(decoded, envelope);
318        Ok(())
319    }
320
321    #[test]
322    fn encoded_layout_starts_with_schema_id_and_big_endian_lengths() -> Result<(), ProtocolError> {
323        let schema_id = SchemaId::new([0x42; 32]);
324        let causal_context = CausalContext {
325            parent_id: Some(MessageId::from("parent")),
326            vector_clock_entry: Some(0x0102_0304_0506_0708),
327        };
328        let causal_len = causal_context.encoded_len()?;
329        let envelope = MessageEnvelope::new(schema_id, causal_context, vec![0xAA, 0xBB]);
330        let encoded = envelope.serialize()?;
331
332        assert_eq!(&encoded[..32], schema_id.as_bytes());
333        assert_eq!(
334            &encoded[32..36],
335            &u32::try_from(causal_len)
336                .map_err(|_| ProtocolError::codec("test causal length exceeded u32"))?
337                .to_be_bytes()
338        );
339        let payload_len_offset = 36 + causal_len;
340        assert_eq!(
341            &encoded[payload_len_offset..payload_len_offset + 4],
342            &2_u32.to_be_bytes()
343        );
344        Ok(())
345    }
346
347    #[test]
348    fn empty_payload_round_trips() -> Result<(), ProtocolError> {
349        let envelope = sample_envelope(Vec::new());
350        let encoded = envelope.serialize()?;
351        let decoded = MessageEnvelope::deserialize(&encoded)?;
352
353        assert_eq!(decoded, envelope);
354        assert_eq!(decoded.payload, Vec::<u8>::new());
355        Ok(())
356    }
357
358    #[test]
359    fn causal_context_is_extractable_from_envelope_bytes() -> Result<(), ProtocolError> {
360        let envelope = sample_envelope(vec![1, 2, 3, 4]);
361        let encoded = envelope.serialize()?;
362
363        assert_eq!(extract_causal_context(&encoded)?, envelope.causal_context);
364        Ok(())
365    }
366
367    fn sample_envelope(payload: Vec<u8>) -> MessageEnvelope {
368        MessageEnvelope::new(
369            SchemaId::new([0x11; 32]),
370            CausalContext {
371                parent_id: Some(MessageId::from("parent-1")),
372                vector_clock_entry: Some(99),
373            },
374            payload,
375        )
376    }
377}