Skip to main content

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}