Skip to main content

nodedb_array/segment/format/
header.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Segment header — 32-byte fixed prefix at file offset 0.
4//!
5//! Layout (little-endian):
6//!
7//! ```text
8//! ┌────────┬─────────┬───────┬─────────────┬──────────┐
9//! │ magic  │ version │ flags │ schema_hash │  crc32c  │
10//! │ 8 byte │  2 byte │ 2 byte│   8 byte    │  4 byte  │
11//! └────────┴─────────┴───────┴─────────────┴──────────┘
12//! ```
13//! Total: 20 bytes covered by CRC + 4 byte CRC = 24 bytes.
14//!
15//! `schema_hash` is a fingerprint of the [`crate::schema::ArraySchema`]
16//! the segment was written against; readers reject segments whose hash
17//! doesn't match the live schema (no implicit migrations).
18
19use crate::error::{ArrayError, ArrayResult};
20
21/// `b"NDAS\0\0\0\1"` — NodeDB Array Segment, version slot in last byte.
22pub const HEADER_MAGIC: [u8; 8] = *b"NDAS\0\0\0\x01";
23
24/// On-disk format version. Bump on layout-incompatible changes.
25pub const FORMAT_VERSION: u16 = 1;
26
27pub const HEADER_SIZE: usize = 24;
28
29/// Fixed-size segment header.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct SegmentHeader {
32    pub version: u16,
33    pub flags: u16,
34    pub schema_hash: u64,
35}
36
37impl SegmentHeader {
38    pub fn new(schema_hash: u64) -> Self {
39        Self {
40            version: FORMAT_VERSION,
41            flags: 0,
42            schema_hash,
43        }
44    }
45
46    /// Write the header to `out`, appending CRC32C of the preceding 28
47    /// bytes. Returns the total bytes written (always [`HEADER_SIZE`]).
48    pub fn encode_to(&self, out: &mut Vec<u8>) -> usize {
49        let start = out.len();
50        out.extend_from_slice(&HEADER_MAGIC);
51        out.extend_from_slice(&self.version.to_le_bytes());
52        out.extend_from_slice(&self.flags.to_le_bytes());
53        out.extend_from_slice(&self.schema_hash.to_le_bytes());
54        let crc = crc32c::crc32c(&out[start..start + 20]);
55        out.extend_from_slice(&crc.to_le_bytes());
56        HEADER_SIZE
57    }
58
59    pub fn decode(bytes: &[u8]) -> ArrayResult<Self> {
60        if bytes.len() < HEADER_SIZE {
61            return Err(ArrayError::SegmentCorruption {
62                detail: format!("segment header truncated: {} bytes", bytes.len()),
63            });
64        }
65        if bytes[..8] != HEADER_MAGIC {
66            return Err(ArrayError::SegmentCorruption {
67                detail: "segment header magic mismatch (not NDAS)".into(),
68            });
69        }
70        let mut u32_buf = [0u8; 4];
71        u32_buf.copy_from_slice(&bytes[20..24]);
72        let crc_stored = u32::from_le_bytes(u32_buf);
73        let crc_calc = crc32c::crc32c(&bytes[..20]);
74        if crc_stored != crc_calc {
75            return Err(ArrayError::SegmentCorruption {
76                detail: format!(
77                    "segment header CRC mismatch: stored={crc_stored:08x} \
78                     calc={crc_calc:08x}"
79                ),
80            });
81        }
82        let mut u16_buf = [0u8; 2];
83        u16_buf.copy_from_slice(&bytes[8..10]);
84        let version = u16::from_le_bytes(u16_buf);
85        u16_buf.copy_from_slice(&bytes[10..12]);
86        let flags = u16::from_le_bytes(u16_buf);
87        let mut u64_buf = [0u8; 8];
88        u64_buf.copy_from_slice(&bytes[12..20]);
89        let schema_hash = u64::from_le_bytes(u64_buf);
90        if version != FORMAT_VERSION {
91            return Err(ArrayError::UnsupportedSegmentFormat { version });
92        }
93        Ok(Self {
94            version,
95            flags,
96            schema_hash,
97        })
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn header_round_trip() {
107        let h = SegmentHeader::new(0xDEAD_BEEF_CAFE_BABE);
108        let mut buf = Vec::new();
109        let n = h.encode_to(&mut buf);
110        assert_eq!(n, HEADER_SIZE);
111        assert_eq!(buf.len(), HEADER_SIZE);
112        let d = SegmentHeader::decode(&buf).unwrap();
113        assert_eq!(d, h);
114    }
115
116    #[test]
117    fn header_rejects_bad_magic() {
118        let mut buf = vec![0u8; HEADER_SIZE];
119        buf[0] = b'X';
120        assert!(SegmentHeader::decode(&buf).is_err());
121    }
122
123    #[test]
124    fn header_rejects_bad_crc() {
125        let h = SegmentHeader::new(42);
126        let mut buf = Vec::new();
127        h.encode_to(&mut buf);
128        buf[20] ^= 0xFF;
129        assert!(SegmentHeader::decode(&buf).is_err());
130    }
131
132    #[test]
133    fn header_rejects_truncated() {
134        assert!(SegmentHeader::decode(&[0u8; 10]).is_err());
135    }
136
137    #[test]
138    fn header_rejects_v2_segment() {
139        let mut buf = Vec::new();
140        buf.extend_from_slice(&HEADER_MAGIC);
141        buf.extend_from_slice(&2u16.to_le_bytes());
142        buf.extend_from_slice(&0u16.to_le_bytes());
143        buf.extend_from_slice(&0u64.to_le_bytes());
144        let crc = crc32c::crc32c(&buf[..20]);
145        buf.extend_from_slice(&crc.to_le_bytes());
146        let err = SegmentHeader::decode(&buf).unwrap_err();
147        assert!(
148            matches!(
149                err,
150                crate::error::ArrayError::UnsupportedSegmentFormat { version: 2 }
151            ),
152            "expected UnsupportedSegmentFormat {{version: 2}}, got {err:?}"
153        );
154    }
155
156    /// Asserts 8-byte magic, FORMAT_VERSION == 1 at bytes [8..10], and CRC at [20..24].
157    #[test]
158    fn golden_array_segment_header_format() {
159        let h = SegmentHeader::new(0xDEAD_BEEF_CAFE_BABE);
160        let mut buf = Vec::new();
161        h.encode_to(&mut buf);
162
163        assert_eq!(&buf[0..8], b"NDAS\0\0\0\x01", "magic mismatch");
164
165        let version = u16::from_le_bytes([buf[8], buf[9]]);
166        assert_eq!(version, FORMAT_VERSION, "version mismatch");
167        assert_eq!(version, 1u16, "expected FORMAT_VERSION == 1");
168
169        let stored_crc = u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
170        let expected_crc = crc32c::crc32c(&buf[..20]);
171        assert_eq!(stored_crc, expected_crc, "header CRC mismatch");
172
173        assert_eq!(buf.len(), HEADER_SIZE);
174    }
175}