Skip to main content

sherlock_nsf_parser/
info2.rs

1//! Database information extension block 2 (`nsfdb_database_information2_t`).
2//!
3//! Lives at file offset **520** in every modern-ODS NSF. Layout of the
4//! preceding regions (per `libnsfdb_io_handle_read_database_header`):
5//!
6//! ```text
7//! offset  width  region
8//!     0      6   file_header (LSIG + db_header_size)
9//!     6    174   nsfdb_database_information_t (DBINFO core - 174 bytes
10//!                on disk; the first 128 bytes are the "info buffer"
11//!                that [`crate::header::DbHeader`] mirrors)
12//!   180     20   nsfdb_database_replication_information_t
13//!                (replication_identifier + flags + cutoff interval/time)
14//!   200    320   nsfdb_database_header_t (a 320-byte composite:
15//!                128 bytes mirror of info-buffer + 128 bytes
16//!                special_note_identifiers + 64 bytes unknown1 padding)
17//!   520    124   THIS BLOCK (nsfdb_database_information2_t)
18//!   644     44   nsfdb_database_information3_t (all unknown)
19//!   688    336   nsfdb_database_information4_t (mostly unknown)
20//!  1024    ...   payload (RRV buckets, summary buckets, superblocks, ...)
21//! ```
22//!
23//! The `database_header_size` field at file offset 2 always reads 1024 in
24//! the modern ODS - libnsfdb asserts this. The total fixed-header region
25//! is 1024 bytes; payload starts at offset 1024.
26//!
27//! This block is the entry point for the **superblock + BDT walk**. It
28//! holds:
29//!
30//! - 4 superblock (position, size) pairs. Domino writes superblocks
31//!   round-robin across the 4 slots; an instantiated database typically
32//!   has 3 populated and 1 empty (the next slot to be written). The
33//!   freshest by `modification_time` is authoritative; the others are
34//!   write-ahead-log redundancy. See [`crate::superblock`].
35//! - 2 bucket-descriptor-block (BDB1, BDB2) position/size pairs.
36//! - Bucket-granularity + fill-factor + size-bound knobs (largely
37//!   diagnostic; not load-bearing for parsing but exposed for the viewer's
38//!   diagnostic card).
39//!
40//! All position fields are in 256-byte units (multiply by 256 to get the
41//! byte offset). Per `libnsfdb_io_handle.c` line ~2318:
42//! `*superblock_offset <<= 8;`.
43//!
44//! Layout per `libnsfdb/nsfdb_database_header.h::nsfdb_database_information2_t`:
45//!
46//! ```text
47//! offset  width  field
48//!     0      8   last_fixup_time (TIMEDATE)
49//!     8      4   database_quota_limit
50//!    12      4   database_quota_warn_threshold
51//!    16      8   unknown_time1 (TIMEDATE)
52//!    24      8   unknown_time2 (TIMEDATE)
53//!    32      8   object_store_replica_identifier (TIMEDATE-shaped opaque)
54//!    40      4   superblock1_position (256-byte units)
55//!    44      4   superblock1_size
56//!    48      4   superblock2_position
57//!    52      4   superblock2_size
58//!    56      4   superblock3_position
59//!    60      4   superblock3_size
60//!    64      4   superblock4_position
61//!    68      4   superblock4_size
62//!    72      4   maximum_extension_granularity
63//!    76      2   summary_bucket_granularity
64//!    78      2   non_summary_bucket_granularity
65//!    80      4   minimum_summary_bucket_size
66//!    84      4   minimum_non_summary_bucket_size
67//!    88      4   maximum_summary_bucket_size
68//!    92      4   maximum_non_summary_bucket_size
69//!    96      2   non_summary_append_size
70//!    98      2   non_summary_append_factor
71//!   100      2   summary_bucket_fill_factor
72//!   102      2   non_summary_bucket_fill_factor
73//!   104      4   bucket_descriptor_block1_size
74//!   108      4   bucket_descriptor_block1_position (256-byte units)
75//!   112      4   bucket_descriptor_block2_size
76//!   116      4   bucket_descriptor_block2_position (256-byte units)
77//!   120      4   unknown2
78//! ```
79
80use crate::error::NsfError;
81use crate::time::Timedate;
82
83/// File offset where `Information2` begins. Computed as 6 (file_header)
84/// + 174 (nsfdb_database_information_t) + 20 (replication_information)
85/// + 320 (nsfdb_database_header_t) = 520.
86///
87/// Verified empirically against the 4-file real-nsf corpus
88/// (XPagesExt.nsf, ToDo.nsf, fakenames.nsf, fakenames-views.nsf) -
89/// every file produces sane in-bounds superblock + BDB positions at this
90/// offset, whereas the naive "after the 320-byte database_header at
91/// offset 6" offset of 326 lands on uninitialized padding bytes.
92pub const INFO2_FILE_OFFSET: usize = 520;
93/// On-disk size of the `Information2` block in bytes.
94pub const INFO2_BYTES: usize = 124;
95
96/// One of the four superblock copies.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct SuperblockSlot {
99    /// Position in 256-byte units. Multiply by 256 to get the byte offset.
100    pub position_pages: u32,
101    /// Size in bytes of the superblock body at that position.
102    pub size_bytes: u32,
103}
104
105impl SuperblockSlot {
106    /// True when this slot is not usable as a superblock pointer.
107    ///
108    /// Either field being zero disqualifies the slot: position=0 points
109    /// at the file header (`1A 00` LSIG, not a superblock), and size=0
110    /// means there is nothing to read. Empirically (comparedbs.ntf and
111    /// other fresh / never-instantiated HCL templates) Domino writes
112    /// `position=0` with a nonzero size on uninitialized slots, so the
113    /// stricter both-must-be-nonzero rule is required to filter them out.
114    pub fn is_empty(&self) -> bool {
115        self.position_pages == 0 || self.size_bytes == 0
116    }
117
118    /// Byte offset of this superblock body. Returns `None` for an empty
119    /// slot so consumers do not chase zero offsets.
120    pub fn byte_offset(&self) -> Option<u64> {
121        if self.is_empty() {
122            None
123        } else {
124            Some(u64::from(self.position_pages) * 256)
125        }
126    }
127}
128
129/// One of the two bucket-descriptor-block copies.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct BdbSlot {
132    /// Size in bytes of the BDB at this slot.
133    pub size_bytes: u32,
134    /// Position in 256-byte units.
135    pub position_pages: u32,
136}
137
138impl BdbSlot {
139    /// True when this slot is not usable as a BDB pointer. Same
140    /// either-zero semantics as [`SuperblockSlot::is_empty`].
141    pub fn is_empty(&self) -> bool {
142        self.position_pages == 0 || self.size_bytes == 0
143    }
144
145    /// Byte offset of this BDB.
146    pub fn byte_offset(&self) -> Option<u64> {
147        if self.is_empty() {
148            None
149        } else {
150            Some(u64::from(self.position_pages) * 256)
151        }
152    }
153}
154
155/// Parsed `nsfdb_database_information2_t`.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct Information2 {
158    /// Most recent fix-up (compact/recovery) timestamp.
159    pub last_fixup_time: Timedate,
160    /// Per-database quota limit. Operator-defined; zero means unlimited.
161    pub database_quota_limit: u32,
162    /// Per-database quota warn threshold.
163    pub database_quota_warn_threshold: u32,
164    /// First of two undocumented TIMEDATEs in this block.
165    pub unknown_time1: Timedate,
166    /// Second of two undocumented TIMEDATEs in this block.
167    pub unknown_time2: Timedate,
168    /// Object store replica identifier (TIMEDATE-shaped opaque).
169    pub object_store_replica_identifier: Timedate,
170    /// Four superblock copies. Pick the freshest via the
171    /// [`crate::superblock`] helpers.
172    pub superblocks: [SuperblockSlot; 4],
173    /// Maximum allowed extension granularity for file growth.
174    pub maximum_extension_granularity: u32,
175    /// Allocation granularity for summary buckets.
176    pub summary_bucket_granularity: u16,
177    /// Allocation granularity for non-summary buckets.
178    pub non_summary_bucket_granularity: u16,
179    /// Lower bound on summary-bucket size.
180    pub minimum_summary_bucket_size: u32,
181    /// Lower bound on non-summary-bucket size.
182    pub minimum_non_summary_bucket_size: u32,
183    /// Upper bound on summary-bucket size.
184    pub maximum_summary_bucket_size: u32,
185    /// Upper bound on non-summary-bucket size.
186    pub maximum_non_summary_bucket_size: u32,
187    /// Size in bytes that non-summary appends grow by.
188    pub non_summary_append_size: u16,
189    /// Append factor (growth multiplier for non-summary regions).
190    pub non_summary_append_factor: u16,
191    /// Fill factor target for summary buckets.
192    pub summary_bucket_fill_factor: u16,
193    /// Fill factor target for non-summary buckets.
194    pub non_summary_bucket_fill_factor: u16,
195    /// Two BDB copies (primary + write-ahead redundancy).
196    pub bdbs: [BdbSlot; 2],
197}
198
199impl Information2 {
200    /// Parse `Information2` from the 124 bytes starting at
201    /// [`INFO2_FILE_OFFSET`] within a full-file buffer. The caller is
202    /// responsible for slicing the right window; this method only checks
203    /// the slice length.
204    pub fn parse(bytes: &[u8]) -> Result<Self, NsfError> {
205        if bytes.len() < INFO2_BYTES {
206            return Err(NsfError::TooShort {
207                actual: bytes.len(),
208                required: INFO2_BYTES,
209            });
210        }
211
212        let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]);
213        let u32_at = |o: usize| {
214            u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]])
215        };
216
217        let last_fixup_time = Timedate::from_bytes(&bytes[0..8])?;
218        let database_quota_limit = u32_at(8);
219        let database_quota_warn_threshold = u32_at(12);
220        let unknown_time1 = Timedate::from_bytes(&bytes[16..24])?;
221        let unknown_time2 = Timedate::from_bytes(&bytes[24..32])?;
222        let object_store_replica_identifier = Timedate::from_bytes(&bytes[32..40])?;
223
224        let superblocks = [
225            SuperblockSlot {
226                position_pages: u32_at(40),
227                size_bytes: u32_at(44),
228            },
229            SuperblockSlot {
230                position_pages: u32_at(48),
231                size_bytes: u32_at(52),
232            },
233            SuperblockSlot {
234                position_pages: u32_at(56),
235                size_bytes: u32_at(60),
236            },
237            SuperblockSlot {
238                position_pages: u32_at(64),
239                size_bytes: u32_at(68),
240            },
241        ];
242
243        let maximum_extension_granularity = u32_at(72);
244        let summary_bucket_granularity = u16_at(76);
245        let non_summary_bucket_granularity = u16_at(78);
246        let minimum_summary_bucket_size = u32_at(80);
247        let minimum_non_summary_bucket_size = u32_at(84);
248        let maximum_summary_bucket_size = u32_at(88);
249        let maximum_non_summary_bucket_size = u32_at(92);
250        let non_summary_append_size = u16_at(96);
251        let non_summary_append_factor = u16_at(98);
252        let summary_bucket_fill_factor = u16_at(100);
253        let non_summary_bucket_fill_factor = u16_at(102);
254
255        let bdbs = [
256            BdbSlot {
257                size_bytes: u32_at(104),
258                position_pages: u32_at(108),
259            },
260            BdbSlot {
261                size_bytes: u32_at(112),
262                position_pages: u32_at(116),
263            },
264        ];
265
266        Ok(Self {
267            last_fixup_time,
268            database_quota_limit,
269            database_quota_warn_threshold,
270            unknown_time1,
271            unknown_time2,
272            object_store_replica_identifier,
273            superblocks,
274            maximum_extension_granularity,
275            summary_bucket_granularity,
276            non_summary_bucket_granularity,
277            minimum_summary_bucket_size,
278            minimum_non_summary_bucket_size,
279            maximum_summary_bucket_size,
280            maximum_non_summary_bucket_size,
281            non_summary_append_size,
282            non_summary_append_factor,
283            summary_bucket_fill_factor,
284            non_summary_bucket_fill_factor,
285            bdbs,
286        })
287    }
288
289    /// Slot indices 0..=3 of populated superblocks (any with non-zero
290    /// position or size).
291    pub fn populated_superblock_indices(&self) -> Vec<usize> {
292        self.superblocks
293            .iter()
294            .enumerate()
295            .filter_map(|(i, s)| if s.is_empty() { None } else { Some(i) })
296            .collect()
297    }
298
299    /// Slot indices 0..=1 of populated BDBs.
300    pub fn populated_bdb_indices(&self) -> Vec<usize> {
301        self.bdbs
302            .iter()
303            .enumerate()
304            .filter_map(|(i, s)| if s.is_empty() { None } else { Some(i) })
305            .collect()
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    /// Build a synthetic 124-byte Information2 with one populated
314    /// superblock (slot 0) and one populated BDB (slot 0). Useful for
315    /// round-trip unit tests; integration tests against the corpus
316    /// validate real-file parsing.
317    fn synthetic(superblock0_pages: u32, superblock0_size: u32, bdb0_pages: u32) -> Vec<u8> {
318        let mut buf = vec![0u8; INFO2_BYTES];
319        // Superblock 1 (slot 0): position + size.
320        buf[40..44].copy_from_slice(&superblock0_pages.to_le_bytes());
321        buf[44..48].copy_from_slice(&superblock0_size.to_le_bytes());
322        // BDB 1 (slot 0): size + position. NOTE field order is (size,
323        // position) per the struct, not (position, size).
324        buf[104..108].copy_from_slice(&512u32.to_le_bytes());
325        buf[108..112].copy_from_slice(&bdb0_pages.to_le_bytes());
326        // Plausible granularity values.
327        buf[76..78].copy_from_slice(&8u16.to_le_bytes());
328        buf[78..80].copy_from_slice(&8u16.to_le_bytes());
329        buf
330    }
331
332    #[test]
333    fn parses_synthetic_with_one_populated_superblock_and_bdb() {
334        let buf = synthetic(0x100, 1024, 0x200);
335        let info = Information2::parse(&buf).unwrap();
336
337        assert_eq!(info.superblocks[0].position_pages, 0x100);
338        assert_eq!(info.superblocks[0].size_bytes, 1024);
339        assert_eq!(info.superblocks[0].byte_offset(), Some(0x100 * 256));
340
341        assert!(info.superblocks[1].is_empty());
342        assert!(info.superblocks[2].is_empty());
343        assert!(info.superblocks[3].is_empty());
344
345        assert_eq!(info.bdbs[0].position_pages, 0x200);
346        assert_eq!(info.bdbs[0].size_bytes, 512);
347        assert_eq!(info.bdbs[0].byte_offset(), Some(0x200 * 256));
348        assert!(info.bdbs[1].is_empty());
349
350        assert_eq!(info.populated_superblock_indices(), vec![0]);
351        assert_eq!(info.populated_bdb_indices(), vec![0]);
352    }
353
354    #[test]
355    fn rejects_short_buffer() {
356        let buf = vec![0u8; INFO2_BYTES - 1];
357        let err = Information2::parse(&buf).unwrap_err();
358        assert!(matches!(err, NsfError::TooShort { .. }));
359    }
360
361    #[test]
362    fn all_empty_returns_no_populated_slots() {
363        let buf = vec![0u8; INFO2_BYTES];
364        let info = Information2::parse(&buf).unwrap();
365        assert!(info.populated_superblock_indices().is_empty());
366        assert!(info.populated_bdb_indices().is_empty());
367    }
368
369    #[test]
370    fn superblock_byte_offset_scales_by_256() {
371        let slot = SuperblockSlot {
372            position_pages: 0x2AF0,
373            size_bytes: 256,
374        };
375        assert_eq!(slot.byte_offset(), Some(0x2AF0 * 256));
376    }
377
378    #[test]
379    fn empty_superblock_returns_none_offset() {
380        let slot = SuperblockSlot {
381            position_pages: 0,
382            size_bytes: 0,
383        };
384        assert_eq!(slot.byte_offset(), None);
385    }
386
387    #[test]
388    fn four_distinct_superblocks_all_parsed() {
389        let mut buf = vec![0u8; INFO2_BYTES];
390        for i in 0..4 {
391            let offset = 40 + i * 8;
392            let pos = (0x100 * (i as u32 + 1)).to_le_bytes();
393            let size = (1024u32).to_le_bytes();
394            buf[offset..offset + 4].copy_from_slice(&pos);
395            buf[offset + 4..offset + 8].copy_from_slice(&size);
396        }
397        let info = Information2::parse(&buf).unwrap();
398        for i in 0..4 {
399            assert_eq!(info.superblocks[i].position_pages, 0x100 * (i as u32 + 1));
400            assert_eq!(info.superblocks[i].size_bytes, 1024);
401        }
402        assert_eq!(info.populated_superblock_indices(), vec![0, 1, 2, 3]);
403    }
404}