ese_core/database.rs
1//! ESE database handle for page-level access.
2
3use std::path::{Path, PathBuf};
4
5use memmap2::Mmap;
6
7use crate::{catalog::CatalogEntry, record::ColumnDef, EseError, EseHeader, EsePage};
8
9/// Iterator over raw record bytes across all leaf pages of a B-tree.
10///
11/// Each item is `(page_number, tag_index, record_bytes)`.
12///
13/// Record bytes are read directly from each tag's `(offset, size)` pair:
14/// `page.data[HEADER_SIZE + offset .. HEADER_SIZE + offset + size]`.
15/// This is correct for SRUM data tables, which store independently-addressed
16/// records rather than key-prefix-compressed B-tree index keys.
17///
18/// Error recovery: if a page cannot be read or its tag array is corrupt,
19/// the error is yielded and the iterator advances to the next page.
20#[derive(Debug)]
21pub struct TableCursor<'db> {
22 db: &'db EseDatabase,
23 leaf_pages: Vec<u32>,
24 page_idx: usize,
25 tag_idx: usize, // starts at 1 (tag 0 is the page header tag)
26}
27
28impl Iterator for TableCursor<'_> {
29 type Item = Result<(u32, usize, Vec<u8>), EseError>;
30
31 fn next(&mut self) -> Option<Self::Item> {
32 loop {
33 let &page_num = self.leaf_pages.get(self.page_idx)?;
34 let page = match self.db.read_page(page_num) {
35 Ok(p) => p,
36 Err(e) => {
37 self.page_idx += 1;
38 self.tag_idx = 1;
39 return Some(Err(e));
40 }
41 };
42 let tags = match page.tags() {
43 Ok(t) => t,
44 Err(e) => {
45 self.page_idx += 1;
46 self.tag_idx = 1;
47 return Some(Err(e));
48 }
49 };
50 if self.tag_idx >= tags.len() {
51 self.page_idx += 1;
52 self.tag_idx = 1;
53 continue;
54 }
55 let tag_idx = self.tag_idx;
56 self.tag_idx += 1;
57
58 let (tag_off, tag_sz) = tags[tag_idx];
59 if tag_sz == 0 {
60 continue; // skip zero-length tags
61 }
62 let start = EsePage::HEADER_SIZE.saturating_add(usize::from(tag_off));
63 let end = start.saturating_add(usize::from(tag_sz));
64 if end > page.data.len() {
65 return Some(Err(EseError::Corrupt {
66 page: page_num,
67 detail: format!(
68 "tag {tag_idx} out of bounds (offset={tag_off}, size={tag_sz}, \
69 page_len={})",
70 page.data.len()
71 ),
72 }));
73 }
74 return Some(Ok((page_num, tag_idx, page.data[start..end].to_vec())));
75 }
76 }
77}
78
79/// An open ESE database file, memory-mapped for zero-copy page access.
80///
81/// The file is mapped once at [`open`][EseDatabase::open] time. All subsequent
82/// [`read_page`][EseDatabase::read_page] and [`raw_page_slice`][EseDatabase::raw_page_slice]
83/// calls slice directly into the mapping — no additional syscalls or heap
84/// allocations per page.
85///
86/// # Safety invariant
87///
88/// The mapping is read-only. Callers must not modify the file on disk while an
89/// `EseDatabase` is live; doing so is undefined behaviour (per `memmap2` docs).
90/// In practice SRUDB.dat is treated as forensic evidence and never written.
91pub struct EseDatabase {
92 path: PathBuf,
93 /// Parsed file header.
94 pub header: EseHeader,
95 mmap: Mmap,
96}
97
98impl std::fmt::Debug for EseDatabase {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct("EseDatabase")
101 .field("path", &self.path)
102 .field("header", &self.header)
103 .field("mmap_len", &self.mmap.len())
104 .finish()
105 }
106}
107
108impl EseDatabase {
109 /// Open an ESE database at `path` and memory-map the entire file.
110 ///
111 /// # Errors
112 ///
113 /// Returns [`EseError`] if the file cannot be opened, mapped, or is not a
114 /// valid ESE database.
115 pub fn open(path: &Path) -> Result<Self, EseError> {
116 let file = std::fs::File::open(path)?;
117 // SAFETY: SRUDB.dat is read-only forensic evidence; we never write to
118 // it while this mapping is live, so the UB precondition cannot trigger.
119 let mmap = unsafe { Mmap::map(&file) }?;
120 let header = EseHeader::from_bytes(&mmap)?;
121 Ok(Self {
122 path: path.to_owned(),
123 header,
124 mmap,
125 })
126 }
127
128 /// Return a zero-copy slice of the raw bytes for page `page_number`.
129 ///
130 /// The slice borrows directly from the memory mapping — no heap allocation.
131 /// Returns [`EseError::Corrupt`] if `page_number` is beyond the end of the
132 /// file.
133 ///
134 /// # Errors
135 ///
136 /// Returns [`EseError`] if the requested page is out of range.
137 pub fn raw_page_slice(&self, page_number: u32) -> Result<&[u8], EseError> {
138 let page_size = self.header.page_size as usize;
139 let start = usize::try_from(page_number)
140 .unwrap_or(usize::MAX)
141 .saturating_mul(page_size);
142 let end = start.saturating_add(page_size);
143 if end > self.mmap.len() {
144 return Err(EseError::Corrupt {
145 page: page_number,
146 detail: format!(
147 "page beyond file end: need offset {end}, file is {} bytes",
148 self.mmap.len()
149 ),
150 });
151 }
152 Ok(&self.mmap[start..end])
153 }
154
155 /// Read a single page by its 0-based page number.
156 ///
157 /// Page 0 is the header page. Data pages start at page 1.
158 /// Returns [`EseError::Corrupt`] if `page_number` is beyond the file.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`EseError`] on I/O error or if the page is out of range.
163 pub fn read_page(&self, page_number: u32) -> Result<EsePage, EseError> {
164 Ok(EsePage {
165 page_number,
166 data: self.raw_page_slice(page_number)?.to_vec(),
167 })
168 }
169
170 /// Return the total number of pages in the file (including the header page).
171 pub fn page_count(&self) -> u64 {
172 u64::try_from(self.mmap.len()).unwrap_or(0) / u64::from(self.header.page_size)
173 }
174
175 /// Read and parse all entries from the ESE catalog (physical page 5).
176 ///
177 /// The catalog (MSysObjects) maps table names to their root B-tree page
178 /// numbers. Physical page 5 is the catalog root in real SRUDB.dat files
179 /// (fdp=2, ROOT|PARENT or ROOT|LEAF).
180 ///
181 /// Each tag on the catalog leaf pages (tags 1+, skipping tag 0) is first
182 /// tried against the real ESE tagged-column format via
183 /// [`CatalogEntry::parse_real_catalog_record`], then against the synthetic
184 /// fixture format via [`CatalogEntry::from_bytes`] as a fallback.
185 ///
186 /// # Errors
187 ///
188 /// Returns [`EseError`] if the catalog page cannot be read or contains
189 /// malformed records.
190 pub fn catalog_entries(&self) -> Result<Vec<CatalogEntry>, EseError> {
191 const CATALOG_ROOT: u32 = 5; // physical page 5 = ESE catalog root (fdp=2)
192 let leaf_pages = self.walk_leaf_pages(CATALOG_ROOT)?;
193 // Last-wins: real SRUDB.dat files contain two catalog entries with the same
194 // GUID name — a placeholder (empty page) registered first and the live data
195 // entry registered second. Walking the B-tree in key order, the second entry
196 // resides on a later leaf page and must overwrite the first so that
197 // find_table_page() returns the correct (non-empty) root page.
198 let mut by_name: std::collections::HashMap<String, CatalogEntry> = Default::default();
199 for page_num in leaf_pages {
200 let page = self.read_page(page_num)?;
201 // First attempt: scan the raw page data area for real ESE catalog records.
202 // Real ESE catalog pages use a cumulative key-prefix-compression layout
203 // where early records live before the first tag offset — scanning the full
204 // data area (header end → tag-array start) finds them all.
205 let real_entries = CatalogEntry::scan_catalog_page_data(page.raw_data_area()?);
206 if !real_entries.is_empty() {
207 for entry in real_entries {
208 by_name.insert(entry.object_name.clone(), entry);
209 }
210 } else {
211 // Fallback for synthetic test-fixture pages that use the simple
212 // fixed-layout format (no \xff\x00 tagged-column encoding).
213 let tags = page.tags()?;
214 for i in 1..tags.len() {
215 let data = page.record_data(i)?;
216 if let Some(entry) = CatalogEntry::parse_real_catalog_record(data) {
217 by_name.insert(entry.object_name.clone(), entry);
218 } else if let Ok(entry) = CatalogEntry::from_bytes(data) {
219 by_name.insert(entry.object_name.clone(), entry);
220 }
221 }
222 }
223 }
224 Ok(by_name.into_values().collect())
225 }
226
227 /// Walk the B-tree rooted at `root_page` and return the page numbers of
228 /// all leaf pages.
229 ///
230 /// - If the page has [`PAGE_FLAG_LEAF`][crate::PAGE_FLAG_LEAF] set, it is
231 /// returned directly.
232 /// - Otherwise each tag (skipping tag 0) is treated as a parent-node
233 /// reference: the child page ESE number is stored in the **last 4 bytes**
234 /// of the tag data (after any B-tree key prefix). The physical page is
235 /// `stored_value + 1` (ESE uses 0-based data-page numbering, offset by
236 /// the file-header page).
237 ///
238 /// # Errors
239 ///
240 /// Returns [`EseError`] if any page cannot be read or parsed.
241 pub fn walk_leaf_pages(&self, root_page: u32) -> Result<Vec<u32>, EseError> {
242 let page = self.read_page(root_page)?;
243 let hdr = page.parse_header()?;
244 if hdr.page_flags & crate::PAGE_FLAG_LEAF != 0 {
245 return Ok(vec![root_page]);
246 }
247 // Parent (or root) page: child page reference is in the LAST 4 bytes
248 // of each tag's data. ESE stores a 0-based data-page number; adding 1
249 // converts it to the physical page number used by read_page().
250 let tag_count = hdr.available_page_tag_count as usize;
251 let mut leaves = Vec::new();
252 for i in 1..tag_count {
253 let data = page.record_data(i)?;
254 // Child page reference is the trailing 4 bytes (after any B-tree key
255 // prefix). Read it through a bounds-checked slice so a record shorter
256 // than 4 bytes is skipped, never a panic — no .unwrap() on an
257 // attacker-controllable length.
258 let Some(child_bytes) = data.get(data.len().saturating_sub(4)..) else {
259 continue; // cov:unreachable: data.len()-4 clamps to 0, slice always valid
260 };
261 if child_bytes.len() < 4 {
262 continue;
263 }
264 let child_ese = u32::from_le_bytes([
265 child_bytes[0],
266 child_bytes[1],
267 child_bytes[2],
268 child_bytes[3],
269 ]);
270 let child_page = child_ese + 1; // ESE 0-based → physical page
271 let mut child_leaves = self.walk_leaf_pages(child_page)?;
272 leaves.append(&mut child_leaves);
273 }
274 Ok(leaves)
275 }
276
277 /// Find the root B-tree page number for the named table.
278 ///
279 /// Reads the catalog and returns the `table_page` of the first entry
280 /// whose `object_name` matches `name`.
281 ///
282 /// # Errors
283 ///
284 /// Returns [`EseError::TableNotFound`] if no matching table is in the catalog,
285 /// or any I/O / parse error from [`catalog_entries`][Self::catalog_entries].
286 pub fn find_table_page(&self, name: &str) -> Result<u32, EseError> {
287 let entries = self.catalog_entries()?;
288 entries
289 .iter()
290 .find(|e| e.object_name == name)
291 .map(|e| e.table_page)
292 .ok_or_else(|| EseError::TableNotFound {
293 name: name.to_owned(),
294 })
295 }
296
297 /// Open a cursor over all leaf records starting at `root_page`.
298 ///
299 /// # Errors
300 ///
301 /// Returns [`EseError`] if the leaf pages cannot be walked from `root_page`.
302 pub fn table_records_from_root(&self, root_page: u32) -> Result<TableCursor<'_>, EseError> {
303 let leaf_pages = self.walk_leaf_pages(root_page)?;
304 Ok(TableCursor {
305 db: self,
306 leaf_pages,
307 page_idx: 0,
308 tag_idx: 1,
309 })
310 }
311
312 /// Return the column definitions for a named table from the catalog.
313 ///
314 /// Reads the catalog, finds the table entry (object_type 1) whose name
315 /// matches `table_name`, then collects all column entries (object_type 2)
316 /// whose `parent_object_id` equals the table's `object_id`. In the
317 /// synthetic catalog format, column entries store the JET coltyp in the
318 /// `table_page` field. Results are sorted ascending by `column_id`.
319 ///
320 /// # Errors
321 ///
322 /// Returns [`EseError::TableNotFound`] if `table_name` is not in the
323 /// catalog, or any I/O / parse error from [`catalog_entries`][Self::catalog_entries].
324 pub fn table_columns(&self, table_name: &str) -> Result<Vec<ColumnDef>, EseError> {
325 let entries = self.catalog_entries()?;
326 let table = entries
327 .iter()
328 .find(|e| e.object_type == 1 && e.object_name == table_name)
329 .ok_or_else(|| EseError::TableNotFound { name: table_name.to_owned() })?;
330 let table_obj_id = table.object_id;
331 let mut cols: Vec<ColumnDef> = entries
332 .iter()
333 .filter(|e| e.object_type == 2 && e.parent_object_id == table_obj_id)
334 .map(|e| ColumnDef {
335 column_id: e.object_id,
336 name: e.object_name.clone(),
337 coltyp: e.table_page as u8,
338 })
339 .collect();
340 cols.sort_by_key(|c| c.column_id);
341 Ok(cols)
342 }
343
344 /// Open a cursor over all records in a named SRUM table.
345 ///
346 /// Returns `Err(EseError::TableNotFound)` immediately if the table is absent.
347 ///
348 /// # Errors
349 ///
350 /// Returns [`EseError::TableNotFound`] if `table_name` is not in the catalog,
351 /// or any I/O / parse error from reading the catalog or walking leaf pages.
352 pub fn table_records(&self, table_name: &str) -> Result<TableCursor<'_>, EseError> {
353 let root_page = self.find_table_page(table_name)?;
354 self.table_records_from_root(root_page)
355 }
356}