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