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

Generated
+4
View File
@@ -3824,6 +3824,7 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
name = "ledger-cli" name = "ledger-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"chrono", "chrono",
"ledger-config", "ledger-config",
"ledger-core", "ledger-core",
@@ -3836,6 +3837,7 @@ dependencies = [
"reqwest", "reqwest",
"rmcp", "rmcp",
"serde_json", "serde_json",
"sha2 0.10.9",
"tempfile", "tempfile",
"tokio", "tokio",
"toml 1.1.8+spec-1.1.0", "toml 1.1.8+spec-1.1.0",
@@ -3908,6 +3910,7 @@ dependencies = [
"rpassword", "rpassword",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -3960,6 +3963,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9",
"tempfile", "tempfile",
"thiserror 2.0.21", "thiserror 2.0.21",
"tokio", "tokio",
+2
View File
@@ -23,6 +23,7 @@ ledger-store = { path = "../ledger-store" }
ledger-gui = { path = "../ledger-gui", optional = true } ledger-gui = { path = "../ledger-gui", optional = true }
[dev-dependencies] [dev-dependencies]
base64 = "0.22"
chrono = "0.4" chrono = "0.4"
libc = "0.2" libc = "0.2"
reqwest = "0.13" reqwest = "0.13"
@@ -32,6 +33,7 @@ ledger-config = { path = "../ledger-config" }
ledger-core = { path = "../ledger-core" } ledger-core = { path = "../ledger-core" }
ledger-vault = { path = "../ledger-vault" } ledger-vault = { path = "../ledger-vault" }
serde_json = "1" serde_json = "1"
sha2 = "0.10"
toml = "1" toml = "1"
tempfile = "3" tempfile = "3"
+50 -3
View File
@@ -400,6 +400,8 @@ fn print_usage() {
); );
eprintln!(" wanderbooks mcp --book ID [--token NAME]"); eprintln!(" wanderbooks mcp --book ID [--token NAME]");
eprintln!(" wanderbooks passphrase"); eprintln!(" wanderbooks passphrase");
eprintln!(" wanderbooks upload --book ID [--token NAME] UPLOAD [--] FILE");
eprintln!(" wanderbooks download --book ID [--token NAME] ATTACHMENT --to FOLDER");
eprintln!(" wanderbooks serve | close | doctor"); eprintln!(" wanderbooks serve | close | doctor");
eprintln!(); eprintln!();
eprintln!(" new make a books directory and start the agent in it (alias: init)"); eprintln!(" new make a books directory and start the agent in it (alias: init)");
@@ -410,6 +412,8 @@ fn print_usage() {
eprintln!(" token make, list or revoke an agent's token for books (`token --help`)"); eprintln!(" token make, list or revoke an agent's token for books (`token --help`)");
eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)"); eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)");
eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)"); eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)");
eprintln!(" upload send a file for an upload an agent began (books.begin_upload)");
eprintln!(" download save a copy of a stored file (one too large to come back inline)");
eprintln!(" serve hold the books open until Ctrl-C"); eprintln!(" serve hold the books open until Ctrl-C");
eprintln!(" close close the books now: everything holding them lets go"); eprintln!(" close close the books now: everything holding them lets go");
eprintln!(" doctor whether the books are open, what holds them, what went wrong"); eprintln!(" doctor whether the books are open, what holds them, what went wrong");
@@ -994,6 +998,34 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
} }
} }
/// `upload` and `download`: the agent's file commands, whose words may
/// be paths that aren't text.
#[cfg(unix)]
fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
let prog = format!("wanderbooks {mode}");
let result = match mode {
"upload" => ledger_host::commands::upload(&prog, args),
_ => ledger_host::commands::download(&prog, args),
};
match result {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// The file commands, where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_files(_mode: &str, _args: Vec<std::ffi::OsString>) -> i32 {
eprintln!("error: opening the books this way comes to this system with P11");
2
}
/// The modes that hold the books, where local sockets aren't built yet. /// The modes that hold the books, where local sockets aren't built yet.
#[cfg(not(unix))] #[cfg(not(unix))]
fn run_local(_mode: &str, _args: Vec<String>) -> i32 { fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
@@ -1009,8 +1041,23 @@ fn run_server(_args: Vec<String>) -> i32 {
} }
fn main() { fn main() {
// A file's path may be any name the system takes: the file commands
// get the words as given. Everything else is text.
let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect();
if let Some(mode @ ("upload" | "download")) = given.first().and_then(|m| m.to_str()) {
let mode = mode.to_string();
std::process::exit(run_files(&mode, given[1..].to_vec()));
}
let Ok(words) = given
.into_iter()
.map(std::ffi::OsString::into_string)
.collect::<Result<Vec<String>, _>>()
else {
eprintln!("error: an argument isn't UTF-8 text");
std::process::exit(2);
};
// The modes run in this process and take their own flags. // The modes run in this process and take their own flags.
let mut all = env::args().skip(1); let mut all = words.clone().into_iter();
match all.next().as_deref() { match all.next().as_deref() {
Some("gui") => std::process::exit(run_gui(all.collect())), Some("gui") => std::process::exit(run_gui(all.collect())),
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())), Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
@@ -1030,7 +1077,7 @@ fn main() {
let mut start = true; let mut start = true;
let mut agent = Agent::Claude; let mut agent = Agent::Claude;
let mut cmd: Option<String> = None; let mut cmd: Option<String> = None;
for arg in env::args().skip(1) { for arg in words {
match arg.as_str() { match arg.as_str() {
"--help" | "-h" => { "--help" | "-h" => {
print_usage(); print_usage();
@@ -1055,7 +1102,7 @@ fn main() {
} }
if let Some( if let Some(
mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve" mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve"
| "close" | "books" | "doctor" | "token" | "passphrase"), | "close" | "books" | "doctor" | "token" | "passphrase" | "upload" | "download"),
) = cmd.as_deref() ) = cmd.as_deref()
{ {
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]"); eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
+806 -26
View File
@@ -265,7 +265,8 @@ async fn post_one(c: &Client, n: u64) -> Result<u64, String> {
c, c,
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"), json!({"entry_id": id, "file_name": format!("r{n}.txt"),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA=="}), "mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
) )
.await?; .await?;
call( call(
@@ -747,6 +748,42 @@ async fn raw_session(http: &reqwest::Client, book: &str) -> String {
session session
} }
/// Begin an upload on `session` (books.begin_upload) and send `bytes` for
/// it over the upload route: its id.
async fn raw_upload(http: &reqwest::Client, session: &str, bytes: &[u8], name: &str) -> String {
let begin = json!({"jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": {
"name": "books.begin_upload", "arguments": {
"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": "application/pdf", "file_name": name, "purpose": "document"}}});
let r = http
.post("http://localhost/b/acme/mcp")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-session-id", session)
.header("mcp-protocol-version", "2025-06-18")
.body(begin.to_string())
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let id = r
.split("\"upload_id\":\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or_else(|| panic!("no upload: {r}"))
.to_string();
let put = http
.put(format!("http://localhost/b/acme/u/{id}"))
.body(bytes.to_vec())
.send()
.await
.unwrap();
assert_eq!(put.status().as_u16(), 200, "{:?}", put.text().await);
id
}
/// Wait for a child to exit, at most `secs`. /// Wait for a child to exit, at most `secs`.
fn wait_exit(child: &mut Child, secs: u64) -> Option<std::process::ExitStatus> { fn wait_exit(child: &mut Child, secs: u64) -> Option<std::process::ExitStatus> {
let deadline = Instant::now() + Duration::from_secs(secs); let deadline = Instant::now() + Duration::from_secs(secs);
@@ -768,14 +805,14 @@ async fn a_call_whose_client_hung_up_finishes_before_the_close() {
home.create("acme", "USD"); home.create("acme", "USD");
let http = mcp_http_for(&home, "acme"); let http = mcp_http_for(&home, "acme");
let session = raw_session(&http, "acme").await; let session = raw_session(&http, "acme").await;
// A large document: its filing takes long enough to close mid-way. // A large document, sent as an upload: its filing takes long enough to
// close mid-way.
let mut bytes = b"%PDF-1.4\n".to_vec(); let mut bytes = b"%PDF-1.4\n".to_vec();
bytes.extend((0..20_000_000u32).map(|i| (i % 251) as u8)); bytes.extend((0..20_000_000u32).map(|i| (i % 251) as u8));
use base64_engine::encode; let upload = raw_upload(&http, &session, &bytes, "big.pdf").await;
let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
"name": "books.file_document", "arguments": { "name": "books.file_document", "arguments": {
"category": "formation", "file_name": "big.pdf", "category": "formation", "upload_id": upload}}});
"mime_type": "application/pdf", "content_base64": encode(&bytes)}}});
let response = http let response = http
.post("http://localhost/b/acme/mcp") .post("http://localhost/b/acme/mcp")
.header("content-type", "application/json") .header("content-type", "application/json")
@@ -810,27 +847,6 @@ async fn a_call_whose_client_hung_up_finishes_before_the_close() {
assert_eq!(snap.attachment_bytes(filed[0].id).unwrap(), bytes); assert_eq!(snap.attachment_bytes(filed[0].id).unwrap(), bytes);
} }
/// Base64 for the tests (standard alphabet, padded).
mod base64_engine {
pub fn encode(bytes: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len() * 4 / 3 + 4);
for c in bytes.chunks(3) {
let n = (u32::from(c[0]) << 16)
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
| u32::from(*c.get(2).unwrap_or(&0));
for (i, shift) in [18, 12, 6, 0].iter().enumerate() {
out.push(if i <= c.len() {
T[((n >> shift) & 63) as usize] as char
} else {
'='
});
}
}
out
}
}
/// Send the start of a request that never finishes. /// Send the start of a request that never finishes.
fn stall(home: &Home) -> std::os::unix::net::UnixStream { fn stall(home: &Home) -> std::os::unix::net::UnixStream {
use std::io::Write; use std::io::Write;
@@ -1767,3 +1783,767 @@ async fn the_vault_and_the_approval_controls_need_their_scopes() {
drop((w, p)); drop((w, p));
assert!(server.close().success()); assert!(server.close().success());
} }
// P2 slice 5: files in and out (ledger-mcp tools/files.rs, local.rs's
// upload and download routes; ledger-store uploads.rs).
fn b64(bytes: &[u8]) -> String {
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes)
}
/// Begin an upload on `c` for `bytes`: its id.
async fn begin(c: &Client, bytes: &[u8], name: &str, purpose: &str) -> String {
let r = call(
c,
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": "application/pdf", "file_name": name, "purpose": purpose}),
)
.await
.unwrap();
r["upload_id"].as_str().unwrap().to_string()
}
/// PUT `bytes` for upload `id` with `token`: the status, the refusal
/// code and the body.
async fn put(home: &Home, token: &str, id: &str, bytes: &[u8]) -> (u16, Option<String>, String) {
let r = mcp_http(home)
.put(format!("http://localhost/b/acme/u/{id}"))
.bearer_auth(token)
.body(bytes.to_vec())
.send()
.await
.unwrap();
let status = r.status().as_u16();
let code = r
.headers()
.get(ledger_mcp::local::REFUSED_HEADER)
.map(|v| v.to_str().unwrap().to_string());
(status, code, r.text().await.unwrap())
}
/// A receipt-like PDF of `size` bytes, different for each `tag`.
fn pdf(tag: &str, size: usize) -> Vec<u8> {
let mut b = format!("%PDF-1.4 {tag} ").into_bytes();
let mut n: u32 = 0;
while b.len() < size {
b.extend_from_slice(&n.to_le_bytes());
n = n.wrapping_mul(1_103_515_245).wrapping_add(12_345);
}
b.truncate(size);
b
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_upload_is_filed_once_by_its_own_token_exactly_as_declared() {
let home = Home::new("upload");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
let bytes = pdf("one", 300 * 1024);
// The command it gives runs with the agent's own path; it names no
// path of the server's.
let r = call(
&c,
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(&bytes), "size": bytes.len(),
"mime_type": "application/pdf", "file_name": "big receipt.pdf",
"purpose": "document"}),
)
.await
.unwrap();
home.assert_hidden(&r.to_string());
let id = r["upload_id"].as_str().unwrap().to_string();
assert_eq!(
r["command"],
json!(format!(
"wanderbooks upload --book acme --token test {id} -- <the file's path>"
))
);
assert_eq!(r["max_bytes"], json!(25 * 1024 * 1024));
assert!(r["expires_at"].as_str().unwrap().ends_with('Z'));
// Not sent yet: nothing to file.
let e = call(
&c,
"books.file_document",
json!({"category": "other", "upload_id": id}),
)
.await
.unwrap_err();
assert!(e.contains("haven't been sent"), "{e}");
// Anything but the bytes declared is refused and kept nowhere.
let mut short = bytes.clone();
short.pop();
let mut changed = bytes.clone();
changed[100] ^= 1;
for wrong in [&short, &changed] {
let (status, code, _) = put(&home, &token, &id, wrong).await;
assert_eq!((status, code.as_deref()), (400, Some("not_as_declared")));
}
// Another token's upload takes nothing from this one.
let other = home.token_with("acme", "other", &["read", "write"]);
let (status, code, _) = put(&home, &other, &id, &bytes).await;
assert_eq!((status, code.as_deref()), (403, Some("not_yours")));
let (status, code, _) = put(&home, &token, "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &bytes).await;
assert_eq!((status, code.as_deref()), (404, Some("no_such_upload")));
let (status, _, body) = put(&home, &token, &id, &bytes).await;
assert_eq!(status, 200, "{body}");
let got: Value = serde_json::from_str(&body).unwrap();
assert_eq!(got["size"], json!(bytes.len()));
let (status, code, _) = put(&home, &token, &id, &bytes).await;
assert_eq!((status, code.as_deref()), (409, Some("already_sent")));
// No token: refused at the door, and recorded as an upload too; what
// was sent (25 MiB, more than a socket holds) is read, so the client
// gets the answer.
let r = mcp_http(&home)
.put(format!("http://localhost/b/acme/u/{id}"))
.body(vec![b'x'; 25 * 1024 * 1024])
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 401);
// Every PUT is in the access log, refusals too, under its token.
let log = call(
&c,
"books.access_log",
json!({"action": "upload", "limit": 20}),
)
.await
.unwrap();
let seen: Vec<(String, String)> = log["records"]
.as_array()
.unwrap()
.iter()
.rev()
.map(|r| {
(
r["principal"]["label"].as_str().unwrap().to_string(),
r["result"].to_string(),
)
})
.collect();
let outcome = |s: &(String, String)| s.1.clone();
assert_eq!(seen.len(), 7, "{seen:?}");
assert!(
outcome(&seen[0]).contains("not_as_declared")
&& outcome(&seen[1]).contains("not_as_declared")
);
assert_eq!(seen[2].0, "other");
assert!(outcome(&seen[2]).contains("not_yours"));
assert!(outcome(&seen[3]).contains("no_such_upload"));
assert_eq!(outcome(&seen[4]), "\"ok\"");
assert!(outcome(&seen[5]).contains("already_sent"));
let at_door = &log["records"][0];
assert_eq!(at_door["subject"], json!(id), "{at_door}");
assert!(
at_door["result"].to_string().contains("refused"),
"{at_door}"
);
// Only the token that began it files it.
let c2 = connect_with(&home, "acme", Some(other.clone()))
.await
.unwrap();
let e = call(
&c2,
"books.file_document",
json!({"category": "other", "upload_id": id}),
)
.await
.unwrap_err();
assert!(e.contains("another token"), "{e}");
// Filed once, under the name and type it was declared with.
let filed = call(
&c,
"books.file_document",
json!({"category": "other", "upload_id": id}),
)
.await
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap();
assert_eq!(
filed["sha256"],
json!(ledger_store::journal::sha256_hex(&bytes))
);
let e = call(
&c,
"books.file_document",
json!({"category": "other", "upload_id": id}),
)
.await
.unwrap_err();
assert!(e.contains("no upload"), "{e}");
let meta = call(&c, "books.get_receipt", json!({"attachment_id": att}))
.await
.unwrap();
assert_eq!(meta["file_name"], json!("big receipt.pdf"));
assert_eq!(meta["mime_type"], json!("application/pdf"));
assert_eq!(meta["size_bytes"], json!(bytes.len()));
assert!(meta.get("content_base64").is_none_or(Value::is_null));
// Too large to come back inline: the command that saves a copy.
let big = call(
&c,
"books.get_receipt",
json!({"attachment_id": att, "view": "bytes"}),
)
.await
.unwrap();
assert!(big.get("content_base64").is_none_or(Value::is_null));
assert_eq!(
big["download"],
json!(format!(
"wanderbooks download --book acme --token test {att} --to <a folder>"
))
);
// The same bytes again: the attachment that holds them, no upload.
let again = call(
&c,
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(&bytes), "size": bytes.len(),
"mime_type": "application/pdf", "file_name": "copy.pdf", "purpose": "receipt"}),
)
.await
.unwrap();
assert_eq!(again["existing_attachment_id"], json!(att));
assert!(again.get("upload_id").is_none_or(Value::is_null));
// Nothing is left behind in the uploads' folder.
let left: Vec<_> = std::fs::read_dir(home.book_dir("acme").join("uploads"))
.unwrap()
.collect();
assert!(left.is_empty(), "{left:?}");
drop((c, c2));
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_call_that_fails_leaves_its_upload_for_the_next() {
let home = Home::new("upload-again");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
chart(&c).await;
let csv = b"Date,Description,Amount\n2026-03-02,Coffee,-4.50\n".to_vec();
let id = begin(&c, &csv, "bank.csv", "import").await;
let (status, _, body) = put(&home, &token, &id, &csv).await;
assert_eq!(status, 200, "{body}");
// Refused after the file was taken (no such account): given back.
let e = call(
&c,
"books.import_bank_csv",
json!({"upload_id": id, "bank_account_code": "1999", "default_offset_code": "6000"}),
)
.await
.unwrap_err();
assert!(e.contains("unknown bank account"), "{e}");
let done = call(
&c,
"books.import_bank_csv",
json!({"upload_id": id, "bank_account_code": "1000", "default_offset_code": "6000"}),
)
.await
.unwrap();
assert_eq!(done["count"], json!(1));
let e = call(
&c,
"books.import_bank_csv",
json!({"upload_id": id, "bank_account_code": "1000", "default_offset_code": "6000"}),
)
.await
.unwrap_err();
assert!(e.contains("no upload"), "{e}");
// A chart refused after it was taken is given back; filed, used up.
let chart = b"code,name,type,parent_code\n7000,Rent,expense,\n".to_vec();
let id = begin(&c, &chart, "chart.csv", "import").await;
assert_eq!(put(&home, &token, &id, &chart).await.0, 200);
let bad = b"code,name,type,parent_code\n7000,Rent,nonsense,\n".to_vec();
let bad_id = begin(&c, &bad, "bad.csv", "import").await;
assert_eq!(put(&home, &token, &bad_id, &bad).await.0, 200);
let e = call(&c, "books.import_chart_csv", json!({"upload_id": bad_id}))
.await
.unwrap_err();
assert!(e.contains("nonsense"), "{e}");
let e = call(&c, "books.import_chart_csv", json!({"upload_id": bad_id}))
.await
.unwrap_err();
assert!(e.contains("nonsense"), "given back: {e}");
call(&c, "books.import_chart_csv", json!({"upload_id": id}))
.await
.unwrap();
let e = call(&c, "books.import_chart_csv", json!({"upload_id": id}))
.await
.unwrap_err();
assert!(e.contains("no upload"), "{e}");
// A receipt attached is used up too.
let receipt = pdf("receipt", 900);
let id = begin(&c, &receipt, "r.pdf", "receipt").await;
assert_eq!(put(&home, &token, &id, &receipt).await.0, 200);
call(&c, "books.attach_receipt", json!({"upload_id": id}))
.await
.unwrap();
let e = call(&c, "books.attach_receipt", json!({"upload_id": id}))
.await
.unwrap_err();
assert!(e.contains("no upload"), "{e}");
// Filed only by a tool of its purpose; the wrong tool leaves it.
let doc = pdf("purpose", 500);
let id = begin(&c, &doc, "doc.pdf", "import").await;
let (status, _, body) = put(&home, &token, &id, &doc).await;
assert_eq!(status, 200, "{body}");
let e = call(
&c,
"books.file_document",
json!({"category": "other", "upload_id": id}),
)
.await
.unwrap_err();
assert!(e.contains("begun for import"), "{e}");
let e = call(&c, "books.import_chart_csv", json!({"upload_id": id}))
.await
.unwrap_err();
assert!(e.contains("isn't UTF-8"), "{e}");
// The import refused the bytes (not a chart) after taking them: still
// there for the next call, which is refused for its purpose again.
let e = call(&c, "books.attach_receipt", json!({"upload_id": id}))
.await
.unwrap_err();
assert!(e.contains("begun for import"), "{e}");
let e = call(
&c,
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(&doc), "size": doc.len(),
"mime_type": "application/pdf", "file_name": "x", "purpose": "anything"}),
)
.await
.unwrap_err();
assert!(e.contains("purpose is"), "{e}");
drop(c);
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn stored_bytes_are_named_only_where_they_can_be_linked() {
let home = Home::new("stored");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let csv = b"code,name,type,parent_code\n1000,Cash,asset,\n".to_vec();
let filed = call(
&c,
"books.file_document",
json!({"category": "import_source", "file_name": "chart.csv", "mime_type": "text/csv",
"content_base64": b64(&csv), "sha256": ledger_store::journal::sha256_hex(&csv)}),
)
.await
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap();
let begin_for = |purpose: &'static str| {
let c = &c;
let csv = csv.clone();
async move {
call(
c,
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(&csv), "size": csv.len(),
"mime_type": "text/csv", "file_name": "chart.csv", "purpose": purpose}),
)
.await
.unwrap()
}
};
// A document: the attachment that holds it. An import needs its bytes.
assert_eq!(
begin_for("document").await["existing_attachment_id"],
json!(att)
);
let import = begin_for("import").await;
assert!(import["upload_id"].as_str().unwrap().starts_with("up_"));
assert!(
import
.get("existing_attachment_id")
.is_none_or(Value::is_null)
);
// A withdrawn attachment can't be linked: never named.
call(
&c,
"books.withdraw_attachment",
json!({"attachment_id": att, "reason": "filed twice"}),
)
.await
.unwrap();
let again = begin_for("document").await;
assert!(again["upload_id"].as_str().unwrap().starts_with("up_"));
assert!(
again
.get("existing_attachment_id")
.is_none_or(Value::is_null)
);
drop(c);
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn bytes_given_inline_are_small_and_carry_their_hash() {
let home = Home::new("inline");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let small = pdf("small", 1000);
let sha = ledger_store::journal::sha256_hex(&small);
let file = |extra: Value| {
let mut args = json!({"category": "other", "file_name": "s.pdf",
"mime_type": "application/pdf"});
args.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
args
};
for (extra, why) in [
(json!({"content_base64": b64(&small)}), "sha256"),
(
json!({"content_base64": b64(&small), "sha256": ledger_store::journal::sha256_hex(b"x")}),
"lost on the way",
),
(
json!({"content_base64": "not base64!", "sha256": sha}),
"not valid base64",
),
(
json!({"content_base64": b64(&small), "sha256": sha, "upload_id": "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}),
"not both",
),
(json!({}), "missing"),
] {
let e = call(&c, "books.file_document", file(extra))
.await
.unwrap_err();
assert!(e.contains(why), "{why}: {e}");
}
// At most 256 KiB inline (the call's own limit is 1 MiB of base64).
let over = pdf("over", 256 * 1024 + 1);
let e = call(
&c,
"books.file_document",
file(json!({"content_base64": b64(&over),
"sha256": ledger_store::journal::sha256_hex(&over)})),
)
.await
.unwrap_err();
assert!(e.contains("upload it with books.begin_upload"), "{e}");
// Text longer than the most bytes' base64 is refused unread.
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4);
let e = call(
&c,
"books.file_document",
file(json!({"content_base64": long, "sha256": sha})),
)
.await
.unwrap_err();
assert!(e.contains("inline is at most"), "{e}");
// A plain name and a real type.
for (name, mime, why) in [
("../escape.pdf", "application/pdf", "no folder"),
("a\\b.pdf", "application/pdf", "no folder"),
("rcpt\u{202e}fdp.exe", "application/pdf", "invisible"),
("a\nb.pdf", "application/pdf", "control"),
(" ", "application/pdf", "name is needed"),
("ok.pdf", "pdf", "MIME type"),
("ok.pdf", "text/plain\r\nX-Evil: 1", "MIME type"),
] {
let e = call(
&c,
"books.file_document",
json!({"category": "other", "file_name": name, "mime_type": mime,
"content_base64": b64(&small), "sha256": sha}),
)
.await
.unwrap_err();
assert!(e.contains(why), "{name:?} {mime:?}: {e}");
}
let e = call(
&c,
"books.file_document",
file(json!({"file_name": "x".repeat(256), "content_base64": b64(&small), "sha256": sha})),
)
.await
.unwrap_err();
assert!(e.contains("at most 255 bytes"), "{e}");
let at = pdf("at", 256 * 1024);
let filed = call(
&c,
"books.file_document",
file(json!({"content_base64": b64(&at),
"sha256": ledger_store::journal::sha256_hex(&at).to_uppercase()})),
)
.await
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap();
let back = call(
&c,
"books.get_receipt",
json!({"attachment_id": att, "view": "bytes"}),
)
.await
.unwrap();
assert_eq!(back["content_base64"], json!(b64(&at)));
assert!(back.get("download").is_none_or(Value::is_null));
let e = call(
&c,
"books.get_receipt",
json!({"attachment_id": att, "view": "everything"}),
)
.await
.unwrap_err();
assert!(e.contains("meta") && e.contains("bytes"), "{e}");
drop(c);
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_download_is_the_stored_bytes_for_a_reader_and_is_recorded() {
let home = Home::new("download");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
let bytes = pdf("down", 2000);
let filed = call(
&c,
"books.file_document",
json!({"category": "other", "file_name": "Résumé \"final\".pdf",
"mime_type": "application/pdf", "content_base64": b64(&bytes),
"sha256": ledger_store::journal::sha256_hex(&bytes)}),
)
.await
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap();
let get = |bearer: Option<String>, what: String| {
let http = mcp_http(&home);
async move {
let mut r = http.get(format!("http://localhost/b/acme/a/{what}"));
if let Some(b) = bearer {
r = r.bearer_auth(b);
}
r.send().await.unwrap()
}
};
let r = get(Some(token.clone()), att.to_string()).await;
assert_eq!(r.status().as_u16(), 200);
let h = r.headers().clone();
assert_eq!(h["content-type"], "application/pdf");
assert_eq!(h["x-content-type-options"], "nosniff");
assert_eq!(
h["content-disposition"],
"attachment; filename=\"R_sum_ _final_.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%22final%22.pdf"
);
let digest = <sha2::Sha256 as sha2::Digest>::digest(&bytes);
assert_eq!(
h["repr-digest"],
format!("sha-256=:{}:", b64(&digest)).as_str()
);
assert_eq!(r.bytes().await.unwrap().to_vec(), bytes);
// A token without books:read gets nothing; nor does no token.
let writer = home.token_with("acme", "writer", &["write"]);
let r = get(Some(writer), att.to_string()).await;
assert_eq!(r.status().as_u16(), 403);
assert!(r.text().await.unwrap().contains("books:read"));
assert_eq!(get(None, att.to_string()).await.status().as_u16(), 401);
// GET only: a HEAD reads nothing and records nothing.
let head = mcp_http(&home)
.head(format!("http://localhost/b/acme/a/{att}"))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(head.status().as_u16(), 405);
assert_eq!(head.headers()["allow"], "GET");
for missing in [
"999".to_string(),
"x".to_string(),
"-1".to_string(),
format!("+{att}"),
format!("0{att}"),
] {
let r = get(Some(token.clone()), missing).await;
assert_eq!(r.status().as_u16(), 404);
home.assert_hidden(&r.text().await.unwrap());
}
// Each is in the access log, refusals too.
let log = call(
&c,
"books.access_log",
json!({"action": "download", "limit": 10}),
)
.await
.unwrap();
let outcomes: Vec<String> = log["records"]
.as_array()
.unwrap()
.iter()
.map(|r| r["result"].to_string())
.collect();
assert_eq!(outcomes.len(), 8, "{outcomes:?}");
// A TIN in the path is masked in the log, whole.
let r = get(Some(token.clone()), "123-45-6789".to_string()).await;
assert_eq!(r.status().as_u16(), 404);
let log = call(
&c,
"books.access_log",
json!({"action": "download", "limit": 1}),
)
.await
.unwrap();
let subject = log["records"][0]["subject"].as_str().unwrap().to_string();
assert!(
!subject.contains("6789") && !subject.contains("123-45"),
"{subject}"
);
assert!(
outcomes
.iter()
.filter(|o| o.contains("no_such_attachment"))
.count()
== 5
);
assert!(outcomes.iter().any(|o| o.contains("insufficient_scope")));
drop(c);
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_w9_and_a_file_holding_a_tin_go_by_upload_only_where_they_belong() {
let home = Home::new("w9-upload");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
call(
&c,
"books.add_party",
json!({"name": "Payee", "roles": ["contractor"]}),
)
.await
.unwrap();
call(
&c,
"books.set_tin",
json!({"party": "Payee", "tin": "620-37-1111", "tin_type": "ssn"}),
)
.await
.unwrap();
// A file holding a TIN the vault keeps is refused, upload or not.
let notes = b"Payee notes: SSN 620-37-1111".to_vec();
let id = begin(&c, &notes, "notes.txt", "document").await;
assert_eq!(put(&home, &token, &id, &notes).await.0, 200);
let e = call(
&c,
"books.file_document",
json!({"category": "other", "upload_id": id, "mime_type": "text/plain"}),
)
.await
.unwrap_err();
assert!(e.contains("TIN vault"), "{e}");
// A W-9 goes only to the vault.
let w9 = b"%PDF-1.4 Form W-9 Payee 620-37-1111".to_vec();
let id = begin(&c, &w9, "payee-w9.pdf", "w9").await;
assert_eq!(put(&home, &token, &id, &w9).await.0, 200);
for (tool, args) in [
("books.attach_receipt", json!({"upload_id": id})),
(
"books.file_document",
json!({"category": "tax", "upload_id": id}),
),
] {
let e = call(&c, tool, args).await.unwrap_err();
assert!(e.contains("begun for w9"), "{tool}: {e}");
}
let w9_args = |party: &str| {
json!({"party": party, "received_on": "2026-01-05",
"entity_type": "individual/sole proprietor", "tin_last4": "1111",
"w9_upload_id": id})
};
// Refused after it was taken: given back.
let e = call(&c, "books.set_w9", w9_args("Nobody"))
.await
.unwrap_err();
assert!(!e.contains("upload"), "{e}");
let set = call(&c, "books.set_w9", w9_args("Payee")).await.unwrap();
assert_eq!(set["w9"]["in_vault"], json!(true), "{set}");
let e = call(&c, "books.set_w9", w9_args("Payee"))
.await
.unwrap_err();
assert!(e.contains("no upload"), "{e}");
let out = home.dir.path().join("payee copy.pdf");
let got = call(
&c,
"books.get_w9",
json!({"party": "Payee", "save_to": out.to_str().unwrap()}),
)
.await
.unwrap();
assert_eq!(got["file_name"], json!("payee-w9.pdf"));
assert_eq!(std::fs::read(&out).unwrap(), w9);
// The W-9's upload is gone; the refused file's waits to lapse.
let left: Vec<String> = std::fs::read_dir(home.book_dir("acme").join("uploads"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(left.iter().all(|n| !n.starts_with(&id)), "{left:?}");
drop(c);
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn lapsed_uploads_are_swept_while_the_server_runs() {
let home = Home::new("sweep");
let server = Server::start(&home);
home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
let bytes = pdf("lapsing", 2000);
let id = begin(&c, &bytes, "w9.pdf", "w9").await;
assert_eq!(put(&home, &token, &id, &bytes).await.0, 200);
let folder = home.book_dir("acme").join("uploads");
// Its time runs out (the grant aged on the disk), and nothing else
// happens: the server sweeps it anyway.
let json = folder.join(format!("{id}.json"));
let mut grant: Value = serde_json::from_slice(&std::fs::read(&json).unwrap()).unwrap();
grant["expires"] = json!(0);
std::fs::write(&json, grant.to_string()).unwrap();
let deadline = Instant::now() + Duration::from_secs(25);
while std::fs::read_dir(&folder).unwrap().count() > 0 {
assert!(Instant::now() < deadline, "the lapsed upload wasn't swept");
tokio::time::sleep(Duration::from_millis(200)).await;
}
drop(c);
assert!(server.close().success());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_mcp_request_is_at_most_a_mebibyte() {
let home = Home::new("body");
let server = Server::start(&home);
home.create("acme", "USD");
let http = mcp_http_for(&home, "acme");
let session = raw_session(&http, "acme").await;
let post = |len: usize| {
// A call whose arguments pad it to `len` bytes exactly.
let head = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"books.chart","arguments":{"pad":""#;
let tail = r#""}}}"#;
let body = format!("{head}{}{tail}", "a".repeat(len - head.len() - tail.len()));
assert_eq!(body.len(), len);
http.post("http://localhost/b/acme/mcp")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-session-id", &session)
.header("mcp-protocol-version", "2025-06-18")
.body(body)
.send()
};
let max = ledger_mcp::local::MAX_BODY;
assert_eq!(post(max + 1).await.unwrap().status().as_u16(), 413);
let at = post(max).await.unwrap();
assert_ne!(at.status().as_u16(), 413, "{:?}", at.text().await);
drop(http);
assert!(server.close().success());
}
@@ -35,16 +35,10 @@ fn dev_stdio_is_the_books_server() {
.tempdir() .tempdir()
.unwrap(); .unwrap();
let data = dir.path().join("books").join("books-data"); let data = dir.path().join("books").join("books-data");
// An import root may not be inside the books dir (the data dir's
// parent), so it is a sibling of it.
let inbox = dir.path().join("inbox");
std::fs::create_dir(&inbox).unwrap();
let mut child = wanderbooks() let mut child = wanderbooks()
.arg("dev-stdio") .arg("dev-stdio")
.arg("--data-dir") .arg("--data-dir")
.arg(&data) .arg(&data)
.arg("--import-root")
.arg(&inbox)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::null()) .stderr(Stdio::null())
+287 -8
View File
@@ -383,7 +383,8 @@ fn post_one(a: &mut Agent, n: u64) -> Result<u64, String> {
a.call( a.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"), json!({"entry_id": id, "file_name": format!("r{n}.txt"),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA=="}), "mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
)?; )?;
a.call( a.call(
"books.post_entry", "books.post_entry",
@@ -1354,11 +1355,10 @@ fn a_message_too_large_is_refused_and_the_session_goes_on() {
a.initialize(); a.initialize();
{ {
let stdin = a.run.child.stdin.as_mut().unwrap(); let stdin = a.run.child.stdin.as_mut().unwrap();
// One byte over the limit (1 MiB), its newline aside.
let max = ledger_mcp::local::MAX_BODY;
stdin.write_all(b"{\"x\":\"").unwrap(); stdin.write_all(b"{\"x\":\"").unwrap();
let chunk = vec![b'a'; 1024 * 1024]; stdin.write_all(&vec![b'a'; max + 1 - 8]).unwrap();
for _ in 0..65 {
stdin.write_all(&chunk).unwrap();
}
stdin.write_all(b"\"}\n").unwrap(); stdin.write_all(b"\"}\n").unwrap();
stdin.flush().unwrap(); stdin.flush().unwrap();
} }
@@ -1427,10 +1427,11 @@ fn malformed_and_boundary_input_never_ends_the_session() {
assert!(line.contains("-32700"), "{line}"); assert!(line.contains("-32700"), "{line}");
let pong = a.request("ping", json!({}), 20); let pong = a.request("ping", json!({}), 20);
assert!(pong.get("result").is_some(), "{pong}"); assert!(pong.get("result").is_some(), "{pong}");
// Exactly 64 MiB of message (a JSON string padded with spaces), then // Exactly the limit, 1 MiB of message (a JSON string padded with
// a ping: the big one is the server's to refuse, the ping answered. // spaces), then a ping: the big one is the server's to refuse, the
// ping answered.
{ {
let max = 64 * 1024 * 1024; let max = 1024 * 1024;
let head = b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/x\",\"params\":{\"pad\":\""; let head = b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/x\",\"params\":{\"pad\":\"";
let tail = b"\"}}"; let tail = b"\"}}";
let stdin = a.run.child.stdin.as_mut().unwrap(); let stdin = a.run.child.stdin.as_mut().unwrap();
@@ -2050,3 +2051,281 @@ fn the_token_commands_and_the_bridge() {
assert!(err.contains("isn't a scope"), "{err}"); assert!(err.contains("isn't a scope"), "{err}");
assert!(b.end().is_some()); assert!(b.end().is_some());
} }
/// Run `command` (as books.begin_upload or books.get_receipt gave it,
/// with its placeholder cut off) with `more` words after it: its output.
fn run_given(
home: &Home,
command: &str,
placeholder: &str,
more: &[&std::ffi::OsStr],
) -> std::process::Output {
let words: Vec<&str> = command
.strip_suffix(placeholder)
.unwrap_or_else(|| panic!("{command}"))
.split(' ')
.collect();
assert_eq!(words[0], "wanderbooks", "{command}");
home.cmd(words[1])
.args(&words[2..])
.args(more)
.stdin(Stdio::null())
.output()
.unwrap()
}
#[test]
fn an_agent_sends_and_saves_files_with_the_commands_it_is_given() {
let home = Home::new("files");
home.create("acme");
let mut a = Agent::start(&home, "acme");
a.initialize();
let folder = home.dir.path().join("Downloads");
std::fs::create_dir(&folder).unwrap();
let mut bytes = b"%PDF-1.4 a big receipt ".to_vec();
bytes.resize(400 * 1024, b'x');
let file = folder.join("big receipt.pdf");
std::fs::write(&file, &bytes).unwrap();
let begin = |a: &mut Agent| {
a.call(
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(&bytes), "size": bytes.len(),
"mime_type": "application/pdf", "file_name": "big receipt.pdf",
"purpose": "receipt"}),
)
.unwrap()
};
let begun = begin(&mut a);
let upload = begun["upload_id"].as_str().unwrap().to_string();
let command = begun["command"].as_str().unwrap().to_string();
let quiet = |out: &std::process::Output| {
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(!text.contains(&home.root().display().to_string()), "{text}");
text
};
// Another file than the one declared: refused, nothing kept.
let other = folder.join("other.pdf");
std::fs::write(&other, b"%PDF-1.4 not it").unwrap();
let out = run_given(&home, &command, " <the file's path>", &[other.as_os_str()]);
assert!(!out.status.success());
assert!(quiet(&out).contains("not the"), "{}", quiet(&out));
// A file of exactly the largest size isn't refused for its size.
let edge = folder.join("edge.pdf");
std::fs::File::create(&edge)
.unwrap()
.set_len(25 * 1024 * 1024)
.unwrap();
let out = run_given(&home, &command, " <the file's path>", &[edge.as_os_str()]);
assert!(!out.status.success());
assert!(
quiet(&out).contains("more than the") && !quiet(&out).contains("at most"),
"{}",
quiet(&out)
);
std::fs::remove_file(&edge).unwrap();
// A file larger than any upload is refused before it is read.
let huge = folder.join("huge.pdf");
std::fs::File::create(&huge)
.unwrap()
.set_len(25 * 1024 * 1024 + 1)
.unwrap();
let out = run_given(&home, &command, " <the file's path>", &[huge.as_os_str()]);
assert!(!out.status.success());
assert!(quiet(&out).contains("at most"), "{}", quiet(&out));
// With no token kept for the name given: what the human does.
let out = home
.cmd("upload")
.args(["--book", "acme", "--token", "nobody", &upload])
.arg(&file)
.stdin(Stdio::null())
.output()
.unwrap();
assert!(!out.status.success());
assert!(
quiet(&out).contains("wanderbooks token create"),
"{}",
quiet(&out)
);
// A path that looks like a flag goes after the command's `--`.
let dashed = folder.join("-r.pdf");
std::fs::copy(&file, &dashed).unwrap();
let out = home
.cmd("upload")
.args(["--book", "acme", "not-an-id", "--"])
.arg(&dashed)
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(
quiet(&out).contains("isn't an upload's id"),
"{}",
quiet(&out)
);
// The file itself goes (by its copy whose name starts with `-`).
let out = run_given(&home, &command, " <the file's path>", &[dashed.as_os_str()]);
assert!(out.status.success(), "{}", quiet(&out));
assert!(quiet(&out).contains(&format!("Sent {} bytes", bytes.len())));
let filed = a
.call(
"books.file_document",
json!({"category": "other", "upload_id": upload}),
)
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap();
// Too big to come back inline: the command saves a copy, beside any
// file of its name, never over it.
let back = a
.call(
"books.get_receipt",
json!({"attachment_id": att, "view": "bytes"}),
)
.unwrap();
let download = back["download"].as_str().unwrap().to_string();
for n in 1..=2 {
let out = run_given(
&home,
&download,
" --to <a folder>",
&["--to".as_ref(), folder.as_os_str()],
);
assert!(out.status.success(), "{}", quiet(&out));
let saved = folder.join(format!("big receipt ({n}).pdf"));
assert_eq!(std::fs::read(&saved).unwrap(), bytes);
assert!(quiet(&out).contains(&saved.display().to_string()));
}
assert_eq!(std::fs::read(&file).unwrap(), bytes, "never overwritten");
let out = run_given(
&home,
&download,
" --to <a folder>",
&["--to".as_ref(), file.as_os_str()],
);
assert!(!out.status.success());
assert!(quiet(&out).contains("isn't a folder"));
let out = home
.cmd("download")
.args(["--book", "acme", "999", "--to"])
.arg(&folder)
.stdin(Stdio::null())
.output()
.unwrap();
assert!(!out.status.success());
quiet(&out);
assert_eq!(
std::fs::read_dir(&folder).unwrap().count(),
6,
"nothing else saved"
);
a.end();
}
#[test]
fn the_commands_given_run_as_the_agents_own_token() {
let home = Home::new("files-token");
home.create("acme");
let out = home
.cmd("token")
.args(["create", "--book", "acme", "--name", "bookkeeper"])
.stdin(Stdio::null())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let mut c = home.cmd("mcp");
c.args(["--book", "acme", "--token", "bookkeeper"]);
let mut a = Agent::from(c);
a.initialize();
let folder = home.dir.path().join("Downloads");
std::fs::create_dir(&folder).unwrap();
let mut bytes = b"%PDF-1.4 kept by the bookkeeper ".to_vec();
bytes.resize(300 * 1024, b'y');
let file = folder.join("r.pdf");
std::fs::write(&file, &bytes).unwrap();
let begun = a
.call(
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(&bytes), "size": bytes.len(),
"mime_type": "application/pdf", "file_name": "r.pdf", "purpose": "receipt"}),
)
.unwrap();
let command = begun["command"].as_str().unwrap().to_string();
assert!(command.contains(" --token bookkeeper "), "{command}");
let out = run_given(&home, &command, " <the file's path>", &[file.as_os_str()]);
assert!(out.status.success(), "{out:?}");
let filed = a
.call(
"books.file_document",
json!({"category": "other", "upload_id": begun["upload_id"]}),
)
.unwrap();
let back = a
.call(
"books.get_receipt",
json!({"attachment_id": filed["attachment_id"], "view": "bytes"}),
)
.unwrap();
let download = back["download"].as_str().unwrap().to_string();
assert!(download.contains(" --token bookkeeper "), "{download}");
let out = run_given(
&home,
&download,
" --to <a folder>",
&["--to".as_ref(), folder.as_os_str()],
);
assert!(out.status.success(), "{out:?}");
assert_eq!(std::fs::read(folder.join("r (1).pdf")).unwrap(), bytes);
// A file whose name isn't text goes, and comes back into a folder
// whose name isn't text.
use std::os::unix::ffi::OsStrExt;
let odd = folder.join(std::ffi::OsStr::from_bytes(b"r\xff.pdf"));
let mut other = bytes.clone();
other[40] = b'z';
std::fs::write(&odd, &other).unwrap();
let begun = a
.call(
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(&other), "size": other.len(),
"mime_type": "application/pdf", "file_name": "r2.pdf", "purpose": "receipt"}),
)
.unwrap();
let out = run_given(
&home,
begun["command"].as_str().unwrap(),
" <the file's path>",
&[odd.as_os_str()],
);
assert!(out.status.success(), "{out:?}");
let filed = a
.call(
"books.file_document",
json!({"category": "other", "upload_id": begun["upload_id"]}),
)
.unwrap();
let odd_folder = folder.join(std::ffi::OsStr::from_bytes(b"out\xfe"));
std::fs::create_dir(&odd_folder).unwrap();
let out = home
.cmd("download")
.args(["--book", "acme", "--token", "bookkeeper"])
.arg(filed["attachment_id"].to_string())
.arg("--to")
.arg(&odd_folder)
.stdin(Stdio::null())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
assert_eq!(std::fs::read(odd_folder.join("r2.pdf")).unwrap(), other);
// Anywhere else, a word that isn't text is refused plainly.
let out = home
.cmd("books")
.arg(std::ffi::OsStr::from_bytes(b"\xff"))
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("isn't UTF-8"));
a.end();
}
+2
View File
@@ -15,6 +15,8 @@ serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time", "process"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time", "process"] }
reqwest = "0.13" reqwest = "0.13"
base64 = "0.22" base64 = "0.22"
# `wanderbooks download` checks what arrived against its Repr-Digest.
sha2 = "0.10"
tokio-util = { version = "0.7", features = ["rt"] } tokio-util = { version = "0.7", features = ["rt"] }
# Agents' tokens are kept in the OS keyring (plan 3.3, 3.5): the Secret # Agents' tokens are kept in the OS keyring (plan 3.3, 3.5): the Secret
# Service on Linux. macOS and Windows stores come with P11. # Service on Linux. macOS and Windows stores come with P11.
+2 -2
View File
@@ -59,8 +59,8 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Notify, mpsc, watch}; use tokio::sync::{Notify, mpsc, watch};
use tokio::time::Instant; use tokio::time::Instant;
/// The longest message the agent may send (receipts ride in calls until /// The longest message the agent may send (a file bigger than a call
/// uploads have their own route). /// takes inline goes by an upload).
const MAX_MESSAGE: usize = ledger_mcp::local::MAX_BODY; const MAX_MESSAGE: usize = ledger_mcp::local::MAX_BODY;
/// The bridge's own request ids upstream start so. /// The bridge's own request ids upstream start so.
const OWN_ID: &str = "wanderbooks-bridge-"; const OWN_ID: &str = "wanderbooks-bridge-";
+666
View File
@@ -1158,10 +1158,676 @@ pub fn passphrase(
Ok(()) Ok(())
} }
/// A request to a book over the server's socket, with the agent's token
/// from the keyring, the books held open meanwhile: its status, the
/// headers wanted, and its body.
fn on_the_socket(
common: &Common,
book: &str,
token_name: &str,
request: impl FnOnce(&reqwest::Client, String) -> reqwest::RequestBuilder,
) -> Result<(u16, reqwest::header::HeaderMap, Vec<u8>), String> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let outcome = rt.block_on(async {
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)?;
let lease = open(&mut opener).await.map_err(|e| match e {
HoldError::ClosedByOwner => {
"the books were closed just now (Close the books): nothing was done".to_string()
}
HoldError::Failed(why) => why,
})?;
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, token_name);
let keeper = crate::tokens::keeper(&run_dir, &common.knobs);
let token = tokio::task::spawn_blocking(move || keeper.get(&account))
.await
.map_err(|e| e.to_string())??
.ok_or_else(|| {
format!(
"this computer has no token for the books {book} (named {token_name:?}): the human makes one with `wanderbooks token create --book {book}`"
)
})?;
let token = zeroize::Zeroizing::new(token);
let socket = ledger_store::root::SocketPath::new(&run_dir, ledger_mcp::local::MCP_SOCKET)
.map_err(|e| format!("can't reach the books server: {e}"))?;
let http = reqwest::Client::builder()
.unix_socket(socket.path())
.no_proxy()
.build()
.map_err(|e| e.to_string())?;
let response = request(&http, format!("http://localhost/b/{book}"))
.bearer_auth(token.as_str())
.send()
.await
.map_err(|e| format!("the books server didn't answer: {e}"))?;
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response
.bytes()
.await
.map_err(|e| format!("the books server's answer was cut off: {e}"))?
.to_vec();
drop(lease);
Ok::<_, String>((status, headers, body))
});
rt.shutdown_timeout(Duration::from_secs(1));
outcome
}
/// The flags `upload` and `download` share: the books, the token's name,
/// and the words given.
struct FileArgs {
common: Common,
book: String,
token: String,
words: Vec<std::ffi::OsString>,
to: Option<PathBuf>,
}
fn file_args(
args: impl IntoIterator<Item = std::ffi::OsString>,
usage: &dyn Fn(),
) -> Result<Option<FileArgs>, Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut book = None;
let mut token = crate::tokens::DEFAULT_NAME.to_string();
let mut words = Vec::new();
let mut to = None;
let mut args = args.into_iter();
// Flags and their values are text; a file's path, or the folder
// `--to` names, may be any name the system takes.
let text = |v: std::ffi::OsString, what: &str| {
v.into_string()
.map_err(|_| format!("{what} isn't UTF-8 text"))
};
while let Some(arg) = args.next() {
// After `--`, words only (a file whose name starts with `-`).
if arg == "--" {
words.extend(args.by_ref());
break;
}
let Some(flag) = arg.to_str().filter(|a| a.starts_with('-')) else {
words.push(arg);
continue;
};
let flag = flag.to_string();
let mut value = |what: &str| args.next().ok_or_else(|| format!("{flag} needs {what}"));
match flag.as_str() {
"--root" => common.root = Some(PathBuf::from(value("a folder")?)),
"--book" => book = Some(text(value("an id")?, "--book's id")?),
"--token" => token = text(value("a name")?, "--token's name")?,
"--to" => to = Some(PathBuf::from(value("a folder")?)),
"--help" | "-h" => {
usage();
return Ok(None);
}
f if f == FLAG => {
common.knobs = Knobs::parse(&text(value("a value")?, FLAG)?)?;
}
s => {
eprintln!("unknown flag: {s}");
usage();
return Err(exit(2));
}
}
}
let Some(book) = book else {
eprintln!("error: which books? give --book ID");
return Err(exit(2));
};
let book = ledger_store::root::BookId::parse(&book)
.map(|id| id.to_string())
.map_err(|e| e.to_string())?;
Ok(Some(FileArgs {
common,
book,
token,
words,
to,
}))
}
/// `wanderbooks upload --book ID UPLOAD FILE`: send a file's bytes for an
/// upload begun with `books.begin_upload` (plan 4.7, D-28: the agent runs
/// it).
pub fn upload(
prog: &str,
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<(), Box<dyn std::error::Error>> {
let usage = || {
eprintln!("usage: {prog} --book ID [--token NAME] UPLOAD [--] FILE");
eprintln!(" Send FILE for UPLOAD (from books.begin_upload), with the agent's token.");
};
let Some(a) = file_args(args, &usage)? else {
return Ok(());
};
let ([upload, file], None) = (a.words.as_slice(), &a.to) else {
usage();
return Err(exit(2));
};
let (Some(upload), file) = (
upload.to_str().filter(|u| ledger_store::uploads::is_id(u)),
std::path::Path::new(file).display(),
) else {
eprintln!(
"error: that isn't an upload's id (books.begin_upload gives one: up_ and 32 letters)"
);
return Err(exit(2));
};
let path = std::path::Path::new(&a.words[1]);
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
let max = ledger_store::uploads::MAX_UPLOAD;
match std::fs::metadata(path) {
Ok(m) if !m.is_file() => return Err(fail(format!("{file} isn't a file"))),
Ok(m) if m.len() > max => {
return Err(fail(format!(
"{file} is {} bytes; an upload is at most {max}",
m.len()
)));
}
Ok(_) => {}
Err(e) => return Err(fail(format!("can't read {file}: {}", e.kind()))),
}
let bytes =
std::fs::read(path).map_err(|e| fail(format!("can't read {file}: {}", e.kind())))?;
let size = bytes.len();
let (status, _, body) = on_the_socket(&a.common, &a.book, &a.token, |http, base| {
http.put(format!("{base}/u/{upload}")).body(bytes)
})
.map_err(fail)?;
let said = String::from_utf8_lossy(&body).to_string();
if status != 200 {
return Err(fail(said));
}
println!("Sent {size} bytes for {upload}: name it (upload_id) in the tool that files it.");
Ok(())
}
/// The `Content-Disposition` parameters (RFC 6266): name and value, a
/// quoted value unquoted; a `;` or `=` inside quotes is part of the value.
fn disposition_params(value: &str) -> Vec<(String, String)> {
let mut params = Vec::new();
let mut rest = value.split_once(';').map_or("", |(_, r)| r);
loop {
rest = rest.trim_start_matches([';', ' ', '\t']);
let Some((name, after)) = rest.split_once('=') else {
return params;
};
let name = name.trim().to_ascii_lowercase();
let after = after.trim_start();
let (v, next) = if let Some(quoted) = after.strip_prefix('"') {
let mut v = String::new();
let mut chars = quoted.char_indices();
let mut end = quoted.len();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
if let Some((_, e)) = chars.next() {
v.push(e);
}
}
'"' => {
end = i + 1;
break;
}
c => v.push(c),
}
}
(v, &quoted[end.min(quoted.len())..])
} else {
let end = after.find(';').unwrap_or(after.len());
(after[..end].trim().to_string(), &after[end..])
};
params.push((name, v));
rest = next;
}
}
/// The longest name a download is saved under, in bytes (under every
/// file system's limit, with room for " (999)").
const SAVED_NAME_MAX: usize = 200;
/// The name a download is saved under: the server's (RFC 6266), its UTF-8
/// `filename*` first; a plain file name, never hidden, nothing invisible
/// in it, at most SAVED_NAME_MAX bytes (its extension kept).
fn saved_name(headers: &reqwest::header::HeaderMap) -> Option<String> {
let value = headers
.get(reqwest::header::CONTENT_DISPOSITION)?
.to_str()
.ok()?;
let params = disposition_params(value);
let utf8 = params
.iter()
.find(|(n, _)| n == "filename*")
.and_then(|(_, v)| v.strip_prefix("UTF-8''"))
.and_then(percent_decoded);
let plain = || {
params
.iter()
.find(|(n, _)| n == "filename")
.map(|(_, v)| v.clone())
};
plain_name(&utf8.or_else(plain)?)
}
/// `name` made a plain, visible file name of at most SAVED_NAME_MAX
/// bytes: folders' separators and control characters become `_`,
/// invisible ones go, leading dots and spaces go; None if nothing's left.
fn plain_name(name: &str) -> Option<String> {
let cleaned: String = name
.chars()
.filter(|c| !ledger_store::uploads::is_hidden_char(*c))
.map(|c| {
if c == '/' || c == '\\' || c.is_control() {
'_'
} else {
c
}
})
.collect();
let cleaned = cleaned.trim().trim_start_matches(['.', ' ']).to_string();
if cleaned.is_empty() {
return None;
}
if cleaned.len() <= SAVED_NAME_MAX {
return Some(cleaned);
}
// Too long: the stem cut on a character boundary, the extension kept
// (if it is short).
let (stem, ext) = match cleaned.rsplit_once('.') {
Some((s, e)) if !s.is_empty() && e.len() <= 16 => (s, format!(".{e}")),
_ => (cleaned.as_str(), String::new()),
};
let mut cut = SAVED_NAME_MAX - ext.len();
while !stem.is_char_boundary(cut) {
cut -= 1;
}
Some(format!("{}{ext}", stem[..cut].trim_end()))
}
/// `%XX`-escaped UTF-8 (RFC 8187), or None when it isn't.
fn percent_decoded(text: &str) -> Option<String> {
let mut bytes = Vec::with_capacity(text.len());
let mut it = text.bytes();
while let Some(b) = it.next() {
if b == b'%' {
let hex = [it.next()?, it.next()?];
bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?);
} else {
bytes.push(b);
}
}
String::from_utf8(bytes).ok()
}
/// Whether `body` is what the server stored: its SHA-256 is the one the
/// server sent (RFC 9530 `Repr-Digest: sha-256=:BASE64:`). No digest, or
/// one that can't be read, is no match.
fn arrived_whole(headers: &reqwest::header::HeaderMap, body: &[u8]) -> bool {
let sent = headers
.get(ledger_mcp::local::DIGEST_HEADER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().strip_prefix("sha-256=:"))
.and_then(|v| v.strip_suffix(':'))
.and_then(|v| base64::Engine::decode(&base64::engine::general_purpose::STANDARD, v).ok());
sent.as_deref() == Some(&<sha2::Sha256 as sha2::Digest>::digest(body)[..])
}
/// Save `body` in `folder` as `named`, or `named (1)`, `named (2)`... the
/// first free; `fallback` if the folder takes no such name. Written aside
/// (a hidden file) and linked in whole, so the name never holds part of
/// it; never over another file. The path it went to.
fn save_new(
folder: &std::path::Path,
named: &str,
fallback: &str,
body: &[u8],
) -> Result<PathBuf, String> {
use std::io::Write;
let fail = |what: &std::path::Path, e: std::io::Error| {
format!("can't write {}: {}", what.display(), e.kind())
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let aside = folder.join(format!(".wanderbooks-{}-{nanos}.part", std::process::id()));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&aside)
.map_err(|e| fail(folder, e))?;
if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(&aside);
return Err(fail(folder, e));
}
drop(f);
let candidates = |name: &str| {
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (name.to_string(), String::new()),
};
let name = name.to_string();
(0..1000).map(move |n| {
if n == 0 {
name.clone()
} else {
format!("{stem} ({n}){ext}")
}
})
};
let mut outcome = Err(format!("{} has no free name for it", folder.display()));
// Why the last name tried was refused, if not for being taken: what
// the human is told when no name is left (a full disk, not a name).
let mut last: Option<std::io::Error> = None;
'names: for name in [named, fallback] {
for candidate in candidates(name) {
let path = folder.join(&candidate);
match std::fs::hard_link(&aside, &path) {
Ok(()) => {
outcome = Ok(path);
break 'names;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
// A file system with no links (FAT): written in place, never
// over another file, removed if it can't be finished.
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied
) =>
{
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(&path);
outcome = Err(fail(&path, e));
last = None;
} else {
outcome = Ok(path);
}
break 'names;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => {
last = Some(e);
continue 'names;
}
}
}
// A name this folder won't take (too long, a character it
// refuses): the next name.
Err(e) => {
last = Some(e);
continue 'names;
}
}
}
}
let _ = std::fs::remove_file(&aside);
match (outcome, last) {
(Err(_), Some(e)) => Err(fail(folder, e)),
(outcome, _) => outcome,
}
}
/// `wanderbooks download --book ID ATTACHMENT --to FOLDER`: save a copy of
/// a stored file (one too large for books.get_receipt to return).
pub fn download(
prog: &str,
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<(), Box<dyn std::error::Error>> {
let usage = || {
eprintln!("usage: {prog} --book ID [--token NAME] ATTACHMENT --to FOLDER");
eprintln!(" Save a copy of the stored file ATTACHMENT in FOLDER (a new file).");
};
let Some(a) = file_args(args, &usage)? else {
return Ok(());
};
let ([attachment], Some(to)) = (a.words.as_slice(), a.to.clone()) else {
usage();
return Err(exit(2));
};
let Some(attachment) = attachment
.to_str()
.filter(|a| a.parse::<u64>().is_ok_and(|n| n.to_string() == *a))
else {
eprintln!("error: an attachment's id is a number");
return Err(exit(2));
};
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
};
if !to.is_dir() {
return Err(fail(format!("{} isn't a folder", to.display())));
}
let (status, headers, body) = on_the_socket(&a.common, &a.book, &a.token, |http, base| {
http.get(format!("{base}/a/{attachment}"))
})
.map_err(fail)?;
let path = keep(status, &headers, &body, attachment, &to).map_err(fail)?;
println!("Saved {} ({} bytes).", path.display(), body.len());
Ok(())
}
/// What `download` does with the server's answer: the server's words if it
/// refused; nothing if the bytes aren't the stored ones; else a new file
/// in `to` (its path).
fn keep(
status: u16,
headers: &reqwest::header::HeaderMap,
body: &[u8],
attachment: &str,
to: &std::path::Path,
) -> Result<PathBuf, String> {
if status != 200 {
return Err(String::from_utf8_lossy(body).to_string());
}
if !arrived_whole(headers, body) {
return Err(
"the file arrived damaged (its SHA-256 isn't the stored one): nothing was saved".into(),
);
}
let fallback = format!("attachment-{attachment}");
let named = saved_name(headers).unwrap_or_else(|| fallback.clone());
save_new(to, &named, &fallback, body)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn disposition(value: &str) -> reqwest::header::HeaderMap {
let mut h = reqwest::header::HeaderMap::new();
h.insert(
reqwest::header::CONTENT_DISPOSITION,
reqwest::header::HeaderValue::from_str(value).unwrap(),
);
h
}
#[test]
fn a_download_is_saved_under_a_plain_visible_name() {
let named = |v: &str| saved_name(&disposition(v));
// The UTF-8 name first, else the plain one.
assert_eq!(
named("attachment; filename=\"R_sum_.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9.pdf")
.as_deref(),
Some("Résumé.pdf")
);
assert_eq!(
named("attachment; filename=\"a b.pdf\"").as_deref(),
Some("a b.pdf")
);
// A parameter inside the quoted name is the name, not a parameter.
assert_eq!(
named("attachment; filename=\"x; filename*=UTF-8''.bashrc; y\"").as_deref(),
Some("x; filename*=UTF-8''.bashrc; y")
);
// No folder, nothing hidden, nothing invisible.
for (given, saved) in [
(
"filename*=UTF-8''..%2F..%2F.ssh%2Fauthorized_keys",
Some("_.._.ssh_authorized_keys"),
),
("filename*=UTF-8''%2Fetc%2Fpasswd", Some("_etc_passwd")),
("filename*=UTF-8''.bashrc", Some("bashrc")),
("filename*=UTF-8''rcpt%E2%80%AEfdp.exe", Some("rcptfdp.exe")),
("filename*=UTF-8''a%E2%80%8Bb.pdf", Some("ab.pdf")),
("filename*=UTF-8''a%0Ab.pdf", Some("a_b.pdf")),
("filename*=UTF-8''..", None),
("filename*=UTF-8''%20.", None),
("filename*=UTF-8''%ZZ", None),
("filename=\"\"", None),
] {
assert_eq!(
named(&format!("attachment; {given}")).as_deref(),
saved,
"{given}"
);
}
assert_eq!(saved_name(&reqwest::header::HeaderMap::new()), None);
// Too long: cut on a character, its extension kept.
let long = "收".repeat(90) + ".pdf";
let cut = plain_name(&long).unwrap();
assert!(
cut.len() <= SAVED_NAME_MAX && cut.ends_with("收.pdf"),
"{cut}"
);
assert_eq!(
plain_name(&"a".repeat(SAVED_NAME_MAX)).unwrap().len(),
SAVED_NAME_MAX
);
assert_eq!(percent_decoded("%E2%9C%93").as_deref(), Some("✓"));
assert_eq!(percent_decoded("%E2%9C"), None, "not UTF-8");
assert_eq!(percent_decoded("%4"), None);
}
#[test]
fn only_bytes_with_the_digest_sent_arrived_whole() {
let body = b"%PDF-1.4 stored";
let digest = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
<sha2::Sha256 as sha2::Digest>::digest(body),
);
let with = |v: &str| {
let mut h = reqwest::header::HeaderMap::new();
h.insert(
ledger_mcp::local::DIGEST_HEADER,
reqwest::header::HeaderValue::from_str(v).unwrap(),
);
h
};
assert!(arrived_whole(&with(&format!("sha-256=:{digest}:")), body));
assert!(!arrived_whole(
&with(&format!("sha-256=:{digest}:")),
b"%PDF-1.4 changed"
));
assert!(!arrived_whole(&with(&format!("sha-512=:{digest}:")), body));
assert!(!arrived_whole(&with(&format!("sha-256={digest}")), body));
assert!(!arrived_whole(&with("sha-256=:not base64:"), body));
assert!(!arrived_whole(&reqwest::header::HeaderMap::new(), body));
}
#[test]
fn only_a_whole_file_the_server_gave_is_kept() {
let dir = tempfile::tempdir().unwrap();
let body = b"%PDF-1.4 stored";
let mut h = disposition("attachment; filename=\"r.pdf\"");
let digest = |b: &[u8]| {
format!(
"sha-256=:{}:",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
<sha2::Sha256 as sha2::Digest>::digest(b)
)
)
};
h.insert(
ledger_mcp::local::DIGEST_HEADER,
reqwest::header::HeaderValue::from_str(&digest(b"something else")).unwrap(),
);
let e = keep(200, &h, body, "7", dir.path()).unwrap_err();
assert!(e.contains("arrived damaged"), "{e}");
let e = keep(404, &h, b"there is no such attachment", "7", dir.path()).unwrap_err();
assert_eq!(e, "there is no such attachment");
assert_eq!(
std::fs::read_dir(dir.path()).unwrap().count(),
0,
"nothing saved"
);
h.insert(
ledger_mcp::local::DIGEST_HEADER,
reqwest::header::HeaderValue::from_str(&digest(body)).unwrap(),
);
assert_eq!(
keep(200, &h, body, "7", dir.path()).unwrap(),
dir.path().join("r.pdf")
);
let bare = reqwest::header::HeaderMap::new();
assert!(
keep(200, &bare, body, "7", dir.path()).is_err(),
"no digest, no file"
);
}
#[test]
fn a_saved_copy_is_new_and_whole_beside_what_is_there() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("r.pdf"), b"theirs").unwrap();
let first = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap();
assert_eq!(first, d.join("r (1).pdf"));
let second = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap();
assert_eq!(second, d.join("r (2).pdf"));
assert_eq!(std::fs::read(d.join("r.pdf")).unwrap(), b"theirs");
assert_eq!(std::fs::read(&first).unwrap(), b"ours");
// A name the folder won't take: the fallback.
let long = "x".repeat(300);
assert_eq!(
save_new(d, &long, "attachment-7", b"ours").unwrap(),
d.join("attachment-7")
);
// Nothing aside is left.
let mut names: Vec<String> = std::fs::read_dir(d)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, ["attachment-7", "r (1).pdf", "r (2).pdf", "r.pdf"]);
// A folder that can't be written: refused, nothing made.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let ro = d.join("ro");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(ro.join("probe"), b"").is_err() {
assert!(save_new(&ro, "r.pdf", "attachment-7", b"ours").is_err());
}
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap();
}
}
#[test] #[test]
fn close_reads_the_books_state_for_what_it_is() { fn close_reads_the_books_state_for_what_it_is() {
let leases = Ok(Some(HostMsg::Leases { let leases = Ok(Some(HostMsg::Leases {
+1 -1
View File
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
/// The keyring service tokens are kept under (INVARIANT 1). /// The keyring service tokens are kept under (INVARIANT 1).
pub const SERVICE: &str = "wanderbooks"; pub const SERVICE: &str = "wanderbooks";
/// A token's name when none is given. /// A token's name when none is given.
pub const DEFAULT_NAME: &str = "agent"; pub const DEFAULT_NAME: &str = ledger_store::auth::DEFAULT_NAME;
/// A place tokens are kept. /// A place tokens are kept.
pub trait Keeper: Send + Sync { pub trait Keeper: Send + Sync {
+5 -3
View File
@@ -20,6 +20,8 @@ ledger-import = { path = "../ledger-import" }
ledger-config = { path = "../ledger-config" } ledger-config = { path = "../ledger-config" }
ledger-vault = { path = "../ledger-vault" } ledger-vault = { path = "../ledger-vault" }
base64 = "0.22" base64 = "0.22"
# A download's Repr-Digest (local.rs).
sha2 = "0.10"
mime_guess = "2" mime_guess = "2"
rust_decimal = "1" rust_decimal = "1"
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] } rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
@@ -32,7 +34,7 @@ serde_json = "1"
chrono = "0.4" chrono = "0.4"
thiserror = "2" thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] }
tokio-util = { version = "0.7", features = ["rt"] } tokio-util = { version = "0.7", features = ["rt", "io", "io-util"] }
# A call that panics is recorded before the panic goes on (access.rs). # A call that panics is recorded before the panic goes on (access.rs).
futures-util = "0.3" futures-util = "0.3"
zip = "9.0.1" zip = "9.0.1"
@@ -41,8 +43,8 @@ toml = "1"
# from memory when dropped, as the vault's own are. # from memory when dropped, as the vault's own are.
zeroize = "1" zeroize = "1"
# O_NOFOLLOW and O_NONBLOCK for books.import_local_file's open (local_files.rs # The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's
# INVARIANT 6): std has no names for these open flags. # same-user check: std has no call for them.
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
+1 -1
View File
@@ -1,5 +1,5 @@
# 1099s # 1099s
- Mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). - Mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically).
- books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault: hand it over (save_csv_to, a new file outside the books and the import folders) and never repeat those numbers anywhere else. - books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault: hand it over (save_csv_to, a new file outside the books) and never repeat those numbers anywhere else.
- Card payments are reported by the card processor (1099-K) and are left out. - Card payments are reported by the card processor (1099-K) and are left out.
+1 -1
View File
@@ -1,5 +1,5 @@
# CPA handoff # CPA handoff
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet), so it needs save_to: a new .zip or a folder outside the books and the import folders. - books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet), so it needs save_to: a new .zip or a folder outside the books.
- Tell the human where it was saved; never open, read, attach, or copy the file, and never echo its TINs. - Tell the human where it was saved; never open, read, attach, or copy the file, and never echo its TINs.
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again. - tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
+6 -4
View File
@@ -1,6 +1,8 @@
# Files in and out # Files in and out
- Files come in as base64 (books.attach_receipt, books.file_document), or, for a file already on this computer, with books.import_local_file: an absolute path to a regular file in the server's import folders (by default your downloads folder). Save the file there first; files of the books themselves are never read, and a path that is missing or outside the import folders gets one refusal, whatever is there. - A file comes in one of two ways, named in the tool that files it (books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv, books.import_chart_csv):
- A W-9 given by path (books.set_w9 w9_file_path) is read the same way. - an upload, for any file up to 25 MiB (books served by `wanderbooks mcp`; on another connection only inline): books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it.
- Files holding full TINs (books.get_w9, books.vendor_1099 with include_tins, books.export_cpa_package with TINs) are never saved in the import folders. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 read, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself. - inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught.
- Receipts come back out through books.get_receipt. - If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
- Files holding full TINs (books.get_w9, books.vendor_1099 with include_tins, books.export_cpa_package with TINs) are never saved inside the books. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (only for books served by `wanderbooks mcp`).
@@ -6,6 +6,6 @@
- The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming. - The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming.
- Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it. - Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it.
- Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. - Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line.
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_file_path (never books.file_document or books.attach_receipt: the form shows the TIN). - Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN).
- Never repeat a TIN back to the human, in chat, a description, note, file name, version-history message, or anywhere else; tools show the last four only. books.get_w9 writes the form out for the human (never open or read it yourself); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. - Never repeat a TIN back to the human, in chat, a description, note, file name, version-history message, or anywhere else; tools show the last four only. books.get_w9 writes the form out for the human (never open or read it yourself); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
- books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history. - books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
+11 -50
View File
@@ -3,8 +3,7 @@
//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks //! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks
//! dev-stdio` (one executable, plan D-22) both call it. //! dev-stdio` (one executable, plan D-22) both call it.
//! //!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] //! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//! [--import-root DIR]...`
//! //!
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db` //! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts), //! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
@@ -12,19 +11,16 @@
//! configured base currency. Company settings (name, fiscal year start, //! configured base currency. Company settings (name, fiscal year start,
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by //! receipt threshold) come from the books dir's `wanderbooks.toml`, by
//! default the one next to the data dir, and reload whenever it changes //! default the one next to the data dir, and reload whenever it changes
//! (settings.rs). `books.import_local_file` and `books.set_w9`'s //! (settings.rs). Files come in only as tool input (inline, at most 256
//! `w9_file_path` read only from the import roots: each `--import-root`, //! KiB): uploads need the books server's socket (`wanderbooks mcp`).
//! or by default the downloads folder (local_files.rs); a root that is
//! `/`, the home folder, or holds the books, the vault or a key is refused
//! at startup.
pub mod access; pub mod access;
pub mod admin; pub mod admin;
mod handbook; mod handbook;
#[cfg(unix)] #[cfg(unix)]
pub mod local; pub mod local;
mod local_files;
pub mod opening; pub mod opening;
mod paths;
mod policy; mod policy;
pub mod scopes; pub mod scopes;
mod server; mod server;
@@ -35,7 +31,6 @@ mod wire;
use ledger_core::Currency; use ledger_core::Currency;
use ledger_store::Store; use ledger_store::Store;
use local_files::ImportRoots;
use policy::ReceiptPolicy; use policy::ReceiptPolicy;
use rmcp::ServiceExt; use rmcp::ServiceExt;
use serde::Deserialize; use serde::Deserialize;
@@ -59,7 +54,7 @@ fn default_data_dir() -> PathBuf {
fn print_usage(prog: &str) { fn print_usage(prog: &str) {
eprintln!( eprintln!(
"usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..." "usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR]"
); );
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)"); eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)"); eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
@@ -72,13 +67,10 @@ fn print_usage(prog: &str) {
eprintln!( eprintln!(
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio" " --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
); );
eprintln!(
" --import-root a folder books.import_local_file may read from (repeatable; default: the downloads folder)"
);
} }
/// How [`run`] stops when it can't go on: the reason is already printed /// How [`run`] stops when it can't go on: the reason is already printed
/// on stderr, and the number is the process's exit code (2: a bad argument or a refused import folder; 1: books from a newer wanderbooks). A /// on stderr, and the number is the process's exit code (2: a bad argument; 1: books from a newer wanderbooks). A
/// library never ends the process itself, so the books it opened are /// library never ends the process itself, so the books it opened are
/// closed properly on the way out; the binaries turn this into the exit. /// closed properly on the way out; the binaries turn this into the exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -95,8 +87,8 @@ impl std::error::Error for Exit {}
/// Run the books server with `args` (the command line after the program /// Run the books server with `args` (the command line after the program
/// name). `prog` names the program in the usage text: `ledger-mcp`, or /// name). `prog` names the program in the usage text: `ledger-mcp`, or
/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad /// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad
/// argument, books from a newer wanderbooks, or a refused import root /// argument or books from a newer wanderbooks return [`Exit`] after a
/// return [`Exit`] after a plain message. /// plain message.
pub fn run( pub fn run(
prog: &str, prog: &str,
args: impl IntoIterator<Item = String>, args: impl IntoIterator<Item = String>,
@@ -106,7 +98,6 @@ pub fn run(
let mut books_toml: Option<PathBuf> = None; let mut books_toml: Option<PathBuf> = None;
let mut vault_key_file: Option<PathBuf> = None; let mut vault_key_file: Option<PathBuf> = None;
let mut http_addr: Option<String> = None; let mut http_addr: Option<String> = None;
let mut import_roots: Vec<PathBuf> = Vec::new();
let mut args = args.into_iter(); let mut args = args.into_iter();
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
@@ -129,11 +120,6 @@ pub fn run(
"--http" => { "--http" => {
http_addr = Some(args.next().ok_or("--http needs an address")?); http_addr = Some(args.next().ok_or("--http needs an address")?);
} }
"--import-root" => {
import_roots.push(PathBuf::from(
args.next().ok_or("--import-root needs a folder")?,
));
}
"--help" | "-h" => { "--help" | "-h" => {
print_usage(prog); print_usage(prog);
return Ok(()); return Ok(());
@@ -153,11 +139,6 @@ pub fn run(
); );
return Err(Box::new(Exit(2))); return Err(Box::new(Exit(2)));
} }
let import_roots = if import_roots.is_empty() {
ImportRoots::defaults()
} else {
ImportRoots::new(import_roots)
};
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json")); let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
// config.json's base currency matters only to books being created: it // config.json's base currency matters only to books being created: it
// is read then, and never otherwise (books that keep their own // is read then, and never otherwise (books that keep their own
@@ -232,36 +213,18 @@ pub fn run(
} }
); );
let live = std::sync::Arc::new(std::sync::Mutex::new(live)); let live = std::sync::Arc::new(std::sync::Mutex::new(live));
// No import root may be `/`, the home folder, or hold the books, the
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
// server here, a default one is dropped.
let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current();
let import_roots = match server::guard_import_roots(
&store,
&settings_now,
&server::Layout::BooksDir,
import_roots,
) {
Ok(roots) => roots,
Err(why) => {
eprintln!("ledger-mcp: {why}");
return Err(Box::new(Exit(2)));
}
};
let rt = tokio::runtime::Builder::new_multi_thread() let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.build()?; .build()?;
if let Some(addr) = http_addr { if let Some(addr) = http_addr {
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}"); eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
rt.block_on(serve_http(addr, store, live, import_roots))?; rt.block_on(serve_http(addr, store, live))?;
return Ok(()); return Ok(());
} }
eprintln!("ledger-mcp: store verified, serving over stdio"); eprintln!("ledger-mcp: store verified, serving over stdio");
let server = BooksServer::new(store, ReceiptPolicy::default()) let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
.with_live_settings(live)
.with_import_roots(import_roots);
rt.block_on(async { rt.block_on(async {
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs). // The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout()); let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
@@ -277,7 +240,6 @@ async fn serve_http(
addr: String, addr: String,
store: Store, store: Store,
live: std::sync::Arc<std::sync::Mutex<settings::Live>>, live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
import_roots: ImportRoots,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
use rmcp::transport::streamable_http_server::{ use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager, StreamableHttpService, session::local::LocalSessionManager,
@@ -290,8 +252,7 @@ async fn serve_http(
let service = StreamableHttpService::new( let service = StreamableHttpService::new(
move || { move || {
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.clone()) .with_live_settings(live.clone());
.with_import_roots(import_roots.clone());
Ok(server) Ok(server)
}, },
Arc::new(LocalSessionManager::default()), Arc::new(LocalSessionManager::default()),
+450 -67
View File
@@ -43,10 +43,20 @@
//! bindings a new session is refused (an initialize), never a binding //! bindings a new session is refused (an initialize), never a binding
//! dropped. Tokens are made, listed and revoked only through //! dropped. Tokens are made, listed and revoked only through
//! the admin socket. //! the admin socket.
//! 9. A file's bytes come in only by `PUT /b/{id}/u/{upload}` and go
//! out only by `GET /b/{id}/a/{attachment}` (plan 4.7), each through
//! the same door as 8 and counted for the close like any request:
//! an upload only from the token that began it, a download only with
//! books:read. Each, refused at the door or not, is recorded in the
//! book's access log as `upload` or `download` with its id, and the
//! bytes (kept, or read) and their record are one task that runs to
//! its end if the client hangs up (a process killed in that task
//! excepted); bytes whose record can't be written aren't kept, and
//! the books stop. Lapsed uploads are swept
//! every SWEEP_EVERY. Nothing comes in once the books stopped.
//! ``` //! ```
use crate::admin::{self, Request, Response}; use crate::admin::{self, Request, Response};
use crate::local_files::ImportRoots;
use crate::policy::ReceiptPolicy; use crate::policy::ReceiptPolicy;
use crate::server::{BooksServer, Layout, SharedStore}; use crate::server::{BooksServer, Layout, SharedStore};
use crate::settings::Live; use crate::settings::Live;
@@ -75,10 +85,9 @@ pub const SERVER_LOCK: &str = "server.lock";
/// What the server prints on stdout when it is ready (INVARIANT 5). /// What the server prints on stdout when it is ready (INVARIANT 5).
pub const READY: &str = "ready"; pub const READY: &str = "ready";
/// How large a request may be. Receipts still ride inside tool calls as /// How large an MCP request may be (plan 3.2): files of any size come
/// base64 until uploads have their own route (P2 slice 5, which brings /// through uploads (`/b/{book}/u/{upload}`); inline, at most 256 KiB.
/// this down to 1 MiB). pub const MAX_BODY: usize = 1024 * 1024;
pub const MAX_BODY: usize = 64 * 1024 * 1024;
/// How long the admin socket waits for a request to arrive. /// How long the admin socket waits for a request to arrive.
const ADMIN_READ_TIMEOUT: Duration = Duration::from_secs(30); const ADMIN_READ_TIMEOUT: Duration = Duration::from_secs(30);
@@ -89,6 +98,9 @@ use ledger_store::root::THE_ROOT;
/// didn't open, books that don't exist): a client knows it didn't run. /// didn't open, books that don't exist): a client knows it didn't run.
pub const REFUSED_HEADER: &str = "x-wanderbooks-refused"; pub const REFUSED_HEADER: &str = "x-wanderbooks-refused";
/// How often each book's lapsed uploads are swept.
const SWEEP_EVERY: Duration = Duration::from_secs(10);
type Service = StreamableHttpService<BooksServer, LocalSessionManager>; type Service = StreamableHttpService<BooksServer, LocalSessionManager>;
/// A book the server holds. /// A book the server holds.
@@ -103,6 +115,8 @@ enum Book {
/// Its access log, where requests refused at the door are /// Its access log, where requests refused at the door are
/// recorded too (access.rs). /// recorded too (access.rs).
access: Arc<AccessLog>, access: Arc<AccessLog>,
/// Its files on their way in (`/b/{id}/u/{upload}`).
uploads: ledger_store::uploads::Uploads,
}, },
/// Not opened, with why (INVARIANT 3). /// Not opened, with why (INVARIANT 3).
Failed(String), Failed(String),
@@ -121,7 +135,6 @@ impl Book {
/// Every book the server holds, and what serving them needs. /// Every book the server holds, and what serving them needs.
struct Books { struct Books {
root: Root, root: Root,
import_roots: ImportRoots,
held: RwLock<BTreeMap<String, Book>>, held: RwLock<BTreeMap<String, Book>>,
/// Ends every MCP session (INVARIANT 6, last). /// Ends every MCP session (INVARIANT 6, last).
sessions: CancellationToken, sessions: CancellationToken,
@@ -250,17 +263,14 @@ impl Books {
let mut live = Live::own(); let mut live = Live::own();
live.sync(store.ledger().settings(), store.base_currency().code()); live.sync(store.ledger().settings(), store.base_currency().code());
let layout = Layout::Root(self.root.path().to_path_buf()); let layout = Layout::Root(self.root.path().to_path_buf());
let roots = crate::server::guard_import_roots(
&store,
&live.current(),
&layout,
self.import_roots.clone(),
)
.map_err(|e| self.hide(e))?;
let access = Arc::new( let access = Arc::new(
AccessLog::open(store.data_dir()) AccessLog::open(store.data_dir())
.map_err(|e| format!("its access log can't be used: {e}"))?, .map_err(|e| format!("its access log can't be used: {e}"))?,
); );
let uploads =
ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| self.hide(e.to_string()))?;
let server_uploads = uploads.clone();
let live = Arc::new(Mutex::new(live)); let live = Arc::new(Mutex::new(live));
let store = SharedStore::new(store); let store = SharedStore::new(store);
let log = access.clone(); let log = access.clone();
@@ -276,9 +286,9 @@ impl Books {
Ok( Ok(
BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.clone()) .with_live_settings(live.clone())
.with_import_roots(roots.clone())
.with_layout(layout.clone()) .with_layout(layout.clone())
.with_access(log.clone()), .with_access(log.clone())
.with_uploads(server_uploads.clone()),
) )
}, },
Arc::new(sessions()), Arc::new(sessions()),
@@ -288,6 +298,7 @@ impl Books {
service, service,
store: shared, store: shared,
access, access,
uploads,
}) })
} }
@@ -848,12 +859,29 @@ const SESSION_HEADER: &str = "mcp-session-id";
/// live sessions). /// live sessions).
const MAX_SESSIONS: usize = 100_000; const MAX_SESSIONS: usize = 100_000;
/// What a request asks for, as the access log names it: an MCP request
/// (action `connect`, its method), an upload (`upload`, its id) or a
/// download (`download`, the attachment's id).
struct Asked {
action: &'static str,
subject: String,
}
impl Asked {
fn connect(method: &axum::http::Method) -> Self {
Self {
action: "connect",
subject: method.as_str().to_string(),
}
}
}
/// A request refused at the door: recorded in the book's access log /// A request refused at the door: recorded in the book's access log
/// (access.rs INVARIANT 3), then answered with `status` and `why`. /// (access.rs INVARIANT 3), then answered with `status` and `why`.
async fn refused_at_door( async fn refused_at_door(
book: &Book, book: &Book,
principal: Principal, principal: Principal,
method: &axum::http::Method, asked: &Asked,
code: &'static str, code: &'static str,
status: axum::http::StatusCode, status: axum::http::StatusCode,
why: String, why: String,
@@ -865,8 +893,9 @@ async fn refused_at_door(
principal, principal,
client: None, client: None,
request_id: None, request_id: None,
action: "connect".into(), action: asked.action.into(),
subject: method.as_str().to_string(), // Masked, then cut, by the log itself (access.rs INVARIANT 2).
subject: asked.subject.clone(),
args_digest: None, args_digest: None,
result: Outcome::Refused(code.into()), result: Outcome::Refused(code.into()),
head_after: None, head_after: None,
@@ -892,34 +921,31 @@ async fn refused_at_door(
response response
} }
/// `/b/{book}/mcp`: the book's MCP service, for a request carrying a token /// The door to `book` (INVARIANT 8): the books held open, the server not
/// for that book (INVARIANT 8). /// closing, and a live token for those books; else the answer refusing
async fn mcp( /// the request (recorded in the books' access log, where there is one).
axum::extract::State(books): axum::extract::State<Arc<Books>>, async fn door(
axum::extract::Path(book): axum::extract::Path<String>, books: &Arc<Books>,
mut request: axum::extract::Request, book: &str,
) -> axum::response::Response { asked: &Asked,
headers: &axum::http::HeaderMap,
) -> Result<(Book, Token), Box<axum::response::Response>> {
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use tower::ServiceExt;
// Counted before the closing check, so closing never misses it.
let counted = (request.method() != axum::http::Method::GET).then(|| InFlight::new(&books));
books
.last_arrival
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
let closing = books.closing.is_cancelled(); let closing = books.closing.is_cancelled();
let found = books.held().get(&book).cloned(); let found = books.held().get(book).cloned();
let (service, held) = match found { let held = match found {
Some(Book::Failed(why)) if !closing => { Some(Book::Failed(why)) if !closing => {
return ( return Err((
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
[(REFUSED_HEADER, "not-open")], [(REFUSED_HEADER, "not-open")],
format!("the books {book} didn't open: {why}"), format!("the books {book} didn't open: {why}"),
) )
.into_response(); .into_response()
.into());
} }
None if !closing => { None if !closing => {
return ( return Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
[(REFUSED_HEADER, "no-such-books")], [(REFUSED_HEADER, "no-such-books")],
format!( format!(
@@ -927,22 +953,20 @@ async fn mcp(
book.chars().take(64).collect::<String>() book.chars().take(64).collect::<String>()
), ),
) )
.into_response(); .into_response()
.into());
} }
Some(b @ Book::Open { .. }) => match &b { Some(b @ Book::Open { .. }) => b,
Book::Open { service, .. } => (service.clone(), b.clone()),
Book::Failed(_) => unreachable!(),
},
_ => { _ => {
return ( return Err((
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"the books are closing: this request wasn't run", "the books are closing: this request wasn't run",
) )
.into_response(); .into_response()
.into());
} }
}; };
let method = request.method().clone(); let token = books.bearer(headers);
let token = books.bearer(request.headers());
let principal = |t: &Token| Principal { let principal = |t: &Token| Principal {
id: t.id.clone(), id: t.id.clone(),
kind: "agent".into(), kind: "agent".into(),
@@ -952,41 +976,382 @@ async fn mcp(
let who = token let who = token
.as_ref() .as_ref()
.map_or_else(|_| Principal::unknown(), principal); .map_or_else(|_| Principal::unknown(), principal);
return refused_at_door( return Err(refused_at_door(
&held, &held,
who, who,
&method, asked,
crate::access::CLOSING, crate::access::CLOSING,
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"the books are closing: this request wasn't run".into(), "the books are closing: this request wasn't run".into(),
) )
.await; .await
.into());
} }
let token = match token { let token = match token {
Ok(t) => t, Ok(t) => t,
Err((code, why)) => { Err((code, why)) => {
return refused_at_door( return Err(refused_at_door(
&held, &held,
Principal::unknown(), Principal::unknown(),
&method, asked,
code, code,
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
why, why,
) )
.await; .await
.into());
} }
}; };
if token.book != book { if token.book != book {
return refused_at_door( return Err(refused_at_door(
&held, &held,
principal(&token), principal(&token),
&method, asked,
"wrong_books", "wrong_books",
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
format!("this token is for the books {}, not {book}", token.book), format!("this token is for the books {}, not {book}", token.book),
) )
.await; .await
.into());
} }
Ok((held, token))
}
/// `PUT /b/{book}/u/{upload}`: the bytes of an upload begun with
/// `books.begin_upload`, from the token that began it (INVARIANT 9; plan
/// 4.7): at most the upload's limit, kept only if exactly the size and
/// SHA-256 declared, once; recorded in the access log (action `upload`).
async fn upload(
axum::extract::State(books): axum::extract::State<Arc<Books>>,
axum::extract::Path((book, id)): axum::extract::Path<(String, String)>,
request: axum::extract::Request,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
use ledger_store::uploads::UploadError;
let _counted = InFlight::new(&books);
books
.last_arrival
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
let asked = Asked {
action: "upload",
subject: id.clone(),
};
let (held, token) = match door(&books, &book, &asked, request.headers()).await {
Ok(passed) => passed,
Err(refused) => {
// What was sent is read and dropped (up to the most an upload
// can be: only this account reaches the socket), so the client
// gets the refusal, not a connection cut mid-send.
use futures_util::StreamExt;
let mut rest = request.into_body().into_data_stream();
let mut read = 0u64;
while let Some(Ok(chunk)) = rest.next().await {
read += chunk.len() as u64;
if read > ledger_store::uploads::MAX_UPLOAD {
break;
}
}
return *refused;
}
};
let Book::Open {
access,
uploads,
store,
..
} = &held
else {
unreachable!("the door lets only open books through")
};
// Streamed to the disk as it comes, never held whole; `receive` reads
// no more than the size declared and one byte. The bytes and their
// record are one blocking task, which runs to its end even if the
// client hangs up: never bytes kept without their record, and a close
// waits for both (the count goes with them).
use futures_util::TryStreamExt;
let stream = request
.into_body()
.into_data_stream()
.map_err(std::io::Error::other);
let mut body = tokio_util::io::SyncIoBridge::new(tokio_util::io::StreamReader::new(stream));
let (uploads, access, stopped) = (uploads.clone(), access.clone(), store.is_stopped());
let task = tokio::task::spawn_blocking(move || {
let _counted = _counted;
// The books server stopped after an internal error: nothing more
// comes in (SharedStore INVARIANT 2).
let received = if stopped {
Err(UploadError::Bad(crate::server::STOPPED.into()))
} else {
uploads.receive(&id, &token.id, &mut body, chrono::Utc::now().timestamp())
};
// Refused: the rest of what was sent (up to the most an upload
// can be) is read and dropped, so the client gets the answer, not
// a connection cut while it was still sending.
if received.is_err() {
use std::io::Read;
let rest = ledger_store::uploads::MAX_UPLOAD + 1;
let _ = std::io::copy(&mut (&mut body).take(rest), &mut std::io::sink());
}
let (status, outcome, text) = match &received {
Ok(g) => (
StatusCode::OK,
Outcome::Ok,
serde_json::json!({"received": g.id, "size": g.size, "sha256": g.sha256})
.to_string(),
),
Err(e) => {
let (status, code) = match e {
_ if stopped => (StatusCode::SERVICE_UNAVAILABLE, "stopped"),
UploadError::Unknown(_) => (StatusCode::NOT_FOUND, "no_such_upload"),
UploadError::NotYours => (StatusCode::FORBIDDEN, "not_yours"),
UploadError::AlreadySent => (StatusCode::CONFLICT, "already_sent"),
UploadError::InUse => (StatusCode::CONFLICT, "in_use"),
UploadError::WrongSize { .. }
| UploadError::TooMuch { .. }
| UploadError::WrongHash => (StatusCode::BAD_REQUEST, "not_as_declared"),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "failed"),
};
(status, Outcome::Refused(code.into()), e.to_string())
}
};
let record = Access {
principal: Principal {
id: token.id.clone(),
kind: "agent".into(),
label: token.name.clone(),
},
client: None,
request_id: None,
action: "upload".into(),
subject: id.clone(),
args_digest: None,
result: outcome.clone(),
head_after: None,
};
let recorded = access.record(&record).is_ok();
if !recorded && received.is_ok() {
// Never bytes kept without their record: sent again later.
uploads.discard(&id);
}
(status, outcome, text, recorded)
});
let Ok((status, outcome, text, true)) = task.await else {
store.stop();
return (
StatusCode::SERVICE_UNAVAILABLE,
[(REFUSED_HEADER, "not-recorded")],
crate::access::NOT_RECORDED,
)
.into_response();
};
match outcome {
Outcome::Refused(code) => (status, [(REFUSED_HEADER, code)], text).into_response(),
_ => (status, text).into_response(),
}
}
/// `GET /b/{book}/a/{attachment}`: a stored file's bytes, for a token
/// with books:read (INVARIANT 9; what `wanderbooks download` asks for);
/// recorded in the access log (action `download`).
async fn download(
axum::extract::State(books): axum::extract::State<Arc<Books>>,
axum::extract::Path((book, attachment)): axum::extract::Path<(String, String)>,
request: axum::extract::Request,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
// GET only (axum hands a HEAD to a GET route): a HEAD would read the
// file and record a download that sent nothing.
if request.method() != axum::http::Method::GET {
return (
StatusCode::METHOD_NOT_ALLOWED,
[(axum::http::header::ALLOW, "GET")],
"a download is a GET",
)
.into_response();
}
let _counted = InFlight::new(&books);
books
.last_arrival
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
let asked = Asked {
action: "download",
subject: attachment.clone(),
};
let (held, token) = match door(&books, &book, &asked, request.headers()).await {
Ok(passed) => passed,
Err(refused) => return *refused,
};
let Book::Open { access, store, .. } = &held else {
unreachable!("the door lets only open books through")
};
// The read and its record are one blocking task, run to its end even
// if the client hangs up, and counted until then.
let (access, store_for) = (access.clone(), store.clone());
let task = tokio::task::spawn_blocking(move || {
let _counted = _counted;
// An id as the books write it (no sign, no leading zeros), so the
// log holds no alias of it.
let id = attachment
.parse::<u64>()
.ok()
.filter(|n| n.to_string() == attachment);
let found = match (token.scopes.contains(&Scope::Read), id) {
// Stopped after an internal error: as for uploads.
_ if store_for.is_stopped() => Err((
StatusCode::SERVICE_UNAVAILABLE,
"stopped",
crate::server::STOPPED.to_string(),
)),
(false, _) => Err((
StatusCode::FORBIDDEN,
"insufficient_scope",
format!(
"this agent's token can't do that: a download needs books:read, and its token ({:?}) hasn't it",
token.name
),
)),
(true, None) => Err((
StatusCode::NOT_FOUND,
"no_such_attachment",
"there is no such attachment".to_string(),
)),
(true, Some(id)) => match store_for.attachment_file(id) {
Ok(Some(file)) => Ok(file),
Ok(None) => Err((
StatusCode::NOT_FOUND,
"no_such_attachment",
"there is no such attachment".to_string(),
)),
// Stopped, or the stored file damaged: not a missing one.
Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)),
},
};
let record = Access {
principal: Principal {
id: token.id.clone(),
kind: "agent".into(),
label: token.name.clone(),
},
client: None,
request_id: None,
action: "download".into(),
subject: attachment.clone(),
args_digest: None,
result: match &found {
Ok(_) => Outcome::Ok,
Err((_, code, _)) => Outcome::Refused((*code).into()),
},
head_after: None,
};
let recorded = access.record(&record).is_ok();
(found, recorded)
});
let Ok((found, true)) = task.await else {
store.stop();
return (
StatusCode::SERVICE_UNAVAILABLE,
[(REFUSED_HEADER, "not-recorded")],
crate::access::NOT_RECORDED,
)
.into_response();
};
match found {
Ok((name, mime, bytes)) => {
let digest = format!(
"sha-256=:{}:",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
<sha2::Sha256 as sha2::Digest>::digest(&bytes)
)
);
let mime = axum::http::HeaderValue::from_str(&mime).unwrap_or(
axum::http::HeaderValue::from_static("application/octet-stream"),
);
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, mime),
(
axum::http::header::CONTENT_DISPOSITION,
axum::http::HeaderValue::from_str(&disposition(&name))
.expect("only visible ASCII"),
),
(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
),
(
axum::http::HeaderName::from_static(DIGEST_HEADER),
axum::http::HeaderValue::from_str(&digest).expect("base64 is ASCII"),
),
],
bytes,
)
.into_response()
}
Err((status, code, why)) => (status, [(REFUSED_HEADER, code)], why).into_response(),
}
}
/// The header a download's SHA-256 rides in (RFC 9530 Repr-Digest), so
/// `wanderbooks download` keeps only what was stored.
pub const DIGEST_HEADER: &str = "repr-digest";
/// A download's Content-Disposition (RFC 6266): its name in ASCII, every
/// other character `_`, and whole in UTF-8 as `filename*` (RFC 8187).
fn disposition(name: &str) -> String {
let plain: String = name
.chars()
.map(|c| {
if (c.is_ascii_graphic() || c == ' ') && c != '"' && c != '\\' {
c
} else {
'_'
}
})
.collect();
let mut encoded = String::new();
for b in name.bytes() {
if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
encoded.push(b as char);
} else {
encoded.push_str(&format!("%{b:02X}"));
}
}
format!("attachment; filename=\"{plain}\"; filename*=UTF-8''{encoded}")
}
/// `/b/{book}/mcp`: the book's MCP service, for a request carrying a token
/// for that book (INVARIANT 8).
async fn mcp(
axum::extract::State(books): axum::extract::State<Arc<Books>>,
axum::extract::Path(book): axum::extract::Path<String>,
mut request: axum::extract::Request,
) -> axum::response::Response {
use axum::http::StatusCode;
use tower::ServiceExt;
// Counted before the closing check, so closing never misses it.
let counted = (request.method() != axum::http::Method::GET).then(|| InFlight::new(&books));
books
.last_arrival
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
let method = request.method().clone();
let (held, token) = match door(&books, &book, &Asked::connect(&method), request.headers()).await
{
Ok(passed) => passed,
Err(refused) => return *refused,
};
let Book::Open { service, .. } = &held else {
unreachable!("the door lets only open books through")
};
let service = service.clone();
let principal = |t: &Token| Principal {
id: t.id.clone(),
kind: "agent".into(),
label: t.name.clone(),
};
let session = request let session = request
.headers() .headers()
.get(SESSION_HEADER) .get(SESSION_HEADER)
@@ -1024,7 +1389,7 @@ async fn mcp(
return refused_at_door( return refused_at_door(
&held, &held,
principal(&token), principal(&token),
&method, &Asked::connect(&method),
"too_many_sessions", "too_many_sessions",
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"the books server has too many sessions open: end some, then try again".into(), "the books server has too many sessions open: end some, then try again".into(),
@@ -1035,7 +1400,7 @@ async fn mcp(
return refused_at_door( return refused_at_door(
&held, &held,
principal(&token), principal(&token),
&method, &Asked::connect(&method),
"session_token", "session_token",
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"this session was opened with another token".into(), "this session was opened with another token".into(),
@@ -1154,7 +1519,7 @@ fn bind(run: &Path, name: &str) -> std::io::Result<tokio::net::UnixListener> {
} }
fn print_usage(prog: &str) { fn print_usage(prog: &str) {
eprintln!("usage: {prog} [--root DIR] [--import-root DIR]..."); eprintln!("usage: {prog} [--root DIR]");
eprintln!(" The books server for the data root: started by the host, not by hand."); eprintln!(" The books server for the data root: started by the host, not by hand.");
eprintln!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP."); eprintln!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP.");
} }
@@ -1166,14 +1531,10 @@ pub fn run(
args: impl IntoIterator<Item = String>, args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut root: Option<PathBuf> = None; let mut root: Option<PathBuf> = None;
let mut import_roots: Vec<PathBuf> = Vec::new();
let mut args = args.into_iter(); let mut args = args.into_iter();
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)), "--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
"--import-root" => import_roots.push(PathBuf::from(
args.next().ok_or("--import-root needs a folder")?,
)),
"--help" | "-h" => { "--help" | "-h" => {
print_usage(prog); print_usage(prog);
return Ok(()); return Ok(());
@@ -1240,14 +1601,8 @@ pub fn run(
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)) std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?; .map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
} }
let import_roots = if import_roots.is_empty() {
ImportRoots::defaults()
} else {
ImportRoots::new(import_roots)
};
let books = Arc::new(Books { let books = Arc::new(Books {
root: root.clone(), root: root.clone(),
import_roots,
held: RwLock::new(BTreeMap::new()), held: RwLock::new(BTreeMap::new()),
sessions: CancellationToken::new(), sessions: CancellationToken::new(),
closing: CancellationToken::new(), closing: CancellationToken::new(),
@@ -1326,9 +1681,37 @@ async fn serve(
} }
let held: Vec<String> = books.held().keys().cloned().collect(); let held: Vec<String> = books.held().keys().cloned().collect();
eprintln!("wanderbooks server: ready, holding {} books", held.len()); eprintln!("wanderbooks server: ready, holding {} books", held.len());
// Every open book's lapsed uploads go within SWEEP_EVERY of lapsing,
// whatever else the server is doing (uploads.rs INVARIANT 3).
let (sweeping, sweep_stop) = (books.clone(), stop.clone());
tokio::spawn(async move {
let mut tick = tokio::time::interval(SWEEP_EVERY);
loop {
tokio::select! {
() = sweep_stop.cancelled() => break,
_ = tick.tick() => {
let uploads: Vec<_> = sweeping
.held()
.values()
.filter_map(|b| match b {
Book::Open { uploads, .. } => Some(uploads.clone()),
Book::Failed(_) => None,
})
.collect();
let now = chrono::Utc::now().timestamp();
let _ = tokio::task::spawn_blocking(move || {
uploads.iter().for_each(|u| u.sweep(now));
})
.await;
}
}
}
});
let app = axum::Router::new() let app = axum::Router::new()
.route("/b/{book}/mcp", axum::routing::any(mcp)) .route("/b/{book}/mcp", axum::routing::any(mcp))
.route("/b/{book}/u/{upload}", axum::routing::put(upload))
.route("/b/{book}/a/{attachment}", axum::routing::get(download))
.fallback(not_found) .fallback(not_found)
.with_state(books.clone()); .with_state(books.clone());
// The HTTP side keeps answering (refusing, once closing) until the // The HTTP side keeps answering (refusing, once closing) until the
File diff suppressed because it is too large. Load diff
+132
View File
@@ -0,0 +1,132 @@
//! Paths as the server judges them: where a path really leads.
use std::path::{Component, Path, PathBuf};
/// `path` made absolute and resolved as the kernel would follow it, name
/// by name: every symlink followed (a dangling one too, to where it
/// points), `.` and `..` folded in order, and whatever doesn't exist kept
/// as written. For an existing path this is its canonical form; for a
/// missing one, where it would be.
pub fn resolved(path: &Path) -> PathBuf {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("/"))
.join(path)
};
// The names still to walk, last first.
let mut todo: Vec<std::ffi::OsString> = Vec::new();
let mut out = PathBuf::new();
// An absolute path starts the walk over at its root.
let push_all = |todo: &mut Vec<std::ffi::OsString>, p: &Path, out: &mut PathBuf| {
*out = PathBuf::new();
let mut parts = Vec::new();
for c in p.components() {
match c {
Component::Prefix(_) | Component::RootDir => out.push(c.as_os_str()),
other => parts.push(other.as_os_str().to_os_string()),
}
}
todo.extend(parts.into_iter().rev());
};
push_all(&mut todo, &abs, &mut out);
// Links followed at most this many times (as the kernel's ELOOP).
let mut hops = 0;
while let Some(name) = todo.pop() {
if name == "." {
continue;
}
if name == ".." {
out.pop();
continue;
}
let next = out.join(&name);
let link = std::fs::symlink_metadata(&next)
.is_ok_and(|m| m.file_type().is_symlink())
.then(|| std::fs::read_link(&next).ok())
.flatten();
match link {
Some(target) if hops < 40 => {
hops += 1;
if target.is_absolute() {
push_all(&mut todo, &target, &mut out);
} else {
todo.extend(
target
.components()
.map(|c| c.as_os_str().to_os_string())
.rev(),
);
}
}
_ => out = next,
}
}
out
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
/// `resolved` follows as many links as the kernel does (40, as
/// `canonicalize` does), and no more.
#[test]
fn resolved_follows_forty_links_and_no_more() {
let dir = tempfile::tempdir().unwrap();
let b = dir.path().canonicalize().unwrap();
let file = b.join("receipt.pdf");
std::fs::write(&file, "x").unwrap();
let link = |n: usize| b.join(format!("link-{n}"));
symlink(&file, link(1)).unwrap();
for n in 2..=41 {
symlink(link(n - 1), link(n)).unwrap();
}
assert_eq!(link(40).canonicalize().unwrap(), file);
assert_eq!(resolved(&link(40)), file);
assert!(link(41).canonicalize().is_err(), "the kernel stops at 40");
assert_ne!(resolved(&link(41)), file);
}
/// `resolved` places a path where the kernel would follow it: the
/// canonical form for one that exists, and through a dangling link to
/// where it points.
#[test]
fn resolved_follows_every_link_a_dangling_one_too() {
let dir = tempfile::tempdir().unwrap();
let b = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(b.join("Downloads/sub")).unwrap();
std::fs::create_dir_all(b.join("data")).unwrap();
std::fs::write(b.join("Downloads/receipt.pdf"), "x").unwrap();
std::fs::write(b.join("data/journal.jsonl"), "x").unwrap();
symlink(
b.join("Downloads/receipt.pdf"),
b.join("Downloads/good-link"),
)
.unwrap();
symlink(b.join("data"), b.join("Downloads/data-link")).unwrap();
for p in [
b.join("Downloads/good-link"),
b.join("Downloads/data-link/journal.jsonl"),
b.join("Downloads/sub/../receipt.pdf"),
b.join("Downloads/./good-link"),
] {
assert_eq!(resolved(&p), p.canonicalize().unwrap(), "{}", p.display());
}
symlink("../elsewhere/gone.pdf", b.join("Downloads/rel-dangling")).unwrap();
assert_eq!(
resolved(&b.join("Downloads/rel-dangling")),
b.join("elsewhere/gone.pdf")
);
symlink(b.join("Downloads/loop-b"), b.join("Downloads/loop-a")).unwrap();
symlink(b.join("Downloads/loop-a"), b.join("Downloads/loop-b")).unwrap();
// A loop ends (the hop limit) inside the folder it loops in.
assert!(resolved(&b.join("Downloads/loop-a")).starts_with(b.join("Downloads")));
assert_eq!(
resolved(&b.join("Downloads/missing/../../elsewhere/x")),
b.join("elsewhere/x")
);
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ pub const TABLE: [(&str, Needs); 109] = [
("books.assign_role", P), ("books.assign_role", P),
("books.set_tax_category", W), ("books.set_tax_category", W),
("books.import_chart_csv", W), ("books.import_chart_csv", W),
("books.import_local_file", W), ("books.begin_upload", W),
("books.export", R), ("books.export", R),
("books.register_asset", W), ("books.register_asset", W),
("books.asset_schedule", R), ("books.asset_schedule", R),
+374 -268
View File
@@ -32,7 +32,7 @@
//! ``` //! ```
use crate::handbook; use crate::handbook;
use crate::local_files::{ImportRoots, Places, Protected, resolved}; use crate::paths::resolved;
use crate::policy::ReceiptPolicy; use crate::policy::ReceiptPolicy;
use crate::settings::{Live, Settings}; use crate::settings::{Live, Settings};
use crate::tin_files; use crate::tin_files;
@@ -57,9 +57,6 @@ use rmcp::{ServerHandler, tool, tool_handler, tool_router};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
/// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
/// The server. One store, one receipt policy, shared across tool calls. /// The server. One store, one receipt policy, shared across tool calls.
/// `Store` is `!Sync`, so access is serialized through a mutex; the server /// `Store` is `!Sync`, so access is serialized through a mutex; the server
/// is single-writer by construction. Tool bodies (synchronous disk and /// is single-writer by construction. Tool bodies (synchronous disk and
@@ -69,12 +66,14 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
pub struct BooksServer { pub struct BooksServer {
store: Arc<SharedStore>, store: Arc<SharedStore>,
settings: Arc<Mutex<Live>>, settings: Arc<Mutex<Live>>,
import_roots: Arc<ImportRoots>,
layout: Arc<Layout>, layout: Arc<Layout>,
/// The book's access log, for books served from the data root: every /// The book's access log, for books served from the data root: every
/// call is then checked against its caller's token and recorded /// call is then checked against its caller's token and recorded
/// (access.rs). None for a books dir served over stdio. /// (access.rs). None for a books dir served over stdio.
access: Option<Arc<ledger_store::access::AccessLog>>, access: Option<Arc<ledger_store::access::AccessLog>>,
/// The book's uploads (books served from the data root; none over
/// stdio, where no bytes can be sent but inline).
uploads: Option<ledger_store::uploads::Uploads>,
} }
/// Where a book's own files live around its data dir. /// Where a book's own files live around its data dir.
@@ -157,6 +156,31 @@ pub struct SharedStore {
} }
impl SharedStore { impl SharedStore {
/// An attachment's name, type and bytes (hash-verified), for a
/// download: blocking (the store's lock, then the file).
/// None when there is no such attachment.
pub fn attachment_file(&self, id: u64) -> Result<Option<(String, String, Vec<u8>)>, String> {
if self.stopped() {
return Err(STOPPED.to_string());
}
let store = self.store.blocking_lock();
let Some(meta) = store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.find(|a| a.id == id)
else {
return Ok(None);
};
let bytes = store.attachment_bytes(id).map_err(|e| e.to_string())?;
Ok(Some((meta.file_name, meta.mime_type, bytes)))
}
/// Whether it stopped taking requests (INVARIANT 2).
pub fn is_stopped(&self) -> bool {
self.stopped()
}
/// Share an open store. /// Share an open store.
pub fn new(store: Store) -> Arc<Self> { pub fn new(store: Store) -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
@@ -200,12 +224,29 @@ impl SharedStore {
} }
} }
/// Why a file with full TINs isn't written into an import root. /// A filed file's name and type: those given, else the upload's own.
const TIN_FILE_IN_ROOT: &str = "a file with full TINs is never saved in the server's import folders (by default your downloads folder), where it could be filed back into the books: save it elsewhere, such as your documents folder"; fn named(
file_name: Option<String>,
mime_type: Option<String>,
file: &files::TakenFile,
) -> Result<(String, String), String> {
let name = file_name
.or_else(|| file.file_name.clone())
.ok_or("file_name is needed")?;
let mime = mime_type
.or_else(|| file.mime.clone())
.ok_or("mime_type is needed")?;
if let Some(why) = ledger_store::uploads::file_name_problem(&name)
.or_else(|| ledger_store::uploads::mime_problem(&mime))
{
return Err(why);
}
Ok((name, mime))
}
/// What every request gets after an internal error (SharedStore /// What every request gets after an internal error (SharedStore
/// INVARIANT 2). /// INVARIANT 2).
const STOPPED: &str = "the books server stopped taking requests after an internal error, to protect the books: ask the human to restart the books server (the books are checked again when it starts)"; pub(crate) const STOPPED: &str = "the books server stopped taking requests after an internal error, to protect the books: ask the human to restart the books server (the books are checked again when it starts)";
/// Why the vault won't open with a key file inside the books. /// Why the vault won't open with a key file inside the books.
const KEY_IN_BOOKS: &str = "the TIN vault's key file is inside the books' folder, beside the records it unlocks: ask the human to move it out of the books and set [vault] key_file in the settings"; const KEY_IN_BOOKS: &str = "the TIN vault's key file is inside the books' folder, beside the records it unlocks: ask the human to move it out of the books and set [vault] key_file in the settings";
@@ -555,12 +596,18 @@ impl BooksServer {
policy, policy,
..Settings::default() ..Settings::default()
}))), }))),
import_roots: Arc::new(ImportRoots::defaults()),
layout: Arc::new(Layout::BooksDir), layout: Arc::new(Layout::BooksDir),
access: None, access: None,
uploads: None,
} }
} }
/// Take uploaded files from `uploads` (`books.begin_upload`).
pub fn with_uploads(mut self, uploads: ledger_store::uploads::Uploads) -> Self {
self.uploads = Some(uploads);
self
}
/// Check every call against its caller's token, and record it in /// Check every call against its caller's token, and record it in
/// `log` (access.rs). /// `log` (access.rs).
pub fn with_access(mut self, log: Arc<ledger_store::access::AccessLog>) -> Self { pub fn with_access(mut self, log: Arc<ledger_store::access::AccessLog>) -> Self {
@@ -575,12 +622,6 @@ impl BooksServer {
self self
} }
/// The folders `books.import_local_file` may read from (local_files.rs).
pub fn with_import_roots(mut self, roots: ImportRoots) -> Self {
self.import_roots = Arc::new(roots);
self
}
/// Take the settings from files, reloading them whenever they change /// Take the settings from files, reloading them whenever they change
/// (crate::settings), shared with the other sessions holding `live`. /// (crate::settings), shared with the other sessions holding `live`.
pub fn with_live_settings(mut self, live: Arc<Mutex<Live>>) -> Self { pub fn with_live_settings(mut self, live: Arc<Mutex<Live>>) -> Self {
@@ -655,7 +696,7 @@ impl BooksServer {
/// Every tool the server serves. /// Every tool the server serves.
pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> { pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
Self::core_router() + Self::kept_router() + Self::access_router() Self::core_router() + Self::kept_router() + Self::access_router() + Self::files_router()
} }
/// The settings in force for this request (reloaded at its start, in /// The settings in force for this request (reloaded at its start, in
@@ -902,20 +943,8 @@ impl BooksServer {
self.layout.vault_dir(store) self.layout.vault_dir(store)
} }
/// What is never read through a path, even inside an import root
/// (local_files.rs INVARIANT 3; Protected::of): the data dir, the
/// vault, the key file in force and the default one (a key left there
/// from before still unlocks the vault), and the folders it seals; and
/// the rest of the books dir.
fn protected(&self, store: &Store) -> Protected {
Protected::of(
&places(store, &self.settings(), &self.layout),
&self.import_roots.usable(),
)
}
/// Refuse bytes on their way into the books (books.attach_receipt, /// Refuse bytes on their way into the books (books.attach_receipt,
/// books.file_document, books.import_local_file) if they carry a full /// books.file_document, books.set_w9's file) if they carry a full
/// TIN: a file the server read or wrote with TINs, or text holding one /// TIN: a file the server read or wrote with TINs, or text holding one
/// the vault holds or one printed as an SSN or EIN is (tin_files.rs /// the vault holds or one printed as an SSN or EIN is (tin_files.rs
/// INVARIANT 3), or text probing for one (INVARIANT 4). /// INVARIANT 3), or text probing for one (INVARIANT 4).
@@ -963,62 +992,78 @@ impl BooksServer {
.collect() .collect()
} }
/// Read a file an agent names (books.import_local_file, books.set_w9
/// w9_file_path) through the import roots' lockdown (local_files.rs),
/// with its canonical path.
fn read_local(&self, path: &str) -> Result<(std::path::PathBuf, Vec<u8>), String> {
let protected = {
let store = self.lock()?;
self.protected(&store)
};
self.import_roots
.read(path.trim(), &protected, MAX_UPLOAD_BYTES)
}
/// Where a file holding full TINs may be written: a new file outside /// Where a file holding full TINs may be written: a new file outside
/// the books (outside_books) and outside every import root, so it can /// the books (outside_books). (It can't be filed back in unnoticed:
/// never be filed back in as a receipt and read out again. /// tin_files.rs records it.)
fn tin_file_dest(&self, store: &Store, path: &str) -> Result<std::path::PathBuf, String> { fn tin_file_dest(&self, store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
let dest = outside_books(&self.layout.books_dir(store), path)?; outside_books(&self.layout.books_dir(store), path)
self.outside_import_roots(&dest)?;
Ok(dest)
} }
fn outside_import_roots(&self, dest: &std::path::Path) -> Result<(), String> { /// The signed W-9 a call gives (an upload, or inline), for the vault:
if self.import_roots.contains(dest) { /// a PDF, PNG, or JPEG by its own first bytes, whatever it is called.
return Err(TIN_FILE_IN_ROOT.to_string()); fn w9_file(
} &self,
Ok(()) p: &SetW9Params,
} ) -> Result<(ledger_vault::W9File, Option<ledger_store::uploads::Claim>), String> {
let file = self.take_file(files::Given {
/// Read a W-9 file (PDF, PNG, or JPEG, 25 MB at most) for the vault, upload_id: p.w9_upload_id.as_deref(),
/// through the import roots' lockdown like any local file: the type content_base64: p.w9_content_base64.as_deref(),
/// is judged by where the path really leads (a link named w9.pdf to sha256: p.w9_sha256.as_deref(),
/// another file is that file). purposes: files::W9,
fn read_w9_file(&self, path: &str) -> Result<ledger_vault::W9File, String> { })?;
let (canonical, bytes) = self.read_local(path)?;
// Not recorded here: set_w9 records it once the call can only // Not recorded here: set_w9 records it once the call can only
// succeed, just before the vault write (tin_files.rs INVARIANT 1), // succeed, just before the vault write (tin_files.rs INVARIANT 1),
// so a refused call never marks an ordinary file. // so a refused call never marks an ordinary file.
let mime_type = match canonical let (mime_type, ext) = if file.bytes.starts_with(b"%PDF-") {
.extension() ("application/pdf", "pdf")
.and_then(|e| e.to_str()) } else if file.bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
.map(str::to_lowercase) ("image/png", "png")
.as_deref() } else if file.bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
{ ("image/jpeg", "jpg")
Some("pdf") => "application/pdf", } else {
Some("png") => "image/png", return Err("the W-9 is a PDF, PNG, or JPEG".into());
Some("jpg" | "jpeg") => "image/jpeg",
_ => return Err("w9_file_path is a PDF, PNG, or JPEG (.pdf, .png, .jpg)".into()),
}; };
Ok(ledger_vault::W9File { let file_name = p
file_name: canonical .w9_file_name
.file_name() .clone()
.map(|n| n.to_string_lossy().into_owned()) .or(file.file_name)
.unwrap_or_else(|| "w9".into()), .unwrap_or_else(|| format!("w9.{ext}"));
mime_type: mime_type.to_string(), if let Some(why) = ledger_store::uploads::file_name_problem(&file_name) {
bytes, return Err(why);
}) }
Ok((
ledger_vault::W9File {
file_name,
mime_type: mime_type.to_string(),
bytes: file.bytes,
},
file.claim,
))
}
/// A CSV's text, given inline or as an upload (books.begin_upload),
/// and the upload's hold.
fn csv_given(
&self,
text: Option<&str>,
upload_id: Option<&str>,
) -> Result<(String, Option<ledger_store::uploads::Claim>), String> {
match (text, upload_id) {
(Some(_), Some(_)) => Err("give csv_text or upload_id, not both".into()),
(Some(t), None) => Ok((t.to_string(), None)),
(None, Some(id)) => {
let file = self.take_file(files::Given {
upload_id: Some(id),
content_base64: None,
sha256: None,
purposes: files::IMPORTED,
})?;
let text = String::from_utf8(file.bytes)
.map_err(|_| "the CSV isn't UTF-8 text".to_string())?;
Ok((text, file.claim))
}
(None, None) => Err("give the CSV: csv_text, or an upload_id".into()),
}
} }
/// Record a file read or written with full TINs (tin_files.rs /// Record a file read or written with full TINs (tin_files.rs
@@ -1920,56 +1965,6 @@ fn known_tin_last4(store: &Store) -> Vec<u32> {
.collect() .collect()
} }
/// Where these books keep what an import must never read
/// (local_files.rs): the data dir, the books dir, the vault, the key file
/// in force and the default one, and the home folder.
fn places(store: &Store, settings: &Settings, layout: &Layout) -> Places {
Places {
data_dir: store.data_dir().to_path_buf(),
books_dir: layout.books_dir(store),
vault_dir: layout.vault_dir(store),
key_files: vec![
layout.key_file(store, settings),
settings.key_file(),
ledger_vault::default_key_file(),
],
// In the data root the home comes from the account's record, as
// the root's own does; never only from the environment.
home: match layout {
Layout::Root(_) => ledger_store::root::account_home().ok(),
Layout::BooksDir => None,
}
.or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)),
}
}
/// The import roots a server for these books may use (local_files.rs
/// INVARIANT 0): a named root that is `/`, the home folder or holds it,
/// or holds the books, the vault or a key, is refused (the server doesn't
/// start); a default one is dropped, leaving none. The text is for the
/// human starting the server, on its standard error.
pub fn guard_import_roots(
store: &Store,
settings: &Settings,
layout: &Layout,
roots: ImportRoots,
) -> Result<ImportRoots, String> {
let places = places(store, settings, layout);
let explicit = roots.explicit();
let (roots, refused) = roots.guard(places.home.as_deref(), &places.keep_out());
if let Some((root, why)) = refused.first() {
let msg = format!(
"import folder {} can't be used: {why}. Name a folder that holds none of them, such as your downloads folder",
root.display()
);
if explicit {
return Err(msg);
}
eprintln!("ledger-mcp: {msg}; books.import_local_file has no folder to read from");
}
Ok(roots)
}
/// Whether `path` is `dir` or inside it. /// Whether `path` is `dir` or inside it.
fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool { fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool {
resolved(path).starts_with(resolved(dir)) resolved(path).starts_with(resolved(dir))
@@ -2719,6 +2714,10 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa
} }
thread_local! { thread_local! {
/// The token of the request running on this thread (books served from
/// the data root), its id and name, for what is bound to it (its
/// uploads) and the commands it is given.
static CALLER: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
/// The journal's head after the last write made by the request /// The journal's head after the last write made by the request
/// running on this thread (StoreGuard's drop), for its access record. /// running on this thread (StoreGuard's drop), for its access record.
static WROTE: std::cell::RefCell<Option<(u64, String)>> = const { std::cell::RefCell::new(None) }; static WROTE: std::cell::RefCell<Option<(u64, String)>> = const { std::cell::RefCell::new(None) };
@@ -2892,6 +2891,8 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
#[path = "tools/access.rs"] #[path = "tools/access.rs"]
mod access_tools; mod access_tools;
#[path = "tools/files.rs"]
mod files;
#[path = "tools/kept.rs"] #[path = "tools/kept.rs"]
mod kept; mod kept;
pub use kept::SettingKeyOut; pub use kept::SettingKeyOut;
@@ -3548,7 +3549,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.attach_receipt", name = "books.attach_receipt",
title = "Attach a receipt", title = "Attach a receipt",
description = "Attach a receipt (base64 file bytes) to an entry, or file it unlinked and link later with books.link_receipt. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 for a file of at most 256 KiB. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -3560,17 +3561,6 @@ impl BooksServer {
params: Parameters<AttachReceiptParams>, params: Parameters<AttachReceiptParams>,
) -> Result<Json<AttachReceiptOut>, String> { ) -> Result<Json<AttachReceiptOut>, String> {
let p = params.0; let p = params.0;
let bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&p.content_base64,
)
.map_err(|_| "content_base64 is not valid base64".to_string())?;
if bytes.len() > MAX_UPLOAD_BYTES {
return Err(format!(
"file is {} bytes; the limit is {MAX_UPLOAD_BYTES}",
bytes.len()
));
}
let mut store = self.lock()?; let mut store = self.lock()?;
if let Some(e) = p.entry_id if let Some(e) = p.entry_id
&& store.ledger().get_entry(e).is_none() && store.ledger().get_entry(e).is_none()
@@ -3578,16 +3568,26 @@ impl BooksServer {
return Err(format!("unknown entry {e}")); return Err(format!("unknown entry {e}"));
} }
let link_to = check_link_to(&store, p.entry_id, &p.link_to)?; let link_to = check_link_to(&store, p.entry_id, &p.link_to)?;
self.refuse_tin_file(&store, &bytes, &p.mime_type, &p.file_name)?; // Taken once everything else is known good.
let file = self.take_file(files::Given {
upload_id: p.upload_id.as_deref(),
content_base64: p.content_base64.as_deref(),
sha256: p.sha256.as_deref(),
purposes: files::FILED,
})?;
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
let (bytes, claim) = (file.bytes, file.claim);
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
let (attachment_id, sha256, deduplicated) = store let (attachment_id, sha256, deduplicated) = store
.attach_receipt( .attach_receipt(
p.entry_id, p.entry_id,
&p.file_name, &file_name,
&p.mime_type, &mime_type,
&bytes, &bytes,
p.description.as_deref(), p.description.as_deref(),
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
files::filed(claim);
for sub in link_to { for sub in link_to {
store store
.link_attachment(attachment_id, sub) .link_attachment(attachment_id, sub)
@@ -3604,7 +3604,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.file_document", name = "books.file_document",
title = "File a document", title = "File a document",
description = "File a document into the content-addressed store: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, the company, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. Never file a W-9 here (it shows the full TIN): books.set_w9 w9_file_path puts it in the vault. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, the company: see books.link_attachment), all checked first. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 (at most 256 KiB). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -3626,29 +3626,27 @@ impl BooksServer {
)); ));
} }
} }
let bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&p.content_base64,
)
.map_err(|_| "content_base64 is not valid base64".to_string())?;
if bytes.len() > MAX_UPLOAD_BYTES {
return Err(format!(
"file is {} bytes; the limit is {MAX_UPLOAD_BYTES}",
bytes.len()
));
}
let mut store = self.lock()?; let mut store = self.lock()?;
let link_to = check_link_to(&store, None, &p.link_to)?; let link_to = check_link_to(&store, None, &p.link_to)?;
self.refuse_tin_file(&store, &bytes, &p.mime_type, &p.file_name)?; let file = self.take_file(files::Given {
upload_id: p.upload_id.as_deref(),
content_base64: p.content_base64.as_deref(),
sha256: p.sha256.as_deref(),
purposes: files::FILED,
})?;
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
let (bytes, claim) = (file.bytes, file.claim);
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
let (attachment_id, sha256, deduplicated) = store let (attachment_id, sha256, deduplicated) = store
.file_document( .file_document(
&p.category, &p.category,
&p.file_name, &file_name,
&p.mime_type, &mime_type,
&bytes, &bytes,
p.description.as_deref(), p.description.as_deref(),
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
files::filed(claim);
for sub in link_to { for sub in link_to {
store store
.link_attachment(attachment_id, sub) .link_attachment(attachment_id, sub)
@@ -3924,7 +3922,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.get_receipt", name = "books.get_receipt",
title = "Get a receipt", title = "Get a receipt",
description = "Fetch a receipt or document's metadata (with everything it is linked to) and base64 bytes. Bytes are hash-verified on read.", description = "A receipt or document: what it is and everything it is linked to. view bytes adds its bytes, base64 and hash-verified, for a file of at most 256 KiB; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (books served by `wanderbooks mcp`; elsewhere a larger file doesn't come back).",
annotations(read_only_hint = true, open_world_hint = false) annotations(read_only_hint = true, open_world_hint = false)
)] )]
async fn get_receipt( async fn get_receipt(
@@ -3932,12 +3930,54 @@ impl BooksServer {
params: Parameters<GetReceiptParams>, params: Parameters<GetReceiptParams>,
) -> Result<Json<GetReceiptOut>, String> { ) -> Result<Json<GetReceiptOut>, String> {
let p = params.0; let p = params.0;
let bytes_wanted = match p.view.as_deref().unwrap_or("meta") {
"meta" => false,
"bytes" => true,
other => {
return Err(format!(
"view {:?}: meta (the default) or bytes",
other.chars().take(20).collect::<String>()
));
}
};
let store = self.lock()?; let store = self.lock()?;
let meta = find_attachment(&store, p.attachment_id)?; let meta = find_attachment(&store, p.attachment_id)?;
let bytes = store let book = store
.attachment_bytes(p.attachment_id) .data_dir()
.map_err(|e| e.to_string())?; .file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let fits = meta.size_bytes as usize <= files::INLINE_MAX;
let content_base64 = if bytes_wanted && fits {
let bytes = store
.attachment_bytes(p.attachment_id)
.map_err(|e| e.to_string())?;
Some(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
))
} else {
None
};
// Too large to come back here: the command that saves a copy (books
// served from the data root; over stdio there is none).
let download = (bytes_wanted && !fits && self.uploads.is_some()).then(|| {
format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
files::token_flag(),
meta.id
)
});
if bytes_wanted && !fits && download.is_none() {
return Err(format!(
"attachment {} is {} bytes; at most {} come back on this connection (a larger file needs these books served by `wanderbooks mcp`)",
meta.id,
meta.size_bytes,
files::INLINE_MAX
));
}
Ok(Json(GetReceiptOut { Ok(Json(GetReceiptOut {
download,
links: links_out(&store, meta.id)?, links: links_out(&store, meta.id)?,
description: meta.description.clone(), description: meta.description.clone(),
attachment_id: meta.id, attachment_id: meta.id,
@@ -3947,10 +3987,7 @@ impl BooksServer {
file_name: meta.file_name, file_name: meta.file_name,
mime_type: meta.mime_type, mime_type: meta.mime_type,
size_bytes: meta.size_bytes, size_bytes: meta.size_bytes,
content_base64: base64::Engine::encode( content_base64,
&base64::engine::general_purpose::STANDARD,
&bytes,
),
})) }))
} }
@@ -4413,7 +4450,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.import_bank_csv", name = "books.import_bank_csv",
title = "Import a bank CSV as drafts", title = "Import a bank CSV as drafts",
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Review with books.digest, then post. File the export first (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).", description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as csv_text, or upload it (books.begin_upload purpose import) and give upload_id. Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -4435,8 +4472,8 @@ impl BooksServer {
)); ));
} }
}; };
let txns = let (csv, claim) = self.csv_given(p.csv_text.as_deref(), p.upload_id.as_deref())?;
ledger_import::parse_bank_csv_with(&p.csv_text, order).map_err(|e| e.to_string())?; let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?;
let mut store = self.lock()?; let mut store = self.lock()?;
let bank = store let bank = store
.ledger() .ledger()
@@ -4507,6 +4544,8 @@ impl BooksServer {
.map_err(|e| format!("{} {}: {e}", txn.date, txn.description)); .map_err(|e| format!("{} {}: {e}", txn.date, txn.description));
e?; e?;
} }
// Rehearsed whole: the upload is used up as the writing begins.
files::filed(claim);
let mut imported = Vec::new(); let mut imported = Vec::new();
for txn in txns { for txn in txns {
let (offset_id, offset_code) = offset_for(&txn.description); let (offset_id, offset_code) = offset_for(&txn.description);
@@ -5578,7 +5617,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.import_chart_csv", name = "books.import_chart_csv",
title = "Import a chart of accounts", title = "Import a chart of accounts",
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported.", description = "Import a chart of accounts from CSV (header: code,name,type,parent_code): csv_text, or upload_id (books.begin_upload purpose import). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -5590,7 +5629,8 @@ impl BooksServer {
params: Parameters<ImportChartCsvParams>, params: Parameters<ImportChartCsvParams>,
) -> Result<Json<ImportChartCsvOut>, String> { ) -> Result<Json<ImportChartCsvOut>, String> {
let p = params.0; let p = params.0;
let rows = ledger_import::parse_chart_csv(&p.csv_text).map_err(|e| e.to_string())?; let (csv, claim) = self.csv_given(p.csv_text.as_deref(), p.upload_id.as_deref())?;
let rows = ledger_import::parse_chart_csv(&csv).map_err(|e| e.to_string())?;
let mut store = self.lock()?; let mut store = self.lock()?;
let parse_type = |t: &str| match t { let parse_type = |t: &str| match t {
"asset" => Ok(AccountType::Asset), "asset" => Ok(AccountType::Asset),
@@ -5633,6 +5673,8 @@ impl BooksServer {
.map_err(|e| format!("{}: {e}", row.code))?; .map_err(|e| format!("{}: {e}", row.code))?;
} }
} }
// Rehearsed whole: the upload is used up as the writing begins.
files::filed(claim);
let mut created = Vec::new(); let mut created = Vec::new();
for row in rows { for row in rows {
let account_type = match row.account_type.as_str() { let account_type = match row.account_type.as_str() {
@@ -5668,71 +5710,6 @@ impl BooksServer {
Ok(Json(ImportChartCsvOut { created, count })) Ok(Json(ImportChartCsvOut { created, count }))
} }
#[tool(
name = "books.import_local_file",
title = "File a local file",
description = "File a receipt or document from a file on this computer: an absolute path to a regular file in the server's import folders (by default your downloads folder; save it there first). Files of the books themselves are never read. A file the server read or wrote with full TINs (a W-9, a 1099 worksheet, a CPA package or a file from one), and text holding a TIN the vault keeps or a number printed as an SSN or EIN is (digits 3-2-4 or 2-7 joined by hyphens), are refused: they never go into the books.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn import_local_file(
&self,
params: Parameters<ImportLocalFileParams>,
) -> Result<Json<ImportLocalFileOut>, String> {
let p = params.0;
// The name and type are those of the file read, where the path
// really leads (a link named receipt.pdf to a CSV is a CSV).
let (canonical, bytes) = self.read_local(&p.path)?;
let file_name = p.file_name.unwrap_or_else(|| {
canonical
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string()
});
let mime_type = mime_guess::from_path(&canonical)
.first_or_octet_stream()
.to_string();
let mut store = self.lock()?;
// A file the server read or wrote with full TINs, or text holding
// one (the vault's, or one printed as an SSN or EIN is), never goes
// into the books (tin_files.rs).
self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?;
let (attachment_id, sha256, deduplicated) = match p.category.as_str() {
"receipt" => store
.attach_receipt(
p.entry_id,
&file_name,
&mime_type,
&bytes,
p.description.as_deref(),
)
.map_err(|e| e.to_string())?,
"document" => {
let cat = p.document_category.as_deref().unwrap_or("other");
store
.file_document(
cat,
&file_name,
&mime_type,
&bytes,
p.description.as_deref(),
)
.map_err(|e| e.to_string())?
}
_ => return Err("category must be \"receipt\" or \"document\"".to_string()),
};
Ok(Json(ImportLocalFileOut {
attachment_id,
sha256,
deduplicated,
file_name,
}))
}
#[tool( #[tool(
name = "books.export", name = "books.export",
title = "Export a report", title = "Export a report",
@@ -7097,7 +7074,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.set_w9", name = "books.set_w9",
title = "Record W-9 facts", title = "Record W-9 facts",
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept outside the books, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give w9_file_path (a local PDF, PNG, or JPEG of the signed form) and the form goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).", description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept outside the books, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload), or w9_content_base64 with w9_sha256 when at most 256 KiB, and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = true, destructive_hint = true,
@@ -7106,7 +7083,14 @@ impl BooksServer {
)] )]
async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> { async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> {
let p = params.0; let p = params.0;
let vault_input = p.tin.is_some() || p.tin_type.is_some() || p.w9_file_path.is_some(); let w9_given = p.w9_upload_id.is_some() || p.w9_content_base64.is_some();
if p.w9_file_name.is_some() && !w9_given {
return Err(
"w9_file_name names the W-9 given with it (w9_upload_id or w9_content_base64)"
.into(),
);
}
let vault_input = p.tin.is_some() || p.tin_type.is_some() || w9_given;
let tin = match (&p.tin, &p.tin_type) { let tin = match (&p.tin, &p.tin_type) {
(Some(t), Some(k)) => Some( (Some(t), Some(k)) => Some(
ledger_vault::Tin::new(t.0.trim(), parse_tin_type(k)?) ledger_vault::Tin::new(t.0.trim(), parse_tin_type(k)?)
@@ -7159,11 +7143,12 @@ impl BooksServer {
vault_record: None, vault_record: None,
}) })
}; };
let w9_file = p let (w9_file, mut w9_claim) = if w9_given {
.w9_file_path let (file, claim) = self.w9_file(&p)?;
.as_deref() (Some(file), claim)
.map(|path| self.read_w9_file(path)) } else {
.transpose()?; (None, None)
};
let mut store = self.lock()?; let mut store = self.lock()?;
let id = resolve_party(&store, &p.party)?; let id = resolve_party(&store, &p.party)?;
let existing = store.ledger().get_party(id).and_then(|p| p.w9.clone()); let existing = store.ledger().get_party(id).and_then(|p| p.w9.clone());
@@ -7245,6 +7230,8 @@ impl BooksServer {
Some(w) => Some(vault.put_w9(id, w).map_err(|e| e.to_string())?), Some(w) => Some(vault.put_w9(id, w).map_err(|e| e.to_string())?),
None => None, None => None,
}; };
// The W-9 is in the vault: its upload is used up.
files::filed(w9_claim.take());
match tin_hash { match tin_hash {
Some(h) => Some(h), Some(h) => Some(h),
None if held_tin => vault.record_hash(id).map_err(|e| e.to_string())?, None if held_tin => vault.record_hash(id).map_err(|e| e.to_string())?,
@@ -7423,7 +7410,7 @@ impl BooksServer {
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| { .ok_or_else(|| {
format!( format!(
"the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_file_path" "the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_upload_id"
) )
})?; })?;
self.wrote_tins(&store, &file.bytes)?; self.wrote_tins(&store, &file.bytes)?;
@@ -8500,11 +8487,6 @@ impl BooksServer {
) )
}) })
.transpose()?; .transpose()?;
if p.include_tins
&& let Some(d) = &dest
{
self.outside_import_roots(d)?;
}
let meta = self.report_meta(&store); let meta = self.report_meta(&store);
let ledger = store.ledger(); let ledger = store.ledger();
// The vault, opened once and never creating a key: locked, each // The vault, opened once and never creating a key: locked, each
@@ -9588,7 +9570,10 @@ impl BooksServer {
.parse() .parse()
.map_err(|_| format!("bad receipt resource URI: {uri}"))?; .map_err(|_| format!("bad receipt resource URI: {uri}"))?;
let Json(out) = self let Json(out) = self
.get_receipt(Parameters(GetReceiptParams { attachment_id: id })) .get_receipt(Parameters(GetReceiptParams {
attachment_id: id,
view: None,
}))
.await?; .await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
} }
@@ -9703,6 +9688,11 @@ impl ServerHandler for BooksServer {
let result = run_blocking(&self.store, move || { let result = run_blocking(&self.store, move || {
Box::pin(async move { Box::pin(async move {
WROTE.with(|w| w.borrow_mut().take()); WROTE.with(|w| w.borrow_mut().take());
let caller = running_call.as_ref().and_then(|c| {
c.caller()
.map(|c| (c.token.id.clone(), c.token.name.clone()))
});
CALLER.with(|c| *c.borrow_mut() = caller);
// Counted as running by now (run_blocking): hashed here. // Counted as running by now (run_blocking): hashed here.
let digest = running_call let digest = running_call
.as_ref() .as_ref()
@@ -10073,11 +10063,16 @@ mod tests {
} }
} }
/// "Max 25 MB" (books.attach_receipt): 25 MiB exactly. /// An upload's limit, 25 MiB exactly; inline, 256 KiB (plan 2.1, 4.7).
#[test] #[test]
fn the_upload_limit_is_25_mib() { fn the_file_limits_are_the_plans() {
assert_eq!(MAX_UPLOAD_BYTES, 25 * 1024 * 1024); assert_eq!(ledger_store::uploads::MAX_UPLOAD, 26_214_400);
assert_eq!(MAX_UPLOAD_BYTES, 26_214_400); assert_eq!(files::INLINE_MAX, 262_144);
// Plan 4.7: an upload lasts 15 minutes; 16 under way per token.
assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900);
assert_eq!(ledger_store::uploads::MAX_LIVE, 16);
assert_eq!(ledger_store::uploads::MAX_NAME_BYTES, 255);
assert_eq!(crate::local::MAX_BODY, 1024 * 1024);
} }
/// A period is "YYYY-MM": a year of the calendar the books keep /// A period is "YYYY-MM": a year of the calendar the books keep
@@ -10450,7 +10445,7 @@ mod tests {
("books.assign_role", D), ("books.assign_role", D),
("books.set_tax_category", W), ("books.set_tax_category", W),
("books.import_chart_csv", W), ("books.import_chart_csv", W),
("books.import_local_file", W), ("books.begin_upload", W),
("books.export", R), ("books.export", R),
("books.register_asset", W), ("books.register_asset", W),
("books.asset_schedule", R), ("books.asset_schedule", R),
@@ -10577,6 +10572,10 @@ mod tests {
"ledger-import xlsx", "ledger-import xlsx",
include_str!("../../ledger-import/src/xlsx.rs"), include_str!("../../ledger-import/src/xlsx.rs"),
), ),
(
"ledger-store uploads",
include_str!("../../ledger-store/src/uploads.rs"),
),
] { ] {
let mut rest = src; let mut rest = src;
let mut n = 0; let mut n = 0;
@@ -10589,6 +10588,18 @@ mod tests {
} }
assert!(n > 0, "{krate}: no error templates found"); assert!(n > 0, "{krate}: no error templates found");
} }
// The files tool's refusals (and description): its sentences.
for line in include_str!("tools/files.rs").lines() {
let line = line.trim_start();
if line.starts_with("//") {
continue;
}
for (i, text) in line.split('"').enumerate() {
if i % 2 == 1 && text.len() > 30 && text.contains(' ') {
out.push(("tools/files.rs text".into(), text.to_string()));
}
}
}
let newer = |verified| ledger_store::StoreError::NewerJournal { let newer = |verified| ledger_store::StoreError::NewerJournal {
seq: 7, seq: 7,
event_type: "settings.set".into(), event_type: "settings.set".into(),
@@ -10605,25 +10616,11 @@ mod tests {
)); ));
for (what, text) in [ for (what, text) in [
("STOPPED", STOPPED), ("STOPPED", STOPPED),
("TIN_FILE_IN_ROOT", TIN_FILE_IN_ROOT),
("KEY_IN_BOOKS", KEY_IN_BOOKS), ("KEY_IN_BOOKS", KEY_IN_BOOKS),
("VAULT_IN_HISTORY", VAULT_IN_HISTORY), ("VAULT_IN_HISTORY", VAULT_IN_HISTORY),
("INSIDE_BOOKS", INSIDE_BOOKS), ("INSIDE_BOOKS", INSIDE_BOOKS),
("NO_FOLDER", NO_FOLDER), ("NO_FOLDER", NO_FOLDER),
("EXISTS", EXISTS), ("EXISTS", EXISTS),
("local_files::CHANGED", crate::local_files::CHANGED),
(
"local_files::NO_SUCH_FILE",
crate::local_files::NO_SUCH_FILE,
),
("local_files::NO_ROOTS", crate::local_files::NO_ROOTS),
("local_files::SEALED", crate::local_files::SEALED),
(
"local_files::IN_THE_BOOKS",
crate::local_files::IN_THE_BOOKS,
),
("local_files::HARD_LINKED", crate::local_files::HARD_LINKED),
("local_files::NOT_REGULAR", crate::local_files::NOT_REGULAR),
("tin_files::TIN_FILE", tin_files::TIN_FILE), ("tin_files::TIN_FILE", tin_files::TIN_FILE),
("tin_files::TIN_HELD", tin_files::TIN_HELD), ("tin_files::TIN_HELD", tin_files::TIN_HELD),
("tin_files::TIN_PROBE", tin_files::TIN_PROBE), ("tin_files::TIN_PROBE", tin_files::TIN_PROBE),
@@ -10768,6 +10765,69 @@ mod tests {
); );
} }
/// No tool takes a path on the server's computer (plan 4.7, D-28):
/// files come by upload or inline. The three outputs with full TINs
/// keep theirs until the viewer takes them over (P2 slice 6).
#[test]
fn no_tool_takes_a_path() {
const UNTIL_THE_VIEWER: &[(&str, &str)] = &[
("books.export_cpa_package", "save_to"),
("books.vendor_1099", "save_csv_to"),
("books.get_w9", "save_to"),
];
fn names(v: &serde_json::Value, out: &mut Vec<String>) {
match v {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::Object(props)) = map.get("properties") {
out.extend(props.keys().cloned());
}
map.values().for_each(|v| names(v, out));
}
serde_json::Value::Array(items) => items.iter().for_each(|v| names(v, out)),
_ => {}
}
}
let mut found = Vec::new();
for t in BooksServer::tool_router().list_all() {
let mut props = Vec::new();
names(
&serde_json::Value::Object((*t.input_schema).clone()),
&mut props,
);
for p in props {
let lower = p.to_ascii_lowercase();
// Names that hold a place to read or write: whole words,
// so `link_to`, `*_file_name`, `tin_on_file` and `date_to`
// pass.
let words: Vec<&str> = lower.split('_').collect();
let pathlike = lower.starts_with("save")
|| lower.contains("path")
|| [
"dir",
"folder",
"root",
"dest",
"destination",
"output",
"location",
"local",
]
.iter()
.any(|w| words.contains(w))
|| (words.last() == Some(&"file") && !lower.ends_with("on_file"))
|| lower == "to"
|| lower == "from"
|| lower == "source";
if pathlike && !UNTIL_THE_VIEWER.contains(&(&*t.name, p.as_str())) {
found.push(format!("{} {p}", t.name));
}
}
}
found.sort();
found.dedup();
assert!(found.is_empty(), "path-like parameters: {found:?}");
}
#[test] #[test]
fn tool_text_lint() { fn tool_text_lint() {
const NEVER: &[&str] = &[ const NEVER: &[&str] = &[
@@ -10793,6 +10853,13 @@ mod tests {
"read the file", "read the file",
"open the file", "open the file",
"cat ", "cat ",
// Gone in P2 slice 5: files come by upload or inline.
"import_local_file",
"w9_file_path",
"import folder",
"import root",
"put_url",
"human_url",
"fl-0001", "fl-0001",
]; ];
// Whole words only: "digit" and "committee" are fine. // Whole words only: "digit" and "committee" are fine.
@@ -10830,6 +10897,45 @@ mod tests {
)); ));
} }
} }
// Every tool the text names is one there is.
let tools: std::collections::HashSet<String> = BooksServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
let mut unknown: Vec<String> = Vec::new();
// (Settings keys, `books.cash_accounts`, are named like tools.)
for (what, text) in texts
.iter()
.filter(|(w, _)| !w.contains("settings literal"))
{
for (i, _) in text.match_indices("books.") {
let name: String = text[i..]
.chars()
.take_while(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '.'
})
.collect();
let name = name.trim_end_matches('.');
// "the books." ends a sentence.
if name == "books" {
continue;
}
let before = text[..i].chars().next_back();
if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '/') {
continue;
}
if !tools.contains(name) {
unknown.push(format!("{what}: {name}"));
}
}
}
unknown.sort();
unknown.dedup();
assert!(
unknown.is_empty(),
"text names tools there aren't: {unknown:?}"
);
let words = |text: &str| { let words = |text: &str| {
text.split(|c: char| !c.is_alphanumeric() && c != '_') text.split(|c: char| !c.is_alphanumeric() && c != '_')
.map(str::to_string) .map(str::to_string)
+266
View File
@@ -0,0 +1,266 @@
//! Files in (plan 4.7, P2 slice 5, D-28): `books.begin_upload`, and the
//! bytes a tool is given, as an upload or inline.
//!
//! ```text
//! GOAL: an agent files a document of any size up to the limit without
//! its bytes passing through its context, and a small one inline,
//! both exactly as the agent declared them.
//! SOURCE: plan 4.7 (begin_upload; "content_base64 (≤256 KiB + required
//! sha256)"; guard base64_corrupt), 2.1 ("256 KiB with a mandatory
//! matching sha256"), D-28 (the agent does it all).
//! INVARIANTS:
//! 1. A tool's bytes are an upload made by the same token, or inline
//! base64 of at most INLINE_MAX bytes whose SHA-256 is the one given;
//! never both, never neither. An upload is used up only by the call
//! that files it (TakenFile::filed); a call that fails leaves it for
//! the next.
//! 2. begin_upload makes nothing for a receipt or document whose bytes
//! are stored already: it names the attachment (not withdrawn) that
//! holds them.
//! 3. Uploads exist only for books served from the data root (the
//! server's socket carries the bytes); over stdio, only inline.
//! 4. An upload is filed only by a tool of the purpose it was begun for
//! (a receipt or document, a CSV import, a W-9); another tool's
//! refusal leaves it for the right one.
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
//! before it is decoded.
//! ```
use super::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The most bytes given inline (plan 2.1: 256 KiB).
pub(crate) const INLINE_MAX: usize = 256 * 1024;
/// Why an upload is refused on a connection that has none (INVARIANT 3).
const STDIO_ONLY_INLINE: &str = "this connection takes files inline only (content_base64 with its sha256, at most 256 KiB): a larger one needs these books served by `wanderbooks mcp`; tell the human";
/// What the bytes of a file a tool files came as (INVARIANT 1), and the
/// upload purposes the tool takes (INVARIANT 4).
pub(crate) struct Given<'a> {
pub upload_id: Option<&'a str>,
pub content_base64: Option<&'a str>,
pub sha256: Option<&'a str>,
pub purposes: &'a [&'a str],
}
/// What attach_receipt and file_document take.
pub(crate) const FILED: &[&str] = &["receipt", "document"];
/// What the CSV imports take.
pub(crate) const IMPORTED: &[&str] = &["import"];
/// What set_w9 takes.
pub(crate) const W9: &[&str] = &["w9"];
/// The bytes, and the name and type an upload declared.
pub(crate) struct TakenFile {
pub bytes: Vec<u8>,
pub file_name: Option<String>,
pub mime: Option<String>,
/// The upload's hold, if it came as one (INVARIANT 1).
pub claim: Option<ledger_store::uploads::Claim>,
}
/// ` --token NAME` for a command given to the agent, when its token isn't
/// the one the commands use by default: the command runs as the caller.
pub(crate) fn token_flag() -> String {
CALLER.with(|c| match c.borrow().as_ref() {
Some((_, name)) if name != ledger_store::auth::DEFAULT_NAME => {
format!(" --token {name}")
}
_ => String::new(),
})
}
/// The bytes are in the books: an upload they came as is used up
/// (INVARIANT 1). Dropped instead, it is given back.
pub(crate) fn filed(claim: Option<ledger_store::uploads::Claim>) {
if let Some(c) = claim {
c.spend();
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BeginUploadParams {
/// The file's SHA-256, hex (`sha256sum FILE`).
pub sha256: String,
/// Its size in bytes (at most 25 MiB).
pub size: u64,
/// Its MIME type, e.g. "application/pdf".
pub mime_type: String,
/// Its name, e.g. "receipt.pdf".
pub file_name: String,
/// What it is for: receipt, document, import (a bank or chart CSV), or
/// w9 (books.set_w9).
pub purpose: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct BeginUploadOut {
/// The upload's id: name it in the tool that files it.
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_id: Option<String>,
/// The command that sends the file (run it with the file's path).
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// When the upload lapses (UTC).
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// The most bytes taken.
pub max_bytes: u64,
/// These bytes are stored already, in this attachment: link it
/// (books.link_attachment) instead of filing a copy.
#[serde(skip_serializing_if = "Option::is_none")]
pub existing_attachment_id: Option<u64>,
}
impl BooksServer {
/// The token of the call running on this thread (its id), if any.
fn caller_token(&self) -> Option<String> {
CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone()))
}
/// The bytes a tool was given (INVARIANT 1).
pub(crate) fn take_file(&self, given: Given<'_>) -> Result<TakenFile, String> {
match (given.upload_id, given.content_base64) {
(Some(_), Some(_)) => Err("give upload_id or content_base64, not both".into()),
(None, None) => Err(
"the file's bytes are missing: upload it (books.begin_upload), or give content_base64 with its sha256 (at most 256 KiB)"
.into(),
),
(Some(id), None) => {
// INVARIANT 3.
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let got = uploads
.take(id.trim(), &token, chrono::Utc::now().timestamp())
.map_err(|e| e.to_string())?;
// INVARIANT 4: dropped, the claim gives it back.
if !given.purposes.contains(&got.grant.purpose.as_str()) {
return Err(format!(
"that upload was begun for {}: this tool takes one begun for {}",
got.grant.purpose,
given.purposes.join(" or ")
));
}
Ok(TakenFile {
bytes: got.bytes,
file_name: Some(got.grant.file_name),
mime: Some(got.grant.mime),
claim: Some(got.claim),
})
}
(None, Some(b64)) => {
let Some(sha) = given.sha256 else {
return Err("content_base64 comes with its sha256 (the file's SHA-256, hex), so a byte lost on the way is caught".into());
};
// INVARIANT 5.
if b64.len() > INLINE_MAX.div_ceil(3) * 4 {
return Err(format!(
"inline is at most {INLINE_MAX} bytes: upload it with books.begin_upload"
));
}
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
.map_err(|_| "content_base64 is not valid base64 (guard base64_corrupt): send the file with books.begin_upload".to_string())?;
if bytes.len() > INLINE_MAX {
return Err(format!(
"the file is {} bytes; inline is at most {INLINE_MAX}: upload it with books.begin_upload",
bytes.len()
));
}
if ledger_store::journal::sha256_hex(&bytes) != sha.trim().to_ascii_lowercase() {
return Err("content_base64 doesn't have the sha256 given: some of it was lost on the way (guard base64_corrupt). Send the file with books.begin_upload instead".into());
}
Ok(TakenFile {
bytes,
file_name: None,
mime: None,
claim: None,
})
}
}
}
}
#[tool_router(router = files_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.begin_upload",
title = "Begin an upload",
description = "Send a file of up to 25 MiB (books served by `wanderbooks mcp`; elsewhere only inline) without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn begin_upload(
&self,
params: Parameters<BeginUploadParams>,
) -> Result<Json<BeginUploadOut>, String> {
let p = params.0;
let max = ledger_store::uploads::MAX_UPLOAD;
let sha = p.sha256.trim().to_ascii_lowercase();
if !ledger_store::uploads::PURPOSES.contains(&p.purpose.as_str()) {
return Err("purpose is receipt, document, import (a bank or chart CSV) or w9".into());
}
// INVARIANT 2: an attachment that can be linked, for a file that
// would be one (an import or a W-9 needs its bytes).
let store = self.lock()?;
let existing = if FILED.contains(&p.purpose.as_str()) {
store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.find(|a| a.blob_sha == sha && a.withdrawn_at.is_none())
.map(|a| a.id)
} else {
None
};
let book = store
.data_dir()
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
drop(store);
if let Some(id) = existing {
return Ok(Json(BeginUploadOut {
upload_id: None,
command: None,
expires_at: None,
max_bytes: max,
existing_attachment_id: Some(id),
}));
}
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let now = chrono::Utc::now();
let grant = uploads
.grant(
&token,
&sha,
p.size,
&p.mime_type,
&p.file_name,
&p.purpose,
now.timestamp(),
)
.map_err(|e| e.to_string())?;
let expires = chrono::DateTime::from_timestamp(grant.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_default();
Ok(Json(BeginUploadOut {
command: Some(format!(
"wanderbooks upload --book {book}{} {} -- <the file's path>",
token_flag(),
grant.id
)),
upload_id: Some(grant.id),
expires_at: Some(expires),
max_bytes: max,
existing_attachment_id: None,
}))
}
}
+80 -65
View File
@@ -369,7 +369,7 @@ pub struct Vendor1099Params {
pub include_tins: bool, pub include_tins: bool,
/// Also write the worksheet as CSV to this local path (TINs included /// Also write the worksheet as CSV to this local path (TINs included
/// only with include_tins). Never inside the books dir, and with TINs /// only with include_tins). Never inside the books dir, and with TINs
/// never in the server's import folders; an existing file is refused. /// an existing file is refused.
pub save_csv_to: Option<String>, pub save_csv_to: Option<String>,
} }
@@ -868,8 +868,12 @@ pub struct CashFlowOut {
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportBankCsvParams { pub struct ImportBankCsvParams {
/// The CSV text (QBO bank upload layout: Date,Description,Amount, or /// The CSV text (QBO bank upload layout: Date,Description,Amount, or
/// Date,Description, money out, money in). /// Date,Description, money out, money in)…
pub csv_text: String, #[serde(default)]
pub csv_text: Option<String>,
/// …or an upload of it made with books.begin_upload (purpose import).
#[serde(default)]
pub upload_id: Option<String>,
/// Account code for the bank account (the offset side of each entry). /// Account code for the bank account (the offset side of each entry).
pub bank_account_code: String, pub bank_account_code: String,
/// Account code for the default offset (e.g. an expense account). /// Account code for the default offset (e.g. an expense account).
@@ -923,8 +927,12 @@ pub struct ImportBankCsvOut {
/// Parameters for `books.import_chart_csv`. /// Parameters for `books.import_chart_csv`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportChartCsvParams { pub struct ImportChartCsvParams {
/// The CSV text: code,name,type,parent_code with a header row. /// The CSV text: code,name,type,parent_code with a header row…
pub csv_text: String, #[serde(default)]
pub csv_text: Option<String>,
/// …or an upload of it made with books.begin_upload (purpose import).
#[serde(default)]
pub upload_id: Option<String>,
} }
/// Result of `books.import_chart_csv`. /// Result of `books.import_chart_csv`.
@@ -936,41 +944,6 @@ pub struct ImportChartCsvOut {
pub count: usize, pub count: usize,
} }
/// Parameters for `books.import_local_file`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportLocalFileParams {
/// Absolute path to a regular file in the server's import folders (by
/// default your downloads folder).
pub path: String,
/// "receipt" or "document".
pub category: String,
/// Entry id to attach to (receipts only). Omit to file unlinked.
#[serde(default)]
pub entry_id: Option<u64>,
/// Document category for documents: formation, legal, tax, accounting, other.
#[serde(default)]
pub document_category: Option<String>,
/// Override the file name (defaults to the path's file name).
#[serde(default)]
pub file_name: Option<String>,
/// Optional description.
#[serde(default)]
pub description: Option<String>,
}
/// Result of `books.import_local_file`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ImportLocalFileOut {
/// Attachment id.
pub attachment_id: u64,
/// SHA-256 hex of the content.
pub sha256: String,
/// True if the content was already stored (deduplicated).
pub deduplicated: bool,
/// File name used.
pub file_name: String,
}
/// Parameters for `books.balance_sheet`. /// Parameters for `books.balance_sheet`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct BalanceSheetParams { pub struct BalanceSheetParams {
@@ -1270,12 +1243,23 @@ pub struct AttachReceiptParams {
/// Entry id to attach to. Omit to file unlinked, then link later with /// Entry id to attach to. Omit to file unlinked, then link later with
/// `books.link_receipt`. /// `books.link_receipt`.
pub entry_id: Option<u64>, pub entry_id: Option<u64>,
/// Original file name, e.g. "receipt.png". /// Original file name, e.g. "receipt.png" (an upload's own, if not
pub file_name: String, /// given).
/// MIME type, e.g. "image/png". #[serde(default)]
pub mime_type: String, pub file_name: Option<String>,
/// File bytes, base64-encoded. Max 25 MB. /// MIME type, e.g. "image/png" (an upload's own, if not given).
pub content_base64: String, #[serde(default)]
pub mime_type: Option<String>,
/// The file's bytes: an upload made with books.begin_upload (its id)…
#[serde(default)]
pub upload_id: Option<String>,
/// …or, for a file of at most 256 KiB, its bytes base64-encoded, with
/// sha256.
#[serde(default)]
pub content_base64: Option<String>,
/// The SHA-256 of the bytes in content_base64, hex.
#[serde(default)]
pub sha256: Option<String>,
/// Optional description. /// Optional description.
pub description: Option<String>, pub description: Option<String>,
/// More subjects to link it to in the same call (each journaled as its /// More subjects to link it to in the same call (each journaled as its
@@ -1304,12 +1288,22 @@ pub struct FileDocumentParams {
/// One of: formation, legal, tax, accounting, statement, contract, /// One of: formation, legal, tax, accounting, statement, contract,
/// photo, warranty, import_source, other. /// photo, warranty, import_source, other.
pub category: String, pub category: String,
/// Original file name. /// Original file name (an upload's own, if not given).
pub file_name: String, #[serde(default)]
/// MIME type. pub file_name: Option<String>,
pub mime_type: String, /// MIME type (an upload's own, if not given).
/// File bytes, base64-encoded. Max 25 MB. #[serde(default)]
pub content_base64: String, pub mime_type: Option<String>,
/// The file's bytes: an upload made with books.begin_upload (its id)…
#[serde(default)]
pub upload_id: Option<String>,
/// …or, for a file of at most 256 KiB, its bytes base64-encoded, with
/// sha256.
#[serde(default)]
pub content_base64: Option<String>,
/// The SHA-256 of the bytes in content_base64, hex.
#[serde(default)]
pub sha256: Option<String>,
/// Optional description. /// Optional description.
pub description: Option<String>, pub description: Option<String>,
/// Subjects to link it to in the same call (each journaled as its own /// Subjects to link it to in the same call (each journaled as its own
@@ -1421,10 +1415,16 @@ pub struct ListReceiptsOut {
pub struct GetReceiptParams { pub struct GetReceiptParams {
/// Attachment id. /// Attachment id.
pub attachment_id: u64, pub attachment_id: u64,
/// What to return: "meta" (default: what it is and what it's linked
/// to), or "bytes" (also its bytes, base64, for a file of at most
/// 256 KiB; a larger one comes with `wanderbooks download`).
#[serde(default)]
pub view: Option<String>,
} }
/// Result of `books.get_receipt`: metadata plus base64 bytes. The bytes are /// Result of `books.get_receipt`: what it is and what it's linked to, and
/// hash-verified on read. /// with view `bytes`, its bytes (hash-verified on read) or the command that
/// saves a copy.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct GetReceiptOut { pub struct GetReceiptOut {
/// Attachment id. /// Attachment id.
@@ -1445,8 +1445,14 @@ pub struct GetReceiptOut {
pub description: Option<String>, pub description: Option<String>,
/// Everything it is linked to (the entry it is filed on first). /// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>, pub links: Vec<SubjectOut>,
/// File bytes, base64-encoded. /// Its bytes, base64-encoded: with view `bytes`, for a file of at most
pub content_base64: String, /// 256 KiB.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
/// With view `bytes`, for a file too large to come back here: the
/// command that saves a copy in a folder you name.
#[serde(skip_serializing_if = "Option::is_none")]
pub download: Option<String>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2605,8 +2611,7 @@ pub struct ExportCpaPackageParams {
pub include_tins: bool, pub include_tins: bool,
/// Where to write the package: a new `.zip` file, or an existing /// Where to write the package: a new `.zip` file, or an existing
/// folder (the zip goes in it as `cpa-package-<as_of>.zip`). Written /// folder (the zip goes in it as `cpa-package-<as_of>.zip`). Written
/// readable by its owner only. With TINs, never inside the books dir /// readable by its owner only. With TINs, never inside the books dir.
/// or the server's import folders.
pub save_to: Option<String>, pub save_to: Option<String>,
} }
@@ -2727,11 +2732,22 @@ pub struct SetW9Params {
pub tin: Option<SecretTin>, pub tin: Option<SecretTin>,
/// The TIN's type: ssn, ein, or itin. Required with tin. /// The TIN's type: ssn, ein, or itin. Required with tin.
pub tin_type: Option<String>, pub tin_type: Option<String>,
/// A local PDF, PNG, or JPEG of the signed W-9, in the server's import /// The signed W-9 itself (a PDF, PNG, or JPEG): an upload made with
/// folders (by default your downloads folder): stored only in the /// books.begin_upload. Stored only in the vault (it shows the full
/// vault (it shows the full TIN), never with the receipts and /// TIN), never with the receipts and documents.
/// documents. Read it back with `books.get_w9`. #[serde(default)]
pub w9_file_path: Option<String>, pub w9_upload_id: Option<String>,
/// …or, for one of at most 256 KiB, its bytes base64-encoded, with
/// w9_sha256.
#[serde(default)]
pub w9_content_base64: Option<String>,
/// The SHA-256 of the bytes in w9_content_base64, hex.
#[serde(default)]
pub w9_sha256: Option<String>,
/// The W-9 file's name (default: the upload's, else w9.pdf, w9.png
/// or w9.jpg by its type).
#[serde(default)]
pub w9_file_name: Option<String>,
} }
/// A full TIN as a tool takes it: text (or a JSON number) whose value is /// A full TIN as a tool takes it: text (or a JSON number) whose value is
@@ -2807,8 +2823,7 @@ pub struct GetW9Params {
/// The party: id or name. /// The party: id or name.
pub party: String, pub party: String,
/// Where to write the W-9 file: a new local file outside the books /// Where to write the W-9 file: a new local file outside the books
/// and outside the server's import folders (the file shows the full /// (the file shows the full TIN). An existing file is refused.
/// TIN). An existing file is refused.
pub save_to: String, pub save_to: String,
} }
+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 /// The places one run uses: the books, the vault key, a folder of files on
/// folder the server runs in (where a relative path lands). /// their way in (as a downloads folder), the folder the server runs in
/// (where a relative path lands).
struct Places { struct Places {
base: PathBuf, base: PathBuf,
books: PathBuf, books: PathBuf,
@@ -130,8 +131,6 @@ impl Mcp {
.arg(p.books.join("books-data")) .arg(p.books.join("books-data"))
.arg("--books-toml") .arg("--books-toml")
.arg(p.books.join("wanderbooks.toml")) .arg(p.books.join("wanderbooks.toml"))
.arg("--import-root")
.arg(&p.inbox)
.current_dir(&p.sandbox) .current_dir(&p.sandbox)
.env("HOME", p.base.join("home")) .env("HOME", p.base.join("home"))
.env("XDG_CONFIG_HOME", p.base.join("config")) .env("XDG_CONFIG_HOME", p.base.join("config"))
@@ -256,7 +255,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
m.ok( m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf", 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 [ for (date, dr, cr, amount) in [
("2026-01-02", "1000", "3000", "5000.00"), ("2026-01-02", "1000", "3000", "5000.00"),
@@ -279,7 +279,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
m.ok( m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf", 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( m.ok(
"books.post_entry", "books.post_entry",
@@ -321,7 +322,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
m.ok( m.ok(
"books.file_document", "books.file_document",
json!({"category": "statement", "file_name": "s.pdf", "mime_type": "application/pdf", 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; let _ = p;
} }
@@ -500,8 +502,8 @@ fn hostile_numbers() -> Vec<Value> {
] ]
} }
/// Paths a tool may be given: inside the sandbox, the import root, the /// Paths a tool may be given: inside the sandbox, the inbox, the books,
/// books, relative ones, odd ones. Never anywhere a real file lives. /// relative ones, odd ones. Never anywhere a real file lives.
fn hostile_paths(p: &Places) -> Vec<String> { fn hostile_paths(p: &Places) -> Vec<String> {
let s = |p: &Path| p.to_str().unwrap().to_string(); let s = |p: &Path| p.to_str().unwrap().to_string();
vec![ vec![
@@ -618,17 +620,20 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
.boxed(); .boxed();
} }
if n.contains("base64") { if n.contains("base64") {
likely.extend( likely.extend(INLINE_FILES.map(String::from));
[ }
"JVBERi0xLjQ=", // 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.
"!!!not base64", if n.contains("sha256") {
"AAAA", use base64::Engine;
"NTcxLTkzLTI4NDY=", likely.extend(INLINE_FILES.iter().filter_map(|f| {
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==", base64::engine::general_purpose::STANDARD
] .decode(f)
.map(String::from), .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> { let pick = |pred: fn(&str) -> bool| -> Vec<String> {
hostile_strings().into_iter().filter(|s| pred(s)).collect() 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() .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 /// An object's value: each property present or not (required ones mostly
/// present), and now and then a field no schema names. /// present), and now and then a field no schema names.
fn object_for( fn object_for(
@@ -1144,7 +1159,8 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
&mut m, &mut m,
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "h.pdf", "mime_type": "application/pdf", 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( pinned(
&mut m, &mut m,
+2 -1
View File
@@ -119,7 +119,8 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
c, c,
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": format!("{tag}.pdf"), 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 .await
.unwrap(); .unwrap();
+87 -234
View File
@@ -126,8 +126,7 @@ impl Mcp {
} }
} }
/// A test's dir and what sits beside it (`.inbox`, `.elsewhere.txt`), /// A test's dir, removed when the test ends, pass or panic.
/// removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf); struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir { impl std::ops::Deref for TestDir {
@@ -146,11 +145,6 @@ impl AsRef<std::path::Path> for TestDir {
impl Drop for TestDir { impl Drop for TestDir {
fn drop(&mut self) { fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0); 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 list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len()); 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(); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true); assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!( assert!(
@@ -267,6 +264,7 @@ fn mcp_end_to_end_over_stdio() {
"file_name": "rent.png", "file_name": "rent.png",
"mime_type": "image/png", "mime_type": "image/png",
"content_base64": png, "content_base64": png,
"sha256": sha(b"fake-png-bytes"),
}), }),
); );
assert_eq!(attached["deduplicated"], false); assert_eq!(attached["deduplicated"], false);
@@ -461,6 +459,7 @@ fn mcp_assets_end_to_end_over_stdio() {
"file_name": "bill-of-sale.pdf", "file_name": "bill-of-sale.pdf",
"mime_type": "application/pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"fake bill of sale"), "content_base64": base64_like(b"fake bill of sale"),
"sha256": sha(b"fake bill of sale"),
}), }),
); );
let aid = bill["attachment_id"].as_u64().unwrap(); let aid = bill["attachment_id"].as_u64().unwrap();
@@ -628,7 +627,8 @@ fn mcp_receipt_corrections_over_stdio() {
let att = mcp.call( let att = mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822", 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( let posted = mcp.call(
"books.post_entry", "books.post_entry",
@@ -655,7 +655,8 @@ fn mcp_receipt_corrections_over_stdio() {
let pdf = mcp.call( let pdf = mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf", 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"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
@@ -782,46 +783,6 @@ fn mcp_resources_over_stdio() {
assert!(resp.get("error").is_some()); 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. /// Start a session: initialize, then initialized.
fn init(mcp: &mut Mcp) -> Value { fn init(mcp: &mut Mcp) -> Value {
let init = mcp.request( let init = mcp.request(
@@ -836,173 +797,25 @@ fn init(mcp: &mut Mcp) -> Value {
init 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 /// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with
/// include_tins, books.export_cpa_package with TINs) are never written /// 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 /// inside the books, and wherever they are written (a downloads folder
/// its bytes: a TIN-bearing file can't be filed back in as a receipt and /// 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). /// read out through books.get_receipt (S7 of the P0 review, 2026-10-16).
#[test] #[test]
fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() { fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
let dir = test_dir("tin-files"); let dir = test_dir("tin-files");
let root = dir.join("inbox"); let books = dir.join("books");
let out = dir.join("out"); let downloads = dir.join("downloads");
let config = dir.join("config"); 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::create_dir_all(d).unwrap();
} }
std::fs::write(root.join("w9.pdf"), b"%PDF W-9 TIN 291-12-2846").unwrap(); let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
let mut mcp = Mcp::spawn_env( let mut mcp = Mcp::spawn_env(
&dir.join("books").join("books-data"), &books.join("books-data"),
&root_arg, &[],
&[("XDG_CONFIG_HOME", &config)], &[("XDG_CONFIG_HOME", &config)],
); );
init(&mut mcp); init(&mut mcp);
@@ -1018,61 +831,92 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
"books.set_w9", "books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual", json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": "291-12-2846", "tin_type": "ssn", "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 [ for (tool, args) in [
( (
"books.get_w9", "books.get_w9",
json!({"party": "Acme", "save_to": in_root("copy.pdf")}), json!({"party": "Acme", "save_to": in_books("copy.pdf")}),
), ),
( (
"books.vendor_1099", "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", "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", "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); let why = mcp.call_err(tool, args);
assert!( assert!(why.contains("inside the books"), "{tool}: {why}");
why.contains("never saved in the server's import folders"),
"{tool}: {why}"
);
} }
assert_eq!( assert_eq!(
std::fs::read_dir(&root).unwrap().count(), std::fs::read_dir(&books).unwrap().count(),
1, before,
"wrote into the root" "wrote into the books"
); );
// Saved elsewhere, then moved into the root: refused by its bytes. // Written to a downloads folder, each is refused by its bytes, by
let w9 = out.join("copy.pdf"); // every way a file is filed.
let in_downloads = |name: &str| downloads.join(name).to_str().unwrap().to_string();
mcp.call( mcp.call(
"books.get_w9", "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( mcp.call(
"books.vendor_1099", "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")] { mcp.call(
std::fs::copy(from, root.join(name)).unwrap(); "books.export_cpa_package",
let why = mcp.call_err( json!({"cash_accounts": ["1000"], "save_to": in_downloads("cpa.zip")}),
"books.import_local_file", );
json!({"path": in_root(name), "category": "receipt"}), let written: Vec<std::path::PathBuf> = std::fs::read_dir(&downloads)
); .unwrap()
assert!(why.contains("wrote with full TINs"), "{why}"); .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. // Without TINs, the 1099 worksheet may go anywhere a file may.
mcp.call( mcp.call(
"books.vendor_1099", "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()); 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 { fn base64_like(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new(); let mut out = String::new();
@@ -1409,7 +1258,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
mcp.call( mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf", 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( mcp.call(
"books.post_entry", "books.post_entry",
@@ -1571,7 +1421,8 @@ fn mcp_period_statements_over_stdio() {
mcp.call( mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf", 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( mcp.call(
"books.post_entry", "books.post_entry",
@@ -2047,6 +1898,7 @@ fn mcp_payout_gross_up_over_stdio() {
"file_name": "payout.pdf", "file_name": "payout.pdf",
"mime_type": "application/pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 statement"), "content_base64": base64_like(b"%PDF-1.4 statement"),
"sha256": sha(b"%PDF-1.4 statement"),
}), }),
); );
mcp.call( mcp.call(
@@ -2133,6 +1985,7 @@ fn mcp_vendor_1099_over_stdio() {
"file_name": "invoice.pdf", "file_name": "invoice.pdf",
"mime_type": "application/pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 invoice"), "content_base64": base64_like(b"%PDF-1.4 invoice"),
"sha256": sha(b"%PDF-1.4 invoice"),
}), }),
); );
mcp.call( mcp.call(
+248 -73
View File
@@ -56,8 +56,7 @@ impl Drop for Mcp {
static DIR_SEQ: AtomicU64 = AtomicU64::new(0); static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
/// A test's dir and the folder beside it (`.inbox`), /// A test's dir, removed when the test ends, pass or panic.
/// removed when the test ends, pass or panic.
struct TestDir(PathBuf); struct TestDir(PathBuf);
impl std::ops::Deref for TestDir { impl std::ops::Deref for TestDir {
@@ -76,7 +75,6 @@ impl AsRef<std::path::Path> for TestDir {
impl Drop for TestDir { impl Drop for TestDir {
fn drop(&mut self) { fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0); 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 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 { fn data_dir(dir: &Path) -> PathBuf {
dir.join("books-data") dir.join("books-data")
} }
@@ -131,8 +121,6 @@ impl Mcp {
.arg(data_dir(dir)) .arg(data_dir(dir))
.arg("--books-toml") .arg("--books-toml")
.arg(&toml) .arg(&toml)
.arg("--import-root")
.arg(inbox(dir))
.env("XDG_RUNTIME_DIR", &run) .env("XDG_RUNTIME_DIR", &run)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@@ -1737,7 +1725,7 @@ fn post_contribution(m: &mut Mcp) -> u64 {
m.ok( m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain", 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( m.ok(
"books.post_entry", "books.post_entry",
@@ -2118,11 +2106,24 @@ fn resources_read_back_as_the_matching_tools() {
let want = m.ok(tool, args); let want = m.ok(tool, args);
assert_eq!(got, want, "{uri} reads as {tool}"); 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"[..])] { for (id, bytes) in [(r1, &b"first receipt"[..]), (r2, &b"second receipt"[..])] {
let got = read(&mut m, &format!("books://receipts/{id}")); let got = read(&mut m, &format!("books://receipts/{id}"));
assert_eq!(got["attachment_id"], json!(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["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 r = m.rpc("resources/read", json!({"uri": "books://nope"}));
let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}")); 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 /// Receipts and documents round-trip: the bytes come back exactly (SHA-256
/// of what was sent), with the name, MIME type, category, entry, and /// of what was sent), with the name, MIME type, category, entry, and
/// description given. Kills: get_receipt's lookup, import_local_file's /// description given; only view bytes carries the bytes. Kills:
/// document arm. /// get_receipt's lookup and its views.
#[test] #[test]
fn receipts_and_documents_round_trip() { fn receipts_and_documents_round_trip() {
let dir = test_dir("files"); let dir = test_dir("files");
@@ -2150,48 +2151,27 @@ fn receipts_and_documents_round_trip() {
let a = m.ok( let a = m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf", 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( let b = m.ok(
"books.file_document", "books.file_document",
json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png", 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( let c = m.ok(
"books.import_local_file", "books.file_document",
json!({"path": local_doc.to_str().unwrap(), "category": "document", json!({"category": "tax", "file_name": "ein-letter.pdf", "mime_type": "application/pdf",
"document_category": "tax", "description": "IRS CP 575"}), "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( let r = m.ok(
"books.import_local_file", "books.attach_receipt",
json!({"path": local_receipt.to_str().unwrap(), "category": "receipt", json!({"entry_id": e, "file_name": "renamed.png", "mime_type": "image/png",
"entry_id": e, "file_name": "renamed.png", "description": "scan"}), "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) // (result, bytes, file name, MIME type, entry, description)
type Case<'a> = (&'a Value, &'a [u8], &'a str, &'a str, Option<u64>, &'a str); 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 { for (out, bytes, name, mime, entry, desc) in cases {
let id = out["attachment_id"].as_u64().unwrap(); let id = out["attachment_id"].as_u64().unwrap();
assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes"); 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!(got["attachment_id"], json!(id));
assert_eq!( assert_eq!(
b64d(got["content_base64"].as_str().unwrap()), b64d(got["content_base64"].as_str().unwrap()),
bytes, bytes,
"{name}" "{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["sha256"], sha256(bytes));
assert_eq!(got["file_name"], name); assert_eq!(got["file_name"], name);
assert_eq!(got["mime_type"], mime); assert_eq!(got["mime_type"], mime);
@@ -2263,19 +2254,26 @@ fn receipts_and_documents_round_trip() {
assert_eq!(cat(&b), "legal"); assert_eq!(cat(&b), "legal");
assert_eq!(cat(&c), "tax"); assert_eq!(cat(&c), "tax");
m.refuse("books.get_receipt", json!({"attachment_id": 999})); 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 /// 256 KiB is the most a file may be given inline (plan 2.1): exactly
/// limit is accepted through every way in, one byte more refused (checked /// the limit is accepted by books.attach_receipt and books.file_document
/// on the local-file path, where it costs no upload). Kills: the limit /// and stored whole, one byte more is refused by both, pointing at
/// comparisons in attach_receipt, file_document, and import_local_file, /// books.begin_upload, and nothing is stored. Kills: the limit comparison
/// and import_local_file's limit arithmetic (2 MiB must pass). /// in take_file.
#[test] #[test]
fn uploads_up_to_25_mib_are_accepted_and_no_more() { fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
const LIMIT: usize = 25 * 1024 * 1024; const LIMIT: usize = 256 * 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).
let dir = test_dir("limits"); let dir = test_dir("limits");
let s = new_store(&dir); let s = new_store(&dir);
drop(s); drop(s);
@@ -2284,18 +2282,19 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
let out = m.ok( let out = m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"file_name": "big.bin", "mime_type": "application/octet-stream", 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 at_id = out["attachment_id"].as_u64().unwrap();
let doc = vec![b'b'; LIMIT]; let doc = vec![b'b'; LIMIT];
let out = m.ok( let out = m.ok(
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": "big.bin", 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 doc_id = out["attachment_id"].as_u64().unwrap();
let listed = m.ok("books.list_receipts", json!({})); 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"] let row = listed["receipts"]
.as_array() .as_array()
.unwrap() .unwrap()
@@ -2304,19 +2303,195 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
.unwrap() .unwrap()
.clone(); .clone();
assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole"); 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)] { /// The bytes a filing tool takes are inline base64 with its own SHA-256,
let path = inbox(&dir).join(format!("local-{n}.bin")); /// or an upload; never both, never neither. A sha256 missing, or not the
std::fs::write(&path, vec![b'c' ^ (n % 7) as u8; n]).unwrap(); /// bytes' own (a byte lost on the way), or base64 that isn't, is refused
let args = json!({"path": path.to_str().unwrap(), "category": "receipt"}); /// (guard base64_corrupt); inline bytes need their file_name and
if accepted { /// mime_type. Over stdio there are no uploads: an upload_id is refused,
m.ok("books.import_local_file", args); /// and books.begin_upload answers only for bytes stored already (the
} else { /// attachment holding them). Every refusal stores nothing.
m.refuse("books.import_local_file", args); #[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 /// 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( self.ok(
"books.file_document", "books.file_document",
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain", 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"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap() .unwrap()
+13 -8
View File
@@ -371,6 +371,11 @@ fn b64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes) 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)] = &[ const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"), ("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"), ("1300", "Prepaid Expenses", "asset"),
@@ -406,7 +411,7 @@ impl Mcp {
self.ok( self.ok(
"books.file_document", "books.file_document",
json!({"category": category, "file_name": format!("{category}.pdf"), 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}), "link_to": link_to}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
@@ -416,7 +421,7 @@ impl Mcp {
/// Attach a receipt (to `entry`, or unlinked); its id. /// Attach a receipt (to `entry`, or unlinked); its id.
fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 { fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf", 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 { if let Some(e) = entry {
args["entry_id"] = json!(e); args["entry_id"] = json!(e);
} }
@@ -787,25 +792,25 @@ fn filing_links_in_the_same_call_all_or_nothing() {
m.refuse( m.refuse(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf", 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( m.refuse(
"books.file_document", "books.file_document",
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf", 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. // An unknown entry_id is refused before anything is filed.
m.refuse( m.refuse(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf", 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"); assert_eq!(count(&mut m), n, "nothing filed");
let out = m.ok( let out = m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf", 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"}]}), "link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
); );
assert_eq!( assert_eq!(
@@ -819,7 +824,7 @@ fn filing_links_in_the_same_call_all_or_nothing() {
let doc = m.ok( let doc = m.ok(
"books.file_document", "books.file_document",
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf", 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!(kinds(&doc), ["company"]);
assert_eq!(m.count("document.filed"), 1); assert_eq!(m.count("document.filed"), 1);
@@ -849,7 +854,7 @@ fn documents_file_under_the_attachment_kinds() {
m.refuse( m.refuse(
"books.file_document", "books.file_document",
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain", 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 /// A directory beside the books dir `dir`, outside it (vault keys, files
/// with TINs). /// 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 { fn outside(dir: &Path) -> PathBuf {
let d = PathBuf::from(format!("{}-outside", dir.display())); let d = PathBuf::from(format!("{}-outside", dir.display()));
std::fs::create_dir_all(&d).unwrap(); std::fs::create_dir_all(&d).unwrap();
@@ -260,10 +252,6 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
.into_iter() .into_iter()
.flatten(), .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 // books.show writes a viewer request here; keep it out of the
// user's real runtime dir. // user's real runtime dir.
.env("XDG_RUNTIME_DIR", &run) .env("XDG_RUNTIME_DIR", &run)
@@ -548,6 +536,7 @@ impl Mcp {
"file_name": "receipt.txt", "file_name": "receipt.txt",
"mime_type": "text/plain", "mime_type": "text/plain",
"content_base64": b64(bytes), "content_base64": b64(bytes),
"sha256": sha(bytes),
}); });
if let Some(e) = entry { if let Some(e) = entry {
args["entry_id"] = json!(e); 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 { fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new(); 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)", json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin_last4": "4321", "kept_at": "CPA portal"}), "tin_last4": "4321", "kept_at": "CPA portal"}),
); );
let w9_pdf = inbox(&dir).join("landlord-w9.pdf"); let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321";
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 79-9714321").unwrap();
m.ok( m.ok(
"books.set_w9", "books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)", 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( m.ok(
"books.get_w9", "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!({"include_withdrawn": true}));
m.ok("books.list_receipts", json!({"entry_id": e2})); 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}));
m.ok(
"books.get_receipt",
json!({"attachment_id": r_old, "view": "bytes"}),
);
let doc = m.ok( let doc = m.ok(
"books.file_document", "books.file_document",
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain", 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"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
let filed = m.ok( let filed = m.ok(
"books.file_document", "books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain", 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"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
@@ -915,13 +915,15 @@ fn happy_path(name: &str) -> Mcp {
"books.unlink_attachment", "books.unlink_attachment",
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}), json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
); );
// From outside the books, in the server's import folder. // An upload of bytes already stored: begin_upload names the
let local = inbox(&dir).join("local-receipt.txt"); // attachment that holds them (over stdio, the only upload answered).
std::fs::write(&local, b"a local receipt").unwrap(); let local = m.attach(None, b"a local receipt");
m.ok( let up = m.ok(
"books.import_local_file", "books.begin_upload",
json!({"path": local.to_str().unwrap(), "category": "receipt"}), 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. // Reversal: preview, then confirmed.
let e3 = m.draft( let e3 = m.draft(
@@ -1071,7 +1073,8 @@ fn happy_path(name: &str) -> Mcp {
let source = m.ok( let source = m.ok(
"books.file_document", "books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv", 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"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
@@ -1092,7 +1095,8 @@ fn happy_path(name: &str) -> Mcp {
let stmt = m.ok( let stmt = m.ok(
"books.file_document", "books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf", 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"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
@@ -1572,7 +1576,8 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
let out = m.tool( let out = m.tool(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain", 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")); 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", json!({"entry_id": entry.resolve(&k.entries), "date": date, "description": "undo",
"confirmed": opt(*confirmed)}), "confirmed": opt(*confirmed)}),
), ),
Op::Attach { entry, content } => ( Op::Attach { entry, content } => {
"books.attach_receipt", // The bytes' own SHA-256 (content that is no base64 has none).
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin", let sha256 = {
"mime_type": "application/octet-stream", "content_base64": content}), 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 } => ( Op::Link { att, entry } => (
"books.link_receipt", "books.link_receipt",
json!({"attachment_id": att.resolve(&k.attachments), 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); 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. /// removed when the test ends, pass or panic.
struct TestDir(PathBuf); struct TestDir(PathBuf);
@@ -92,11 +92,7 @@ impl AsRef<std::path::Path> for TestDir {
impl Drop for TestDir { impl Drop for TestDir {
fn drop(&mut self) { fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0); let _ = std::fs::remove_dir_all(&self.0);
for beside in ["-inbox", "-outside"] { let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display()));
let p = PathBuf::from(format!("{}{beside}", self.0.display()));
let _ = std::fs::remove_dir_all(&p);
let _ = std::fs::remove_file(&p);
}
} }
} }
@@ -119,12 +115,15 @@ fn outside(dir: &Path) -> PathBuf {
d d
} }
/// The server's import root, beside the books dir `dir`: W-9 files handed /// `bytes` as a tool takes them inline: base64.
/// to books.set_w9 by path. Never where the server writes TINs. fn b64(bytes: &[u8]) -> String {
fn inbox(dir: &Path) -> PathBuf { use base64::Engine as _;
let d = PathBuf::from(format!("{}-inbox", dir.display())); base64::engine::general_purpose::STANDARD.encode(bytes)
std::fs::create_dir_all(&d).unwrap(); }
d
/// 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 /// `wanderbooks.toml` with a company name, the receipt threshold, and
@@ -169,9 +168,6 @@ impl Mcp {
.into_iter() .into_iter()
.flatten() .flatten()
}) })
// Files handed over by path come only from here.
.arg("--import-root")
.arg(inbox(dir))
.env("XDG_RUNTIME_DIR", &run) .env("XDG_RUNTIME_DIR", &run)
// Never the user's real vault key (the default key file is // Never the user's real vault key (the default key file is
// under XDG_CONFIG_HOME); the tests name theirs anyway. // under XDG_CONFIG_HOME); the tests name theirs anyway.
@@ -519,11 +515,9 @@ impl Mcp {
} }
self.tool("books.set_w9", args) self.tool("books.set_w9", args)
} }
/// A W-9 file outside the books; it shows the TIN, like the real one. /// A W-9 file (a PDF); it shows the TIN, like the real one.
fn w9_file(&self, name: &str, tin: &str) -> PathBuf { fn w9_file(&self, tin: &str) -> Vec<u8> {
let p = inbox(&self.books_dir()).join(name); format!("%PDF-1.4 Form W-9 TIN {tin}").into_bytes()
std::fs::write(&p, format!("%PDF-1.4 Form W-9 TIN {tin}")).unwrap();
p
} }
/// Pay `vendor` `amount` from the bank in 2026, tagged. /// Pay `vendor` `amount` from the bank in 2026, tagged.
fn pay(&mut self, vendor: &str, amount: &str) { 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 mut m = books("refusals");
let acme = m.party_id("Acme Design"); let acme = m.party_id("Acme Design");
m.add_party("IRS", &["tax_agency"]); m.add_party("IRS", &["tax_agency"]);
let missing = inbox(&m.books_dir()).join("no-such-w9.pdf"); let pdf = m.w9_file(SSN);
let txt = inbox(&m.books_dir()).join("w9.txt"); let txt = b"W-9";
std::fs::write(&txt, "W-9").unwrap(); let big = [b"%PDF-1.4 ".as_slice(), &vec![b' '; 256 * 1024]].concat();
for (party, extra) in [ for (party, extra) in [
("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits ("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits
( (
@@ -653,14 +647,6 @@ fn set_w9_refusals_write_nothing_anywhere() {
"Acme Design", "Acme Design",
json!({"tin": 291122846.0, "tin_type": "ssn"}), json!({"tin": 291122846.0, "tin_type": "ssn"}),
), // a float ), // 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", "Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "clear": true}), json!({"tin": SSN, "tin_type": "ssn", "clear": true}),
@@ -680,6 +666,51 @@ fn set_w9_refusals_write_nothing_anywhere() {
out.err_text() 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!( assert!(
!m.vault().join(format!("tin-{acme}.bin")).exists(), !m.vault().join(format!("tin-{acme}.bin")).exists(),
"no record written" "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() { fn the_w9_file_goes_to_the_vault_never_the_documents() {
let mut m = books("w9-file"); let mut m = books("w9-file");
let acme = m.party_id("Acme Design"); 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( let Out::Ok(p) = m.w9(
"Acme Design", "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 { ) else {
panic!("set_w9 with a file") 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()}), json!({"party": "Acme Design", "save_to": to.to_str().unwrap()}),
); );
assert_eq!(out["path"], json!(to.display().to_string())); assert_eq!(out["path"], json!(to.display().to_string()));
assert_eq!(out["file_name"], "acme-w9.pdf"); // Given inline, the file is named for its type.
assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap()); 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)); assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS));
#[cfg(unix)] #[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. // A W-9 file alone (the TIN typed in later) records the vault too.
m.add_party("Solo", &["contractor"]); 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( let Out::Ok(p) = m.w9(
"Solo", "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 { ) else {
panic!("a W-9 file alone") 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"); assert_eq!(p["w9"]["kept_at"], "wanderbooks vault");
} }
/// A W-9 file is a PDF, PNG or JPEG, by its extension in any case /// A W-9 file is a PDF, PNG or JPEG, judged by its own first bytes: each
/// (.pdf, .png, .jpg, .jpeg): each is taken into the vault and comes back /// is taken into the vault and comes back byte for byte, named for its
/// byte for byte, under its own name. /// type.
#[test] #[test]
fn every_w9_file_type_is_taken() { fn every_w9_file_type_is_taken() {
let mut m = books("w9-types"); let mut m = books("w9-types");
for (n, name) in ["w9.pdf", "w9.png", "w9.jpg", "w9.jpeg", "W9.JPG"] for (n, (bytes, name)) in [
.iter() (b"%PDF-1.4 Form W-9".as_slice(), "w9.pdf"),
.enumerate() (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}"); let party = format!("Payee {n}");
m.add_party(&party, &["contractor"]); m.add_party(&party, &["contractor"]);
let src = m.w9_file(name, "000");
let Out::Ok(p) = m.w9( let Out::Ok(p) = m.w9(
&party, &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 { ) else {
panic!("{name} refused") panic!("{name} refused")
}; };
@@ -841,17 +876,57 @@ fn every_w9_file_type_is_taken() {
json!({"party": party, "save_to": to.to_str().unwrap()}), json!({"party": party, "save_to": to.to_str().unwrap()}),
); );
assert_eq!(out["file_name"], *name); 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] #[test]
fn a_locked_vault_says_so_and_never_guesses() { fn a_locked_vault_says_so_and_never_guesses() {
let mut m = books("locked"); let mut m = books("locked");
let src = m.w9_file("w9.pdf", SSN); let src = m.w9_file(SSN);
m.w9( m.w9(
"Acme Design", "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"); m.pay("Acme Design", "700.00");
let key = std::fs::read(m.key()).unwrap(); 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() { fn purge_deletes_the_vault_records_and_journals_why() {
let mut m = books("purge"); let mut m = books("purge");
let acme = m.party_id("Acme Design"); let acme = m.party_id("Acme Design");
let src = m.w9_file("w9.pdf", SSN); let src = m.w9_file(SSN);
m.w9( m.w9(
"Acme Design", "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. // Clearing while the vault holds it: refused.
let why = m let why = m
@@ -1086,10 +1161,10 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
"no-leak", "no-leak",
"[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n", "[[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( m.w9(
"Acme Design", "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.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
m.ok("books.add_vendor", json!({"name": "Rent Co"})); 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 /// `--import-root` is gone (files come in as tool input, P2 slice 5): a
/// as far as serving), with its standard error. /// server started with it stops at once, as for any unknown argument,
fn refuses_to_start(cmd: &mut Command) -> Option<String> { /// and never serves as if it had read from that folder.
let mut child = cmd #[test]
.stdin(Stdio::piped()) fn the_import_root_flag_is_refused_as_unknown() {
.stdout(Stdio::piped()) let dir = temp("import-root");
.stderr(Stdio::piped()) let out = Command::new(BIN)
.spawn() .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(); .unwrap();
// A server that starts waits on stdin; closing it ends the server. assert_eq!(out.status.code(), Some(2));
drop(child.stdin.take()); let err = String::from_utf8_lossy(&out.stderr);
let out = child.wait_with_output().unwrap(); assert!(err.contains("unknown argument: --import-root"), "{err}");
let err = String::from_utf8_lossy(&out.stderr).into_owned(); assert!(!err.contains("serving"), "{err}");
(!err.contains("serving over stdio")).then_some(err) assert!(!dir.join("books-data").exists(), "opened the books");
}
/// 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}");
} }
+55 -54
View File
@@ -9,8 +9,8 @@
//! SOURCE: rules.md B-26; DECISION (P0 review round 2, 2026-10-16). //! SOURCE: rules.md B-26; DECISION (P0 review round 2, 2026-10-16).
//! INVARIANTS (oracle): //! INVARIANTS (oracle):
//! 1. Every file the server read or wrote with full TINs is refused by //! 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, //! every way a file enters the books (books.attach_receipt and
//! books.attach_receipt, books.file_document), from any session and //! books.file_document, its bytes given inline), from any session and
//! after the server restarts. //! after the server restarts.
//! 2. Text holding a full TIN is refused, whoever made it: one the vault //! 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 //! 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 /// The books dir, a folder for files on their way in, a folder outside
/// settings (a member, so the CPA package has a K-1 worksheet). /// both, and the settings (a member, so the CPA package has a K-1
/// worksheet).
struct Books { struct Books {
dir: PathBuf, dir: PathBuf,
inbox: PathBuf, inbox: PathBuf,
@@ -125,8 +126,6 @@ fn serve(b: &Books) -> (Server, String) {
.arg(b.dir.join("books-data")) .arg(b.dir.join("books-data"))
.arg("--books-toml") .arg("--books-toml")
.arg(&b.toml) .arg(&b.toml)
.arg("--import-root")
.arg(&b.inbox)
.arg("--http") .arg("--http")
.arg(&addr) .arg(&addr)
.env("XDG_CONFIG_HOME", base.join("config")) .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}")) .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; 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`) /// Every way the file at `path` (named `name`) could enter the books is
/// could enter the books is refused with `why`, and nothing is stored: /// refused with `why`, and nothing is stored: the refusals, tool by tool.
/// the refusals, tool by tool.
async fn refused_everywhere( async fn refused_everywhere(
c: &Client, c: &Client,
b: &Books, b: &Books,
@@ -193,23 +195,20 @@ async fn refused_everywhere(
why: &str, why: &str,
) -> Vec<String> { ) -> Vec<String> {
let bytes = std::fs::read(path).unwrap(); let bytes = std::fs::read(path).unwrap();
let copy = b.inbox.join(name); let (content, sha) = inline(&bytes);
std::fs::copy(path, &copy).unwrap();
let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE); let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE);
let before = std::fs::read(&journal).unwrap(); let before = std::fs::read(&journal).unwrap();
let mut refusals = Vec::new(); let mut refusals = Vec::new();
for (tool, args) in [ for (tool, args) in [
(
"books.import_local_file",
json!({"path": copy.to_str().unwrap(), "category": "document", "document_category": "tax"}),
),
( (
"books.attach_receipt", "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", "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); let err = call(c, tool, args).await.expect_err(tool);
@@ -221,7 +220,6 @@ async fn refused_everywhere(
before, before,
"a refusal stored something" "a refusal stored something"
); );
std::fs::remove_file(copy).unwrap();
refusals 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", b"%PDF-1.4\n\xff\xfe\x00 scanned W-9 \x01\x02\x03\n%%EOF\n",
) )
.unwrap(); .unwrap();
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
let (server, url) = serve(&b); let (server, url) = serve(&b);
let one = connect(&url).await; let one = connect(&url).await;
for (code, name, ty) in [ 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, &one,
"books.set_w9", "books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual", 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; .await;
ok( ok(
@@ -281,7 +280,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
.await; .await;
// A bank statement filed in the books: the package carries it, and it // A bank statement filed in the books: the package carries it, and it
// is no TIN file (its routing numbers are not TINs). // 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. // What the server writes with TINs, from the first session.
let copy = b.out.join("w9-copy.pdf"); let copy = b.out.join("w9-copy.pdf");
ok( 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(); std::fs::write(&fresh, format!("name,ssn\nDana,{}\n", "187-05-1907")).unwrap();
refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await; refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await;
// An ordinary file still goes in, every way. // An ordinary file still goes in, every way.
let fine = b.inbox.join("lunch.pdf"); let (fine, fine_sha) = inline(b"%PDF-1.4\n\xff lunch receipt, order 12-34\n");
std::fs::write(&fine, b"%PDF-1.4\n\xff lunch receipt, order 12-34\n").unwrap();
ok( ok(
&two, &two,
"books.import_local_file", "books.attach_receipt",
json!({"path": fine.to_str().unwrap(), "category": "receipt"}), json!({"file_name": "lunch.pdf", "mime_type": "application/pdf",
"content_base64": fine, "sha256": fine_sha}),
) )
.await; .await;
// The books' own files the package carried are not marked: the // 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" "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((one, two));
drop(server); drop(server);
@@ -406,8 +405,9 @@ async fn a_refused_set_w9_never_marks_its_file() {
.await; .await;
} }
let w9_args = |party: &str, path: &Path| { 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", 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 [ for (party, path, why) in [
("Nobody", &w9, "unknown party"), ("Nobody", &w9, "unknown party"),
@@ -424,51 +424,54 @@ async fn a_refused_set_w9_never_marks_its_file() {
("w9-scan.pdf", w9_bytes.clone()), ("w9-scan.pdf", w9_bytes.clone()),
("notes.txt", std::fs::read(&notes).unwrap()), ("notes.txt", std::fs::read(&notes).unwrap()),
] { ] {
let (content, sha) = inline(&bytes);
let out = ok( let out = ok(
&c, &c,
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": name, "mime_type": "application/pdf", json!({"category": "other", "file_name": name, "mime_type": "application/pdf",
"content_base64": b64(&bytes)}), "content_base64": content, "sha256": sha}),
) )
.await; .await;
assert!(out["attachment_id"].is_u64(), "{name}: {out}"); assert!(out["attachment_id"].is_u64(), "{name}: {out}");
} }
// A set_w9 that succeeds marks it. // A set_w9 that succeeds marks it.
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await; ok(&c, "books.set_w9", w9_args("Acme", &w9)).await;
let err = call( let (content, sha) = inline(&w9_bytes);
&c, for (tool, args) in [
"books.file_document", (
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf", "books.file_document",
"content_base64": b64(&w9_bytes)}), json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
) "content_base64": content, "sha256": sha}),
.await ),
.expect_err("marked"); (
assert!(err.contains(TIN_FILE), "{err}"); "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 /// Every way a file enters the books takes the text `name` holding
/// `text`, and the journal shows it. /// `text`, and the journal shows it.
async fn accepted_everywhere(c: &Client, b: &Books, name: &str, text: &str) { async fn accepted_everywhere(c: &Client, name: &str, text: &str) {
let path = b.inbox.join(name); let (content, sha) = inline(text.as_bytes());
std::fs::write(&path, text).unwrap();
for (tool, args) in [ for (tool, args) in [
(
"books.import_local_file",
json!({"path": path.to_str().unwrap(), "category": "document", "document_category": "statement"}),
),
( (
"books.attach_receipt", "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", "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; let out = ok(c, tool, args).await;
assert!(out["attachment_id"].is_u64(), "{tool}: {out}"); 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 /// 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(); std::fs::write(&path, format!("payee,tin\n{printed}\n")).unwrap();
refused_everywhere(&c, &b, &path, &format!("payees-{n}.csv"), TIN_PRINTED).await; 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(c);
drop(server); 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; refused_everywhere(&c, &b, &path, "members-1.csv", TIN_PRINTED).await;
accepted_everywhere( accepted_everywhere(
&c, &c,
&b,
"bank-2.csv", "bank-2.csv",
&format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"), &format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"),
) )
.await; .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 /// 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; let c = connect(&url).await;
accepted_everywhere( accepted_everywhere(
&c, &c,
&b,
"plan.md", "plan.md",
"# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n", "# 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; let open = probes(&c).await;
refused_everywhere(&c, &b, &tin, "tin.csv", TIN_HELD).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, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await; accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
drop(c); drop(c);
drop(server); drop(server);
+4
View File
@@ -168,6 +168,10 @@ impl From<ChainError> for AuthError {
} }
} }
/// A token's name when none is given (`wanderbooks token create`, the
/// bridge, `upload`, `download`).
pub const DEFAULT_NAME: &str = "agent";
/// Check a token's name: 1 to 32 lowercase letters, digits and hyphens, /// Check a token's name: 1 to 32 lowercase letters, digits and hyphens,
/// starting with a letter or digit. /// starting with a letter or digit.
pub fn check_name(name: &str) -> Result<(), AuthError> { pub fn check_name(name: &str) -> Result<(), AuthError> {
+1
View File
@@ -32,6 +32,7 @@ pub mod root;
pub mod schema; pub mod schema;
pub mod snapshot; pub mod snapshot;
pub mod store; pub mod store;
pub mod uploads;
pub mod viewer; pub mod viewer;
pub use blob::BlobStore; pub use blob::BlobStore;
File diff suppressed because it is too large. Load diff
+190 -111
View File
@@ -191,6 +191,57 @@ settings-and-conventions section says how to use them.
binding a LAN, tailnet or public interface is an explicit human binding a LAN, tailnet or public interface is an explicit human
decision, never a default. Auth, scopes and Origin validation arrive decision, never a default. Auth, scopes and Origin validation arrive
with the served books (`plan/served.md`, P2). with the served books (`plan/served.md`, P2).
- **Served books** (`wanderbooks server`, started by the host; an agent
connects with `wanderbooks mcp --book ID`, a stdio bridge): every book
in the data root, on a socket in the root's private `run/` folder that
only this account can connect to. Every request carries a bearer token
live for that book (refused 401 without one, 403 with another book's).
Refusals at the door, tool calls, uploads and downloads are recorded
in the book's access log; when a record can't be written the book
stops taking requests (an upload or download is then refused, 503).
- `/b/{book}/mcp`: the book's MCP service. A request is at most 1 MiB.
- `PUT /b/{book}/u/{upload}`: an upload's bytes (`books.begin_upload`),
from the token that began it; what `wanderbooks upload --book B
[--token NAME] UPLOAD FILE` sends. Streamed to the disk, kept only if
exactly the size and SHA-256 declared (so at most 25 MiB), once. The
receive and its access record are one blocking task: bytes are never
kept without their record (if it can't be written they are thrown
away, 503 `not-recorded`, and the books stop). 200 with `{ received,
size, sha256 }`; else, with nothing kept and the code in
`x-wanderbooks-refused`: 404 `no_such_upload` (unknown or lapsed,
also if it lapsed while being sent), 403 `not_yours`, 409
`already_sent` (its bytes came, or a call holds them), 409 `in_use`
(another request is sending them), 400 `not_as_declared` (short, more than declared, or
the wrong hash), 503 `stopped` (the books stopped after an internal
error), 500 `failed`. A refused request's body is read and dropped
(up to 25 MiB), so the client gets the answer, not a cut connection.
Logged as action `upload` with the upload's id, a refusal at the
door (no token, another book's) too. An upload's id is `up_` and 32
letters `a`-`p`: no digits, so the access log's TIN masking never
hides it.
- `GET /b/{book}/a/{attachment}`: a stored file's bytes, for a token
with `books:read` (403 `insufficient_scope` without it; 404
`no_such_attachment`); the read and its access record are one
blocking task. Sent with `Content-Type` (the stored MIME type, or
`application/octet-stream` if it can't be a header),
`Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`
(the plain name: anything but visible ASCII and spaces, and `"` and
`\`, made `_`; then the whole name, RFC 8187 percent-encoded),
`X-Content-Type-Options: nosniff`, and `Repr-Digest:
sha-256=:<base64>:` (RFC 9530). A stored file that can't be read
whole is 500 `failed`; books that stopped, 503 `stopped`. GET only
(a HEAD is 405, unrecorded). Logged as
action `download` with the attachment's id, a refusal at the door
too.
`wanderbooks download --book B [--token NAME] ATTACHMENT --to DIR`
asks for it, checks the digest before saving, and saves it under the
server's name (`filename*` first) made plain: separators and control
characters made `_`, invisible (Unicode format) characters and
leading dots and spaces dropped, at most 200 bytes with the extension
kept. Beside a file of that name it is `name (1).ext`, and so on;
`attachment-N` if the folder takes no such name. It is written to a
hidden file and hard-linked in: never a partial file under the real
name, never over another file.
## Server instructions ## Server instructions
@@ -256,7 +307,7 @@ No text the agent is given (tool JSON with its schemas, resources, connect
text, handbook, and every refusal fixed in code: each error variant's text, handbook, and every refusal fixed in code: each error variant's
template in the crates under the server, scanned from source, built error template in the crates under the server, scanned from source, built error
texts, and the server's refusal constants) names a helper command, a home texts, and the server's refusal constants) names a helper command, a home
path, the data dir or its flag, the import-root flag, a file of the books path, the data dir or its flag, the old import-root flag, a file of the books
(`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull (`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull
request, a repo doc (`docs/`, `.md`), or tells the agent to read the request, a repo doc (`docs/`, `.md`), or tells the agent to read the
books' files; no tool title or description, connect text or handbook books' files; no tool title or description, connect text or handbook
@@ -614,14 +665,17 @@ texts are at most 500 characters, no control characters.
`party.role_set`. Removing the last 1099 role with W-9 facts on file, or `party.role_set`. Removing the last 1099 role with W-9 facts on file, or
employee with W-4 facts, is refused: clear the facts first. employee with W-4 facts, is refused: clear the facts first.
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?, - `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_file_path? }` or fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_upload_id?,
`{ party, clear: true }` → the party. Journaled as `party.w9_set`. A w9_content_base64?, w9_sha256?, w9_file_name? }` or `{ party, clear:
party with a vendor record also gets the record's `tin_on_file` set to true }` → the party. Journaled as `party.w9_set`. A party with a
match (journaled as `vendor.1099_set`), so `books.vendor_1099`'s vendor record also gets the record's `tin_on_file` set to match
`missing_w9` follows. `tin`, `tin_type`, and `w9_file_path` put the full (journaled as `vendor.1099_set`), so `books.vendor_1099`'s
TIN and the signed form in the TIN vault (below); with them `tin_last4` `missing_w9` follows.
comes from the TIN (a given one must agree) and `kept_at` defaults to `tin` and `tin_type` put the full TIN in the TIN vault, and the signed
`"wanderbooks vault"`. form (`w9_upload_id`, or `w9_content_base64` with `w9_sha256`, as in
"Tools — receipts & documents") goes there too (below); with them
`tin_last4` comes from the TIN (a given one must agree) and `kept_at`
defaults to `"wanderbooks vault"`.
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear: - `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
true }` → the party. Journaled as `party.w4_set`. true }` → the party. Journaled as `party.w4_set`.
- `books.set_entry_party { entry_id, party?, parties?, no_party_reason? }` - `books.set_entry_party { entry_id, party?, parties?, no_party_reason? }`
@@ -749,56 +803,58 @@ and the CPA package carries them.
facts name another last four is refused (a payee's new TIN comes with facts name another last four is refused (a payee's new TIN comes with
its W-9: `books.set_w9`); one whose W-9 facts agree gets their its W-9: `books.set_w9`); one whose W-9 facts agree gets their
`vault_record` updated too (`party.w9_set`). `vault_record` updated too (`party.w9_set`).
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates - `books.set_w9 { ..., tin, tin_type, w9_upload_id? | w9_content_base64? +
the TIN against its type (nine digits, hyphens allowed; an ITIN starts w9_sha256?, w9_file_name? }` (above): validates the TIN against its type (nine digits,
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start hyphens allowed; an ITIN starts with 9 and has an IRS group in digits
with 9), checks the facts on a copy of the ledger, then writes the vault 4-5; an SSN or EIN doesn't start with 9), checks the facts on a copy of
and journals the facts with `vault_record`, the SHA-256 of the TIN the ledger, then writes the vault and journals the facts with
record (of the form's record when only a form is given). With a TIN it `vault_record`, the SHA-256 of the TIN record (of the form's record when
also journals the party's TIN facts (`party.tin_set`, as only a form is given). With a TIN it also journals the party's TIN facts
`books.set_tin`); a TIN with a new last four clears the old TIN facts (`party.tin_set`, as `books.set_tin`); a TIN with a new last four clears
first (`party.tin_set` null), so the two never disagree. The form (PDF, the old TIN facts first (`party.tin_set` null), so the two never
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment disagree. The form (a PDF, PNG or JPEG, judged by its own first bytes,
store. `w9_file_path` is read exactly as `books.import_local_file` reads whatever it is called; an upload of purpose `w9`, at most 25 MiB, or
a path (the import roots, the sealed places, no links followed out of inline at most 256 KiB) goes to the vault only, never the attachment
them), and its type is judged by the file the path really leads to. store. Its file name is `w9_file_name` if given, else the upload's,
Facts given later without a TIN keep the vault record when their else `w9.pdf`, `w9.png` or `w9.jpg` by its type, checked as any filed
last four agree with the vault's TIN, and are refused when they don't name; `w9_file_name` without a W-9 is refused. Facts given later without a TIN
(pass the new TIN, or purge). Clearing the facts (`clear: true`) is keep the vault record when their last four agree with the vault's TIN,
refused while the vault holds the party's TIN or form: purge first. and are refused when they don't (pass the new TIN, or purge). Clearing
the facts (`clear: true`) is refused while the vault holds the party's
TIN or form: purge first.
- `books.get_w9 { party, save_to }` → `{ party_id, path, file_name, - `books.get_w9 { party, save_to }` → `{ party_id, path, file_name,
size_bytes, note }`. Writes the party's form from the vault to `save_to`: size_bytes, note }`. Writes the party's form from the vault to
a new file (an existing one is refused), in a folder that exists, `save_to`: a new file (an existing one is refused), in a folder that
outside the books dir and outside the import roots, mode 0600. Returns exists, outside the books dir (where it really leads, every link
the path, never the contents. Journals nothing. The same holds for followed), mode 0600. Returns the path, never the contents. Journals
`books.vendor_1099`'s `save_csv_to` with `include_tins` and nothing. The same holds for `books.vendor_1099`'s `save_csv_to` with
`books.export_cpa_package`'s `save_to` with TINs: a file with full TINs `include_tins` and `books.export_cpa_package`'s `save_to` with TINs: a
never lands where it could be filed back in. Nor does one go in from file with full TINs never lands inside the books. Nor does one go in
elsewhere: the server keeps, beside the vault, the SHA-256 of every file from elsewhere: the server keeps, beside the vault, the SHA-256 of every
it read or wrote with full TINs (the W-9 `books.set_w9` reads, recorded file it read or wrote with full TINs (the W-9 `books.set_w9` reads,
once the call has passed every check, just before the vault keeps it, recorded once the call has passed every check, just before the vault
so a refused call marks no file; each of these outputs; each file inside a CPA package keeps it, so a refused call marks no file; each of these outputs; each
that holds a TIN), shared by every session and kept across restarts, file inside a CPA package that holds a TIN), shared by every session and
and every filing tool (`books.import_local_file`, `books.attach_receipt`, kept across restarts, and every filing tool (`books.attach_receipt`,
`books.file_document`) refuses those exact files and any text file `books.file_document`) refuses those exact files and any text file (CSV,
(CSV, TSV, text, JSON, IIF, XML, HTML, Markdown, by type, name or UTF-8 TSV, text, JSON, IIF, XML, HTML, Markdown, by type, name or UTF-8 or
or UTF-16 decoding, UTF-16 with a byte-order mark or NUL-interleaved UTF-16 decoding, UTF-16 with a byte-order mark or NUL-interleaved
without one) holding a full TIN, storing nothing. A text file without one) holding a full TIN, storing nothing. A text file holds one
holds one when it has a TIN the vault holds, anywhere, as nine digits, when it has a TIN the vault holds, anywhere, as nine digits, 3-2-4
3-2-4 joined by hyphens, spaces or dots, or 2-7 joined by a hyphen or a joined by hyphens, spaces or dots, or 2-7 joined by a hyphen or a space
space (compared on the server; a vault that can't be opened leaves the (compared on the server; a vault that can't be opened leaves the next
next test alone), or a stand-alone number printed as an SSN or EIN is test alone), or a stand-alone number printed as an SSN or EIN is
(123-45-6789, 12-3456789; no digit, and no hyphen or dot followed by a (123-45-6789, 12-3456789; no digit, and no hyphen or dot followed by a
digit, beside it). Bank exports pass: routing and account numbers, digit, beside it). Bank exports pass: routing and account numbers, long
long references and dates next to amounts are not TINs (rules.md B-26). references and dates next to amounts are not TINs (rules.md B-26). No
No probing: a text with more than two TIN-shaped numbers ending in the probing: a text with more than two TIN-shaped numbers ending in the last
last four of a TIN the books know is refused before the vault is four of a TIN the books know is refused before the vault is opened, in
opened, in the same words whether or not one is that TIN, so the vault the same words whether or not one is that TIN, so the vault is compared
is compared against at most two candidates a filing (rules.md B-26). against at most two candidates a filing (rules.md B-26). An altered copy
An altered copy of a binary file (a W-9 PDF re-saved or re-scanned), of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault
and a TIN the vault doesn't hold written another way, are not doesn't hold written another way, are not detected: P2 slice 6 moves the
detected: P2 and P7 take the path tools and full TINs out of the TIN outputs (`save_to`, `save_csv_to`) to the viewer, and P7 takes full
agent's reach. TINs out of the agent's reach.
- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" | - `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" |
"purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?, "purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?,
journaled, note }`. Without `confirmed`, a preview that changes nothing. journaled, note }`. Without `confirmed`, a preview that changes nothing.
@@ -841,10 +897,56 @@ the receipt policy counts it.
## Tools — receipts & documents ## Tools — receipts & documents
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64, A file comes in one of two ways, named in the tool that files it
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links (`books.attach_receipt`, `books.file_document`, `books.set_w9`,
}`. `entry_id` is optional: a receipt can be filed before its entry `books.import_bank_csv`, `books.import_chart_csv`): `upload_id`, an
exists. `link_to` as in "Tools — attachments". upload made with `books.begin_upload` (any file up to 25 MiB; its bytes
never pass through the agent), or `content_base64` with its `sha256`
(at most 256 KiB decoded; longer text is refused before it is decoded).
Never both, never neither. Inline bytes that aren't base64, or whose
SHA-256 isn't the one given, are refused (guard `base64_corrupt`): send
the file as an upload. Uploads exist only for books served from the
data root (the server's socket carries the bytes); over stdio, only
inline, and the refusal says so and to tell the human. On the socket an
MCP request is at most 1 MiB. A file's name and type are checked when
it is filed (every filing tool, `books.begin_upload`, and the W-9's
name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`,
and no control or invisible (Unicode format) characters; a MIME type
`type/subtype` (parameters allowed) of visible ASCII, at most 127 bytes.
- `books.begin_upload { sha256, size, mime_type, file_name, purpose:
"receipt" | "document" | "import" | "w9" }` → `{ upload_id?, command?,
expires_at?, max_bytes, existing_attachment_id? }`. Makes a grant for
the calling token: one SHA-256, one size (at most 25 MiB; empty
refused), one purpose, lasting 15 minutes; at most 16 under way per
token. A tool takes only an upload of its own purpose:
`books.attach_receipt` and `books.file_document` take `receipt` or
`document`, the CSV imports `import`, `books.set_w9` `w9`; another
tool refuses it and leaves it for the right one. `command` is
`wanderbooks upload --book B <upload_id> -- <the file's path>` (with
`--token NAME` when the caller's token isn't the default `agent`),
which sends the bytes (`PUT /b/{book}/u/{upload}`, "Transports"); the
agent runs it, then names `upload_id` in the tool that files it.
Bytes that aren't the size and SHA-256 declared are refused and
nothing is kept. An upload's bytes are sent once, only by the token
that began it, and used once: the call that takes them holds them
(hashed again) and spends them only when they are in the books (an
import: once its rehearsal passed); a call refused after taking them,
or one that panics, gives them back, and a server stopped mid-call
gives them back when the books next open. Lapsed grants are refused,
and swept when the books open, whenever a grant is made, and every
10 seconds. For `receipt` or `document`, when an attachment not withdrawn
already holds those bytes, no grant is made: `existing_attachment_id`
names it (link it, `books.link_attachment`, instead of filing a copy);
for `import` and `w9` a grant is always made. Refusals never name a
path.
- `books.attach_receipt { entry_id?, file_name?, mime_type?, upload_id? |
content_base64? + sha256?, description?, link_to? }` → `{ attachment_id,
sha256, deduplicated, links }`. `entry_id` is optional: a receipt can be
filed before its entry exists. `file_name` and `mime_type` default to
the upload's; inline they are required. The upload is taken only after
the entry and every `link_to` subject check out. `link_to` as in "Tools
— attachments".
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed - `books.link_receipt { receipt_id, entry_id }` → link a previously filed
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere (unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
must be unlinked first; one already linked to that entry must be unlinked first; one already linked to that entry
@@ -865,15 +967,23 @@ the receipt policy counts it.
subject it is linked to, its filing entry first). Withdrawn attachments subject it is linked to, its filing entry first). Withdrawn attachments
are omitted unless `include_withdrawn` is true. `entry_id` lists the are omitted unless `include_withdrawn` is true. `entry_id` lists the
files filed on the entry and linked to it. files filed on the entry and linked to it.
- `books.get_receipt { attachment_id }` → `{ metadata, description?, - `books.get_receipt { attachment_id, view?: "meta" | "bytes" }` → `{
links, content_base64 }`. metadata, description?, links, content_base64?, download? }`. `meta`
(the default) returns no bytes. `bytes` adds `content_base64`
(hash-verified) for a file of at most 256 KiB; a larger one gives
`download`, the command `wanderbooks download --book B <id> --to <a
folder>` (with `--token NAME` when the caller's token isn't the
default `agent`; `GET /b/{book}/a/{attachment}`, "Transports"), which
saves a copy as a new file in that folder. Over stdio there is no
download: a larger file is refused.
- `books.file_document { category: "formation" | "legal" | "tax" | - `books.file_document { category: "formation" | "legal" | "tax" |
"accounting" | "statement" | "contract" | "photo" | "warranty" | "accounting" | "statement" | "contract" | "photo" | "warranty" |
"import_source" | "other", file_name, mime_type, content_base64, "import_source" | "other", file_name?, mime_type?, upload_id? |
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links content_base64? + sha256?, description?, link_to? }` → `{ attachment_id,
}`. The Drive-folder replacement (today's 01 Formation / 02 Legal / 03 sha256, deduplicated, links }`. The bytes, name and type as in
Tax / 04 Accounting). Never a W-9: it shows the full TIN (`books.set_w9 `books.attach_receipt`. The Drive-folder replacement (today's 01
w9_file_path`). Formation / 02 Legal / 03 Tax / 04 Accounting). Never a W-9: it shows
the full TIN (`books.set_w9 w9_upload_id`).
## Receipt enforcement ## Receipt enforcement
@@ -977,51 +1087,19 @@ posting.
## Tools — import ## Tools — import
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code, - `books.import_bank_csv { csv_text? | upload_id?, bank_account_code,
rules?, date_order?, source_attachment_id? }` → creates one draft per default_offset_code, rules?, date_order?, source_attachment_id? }` →
transaction (QBO upload layout). `rules` maps description substrings to creates one draft per transaction (QBO upload layout). `rules` maps
account codes. `source_attachment_id` (the export as filed, category description substrings to account codes. `source_attachment_id` (the export as filed, category
`import_source`) is checked before any draft and linked to the bank `import_source`) is checked before any draft and linked to the bank
account (`source_linked`; false when it was already), never to the account (`source_linked`; false when it was already), never to the
drafts: a bank export is not a receipt. drafts: a bank export is not a receipt.
- `books.import_chart_csv { csv_text }` → creates accounts from - `books.import_chart_csv { csv_text? | upload_id? }` → creates accounts
`code,name,type,parent_code` CSV. All-or-nothing. from `code,name,type,parent_code` CSV. All-or-nothing.
- `books.import_local_file { path, category: "receipt" | "document", ... }` - Each takes the CSV as `csv_text` or as an upload (`books.begin_upload`
→ files a file from the server's own disk, read only from the **import purpose `import`; UTF-8), never both. The CSV itself is not filed: file
roots**: each `--import-root DIR` given to `ledger-mcp` (repeatable), or it with `books.file_document` (category `import_source`) for
by default the downloads folder only (`$XDG_DOWNLOAD_DIR`, else `source_attachment_id`.
`~/Downloads`). No root may be `/`, the home folder or a folder holding
it, a folder holding the books dir, the data dir (where a symlinked one
really is), the vault or a vault key, or a folder inside the books dir,
the data dir or the vault (every file there would be refused): a named
root like that stops
the server at startup, saying why; a default one is dropped. The default
is used only when it is a real folder that isn't the home folder (an
`XDG_DOWNLOAD_DIR` of `$HOME/` falls back to `~/Downloads`, and to no
root when that doesn't exist; an import then says the server has no
import folder). The path must be absolute, with no `.` or `..` name,
and where it leads (every link followed) a regular file strictly inside
a root, with one hard link. Refusals never name a path and never probe:
a path that is missing, a dangling link, outside the roots, or with a
`.` or `..` name (refused before anything is looked up: `a/b/..`
resolves only when `b` is a folder) gets one refusal, "no
such file in the server's import folders (by default your downloads
folder): save it there first". Inside a root, the books' sealed places
(the data dir, the vault, the key file in force and the default one,
where each leads, `~/.config/wanderbooks`, and the key files' folders
unless that folder is the home folder, a root, or holds one) are refused
as "belongs to the books or the TIN vault"; anything else in the books
dir is refused with "save a copy of it in the server's import folders
first". The file is opened by a walk from `/`, one name at a time, each
folder with `openat(O_DIRECTORY | O_NOFOLLOW)` and the file with
`O_NOFOLLOW | O_NONBLOCK`, so a folder on the path swapped for a link
after the check fails the open and a pipe can't hang the server; no step
may land on a protected place by device and inode (one renamed into a
root), and the file opened must be the checked one (device, inode, one
link). It is filed under the name and type of the file the path leads
to. Files holding full TINs are refused as above
(`ledger-mcp/src/local_files.rs`, `tin_files.rs`). Deleted in P2
(uploads replace it).
## Resources (read-only) ## Resources (read-only)
@@ -1036,7 +1114,8 @@ Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
this month so far this month so far
- `books://handbook` (the index) and `books://handbook/{section}` (one per - `books://handbook` (the index) and `books://handbook/{section}` (one per
section, Markdown) section, Markdown)
- `books://receipts/{id}` — receipt metadata + base64 bytes - `books://receipts/{id}` — receipt metadata (as `books.get_receipt`
view `meta`; no bytes)
Resources exist so read-only clients (dashboards, the future read-only GUI, Resources exist so read-only clients (dashboards, the future read-only GUI,
auditors) never need tool credentials. Tested end-to-end over stdio. auditors) never need tool credentials. Tested end-to-end over stdio.
+3 -2
View File
@@ -57,8 +57,9 @@ version refuses books from a newer one as newer, not corrupt (rules.md
B-27); the MCP server fails closed after an internal error, and no input B-27); the MCP server fails closed after an internal error, and no input
reaches one (dates are four-digit years, amounts at most a line's, B-28); reaches one (dates are four-digit years, amounts at most a line's, B-28);
local files local files
are read only from the import roots, never the books, the vault or its were read only from the import roots, never the books, the vault or its
key; no file with a full TIN is filed into the books (B-26); the agent is key (P2 slice 5 removed local paths: files now come in as uploads or
inline bytes); no file with a full TIN is filed into the books (B-26); the agent is
told the rules in force in a short connect text and a handbook, and no told the rules in force in a short connect text and a handbook, and no
agent text points at the books' files, git or the repo. agent text points at the books' files, git or the repo.
+3 -2
View File
@@ -275,8 +275,9 @@ damn CPA package."
open and verify; the 1099 worksheet and the CPA package then say which open and verify; the 1099 worksheet and the CPA package then say which
TINs are missing (`tin_gaps`), and the package is still built. TINs are missing (`tin_gaps`), and the package is still built.
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"): - **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
`books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form `books.set_w9` takes `tin` + `tin_type` and the signed form as
goes to the vault, never the attachment store) and journals the last `w9_upload_id` (`books.begin_upload`) or `w9_content_base64` with
`w9_sha256` (the form goes to the vault, never the attachment store) and journals the last
four with `vault_record`; facts that disagree with the vault's TIN are four with `vault_record`; facts that disagree with the vault's TIN are
refused, and clearing facts with a vault record needs a purge first. refused, and clearing facts with a vault record needs a purge first.
With a TIN it also records the party's TIN facts. `books.set_tin With a TIN it also records the party's TIN facts. `books.set_tin
+7 -2
View File
@@ -89,8 +89,13 @@ before backups.
- **Deduplicated.** Attaching the same receipt twice stores one blob and two - **Deduplicated.** Attaching the same receipt twice stores one blob and two
attachment rows. `attach_receipt` / `file_document` return attachment rows. `attach_receipt` / `file_document` return
`deduplicated: true` when the blob already existed. `deduplicated: true` when the blob already existed.
- **Limits.** 25 MB per file by default, configurable. MIME type is accepted - **Limits.** 25 MiB per file as an upload (`books.begin_upload`), 256
from the client and stored; server-side sniffing is future work. KiB given inline. The name and MIME type are the client's, checked
when the file is filed: a name of 1 to 255 bytes, no `/` or `\`, not
`.` or `..`, no control or invisible characters; a `type/subtype` of
visible ASCII, at most 127 bytes (`docs/mcp-interface.md`, "Tools —
receipts & documents"). The type is stored as given; server-side
sniffing is future work (only a W-9 is judged by its first bytes).
- **Metadata** lives in SQLite (`blobs` table), not in the filesystem: - **Metadata** lives in SQLite (`blobs` table), not in the filesystem:
`sha256 PK, size_bytes, mime_type, original_name, created_at`. `sha256 PK, size_bytes, mime_type, original_name, created_at`.
+7 -6
View File
@@ -583,10 +583,11 @@ asked): a refusal that quotes its input masks any TIN in it
and a party text field refuses nine digits however they are separated and a party text field refuses nine digits however they are separated
(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA (space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA
package, whose K-1 and 1099 worksheets carry every member's and payee's package, whose K-1 and 1099 worksheets carry every member's and payee's
TIN by default) are written only outside the books dir and outside the TIN by default) are written only outside the books dir (DECISION, P0
server's import folders (DECISION, P0 review, 2026-10-16: so none can be review, 2026-10-16: so none can be filed back in as a receipt and read
filed back in as a receipt and read out again), and the package comes out again; the server's import folders, refused then too, went with
back only as its path. No file holding a full TIN is filed into the books `books.import_local_file` in P2 slice 5), and the package comes back
only as its path. No file holding a full TIN is filed into the books
(DECISION, P0 review round 2, 2026-10-16): the server keeps, beside the (DECISION, P0 review round 2, 2026-10-16): the server keeps, beside the
vault, the SHA-256 of every file it read or wrote with full TINs (the W-9 vault, the SHA-256 of every file it read or wrote with full TINs (the W-9
`books.set_w9` reads, recorded once the call has passed every check, just `books.set_w9` reads, recorded once the call has passed every check, just
@@ -594,8 +595,8 @@ before the vault keeps it, so a refused call marks no file; each
`books.get_w9` copy; `books.get_w9` copy;
a 1099 worksheet with TINs; a CPA package with TINs and each file in it a 1099 worksheet with TINs; a CPA package with TINs and each file in it
that holds one), shared by every session and every later server for the that holds one), shared by every session and every later server for the
books, and every way a file enters the books (`books.import_local_file`, books, and every way a file enters the books (`books.attach_receipt`,
`books.attach_receipt`, `books.file_document`) refuses those exact files, `books.file_document`, an upload or inline) refuses those exact files,
and refuses any text file (by its type or name, or because it decodes as and refuses any text file (by its type or name, or because it decodes as
UTF-8 or UTF-16, with a byte-order mark or, NUL-interleaved, without one) UTF-8 or UTF-16, with a byte-order mark or, NUL-interleaved, without one)
holding (a) a TIN the vault holds for a party of the holding (a) a TIN the vault holds for a party of the
+17 -19
View File
@@ -65,13 +65,10 @@ that would fail if it broke:
| No input reaches a panic (each would stop the server for every session): every tool with arguments generated from its schema and made hostile (wrong types, u64::MAX, decimals of 30+ digits or absurd exponents, dates like 0000-00-00, 99999-12-31, +262142-12-31, 2026-02-30, periods like 999999-01, control and format characters, long strings), every resource URI generated; the inputs that once panicked, pinned; after each property the server restarts and the books reopen (a call a command accepts but replay refuses would brick them); every tool and resource over every old book (rules.md B-28) | `ledger-mcp` `tests/hostile_args.rs` `hostile_tool_arguments_never_stop_the_server` (with a floor: most calls reach a tool, some succeed), `hostile_resource_uris_never_stop_the_server`, `inputs_that_once_panicked_are_refused_or_answered`, `old_books_answer_every_tool`; `MCP_CASES` and `OLD_BOOKS_CASES` (default 3 per tool per book) set the cases | | No input reaches a panic (each would stop the server for every session): every tool with arguments generated from its schema and made hostile (wrong types, u64::MAX, decimals of 30+ digits or absurd exponents, dates like 0000-00-00, 99999-12-31, +262142-12-31, 2026-02-30, periods like 999999-01, control and format characters, long strings), every resource URI generated; the inputs that once panicked, pinned; after each property the server restarts and the books reopen (a call a command accepts but replay refuses would brick them); every tool and resource over every old book (rules.md B-28) | `ledger-mcp` `tests/hostile_args.rs` `hostile_tool_arguments_never_stop_the_server` (with a floor: most calls reach a tool, some succeed), `hostile_resource_uris_never_stop_the_server`, `inputs_that_once_panicked_are_refused_or_answered`, `old_books_answer_every_tool`; `MCP_CASES` and `OLD_BOOKS_CASES` (default 3 per tool per book) set the cases |
| Dates are in 0001-01-01 through 9999-12-31 and amounts at most a line's, for every new command; replay accepts every line an earlier version accepted (each bound has its pair: the command refuses past it, the replayed old line is accepted); a figure derived past exact arithmetic or chrono's calendar is refused, never a panic; a recurring run makes at most 1,000 drafts (rules.md B-28) | `ledger-core` `tests/spec_bounds.rs`, `calendar.rs` tests, `plans_at_the_edges_are_computed_or_refused`, `intervals_reach_chronos_last_day`, `a_vendor_part_is_scaled_exactly_or_refused`, the `share_property` cases; `ledger-store` `tests/bounds.rs`, `tests/store.rs` (the old-books tests); `server.rs` `periods_are_a_year_and_a_month`, `dates_are_four_digit_years_and_real_days` | | Dates are in 0001-01-01 through 9999-12-31 and amounts at most a line's, for every new command; replay accepts every line an earlier version accepted (each bound has its pair: the command refuses past it, the replayed old line is accepted); a figure derived past exact arithmetic or chrono's calendar is refused, never a panic; a recurring run makes at most 1,000 drafts (rules.md B-28) | `ledger-core` `tests/spec_bounds.rs`, `calendar.rs` tests, `plans_at_the_edges_are_computed_or_refused`, `intervals_reach_chronos_last_day`, `a_vendor_part_is_scaled_exactly_or_refused`, the `share_property` cases; `ledger-store` `tests/bounds.rs`, `tests/store.rs` (the old-books tests); `server.rs` `periods_are_a_year_and_a_month`, `dates_are_four_digit_years_and_real_days` |
| Opening the books changes nothing until every complete line passes in memory (chain, gate, first line, replay); the viewer applies the writer's first-line rule (rules.md B-27) | `format.rs` `a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was`, `the_first_line_is_store_created_for_the_viewer_and_the_writer`; `fuzz_store_replay` (the first line varies) | | Opening the books changes nothing until every complete line passes in memory (chain, gate, first line, replay); the viewer applies the writer's first-line rule (rules.md B-27) | `format.rs` `a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was`, `the_first_line_is_store_created_for_the_viewer_and_the_writer`; `fuzz_store_replay` (the first line varies) |
| A path with a `.` or `..` name is refused as missing before anything is looked up; a root inside the books, the data dir or the vault is refused at startup (dropped as the default) | `local_files.rs` `a_dot_or_dotdot_name_never_tells_whether_folders_outside_exist`, `a_root_holding_the_books_or_the_home_folder_is_refused`; `startup.rs` `an_import_root_holding_the_books_or_the_home_folder_is_refused`, `a_default_download_dir_holding_the_books_is_dropped_not_fatal` |
| UTF-16 text without a byte-order mark is scanned like any text; a refused `books.set_w9` marks no file; a refused resource read never echoes a TIN | `tin_files.rs` (tests) `utf16_text_without_a_byte_order_mark_is_scanned_either_way_round`, `a_refused_set_w9_never_marks_its_file`, unit test `only_nul_interleaved_bytes_read_as_utf16_without_a_mark`; `spec_vault.rs` `resource_refusals_never_echo_a_tin` | | UTF-16 text without a byte-order mark is scanned like any text; a refused `books.set_w9` marks no file; a refused resource read never echoes a TIN | `tin_files.rs` (tests) `utf16_text_without_a_byte_order_mark_is_scanned_either_way_round`, `a_refused_set_w9_never_marks_its_file`, unit test `only_nul_interleaved_bytes_read_as_utf16_without_a_mark`; `spec_vault.rs` `resource_refusals_never_echo_a_tin` |
| idempotentHint only where a repeat with the same arguments leaves the journal and the vault as they were; every tool's hints pinned in one table | `spec_vault.rs` `idempotent_tools_change_nothing_when_repeated`; `annotations_complete` | | idempotentHint only where a repeat with the same arguments leaves the journal and the vault as they were; every tool's hints pinned in one table | `spec_vault.rs` `idempotent_tools_change_nothing_when_repeated`; `annotations_complete` |
| books.settings lists every receipt-required and exempt account, uncut; a fresh company is "a new company"; the lint catches `just` however it ends and where the books are | `mcp_stdio.rs` `every_receipt_account_left_out_of_the_connect_text_is_in_books_settings`; `handbook.rs` `a_fresh_company_is_a_new_company`; `tool_text_lint_catches_helpers_and_locations` | | books.settings lists every receipt-required and exempt account, uncut; a fresh company is "a new company"; the lint catches `just` however it ends and where the books are | `mcp_stdio.rs` `every_receipt_account_left_out_of_the_connect_text_is_in_books_settings`; `handbook.rs` `a_fresh_company_is_a_new_company`; `tool_text_lint_catches_helpers_and_locations` |
| A local file is read only from inside an import root, never a sealed place (data dir, vault, keys, the key folders that don't hold a root), never through a swap after the check (a component walk with `O_NOFOLLOW`, no step onto a protected inode); a missing, dangling or outside path gets one refusal | `local_files.rs` `import_local_file_refuses_key_and_journal`, the property `import_allowed_iff_regular_file_inside_a_root_outside_the_books`, `refusals_never_tell_what_is_there`, `a_file_swapped_after_the_check_is_never_read`, `a_folder_swapped_after_the_check_is_never_walked_through` (the reviewer's race, without `/proc`), `resolved_follows_every_link_a_dangling_one_too`, `sealing_a_key_never_seals_a_whole_root`; through the tools, `mcp_stdio.rs` `import_local_file_and_set_w9_refuse_the_key_vault_and_journal_inside_a_root` | | A file with full TINs is never written inside the books, and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_stdio.rs` `tin_bearing_files_never_land_where_they_could_be_filed_back_in`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` |
| No import root is `/`, the home folder or holds it, or holds the books, data dir, vault or a key; the default is a real downloads folder or none | `local_files.rs` `the_default_root_is_never_the_home_folder_or_the_filesystem`, `a_root_holding_the_books_or_the_home_folder_is_refused`; `startup.rs` `an_import_root_holding_the_books_or_the_home_folder_is_refused`, `a_download_dir_of_home_never_opens_the_home_folder` |
| A file with full TINs is never written into an import root, and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_stdio.rs` `tin_bearing_files_never_land_where_they_could_be_filed_back_in`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` |
| Text filed into the books is refused when it holds a TIN the vault keeps (nine digits, 3-2-4 by hyphen, space or dot, 2-7 by hyphen or space, anywhere) or a stand-alone printed SSN or EIN (123-45-6789, 12-3456789), and only then: a bank export (routing numbers, long references, a date next to an amount) and a longer number holding an SSN shape go in; a locked vault leaves the printed shapes (rules.md B-26) | `tin_files.rs` (tests) `text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`; unit tests `text_holding_a_vault_tin_is_refused_in_every_rendering`, `a_printed_ssn_or_ein_is_refused`, `a_longer_number_holding_a_tin_shape_passes`, `an_ordinary_bank_export_passes`, `binary_bytes_are_not_scanned` | | Text filed into the books is refused when it holds a TIN the vault keeps (nine digits, 3-2-4 by hyphen, space or dot, 2-7 by hyphen or space, anywhere) or a stand-alone printed SSN or EIN (123-45-6789, 12-3456789), and only then: a bank export (routing numbers, long references, a date next to an amount) and a longer number holding an SSN shape go in; a locked vault leaves the printed shapes (rules.md B-26) | `tin_files.rs` (tests) `text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`; unit tests `text_holding_a_vault_tin_is_refused_in_every_rendering`, `a_printed_ssn_or_ein_is_refused`, `a_longer_number_holding_a_tin_shape_passes`, `an_ordinary_bank_export_passes`, `binary_bytes_are_not_scanned` |
| Filing is no TIN oracle: text with more than two TIN-shaped numbers ending in a known TIN's last four (the books' TIN and W-9 facts, then the vault's own) is refused before the vault is opened, byte for byte alike whether one is the vault's TIN or none is, the vault open or locked, by every filing tool; one or two such numbers are compared against the vault, one number written many ways counts once, and other last fours never count (rules.md B-26) | `tin_files.rs` (tests) `text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`; unit tests `text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it` (the vault never opened for a probe), `one_or_two_candidates_are_compared_against_the_vault` | | Filing is no TIN oracle: text with more than two TIN-shaped numbers ending in a known TIN's last four (the books' TIN and W-9 facts, then the vault's own) is refused before the vault is opened, byte for byte alike whether one is the vault's TIN or none is, the vault open or locked, by every filing tool; one or two such numbers are compared against the vault, one number written many ways counts once, and other last fours never count (rules.md B-26) | `tin_files.rs` (tests) `text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`; unit tests `text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it` (the vault never opened for a probe), `one_or_two_candidates_are_compared_against_the_vault` |
| The connect text fits its budgets, its lead stands alone, it states the receipt rule enforced (exact threshold in the base currency, required and exempt accounts, reversals) and the parties rule in force, and the company name can't break it (control and format characters) | `handbook.rs` `instructions_budget`, `a_typical_company_gets_the_whole_connect_text`, `the_company_name_is_one_plain_line`; `receipt_policy.rs` `the_rule_told_is_the_rule_enforced`, `a_long_or_odd_account_list_stays_short_and_plain`; a new session is told the settings in force: `a_new_session_is_told_the_settings_in_force` | | The connect text fits its budgets, its lead stands alone, it states the receipt rule enforced (exact threshold in the base currency, required and exempt accounts, reversals) and the parties rule in force, and the company name can't break it (control and format characters) | `handbook.rs` `instructions_budget`, `a_typical_company_gets_the_whole_connect_text`, `the_company_name_is_one_plain_line`; `receipt_policy.rs` `the_rule_told_is_the_rule_enforced`, `a_long_or_odd_account_list_stays_short_and_plain`; a new session is told the settings in force: `a_new_session_is_told_the_settings_in_force` |
@@ -110,19 +107,17 @@ broke, removed by simpler code, or is equivalent:
- **Killed (MCP).** A section's title is its heading - **Killed (MCP).** A section's title is its heading
(`a_title_is_the_heading_text`; over stdio the handbook tool titles a (`a_title_is_the_heading_text`; over stdio the handbook tool titles a
section by its heading and "all" as the whole handbook, section by its heading and "all" as the whole handbook,
`handbook_tool_and_resources_over_stdio`). A refused default import `handbook_tool_and_resources_over_stdio`). A `.jpg` or `.jpeg` W-9
root is dropped, never fatal (`ImportRoots::explicit`: was taken (`every_w9_file_type_is_taken`); P2 slice 5 judges the W-9
`a_default_download_dir_holding_the_books_is_dropped_not_fatal`). A by its first bytes, not its name, so that mutant's code is gone (the
`.jpg` or `.jpeg` W-9 is taken (`every_w9_file_type_is_taken`). The test now checks each type by its bytes). The waker wakes: a request pending until another thread wakes it completes
waker wakes: a request pending until another thread wakes it completes (`a_request_that_waits_is_woken_and_completes`). Three more were
(`a_request_that_waits_is_woken_and_completes`). The walk opens each killed by tests of the import roots and their walk (a refused default
folder with `O_DIRECTORY | O_NOFOLLOW`: a folder swapped for a link to root dropped, never fatal; a folder swapped for a link or a pipe never
an unprotected folder outside the roots, or for a pipe, is never walked walked through; a read failure naming its kind, never the path): P2
through and never blocks slice 5 removed `books.import_local_file` and the import roots, and
(`a_folder_swapped_for_a_link_or_a_pipe_is_never_walked_through`). A those tests with them. `resolved` follows 40 links, as the kernel
read failure names its kind, never the path does, and no more
(`cant_read_names_the_failure_not_the_path`). `resolved` follows 40
links, as the kernel does, and no more
(`resolved_follows_forty_links_and_no_more`). A file is saved only to (`resolved_follows_forty_links_and_no_more`). A file is saved only to
a new file in a folder that exists, and a missing folder or a taken a new file in a folder that exists, and a missing folder or a taken
file is refused as such, never as a failed write file is refused as such, never as a failed write
@@ -154,8 +149,11 @@ broke, removed by simpler code, or is equivalent:
file's size before reading, so its read limit (`limit + 1`) was only file's size before reading, so its read limit (`limit + 1`) was only
reached by a file growing during the read; the read's limit is now the reached by a file growing during the read; the read's limit is now the
one check (the 25 MiB upload test kills `+ 1` → `* 1`). `openat` failed one check (the 25 MiB upload test kills `+ 1` → `* 1`). `openat` failed
on `fd < 0`, now on `fd == -1`, as openat(2) specifies. on `fd < 0`, now on `fd == -1`, as openat(2) specifies. (`inspect`,
- **Equivalent (MCP, all 14 missed).** `^` for `|` in the open flags `read_checked`, the `openat` walk and that 25 MiB test were removed
with the import roots in P2 slice 5.)
- **Equivalent (MCP, all 14 missed; the walk and its stubs were removed
in P2 slice 5).** `^` for `|` in the open flags
(six: the walk's first step, each folder's two, the file's two, and (six: the walk's first step, each folder's two, the file's two, and
`openat`'s `O_CLOEXEC`): `O_RDONLY` is 0 and `O_DIRECTORY`, `O_NOFOLLOW`, `openat`'s `O_CLOEXEC`): `O_RDONLY` is 0 and `O_DIRECTORY`, `O_NOFOLLOW`,
`O_NONBLOCK`, `O_CLOEXEC` are distinct bits, so `a ^ b` is `a | b`. `O_NONBLOCK`, `O_CLOEXEC` are distinct bits, so `a ^ b` is `a | b`.
+2 -1
View File
@@ -238,7 +238,8 @@ was weakened. What it added is checked by tests and properties (listed in
never called corrupt, damage always is; the envelope and its hash are never called corrupt, damage always is; the envelope and its hash are
frozen across formats, rules.md B-27), the server failing closed after a frozen across formats, rules.md B-27), the server failing closed after a
panic, the import roots' lockdown (a property over generated paths, and a panic, the import roots' lockdown (a property over generated paths, and a
component walk that refuses a folder swapped for a link), files with full component walk that refuses a folder swapped for a link; removed with the
import roots in P2 slice 5), files with full
TINs kept out of the books (B-26), and the agent text lint. Out of scope TINs kept out of the books (B-26), and the agent text lint. Out of scope
for proof today: the format gate and the walk are I/O, and the model in for proof today: the format gate and the walk are I/O, and the model in
`verify/lean` stops at the ledger. `verify/lean` stops at the ledger.
+10 -2
View File
@@ -307,7 +307,7 @@ The books are open while something holds them; there is no background service. T
- `run/mcp.sock`: served by the **server**, MCP for the bridge. Local agents never use TCP, so no other account on the machine can squat a port and catch the token or the traffic. Both ends check the peer's user ID (`SO_PEERCRED`/`getpeereid`, the pipe's SID). TCP is off by default. The owner can turn it on (`server.toml`, or `wanderbooks serve --http`) for a client that needs HTTP; it then binds 127.0.0.1:7411, a failed bind is reported by `doctor` and is not fatal, and such clients hold no lease, so the books must already be open. - `run/mcp.sock`: served by the **server**, MCP for the bridge. Local agents never use TCP, so no other account on the machine can squat a port and catch the token or the traffic. Both ends check the peer's user ID (`SO_PEERCRED`/`getpeereid`, the pipe's SID). TCP is off by default. The owner can turn it on (`server.toml`, or `wanderbooks serve --http`) for a client that needs HTTP; it then binds 127.0.0.1:7411, a failed bind is reported by `doctor` and is not fatal, and such clients hold no lease, so the books must already be open.
- `run/admin.sock`: served by the server, admin only (create, adopt, tokens), as in 3.1. Neither the viewer nor the bridge opens it. - `run/admin.sock`: served by the server, admin only (create, adopt, tokens), as in 3.1. Neither the viewer nor the bridge opens it.
**The server's process (P2 slice 3).** The server is the hidden mode `wanderbooks server`, started by the host with a pipe on its standard input; it runs until that input ends (the host let go of it, or died) or it gets SIGTERM, SIGINT or SIGHUP (watched from before the books are opened). It takes `run/server.lock` before anything else (a second server waits for it, touching nothing, and leaves if told to close first), opens and verifies every book (a book that won't open is listed with why, and the others are served), binds both sockets, and only then prints `ready` on its standard output. `run/mcp.sock` speaks Streamable HTTP over the Unix socket, `/b/{id}/mcp` per book, so local and remote share one code path (3.2); `run/admin.sock` takes one JSON line and answers one (`list`, `create`, `adopt`). A socket path longer than the system allows is reached through `/proc/self/fd` on Linux. Closing: new requests are refused, unrun (HTTP 503 at the door, or, for a call already past it, JSON-RPC error -32050 "closing: this call wasn't run"; the bridge sends either again once the books are back); every request already running finishes its work, however long, whether or not its client is still there; answers on their way out get 10 s more (a second signal skips that wait, never the first); then the sessions end and the sockets go. Until uploads have their own route (slice 5) a request may be 64 MiB, since receipts still ride in tool calls. A book in the root keeps its vault in its own `vault/` and its v1 key in its own `keys/legacy.key` (0700 and 0600, made private whenever first written), and no tool writes a file anywhere in the root. Adoption is a preview, then a confirm tied to the head the preview read; entries of a settings file the books don't know are refused unless skipped, its comments are shown, the vault key it names must be the one given, and the key must open every vault record. Open for slice 8: under Landlock the server can't read an adoption's source outside the root, so adoption needs another way in by then. **The server's process (P2 slice 3).** The server is the hidden mode `wanderbooks server`, started by the host with a pipe on its standard input; it runs until that input ends (the host let go of it, or died) or it gets SIGTERM, SIGINT or SIGHUP (watched from before the books are opened). It takes `run/server.lock` before anything else (a second server waits for it, touching nothing, and leaves if told to close first), opens and verifies every book (a book that won't open is listed with why, and the others are served), binds both sockets, and only then prints `ready` on its standard output. `run/mcp.sock` speaks Streamable HTTP over the Unix socket, `/b/{id}/mcp` per book, so local and remote share one code path (3.2); `run/admin.sock` takes one JSON line and answers one (`list`, `create`, `adopt`). A socket path longer than the system allows is reached through `/proc/self/fd` on Linux. Closing: new requests are refused, unrun (HTTP 503 at the door, or, for a call already past it, JSON-RPC error -32050 "closing: this call wasn't run"; the bridge sends either again once the books are back); every request already running finishes its work, however long, whether or not its client is still there; answers on their way out get 10 s more (a second signal skips that wait, never the first); then the sessions end and the sockets go. A request is at most 1 MiB (64 MiB until slice 5 gave files their own route). A book in the root keeps its vault in its own `vault/` and its v1 key in its own `keys/legacy.key` (0700 and 0600, made private whenever first written), and no tool writes a file anywhere in the root (the server keeps uploads on their way in in the book's own `uploads/`, slice 5). Adoption is a preview, then a confirm tied to the head the preview read; entries of a settings file the books don't know are refused unless skipped, its comments are shown, the vault key it names must be the one given, and the key must open every vault record. Open for slice 8: under Landlock the server can't read an adoption's source outside the root, so adoption needs another way in by then.
**The host's process (P2 slice 3).** `wanderbooks host` closes every descriptor above 2 it was handed, refuses to run confined, takes `run/host.lock` (waiting up to 30 s for a host that is closing; one that finds a live host exits at once), binds `run/lease.sock`, and starts the server from `/proc/self/exe`, naming the root on its command line only when the host itself was given one (tests): nothing in a process list names it. Ending leases, it tells each holder why and closes the server only once they have let go (at most 2 s), so holders stop sending before it closes; a call already on its way is refused at the door, unrun, and sent again once the books are back. A closing server is never killed, however long its last call runs; a new server waits for an old one still finishing (its host killed) rather than fail. A restart begins when the new version appears, not at a quiet moment: the server first finishes every call in progress (none is cut off), and calls arriving meanwhile wait for the new server (up to 60 s); waiting for idle comes with jobs (`books.job`), which are what can run long. On a restart the old host hands over: it lets go of `host.lock` and keeps `update.lock` until the new host answers, so waiting holders find it. A holder starts a host only when none holds the lock; one that died at once is noticed within 2 s; a server that fails soon after opening counts as failing (only 60 s open clears the count). Lease records carry the holder's process as the kernel reports it. The lease reasons are `closed_by_owner`, `restarting`, `closing` (the host is closing because nothing held it: hold again, a new host will answer; the owner's close, by the menu, `wanderbooks close` or a signal to the host, is always `closed_by_owner`, and a holder that had held stays closed) and `server_failed`. The bridge never sends a call the agent cancelled before it was sent (MCP owes no answer to a cancelled request), answers a ping itself while the books open, refuses JSON-RPC batches request by request, passes no client offer it can't carry back (sampling, elicitation, roots), reports a call answered with nothing as possibly run, and carries the per-request protocol headers of the 2026-07-28 era. Sessions on the server time out only after 24 h without a message (rmcp's idle timer ignores a call in progress, so a shorter one could cut a long call off); the bridge ends its own when its stdin closes or it is signalled. `wanderbooks doctor` (a first form, completed in slice 8) says whether the books are open, what holds them, and the host's and server's last log lines, naming no folder. Tests shorten the timings with a hidden `--test-knobs` flag, passed down from holder to helper to host. **The host's process (P2 slice 3).** `wanderbooks host` closes every descriptor above 2 it was handed, refuses to run confined, takes `run/host.lock` (waiting up to 30 s for a host that is closing; one that finds a live host exits at once), binds `run/lease.sock`, and starts the server from `/proc/self/exe`, naming the root on its command line only when the host itself was given one (tests): nothing in a process list names it. Ending leases, it tells each holder why and closes the server only once they have let go (at most 2 s), so holders stop sending before it closes; a call already on its way is refused at the door, unrun, and sent again once the books are back. A closing server is never killed, however long its last call runs; a new server waits for an old one still finishing (its host killed) rather than fail. A restart begins when the new version appears, not at a quiet moment: the server first finishes every call in progress (none is cut off), and calls arriving meanwhile wait for the new server (up to 60 s); waiting for idle comes with jobs (`books.job`), which are what can run long. On a restart the old host hands over: it lets go of `host.lock` and keeps `update.lock` until the new host answers, so waiting holders find it. A holder starts a host only when none holds the lock; one that died at once is noticed within 2 s; a server that fails soon after opening counts as failing (only 60 s open clears the count). Lease records carry the holder's process as the kernel reports it. The lease reasons are `closed_by_owner`, `restarting`, `closing` (the host is closing because nothing held it: hold again, a new host will answer; the owner's close, by the menu, `wanderbooks close` or a signal to the host, is always `closed_by_owner`, and a holder that had held stays closed) and `server_failed`. The bridge never sends a call the agent cancelled before it was sent (MCP owes no answer to a cancelled request), answers a ping itself while the books open, refuses JSON-RPC batches request by request, passes no client offer it can't carry back (sampling, elicitation, roots), reports a call answered with nothing as possibly run, and carries the per-request protocol headers of the 2026-07-28 era. Sessions on the server time out only after 24 h without a message (rmcp's idle timer ignores a call in progress, so a shorter one could cut a long call off); the bridge ends its own when its stdin closes or it is signalled. `wanderbooks doctor` (a first form, completed in slice 8) says whether the books are open, what holds them, and the host's and server's last log lines, naming no folder. Tests shorten the timings with a hidden `--test-knobs` flag, passed down from holder to helper to host.
@@ -441,7 +441,14 @@ Report results also carry `as_of_status {drafts_open_on_or_before, unreconciled,
- `export`, `asset_labels` and `export_cpa_package` return a link, plus inline JSON/CSV when ≤ 50K characters. - `export`, `asset_labels` and `export_cpa_package` return a link, plus inline JSON/CSV when ≤ 50K characters.
- TIN-bearing outputs come only from `books.request_human_download`, which returns an `/h/` link. - TIN-bearing outputs come only from `books.request_human_download`, which returns an `/h/` link.
**Typical receipt flow (Claude Code, the daily case in 7.2):** `sha256sum` → `books.begin_upload` → `curl -T` the file to `put_url` → `attach_receipt {upload_id}` → `get_receipt` (text) → `draft_from_template` or `draft_entry` → `preview_entry`, shown to the human → on "yes", `post_entry` with the `preview_token` → `next_steps` and the head come back, and `books.show` moves the viewer to the entry. No step raises a client permission prompt beyond the client's normal rules. "Save it as a draft" is `set_draft_state awaiting_evidence`. On claude.ai, files ≤ 256 KiB go inline with their sha256; larger ones go through `human_url` and the `process_inbox` prompt. **Typical receipt flow (Claude Code, the daily case in 7.2), as planned; slice 5 built it with a command instead of `curl` (below):** `sha256sum` → `books.begin_upload` → `curl -T` the file to `put_url` → `attach_receipt {upload_id}` → `get_receipt` (text) → `draft_from_template` or `draft_entry` → `preview_entry`, shown to the human → on "yes", `post_entry` with the `preview_token` → `next_steps` and the head come back, and `books.show` moves the viewer to the entry. No step raises a client permission prompt beyond the client's normal rules. "Save it as a draft" is `set_draft_state awaiting_evidence`. On claude.ai, files ≤ 256 KiB go inline with their sha256; larger ones go through `human_url` and the `process_inbox` prompt.
**As built (P2 slice 5), and where it departs:**
- **No path on the server's computer is read.** `books.import_local_file`, the import roots and `--import-root` are gone, and so is `set_w9`'s `w9_file_path`; a test (`no_tool_takes_a_path`) keeps any path-like parameter out, except the three outputs with full TINs (`export_cpa_package` and `get_w9` `save_to`, `vendor_1099` `save_csv_to`), which keep theirs, written only outside the books, until the viewer takes them over in slice 6 (D-28: the viewer reads and downloads; it never writes).
- **An upload, for books served from the root:** `books.begin_upload {sha256, size, mime_type, file_name, purpose: receipt|document|import|w9}` returns `{upload_id, command, expires_at, max_bytes}`. No `put_url`, `curl` or `human_url`: the server listens on a Unix socket, so the agent runs `command`, `wanderbooks upload --book ID UPLOAD FILE`, which reads its token from the keyring (`--token NAME` is in the command when the caller's token isn't the default `agent`) and PUTs the file to `/b/{book}/u/{upload}` (D-28: the agent does it all; no `/h/inbox` drop zone, no `inbox_id`; the hosted server of P9 adds a URL). The bytes stream to `uploads/<id>.part` and are kept only if exactly the size and SHA-256 declared, by the token that began it, once (409 after); 25 MiB at most; 15 minutes; at most 16 under way per token; lapsed ones swept when the books open, whenever a grant is made, and every 10 seconds (owner, 2026-10-19: a W-9 or a file with a TIN "shouldn't just linger there forever... EVENTUALLY cleaned up is fine"). One lock per book orders every change to its uploads (grants, sweeps, a send's start and end, a take, a give-back), and a grant is written aside and renamed. Refusals carry their code (`no_such_upload`, `not_yours`, `already_sent`, `in_use`, `not_as_declared`, `stopped`), and a refused body is read and dropped (up to 25 MiB) so the client gets the answer. Every PUT to books the server holds open is in the access log (action `upload`, with the upload's id, refusals at the door included; a request for books it doesn't hold has no log to go in), written in the same blocking task as the bytes, so a client hanging up never leaves bytes kept without their record, and bytes whose record can't be written are thrown away. An upload's id is `up_` and 32 letters `a`-`p`: with no digits, the access log's TIN masking never hides one.
- **Filed once, and only when filed.** A tool takes the upload by renaming its bytes aside (so two calls never both have it), hashes them again, and spends it only once they are in the books (an import: once its rehearsal passed and the writing begins); a call refused after taking it, or one that panics, gives it back, and a server stopped mid-call gives it back when the books next open. A tool takes only an upload of its own purpose (receipt or document: `attach_receipt`, `file_document`; import: the two CSV imports; w9: `set_w9`). `existing_attachment_id` is offered only for a receipt or document, and never names a withdrawn attachment.
- **Inline:** `content_base64` (≤ 256 KiB, its length checked before it is decoded) with a required `sha256`; never both, never neither. Over stdio only inline, and no larger file comes back: stdio is the development transport now (the owner's books run released code until the cutover, `just build` building `main`), so it gets no upload route. Names and types are checked when a file is filed, whichever way it came: a name of 1 to 255 bytes, no folder in it, no control or invisible characters; a MIME type of visible ASCII. `attach_email` and `inbox_id` aren't built. `set_w9` takes `w9_upload_id` or `w9_content_base64` + `w9_sha256`, judged a PDF, PNG or JPEG by its first bytes, with an optional `w9_file_name`. A request on `/b/{book}/mcp` is at most 1 MiB now that files have their own route (it was 64 MiB).
- **Out:** `books.get_receipt {attachment_id, view: meta (default) | bytes}`: bytes inline for a file ≤ 256 KiB; a larger one answers with `download`, the command `wanderbooks download --book ID ATTACHMENT --to FOLDER`, which GETs `/b/{book}/a/{attachment}` with the agent's token (books:read; `Content-Disposition` with the name in ASCII and RFC 8187 UTF-8, `nosniff`, and the SHA-256 as an RFC 9530 `Repr-Digest`, which the command checks before it saves), into a new file beside any of that name (`name (1).pdf`), never over one: written to a hidden file and linked in whole, so the name never holds part of it. The name it saves under is the server's made plain (no folder, no leading dot, nothing invisible, at most 200 bytes), else `attachment-N`. The read and its record are one blocking task, as for uploads. The `books://receipts/{id}` resource is meta only. Not built yet: `text`, `page_image` and `link` views, `books.download_link`, and links from `export`, `asset_labels` and `export_cpa_package` (slice 6, with the viewer's downloads).
### 4.8 Drafts waiting on evidence ### 4.8 Drafts waiting on evidence
@@ -1245,6 +1252,7 @@ These are the only open owner questions. Ask them just before the move, one at a
- the scaffold: `justfile`, `scripts/`, `.claude/settings.json`'s SessionStart hook, `.codex/hooks.json`; `AGENTS.md`/`CLAUDE.md` become the 3-line pointer; - the scaffold: `justfile`, `scripts/`, `.claude/settings.json`'s SessionStart hook, `.codex/hooks.json`; `AGENTS.md`/`CLAUDE.md` become the 3-line pointer;
- the agents' old registrations: Claude Code's stdio `wanderbooks` server from `just mcp-add` (replaced by `wanderbooks connect`), and Codex's; - the agents' old registrations: Claude Code's stdio `wanderbooks` server from `just mcp-add` (replaced by `wanderbooks connect`), and Codex's;
- old binaries: `ledger-mcp`/`ledger-gui` from `just build` or `cargo install` (the one `wanderbooks` executable replaces them); - old binaries: `ledger-mcp`/`ledger-gui` from `just build` or `cargo install` (the one `wanderbooks` executable replaces them);
- the `live` jj workspace (`~/.cache/wanderbooks/live-ws`) that `just build` builds the released `main` in: until the cutover these books run only released code, since the work on top of `main` drops what their stdio server needs (slice 5: files over 256 KiB only by upload, which needs the data root);
- the viewer's old preferences file, eframe's `app.ron` in `$XDG_DATA_HOME/wanderbooks` (the data root's default place): the viewer moves it on its first run (ledger-gui prefs.rs), and `setup` must not refuse the default place for holding only that file (slice 8 handles it: moved to the viewer's own folder first); - the viewer's old preferences file, eframe's `app.ron` in `$XDG_DATA_HOME/wanderbooks` (the data root's default place): the viewer moves it on its first run (ledger-gui prefs.rs), and `setup` must not refuse the default place for holding only that file (slice 8 handles it: moved to the viewer's own folder first);
- the agent's own notes that name the old paths (its memory), rewritten to the new arrangement. - the agent's own notes that name the old paths (its memory), rewritten to the new arrangement.
The cutover's check adds: the project folder holds none of the above, and `wanderbooks doctor` is clean. The cutover's check adds: the project folder holds none of the above, and `wanderbooks doctor` is clean.