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();
|
||||
}
|
||||
Reference in new issue
Block a user