check / check (push) Waiting to run
MCP: books.link_attachment, unlink_attachment, attachments; link_to on attach_receipt and file_document; record_statement, statements; books.reconcile previews and, confirmed against a recorded statement, records the reconciliation; void_reconciliation, reconciliations; import_bank_csv links its source file; digest lists unreconciled accounts. TIN vault: books.set_w9 takes the TIN and the form into the encrypted vault (last four in the books); books.vendor_1099 include_tins and save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak test scans every tool output, the journal, the database, and the blobs. [books] parties = off | suggest | require (default suggest): every preview names the entry's party or suggests likely ones; require refuses to post until it names one or is marked as having none (entry.no_party). Viewer: documents on every subject page; statements and reconciliation history per account; reconciliation pages; reconcile and no-counterparty attention items; party money by period (paid out, received in, net, what for, by month); Needs attention gets its own full-width section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
224 lines
7.4 KiB
Rust
224 lines
7.4 KiB
Rust
//! The vault against its spec (crates/ledger-vault/src/lib.rs).
|
|
|
|
use ledger_vault::{Error, Tin, TinType, Vault, W9File};
|
|
use std::path::PathBuf;
|
|
|
|
fn dir(tag: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("wb-vault-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn vault(d: &std::path::Path) -> Vault {
|
|
Vault::open(&d.join("vault"), &d.join("keys/vault.key"), true).unwrap()
|
|
}
|
|
|
|
/// INVARIANT 3: nine digits, hyphens allowed; ITINs start with 9 and SSNs
|
|
/// and EINs don't.
|
|
#[test]
|
|
fn tins_are_nine_digits_of_their_type() {
|
|
assert_eq!(
|
|
Tin::new("123-45-6789", TinType::Ssn).unwrap().digits(),
|
|
"123456789"
|
|
);
|
|
assert_eq!(
|
|
Tin::new("12-3456789", TinType::Ein).unwrap().last4(),
|
|
"6789"
|
|
);
|
|
// Pub. 1915: fourth and fifth digits 50-65, 70-88, 90-92, 94-99.
|
|
for ok in [
|
|
"900-50-0000",
|
|
"900-65-0000",
|
|
"900-70-0000",
|
|
"900-88-0000",
|
|
"900-90-0000",
|
|
"900-92-0000",
|
|
"900-94-0000",
|
|
"900-99-0000",
|
|
] {
|
|
assert!(Tin::new(ok, TinType::Itin).is_ok(), "{ok}");
|
|
}
|
|
for bad in [
|
|
"900-49-0000",
|
|
"900-66-0000",
|
|
"900-69-0000",
|
|
"900-89-0000",
|
|
"900-93-0000",
|
|
"900-00-0000",
|
|
] {
|
|
assert!(Tin::new(bad, TinType::Itin).is_err(), "{bad}");
|
|
}
|
|
for (bad, kind) in [
|
|
("12345678", TinType::Ssn),
|
|
("1234567890", TinType::Ssn),
|
|
("12345678a", TinType::Ein),
|
|
("123 45 6789", TinType::Ssn),
|
|
("912-70-1234", TinType::Ssn),
|
|
("923456789", TinType::Ein),
|
|
("123456789", TinType::Itin),
|
|
] {
|
|
assert!(Tin::new(bad, kind).is_err(), "{bad} as {kind:?}");
|
|
}
|
|
assert_eq!(
|
|
Tin::new("123456789", TinType::Ssn).unwrap().formatted(),
|
|
"123-45-6789"
|
|
);
|
|
assert_eq!(
|
|
Tin::new("123456789", TinType::Ein).unwrap().formatted(),
|
|
"12-3456789"
|
|
);
|
|
}
|
|
|
|
/// INVARIANT 4: Debug and errors never show the digits.
|
|
#[test]
|
|
fn a_tin_never_prints() {
|
|
let tin = Tin::new("123-45-6789", TinType::Ssn).unwrap();
|
|
let shown = format!("{tin:?}");
|
|
assert!(
|
|
!shown.contains("12345") && shown.contains("6789"),
|
|
"{shown}"
|
|
);
|
|
let err = Tin::new("123-45-678", TinType::Ssn)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(!err.contains("12345678"), "{err}");
|
|
}
|
|
|
|
/// INVARIANT 1, 5: a TIN and a W-9 round-trip; the record on disk holds
|
|
/// neither in the clear; record_hash matches the bytes.
|
|
#[test]
|
|
fn records_round_trip_encrypted() {
|
|
let d = dir("roundtrip");
|
|
let v = vault(&d);
|
|
let tin = Tin::new("12-3456789", TinType::Ein).unwrap();
|
|
let hash = v.put_tin(7, &tin).unwrap();
|
|
let back = v.tin(7).unwrap().unwrap();
|
|
assert_eq!(back.digits(), "123456789");
|
|
assert_eq!(back.kind, TinType::Ein);
|
|
let raw = std::fs::read(d.join("vault/tin-7.bin")).unwrap();
|
|
assert!(
|
|
!raw.windows(9).any(|w| w == b"123456789"),
|
|
"TIN in the clear"
|
|
);
|
|
assert_eq!(v.record_hash(7).unwrap().as_deref(), Some(hash.as_str()));
|
|
let w9 = W9File {
|
|
file_name: "w9.pdf".into(),
|
|
mime_type: "application/pdf".into(),
|
|
bytes: b"%PDF secret 123456789".to_vec(),
|
|
};
|
|
v.put_w9(7, &w9).unwrap();
|
|
assert_eq!(v.w9(7).unwrap().unwrap(), w9);
|
|
assert!(v.tin(8).unwrap().is_none(), "SCENARIO 2: missing is None");
|
|
// A fresh nonce each write: the same TIN twice is two different records.
|
|
let again = v.put_tin(7, &tin).unwrap();
|
|
assert_ne!(again, hash);
|
|
}
|
|
|
|
/// INVARIANT 2 and SCENARIO 3: a record moved to another party, altered,
|
|
/// or opened with another key is unreadable, never another party's TIN.
|
|
#[test]
|
|
fn moved_altered_or_foreign_records_refuse() {
|
|
let d = dir("tamper");
|
|
let v = vault(&d);
|
|
v.put_tin(1, &Tin::new("111-22-3333", TinType::Ssn).unwrap())
|
|
.unwrap();
|
|
std::fs::copy(d.join("vault/tin-1.bin"), d.join("vault/tin-2.bin")).unwrap();
|
|
assert!(matches!(v.tin(2), Err(Error::Unreadable { party: 2, .. })));
|
|
let mut raw = std::fs::read(d.join("vault/tin-1.bin")).unwrap();
|
|
let last = raw.len() - 1;
|
|
raw[last] ^= 1;
|
|
std::fs::write(d.join("vault/tin-1.bin"), &raw).unwrap();
|
|
assert!(matches!(v.tin(1), Err(Error::Unreadable { party: 1, .. })));
|
|
let other = Vault::open(&d.join("vault"), &d.join("other.key"), true).unwrap();
|
|
v.put_tin(3, &Tin::new("111-22-3333", TinType::Ssn).unwrap())
|
|
.unwrap();
|
|
assert!(matches!(other.tin(3), Err(Error::Unreadable { .. })));
|
|
}
|
|
|
|
/// SCENARIO 1: no key file and no permission to create one is a locked
|
|
/// vault; a created key file is 0600 and survives reopening.
|
|
#[test]
|
|
fn the_key_file() {
|
|
let d = dir("key");
|
|
assert!(matches!(
|
|
Vault::open(&d.join("vault"), &d.join("missing.key"), false),
|
|
Err(Error::NoKey(_))
|
|
));
|
|
let v = vault(&d);
|
|
v.put_tin(1, &Tin::new("123456789", TinType::Ein).unwrap())
|
|
.unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mode = std::fs::metadata(d.join("keys/vault.key"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode();
|
|
assert_eq!(mode & 0o777, 0o600);
|
|
}
|
|
let reopened = Vault::open(&d.join("vault"), &d.join("keys/vault.key"), false).unwrap();
|
|
assert_eq!(reopened.tin(1).unwrap().unwrap().digits(), "123456789");
|
|
std::fs::write(d.join("bad.key"), "not hex").unwrap();
|
|
assert!(matches!(
|
|
Vault::open(&d.join("vault"), &d.join("bad.key"), false),
|
|
Err(Error::BadKey(_))
|
|
));
|
|
}
|
|
|
|
/// INVARIANT 6: purge deletes the party's records and only theirs.
|
|
#[test]
|
|
fn purge_removes_a_party() {
|
|
let d = dir("purge");
|
|
let v = vault(&d);
|
|
let tin = Tin::new("123456789", TinType::Ein).unwrap();
|
|
v.put_tin(1, &tin).unwrap();
|
|
v.put_tin(2, &tin).unwrap();
|
|
assert!(v.purge(1).unwrap());
|
|
assert!(v.tin(1).unwrap().is_none());
|
|
assert!(!d.join("vault/tin-1.bin").exists());
|
|
assert!(v.tin(2).unwrap().is_some());
|
|
assert!(!v.purge(1).unwrap(), "nothing left to purge");
|
|
}
|
|
|
|
/// INVARIANT 6, without the key: `records_held` says what a party has on
|
|
/// disk and `purge_records` deletes it, so a vault whose key is lost can
|
|
/// still be purged.
|
|
#[test]
|
|
fn purge_and_holdings_need_no_key() {
|
|
let d = dir("keyless");
|
|
let v = vault(&d);
|
|
let tin = Tin::new("123456789", TinType::Ssn).unwrap();
|
|
v.put_tin(1, &tin).unwrap();
|
|
v.put_w9(
|
|
1,
|
|
&W9File {
|
|
file_name: "w9.pdf".into(),
|
|
mime_type: "application/pdf".into(),
|
|
bytes: b"%PDF".to_vec(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
v.put_tin(2, &tin).unwrap();
|
|
std::fs::remove_file(d.join("keys/vault.key")).unwrap();
|
|
let vdir = d.join("vault");
|
|
assert_eq!(ledger_vault::records_held(&vdir, 1).unwrap(), (true, true));
|
|
assert_eq!(ledger_vault::records_held(&vdir, 2).unwrap(), (true, false));
|
|
assert_eq!(
|
|
ledger_vault::records_held(&vdir, 3).unwrap(),
|
|
(false, false)
|
|
);
|
|
assert!(ledger_vault::purge_records(&vdir, 1).unwrap());
|
|
assert_eq!(
|
|
ledger_vault::records_held(&vdir, 1).unwrap(),
|
|
(false, false)
|
|
);
|
|
assert_eq!(ledger_vault::records_held(&vdir, 2).unwrap(), (true, false));
|
|
assert!(!ledger_vault::purge_records(&vdir, 1).unwrap());
|
|
// A vault directory that doesn't exist holds nothing.
|
|
assert_eq!(
|
|
ledger_vault::records_held(&d.join("nowhere"), 1).unwrap(),
|
|
(false, false)
|
|
);
|
|
}
|