check / check (push) Canceled after 0s
Settings, conventions (posting templates, approval rules, policies, close steps, calendar, mappings, notes), decisions, open items and entry approvals are journal events (journal format 2). Books that keep their settings never read wanderbooks.toml or config.json; the server and the viewer take the vault key from --vault-key-file instead. books.import_settings moves a settings file in once, exactly as the server reads it. Approval rules, when enforced, gate every posting path. Replay refuses what it can't read as newer, never as corrupt. Lean: a record changes no figure. Mutation on the whole diff: every survivor killed by a test but two equivalent (docs/testing.md). The Company page says a late open item is overdue in words, not colour alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
122 lines
6.3 KiB
Rust
122 lines
6.3 KiB
Rust
// Fuzz target: validly chained journals with fuzz-chosen events.
|
|
//
|
|
// Raw bytes (fuzz_store_open) almost never pass the hash chain, so they
|
|
// never reach replay. Here the journal is written by the real
|
|
// JournalWriter (whole lines, gapless seq, correct prev), starting with a
|
|
// real store.created most of the time (else a store.created with a
|
|
// fuzz-chosen currency or payload, or another type: the writer and the
|
|
// viewer share one rule for the first line); the events after it are
|
|
// fuzz-chosen types with payloads built from the field names replay
|
|
// reads. Oracles:
|
|
// 1. Opening never panics or hangs (an Err is fine: bad facts are
|
|
// refused).
|
|
// 2. Books that open must verify (live ledger = replay = projection).
|
|
// 3. The viewer (Snapshot::load) accepts exactly the journals the writer
|
|
// does, and then holds the same ledger.
|
|
// 4. When both refuse, they agree on whether the books are newer
|
|
// (NewerJournal: a type or format this version doesn't read) or bad.
|
|
#![no_main]
|
|
|
|
use arbitrary::{Arbitrary, Unstructured};
|
|
use libfuzzer_sys::fuzz_target;
|
|
use serde_json::{Map, Value, json};
|
|
|
|
const TYPES: &[&str] = &[
|
|
"account.added", "account.tax_set", "asset.depreciation_posted", "asset.disposed",
|
|
"asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added",
|
|
"customer.added", "document.filed", "entry.created", "entry.line_added",
|
|
"entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set",
|
|
"fx.rate_set", "invoice.created", "invoice.payment_received", "journal.tail_repaired",
|
|
"line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached",
|
|
"receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered",
|
|
"schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added",
|
|
"journal.format_set", "settings.set", "convention.set", "convention.retired",
|
|
"decision.recorded", "open_item.raised", "open_item.resolved", "entry.approved",
|
|
];
|
|
|
|
const FIELDS: &[&str] = &[
|
|
"account", "account_id", "accum_depr_account", "amount", "asset_id", "attachment_id",
|
|
"balance_account", "bill_id", "category", "class_id", "cleared", "close_to",
|
|
"closing_entry", "code", "cost", "cost_account", "currency", "customer_id", "date",
|
|
"deductible_percent", "description", "due_date", "employee_name", "end", "entry",
|
|
"entry_id", "expense_account", "file_name", "foreign_amount", "foreign_currency", "id",
|
|
"into", "invoice_id", "line_index", "lines", "method", "mime_type", "month", "months",
|
|
"name", "parent", "period", "pl_account", "placeholder", "proceeds", "purchase_date",
|
|
"rate", "reason", "recurring_id", "reversal_of", "reverses", "salvage_value",
|
|
"schedule_id", "share", "side", "size_bytes", "start", "tax_category", "tin_on_file",
|
|
"total", "useful_life_months", "vendor_id", "type", "account_type", "rounding",
|
|
"members", "distributions", "sweep", "format", "features", "changes", "key", "value",
|
|
"source", "decided_by", "decided_on", "kind", "title", "body_md", "structured", "subject",
|
|
"decision_md", "supersedes", "resolution", "approver",
|
|
];
|
|
|
|
/// A value replay might plausibly see: small ids, amounts as decimal
|
|
/// strings, dates, sides, currencies, account types, nested objects.
|
|
fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
|
|
Ok(match u.int_in_range(0..=11)? {
|
|
0 => json!(u.int_in_range(0u64..=6)?),
|
|
1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=5000)?, u.int_in_range(0u32..=99)?)),
|
|
2 => json!(format!("2026-{:02}-{:02}", u.int_in_range(1u32..=12)?, u.int_in_range(1u32..=28)?)),
|
|
3 => json!(*u.choose(&["debit", "credit"])?),
|
|
4 => json!(*u.choose(&["USD", "EUR", "JPY", "XYZ"])?),
|
|
5 => json!(*u.choose(&["asset", "liability", "equity", "revenue", "expense"])?),
|
|
6 => json!(bool::arbitrary(u)?),
|
|
7 => Value::Null,
|
|
8 => json!(*u.choose(&["1000", "2000", "3000", "4000", "6000", "x"])?),
|
|
9 if depth < 2 => {
|
|
let n = u.int_in_range(0..=3)?;
|
|
Value::Array((0..n).map(|_| object(u, depth + 1)).collect::<arbitrary::Result<_>>()?)
|
|
}
|
|
10 if depth < 2 => object(u, depth + 1)?,
|
|
_ => json!(String::arbitrary(u)?),
|
|
})
|
|
}
|
|
|
|
fn object(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
|
|
let mut m = Map::new();
|
|
for _ in 0..u.int_in_range(0..=7)? {
|
|
m.insert(u.choose(FIELDS)?.to_string(), value(u, depth)?);
|
|
}
|
|
Ok(Value::Object(m))
|
|
}
|
|
|
|
fuzz_target!(|data: &[u8]| {
|
|
let mut u = Unstructured::new(data);
|
|
let dir = std::env::temp_dir().join(format!("wb-fuzz-replay-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
{
|
|
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
|
let (first, payload) = match u.int_in_range(0..=7).unwrap_or(0) {
|
|
0 => ("store.created", object(&mut u, 0).unwrap_or_default()),
|
|
1 => (*u.choose(TYPES).unwrap_or(&"account.added"), object(&mut u, 0).unwrap_or_default()),
|
|
2 => ("store.created", json!({"base_currency": value(&mut u, 0).unwrap_or_default()})),
|
|
_ => ("store.created", json!({"base_currency": "USD"})),
|
|
};
|
|
w.append(first, payload).unwrap();
|
|
for _ in 0..u.int_in_range(0..=24).unwrap_or(0) {
|
|
let (Ok(t), Ok(p)) = (u.choose(TYPES), object(&mut u, 0)) else { break };
|
|
w.append(t, p).unwrap();
|
|
}
|
|
}
|
|
let store = ledger_store::Store::open(&dir);
|
|
let snap = ledger_store::Snapshot::load(&dir);
|
|
match (store, snap) {
|
|
(Ok(store), Ok(snap)) => {
|
|
store.verify().expect("books that open must verify");
|
|
assert!(
|
|
store.ledger().differs_from(snap.ledger()).is_empty(),
|
|
"the viewer and the writer read different books"
|
|
);
|
|
}
|
|
(Err(a), Err(b)) => {
|
|
let newer = |e: &ledger_store::StoreError| {
|
|
matches!(e, ledger_store::StoreError::NewerJournal { .. })
|
|
};
|
|
assert_eq!(newer(&a), newer(&b), "the writer says {a}; the viewer says {b}");
|
|
}
|
|
(Ok(_), Err(e)) => panic!("the writer opens books the viewer refuses: {e}"),
|
|
(Err(e), Ok(_)) => panic!("the viewer loads books the writer refuses: {e}"),
|
|
}
|
|
});
|