test: fuzzing, Miri, benchmarks, CI, and a strategy note; the journal refuses floats
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Max-testing pass, ranks 4-6 and step 9 for ledger-core. Fuzzing (rank 4): fuzz_op_sequence now drives balanced pairs, foreign lines, periods, closes, and amounts from raw decimal bits, and asserts the books stay valid (TB balances, posted entries validate, no draft in a closed period); new fuzz_core_serde (every persisted core type round-trips) and fuzz_store_open (a corrupt journal is an error, never a panic); hangs count (-timeout=10). 54M executions; one crash: - fuzz_journal_line: a number too big for an integer parsed as a float, and its canonical form (which the hash chain covers) re-canonicalized differently. The journal never holds floats (R-2), so the reader now refuses them (JournalLine::parse, used by every journal reader); regression a_journal_line_with_a_float_is_refused. Checked first: the real books' journal has none. Rerun: 13.2M executions, clean. Seeds in fuzz/seeds/ (synthetic, minimal); the grown corpora stay out of git. `just fuzz <target> <seconds>`. Miri (rank 4): the core's unit, bridge, and invariant tests under Miri (our code has no unsafe; rust_decimal and chrono do): no undefined behavior. `just miri`. A pinned nightly (nightly-2026-10-01, with miri) is now in mise.toml. Benchmarks (rank 6): criterion (new dev-dependency: the playbook's Rust benchmark tool; none existed) in benches/books.rs; baselines in docs/testing.md. The store's per-command rehearsal clone costs ~1 ms at 10k entries. No budget yet, so nothing gates on them. `just bench`. CI: .github/workflows/check.yml runs `just check` on every push; scheduled.yml runs proofs, the GnuCash oracle, fuzzing, and mutation weekly. Tools come from mise.toml. docs/testing.md: every layer, its oracle rank, command, runtime, and gate; benchmark baselines; the ASSUMED invariants for Stephen to reject; the gap list with triggers. docs/oracle.md's fuzzing paragraph had claimed persistent corpora that didn't exist; corrected. Also: ledger-import now inherits the workspace lints (nightly cargo warned). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
8a9a2485cf
commit
c539004d1a
25 files changed
+1635
-81
No files matched your search
@@ -1,28 +1,49 @@
|
||||
// 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.
|
||||
// Fuzz target: arbitrary command sequences against a Ledger, including
|
||||
// balanced pairs, foreign-currency lines, periods, and closes, with
|
||||
// amounts built from raw decimal bits (huge, tiny, odd scales). Refusals
|
||||
// are fine; panics, hangs, and broken books are not. After any sequence:
|
||||
// the trial balance balances, every posted entry validates, and no draft
|
||||
// sits in a closed period (the Lean invariant Inv).
|
||||
#![no_main]
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||||
use ledger_core::{AccountType, Currency, EntryStatus, Ledger, Money, Side};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[derive(Arbitrary, Debug, Clone, Copy)]
|
||||
enum Op {
|
||||
AddAccount { code: u8, typ: u8 },
|
||||
AddAccount { code: u8, typ: u8, placeholder: bool },
|
||||
CreateEntry { day: u8 },
|
||||
AddLine {
|
||||
entry: u8,
|
||||
account: u8,
|
||||
debit: bool,
|
||||
cents: u32,
|
||||
},
|
||||
AddLine { entry: u8, account: u8, debit: bool, amount: Amount },
|
||||
Pair { entry: u8, debit: u8, credit: u8, amount: Amount },
|
||||
AddLineFx { entry: u8, account: u8, debit: bool, foreign: Amount, rate: Amount },
|
||||
PostEntry { entry: u8 },
|
||||
ReverseEntry { entry: u8 },
|
||||
ReverseEntry { entry: u8, day: u8 },
|
||||
CreatePeriod { start: u8, len: u8 },
|
||||
ClosePeriod { period: u8 },
|
||||
}
|
||||
|
||||
/// A decimal from raw parts: any mantissa up to 96 bits, any scale 0-28.
|
||||
#[derive(Arbitrary, Debug, Clone, Copy)]
|
||||
struct Amount {
|
||||
lo: u32,
|
||||
mid: u32,
|
||||
hi: u32,
|
||||
negative: bool,
|
||||
scale: u8,
|
||||
small: bool,
|
||||
}
|
||||
|
||||
impl Amount {
|
||||
fn get(self) -> Decimal {
|
||||
if self.small {
|
||||
// Mostly ordinary money, so entries balance and post.
|
||||
return Decimal::new(i64::from(self.lo % 1_000_000), 2);
|
||||
}
|
||||
Decimal::from_parts(self.lo, self.mid, self.hi, self.negative, u32::from(self.scale % 29))
|
||||
}
|
||||
}
|
||||
|
||||
fn account_type(t: u8) -> AccountType {
|
||||
@@ -35,67 +56,79 @@ fn account_type(t: u8) -> AccountType {
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|ops: Vec<Op>| {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let mut entry_ids: Vec<u64> = Vec::new();
|
||||
let mut next_code: u32 = 1000;
|
||||
fn day(d: u8) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(d % 200))
|
||||
}
|
||||
|
||||
for op in ops {
|
||||
fuzz_target!(|ops: Vec<Op>| {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut entries: Vec<u64> = Vec::new();
|
||||
let pick = |v: &[u64], i: u8| v.get(i as usize % v.len().max(1)).copied();
|
||||
for op in ops.into_iter().take(200) {
|
||||
let accounts: Vec<u64> = l.chart().all().map(|a| a.id).collect();
|
||||
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::AddAccount { code, typ, placeholder } => {
|
||||
let _ = l.add_account(&format!("{}", 1000 + u32::from(code % 40)), "f", account_type(typ), None, placeholder);
|
||||
}
|
||||
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::CreateEntry { day: d } => {
|
||||
if let Ok(id) = l.try_create_entry(day(d), "f") {
|
||||
entries.push(id);
|
||||
}
|
||||
}
|
||||
Op::AddLine {
|
||||
entry,
|
||||
account,
|
||||
debit,
|
||||
cents,
|
||||
} => {
|
||||
if entry_ids.is_empty() {
|
||||
continue;
|
||||
Op::AddLine { entry, account, debit, amount } => {
|
||||
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
|
||||
let side = if debit { Side::Debit } else { Side::Credit };
|
||||
let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD));
|
||||
}
|
||||
}
|
||||
Op::Pair { entry, debit, credit, amount } => {
|
||||
if let (Some(e), Some(a), Some(b)) =
|
||||
(pick(&entries, entry), pick(&accounts, debit), pick(&accounts, credit))
|
||||
{
|
||||
let m = Money::new(amount.get(), Currency::USD);
|
||||
let _ = l.add_line(e, a, Side::Debit, m);
|
||||
let _ = l.add_line(e, b, Side::Credit, m);
|
||||
}
|
||||
}
|
||||
Op::AddLineFx { entry, account, debit, foreign, rate } => {
|
||||
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
|
||||
let side = if debit { Side::Debit } else { Side::Credit };
|
||||
let _ = l.add_line_fx(e, a, side, Currency::EUR, foreign.get(), rate.get());
|
||||
}
|
||||
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;
|
||||
if let Some(e) = pick(&entries, entry) {
|
||||
let _ = l.post_entry(e);
|
||||
}
|
||||
let eid = entry_ids[entry as usize % entry_ids.len()];
|
||||
let _ = ledger.post_entry(eid);
|
||||
}
|
||||
Op::ReverseEntry { entry } => {
|
||||
if entry_ids.is_empty() {
|
||||
continue;
|
||||
Op::ReverseEntry { entry, day: d } => {
|
||||
if let Some(e) = pick(&entries, entry)
|
||||
&& let Ok(r) = l.reverse_entry(e, day(d), "f")
|
||||
{
|
||||
entries.push(r);
|
||||
}
|
||||
}
|
||||
Op::CreatePeriod { start, len } => {
|
||||
let _ = l.create_period("p", day(start), day(start.saturating_add(len % 60)));
|
||||
}
|
||||
Op::ClosePeriod { period } => {
|
||||
let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect();
|
||||
if let Some(p) = pick(&ids, period) {
|
||||
let _ = l.close_period(p);
|
||||
}
|
||||
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"
|
||||
);
|
||||
let tb = ledger_core::reports::trial_balance(&l);
|
||||
assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken");
|
||||
for e in l.posted_entries() {
|
||||
assert!(e.validate(l.chart()).is_ok(), "posted entry {} invalid", e.id());
|
||||
}
|
||||
for id in 1..l.next_entry_id() {
|
||||
if let Some(e) = l.get_entry(id)
|
||||
&& e.status() == EntryStatus::Draft
|
||||
{
|
||||
assert!(l.closed_period_containing(e.date()).is_none(), "draft in a closed period");
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user