test: fuzzing, Miri, benchmarks, CI, and a strategy note; the journal refuses floats

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 the owner 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:
swaitsandClaude Opus 5.5 committed 2026-10-13 15:09:22 -06:00
1 parent b35fad7e05
commit c4c4ce05c3
25 files changed
+1635 -81

No files matched your search

+34 -2
View File
@@ -72,6 +72,38 @@ fn sort_value(v: &Value) -> Value {
}
}
impl JournalLine {
/// Parse one journal line, refusing what the journal never holds.
///
/// ```text
/// GOAL: read a journal line so that its canonical form (which the
/// hash chain covers) is a fixed point.
/// SOURCE: R-2 (no floats in money paths); found by fuzz_journal_line
/// (2026-10-13): a float payload re-canonicalized differently.
/// INVARIANTS:
/// 1. NEVER accepts a floating-point number anywhere in the line:
/// amounts are decimal strings and ids are integers.
/// 2. For an accepted line, parse(canonical) re-canonicalizes to the
/// same bytes.
/// ```
pub fn parse(raw: &[u8]) -> Result<JournalLine, String> {
let value: Value = serde_json::from_slice(raw).map_err(|e| e.to_string())?;
if has_float(&value) {
return Err("a number with a fraction or exponent; the journal holds none".into());
}
serde_json::from_value(value).map_err(|e| e.to_string())
}
}
fn has_float(v: &Value) -> bool {
match v {
Value::Number(n) => n.is_f64(),
Value::Array(a) => a.iter().any(has_float),
Value::Object(m) => m.values().any(has_float),
_ => false,
}
}
/// Hex SHA-256 of bytes.
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
@@ -158,7 +190,7 @@ pub fn read_and_verify_journal(path: &Path) -> Result<Vec<JournalLine>, StoreErr
let mut expected_prev = GENESIS_PREV.to_string();
for (i, raw) in reader.lines().enumerate() {
let raw = raw?;
let line: JournalLine = serde_json::from_str(&raw).map_err(|e| {
let line: JournalLine = JournalLine::parse(raw.as_bytes()).map_err(|e| {
StoreError::JournalCorrupt(format!("seq {}: unparseable line: {e}", i + 1))
})?;
let want_seq = i as u64 + 1;
@@ -204,7 +236,7 @@ pub fn truncate_to_last_good_seq(path: &Path) -> Result<u64, StoreError> {
if line_str.trim().is_empty() {
break; // blank line: our writer never emits these
}
match serde_json::from_str::<JournalLine>(line_str) {
match JournalLine::parse(line_str.as_bytes()) {
Ok(line) if line.seq == last_good_seq + 1 && line.prev == expected_prev => {
expected_prev = sha256_hex(line.to_canonical().as_bytes());
last_good_seq = line.seq;
+1 -1
View File
@@ -118,7 +118,7 @@ impl Snapshot {
let complete = &buf[..=last_newline];
let mut added = 0;
for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
let line: JournalLine = serde_json::from_slice(raw).map_err(|e| {
let line: JournalLine = JournalLine::parse(raw).map_err(|e| {
StoreError::JournalCorrupt(format!(
"seq {}: unparseable line: {e}",
self.lines.len() + 1