Skip to main content

tf_types/
binary_format.rs

1#![allow(clippy::doc_lazy_continuation)]
2//! Binary container formats — Rust mirror of TS `binary-format.ts`.
3//!
4//!   .tfbundle  — sealed/serialized proof bundle, L4/L5 capable.
5//!      magic     = "TFBND" 0x01 0x00 0x00            (8 bytes)
6//!      body_len  = u32 BE
7//!      body      = CBOR-encoded ProofBundleEncrypted | ProofBundle
8//!      sig_len   = u32 BE   (0 when unsigned)
9//!      signature = sig_len bytes (raw ed25519)
10//!
11//!   .tfpkt     — packet-on-the-wire envelope.
12//!      magic     = "TFPKT" 0x01 0x00 0x00            (8 bytes)
13//!      body_len  = u32 BE
14//!      body      = CBOR-encoded Packet
15//!
16//! The Rust encoder must produce byte-identical output to the TS
17//! encoder for the same canonical input — verified by
18//! `conformance/binary-format-vectors.yaml`.
19//!
20//! --- CBOR DETERMINISM (READ BEFORE EDITING) ---
21//!
22//! For wire-level parity with the TS encoder, the Rust encoder converts
23//! through `serde_json::Value` first and explicitly sorts every object's
24//! keys lexicographically (`canonicalize_json`); the in-house
25//! `crate::cbor` encoder then emits map entries in exactly that order
26//! with the smallest definite-length headers. Without this
27//! intermediate, a native `#[derive(Serialize)]` struct would emit
28//! fields in declaration order and break parity.
29//!
30//! Yes, this costs one extra ser/deser per encode. The packets are
31//! small (typical .tfpkt <1 KiB) and constrained-mode use cases never
32//! hot-loop the encoder, so the trade for a stable wire format is
33//! correct. Do NOT remove the round-trip without first updating
34//! `conformance/binary-format-vectors.yaml` and the matching TS test.
35
36use crate::cbor;
37use crate::generated::Packet;
38use serde::{de::DeserializeOwned, Serialize};
39
40pub const TFBUNDLE_MAGIC: [u8; 8] = [0x54, 0x46, 0x42, 0x4e, 0x44, 0x01, 0x00, 0x00];
41pub const TFPKT_MAGIC: [u8; 8] = [0x54, 0x46, 0x50, 0x4b, 0x54, 0x01, 0x00, 0x00];
42
43#[derive(Debug, thiserror::Error)]
44pub enum BinaryFormatError {
45    #[error("bad magic")]
46    BadMagic,
47    #[error("truncated at offset {0}")]
48    Truncated(usize),
49    #[error("cbor: {0}")]
50    Cbor(String),
51    #[error("length out of range: {0}")]
52    LengthOutOfRange(u64),
53}
54
55fn put_u32_be(buf: &mut Vec<u8>, n: usize) -> Result<(), BinaryFormatError> {
56    if n > u32::MAX as usize {
57        return Err(BinaryFormatError::LengthOutOfRange(n as u64));
58    }
59    let n = n as u32;
60    buf.extend_from_slice(&n.to_be_bytes());
61    Ok(())
62}
63
64fn read_u32_be(buf: &[u8], off: usize) -> Result<u32, BinaryFormatError> {
65    if off + 4 > buf.len() {
66        return Err(BinaryFormatError::Truncated(off));
67    }
68    Ok(u32::from_be_bytes([
69        buf[off],
70        buf[off + 1],
71        buf[off + 2],
72        buf[off + 3],
73    ]))
74}
75
76fn canonicalize_json(v: serde_json::Value) -> serde_json::Value {
77    use serde_json::Value;
78    match v {
79        Value::Object(map) => {
80            let mut entries: Vec<(String, Value)> = map
81                .into_iter()
82                .map(|(k, val)| (k, canonicalize_json(val)))
83                .collect();
84            entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
85            let mut out = serde_json::Map::with_capacity(entries.len());
86            for (k, val) in entries {
87                out.insert(k, val);
88            }
89            Value::Object(out)
90        }
91        Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize_json).collect()),
92        other => other,
93    }
94}
95
96fn cbor_encode<T: Serialize>(v: &T) -> Result<Vec<u8>, BinaryFormatError> {
97    // RFC 8949 §4.2.3 deterministic encoding. We canonicalize through a
98    // `serde_json::Value` intermediate then explicitly sort all object
99    // keys lexicographically — relying on `serde_json::Map`'s default
100    // BTreeMap backing isn't safe because any workspace dep may pull in
101    // `serde_json` with the `preserve_order` feature, which silently
102    // switches the backing map to `IndexMap` and breaks parity.
103    let json_value: serde_json::Value =
104        serde_json::to_value(v).map_err(|e| BinaryFormatError::Cbor(e.to_string()))?;
105    let canonical = canonicalize_json(json_value);
106    let value = cbor::from_json(&canonical).map_err(|e| BinaryFormatError::Cbor(e.to_string()))?;
107    cbor::encode(&value).map_err(|e| BinaryFormatError::Cbor(e.to_string()))
108}
109
110fn cbor_decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, BinaryFormatError> {
111    let value = cbor::decode(bytes).map_err(|e| BinaryFormatError::Cbor(e.to_string()))?;
112    let json = cbor::to_json(&value).map_err(|e| BinaryFormatError::Cbor(e.to_string()))?;
113    serde_json::from_value(json).map_err(|e| BinaryFormatError::Cbor(e.to_string()))
114}
115
116/* -------------------------------------------------------------------------- */
117/*  .tfbundle                                                                  */
118/* -------------------------------------------------------------------------- */
119
120pub fn write_tfbundle<T: Serialize>(
121    body: &T,
122    signature: Option<&[u8]>,
123) -> Result<Vec<u8>, BinaryFormatError> {
124    let body_bytes = cbor_encode(body)?;
125    let mut out = Vec::with_capacity(TFBUNDLE_MAGIC.len() + 4 + body_bytes.len() + 4);
126    out.extend_from_slice(&TFBUNDLE_MAGIC);
127    put_u32_be(&mut out, body_bytes.len())?;
128    out.extend_from_slice(&body_bytes);
129    let sig = signature.unwrap_or(&[]);
130    put_u32_be(&mut out, sig.len())?;
131    out.extend_from_slice(sig);
132    Ok(out)
133}
134
135#[derive(Debug)]
136pub struct TfbundleParts {
137    /// CBOR-decoded body as a generic Value; callers can deserialize
138    /// into a typed struct via `serde_json::to_value` round-trip if
139    /// they don't want to call `read_tfbundle_typed::<T>` directly.
140    pub body: cbor::Value,
141    pub signature: Vec<u8>,
142    pub body_bytes: Vec<u8>,
143}
144
145pub fn read_tfbundle(buf: &[u8]) -> Result<TfbundleParts, BinaryFormatError> {
146    if buf.len() < TFBUNDLE_MAGIC.len() {
147        return Err(BinaryFormatError::BadMagic);
148    }
149    if buf[..TFBUNDLE_MAGIC.len()] != TFBUNDLE_MAGIC {
150        return Err(BinaryFormatError::BadMagic);
151    }
152    let mut off = TFBUNDLE_MAGIC.len();
153    let body_len = read_u32_be(buf, off)? as usize;
154    off += 4;
155    if off + body_len > buf.len() {
156        return Err(BinaryFormatError::Truncated(off));
157    }
158    let body_bytes = buf[off..off + body_len].to_vec();
159    let body = cbor::decode(&body_bytes).map_err(|e| BinaryFormatError::Cbor(e.to_string()))?;
160    off += body_len;
161    let sig_len = read_u32_be(buf, off)? as usize;
162    off += 4;
163    if off + sig_len > buf.len() {
164        return Err(BinaryFormatError::Truncated(off));
165    }
166    let signature = buf[off..off + sig_len].to_vec();
167    Ok(TfbundleParts {
168        body,
169        signature,
170        body_bytes,
171    })
172}
173
174/// Read a .tfbundle and deserialize the body into a typed `T`.
175pub fn read_tfbundle_typed<T: DeserializeOwned>(
176    buf: &[u8],
177) -> Result<(T, Vec<u8>), BinaryFormatError> {
178    let parts = read_tfbundle(buf)?;
179    let body: T = cbor_decode(&parts.body_bytes)?;
180    Ok((body, parts.signature))
181}
182
183/* -------------------------------------------------------------------------- */
184/*  .tfpkt                                                                     */
185/* -------------------------------------------------------------------------- */
186
187pub fn write_tfpkt(packet: &Packet) -> Result<Vec<u8>, BinaryFormatError> {
188    let body_bytes = cbor_encode(packet)?;
189    let mut out = Vec::with_capacity(TFPKT_MAGIC.len() + 4 + body_bytes.len());
190    out.extend_from_slice(&TFPKT_MAGIC);
191    put_u32_be(&mut out, body_bytes.len())?;
192    out.extend_from_slice(&body_bytes);
193    Ok(out)
194}
195
196pub fn read_tfpkt(buf: &[u8]) -> Result<Packet, BinaryFormatError> {
197    if buf.len() < TFPKT_MAGIC.len() {
198        return Err(BinaryFormatError::BadMagic);
199    }
200    if buf[..TFPKT_MAGIC.len()] != TFPKT_MAGIC {
201        return Err(BinaryFormatError::BadMagic);
202    }
203    let mut off = TFPKT_MAGIC.len();
204    let body_len = read_u32_be(buf, off)? as usize;
205    off += 4;
206    if off + body_len > buf.len() {
207        return Err(BinaryFormatError::Truncated(off));
208    }
209    cbor_decode(&buf[off..off + body_len])
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::json;
216
217    #[test]
218    fn tfbundle_round_trip_unsigned() {
219        let body = json!({
220            "bundle_version": "1",
221            "events": [],
222        });
223        let buf = write_tfbundle(&body, None).expect("write");
224        assert_eq!(buf[..TFBUNDLE_MAGIC.len()], TFBUNDLE_MAGIC);
225        let parts = read_tfbundle(&buf).expect("read");
226        assert_eq!(parts.signature.len(), 0);
227        // Round-trip the CBOR body back through serde_json.
228        let serialised = crate::cbor::encode(&parts.body).unwrap();
229        // Re-decode as a typed Value to assert structure.
230        let decoded = crate::cbor::to_json(&crate::cbor::decode(&serialised).unwrap()).unwrap();
231        assert_eq!(decoded["bundle_version"], "1");
232    }
233
234    #[test]
235    fn tfbundle_round_trip_with_signature() {
236        let body = json!({"bundle_version": "1", "events": []});
237        let signature = vec![0xaa; 64];
238        let buf = write_tfbundle(&body, Some(&signature)).expect("write");
239        let parts = read_tfbundle(&buf).expect("read");
240        assert_eq!(parts.signature, signature);
241    }
242
243    #[test]
244    fn tfbundle_bad_magic_rejected() {
245        let buf = b"NOT-A-BUNDLE\x00\x00\x00\x00";
246        let err = read_tfbundle(buf).unwrap_err();
247        assert!(matches!(err, BinaryFormatError::BadMagic));
248    }
249
250    #[test]
251    fn tfpkt_round_trip_envelope() {
252        // Build a minimal Packet via serde_json so we don't depend on
253        // sign_packet here; the format itself is what's under test.
254        let pkt: Packet = serde_json::from_value(json!({
255            "packet_version": "1",
256            "packet_id": "pkt-roundtrip",
257            "source": "tf:actor:agent:example.com/x",
258            "destination": "tf:actor:service:example.com/d",
259            "priority": "P3",
260            "created_at": "2026-04-24T12:00:00Z",
261            "encoding": "cbor",
262            "compression": "none",
263            "payload": "AAAA",
264            "signature": {
265                "algorithm": "ed25519",
266                "signer": "tf:actor:agent:example.com/x",
267                "signature": "AAAA",
268            },
269        }))
270        .expect("packet");
271        let buf = write_tfpkt(&pkt).expect("write");
272        assert_eq!(buf[..TFPKT_MAGIC.len()], TFPKT_MAGIC);
273        let decoded = read_tfpkt(&buf).expect("read");
274        assert_eq!(decoded.packet_id, pkt.packet_id);
275    }
276
277    #[test]
278    fn tfpkt_truncated_body_rejected() {
279        let pkt: Packet = serde_json::from_value(json!({
280            "packet_version": "1",
281            "packet_id": "pkt-trunc",
282            "source": "tf:actor:agent:example.com/x",
283            "destination": "tf:actor:service:example.com/d",
284            "priority": "P3",
285            "created_at": "2026-04-24T12:00:00Z",
286            "encoding": "cbor",
287            "compression": "none",
288            "payload": "AAAA",
289            "signature": {
290                "algorithm": "ed25519",
291                "signer": "tf:actor:agent:example.com/x",
292                "signature": "AAAA",
293            },
294        }))
295        .expect("packet");
296        let buf = write_tfpkt(&pkt).expect("write");
297        let chopped = &buf[..buf.len() - 5];
298        let err = read_tfpkt(chopped).unwrap_err();
299        assert!(matches!(err, BinaryFormatError::Truncated(_)));
300    }
301}