Skip to main content

zerodds_inspect_endpoint/
frame.rs

1//! Side-channel frame format.
2//!
3//! Versioned, length-prefixed frames exchanged between the inspect endpoint
4//! and the external Reality Inspector. Layer-agnostic:
5//! a frame carries a DCPS/RTPS/transport sample plus
6//! metadata.
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11/// Which tap layer emitted the sample.
12#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
13pub enum FrameKind {
14    /// DCPS-layer sample (typed after deserialization).
15    Dcps,
16    /// RTPS-layer sample (reader/writer cache, sees all transports).
17    Rtps,
18    /// Transport-layer raw bytes (also sees shared memory).
19    Transport,
20}
21
22/// A side-channel frame.
23///
24/// Frames are auditable (Zero-Principle Pillar 6 zero-context-loss):
25/// `content_hash` is SHA-256 over `(kind, topic, timestamp_ns,
26/// correlation_id, payload)`. Phase-1: the hash field is optional and
27/// defaults to `None`; in Phase-2 the hash is set during server-side
28/// dispatch.
29#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
30pub struct Frame {
31    /// Which layer emitted the frame.
32    pub kind: FrameKind,
33    /// Topic name (or empty when a transport layer without a TypeObject).
34    pub topic: String,
35    /// Wall-clock timestamp in nanoseconds since the UNIX epoch.
36    pub timestamp_ns: u64,
37    /// Sequence number / sample-identity GUID prefix for cross-
38    /// layer correlation (R-077).
39    pub correlation_id: u64,
40    /// Sample payload as bytes (layer-specific encoding).
41    pub payload: Vec<u8>,
42    /// Optional content hash (Zero-Principle Foundation 2). Set
43    /// by the server when audit streams are active. Phase-1
44    /// defaults to `None`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub content_hash: Option<[u8; 32]>,
47}
48
49impl Frame {
50    /// Creates a DCPS frame.
51    #[must_use]
52    pub fn dcps(topic: String, timestamp_ns: u64, correlation_id: u64, payload: Vec<u8>) -> Self {
53        Self {
54            kind: FrameKind::Dcps,
55            topic,
56            timestamp_ns,
57            correlation_id,
58            payload,
59            content_hash: None,
60        }
61    }
62
63    /// Creates an RTPS frame.
64    #[must_use]
65    pub fn rtps(topic: String, timestamp_ns: u64, correlation_id: u64, payload: Vec<u8>) -> Self {
66        Self {
67            kind: FrameKind::Rtps,
68            topic,
69            timestamp_ns,
70            correlation_id,
71            payload,
72            content_hash: None,
73        }
74    }
75
76    /// Creates a transport frame.
77    #[must_use]
78    pub fn transport(timestamp_ns: u64, correlation_id: u64, payload: Vec<u8>) -> Self {
79        Self {
80            kind: FrameKind::Transport,
81            topic: String::new(),
82            timestamp_ns,
83            correlation_id,
84            payload,
85            content_hash: None,
86        }
87    }
88
89    /// Computes the content hash and stores it.
90    ///
91    /// Hash input: kind tag + topic + timestamp\_ns + correlation\_id +
92    /// payload. Idempotent — repeated calls change nothing as long as the
93    /// content stays the same.
94    pub fn with_content_hash(mut self) -> Self {
95        let mut hasher = Sha256::new();
96        let kind_tag: u8 = match self.kind {
97            FrameKind::Dcps => 1,
98            FrameKind::Rtps => 2,
99            FrameKind::Transport => 3,
100        };
101        hasher.update([kind_tag]);
102        hasher.update(self.topic.as_bytes());
103        hasher.update(b"\0");
104        hasher.update(self.timestamp_ns.to_be_bytes());
105        hasher.update(self.correlation_id.to_be_bytes());
106        hasher.update(&self.payload);
107        let h: [u8; 32] = hasher.finalize().into();
108        self.content_hash = Some(h);
109        self
110    }
111
112    /// Verifies that the stored hash matches the one computed from the
113    /// current frame content.
114    #[must_use]
115    pub fn verify_content_hash(&self) -> bool {
116        let Some(stored) = self.content_hash else {
117            return false;
118        };
119        let mut hasher = Sha256::new();
120        let kind_tag: u8 = match self.kind {
121            FrameKind::Dcps => 1,
122            FrameKind::Rtps => 2,
123            FrameKind::Transport => 3,
124        };
125        hasher.update([kind_tag]);
126        hasher.update(self.topic.as_bytes());
127        hasher.update(b"\0");
128        hasher.update(self.timestamp_ns.to_be_bytes());
129        hasher.update(self.correlation_id.to_be_bytes());
130        hasher.update(&self.payload);
131        let computed: [u8; 32] = hasher.finalize().into();
132        stored == computed
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn dcps_frame_has_dcps_kind() {
142        let f = Frame::dcps("speed".into(), 1000, 42, vec![1, 2, 3]);
143        assert_eq!(f.kind, FrameKind::Dcps);
144        assert_eq!(f.topic, "speed");
145        assert_eq!(f.correlation_id, 42);
146    }
147
148    #[test]
149    fn rtps_frame_has_rtps_kind() {
150        let f = Frame::rtps("speed".into(), 0, 0, vec![]);
151        assert_eq!(f.kind, FrameKind::Rtps);
152    }
153
154    #[test]
155    fn transport_frame_has_no_topic() {
156        let f = Frame::transport(0, 0, vec![0xFF]);
157        assert_eq!(f.kind, FrameKind::Transport);
158        assert!(f.topic.is_empty());
159    }
160
161    #[test]
162    fn frame_clone_preserves_fields() {
163        let f = Frame::dcps("topic".into(), 12345, 99, vec![0xDE, 0xAD]);
164        let cloned = f.clone();
165        assert_eq!(f, cloned);
166    }
167
168    #[test]
169    fn frame_kinds_are_distinct() {
170        let a = Frame::dcps(String::new(), 0, 0, vec![]);
171        let b = Frame::rtps(String::new(), 0, 0, vec![]);
172        let c = Frame::transport(0, 0, vec![]);
173        assert_ne!(a.kind, b.kind);
174        assert_ne!(b.kind, c.kind);
175    }
176
177    #[test]
178    fn unhashed_frame_has_no_content_hash() {
179        let f = Frame::dcps("t".into(), 0, 0, vec![]);
180        assert!(f.content_hash.is_none());
181    }
182
183    #[test]
184    fn with_content_hash_sets_hash() {
185        let f = Frame::dcps("t".into(), 100, 1, vec![1, 2]).with_content_hash();
186        assert!(f.content_hash.is_some());
187    }
188
189    #[test]
190    fn content_hash_is_deterministic() {
191        let f1 = Frame::dcps("t".into(), 100, 1, vec![1, 2]).with_content_hash();
192        let f2 = Frame::dcps("t".into(), 100, 1, vec![1, 2]).with_content_hash();
193        assert_eq!(f1.content_hash, f2.content_hash);
194    }
195
196    #[test]
197    fn payload_change_changes_hash() {
198        let f1 = Frame::dcps("t".into(), 100, 1, vec![1, 2]).with_content_hash();
199        let f2 = Frame::dcps("t".into(), 100, 1, vec![1, 3]).with_content_hash();
200        assert_ne!(f1.content_hash, f2.content_hash);
201    }
202
203    #[test]
204    fn topic_change_changes_hash() {
205        let f1 = Frame::dcps("a".into(), 100, 1, vec![1]).with_content_hash();
206        let f2 = Frame::dcps("b".into(), 100, 1, vec![1]).with_content_hash();
207        assert_ne!(f1.content_hash, f2.content_hash);
208    }
209
210    #[test]
211    fn timestamp_change_changes_hash() {
212        let f1 = Frame::dcps("t".into(), 100, 1, vec![1]).with_content_hash();
213        let f2 = Frame::dcps("t".into(), 200, 1, vec![1]).with_content_hash();
214        assert_ne!(f1.content_hash, f2.content_hash);
215    }
216
217    #[test]
218    fn kind_change_changes_hash() {
219        let f1 = Frame::dcps("t".into(), 100, 1, vec![1]).with_content_hash();
220        let f2 = Frame::rtps("t".into(), 100, 1, vec![1]).with_content_hash();
221        assert_ne!(f1.content_hash, f2.content_hash);
222    }
223
224    #[test]
225    fn verify_content_hash_returns_false_when_unhashed() {
226        let f = Frame::dcps("t".into(), 0, 0, vec![]);
227        assert!(!f.verify_content_hash());
228    }
229
230    #[test]
231    fn verify_content_hash_detects_post_hash_mutation() {
232        let mut f = Frame::dcps("t".into(), 100, 1, vec![1, 2]).with_content_hash();
233        assert!(f.verify_content_hash());
234        f.payload.push(3);
235        assert!(!f.verify_content_hash());
236    }
237}