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
227 lines
10 KiB
Rust
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}"),
|
|
}
|
|
});
|