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}