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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
27da4b63bf
commit
111a5af470
11 files changed
+2075
-75
No files matched your search
@@ -263,6 +263,17 @@ impl Ledger {
|
|||||||
/// Register an amortization schedule. Returns its id.
|
/// Register an amortization schedule. Returns its id.
|
||||||
pub fn register_schedule(&mut self, new: NewSchedule) -> Result<ScheduleId, LedgerError> {
|
pub fn register_schedule(&mut self, new: NewSchedule) -> Result<ScheduleId, LedgerError> {
|
||||||
schedules::validate_schedule(&new, &self.chart)?;
|
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<ScheduleId, LedgerError> {
|
||||||
|
schedules::validate_schedule_shape(&new, &self.chart)?;
|
||||||
let id = self.next_schedule_id;
|
let id = self.next_schedule_id;
|
||||||
self.next_schedule_id += 1;
|
self.next_schedule_id += 1;
|
||||||
self.schedules.insert(
|
self.schedules.insert(
|
||||||
@@ -339,6 +350,15 @@ impl Ledger {
|
|||||||
&mut self,
|
&mut self,
|
||||||
id: ScheduleId,
|
id: ScheduleId,
|
||||||
month_end: NaiveDate,
|
month_end: NaiveDate,
|
||||||
|
) -> Result<EntryId, LedgerError> {
|
||||||
|
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<EntryId, LedgerError> {
|
) -> Result<EntryId, LedgerError> {
|
||||||
let (date, description, lines) = self.preview_schedule_month(id, month_end)?;
|
let (date, description, lines) = self.preview_schedule_month(id, month_end)?;
|
||||||
if let Some(p) = self.closed_period_containing(date) {
|
if let Some(p) = self.closed_period_containing(date) {
|
||||||
@@ -532,6 +552,18 @@ impl Ledger {
|
|||||||
.collect()
|
.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<T>(
|
||||||
|
&mut self,
|
||||||
|
steps: impl FnOnce(&mut Self) -> Result<T, LedgerError>,
|
||||||
|
) -> Result<T, LedgerError> {
|
||||||
|
let before = self.clone();
|
||||||
|
steps(self).inspect_err(|_| *self = before)
|
||||||
|
}
|
||||||
|
|
||||||
/// The id the next account added will receive.
|
/// The id the next account added will receive.
|
||||||
///
|
///
|
||||||
/// Exposed for journaling stores that record the id before mutating.
|
/// Exposed for journaling stores that record the id before mutating.
|
||||||
@@ -1062,6 +1094,16 @@ impl Ledger {
|
|||||||
entry: EntryId,
|
entry: EntryId,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
description: &str,
|
description: &str,
|
||||||
|
) -> Result<EntryId, LedgerError> {
|
||||||
|
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<EntryId, LedgerError> {
|
) -> Result<EntryId, LedgerError> {
|
||||||
self.check_reversal(entry, date)?;
|
self.check_reversal(entry, date)?;
|
||||||
let original = self
|
let original = self
|
||||||
@@ -1108,6 +1150,11 @@ impl Ledger {
|
|||||||
if new.cost.amount <= Decimal::ZERO {
|
if new.cost.amount <= Decimal::ZERO {
|
||||||
return Err(LedgerError::InvalidAssetCost(new.cost.amount));
|
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 {
|
if new.cost.currency != self.base_currency {
|
||||||
return Err(LedgerError::CurrencyMismatch {
|
return Err(LedgerError::CurrencyMismatch {
|
||||||
expected: self.base_currency.code().to_string(),
|
expected: self.base_currency.code().to_string(),
|
||||||
@@ -1379,6 +1426,15 @@ impl Ledger {
|
|||||||
&mut self,
|
&mut self,
|
||||||
id: PeriodId,
|
id: PeriodId,
|
||||||
to: &periods::CloseTo,
|
to: &periods::CloseTo,
|
||||||
|
) -> Result<Option<EntryId>, 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<Option<EntryId>, LedgerError> {
|
) -> Result<Option<EntryId>, LedgerError> {
|
||||||
self.preview_close_period_to(id, to)?;
|
self.preview_close_period_to(id, to)?;
|
||||||
let period = self.periods.get(&id).expect("previewed").clone();
|
let period = self.periods.get(&id).expect("previewed").clone();
|
||||||
@@ -1892,6 +1948,17 @@ impl Ledger {
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
due_date: NaiveDate,
|
due_date: NaiveDate,
|
||||||
lines: Vec<DocumentLine>,
|
lines: Vec<DocumentLine>,
|
||||||
|
) -> Result<InvoiceId, LedgerError> {
|
||||||
|
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<DocumentLine>,
|
||||||
) -> Result<InvoiceId, LedgerError> {
|
) -> Result<InvoiceId, LedgerError> {
|
||||||
if !self.customers.contains_key(&customer_id) {
|
if !self.customers.contains_key(&customer_id) {
|
||||||
return Err(LedgerError::UnknownCustomer(customer_id));
|
return Err(LedgerError::UnknownCustomer(customer_id));
|
||||||
@@ -1952,6 +2019,17 @@ impl Ledger {
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
due_date: NaiveDate,
|
due_date: NaiveDate,
|
||||||
lines: Vec<DocumentLine>,
|
lines: Vec<DocumentLine>,
|
||||||
|
) -> Result<BillId, LedgerError> {
|
||||||
|
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<DocumentLine>,
|
||||||
) -> Result<BillId, LedgerError> {
|
) -> Result<BillId, LedgerError> {
|
||||||
if !self.vendors.contains_key(&vendor_id) {
|
if !self.vendors.contains_key(&vendor_id) {
|
||||||
return Err(LedgerError::UnknownVendor(vendor_id));
|
return Err(LedgerError::UnknownVendor(vendor_id));
|
||||||
@@ -2021,6 +2099,17 @@ impl Ledger {
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
|
) -> Result<EntryId, LedgerError> {
|
||||||
|
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<EntryId, LedgerError> {
|
) -> Result<EntryId, LedgerError> {
|
||||||
if amount <= Decimal::ZERO {
|
if amount <= Decimal::ZERO {
|
||||||
return Err(LedgerError::InvalidPaymentAmount(amount));
|
return Err(LedgerError::InvalidPaymentAmount(amount));
|
||||||
@@ -2084,6 +2173,17 @@ impl Ledger {
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
|
) -> Result<EntryId, LedgerError> {
|
||||||
|
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<EntryId, LedgerError> {
|
) -> Result<EntryId, LedgerError> {
|
||||||
if amount <= Decimal::ZERO {
|
if amount <= Decimal::ZERO {
|
||||||
return Err(LedgerError::InvalidPaymentAmount(amount));
|
return Err(LedgerError::InvalidPaymentAmount(amount));
|
||||||
@@ -2589,6 +2689,16 @@ impl Ledger {
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
lines: Vec<PayrollLine>,
|
lines: Vec<PayrollLine>,
|
||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
|
) -> Result<PayrollRunId, LedgerError> {
|
||||||
|
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<PayrollLine>,
|
||||||
|
cash_account: AccountId,
|
||||||
) -> Result<PayrollRunId, LedgerError> {
|
) -> Result<PayrollRunId, LedgerError> {
|
||||||
self.validate_payroll(date, &lines, cash_account)?;
|
self.validate_payroll(date, &lines, cash_account)?;
|
||||||
let accounts = self.payroll_accounts();
|
let accounts = self.payroll_accounts();
|
||||||
@@ -2649,6 +2759,15 @@ impl Ledger {
|
|||||||
&mut self,
|
&mut self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
|
) -> Result<EntryId, LedgerError> {
|
||||||
|
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<EntryId, LedgerError> {
|
) -> Result<EntryId, LedgerError> {
|
||||||
self.chart
|
self.chart
|
||||||
.get(cash_account)
|
.get(cash_account)
|
||||||
@@ -2808,7 +2927,7 @@ impl Ledger {
|
|||||||
|
|
||||||
/// Refuse an amount finer than its currency's minor unit (rules.md B-3).
|
/// 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.
|
/// 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() {
|
match amount.currency.minor_units() {
|
||||||
Some(places) if amount.amount.normalize().scale() > places => {
|
Some(places) if amount.amount.normalize().scale() > places => {
|
||||||
Err(LedgerError::FinerThanMinorUnit {
|
Err(LedgerError::FinerThanMinorUnit {
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ pub fn amortization_plan(
|
|||||||
|
|
||||||
/// Validate a new schedule against the chart.
|
/// Validate a new schedule against the chart.
|
||||||
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
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));
|
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||||
if new.description.trim().is_empty() {
|
if new.description.trim().is_empty() {
|
||||||
return bad("description is required".into());
|
return bad("description is required".into());
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -290,42 +290,3 @@ pub fn repair_torn_tail(path: &Path) -> Result<Option<TornTail>, StoreError> {
|
|||||||
saved_as,
|
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<u64, StoreError> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
//! is the current version.
|
//! is the current version.
|
||||||
|
|
||||||
/// Current schema 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`.
|
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||||
pub const MIGRATIONS: &[&str] = &[
|
pub const MIGRATIONS: &[&str] = &[
|
||||||
@@ -195,5 +195,17 @@ CREATE TABLE IF NOT EXISTS entry_vendors(
|
|||||||
entry_id INTEGER PRIMARY KEY,
|
entry_id INTEGER PRIMARY KEY,
|
||||||
vendor_id INTEGER NOT NULL
|
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);
|
||||||
"#,
|
"#,
|
||||||
];
|
];
|
||||||
@@ -602,7 +602,8 @@ impl Store {
|
|||||||
side: Side,
|
side: Side,
|
||||||
amount: Money,
|
amount: Money,
|
||||||
) -> Result<(), StoreError> {
|
) -> 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(
|
let line = self.append_journal(
|
||||||
"entry.line_added",
|
"entry.line_added",
|
||||||
json!({
|
json!({
|
||||||
@@ -616,6 +617,7 @@ impl Store {
|
|||||||
"currency": amount.currency.code(),
|
"currency": amount.currency.code(),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
self.ledger = next;
|
||||||
apply_line_to_sqlite(&self.conn, &line)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1128,20 +1130,20 @@ impl Store {
|
|||||||
id: PeriodId,
|
id: PeriodId,
|
||||||
to: &CloseTo,
|
to: &CloseTo,
|
||||||
) -> Result<Option<EntryId>, StoreError> {
|
) -> Result<Option<EntryId>, StoreError> {
|
||||||
// Pure pre-validation: a doomed close journals nothing, not even
|
// Rehearsed whole: a doomed close journals nothing, not even the
|
||||||
// the Retained Earnings account creation below.
|
// Retained Earnings account creation below.
|
||||||
self.ledger.preview_close_period_to(id, to)?;
|
self.rehearse(|l| l.close_period_to(id, to))?;
|
||||||
if matches!(to, CloseTo::RetainedEarnings { .. }) {
|
if matches!(to, CloseTo::RetainedEarnings { .. }) {
|
||||||
// Retained Earnings must exist before the close so its creation
|
// Retained Earnings must exist before the close so its creation
|
||||||
// (if needed) is journaled as account.added; the close's own
|
// (if needed) is journaled as account.added; the close's own
|
||||||
// ensure then no-ops and replay stays deterministic.
|
// ensure then no-ops and replay stays deterministic.
|
||||||
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
|
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 {
|
let entry_detail = match closing_entry {
|
||||||
Some(eid) => {
|
Some(eid) => {
|
||||||
let e = self
|
let e = next
|
||||||
.ledger
|
|
||||||
.get_entry(eid)
|
.get_entry(eid)
|
||||||
.expect("close just posted the closing entry");
|
.expect("close just posted the closing entry");
|
||||||
Some(json!({
|
Some(json!({
|
||||||
@@ -1190,6 +1192,7 @@ impl Store {
|
|||||||
"entry": entry_detail,
|
"entry": entry_detail,
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
self.ledger = next;
|
||||||
apply_line_to_sqlite(&self.conn, &line)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(closing_entry)
|
Ok(closing_entry)
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1209,8 @@ impl Store {
|
|||||||
line_index: usize,
|
line_index: usize,
|
||||||
cleared: bool,
|
cleared: bool,
|
||||||
) -> Result<(), StoreError> {
|
) -> 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(
|
let line = self.append_journal(
|
||||||
"line.cleared",
|
"line.cleared",
|
||||||
json!({
|
json!({
|
||||||
@@ -1215,6 +1219,7 @@ impl Store {
|
|||||||
"cleared": cleared,
|
"cleared": cleared,
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
self.ledger = next;
|
||||||
apply_line_to_sqlite(&self.conn, &line)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1587,7 +1592,8 @@ impl Store {
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
rate: Decimal,
|
rate: Decimal,
|
||||||
) -> Result<(), StoreError> {
|
) -> 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(
|
let line = self.append_journal(
|
||||||
"fx.rate_set",
|
"fx.rate_set",
|
||||||
json!({
|
json!({
|
||||||
@@ -1596,6 +1602,7 @@ impl Store {
|
|||||||
"rate": rate.to_string(),
|
"rate": rate.to_string(),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
self.ledger = next;
|
||||||
apply_line_to_sqlite(&self.conn, &line)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1613,7 +1620,8 @@ impl Store {
|
|||||||
foreign_amount: Decimal,
|
foreign_amount: Decimal,
|
||||||
fx_rate: Decimal,
|
fx_rate: Decimal,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
self.ledger.add_line_fx(
|
let mut next = self.ledger.clone();
|
||||||
|
next.add_line_fx(
|
||||||
entry_id,
|
entry_id,
|
||||||
account,
|
account,
|
||||||
side,
|
side,
|
||||||
@@ -1623,8 +1631,7 @@ impl Store {
|
|||||||
)?;
|
)?;
|
||||||
// Read back the stored base amount rather than recomputing it: the
|
// Read back the stored base amount rather than recomputing it: the
|
||||||
// journal must carry exactly what the ledger holds.
|
// journal must carry exactly what the ledger holds.
|
||||||
let stored = self
|
let stored = next
|
||||||
.ledger
|
|
||||||
.get_entry(entry_id)
|
.get_entry(entry_id)
|
||||||
.expect("entry exists after add_line_fx")
|
.expect("entry exists after add_line_fx")
|
||||||
.lines()
|
.lines()
|
||||||
@@ -1647,6 +1654,7 @@ impl Store {
|
|||||||
"fx_rate": fx_rate.to_string(),
|
"fx_rate": fx_rate.to_string(),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
self.ledger = next;
|
||||||
apply_line_to_sqlite(&self.conn, &line)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2032,20 +2040,21 @@ impl Store {
|
|||||||
id: ledger_core::ScheduleId,
|
id: ledger_core::ScheduleId,
|
||||||
month_end: NaiveDate,
|
month_end: NaiveDate,
|
||||||
) -> Result<EntryId, StoreError> {
|
) -> Result<EntryId, StoreError> {
|
||||||
// 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)?;
|
let (date, _, _) = self.ledger.preview_schedule_month(id, month_end)?;
|
||||||
if let Some(p) = self.ledger.closed_period_containing(date) {
|
let mut next = self.ledger.clone();
|
||||||
return Err(LedgerError::ClosedPeriod(p, date.to_string()).into());
|
let entry = next.post_schedule_month(id, month_end)?;
|
||||||
}
|
|
||||||
let entry = self.ledger.post_schedule_month(id, month_end)?;
|
|
||||||
let line = self.append_journal(
|
let line = self.append_journal(
|
||||||
"schedule.posted",
|
"schedule.posted",
|
||||||
json!({
|
json!({
|
||||||
"schedule_id": id,
|
"schedule_id": id,
|
||||||
"month": date.to_string(),
|
"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)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
@@ -2302,8 +2311,8 @@ impl Store {
|
|||||||
Ok(entry_id)
|
Ok(entry_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Withdraw a receipt or document, recording why: it drops out of
|
/// Withdraw a receipt or document, recording why: it is marked withdrawn
|
||||||
/// listings and can never be linked again. Nothing is deleted: the bytes
|
/// (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
|
/// stay in the blob store and the journal keeps the filing and the
|
||||||
/// withdrawal. A linked receipt must be unlinked first.
|
/// 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.
|
/// 1. IF the attachment is unknown or already withdrawn: SHOULD refuse.
|
||||||
/// 2. IF it is linked to an entry: SHOULD refuse (AttachmentLinked).
|
/// 2. IF it is linked to an entry: SHOULD refuse (AttachmentLinked).
|
||||||
/// 3. IF the reason is blank: SHOULD refuse.
|
/// 3. IF the reason is blank: SHOULD refuse.
|
||||||
/// 4. Otherwise: SHOULD journal `attachment.withdrawn`; listings omit
|
/// 4. Otherwise: SHOULD journal `attachment.withdrawn`; the store's
|
||||||
/// it; its bytes still verify and read.
|
/// listing marks it withdrawn with the reason (the MCP listing
|
||||||
|
/// hides it unless asked); its bytes still verify and read.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn withdraw_attachment(
|
pub fn withdraw_attachment(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -3252,7 +3262,7 @@ pub(crate) fn replay_line_into_ledger(
|
|||||||
}
|
}
|
||||||
"schedule.registered" => {
|
"schedule.registered" => {
|
||||||
let want = get_u64(p, "id")?;
|
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(),
|
description: get_str(p, "description")?.to_string(),
|
||||||
kind: serde_json::from_value(p["kind"].clone())?,
|
kind: serde_json::from_value(p["kind"].clone())?,
|
||||||
total: parse_money(p, "total")?,
|
total: parse_money(p, "total")?,
|
||||||
@@ -3840,7 +3850,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
conn.execute(
|
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![
|
params![
|
||||||
get_u64(p, "attachment_id")? as i64,
|
get_u64(p, "attachment_id")? as i64,
|
||||||
get_str(p, "blob_sha")?,
|
get_str(p, "blob_sha")?,
|
||||||
@@ -3848,6 +3858,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
|||||||
get_str(p, "category")?,
|
get_str(p, "category")?,
|
||||||
p.get("description").and_then(|v| v.as_str()),
|
p.get("description").and_then(|v| v.as_str()),
|
||||||
line.ts,
|
line.ts,
|
||||||
|
get_str(p, "file_name")?,
|
||||||
|
get_str(p, "mime_type")?,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -4252,7 +4264,8 @@ pub(crate) fn query_attachments(
|
|||||||
entry_id: Option<EntryId>,
|
entry_id: Option<EntryId>,
|
||||||
) -> Result<Vec<Attachment>, StoreError> {
|
) -> Result<Vec<Attachment>, StoreError> {
|
||||||
let mut sql = String::from(
|
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 \
|
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
||||||
FROM attachments a \
|
FROM attachments a \
|
||||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1351,6 +1351,8 @@ mod refusals {
|
|||||||
}
|
}
|
||||||
let tb = ledger_core::reports::trial_balance(l);
|
let tb = ledger_core::reports::trial_balance(l);
|
||||||
prop_assert_eq!(tb.total_debits, tb.total_credits);
|
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() {
|
for id in 1..l.next_entry_id() {
|
||||||
if let Some(e) = l.get_entry(id)
|
if let Some(e) = l.get_entry(id)
|
||||||
&& e.status() == ledger_core::EntryStatus::Draft
|
&& e.status() == ledger_core::EntryStatus::Draft
|
||||||
@@ -1432,6 +1434,16 @@ mod refusals {
|
|||||||
Err(e) => eprintln!("REFUSED {kind} {e}"),
|
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 {
|
if let Err(e) = result {
|
||||||
let after = std::fs::read_to_string(&journal).unwrap();
|
let after = std::fs::read_to_string(&journal).unwrap();
|
||||||
prop_assert!(
|
prop_assert!(
|
||||||
|
|||||||
@@ -21,15 +21,13 @@ easily backupable, journable":
|
|||||||
from rewriting the chain — nothing local does.
|
from rewriting the chain — nothing local does.
|
||||||
- **Backupable.** The backup is `cp -r $WANDERBOOKS_DATA /backup`: the
|
- **Backupable.** The backup is `cp -r $WANDERBOOKS_DATA /backup`: the
|
||||||
journal file plus the blob directory. Plain files, no WAL-file timing
|
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
|
windows, no special backup API. A copy taken mid-write, like a crash or
|
||||||
(the last line fails hash/parse) and recoverable (truncate to the last
|
power loss mid-append, can end in a cut-off line (bytes after the last
|
||||||
good seq — the projection never saw it). The live journal recovers the
|
newline). It was never acknowledged, so opening the books saves those
|
||||||
same way on its own: an append cut off by a crash or power loss (bytes
|
bytes to `journal.jsonl.torn.<time>`, truncates to the last newline, and
|
||||||
after the last newline) was never acknowledged, so opening the books
|
journals a `journal.tail_repaired` line; `books.digest` reports it.
|
||||||
saves those bytes to `journal.jsonl.torn.<time>`, truncates to the last
|
Damage before the last newline is corruption and refuses to open: that
|
||||||
newline, and journals a `journal.tail_repaired` line; `books.digest`
|
is for a human, never repaired automatically. Better: the journal is
|
||||||
reports it. Damage before the last newline is corruption and still
|
|
||||||
refuses to open. Better: the journal is
|
|
||||||
git-friendly, so an optional `wanderbooks backup` that commits it to a
|
git-friendly, so an optional `wanderbooks backup` that commits it to a
|
||||||
private git repo gives versioned, distributed, deduplicated backup with
|
private git repo gives versioned, distributed, deduplicated backup with
|
||||||
full history.
|
full history.
|
||||||
|
|||||||
@@ -57,3 +57,10 @@ path = "fuzz_targets/fuzz_store_open.rs"
|
|||||||
test = false
|
test = false
|
||||||
doc = false
|
doc = false
|
||||||
bench = false
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_store_replay"
|
||||||
|
path = "fuzz_targets/fuzz_store_replay.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Fuzz target: validly chained journals with fuzz-chosen events.
|
||||||
|
//
|
||||||
|
// Raw bytes (fuzz_store_open) almost never pass the hash chain, so they
|
||||||
|
// never reach replay. Here the journal is written by the real
|
||||||
|
// JournalWriter (whole lines, gapless seq, correct prev), starting with a
|
||||||
|
// real store.created; the events after it are fuzz-chosen types with
|
||||||
|
// payloads built from the field names replay reads. Oracles:
|
||||||
|
// 1. Opening never panics or hangs (an Err is fine: bad facts are
|
||||||
|
// refused).
|
||||||
|
// 2. Books that open must verify (live ledger = replay = projection).
|
||||||
|
// 3. The viewer (Snapshot::load) accepts exactly the journals the writer
|
||||||
|
// does, and then holds the same ledger.
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use arbitrary::{Arbitrary, Unstructured};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
const TYPES: &[&str] = &[
|
||||||
|
"account.added", "account.tax_set", "asset.depreciation_posted", "asset.disposed",
|
||||||
|
"asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added",
|
||||||
|
"customer.added", "document.filed", "entry.created", "entry.line_added",
|
||||||
|
"entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set",
|
||||||
|
"fx.rate_set", "invoice.created", "invoice.payment_received", "journal.tail_repaired",
|
||||||
|
"line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached",
|
||||||
|
"receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered",
|
||||||
|
"schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIELDS: &[&str] = &[
|
||||||
|
"account", "account_id", "accum_depr_account", "amount", "asset_id", "attachment_id",
|
||||||
|
"balance_account", "bill_id", "category", "class_id", "cleared", "close_to",
|
||||||
|
"closing_entry", "code", "cost", "cost_account", "currency", "customer_id", "date",
|
||||||
|
"deductible_percent", "description", "due_date", "employee_name", "end", "entry",
|
||||||
|
"entry_id", "expense_account", "file_name", "foreign_amount", "foreign_currency", "id",
|
||||||
|
"into", "invoice_id", "line_index", "lines", "method", "mime_type", "month", "months",
|
||||||
|
"name", "parent", "period", "pl_account", "placeholder", "proceeds", "purchase_date",
|
||||||
|
"rate", "reason", "recurring_id", "reversal_of", "reverses", "salvage_value",
|
||||||
|
"schedule_id", "share", "side", "size_bytes", "start", "tax_category", "tin_on_file",
|
||||||
|
"total", "useful_life_months", "vendor_id", "type", "account_type", "rounding",
|
||||||
|
"members", "distributions", "sweep",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A value replay might plausibly see: small ids, amounts as decimal
|
||||||
|
/// strings, dates, sides, currencies, account types, nested objects.
|
||||||
|
fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
|
||||||
|
Ok(match u.int_in_range(0..=11)? {
|
||||||
|
0 => json!(u.int_in_range(0u64..=6)?),
|
||||||
|
1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=5000)?, u.int_in_range(0u32..=99)?)),
|
||||||
|
2 => json!(format!("2026-{:02}-{:02}", u.int_in_range(1u32..=12)?, u.int_in_range(1u32..=28)?)),
|
||||||
|
3 => json!(*u.choose(&["debit", "credit"])?),
|
||||||
|
4 => json!(*u.choose(&["USD", "EUR", "JPY", "XYZ"])?),
|
||||||
|
5 => json!(*u.choose(&["asset", "liability", "equity", "revenue", "expense"])?),
|
||||||
|
6 => json!(bool::arbitrary(u)?),
|
||||||
|
7 => Value::Null,
|
||||||
|
8 => json!(*u.choose(&["1000", "2000", "3000", "4000", "6000", "x"])?),
|
||||||
|
9 if depth < 2 => {
|
||||||
|
let n = u.int_in_range(0..=3)?;
|
||||||
|
Value::Array((0..n).map(|_| object(u, depth + 1)).collect::<arbitrary::Result<_>>()?)
|
||||||
|
}
|
||||||
|
10 if depth < 2 => object(u, depth + 1)?,
|
||||||
|
_ => json!(String::arbitrary(u)?),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
|
||||||
|
let mut m = Map::new();
|
||||||
|
for _ in 0..u.int_in_range(0..=7)? {
|
||||||
|
m.insert(u.choose(FIELDS)?.to_string(), value(u, depth)?);
|
||||||
|
}
|
||||||
|
Ok(Value::Object(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let mut u = Unstructured::new(data);
|
||||||
|
let dir = std::env::temp_dir().join(format!("wb-fuzz-replay-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
{
|
||||||
|
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||||
|
w.append("store.created", json!({"base_currency": "USD"})).unwrap();
|
||||||
|
for _ in 0..u.int_in_range(0..=24).unwrap_or(0) {
|
||||||
|
let (Ok(t), Ok(p)) = (u.choose(TYPES), object(&mut u, 0)) else { break };
|
||||||
|
w.append(t, p).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let store = ledger_store::Store::open(&dir);
|
||||||
|
let snap = ledger_store::Snapshot::load(&dir);
|
||||||
|
match (store, snap) {
|
||||||
|
(Ok(store), Ok(snap)) => {
|
||||||
|
store.verify().expect("books that open must verify");
|
||||||
|
assert!(
|
||||||
|
store.ledger().differs_from(snap.ledger()).is_empty(),
|
||||||
|
"the viewer and the writer read different books"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Err(_), Err(_)) => {}
|
||||||
|
(Ok(_), Err(e)) => panic!("the writer opens books the viewer refuses: {e}"),
|
||||||
|
(Err(e), Ok(_)) => panic!("the viewer loads books the writer refuses: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user