P2 slice 5: files in and out

Files reach the books through the server: uploads by hash and size,
then filed; exports (the CPA package, the 1099 worksheet, W-9s) handed
over as downloads from the server, never read off disk by the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent d1f3ad891a
commit b26c099a68
44 files changed
+5075 -2739

No files matched your search

+37 -21
View File
@@ -67,8 +67,9 @@ impl Drop for Mcp {
}
}
/// The places one run uses: the books, the vault key, the import root, the
/// folder the server runs in (where a relative path lands).
/// The places one run uses: the books, the vault key, a folder of files on
/// their way in (as a downloads folder), the folder the server runs in
/// (where a relative path lands).
struct Places {
base: PathBuf,
books: PathBuf,
@@ -130,8 +131,6 @@ impl Mcp {
.arg(p.books.join("books-data"))
.arg("--books-toml")
.arg(p.books.join("wanderbooks.toml"))
.arg("--import-root")
.arg(&p.inbox)
.current_dir(&p.sandbox)
.env("HOME", p.base.join("home"))
.env("XDG_CONFIG_HOME", p.base.join("config"))
@@ -256,7 +255,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
m.ok(
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0"}),
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a receipt")}),
);
for (date, dr, cr, amount) in [
("2026-01-02", "1000", "3000", "5000.00"),
@@ -279,7 +279,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
m.ok(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(pdf.as_bytes())}),
"content_base64": b64(pdf.as_bytes()),
"sha256": ledger_store::journal::sha256_hex(pdf.as_bytes())}),
);
m.ok(
"books.post_entry",
@@ -321,7 +322,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "s.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ="}),
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ=",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a statement")}),
);
let _ = p;
}
@@ -500,8 +502,8 @@ fn hostile_numbers() -> Vec<Value> {
]
}
/// Paths a tool may be given: inside the sandbox, the import root, the
/// books, relative ones, odd ones. Never anywhere a real file lives.
/// Paths a tool may be given: inside the sandbox, the inbox, the books,
/// relative ones, odd ones. Never anywhere a real file lives.
fn hostile_paths(p: &Places) -> Vec<String> {
let s = |p: &Path| p.to_str().unwrap().to_string();
vec![
@@ -618,17 +620,20 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
.boxed();
}
if n.contains("base64") {
likely.extend(
[
"JVBERi0xLjQ=",
"",
"!!!not base64",
"AAAA",
"NTcxLTkzLTI4NDY=",
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
]
.map(String::from),
);
likely.extend(INLINE_FILES.map(String::from));
}
// A file's SHA-256: of one of the files above (so some calls carry a
// matching pair and go on past the check), or not.
if n.contains("sha256") {
use base64::Engine;
likely.extend(INLINE_FILES.iter().filter_map(|f| {
base64::engine::general_purpose::STANDARD
.decode(f)
.ok()
.map(|b| ledger_store::journal::sha256_hex(&b))
}));
likely.push("0".repeat(64));
likely.push("not hex".into());
}
let pick = |pred: fn(&str) -> bool| -> Vec<String> {
hostile_strings().into_iter().filter(|s| pred(s)).collect()
@@ -694,6 +699,16 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
.boxed()
}
/// Files given inline (base64), hostile and not.
const INLINE_FILES: [&str; 6] = [
"JVBERi0xLjQ=",
"",
"!!!not base64",
"AAAA",
"NTcxLTkzLTI4NDY=",
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
];
/// An object's value: each property present or not (required ones mostly
/// present), and now and then a field no schema names.
fn object_for(
@@ -1144,7 +1159,8 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
&mut m,
"books.attach_receipt",
json!({"entry_id": id, "file_name": "h.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgaHVnZQ=="}),
"content_base64": "JVBERi0xLjQgaHVnZQ==",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 huge")}),
);
pinned(
&mut m,
+2 -1
View File
@@ -119,7 +119,8 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
c,
"books.attach_receipt",
json!({"entry_id": id, "file_name": format!("{tag}.pdf"),
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes())}),
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes()),
"sha256": ledger_store::journal::sha256_hex(tag.as_bytes())}),
)
.await
.unwrap();
+87 -234
View File
@@ -126,8 +126,7 @@ impl Mcp {
}
}
/// A test's dir and what sits beside it (`.inbox`, `.elsewhere.txt`),
/// removed when the test ends, pass or panic.
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir {
@@ -146,11 +145,6 @@ impl AsRef<std::path::Path> for TestDir {
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
for beside in [".inbox", ".elsewhere.txt"] {
let p = std::path::PathBuf::from(format!("{}{beside}", self.0.display()));
let _ = std::fs::remove_dir_all(&p);
let _ = std::fs::remove_file(&p);
}
}
}
@@ -185,6 +179,9 @@ fn mcp_end_to_end_over_stdio() {
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
@@ -267,6 +264,7 @@ fn mcp_end_to_end_over_stdio() {
"file_name": "rent.png",
"mime_type": "image/png",
"content_base64": png,
"sha256": sha(b"fake-png-bytes"),
}),
);
assert_eq!(attached["deduplicated"], false);
@@ -461,6 +459,7 @@ fn mcp_assets_end_to_end_over_stdio() {
"file_name": "bill-of-sale.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"fake bill of sale"),
"sha256": sha(b"fake bill of sale"),
}),
);
let aid = bill["attachment_id"].as_u64().unwrap();
@@ -628,7 +627,8 @@ fn mcp_receipt_corrections_over_stdio() {
let att = mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
"content_base64": base64_like(receipt)}),
"content_base64": base64_like(receipt),
"sha256": sha(receipt)}),
);
let posted = mcp.call(
"books.post_entry",
@@ -655,7 +655,8 @@ fn mcp_receipt_corrections_over_stdio() {
let pdf = mcp.call(
"books.attach_receipt",
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"pdf-bytes")}),
"content_base64": base64_like(b"pdf-bytes"),
"sha256": sha(b"pdf-bytes")}),
)["attachment_id"]
.as_u64()
.unwrap();
@@ -782,46 +783,6 @@ fn mcp_resources_over_stdio() {
assert!(resp.get("error").is_some());
}
/// `--import-root` replaces the default import root (the downloads
/// folder): a file in the named folder is filed; one beside it is not.
#[test]
fn import_root_flag_replaces_the_defaults() {
let dir = test_dir("import-root");
let inbox = dir.with_extension("inbox");
std::fs::create_dir_all(&inbox).unwrap();
let inside = inbox.join("receipt.txt");
let temp = dir.with_extension("elsewhere.txt");
for p in [&inside, &temp] {
std::fs::write(p, b"a receipt").unwrap();
}
let mut mcp = Mcp::spawn_with(
&dir.join("books-data"),
&[std::ffi::OsStr::new("--import-root"), inbox.as_os_str()],
);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
let ok = mcp.call(
"books.import_local_file",
json!({"path": inside.to_str().unwrap(), "category": "receipt"}),
);
assert_eq!(ok["file_name"], "receipt.txt");
let why = mcp.call_err(
"books.import_local_file",
json!({"path": temp.to_str().unwrap(), "category": "receipt"}),
);
assert!(
why.contains("no such file in the server's import folders"),
"{why}"
);
}
/// Start a session: initialize, then initialized.
fn init(mcp: &mut Mcp) -> Value {
let init = mcp.request(
@@ -836,173 +797,25 @@ fn init(mcp: &mut Mcp) -> Value {
init
}
/// Through the real tools, inside a root (local_files.rs INVARIANTS 1-3,
/// Protected::of; S1 and S5 of the P0 review, I of round 2): the key
/// file in force, moved into the root after the server started (a root
/// holding the books or a key is refused at startup: startup.rs), is
/// sealed with what its link leads to and their folders, but never the
/// root itself; links in the root to the journal or a vault record lead
/// outside it, and get the one refusal a missing file gets (INVARIANT 2).
/// books.import_local_file and books.set_w9's w9_file_path agree.
#[test]
fn import_local_file_and_set_w9_refuse_the_key_vault_and_journal_inside_a_root() {
use std::os::unix::fs::symlink;
let dir = test_dir("sealed-in-root");
let root = dir.join("root");
let books = dir.join("books");
let data = books.join("books-data");
let tray = root.join("drop");
let config = dir.join("config");
for d in [
&data,
&tray,
&root.join("alias"),
&root.join("hidden"),
&config.join("wanderbooks"),
] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(root.join("hidden/real.key"), "ab".repeat(32)).unwrap();
std::fs::write(root.join("hidden/notes.pdf"), b"%PDF beside the key").unwrap();
std::fs::write(root.join("loose.key"), "ef".repeat(32)).unwrap();
symlink(root.join("hidden/real.key"), root.join("alias/vault.key")).unwrap();
std::fs::write(config.join("wanderbooks/vault.key"), "cd".repeat(32)).unwrap();
let settings = |key: &std::path::Path| {
std::fs::write(
books.join("wanderbooks.toml"),
format!(
"[company]\nname = \"Sealed Co\"\n[vault]\nkey_file = \"{}\"\n",
key.display()
),
)
.unwrap();
};
// Started with the default key (outside the root), so it starts.
settings(&config.join("wanderbooks/vault.key"));
std::fs::write(tray.join("receipt.pdf"), b"%PDF a receipt").unwrap();
std::fs::write(tray.join("w9.pdf"), b"%PDF\xff W-9 scan").unwrap();
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
let mut mcp = Mcp::spawn_env(&data, &root_arg, &[("XDG_CONFIG_HOME", &config)]);
init(&mut mcp);
mcp.call(
"books.add_party",
json!({"name": "Acme", "roles": ["contractor"]}),
);
mcp.call(
"books.set_tin",
json!({"party": "Acme", "tin": "291-12-2846", "tin_type": "ssn"}),
);
let journal = data.join(ledger_store::JOURNAL_FILE);
let vault_record = books
.join("vault")
.read_dir()
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|e| e == "bin"))
.unwrap();
for (name, to) in [
("journal-link.pdf", journal.clone()),
("record-link.pdf", vault_record.clone()),
("default-key-link.pdf", config.join("wanderbooks/vault.key")),
("dangling-link.pdf", config.join("wanderbooks/missing.key")),
] {
symlink(&to, tray.join(name)).unwrap();
}
// The key in force moves into the root (a link, alias/vault.key ->
// hidden/real.key); the settings reload.
settings(&root.join("alias/vault.key"));
let refuse = |mcp: &mut Mcp, path: &std::path::Path, why: &str| {
let path = path.to_str().unwrap();
let err = mcp.call_err(
"books.import_local_file",
json!({"path": path, "category": "document"}),
);
assert!(err.contains(why), "{path}: {err}");
assert!(!err.contains(dir.to_str().unwrap()), "names a path: {err}");
let err = mcp.call_err(
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": "291-12-2846", "tin_type": "ssn", "w9_file_path": path}),
);
assert!(err.contains(why), "{path}: {err}");
};
for p in [
root.join("hidden/real.key"),
root.join("hidden/notes.pdf"),
root.join("alias/vault.key"),
] {
refuse(&mut mcp, &p, "belongs to the books or the TIN vault");
}
for p in [
journal.clone(),
vault_record,
config.join("wanderbooks/vault.key"),
tray.join("journal-link.pdf"),
tray.join("record-link.pdf"),
tray.join("default-key-link.pdf"),
tray.join("dangling-link.pdf"),
tray.join("missing.pdf"),
] {
refuse(
&mut mcp,
&p,
"no such file in the server's import folders (by default your downloads folder)",
);
}
// A key kept loose in the root itself seals the key, not the root.
settings(&root.join("loose.key"));
refuse(
&mut mcp,
&root.join("loose.key"),
"belongs to the books or the TIN vault",
);
// Ordinary files in the root are filed (with the key the vault was
// made with back in force).
mcp.call(
"books.import_local_file",
json!({"path": tray.join("receipt.pdf").to_str().unwrap(), "category": "receipt"}),
);
// A link is filed under the name and type of the file it leads to.
std::fs::write(tray.join("photo.png"), b"\x89PNG a photo").unwrap();
symlink(tray.join("photo.png"), tray.join("named-like.pdf")).unwrap();
let filed = mcp.call(
"books.import_local_file",
json!({"path": tray.join("named-like.pdf").to_str().unwrap(), "category": "receipt"}),
);
assert_eq!(filed["file_name"], "photo.png");
let back = mcp.call(
"books.get_receipt",
json!({"attachment_id": filed["attachment_id"]}),
);
assert_eq!(back["mime_type"], "image/png", "{back}");
settings(&config.join("wanderbooks/vault.key"));
mcp.call(
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": "291-12-2846", "tin_type": "ssn",
"w9_file_path": tray.join("w9.pdf").to_str().unwrap()}),
);
}
/// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with
/// include_tins, books.export_cpa_package with TINs) are never written
/// into an import root, and a copy of one moved into a root is refused by
/// its bytes: a TIN-bearing file can't be filed back in as a receipt and
/// inside the books, and wherever they are written (a downloads folder
/// too), each is refused by its bytes when given back to be filed: a
/// TIN-bearing file can't be filed back in as a receipt or document and
/// read out through books.get_receipt (S7 of the P0 review, 2026-10-16).
#[test]
fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
let dir = test_dir("tin-files");
let root = dir.join("inbox");
let out = dir.join("out");
let books = dir.join("books");
let downloads = dir.join("downloads");
let config = dir.join("config");
for d in [&root, &out, &config] {
for d in [&downloads, &config] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(root.join("w9.pdf"), b"%PDF W-9 TIN 291-12-2846").unwrap();
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
let mut mcp = Mcp::spawn_env(
&dir.join("books").join("books-data"),
&root_arg,
&books.join("books-data"),
&[],
&[("XDG_CONFIG_HOME", &config)],
);
init(&mut mcp);
@@ -1018,61 +831,92 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": "291-12-2846", "tin_type": "ssn",
"w9_file_path": root.join("w9.pdf").to_str().unwrap()}),
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
);
let in_root = |name: &str| root.join(name).to_str().unwrap().to_string();
// Inside the books: refused, and nothing written there.
let before = std::fs::read_dir(&books).unwrap().count();
let in_books = |name: &str| books.join(name).to_str().unwrap().to_string();
for (tool, args) in [
(
"books.get_w9",
json!({"party": "Acme", "save_to": in_root("copy.pdf")}),
json!({"party": "Acme", "save_to": in_books("copy.pdf")}),
),
(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true, "save_csv_to": in_root("1099.csv")}),
json!({"year": 2026, "include_tins": true, "save_csv_to": in_books("1099.csv")}),
),
(
"books.export_cpa_package",
json!({"cash_accounts": ["1000"], "save_to": in_root("cpa.zip")}),
json!({"cash_accounts": ["1000"], "save_to": in_books("cpa.zip")}),
),
(
"books.export_cpa_package",
json!({"cash_accounts": ["1000"], "save_to": root.to_str().unwrap()}),
json!({"cash_accounts": ["1000"], "save_to": books.to_str().unwrap()}),
),
] {
let why = mcp.call_err(tool, args);
assert!(
why.contains("never saved in the server's import folders"),
"{tool}: {why}"
);
assert!(why.contains("inside the books"), "{tool}: {why}");
}
assert_eq!(
std::fs::read_dir(&root).unwrap().count(),
1,
"wrote into the root"
std::fs::read_dir(&books).unwrap().count(),
before,
"wrote into the books"
);
// Saved elsewhere, then moved into the root: refused by its bytes.
let w9 = out.join("copy.pdf");
// Written to a downloads folder, each is refused by its bytes, by
// every way a file is filed.
let in_downloads = |name: &str| downloads.join(name).to_str().unwrap().to_string();
mcp.call(
"books.get_w9",
json!({"party": "Acme", "save_to": w9.to_str().unwrap()}),
json!({"party": "Acme", "save_to": in_downloads("copy.pdf")}),
);
let csv = out.join("1099.csv");
mcp.call(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}),
json!({"year": 2026, "include_tins": true, "save_csv_to": in_downloads("1099.csv")}),
);
for (from, name) in [(&w9, "receipt.pdf"), (&csv, "statement.csv")] {
std::fs::copy(from, root.join(name)).unwrap();
let why = mcp.call_err(
"books.import_local_file",
json!({"path": in_root(name), "category": "receipt"}),
);
assert!(why.contains("wrote with full TINs"), "{why}");
mcp.call(
"books.export_cpa_package",
json!({"cash_accounts": ["1000"], "save_to": in_downloads("cpa.zip")}),
);
let written: Vec<std::path::PathBuf> = std::fs::read_dir(&downloads)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(written.len(), 3, "{written:?}");
let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap();
for path in &written {
let bytes = std::fs::read(path).unwrap();
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(&bytes),
"sha256": sha(&bytes)}),
),
(
"books.file_document",
json!({"category": "statement", "file_name": "statement.csv",
"mime_type": "text/csv",
"content_base64": base64_like(&bytes),
"sha256": sha(&bytes)}),
),
] {
let why = mcp.call_err(tool, args);
assert!(
why.contains("wrote with full TINs"),
"{tool} {}: {why}",
path.display()
);
}
}
assert_eq!(
std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(),
journal,
"a refusal stored something"
);
// Without TINs, the 1099 worksheet may go anywhere a file may.
mcp.call(
"books.vendor_1099",
json!({"year": 2026, "save_csv_to": in_root("plain.csv")}),
json!({"year": 2026, "save_csv_to": in_downloads("plain.csv")}),
);
}
@@ -1317,6 +1161,11 @@ fn handbook_tool_and_resources_over_stdio() {
assert!(resp.get("error").is_some());
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn base64_like(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
@@ -1409,7 +1258,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(desc.as_bytes())}),
"content_base64": base64_like(desc.as_bytes()),
"sha256": sha(desc.as_bytes())}),
);
mcp.call(
"books.post_entry",
@@ -1571,7 +1421,8 @@ fn mcp_period_statements_over_stdio() {
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"r")}),
"content_base64": base64_like(b"r"),
"sha256": sha(b"r")}),
);
mcp.call(
"books.post_entry",
@@ -2047,6 +1898,7 @@ fn mcp_payout_gross_up_over_stdio() {
"file_name": "payout.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 statement"),
"sha256": sha(b"%PDF-1.4 statement"),
}),
);
mcp.call(
@@ -2133,6 +1985,7 @@ fn mcp_vendor_1099_over_stdio() {
"file_name": "invoice.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 invoice"),
"sha256": sha(b"%PDF-1.4 invoice"),
}),
);
mcp.call(
+248 -73
View File
@@ -56,8 +56,7 @@ impl Drop for Mcp {
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
/// A test's dir and the folder beside it (`.inbox`),
/// removed when the test ends, pass or panic.
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
@@ -76,7 +75,6 @@ impl AsRef<std::path::Path> for TestDir {
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
let _ = std::fs::remove_dir_all(format!("{}.inbox", self.0.display()));
}
}
@@ -109,14 +107,6 @@ fn books_toml(dir: &Path, threshold: &str) -> PathBuf {
path
}
/// The server's import root, beside the books (never the books dir): files
/// given to books.import_local_file.
fn inbox(dir: &Path) -> PathBuf {
let inbox = dir.with_extension("inbox");
std::fs::create_dir_all(&inbox).unwrap();
inbox
}
fn data_dir(dir: &Path) -> PathBuf {
dir.join("books-data")
}
@@ -131,8 +121,6 @@ impl Mcp {
.arg(data_dir(dir))
.arg("--books-toml")
.arg(&toml)
.arg("--import-root")
.arg(inbox(dir))
.env("XDG_RUNTIME_DIR", &run)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -1737,7 +1725,7 @@ fn post_contribution(m: &mut Mcp) -> u64 {
m.ok(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain",
"content_base64": b64e(b"contribution")}),
"content_base64": b64e(b"contribution"), "sha256": sha256(b"contribution")}),
);
m.ok(
"books.post_entry",
@@ -2118,11 +2106,24 @@ fn resources_read_back_as_the_matching_tools() {
let want = m.ok(tool, args);
assert_eq!(got, want, "{uri} reads as {tool}");
}
// A receipt reads as books.get_receipt's meta view: what it is, never
// its bytes (those come with view bytes).
for (id, bytes) in [(r1, &b"first receipt"[..]), (r2, &b"second receipt"[..])] {
let got = read(&mut m, &format!("books://receipts/{id}"));
assert_eq!(got["attachment_id"], json!(id));
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), bytes);
assert!(got.get("content_base64").is_none(), "no bytes: {got}");
assert_eq!(got["sha256"], sha256(bytes));
assert_eq!(got["size_bytes"], json!(bytes.len()));
assert_eq!(
got,
m.ok("books.get_receipt", json!({"attachment_id": id})),
"books://receipts/{id} reads as books.get_receipt"
);
let full = m.ok(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
assert_eq!(b64d(full["content_base64"].as_str().unwrap()), bytes);
}
let r = m.rpc("resources/read", json!({"uri": "books://nope"}));
let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}"));
@@ -2136,8 +2137,8 @@ fn resources_read_back_as_the_matching_tools() {
/// Receipts and documents round-trip: the bytes come back exactly (SHA-256
/// of what was sent), with the name, MIME type, category, entry, and
/// description given. Kills: get_receipt's lookup, import_local_file's
/// document arm.
/// description given; only view bytes carries the bytes. Kills:
/// get_receipt's lookup and its views.
#[test]
fn receipts_and_documents_round_trip() {
let dir = test_dir("files");
@@ -2150,48 +2151,27 @@ fn receipts_and_documents_round_trip() {
let a = m.ok(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
"content_base64": b64e(first), "description": "Receipt ID 1007; Paid from: Bluevine"}),
"content_base64": b64e(first), "sha256": sha256(first),
"description": "Receipt ID 1007; Paid from: Bluevine"}),
);
let b = m.ok(
"books.file_document",
json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png",
"content_base64": b64e(second), "description": "Operating agreement"}),
"content_base64": b64e(second), "sha256": sha256(second),
"description": "Operating agreement"}),
);
// Local files come from outside the books, in the server's import
// folder (local_files.rs).
let inbox = inbox(&dir);
let local_doc = inbox.join("ein-letter.pdf");
std::fs::write(&local_doc, b"EIN letter bytes").unwrap();
let c = m.ok(
"books.import_local_file",
json!({"path": local_doc.to_str().unwrap(), "category": "document",
"document_category": "tax", "description": "IRS CP 575"}),
"books.file_document",
json!({"category": "tax", "file_name": "ein-letter.pdf", "mime_type": "application/pdf",
"content_base64": b64e(b"EIN letter bytes"), "sha256": sha256(b"EIN letter bytes"),
"description": "IRS CP 575"}),
);
assert_eq!(c["file_name"], "ein-letter.pdf");
let local_receipt = inbox.join("scan.png");
std::fs::write(&local_receipt, b"a scanned receipt").unwrap();
let r = m.ok(
"books.import_local_file",
json!({"path": local_receipt.to_str().unwrap(), "category": "receipt",
"entry_id": e, "file_name": "renamed.png", "description": "scan"}),
"books.attach_receipt",
json!({"entry_id": e, "file_name": "renamed.png", "mime_type": "image/png",
"content_base64": b64e(b"a scanned receipt"),
"sha256": sha256(b"a scanned receipt"), "description": "scan"}),
);
assert_eq!(r["file_name"], "renamed.png");
m.refuse(
"books.import_local_file",
json!({"path": local_receipt.to_str().unwrap(), "category": "photo"}),
);
// Nothing of the books is ever read back in through a path: outside
// the import folders, it gets the one refusal any path there gets.
let journal = data_dir(&dir).join(ledger_store::JOURNAL_FILE);
for p in [journal, dir.join("wanderbooks-1000000.00.toml")] {
assert!(p.exists());
let why = m.refuse(
"books.import_local_file",
json!({"path": p.to_str().unwrap(), "category": "document"}),
);
assert!(why.contains("import folders"), "{why}");
assert!(!why.contains(dir.to_str().unwrap()), "names a path: {why}");
}
// (result, bytes, file name, MIME type, entry, description)
type Case<'a> = (&'a Value, &'a [u8], &'a str, &'a str, Option<u64>, &'a str);
@@ -2233,13 +2213,24 @@ fn receipts_and_documents_round_trip() {
for (out, bytes, name, mime, entry, desc) in cases {
let id = out["attachment_id"].as_u64().unwrap();
assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes");
let got = m.ok("books.get_receipt", json!({"attachment_id": id}));
let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
assert!(
meta.get("content_base64").is_none(),
"{name}: meta, no bytes"
);
let got = m.ok(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
assert_eq!(got["attachment_id"], json!(id));
assert_eq!(
b64d(got["content_base64"].as_str().unwrap()),
bytes,
"{name}"
);
let mut without = got.clone();
without.as_object_mut().unwrap().remove("content_base64");
assert_eq!(without, meta, "{name}: view bytes is meta and the bytes");
assert_eq!(got["sha256"], sha256(bytes));
assert_eq!(got["file_name"], name);
assert_eq!(got["mime_type"], mime);
@@ -2263,19 +2254,26 @@ fn receipts_and_documents_round_trip() {
assert_eq!(cat(&b), "legal");
assert_eq!(cat(&c), "tax");
m.refuse("books.get_receipt", json!({"attachment_id": 999}));
for view in ["", "BYTES", "content", "meta "] {
let why = m.refuse(
"books.get_receipt",
json!({"attachment_id": a["attachment_id"], "view": view}),
);
assert!(
why.contains("meta (the default) or bytes"),
"{view:?}: {why}"
);
}
}
/// 25 MB (MiB) is the most a receipt or document may be: exactly the
/// limit is accepted through every way in, one byte more refused (checked
/// on the local-file path, where it costs no upload). Kills: the limit
/// comparisons in attach_receipt, file_document, and import_local_file,
/// and import_local_file's limit arithmetic (2 MiB must pass).
/// 256 KiB is the most a file may be given inline (plan 2.1): exactly
/// the limit is accepted by books.attach_receipt and books.file_document
/// and stored whole, one byte more is refused by both, pointing at
/// books.begin_upload, and nothing is stored. Kills: the limit comparison
/// in take_file.
#[test]
fn uploads_up_to_25_mib_are_accepted_and_no_more() {
const LIMIT: usize = 25 * 1024 * 1024;
// Over 50 MB of books: removed however the test ends, with its inbox
// (the system temp dir is often RAM, and a mutation run starts this
// test hundreds of times).
fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
const LIMIT: usize = 256 * 1024;
let dir = test_dir("limits");
let s = new_store(&dir);
drop(s);
@@ -2284,18 +2282,19 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
let out = m.ok(
"books.attach_receipt",
json!({"file_name": "big.bin", "mime_type": "application/octet-stream",
"content_base64": b64e(&at)}),
"content_base64": b64e(&at), "sha256": sha256(&at)}),
);
let at_id = out["attachment_id"].as_u64().unwrap();
let doc = vec![b'b'; LIMIT];
let out = m.ok(
"books.file_document",
json!({"category": "other", "file_name": "big.bin",
"mime_type": "application/octet-stream", "content_base64": b64e(&doc)}),
"mime_type": "application/octet-stream", "content_base64": b64e(&doc),
"sha256": sha256(&doc)}),
);
let doc_id = out["attachment_id"].as_u64().unwrap();
let listed = m.ok("books.list_receipts", json!({}));
for id in [at_id, doc_id] {
for (id, bytes) in [(at_id, &at), (doc_id, &doc)] {
let row = listed["receipts"]
.as_array()
.unwrap()
@@ -2304,19 +2303,195 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
.unwrap()
.clone();
assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole");
// And it comes back inline, whole.
let got = m.ok(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
assert_eq!(&b64d(got["content_base64"].as_str().unwrap()), bytes);
}
let journal = std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
let over = vec![b'c'; LIMIT + 1];
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": "over.bin", "mime_type": "application/octet-stream",
"content_base64": b64e(&over), "sha256": sha256(&over)}),
),
(
"books.file_document",
json!({"category": "other", "file_name": "over.bin",
"mime_type": "application/octet-stream", "content_base64": b64e(&over),
"sha256": sha256(&over)}),
),
] {
let why = m.refuse(tool, args);
assert!(
why.contains("books.begin_upload") && why.contains(&(LIMIT + 1).to_string()),
"{tool}: {why}"
);
}
assert_eq!(
std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(),
journal,
"a refusal stored something"
);
}
for (n, accepted) in [(2 * 1024 * 1024, true), (LIMIT, true), (LIMIT + 1, false)] {
let path = inbox(&dir).join(format!("local-{n}.bin"));
std::fs::write(&path, vec![b'c' ^ (n % 7) as u8; n]).unwrap();
let args = json!({"path": path.to_str().unwrap(), "category": "receipt"});
if accepted {
m.ok("books.import_local_file", args);
} else {
m.refuse("books.import_local_file", args);
/// The bytes a filing tool takes are inline base64 with its own SHA-256,
/// or an upload; never both, never neither. A sha256 missing, or not the
/// bytes' own (a byte lost on the way), or base64 that isn't, is refused
/// (guard base64_corrupt); inline bytes need their file_name and
/// mime_type. Over stdio there are no uploads: an upload_id is refused,
/// and books.begin_upload answers only for bytes stored already (the
/// attachment holding them). Every refusal stores nothing.
#[test]
fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
let dir = test_dir("inline");
let s = new_store(&dir);
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
let bytes: &[u8] = b"%PDF-1.4 a receipt";
let other: &[u8] = b"%PDF-1.4 a receipt!";
let journal = || std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
let before = journal();
for (tool, base) in [
("books.attach_receipt", json!({})),
("books.file_document", json!({"category": "other"})),
] {
let with = |extra: Value| {
let mut args = base.clone();
for (k, v) in json!({"file_name": "r.pdf", "mime_type": "application/pdf"})
.as_object()
.unwrap()
.iter()
.chain(extra.as_object().unwrap())
{
args[k] = v.clone();
}
args
};
for (args, said) in [
// Neither, both.
(with(json!({})), "books.begin_upload"),
(
with(json!({"upload_id": "u1", "content_base64": b64e(bytes),
"sha256": sha256(bytes)})),
"not both",
),
// The sha256 missing, another file's, not hex; base64 that isn't.
(with(json!({"content_base64": b64e(bytes)})), "sha256"),
(
with(json!({"content_base64": b64e(bytes), "sha256": sha256(other)})),
"base64_corrupt",
),
(
with(json!({"content_base64": b64e(bytes), "sha256": "not hex"})),
"base64_corrupt",
),
(
with(json!({"content_base64": "!!not base64", "sha256": sha256(bytes)})),
"base64_corrupt",
),
// An upload, over stdio.
(with(json!({"upload_id": "u1"})), "takes files inline only"),
] {
let why = m.refuse(tool, args.clone());
assert!(why.contains(said), "{tool} {args}: {why}");
}
// Inline bytes need their name and type.
for missing in ["file_name", "mime_type"] {
let mut args = with(json!({"content_base64": b64e(bytes), "sha256": sha256(bytes)}));
args.as_object_mut().unwrap().remove(missing);
let why = m.refuse(tool, args);
assert!(why.contains(missing), "{tool} without {missing}: {why}");
}
std::fs::remove_file(&path).unwrap();
}
// A sha256 given in capitals, or with spaces around it, is the same
// hash.
let out = m.ok(
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64e(bytes),
"sha256": format!(" {} ", sha256(bytes).to_ascii_uppercase())}),
);
assert_eq!(out["sha256"], sha256(bytes));
let id = out["attachment_id"].as_u64().unwrap();
assert_ne!(journal(), before);
// begin_upload: the bytes stored already name their attachment; new
// ones need the books server.
let up = |sha: String| {
json!({"sha256": sha, "size": bytes.len(), "mime_type": "application/pdf",
"file_name": "r.pdf", "purpose": "receipt"})
};
let got = m.ok("books.begin_upload", up(sha256(bytes)));
assert_eq!(got["existing_attachment_id"], json!(id), "{got}");
assert!(got.get("upload_id").is_none(), "{got}");
assert!(got.get("command").is_none(), "{got}");
let got = m.ok("books.begin_upload", up(sha256(bytes).to_ascii_uppercase()));
assert_eq!(got["existing_attachment_id"], json!(id), "{got}");
let stored = journal();
let why = m.refuse("books.begin_upload", up(sha256(other)));
assert!(why.contains("takes files inline only"), "{why}");
// The CSV imports take csv_text or an upload, never both, never
// neither; an upload over stdio is refused.
for (tool, base) in [
(
"books.import_bank_csv",
json!({"bank_account_code": "1000", "default_offset_code": "4000"}),
),
("books.import_chart_csv", json!({})),
] {
for (extra, said) in [
(json!({}), "csv_text"),
(json!({"csv_text": "a,b\n", "upload_id": "u1"}), "not both"),
(json!({"upload_id": "u1"}), "takes files inline only"),
] {
let mut args = base.clone();
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
let why = m.refuse(tool, args.clone());
assert!(why.contains(said), "{tool} {args}: {why}");
}
}
assert_eq!(journal(), stored, "a refusal stored something");
}
/// books.get_receipt's view bytes gives the bytes of a file of at most
/// 256 KiB; a larger one (filed before, or by an upload) is refused over
/// stdio, where there is no download, while its meta view still answers.
#[test]
fn get_receipt_gives_bytes_up_to_256_kib_over_stdio() {
const LIMIT: usize = 256 * 1024;
let dir = test_dir("get-bytes");
let mut s = new_store(&dir);
let at = vec![b'x'; LIMIT];
let over = vec![b'y'; LIMIT + 1];
let (at_id, _, _) = s
.attach_receipt(None, "at.bin", "application/octet-stream", &at, None)
.unwrap();
let (over_id, _, _) = s
.attach_receipt(None, "over.bin", "application/octet-stream", &over, None)
.unwrap();
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
let got = m.ok(
"books.get_receipt",
json!({"attachment_id": at_id, "view": "bytes"}),
);
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), at);
assert!(got.get("download").is_none(), "{got}");
let why = m.refuse(
"books.get_receipt",
json!({"attachment_id": over_id, "view": "bytes"}),
);
assert!(why.contains(&(LIMIT + 1).to_string()), "{why}");
let meta = m.ok("books.get_receipt", json!({"attachment_id": over_id}));
assert_eq!(meta["size_bytes"], json!(LIMIT + 1));
assert_eq!(meta["sha256"], sha256(&over));
assert!(meta.get("content_base64").is_none(), "{meta}");
assert!(meta.get("download").is_none(), "{meta}");
}
/// Bank CSV import: money in debits the bank, money out credits it; the
+3 -1
View File
@@ -360,7 +360,9 @@ impl Mcp {
self.ok(
"books.file_document",
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain",
"content_base64": b64(b"bill of sale"), "description": "Bill of sale"}),
"content_base64": b64(b"bill of sale"),
"sha256": ledger_store::journal::sha256_hex(b"bill of sale"),
"description": "Bill of sale"}),
)["attachment_id"]
.as_u64()
.unwrap()
+13 -8
View File
@@ -371,6 +371,11 @@ fn b64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"),
@@ -406,7 +411,7 @@ impl Mcp {
self.ok(
"books.file_document",
json!({"category": category, "file_name": format!("{category}.pdf"),
"mime_type": "application/pdf", "content_base64": b64(bytes),
"mime_type": "application/pdf", "content_base64": b64(bytes), "sha256": sha(bytes),
"link_to": link_to}),
)["attachment_id"]
.as_u64()
@@ -416,7 +421,7 @@ impl Mcp {
/// Attach a receipt (to `entry`, or unlinked); its id.
fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(bytes)});
"content_base64": b64(bytes), "sha256": sha(bytes)});
if let Some(e) = entry {
args["entry_id"] = json!(e);
}
@@ -787,25 +792,25 @@ fn filing_links_in_the_same_call_all_or_nothing() {
m.refuse(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"r"), "link_to": link_to}),
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": link_to}),
);
}
m.refuse(
"books.file_document",
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
"content_base64": b64(b"c"), "sha256": sha(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
);
// An unknown entry_id is refused before anything is filed.
m.refuse(
"books.attach_receipt",
json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"r"), "link_to": [{"kind": "company"}]}),
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": [{"kind": "company"}]}),
);
assert_eq!(count(&mut m), n, "nothing filed");
let out = m.ok(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"r"),
"content_base64": b64(b"r"), "sha256": sha(b"r"),
"link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
);
assert_eq!(
@@ -819,7 +824,7 @@ fn filing_links_in_the_same_call_all_or_nothing() {
let doc = m.ok(
"books.file_document",
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"certificate"), "link_to": [{"kind": "company"}]}),
"content_base64": b64(b"certificate"), "sha256": sha(b"certificate"), "link_to": [{"kind": "company"}]}),
);
assert_eq!(kinds(&doc), ["company"]);
assert_eq!(m.count("document.filed"), 1);
@@ -849,7 +854,7 @@ fn documents_file_under_the_attachment_kinds() {
m.refuse(
"books.file_document",
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain",
"content_base64": b64(b"x")}),
"content_base64": b64(b"x"), "sha256": sha(b"x")}),
);
}
+46 -31
View File
@@ -213,14 +213,6 @@ fn test_dir(name: &str) -> TestDir {
/// A directory beside the books dir `dir`, outside it (vault keys, files
/// with TINs).
/// The import root beside the books dir `dir`: files the tests hand the
/// server by path (books.import_local_file, books.set_w9 w9_file_path).
fn inbox(dir: &Path) -> PathBuf {
let d = dir.with_extension("inbox");
std::fs::create_dir_all(&d).unwrap();
d
}
fn outside(dir: &Path) -> PathBuf {
let d = PathBuf::from(format!("{}-outside", dir.display()));
std::fs::create_dir_all(&d).unwrap();
@@ -260,10 +252,6 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
.into_iter()
.flatten(),
)
// Local files come only from here (the default is the downloads
// folder): never the books, never where the server writes.
.arg("--import-root")
.arg(inbox(dir))
// books.show writes a viewer request here; keep it out of the
// user's real runtime dir.
.env("XDG_RUNTIME_DIR", &run)
@@ -548,6 +536,7 @@ impl Mcp {
"file_name": "receipt.txt",
"mime_type": "text/plain",
"content_base64": b64(bytes),
"sha256": sha(bytes),
});
if let Some(e) = entry {
args["entry_id"] = json!(e);
@@ -660,6 +649,11 @@ fn canon(s: &str) -> String {
}
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
@@ -811,12 +805,12 @@ fn happy_path(name: &str) -> Mcp {
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin_last4": "4321", "kept_at": "CPA portal"}),
);
let w9_pdf = inbox(&dir).join("landlord-w9.pdf");
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 79-9714321").unwrap();
let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321";
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "79-9714321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}),
"tin": "79-9714321", "tin_type": "ein",
"w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}),
);
m.ok(
"books.get_w9",
@@ -889,17 +883,23 @@ fn happy_path(name: &str) -> Mcp {
m.ok("books.list_receipts", json!({"include_withdrawn": true}));
m.ok("books.list_receipts", json!({"entry_id": e2}));
m.ok("books.get_receipt", json!({"attachment_id": r_old}));
m.ok(
"books.get_receipt",
json!({"attachment_id": r_old, "view": "bytes"}),
);
let doc = m.ok(
"books.file_document",
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain",
"content_base64": b64(b"operating agreement"), "description": "OA"}),
"content_base64": b64(b"operating agreement"),
"sha256": sha(b"operating agreement"), "description": "OA"}),
)["attachment_id"]
.as_u64()
.unwrap();
let filed = m.ok(
"books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
"content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}),
"content_base64": b64(b"lease"),
"sha256": sha(b"lease"), "link_to": [{"kind": "company"}]}),
)["attachment_id"]
.as_u64()
.unwrap();
@@ -915,13 +915,15 @@ fn happy_path(name: &str) -> Mcp {
"books.unlink_attachment",
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
);
// From outside the books, in the server's import folder.
let local = inbox(&dir).join("local-receipt.txt");
std::fs::write(&local, b"a local receipt").unwrap();
m.ok(
"books.import_local_file",
json!({"path": local.to_str().unwrap(), "category": "receipt"}),
// An upload of bytes already stored: begin_upload names the
// attachment that holds them (over stdio, the only upload answered).
let local = m.attach(None, b"a local receipt");
let up = m.ok(
"books.begin_upload",
json!({"sha256": sha(b"a local receipt"), "size": 15, "mime_type": "text/plain",
"file_name": "local-receipt.txt", "purpose": "receipt"}),
);
assert_eq!(up["existing_attachment_id"], json!(local), "{up}");
// Reversal: preview, then confirmed.
let e3 = m.draft(
@@ -1071,7 +1073,8 @@ fn happy_path(name: &str) -> Mcp {
let source = m.ok(
"books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
"content_base64": b64(csv.as_bytes())}),
"content_base64": b64(csv.as_bytes()),
"sha256": sha(csv.as_bytes())}),
)["attachment_id"]
.as_u64()
.unwrap();
@@ -1092,7 +1095,8 @@ fn happy_path(name: &str) -> Mcp {
let stmt = m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"%PDF statement")}),
"content_base64": b64(b"%PDF statement"),
"sha256": sha(b"%PDF statement")}),
)["attachment_id"]
.as_u64()
.unwrap();
@@ -1572,7 +1576,8 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
let out = m.tool(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain",
"content_base64": b64(bytes.as_bytes())}),
"content_base64": b64(bytes.as_bytes()),
"sha256": sha(bytes.as_bytes())}),
);
k.attachments.extend(new_id(&out, "attachment_id"));
}
@@ -1601,11 +1606,21 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
json!({"entry_id": entry.resolve(&k.entries), "date": date, "description": "undo",
"confirmed": opt(*confirmed)}),
),
Op::Attach { entry, content } => (
"books.attach_receipt",
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin",
"mime_type": "application/octet-stream", "content_base64": content}),
),
Op::Attach { entry, content } => {
// The bytes' own SHA-256 (content that is no base64 has none).
let sha256 = {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(content)
.map_or_else(|_| "0".repeat(64), |b| sha(&b))
};
(
"books.attach_receipt",
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin",
"mime_type": "application/octet-stream", "content_base64": content,
"sha256": sha256}),
)
}
Op::Link { att, entry } => (
"books.link_receipt",
json!({"attachment_id": att.resolve(&k.attachments),
+127 -52
View File
@@ -72,7 +72,7 @@ impl Drop for Mcp {
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
/// A test's dir and the folders beside it (`-inbox`, `-outside`),
/// A test's dir and the folder beside it (`-outside`),
/// removed when the test ends, pass or panic.
struct TestDir(PathBuf);
@@ -92,11 +92,7 @@ impl AsRef<std::path::Path> for TestDir {
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
for beside in ["-inbox", "-outside"] {
let p = PathBuf::from(format!("{}{beside}", self.0.display()));
let _ = std::fs::remove_dir_all(&p);
let _ = std::fs::remove_file(&p);
}
let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display()));
}
}
@@ -119,12 +115,15 @@ fn outside(dir: &Path) -> PathBuf {
d
}
/// The server's import root, beside the books dir `dir`: W-9 files handed
/// to books.set_w9 by path. Never where the server writes TINs.
fn inbox(dir: &Path) -> PathBuf {
let d = PathBuf::from(format!("{}-inbox", dir.display()));
std::fs::create_dir_all(&d).unwrap();
d
/// `bytes` as a tool takes them inline: base64.
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
/// `wanderbooks.toml` with a company name, the receipt threshold, and
@@ -169,9 +168,6 @@ impl Mcp {
.into_iter()
.flatten()
})
// Files handed over by path come only from here.
.arg("--import-root")
.arg(inbox(dir))
.env("XDG_RUNTIME_DIR", &run)
// Never the user's real vault key (the default key file is
// under XDG_CONFIG_HOME); the tests name theirs anyway.
@@ -519,11 +515,9 @@ impl Mcp {
}
self.tool("books.set_w9", args)
}
/// A W-9 file outside the books; it shows the TIN, like the real one.
fn w9_file(&self, name: &str, tin: &str) -> PathBuf {
let p = inbox(&self.books_dir()).join(name);
std::fs::write(&p, format!("%PDF-1.4 Form W-9 TIN {tin}")).unwrap();
p
/// A W-9 file (a PDF); it shows the TIN, like the real one.
fn w9_file(&self, tin: &str) -> Vec<u8> {
format!("%PDF-1.4 Form W-9 TIN {tin}").into_bytes()
}
/// Pay `vendor` `amount` from the bank in 2026, tagged.
fn pay(&mut self, vendor: &str, amount: &str) {
@@ -626,9 +620,9 @@ fn set_w9_refusals_write_nothing_anywhere() {
let mut m = books("refusals");
let acme = m.party_id("Acme Design");
m.add_party("IRS", &["tax_agency"]);
let missing = inbox(&m.books_dir()).join("no-such-w9.pdf");
let txt = inbox(&m.books_dir()).join("w9.txt");
std::fs::write(&txt, "W-9").unwrap();
let pdf = m.w9_file(SSN);
let txt = b"W-9";
let big = [b"%PDF-1.4 ".as_slice(), &vec![b' '; 256 * 1024]].concat();
for (party, extra) in [
("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits
(
@@ -653,14 +647,6 @@ fn set_w9_refusals_write_nothing_anywhere() {
"Acme Design",
json!({"tin": 291122846.0, "tin_type": "ssn"}),
), // a float
(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": missing.to_str().unwrap()}),
),
(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": txt.to_str().unwrap()}),
),
(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "clear": true}),
@@ -680,6 +666,51 @@ fn set_w9_refusals_write_nothing_anywhere() {
out.err_text()
);
}
// The W-9 file, each refused for its own reason.
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4);
for (extra, why) in [
(
json!({"w9_content_base64": b64(txt), "w9_sha256": sha(txt)}),
"a PDF, PNG, or JPEG",
),
(
json!({"w9_content_base64": b64(&pdf)}),
"comes with its sha256",
),
(
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(txt)}),
"lost on the way",
),
(
json!({"w9_content_base64": "!!not base64", "w9_sha256": sha(&pdf)}),
"not valid base64",
),
(
json!({"w9_content_base64": b64(&big), "w9_sha256": sha(&big)}),
"upload it with books.begin_upload",
),
(
json!({"w9_content_base64": long, "w9_sha256": sha(&big)}),
"inline is at most",
),
(json!({"w9_upload_id": "u1"}), "takes files inline only"),
(
json!({"w9_upload_id": "u1", "w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf)}),
"not both",
),
(
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf), "w9_file_name": "../w9.pdf"}),
"no folder",
),
] {
let mut args = json!({"tin": SSN, "tin_type": "ssn"});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
let why_given = m.w9("Acme Design", args).err_text();
assert!(why_given.contains(why), "{extra}: {why_given}");
assert!(!holds_tin(why_given.as_bytes(), SSN_DIGITS), "{why_given}");
}
assert!(
!m.vault().join(format!("tin-{acme}.bin")).exists(),
"no record written"
@@ -739,10 +770,10 @@ fn the_facts_never_disagree_with_the_vault() {
fn the_w9_file_goes_to_the_vault_never_the_documents() {
let mut m = books("w9-file");
let acme = m.party_id("Acme Design");
let src = m.w9_file("acme-w9.pdf", SSN);
let src = m.w9_file(SSN);
let Out::Ok(p) = m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
) else {
panic!("set_w9 with a file")
};
@@ -760,8 +791,9 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
json!({"party": "Acme Design", "save_to": to.to_str().unwrap()}),
);
assert_eq!(out["path"], json!(to.display().to_string()));
assert_eq!(out["file_name"], "acme-w9.pdf");
assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap());
// Given inline, the file is named for its type.
assert_eq!(out["file_name"], "w9.pdf");
assert_eq!(std::fs::read(&to).unwrap(), src);
assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS));
#[cfg(unix)]
{
@@ -804,10 +836,10 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
);
// A W-9 file alone (the TIN typed in later) records the vault too.
m.add_party("Solo", &["contractor"]);
let solo = m.w9_file("solo.png", "000");
let solo = m.w9_file("000");
let Out::Ok(p) = m.w9(
"Solo",
json!({"tin_last4": "0000", "w9_file_path": solo.to_str().unwrap()}),
json!({"tin_last4": "0000", "w9_content_base64": b64(&solo), "w9_sha256": sha(&solo)}),
) else {
panic!("a W-9 file alone")
};
@@ -815,22 +847,25 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
assert_eq!(p["w9"]["kept_at"], "wanderbooks vault");
}
/// A W-9 file is a PDF, PNG or JPEG, by its extension in any case
/// (.pdf, .png, .jpg, .jpeg): each is taken into the vault and comes back
/// byte for byte, under its own name.
/// A W-9 file is a PDF, PNG or JPEG, judged by its own first bytes: each
/// is taken into the vault and comes back byte for byte, named for its
/// type.
#[test]
fn every_w9_file_type_is_taken() {
let mut m = books("w9-types");
for (n, name) in ["w9.pdf", "w9.png", "w9.jpg", "w9.jpeg", "W9.JPG"]
.iter()
.enumerate()
for (n, (bytes, name)) in [
(b"%PDF-1.4 Form W-9".as_slice(), "w9.pdf"),
(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR W-9".as_slice(), "w9.png"),
(b"\xff\xd8\xff\xe0\0\x10JFIF W-9".as_slice(), "w9.jpg"),
]
.iter()
.enumerate()
{
let party = format!("Payee {n}");
m.add_party(&party, &["contractor"]);
let src = m.w9_file(name, "000");
let Out::Ok(p) = m.w9(
&party,
json!({"tin_last4": "0000", "w9_file_path": src.to_str().unwrap()}),
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
) else {
panic!("{name} refused")
};
@@ -841,17 +876,57 @@ fn every_w9_file_type_is_taken() {
json!({"party": party, "save_to": to.to_str().unwrap()}),
);
assert_eq!(out["file_name"], *name);
assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap());
assert_eq!(std::fs::read(&to).unwrap(), *bytes);
}
// Its own name, when given; given alone, a name is refused.
m.add_party("Payee N", &["contractor"]);
let bytes = b"%PDF-1.4 Form W-9 named";
let Out::Ok(_) = m.w9(
"Payee N",
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes),
"w9_file_name": "payee-n-w9.pdf"}),
) else {
panic!("named W-9 refused")
};
let to = m.outside().join("copy-named");
let out = m.ok(
"books.get_w9",
json!({"party": "Payee N", "save_to": to.to_str().unwrap()}),
);
assert_eq!(out["file_name"], "payee-n-w9.pdf");
let why = m
.w9(
"Payee N",
json!({"tin_last4": "0000", "w9_file_name": "x.pdf"}),
)
.err_text();
assert!(why.contains("w9_file_name names the W-9"), "{why}");
// Anything else is refused, near misses too.
m.add_party("Payee X", &["contractor"]);
for bytes in [
b"PDF-1.4 no percent".as_slice(),
b"\x89PNG\r\n no".as_slice(),
b"\xff\xd8 short".as_slice(),
b"GIF89a".as_slice(),
] {
let why = m
.w9(
"Payee X",
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
)
.err_text();
assert!(why.contains("PDF, PNG, or JPEG"), "{why}");
}
assert_eq!(m.party("Payee X", 2026)["party"]["w9"], Value::Null);
}
#[test]
fn a_locked_vault_says_so_and_never_guesses() {
let mut m = books("locked");
let src = m.w9_file("w9.pdf", SSN);
let src = m.w9_file(SSN);
m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
);
m.pay("Acme Design", "700.00");
let key = std::fs::read(m.key()).unwrap();
@@ -1003,10 +1078,10 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
fn purge_deletes_the_vault_records_and_journals_why() {
let mut m = books("purge");
let acme = m.party_id("Acme Design");
let src = m.w9_file("w9.pdf", SSN);
let src = m.w9_file(SSN);
m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
);
// Clearing while the vault holds it: refused.
let why = m
@@ -1086,10 +1161,10 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
"no-leak",
"[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
);
let src = m.w9_file("acme-w9.pdf", SSN);
let src = m.w9_file(SSN);
m.w9(
"Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
);
m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
+20 -182
View File
@@ -240,187 +240,25 @@ fn newer_books_are_refused_as_newer_not_corrupt() {
);
}
/// Start the server and report whether it refused to start (it never got
/// as far as serving), with its standard error.
fn refuses_to_start(cmd: &mut Command) -> Option<String> {
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
/// `--import-root` is gone (files come in as tool input, P2 slice 5): a
/// server started with it stops at once, as for any unknown argument,
/// and never serves as if it had read from that folder.
#[test]
fn the_import_root_flag_is_refused_as_unknown() {
let dir = temp("import-root");
let out = Command::new(BIN)
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--import-root")
.arg(dir.join("Downloads"))
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config"))
.stdin(Stdio::null())
.output()
.unwrap();
// A server that starts waits on stdin; closing it ends the server.
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr).into_owned();
(!err.contains("serving over stdio")).then_some(err)
}
/// local_files.rs INVARIANT 0: a named import root that is `/`, the home
/// folder, a folder holding it, a folder holding the books, the data dir
/// (a symlinked --data-dir placed where it really is), the vault or a key
/// file, or a folder inside the books, the data dir or the vault, stops
/// the server at startup, saying why; a folder holding none of them is
/// used.
#[test]
fn an_import_root_holding_the_books_or_the_home_folder_is_refused() {
let tmp = temp("bad-roots");
let dir = tmp.canonicalize().unwrap();
let home = dir.join("home");
let books = home.join("work/books");
let data = books.join("books-data");
let keys = dir.join("keys");
for d in [&home.join("Downloads"), &books, &keys, &dir.join("config")] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(
books.join("wanderbooks.toml"),
format!(
"[vault]\nkey_file = \"{}\"\n",
keys.join("vault.key").display()
),
)
.unwrap();
let link = dir.join("data-link");
std::os::unix::fs::symlink(&data, &link).unwrap();
let start = |data: &std::path::Path, root: &std::path::Path| {
let mut cmd = Command::new(BIN);
cmd.arg("--data-dir")
.arg(data)
.arg("--import-root")
.arg(root)
.env("HOME", &home)
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config"));
refuses_to_start(&mut cmd)
};
for (root, why) in [
(std::path::PathBuf::from("/"), "whole filesystem"),
(home.clone(), "home folder"),
(dir.clone(), "home folder"),
(home.join("work"), "holds the books"),
(books.clone(), "holds the books"),
(data.clone(), "holds the books"),
(
keys.clone(),
"holds the books, the TIN vault, or a vault key",
),
(
dir.join("config"),
"holds the books, the TIN vault, or a vault key",
),
] {
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
assert!(
err.contains("can't be used") && err.contains(why),
"{}: {err}",
root.display()
);
}
// A folder inside the books, the data dir or the vault: every file in
// it would be refused, so the root is.
for root in [
books.join("company"),
data.join("blobs"),
books.join("vault/inner"),
] {
std::fs::create_dir_all(&root).unwrap();
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
assert!(
err.contains("can't be used") && err.contains("inside the books"),
"{}: {err}",
root.display()
);
}
// Through a symlinked data dir, the books are where they really are.
let err = start(&link, &home.join("work")).expect("a root holding the real books");
assert!(err.contains("holds the books"), "{err}");
// A folder holding none of them is used.
assert_eq!(start(&data, &home.join("Downloads")), None);
}
/// local_files.rs INVARIANT 0, the default: an XDG_DOWNLOAD_DIR of
/// "$HOME/" (or "/") never makes the home folder an import root. With no
/// real ~/Downloads there is no root, and an import says so; with one, it
/// is the root, and the rest of the home folder stays out of reach.
#[test]
fn a_download_dir_of_home_never_opens_the_home_folder() {
let tmp = temp("xdg-home");
let dir = tmp.canonicalize().unwrap();
let home = dir.join("home");
std::fs::create_dir_all(home.join(".ssh")).unwrap();
std::fs::write(home.join(".ssh/id_ed25519"), "PRIVATE KEY").unwrap();
let key = home.join(".ssh/id_ed25519");
let import = |xdg: &str, path: &std::path::Path| {
one_call(
Command::new(BIN)
.arg("--data-dir")
.arg(dir.join("books/books-data"))
.env("HOME", &home)
.env("XDG_DOWNLOAD_DIR", xdg)
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config")),
"books.import_local_file",
serde_json::json!({"path": path.to_str().unwrap(), "category": "document"}),
)
};
for xdg in ["$HOME/", "$HOME", "/"] {
let reply = import(xdg, &key);
assert!(reply.contains("has no import folder"), "{xdg}: {reply}");
}
std::fs::create_dir_all(home.join("Downloads")).unwrap();
std::fs::write(home.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
for xdg in ["$HOME/", "/"] {
let reply = import(xdg, &key);
assert!(
reply.contains("no such file in the server's import folders"),
"{xdg}: {reply}"
);
assert!(!reply.contains("PRIVATE"), "{reply}");
}
let reply = import("$HOME/", &home.join("Downloads/receipt.pdf"));
assert!(reply.contains("attachment_id"), "{reply}");
}
/// local_files.rs INVARIANT 0, a refused default: a downloads folder
/// that holds the books, or one inside them, is dropped, never fatal
/// (only a named root stops the server): it serves, with no import
/// folder, and an import says so.
#[test]
fn a_default_download_dir_holding_the_books_is_dropped_not_fatal() {
let tmp = temp("xdg-books");
let dir = tmp.canonicalize().unwrap();
let home = dir.join("home");
let downloads = dir.join("downloads");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(downloads.join("books")).unwrap();
std::fs::write(downloads.join("receipt.pdf"), "%PDF a receipt").unwrap();
let reply = one_call(
Command::new(BIN)
.arg("--data-dir")
.arg(downloads.join("books/books-data"))
.env("HOME", &home)
.env("XDG_DOWNLOAD_DIR", &downloads)
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config")),
"books.import_local_file",
serde_json::json!({"path": downloads.join("receipt.pdf").to_str().unwrap(), "category": "document"}),
);
assert!(reply.contains("has no import folder"), "{reply}");
// A downloads folder inside the books dir is dropped the same way.
let books = dir.join("books");
std::fs::create_dir_all(books.join("Downloads")).unwrap();
std::fs::write(books.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
let reply = one_call(
Command::new(BIN)
.arg("--data-dir")
.arg(books.join("books-data"))
.env("HOME", &home)
.env("XDG_DOWNLOAD_DIR", books.join("Downloads"))
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config")),
"books.import_local_file",
serde_json::json!({"path": books.join("Downloads/receipt.pdf").to_str().unwrap(), "category": "document"}),
);
assert!(reply.contains("has no import folder"), "{reply}");
assert_eq!(out.status.code(), Some(2));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("unknown argument: --import-root"), "{err}");
assert!(!err.contains("serving"), "{err}");
assert!(!dir.join("books-data").exists(), "opened the books");
}
+55 -54
View File
@@ -9,8 +9,8 @@
//! SOURCE: rules.md B-26; DECISION (P0 review round 2, 2026-10-16).
//! INVARIANTS (oracle):
//! 1. Every file the server read or wrote with full TINs is refused by
//! every way a file enters the books (books.import_local_file,
//! books.attach_receipt, books.file_document), from any session and
//! every way a file enters the books (books.attach_receipt and
//! books.file_document, its bytes given inline), from any session and
//! after the server restarts.
//! 2. Text holding a full TIN is refused, whoever made it: one the vault
//! holds, in every common rendering (nine digits; 3-2-4 joined by
@@ -59,8 +59,9 @@ impl Drop for Server {
}
}
/// The books dir, the import root, a folder outside both, and the
/// settings (a member, so the CPA package has a K-1 worksheet).
/// The books dir, a folder for files on their way in, a folder outside
/// both, and the settings (a member, so the CPA package has a K-1
/// worksheet).
struct Books {
dir: PathBuf,
inbox: PathBuf,
@@ -125,8 +126,6 @@ fn serve(b: &Books) -> (Server, String) {
.arg(b.dir.join("books-data"))
.arg("--books-toml")
.arg(&b.toml)
.arg("--import-root")
.arg(&b.inbox)
.arg("--http")
.arg(&addr)
.env("XDG_CONFIG_HOME", base.join("config"))
@@ -177,14 +176,17 @@ async fn ok(c: &Client, tool: &str, args: Value) -> Value {
.unwrap_or_else(|e| panic!("{tool}: {e}"))
}
fn b64(bytes: &[u8]) -> String {
/// `bytes` as a tool takes them inline: base64, and their SHA-256 (hex).
fn inline(bytes: &[u8]) -> (String, String) {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
(
base64::engine::general_purpose::STANDARD.encode(bytes),
ledger_store::journal::sha256_hex(bytes),
)
}
/// Every way the file at `path` (copied into the import root as `name`)
/// could enter the books is refused with `why`, and nothing is stored:
/// the refusals, tool by tool.
/// Every way the file at `path` (named `name`) could enter the books is
/// refused with `why`, and nothing is stored: the refusals, tool by tool.
async fn refused_everywhere(
c: &Client,
b: &Books,
@@ -193,23 +195,20 @@ async fn refused_everywhere(
why: &str,
) -> Vec<String> {
let bytes = std::fs::read(path).unwrap();
let copy = b.inbox.join(name);
std::fs::copy(path, &copy).unwrap();
let (content, sha) = inline(&bytes);
let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE);
let before = std::fs::read(&journal).unwrap();
let mut refusals = Vec::new();
for (tool, args) in [
(
"books.import_local_file",
json!({"path": copy.to_str().unwrap(), "category": "document", "document_category": "tax"}),
),
(
"books.attach_receipt",
json!({"file_name": name, "mime_type": "application/octet-stream", "content_base64": b64(&bytes)}),
json!({"file_name": name, "mime_type": "application/octet-stream",
"content_base64": content, "sha256": sha}),
),
(
"books.file_document",
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf", "content_base64": b64(&bytes)}),
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
),
] {
let err = call(c, tool, args).await.expect_err(tool);
@@ -221,7 +220,6 @@ async fn refused_everywhere(
before,
"a refusal stored something"
);
std::fs::remove_file(copy).unwrap();
refusals
}
@@ -240,6 +238,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
b"%PDF-1.4\n\xff\xfe\x00 scanned W-9 \x01\x02\x03\n%%EOF\n",
)
.unwrap();
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
let (server, url) = serve(&b);
let one = connect(&url).await;
for (code, name, ty) in [
@@ -270,7 +269,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
&one,
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_file_path": w9.to_str().unwrap()}),
"tin": SSN, "tin_type": "ssn", "w9_content_base64": w9_content, "w9_sha256": w9_sha}),
)
.await;
ok(
@@ -281,7 +280,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
.await;
// A bank statement filed in the books: the package carries it, and it
// is no TIN file (its routing numbers are not TINs).
accepted_everywhere(&one, &b, "statement.csv", BANK_CSV).await;
accepted_everywhere(&one, "statement.csv", BANK_CSV).await;
// What the server writes with TINs, from the first session.
let copy = b.out.join("w9-copy.pdf");
ok(
@@ -333,12 +332,12 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
std::fs::write(&fresh, format!("name,ssn\nDana,{}\n", "187-05-1907")).unwrap();
refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await;
// An ordinary file still goes in, every way.
let fine = b.inbox.join("lunch.pdf");
std::fs::write(&fine, b"%PDF-1.4\n\xff lunch receipt, order 12-34\n").unwrap();
let (fine, fine_sha) = inline(b"%PDF-1.4\n\xff lunch receipt, order 12-34\n");
ok(
&two,
"books.import_local_file",
json!({"path": fine.to_str().unwrap(), "category": "receipt"}),
"books.attach_receipt",
json!({"file_name": "lunch.pdf", "mime_type": "application/pdf",
"content_base64": fine, "sha256": fine_sha}),
)
.await;
// The books' own files the package carried are not marked: the
@@ -353,7 +352,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
"the package carries the statement"
);
}
accepted_everywhere(&two, &b, "statement-again.csv", BANK_CSV).await;
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await;
drop((one, two));
drop(server);
@@ -406,8 +405,9 @@ async fn a_refused_set_w9_never_marks_its_file() {
.await;
}
let w9_args = |party: &str, path: &Path| {
let (content, sha) = inline(&std::fs::read(path).unwrap());
json!({"party": party, "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_file_path": path.to_str().unwrap()})
"tin": SSN, "tin_type": "ssn", "w9_content_base64": content, "w9_sha256": sha})
};
for (party, path, why) in [
("Nobody", &w9, "unknown party"),
@@ -424,51 +424,54 @@ async fn a_refused_set_w9_never_marks_its_file() {
("w9-scan.pdf", w9_bytes.clone()),
("notes.txt", std::fs::read(&notes).unwrap()),
] {
let (content, sha) = inline(&bytes);
let out = ok(
&c,
"books.file_document",
json!({"category": "other", "file_name": name, "mime_type": "application/pdf",
"content_base64": b64(&bytes)}),
"content_base64": content, "sha256": sha}),
)
.await;
assert!(out["attachment_id"].is_u64(), "{name}: {out}");
}
// A set_w9 that succeeds marks it.
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await;
let err = call(
&c,
"books.file_document",
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
"content_base64": b64(&w9_bytes)}),
)
.await
.expect_err("marked");
assert!(err.contains(TIN_FILE), "{err}");
let (content, sha) = inline(&w9_bytes);
for (tool, args) in [
(
"books.file_document",
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
),
(
"books.attach_receipt",
json!({"file_name": "again.pdf", "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
),
] {
let err = call(&c, tool, args).await.expect_err("marked");
assert!(err.contains(TIN_FILE), "{tool}: {err}");
}
}
/// Every way a file enters the books takes the text `name` holding
/// `text`, and the journal shows it.
async fn accepted_everywhere(c: &Client, b: &Books, name: &str, text: &str) {
let path = b.inbox.join(name);
std::fs::write(&path, text).unwrap();
async fn accepted_everywhere(c: &Client, name: &str, text: &str) {
let (content, sha) = inline(text.as_bytes());
for (tool, args) in [
(
"books.import_local_file",
json!({"path": path.to_str().unwrap(), "category": "document", "document_category": "statement"}),
),
(
"books.attach_receipt",
json!({"file_name": name, "mime_type": "text/csv", "content_base64": b64(text.as_bytes())}),
json!({"file_name": name, "mime_type": "text/csv", "content_base64": content, "sha256": sha}),
),
(
"books.file_document",
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv", "content_base64": b64(text.as_bytes())}),
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv",
"content_base64": content, "sha256": sha}),
),
] {
let out = ok(c, tool, args).await;
assert!(out["attachment_id"].is_u64(), "{tool}: {out}");
}
std::fs::remove_file(path).unwrap();
}
/// A real bank export: ABA routing numbers (nine digits), account and
@@ -526,7 +529,7 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
std::fs::write(&path, format!("payee,tin\n{printed}\n")).unwrap();
refused_everywhere(&c, &b, &path, &format!("payees-{n}.csv"), TIN_PRINTED).await;
}
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
drop(c);
drop(server);
@@ -542,12 +545,11 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
refused_everywhere(&c, &b, &path, "members-1.csv", TIN_PRINTED).await;
accepted_everywhere(
&c,
&b,
"bank-2.csv",
&format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"),
)
.await;
accepted_everywhere(&c, &b, "members-0.csv", "member,tin\nKat,830835531\n").await;
accepted_everywhere(&c, "members-0.csv", "member,tin\nKat,830835531\n").await;
}
/// INVARIANT 3b through the tools: the company's own EIN, as its
@@ -571,7 +573,6 @@ async fn the_company_s_own_ein_goes_in_and_no_other() {
let c = connect(&url).await;
accepted_everywhere(
&c,
&b,
"plan.md",
"# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n",
)
@@ -697,8 +698,8 @@ async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() {
};
let open = probes(&c).await;
refused_everywhere(&c, &b, &tin, "tin.csv", TIN_HELD).await;
accepted_everywhere(&c, &b, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
drop(c);
drop(server);