Skip to main content

ese_core/
record.rs

1//! ESE record decoding — column definitions and value types.
2
3use crate::EseError;
4
5/// Result of verifying the checksum stored at the start of an ESE page.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ChecksumResult {
8    /// Stored checksum matches the computed value.
9    Valid,
10    /// Legacy XOR checksum: stored value does not match computed value.
11    LegacyXorMismatch { stored: u32, computed: u32 },
12    /// Vista+ ECC checksum: the stored ECC does not match the computed ECC.
13    EccMismatch,
14    /// Stored checksum field is zero — page was never checksummed.
15    Unknown,
16}
17
18/// Verify the checksum stored at offset 0 of `page_data`.
19///
20/// ## Format detection heuristic
21///
22/// * If stored bytes 0–3 are all zero → [`ChecksumResult::Unknown`].
23/// * If stored bytes 4–7 are all zero → legacy XOR format:
24///   seed `0x89AB_CDEF` XOR'd with all 4-byte words from offset 4 onward.
25///   Mismatch → [`ChecksumResult::LegacyXorMismatch`].
26/// * If stored bytes 4–7 are non-zero → Vista+ ECC format:
27///   XOR covers bytes 8+; ECC is a column-parity code over bytes 8+.
28///   Mismatch → [`ChecksumResult::EccMismatch`].
29///
30/// `page_number` is unused in the computation but kept for diagnostic use.
31pub fn verify_page_checksum(page_data: &[u8], _page_number: u32) -> ChecksumResult {
32    if page_data.len() < 8 {
33        return ChecksumResult::Unknown;
34    }
35    let stored_xor = u32::from_le_bytes([
36        page_data[0], page_data[1], page_data[2], page_data[3],
37    ]);
38    if stored_xor == 0 {
39        return ChecksumResult::Unknown;
40    }
41    let stored_ecc = u32::from_le_bytes([
42        page_data[4], page_data[5], page_data[6], page_data[7],
43    ]);
44
45    if stored_ecc == 0 {
46        // Legacy XOR format: covers bytes 4+.
47        let computed = xor_page_checksum(&page_data[4..]);
48        if computed == stored_xor {
49            ChecksumResult::Valid
50        } else {
51            ChecksumResult::LegacyXorMismatch { stored: stored_xor, computed }
52        }
53    } else {
54        // Vista+ ECC format: XOR + column-parity ECC, both covering bytes 8+.
55        if page_data.len() < 9 {
56            return ChecksumResult::Unknown;
57        }
58        let computed_xor = xor_page_checksum(&page_data[8..]);
59        let computed_ecc = column_parity_ecc(&page_data[8..]);
60        if computed_xor == stored_xor && computed_ecc == stored_ecc {
61            ChecksumResult::Valid
62        } else {
63            ChecksumResult::EccMismatch
64        }
65    }
66}
67
68const XOR_CHECKSUM_SEED: u32 = 0x89AB_CDEF;
69
70fn xor_page_checksum(data: &[u8]) -> u32 {
71    let mut csum = XOR_CHECKSUM_SEED;
72    for chunk in data.chunks_exact(4) {
73        csum ^= u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
74    }
75    csum
76}
77
78/// Column-parity ECC: XOR each word rotated by its position mod 32.
79fn column_parity_ecc(data: &[u8]) -> u32 {
80    let mut ecc: u32 = 0;
81    for (i, chunk) in data.chunks_exact(4).enumerate() {
82        let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
83        ecc ^= word.rotate_left((i % 32) as u32);
84    }
85    ecc
86}
87
88/// JET column type codes (coltyp field in MSysObjects).
89pub mod coltyp {
90    pub const BIT: u8 = 1;
91    pub const UNSIGNED_BYTE: u8 = 2;
92    pub const SHORT: u8 = 3;
93    pub const LONG: u8 = 4;
94    pub const CURRENCY: u8 = 5;
95    pub const IEEE_SINGLE: u8 = 6;
96    pub const IEEE_DOUBLE: u8 = 7;
97    pub const DATE_TIME: u8 = 8;
98    pub const BINARY: u8 = 9;
99    pub const TEXT: u8 = 10;
100    pub const LONG_BINARY: u8 = 11;
101    pub const LONG_TEXT: u8 = 12;
102    pub const GUID: u8 = 16;
103    pub const UNSIGNED_SHORT: u8 = 17;
104    pub const UNSIGNED_LONG: u8 = 14;
105    pub const LONG_LONG: u8 = 15;
106    pub const UNSIGNED_LONG_LONG: u8 = 18;
107}
108
109/// A column definition from the ESE catalog.
110#[derive(Debug, Clone)]
111pub struct ColumnDef {
112    /// 1-based column identifier (matches ESE catalog column_id).
113    pub column_id: u32,
114    /// Human-readable column name.
115    pub name: String,
116    /// JET column type code (see [`coltyp`] constants).
117    pub coltyp: u8,
118}
119
120/// A decoded ESE column value.
121#[derive(Debug, Clone, serde::Serialize)]
122pub enum EseValue {
123    Null,
124    Bool(bool),
125    U8(u8),
126    I16(i16),
127    I32(i32),
128    I64(i64),
129    U16(u16),
130    U32(u32),
131    U64(u64),
132    F32(f32),
133    F64(f64),
134    /// OLE Automation Date: days since 1899-12-30 as a floating-point number.
135    DateTime(f64),
136    Binary(Vec<u8>),
137    Text(String),
138    Guid([u8; 16]),
139}
140
141/// Return the fixed byte size for a fixed-length coltyp, or `None` for
142/// variable-length (Binary, Text) and tagged (LongBinary, LongText) types.
143pub fn fixed_col_size(coltyp: u8) -> Option<usize> {
144    match coltyp {
145        coltyp::BIT | coltyp::UNSIGNED_BYTE => Some(1),
146        coltyp::SHORT | coltyp::UNSIGNED_SHORT => Some(2),
147        coltyp::LONG | coltyp::UNSIGNED_LONG | coltyp::IEEE_SINGLE => Some(4),
148        coltyp::CURRENCY | coltyp::IEEE_DOUBLE | coltyp::DATE_TIME
149        | coltyp::LONG_LONG | coltyp::UNSIGNED_LONG_LONG => Some(8),
150        coltyp::GUID => Some(16),
151        _ => None, // variable or tagged
152    }
153}
154
155/// Decode one fixed-length column value from `data` (exactly `fixed_col_size` bytes).
156fn decode_fixed(data: &[u8], coltyp: u8) -> EseValue {
157    match coltyp {
158        coltyp::BIT => EseValue::Bool(data[0] != 0),
159        coltyp::UNSIGNED_BYTE => EseValue::U8(data[0]),
160        coltyp::SHORT => EseValue::I16(i16::from_le_bytes([data[0], data[1]])),
161        coltyp::UNSIGNED_SHORT => EseValue::U16(u16::from_le_bytes([data[0], data[1]])),
162        coltyp::LONG => EseValue::I32(i32::from_le_bytes([data[0], data[1], data[2], data[3]])),
163        coltyp::UNSIGNED_LONG => {
164            EseValue::U32(u32::from_le_bytes([data[0], data[1], data[2], data[3]]))
165        }
166        coltyp::IEEE_SINGLE => {
167            EseValue::F32(f32::from_le_bytes([data[0], data[1], data[2], data[3]]))
168        }
169        coltyp::CURRENCY | coltyp::LONG_LONG => EseValue::I64(i64::from_le_bytes([
170            data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
171        ])),
172        coltyp::UNSIGNED_LONG_LONG => EseValue::U64(u64::from_le_bytes([
173            data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
174        ])),
175        coltyp::IEEE_DOUBLE => EseValue::F64(f64::from_le_bytes([
176            data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
177        ])),
178        coltyp::DATE_TIME => EseValue::DateTime(f64::from_le_bytes([
179            data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
180        ])),
181        coltyp::GUID => {
182            let mut g = [0u8; 16];
183            g.copy_from_slice(&data[..16]);
184            EseValue::Guid(g)
185        }
186        _ => EseValue::Binary(data.to_vec()),
187    }
188}
189
190/// Decode an ESE data record into named column values.
191///
192/// # Record format (Vista+)
193///
194/// ```text
195/// Offset 0:  last_fixed_col_id   (u8)  — highest fixed column ID in this record
196/// Offset 1:  last_var_col_idx    (u8)  — count of variable columns in this record
197/// Offset 2:  var_data_offset     (u16) — offset from record start to variable data
198/// Offset 4…: fixed column data (packed, column_id 1 through last_fixed_col_id)
199/// var_data_offset…end-of-var: variable column data
200/// (var_data_offset - 4 - fixed_size) / 2 entries before var data: end offsets
201/// ```
202///
203/// Fixed-column data is packed contiguously in column_id order starting at byte 4.
204/// Each column occupies exactly [`fixed_col_size`] bytes regardless of nullity
205/// (null fixed columns are stored as zero bytes in their normal slot).
206///
207/// Variable columns follow: a 2-byte per-column end-offset array immediately
208/// before the variable data (end offsets relative to `var_data_offset`).
209/// High bit of an offset entry indicates a NULL variable column.
210///
211/// # Errors
212///
213/// Returns `EseError::Corrupt` if the header cannot be read or an offset is out
214/// of bounds. Unknown coltypes are returned as `EseValue::Binary`.
215pub fn decode_record(data: &[u8], columns: &[ColumnDef]) -> Result<Vec<(String, EseValue)>, EseError> {
216    if data.len() < 4 {
217        return Ok(Vec::new());
218    }
219
220    let last_fixed_col = data[0] as u32;
221    let num_var_cols = data[1] as usize;
222    let var_data_offset = u16::from_le_bytes([data[2], data[3]]) as usize;
223
224    let mut result = Vec::new();
225
226    // ── fixed columns (column_id 1..=last_fixed_col) ─────────────────────────
227    let mut fixed_cursor = 4usize; // fixed data starts at byte 4
228    let mut fixed_col_idx = 1u32; // current column_id being read
229
230    for col in columns {
231        if fixed_col_size(col.coltyp).is_none() {
232            continue; // skip variable/tagged columns in this pass
233        }
234        if col.column_id > last_fixed_col {
235            break; // record doesn't contain this column
236        }
237        // Advance past any fixed columns with lower IDs that aren't in our def list.
238        // (We only need to handle columns in `columns` in order; gaps between
239        // column_ids in the def list mean we skip those fixed-size slots.)
240        while fixed_col_idx < col.column_id {
241            // Find the size of the skipped column — we don't have its def, so
242            // we can't skip it without knowing its coltyp. In practice SRUM
243            // column definitions are contiguous from 1, so this path is rare.
244            // Conservative: bail out if gap encountered.
245            fixed_col_idx += 1;
246            if fixed_col_idx > last_fixed_col {
247                break;
248            }
249        }
250        if fixed_col_idx > last_fixed_col {
251            break;
252        }
253
254        // Variable/tagged columns were skipped above, so this is always Some;
255        // read it fallibly anyway so a future change can never turn it into a panic.
256        let Some(size) = fixed_col_size(col.coltyp) else {
257            continue; // cov:unreachable: fixed_col_size(col.coltyp).is_none() already `continue`d
258        };
259        if fixed_cursor + size > data.len() {
260            break;
261        }
262        let val = decode_fixed(&data[fixed_cursor..fixed_cursor + size], col.coltyp);
263        result.push((col.name.clone(), val));
264        fixed_cursor += size;
265        fixed_col_idx += 1;
266    }
267
268    // ── variable columns ─────────────────────────────────────────────────────
269    if num_var_cols == 0 || var_data_offset > data.len() {
270        return Ok(result);
271    }
272    // Variable-column end-offset array lives at var_data_offset.
273    let offsets_area_start = var_data_offset;
274    let offsets_area_end = offsets_area_start + num_var_cols * 2;
275    if offsets_area_end > data.len() {
276        return Ok(result);
277    }
278    // Variable data follows the offset array.
279    let var_payload_start = offsets_area_end;
280
281    let mut var_col_idx = 0usize; // 0-based index into the offset array
282    let mut prev_end = 0u16; // end offset of the previous variable column
283
284    for col in columns {
285        if fixed_col_size(col.coltyp).is_some() {
286            continue; // fixed column — already handled
287        }
288        if var_col_idx >= num_var_cols {
289            break;
290        }
291        let off_pos = offsets_area_start + var_col_idx * 2;
292        let raw_end = u16::from_le_bytes([data[off_pos], data[off_pos + 1]]);
293        let is_null = raw_end & 0x8000 != 0;
294        let end_offset = (raw_end & 0x7FFF) as usize;
295
296        if !is_null {
297            let start = var_payload_start + prev_end as usize;
298            let end = var_payload_start + end_offset;
299            if end <= data.len() && start <= end {
300                let bytes = &data[start..end];
301                let val = match col.coltyp {
302                    coltyp::TEXT => {
303                        let s = String::from_utf8_lossy(bytes).into_owned();
304                        EseValue::Text(s)
305                    }
306                    _ => EseValue::Binary(bytes.to_vec()),
307                };
308                result.push((col.name.clone(), val));
309            }
310        }
311        prev_end = raw_end & 0x7FFF;
312        var_col_idx += 1;
313    }
314
315    Ok(result)
316}