check / check (push) Canceled after 0s
The refusal property gained the converse law (an accepted command journals something; only batch runs may find nothing to do) and checks the store's class and payroll listings. It found a schedule registered with an out-of-range total whose first month was refused after its entry was begun: the live ledger kept a draft the journal never had. - Core: compound commands (post_schedule_month, reverse_entry, close_period_to, create_invoice, create_bill, receive_payment, pay_bill, run_payroll, remit_payroll_taxes) run inside Ledger::atomically, so a refusal partway leaves the ledger exactly as it was. - Core: a schedule total no line could carry, or finer than a cent, and an asset cost beyond the line range, are refused at registration. Replay keeps older schedules (replay_register_schedule). - Store: add_line, add_line_fx, mark_cleared, set_fx_rate, post_schedule_month and close_period_to changed the live ledger before journaling; they now run on a copy, journal what it produced, then swap it in, so a failed write leaves the live books as journaled. New spec-blind suite tests/spec_store.rs (57 tests written from the store's specs alone). Its two findings, adjudicated: - A file filed twice under different names listed the first name and type for both (the projection kept them per blob). Schema v8 gives each attachment its own; older databases backfill and heal by verify. - Withdrawn attachments stay in the store's listing, marked (the MCP listing hides them): the spec and doc said "omit" and are corrected. Also removes journal::truncate_to_last_good_seq: unused, and it dropped everything after the first bad complete line, which the books now treat as corruption for a person, never repaired automatically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
1708 lines
58 KiB
Rust
1708 lines
58 KiB
Rust
//! 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<u8> {
|
|
std::fs::read(journal_path(dir)).unwrap()
|
|
}
|
|
|
|
/// The journal's lines, raw (without the newline).
|
|
fn raw_lines(dir: &Path) -> Vec<Vec<u8>> {
|
|
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<Value> {
|
|
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<String> = sorted
|
|
.iter()
|
|
.map(|(k, v)| format!("{}:{}", serde_json::to_string(k).unwrap(), canon(v)))
|
|
.collect();
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
Value::Array(a) => {
|
|
let parts: Vec<String> = 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::<String>()
|
|
}
|
|
|
|
fn line_types(dir: &Path) -> Vec<String> {
|
|
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<PathBuf, Vec<u8>> {
|
|
fn walk(root: &Path, d: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
|
|
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<String> {
|
|
let mut v: Vec<String> = 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<T: std::fmt::Debug>(
|
|
dir: &Path,
|
|
store: &mut Store,
|
|
f: impl FnOnce(&mut Store) -> Result<T, StoreError>,
|
|
) -> 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<String> = 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<u8>| {
|
|
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<String> = 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<u8> = 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/<aa>/<bb>/<hex>) 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<Value> = 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<u64> = 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<String> = 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));
|
|
}
|