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}