store: refusals leave the live books untouched; per-filing names; spec suite
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The refusal property gained the converse law (an accepted command journals something; only batch runs may find nothing to do) and checks the store's class and payroll listings. It found a schedule registered with an out-of-range total whose first month was refused after its entry was begun: the live ledger kept a draft the journal never had. - Core: compound commands (post_schedule_month, reverse_entry, close_period_to, create_invoice, create_bill, receive_payment, pay_bill, run_payroll, remit_payroll_taxes) run inside Ledger::atomically, so a refusal partway leaves the ledger exactly as it was. - Core: a schedule total no line could carry, or finer than a cent, and an asset cost beyond the line range, are refused at registration. Replay keeps older schedules (replay_register_schedule). - Store: add_line, add_line_fx, mark_cleared, set_fx_rate, post_schedule_month and close_period_to changed the live ledger before journaling; they now run on a copy, journal what it produced, then swap it in, so a failed write leaves the live books as journaled. New spec-blind suite tests/spec_store.rs (57 tests written from the store's specs alone). Its two findings, adjudicated: - A file filed twice under different names listed the first name and type for both (the projection kept them per blob). Schema v8 gives each attachment its own; older databases backfill and heal by verify. - Withdrawn attachments stay in the store's listing, marked (the MCP listing hides them): the spec and doc said "omit" and are corrected. Also removes journal::truncate_to_last_good_seq: unused, and it dropped everything after the first bad complete line, which the books now treat as corruption for a person, never repaired automatically. 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
8a49f0ff0e
commit
4d8d5daf37
11 files changed
+2075
-75
No files matched your search
@@ -57,3 +57,10 @@ path = "fuzz_targets/fuzz_store_open.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_store_replay"
|
||||
path = "fuzz_targets/fuzz_store_replay.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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; 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.
|
||||
#![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",
|
||||
];
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
/// 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();
|
||||
w.append("store.created", json!({"base_currency": "USD"})).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(_), Err(_)) => {}
|
||||
(Ok(_), Err(e)) => panic!("the writer opens books the viewer refuses: {e}"),
|
||||
(Err(e), Ok(_)) => panic!("the viewer loads books the writer refuses: {e}"),
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user