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:
1 parent
d1f3ad891a
commit
b26c099a68
44 files changed
+5075
-2739
No files matched your search
@@ -23,6 +23,7 @@ ledger-store = { path = "../ledger-store" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = "0.22"
|
||||
chrono = "0.4"
|
||||
libc = "0.2"
|
||||
reqwest = "0.13"
|
||||
@@ -32,6 +33,7 @@ ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
|
||||
|
||||
@@ -400,6 +400,8 @@ fn print_usage() {
|
||||
);
|
||||
eprintln!(" wanderbooks mcp --book ID [--token NAME]");
|
||||
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!();
|
||||
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!(" 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!(" 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!(" close close the books now: everything holding them lets go");
|
||||
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.
|
||||
#[cfg(not(unix))]
|
||||
fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
|
||||
@@ -1009,8 +1041,23 @@ fn run_server(_args: Vec<String>) -> i32 {
|
||||
}
|
||||
|
||||
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.
|
||||
let mut all = env::args().skip(1);
|
||||
let mut all = words.clone().into_iter();
|
||||
match all.next().as_deref() {
|
||||
Some("gui") => std::process::exit(run_gui(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 agent = Agent::Claude;
|
||||
let mut cmd: Option<String> = None;
|
||||
for arg in env::args().skip(1) {
|
||||
for arg in words {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
@@ -1055,7 +1102,7 @@ fn main() {
|
||||
}
|
||||
if let Some(
|
||||
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()
|
||||
{
|
||||
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
|
||||
|
||||
@@ -265,7 +265,8 @@ async fn post_one(c: &Client, n: u64) -> Result<u64, String> {
|
||||
c,
|
||||
"books.attach_receipt",
|
||||
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?;
|
||||
call(
|
||||
@@ -747,6 +748,42 @@ async fn raw_session(http: &reqwest::Client, book: &str) -> String {
|
||||
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`.
|
||||
fn wait_exit(child: &mut Child, secs: u64) -> Option<std::process::ExitStatus> {
|
||||
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");
|
||||
let http = mcp_http_for(&home, "acme");
|
||||
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();
|
||||
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": {
|
||||
"name": "books.file_document", "arguments": {
|
||||
"category": "formation", "file_name": "big.pdf",
|
||||
"mime_type": "application/pdf", "content_base64": encode(&bytes)}}});
|
||||
"category": "formation", "upload_id": upload}}});
|
||||
let response = http
|
||||
.post("http://localhost/b/acme/mcp")
|
||||
.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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn stall(home: &Home) -> std::os::unix::net::UnixStream {
|
||||
use std::io::Write;
|
||||
@@ -1767,3 +1783,767 @@ async fn the_vault_and_the_approval_controls_need_their_scopes() {
|
||||
drop((w, p));
|
||||
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, ¬es, "notes.txt", "document").await;
|
||||
assert_eq!(put(&home, &token, &id, ¬es).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()
|
||||
.unwrap();
|
||||
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()
|
||||
.arg("dev-stdio")
|
||||
.arg("--data-dir")
|
||||
.arg(&data)
|
||||
.arg("--import-root")
|
||||
.arg(&inbox)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
|
||||
@@ -383,7 +383,8 @@ fn post_one(a: &mut Agent, n: u64) -> Result<u64, String> {
|
||||
a.call(
|
||||
"books.attach_receipt",
|
||||
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(
|
||||
"books.post_entry",
|
||||
@@ -1354,11 +1355,10 @@ fn a_message_too_large_is_refused_and_the_session_goes_on() {
|
||||
a.initialize();
|
||||
{
|
||||
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();
|
||||
let chunk = vec![b'a'; 1024 * 1024];
|
||||
for _ in 0..65 {
|
||||
stdin.write_all(&chunk).unwrap();
|
||||
}
|
||||
stdin.write_all(&vec![b'a'; max + 1 - 8]).unwrap();
|
||||
stdin.write_all(b"\"}\n").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
}
|
||||
@@ -1427,10 +1427,11 @@ fn malformed_and_boundary_input_never_ends_the_session() {
|
||||
assert!(line.contains("-32700"), "{line}");
|
||||
let pong = a.request("ping", json!({}), 20);
|
||||
assert!(pong.get("result").is_some(), "{pong}");
|
||||
// Exactly 64 MiB of message (a JSON string padded with spaces), then
|
||||
// a ping: the big one is the server's to refuse, the ping answered.
|
||||
// Exactly the limit, 1 MiB of message (a JSON string padded with
|
||||
// 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 tail = b"\"}}";
|
||||
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!(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();
|
||||
}
|
||||
@@ -15,6 +15,8 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time", "process"] }
|
||||
reqwest = "0.13"
|
||||
base64 = "0.22"
|
||||
# `wanderbooks download` checks what arrived against its Repr-Digest.
|
||||
sha2 = "0.10"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
# 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.
|
||||
|
||||
@@ -59,8 +59,8 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{Notify, mpsc, watch};
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// The longest message the agent may send (receipts ride in calls until
|
||||
/// uploads have their own route).
|
||||
/// The longest message the agent may send (a file bigger than a call
|
||||
/// takes inline goes by an upload).
|
||||
const MAX_MESSAGE: usize = ledger_mcp::local::MAX_BODY;
|
||||
/// The bridge's own request ids upstream start so.
|
||||
const OWN_ID: &str = "wanderbooks-bridge-";
|
||||
|
||||
@@ -1158,10 +1158,676 @@ pub fn passphrase(
|
||||
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, "ed[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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn close_reads_the_books_state_for_what_it_is() {
|
||||
let leases = Ok(Some(HostMsg::Leases {
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
|
||||
/// The keyring service tokens are kept under (INVARIANT 1).
|
||||
pub const SERVICE: &str = "wanderbooks";
|
||||
/// 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.
|
||||
pub trait Keeper: Send + Sync {
|
||||
|
||||
@@ -20,6 +20,8 @@ ledger-import = { path = "../ledger-import" }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
base64 = "0.22"
|
||||
# A download's Repr-Digest (local.rs).
|
||||
sha2 = "0.10"
|
||||
mime_guess = "2"
|
||||
rust_decimal = "1"
|
||||
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
|
||||
@@ -32,7 +34,7 @@ serde_json = "1"
|
||||
chrono = "0.4"
|
||||
thiserror = "2"
|
||||
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).
|
||||
futures-util = "0.3"
|
||||
zip = "9.0.1"
|
||||
@@ -41,8 +43,8 @@ toml = "1"
|
||||
# from memory when dropped, as the vault's own are.
|
||||
zeroize = "1"
|
||||
|
||||
# O_NOFOLLOW and O_NONBLOCK for books.import_local_file's open (local_files.rs
|
||||
# INVARIANT 6): std has no names for these open flags.
|
||||
# The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's
|
||||
# same-user check: std has no call for them.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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).
|
||||
- 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.
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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.
|
||||
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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 W-9 given by path (books.set_w9 w9_file_path) is read the same way.
|
||||
- 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.
|
||||
- Receipts come back out through books.get_receipt.
|
||||
- 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):
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -3,8 +3,7 @@
|
||||
//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks
|
||||
//! dev-stdio` (one executable, plan D-22) both call it.
|
||||
//!
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]
|
||||
//! [--import-root DIR]...`
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
|
||||
//!
|
||||
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||
@@ -12,19 +11,16 @@
|
||||
//! configured base currency. Company settings (name, fiscal year start,
|
||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||
//! default the one next to the data dir, and reload whenever it changes
|
||||
//! (settings.rs). `books.import_local_file` and `books.set_w9`'s
|
||||
//! `w9_file_path` read only from the import roots: each `--import-root`,
|
||||
//! 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.
|
||||
//! (settings.rs). Files come in only as tool input (inline, at most 256
|
||||
//! KiB): uploads need the books server's socket (`wanderbooks mcp`).
|
||||
|
||||
pub mod access;
|
||||
pub mod admin;
|
||||
mod handbook;
|
||||
#[cfg(unix)]
|
||||
pub mod local;
|
||||
mod local_files;
|
||||
pub mod opening;
|
||||
mod paths;
|
||||
mod policy;
|
||||
pub mod scopes;
|
||||
mod server;
|
||||
@@ -35,7 +31,6 @@ mod wire;
|
||||
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::Store;
|
||||
use local_files::ImportRoots;
|
||||
use policy::ReceiptPolicy;
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
@@ -59,7 +54,7 @@ fn default_data_dir() -> PathBuf {
|
||||
|
||||
fn print_usage(prog: &str) {
|
||||
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!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
||||
@@ -72,13 +67,10 @@ fn print_usage(prog: &str) {
|
||||
eprintln!(
|
||||
" --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
|
||||
/// 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
|
||||
/// closed properly on the way out; the binaries turn this into the exit.
|
||||
#[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
|
||||
/// name). `prog` names the program in the usage text: `ledger-mcp`, or
|
||||
/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad
|
||||
/// argument, books from a newer wanderbooks, or a refused import root
|
||||
/// return [`Exit`] after a plain message.
|
||||
/// argument or books from a newer wanderbooks return [`Exit`] after a
|
||||
/// plain message.
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
@@ -106,7 +98,6 @@ pub fn run(
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut vault_key_file: Option<PathBuf> = None;
|
||||
let mut http_addr: Option<String> = None;
|
||||
let mut import_roots: Vec<PathBuf> = Vec::new();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
@@ -129,11 +120,6 @@ pub fn run(
|
||||
"--http" => {
|
||||
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" => {
|
||||
print_usage(prog);
|
||||
return Ok(());
|
||||
@@ -153,11 +139,6 @@ pub fn run(
|
||||
);
|
||||
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"));
|
||||
// config.json's base currency matters only to books being created: it
|
||||
// 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));
|
||||
// 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()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
if let Some(addr) = http_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(());
|
||||
}
|
||||
|
||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default())
|
||||
.with_live_settings(live)
|
||||
.with_import_roots(import_roots);
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
|
||||
rt.block_on(async {
|
||||
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
||||
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
|
||||
@@ -277,7 +240,6 @@ async fn serve_http(
|
||||
addr: String,
|
||||
store: Store,
|
||||
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
|
||||
import_roots: ImportRoots,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
@@ -290,8 +252,7 @@ async fn serve_http(
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
|
||||
.with_live_settings(live.clone())
|
||||
.with_import_roots(import_roots.clone());
|
||||
.with_live_settings(live.clone());
|
||||
Ok(server)
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
|
||||
+450
-67
@@ -43,10 +43,20 @@
|
||||
//! bindings a new session is refused (an initialize), never a binding
|
||||
//! dropped. Tokens are made, listed and revoked only through
|
||||
//! 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::local_files::ImportRoots;
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use crate::server::{BooksServer, Layout, SharedStore};
|
||||
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).
|
||||
pub const READY: &str = "ready";
|
||||
|
||||
/// How large a request may be. Receipts still ride inside tool calls as
|
||||
/// base64 until uploads have their own route (P2 slice 5, which brings
|
||||
/// this down to 1 MiB).
|
||||
pub const MAX_BODY: usize = 64 * 1024 * 1024;
|
||||
/// How large an MCP request may be (plan 3.2): files of any size come
|
||||
/// through uploads (`/b/{book}/u/{upload}`); inline, at most 256 KiB.
|
||||
pub const MAX_BODY: usize = 1024 * 1024;
|
||||
|
||||
/// How long the admin socket waits for a request to arrive.
|
||||
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.
|
||||
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>;
|
||||
|
||||
/// A book the server holds.
|
||||
@@ -103,6 +115,8 @@ enum Book {
|
||||
/// Its access log, where requests refused at the door are
|
||||
/// recorded too (access.rs).
|
||||
access: Arc<AccessLog>,
|
||||
/// Its files on their way in (`/b/{id}/u/{upload}`).
|
||||
uploads: ledger_store::uploads::Uploads,
|
||||
},
|
||||
/// Not opened, with why (INVARIANT 3).
|
||||
Failed(String),
|
||||
@@ -121,7 +135,6 @@ impl Book {
|
||||
/// Every book the server holds, and what serving them needs.
|
||||
struct Books {
|
||||
root: Root,
|
||||
import_roots: ImportRoots,
|
||||
held: RwLock<BTreeMap<String, Book>>,
|
||||
/// Ends every MCP session (INVARIANT 6, last).
|
||||
sessions: CancellationToken,
|
||||
@@ -250,17 +263,14 @@ impl Books {
|
||||
let mut live = Live::own();
|
||||
live.sync(store.ledger().settings(), store.base_currency().code());
|
||||
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(
|
||||
AccessLog::open(store.data_dir())
|
||||
.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 store = SharedStore::new(store);
|
||||
let log = access.clone();
|
||||
@@ -276,9 +286,9 @@ impl Books {
|
||||
Ok(
|
||||
BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
|
||||
.with_live_settings(live.clone())
|
||||
.with_import_roots(roots.clone())
|
||||
.with_layout(layout.clone())
|
||||
.with_access(log.clone()),
|
||||
.with_access(log.clone())
|
||||
.with_uploads(server_uploads.clone()),
|
||||
)
|
||||
},
|
||||
Arc::new(sessions()),
|
||||
@@ -288,6 +298,7 @@ impl Books {
|
||||
service,
|
||||
store: shared,
|
||||
access,
|
||||
uploads,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -848,12 +859,29 @@ const SESSION_HEADER: &str = "mcp-session-id";
|
||||
/// live sessions).
|
||||
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
|
||||
/// (access.rs INVARIANT 3), then answered with `status` and `why`.
|
||||
async fn refused_at_door(
|
||||
book: &Book,
|
||||
principal: Principal,
|
||||
method: &axum::http::Method,
|
||||
asked: &Asked,
|
||||
code: &'static str,
|
||||
status: axum::http::StatusCode,
|
||||
why: String,
|
||||
@@ -865,8 +893,9 @@ async fn refused_at_door(
|
||||
principal,
|
||||
client: None,
|
||||
request_id: None,
|
||||
action: "connect".into(),
|
||||
subject: method.as_str().to_string(),
|
||||
action: asked.action.into(),
|
||||
// Masked, then cut, by the log itself (access.rs INVARIANT 2).
|
||||
subject: asked.subject.clone(),
|
||||
args_digest: None,
|
||||
result: Outcome::Refused(code.into()),
|
||||
head_after: None,
|
||||
@@ -892,34 +921,31 @@ async fn refused_at_door(
|
||||
response
|
||||
}
|
||||
|
||||
/// `/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 {
|
||||
/// The door to `book` (INVARIANT 8): the books held open, the server not
|
||||
/// closing, and a live token for those books; else the answer refusing
|
||||
/// the request (recorded in the books' access log, where there is one).
|
||||
async fn door(
|
||||
books: &Arc<Books>,
|
||||
book: &str,
|
||||
asked: &Asked,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<(Book, Token), Box<axum::response::Response>> {
|
||||
use axum::http::StatusCode;
|
||||
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 found = books.held().get(&book).cloned();
|
||||
let (service, held) = match found {
|
||||
let found = books.held().get(book).cloned();
|
||||
let held = match found {
|
||||
Some(Book::Failed(why)) if !closing => {
|
||||
return (
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
[(REFUSED_HEADER, "not-open")],
|
||||
format!("the books {book} didn't open: {why}"),
|
||||
)
|
||||
.into_response();
|
||||
.into_response()
|
||||
.into());
|
||||
}
|
||||
None if !closing => {
|
||||
return (
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
[(REFUSED_HEADER, "no-such-books")],
|
||||
format!(
|
||||
@@ -927,22 +953,20 @@ async fn mcp(
|
||||
book.chars().take(64).collect::<String>()
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
.into_response()
|
||||
.into());
|
||||
}
|
||||
Some(b @ Book::Open { .. }) => match &b {
|
||||
Book::Open { service, .. } => (service.clone(), b.clone()),
|
||||
Book::Failed(_) => unreachable!(),
|
||||
},
|
||||
Some(b @ Book::Open { .. }) => b,
|
||||
_ => {
|
||||
return (
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"the books are closing: this request wasn't run",
|
||||
)
|
||||
.into_response();
|
||||
.into_response()
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let method = request.method().clone();
|
||||
let token = books.bearer(request.headers());
|
||||
let token = books.bearer(headers);
|
||||
let principal = |t: &Token| Principal {
|
||||
id: t.id.clone(),
|
||||
kind: "agent".into(),
|
||||
@@ -952,41 +976,382 @@ async fn mcp(
|
||||
let who = token
|
||||
.as_ref()
|
||||
.map_or_else(|_| Principal::unknown(), principal);
|
||||
return refused_at_door(
|
||||
return Err(refused_at_door(
|
||||
&held,
|
||||
who,
|
||||
&method,
|
||||
asked,
|
||||
crate::access::CLOSING,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"the books are closing: this request wasn't run".into(),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.into());
|
||||
}
|
||||
let token = match token {
|
||||
Ok(t) => t,
|
||||
Err((code, why)) => {
|
||||
return refused_at_door(
|
||||
return Err(refused_at_door(
|
||||
&held,
|
||||
Principal::unknown(),
|
||||
&method,
|
||||
asked,
|
||||
code,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
why,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.into());
|
||||
}
|
||||
};
|
||||
if token.book != book {
|
||||
return refused_at_door(
|
||||
return Err(refused_at_door(
|
||||
&held,
|
||||
principal(&token),
|
||||
&method,
|
||||
asked,
|
||||
"wrong_books",
|
||||
StatusCode::FORBIDDEN,
|
||||
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
|
||||
.headers()
|
||||
.get(SESSION_HEADER)
|
||||
@@ -1024,7 +1389,7 @@ async fn mcp(
|
||||
return refused_at_door(
|
||||
&held,
|
||||
principal(&token),
|
||||
&method,
|
||||
&Asked::connect(&method),
|
||||
"too_many_sessions",
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"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(
|
||||
&held,
|
||||
principal(&token),
|
||||
&method,
|
||||
&Asked::connect(&method),
|
||||
"session_token",
|
||||
StatusCode::FORBIDDEN,
|
||||
"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) {
|
||||
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!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP.");
|
||||
}
|
||||
@@ -1166,14 +1531,10 @@ pub fn run(
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut root: Option<PathBuf> = None;
|
||||
let mut import_roots: Vec<PathBuf> = Vec::new();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--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" => {
|
||||
print_usage(prog);
|
||||
return Ok(());
|
||||
@@ -1240,14 +1601,8 @@ pub fn run(
|
||||
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()))?;
|
||||
}
|
||||
let import_roots = if import_roots.is_empty() {
|
||||
ImportRoots::defaults()
|
||||
} else {
|
||||
ImportRoots::new(import_roots)
|
||||
};
|
||||
let books = Arc::new(Books {
|
||||
root: root.clone(),
|
||||
import_roots,
|
||||
held: RwLock::new(BTreeMap::new()),
|
||||
sessions: CancellationToken::new(),
|
||||
closing: CancellationToken::new(),
|
||||
@@ -1326,9 +1681,37 @@ async fn serve(
|
||||
}
|
||||
let held: Vec<String> = books.held().keys().cloned().collect();
|
||||
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()
|
||||
.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)
|
||||
.with_state(books.clone());
|
||||
// The HTTP side keeps answering (refusing, once closing) until the
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ pub const TABLE: [(&str, Needs); 109] = [
|
||||
("books.assign_role", P),
|
||||
("books.set_tax_category", W),
|
||||
("books.import_chart_csv", W),
|
||||
("books.import_local_file", W),
|
||||
("books.begin_upload", W),
|
||||
("books.export", R),
|
||||
("books.register_asset", W),
|
||||
("books.asset_schedule", R),
|
||||
|
||||
+374
-268
@@ -32,7 +32,7 @@
|
||||
//! ```
|
||||
|
||||
use crate::handbook;
|
||||
use crate::local_files::{ImportRoots, Places, Protected, resolved};
|
||||
use crate::paths::resolved;
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use crate::settings::{Live, Settings};
|
||||
use crate::tin_files;
|
||||
@@ -57,9 +57,6 @@ use rmcp::{ServerHandler, tool, tool_handler, tool_router};
|
||||
use rust_decimal::Decimal;
|
||||
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.
|
||||
/// `Store` is `!Sync`, so access is serialized through a mutex; the server
|
||||
/// 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 {
|
||||
store: Arc<SharedStore>,
|
||||
settings: Arc<Mutex<Live>>,
|
||||
import_roots: Arc<ImportRoots>,
|
||||
layout: Arc<Layout>,
|
||||
/// The book's access log, for books served from the data root: every
|
||||
/// call is then checked against its caller's token and recorded
|
||||
/// (access.rs). None for a books dir served over stdio.
|
||||
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.
|
||||
@@ -157,6 +156,31 @@ pub struct 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.
|
||||
pub fn new(store: Store) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
@@ -200,12 +224,29 @@ impl SharedStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a file with full TINs isn't written into an import root.
|
||||
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";
|
||||
/// A filed file's name and type: those given, else the upload's own.
|
||||
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
|
||||
/// 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.
|
||||
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,
|
||||
..Settings::default()
|
||||
}))),
|
||||
import_roots: Arc::new(ImportRoots::defaults()),
|
||||
layout: Arc::new(Layout::BooksDir),
|
||||
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
|
||||
/// `log` (access.rs).
|
||||
pub fn with_access(mut self, log: Arc<ledger_store::access::AccessLog>) -> Self {
|
||||
@@ -575,12 +622,6 @@ impl BooksServer {
|
||||
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
|
||||
/// (crate::settings), shared with the other sessions holding `live`.
|
||||
pub fn with_live_settings(mut self, live: Arc<Mutex<Live>>) -> Self {
|
||||
@@ -655,7 +696,7 @@ impl BooksServer {
|
||||
|
||||
/// Every tool the server serves.
|
||||
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
|
||||
@@ -902,20 +943,8 @@ impl BooksServer {
|
||||
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,
|
||||
/// 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
|
||||
/// the vault holds or one printed as an SSN or EIN is (tin_files.rs
|
||||
/// INVARIANT 3), or text probing for one (INVARIANT 4).
|
||||
@@ -963,62 +992,78 @@ impl BooksServer {
|
||||
.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
|
||||
/// the books (outside_books) and outside every import root, so it can
|
||||
/// never be filed back in as a receipt and read out again.
|
||||
/// the books (outside_books). (It can't be filed back in unnoticed:
|
||||
/// tin_files.rs records it.)
|
||||
fn tin_file_dest(&self, store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dest = outside_books(&self.layout.books_dir(store), path)?;
|
||||
self.outside_import_roots(&dest)?;
|
||||
Ok(dest)
|
||||
outside_books(&self.layout.books_dir(store), path)
|
||||
}
|
||||
|
||||
fn outside_import_roots(&self, dest: &std::path::Path) -> Result<(), String> {
|
||||
if self.import_roots.contains(dest) {
|
||||
return Err(TIN_FILE_IN_ROOT.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a W-9 file (PDF, PNG, or JPEG, 25 MB at most) for the vault,
|
||||
/// through the import roots' lockdown like any local file: the type
|
||||
/// is judged by where the path really leads (a link named w9.pdf to
|
||||
/// another file is that file).
|
||||
fn read_w9_file(&self, path: &str) -> Result<ledger_vault::W9File, String> {
|
||||
let (canonical, bytes) = self.read_local(path)?;
|
||||
/// The signed W-9 a call gives (an upload, or inline), for the vault:
|
||||
/// a PDF, PNG, or JPEG by its own first bytes, whatever it is called.
|
||||
fn w9_file(
|
||||
&self,
|
||||
p: &SetW9Params,
|
||||
) -> Result<(ledger_vault::W9File, Option<ledger_store::uploads::Claim>), String> {
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id: p.w9_upload_id.as_deref(),
|
||||
content_base64: p.w9_content_base64.as_deref(),
|
||||
sha256: p.w9_sha256.as_deref(),
|
||||
purposes: files::W9,
|
||||
})?;
|
||||
// Not recorded here: set_w9 records it once the call can only
|
||||
// succeed, just before the vault write (tin_files.rs INVARIANT 1),
|
||||
// so a refused call never marks an ordinary file.
|
||||
let mime_type = match canonical
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
_ => return Err("w9_file_path is a PDF, PNG, or JPEG (.pdf, .png, .jpg)".into()),
|
||||
let (mime_type, ext) = if file.bytes.starts_with(b"%PDF-") {
|
||||
("application/pdf", "pdf")
|
||||
} else if file.bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
("image/png", "png")
|
||||
} else if file.bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
("image/jpeg", "jpg")
|
||||
} else {
|
||||
return Err("the W-9 is a PDF, PNG, or JPEG".into());
|
||||
};
|
||||
Ok(ledger_vault::W9File {
|
||||
file_name: canonical
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "w9".into()),
|
||||
mime_type: mime_type.to_string(),
|
||||
bytes,
|
||||
})
|
||||
let file_name = p
|
||||
.w9_file_name
|
||||
.clone()
|
||||
.or(file.file_name)
|
||||
.unwrap_or_else(|| format!("w9.{ext}"));
|
||||
if let Some(why) = ledger_store::uploads::file_name_problem(&file_name) {
|
||||
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
|
||||
@@ -1920,56 +1965,6 @@ fn known_tin_last4(store: &Store) -> Vec<u32> {
|
||||
.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.
|
||||
fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool {
|
||||
resolved(path).starts_with(resolved(dir))
|
||||
@@ -2719,6 +2714,10 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa
|
||||
}
|
||||
|
||||
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
|
||||
/// 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) };
|
||||
@@ -2892,6 +2891,8 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
|
||||
|
||||
#[path = "tools/access.rs"]
|
||||
mod access_tools;
|
||||
#[path = "tools/files.rs"]
|
||||
mod files;
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
pub use kept::SettingKeyOut;
|
||||
@@ -3548,7 +3549,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.attach_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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -3560,17 +3561,6 @@ impl BooksServer {
|
||||
params: Parameters<AttachReceiptParams>,
|
||||
) -> Result<Json<AttachReceiptOut>, String> {
|
||||
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()?;
|
||||
if let Some(e) = p.entry_id
|
||||
&& store.ledger().get_entry(e).is_none()
|
||||
@@ -3578,16 +3568,26 @@ impl BooksServer {
|
||||
return Err(format!("unknown entry {e}"));
|
||||
}
|
||||
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
|
||||
.attach_receipt(
|
||||
p.entry_id,
|
||||
&p.file_name,
|
||||
&p.mime_type,
|
||||
&file_name,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
p.description.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
files::filed(claim);
|
||||
for sub in link_to {
|
||||
store
|
||||
.link_attachment(attachment_id, sub)
|
||||
@@ -3604,7 +3604,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.file_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(
|
||||
read_only_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 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
|
||||
.file_document(
|
||||
&p.category,
|
||||
&p.file_name,
|
||||
&p.mime_type,
|
||||
&file_name,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
p.description.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
files::filed(claim);
|
||||
for sub in link_to {
|
||||
store
|
||||
.link_attachment(attachment_id, sub)
|
||||
@@ -3924,7 +3922,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.get_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)
|
||||
)]
|
||||
async fn get_receipt(
|
||||
@@ -3932,12 +3930,54 @@ impl BooksServer {
|
||||
params: Parameters<GetReceiptParams>,
|
||||
) -> Result<Json<GetReceiptOut>, String> {
|
||||
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 meta = find_attachment(&store, p.attachment_id)?;
|
||||
let bytes = store
|
||||
.attachment_bytes(p.attachment_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let book = store
|
||||
.data_dir()
|
||||
.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 {
|
||||
download,
|
||||
links: links_out(&store, meta.id)?,
|
||||
description: meta.description.clone(),
|
||||
attachment_id: meta.id,
|
||||
@@ -3947,10 +3987,7 @@ impl BooksServer {
|
||||
file_name: meta.file_name,
|
||||
mime_type: meta.mime_type,
|
||||
size_bytes: meta.size_bytes,
|
||||
content_base64: base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&bytes,
|
||||
),
|
||||
content_base64,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4413,7 +4450,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.import_bank_csv",
|
||||
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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -4435,8 +4472,8 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
};
|
||||
let txns =
|
||||
ledger_import::parse_bank_csv_with(&p.csv_text, order).map_err(|e| e.to_string())?;
|
||||
let (csv, claim) = self.csv_given(p.csv_text.as_deref(), p.upload_id.as_deref())?;
|
||||
let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?;
|
||||
let mut store = self.lock()?;
|
||||
let bank = store
|
||||
.ledger()
|
||||
@@ -4507,6 +4544,8 @@ impl BooksServer {
|
||||
.map_err(|e| format!("{} {}: {e}", txn.date, txn.description));
|
||||
e?;
|
||||
}
|
||||
// Rehearsed whole: the upload is used up as the writing begins.
|
||||
files::filed(claim);
|
||||
let mut imported = Vec::new();
|
||||
for txn in txns {
|
||||
let (offset_id, offset_code) = offset_for(&txn.description);
|
||||
@@ -5578,7 +5617,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.import_chart_csv",
|
||||
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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -5590,7 +5629,8 @@ impl BooksServer {
|
||||
params: Parameters<ImportChartCsvParams>,
|
||||
) -> Result<Json<ImportChartCsvOut>, String> {
|
||||
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 parse_type = |t: &str| match t {
|
||||
"asset" => Ok(AccountType::Asset),
|
||||
@@ -5633,6 +5673,8 @@ impl BooksServer {
|
||||
.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();
|
||||
for row in rows {
|
||||
let account_type = match row.account_type.as_str() {
|
||||
@@ -5668,71 +5710,6 @@ impl BooksServer {
|
||||
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(
|
||||
name = "books.export",
|
||||
title = "Export a report",
|
||||
@@ -7097,7 +7074,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_w9",
|
||||
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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
@@ -7106,7 +7083,14 @@ impl BooksServer {
|
||||
)]
|
||||
async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> {
|
||||
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) {
|
||||
(Some(t), Some(k)) => Some(
|
||||
ledger_vault::Tin::new(t.0.trim(), parse_tin_type(k)?)
|
||||
@@ -7159,11 +7143,12 @@ impl BooksServer {
|
||||
vault_record: None,
|
||||
})
|
||||
};
|
||||
let w9_file = p
|
||||
.w9_file_path
|
||||
.as_deref()
|
||||
.map(|path| self.read_w9_file(path))
|
||||
.transpose()?;
|
||||
let (w9_file, mut w9_claim) = if w9_given {
|
||||
let (file, claim) = self.w9_file(&p)?;
|
||||
(Some(file), claim)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
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())?),
|
||||
None => None,
|
||||
};
|
||||
// The W-9 is in the vault: its upload is used up.
|
||||
files::filed(w9_claim.take());
|
||||
match tin_hash {
|
||||
Some(h) => Some(h),
|
||||
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())?
|
||||
.ok_or_else(|| {
|
||||
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)?;
|
||||
@@ -8500,11 +8487,6 @@ impl BooksServer {
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
if p.include_tins
|
||||
&& let Some(d) = &dest
|
||||
{
|
||||
self.outside_import_roots(d)?;
|
||||
}
|
||||
let meta = self.report_meta(&store);
|
||||
let ledger = store.ledger();
|
||||
// The vault, opened once and never creating a key: locked, each
|
||||
@@ -9588,7 +9570,10 @@ impl BooksServer {
|
||||
.parse()
|
||||
.map_err(|_| format!("bad receipt resource URI: {uri}"))?;
|
||||
let Json(out) = self
|
||||
.get_receipt(Parameters(GetReceiptParams { attachment_id: id }))
|
||||
.get_receipt(Parameters(GetReceiptParams {
|
||||
attachment_id: id,
|
||||
view: None,
|
||||
}))
|
||||
.await?;
|
||||
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 || {
|
||||
Box::pin(async move {
|
||||
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.
|
||||
let digest = running_call
|
||||
.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]
|
||||
fn the_upload_limit_is_25_mib() {
|
||||
assert_eq!(MAX_UPLOAD_BYTES, 25 * 1024 * 1024);
|
||||
assert_eq!(MAX_UPLOAD_BYTES, 26_214_400);
|
||||
fn the_file_limits_are_the_plans() {
|
||||
assert_eq!(ledger_store::uploads::MAX_UPLOAD, 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
|
||||
@@ -10450,7 +10445,7 @@ mod tests {
|
||||
("books.assign_role", D),
|
||||
("books.set_tax_category", W),
|
||||
("books.import_chart_csv", W),
|
||||
("books.import_local_file", W),
|
||||
("books.begin_upload", W),
|
||||
("books.export", R),
|
||||
("books.register_asset", W),
|
||||
("books.asset_schedule", R),
|
||||
@@ -10577,6 +10572,10 @@ mod tests {
|
||||
"ledger-import xlsx",
|
||||
include_str!("../../ledger-import/src/xlsx.rs"),
|
||||
),
|
||||
(
|
||||
"ledger-store uploads",
|
||||
include_str!("../../ledger-store/src/uploads.rs"),
|
||||
),
|
||||
] {
|
||||
let mut rest = src;
|
||||
let mut n = 0;
|
||||
@@ -10589,6 +10588,18 @@ mod tests {
|
||||
}
|
||||
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 {
|
||||
seq: 7,
|
||||
event_type: "settings.set".into(),
|
||||
@@ -10605,25 +10616,11 @@ mod tests {
|
||||
));
|
||||
for (what, text) in [
|
||||
("STOPPED", STOPPED),
|
||||
("TIN_FILE_IN_ROOT", TIN_FILE_IN_ROOT),
|
||||
("KEY_IN_BOOKS", KEY_IN_BOOKS),
|
||||
("VAULT_IN_HISTORY", VAULT_IN_HISTORY),
|
||||
("INSIDE_BOOKS", INSIDE_BOOKS),
|
||||
("NO_FOLDER", NO_FOLDER),
|
||||
("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_HELD", tin_files::TIN_HELD),
|
||||
("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]
|
||||
fn tool_text_lint() {
|
||||
const NEVER: &[&str] = &[
|
||||
@@ -10793,6 +10853,13 @@ mod tests {
|
||||
"read the file",
|
||||
"open the file",
|
||||
"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",
|
||||
];
|
||||
// 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| {
|
||||
text.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,7 @@ pub struct Vendor1099Params {
|
||||
pub include_tins: bool,
|
||||
/// Also write the worksheet as CSV to this local path (TINs included
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -868,8 +868,12 @@ pub struct CashFlowOut {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ImportBankCsvParams {
|
||||
/// The CSV text (QBO bank upload layout: Date,Description,Amount, or
|
||||
/// Date,Description, money out, money in).
|
||||
pub csv_text: String,
|
||||
/// Date,Description, money out, money in)…
|
||||
#[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).
|
||||
pub bank_account_code: String,
|
||||
/// Account code for the default offset (e.g. an expense account).
|
||||
@@ -923,8 +927,12 @@ pub struct ImportBankCsvOut {
|
||||
/// Parameters for `books.import_chart_csv`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ImportChartCsvParams {
|
||||
/// The CSV text: code,name,type,parent_code with a header row.
|
||||
pub csv_text: String,
|
||||
/// The CSV text: code,name,type,parent_code with a header row…
|
||||
#[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`.
|
||||
@@ -936,41 +944,6 @@ pub struct ImportChartCsvOut {
|
||||
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`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BalanceSheetParams {
|
||||
@@ -1270,12 +1243,23 @@ pub struct AttachReceiptParams {
|
||||
/// Entry id to attach to. Omit to file unlinked, then link later with
|
||||
/// `books.link_receipt`.
|
||||
pub entry_id: Option<u64>,
|
||||
/// Original file name, e.g. "receipt.png".
|
||||
pub file_name: String,
|
||||
/// MIME type, e.g. "image/png".
|
||||
pub mime_type: String,
|
||||
/// File bytes, base64-encoded. Max 25 MB.
|
||||
pub content_base64: String,
|
||||
/// Original file name, e.g. "receipt.png" (an upload's own, if not
|
||||
/// given).
|
||||
#[serde(default)]
|
||||
pub file_name: Option<String>,
|
||||
/// MIME type, e.g. "image/png" (an upload's own, if not given).
|
||||
#[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.
|
||||
pub description: Option<String>,
|
||||
/// 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,
|
||||
/// photo, warranty, import_source, other.
|
||||
pub category: String,
|
||||
/// Original file name.
|
||||
pub file_name: String,
|
||||
/// MIME type.
|
||||
pub mime_type: String,
|
||||
/// File bytes, base64-encoded. Max 25 MB.
|
||||
pub content_base64: String,
|
||||
/// Original file name (an upload's own, if not given).
|
||||
#[serde(default)]
|
||||
pub file_name: Option<String>,
|
||||
/// MIME type (an upload's own, if not given).
|
||||
#[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.
|
||||
pub description: Option<String>,
|
||||
/// Subjects to link it to in the same call (each journaled as its own
|
||||
@@ -1421,10 +1415,16 @@ pub struct ListReceiptsOut {
|
||||
pub struct GetReceiptParams {
|
||||
/// Attachment id.
|
||||
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
|
||||
/// hash-verified on read.
|
||||
/// Result of `books.get_receipt`: what it is and what it's linked to, and
|
||||
/// with view `bytes`, its bytes (hash-verified on read) or the command that
|
||||
/// saves a copy.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct GetReceiptOut {
|
||||
/// Attachment id.
|
||||
@@ -1445,8 +1445,14 @@ pub struct GetReceiptOut {
|
||||
pub description: Option<String>,
|
||||
/// Everything it is linked to (the entry it is filed on first).
|
||||
pub links: Vec<SubjectOut>,
|
||||
/// File bytes, base64-encoded.
|
||||
pub content_base64: String,
|
||||
/// Its bytes, base64-encoded: with view `bytes`, for a file of at most
|
||||
/// 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,
|
||||
/// 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
|
||||
/// readable by its owner only. With TINs, never inside the books dir
|
||||
/// or the server's import folders.
|
||||
/// readable by its owner only. With TINs, never inside the books dir.
|
||||
pub save_to: Option<String>,
|
||||
}
|
||||
|
||||
@@ -2727,11 +2732,22 @@ pub struct SetW9Params {
|
||||
pub tin: Option<SecretTin>,
|
||||
/// The TIN's type: ssn, ein, or itin. Required with tin.
|
||||
pub tin_type: Option<String>,
|
||||
/// A local PDF, PNG, or JPEG of the signed W-9, in the server's import
|
||||
/// folders (by default your downloads folder): stored only in the
|
||||
/// vault (it shows the full TIN), never with the receipts and
|
||||
/// documents. Read it back with `books.get_w9`.
|
||||
pub w9_file_path: Option<String>,
|
||||
/// The signed W-9 itself (a PDF, PNG, or JPEG): an upload made with
|
||||
/// books.begin_upload. Stored only in the vault (it shows the full
|
||||
/// TIN), never with the receipts and documents.
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -2807,8 +2823,7 @@ pub struct GetW9Params {
|
||||
/// The party: id or name.
|
||||
pub party: String,
|
||||
/// 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
|
||||
/// TIN). An existing file is refused.
|
||||
/// (the file shows the full TIN). An existing file is refused.
|
||||
pub save_to: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,9 @@ impl Drop for Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The places one run uses: the books, the vault key, the import root, the
|
||||
/// folder the server runs in (where a relative path lands).
|
||||
/// The places one run uses: the books, the vault key, a folder of files on
|
||||
/// their way in (as a downloads folder), the folder the server runs in
|
||||
/// (where a relative path lands).
|
||||
struct Places {
|
||||
base: PathBuf,
|
||||
books: PathBuf,
|
||||
@@ -130,8 +131,6 @@ impl Mcp {
|
||||
.arg(p.books.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(p.books.join("wanderbooks.toml"))
|
||||
.arg("--import-root")
|
||||
.arg(&p.inbox)
|
||||
.current_dir(&p.sandbox)
|
||||
.env("HOME", p.base.join("home"))
|
||||
.env("XDG_CONFIG_HOME", p.base.join("config"))
|
||||
@@ -256,7 +255,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0"}),
|
||||
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a receipt")}),
|
||||
);
|
||||
for (date, dr, cr, amount) in [
|
||||
("2026-01-02", "1000", "3000", "5000.00"),
|
||||
@@ -279,7 +279,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(pdf.as_bytes())}),
|
||||
"content_base64": b64(pdf.as_bytes()),
|
||||
"sha256": ledger_store::journal::sha256_hex(pdf.as_bytes())}),
|
||||
);
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
@@ -321,7 +322,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "s.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ="}),
|
||||
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ=",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a statement")}),
|
||||
);
|
||||
let _ = p;
|
||||
}
|
||||
@@ -500,8 +502,8 @@ fn hostile_numbers() -> Vec<Value> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Paths a tool may be given: inside the sandbox, the import root, the
|
||||
/// books, relative ones, odd ones. Never anywhere a real file lives.
|
||||
/// Paths a tool may be given: inside the sandbox, the inbox, the books,
|
||||
/// relative ones, odd ones. Never anywhere a real file lives.
|
||||
fn hostile_paths(p: &Places) -> Vec<String> {
|
||||
let s = |p: &Path| p.to_str().unwrap().to_string();
|
||||
vec![
|
||||
@@ -618,17 +620,20 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
|
||||
.boxed();
|
||||
}
|
||||
if n.contains("base64") {
|
||||
likely.extend(
|
||||
[
|
||||
"JVBERi0xLjQ=",
|
||||
"",
|
||||
"!!!not base64",
|
||||
"AAAA",
|
||||
"NTcxLTkzLTI4NDY=",
|
||||
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
likely.extend(INLINE_FILES.map(String::from));
|
||||
}
|
||||
// A file's SHA-256: of one of the files above (so some calls carry a
|
||||
// matching pair and go on past the check), or not.
|
||||
if n.contains("sha256") {
|
||||
use base64::Engine;
|
||||
likely.extend(INLINE_FILES.iter().filter_map(|f| {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(f)
|
||||
.ok()
|
||||
.map(|b| ledger_store::journal::sha256_hex(&b))
|
||||
}));
|
||||
likely.push("0".repeat(64));
|
||||
likely.push("not hex".into());
|
||||
}
|
||||
let pick = |pred: fn(&str) -> bool| -> Vec<String> {
|
||||
hostile_strings().into_iter().filter(|s| pred(s)).collect()
|
||||
@@ -694,6 +699,16 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
/// Files given inline (base64), hostile and not.
|
||||
const INLINE_FILES: [&str; 6] = [
|
||||
"JVBERi0xLjQ=",
|
||||
"",
|
||||
"!!!not base64",
|
||||
"AAAA",
|
||||
"NTcxLTkzLTI4NDY=",
|
||||
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
|
||||
];
|
||||
|
||||
/// An object's value: each property present or not (required ones mostly
|
||||
/// present), and now and then a field no schema names.
|
||||
fn object_for(
|
||||
@@ -1144,7 +1159,8 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
|
||||
&mut m,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "h.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgaHVnZQ=="}),
|
||||
"content_base64": "JVBERi0xLjQgaHVnZQ==",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 huge")}),
|
||||
);
|
||||
pinned(
|
||||
&mut m,
|
||||
|
||||
@@ -119,7 +119,8 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
|
||||
c,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": format!("{tag}.pdf"),
|
||||
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes())}),
|
||||
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes()),
|
||||
"sha256": ledger_store::journal::sha256_hex(tag.as_bytes())}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -126,8 +126,7 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's dir and what sits beside it (`.inbox`, `.elsewhere.txt`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
@@ -146,11 +145,6 @@ impl AsRef<std::path::Path> for TestDir {
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
for beside in [".inbox", ".elsewhere.txt"] {
|
||||
let p = std::path::PathBuf::from(format!("{}{beside}", self.0.display()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +179,9 @@ fn mcp_end_to_end_over_stdio() {
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
@@ -267,6 +264,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
"file_name": "rent.png",
|
||||
"mime_type": "image/png",
|
||||
"content_base64": png,
|
||||
"sha256": sha(b"fake-png-bytes"),
|
||||
}),
|
||||
);
|
||||
assert_eq!(attached["deduplicated"], false);
|
||||
@@ -461,6 +459,7 @@ fn mcp_assets_end_to_end_over_stdio() {
|
||||
"file_name": "bill-of-sale.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"fake bill of sale"),
|
||||
"sha256": sha(b"fake bill of sale"),
|
||||
}),
|
||||
);
|
||||
let aid = bill["attachment_id"].as_u64().unwrap();
|
||||
@@ -628,7 +627,8 @@ fn mcp_receipt_corrections_over_stdio() {
|
||||
let att = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
|
||||
"content_base64": base64_like(receipt)}),
|
||||
"content_base64": base64_like(receipt),
|
||||
"sha256": sha(receipt)}),
|
||||
);
|
||||
let posted = mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -655,7 +655,8 @@ fn mcp_receipt_corrections_over_stdio() {
|
||||
let pdf = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"pdf-bytes")}),
|
||||
"content_base64": base64_like(b"pdf-bytes"),
|
||||
"sha256": sha(b"pdf-bytes")}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -782,46 +783,6 @@ fn mcp_resources_over_stdio() {
|
||||
assert!(resp.get("error").is_some());
|
||||
}
|
||||
|
||||
/// `--import-root` replaces the default import root (the downloads
|
||||
/// folder): a file in the named folder is filed; one beside it is not.
|
||||
#[test]
|
||||
fn import_root_flag_replaces_the_defaults() {
|
||||
let dir = test_dir("import-root");
|
||||
let inbox = dir.with_extension("inbox");
|
||||
std::fs::create_dir_all(&inbox).unwrap();
|
||||
let inside = inbox.join("receipt.txt");
|
||||
let temp = dir.with_extension("elsewhere.txt");
|
||||
for p in [&inside, &temp] {
|
||||
std::fs::write(p, b"a receipt").unwrap();
|
||||
}
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--import-root"), inbox.as_os_str()],
|
||||
);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
let ok = mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": inside.to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert_eq!(ok["file_name"], "receipt.txt");
|
||||
let why = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": temp.to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert!(
|
||||
why.contains("no such file in the server's import folders"),
|
||||
"{why}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a session: initialize, then initialized.
|
||||
fn init(mcp: &mut Mcp) -> Value {
|
||||
let init = mcp.request(
|
||||
@@ -836,173 +797,25 @@ fn init(mcp: &mut Mcp) -> Value {
|
||||
init
|
||||
}
|
||||
|
||||
/// Through the real tools, inside a root (local_files.rs INVARIANTS 1-3,
|
||||
/// Protected::of; S1 and S5 of the P0 review, I of round 2): the key
|
||||
/// file in force, moved into the root after the server started (a root
|
||||
/// holding the books or a key is refused at startup: startup.rs), is
|
||||
/// sealed with what its link leads to and their folders, but never the
|
||||
/// root itself; links in the root to the journal or a vault record lead
|
||||
/// outside it, and get the one refusal a missing file gets (INVARIANT 2).
|
||||
/// books.import_local_file and books.set_w9's w9_file_path agree.
|
||||
#[test]
|
||||
fn import_local_file_and_set_w9_refuse_the_key_vault_and_journal_inside_a_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = test_dir("sealed-in-root");
|
||||
let root = dir.join("root");
|
||||
let books = dir.join("books");
|
||||
let data = books.join("books-data");
|
||||
let tray = root.join("drop");
|
||||
let config = dir.join("config");
|
||||
for d in [
|
||||
&data,
|
||||
&tray,
|
||||
&root.join("alias"),
|
||||
&root.join("hidden"),
|
||||
&config.join("wanderbooks"),
|
||||
] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("hidden/real.key"), "ab".repeat(32)).unwrap();
|
||||
std::fs::write(root.join("hidden/notes.pdf"), b"%PDF beside the key").unwrap();
|
||||
std::fs::write(root.join("loose.key"), "ef".repeat(32)).unwrap();
|
||||
symlink(root.join("hidden/real.key"), root.join("alias/vault.key")).unwrap();
|
||||
std::fs::write(config.join("wanderbooks/vault.key"), "cd".repeat(32)).unwrap();
|
||||
let settings = |key: &std::path::Path| {
|
||||
std::fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
format!(
|
||||
"[company]\nname = \"Sealed Co\"\n[vault]\nkey_file = \"{}\"\n",
|
||||
key.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
// Started with the default key (outside the root), so it starts.
|
||||
settings(&config.join("wanderbooks/vault.key"));
|
||||
std::fs::write(tray.join("receipt.pdf"), b"%PDF a receipt").unwrap();
|
||||
std::fs::write(tray.join("w9.pdf"), b"%PDF\xff W-9 scan").unwrap();
|
||||
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
|
||||
let mut mcp = Mcp::spawn_env(&data, &root_arg, &[("XDG_CONFIG_HOME", &config)]);
|
||||
init(&mut mcp);
|
||||
mcp.call(
|
||||
"books.add_party",
|
||||
json!({"name": "Acme", "roles": ["contractor"]}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.set_tin",
|
||||
json!({"party": "Acme", "tin": "291-12-2846", "tin_type": "ssn"}),
|
||||
);
|
||||
let journal = data.join(ledger_store::JOURNAL_FILE);
|
||||
let vault_record = books
|
||||
.join("vault")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.find(|p| p.extension().is_some_and(|e| e == "bin"))
|
||||
.unwrap();
|
||||
for (name, to) in [
|
||||
("journal-link.pdf", journal.clone()),
|
||||
("record-link.pdf", vault_record.clone()),
|
||||
("default-key-link.pdf", config.join("wanderbooks/vault.key")),
|
||||
("dangling-link.pdf", config.join("wanderbooks/missing.key")),
|
||||
] {
|
||||
symlink(&to, tray.join(name)).unwrap();
|
||||
}
|
||||
// The key in force moves into the root (a link, alias/vault.key ->
|
||||
// hidden/real.key); the settings reload.
|
||||
settings(&root.join("alias/vault.key"));
|
||||
let refuse = |mcp: &mut Mcp, path: &std::path::Path, why: &str| {
|
||||
let path = path.to_str().unwrap();
|
||||
let err = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": path, "category": "document"}),
|
||||
);
|
||||
assert!(err.contains(why), "{path}: {err}");
|
||||
assert!(!err.contains(dir.to_str().unwrap()), "names a path: {err}");
|
||||
let err = mcp.call_err(
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn", "w9_file_path": path}),
|
||||
);
|
||||
assert!(err.contains(why), "{path}: {err}");
|
||||
};
|
||||
for p in [
|
||||
root.join("hidden/real.key"),
|
||||
root.join("hidden/notes.pdf"),
|
||||
root.join("alias/vault.key"),
|
||||
] {
|
||||
refuse(&mut mcp, &p, "belongs to the books or the TIN vault");
|
||||
}
|
||||
for p in [
|
||||
journal.clone(),
|
||||
vault_record,
|
||||
config.join("wanderbooks/vault.key"),
|
||||
tray.join("journal-link.pdf"),
|
||||
tray.join("record-link.pdf"),
|
||||
tray.join("default-key-link.pdf"),
|
||||
tray.join("dangling-link.pdf"),
|
||||
tray.join("missing.pdf"),
|
||||
] {
|
||||
refuse(
|
||||
&mut mcp,
|
||||
&p,
|
||||
"no such file in the server's import folders (by default your downloads folder)",
|
||||
);
|
||||
}
|
||||
// A key kept loose in the root itself seals the key, not the root.
|
||||
settings(&root.join("loose.key"));
|
||||
refuse(
|
||||
&mut mcp,
|
||||
&root.join("loose.key"),
|
||||
"belongs to the books or the TIN vault",
|
||||
);
|
||||
// Ordinary files in the root are filed (with the key the vault was
|
||||
// made with back in force).
|
||||
mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": tray.join("receipt.pdf").to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
// A link is filed under the name and type of the file it leads to.
|
||||
std::fs::write(tray.join("photo.png"), b"\x89PNG a photo").unwrap();
|
||||
symlink(tray.join("photo.png"), tray.join("named-like.pdf")).unwrap();
|
||||
let filed = mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": tray.join("named-like.pdf").to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert_eq!(filed["file_name"], "photo.png");
|
||||
let back = mcp.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": filed["attachment_id"]}),
|
||||
);
|
||||
assert_eq!(back["mime_type"], "image/png", "{back}");
|
||||
settings(&config.join("wanderbooks/vault.key"));
|
||||
mcp.call(
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_file_path": tray.join("w9.pdf").to_str().unwrap()}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with
|
||||
/// include_tins, books.export_cpa_package with TINs) are never written
|
||||
/// into an import root, and a copy of one moved into a root is refused by
|
||||
/// its bytes: a TIN-bearing file can't be filed back in as a receipt and
|
||||
/// inside the books, and wherever they are written (a downloads folder
|
||||
/// too), each is refused by its bytes when given back to be filed: a
|
||||
/// TIN-bearing file can't be filed back in as a receipt or document and
|
||||
/// read out through books.get_receipt (S7 of the P0 review, 2026-10-16).
|
||||
#[test]
|
||||
fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
let dir = test_dir("tin-files");
|
||||
let root = dir.join("inbox");
|
||||
let out = dir.join("out");
|
||||
let books = dir.join("books");
|
||||
let downloads = dir.join("downloads");
|
||||
let config = dir.join("config");
|
||||
for d in [&root, &out, &config] {
|
||||
for d in [&downloads, &config] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("w9.pdf"), b"%PDF W-9 TIN 291-12-2846").unwrap();
|
||||
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
|
||||
let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
|
||||
let mut mcp = Mcp::spawn_env(
|
||||
&dir.join("books").join("books-data"),
|
||||
&root_arg,
|
||||
&books.join("books-data"),
|
||||
&[],
|
||||
&[("XDG_CONFIG_HOME", &config)],
|
||||
);
|
||||
init(&mut mcp);
|
||||
@@ -1018,61 +831,92 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_file_path": root.join("w9.pdf").to_str().unwrap()}),
|
||||
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
|
||||
);
|
||||
let in_root = |name: &str| root.join(name).to_str().unwrap().to_string();
|
||||
// Inside the books: refused, and nothing written there.
|
||||
let before = std::fs::read_dir(&books).unwrap().count();
|
||||
let in_books = |name: &str| books.join(name).to_str().unwrap().to_string();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": in_root("copy.pdf")}),
|
||||
json!({"party": "Acme", "save_to": in_books("copy.pdf")}),
|
||||
),
|
||||
(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_root("1099.csv")}),
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_books("1099.csv")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_root("cpa.zip")}),
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_books("cpa.zip")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": root.to_str().unwrap()}),
|
||||
json!({"cash_accounts": ["1000"], "save_to": books.to_str().unwrap()}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(
|
||||
why.contains("never saved in the server's import folders"),
|
||||
"{tool}: {why}"
|
||||
);
|
||||
assert!(why.contains("inside the books"), "{tool}: {why}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&root).unwrap().count(),
|
||||
1,
|
||||
"wrote into the root"
|
||||
std::fs::read_dir(&books).unwrap().count(),
|
||||
before,
|
||||
"wrote into the books"
|
||||
);
|
||||
// Saved elsewhere, then moved into the root: refused by its bytes.
|
||||
let w9 = out.join("copy.pdf");
|
||||
// Written to a downloads folder, each is refused by its bytes, by
|
||||
// every way a file is filed.
|
||||
let in_downloads = |name: &str| downloads.join(name).to_str().unwrap().to_string();
|
||||
mcp.call(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": w9.to_str().unwrap()}),
|
||||
json!({"party": "Acme", "save_to": in_downloads("copy.pdf")}),
|
||||
);
|
||||
let csv = out.join("1099.csv");
|
||||
mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}),
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_downloads("1099.csv")}),
|
||||
);
|
||||
for (from, name) in [(&w9, "receipt.pdf"), (&csv, "statement.csv")] {
|
||||
std::fs::copy(from, root.join(name)).unwrap();
|
||||
let why = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": in_root(name), "category": "receipt"}),
|
||||
);
|
||||
assert!(why.contains("wrote with full TINs"), "{why}");
|
||||
mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_downloads("cpa.zip")}),
|
||||
);
|
||||
let written: Vec<std::path::PathBuf> = std::fs::read_dir(&downloads)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
assert_eq!(written.len(), 3, "{written:?}");
|
||||
let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for path in &written {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(&bytes),
|
||||
"sha256": sha(&bytes)}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "statement.csv",
|
||||
"mime_type": "text/csv",
|
||||
"content_base64": base64_like(&bytes),
|
||||
"sha256": sha(&bytes)}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(
|
||||
why.contains("wrote with full TINs"),
|
||||
"{tool} {}: {why}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"a refusal stored something"
|
||||
);
|
||||
// Without TINs, the 1099 worksheet may go anywhere a file may.
|
||||
mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "save_csv_to": in_root("plain.csv")}),
|
||||
json!({"year": 2026, "save_csv_to": in_downloads("plain.csv")}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1317,6 +1161,11 @@ fn handbook_tool_and_resources_over_stdio() {
|
||||
assert!(resp.get("error").is_some());
|
||||
}
|
||||
|
||||
/// The SHA-256 (hex) a file given inline comes with.
|
||||
fn sha(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
fn base64_like(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
@@ -1409,7 +1258,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(desc.as_bytes())}),
|
||||
"content_base64": base64_like(desc.as_bytes()),
|
||||
"sha256": sha(desc.as_bytes())}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -1571,7 +1421,8 @@ fn mcp_period_statements_over_stdio() {
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"r")}),
|
||||
"content_base64": base64_like(b"r"),
|
||||
"sha256": sha(b"r")}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -2047,6 +1898,7 @@ fn mcp_payout_gross_up_over_stdio() {
|
||||
"file_name": "payout.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 statement"),
|
||||
"sha256": sha(b"%PDF-1.4 statement"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
@@ -2133,6 +1985,7 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
"file_name": "invoice.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 invoice"),
|
||||
"sha256": sha(b"%PDF-1.4 invoice"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
|
||||
@@ -56,8 +56,7 @@ impl Drop for Mcp {
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir and the folder beside it (`.inbox`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
@@ -76,7 +75,6 @@ impl AsRef<std::path::Path> for TestDir {
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
let _ = std::fs::remove_dir_all(format!("{}.inbox", self.0.display()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,14 +107,6 @@ fn books_toml(dir: &Path, threshold: &str) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
/// The server's import root, beside the books (never the books dir): files
|
||||
/// given to books.import_local_file.
|
||||
fn inbox(dir: &Path) -> PathBuf {
|
||||
let inbox = dir.with_extension("inbox");
|
||||
std::fs::create_dir_all(&inbox).unwrap();
|
||||
inbox
|
||||
}
|
||||
|
||||
fn data_dir(dir: &Path) -> PathBuf {
|
||||
dir.join("books-data")
|
||||
}
|
||||
@@ -131,8 +121,6 @@ impl Mcp {
|
||||
.arg(data_dir(dir))
|
||||
.arg("--books-toml")
|
||||
.arg(&toml)
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -1737,7 +1725,7 @@ fn post_contribution(m: &mut Mcp) -> u64 {
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64e(b"contribution")}),
|
||||
"content_base64": b64e(b"contribution"), "sha256": sha256(b"contribution")}),
|
||||
);
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
@@ -2118,11 +2106,24 @@ fn resources_read_back_as_the_matching_tools() {
|
||||
let want = m.ok(tool, args);
|
||||
assert_eq!(got, want, "{uri} reads as {tool}");
|
||||
}
|
||||
// A receipt reads as books.get_receipt's meta view: what it is, never
|
||||
// its bytes (those come with view bytes).
|
||||
for (id, bytes) in [(r1, &b"first receipt"[..]), (r2, &b"second receipt"[..])] {
|
||||
let got = read(&mut m, &format!("books://receipts/{id}"));
|
||||
assert_eq!(got["attachment_id"], json!(id));
|
||||
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), bytes);
|
||||
assert!(got.get("content_base64").is_none(), "no bytes: {got}");
|
||||
assert_eq!(got["sha256"], sha256(bytes));
|
||||
assert_eq!(got["size_bytes"], json!(bytes.len()));
|
||||
assert_eq!(
|
||||
got,
|
||||
m.ok("books.get_receipt", json!({"attachment_id": id})),
|
||||
"books://receipts/{id} reads as books.get_receipt"
|
||||
);
|
||||
let full = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(b64d(full["content_base64"].as_str().unwrap()), bytes);
|
||||
}
|
||||
let r = m.rpc("resources/read", json!({"uri": "books://nope"}));
|
||||
let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}"));
|
||||
@@ -2136,8 +2137,8 @@ fn resources_read_back_as_the_matching_tools() {
|
||||
|
||||
/// Receipts and documents round-trip: the bytes come back exactly (SHA-256
|
||||
/// of what was sent), with the name, MIME type, category, entry, and
|
||||
/// description given. Kills: get_receipt's lookup, import_local_file's
|
||||
/// document arm.
|
||||
/// description given; only view bytes carries the bytes. Kills:
|
||||
/// get_receipt's lookup and its views.
|
||||
#[test]
|
||||
fn receipts_and_documents_round_trip() {
|
||||
let dir = test_dir("files");
|
||||
@@ -2150,48 +2151,27 @@ fn receipts_and_documents_round_trip() {
|
||||
let a = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(first), "description": "Receipt ID 1007; Paid from: Bluevine"}),
|
||||
"content_base64": b64e(first), "sha256": sha256(first),
|
||||
"description": "Receipt ID 1007; Paid from: Bluevine"}),
|
||||
);
|
||||
let b = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png",
|
||||
"content_base64": b64e(second), "description": "Operating agreement"}),
|
||||
"content_base64": b64e(second), "sha256": sha256(second),
|
||||
"description": "Operating agreement"}),
|
||||
);
|
||||
// Local files come from outside the books, in the server's import
|
||||
// folder (local_files.rs).
|
||||
let inbox = inbox(&dir);
|
||||
let local_doc = inbox.join("ein-letter.pdf");
|
||||
std::fs::write(&local_doc, b"EIN letter bytes").unwrap();
|
||||
let c = m.ok(
|
||||
"books.import_local_file",
|
||||
json!({"path": local_doc.to_str().unwrap(), "category": "document",
|
||||
"document_category": "tax", "description": "IRS CP 575"}),
|
||||
"books.file_document",
|
||||
json!({"category": "tax", "file_name": "ein-letter.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(b"EIN letter bytes"), "sha256": sha256(b"EIN letter bytes"),
|
||||
"description": "IRS CP 575"}),
|
||||
);
|
||||
assert_eq!(c["file_name"], "ein-letter.pdf");
|
||||
let local_receipt = inbox.join("scan.png");
|
||||
std::fs::write(&local_receipt, b"a scanned receipt").unwrap();
|
||||
let r = m.ok(
|
||||
"books.import_local_file",
|
||||
json!({"path": local_receipt.to_str().unwrap(), "category": "receipt",
|
||||
"entry_id": e, "file_name": "renamed.png", "description": "scan"}),
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "renamed.png", "mime_type": "image/png",
|
||||
"content_base64": b64e(b"a scanned receipt"),
|
||||
"sha256": sha256(b"a scanned receipt"), "description": "scan"}),
|
||||
);
|
||||
assert_eq!(r["file_name"], "renamed.png");
|
||||
m.refuse(
|
||||
"books.import_local_file",
|
||||
json!({"path": local_receipt.to_str().unwrap(), "category": "photo"}),
|
||||
);
|
||||
// Nothing of the books is ever read back in through a path: outside
|
||||
// the import folders, it gets the one refusal any path there gets.
|
||||
let journal = data_dir(&dir).join(ledger_store::JOURNAL_FILE);
|
||||
for p in [journal, dir.join("wanderbooks-1000000.00.toml")] {
|
||||
assert!(p.exists());
|
||||
let why = m.refuse(
|
||||
"books.import_local_file",
|
||||
json!({"path": p.to_str().unwrap(), "category": "document"}),
|
||||
);
|
||||
assert!(why.contains("import folders"), "{why}");
|
||||
assert!(!why.contains(dir.to_str().unwrap()), "names a path: {why}");
|
||||
}
|
||||
|
||||
// (result, bytes, file name, MIME type, entry, description)
|
||||
type Case<'a> = (&'a Value, &'a [u8], &'a str, &'a str, Option<u64>, &'a str);
|
||||
@@ -2233,13 +2213,24 @@ fn receipts_and_documents_round_trip() {
|
||||
for (out, bytes, name, mime, entry, desc) in cases {
|
||||
let id = out["attachment_id"].as_u64().unwrap();
|
||||
assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes");
|
||||
let got = m.ok("books.get_receipt", json!({"attachment_id": id}));
|
||||
let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
|
||||
assert!(
|
||||
meta.get("content_base64").is_none(),
|
||||
"{name}: meta, no bytes"
|
||||
);
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(got["attachment_id"], json!(id));
|
||||
assert_eq!(
|
||||
b64d(got["content_base64"].as_str().unwrap()),
|
||||
bytes,
|
||||
"{name}"
|
||||
);
|
||||
let mut without = got.clone();
|
||||
without.as_object_mut().unwrap().remove("content_base64");
|
||||
assert_eq!(without, meta, "{name}: view bytes is meta and the bytes");
|
||||
assert_eq!(got["sha256"], sha256(bytes));
|
||||
assert_eq!(got["file_name"], name);
|
||||
assert_eq!(got["mime_type"], mime);
|
||||
@@ -2263,19 +2254,26 @@ fn receipts_and_documents_round_trip() {
|
||||
assert_eq!(cat(&b), "legal");
|
||||
assert_eq!(cat(&c), "tax");
|
||||
m.refuse("books.get_receipt", json!({"attachment_id": 999}));
|
||||
for view in ["", "BYTES", "content", "meta "] {
|
||||
let why = m.refuse(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": a["attachment_id"], "view": view}),
|
||||
);
|
||||
assert!(
|
||||
why.contains("meta (the default) or bytes"),
|
||||
"{view:?}: {why}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 25 MB (MiB) is the most a receipt or document may be: exactly the
|
||||
/// limit is accepted through every way in, one byte more refused (checked
|
||||
/// on the local-file path, where it costs no upload). Kills: the limit
|
||||
/// comparisons in attach_receipt, file_document, and import_local_file,
|
||||
/// and import_local_file's limit arithmetic (2 MiB must pass).
|
||||
/// 256 KiB is the most a file may be given inline (plan 2.1): exactly
|
||||
/// the limit is accepted by books.attach_receipt and books.file_document
|
||||
/// and stored whole, one byte more is refused by both, pointing at
|
||||
/// books.begin_upload, and nothing is stored. Kills: the limit comparison
|
||||
/// in take_file.
|
||||
#[test]
|
||||
fn uploads_up_to_25_mib_are_accepted_and_no_more() {
|
||||
const LIMIT: usize = 25 * 1024 * 1024;
|
||||
// Over 50 MB of books: removed however the test ends, with its inbox
|
||||
// (the system temp dir is often RAM, and a mutation run starts this
|
||||
// test hundreds of times).
|
||||
fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
let dir = test_dir("limits");
|
||||
let s = new_store(&dir);
|
||||
drop(s);
|
||||
@@ -2284,18 +2282,19 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
|
||||
let out = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "big.bin", "mime_type": "application/octet-stream",
|
||||
"content_base64": b64e(&at)}),
|
||||
"content_base64": b64e(&at), "sha256": sha256(&at)}),
|
||||
);
|
||||
let at_id = out["attachment_id"].as_u64().unwrap();
|
||||
let doc = vec![b'b'; LIMIT];
|
||||
let out = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "big.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&doc)}),
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&doc),
|
||||
"sha256": sha256(&doc)}),
|
||||
);
|
||||
let doc_id = out["attachment_id"].as_u64().unwrap();
|
||||
let listed = m.ok("books.list_receipts", json!({}));
|
||||
for id in [at_id, doc_id] {
|
||||
for (id, bytes) in [(at_id, &at), (doc_id, &doc)] {
|
||||
let row = listed["receipts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -2304,19 +2303,195 @@ fn uploads_up_to_25_mib_are_accepted_and_no_more() {
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole");
|
||||
// And it comes back inline, whole.
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(&b64d(got["content_base64"].as_str().unwrap()), bytes);
|
||||
}
|
||||
let journal = std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let over = vec![b'c'; LIMIT + 1];
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "over.bin", "mime_type": "application/octet-stream",
|
||||
"content_base64": b64e(&over), "sha256": sha256(&over)}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "over.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&over),
|
||||
"sha256": sha256(&over)}),
|
||||
),
|
||||
] {
|
||||
let why = m.refuse(tool, args);
|
||||
assert!(
|
||||
why.contains("books.begin_upload") && why.contains(&(LIMIT + 1).to_string()),
|
||||
"{tool}: {why}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"a refusal stored something"
|
||||
);
|
||||
}
|
||||
|
||||
for (n, accepted) in [(2 * 1024 * 1024, true), (LIMIT, true), (LIMIT + 1, false)] {
|
||||
let path = inbox(&dir).join(format!("local-{n}.bin"));
|
||||
std::fs::write(&path, vec![b'c' ^ (n % 7) as u8; n]).unwrap();
|
||||
let args = json!({"path": path.to_str().unwrap(), "category": "receipt"});
|
||||
if accepted {
|
||||
m.ok("books.import_local_file", args);
|
||||
} else {
|
||||
m.refuse("books.import_local_file", args);
|
||||
/// The bytes a filing tool takes are inline base64 with its own SHA-256,
|
||||
/// or an upload; never both, never neither. A sha256 missing, or not the
|
||||
/// bytes' own (a byte lost on the way), or base64 that isn't, is refused
|
||||
/// (guard base64_corrupt); inline bytes need their file_name and
|
||||
/// mime_type. Over stdio there are no uploads: an upload_id is refused,
|
||||
/// and books.begin_upload answers only for bytes stored already (the
|
||||
/// attachment holding them). Every refusal stores nothing.
|
||||
#[test]
|
||||
fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
let dir = test_dir("inline");
|
||||
let s = new_store(&dir);
|
||||
drop(s);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
let bytes: &[u8] = b"%PDF-1.4 a receipt";
|
||||
let other: &[u8] = b"%PDF-1.4 a receipt!";
|
||||
let journal = || std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let before = journal();
|
||||
for (tool, base) in [
|
||||
("books.attach_receipt", json!({})),
|
||||
("books.file_document", json!({"category": "other"})),
|
||||
] {
|
||||
let with = |extra: Value| {
|
||||
let mut args = base.clone();
|
||||
for (k, v) in json!({"file_name": "r.pdf", "mime_type": "application/pdf"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.chain(extra.as_object().unwrap())
|
||||
{
|
||||
args[k] = v.clone();
|
||||
}
|
||||
args
|
||||
};
|
||||
for (args, said) in [
|
||||
// Neither, both.
|
||||
(with(json!({})), "books.begin_upload"),
|
||||
(
|
||||
with(json!({"upload_id": "u1", "content_base64": b64e(bytes),
|
||||
"sha256": sha256(bytes)})),
|
||||
"not both",
|
||||
),
|
||||
// The sha256 missing, another file's, not hex; base64 that isn't.
|
||||
(with(json!({"content_base64": b64e(bytes)})), "sha256"),
|
||||
(
|
||||
with(json!({"content_base64": b64e(bytes), "sha256": sha256(other)})),
|
||||
"base64_corrupt",
|
||||
),
|
||||
(
|
||||
with(json!({"content_base64": b64e(bytes), "sha256": "not hex"})),
|
||||
"base64_corrupt",
|
||||
),
|
||||
(
|
||||
with(json!({"content_base64": "!!not base64", "sha256": sha256(bytes)})),
|
||||
"base64_corrupt",
|
||||
),
|
||||
// An upload, over stdio.
|
||||
(with(json!({"upload_id": "u1"})), "takes files inline only"),
|
||||
] {
|
||||
let why = m.refuse(tool, args.clone());
|
||||
assert!(why.contains(said), "{tool} {args}: {why}");
|
||||
}
|
||||
// Inline bytes need their name and type.
|
||||
for missing in ["file_name", "mime_type"] {
|
||||
let mut args = with(json!({"content_base64": b64e(bytes), "sha256": sha256(bytes)}));
|
||||
args.as_object_mut().unwrap().remove(missing);
|
||||
let why = m.refuse(tool, args);
|
||||
assert!(why.contains(missing), "{tool} without {missing}: {why}");
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
// A sha256 given in capitals, or with spaces around it, is the same
|
||||
// hash.
|
||||
let out = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(bytes),
|
||||
"sha256": format!(" {} ", sha256(bytes).to_ascii_uppercase())}),
|
||||
);
|
||||
assert_eq!(out["sha256"], sha256(bytes));
|
||||
let id = out["attachment_id"].as_u64().unwrap();
|
||||
assert_ne!(journal(), before);
|
||||
// begin_upload: the bytes stored already name their attachment; new
|
||||
// ones need the books server.
|
||||
let up = |sha: String| {
|
||||
json!({"sha256": sha, "size": bytes.len(), "mime_type": "application/pdf",
|
||||
"file_name": "r.pdf", "purpose": "receipt"})
|
||||
};
|
||||
let got = m.ok("books.begin_upload", up(sha256(bytes)));
|
||||
assert_eq!(got["existing_attachment_id"], json!(id), "{got}");
|
||||
assert!(got.get("upload_id").is_none(), "{got}");
|
||||
assert!(got.get("command").is_none(), "{got}");
|
||||
let got = m.ok("books.begin_upload", up(sha256(bytes).to_ascii_uppercase()));
|
||||
assert_eq!(got["existing_attachment_id"], json!(id), "{got}");
|
||||
let stored = journal();
|
||||
let why = m.refuse("books.begin_upload", up(sha256(other)));
|
||||
assert!(why.contains("takes files inline only"), "{why}");
|
||||
// The CSV imports take csv_text or an upload, never both, never
|
||||
// neither; an upload over stdio is refused.
|
||||
for (tool, base) in [
|
||||
(
|
||||
"books.import_bank_csv",
|
||||
json!({"bank_account_code": "1000", "default_offset_code": "4000"}),
|
||||
),
|
||||
("books.import_chart_csv", json!({})),
|
||||
] {
|
||||
for (extra, said) in [
|
||||
(json!({}), "csv_text"),
|
||||
(json!({"csv_text": "a,b\n", "upload_id": "u1"}), "not both"),
|
||||
(json!({"upload_id": "u1"}), "takes files inline only"),
|
||||
] {
|
||||
let mut args = base.clone();
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
let why = m.refuse(tool, args.clone());
|
||||
assert!(why.contains(said), "{tool} {args}: {why}");
|
||||
}
|
||||
}
|
||||
assert_eq!(journal(), stored, "a refusal stored something");
|
||||
}
|
||||
|
||||
/// books.get_receipt's view bytes gives the bytes of a file of at most
|
||||
/// 256 KiB; a larger one (filed before, or by an upload) is refused over
|
||||
/// stdio, where there is no download, while its meta view still answers.
|
||||
#[test]
|
||||
fn get_receipt_gives_bytes_up_to_256_kib_over_stdio() {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
let dir = test_dir("get-bytes");
|
||||
let mut s = new_store(&dir);
|
||||
let at = vec![b'x'; LIMIT];
|
||||
let over = vec![b'y'; LIMIT + 1];
|
||||
let (at_id, _, _) = s
|
||||
.attach_receipt(None, "at.bin", "application/octet-stream", &at, None)
|
||||
.unwrap();
|
||||
let (over_id, _, _) = s
|
||||
.attach_receipt(None, "over.bin", "application/octet-stream", &over, None)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": at_id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), at);
|
||||
assert!(got.get("download").is_none(), "{got}");
|
||||
let why = m.refuse(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": over_id, "view": "bytes"}),
|
||||
);
|
||||
assert!(why.contains(&(LIMIT + 1).to_string()), "{why}");
|
||||
let meta = m.ok("books.get_receipt", json!({"attachment_id": over_id}));
|
||||
assert_eq!(meta["size_bytes"], json!(LIMIT + 1));
|
||||
assert_eq!(meta["sha256"], sha256(&over));
|
||||
assert!(meta.get("content_base64").is_none(), "{meta}");
|
||||
assert!(meta.get("download").is_none(), "{meta}");
|
||||
}
|
||||
|
||||
/// Bank CSV import: money in debits the bank, money out credits it; the
|
||||
|
||||
@@ -360,7 +360,9 @@ impl Mcp {
|
||||
self.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"bill of sale"), "description": "Bill of sale"}),
|
||||
"content_base64": b64(b"bill of sale"),
|
||||
"sha256": ledger_store::journal::sha256_hex(b"bill of sale"),
|
||||
"description": "Bill of sale"}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
|
||||
@@ -371,6 +371,11 @@ fn b64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
/// The SHA-256 (hex) a file given inline comes with.
|
||||
fn sha(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
const CHART: &[(&str, &str, &str)] = &[
|
||||
("1000", "Bank", "asset"),
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
@@ -406,7 +411,7 @@ impl Mcp {
|
||||
self.ok(
|
||||
"books.file_document",
|
||||
json!({"category": category, "file_name": format!("{category}.pdf"),
|
||||
"mime_type": "application/pdf", "content_base64": b64(bytes),
|
||||
"mime_type": "application/pdf", "content_base64": b64(bytes), "sha256": sha(bytes),
|
||||
"link_to": link_to}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
@@ -416,7 +421,7 @@ impl Mcp {
|
||||
/// Attach a receipt (to `entry`, or unlinked); its id.
|
||||
fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
|
||||
let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(bytes)});
|
||||
"content_base64": b64(bytes), "sha256": sha(bytes)});
|
||||
if let Some(e) = entry {
|
||||
args["entry_id"] = json!(e);
|
||||
}
|
||||
@@ -787,25 +792,25 @@ fn filing_links_in_the_same_call_all_or_nothing() {
|
||||
m.refuse(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"r"), "link_to": link_to}),
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": link_to}),
|
||||
);
|
||||
}
|
||||
m.refuse(
|
||||
"books.file_document",
|
||||
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
|
||||
"content_base64": b64(b"c"), "sha256": sha(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
|
||||
);
|
||||
// An unknown entry_id is refused before anything is filed.
|
||||
m.refuse(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"r"), "link_to": [{"kind": "company"}]}),
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": [{"kind": "company"}]}),
|
||||
);
|
||||
assert_eq!(count(&mut m), n, "nothing filed");
|
||||
let out = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"r"),
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"),
|
||||
"link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -819,7 +824,7 @@ fn filing_links_in_the_same_call_all_or_nothing() {
|
||||
let doc = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"certificate"), "link_to": [{"kind": "company"}]}),
|
||||
"content_base64": b64(b"certificate"), "sha256": sha(b"certificate"), "link_to": [{"kind": "company"}]}),
|
||||
);
|
||||
assert_eq!(kinds(&doc), ["company"]);
|
||||
assert_eq!(m.count("document.filed"), 1);
|
||||
@@ -849,7 +854,7 @@ fn documents_file_under_the_attachment_kinds() {
|
||||
m.refuse(
|
||||
"books.file_document",
|
||||
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"x")}),
|
||||
"content_base64": b64(b"x"), "sha256": sha(b"x")}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -213,14 +213,6 @@ fn test_dir(name: &str) -> TestDir {
|
||||
|
||||
/// A directory beside the books dir `dir`, outside it (vault keys, files
|
||||
/// with TINs).
|
||||
/// The import root beside the books dir `dir`: files the tests hand the
|
||||
/// server by path (books.import_local_file, books.set_w9 w9_file_path).
|
||||
fn inbox(dir: &Path) -> PathBuf {
|
||||
let d = dir.with_extension("inbox");
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn outside(dir: &Path) -> PathBuf {
|
||||
let d = PathBuf::from(format!("{}-outside", dir.display()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
@@ -260,10 +252,6 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
// Local files come only from here (the default is the downloads
|
||||
// folder): never the books, never where the server writes.
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
// books.show writes a viewer request here; keep it out of the
|
||||
// user's real runtime dir.
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
@@ -548,6 +536,7 @@ impl Mcp {
|
||||
"file_name": "receipt.txt",
|
||||
"mime_type": "text/plain",
|
||||
"content_base64": b64(bytes),
|
||||
"sha256": sha(bytes),
|
||||
});
|
||||
if let Some(e) = entry {
|
||||
args["entry_id"] = json!(e);
|
||||
@@ -660,6 +649,11 @@ fn canon(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The SHA-256 (hex) a file given inline comes with.
|
||||
fn sha(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
@@ -811,12 +805,12 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
|
||||
"tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
let w9_pdf = inbox(&dir).join("landlord-w9.pdf");
|
||||
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 79-9714321").unwrap();
|
||||
let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321";
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
|
||||
"tin": "79-9714321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}),
|
||||
"tin": "79-9714321", "tin_type": "ein",
|
||||
"w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}),
|
||||
);
|
||||
m.ok(
|
||||
"books.get_w9",
|
||||
@@ -889,17 +883,23 @@ fn happy_path(name: &str) -> Mcp {
|
||||
m.ok("books.list_receipts", json!({"include_withdrawn": true}));
|
||||
m.ok("books.list_receipts", json!({"entry_id": e2}));
|
||||
m.ok("books.get_receipt", json!({"attachment_id": r_old}));
|
||||
m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": r_old, "view": "bytes"}),
|
||||
);
|
||||
let doc = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"operating agreement"), "description": "OA"}),
|
||||
"content_base64": b64(b"operating agreement"),
|
||||
"sha256": sha(b"operating agreement"), "description": "OA"}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let filed = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}),
|
||||
"content_base64": b64(b"lease"),
|
||||
"sha256": sha(b"lease"), "link_to": [{"kind": "company"}]}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -915,13 +915,15 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.unlink_attachment",
|
||||
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
|
||||
);
|
||||
// From outside the books, in the server's import folder.
|
||||
let local = inbox(&dir).join("local-receipt.txt");
|
||||
std::fs::write(&local, b"a local receipt").unwrap();
|
||||
m.ok(
|
||||
"books.import_local_file",
|
||||
json!({"path": local.to_str().unwrap(), "category": "receipt"}),
|
||||
// An upload of bytes already stored: begin_upload names the
|
||||
// attachment that holds them (over stdio, the only upload answered).
|
||||
let local = m.attach(None, b"a local receipt");
|
||||
let up = m.ok(
|
||||
"books.begin_upload",
|
||||
json!({"sha256": sha(b"a local receipt"), "size": 15, "mime_type": "text/plain",
|
||||
"file_name": "local-receipt.txt", "purpose": "receipt"}),
|
||||
);
|
||||
assert_eq!(up["existing_attachment_id"], json!(local), "{up}");
|
||||
|
||||
// Reversal: preview, then confirmed.
|
||||
let e3 = m.draft(
|
||||
@@ -1071,7 +1073,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
let source = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
|
||||
"content_base64": b64(csv.as_bytes())}),
|
||||
"content_base64": b64(csv.as_bytes()),
|
||||
"sha256": sha(csv.as_bytes())}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -1092,7 +1095,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
let stmt = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"%PDF statement")}),
|
||||
"content_base64": b64(b"%PDF statement"),
|
||||
"sha256": sha(b"%PDF statement")}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -1572,7 +1576,8 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
|
||||
let out = m.tool(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(bytes.as_bytes())}),
|
||||
"content_base64": b64(bytes.as_bytes()),
|
||||
"sha256": sha(bytes.as_bytes())}),
|
||||
);
|
||||
k.attachments.extend(new_id(&out, "attachment_id"));
|
||||
}
|
||||
@@ -1601,11 +1606,21 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
|
||||
json!({"entry_id": entry.resolve(&k.entries), "date": date, "description": "undo",
|
||||
"confirmed": opt(*confirmed)}),
|
||||
),
|
||||
Op::Attach { entry, content } => (
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": content}),
|
||||
),
|
||||
Op::Attach { entry, content } => {
|
||||
// The bytes' own SHA-256 (content that is no base64 has none).
|
||||
let sha256 = {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(content)
|
||||
.map_or_else(|_| "0".repeat(64), |b| sha(&b))
|
||||
};
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": content,
|
||||
"sha256": sha256}),
|
||||
)
|
||||
}
|
||||
Op::Link { att, entry } => (
|
||||
"books.link_receipt",
|
||||
json!({"attachment_id": att.resolve(&k.attachments),
|
||||
|
||||
@@ -72,7 +72,7 @@ impl Drop for Mcp {
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir and the folders beside it (`-inbox`, `-outside`),
|
||||
/// A test's dir and the folder beside it (`-outside`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
@@ -92,11 +92,7 @@ impl AsRef<std::path::Path> for TestDir {
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
for beside in ["-inbox", "-outside"] {
|
||||
let p = PathBuf::from(format!("{}{beside}", self.0.display()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +115,15 @@ fn outside(dir: &Path) -> PathBuf {
|
||||
d
|
||||
}
|
||||
|
||||
/// The server's import root, beside the books dir `dir`: W-9 files handed
|
||||
/// to books.set_w9 by path. Never where the server writes TINs.
|
||||
fn inbox(dir: &Path) -> PathBuf {
|
||||
let d = PathBuf::from(format!("{}-inbox", dir.display()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
/// `bytes` as a tool takes them inline: base64.
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
/// The SHA-256 (hex) a file given inline comes with.
|
||||
fn sha(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name, the receipt threshold, and
|
||||
@@ -169,9 +168,6 @@ impl Mcp {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
// Files handed over by path come only from here.
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
// Never the user's real vault key (the default key file is
|
||||
// under XDG_CONFIG_HOME); the tests name theirs anyway.
|
||||
@@ -519,11 +515,9 @@ impl Mcp {
|
||||
}
|
||||
self.tool("books.set_w9", args)
|
||||
}
|
||||
/// A W-9 file outside the books; it shows the TIN, like the real one.
|
||||
fn w9_file(&self, name: &str, tin: &str) -> PathBuf {
|
||||
let p = inbox(&self.books_dir()).join(name);
|
||||
std::fs::write(&p, format!("%PDF-1.4 Form W-9 TIN {tin}")).unwrap();
|
||||
p
|
||||
/// A W-9 file (a PDF); it shows the TIN, like the real one.
|
||||
fn w9_file(&self, tin: &str) -> Vec<u8> {
|
||||
format!("%PDF-1.4 Form W-9 TIN {tin}").into_bytes()
|
||||
}
|
||||
/// Pay `vendor` `amount` from the bank in 2026, tagged.
|
||||
fn pay(&mut self, vendor: &str, amount: &str) {
|
||||
@@ -626,9 +620,9 @@ fn set_w9_refusals_write_nothing_anywhere() {
|
||||
let mut m = books("refusals");
|
||||
let acme = m.party_id("Acme Design");
|
||||
m.add_party("IRS", &["tax_agency"]);
|
||||
let missing = inbox(&m.books_dir()).join("no-such-w9.pdf");
|
||||
let txt = inbox(&m.books_dir()).join("w9.txt");
|
||||
std::fs::write(&txt, "W-9").unwrap();
|
||||
let pdf = m.w9_file(SSN);
|
||||
let txt = b"W-9";
|
||||
let big = [b"%PDF-1.4 ".as_slice(), &vec![b' '; 256 * 1024]].concat();
|
||||
for (party, extra) in [
|
||||
("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits
|
||||
(
|
||||
@@ -653,14 +647,6 @@ fn set_w9_refusals_write_nothing_anywhere() {
|
||||
"Acme Design",
|
||||
json!({"tin": 291122846.0, "tin_type": "ssn"}),
|
||||
), // a float
|
||||
(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": missing.to_str().unwrap()}),
|
||||
),
|
||||
(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": txt.to_str().unwrap()}),
|
||||
),
|
||||
(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "clear": true}),
|
||||
@@ -680,6 +666,51 @@ fn set_w9_refusals_write_nothing_anywhere() {
|
||||
out.err_text()
|
||||
);
|
||||
}
|
||||
// The W-9 file, each refused for its own reason.
|
||||
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4);
|
||||
for (extra, why) in [
|
||||
(
|
||||
json!({"w9_content_base64": b64(txt), "w9_sha256": sha(txt)}),
|
||||
"a PDF, PNG, or JPEG",
|
||||
),
|
||||
(
|
||||
json!({"w9_content_base64": b64(&pdf)}),
|
||||
"comes with its sha256",
|
||||
),
|
||||
(
|
||||
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(txt)}),
|
||||
"lost on the way",
|
||||
),
|
||||
(
|
||||
json!({"w9_content_base64": "!!not base64", "w9_sha256": sha(&pdf)}),
|
||||
"not valid base64",
|
||||
),
|
||||
(
|
||||
json!({"w9_content_base64": b64(&big), "w9_sha256": sha(&big)}),
|
||||
"upload it with books.begin_upload",
|
||||
),
|
||||
(
|
||||
json!({"w9_content_base64": long, "w9_sha256": sha(&big)}),
|
||||
"inline is at most",
|
||||
),
|
||||
(json!({"w9_upload_id": "u1"}), "takes files inline only"),
|
||||
(
|
||||
json!({"w9_upload_id": "u1", "w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf)}),
|
||||
"not both",
|
||||
),
|
||||
(
|
||||
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf), "w9_file_name": "../w9.pdf"}),
|
||||
"no folder",
|
||||
),
|
||||
] {
|
||||
let mut args = json!({"tin": SSN, "tin_type": "ssn"});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
let why_given = m.w9("Acme Design", args).err_text();
|
||||
assert!(why_given.contains(why), "{extra}: {why_given}");
|
||||
assert!(!holds_tin(why_given.as_bytes(), SSN_DIGITS), "{why_given}");
|
||||
}
|
||||
assert!(
|
||||
!m.vault().join(format!("tin-{acme}.bin")).exists(),
|
||||
"no record written"
|
||||
@@ -739,10 +770,10 @@ fn the_facts_never_disagree_with_the_vault() {
|
||||
fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
let mut m = books("w9-file");
|
||||
let acme = m.party_id("Acme Design");
|
||||
let src = m.w9_file("acme-w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
let Out::Ok(p) = m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
) else {
|
||||
panic!("set_w9 with a file")
|
||||
};
|
||||
@@ -760,8 +791,9 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
json!({"party": "Acme Design", "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["path"], json!(to.display().to_string()));
|
||||
assert_eq!(out["file_name"], "acme-w9.pdf");
|
||||
assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap());
|
||||
// Given inline, the file is named for its type.
|
||||
assert_eq!(out["file_name"], "w9.pdf");
|
||||
assert_eq!(std::fs::read(&to).unwrap(), src);
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -804,10 +836,10 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
);
|
||||
// A W-9 file alone (the TIN typed in later) records the vault too.
|
||||
m.add_party("Solo", &["contractor"]);
|
||||
let solo = m.w9_file("solo.png", "000");
|
||||
let solo = m.w9_file("000");
|
||||
let Out::Ok(p) = m.w9(
|
||||
"Solo",
|
||||
json!({"tin_last4": "0000", "w9_file_path": solo.to_str().unwrap()}),
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(&solo), "w9_sha256": sha(&solo)}),
|
||||
) else {
|
||||
panic!("a W-9 file alone")
|
||||
};
|
||||
@@ -815,22 +847,25 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
assert_eq!(p["w9"]["kept_at"], "wanderbooks vault");
|
||||
}
|
||||
|
||||
/// A W-9 file is a PDF, PNG or JPEG, by its extension in any case
|
||||
/// (.pdf, .png, .jpg, .jpeg): each is taken into the vault and comes back
|
||||
/// byte for byte, under its own name.
|
||||
/// A W-9 file is a PDF, PNG or JPEG, judged by its own first bytes: each
|
||||
/// is taken into the vault and comes back byte for byte, named for its
|
||||
/// type.
|
||||
#[test]
|
||||
fn every_w9_file_type_is_taken() {
|
||||
let mut m = books("w9-types");
|
||||
for (n, name) in ["w9.pdf", "w9.png", "w9.jpg", "w9.jpeg", "W9.JPG"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
for (n, (bytes, name)) in [
|
||||
(b"%PDF-1.4 Form W-9".as_slice(), "w9.pdf"),
|
||||
(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR W-9".as_slice(), "w9.png"),
|
||||
(b"\xff\xd8\xff\xe0\0\x10JFIF W-9".as_slice(), "w9.jpg"),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let party = format!("Payee {n}");
|
||||
m.add_party(&party, &["contractor"]);
|
||||
let src = m.w9_file(name, "000");
|
||||
let Out::Ok(p) = m.w9(
|
||||
&party,
|
||||
json!({"tin_last4": "0000", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
|
||||
) else {
|
||||
panic!("{name} refused")
|
||||
};
|
||||
@@ -841,17 +876,57 @@ fn every_w9_file_type_is_taken() {
|
||||
json!({"party": party, "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["file_name"], *name);
|
||||
assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap());
|
||||
assert_eq!(std::fs::read(&to).unwrap(), *bytes);
|
||||
}
|
||||
// Its own name, when given; given alone, a name is refused.
|
||||
m.add_party("Payee N", &["contractor"]);
|
||||
let bytes = b"%PDF-1.4 Form W-9 named";
|
||||
let Out::Ok(_) = m.w9(
|
||||
"Payee N",
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes),
|
||||
"w9_file_name": "payee-n-w9.pdf"}),
|
||||
) else {
|
||||
panic!("named W-9 refused")
|
||||
};
|
||||
let to = m.outside().join("copy-named");
|
||||
let out = m.ok(
|
||||
"books.get_w9",
|
||||
json!({"party": "Payee N", "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["file_name"], "payee-n-w9.pdf");
|
||||
let why = m
|
||||
.w9(
|
||||
"Payee N",
|
||||
json!({"tin_last4": "0000", "w9_file_name": "x.pdf"}),
|
||||
)
|
||||
.err_text();
|
||||
assert!(why.contains("w9_file_name names the W-9"), "{why}");
|
||||
// Anything else is refused, near misses too.
|
||||
m.add_party("Payee X", &["contractor"]);
|
||||
for bytes in [
|
||||
b"PDF-1.4 no percent".as_slice(),
|
||||
b"\x89PNG\r\n no".as_slice(),
|
||||
b"\xff\xd8 short".as_slice(),
|
||||
b"GIF89a".as_slice(),
|
||||
] {
|
||||
let why = m
|
||||
.w9(
|
||||
"Payee X",
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
|
||||
)
|
||||
.err_text();
|
||||
assert!(why.contains("PDF, PNG, or JPEG"), "{why}");
|
||||
}
|
||||
assert_eq!(m.party("Payee X", 2026)["party"]["w9"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_vault_says_so_and_never_guesses() {
|
||||
let mut m = books("locked");
|
||||
let src = m.w9_file("w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
);
|
||||
m.pay("Acme Design", "700.00");
|
||||
let key = std::fs::read(m.key()).unwrap();
|
||||
@@ -1003,10 +1078,10 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
fn purge_deletes_the_vault_records_and_journals_why() {
|
||||
let mut m = books("purge");
|
||||
let acme = m.party_id("Acme Design");
|
||||
let src = m.w9_file("w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
);
|
||||
// Clearing while the vault holds it: refused.
|
||||
let why = m
|
||||
@@ -1086,10 +1161,10 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
"no-leak",
|
||||
"[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
|
||||
);
|
||||
let src = m.w9_file("acme-w9.pdf", SSN);
|
||||
let src = m.w9_file(SSN);
|
||||
m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}),
|
||||
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}),
|
||||
);
|
||||
m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
|
||||
@@ -240,187 +240,25 @@ fn newer_books_are_refused_as_newer_not_corrupt() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Start the server and report whether it refused to start (it never got
|
||||
/// as far as serving), with its standard error.
|
||||
fn refuses_to_start(cmd: &mut Command) -> Option<String> {
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
/// `--import-root` is gone (files come in as tool input, P2 slice 5): a
|
||||
/// server started with it stops at once, as for any unknown argument,
|
||||
/// and never serves as if it had read from that folder.
|
||||
#[test]
|
||||
fn the_import_root_flag_is_refused_as_unknown() {
|
||||
let dir = temp("import-root");
|
||||
let out = Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--import-root")
|
||||
.arg(dir.join("Downloads"))
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config"))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
// A server that starts waits on stdin; closing it ends the server.
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
(!err.contains("serving over stdio")).then_some(err)
|
||||
}
|
||||
|
||||
/// local_files.rs INVARIANT 0: a named import root that is `/`, the home
|
||||
/// folder, a folder holding it, a folder holding the books, the data dir
|
||||
/// (a symlinked --data-dir placed where it really is), the vault or a key
|
||||
/// file, or a folder inside the books, the data dir or the vault, stops
|
||||
/// the server at startup, saying why; a folder holding none of them is
|
||||
/// used.
|
||||
#[test]
|
||||
fn an_import_root_holding_the_books_or_the_home_folder_is_refused() {
|
||||
let tmp = temp("bad-roots");
|
||||
let dir = tmp.canonicalize().unwrap();
|
||||
let home = dir.join("home");
|
||||
let books = home.join("work/books");
|
||||
let data = books.join("books-data");
|
||||
let keys = dir.join("keys");
|
||||
for d in [&home.join("Downloads"), &books, &keys, &dir.join("config")] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
format!(
|
||||
"[vault]\nkey_file = \"{}\"\n",
|
||||
keys.join("vault.key").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let link = dir.join("data-link");
|
||||
std::os::unix::fs::symlink(&data, &link).unwrap();
|
||||
let start = |data: &std::path::Path, root: &std::path::Path| {
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.arg("--data-dir")
|
||||
.arg(data)
|
||||
.arg("--import-root")
|
||||
.arg(root)
|
||||
.env("HOME", &home)
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config"));
|
||||
refuses_to_start(&mut cmd)
|
||||
};
|
||||
for (root, why) in [
|
||||
(std::path::PathBuf::from("/"), "whole filesystem"),
|
||||
(home.clone(), "home folder"),
|
||||
(dir.clone(), "home folder"),
|
||||
(home.join("work"), "holds the books"),
|
||||
(books.clone(), "holds the books"),
|
||||
(data.clone(), "holds the books"),
|
||||
(
|
||||
keys.clone(),
|
||||
"holds the books, the TIN vault, or a vault key",
|
||||
),
|
||||
(
|
||||
dir.join("config"),
|
||||
"holds the books, the TIN vault, or a vault key",
|
||||
),
|
||||
] {
|
||||
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
|
||||
assert!(
|
||||
err.contains("can't be used") && err.contains(why),
|
||||
"{}: {err}",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
// A folder inside the books, the data dir or the vault: every file in
|
||||
// it would be refused, so the root is.
|
||||
for root in [
|
||||
books.join("company"),
|
||||
data.join("blobs"),
|
||||
books.join("vault/inner"),
|
||||
] {
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
|
||||
assert!(
|
||||
err.contains("can't be used") && err.contains("inside the books"),
|
||||
"{}: {err}",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
// Through a symlinked data dir, the books are where they really are.
|
||||
let err = start(&link, &home.join("work")).expect("a root holding the real books");
|
||||
assert!(err.contains("holds the books"), "{err}");
|
||||
// A folder holding none of them is used.
|
||||
assert_eq!(start(&data, &home.join("Downloads")), None);
|
||||
}
|
||||
|
||||
/// local_files.rs INVARIANT 0, the default: an XDG_DOWNLOAD_DIR of
|
||||
/// "$HOME/" (or "/") never makes the home folder an import root. With no
|
||||
/// real ~/Downloads there is no root, and an import says so; with one, it
|
||||
/// is the root, and the rest of the home folder stays out of reach.
|
||||
#[test]
|
||||
fn a_download_dir_of_home_never_opens_the_home_folder() {
|
||||
let tmp = temp("xdg-home");
|
||||
let dir = tmp.canonicalize().unwrap();
|
||||
let home = dir.join("home");
|
||||
std::fs::create_dir_all(home.join(".ssh")).unwrap();
|
||||
std::fs::write(home.join(".ssh/id_ed25519"), "PRIVATE KEY").unwrap();
|
||||
let key = home.join(".ssh/id_ed25519");
|
||||
let import = |xdg: &str, path: &std::path::Path| {
|
||||
one_call(
|
||||
Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books/books-data"))
|
||||
.env("HOME", &home)
|
||||
.env("XDG_DOWNLOAD_DIR", xdg)
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config")),
|
||||
"books.import_local_file",
|
||||
serde_json::json!({"path": path.to_str().unwrap(), "category": "document"}),
|
||||
)
|
||||
};
|
||||
for xdg in ["$HOME/", "$HOME", "/"] {
|
||||
let reply = import(xdg, &key);
|
||||
assert!(reply.contains("has no import folder"), "{xdg}: {reply}");
|
||||
}
|
||||
std::fs::create_dir_all(home.join("Downloads")).unwrap();
|
||||
std::fs::write(home.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
|
||||
for xdg in ["$HOME/", "/"] {
|
||||
let reply = import(xdg, &key);
|
||||
assert!(
|
||||
reply.contains("no such file in the server's import folders"),
|
||||
"{xdg}: {reply}"
|
||||
);
|
||||
assert!(!reply.contains("PRIVATE"), "{reply}");
|
||||
}
|
||||
let reply = import("$HOME/", &home.join("Downloads/receipt.pdf"));
|
||||
assert!(reply.contains("attachment_id"), "{reply}");
|
||||
}
|
||||
|
||||
/// local_files.rs INVARIANT 0, a refused default: a downloads folder
|
||||
/// that holds the books, or one inside them, is dropped, never fatal
|
||||
/// (only a named root stops the server): it serves, with no import
|
||||
/// folder, and an import says so.
|
||||
#[test]
|
||||
fn a_default_download_dir_holding_the_books_is_dropped_not_fatal() {
|
||||
let tmp = temp("xdg-books");
|
||||
let dir = tmp.canonicalize().unwrap();
|
||||
let home = dir.join("home");
|
||||
let downloads = dir.join("downloads");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::create_dir_all(downloads.join("books")).unwrap();
|
||||
std::fs::write(downloads.join("receipt.pdf"), "%PDF a receipt").unwrap();
|
||||
let reply = one_call(
|
||||
Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(downloads.join("books/books-data"))
|
||||
.env("HOME", &home)
|
||||
.env("XDG_DOWNLOAD_DIR", &downloads)
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config")),
|
||||
"books.import_local_file",
|
||||
serde_json::json!({"path": downloads.join("receipt.pdf").to_str().unwrap(), "category": "document"}),
|
||||
);
|
||||
assert!(reply.contains("has no import folder"), "{reply}");
|
||||
// A downloads folder inside the books dir is dropped the same way.
|
||||
let books = dir.join("books");
|
||||
std::fs::create_dir_all(books.join("Downloads")).unwrap();
|
||||
std::fs::write(books.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
|
||||
let reply = one_call(
|
||||
Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(books.join("books-data"))
|
||||
.env("HOME", &home)
|
||||
.env("XDG_DOWNLOAD_DIR", books.join("Downloads"))
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config")),
|
||||
"books.import_local_file",
|
||||
serde_json::json!({"path": books.join("Downloads/receipt.pdf").to_str().unwrap(), "category": "document"}),
|
||||
);
|
||||
assert!(reply.contains("has no import folder"), "{reply}");
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("unknown argument: --import-root"), "{err}");
|
||||
assert!(!err.contains("serving"), "{err}");
|
||||
assert!(!dir.join("books-data").exists(), "opened the books");
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
//! SOURCE: rules.md B-26; DECISION (P0 review round 2, 2026-10-16).
|
||||
//! INVARIANTS (oracle):
|
||||
//! 1. Every file the server read or wrote with full TINs is refused by
|
||||
//! every way a file enters the books (books.import_local_file,
|
||||
//! books.attach_receipt, books.file_document), from any session and
|
||||
//! every way a file enters the books (books.attach_receipt and
|
||||
//! books.file_document, its bytes given inline), from any session and
|
||||
//! after the server restarts.
|
||||
//! 2. Text holding a full TIN is refused, whoever made it: one the vault
|
||||
//! holds, in every common rendering (nine digits; 3-2-4 joined by
|
||||
@@ -59,8 +59,9 @@ impl Drop for Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// The books dir, the import root, a folder outside both, and the
|
||||
/// settings (a member, so the CPA package has a K-1 worksheet).
|
||||
/// The books dir, a folder for files on their way in, a folder outside
|
||||
/// both, and the settings (a member, so the CPA package has a K-1
|
||||
/// worksheet).
|
||||
struct Books {
|
||||
dir: PathBuf,
|
||||
inbox: PathBuf,
|
||||
@@ -125,8 +126,6 @@ fn serve(b: &Books) -> (Server, String) {
|
||||
.arg(b.dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(&b.toml)
|
||||
.arg("--import-root")
|
||||
.arg(&b.inbox)
|
||||
.arg("--http")
|
||||
.arg(&addr)
|
||||
.env("XDG_CONFIG_HOME", base.join("config"))
|
||||
@@ -177,14 +176,17 @@ async fn ok(c: &Client, tool: &str, args: Value) -> Value {
|
||||
.unwrap_or_else(|e| panic!("{tool}: {e}"))
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
/// `bytes` as a tool takes them inline: base64, and their SHA-256 (hex).
|
||||
fn inline(bytes: &[u8]) -> (String, String) {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
(
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||
ledger_store::journal::sha256_hex(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
/// Every way the file at `path` (copied into the import root as `name`)
|
||||
/// could enter the books is refused with `why`, and nothing is stored:
|
||||
/// the refusals, tool by tool.
|
||||
/// Every way the file at `path` (named `name`) could enter the books is
|
||||
/// refused with `why`, and nothing is stored: the refusals, tool by tool.
|
||||
async fn refused_everywhere(
|
||||
c: &Client,
|
||||
b: &Books,
|
||||
@@ -193,23 +195,20 @@ async fn refused_everywhere(
|
||||
why: &str,
|
||||
) -> Vec<String> {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let copy = b.inbox.join(name);
|
||||
std::fs::copy(path, ©).unwrap();
|
||||
let (content, sha) = inline(&bytes);
|
||||
let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE);
|
||||
let before = std::fs::read(&journal).unwrap();
|
||||
let mut refusals = Vec::new();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.import_local_file",
|
||||
json!({"path": copy.to_str().unwrap(), "category": "document", "document_category": "tax"}),
|
||||
),
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": name, "mime_type": "application/octet-stream", "content_base64": b64(&bytes)}),
|
||||
json!({"file_name": name, "mime_type": "application/octet-stream",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf", "content_base64": b64(&bytes)}),
|
||||
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
] {
|
||||
let err = call(c, tool, args).await.expect_err(tool);
|
||||
@@ -221,7 +220,6 @@ async fn refused_everywhere(
|
||||
before,
|
||||
"a refusal stored something"
|
||||
);
|
||||
std::fs::remove_file(copy).unwrap();
|
||||
refusals
|
||||
}
|
||||
|
||||
@@ -240,6 +238,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
b"%PDF-1.4\n\xff\xfe\x00 scanned W-9 \x01\x02\x03\n%%EOF\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
|
||||
let (server, url) = serve(&b);
|
||||
let one = connect(&url).await;
|
||||
for (code, name, ty) in [
|
||||
@@ -270,7 +269,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
&one,
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": SSN, "tin_type": "ssn", "w9_file_path": w9.to_str().unwrap()}),
|
||||
"tin": SSN, "tin_type": "ssn", "w9_content_base64": w9_content, "w9_sha256": w9_sha}),
|
||||
)
|
||||
.await;
|
||||
ok(
|
||||
@@ -281,7 +280,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
.await;
|
||||
// A bank statement filed in the books: the package carries it, and it
|
||||
// is no TIN file (its routing numbers are not TINs).
|
||||
accepted_everywhere(&one, &b, "statement.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&one, "statement.csv", BANK_CSV).await;
|
||||
// What the server writes with TINs, from the first session.
|
||||
let copy = b.out.join("w9-copy.pdf");
|
||||
ok(
|
||||
@@ -333,12 +332,12 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
std::fs::write(&fresh, format!("name,ssn\nDana,{}\n", "187-05-1907")).unwrap();
|
||||
refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await;
|
||||
// An ordinary file still goes in, every way.
|
||||
let fine = b.inbox.join("lunch.pdf");
|
||||
std::fs::write(&fine, b"%PDF-1.4\n\xff lunch receipt, order 12-34\n").unwrap();
|
||||
let (fine, fine_sha) = inline(b"%PDF-1.4\n\xff lunch receipt, order 12-34\n");
|
||||
ok(
|
||||
&two,
|
||||
"books.import_local_file",
|
||||
json!({"path": fine.to_str().unwrap(), "category": "receipt"}),
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "lunch.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": fine, "sha256": fine_sha}),
|
||||
)
|
||||
.await;
|
||||
// The books' own files the package carried are not marked: the
|
||||
@@ -353,7 +352,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
"the package carries the statement"
|
||||
);
|
||||
}
|
||||
accepted_everywhere(&two, &b, "statement-again.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await;
|
||||
drop((one, two));
|
||||
drop(server);
|
||||
|
||||
@@ -406,8 +405,9 @@ async fn a_refused_set_w9_never_marks_its_file() {
|
||||
.await;
|
||||
}
|
||||
let w9_args = |party: &str, path: &Path| {
|
||||
let (content, sha) = inline(&std::fs::read(path).unwrap());
|
||||
json!({"party": party, "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": SSN, "tin_type": "ssn", "w9_file_path": path.to_str().unwrap()})
|
||||
"tin": SSN, "tin_type": "ssn", "w9_content_base64": content, "w9_sha256": sha})
|
||||
};
|
||||
for (party, path, why) in [
|
||||
("Nobody", &w9, "unknown party"),
|
||||
@@ -424,51 +424,54 @@ async fn a_refused_set_w9_never_marks_its_file() {
|
||||
("w9-scan.pdf", w9_bytes.clone()),
|
||||
("notes.txt", std::fs::read(¬es).unwrap()),
|
||||
] {
|
||||
let (content, sha) = inline(&bytes);
|
||||
let out = ok(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": name, "mime_type": "application/pdf",
|
||||
"content_base64": b64(&bytes)}),
|
||||
"content_base64": content, "sha256": sha}),
|
||||
)
|
||||
.await;
|
||||
assert!(out["attachment_id"].is_u64(), "{name}: {out}");
|
||||
}
|
||||
// A set_w9 that succeeds marks it.
|
||||
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await;
|
||||
let err = call(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(&w9_bytes)}),
|
||||
)
|
||||
.await
|
||||
.expect_err("marked");
|
||||
assert!(err.contains(TIN_FILE), "{err}");
|
||||
let (content, sha) = inline(&w9_bytes);
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
] {
|
||||
let err = call(&c, tool, args).await.expect_err("marked");
|
||||
assert!(err.contains(TIN_FILE), "{tool}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way a file enters the books takes the text `name` holding
|
||||
/// `text`, and the journal shows it.
|
||||
async fn accepted_everywhere(c: &Client, b: &Books, name: &str, text: &str) {
|
||||
let path = b.inbox.join(name);
|
||||
std::fs::write(&path, text).unwrap();
|
||||
async fn accepted_everywhere(c: &Client, name: &str, text: &str) {
|
||||
let (content, sha) = inline(text.as_bytes());
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.import_local_file",
|
||||
json!({"path": path.to_str().unwrap(), "category": "document", "document_category": "statement"}),
|
||||
),
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": name, "mime_type": "text/csv", "content_base64": b64(text.as_bytes())}),
|
||||
json!({"file_name": name, "mime_type": "text/csv", "content_base64": content, "sha256": sha}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv", "content_base64": b64(text.as_bytes())}),
|
||||
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
),
|
||||
] {
|
||||
let out = ok(c, tool, args).await;
|
||||
assert!(out["attachment_id"].is_u64(), "{tool}: {out}");
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// A real bank export: ABA routing numbers (nine digits), account and
|
||||
@@ -526,7 +529,7 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
|
||||
std::fs::write(&path, format!("payee,tin\n{printed}\n")).unwrap();
|
||||
refused_everywhere(&c, &b, &path, &format!("payees-{n}.csv"), TIN_PRINTED).await;
|
||||
}
|
||||
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
|
||||
drop(c);
|
||||
drop(server);
|
||||
|
||||
@@ -542,12 +545,11 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
|
||||
refused_everywhere(&c, &b, &path, "members-1.csv", TIN_PRINTED).await;
|
||||
accepted_everywhere(
|
||||
&c,
|
||||
&b,
|
||||
"bank-2.csv",
|
||||
&format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"),
|
||||
)
|
||||
.await;
|
||||
accepted_everywhere(&c, &b, "members-0.csv", "member,tin\nKat,830835531\n").await;
|
||||
accepted_everywhere(&c, "members-0.csv", "member,tin\nKat,830835531\n").await;
|
||||
}
|
||||
|
||||
/// INVARIANT 3b through the tools: the company's own EIN, as its
|
||||
@@ -571,7 +573,6 @@ async fn the_company_s_own_ein_goes_in_and_no_other() {
|
||||
let c = connect(&url).await;
|
||||
accepted_everywhere(
|
||||
&c,
|
||||
&b,
|
||||
"plan.md",
|
||||
"# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n",
|
||||
)
|
||||
@@ -697,8 +698,8 @@ async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() {
|
||||
};
|
||||
let open = probes(&c).await;
|
||||
refused_everywhere(&c, &b, &tin, "tin.csv", TIN_HELD).await;
|
||||
accepted_everywhere(&c, &b, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
|
||||
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
|
||||
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
|
||||
drop(c);
|
||||
drop(server);
|
||||
|
||||
|
||||
@@ -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,
|
||||
/// starting with a letter or digit.
|
||||
pub fn check_name(name: &str) -> Result<(), AuthError> {
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod root;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
pub mod uploads;
|
||||
pub mod viewer;
|
||||
|
||||
pub use blob::BlobStore;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user