//! Spec-blind oracle for `ledger-store`. //! //! Every expectation here is derived from the written specs (the ```text //! GOAL / SOURCE / INVARIANTS / SCENARIOS blocks in the doc comments of //! `store.rs`, `snapshot.rs`, `journal.rs`, `blob.rs`, and the design docs //! `docs/receipt-store.md` and `docs/requirements.md`), never from the //! implementation. A test marked `#[ignore = "FINDING: ..."]` follows the //! spec and fails against the code. use chrono::NaiveDate; use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side}; use ledger_store::journal::repair_torn_tail; use ledger_store::{BlobStore, DB_FILE, JOURNAL_FILE, Snapshot, Store, StoreError}; use rust_decimal::Decimal; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; // ---------------------------------------------------------------- helpers const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000"; fn test_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("wb-spec-store-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } fn usd(amount: u64) -> Money { Money::new(Decimal::from(amount), Currency::USD) } fn date(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).unwrap() } fn journal_path(dir: &Path) -> PathBuf { dir.join(JOURNAL_FILE) } fn jbytes(dir: &Path) -> Vec { std::fs::read(journal_path(dir)).unwrap() } /// The journal's lines, raw (without the newline). fn raw_lines(dir: &Path) -> Vec> { let bytes = jbytes(dir); let mut out = Vec::new(); let mut start = 0; for (i, b) in bytes.iter().enumerate() { if *b == b'\n' { out.push(bytes[start..i].to_vec()); start = i + 1; } } if start < bytes.len() { out.push(bytes[start..].to_vec()); } out } fn parsed_lines(dir: &Path) -> Vec { raw_lines(dir) .iter() .map(|l| serde_json::from_slice(l).unwrap()) .collect() } /// Canonical JSON per the spec: keys sorted recursively, no whitespace. fn canon(v: &Value) -> String { match v { Value::Object(m) => { let sorted: BTreeMap<&String, &Value> = m.iter().collect(); let parts: Vec = sorted .iter() .map(|(k, v)| format!("{}:{}", serde_json::to_string(k).unwrap(), canon(v))) .collect(); format!("{{{}}}", parts.join(",")) } Value::Array(a) => { let parts: Vec = a.iter().map(canon).collect(); format!("[{}]", parts.join(",")) } other => serde_json::to_string(other).unwrap(), } } fn sha_hex(bytes: &[u8]) -> String { let mut h = Sha256::new(); h.update(bytes); h.finalize() .iter() .map(|b| format!("{b:02x}")) .collect::() } fn line_types(dir: &Path) -> Vec { parsed_lines(dir) .iter() .map(|l| l["type"].as_str().unwrap().to_string()) .collect() } /// Every file under `dir` (relative path → bytes). fn tree(dir: &Path) -> BTreeMap> { fn walk(root: &Path, d: &Path, out: &mut BTreeMap>) { for e in std::fs::read_dir(d).unwrap() { let p = e.unwrap().path(); if p.is_dir() { walk(root, &p, out); } else { out.insert( p.strip_prefix(root).unwrap().to_path_buf(), std::fs::read(&p).unwrap(), ); } } } let mut out = BTreeMap::new(); walk(dir, dir, &mut out); out } fn file_names(dir: &Path) -> Vec { let mut v: Vec = std::fs::read_dir(dir) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); v.sort(); v } fn remove_db(dir: &Path) { for suffix in ["", "-wal", "-shm"] { let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}"))); } } /// True when `needle` appears as a string or number anywhere in `v`. fn contains_value(v: &Value, needle: &str) -> bool { match v { Value::String(s) => s == needle, Value::Number(n) => n.to_string() == needle, Value::Array(a) => a.iter().any(|x| contains_value(x, needle)), Value::Object(m) => m.values().any(|x| contains_value(x, needle)), _ => false, } } struct Books { store: Store, cash: AccountId, equity: AccountId, rent: AccountId, e1: EntryId, e2: EntryId, } /// Small books: three accounts, two posted entries, no receipts. fn books(dir: &Path) -> Books { let mut store = Store::create(dir, Currency::USD).unwrap(); let cash = store .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let equity = store .add_account("3000", "Owner Capital", AccountType::Equity, None, false) .unwrap(); let rent = store .add_account("6000", "Rent Expense", AccountType::Expense, None, false) .unwrap(); let e1 = store .create_entry(date(2026, 1, 2), "Owner investment") .unwrap(); store.add_line(e1, cash, Side::Debit, usd(10_000)).unwrap(); store .add_line(e1, equity, Side::Credit, usd(10_000)) .unwrap(); store.post_entry(e1).unwrap(); let e2 = store .create_entry(date(2026, 1, 15), "January rent") .unwrap(); store.add_line(e2, rent, Side::Debit, usd(800)).unwrap(); store.add_line(e2, cash, Side::Credit, usd(800)).unwrap(); store.post_entry(e2).unwrap(); Books { store, cash, equity, rent, e1, e2, } } /// Run a command that must be refused; assert the journal is byte-identical /// and the ledger unchanged afterwards. Returns the error. fn refused( dir: &Path, store: &mut Store, f: impl FnOnce(&mut Store) -> Result, ) -> StoreError { let before = jbytes(dir); let ledger_before = store.ledger().clone(); let seq_before = store.journal_seq(); let atts_before = format!("{:?}", store.list_attachments(None).unwrap()); let err = f(store).expect_err("command should have been refused"); assert_eq!(jbytes(dir), before, "refused command changed the journal"); assert_eq!( store.ledger(), &ledger_before, "refused command changed the ledger" ); assert_eq!(store.journal_seq(), seq_before); assert_eq!( format!("{:?}", store.list_attachments(None).unwrap()), atts_before, "refused command changed the projection's attachments" ); err } /// Assert the chain spec: whole lines, gapless seq, prev chaining. fn assert_chain(dir: &Path) { let bytes = jbytes(dir); assert_eq!( bytes.last(), Some(&b'\n'), "journal must end with a whole line" ); let raws = raw_lines(dir); let mut prev = GENESIS.to_string(); for (i, raw) in raws.iter().enumerate() { let v: Value = serde_json::from_slice(raw).unwrap(); assert_eq!(v["seq"].as_u64(), Some(i as u64 + 1), "seq gap at line {i}"); assert_eq!( v["prev"].as_str(), Some(prev.as_str()), "prev broken at seq {}", i + 1 ); let c = canon(&v); assert_eq!( c.as_bytes(), raw.as_slice(), "writer must emit canonical form (seq {})", i + 1 ); prev = sha_hex(c.as_bytes()); } } // ------------------------------------------------- module INVARIANT 1 /// Oracle: store module INVARIANT 1 — a refused command leaves the journal /// byte-identical and the ledger unchanged (unbalanced post). #[test] fn inv1_refused_unbalanced_post_leaves_journal_identical() { let dir = test_dir("inv1-post"); let mut b = books(&dir); let e = b.store.create_entry(date(2026, 2, 1), "lopsided").unwrap(); b.store.add_line(e, b.cash, Side::Debit, usd(5)).unwrap(); b.store.add_line(e, b.equity, Side::Credit, usd(4)).unwrap(); refused(&dir, &mut b.store, |s| s.post_entry(e)); } /// Oracle: store module INVARIANT 1 — refused commands of several kinds /// leave the journal byte-identical. #[test] fn inv1_refused_commands_of_many_kinds_leave_journal_identical() { let dir = test_dir("inv1-many"); let mut b = books(&dir); let (cash, e1) = (b.cash, b.e1); // Line on an unknown entry. refused(&dir, &mut b.store, |s| { s.add_line(9999, cash, Side::Debit, usd(1)) }); // Line on an unknown account. let d = b.store.create_entry(date(2026, 2, 2), "draft").unwrap(); refused(&dir, &mut b.store, |s| { s.add_line(d, 9999, Side::Debit, usd(1)) }); // Post an unknown entry. refused(&dir, &mut b.store, |s| s.post_entry(9999)); // Re-post an already posted entry. refused(&dir, &mut b.store, |s| s.post_entry(e1)); // Duplicate account code. refused(&dir, &mut b.store, |s| { s.add_account("1000", "Cash again", AccountType::Asset, None, false) }); // Reverse an unknown entry. refused(&dir, &mut b.store, |s| { s.reverse_entry(9999, date(2026, 3, 1), "nope") }); // Receipt commands. refused(&dir, &mut b.store, |s| s.link_receipt(4242, e1)); refused(&dir, &mut b.store, |s| s.unlink_receipt(4242, "why")); refused(&dir, &mut b.store, |s| s.withdraw_attachment(4242, "why")); refused(&dir, &mut b.store, |s| { s.attach_receipt(Some(9999), "r.pdf", "application/pdf", b"x", None) }); } // ------------------------------------------------- module INVARIANT 2 /// Oracle: store module INVARIANT 2 — an accepted command is journaled and /// applied to SQLite after: the projection's event mirror matches the /// journal line for line once each command returns. #[test] fn inv2_accepted_commands_are_journaled_and_applied() { let dir = test_dir("inv2"); let mut b = books(&dir); let n_before = raw_lines(&dir).len(); b.store .add_account("6100", "Software", AccountType::Expense, None, false) .unwrap(); let after = parsed_lines(&dir); assert!(after.len() > n_before, "accepted command must be journaled"); assert_eq!(after.last().unwrap()["type"], "account.added"); assert_eq!(b.store.journal_seq(), after.len() as u64); let events = b.store.events_since(0, 1_000_000).unwrap(); let types: Vec = events.iter().map(|e| e.event_type.clone()).collect(); assert_eq!( types, line_types(&dir), "projection must mirror the journal" ); for (ev, line) in events.iter().zip(after.iter()) { assert_eq!(Some(ev.seq), line["seq"].as_u64()); assert_eq!(ev.payload, line["payload"]); } } // ------------------------------------------------- module INVARIANT 3 /// Oracle: store module INVARIANT 3 + docs/receipt-store.md "Line format" — /// whole lines, gapless 1-based seq, each prev the SHA-256 of the previous /// line's canonical JSON, genesis 64 zeros. #[test] fn inv3_journal_is_whole_gapless_and_chained() { let dir = test_dir("inv3"); let mut b = books(&dir); let (aid, _, _) = b .store .attach_receipt( Some(b.e2), "rent.pdf", "application/pdf", b"rent pdf", Some("rent"), ) .unwrap(); b.store.unlink_receipt(aid, "wrong file").unwrap(); b.store.withdraw_attachment(aid, "superseded").unwrap(); b.store .file_document( "legal", "oa.pdf", "application/pdf", b"operating agreement", None, ) .unwrap(); b.store .reverse_entry(b.e2, date(2026, 1, 31), "oops") .unwrap(); assert_chain(&dir); // Fields per the line format. for l in parsed_lines(&dir) { let obj = l.as_object().unwrap(); for k in ["seq", "ts", "type", "payload", "prev"] { assert!(obj.contains_key(k), "line missing {k}: {l}"); } } drop(b); // Still chained after reopen + more writes. let mut s = Store::open(&dir).unwrap(); s.add_account("6200", "Travel", AccountType::Expense, None, false) .unwrap(); assert_chain(&dir); } // ------------------------------------------------- module INVARIANT 4 /// Oracle: store module INVARIANT 4 — replaying the journal rebuilds /// exactly the live ledger (verify passes; a reopen's ledger equals it). #[test] fn inv4_replay_rebuilds_exactly_the_live_ledger() { let dir = test_dir("inv4"); let mut b = books(&dir); b.store .attach_receipt(Some(b.e1), "dep.pdf", "application/pdf", b"deposit", None) .unwrap(); let draft = b.store.create_entry(date(2026, 3, 3), "a draft").unwrap(); b.store .add_line(draft, b.rent, Side::Debit, usd(7)) .unwrap(); b.store .reverse_entry(b.e2, date(2026, 2, 1), "reverse rent") .unwrap(); b.store.verify().unwrap(); let live = b.store.ledger().clone(); drop(b); let reopened = Store::open(&dir).unwrap(); assert_eq!(reopened.ledger(), &live); reopened.verify().unwrap(); } // ------------------------------------------------- module INVARIANT 5 /// Oracle: store module INVARIANT 5 — a multi-step command (a disposal, /// which auto-posts depreciation first) refused anywhere writes nothing. #[test] fn inv5_refused_disposal_writes_nothing() { let dir = test_dir("inv5"); let mut b = books(&dir); let equip = b .store .add_account("1500", "Equipment", AccountType::Asset, None, false) .unwrap(); let accum = b .store .add_account( "1510", "Accumulated Depreciation", AccountType::Asset, None, false, ) .unwrap(); let depr = b .store .add_account( "6500", "Depreciation Expense", AccountType::Expense, None, false, ) .unwrap(); let asset = b .store .register_asset(NewAsset { description: "Laptop".into(), purchase_date: date(2026, 1, 1), cost: usd(3_600), salvage_value: usd(0), useful_life_months: 36, cost_account: equip, accum_depr_account: accum, expense_account: depr, }) .unwrap(); // Gain/loss and proceeds accounts that do not exist: refused, after the // depreciation step would have run. let cash = b.cash; refused(&dir, &mut b.store, |s| { s.dispose_asset(asset, date(2026, 6, 15), usd(100), cash, 9999) }); refused(&dir, &mut b.store, |s| { s.dispose_asset(asset, date(2026, 6, 15), usd(100), 9998, cash) }); // Unknown asset. refused(&dir, &mut b.store, |s| { s.dispose_asset(777, date(2026, 6, 15), usd(100), cash, cash) }); b.store.verify().unwrap(); } // ------------------------------------------------- module INVARIANT 6 /// Oracle: store module INVARIANT 6 — never delete or rewrite a journal /// line or a stored file: every earlier journal is a prefix of later ones, /// and a withdrawn file's bytes stay stored. #[test] fn inv6_journal_only_grows_and_files_stay() { let dir = test_dir("inv6"); let mut b = books(&dir); let mut prev = jbytes(&dir); let (aid, sha, _) = b .store .attach_receipt(Some(b.e2), "r.pdf", "application/pdf", b"keep me", None) .unwrap(); let blob = dir .join("blobs") .join(&sha[..2]) .join(&sha[2..4]) .join(&sha); let check = |prev: &mut Vec| { let now = jbytes(&dir); assert!(now.starts_with(prev), "journal was rewritten"); *prev = now; }; check(&mut prev); b.store.unlink_receipt(aid, "wrong").unwrap(); check(&mut prev); b.store.withdraw_attachment(aid, "retired").unwrap(); check(&mut prev); b.store.rebuild().unwrap(); check(&mut prev); b.store.verify_and_heal().unwrap(); check(&mut prev); assert_eq!(std::fs::read(&blob).unwrap(), b"keep me"); assert_eq!(b.store.attachment_bytes(aid).unwrap(), b"keep me"); } // ------------------------------------------------- lock_books /// Oracle: lock_books SCENARIO 1 — a second writer on the same books (in /// this process) is refused with Locked, saying so; it never extends the /// journal. #[test] fn lock_second_writer_refused() { let dir = test_dir("lock"); let b = books(&dir); let before = jbytes(&dir); match Store::open(&dir) { Err(StoreError::Locked(msg)) => assert!(!msg.trim().is_empty(), "Locked must say so"), Err(e) => panic!("expected Locked, got {e:?}"), Ok(_) => panic!("two writers opened the same books"), } assert_eq!(jbytes(&dir), before); drop(b); // Released on drop. Store::open(&dir).unwrap(); } // ------------------------------------------------- Store::create /// Oracle: Store::create INVARIANT 1 — the first line is `store.created` /// with the base currency, seq 1, chained from 64 zeros. #[test] fn create_first_line_is_store_created() { let dir = test_dir("create-first"); let s = Store::create(&dir, Currency::USD).unwrap(); let lines = parsed_lines(&dir); assert!(!lines.is_empty()); let first = &lines[0]; assert_eq!(first["type"], "store.created"); assert_eq!(first["seq"], 1); assert_eq!(first["prev"], GENESIS); assert!( contains_value(&first["payload"], "USD"), "payload lacks base currency: {first}" ); assert_eq!(s.base_currency(), Currency::USD); assert_chain(&dir); } /// Oracle: Store::create INVARIANT 2 — the writer lock is held from /// creation on. #[test] fn create_holds_the_lock() { let dir = test_dir("create-lock"); let _s = Store::create(&dir, Currency::USD).unwrap(); assert!(matches!(Store::open(&dir), Err(StoreError::Locked(_)))); } /// Oracle: Store::create SCENARIO 1 — refuses where a journal exists; never /// overwrites or appends. #[test] fn create_refuses_existing_books() { let dir = test_dir("create-exists"); drop(books(&dir)); let before = tree(&dir); let r = Store::create(&dir, Currency::USD); assert!( matches!(r, Err(StoreError::AlreadyExists(_))), "got {:?}", r.err() ); assert_eq!(tree(&dir), before, "create touched existing books"); // A bare journal file alone also counts. let dir2 = test_dir("create-exists-bare"); std::fs::write(journal_path(&dir2), jbytes(&dir)).unwrap(); let before2 = jbytes(&dir2); assert!(Store::create(&dir2, Currency::USD).is_err()); assert_eq!(jbytes(&dir2), before2); } // ------------------------------------------------- Store::open /// Oracle: Store::open SCENARIO 1 — no journal: refuse (NotFound). #[test] fn open_without_journal_is_not_found() { let dir = test_dir("open-nf"); let r = Store::open(&dir); assert!( matches!(r, Err(StoreError::NotFound(_))), "got {:?}", r.err() ); assert!( !journal_path(&dir).exists(), "open must not create a journal" ); } /// Oracle: Store::open SCENARIO 2 — another writer: refuse (Locked). #[test] fn open_while_open_is_locked() { let dir = test_dir("open-locked"); let _b = books(&dir); assert!(matches!(Store::open(&dir), Err(StoreError::Locked(_)))); } /// Oracle: Store::open INVARIANT 1 — the lock is taken before anything is /// read: with another writer holding the books, even a damaged journal /// answers Locked (nothing was read). #[test] fn open_takes_lock_before_reading() { let dir = test_dir("open-lock-first"); let _b = books(&dir); let good = jbytes(&dir); std::fs::write(journal_path(&dir), b"garbage, not a journal\n").unwrap(); let r = Store::open(&dir); std::fs::write(journal_path(&dir), &good).unwrap(); assert!(matches!(r, Err(StoreError::Locked(_))), "got {:?}", r.err()); } /// Oracle: Store::open INVARIANTS 2 and 3 — the ledger is the replay of /// every line, and the projection ends caught up to the head. #[test] fn open_replays_and_catches_up() { let dir = test_dir("open-replay"); let b = books(&dir); let live = b.store.ledger().clone(); drop(b); let s = Store::open(&dir).unwrap(); assert_eq!(s.ledger(), &live); let n = raw_lines(&dir).len() as u64; assert_eq!(s.journal_seq(), n); let evs = s.events_since(0, 1_000_000).unwrap(); assert_eq!(evs.last().map(|e| e.seq), Some(n)); s.verify().unwrap(); } /// Oracle: Store::open SCENARIO 3 — a cut-off last line is set aside, /// truncated, and journaled as `journal.tail_repaired`. #[test] fn open_sets_aside_a_torn_tail() { let dir = test_dir("open-torn"); drop(books(&dir)); let complete = jbytes(&dir); let n = raw_lines(&dir).len(); let torn: &[u8] = b"{\"payload\":{\"code\":\"9"; let mut with_tail = complete.clone(); with_tail.extend_from_slice(torn); std::fs::write(journal_path(&dir), &with_tail).unwrap(); let s = Store::open(&dir).unwrap(); let after = jbytes(&dir); assert!( after.starts_with(&complete), "complete lines must be untouched" ); let lines = parsed_lines(&dir); assert_eq!(lines.len(), n + 1); assert_eq!(lines[n]["type"], "journal.tail_repaired"); assert_chain(&dir); // Saved beside the journal. let saved: Vec = file_names(&dir) .into_iter() .filter(|f| f.starts_with(&format!("{JOURNAL_FILE}.torn."))) .collect(); assert_eq!(saved.len(), 1, "files: {:?}", file_names(&dir)); assert_eq!(std::fs::read(dir.join(&saved[0])).unwrap(), torn); let repairs = s.tail_repairs().unwrap(); assert_eq!(repairs.len(), 1); assert_eq!(repairs[0].1, torn.len() as u64); s.verify().unwrap(); } /// Oracle: Store::open SCENARIO 4 — a complete line that fails its hash is /// refused, never repaired or skipped. #[test] fn open_refuses_a_damaged_complete_line() { let dir = test_dir("open-hash"); drop(books(&dir)); let good = jbytes(&dir); let s = String::from_utf8(good.clone()).unwrap(); assert!(s.contains("Owner investment")); let bad = s.replacen("Owner investment", "Owner investmenT", 1); std::fs::write(journal_path(&dir), bad.as_bytes()).unwrap(); let r = Store::open(&dir); assert!(r.is_err(), "a damaged line must refuse to open"); assert_eq!( jbytes(&dir), bad.as_bytes(), "open must not repair a complete line" ); } /// Oracle: Store::open SCENARIO 4 — a complete line that fails to parse /// is refused. #[test] fn open_refuses_an_unparseable_complete_line() { let dir = test_dir("open-parse"); drop(books(&dir)); let mut raws = raw_lines(&dir); raws[2] = b"this is not json".to_vec(); let mut bytes = raws.join(&b'\n'); bytes.push(b'\n'); std::fs::write(journal_path(&dir), &bytes).unwrap(); assert!(Store::open(&dir).is_err()); assert_eq!(jbytes(&dir), bytes); } /// Oracle: Store::open SCENARIO 4 — a bad `seq` is refused even when the /// hash chain is recomputed to be intact. #[test] fn open_refuses_a_seq_gap_with_an_intact_chain() { let dir = test_dir("open-seq"); drop(books(&dir)); let mut lines = parsed_lines(&dir); // Renumber from line 3 on (skipping a seq), then re-chain. for (i, l) in lines.iter_mut().enumerate().skip(2) { l["seq"] = Value::from(i as u64 + 2); } let mut prev = GENESIS.to_string(); let mut out = Vec::new(); for l in lines.iter_mut() { l["prev"] = Value::from(prev.clone()); let c = canon(l); prev = sha_hex(c.as_bytes()); out.extend_from_slice(c.as_bytes()); out.push(b'\n'); } std::fs::write(journal_path(&dir), &out).unwrap(); assert!(Store::open(&dir).is_err(), "a seq gap must refuse to open"); assert_eq!(jbytes(&dir), out); } /// Oracle: Store::open SCENARIO 5 — a missing database is rebuilt from the /// journal. #[test] fn open_rebuilds_a_missing_database() { let dir = test_dir("open-nodb"); let b = books(&dir); let live = b.store.ledger().clone(); drop(b); remove_db(&dir); let s = Store::open(&dir).unwrap(); assert!(dir.join(DB_FILE).exists()); assert_eq!(s.ledger(), &live); assert_eq!( s.events_since(0, 1_000_000).unwrap().len(), raw_lines(&dir).len() ); s.verify().unwrap(); } /// Oracle: Store::open SCENARIO 6 — a database failing the integrity check /// is kept aside for forensics and rebuilt from the journal. #[test] fn open_keeps_a_corrupt_database_aside_and_rebuilds() { let dir = test_dir("open-corruptdb"); let b = books(&dir); let live = b.store.ledger().clone(); drop(b); remove_db(&dir); let garbage: Vec = b"definitely not an sqlite database " .iter() .cycle() .take(8192) .copied() .collect(); std::fs::write(dir.join(DB_FILE), &garbage).unwrap(); let journal_before = jbytes(&dir); let s = Store::open(&dir).unwrap(); assert_eq!(s.ledger(), &live); s.verify().unwrap(); // Kept aside: some other file in the directory holds the garbage. let kept = tree(&dir) .into_iter() .any(|(p, bytes)| p != Path::new(DB_FILE) && bytes == garbage); assert!( kept, "corrupt db not kept aside; files: {:?}", file_names(&dir) ); assert_eq!( jbytes(&dir), journal_before, "rebuilding the db must not touch the journal" ); } /// Oracle: Store::open SCENARIO 7 — a database behind the journal gets the /// rest applied. #[test] fn open_catches_up_a_database_behind_the_journal() { let dir = test_dir("open-behind"); drop(books(&dir)); let old_db = std::fs::read(dir.join(DB_FILE)).unwrap(); let old_wal = std::fs::read(dir.join(format!("{DB_FILE}-wal"))).ok(); let mut s = Store::open(&dir).unwrap(); s.add_account("6300", "Meals", AccountType::Expense, None, false) .unwrap(); let (aid, _, _) = s .attach_receipt(None, "x.pdf", "application/pdf", b"later", None) .unwrap(); let live = s.ledger().clone(); drop(s); remove_db(&dir); std::fs::write(dir.join(DB_FILE), &old_db).unwrap(); if let Some(w) = old_wal { std::fs::write(dir.join(format!("{DB_FILE}-wal")), w).unwrap(); } let s = Store::open(&dir).unwrap(); assert_eq!(s.ledger(), &live); assert!( s.list_attachments(None) .unwrap() .iter() .any(|a| a.id == aid) ); assert_eq!( s.events_since(0, 1_000_000).unwrap().len(), raw_lines(&dir).len() ); s.verify().unwrap(); } // ------------------------------------------------- journal::repair_torn_tail /// Oracle: repair_torn_tail SCENARIO 1 — a journal ending in a newline is /// left alone. #[test] fn repair_torn_tail_noop_on_whole_journal() { let dir = test_dir("rtt-noop"); drop(books(&dir)); let before = tree(&dir); let r = repair_torn_tail(&journal_path(&dir)).unwrap(); assert!(r.is_none()); assert_eq!(tree(&dir), before); } /// Oracle: repair_torn_tail SCENARIO 2 — bytes after the last newline are /// saved beside the journal, truncated away, and reported. #[test] fn repair_torn_tail_sets_aside_and_reports() { let dir = test_dir("rtt-tail"); drop(books(&dir)); let complete = jbytes(&dir); let torn: &[u8] = b"{\"seq\":99,\"ty"; let mut bytes = complete.clone(); bytes.extend_from_slice(torn); std::fs::write(journal_path(&dir), &bytes).unwrap(); let t = repair_torn_tail(&journal_path(&dir)) .unwrap() .expect("tail reported"); assert_eq!(t.bytes, torn.len() as u64); assert_eq!(t.sha256, sha_hex(torn)); assert_eq!(std::fs::read(&t.saved_as).unwrap(), torn); let parent = t.saved_as.parent().unwrap().canonicalize().unwrap(); assert_eq!( parent, dir.canonicalize().unwrap(), "saved beside the journal" ); assert_eq!(jbytes(&dir), complete); } /// Oracle: repair_torn_tail SCENARIO 3 — never touch a complete line, even /// a damaged one. #[test] fn repair_torn_tail_never_touches_complete_lines() { let dir = test_dir("rtt-damaged"); drop(books(&dir)); let s = String::from_utf8(jbytes(&dir)).unwrap(); let damaged = s.replacen("January rent", "January renT", 1); std::fs::write(journal_path(&dir), damaged.as_bytes()).unwrap(); let _ = repair_torn_tail(&journal_path(&dir)); assert_eq!(jbytes(&dir), damaged.as_bytes()); // Damaged line plus a torn tail: only the tail goes. let mut with_tail = damaged.clone().into_bytes(); with_tail.extend_from_slice(b"{\"partial"); std::fs::write(journal_path(&dir), &with_tail).unwrap(); let _ = repair_torn_tail(&journal_path(&dir)); let after = jbytes(&dir); assert!( after == damaged.as_bytes() || after == with_tail, "a complete line was changed" ); assert!(after.starts_with(damaged.as_bytes())); } // ------------------------------------------------- Store::attach_receipt /// Oracle: Store::attach_receipt INVARIANT 1 — bytes stored under their /// SHA-256 (at blobs///) and read back identical. #[test] fn attach_stores_under_sha256() { let dir = test_dir("attach-sha"); let mut b = books(&dir); let bytes = b"%PDF-1.4 rent receipt"; let (aid, sha, dedup) = b .store .attach_receipt( Some(b.e2), "rent.pdf", "application/pdf", bytes, Some("rent"), ) .unwrap(); assert_eq!(sha, sha_hex(bytes)); assert!(!dedup); let p = dir .join("blobs") .join(&sha[..2]) .join(&sha[2..4]) .join(&sha); assert_eq!(std::fs::read(&p).unwrap(), bytes); assert_eq!(b.store.attachment_bytes(aid).unwrap(), bytes); assert_eq!(b.store.verify_blobs().unwrap(), 1); } /// Oracle: Store::attach_receipt INVARIANTS 2 and 3 — the same bytes filed /// twice share one stored file, the second says deduplicated, and each /// filing is its own journaled attachment with its name, type, description. #[test] fn attach_dedups_bytes_but_not_attachments() { let dir = test_dir("attach-dedup"); let mut b = books(&dir); let bytes = b"same bytes"; let (a1, s1, d1) = b .store .attach_receipt( Some(b.e1), "one.pdf", "application/pdf", bytes, Some("first"), ) .unwrap(); let (a2, s2, d2) = b .store .attach_receipt(Some(b.e2), "two.png", "image/png", bytes, Some("second")) .unwrap(); assert_eq!(s1, s2); assert!(!d1); assert!(d2); assert_ne!(a1, a2); let blob_files: Vec<_> = tree(&dir) .into_keys() .filter(|p| p.starts_with("blobs")) .collect(); assert_eq!(blob_files.len(), 1, "one stored file: {blob_files:?}"); let all = b.store.list_attachments(None).unwrap(); let one = all.iter().find(|a| a.id == a1).expect("first listed"); let two = all.iter().find(|a| a.id == a2).expect("second listed"); assert_eq!(one.entry_id, Some(b.e1)); assert_eq!(two.entry_id, Some(b.e2)); assert_eq!( (one.file_name.as_str(), one.mime_type.as_str()), ("one.pdf", "application/pdf") ); assert_eq!(one.description.as_deref(), Some("first")); assert_eq!(two.description.as_deref(), Some("second")); assert_eq!(one.size_bytes, bytes.len() as u64); assert_eq!(one.blob_sha, s1); let attached: Vec = parsed_lines(&dir) .into_iter() .filter(|l| l["type"] == "receipt.attached") .collect(); assert_eq!(attached.len(), 2); for (l, (name, mime, desc)) in attached.iter().zip([ ("one.pdf", "application/pdf", "first"), ("two.png", "image/png", "second"), ]) { for needle in [name, mime, desc, s1.as_str()] { assert!( contains_value(&l["payload"], needle), "{needle} missing from {l}" ); } } assert_eq!(b.store.attachment_bytes(a2).unwrap(), bytes); } /// Oracle: Store::attach_receipt INVARIANT 3 — each filing is its own /// attachment with ITS name and type, even when its bytes deduplicate /// against an earlier filing. #[test] fn attach_dedup_keeps_each_filings_name_and_type() { let dir = test_dir("attach-dedup-name"); let mut b = books(&dir); let bytes = b"same bytes, two names"; let (a1, _, _) = b .store .attach_receipt(Some(b.e1), "one.pdf", "application/pdf", bytes, None) .unwrap(); let (a2, _, d2) = b .store .attach_receipt(Some(b.e2), "two.png", "image/png", bytes, None) .unwrap(); assert!(d2); let all = b.store.list_attachments(None).unwrap(); let one = all.iter().find(|a| a.id == a1).unwrap(); let two = all.iter().find(|a| a.id == a2).unwrap(); assert_eq!( (one.file_name.as_str(), one.mime_type.as_str()), ("one.pdf", "application/pdf") ); assert_eq!( (two.file_name.as_str(), two.mime_type.as_str()), ("two.png", "image/png") ); let snap = Snapshot::load(&dir).unwrap(); let s2 = snap .attachments(None) .unwrap() .into_iter() .find(|a| a.id == a2) .unwrap(); assert_eq!( (s2.file_name.as_str(), s2.mime_type.as_str()), ("two.png", "image/png") ); } /// Oracle: Store::attach_receipt SCENARIO 1 — an entry_id naming no entry /// is refused; no attachment points at nothing. #[test] fn attach_to_unknown_entry_refused() { let dir = test_dir("attach-unknown"); let mut b = books(&dir); refused(&dir, &mut b.store, |s| { s.attach_receipt(Some(9999), "r.pdf", "application/pdf", b"orphan", None) }); assert!(b.store.list_attachments(None).unwrap().is_empty()); assert!(b.store.list_attachments(Some(9999)).unwrap().is_empty()); } /// Oracle: Store::attach_receipt SCENARIO 2 — entry_id None files it /// unlinked, to link later. #[test] fn attach_unlinked_then_link() { let dir = test_dir("attach-none"); let mut b = books(&dir); let (aid, _, _) = b .store .attach_receipt(None, "later.pdf", "application/pdf", b"later", None) .unwrap(); let a = b.store.list_attachments(None).unwrap(); assert_eq!(a.len(), 1); assert_eq!(a[0].id, aid); assert_eq!(a[0].entry_id, None); b.store.link_receipt(aid, b.e2).unwrap(); assert_eq!(b.store.list_attachments(Some(b.e2)).unwrap()[0].id, aid); } // ------------------------------------------------- Store::link_receipt fn filed_unlinked(b: &mut Books, bytes: &[u8]) -> u64 { b.store .attach_receipt(None, "f.pdf", "application/pdf", bytes, None) .unwrap() .0 } /// Oracle: Store::link_receipt SCENARIO 1 — an unknown attachment is /// refused. #[test] fn link_unknown_attachment_refused() { let dir = test_dir("link-unknown"); let mut b = books(&dir); let e = b.e1; let err = refused(&dir, &mut b.store, |s| s.link_receipt(4242, e)); assert!( matches!(err, StoreError::UnknownAttachment(4242)), "got {err:?}" ); } /// Oracle: Store::link_receipt SCENARIO 2 — a withdrawn attachment is /// refused (AttachmentWithdrawn). #[test] fn link_withdrawn_refused() { let dir = test_dir("link-withdrawn"); let mut b = books(&dir); let aid = filed_unlinked(&mut b, b"w"); b.store.withdraw_attachment(aid, "not needed").unwrap(); let e = b.e1; let err = refused(&dir, &mut b.store, |s| s.link_receipt(aid, e)); assert!( matches!(err, StoreError::AttachmentWithdrawn(x) if x == aid), "got {err:?}" ); } /// Oracle: Store::link_receipt SCENARIO 3 — an attachment linked to any /// entry (the same one or another) is refused (AttachmentLinked). #[test] fn link_already_linked_refused() { let dir = test_dir("link-linked"); let mut b = books(&dir); let (aid, _, _) = b .store .attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"l", None) .unwrap(); let (e1, e2) = (b.e1, b.e2); let err = refused(&dir, &mut b.store, |s| s.link_receipt(aid, e2)); assert!( matches!(err, StoreError::AttachmentLinked(x, y) if x == aid && y == e1), "got {err:?}" ); let err = refused(&dir, &mut b.store, |s| s.link_receipt(aid, e1)); assert!( matches!(err, StoreError::AttachmentLinked(_, _)), "got {err:?}" ); } /// Oracle: Store::link_receipt SCENARIO 4 — an unknown entry is refused. #[test] fn link_unknown_entry_refused() { let dir = test_dir("link-noentry"); let mut b = books(&dir); let aid = filed_unlinked(&mut b, b"u"); refused(&dir, &mut b.store, |s| s.link_receipt(aid, 9999)); assert_eq!(b.store.list_attachments(None).unwrap()[0].entry_id, None); } /// Oracle: Store::link_receipt SCENARIO 5 — otherwise `receipt.linked` is /// journaled and the attachment is linked. #[test] fn link_journals_receipt_linked() { let dir = test_dir("link-ok"); let mut b = books(&dir); let aid = filed_unlinked(&mut b, b"ok"); let n = raw_lines(&dir).len(); b.store.link_receipt(aid, b.e2).unwrap(); let lines = parsed_lines(&dir); assert_eq!(lines.len(), n + 1); let last = &lines[n]; assert_eq!(last["type"], "receipt.linked"); assert!(contains_value(&last["payload"], &aid.to_string())); assert!(contains_value(&last["payload"], &b.e2.to_string())); let a = b.store.list_attachments(Some(b.e2)).unwrap(); assert_eq!(a.len(), 1); assert_eq!(a[0].id, aid); b.store.verify().unwrap(); } // ------------------------------------------------- Store::unlink_receipt /// Oracle: Store::unlink_receipt SCENARIO 1 — unknown, withdrawn, or not /// linked: refused. #[test] fn unlink_refusals() { let dir = test_dir("unlink-refuse"); let mut b = books(&dir); let err = refused(&dir, &mut b.store, |s| s.unlink_receipt(4242, "why")); assert!( matches!(err, StoreError::UnknownAttachment(4242)), "got {err:?}" ); let loose = filed_unlinked(&mut b, b"loose"); let err = refused(&dir, &mut b.store, |s| s.unlink_receipt(loose, "why")); assert!( matches!(err, StoreError::AttachmentNotLinked(x) if x == loose), "got {err:?}" ); let gone = filed_unlinked(&mut b, b"gone"); b.store.withdraw_attachment(gone, "retired").unwrap(); refused(&dir, &mut b.store, |s| s.unlink_receipt(gone, "why")); } /// Oracle: Store::unlink_receipt SCENARIO 2 — a blank reason is refused. #[test] fn unlink_blank_reason_refused() { let dir = test_dir("unlink-blank"); let mut b = books(&dir); let (aid, _, _) = b .store .attach_receipt(Some(b.e2), "r.pdf", "application/pdf", b"b", None) .unwrap(); for reason in ["", " ", "\t\n"] { let err = refused(&dir, &mut b.store, |s| s.unlink_receipt(aid, reason)); assert!( matches!(err, StoreError::ReasonRequired), "{reason:?}: got {err:?}" ); } assert_eq!(b.store.list_attachments(Some(b.e2)).unwrap().len(), 1); } /// Oracle: Store::unlink_receipt SCENARIO 3 — journals `receipt.unlinked` /// with the entry and reason, returns the entry; the file stays stored and /// filed. #[test] fn unlink_journals_and_keeps_the_file() { let dir = test_dir("unlink-ok"); let mut b = books(&dir); let (aid, _, _) = b .store .attach_receipt(Some(b.e2), "r.pdf", "application/pdf", b"unlink me", None) .unwrap(); let n = raw_lines(&dir).len(); let e = b.store.unlink_receipt(aid, "wrong receipt").unwrap(); assert_eq!(e, b.e2); let lines = parsed_lines(&dir); assert_eq!(lines.len(), n + 1); let last = &lines[n]; assert_eq!(last["type"], "receipt.unlinked"); assert!(contains_value(&last["payload"], &b.e2.to_string())); assert!(contains_value(&last["payload"], &aid.to_string())); assert!(contains_value(&last["payload"], "wrong receipt")); assert!(b.store.list_attachments(Some(b.e2)).unwrap().is_empty()); let all = b.store.list_attachments(None).unwrap(); let a = all.iter().find(|a| a.id == aid).expect("still filed"); assert_eq!(a.entry_id, None); assert_eq!(b.store.attachment_bytes(aid).unwrap(), b"unlink me"); // Can be linked again. b.store.link_receipt(aid, b.e1).unwrap(); b.store.verify().unwrap(); } // ------------------------------------------------- Store::withdraw_attachment /// Oracle: Store::withdraw_attachment SCENARIO 1 — unknown or already /// withdrawn: refused. #[test] fn withdraw_unknown_or_twice_refused() { let dir = test_dir("withdraw-refuse"); let mut b = books(&dir); let err = refused(&dir, &mut b.store, |s| s.withdraw_attachment(4242, "why")); assert!( matches!(err, StoreError::UnknownAttachment(4242)), "got {err:?}" ); let aid = filed_unlinked(&mut b, b"twice"); b.store.withdraw_attachment(aid, "first").unwrap(); refused(&dir, &mut b.store, |s| s.withdraw_attachment(aid, "second")); } /// Oracle: Store::withdraw_attachment SCENARIO 2 — a linked attachment is /// refused (AttachmentLinked). #[test] fn withdraw_linked_refused() { let dir = test_dir("withdraw-linked"); let mut b = books(&dir); let (aid, _, _) = b .store .attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"linked", None) .unwrap(); let e1 = b.e1; let err = refused(&dir, &mut b.store, |s| s.withdraw_attachment(aid, "why")); assert!( matches!(err, StoreError::AttachmentLinked(x, y) if x == aid && y == e1), "got {err:?}" ); } /// Oracle: Store::withdraw_attachment SCENARIO 3 — a blank reason is /// refused. #[test] fn withdraw_blank_reason_refused() { let dir = test_dir("withdraw-blank"); let mut b = books(&dir); let aid = filed_unlinked(&mut b, b"blank"); for reason in ["", " ", "\n"] { let err = refused(&dir, &mut b.store, |s| s.withdraw_attachment(aid, reason)); assert!( matches!(err, StoreError::ReasonRequired), "{reason:?}: got {err:?}" ); } } /// Oracle: Store::withdraw_attachment SCENARIO 4 — journals /// `attachment.withdrawn`; its bytes still verify and read; it can never be /// linked again. #[test] fn withdraw_journals_and_bytes_still_read() { let dir = test_dir("withdraw-ok"); let mut b = books(&dir); let aid = filed_unlinked(&mut b, b"retire me"); let (doc, _, _) = b .store .file_document("tax", "1065.pdf", "application/pdf", b"tax doc", None) .unwrap(); let n = raw_lines(&dir).len(); b.store.withdraw_attachment(aid, "duplicate").unwrap(); b.store.withdraw_attachment(doc, "filed in error").unwrap(); let lines = parsed_lines(&dir); assert_eq!(lines.len(), n + 2); for (l, (id, why)) in lines[n..] .iter() .zip([(aid, "duplicate"), (doc, "filed in error")]) { assert_eq!(l["type"], "attachment.withdrawn"); assert!(contains_value(&l["payload"], &id.to_string())); assert!(contains_value(&l["payload"], why)); } assert_eq!(b.store.attachment_bytes(aid).unwrap(), b"retire me"); assert_eq!(b.store.attachment_bytes(doc).unwrap(), b"tax doc"); assert_eq!(b.store.verify_blobs().unwrap(), 2); let e = b.e1; refused(&dir, &mut b.store, |s| s.link_receipt(aid, e)); b.store.verify().unwrap(); } /// Oracle: Store::withdraw_attachment SCENARIO 4 — the store's listing /// marks it withdrawn (with the reason) and the active set omits it. /// (Adjudicated 2026-10-13: the store lists everything, marked; the MCP /// listing hides withdrawn files unless asked.) #[test] fn withdraw_listings_omit_it() { let dir = test_dir("withdraw-listing"); let mut b = books(&dir); let keep = filed_unlinked(&mut b, b"keep"); let aid = filed_unlinked(&mut b, b"omit"); b.store.withdraw_attachment(aid, "retired").unwrap(); let ids: Vec = b .store .list_attachments(None) .unwrap() .iter() .filter(|a| a.withdrawn_at.is_none()) .map(|a| a.id) .collect(); assert_eq!(ids, vec![keep]); let all = b.store.list_attachments(None).unwrap(); let withdrawn = all .iter() .find(|a| a.id == aid) .expect("still listed, marked"); assert!(withdrawn.withdrawn_at.is_some()); assert_eq!(withdrawn.withdrawn_reason.as_deref(), Some("retired")); } // ------------------------------------------------- Store::verify /// Oracle: Store::verify INVARIANT 1 — verify changes nothing. #[test] fn verify_changes_nothing() { let dir = test_dir("verify-nochange"); let mut b = books(&dir); b.store .attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"v", None) .unwrap(); let journal = jbytes(&dir); let names = file_names(&dir); let ledger = b.store.ledger().clone(); let seq = b.store.journal_seq(); let atts = format!("{:?}", b.store.list_attachments(None).unwrap()); let events = b.store.events_since(0, 1_000_000).unwrap().len(); b.store.verify().unwrap(); b.store.verify().unwrap(); assert_eq!(jbytes(&dir), journal); assert_eq!(file_names(&dir), names); assert_eq!(b.store.ledger(), &ledger); assert_eq!(b.store.journal_seq(), seq); assert_eq!( format!("{:?}", b.store.list_attachments(None).unwrap()), atts ); assert_eq!(b.store.events_since(0, 1_000_000).unwrap().len(), events); } /// Oracle: Store::verify INVARIANT 2 — a projection table that differs from /// a fresh projection fails with VerifyMismatch, naming what differs. #[test] fn verify_detects_projection_divergence() { let dir = test_dir("verify-diverge"); let mut b = books(&dir); b.store .attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"d", None) .unwrap(); { let c = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap(); let n = c.execute("DELETE FROM attachments", []).unwrap(); assert_eq!(n, 1); } let journal = jbytes(&dir); match b.store.verify() { Err(StoreError::VerifyMismatch(msg)) => assert!(!msg.trim().is_empty()), other => panic!("expected VerifyMismatch, got {other:?}"), } assert_eq!(jbytes(&dir), journal); } /// Oracle: Store::verify INVARIANT 2 — verify fails unless the chain /// verifies (journal damaged under a live store). #[test] fn verify_detects_a_broken_chain() { let dir = test_dir("verify-chain"); let b = books(&dir); let s = String::from_utf8(jbytes(&dir)).unwrap(); let bad = s.replacen("January rent", "January renT", 1); std::fs::write(journal_path(&dir), bad.as_bytes()).unwrap(); assert!(b.store.verify().is_err()); assert_eq!(jbytes(&dir), bad.as_bytes()); } // ------------------------------------------------- Store::verify_and_heal /// Oracle: Store::verify_and_heal SCENARIO 1 — clean books report the event /// count and head hash, healed: none. #[test] fn heal_clean_books_reports_nothing_healed() { let dir = test_dir("heal-clean"); let mut b = books(&dir); let journal = jbytes(&dir); let r = b.store.verify_and_heal().unwrap(); let lines = parsed_lines(&dir); assert_eq!(r.events, lines.len() as u64); assert_eq!(r.head, sha_hex(canon(lines.last().unwrap()).as_bytes())); assert_eq!(r.healed, None); assert_eq!(jbytes(&dir), journal); } /// Oracle: Store::verify_and_heal SCENARIO 2 — a diverged projection is /// copied aside, rebuilt from the journal, verified again, with why. #[test] fn heal_rebuilds_a_diverged_projection() { let dir = test_dir("heal-diverged"); let mut b = books(&dir); let (aid, _, _) = b .store .attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"h", None) .unwrap(); { let c = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap(); c.execute("DELETE FROM attachments", []).unwrap(); } let journal = jbytes(&dir); let names_before = file_names(&dir); let r = b.store.verify_and_heal().unwrap(); let why = r.healed.expect("must report why it healed"); assert!(!why.trim().is_empty()); assert_eq!(r.events, raw_lines(&dir).len() as u64); assert_eq!(jbytes(&dir), journal, "healing must not touch the journal"); let names_after = file_names(&dir); assert!( names_after.iter().any(|n| !names_before.contains(n)), "the divergent db was not kept aside: {names_before:?} -> {names_after:?}" ); b.store.verify().unwrap(); assert_eq!(b.store.list_attachments(None).unwrap()[0].id, aid); } /// Oracle: Store::verify_and_heal SCENARIO 3 — a journal that fails is /// refused, never touched. #[test] fn heal_refuses_a_broken_journal() { let dir = test_dir("heal-journal"); let mut b = books(&dir); let s = String::from_utf8(jbytes(&dir)).unwrap(); let bad = s.replacen("Owner investment", "Owner investmenT", 1); std::fs::write(journal_path(&dir), bad.as_bytes()).unwrap(); assert!(b.store.verify_and_heal().is_err()); assert_eq!(jbytes(&dir), bad.as_bytes()); } // ------------------------------------------------- Snapshot::refresh /// Oracle: Snapshot::refresh SCENARIO 1 and INVARIANT 1 — nothing appended: /// returns 0, changes nothing, never writes to the data directory. #[test] fn refresh_nothing_appended_returns_zero_and_writes_nothing() { let dir = test_dir("snap-zero"); drop(books(&dir)); let before = tree(&dir); let mut snap = Snapshot::load(&dir).unwrap(); let seq = snap.journal_seq(); let head = snap.head_hash().to_string(); let ledger = snap.ledger().clone(); assert_eq!(snap.refresh().unwrap(), 0); assert_eq!(snap.refresh().unwrap(), 0); assert_eq!(snap.journal_seq(), seq); assert_eq!(snap.head_hash(), head); assert_eq!(snap.ledger(), &ledger); assert_eq!(tree(&dir), before, "snapshot wrote to the data directory"); } /// Oracle: Snapshot::refresh INVARIANTS 2 and 3 — picks up lines the live /// writer appended; afterwards the ledger equals a replay of the lines read. #[test] fn refresh_follows_the_live_writer() { let dir = test_dir("snap-follow"); let mut b = books(&dir); let mut snap = Snapshot::load(&dir).unwrap(); assert_eq!(snap.ledger(), b.store.ledger()); let n0 = raw_lines(&dir).len(); b.store .add_account("6100", "Software", AccountType::Expense, None, false) .unwrap(); b.store .attach_receipt(Some(b.e1), "r.pdf", "application/pdf", b"snap", None) .unwrap(); let n1 = raw_lines(&dir).len(); assert_eq!(snap.refresh().unwrap(), n1 - n0); assert_eq!(snap.journal_seq(), n1 as u64); assert_eq!(snap.journal().len(), n1); assert_eq!(snap.ledger(), b.store.ledger()); let last = parsed_lines(&dir).pop().unwrap(); assert_eq!(snap.head_hash(), sha_hex(canon(&last).as_bytes())); assert_eq!(snap.attachments(None).unwrap().len(), 1); assert_eq!(snap.refresh().unwrap(), 0); // Equal to a fresh replay. let fresh = Snapshot::load(&dir).unwrap(); assert_eq!(snap.ledger(), fresh.ledger()); } /// Oracle: Snapshot::refresh INVARIANT 2 — a line still being appended (no /// newline yet) waits for the next refresh; once completed, it applies. #[test] fn refresh_waits_for_a_partial_line() { let dir = test_dir("snap-partial"); let mut b = books(&dir); b.store .add_account("6100", "Software", AccountType::Expense, None, false) .unwrap(); let full_ledger = b.store.ledger().clone(); drop(b); let full = jbytes(&dir); let raws = raw_lines(&dir); let last = raws.last().unwrap().clone(); let prefix_len = full.len() - last.len() - 1; let prefix = full[..prefix_len].to_vec(); std::fs::write(journal_path(&dir), &prefix).unwrap(); let mut snap = Snapshot::load(&dir).unwrap(); let n = raws.len() as u64 - 1; assert_eq!(snap.journal_seq(), n); let before_ledger = snap.ledger().clone(); // Half the line, no newline. let half = last.len() / 2; let mut partial = prefix.clone(); partial.extend_from_slice(&last[..half]); std::fs::write(journal_path(&dir), &partial).unwrap(); let tree_before = tree(&dir); assert_eq!(snap.refresh().unwrap(), 0); assert_eq!(snap.journal_seq(), n); assert_eq!(snap.ledger(), &before_ledger); // The whole line, still without its newline. let mut no_nl = prefix.clone(); no_nl.extend_from_slice(&last); std::fs::write(journal_path(&dir), &no_nl).unwrap(); assert_eq!( snap.refresh().unwrap(), 0, "a line without its newline is not complete" ); assert_eq!(snap.journal_seq(), n); assert_eq!(tree(&dir).len(), tree_before.len(), "refresh wrote a file"); // Completed. std::fs::write(journal_path(&dir), &full).unwrap(); assert_eq!(snap.refresh().unwrap(), 1); assert_eq!(snap.journal_seq(), n + 1); assert_eq!(snap.ledger(), &full_ledger); assert_eq!(jbytes(&dir), full, "refresh must never write"); } /// Oracle: Snapshot::refresh SCENARIO 2 — the file shrank (a restore, a /// repaired tail): reload from scratch. #[test] fn refresh_reloads_when_the_file_shrinks() { let dir = test_dir("snap-shrink"); let mut b = books(&dir); let short = jbytes(&dir); let short_ledger = b.store.ledger().clone(); let short_n = raw_lines(&dir).len() as u64; b.store .add_account("6100", "Software", AccountType::Expense, None, false) .unwrap(); drop(b); let mut snap = Snapshot::load(&dir).unwrap(); assert_eq!(snap.journal_seq(), short_n + 1); std::fs::write(journal_path(&dir), &short).unwrap(); snap.refresh().unwrap(); assert_eq!(snap.journal_seq(), short_n); assert_eq!(snap.ledger(), &short_ledger); assert_eq!(snap.journal().len() as u64, short_n); } /// Oracle: Snapshot::refresh SCENARIO 2 — the journal no longer chains (a /// different books' journal restored in place): reload from scratch. #[test] fn refresh_reloads_when_the_chain_changes() { let dir = test_dir("snap-rechain"); drop(books(&dir)); let mut snap = Snapshot::load(&dir).unwrap(); // Other books, longer, with a different history. let other = test_dir("snap-rechain-other"); let mut o = Store::create(&other, Currency::USD).unwrap(); for (code, name) in [("1001", "Bank"), ("6100", "Software"), ("6200", "Travel")] { o.add_account(code, name, AccountType::Expense, None, false) .unwrap(); } for _ in 0..20 { o.add_class(&format!("class {}", o.journal_seq())).unwrap(); } drop(o); let other_bytes = jbytes(&other); let ours = jbytes(&dir); assert!(other_bytes.len() > ours.len()); assert!( !other_bytes.starts_with(&ours), "other books must not extend ours" ); std::fs::write(journal_path(&dir), &other_bytes).unwrap(); snap.refresh().unwrap(); let fresh = Snapshot::load(&dir).unwrap(); assert_eq!(snap.journal_seq(), fresh.journal_seq()); assert_eq!(snap.head_hash(), fresh.head_hash()); assert_eq!(snap.ledger(), fresh.ledger()); let last = parsed_lines(&dir).pop().unwrap(); assert_eq!(snap.head_hash(), sha_hex(canon(&last).as_bytes())); } /// Oracle: Snapshot::refresh SCENARIO 3 — a complete new line that fails /// its parse is refused. #[test] fn refresh_refuses_an_unparseable_new_line() { let dir = test_dir("snap-badparse"); drop(books(&dir)); let mut snap = Snapshot::load(&dir).unwrap(); let mut bytes = jbytes(&dir); bytes.extend_from_slice(b"not a journal line\n"); std::fs::write(journal_path(&dir), &bytes).unwrap(); assert!(snap.refresh().is_err()); assert_eq!(jbytes(&dir), bytes, "refresh must never write"); } /// Oracle: Snapshot::refresh SCENARIO 3 — a complete new line that fails /// its hash is refused. #[test] fn refresh_refuses_a_bad_hash_new_line() { let dir = test_dir("snap-badhash"); let mut b = books(&dir); b.store .add_account("6100", "Software", AccountType::Expense, None, false) .unwrap(); drop(b); let full = jbytes(&dir); let raws = raw_lines(&dir); let last = raws.last().unwrap().clone(); let prefix = full[..full.len() - last.len() - 1].to_vec(); std::fs::write(journal_path(&dir), &prefix).unwrap(); let mut snap = Snapshot::load(&dir).unwrap(); // The real next line, its content altered: it no longer hashes to what // a following line would expect, and its own prev is fine, so alter the // prev instead to break the chain onto what the snapshot holds. let mut v: Value = serde_json::from_slice(&last).unwrap(); v["prev"] = Value::from("ab".repeat(32)); let mut bytes = prefix.clone(); bytes.extend_from_slice(canon(&v).as_bytes()); bytes.push(b'\n'); std::fs::write(journal_path(&dir), &bytes).unwrap(); assert!(snap.refresh().is_err()); assert_eq!(jbytes(&dir), bytes); } // ------------------------------------------------- blob::path_for /// Oracle: blob::path_for INVARIANTS 1 and 2 — only a 64-hex-digit id /// names a blob; nothing resolves outside the blob directory, whatever the /// input (observed through the public exists/get). #[test] fn blob_ids_are_only_sha256_hex() { let dir = test_dir("blob-ids"); let root = dir.join("blobs"); let blobs = BlobStore::open(&root).unwrap(); let (sha, dedup) = blobs.put(b"blob bytes").unwrap(); assert!(!dedup); assert_eq!(sha, sha_hex(b"blob bytes")); assert!(blobs.exists(&sha)); assert_eq!(blobs.get(&sha).unwrap(), b"blob bytes"); // Files outside the blob root that traversal would reach. std::fs::write(dir.join("secret"), b"outside").unwrap(); let shard = root.join(&sha[..2]).join(&sha[2..4]); std::fs::write(shard.join("short"), b"not a blob").unwrap(); let bad: Vec = vec![ String::new(), "a".into(), "abc".into(), sha[..63].to_string(), format!("{sha}0"), "../secret".into(), "../../secret".into(), "/etc/passwd".into(), format!("{}/{}/short", &sha[..2], &sha[2..4]), format!("../{}", &sha[3..]), "g".repeat(64), "é".repeat(32), "é".repeat(64), format!("{}/..", &sha[..61]), "..".repeat(32), ]; for id in &bad { assert!(!blobs.exists(id), "{id:?} named a blob"); assert!(blobs.get(id).is_err(), "{id:?} was served"); } let ro = BlobStore::open_read_only(&root); for id in &bad { assert!(!ro.exists(id), "{id:?} named a blob (read-only)"); assert!(ro.get(id).is_err()); } assert!(ro.exists(&sha)); }