use crate::EseError;
fn le_u32_at(data: &[u8], off: usize) -> u32 {
let mut b = [0u8; 4];
if let Some(s) = data.get(off..off + 4) {
b.copy_from_slice(s);
}
u32::from_le_bytes(b)
}
#[derive(Debug, Clone)]
pub struct CatalogEntry {
pub object_type: u16,
pub object_id: u32,
pub parent_object_id: u32,
pub table_page: u32,
pub object_name: String,
}
impl CatalogEntry {
pub const MIN_SIZE: usize = 16;
pub fn from_bytes(data: &[u8]) -> Result<Self, EseError> {
if data.len() < Self::MIN_SIZE {
return Err(EseError::Corrupt {
page: 0,
detail: format!(
"catalog record too short: {} < {}",
data.len(),
Self::MIN_SIZE
),
});
}
let object_type = u16::from_le_bytes([data[0], data[1]]);
let object_id = u32::from_le_bytes([data[2], data[3], data[4], data[5]]);
let parent_object_id = u32::from_le_bytes([data[6], data[7], data[8], data[9]]);
let table_page = u32::from_le_bytes([data[10], data[11], data[12], data[13]]);
let name_len = u16::from_le_bytes([data[14], data[15]]) as usize;
if data.len() < Self::MIN_SIZE + name_len {
return Err(EseError::Corrupt {
page: 0,
detail: format!(
"catalog record name truncated: need {}, got {}",
Self::MIN_SIZE + name_len,
data.len()
),
});
}
let name_bytes = &data[16..16 + name_len];
let object_name = std::str::from_utf8(name_bytes)
.map_err(|e| EseError::Corrupt {
page: 0,
detail: format!("catalog name not UTF-8: {e}"),
})?
.to_owned();
Ok(Self {
object_type,
object_id,
parent_object_id,
table_page,
object_name,
})
}
pub fn to_bytes(&self) -> Vec<u8> {
let name_bytes = self.object_name.as_bytes();
let mut out = Vec::with_capacity(Self::MIN_SIZE + name_bytes.len());
out.extend_from_slice(&self.object_type.to_le_bytes());
out.extend_from_slice(&self.object_id.to_le_bytes());
out.extend_from_slice(&self.parent_object_id.to_le_bytes());
out.extend_from_slice(&self.table_page.to_le_bytes());
out.extend_from_slice(&(u16::try_from(name_bytes.len()).unwrap_or(u16::MAX)).to_le_bytes());
out.extend_from_slice(name_bytes);
out
}
pub fn scan_catalog_page_data(data_area: &[u8]) -> Vec<Self> {
const MIN_I: usize = 20; const MAX_NAME: usize = 64;
let len = data_area.len();
let mut entries: Vec<Self> = Vec::new();
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut i = MIN_I;
while i + 4 <= len {
if data_area[i] != 0xff || data_area[i + 1] != 0x00 {
i += 1;
continue;
}
let name_len = u16::from_le_bytes([data_area[i + 2], data_area[i + 3]]) as usize;
if name_len == 0 || name_len > MAX_NAME || i + 4 + name_len > len {
i += 1;
continue;
}
let name_bytes = &data_area[i + 4..i + 4 + name_len];
if !name_bytes.is_ascii() {
i += 1;
continue;
}
let Ok(name) = std::str::from_utf8(name_bytes) else {
i += 1;
continue;
};
if name.is_empty() || seen.contains(name) {
i += 1;
continue;
}
let pgnofdf_raw = le_u32_at(data_area, i - 16);
let object_id = le_u32_at(data_area, i - 20);
seen.insert(name);
entries.push(Self {
object_type: 1,
object_id,
parent_object_id: 1,
table_page: pgnofdf_raw + 1,
object_name: name.to_owned(),
});
i += 4 + name_len;
}
entries
}
pub fn parse_real_catalog_record(data: &[u8]) -> Option<Self> {
const MIN_BEFORE: usize = 20; let len = data.len();
let mut i = MIN_BEFORE;
while i + 4 <= len {
if data[i] != 0xff || data[i + 1] != 0x00 {
i += 1;
continue;
}
let name_len = u16::from_le_bytes([data[i + 2], data[i + 3]]) as usize;
if name_len == 0 || i + 4 + name_len > len {
i += 1;
continue;
}
let name_bytes = &data[i + 4..i + 4 + name_len];
if !name_bytes.is_ascii() {
i += 1;
continue;
}
let Ok(name) = std::str::from_utf8(name_bytes) else {
i += 1;
continue;
};
if name.is_empty() {
i += 1;
continue;
}
let pgnofdf_raw = u32::from_le_bytes(data[i - 16..i - 12].try_into().ok()?);
let object_id = u32::from_le_bytes(data[i - 20..i - 16].try_into().ok()?);
let table_page = pgnofdf_raw + 1; return Some(Self {
object_type: 1,
object_id,
parent_object_id: 1,
table_page,
object_name: name.to_owned(),
});
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_entry_roundtrip() {
let entry = CatalogEntry {
object_type: 1,
object_id: 10,
parent_object_id: 1,
table_page: 42,
object_name: "SruDbNetworkTable".to_owned(),
};
let bytes = entry.to_bytes();
let parsed = CatalogEntry::from_bytes(&bytes).expect("parse");
assert_eq!(parsed.object_name, "SruDbNetworkTable");
assert_eq!(parsed.table_page, 42);
assert_eq!(parsed.object_type, 1);
}
#[test]
fn catalog_entry_too_short_returns_err() {
let result = CatalogEntry::from_bytes(&[0u8; 5]);
assert!(result.is_err());
}
#[test]
fn parse_real_catalog_record_extracts_name_and_page() {
let object_id: u32 = 42;
let pgnofdf_raw: u32 = 31; let name = b"SruDbIdMapTable";
let name_len = name.len() as u16;
let mut data = vec![0u8; 20 + 4 + name.len()];
data[0..4].copy_from_slice(&object_id.to_le_bytes());
data[4..8].copy_from_slice(&pgnofdf_raw.to_le_bytes());
data[20] = 0xff;
data[21] = 0x00;
data[22..24].copy_from_slice(&name_len.to_le_bytes());
data[24..24 + name.len()].copy_from_slice(name);
let entry = CatalogEntry::parse_real_catalog_record(&data).expect("must find TABLE entry");
assert_eq!(entry.object_name, "SruDbIdMapTable");
assert_eq!(entry.table_page, 32); assert_eq!(entry.object_id, 42);
assert_eq!(entry.object_type, 1);
}
#[test]
fn parse_real_catalog_record_returns_none_for_synthetic_format() {
let entry = CatalogEntry {
object_type: 1,
object_id: 2,
parent_object_id: 1,
table_page: 100,
object_name: "OrphanedTable".to_owned(),
};
let bytes = entry.to_bytes();
assert!(
CatalogEntry::parse_real_catalog_record(&bytes).is_none(),
"synthetic format must not match real catalog scanner"
);
}
}