From 4d8d5daf3735ba819ffe9bf794d10c84104fe2b8 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 19:24:34 -0600 Subject: [PATCH] store: refusals leave the live books untouched; per-filing names; spec suite 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 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/ledger.rs | 121 +- crates/ledger-core/src/schedules.rs | 16 + crates/ledger-core/tests/schedules.rs | 54 + crates/ledger-store/src/journal.rs | 39 - crates/ledger-store/src/schema.rs | 14 +- crates/ledger-store/src/store.rs | 63 +- crates/ledger-store/tests/spec_store.rs | 1707 +++++++++++++++++++++++ crates/ledger-store/tests/store.rs | 12 + docs/receipt-store.md | 16 +- fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/fuzz_store_replay.rs | 101 ++ 11 files changed, 2075 insertions(+), 75 deletions(-) create mode 100644 crates/ledger-store/tests/spec_store.rs create mode 100644 fuzz/fuzz_targets/fuzz_store_replay.rs diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 08f9421..2322da2 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -263,6 +263,17 @@ impl Ledger { /// Register an amortization schedule. Returns its id. pub fn register_schedule(&mut self, new: NewSchedule) -> Result { schedules::validate_schedule(&new, &self.chart)?; + self.replay_register_schedule(new) + } + + /// [`Self::register_schedule`] without the total's range and minor-unit + /// checks added on 2026-10-13, for replaying journals written before + /// them. Never for new work. + pub fn replay_register_schedule( + &mut self, + new: NewSchedule, + ) -> Result { + schedules::validate_schedule_shape(&new, &self.chart)?; let id = self.next_schedule_id; self.next_schedule_id += 1; self.schedules.insert( @@ -339,6 +350,15 @@ impl Ledger { &mut self, id: ScheduleId, month_end: NaiveDate, + ) -> Result { + self.atomically(|l| l.post_schedule_month_steps(id, month_end)) + } + + /// The steps of [`Self::post_schedule_month`], run inside [`Self::atomically`]. + fn post_schedule_month_steps( + &mut self, + id: ScheduleId, + month_end: NaiveDate, ) -> Result { let (date, description, lines) = self.preview_schedule_month(id, month_end)?; if let Some(p) = self.closed_period_containing(date) { @@ -532,6 +552,18 @@ impl Ledger { .collect() } + /// Run a command made of several steps as one: if any step is + /// refused, the ledger is exactly as it was (no half-built draft, no + /// id spent). Found 2026-10-13: a schedule month whose amount was out + /// of range left a draft behind. + fn atomically( + &mut self, + steps: impl FnOnce(&mut Self) -> Result, + ) -> Result { + let before = self.clone(); + steps(self).inspect_err(|_| *self = before) + } + /// The id the next account added will receive. /// /// Exposed for journaling stores that record the id before mutating. @@ -1062,6 +1094,16 @@ impl Ledger { entry: EntryId, date: NaiveDate, description: &str, + ) -> Result { + self.atomically(|l| l.reverse_entry_steps(entry, date, description)) + } + + /// The steps of [`Self::reverse_entry`], run inside [`Self::atomically`]. + fn reverse_entry_steps( + &mut self, + entry: EntryId, + date: NaiveDate, + description: &str, ) -> Result { self.check_reversal(entry, date)?; let original = self @@ -1108,6 +1150,11 @@ impl Ledger { if new.cost.amount <= Decimal::ZERO { return Err(LedgerError::InvalidAssetCost(new.cost.amount)); } + // Every depreciation and disposal line derives from the cost: one + // that no line could carry is refused here, not months later. + if new.cost.amount > kernel::MAX_LINE_AMOUNT { + return Err(LedgerError::AmountOutOfRange); + } if new.cost.currency != self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: self.base_currency.code().to_string(), @@ -1379,6 +1426,15 @@ impl Ledger { &mut self, id: PeriodId, to: &periods::CloseTo, + ) -> Result, LedgerError> { + self.atomically(|l| l.close_period_to_steps(id, to)) + } + + /// The steps of [`Self::close_period_to`], run inside [`Self::atomically`]. + fn close_period_to_steps( + &mut self, + id: PeriodId, + to: &periods::CloseTo, ) -> Result, LedgerError> { self.preview_close_period_to(id, to)?; let period = self.periods.get(&id).expect("previewed").clone(); @@ -1892,6 +1948,17 @@ impl Ledger { date: NaiveDate, due_date: NaiveDate, lines: Vec, + ) -> Result { + self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines)) + } + + /// The steps of [`Self::create_invoice`], run inside [`Self::atomically`]. + fn create_invoice_steps( + &mut self, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, ) -> Result { if !self.customers.contains_key(&customer_id) { return Err(LedgerError::UnknownCustomer(customer_id)); @@ -1952,6 +2019,17 @@ impl Ledger { date: NaiveDate, due_date: NaiveDate, lines: Vec, + ) -> Result { + self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines)) + } + + /// The steps of [`Self::create_bill`], run inside [`Self::atomically`]. + fn create_bill_steps( + &mut self, + vendor_id: VendorId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, ) -> Result { if !self.vendors.contains_key(&vendor_id) { return Err(LedgerError::UnknownVendor(vendor_id)); @@ -2021,6 +2099,17 @@ impl Ledger { date: NaiveDate, amount: Decimal, cash_account: AccountId, + ) -> Result { + self.atomically(|l| l.receive_payment_steps(invoice_id, date, amount, cash_account)) + } + + /// The steps of [`Self::receive_payment`], run inside [`Self::atomically`]. + fn receive_payment_steps( + &mut self, + invoice_id: InvoiceId, + date: NaiveDate, + amount: Decimal, + cash_account: AccountId, ) -> Result { if amount <= Decimal::ZERO { return Err(LedgerError::InvalidPaymentAmount(amount)); @@ -2084,6 +2173,17 @@ impl Ledger { date: NaiveDate, amount: Decimal, cash_account: AccountId, + ) -> Result { + self.atomically(|l| l.pay_bill_steps(bill_id, date, amount, cash_account)) + } + + /// The steps of [`Self::pay_bill`], run inside [`Self::atomically`]. + fn pay_bill_steps( + &mut self, + bill_id: BillId, + date: NaiveDate, + amount: Decimal, + cash_account: AccountId, ) -> Result { if amount <= Decimal::ZERO { return Err(LedgerError::InvalidPaymentAmount(amount)); @@ -2589,6 +2689,16 @@ impl Ledger { date: NaiveDate, lines: Vec, cash_account: AccountId, + ) -> Result { + self.atomically(|l| l.run_payroll_steps(date, lines, cash_account)) + } + + /// The steps of [`Self::run_payroll`], run inside [`Self::atomically`]. + fn run_payroll_steps( + &mut self, + date: NaiveDate, + lines: Vec, + cash_account: AccountId, ) -> Result { self.validate_payroll(date, &lines, cash_account)?; let accounts = self.payroll_accounts(); @@ -2649,6 +2759,15 @@ impl Ledger { &mut self, date: NaiveDate, cash_account: AccountId, + ) -> Result { + self.atomically(|l| l.remit_payroll_taxes_steps(date, cash_account)) + } + + /// The steps of [`Self::remit_payroll_taxes`], run inside [`Self::atomically`]. + fn remit_payroll_taxes_steps( + &mut self, + date: NaiveDate, + cash_account: AccountId, ) -> Result { self.chart .get(cash_account) @@ -2808,7 +2927,7 @@ impl Ledger { /// Refuse an amount finer than its currency's minor unit (rules.md B-3). /// A currency with no defined minor unit (ISO 4217 "N.A.") is not checked. -fn check_minor_units(amount: Money) -> Result<(), LedgerError> { +pub(crate) fn check_minor_units(amount: Money) -> Result<(), LedgerError> { match amount.currency.minor_units() { Some(places) if amount.amount.normalize().scale() > places => { Err(LedgerError::FinerThanMinorUnit { diff --git a/crates/ledger-core/src/schedules.rs b/crates/ledger-core/src/schedules.rs index 575807d..858943a 100644 --- a/crates/ledger-core/src/schedules.rs +++ b/crates/ledger-core/src/schedules.rs @@ -112,6 +112,22 @@ pub fn amortization_plan( /// Validate a new schedule against the chart. pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> { + validate_schedule_shape(new, chart)?; + // Every month's posting derives from the total: one no line could + // carry, or finer than a cent, is refused here, not at a month end + // (found by the store's refusal property, 2026-10-13). + if new.total.amount > crate::kernel::MAX_LINE_AMOUNT { + return Err(LedgerError::AmountOutOfRange); + } + crate::ledger::check_minor_units(new.total) +} + +/// [`validate_schedule`] without the total's range and minor-unit checks +/// added on 2026-10-13, for replaying journals written before them. +pub fn validate_schedule_shape( + new: &NewSchedule, + chart: &ChartOfAccounts, +) -> Result<(), LedgerError> { let bad = |m: String| Err(LedgerError::InvalidSchedule(m)); if new.description.trim().is_empty() { return bad("description is required".into()); diff --git a/crates/ledger-core/tests/schedules.rs b/crates/ledger-core/tests/schedules.rs index f6a923a..6c0f280 100644 --- a/crates/ledger-core/tests/schedules.rs +++ b/crates/ledger-core/tests/schedules.rs @@ -353,3 +353,57 @@ fn frequencies_parse_and_step_from_the_start() { ); } } + +/// Regression (found by the store's refusal property, 2026-10-13): a +/// schedule whose total no line could carry, or finer than a cent, is +/// refused when registered, not at a month end. +#[test] +fn a_schedule_total_no_month_could_post_is_refused() { + let mut b = books(); + let new = |total: &str| NewSchedule { + description: "Insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd(total), + start: d(1, 1), + months: 6, + balance_account: b.prepaid, + pl_account: b.insurance, + }; + let huge = new("46116860184273879.03"); + let sub_cent = new("120.005"); + assert!(matches!( + b.l.register_schedule(huge), + Err(LedgerError::AmountOutOfRange) + )); + assert!(matches!( + b.l.register_schedule(sub_cent), + Err(LedgerError::FinerThanMinorUnit { .. }) + )); +} + +/// Regression: a schedule month refused after its entry was begun leaves +/// the ledger exactly as it was: no half-built draft, no id spent +/// (Ledger::atomically). The doomed schedule can only come from an older +/// journal now, so it is registered through the replay path. +#[test] +fn a_refused_schedule_month_leaves_nothing_behind() { + let mut b = books(); + let id = + b.l.replay_register_schedule(NewSchedule { + description: "Insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd("46116860184273879.03"), + start: d(1, 1), + months: 6, + balance_account: b.prepaid, + pl_account: b.insurance, + }) + .unwrap(); + let before = b.l.clone(); + assert!(b.l.post_schedule_month(id, d(1, 31)).is_err()); + assert!( + b.l.differs_from(&before).is_empty(), + "{:?}", + b.l.differs_from(&before) + ); +} diff --git a/crates/ledger-store/src/journal.rs b/crates/ledger-store/src/journal.rs index e92cf8c..bb51f93 100644 --- a/crates/ledger-store/src/journal.rs +++ b/crates/ledger-store/src/journal.rs @@ -290,42 +290,3 @@ pub fn repair_torn_tail(path: &Path) -> Result, StoreError> { saved_as, })) } - -/// Truncate a journal file to its last good line. For recovering *copies* -/// with a torn tail (e.g. a backup taken mid-write): the projection never -/// saw the torn line, so dropping it is safe. Returns the last good seq. -/// The live journal's torn tail is handled on open by [`repair_torn_tail`]. -pub fn truncate_to_last_good_seq(path: &Path) -> Result { - let raw = std::fs::read(path)?; - let mut good_len = 0usize; - let mut last_good_seq = 0u64; - let mut expected_prev = GENESIS_PREV.to_string(); - // Walk raw bytes: a line is bytes up to and including b'\n'. Trailing - // bytes without a newline are a torn tail and are dropped. - let mut start = 0usize; - for (i, &b) in raw.iter().enumerate() { - if b != b'\n' { - continue; - } - let line_bytes = &raw[start..i]; - start = i + 1; - let line_str = std::str::from_utf8(line_bytes).map_err(|_| { - StoreError::JournalCorrupt(format!("seq {}: invalid utf-8", last_good_seq + 1)) - })?; - if line_str.trim().is_empty() { - break; // blank line: our writer never emits these - } - 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; - good_len = i + 1; // include the newline - } - _ => break, - } - } - let file = OpenOptions::new().write(true).open(path)?; - file.set_len(good_len as u64)?; - file.sync_all()?; - Ok(last_good_seq) -} diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 6663aee..d5abefd 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 7; +pub const SCHEMA_VERSION: u32 = 8; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -195,5 +195,17 @@ CREATE TABLE IF NOT EXISTS entry_vendors( entry_id INTEGER PRIMARY KEY, vendor_id INTEGER NOT NULL ); +"#, + r#" +-- v8: each filing keeps its own file name and type. Before, they came from +-- the blob, so filing the same bytes again under another name listed the +-- first name (found by the spec-blind store suite, 2026-10-13). Existing +-- rows take the blob's; a database whose filings differed then fails +-- verify's table check and is rebuilt from the journal. +ALTER TABLE attachments ADD COLUMN file_name TEXT; +ALTER TABLE attachments ADD COLUMN mime_type TEXT; +UPDATE attachments SET + file_name = (SELECT original_name FROM blobs WHERE sha256 = attachments.blob_sha), + mime_type = (SELECT mime_type FROM blobs WHERE sha256 = attachments.blob_sha); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 68a34c9..60bfcba 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -602,7 +602,8 @@ impl Store { side: Side, amount: Money, ) -> Result<(), StoreError> { - self.ledger.add_line(entry, account, side, amount)?; + let mut next = self.ledger.clone(); + next.add_line(entry, account, side, amount)?; let line = self.append_journal( "entry.line_added", json!({ @@ -616,6 +617,7 @@ impl Store { "currency": amount.currency.code(), }), )?; + self.ledger = next; apply_line_to_sqlite(&self.conn, &line)?; Ok(()) } @@ -1128,20 +1130,20 @@ impl Store { id: PeriodId, to: &CloseTo, ) -> Result, StoreError> { - // Pure pre-validation: a doomed close journals nothing, not even - // the Retained Earnings account creation below. - self.ledger.preview_close_period_to(id, to)?; + // Rehearsed whole: a doomed close journals nothing, not even the + // Retained Earnings account creation below. + self.rehearse(|l| l.close_period_to(id, to))?; if matches!(to, CloseTo::RetainedEarnings { .. }) { // Retained Earnings must exist before the close so its creation // (if needed) is journaled as account.added; the close's own // ensure then no-ops and replay stays deterministic. self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?; } - let closing_entry = self.ledger.close_period_to(id, to)?; + let mut next = self.ledger.clone(); + let closing_entry = next.close_period_to(id, to)?; let entry_detail = match closing_entry { Some(eid) => { - let e = self - .ledger + let e = next .get_entry(eid) .expect("close just posted the closing entry"); Some(json!({ @@ -1190,6 +1192,7 @@ impl Store { "entry": entry_detail, }), )?; + self.ledger = next; apply_line_to_sqlite(&self.conn, &line)?; Ok(closing_entry) } @@ -1206,7 +1209,8 @@ impl Store { line_index: usize, cleared: bool, ) -> Result<(), StoreError> { - self.ledger.mark_cleared(entry_id, line_index, cleared)?; + let mut next = self.ledger.clone(); + next.mark_cleared(entry_id, line_index, cleared)?; let line = self.append_journal( "line.cleared", json!({ @@ -1215,6 +1219,7 @@ impl Store { "cleared": cleared, }), )?; + self.ledger = next; apply_line_to_sqlite(&self.conn, &line)?; Ok(()) } @@ -1587,7 +1592,8 @@ impl Store { date: NaiveDate, rate: Decimal, ) -> Result<(), StoreError> { - self.ledger.set_fx_rate(currency, date, rate)?; + let mut next = self.ledger.clone(); + next.set_fx_rate(currency, date, rate)?; let line = self.append_journal( "fx.rate_set", json!({ @@ -1596,6 +1602,7 @@ impl Store { "rate": rate.to_string(), }), )?; + self.ledger = next; apply_line_to_sqlite(&self.conn, &line)?; Ok(()) } @@ -1613,7 +1620,8 @@ impl Store { foreign_amount: Decimal, fx_rate: Decimal, ) -> Result<(), StoreError> { - self.ledger.add_line_fx( + let mut next = self.ledger.clone(); + next.add_line_fx( entry_id, account, side, @@ -1623,8 +1631,7 @@ impl Store { )?; // Read back the stored base amount rather than recomputing it: the // journal must carry exactly what the ledger holds. - let stored = self - .ledger + let stored = next .get_entry(entry_id) .expect("entry exists after add_line_fx") .lines() @@ -1647,6 +1654,7 @@ impl Store { "fx_rate": fx_rate.to_string(), }), )?; + self.ledger = next; apply_line_to_sqlite(&self.conn, &line)?; Ok(()) } @@ -2032,20 +2040,21 @@ impl Store { id: ledger_core::ScheduleId, month_end: NaiveDate, ) -> Result { - // Pure pre-validation, so the mutation below cannot fail. + // On a copy first: the posting can still be refused after the + // preview (an amount out of range, found by the refusal property, + // 2026-10-13), and a refusal must leave the live books untouched. let (date, _, _) = self.ledger.preview_schedule_month(id, month_end)?; - if let Some(p) = self.ledger.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(p, date.to_string()).into()); - } - let entry = self.ledger.post_schedule_month(id, month_end)?; + let mut next = self.ledger.clone(); + let entry = next.post_schedule_month(id, month_end)?; let line = self.append_journal( "schedule.posted", json!({ "schedule_id": id, "month": date.to_string(), - "entry": entry_detail_json(&self.ledger, entry), + "entry": entry_detail_json(&next, entry), }), )?; + self.ledger = next; apply_line_to_sqlite(&self.conn, &line)?; Ok(entry) } @@ -2302,8 +2311,8 @@ impl Store { Ok(entry_id) } - /// Withdraw a receipt or document, recording why: it drops out of - /// listings and can never be linked again. Nothing is deleted: the bytes + /// Withdraw a receipt or document, recording why: it is marked withdrawn + /// (active listings omit it) and can never be linked again. Nothing is deleted: the bytes /// stay in the blob store and the journal keeps the filing and the /// withdrawal. A linked receipt must be unlinked first. /// @@ -2315,8 +2324,9 @@ impl Store { /// 1. IF the attachment is unknown or already withdrawn: SHOULD refuse. /// 2. IF it is linked to an entry: SHOULD refuse (AttachmentLinked). /// 3. IF the reason is blank: SHOULD refuse. - /// 4. Otherwise: SHOULD journal `attachment.withdrawn`; listings omit - /// it; its bytes still verify and read. + /// 4. Otherwise: SHOULD journal `attachment.withdrawn`; the store's + /// listing marks it withdrawn with the reason (the MCP listing + /// hides it unless asked); its bytes still verify and read. /// ``` pub fn withdraw_attachment( &mut self, @@ -3252,7 +3262,7 @@ pub(crate) fn replay_line_into_ledger( } "schedule.registered" => { let want = get_u64(p, "id")?; - let got = ledger.register_schedule(ledger_core::NewSchedule { + let got = ledger.replay_register_schedule(ledger_core::NewSchedule { description: get_str(p, "description")?.to_string(), kind: serde_json::from_value(p["kind"].clone())?, total: parse_money(p, "total")?, @@ -3840,7 +3850,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; conn.execute( - "INSERT INTO attachments(id, blob_sha, entry_id, category, description, created_at) VALUES (?,?,?,?,?,?)", + "INSERT INTO attachments(id, blob_sha, entry_id, category, description, created_at, file_name, mime_type) VALUES (?,?,?,?,?,?,?,?)", params![ get_u64(p, "attachment_id")? as i64, get_str(p, "blob_sha")?, @@ -3848,6 +3858,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr get_str(p, "category")?, p.get("description").and_then(|v| v.as_str()), line.ts, + get_str(p, "file_name")?, + get_str(p, "mime_type")?, ], )?; } @@ -4252,7 +4264,8 @@ pub(crate) fn query_attachments( entry_id: Option, ) -> Result, StoreError> { let mut sql = String::from( - "SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \ + "SELECT a.id, a.blob_sha, a.entry_id, a.category, \ + COALESCE(a.file_name, b.original_name), COALESCE(a.mime_type, b.mime_type), \ b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \ FROM attachments a \ JOIN blobs b ON b.sha256 = a.blob_sha", diff --git a/crates/ledger-store/tests/spec_store.rs b/crates/ledger-store/tests/spec_store.rs new file mode 100644 index 0000000..8e9ad25 --- /dev/null +++ b/crates/ledger-store/tests/spec_store.rs @@ -0,0 +1,1707 @@ +//! 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)); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index e2e02a3..4086a70 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1351,6 +1351,8 @@ mod refusals { } let tb = ledger_core::reports::trial_balance(l); prop_assert_eq!(tb.total_debits, tb.total_credits); + prop_assert_eq!(store.list_classes().len(), l.list_classes().len()); + prop_assert_eq!(store.list_payroll_runs().len(), l.list_payroll_runs().len()); for id in 1..l.next_entry_id() { if let Some(e) = l.get_entry(id) && e.status() == ledger_core::EntryStatus::Draft @@ -1432,6 +1434,16 @@ mod refusals { Err(e) => eprintln!("REFUSED {kind} {e}"), } } + // The converse law: an accepted command is on the record. Only + // a batch run may find nothing to do. + if result.is_ok() && !matches!(op, Op::RunDepreciation(..) | Op::RunRecurring(..)) { + let after = std::fs::read_to_string(&journal).unwrap(); + prop_assert!( + after.len() > before.len(), + "accepted {:?} but journaled nothing", + op + ); + } if let Err(e) = result { let after = std::fs::read_to_string(&journal).unwrap(); prop_assert!( diff --git a/docs/receipt-store.md b/docs/receipt-store.md index 77f0a55..f8b2f2a 100644 --- a/docs/receipt-store.md +++ b/docs/receipt-store.md @@ -21,15 +21,13 @@ easily backupable, journable": from rewriting the chain — nothing local does. - **Backupable.** The backup is `cp -r $WANDERBOOKS_DATA /backup`: the journal file plus the blob directory. Plain files, no WAL-file timing - windows, no special backup API. A torn tail from a mid-copy is detectable - (the last line fails hash/parse) and recoverable (truncate to the last - good seq — the projection never saw it). The live journal recovers the - same way on its own: an append cut off by a crash or power loss (bytes - after the last newline) was never acknowledged, so opening the books - saves those bytes to `journal.jsonl.torn.