Skip to main content

nodedb_array/segment/format/
footer.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Segment footer — trailing metadata block.
4//!
5//! Trailer (last 16 bytes of the file):
6//!
7//! ```text
8//! ┌─────────────┬───────────────┬────────┐
9//! │ footer_off  │ footer_length │ magic  │
10//! │   8 byte    │     4 byte    │ 4 byte │
11//! └─────────────┴───────────────┴────────┘
12//! ```
13//!
14//! The footer body sits at `footer_off..footer_off+footer_length` and
15//! is a single framed block (length + payload + CRC). The payload is
16//! a zerompk-encoded [`SegmentFooter`].
17
18use serde::{Deserialize, Serialize};
19
20use super::framing::{BlockFraming, FRAMING_OVERHEAD};
21use super::tile_entry::TileEntry;
22use crate::error::{ArrayError, ArrayResult};
23
24/// `b"NDFT"` — NodeDB Footer Trailer.
25pub const FOOTER_MAGIC: [u8; 4] = *b"NDFT";
26
27pub const TRAILER_SIZE: usize = 16;
28
29/// Decoded segment footer body.
30#[derive(
31    Debug,
32    Clone,
33    PartialEq,
34    Serialize,
35    Deserialize,
36    zerompk::ToMessagePack,
37    zerompk::FromMessagePack,
38)]
39pub struct SegmentFooter {
40    pub schema_hash: u64,
41    pub tiles: Vec<TileEntry>,
42}
43
44impl SegmentFooter {
45    pub fn new(schema_hash: u64, tiles: Vec<TileEntry>) -> Self {
46        Self { schema_hash, tiles }
47    }
48
49    /// Encode footer body + trailer into `out`. Returns `(footer_off,
50    /// footer_length)` so the writer can record them.
51    pub fn encode_to(&self, out: &mut Vec<u8>) -> ArrayResult<(u64, u32)> {
52        let body = zerompk::to_msgpack_vec(self).map_err(|e| ArrayError::SegmentCorruption {
53            detail: format!("footer encode failed: {e}"),
54        })?;
55        let footer_off = out.len() as u64;
56        let framed_len = BlockFraming::encode(&body, out);
57        // Trailer
58        out.extend_from_slice(&footer_off.to_le_bytes());
59        out.extend_from_slice(&(framed_len as u32).to_le_bytes());
60        out.extend_from_slice(&FOOTER_MAGIC);
61        Ok((footer_off, framed_len as u32))
62    }
63
64    /// Decode footer from a complete segment byte slice.
65    pub fn decode(segment: &[u8]) -> ArrayResult<Self> {
66        if segment.len() < TRAILER_SIZE + FRAMING_OVERHEAD {
67            return Err(ArrayError::SegmentCorruption {
68                detail: format!("segment too small for footer: {}", segment.len()),
69            });
70        }
71        let trailer = &segment[segment.len() - TRAILER_SIZE..];
72        if trailer[12..16] != FOOTER_MAGIC {
73            return Err(ArrayError::SegmentCorruption {
74                detail: "footer trailer magic mismatch (not NDFT)".into(),
75            });
76        }
77        let mut u64_buf = [0u8; 8];
78        u64_buf.copy_from_slice(&trailer[..8]);
79        let footer_off = u64::from_le_bytes(u64_buf) as usize;
80        let mut u32_buf = [0u8; 4];
81        u32_buf.copy_from_slice(&trailer[8..12]);
82        let footer_len = u32::from_le_bytes(u32_buf) as usize;
83        if footer_off + footer_len > segment.len() - TRAILER_SIZE {
84            return Err(ArrayError::SegmentCorruption {
85                detail: format!(
86                    "footer offset/length out of bounds: off={footer_off} len={footer_len} \
87                     file_size={}",
88                    segment.len()
89                ),
90            });
91        }
92        let (body, _) = BlockFraming::decode(&segment[footer_off..footer_off + footer_len])?;
93        zerompk::from_msgpack(body).map_err(|e| ArrayError::SegmentCorruption {
94            detail: format!("footer body decode failed: {e}"),
95        })
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::segment::format::tile_entry::TileKind;
103    use crate::tile::mbr::TileMBR;
104    use crate::types::TileId;
105
106    #[test]
107    fn footer_round_trip() {
108        let f = SegmentFooter::new(
109            0xABCDEF,
110            vec![TileEntry::new(
111                TileId::snapshot(7),
112                TileKind::Sparse,
113                32,
114                64,
115                TileMBR::new(0, 0),
116            )],
117        );
118        // Simulate a segment: junk bytes for tile region, then footer.
119        let mut buf = vec![0u8; 32];
120        buf.extend_from_slice(&[0u8; 64]);
121        f.encode_to(&mut buf).unwrap();
122        let d = SegmentFooter::decode(&buf).unwrap();
123        assert_eq!(d, f);
124    }
125
126    #[test]
127    fn footer_rejects_bad_trailer_magic() {
128        let f = SegmentFooter::new(0, vec![]);
129        let mut buf = Vec::new();
130        f.encode_to(&mut buf).unwrap();
131        let last = buf.len() - 1;
132        buf[last] ^= 0xFF;
133        assert!(SegmentFooter::decode(&buf).is_err());
134    }
135
136    /// Asserts `NDFT` magic at trailer bytes [12..16], footer_off points inside
137    /// the buffer, and footer_length is non-zero.
138    #[test]
139    fn golden_array_segment_footer_format() {
140        let f = SegmentFooter::new(0xCAFE_BABE_1234_5678, vec![]);
141        let mut buf = vec![0u8; 32]; // simulate preceding segment data
142        f.encode_to(&mut buf).unwrap();
143
144        let n = buf.len();
145        assert!(n >= TRAILER_SIZE, "buffer too small for trailer");
146        let trailer = &buf[n - TRAILER_SIZE..];
147
148        // Last 4 bytes of trailer must be NDFT.
149        assert_eq!(&trailer[12..16], b"NDFT", "NDFT magic not at EOF trailer");
150
151        // footer_off must point inside the buffer (before the trailer).
152        let mut u64_buf = [0u8; 8];
153        u64_buf.copy_from_slice(&trailer[..8]);
154        let footer_off = u64::from_le_bytes(u64_buf) as usize;
155        assert!(footer_off < n - TRAILER_SIZE, "footer_off out of range");
156
157        // footer_length must be non-zero.
158        let mut u32_buf = [0u8; 4];
159        u32_buf.copy_from_slice(&trailer[8..12]);
160        let footer_len = u32::from_le_bytes(u32_buf) as usize;
161        assert!(footer_len > 0, "footer_length must be > 0");
162
163        // Round-trip validates schema_hash survives encode/decode.
164        let decoded = SegmentFooter::decode(&buf).unwrap();
165        assert_eq!(decoded.schema_hash, 0xCAFE_BABE_1234_5678);
166    }
167}