diff --git a/fuzz/fuzz_targets/fuzz_op_sequence.rs b/fuzz/fuzz_targets/fuzz_op_sequence.rs new file mode 100644 index 0000000..7bd7567 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_op_sequence.rs @@ -0,0 +1,101 @@ +// Fuzz target: arbitrary operation sequences against a Ledger. Errors +// are fine (invalid ops are rejected); the invariant is that the trial +// balance ALWAYS balances afterward, no matter the sequence. This is the +// stateful counterpart of the proptest properties — libfuzzer explores +// deeper interleavings. Staged here; `cargo fuzz init` wires it up. +#![no_main] + +use arbitrary::Arbitrary; +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, Ledger, Money, Side}; +use libfuzzer_sys::fuzz_target; +use rust_decimal::Decimal; + +#[derive(Arbitrary, Debug, Clone, Copy)] +enum Op { + AddAccount { code: u8, typ: u8 }, + CreateEntry { day: u8 }, + AddLine { + entry: u8, + account: u8, + debit: bool, + cents: u32, + }, + PostEntry { entry: u8 }, + ReverseEntry { entry: u8 }, +} + +fn account_type(t: u8) -> AccountType { + match t % 5 { + 0 => AccountType::Asset, + 1 => AccountType::Liability, + 2 => AccountType::Equity, + 3 => AccountType::Revenue, + _ => AccountType::Expense, + } +} + +fuzz_target!(|ops: Vec| { + let mut ledger = Ledger::new(Currency::USD); + let mut entry_ids: Vec = Vec::new(); + let mut next_code: u32 = 1000; + + for op in ops { + match op { + Op::AddAccount { code, typ } => { + // Unique codes by construction; duplicates are rejected. + let c = format!("{:04}", next_code + (code as u32 % 50)); + next_code += 1; + let _ = ledger.add_account(&c, "fuzz", account_type(typ), None, false); + } + Op::CreateEntry { day } => { + let date = + NaiveDate::from_ymd_opt(2026, 1, 1 + (day as u32 % 28)).unwrap(); + let id = ledger.create_entry(date, "fuzz"); + entry_ids.push(id); + } + Op::AddLine { + entry, + account, + debit, + cents, + } => { + if entry_ids.is_empty() { + continue; + } + let eid = entry_ids[entry as usize % entry_ids.len()]; + // Account ids are 1-based; may not exist — rejection is fine. + let aid = (account as u64 % 60) + 1; + let side = if debit { Side::Debit } else { Side::Credit }; + let amount = Decimal::new((cents % 1_000_000) as i64, 2); + let _ = ledger.add_line(eid, aid, side, Money::new(amount, Currency::USD)); + } + Op::PostEntry { entry } => { + if entry_ids.is_empty() { + continue; + } + let eid = entry_ids[entry as usize % entry_ids.len()]; + let _ = ledger.post_entry(eid); + } + Op::ReverseEntry { entry } => { + if entry_ids.is_empty() { + continue; + } + let eid = entry_ids[entry as usize % entry_ids.len()]; + let _ = ledger.reverse_entry( + eid, + NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), + "fuzz reversal", + ); + } + } + } + + // THE invariant: no sequence of operations, valid or rejected, may + // leave the trial balance out of balance. + let tb = ledger_core::reports::trial_balance(&ledger); + assert_eq!( + tb.total_debits, tb.total_credits, + "trial balance broken after fuzzed op sequence" + ); +});