Skip to main content

ese_core/
catalog.rs

1//! ESE catalog reader.
2//!
3//! The catalog lives at page 4 and maps table names to their root B-tree
4//! pages. This implementation uses a synthetic record format suitable for
5//! test fixtures.
6//!
7//! Synthetic catalog record layout (all little-endian):
8//!
9//! - bytes `0..2`: `object_type` (u16): 1 = table, 2 = column, etc.
10//! - bytes `2..6`: `object_id` (u32)
11//! - bytes `6..10`: `parent_object_id` (u32)
12//! - bytes `10..14`: `table_page` (u32) — root page of this table's B-tree
13//! - bytes `14..16`: `name_len` (u16)
14//! - bytes `16..`: name bytes (UTF-8)
15
16use crate::EseError;
17
18/// Read a little-endian `u32` from `data` at `off`, bounds-checked.
19///
20/// Returns 0 when the 4-byte window falls outside `data` — never panics. ESE
21/// records come from untrusted, attacker-controllable images, so every offset
22/// read must degrade gracefully rather than index out of bounds.
23fn le_u32_at(data: &[u8], off: usize) -> u32 {
24    let mut b = [0u8; 4];
25    if let Some(s) = data.get(off..off + 4) {
26        b.copy_from_slice(s);
27    }
28    u32::from_le_bytes(b)
29}
30
31/// One entry from the ESE catalog.
32#[derive(Debug, Clone)]
33pub struct CatalogEntry {
34    /// Object type: 1 = table, 2 = column, etc.
35    pub object_type: u16,
36    /// Object ID.
37    pub object_id: u32,
38    /// Parent object ID.
39    pub parent_object_id: u32,
40    /// Root page number for this table's B-tree.
41    pub table_page: u32,
42    /// Object name (table name, column name, etc.).
43    pub object_name: String,
44}
45
46impl CatalogEntry {
47    /// Minimum record size.
48    pub const MIN_SIZE: usize = 16;
49
50    /// Parse one catalog record from a byte slice.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`EseError::Corrupt`] if the slice is too short or
55    /// the name bytes are not valid UTF-8.
56    pub fn from_bytes(data: &[u8]) -> Result<Self, EseError> {
57        if data.len() < Self::MIN_SIZE {
58            return Err(EseError::Corrupt {
59                page: 0,
60                detail: format!(
61                    "catalog record too short: {} < {}",
62                    data.len(),
63                    Self::MIN_SIZE
64                ),
65            });
66        }
67        let object_type = u16::from_le_bytes([data[0], data[1]]);
68        let object_id = u32::from_le_bytes([data[2], data[3], data[4], data[5]]);
69        let parent_object_id = u32::from_le_bytes([data[6], data[7], data[8], data[9]]);
70        let table_page = u32::from_le_bytes([data[10], data[11], data[12], data[13]]);
71        let name_len = u16::from_le_bytes([data[14], data[15]]) as usize;
72        if data.len() < Self::MIN_SIZE + name_len {
73            return Err(EseError::Corrupt {
74                page: 0,
75                detail: format!(
76                    "catalog record name truncated: need {}, got {}",
77                    Self::MIN_SIZE + name_len,
78                    data.len()
79                ),
80            });
81        }
82        let name_bytes = &data[16..16 + name_len];
83        let object_name = std::str::from_utf8(name_bytes)
84            .map_err(|e| EseError::Corrupt {
85                page: 0,
86                detail: format!("catalog name not UTF-8: {e}"),
87            })?
88            .to_owned();
89        Ok(Self {
90            object_type,
91            object_id,
92            parent_object_id,
93            table_page,
94            object_name,
95        })
96    }
97
98    /// Serialize this entry to bytes (for building test fixtures).
99    pub fn to_bytes(&self) -> Vec<u8> {
100        let name_bytes = self.object_name.as_bytes();
101        let mut out = Vec::with_capacity(Self::MIN_SIZE + name_bytes.len());
102        out.extend_from_slice(&self.object_type.to_le_bytes());
103        out.extend_from_slice(&self.object_id.to_le_bytes());
104        out.extend_from_slice(&self.parent_object_id.to_le_bytes());
105        out.extend_from_slice(&self.table_page.to_le_bytes());
106        out.extend_from_slice(&(u16::try_from(name_bytes.len()).unwrap_or(u16::MAX)).to_le_bytes());
107        out.extend_from_slice(name_bytes);
108        out
109    }
110
111    /// Scan the raw data area of an ESE catalog leaf page for all TABLE entries.
112    ///
113    /// Unlike [`parse_real_catalog_record`], which scans a single tag's bytes
114    /// and returns the first match, this function scans the entire page data
115    /// area (from the end of the 40-byte header to the start of the tag array)
116    /// and returns every distinct entry found.
117    ///
118    /// Real ESE catalog leaf pages use a cumulative key-prefix-compression
119    /// format where the first logical records can reside in the page data area
120    /// before the offset of the first tag.  Scanning individual tags therefore
121    /// misses those early records.  This function avoids that problem by
122    /// scanning the full data span directly.
123    ///
124    /// Entries are deduplicated by `object_name` — if the same name appears
125    /// more than once (because the cumulative format causes successive tags to
126    /// re-include earlier data), only the first occurrence is kept.
127    pub fn scan_catalog_page_data(data_area: &[u8]) -> Vec<Self> {
128        const MIN_I: usize = 20; // need ≥20 bytes before \xff for obj_id + pgnoFDP
129        const MAX_NAME: usize = 64;
130        let len = data_area.len();
131        let mut entries: Vec<Self> = Vec::new();
132        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
133        let mut i = MIN_I;
134        while i + 4 <= len {
135            if data_area[i] != 0xff || data_area[i + 1] != 0x00 {
136                i += 1;
137                continue;
138            }
139            let name_len = u16::from_le_bytes([data_area[i + 2], data_area[i + 3]]) as usize;
140            if name_len == 0 || name_len > MAX_NAME || i + 4 + name_len > len {
141                i += 1;
142                continue;
143            }
144            let name_bytes = &data_area[i + 4..i + 4 + name_len];
145            if !name_bytes.is_ascii() {
146                i += 1;
147                continue;
148            }
149            let Ok(name) = std::str::from_utf8(name_bytes) else {
150                i += 1;
151                continue;
152            };
153            if name.is_empty() || seen.contains(name) {
154                i += 1;
155                continue;
156            }
157            // i >= 20 here, so i-16 and i-20 are both in-bounds. Read each u32
158            // through a bounds-checked slice so a future change to the loop guard
159            // can never turn these into a panic on attacker-controlled bytes.
160            let pgnofdf_raw = le_u32_at(data_area, i - 16);
161            let object_id = le_u32_at(data_area, i - 20);
162            seen.insert(name);
163            entries.push(Self {
164                object_type: 1,
165                object_id,
166                parent_object_id: 1,
167                table_page: pgnofdf_raw + 1,
168                object_name: name.to_owned(),
169            });
170            i += 4 + name_len;
171        }
172        entries
173    }
174
175    /// Try to parse a real ESE catalog TABLE entry from a leaf-page tag byte slice.
176    ///
177    /// Real ESE MSysObjects records use a tagged-column encoding where the `Name`
178    /// column (column 128) is preceded by a two-byte marker `[0xFF, 0x00]` followed
179    /// by a two-byte LE length and the ASCII name bytes.  The `pgnoFDP` (root B-tree
180    /// page of the table) lives 16 bytes before the `0xFF` marker, and the object ID
181    /// lives 20 bytes before it — both as u32 LE.
182    ///
183    /// `pgnoFDP` is stored as an ESE 0-based data-page number; this function adds 1
184    /// to convert it to the physical page number expected by [`EseDatabase::read_page`].
185    ///
186    /// Returns `None` if the slice contains no recognisable TABLE entry.
187    pub fn parse_real_catalog_record(data: &[u8]) -> Option<Self> {
188        const MIN_BEFORE: usize = 20; // need ≥20 bytes before 0xFF for object_id + pgnoFDP + gap
189        let len = data.len();
190        let mut i = MIN_BEFORE;
191        while i + 4 <= len {
192            if data[i] != 0xff || data[i + 1] != 0x00 {
193                i += 1;
194                continue;
195            }
196            let name_len = u16::from_le_bytes([data[i + 2], data[i + 3]]) as usize;
197            if name_len == 0 || i + 4 + name_len > len {
198                i += 1;
199                continue;
200            }
201            let name_bytes = &data[i + 4..i + 4 + name_len];
202            if !name_bytes.is_ascii() {
203                i += 1;
204                continue;
205            }
206            let Ok(name) = std::str::from_utf8(name_bytes) else {
207                i += 1;
208                continue;
209            };
210            if name.is_empty() {
211                i += 1;
212                continue;
213            }
214            let pgnofdf_raw = u32::from_le_bytes(data[i - 16..i - 12].try_into().ok()?);
215            let object_id = u32::from_le_bytes(data[i - 20..i - 16].try_into().ok()?);
216            let table_page = pgnofdf_raw + 1; // ESE 0-based → physical page
217            return Some(Self {
218                object_type: 1,
219                object_id,
220                parent_object_id: 1,
221                table_page,
222                object_name: name.to_owned(),
223            });
224        }
225        None
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn catalog_entry_roundtrip() {
235        let entry = CatalogEntry {
236            object_type: 1,
237            object_id: 10,
238            parent_object_id: 1,
239            table_page: 42,
240            object_name: "SruDbNetworkTable".to_owned(),
241        };
242        let bytes = entry.to_bytes();
243        let parsed = CatalogEntry::from_bytes(&bytes).expect("parse");
244        assert_eq!(parsed.object_name, "SruDbNetworkTable");
245        assert_eq!(parsed.table_page, 42);
246        assert_eq!(parsed.object_type, 1);
247    }
248
249    #[test]
250    fn catalog_entry_too_short_returns_err() {
251        let result = CatalogEntry::from_bytes(&[0u8; 5]);
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn parse_real_catalog_record_extracts_name_and_page() {
257        // Build a minimal real-format catalog record:
258        // 20 bytes before 0xFF: [object_id at -20..-16][pgnoFDP at -16..-12][12 bytes padding]
259        // then: [0xFF][0x00][name_len u16 LE][name bytes]
260        let object_id: u32 = 42;
261        let pgnofdf_raw: u32 = 31; // ESE page 31 → physical page 32
262        let name = b"SruDbIdMapTable";
263        let name_len = name.len() as u16;
264
265        let mut data = vec![0u8; 20 + 4 + name.len()];
266        // object_id at offset 0 (= i-20)
267        data[0..4].copy_from_slice(&object_id.to_le_bytes());
268        // pgnoFDP at offset 4 (= i-16)
269        data[4..8].copy_from_slice(&pgnofdf_raw.to_le_bytes());
270        // 12 bytes of zero padding (offsets 8..20)
271        // 0xFF 0x00 marker at offset 20 (= i)
272        data[20] = 0xff;
273        data[21] = 0x00;
274        data[22..24].copy_from_slice(&name_len.to_le_bytes());
275        data[24..24 + name.len()].copy_from_slice(name);
276
277        let entry = CatalogEntry::parse_real_catalog_record(&data).expect("must find TABLE entry");
278        assert_eq!(entry.object_name, "SruDbIdMapTable");
279        assert_eq!(entry.table_page, 32); // pgnoFDP + 1
280        assert_eq!(entry.object_id, 42);
281        assert_eq!(entry.object_type, 1);
282    }
283
284    #[test]
285    fn parse_real_catalog_record_returns_none_for_synthetic_format() {
286        // Synthetic format starts with object_type u16 = [0x01, 0x00],
287        // which does not contain the 0xFF marker, so must return None.
288        let entry = CatalogEntry {
289            object_type: 1,
290            object_id: 2,
291            parent_object_id: 1,
292            table_page: 100,
293            object_name: "OrphanedTable".to_owned(),
294        };
295        let bytes = entry.to_bytes();
296        assert!(
297            CatalogEntry::parse_real_catalog_record(&bytes).is_none(),
298            "synthetic format must not match real catalog scanner"
299        );
300    }
301}