Files
wanderbooks/fuzz/fuzz_targets/fuzz_settings_replay.rs
T
swaitsandClaude Fable 5.1 76285434cb fix(fuzz): fuzz_settings_replay builds again; a B-27 check for an unknown convention kind
The weekly run's fuzz_settings_replay job failed to build since P3 gave
each projection schema its own file: the target still named
ledger_store::DB_FILE, which became db_file(). The fuzz crate is outside
the workspace, so just check never compiled it; every target builds
again (cargo fuzz build, 2026-10-29).

The same run's fuzz_store_replay crash (the writer refusing a journal
with an unknown convention kind as newer while the viewer said something
else) is not reproduced yet: the hand-made case, kept here as
an_unknown_convention_kind_is_newer_for_the_writer_and_the_viewer_alike,
passes, so the divergence needs the fuzzer's exact journal, which the
weekly job now keeps as an artifact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 01:56:32 -06:00

227 lines
10 KiB
Rust

// Fuzz target: the books' own record of how they are kept (journal format
// 2: settings.set, convention.*, decision.recorded, open_item.*,
// entry.approved), journaled by the real JournalWriter with fuzz-chosen
// keys, values and shapes after a real store and chart.
// Oracles:
// 1. Opening never panics or hangs (an Err is fine: a bad record is
// refused as corrupt, never misread).
// 2. Books that open verify (live = replay = projection, row for row).
// 3. The viewer (Snapshot::load) accepts exactly the journals the writer
// does, and then holds the same settings and conventions.
// 4. Whatever opens can be read every way the server and viewer read it:
// the company, the receipt policy, the dates, the approvals, the
// settings file export, never a panic.
#![no_main]
use arbitrary::{Arbitrary, Unstructured};
use libfuzzer_sys::fuzz_target;
use serde_json::{Map, Value, json};
const TYPES: &[&str] = &[
"settings.set",
"convention.set",
"convention.retired",
"decision.recorded",
"open_item.raised",
"open_item.resolved",
"entry.approved",
"journal.format_set",
];
const KEYS: &[&str] = &[
"company.name", "company.fiscal_year_start", "company.members", "company.tax_form",
"company.tax_classification", "company.timezone", "company.ein", "company.formed",
"books.accounting_basis", "books.cash_accounts", "books.close_into", "books.close_sweep",
"books.asset_tag_prefix", "policy.evidence.threshold", "policy.evidence.required_accounts",
"policy.parties", "policy.approval_rules", "no.such.key",
];
fn scalar(u: &mut Unstructured) -> arbitrary::Result<Value> {
Ok(match u.int_in_range(0..=11)? {
0 => json!(u.int_in_range(0u64..=5)?),
1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=999)?, u.int_in_range(0u32..=99)?)),
11 => json!(*u.choose(&["0.5", "1", "0.3333333333333333333333333333", "79228162514264337593543950335", "-1", "0"])?),
2 => json!(format!("{:02}-{:02}", u.int_in_range(0u32..=13)?, u.int_in_range(0u32..=32)?)),
3 => json!(*u.choose(&["America/Denver", "Mars/Base", "UTC", ""])?),
4 => json!(*u.choose(&["1065", "accrual", "cash", "enforce", "off", "require", "x"])?),
5 => json!(*u.choose(&["1000", "2100", "3000", "3001", "6000", "9999"])?),
6 => json!(format!("2026-{:02}-{:02}", u.int_in_range(0u32..=13)?, u.int_in_range(0u32..=32)?)),
7 => Value::Null,
8 => json!(bool::arbitrary(u)?),
_ => json!(String::arbitrary(u)?),
})
}
fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
Ok(match u.int_in_range(0..=4)? {
0 if depth < 2 => Value::Array(
(0..u.int_in_range(0..=3)?)
.map(|_| value(u, depth + 1))
.collect::<arbitrary::Result<_>>()?,
),
1 if depth < 2 => {
let mut m = Map::new();
for _ in 0..u.int_in_range(0..=5)? {
let k = *u.choose(&[
"name", "share", "capital_account", "files_jointly_with", "lines",
"account", "side", "approver", "accounts", "amount_over", "description",
"share",
])?;
m.insert(k.into(), value(u, depth + 1)?);
}
Value::Object(m)
}
_ => scalar(u)?,
})
}
fn source(u: &mut Unstructured) -> arbitrary::Result<Value> {
Ok(match u.int_in_range(0..=5)? {
0 => value(u, 1)?,
_ => json!({"decided_by": String::arbitrary(u)?, "decided_on": scalar(u)?}),
})
}
fn payload(u: &mut Unstructured, t: &str) -> arbitrary::Result<Value> {
let mut m = Map::new();
match t {
"settings.set" => {
let changes: Vec<Value> = (0..u.int_in_range(0..=4)?)
.map(|_| Ok(json!({"key": *u.choose(KEYS)?, "value": value(u, 0)?})))
.collect::<arbitrary::Result<_>>()?;
m.insert("changes".into(), json!(changes));
}
"convention.set" => {
m.insert("id".into(), json!(format!("c{}", u.int_in_range(0..=3)?)));
m.insert(
"kind".into(),
json!(*u.choose(&["posting_template", "approval_rule", "note", "nope"])?),
);
m.insert("title".into(), json!(String::arbitrary(u)?));
m.insert("body_md".into(), json!(String::arbitrary(u)?));
m.insert("structured".into(), value(u, 0)?);
}
"convention.retired" => {
m.insert("id".into(), json!(format!("c{}", u.int_in_range(0..=3)?)));
m.insert("reason".into(), json!(String::arbitrary(u)?));
}
"decision.recorded" => {
m.insert("id".into(), json!(u.int_in_range(0u64..=4)?));
m.insert("subject".into(), json!(String::arbitrary(u)?));
m.insert("decision_md".into(), json!(String::arbitrary(u)?));
m.insert("decided_by".into(), value(u, 0)?);
m.insert("decided_on".into(), scalar(u)?);
m.insert("supersedes".into(), scalar(u)?);
}
"open_item.raised" | "open_item.resolved" => {
m.insert("id".into(), json!(u.int_in_range(0u64..=4)?));
m.insert("title".into(), json!(String::arbitrary(u)?));
m.insert("resolution".into(), json!(String::arbitrary(u)?));
m.insert("due".into(), scalar(u)?);
}
"entry.approved" => {
m.insert("entry_id".into(), json!(u.int_in_range(0u64..=3)?));
m.insert("approver".into(), json!(String::arbitrary(u)?));
m.insert("fingerprint".into(), scalar(u)?);
}
_ => {
m.insert("format".into(), json!(u.int_in_range(0u64..=3)?));
m.insert("features".into(), json!([]));
}
}
if u.ratio(4, 5)? {
m.insert("source".into(), source(u)?);
}
Ok(Value::Object(m))
}
fuzz_target!(|data: &[u8]| {
let mut u = Unstructured::new(data);
let dir = std::env::temp_dir().join(format!("wb-fuzz-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
// Real books first: a chart and a draft for approvals and
// templates to name.
let mut s = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
for (code, t) in [
("1000", ledger_core::AccountType::Asset),
("3000", ledger_core::AccountType::Equity),
("3001", ledger_core::AccountType::Equity),
("6000", ledger_core::AccountType::Expense),
] {
s.add_account(code, code, t, None, false).unwrap();
}
s.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), "draft")
.unwrap();
// Members the settings may name, for the figures that use shares.
for name in ["A", "B"] {
s.add_party(name, &[ledger_core::Role::Member]).unwrap();
}
}
{
let mut w =
ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
for _ in 0..u.int_in_range(0..=16).unwrap_or(0) {
let Ok(t) = u.choose(TYPES) else { break };
let Ok(p) = payload(&mut u, t) else { break };
w.append(t, p).unwrap();
}
}
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
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");
let l = store.ledger();
assert_eq!(l.settings(), snap.ledger().settings());
assert_eq!(l.conventions(), snap.ledger().conventions());
// Every way the books' record is read.
let s = l.settings();
let c = s.company("USD");
let _ = c.fiscal_year_containing(chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap());
let _ = c.party_members(l);
// The statement of members' equity over the members as set.
let caps: Vec<ledger_core::MemberCapital> = c
.members
.iter()
.filter_map(|m| {
Some(ledger_core::MemberCapital {
name: m.name.clone(),
capital_account: l.chart().find_by_code(&m.capital_account)?.id,
distribution_account: None,
share: m.share,
})
})
.collect();
let d = |y, m, day| chrono::NaiveDate::from_ymd_opt(y, m, day).unwrap();
let _ = ledger_core::members_equity(l, d(2026, 1, 1), d(2026, 12, 31), &caps);
let _ = ledger_config::codec::books_settings(s, "USD").asset_tag_prefix();
let _ = ledger_config::codec::receipt_policy(s).threshold();
let _ = ledger_config::codec::export(s, "USD");
let _ = s.today();
let _ = s.check_whole();
for (k, r) in s.iter() {
let _ = ledger_core::settings::display(k, &r.value, false);
}
for e in 1..l.next_entry_id() {
let _ = l.approvals_missing(e);
}
for conv in l.conventions().conventions() {
let _ = conv.summary();
let slots = [("expense".to_string(), "6000".to_string())].into();
let _ = l.template_lines(&conv.id, rust_decimal::Decimal::new(10001, 2), &slots);
let _ = l.template_lines(&conv.id, rust_decimal::Decimal::new(10001, 2), &Default::default());
}
}
(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}"),
}
});