Skip to main content

rvf_types/
dashboard.rs

1//! DASHBOARD_SEG (0x11) types for the RVF computational container.
2//!
3//! Defines the 64-byte `DashboardHeader` and associated constants per ADR-040.
4//! The DASHBOARD_SEG embeds a pre-built web dashboard (e.g. Vite + Three.js)
5//! that the RVF HTTP server can serve at `/`.
6
7use crate::error::RvfError;
8
9/// Magic number for `DashboardHeader`: "RVDB" in big-endian.
10pub const DASHBOARD_MAGIC: u32 = 0x5256_4442;
11
12/// Maximum dashboard bundle size (64 MiB).
13pub const DASHBOARD_MAX_SIZE: u64 = 64 * 1024 * 1024;
14
15/// 64-byte header for DASHBOARD_SEG payloads.
16///
17/// Follows the standard 64-byte `SegmentHeader`. All multi-byte fields are
18/// little-endian on the wire.
19///
20/// Payload layout after header:
21/// `[entry_path_bytes | file_table | file_data...]`
22///
23/// File table: array of `(path_len: u16, data_offset: u64, data_size: u64, path_bytes: [u8])`
24/// File data: concatenated raw file contents.
25#[derive(Clone, Copy, Debug)]
26#[repr(C)]
27pub struct DashboardHeader {
28    /// Magic: `DASHBOARD_MAGIC` (0x52564442, "RVDB").
29    pub dashboard_magic: u32,
30    /// DashboardHeader format version (currently 1).
31    pub header_version: u16,
32    /// UI framework: 0=threejs, 1=react, 2=custom.
33    pub ui_framework: u8,
34    /// Compression: 0=none, 1=gzip, 2=brotli.
35    pub compression: u8,
36    /// Total uncompressed bundle size in bytes.
37    pub bundle_size: u64,
38    /// Number of files in the bundle.
39    pub file_count: u32,
40    /// Length of the entry point path string.
41    pub entry_path_len: u16,
42    /// Reserved padding.
43    pub reserved: u16,
44    /// Build timestamp (unix epoch seconds).
45    pub build_timestamp: u64,
46    /// SHAKE-256-256 of the entire bundle payload.
47    pub content_hash: [u8; 32],
48}
49
50// Compile-time assertion: DashboardHeader must be exactly 64 bytes.
51const _: () = assert!(core::mem::size_of::<DashboardHeader>() == 64);
52
53impl DashboardHeader {
54    /// Serialize the header to a 64-byte little-endian array.
55    pub fn to_bytes(&self) -> [u8; 64] {
56        let mut buf = [0u8; 64];
57        buf[0x00..0x04].copy_from_slice(&self.dashboard_magic.to_le_bytes());
58        buf[0x04..0x06].copy_from_slice(&self.header_version.to_le_bytes());
59        buf[0x06] = self.ui_framework;
60        buf[0x07] = self.compression;
61        buf[0x08..0x10].copy_from_slice(&self.bundle_size.to_le_bytes());
62        buf[0x10..0x14].copy_from_slice(&self.file_count.to_le_bytes());
63        buf[0x14..0x16].copy_from_slice(&self.entry_path_len.to_le_bytes());
64        buf[0x16..0x18].copy_from_slice(&self.reserved.to_le_bytes());
65        buf[0x18..0x20].copy_from_slice(&self.build_timestamp.to_le_bytes());
66        buf[0x20..0x40].copy_from_slice(&self.content_hash);
67        buf
68    }
69
70    /// Deserialize a `DashboardHeader` from a 64-byte slice.
71    pub fn from_bytes(data: &[u8; 64]) -> Result<Self, RvfError> {
72        let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
73        if magic != DASHBOARD_MAGIC {
74            return Err(RvfError::BadMagic {
75                expected: DASHBOARD_MAGIC,
76                got: magic,
77            });
78        }
79
80        Ok(Self {
81            dashboard_magic: magic,
82            header_version: u16::from_le_bytes([data[0x04], data[0x05]]),
83            ui_framework: data[0x06],
84            compression: data[0x07],
85            bundle_size: u64::from_le_bytes([
86                data[0x08], data[0x09], data[0x0A], data[0x0B], data[0x0C], data[0x0D], data[0x0E],
87                data[0x0F],
88            ]),
89            file_count: u32::from_le_bytes([data[0x10], data[0x11], data[0x12], data[0x13]]),
90            entry_path_len: u16::from_le_bytes([data[0x14], data[0x15]]),
91            reserved: u16::from_le_bytes([data[0x16], data[0x17]]),
92            build_timestamp: u64::from_le_bytes([
93                data[0x18], data[0x19], data[0x1A], data[0x1B], data[0x1C], data[0x1D], data[0x1E],
94                data[0x1F],
95            ]),
96            content_hash: {
97                let mut h = [0u8; 32];
98                h.copy_from_slice(&data[0x20..0x40]);
99                h
100            },
101        })
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn sample_header() -> DashboardHeader {
110        DashboardHeader {
111            dashboard_magic: DASHBOARD_MAGIC,
112            header_version: 1,
113            ui_framework: 0, // threejs
114            compression: 0,  // none
115            bundle_size: 524288,
116            file_count: 12,
117            entry_path_len: 10,
118            reserved: 0,
119            build_timestamp: 1_700_000_000,
120            content_hash: [0xAB; 32],
121        }
122    }
123
124    #[test]
125    fn header_size_is_64() {
126        assert_eq!(core::mem::size_of::<DashboardHeader>(), 64);
127    }
128
129    #[test]
130    fn magic_bytes_match_ascii() {
131        let bytes_be = DASHBOARD_MAGIC.to_be_bytes();
132        assert_eq!(&bytes_be, b"RVDB");
133    }
134
135    #[test]
136    fn round_trip_serialization() {
137        let original = sample_header();
138        let bytes = original.to_bytes();
139        let decoded = DashboardHeader::from_bytes(&bytes).expect("from_bytes should succeed");
140
141        assert_eq!(decoded.dashboard_magic, DASHBOARD_MAGIC);
142        assert_eq!(decoded.header_version, 1);
143        assert_eq!(decoded.ui_framework, 0);
144        assert_eq!(decoded.compression, 0);
145        assert_eq!(decoded.bundle_size, 524288);
146        assert_eq!(decoded.file_count, 12);
147        assert_eq!(decoded.entry_path_len, 10);
148        assert_eq!(decoded.reserved, 0);
149        assert_eq!(decoded.build_timestamp, 1_700_000_000);
150        assert_eq!(decoded.content_hash, [0xAB; 32]);
151    }
152
153    #[test]
154    fn bad_magic_returns_error() {
155        let mut bytes = sample_header().to_bytes();
156        bytes[0] = 0x00; // corrupt magic
157        let err = DashboardHeader::from_bytes(&bytes).unwrap_err();
158        match err {
159            RvfError::BadMagic { expected, .. } => assert_eq!(expected, DASHBOARD_MAGIC),
160            other => panic!("expected BadMagic, got {other:?}"),
161        }
162    }
163
164    #[test]
165    fn field_offsets() {
166        let h = sample_header();
167        let base = &h as *const _ as usize;
168
169        assert_eq!(&h.dashboard_magic as *const _ as usize - base, 0x00);
170        assert_eq!(&h.header_version as *const _ as usize - base, 0x04);
171        assert_eq!(&h.ui_framework as *const _ as usize - base, 0x06);
172        assert_eq!(&h.compression as *const _ as usize - base, 0x07);
173        assert_eq!(&h.bundle_size as *const _ as usize - base, 0x08);
174        assert_eq!(&h.file_count as *const _ as usize - base, 0x10);
175        assert_eq!(&h.entry_path_len as *const _ as usize - base, 0x14);
176        assert_eq!(&h.reserved as *const _ as usize - base, 0x16);
177        assert_eq!(&h.build_timestamp as *const _ as usize - base, 0x18);
178        assert_eq!(&h.content_hash as *const _ as usize - base, 0x20);
179    }
180
181    #[test]
182    fn large_bundle_size_round_trip() {
183        let mut h = sample_header();
184        h.bundle_size = DASHBOARD_MAX_SIZE;
185        h.file_count = 500;
186        let bytes = h.to_bytes();
187        let decoded = DashboardHeader::from_bytes(&bytes).unwrap();
188        assert_eq!(decoded.bundle_size, DASHBOARD_MAX_SIZE);
189        assert_eq!(decoded.file_count, 500);
190    }
191}