Skip to main content

bcp_wire/
header.rs

1use crate::error::WireError;
2
3// Quick note on the magic bytes: 0x42 is B, 0x43 is C, 0x50 is P, 0x00 is null.
4// You can verify this in any ASCII table.
5// We store it as raw bytes rather than a u32
6// so we don't have to think about endianness — it's always these 4 bytes in this order.
7
8/// Magic number: ASCII "BCP\0".
9/// Written as raw bytes, not as a u32, so byte order doesn't matter.
10/// each u8 (unsigned 8bit integer) can be represented as a byte
11/// A single hex digit represents exactly 4 bits (a "nibble").
12/// So a byte (8 bits) is always exactly 2 hex digits
13pub const BCP_MAGIC: [u8; 4] = [0x42, 0x43, 0x50, 0x00];
14
15/// Total header size in bytes (fixed).
16pub const HEADER_SIZE: usize = 8;
17
18/// Current format version major.
19pub const VERSION_MAJOR: u8 = 1;
20
21/// Current format version minor.
22pub const VERSION_MINOR: u8 = 0;
23
24// Now HeaderFlags. This is a newtype pattern — a single-field struct wrapping a primitive.
25// You'll see this a lot in Rust where you want type safety around a raw value:
26
27/// Header flags bitfield.
28///
29/// Bit layout:
30///   bit 0 = compressed (whole-payload zstd compression)
31///   bit 1 = has_index  (index trailer appended after END block)
32///   bits 2-7 = reserved (MUST be 0)
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub struct HeaderFlags(u8);
35
36impl HeaderFlags {
37    /// Whole-payload compression is enabled.
38    pub const COMPRESSED: Self = Self(0b0000_0001);
39
40    /// An index trailer is appended after the END block.
41    pub const HAS_INDEX: Self = Self(0b0000_0010);
42
43    /// No flags set.
44    pub const NONE: Self = Self(0);
45
46    /// Create flags from a raw byte.
47    pub fn from_raw(raw: u8) -> Self {
48        Self(raw)
49    }
50
51    /// Get the underlying byte value.
52    pub fn raw(self) -> u8 {
53        self.0
54    }
55
56    pub fn is_compressed(self) -> bool {
57        self.0 & Self::COMPRESSED.0 != 0
58    }
59
60    pub fn has_index(self) -> bool {
61        self.0 & Self::HAS_INDEX.0 != 0
62    }
63}
64
65// The key thing here: HeaderFlags(u8) is a tuple struct.
66// The self.0 accesses the inner u8.
67// The constants like COMPRESSED are const values of Self,
68// so HeaderFlags::COMPRESSED is HeaderFlags(0b0000_0001).
69// The methods use the same & masking pattern from varint:
70//      self.0 & Self::COMPRESSED.0 != 0 checks if bit 0 is set.
71// This is the & 0x80 pattern you just learned, but checking bit 0 instead of bit 7.
72// Also notice #[derive(Clone, Copy)] —
73// this tells Rust that HeaderFlags can be copied implicitly, like a primitive.
74// Without Copy, passing a HeaderFlags to a function would move it (transferring ownership).
75// Since it's just a u8 wrapper, copying is trivially cheap and we want value semantics.
76
77/// BCP file header — the first 8 bytes of every payload.
78///
79/// ```text
80/// ┌────────┬─────────┬──────────────────────────────────┐
81/// │ Offset │ Size    │ Description                      │
82/// ├────────┼─────────┼──────────────────────────────────┤
83/// │ 0x00   │ 4 bytes │ Magic: "BCP\0" (0x42435000)      │
84/// │ 0x04   │ 1 byte  │ Version major                    │
85/// │ 0x05   │ 1 byte  │ Version minor                    │
86/// │ 0x06   │ 1 byte  │ Flags                            │
87/// │ 0x07   │ 1 byte  │ Reserved (0x00)                  │
88/// └────────┴─────────┴──────────────────────────────────┘
89/// ```
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct BcpHeader {
92    pub version_major: u8,
93    pub version_minor: u8,
94    pub flags: HeaderFlags,
95}
96
97impl BcpHeader {
98    /// Create a new header with the current version and the given flags.
99    pub fn new(flags: HeaderFlags) -> Self {
100        Self {
101            version_major: VERSION_MAJOR,
102            version_minor: VERSION_MINOR,
103            flags,
104        }
105    }
106
107    /// Write the 8-byte header into the provided buffer.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`WireError::UnexpectedEof`] if `buf` is shorter than
112    /// [`HEADER_SIZE`] (8 bytes).
113    pub fn write_to(&self, buf: &mut [u8]) -> Result<(), WireError> {
114        if buf.len() < HEADER_SIZE {
115            return Err(WireError::UnexpectedEof { offset: buf.len() });
116        }
117
118        buf[0..4].copy_from_slice(&BCP_MAGIC);
119        buf[4] = self.version_major;
120        buf[5] = self.version_minor;
121        buf[6] = self.flags.raw();
122        buf[7] = 0x00; // reserved
123
124        Ok(())
125    }
126
127    /// Parse a header from the first 8 bytes of the provided buffer.
128    ///
129    /// # Errors
130    ///
131    /// - [`WireError::UnexpectedEof`] if buffer is too short.
132    /// - [`WireError::InvalidMagic`] if the magic number doesn't match.
133    /// - [`WireError::UnsupportedVersion`] if the major version is unknown.
134    /// - [`WireError::ReservedNonZero`] if the reserved byte is not 0x00.
135    pub fn read_from(buf: &[u8]) -> Result<Self, WireError> {
136        if buf.len() < HEADER_SIZE {
137            return Err(WireError::UnexpectedEof { offset: buf.len() });
138        }
139
140        // Validate magic
141        if buf[0..4] != BCP_MAGIC {
142            let found = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
143            return Err(WireError::InvalidMagic { found });
144        }
145
146        let version_major = buf[4];
147        let version_minor = buf[5];
148
149        // We only support version 1.x
150        if version_major != VERSION_MAJOR {
151            return Err(WireError::UnsupportedVersion {
152                major: version_major,
153                minor: version_minor,
154            });
155        }
156
157        let flags = HeaderFlags::from_raw(buf[6]);
158
159        // Reserved byte must be zero
160        if buf[7] != 0x00 {
161            return Err(WireError::ReservedNonZero {
162                offset: 7,
163                value: buf[7],
164            });
165        }
166
167        Ok(Self {
168            version_major,
169            version_minor,
170            flags,
171        })
172    }
173}
174
175// A few things worth noting:
176// ```rs buf[0..4].copy_from_slice(&BCP_MAGIC)```
177// — this copies the 4 magic bytes into the buffer.
178// copy_from_slice is a slice method that copies from one slice into another.
179// It panics if the lengths don't match,
180// but we already checked buf.len() >= HEADER_SIZE
181// so the subslice buf[0..4] is guaranteed to be 4 bytes.
182// ```rs u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])```
183// — this constructs a u32 from 4 bytes in little-endian order.
184// We only use this for the error message so the developer sees a readable hex value.
185// We don't use it for the comparison itself — comparing byte slices directly (buf[0..4] != BCP_MAGIC) is cleaner
186// and sidesteps endianness entirely.
187// The validation order matters — we check magic first (is this even a BCP file?),
188// then version (is it a version we understand?), then reserved fields.
189// This gives the most useful error message for each failure case.
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn roundtrip_default_header() {
197        let header = BcpHeader::new(HeaderFlags::NONE);
198        let mut buf = [0u8; HEADER_SIZE];
199        header.write_to(&mut buf).unwrap();
200        let parsed = BcpHeader::read_from(&buf).unwrap();
201        assert_eq!(header, parsed);
202    }
203
204    #[test]
205    fn roundtrip_with_flags() {
206        let flags =
207            HeaderFlags::from_raw(HeaderFlags::COMPRESSED.raw() | HeaderFlags::HAS_INDEX.raw());
208        let header = BcpHeader::new(flags);
209        let mut buf = [0u8; HEADER_SIZE];
210        header.write_to(&mut buf).unwrap();
211        let parsed = BcpHeader::read_from(&buf).unwrap();
212        assert!(parsed.flags.is_compressed());
213        assert!(parsed.flags.has_index());
214    }
215
216    #[test]
217    fn magic_bytes_are_correct() {
218        let header = BcpHeader::new(HeaderFlags::NONE);
219        let mut buf = [0u8; HEADER_SIZE];
220        header.write_to(&mut buf).unwrap();
221        assert_eq!(&buf[0..4], b"BCP\0");
222    }
223
224    #[test]
225    fn reject_bad_magic() {
226        let mut buf = [0u8; HEADER_SIZE];
227        buf[0..4].copy_from_slice(b"NOPE");
228        buf[4] = VERSION_MAJOR;
229        let result = BcpHeader::read_from(&buf);
230        assert!(matches!(result, Err(WireError::InvalidMagic { .. })));
231    }
232
233    #[test]
234    fn reject_unsupported_version() {
235        let mut buf = [0u8; HEADER_SIZE];
236        buf[0..4].copy_from_slice(&BCP_MAGIC);
237        buf[4] = 2; // unsupported major version
238        let result = BcpHeader::read_from(&buf);
239        assert!(matches!(
240            result,
241            Err(WireError::UnsupportedVersion { major: 2, .. })
242        ));
243    }
244
245    #[test]
246    fn reject_nonzero_reserved() {
247        let mut buf = [0u8; HEADER_SIZE];
248        buf[0..4].copy_from_slice(&BCP_MAGIC);
249        buf[4] = VERSION_MAJOR;
250        buf[7] = 0xFF; // reserved byte must be 0
251        let result = BcpHeader::read_from(&buf);
252        assert!(matches!(
253            result,
254            Err(WireError::ReservedNonZero {
255                offset: 7,
256                value: 0xFF
257            })
258        ));
259    }
260
261    #[test]
262    fn reject_buffer_too_short() {
263        let buf = [0u8; 4]; // only 4 bytes, need 8
264        let result = BcpHeader::read_from(&buf);
265        assert!(matches!(result, Err(WireError::UnexpectedEof { .. })));
266    }
267
268    #[test]
269    fn flags_default_is_none() {
270        let flags = HeaderFlags::default();
271        assert!(!flags.is_compressed());
272        assert!(!flags.has_index());
273        assert_eq!(flags.raw(), 0);
274    }
275}