use std::iter::FusedIterator;
use opensqlany::{ApModel, Page, PageStore, PageType, Result as SaResult, SlottedPage};
use crate::bv_recovery::{deobfuscate_with_bv, oracle_bv_e_page, recover_bv_qb_data};
use crate::page_attribution::PageAttribution;
const PAGE_DATA_END: usize = 0xFF0;
const QB_ID_LEN: usize = 16;
const HEADER_PREFIX: [u8; 3] = [0x0E, 0x00, 0x10];
const HEADER_SUFFIX: &str = "_header";
#[derive(Debug, Clone)]
pub struct TransactionHeader {
pub qb_id: String,
pub source_table: String,
pub txn_date_raw: Option<u32>,
pub counter: Option<u32>,
pub page_number: u64,
pub page_offset: usize,
}
impl TransactionHeader {
pub fn txn_type(&self) -> &str {
let s = self.source_table.as_str();
let s = s.strip_prefix("abmc_").unwrap_or(s);
s.strip_suffix("_header").unwrap_or(s)
}
}
pub fn iter_transaction_headers<'a>(
store: &'a PageStore,
model: &'a ApModel,
attribution: &'a PageAttribution,
) -> impl Iterator<Item = TransactionHeader> + 'a {
TransactionHeaderIter::new(store, model, attribution)
}
fn is_header_table(name: &str) -> bool {
name.ends_with(HEADER_SUFFIX)
}
fn scan_page(body: &[u8], pn: u64, source_table: &str, out: &mut Vec<TransactionHeader>) {
if body.len() < HEADER_PREFIX.len() + QB_ID_LEN {
return;
}
let end = body.len().min(PAGE_DATA_END);
if end < HEADER_PREFIX.len() + QB_ID_LEN {
return;
}
let limit = end - (HEADER_PREFIX.len() + QB_ID_LEN);
let mut pos = 0usize;
while pos <= limit {
if body[pos] != HEADER_PREFIX[0]
|| body[pos + 1] != HEADER_PREFIX[1]
|| body[pos + 2] != HEADER_PREFIX[2]
{
pos += 1;
continue;
}
let id_start = pos + HEADER_PREFIX.len();
let id_bytes = &body[id_start..id_start + QB_ID_LEN];
if !is_base62(id_bytes) {
pos += 1;
continue;
}
let qb_id = std::str::from_utf8(id_bytes)
.expect("guarded by is_base62")
.to_owned();
let (txn_date_raw, counter) = find_date_counter(body, id_start + QB_ID_LEN);
out.push(TransactionHeader {
qb_id,
source_table: source_table.to_owned(),
txn_date_raw,
counter,
page_number: pn,
page_offset: pos,
});
pos = id_start + QB_ID_LEN;
}
}
fn find_date_counter(body: &[u8], start: usize) -> (Option<u32>, Option<u32>) {
let end = (start + 96).min(body.len().saturating_sub(8));
let mut i = start;
while i + 8 <= end {
let d = u32::from_le_bytes([body[i], body[i + 1], body[i + 2], body[i + 3]]);
let c = u32::from_le_bytes([body[i + 4], body[i + 5], body[i + 6], body[i + 7]]);
if (13_000..20_000).contains(&d) && c > 0 && c < 10_000_000 {
return (Some(d), Some(c));
}
i += 1;
}
(None, None)
}
fn scan_page_slotted(
plain: &[u8],
pn: u64,
source_table: &str,
out: &mut Vec<TransactionHeader>,
) -> Option<usize> {
let page = Page::from_bytes(pn, plain);
let sp = SlottedPage::parse(page);
sp.directory.as_ref()?;
let rows = sp.row_bytes();
let mut added = 0usize;
for (slot_off, row) in rows {
if row.len() < HEADER_PREFIX.len() + QB_ID_LEN {
continue;
}
let limit = row.len() - (HEADER_PREFIX.len() + QB_ID_LEN);
let mut i = 0usize;
while i <= limit {
if row[i] == HEADER_PREFIX[0]
&& row[i + 1] == HEADER_PREFIX[1]
&& row[i + 2] == HEADER_PREFIX[2]
{
let id_bytes = &row[i + 3..i + 3 + QB_ID_LEN];
if is_base62(id_bytes) {
let qb_id = std::str::from_utf8(id_bytes)
.expect("guarded by is_base62")
.to_owned();
let after = i + 3 + QB_ID_LEN;
let (txn_date_raw, counter) = find_date_counter(row, after);
out.push(TransactionHeader {
qb_id,
source_table: source_table.to_owned(),
txn_date_raw,
counter,
page_number: pn,
page_offset: slot_off as usize + i,
});
added += 1;
break;
}
}
i += 1;
}
}
Some(added)
}
fn is_base62(s: &[u8]) -> bool {
s.iter()
.all(|&b| matches!(b, b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'))
}
struct TransactionHeaderIter<'a> {
store: &'a PageStore,
model: &'a ApModel,
attribution: &'a PageAttribution,
pn: u64,
n_pages: u64,
buffer: Vec<TransactionHeader>,
}
impl<'a> TransactionHeaderIter<'a> {
fn new(store: &'a PageStore, model: &'a ApModel, attribution: &'a PageAttribution) -> Self {
Self {
store,
model,
attribution,
pn: 1,
n_pages: store.page_count(),
buffer: Vec::new(),
}
}
fn fill_buffer(&mut self) -> SaResult<bool> {
while self.buffer.is_empty() && self.pn < self.n_pages {
let pn = self.pn;
self.pn += 1;
let source_table = match self.attribution.attribute(pn) {
Some(entry) if is_header_table(&entry.name) => entry.name.clone(),
_ => continue,
};
let page = self.store.page(pn)?;
if page.trailer().page_type() != PageType::Extent {
continue;
}
let raw = page.bytes();
let plain = if let Some(bv) = recover_bv_qb_data(pn, raw) {
deobfuscate_with_bv(raw, pn, bv)
} else {
let bv = oracle_bv_e_page(pn, raw);
let candidate = deobfuscate_with_bv(raw, pn, bv);
if candidate[0] == 0 {
candidate
} else {
self.model.deobfuscate_with_store(raw, pn, self.store)
}
};
let before = self.buffer.len();
let slotted_added = scan_page_slotted(&plain, pn, &source_table, &mut self.buffer);
if slotted_added.is_none() {
scan_page(&plain[..PAGE_DATA_END], pn, &source_table, &mut self.buffer);
}
self.buffer[before..].reverse();
}
Ok(!self.buffer.is_empty())
}
}
impl Iterator for TransactionHeaderIter<'_> {
type Item = TransactionHeader;
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(h) = self.buffer.pop() {
return Some(h);
}
match self.fill_buffer() {
Ok(true) => continue,
_ => return None,
}
}
}
}
impl FusedIterator for TransactionHeaderIter<'_> {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn txn_type_strips_prefix_and_suffix() {
let h = TransactionHeader {
qb_id: "0000000000001QBm".into(),
source_table: "abmc_invoice_header".into(),
txn_date_raw: None,
counter: None,
page_number: 0,
page_offset: 0,
};
assert_eq!(h.txn_type(), "invoice");
}
#[test]
fn txn_type_compound_name() {
let h = TransactionHeader {
qb_id: "0000000000001QBm".into(),
source_table: "abmc_general_journal_header".into(),
txn_date_raw: None,
counter: None,
page_number: 0,
page_offset: 0,
};
assert_eq!(h.txn_type(), "general_journal");
}
#[test]
fn is_header_table_recognises_suffix() {
assert!(is_header_table("abmc_invoice_header"));
assert!(is_header_table("abmc_bill_header"));
assert!(!is_header_table("abmc_invoice_lineitem"));
assert!(!is_header_table("SYSTABLE"));
}
#[test]
fn scan_page_finds_synthetic_header() {
let mut body = vec![0xAAu8; PAGE_DATA_END];
let anchor = 64;
body[anchor..anchor + 3].copy_from_slice(&HEADER_PREFIX);
body[anchor + 3..anchor + 3 + QB_ID_LEN].copy_from_slice(b"0000000000001QBm");
let after = anchor + 3 + QB_ID_LEN;
body[after..after + 4].copy_from_slice(&15511u32.to_le_bytes());
body[after + 4..after + 8].copy_from_slice(&12671u32.to_le_bytes());
let mut out = Vec::new();
scan_page(&body, 3628, "abmc_invoice_header", &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].qb_id, "0000000000001QBm");
assert_eq!(out[0].source_table, "abmc_invoice_header");
assert_eq!(out[0].txn_date_raw, Some(15511));
assert_eq!(out[0].counter, Some(12671));
assert_eq!(out[0].page_offset, anchor);
}
#[test]
fn scan_page_rejects_non_base62_qbid() {
let mut body = vec![0xAAu8; PAGE_DATA_END];
let anchor = 64;
body[anchor..anchor + 3].copy_from_slice(&HEADER_PREFIX);
body[anchor + 3..anchor + 3 + QB_ID_LEN].copy_from_slice(b"0000000000001QB!");
let mut out = Vec::new();
scan_page(&body, 3628, "abmc_invoice_header", &mut out);
assert!(out.is_empty());
}
#[test]
fn scan_page_advances_past_match() {
let mut body = vec![0u8; PAGE_DATA_END];
let first = 16;
body[first..first + 3].copy_from_slice(&HEADER_PREFIX);
body[first + 3..first + 3 + QB_ID_LEN].copy_from_slice(b"0000000000001QBm");
let second = first + 3 + QB_ID_LEN;
body[second..second + 3].copy_from_slice(&HEADER_PREFIX);
body[second + 3..second + 3 + QB_ID_LEN].copy_from_slice(b"0000000000001QBn");
let mut out = Vec::new();
scan_page(&body, 3628, "abmc_invoice_header", &mut out);
assert_eq!(out.len(), 2);
assert_eq!(out[0].qb_id, "0000000000001QBm");
assert_eq!(out[1].qb_id, "0000000000001QBn");
}
fn build_slotted_page(rows: &[(u16, &[u8])]) -> Vec<u8> {
let mut page = vec![0u8; 4096];
for &(off, body) in rows {
let start = off as usize;
page[start..start + body.len()].copy_from_slice(body);
}
let mut offs: Vec<u16> = rows.iter().map(|(o, _)| *o).collect();
offs.sort_by(|a, b| b.cmp(a));
let mut dir = 0x100usize;
for o in &offs {
page[dir..dir + 2].copy_from_slice(&o.to_le_bytes());
dir += 2;
}
page
}
#[test]
fn scan_page_slotted_emits_one_record_per_row() {
let mut bodies: Vec<Vec<u8>> = Vec::new();
let qb_ids: Vec<String> = (0..8)
.map(|i| format!("00000000000{:05}", i + 1000))
.collect();
for id in &qb_ids {
let mut body = vec![0u8; 64];
body[0..3].copy_from_slice(&HEADER_PREFIX);
body[3..3 + QB_ID_LEN].copy_from_slice(id.as_bytes());
body[19..23].copy_from_slice(&15511u32.to_le_bytes());
body[23..27].copy_from_slice(&12671u32.to_le_bytes());
bodies.push(body);
}
let offsets: Vec<u16> = (0..8).map(|i| 0x800 + (i as u16) * 64).collect();
let rows: Vec<(u16, &[u8])> = offsets
.iter()
.zip(bodies.iter())
.map(|(o, b)| (*o, b.as_slice()))
.collect();
let page = build_slotted_page(&rows);
let mut out = Vec::new();
let n = scan_page_slotted(&page, 4242, "abmc_invoice_header", &mut out);
assert_eq!(n, Some(8));
assert_eq!(out.len(), 8);
for h in &out {
assert_eq!(h.page_number, 4242);
assert_eq!(h.source_table, "abmc_invoice_header");
assert_eq!(h.txn_date_raw, Some(15511));
assert_eq!(h.counter, Some(12671));
}
let mut got: Vec<String> = out.iter().map(|h| h.qb_id.clone()).collect();
got.sort();
let mut want = qb_ids.clone();
want.sort();
assert_eq!(got, want);
}
#[test]
fn scan_page_slotted_returns_none_without_directory() {
let page = vec![0u8; 4096];
let mut out = Vec::new();
let n = scan_page_slotted(&page, 1, "abmc_invoice_header", &mut out);
assert!(n.is_none());
assert!(out.is_empty());
}
#[test]
fn scan_page_slotted_skips_rows_without_anchor() {
let mut bodies: Vec<Vec<u8>> = (0..8).map(|i| vec![i as u8; 64]).collect();
bodies[0] = {
let mut body = vec![0u8; 64];
body[0..3].copy_from_slice(&HEADER_PREFIX);
body[3..3 + QB_ID_LEN].copy_from_slice(b"0000000000001QBm");
body
};
let offsets: Vec<u16> = (0..8).map(|i| 0x800 + (i as u16) * 64).collect();
let rows: Vec<(u16, &[u8])> = offsets
.iter()
.zip(bodies.iter())
.map(|(o, b)| (*o, b.as_slice()))
.collect();
let page = build_slotted_page(&rows);
let mut out = Vec::new();
let n = scan_page_slotted(&page, 7, "abmc_invoice_header", &mut out);
assert_eq!(n, Some(1));
assert_eq!(out.len(), 1);
assert_eq!(out[0].qb_id, "0000000000001QBm");
}
#[test]
fn scan_page_slotted_emits_at_most_one_per_slot() {
let mut bodies: Vec<Vec<u8>> = (1..8).map(|i| vec![i as u8; 64]).collect();
let mut row0 = vec![0u8; 128];
row0[0..3].copy_from_slice(&HEADER_PREFIX);
row0[3..3 + QB_ID_LEN].copy_from_slice(b"0000000000001QBm");
let second = 3 + QB_ID_LEN;
row0[second..second + 3].copy_from_slice(&HEADER_PREFIX);
row0[second + 3..second + 3 + QB_ID_LEN].copy_from_slice(b"0000000000001QBn");
bodies.insert(0, row0);
let offsets: Vec<u16> = (0..8).map(|i| 0x700 + (i as u16) * 128).collect();
let rows: Vec<(u16, &[u8])> = offsets
.iter()
.zip(bodies.iter())
.map(|(o, b)| (*o, b.as_slice()))
.collect();
let page = build_slotted_page(&rows);
let mut out = Vec::new();
let _ = scan_page_slotted(&page, 9, "abmc_invoice_header", &mut out);
let qbm = out.iter().filter(|h| h.qb_id == "0000000000001QBm").count();
let qbn = out.iter().filter(|h| h.qb_id == "0000000000001QBn").count();
assert_eq!(qbm, 1);
assert_eq!(qbn, 0);
}
}