P2: tests mutation found, three bugs fixed; plan P12 and P13
Mutation testing pauses until P12 (D-33, owner 2026-10-20), which reruns
the whole max-testing pass over everything since P1. Before the pause,
pass 1 on slices 3-6 and its triage gave tests in ledger-store,
ledger-package and ledger-mcp, and three real bugs, fixed:
- the command line took an admin answer longer than four MiB, or cut off
before its newline, whenever the bytes that came still parsed;
- The CPA package put a twin's number on the last dot of the whole path,
so in a folder with a dot the second file landed in another folder;
- a document or category named of dots alone ("..") could climb out of
its folder when the CPA package is unpacked.
The plan gains P13, bookspec (D-34): a formal, sourced accounting
specification first (rules with symbolic forms and worked examples), then
its own formal validation and a conformance harness any engine can be
tested against, seeded by P3's regression tests. docs/testing.md records
slices 3-8 and what P12 must cover; plan/STATUS.md says where P2 stands.
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
3cf359a0cd
commit
d5c9522ad1
24 files changed
+2937
-20
No files matched your search
@@ -1466,6 +1466,21 @@ async fn every_call_is_recorded_once_refusals_included() {
|
||||
let snap = ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap();
|
||||
assert_eq!(heads.last().unwrap().1, snap.head_hash());
|
||||
assert_eq!(added["client"]["name"], "rmcp");
|
||||
// Each record carries its request's own id, as the client numbered
|
||||
// it: one connection's calls, oldest first, climb, none twice.
|
||||
let ids: Vec<u64> = tools
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|r| r["principal"]["label"] != "reader")
|
||||
.map(|r| {
|
||||
r["request_id"]
|
||||
.as_str()
|
||||
.and_then(|id| id.parse().ok())
|
||||
.unwrap_or_else(|| panic!("{r}"))
|
||||
})
|
||||
.collect();
|
||||
assert!(ids.len() >= 8, "{ids:?}");
|
||||
assert!(ids.windows(2).all(|w| w[0] < w[1]), "{ids:?}");
|
||||
let door: Vec<&Value> = records
|
||||
.iter()
|
||||
.filter(|r| r["action"] == "connect")
|
||||
@@ -2721,3 +2736,409 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
|
||||
drop(c);
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
// Mutation survivors of local.rs (P2): the close, the waits, the admin
|
||||
// line, adoption's settings and vault, and the peer check.
|
||||
|
||||
/// INVARIANT 6: once closing, every request is refused as closing, a
|
||||
/// route for no books included (never a connection cut it couldn't tell
|
||||
/// from a call that ran), and the door stays open refusing while they
|
||||
/// keep coming (LINGER_MAX, 3 s, after the work is done); then the
|
||||
/// sockets go. Anything but a books route is simply not found.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn while_closing_the_door_stays_open_refusing_as_requests_come() {
|
||||
let home = Home::new("linger");
|
||||
let mut server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let http = mcp_http(&home);
|
||||
let r = http
|
||||
.get("http://localhost/b/acme/nothing")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status().as_u16(), 404);
|
||||
assert_eq!(r.text().await.unwrap(), "not found");
|
||||
drop(server.child.stdin.take());
|
||||
let (mut first, mut last) = (None, None);
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
assert!(started.elapsed() < Duration::from_secs(60), "never shut");
|
||||
let Ok(r) = http
|
||||
.post("http://localhost/b/nobody/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let status = r.status().as_u16();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
if text.contains("closing") {
|
||||
assert_eq!(status, 503, "{text}");
|
||||
first.get_or_insert_with(Instant::now);
|
||||
last = Some(Instant::now());
|
||||
} else {
|
||||
// Not found, until closing; never again after.
|
||||
assert!(first.is_none(), "after closing: {status} {text}");
|
||||
assert_eq!(status, 404, "{text}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
let first = first.expect("never refused as closing");
|
||||
let open = last.unwrap() - first;
|
||||
assert!(open >= Duration::from_millis(2500), "{open:?}");
|
||||
let status = tokio::task::spawn_blocking(move || wait_exit(&mut server.child, 30))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(status.is_some_and(|s| s.success()), "{status:?}");
|
||||
}
|
||||
|
||||
/// INVARIANT 6: standard input that can't be read (an error, not a read
|
||||
/// interrupted) counts as its end, the host gone: the server closes.
|
||||
#[test]
|
||||
fn a_server_whose_input_cant_be_read_closes() {
|
||||
let home = Home::new("badstdin");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
assert!(server.close().success());
|
||||
// A folder as its input: every read of it fails (EISDIR).
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.args(["server", "--root"])
|
||||
.arg(home.root())
|
||||
.env_clear()
|
||||
.env("HOME", home.dir.path())
|
||||
.stdin(std::fs::File::open(home.dir.path()).unwrap())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let status = wait_exit(&mut child, 30);
|
||||
if status.is_none() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
assert!(status.is_some_and(|s| s.success()), "{status:?}");
|
||||
let mut store = ledger_store::Store::open(&home.book_dir("acme")).unwrap();
|
||||
store.verify_and_heal().unwrap();
|
||||
}
|
||||
|
||||
/// Lines a child writes on stderr, as they come.
|
||||
fn stderr_lines(child: &mut Child) -> std::sync::mpsc::Receiver<String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let err = child.stderr.take().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// INVARIANT 1: a server waiting for another to finish says so, once,
|
||||
/// however long it waits.
|
||||
#[test]
|
||||
fn a_server_waiting_for_another_says_so_once() {
|
||||
let home = Home::new("saywait");
|
||||
let first = Server::start(&home);
|
||||
let mut second = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.args(["server", "--root"])
|
||||
.arg(home.root())
|
||||
.env_clear()
|
||||
.env("HOME", home.dir.path())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let lines = stderr_lines(&mut second);
|
||||
let waiting = "another books server has these books open; waiting for it to finish";
|
||||
let said = lines.recv_timeout(Duration::from_secs(30));
|
||||
if !said.as_ref().is_ok_and(|l| l.contains(waiting)) {
|
||||
let _ = second.kill();
|
||||
let _ = second.wait();
|
||||
panic!("it never said it was waiting: {said:?}");
|
||||
}
|
||||
// Many more tries at the lock (one each 100 ms), said nothing more.
|
||||
std::thread::sleep(Duration::from_millis(1000));
|
||||
drop(second.stdin.take());
|
||||
assert!(wait_exit(&mut second, 20).is_some_and(|s| s.success()));
|
||||
let rest: Vec<String> = lines.iter().collect();
|
||||
assert!(!rest.iter().any(|l| l.contains(waiting)), "{rest:?}");
|
||||
assert!(first.close().success());
|
||||
}
|
||||
|
||||
/// admin INVARIANT 1: a request is one line of at most MAX_LINE bytes
|
||||
/// before its newline: one that long is answered, a byte more is refused
|
||||
/// as too long.
|
||||
#[test]
|
||||
fn an_admin_request_may_be_as_long_as_a_line_may_be() {
|
||||
let home = Home::new("adminline");
|
||||
let server = Server::start(&home);
|
||||
let sock = home.run().join("admin.sock");
|
||||
let request = "{\"proto\":1,\"op\":\"list\"}";
|
||||
let padded = |len: usize| format!("{request}{}\n", " ".repeat(len - request.len()));
|
||||
let answer = raw_admin(&sock, &padded(admin::MAX_LINE));
|
||||
let answered: Response = serde_json::from_str(answer.trim_end())
|
||||
.unwrap_or_else(|e| panic!("{e}: {}", &answer[..answer.len().min(200)]));
|
||||
assert_eq!(answered, Response::Books(vec![]));
|
||||
// A byte more and no newline yet: all of it read, refused.
|
||||
let longer = padded(admin::MAX_LINE + 1);
|
||||
let answer = raw_admin(&sock, longer.trim_end_matches('\n'));
|
||||
assert!(answer.contains("too long"), "{answer}");
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Old books that keep settings of their own (a `settings.set` in their
|
||||
/// journal), in `dir`.
|
||||
fn old_books_keeping_settings(dir: &Path) -> PathBuf {
|
||||
let data = old_books(dir);
|
||||
let mut store = ledger_store::Store::open(&data).unwrap();
|
||||
store
|
||||
.change_settings(
|
||||
&[ledger_core::settings::Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("Kept LLC")),
|
||||
}],
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the owner".into(),
|
||||
decided_on: ledger_core::settings::today_in(None),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
data
|
||||
}
|
||||
|
||||
/// Adoption (admin INVARIANTS 4, 5): books that keep no settings need
|
||||
/// their file, even for a preview; a file naming the very key given is
|
||||
/// taken; every vault record, TINs and W-9s, is opened and counted; and a
|
||||
/// file given for books that keep their own is reported unused, never
|
||||
/// when none was given.
|
||||
#[test]
|
||||
fn adoption_takes_the_settings_and_the_vault_as_they_are() {
|
||||
let home = Home::new("adoptsv");
|
||||
let server = Server::start(&home);
|
||||
let old = tempfile::Builder::new()
|
||||
.prefix("wb-srv-oldsv-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let (data, vault, key) = old_books_with_vault(old.path());
|
||||
ledger_vault::Vault::open(&vault, &key, false)
|
||||
.unwrap()
|
||||
.put_w9(
|
||||
1,
|
||||
&ledger_vault::W9File {
|
||||
file_name: "w9.pdf".into(),
|
||||
mime_type: "application/pdf".into(),
|
||||
bytes: b"%PDF-1.4 w9".to_vec(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let vaulted = Adopt {
|
||||
vault: Some((vault.clone(), key.clone())),
|
||||
..Adopt::new("acme", &data)
|
||||
};
|
||||
match home.admin(vaulted.clone().request()) {
|
||||
Response::Refused(why) => {
|
||||
assert!(why.contains("keep no settings of their own"), "{why}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let toml = format!(
|
||||
"[company]\nname = \"Acme\"\n[vault]\nkey_file = \"{}\"\n",
|
||||
key.display()
|
||||
);
|
||||
let named = Adopt {
|
||||
settings_toml: Some(toml),
|
||||
..vaulted
|
||||
};
|
||||
match home.admin(named.request()) {
|
||||
Response::Preview(p) => assert_eq!(p.vault_records, 2, "{p:?}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let toml = "[company]\nname = \"Acme\"\n".to_string();
|
||||
for (id, given, ignored) in [("beta", Some(toml), true), ("gamma", None, false)] {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-srv-kept-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let plan = Adopt {
|
||||
settings_toml: given,
|
||||
..Adopt::new(id, &old_books_keeping_settings(dir.path()))
|
||||
};
|
||||
let head = match home.admin(plan.clone().request()) {
|
||||
Response::Preview(p) => {
|
||||
assert!(p.keeps_settings);
|
||||
p.source_head
|
||||
}
|
||||
other => panic!("{id}: {other:?}"),
|
||||
};
|
||||
match home.admin(plan.at(&head).request()) {
|
||||
Response::Adopted(a) => {
|
||||
assert_eq!(
|
||||
(a.settings_ignored, a.settings_imported),
|
||||
(ignored, 0),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// INVARIANT 2: a connection from another account is dropped before a
|
||||
/// byte is read, even with the sockets' permissions loosened (as a slip
|
||||
/// might). The other account is made in a user namespace, as a user ID
|
||||
/// of this account's /etc/subuid range; where that can't be made (no
|
||||
/// `unshare --map-auto` or python3 here), the test says so and skips.
|
||||
#[test]
|
||||
fn a_connection_from_another_account_is_dropped_unread() {
|
||||
let home = Home::new("otheruid");
|
||||
let server = Server::start(&home);
|
||||
std::fs::set_permissions(home.run(), std::fs::Permissions::from_mode(0o711)).unwrap();
|
||||
for sock in ["mcp.sock", "admin.sock"] {
|
||||
std::fs::set_permissions(
|
||||
home.run().join(sock),
|
||||
std::fs::Permissions::from_mode(0o666),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Connect (from the run folder), send, read to the end: what came.
|
||||
let script = "import socket, sys\n\
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n\
|
||||
s.connect(sys.argv[1])\n\
|
||||
print('connected', flush=True)\n\
|
||||
got = b''\n\
|
||||
try:\n \
|
||||
s.sendall(sys.argv[2].encode())\n \
|
||||
while True:\n \
|
||||
b = s.recv(65536)\n \
|
||||
if not b:\n \
|
||||
break\n \
|
||||
got += b\n\
|
||||
except OSError:\n \
|
||||
pass\n\
|
||||
print('answer:' + got.decode(errors='replace'))\n";
|
||||
let ask = |other: bool, sock: &str, request: &str| -> Option<String> {
|
||||
let mut cmd = if other {
|
||||
let mut c = Command::new("unshare");
|
||||
c.args([
|
||||
"--user",
|
||||
"--map-auto",
|
||||
"--setuid",
|
||||
"0",
|
||||
"--setgid",
|
||||
"0",
|
||||
"--",
|
||||
"python3",
|
||||
]);
|
||||
c
|
||||
} else {
|
||||
Command::new("python3")
|
||||
};
|
||||
let out = cmd
|
||||
.args(["-I", "-c", script, sock, request])
|
||||
.current_dir(home.run())
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
text.contains("connected\n").then_some(text)
|
||||
};
|
||||
let http = "GET /nothing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
||||
let list = "{\"proto\":1,\"op\":\"list\"}\n";
|
||||
// This account: answered, on both sockets.
|
||||
let Some(ours) = ask(false, "mcp.sock", http) else {
|
||||
eprintln!("SKIPPED a_connection_from_another_account_is_dropped_unread: no python3");
|
||||
return;
|
||||
};
|
||||
assert!(ours.contains("404"), "{ours}");
|
||||
assert!(ask(false, "admin.sock", list).is_some_and(|a| a.contains("books")));
|
||||
// Another account: connected, and dropped unanswered.
|
||||
for (sock, request) in [("mcp.sock", http), ("admin.sock", list)] {
|
||||
let Some(theirs) = ask(true, sock, request) else {
|
||||
eprintln!(
|
||||
"SKIPPED a_connection_from_another_account_is_dropped_unread: no other account can be made here (unshare --map-auto)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(theirs.ends_with("answer:\n"), "{sock}: {theirs}");
|
||||
}
|
||||
// And this account is still served.
|
||||
assert!(ask(false, "admin.sock", list).is_some_and(|a| a.contains("books")));
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Out of descriptors, the server says so and waits a moment before
|
||||
/// accepting again (never spinning), and serves again once some are free.
|
||||
#[test]
|
||||
fn a_server_out_of_descriptors_waits_then_serves_again() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let home = Home::new("nofile");
|
||||
let mut server = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||
server
|
||||
.args(["server", "--root"])
|
||||
.arg(home.root())
|
||||
.env_clear()
|
||||
.env("HOME", home.dir.path())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
// SAFETY: setrlimit is async-signal-safe; nothing else runs here.
|
||||
unsafe {
|
||||
server.pre_exec(|| {
|
||||
let limit = libc::rlimit {
|
||||
rlim_cur: 40,
|
||||
rlim_max: 40,
|
||||
};
|
||||
if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = server.spawn().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
assert_eq!(line, "ready\n");
|
||||
let lines = stderr_lines(&mut child);
|
||||
let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap();
|
||||
let held: Vec<_> = (0..80)
|
||||
.map(|_| std::os::unix::net::UnixStream::connect(short.path()).unwrap())
|
||||
.collect();
|
||||
let said = std::iter::from_fn(|| lines.recv_timeout(Duration::from_secs(30)).ok())
|
||||
.find(|l| l.contains("accept failed"));
|
||||
if said.is_none() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("out of descriptors, it never said so");
|
||||
}
|
||||
drop(held);
|
||||
// Served again once descriptors are free (the admin socket too).
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Ok(Response::Books(b)) = admin::call(&home.run().join("admin.sock"), Request::List) {
|
||||
assert!(b.is_empty());
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "never served again");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let status = wait_exit(&mut child, 30);
|
||||
if status.is_none() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
assert!(status.is_some_and(|s| s.success()), "{status:?}");
|
||||
}
|
||||
@@ -296,3 +296,143 @@ fn dev_stdio_refuses_books_in_the_private_folder() {
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `upload` and `download` say how they ended in their exit code, as
|
||||
/// every mode does: 0 for their usage on request, 2 for a slip in how
|
||||
/// they were called, 1 for what went wrong doing it. A file's path may be
|
||||
/// a name that isn't text: it reaches the command as given.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_file_commands_end_with_the_code_that_says_how() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-one-files-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let root = home.path().join("wanderbooks");
|
||||
let run = |args: &[&std::ffi::OsStr]| {
|
||||
wanderbooks()
|
||||
.args(args)
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
let os = |s: &'static str| std::ffi::OsStr::new(s);
|
||||
for (mode, usage) in [
|
||||
("upload", "usage: wanderbooks upload --book ID"),
|
||||
("download", "usage: wanderbooks download --book ID"),
|
||||
] {
|
||||
// Its usage on request.
|
||||
let out = run(&[os(mode), os("--help")]);
|
||||
assert_eq!(out.status.code(), Some(0), "{mode}: {out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains(usage),
|
||||
"{out:?}"
|
||||
);
|
||||
// Called wrong: no file, or no books named.
|
||||
let out = run(&[
|
||||
os(mode),
|
||||
os("--root"),
|
||||
root.as_os_str(),
|
||||
os("--book"),
|
||||
os("acme"),
|
||||
]);
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains(usage),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = run(&[os(mode), os("--root"), root.as_os_str(), os("1")]);
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("which books? give --book ID"),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
// A file that isn't there, named by a path that isn't text: what went
|
||||
// wrong, said, and nothing sent.
|
||||
let missing = home
|
||||
.path()
|
||||
.join(std::ffi::OsStr::from_bytes(b"receipt-\xff.pdf"));
|
||||
let out = run(&[
|
||||
os("upload"),
|
||||
os("--root"),
|
||||
root.as_os_str(),
|
||||
os("--book"),
|
||||
os("acme"),
|
||||
os("up_abcdefghijklmnopabcdefghijklmnop"),
|
||||
missing.as_os_str(),
|
||||
]);
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("error: can't read"),
|
||||
"{out:?}"
|
||||
);
|
||||
// A folder to save in that isn't there: the same.
|
||||
let out = run(&[
|
||||
os("download"),
|
||||
os("--root"),
|
||||
root.as_os_str(),
|
||||
os("--book"),
|
||||
os("acme"),
|
||||
os("7"),
|
||||
os("--to"),
|
||||
home.path().join("no-such-folder").as_os_str(),
|
||||
]);
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("isn't a folder"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(!root.exists(), "the books were reached for");
|
||||
}
|
||||
|
||||
/// Where local sockets aren't built yet (P11, Windows): the modes that
|
||||
/// hold the books say so and end with 2, never a success; setup makes
|
||||
/// the folder and ends with 0, asking for no passphrase (nothing there
|
||||
/// asks for one yet).
|
||||
#[cfg(not(unix))]
|
||||
#[test]
|
||||
fn without_local_sockets_the_modes_that_hold_the_books_say_so() {
|
||||
for mode in [
|
||||
"server",
|
||||
"host",
|
||||
"mcp",
|
||||
"serve",
|
||||
"close",
|
||||
"books",
|
||||
"doctor",
|
||||
"token",
|
||||
"connect",
|
||||
"passphrase",
|
||||
"upload",
|
||||
"download",
|
||||
] {
|
||||
let out = wanderbooks()
|
||||
.arg(mode)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("to this system with P11"),
|
||||
"{mode}: {out:?}"
|
||||
);
|
||||
}
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-one-nosock-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(0), "{out:?}");
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("The books' private folder: made"), "{text}");
|
||||
assert!(!text.contains("passphrase"), "{text}");
|
||||
}
|
||||
@@ -462,6 +462,13 @@ pub fn call_within(
|
||||
if answer.is_empty() {
|
||||
return Err(unknown("the books server stopped before answering".into()));
|
||||
}
|
||||
// INVARIANT 1: an answer is one line of at most MAX_LINE bytes; a
|
||||
// longer one, or one cut off, is refused, even if what came parses.
|
||||
if !answer.ends_with('\n') {
|
||||
return Err(unknown(
|
||||
"the books server's answer is too long, or cut off".into(),
|
||||
));
|
||||
}
|
||||
serde_json::from_str(answer.trim_end())
|
||||
.map_err(|e| unknown(format!("the books server's answer isn't understood ({e})")))
|
||||
}
|
||||
@@ -619,6 +626,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
assert!(!shown.contains("wb_secret"), "{shown}");
|
||||
// The text is there, and hidden: never shown as nothing.
|
||||
assert!(shown.contains("text: TokenText(…)"), "{shown}");
|
||||
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap();
|
||||
assert_eq!(r.request, Request::TokenList);
|
||||
assert!(
|
||||
@@ -648,4 +657,100 @@ mod tests {
|
||||
assert!(!text.contains(&t.sha256));
|
||||
assert!(text.contains("books:read"));
|
||||
}
|
||||
|
||||
/// Why an admin call has no answer is what the command line shows.
|
||||
#[test]
|
||||
fn a_call_error_says_why() {
|
||||
assert_eq!(
|
||||
CallError::NotSent("the books server isn't running".into()).to_string(),
|
||||
"the books server isn't running"
|
||||
);
|
||||
assert_eq!(
|
||||
CallError::Unknown("no answer came".into()).to_string(),
|
||||
"no answer came"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fake books server on the socket `name` in `dir`: it takes one
|
||||
/// request and answers `answer`, byte for byte.
|
||||
#[cfg(unix)]
|
||||
fn answering(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
answer: Vec<u8>,
|
||||
) -> (std::path::PathBuf, std::thread::JoinHandle<()>) {
|
||||
let socket = dir.join(name);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
use std::io::{BufRead, Write};
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut request = String::new();
|
||||
std::io::BufReader::new(&stream)
|
||||
.read_line(&mut request)
|
||||
.unwrap();
|
||||
// A client that stops reading part way breaks the pipe: fine.
|
||||
let _ = (&stream).write_all(&answer);
|
||||
});
|
||||
(socket, server)
|
||||
}
|
||||
|
||||
/// A refusal padded with spaces to `len` bytes, then its newline.
|
||||
fn padded(len: usize) -> Vec<u8> {
|
||||
let mut answer = br#"{"refused":"no"}"#.to_vec();
|
||||
answer.resize(len, b' ');
|
||||
answer.push(b'\n');
|
||||
answer
|
||||
}
|
||||
|
||||
/// INVARIANT 1, the command line's side: an answer of 4 MiB, its
|
||||
/// newline not counted, is taken whole (settings texts ride in them);
|
||||
/// a byte more is refused, as is an answer cut off before its
|
||||
/// newline, even when what came parses.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_answer_is_one_line_of_at_most_four_mib() {
|
||||
let four_mib = 4 * 1024 * 1024;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (socket, server) = answering(dir.path(), "whole.sock", padded(four_mib));
|
||||
assert_eq!(
|
||||
call(&socket, Request::List),
|
||||
Ok(Response::Refused("no".into()))
|
||||
);
|
||||
server.join().unwrap();
|
||||
let too_long_or_cut = Err(CallError::Unknown(
|
||||
"the books server's answer is too long, or cut off".into(),
|
||||
));
|
||||
let (socket, server) = answering(dir.path(), "long.sock", padded(four_mib + 1));
|
||||
assert_eq!(call(&socket, Request::List), too_long_or_cut);
|
||||
server.join().unwrap();
|
||||
let (socket, server) = answering(dir.path(), "cut.sock", br#"{"refused":"no"}"#.into());
|
||||
assert_eq!(call(&socket, Request::List), too_long_or_cut);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
/// No server on the socket: the request never left, and the command
|
||||
/// line says the server isn't running, whether there is no socket or
|
||||
/// one a stopped server left behind.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn no_server_means_nothing_was_sent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let not_running = Err(CallError::NotSent("the books server isn't running".into()));
|
||||
assert_eq!(
|
||||
call(&dir.path().join("admin.sock"), Request::List),
|
||||
not_running
|
||||
);
|
||||
let stale = dir.path().join("stale.sock");
|
||||
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
|
||||
assert_eq!(
|
||||
call(
|
||||
&stale,
|
||||
Request::Create {
|
||||
id: "acme".into(),
|
||||
currency: "USD".into(),
|
||||
}
|
||||
),
|
||||
not_running
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2452,4 +2452,528 @@ mod tests {
|
||||
assert!(peer_uid(&file).is_err());
|
||||
assert!(!peer_is_us(&file));
|
||||
}
|
||||
|
||||
/// A socket with no other end: the system answers for it (Linux: no
|
||||
/// process, no user), and that answer names neither this process nor
|
||||
/// this account; the client's check refuses it.
|
||||
#[test]
|
||||
fn a_socket_with_no_other_end_names_no_process_and_isnt_us() {
|
||||
use std::os::fd::FromRawFd;
|
||||
// SAFETY: socket(2) has no preconditions; the descriptor is checked
|
||||
// and then owned by the stream alone.
|
||||
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) };
|
||||
assert!(fd >= 0);
|
||||
// SAFETY: a fresh descriptor of a Unix stream socket, owned by none.
|
||||
let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) };
|
||||
assert_eq!(peer_pid(&lone), None);
|
||||
assert!(!peer_is_us(&lone));
|
||||
assert_eq!(
|
||||
check_peer_std(&lone).unwrap_err(),
|
||||
"the books server's socket belongs to another account: not used"
|
||||
);
|
||||
}
|
||||
|
||||
/// A session ends only after a day without a message (SESSION_IDLE),
|
||||
/// never rmcp's few minutes: a call longer than that keeps its answer.
|
||||
#[test]
|
||||
fn a_session_ends_only_after_a_day_without_a_message() {
|
||||
assert_eq!(
|
||||
sessions().session_config.keep_alive,
|
||||
Some(Duration::from_secs(86_400))
|
||||
);
|
||||
}
|
||||
|
||||
/// Settings moved in at adoption are dated in the time zone the file
|
||||
/// sets (company.timezone), not by any other entry's text. The two
|
||||
/// zones are 26 hours apart, so their dates always differ.
|
||||
#[test]
|
||||
fn settings_moved_in_are_dated_in_the_files_own_time_zone() {
|
||||
let change = |key: &str, value: &str| ledger_core::settings::Change {
|
||||
key: key.into(),
|
||||
value: Some(serde_json::json!(value)),
|
||||
};
|
||||
let changes = [
|
||||
change("company.name", "Etc/GMT+12"),
|
||||
change("company.timezone", "Pacific/Kiritimati"),
|
||||
];
|
||||
let source = moved_in_at_adoption(&changes);
|
||||
assert_eq!(
|
||||
source.decided_on,
|
||||
ledger_core::settings::today_in(Some("Pacific/Kiritimati"))
|
||||
);
|
||||
assert_ne!(
|
||||
source.decided_on,
|
||||
ledger_core::settings::today_in(Some("Etc/GMT+12"))
|
||||
);
|
||||
assert_eq!(source.decided_by, "the settings file");
|
||||
}
|
||||
|
||||
/// A server's books in a fresh root under `home`, as `run` holds them,
|
||||
/// without its sockets: each test drives the handlers itself.
|
||||
fn books_in(home: &Path) -> Arc<Books> {
|
||||
let (root, _) = Root::setup(home, Some(&home.join("root")), None).unwrap();
|
||||
let books = Arc::new(Books {
|
||||
root,
|
||||
held: RwLock::new(BTreeMap::new()),
|
||||
sessions: CancellationToken::new(),
|
||||
closing: CancellationToken::new(),
|
||||
in_flight: AtomicUsize::new(0),
|
||||
finished: tokio::sync::Notify::new(),
|
||||
last_arrival: std::sync::atomic::AtomicU64::new(0),
|
||||
started: Instant::now(),
|
||||
principals: RwLock::new(Err(String::new())),
|
||||
session_tokens: Mutex::new(BTreeMap::new()),
|
||||
backup_quiet: BACKUP_QUIET,
|
||||
drill_first: DRILL_FIRST,
|
||||
restoring: Mutex::new(()),
|
||||
});
|
||||
let loaded = books.load_principals();
|
||||
*books.principals.write().unwrap() = loaded;
|
||||
books
|
||||
}
|
||||
|
||||
/// Make the books `id` through the admin request.
|
||||
fn create(books: &Books, id: &str) {
|
||||
match books.admin(Request::Create {
|
||||
id: id.into(),
|
||||
currency: "USD".into(),
|
||||
}) {
|
||||
Response::Created { problem: None, .. } => {}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A token for `book` named `name` with every scope (made, or made
|
||||
/// again): its id and its text.
|
||||
fn token(books: &Books, book: &str, name: &str) -> (String, String) {
|
||||
match books.admin(Request::TokenCreate {
|
||||
book: book.into(),
|
||||
name: name.into(),
|
||||
scopes: ["read", "write", "post", "admin"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
replace: true,
|
||||
}) {
|
||||
Response::TokenCreated { token, text } => (token.id, text.0),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sessions bound, by id.
|
||||
fn bound(books: &Books) -> Vec<String> {
|
||||
books
|
||||
.session_tokens
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The status, the refusal code and the text of an answer.
|
||||
async fn answer(r: axum::response::Response) -> (u16, Option<String>, String) {
|
||||
let code = r
|
||||
.headers()
|
||||
.get(REFUSED_HEADER)
|
||||
.map(|v| v.to_str().unwrap().to_string());
|
||||
let status = r.status().as_u16();
|
||||
let body = axum::body::to_bytes(r.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
(status, code, String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
/// A PUT of `body` for upload `id` of the books `acme`.
|
||||
fn put(id: &str, bearer: Option<&str>, body: axum::body::Body) -> axum::extract::Request {
|
||||
let mut r = axum::http::Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("http://localhost/b/acme/u/{id}"));
|
||||
if let Some(b) = bearer {
|
||||
r = r.header("authorization", format!("Bearer {b}"));
|
||||
}
|
||||
r.body(body).unwrap()
|
||||
}
|
||||
|
||||
/// Send a PUT to the upload route as the router would.
|
||||
async fn send_upload(
|
||||
books: &Arc<Books>,
|
||||
id: &str,
|
||||
bearer: Option<&str>,
|
||||
body: axum::body::Body,
|
||||
) -> axum::response::Response {
|
||||
upload(
|
||||
axum::extract::State(books.clone()),
|
||||
axum::extract::Path(("acme".to_string(), id.to_string())),
|
||||
put(id, bearer, body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A request body that arrives as the test sends it, one chunk at a
|
||||
/// time (the sender's `reserve` returns once the last chunk was taken).
|
||||
fn fed() -> (tokio::sync::mpsc::Sender<Vec<u8>>, axum::body::Body) {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
|
||||
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
|
||||
rx.recv()
|
||||
.await
|
||||
.map(|b| (Ok::<_, std::io::Error>(axum::body::Bytes::from(b)), rx))
|
||||
});
|
||||
(tx, axum::body::Body::from_stream(stream))
|
||||
}
|
||||
|
||||
/// The upload's grant, for `token_id`, of `bytes`.
|
||||
fn grant(books: &Books, token_id: &str, bytes: &[u8]) -> ledger_store::uploads::Grant {
|
||||
let held = books.held();
|
||||
let Some(Book::Open { uploads, .. }) = held.get("acme") else {
|
||||
panic!("acme isn't open");
|
||||
};
|
||||
uploads
|
||||
.grant(
|
||||
token_id,
|
||||
&ledger_store::journal::sha256_hex(bytes),
|
||||
bytes.len() as u64,
|
||||
"application/pdf",
|
||||
"upload.pdf",
|
||||
"document",
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The last access record of `acme`: its action and its outcome.
|
||||
fn last_access(books: &Books) -> (String, Outcome) {
|
||||
let dir = books.root.book_dir(&BookId::parse("acme").unwrap());
|
||||
let (records, _) = ledger_store::access::read(&dir).unwrap();
|
||||
let last = records.last().unwrap();
|
||||
(last.access.action.clone(), last.access.result.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_that_didnt_open_says_why_and_one_open_says_nothing() {
|
||||
let why = "its access log can't be used".to_string();
|
||||
assert_eq!(Book::Failed(why.clone()).problem(), Some(why));
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
assert_eq!(books.held()["acme"].problem(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 6: once closing, every request is refused as closing,
|
||||
/// even one for books that didn't open or don't exist (until then
|
||||
/// told so), so a client knows the server is going, not that its
|
||||
/// books are gone.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn once_closing_every_request_is_refused_as_closing() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
books.hold(
|
||||
&BookId::parse("broken").unwrap(),
|
||||
Book::Failed("its access log can't be used".into()),
|
||||
);
|
||||
let asked = Asked::connect(&axum::http::Method::POST);
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let refused = |books: Arc<Books>, book: &'static str| {
|
||||
let (asked, headers) = (&asked, &headers);
|
||||
async move {
|
||||
match door(&books, book, asked, headers).await {
|
||||
Ok(_) => panic!("{book}: let through"),
|
||||
Err(r) => answer(*r).await,
|
||||
}
|
||||
}
|
||||
};
|
||||
let (status, code, text) = refused(books.clone(), "broken").await;
|
||||
assert_eq!((status, code.as_deref()), (503, Some("not-open")), "{text}");
|
||||
let (status, code, text) = refused(books.clone(), "nobody").await;
|
||||
assert_eq!(
|
||||
(status, code.as_deref()),
|
||||
(404, Some("no-such-books")),
|
||||
"{text}"
|
||||
);
|
||||
books.closing.cancel();
|
||||
for book in ["broken", "nobody"] {
|
||||
let (status, code, text) = refused(books.clone(), book).await;
|
||||
assert_eq!((status, code), (503, None), "{book}: {text}");
|
||||
assert_eq!(text, "the books are closing: this request wasn't run");
|
||||
}
|
||||
let (status, code, text) = refused(books.clone(), "acme").await;
|
||||
assert_eq!(
|
||||
(status, code.as_deref()),
|
||||
(503, Some(crate::access::CLOSING))
|
||||
);
|
||||
assert_eq!(text, "the books are closing: this request wasn't run");
|
||||
}
|
||||
|
||||
/// INVARIANT 9: nothing comes in once the books stopped (SharedStore
|
||||
/// INVARIANT 2): an upload is refused as stopped, and recorded so.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn an_upload_to_books_that_stopped_is_refused_as_stopped() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
let (id, text) = token(&books, "acme", "agent");
|
||||
let bytes = b"%PDF-1.4 stopped".to_vec();
|
||||
let g = grant(&books, &id, &bytes);
|
||||
match books.held().get("acme") {
|
||||
Some(Book::Open { store, .. }) => store.stop(),
|
||||
_ => panic!("acme isn't open"),
|
||||
}
|
||||
let r = send_upload(&books, &g.id, Some(&text), bytes.into()).await;
|
||||
let (status, code, said) = answer(r).await;
|
||||
assert_eq!((status, code.as_deref()), (503, Some("stopped")), "{said}");
|
||||
assert!(said.contains("stopped taking requests"), "{said}");
|
||||
assert_eq!(
|
||||
last_access(&books),
|
||||
("upload".to_string(), Outcome::Refused("stopped".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 9: an upload is received from one request at a time;
|
||||
/// a second sent while the first is still arriving is refused as in
|
||||
/// use, and the first is kept.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn an_upload_still_arriving_is_refused_to_a_second_sender() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
let (id, text) = token(&books, "acme", "agent");
|
||||
let bytes: Vec<u8> = b"%PDF-1.4 "
|
||||
.iter()
|
||||
.copied()
|
||||
.chain((0..2_000_000u32).map(|i| (i % 251) as u8))
|
||||
.collect();
|
||||
let g = grant(&books, &id, &bytes);
|
||||
let (tx, body) = fed();
|
||||
let first = tokio::spawn({
|
||||
let (books, id, text) = (books.clone(), g.id.clone(), text.clone());
|
||||
async move { send_upload(&books, &id, Some(&text), body).await }
|
||||
});
|
||||
tx.send(bytes[..1_000_000].to_vec()).await.unwrap();
|
||||
// Arriving: its part is on the disk.
|
||||
let part = books
|
||||
.root
|
||||
.book_dir(&BookId::parse("acme").unwrap())
|
||||
.join(ledger_store::uploads::UPLOADS_DIR)
|
||||
.join(format!("{}.part", g.id));
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while !part.exists() {
|
||||
assert!(Instant::now() < deadline, "the first upload never began");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let second = send_upload(&books, &g.id, Some(&text), bytes.clone().into()).await;
|
||||
let (status, code, said) = answer(second).await;
|
||||
assert_eq!((status, code.as_deref()), (409, Some("in_use")), "{said}");
|
||||
assert_eq!(
|
||||
last_access(&books),
|
||||
("upload".to_string(), Outcome::Refused("in_use".into()))
|
||||
);
|
||||
tx.send(bytes[1_000_000..].to_vec()).await.unwrap();
|
||||
drop(tx);
|
||||
let (status, _, said) = answer(first.await.unwrap()).await;
|
||||
assert_eq!(status, 200, "{said}");
|
||||
}
|
||||
|
||||
/// INVARIANT 9: a refused upload's bytes are read and dropped up to
|
||||
/// the most an upload can be, so a client sending no more than that
|
||||
/// is never cut off mid-send, and no further, so one that never stops
|
||||
/// sending is still answered: refused at the door (no token) and past
|
||||
/// it (no such upload) alike.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_refused_upload_is_read_up_to_the_most_an_upload_can_be() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
let (_, text) = token(&books, "acme", "agent");
|
||||
let max = ledger_store::uploads::MAX_UPLOAD as usize;
|
||||
let mib = 1024 * 1024;
|
||||
assert_eq!(max % mib, 0);
|
||||
for (bearer, status, code) in [
|
||||
(None, 401, crate::access::UNAUTHORIZED),
|
||||
(Some(text.clone()), 404, "no_such_upload"),
|
||||
] {
|
||||
let (tx, body) = fed();
|
||||
let answered = tokio::spawn({
|
||||
let books = books.clone();
|
||||
async move {
|
||||
let id = "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
send_upload(&books, id, bearer.as_deref(), body).await
|
||||
}
|
||||
});
|
||||
for _ in 0..max / mib {
|
||||
tx.send(vec![b'x'; mib]).await.unwrap();
|
||||
}
|
||||
// Every byte up to the most taken, and a moment to act on it:
|
||||
// still reading.
|
||||
let more = tx.reserve().await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
assert!(
|
||||
!answered.is_finished(),
|
||||
"{status}: answered before the most an upload can be was read"
|
||||
);
|
||||
// One byte past it: answered, though the body never ends.
|
||||
more.send(vec![b'x']);
|
||||
let r = tokio::time::timeout(Duration::from_secs(60), answered)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("{status}: never answered"))
|
||||
.unwrap();
|
||||
let (got, refused, said) = answer(r).await;
|
||||
assert_eq!((got, refused.as_deref()), (status, Some(code)), "{said}");
|
||||
drop(tx);
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 8: a token revoked or replaced lets go of the sessions it
|
||||
/// opened, and only those: not another book's token of its name, nor
|
||||
/// its own book's token of another name.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_replaced_or_revoked_token_lets_go_of_its_own_sessions_alone() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
create(&books, "beta");
|
||||
// Oldest first: another book's token of the same name, then one of
|
||||
// the same book with another name, then the one replaced.
|
||||
let (beta, _) = token(&books, "beta", "agent");
|
||||
let (other, _) = token(&books, "acme", "other");
|
||||
let (agent, _) = token(&books, "acme", "agent");
|
||||
{
|
||||
let mut bound = books.session_tokens.lock().unwrap();
|
||||
for (sid, owner) in [("s-beta", &beta), ("s-other", &other), ("s-agent", &agent)] {
|
||||
bound.insert(sid.into(), owner.clone());
|
||||
}
|
||||
}
|
||||
token(&books, "acme", "agent");
|
||||
assert_eq!(bound(&books), ["s-beta", "s-other"]);
|
||||
match books.admin(Request::TokenRevoke {
|
||||
book: "beta".into(),
|
||||
name: "agent".into(),
|
||||
id: None,
|
||||
}) {
|
||||
Response::TokenRevoked(t) => assert_eq!(t.id, beta),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(bound(&books), ["s-other"]);
|
||||
}
|
||||
|
||||
/// An MCP request to `acme` with `bearer`, on `session` if given.
|
||||
fn mcp_request(
|
||||
method: axum::http::Method,
|
||||
bearer: &str,
|
||||
session: Option<&str>,
|
||||
body: serde_json::Value,
|
||||
) -> axum::extract::Request {
|
||||
let mut r = axum::http::Request::builder()
|
||||
.method(method)
|
||||
.uri("http://localhost/b/acme/mcp")
|
||||
.header("host", "localhost")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.header("authorization", format!("Bearer {bearer}"));
|
||||
if let Some(sid) = session {
|
||||
r = r
|
||||
.header(SESSION_HEADER, sid)
|
||||
.header("mcp-protocol-version", "2025-06-18");
|
||||
}
|
||||
r.body(axum::body::Body::from(body.to_string())).unwrap()
|
||||
}
|
||||
|
||||
async fn send_mcp(
|
||||
books: &Arc<Books>,
|
||||
request: axum::extract::Request,
|
||||
) -> axum::response::Response {
|
||||
mcp(
|
||||
axum::extract::State(books.clone()),
|
||||
axum::extract::Path("acme".to_string()),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn initialize() -> serde_json::Value {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "test", "version": "0"}}})
|
||||
}
|
||||
|
||||
/// INVARIANT 8: a session stays bound to the token that opened it
|
||||
/// through every request it serves, and is forgotten when it ends.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_session_stays_bound_to_its_token_until_it_ends() {
|
||||
use axum::http::Method;
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
let (agent_id, agent) = token(&books, "acme", "agent");
|
||||
let (_, other) = token(&books, "acme", "other");
|
||||
let r = send_mcp(
|
||||
&books,
|
||||
mcp_request(Method::POST, &agent, None, initialize()),
|
||||
)
|
||||
.await;
|
||||
let sid = r.headers()[SESSION_HEADER].to_str().unwrap().to_string();
|
||||
let (status, _, said) = answer(r).await;
|
||||
assert_eq!(status, 200, "{said}");
|
||||
let initialized =
|
||||
serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
|
||||
let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
|
||||
for body in [initialized, list.clone()] {
|
||||
let r = send_mcp(&books, mcp_request(Method::POST, &agent, Some(&sid), body)).await;
|
||||
let (status, _, said) = answer(r).await;
|
||||
assert!((200..300).contains(&status), "{status}: {said}");
|
||||
assert_eq!(
|
||||
books.session_tokens.lock().unwrap().get(&sid),
|
||||
Some(&agent_id)
|
||||
);
|
||||
}
|
||||
let r = send_mcp(&books, mcp_request(Method::POST, &other, Some(&sid), list)).await;
|
||||
let (status, code, _) = answer(r).await;
|
||||
assert_eq!((status, code.as_deref()), (403, Some("session_token")));
|
||||
// Ended: forgotten.
|
||||
let r = send_mcp(
|
||||
&books,
|
||||
mcp_request(Method::DELETE, &agent, Some(&sid), serde_json::json!({})),
|
||||
)
|
||||
.await;
|
||||
let (status, _, said) = answer(r).await;
|
||||
assert!((200..300).contains(&status), "{status}: {said}");
|
||||
assert!(bound(&books).is_empty());
|
||||
}
|
||||
|
||||
/// INVARIANT 8: past MAX_SESSIONS bindings only a request that opens
|
||||
/// a session (an initialize) is refused; any other still reaches the
|
||||
/// MCP service.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn past_the_most_sessions_only_opening_another_is_refused() {
|
||||
use axum::http::Method;
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let books = books_in(home.path());
|
||||
create(&books, "acme");
|
||||
let (agent_id, agent) = token(&books, "acme", "agent");
|
||||
{
|
||||
let mut bound = books.session_tokens.lock().unwrap();
|
||||
for n in 0..MAX_SESSIONS {
|
||||
bound.insert(format!("s-{n}"), agent_id.clone());
|
||||
}
|
||||
}
|
||||
let r = send_mcp(
|
||||
&books,
|
||||
mcp_request(Method::POST, &agent, None, initialize()),
|
||||
)
|
||||
.await;
|
||||
let (status, code, said) = answer(r).await;
|
||||
assert_eq!(
|
||||
(status, code.as_deref()),
|
||||
(503, Some("too_many_sessions")),
|
||||
"{said}"
|
||||
);
|
||||
let list = serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"});
|
||||
let r = send_mcp(&books, mcp_request(Method::POST, &agent, None, list)).await;
|
||||
let (status, code, said) = answer(r).await;
|
||||
assert_ne!(
|
||||
code.as_deref(),
|
||||
Some("too_many_sessions"),
|
||||
"{status}: {said}"
|
||||
);
|
||||
assert_eq!(bound(&books).len(), MAX_SESSIONS);
|
||||
}
|
||||
}
|
||||
@@ -10274,4 +10274,459 @@ mod tests {
|
||||
);
|
||||
assert_eq!(digest_since("", d(2027, 1, 9)), "2027-01-01");
|
||||
}
|
||||
|
||||
// Calls through the access door (access.rs), driven straight into the
|
||||
// handler with a context the HTTP door would have left: its Caller,
|
||||
// or none.
|
||||
|
||||
/// A peer to make request contexts with: a client and a server over
|
||||
/// an in-memory pipe, kept alive while it is.
|
||||
struct Session {
|
||||
peer: rmcp::service::Peer<RoleServer>,
|
||||
_server: rmcp::service::RunningService<RoleServer, BooksServer>,
|
||||
_client: rmcp::service::RunningService<rmcp::service::RoleClient, ()>,
|
||||
_books: TestDir,
|
||||
}
|
||||
|
||||
async fn session(tag: &str) -> Session {
|
||||
use rmcp::ServiceExt;
|
||||
let books = unit_dir(&format!("{tag}-peer"));
|
||||
let server = BooksServer::new(store(&books), ReceiptPolicy::default());
|
||||
let (a, b) = tokio::io::duplex(1 << 16);
|
||||
let served = tokio::spawn(async move { server.serve(a).await });
|
||||
let client = ().serve(b).await.unwrap();
|
||||
let server = served.await.unwrap().unwrap();
|
||||
Session {
|
||||
peer: server.peer().clone(),
|
||||
_server: server,
|
||||
_client: client,
|
||||
_books: books,
|
||||
}
|
||||
}
|
||||
|
||||
/// A request's context: from a token with `scopes` (the door's
|
||||
/// Caller), or with none.
|
||||
fn context(
|
||||
peer: &rmcp::service::Peer<RoleServer>,
|
||||
scopes: Option<&[ledger_store::auth::Scope]>,
|
||||
) -> RequestContext<RoleServer> {
|
||||
let mut cx = RequestContext::new(rmcp::model::NumberOrString::Number(7), peer.clone());
|
||||
if let Some(scopes) = scopes {
|
||||
let token = ledger_store::auth::Token {
|
||||
id: "tok_0123456789ab".into(),
|
||||
book: "acme".into(),
|
||||
name: "test".into(),
|
||||
scopes: scopes.iter().copied().collect(),
|
||||
created: "2026-10-20T00:00:00Z".into(),
|
||||
sha256: String::new(),
|
||||
};
|
||||
let (mut parts, ()) = axum::http::Request::new(()).into_parts();
|
||||
parts.extensions.insert(crate::access::Caller { token });
|
||||
cx.extensions.insert(parts);
|
||||
}
|
||||
cx
|
||||
}
|
||||
|
||||
const ALL: &[ledger_store::auth::Scope] = &[
|
||||
ledger_store::auth::Scope::Read,
|
||||
ledger_store::auth::Scope::Write,
|
||||
ledger_store::auth::Scope::Post,
|
||||
ledger_store::auth::Scope::Admin,
|
||||
];
|
||||
|
||||
/// Books served from the data root: calls are checked against their
|
||||
/// caller's token and recorded in the access log beside them.
|
||||
fn logged(tag: &str) -> (BooksServer, TestDir) {
|
||||
let books = unit_dir(tag);
|
||||
let store = store(&books);
|
||||
let log = ledger_store::access::AccessLog::open(&books.0).unwrap();
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log));
|
||||
(server, books)
|
||||
}
|
||||
|
||||
/// Books whose access log can't be written: its file is a FIFO no one
|
||||
/// reads, so every record fails (as on a full disk).
|
||||
fn unwritable(tag: &str) -> (BooksServer, TestDir) {
|
||||
let books = unit_dir(tag);
|
||||
let store = store(&books);
|
||||
let fifo = books.0.join(ledger_store::access::ACCESS_FILE);
|
||||
let made = std::process::Command::new("mkfifo")
|
||||
.arg(&fifo)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(made.success());
|
||||
let path = fifo.clone();
|
||||
let other_end = std::thread::spawn(move || {
|
||||
// The log reads the file whole as it opens (empty: a writer
|
||||
// that goes at once), then opens it to append (a reader that
|
||||
// goes at once): nothing reads what is written after.
|
||||
drop(std::fs::OpenOptions::new().write(true).open(&path).unwrap());
|
||||
drop(std::fs::File::open(&path).unwrap());
|
||||
});
|
||||
let log = ledger_store::access::AccessLog::open(&books.0).unwrap();
|
||||
other_end.join().unwrap();
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log));
|
||||
(server, books)
|
||||
}
|
||||
|
||||
/// The access log's records: (subject, outcome, whether it carries
|
||||
/// the arguments' digest).
|
||||
fn records(books: &TestDir) -> Vec<(String, ledger_store::access::Outcome, bool)> {
|
||||
ledger_store::access::read(&books.0)
|
||||
.unwrap()
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.access.subject,
|
||||
r.access.result,
|
||||
r.access.args_digest.is_some(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refused(code: &str) -> ledger_store::access::Outcome {
|
||||
ledger_store::access::Outcome::Refused(code.into())
|
||||
}
|
||||
|
||||
fn tool_call(name: &str, args: serde_json::Value) -> rmcp::model::CallToolRequestParams {
|
||||
rmcp::model::CallToolRequestParams::new(name.to_string())
|
||||
.with_arguments(args.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A tool's answer: Ok((is it a refusal, its text)), or the protocol
|
||||
/// error.
|
||||
fn answer(
|
||||
out: Result<rmcp::model::CallToolResponse, rmcp::ErrorData>,
|
||||
) -> Result<(bool, String), rmcp::ErrorData> {
|
||||
match out? {
|
||||
rmcp::model::CallToolResponse::Complete(r) => Ok((
|
||||
r.is_error == Some(true),
|
||||
r.content
|
||||
.iter()
|
||||
.find_map(|c| c.as_text().map(|t| t.text.clone()))
|
||||
.unwrap_or_default(),
|
||||
)),
|
||||
other => panic!("not a complete answer: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rt() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// access.rs INVARIANTS 1-3: a call that reaches the books without a
|
||||
/// Caller, or names a tool no table knows, is refused unrun (fail
|
||||
/// closed), told why in words, and recorded so.
|
||||
#[test]
|
||||
fn a_call_without_a_caller_or_to_no_tool_is_refused_unrun_and_recorded() {
|
||||
rt().block_on(async {
|
||||
let s = session("door").await;
|
||||
let (server, books) = logged("door");
|
||||
let add = || {
|
||||
tool_call(
|
||||
"books.add_account",
|
||||
serde_json::json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
)
|
||||
};
|
||||
let (refusal, text) =
|
||||
answer(server.call_tool(add(), context(&s.peer, None)).await).unwrap();
|
||||
assert!(refusal, "{text}");
|
||||
assert!(text.contains("without a token"), "{text}");
|
||||
let unknown = tool_call("books.no_such_tool", serde_json::json!({}));
|
||||
let (refusal, text) =
|
||||
answer(server.call_tool(unknown, context(&s.peer, Some(ALL))).await).unwrap();
|
||||
assert!(refusal, "{text}");
|
||||
assert!(text.contains("there is no tool"), "{text}");
|
||||
// Neither ran: the account isn't there.
|
||||
let chart = tool_call("books.chart", serde_json::json!({}));
|
||||
let (refusal, text) =
|
||||
answer(server.call_tool(chart, context(&s.peer, Some(ALL))).await).unwrap();
|
||||
assert!(!refusal, "{text}");
|
||||
assert!(!text.contains("Cash"), "{text}");
|
||||
assert_eq!(
|
||||
records(&books),
|
||||
vec![
|
||||
("books.add_account".into(), refused("unauthorized"), true),
|
||||
("books.no_such_tool".into(), refused("unknown_tool"), true),
|
||||
(
|
||||
"books.chart".into(),
|
||||
ledger_store::access::Outcome::Ok,
|
||||
true
|
||||
),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// SharedStore INVARIANT 3: once the books are closing a call or a
|
||||
/// read isn't run, and says so with the not-run code (a JSON-RPC
|
||||
/// server error, which the agents' bridge sends again once the books
|
||||
/// are back); each is recorded as refused.
|
||||
#[test]
|
||||
fn a_call_while_the_books_close_is_refused_as_not_run() {
|
||||
rt().block_on(async {
|
||||
let s = session("closing").await;
|
||||
let (server, books) = logged("closing");
|
||||
server.store.close().await;
|
||||
let chart = tool_call("books.chart", serde_json::json!({}));
|
||||
let e = server
|
||||
.call_tool(chart, context(&s.peer, Some(ALL)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
|
||||
assert_eq!(e.message, NOT_RUN_CLOSING);
|
||||
// JSON-RPC 2.0 5.1: -32000 to -32099 is for the server's own
|
||||
// errors.
|
||||
assert!((-32099..=-32000).contains(&NOT_RUN_CLOSING_CODE));
|
||||
assert!((-32099..=-32000).contains(&NOT_RUN_STOPPED_CODE));
|
||||
let read = ReadResourceRequestParams::new("books://chart");
|
||||
let e = server
|
||||
.read_resource(read, context(&s.peer, Some(ALL)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
|
||||
assert_eq!(
|
||||
records(&books),
|
||||
vec![
|
||||
("books.chart".into(), refused("closing"), true),
|
||||
("books://chart".into(), refused("closing"), false),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reading a resource is reading the books (access.rs): only a token
|
||||
/// with books:read reads one, and every read is recorded with what
|
||||
/// came of it.
|
||||
#[test]
|
||||
fn a_resource_is_read_only_with_books_read_and_each_read_is_recorded() {
|
||||
use ledger_store::access::Outcome;
|
||||
rt().block_on(async {
|
||||
let s = session("resources").await;
|
||||
let (server, books) = logged("resources");
|
||||
let read = |uri: &str| ReadResourceRequestParams::new(uri);
|
||||
assert!(
|
||||
server
|
||||
.read_resource(read("books://chart"), context(&s.peer, Some(ALL)))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
let writer = [ledger_store::auth::Scope::Write];
|
||||
let e = server
|
||||
.read_resource(read("books://chart"), context(&s.peer, Some(&writer[..])))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.message, crate::access::INSUFFICIENT_SCOPE);
|
||||
let e = server
|
||||
.read_resource(read("books://chart"), context(&s.peer, None))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.message, crate::access::UNAUTHORIZED);
|
||||
// One that ran and failed is an error, not a refusal.
|
||||
assert!(
|
||||
server
|
||||
.read_resource(read("books://nope"), context(&s.peer, Some(ALL)))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let outcomes: Vec<_> = records(&books).into_iter().map(|r| r.1).collect();
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![
|
||||
Outcome::Ok,
|
||||
refused("insufficient_scope"),
|
||||
refused("unauthorized"),
|
||||
Outcome::Error,
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// SharedStore INVARIANTS 2-3 at the last moment: a call that passed
|
||||
/// the checks, then found the books closing (or the server stopped)
|
||||
/// as it went to run, isn't run either, and is answered and recorded
|
||||
/// as one refused at the start would be. The settings are held so
|
||||
/// the call waits between its checks and its run.
|
||||
#[test]
|
||||
fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() {
|
||||
let rt = rt();
|
||||
for (how, stops) in [("closes", false), ("stops", true)] {
|
||||
let (server, books) = logged(&format!("late-{how}"));
|
||||
// The settings, held on a thread of their own until released.
|
||||
let (locked, is_locked) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel::<()>();
|
||||
let settings = server.settings.clone();
|
||||
let holder = std::thread::spawn(move || {
|
||||
let _held = settings.lock().unwrap();
|
||||
locked.send(()).unwrap();
|
||||
let _ = released.recv();
|
||||
});
|
||||
is_locked.recv().unwrap();
|
||||
let answered = rt.block_on(async {
|
||||
let s = session(&format!("late-{how}")).await;
|
||||
let (started, began) = tokio::sync::oneshot::channel();
|
||||
let calling = server.clone();
|
||||
let cx = context(&s.peer, Some(ALL));
|
||||
let call = tokio::spawn(async move {
|
||||
let _ = started.send(());
|
||||
let chart = tool_call("books.chart", serde_json::json!({}));
|
||||
calling.call_tool(chart, cx).await
|
||||
});
|
||||
began.await.unwrap();
|
||||
// Past its checks (no I/O between): waiting for the settings.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
if stops {
|
||||
server.store.stop();
|
||||
} else {
|
||||
server
|
||||
.store
|
||||
.closing
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
release.send(()).unwrap();
|
||||
call.await.unwrap()
|
||||
});
|
||||
holder.join().unwrap();
|
||||
if stops {
|
||||
assert_eq!(answer(answered).unwrap(), (true, STOPPED.to_string()));
|
||||
// Refused after its arguments were taken: their digest too.
|
||||
assert_eq!(
|
||||
records(&books),
|
||||
vec![("books.chart".into(), refused("stopped"), true)]
|
||||
);
|
||||
} else {
|
||||
let e = answered.unwrap_err();
|
||||
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE, "{e:?}");
|
||||
// Its arguments went with the request: no digest.
|
||||
assert_eq!(
|
||||
records(&books),
|
||||
vec![("books.chart".into(), refused("closing"), false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// access.rs INVARIANT 3: a record that can't be written stops the
|
||||
/// books, and the answer says so; a call or read refused as not run
|
||||
/// (closing) keeps its not-run code, so the bridge still sends it
|
||||
/// again; anything else is an error in words.
|
||||
#[test]
|
||||
fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() {
|
||||
rt().block_on(async {
|
||||
let s = session("unrecorded").await;
|
||||
// A call refused as the books close.
|
||||
let (server, _books) = unwritable("unrecorded-call");
|
||||
server.store.close().await;
|
||||
let chart = tool_call("books.chart", serde_json::json!({}));
|
||||
let e = server
|
||||
.call_tool(chart, context(&s.peer, Some(ALL)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
|
||||
assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}");
|
||||
assert!(server.store.stopped());
|
||||
// Any other answer refused unrun becomes the words alone.
|
||||
let (server, _books) = unwritable("unrecorded-refusal");
|
||||
let call = crate::access::Call::new(
|
||||
server.access.as_ref().unwrap(),
|
||||
&context(&s.peer, Some(ALL)),
|
||||
"tool",
|
||||
"books.chart",
|
||||
);
|
||||
let out = server
|
||||
.refuse(
|
||||
Some(&call),
|
||||
crate::access::STOPPED,
|
||||
Err(rmcp::ErrorData::internal_error("not run", None)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
answer(out).unwrap(),
|
||||
(true, crate::access::NOT_RECORDED.to_string())
|
||||
);
|
||||
assert!(server.store.stopped());
|
||||
// A read refused as the books close.
|
||||
let (server, _books) = unwritable("unrecorded-closing-read");
|
||||
server.store.close().await;
|
||||
let e = server
|
||||
.read_resource(
|
||||
ReadResourceRequestParams::new("books://chart"),
|
||||
context(&s.peer, Some(ALL)),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE);
|
||||
assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}");
|
||||
assert!(server.store.stopped());
|
||||
// A read that ran and failed.
|
||||
let (server, _books) = unwritable("unrecorded-read");
|
||||
let e = server
|
||||
.read_resource(
|
||||
ReadResourceRequestParams::new("books://nope"),
|
||||
context(&s.peer, Some(ALL)),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.code, rmcp::model::ErrorCode::INTERNAL_ERROR);
|
||||
assert_eq!(e.message, crate::access::NOT_RECORDED);
|
||||
assert!(server.store.stopped());
|
||||
});
|
||||
}
|
||||
|
||||
/// A request whose blocking work the runtime never ran (it was
|
||||
/// shutting down) is answered as not run, in the words a stopped
|
||||
/// server uses, and stops nothing: only a request that panicked
|
||||
/// stops the books (run_blocking).
|
||||
#[test]
|
||||
fn a_request_the_runtime_never_ran_is_answered_not_run_and_stops_nothing() {
|
||||
let dead = rt();
|
||||
let handle = dead.handle().clone();
|
||||
drop(dead);
|
||||
rt().block_on(async {
|
||||
let s = session("never-ran").await;
|
||||
let (server, _books) = logged("never-ran");
|
||||
let chart = tool_call("books.chart", serde_json::json!({}));
|
||||
let mut call = std::pin::pin!(server.call_tool(chart, context(&s.peer, Some(ALL))));
|
||||
// Its blocking work goes to the runtime that shut down.
|
||||
let out = std::future::poll_fn(|cx| {
|
||||
let _in = handle.enter();
|
||||
call.as_mut().poll(cx)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(answer(out).unwrap(), (true, STOPPED.to_string()));
|
||||
assert!(!server.store.stopped());
|
||||
});
|
||||
}
|
||||
|
||||
/// A tool list differs by caller (scopes.rs): a client on 2026-07-28
|
||||
/// or later is told to cache it for this caller alone, and not to keep
|
||||
/// it; an older one, which knows neither hint, is told neither.
|
||||
#[test]
|
||||
fn a_tool_list_is_cached_only_for_its_caller() {
|
||||
rt().block_on(async {
|
||||
let s = session("hints").await;
|
||||
let books = unit_dir("hints");
|
||||
let server = BooksServer::new(store(&books), ReceiptPolicy::default());
|
||||
for (version, hinted) in [
|
||||
(rmcp::model::ProtocolVersion::V_2026_07_28, true),
|
||||
(rmcp::model::ProtocolVersion::V_2025_11_25, false),
|
||||
] {
|
||||
let mut cx = context(&s.peer, None);
|
||||
cx.meta.set_protocol_version(version.clone());
|
||||
let list = server.list_tools(None, cx).await.unwrap();
|
||||
assert!(!list.tools.is_empty());
|
||||
assert_eq!(list.ttl_ms, hinted.then_some(0), "{version:?}");
|
||||
assert_eq!(
|
||||
list.cache_scope,
|
||||
hinted.then_some(rmcp::model::CacheScope::Private),
|
||||
"{version:?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -568,4 +568,19 @@ mod tests {
|
||||
live.sync(&books, "USD");
|
||||
assert!(live.is_journaled());
|
||||
}
|
||||
|
||||
/// Books in the data root keep their own settings from the start
|
||||
/// (INVARIANT 6): before they hold any, the settings in force say so
|
||||
/// too, so no message sends anyone to a settings file; none is read.
|
||||
#[test]
|
||||
fn books_in_the_data_root_keep_their_own_settings_before_they_hold_any() {
|
||||
let mut live = Live::own();
|
||||
assert!(live.current().journaled);
|
||||
assert!(live.is_journaled());
|
||||
assert!(live.file_texts().is_none());
|
||||
live.sync(&ledger_core::settings::Settings::default(), "USD");
|
||||
assert!(live.current().journaled);
|
||||
let s = live.status();
|
||||
assert!(s.journaled && s.books_toml.is_none(), "{s:?}");
|
||||
}
|
||||
}
|
||||
@@ -347,7 +347,7 @@ impl BooksServer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plain_file_name;
|
||||
use super::{CALLER, plain_file_name, token_flag};
|
||||
|
||||
/// A name kept before names were checked is handed over cleaned, never
|
||||
/// refused (a W-9 sealed earlier, say).
|
||||
@@ -364,6 +364,10 @@ mod tests {
|
||||
let long = "x".repeat(400);
|
||||
let cut = plain_file_name(&long);
|
||||
assert!(cut.len() <= ledger_store::uploads::MAX_NAME_BYTES);
|
||||
// Cut to fit, not dropped: as much of the name as the checks take,
|
||||
// and a name that fits already kept whole.
|
||||
assert_eq!(cut, "x".repeat(255));
|
||||
assert_eq!(plain_file_name(&"y".repeat(255)), "y".repeat(255));
|
||||
for n in [
|
||||
"W9 \u{2714}\u{FE0F}.pdf",
|
||||
"a/b\\c\n.pdf",
|
||||
@@ -377,4 +381,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A command given to the agent names its token only when it isn't
|
||||
/// the one the commands use by default ("agent"): the command runs as
|
||||
/// the caller, never as another token.
|
||||
#[test]
|
||||
fn a_command_names_the_callers_token_unless_it_is_the_default() {
|
||||
let flag_for = |caller: Option<(&str, &str)>| {
|
||||
CALLER.with(|c| {
|
||||
*c.borrow_mut() = caller.map(|(id, name)| (id.to_string(), name.to_string()))
|
||||
});
|
||||
token_flag()
|
||||
};
|
||||
assert_eq!(flag_for(None), "");
|
||||
assert_eq!(flag_for(Some(("tok_1", "agent"))), "");
|
||||
assert_eq!(flag_for(Some(("tok_2", "reader"))), " --token reader");
|
||||
assert_eq!(flag_for(None), "");
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,13 @@ fn a_settings_file_moves_into_the_books() {
|
||||
json!({"changes": [{"key": "company.cpa", "value": "Casey"}], "decided_by": "A"}),
|
||||
);
|
||||
assert!(refused.contains("books.import_settings"), "{refused}");
|
||||
// Nor is there an export yet: the file is the copy.
|
||||
let refused = m.call_err("books.export_settings", json!({}));
|
||||
assert!(
|
||||
refused.contains("still read their settings file")
|
||||
&& refused.contains("books.import_settings"),
|
||||
"{refused}"
|
||||
);
|
||||
// The preview records nothing.
|
||||
let head = |m: &mut Mcp| m.call("books.start", json!({}))["head"]["seq"].clone();
|
||||
let before = head(&mut m);
|
||||
|
||||
@@ -410,8 +410,12 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
let mut used: std::collections::HashSet<String> =
|
||||
files.iter().map(|(n, _)| n.to_lowercase()).collect();
|
||||
let mut unique = |name: String| -> String {
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) if !s.ends_with('/') && !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
// The extension is the last segment's own: a folder with a dot
|
||||
// ("a.b/notes") keeps its name, and a name that is all extension
|
||||
// (".env") has none.
|
||||
let cut = name.rfind('/').map_or(0, |i| i + 1);
|
||||
let (stem, ext) = match name[cut..].rfind('.') {
|
||||
Some(i) if i > 0 => (name[..cut + i].to_string(), name[cut + i..].to_string()),
|
||||
_ => (name.clone(), String::new()),
|
||||
};
|
||||
let mut n = 1;
|
||||
@@ -424,7 +428,8 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
};
|
||||
if req.include_receipts {
|
||||
let clean = |s: &str| -> String {
|
||||
s.chars()
|
||||
let c: String = s
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || ".-_ ".contains(c) {
|
||||
c
|
||||
@@ -432,7 +437,14 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
// A name of dots alone ("", ".", "..") would name no file, or
|
||||
// climb out of its folder when the zip is unpacked.
|
||||
if c.chars().all(|c| c == '.') {
|
||||
"_".repeat(c.len().max(1))
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
let mut index = String::from("entry_id,date,description,amount,file,sha256\n");
|
||||
for a in books.all_attachments().map_err(|e| e.to_string())? {
|
||||
|
||||
@@ -797,3 +797,194 @@ fn a_single_members_readme_promises_no_totals_row() {
|
||||
let p = build_package(&b.store, &settings(&two, &cash), &request(false, None)).unwrap();
|
||||
assert!(text(&p, "README.txt").contains("the last row totals"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dotfiles_twin_keeps_its_whole_name_and_its_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, _) = plain_books(dir.path());
|
||||
// A name that is all extension (".env"): its twin is ".env (2)", never
|
||||
// " (2).env", a name made of nothing but a number.
|
||||
for bytes in [b"one".as_slice(), b"two".as_slice()] {
|
||||
s.file_document("legal", ".env", "text/plain", bytes, None)
|
||||
.unwrap();
|
||||
}
|
||||
let company = company(false);
|
||||
let cash = cash();
|
||||
let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap();
|
||||
let docs: Vec<&str> = names(&p)
|
||||
.into_iter()
|
||||
.filter(|n| n.starts_with("documents/"))
|
||||
.collect();
|
||||
assert_eq!(docs, ["documents/legal/.env", "documents/legal/.env (2)"]);
|
||||
assert_eq!(text(&p, "documents/legal/.env"), "one");
|
||||
assert_eq!(text(&p, "documents/legal/.env (2)"), "two");
|
||||
assert_zip_is_the_files(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_twins_number_goes_on_its_own_name_and_no_name_climbs_out_of_its_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, _) = plain_books(dir.path());
|
||||
// A dot in the folder is not the file's extension: the second "notes"
|
||||
// in "a.b" is "a.b/notes (2)", never "a (2).b/notes" in another
|
||||
// folder. And a name of dots alone would climb out of its folder when
|
||||
// the zip is unpacked: it becomes underscores.
|
||||
for bytes in [b"one".as_slice(), b"two".as_slice()] {
|
||||
s.file_document("a.b", "notes", "text/plain", bytes, None)
|
||||
.unwrap();
|
||||
}
|
||||
s.file_document("..", "..", "text/plain", b"three", None)
|
||||
.unwrap();
|
||||
s.file_document("legal", ".", "text/plain", b"four", None)
|
||||
.unwrap();
|
||||
let company = company(false);
|
||||
let cash = cash();
|
||||
let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap();
|
||||
let docs: Vec<&str> = names(&p)
|
||||
.into_iter()
|
||||
.filter(|n| n.starts_with("documents/"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
docs,
|
||||
[
|
||||
"documents/a.b/notes",
|
||||
"documents/a.b/notes (2)",
|
||||
"documents/__/__",
|
||||
"documents/legal/_"
|
||||
]
|
||||
);
|
||||
assert_eq!(text(&p, "documents/a.b/notes (2)"), "two");
|
||||
assert_eq!(text(&p, "documents/__/__"), "three");
|
||||
assert!(
|
||||
names(&p).iter().all(|n| n
|
||||
.split('/')
|
||||
.all(|seg| seg != ".." && seg != "." && !seg.is_empty())),
|
||||
"{:?}",
|
||||
names(&p)
|
||||
);
|
||||
assert_zip_is_the_files(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_years_first_and_last_days_receipts_are_in_and_the_days_beyond_are_not() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, _) = plain_books(dir.path());
|
||||
// The fiscal year is 2026 (the default start, January 1) and as_of is
|
||||
// its last day: the receipts on both edges go in, the ones a day
|
||||
// outside stay out.
|
||||
let mut on = Vec::new();
|
||||
for date in ["2025-12-31", "2026-01-01", "2026-12-31", "2027-01-01"] {
|
||||
let e = post(
|
||||
&mut s,
|
||||
date,
|
||||
"Rent",
|
||||
&[
|
||||
("6000", Side::Debit, "100.00"),
|
||||
("1000", Side::Credit, "100.00"),
|
||||
],
|
||||
);
|
||||
s.attach_receipt(Some(e), "r.pdf", "application/pdf", date.as_bytes(), None)
|
||||
.unwrap();
|
||||
on.push((date, e));
|
||||
}
|
||||
let company = company(false);
|
||||
let cash = cash();
|
||||
let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap();
|
||||
let receipts: Vec<&str> = names(&p)
|
||||
.into_iter()
|
||||
.filter(|n| n.starts_with("receipts/") && *n != "receipts/index.csv")
|
||||
.collect();
|
||||
let first = format!("receipts/2026-01-01-entry-{}-r.pdf", on[1].1);
|
||||
let last = format!("receipts/2026-12-31-entry-{}-r.pdf", on[2].1);
|
||||
assert_eq!(receipts, [first.as_str(), last.as_str()]);
|
||||
assert_eq!(p.receipt_count, 2);
|
||||
assert_eq!(text(&p, &first), "2026-01-01");
|
||||
assert_eq!(text(&p, &last), "2026-12-31");
|
||||
assert_eq!(text(&p, "receipts/index.csv").lines().count(), 3);
|
||||
}
|
||||
|
||||
/// Contract labor paid three ways: from checking (cash), from a prepaid
|
||||
/// asset that isn't cash, and by a member's advance (financing).
|
||||
#[test]
|
||||
fn payments_to_a_vendor_come_from_the_settings_accounts_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, _) = plain_books(dir.path());
|
||||
for (code, name, ty) in [
|
||||
("1300", "Prepaid expenses", AccountType::Asset),
|
||||
("2100", "Member advances", AccountType::Liability),
|
||||
("6100", "Contract labor", AccountType::Expense),
|
||||
] {
|
||||
s.add_account(code, name, ty, None, false).unwrap();
|
||||
}
|
||||
let vendor = s.add_vendor("Acme Design").unwrap();
|
||||
s.set_vendor_1099(vendor, Some(Form1099::Nec), true)
|
||||
.unwrap();
|
||||
for (from, amount) in [("1000", "1000.00"), ("1300", "400.00"), ("2100", "700.00")] {
|
||||
let e = s.create_entry(d("2026-05-01"), "Acme design work").unwrap();
|
||||
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount))
|
||||
.unwrap();
|
||||
s.add_line(e, id_of(&s, from), Side::Credit, usd(amount))
|
||||
.unwrap();
|
||||
s.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
}
|
||||
let company = company(false);
|
||||
// A code the chart lacks is skipped.
|
||||
let cash = vec!["1000".to_string(), "1999".to_string()];
|
||||
let financing = vec!["2100".to_string()];
|
||||
let st = Settings {
|
||||
company: &company,
|
||||
cash_accounts: &cash,
|
||||
financing_accounts: &financing,
|
||||
investing_accounts: &[],
|
||||
};
|
||||
let ledger = s.ledger();
|
||||
assert_eq!(
|
||||
ledger_package::payment_accounts(ledger, &st),
|
||||
ledger_core::PaymentAccounts {
|
||||
cash: vec![id_of(&s, "1000")],
|
||||
financing: vec![id_of(&s, "2100")],
|
||||
}
|
||||
);
|
||||
// Checking and the advance count; the prepaid asset doesn't.
|
||||
let w = vendor_1099_worksheet(ledger, &st, 2026, None, None).unwrap();
|
||||
assert_eq!(w.lines.len(), 1);
|
||||
assert_eq!(w.lines[0].vendor_id, vendor);
|
||||
assert_eq!(w.lines[0].paid, Decimal::from(1700));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_receipts_index_amount_is_the_entrys_debit_total_as_written() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, _) = plain_books(dir.path());
|
||||
// A tool takes "100.000" as written (it is whole cents): the two sides
|
||||
// are equal in value but not in spelling. The index prints the debit
|
||||
// side's total, padded to cents, never the credit side's "100.000".
|
||||
let e = post(
|
||||
&mut s,
|
||||
"2026-06-01",
|
||||
"Rent, June",
|
||||
&[
|
||||
("6000", Side::Debit, "60"),
|
||||
("6000", Side::Debit, "40.0"),
|
||||
("1000", Side::Credit, "100.000"),
|
||||
],
|
||||
);
|
||||
s.attach_receipt(Some(e), "june.pdf", "application/pdf", b"%PDF june", None)
|
||||
.unwrap();
|
||||
let company = company(false);
|
||||
let cash = cash();
|
||||
let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap();
|
||||
let index = text(&p, "receipts/index.csv");
|
||||
let row = index
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&format!("{e},")))
|
||||
.unwrap();
|
||||
let fields: Vec<&str> = row.split(',').collect();
|
||||
assert_eq!(
|
||||
fields[..4],
|
||||
[e.to_string().as_str(), "2026-06-01", "\"Rent", " June\""]
|
||||
);
|
||||
assert_eq!(fields[4], "100.00", "{row}");
|
||||
assert_eq!(fields[5], format!("receipts/2026-06-01-entry-{e}-june.pdf"));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! vault_tin: a worksheet's full TIN comes from the vault only when the
|
||||
//! books' TIN facts and W-9 facts agree with it (the module's INVARIANTS
|
||||
//! 1 and 2), checked against a real vault in a temp directory.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{Currency, PartyId, Role, TinFacts, TinType, W9Facts};
|
||||
use ledger_package::vault_tin;
|
||||
use ledger_store::Store;
|
||||
use ledger_vault::{Tin, Vault};
|
||||
use std::path::Path;
|
||||
|
||||
const EIN: &str = "12-3456789";
|
||||
/// A SHA-256 no vault record has.
|
||||
const STALE: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
/// Books with one contractor, and a vault holding its EIN; the record's
|
||||
/// hash comes back.
|
||||
fn setup(dir: &Path) -> (Store, Result<Vault, String>, PartyId, String) {
|
||||
let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap();
|
||||
let acme = s.add_party("Acme Design", &[Role::Contractor]).unwrap();
|
||||
let vault = Vault::open(&dir.join("vault"), &dir.join("vault.key"), true).unwrap();
|
||||
let hash = vault
|
||||
.put_tin(acme, &Tin::new(EIN, ledger_vault::TinType::Ein).unwrap())
|
||||
.unwrap();
|
||||
(s, Ok(vault), acme, hash)
|
||||
}
|
||||
|
||||
fn tin_facts(tin_type: TinType, vault_record: Option<&str>) -> TinFacts {
|
||||
TinFacts {
|
||||
tin_type,
|
||||
last4: "6789".into(),
|
||||
vault_record: vault_record.map(String::from),
|
||||
purged: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn w9(vault_record: Option<&str>) -> W9Facts {
|
||||
W9Facts {
|
||||
received: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
entity_type: "C corporation".into(),
|
||||
exempt_payee_code: None,
|
||||
fatca_code: None,
|
||||
tin_last4: "6789".into(),
|
||||
kept_at: "wanderbooks vault".into(),
|
||||
vault_record: vault_record.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tin_of_another_type_with_the_same_last_four_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, vault, acme, _) = setup(dir.path());
|
||||
// The books say an SSN ending 6789; the vault holds an EIN ending 6789.
|
||||
s.set_party_tin(acme, Some(tin_facts(TinType::Ssn, None)))
|
||||
.unwrap();
|
||||
let err = vault_tin(s.ledger(), &vault, acme).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"the vault's TIN (ein, ending 6789) isn't the one the books record (ssn, ending 6789): record the right one with books.set_tin"
|
||||
);
|
||||
// The right type: the TIN.
|
||||
s.set_party_tin(acme, Some(tin_facts(TinType::Ein, None)))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vault_tin(s.ledger(), &vault, acme).unwrap().digits(),
|
||||
"123456789"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_only_w9_facts_their_vault_record_must_be_the_records_hash() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, vault, acme, hash) = setup(dir.path());
|
||||
// The W-9 facts name the record: the TIN.
|
||||
s.set_party_w9(acme, Some(w9(Some(&hash)))).unwrap();
|
||||
assert_eq!(
|
||||
vault_tin(s.ledger(), &vault, acme).unwrap().digits(),
|
||||
"123456789"
|
||||
);
|
||||
// They name no record: nothing to check it against, the TIN.
|
||||
s.set_party_w9(acme, Some(w9(None))).unwrap();
|
||||
assert_eq!(
|
||||
vault_tin(s.ledger(), &vault, acme).unwrap().digits(),
|
||||
"123456789"
|
||||
);
|
||||
// They name another record: refused.
|
||||
s.set_party_w9(acme, Some(w9(Some(STALE)))).unwrap();
|
||||
assert_eq!(
|
||||
vault_tin(s.ledger(), &vault, acme).unwrap_err(),
|
||||
"the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_tin_facts_the_w9_facts_record_is_not_the_one_checked() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, vault, acme, hash) = setup(dir.path());
|
||||
// The TIN facts name the record; the W-9 facts name an older one, or
|
||||
// none: the TIN facts' record is the one that counts (INVARIANT 2).
|
||||
s.set_party_tin(acme, Some(tin_facts(TinType::Ein, Some(&hash))))
|
||||
.unwrap();
|
||||
for record in [Some(STALE), None] {
|
||||
s.set_party_w9(acme, Some(w9(record))).unwrap();
|
||||
assert_eq!(
|
||||
vault_tin(s.ledger(), &vault, acme).unwrap().digits(),
|
||||
"123456789",
|
||||
"{record:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -404,6 +404,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_under_a_key_naming_a_tin_is_masked_whatever_it_looks_like() {
|
||||
// Even a value that doesn't look like a TIN (a last four, a typo):
|
||||
// the key says what it is.
|
||||
for key in [
|
||||
"tin",
|
||||
"TIN",
|
||||
"payee_tin",
|
||||
"Member_TIN",
|
||||
"ssn",
|
||||
"ein",
|
||||
"itin",
|
||||
] {
|
||||
assert_eq!(
|
||||
digest(&serde_json::json!({ key: "6789" })),
|
||||
digest(&serde_json::json!({ key: "1234" })),
|
||||
"{key}"
|
||||
);
|
||||
assert_eq!(
|
||||
digest(&serde_json::json!({ key: {"last4": 6789} })),
|
||||
digest(&serde_json::json!({ key: ["anything"] })),
|
||||
"{key}"
|
||||
);
|
||||
// A TIN not given isn't one: null stays null.
|
||||
assert_ne!(
|
||||
digest(&serde_json::json!({ key: null })),
|
||||
digest(&serde_json::json!({ key: "6789" })),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
// Only those keys: one that merely holds the letters counts.
|
||||
assert_ne!(
|
||||
digest(&serde_json::json!({"tint": "6789"})),
|
||||
digest(&serde_json::json!({"tint": "1234"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_bytes_are_hashed_as_they_are_even_where_they_spell_digits() {
|
||||
// Base64 can hold a run of nine digits; it is still a file's bytes,
|
||||
// not a TIN, and two files differing there differ.
|
||||
for key in FILE_BYTES {
|
||||
assert_ne!(
|
||||
digest(&serde_json::json!({ key: "QUJD123456789QUJD" })),
|
||||
digest(&serde_json::json!({ key: "QUJD987814321QUJD" })),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
// The same text anywhere else is masked.
|
||||
assert_eq!(
|
||||
digest(&serde_json::json!({"memo": "QUJD123456789QUJD"})),
|
||||
digest(&serde_json::json!({"memo": "QUJD987814321QUJD"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tin_at_the_cut_is_masked_whole() {
|
||||
// The TIN straddles the 64-character limit.
|
||||
|
||||
@@ -579,6 +579,40 @@ mod tests {
|
||||
assert_eq!(e, AuthError::Newer(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replacement_names_the_live_token_of_its_own_book_and_name() {
|
||||
let replacing = |old: &str, book: &str, name: &str| {
|
||||
let mut r = created("tok_2", name);
|
||||
r["book"] = book.into();
|
||||
r["replaces"] = old.into();
|
||||
r
|
||||
};
|
||||
// The right one: it takes the old one's place.
|
||||
let p = open_with(&[created("tok_1", "a"), replacing("tok_1", "acme", "a")]).unwrap();
|
||||
let live: Vec<&str> = p.tokens().iter().map(|t| t.id.as_str()).collect();
|
||||
assert_eq!(live, ["tok_2"]);
|
||||
// Any one of id, book or name wrong: a log that contradicts itself.
|
||||
for wrong in [
|
||||
replacing("tok_9", "acme", "a"),
|
||||
replacing("tok_1", "other", "a"),
|
||||
replacing("tok_1", "acme", "b"),
|
||||
] {
|
||||
let e = open_with(&[created("tok_1", "a"), wrong.clone()]).unwrap_err();
|
||||
assert!(matches!(e, AuthError::Inconsistent(2, _)), "{wrong}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_revocation_ends_the_token_it_names_and_no_other() {
|
||||
let revoked = |id: &str| serde_json::json!({"event": "token.revoked", "id": id});
|
||||
for (gone, left) in [("tok_1", "tok_2"), ("tok_2", "tok_1")] {
|
||||
let p =
|
||||
open_with(&[created("tok_1", "a"), created("tok_2", "b"), revoked(gone)]).unwrap();
|
||||
let live: Vec<&str> = p.tokens().iter().map(|t| t.id.as_str()).collect();
|
||||
assert_eq!(live, [left], "{gone} revoked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_record_is_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -441,6 +441,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_the_disk_wont_read_is_refused_not_taken_for_empty() {
|
||||
// A folder where the log should be: reading it fails, and an empty
|
||||
// log is never made of that.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("log.jsonl");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
assert!(matches!(read(&path, G), Err(ChainError::Io(_))));
|
||||
assert!(matches!(ChainLog::open(&path, G), Err(ChainError::Io(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_shows_its_head_but_never_its_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = three(dir.path());
|
||||
let (log, _) = ChainLog::open(&path, G).unwrap();
|
||||
let shown = format!("{log:?}");
|
||||
assert!(shown.starts_with("ChainLog"), "{shown}");
|
||||
assert!(shown.contains("seq: 3"), "{shown}");
|
||||
assert!(shown.contains(&log.head().hash), "{shown}");
|
||||
assert!(shown.contains("broken: false"), "{shown}");
|
||||
assert!(
|
||||
!shown.contains(&dir.path().display().to_string()),
|
||||
"{shown}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_a_new_log_syncs_its_folder_and_only_others_bits_are_taken_away() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = three(dir.path());
|
||||
// The owner's own bits are the owner's: a log 0700 stays 0700.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
drop(ChainLog::open(&path, G).unwrap());
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
// A folder that can be entered but not listed: a log already in it
|
||||
// opens (its folder's entry for it was synced when it was made);
|
||||
// a new one can't be made there, as its entry can't be synced.
|
||||
let shut = dir.path().join("shut");
|
||||
std::fs::create_dir(&shut).unwrap();
|
||||
let (mut log, _) = ChainLog::open(&shut.join("old.jsonl"), G).unwrap();
|
||||
log.append(fields(1)).unwrap();
|
||||
drop(log);
|
||||
std::fs::set_permissions(&shut, std::fs::Permissions::from_mode(0o300)).unwrap();
|
||||
if File::open(&shut).is_err() {
|
||||
let (_, records) = ChainLog::open(&shut.join("old.jsonl"), G).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(
|
||||
ChainLog::open(&shut.join("new.jsonl"), G).unwrap_err(),
|
||||
ChainError::Io(std::io::ErrorKind::PermissionDenied)
|
||||
);
|
||||
}
|
||||
std::fs::set_permissions(&shut, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unfinished_last_line_is_cut_away_on_open() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -484,6 +542,23 @@ mod tests {
|
||||
] {
|
||||
assert!(!e.to_string().contains('/'), "{e}");
|
||||
}
|
||||
// Each says what went wrong, and where in the log.
|
||||
assert_eq!(
|
||||
ChainError::Damaged {
|
||||
line: 3,
|
||||
why: "its number is out of order".into(),
|
||||
}
|
||||
.to_string(),
|
||||
"record 3 is damaged: its number is out of order"
|
||||
);
|
||||
assert_eq!(
|
||||
ChainError::Io(std::io::ErrorKind::PermissionDenied).to_string(),
|
||||
"the disk refused: permission denied"
|
||||
);
|
||||
assert_eq!(
|
||||
ChainError::Broken.to_string(),
|
||||
"an earlier record couldn't be written"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -256,6 +256,85 @@ mod tests {
|
||||
|
||||
const NOW: i64 = 1_800_000_000;
|
||||
|
||||
#[test]
|
||||
fn an_export_is_kept_fifteen_minutes() {
|
||||
// What its refusal tells the human: kept 15 minutes, then gone.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let x = Exports::open(dir.path(), NOW).unwrap();
|
||||
let e = x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap();
|
||||
assert_eq!(e.expires, NOW + 15 * 60);
|
||||
assert!(x.get(&e.id, "t", NOW + 15 * 60 - 1).is_ok());
|
||||
let lapsed = x.get(&e.id, "t", NOW + 15 * 60).unwrap_err();
|
||||
assert!(lapsed.to_string().contains("kept 15 minutes"), "{lapsed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_is_ex_and_thirty_two_letters_a_to_p_and_nothing_else() {
|
||||
assert!(is_id(&format!("ex_{}", "a".repeat(32))));
|
||||
assert!(is_id(&format!("ex_{}", "p".repeat(32))));
|
||||
for bad in [
|
||||
String::new(),
|
||||
"ex_".into(),
|
||||
format!("ex_{}", "a".repeat(31)),
|
||||
format!("ex_{}", "a".repeat(33)),
|
||||
format!("up_{}", "a".repeat(32)),
|
||||
format!("exx{}", "a".repeat(32)),
|
||||
format!("ex_{}", "q".repeat(32)),
|
||||
format!("ex_{}0", "a".repeat(31)),
|
||||
format!("ex_{}.", "a".repeat(31)),
|
||||
] {
|
||||
assert!(!is_id(&bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fetch_never_reaches_past_the_exports_folder() {
|
||||
// A whole export kept beside the books, not among their exports:
|
||||
// no id names it.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let x = Exports::open(dir.path(), NOW).unwrap();
|
||||
let bytes = b"not an export".to_vec();
|
||||
for id in ["../outside", &format!("up_{}", "a".repeat(32))] {
|
||||
let at = dir.path().join(EXPORTS_DIR).join(id);
|
||||
let record = Export {
|
||||
id: id.into(),
|
||||
token: "t".into(),
|
||||
file_name: "a.csv".into(),
|
||||
mime: "text/csv".into(),
|
||||
size: bytes.len() as u64,
|
||||
sha256: sha256_hex(&bytes),
|
||||
expires: NOW + EXPORT_TTL,
|
||||
};
|
||||
std::fs::write(
|
||||
at.with_extension("json"),
|
||||
serde_json::to_vec(&record).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(at.with_extension("bin"), &bytes).unwrap();
|
||||
assert!(
|
||||
matches!(x.get(id, "t", NOW).unwrap_err(), ExportError::Unknown(_)),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
// And a refusal quotes no more than 40 characters of what it got.
|
||||
let long = format!("../{}", "x".repeat(100));
|
||||
let ExportError::Unknown(quoted) = x.get(&long, "t", NOW).unwrap_err() else {
|
||||
panic!("an unknown export")
|
||||
};
|
||||
assert_eq!(quoted.chars().count(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_counts_against_its_token_until_the_moment_it_lapses() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let x = Exports::open(dir.path(), NOW).unwrap();
|
||||
let e = x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap();
|
||||
assert_eq!(x.live_for("t", e.expires - 1), 1);
|
||||
// Lapsed at its expiry, as a fetch then finds (not yet swept).
|
||||
assert_eq!(x.live_for("t", e.expires), 0);
|
||||
assert_eq!(x.live_for("u", NOW), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_is_fetched_by_its_token_until_it_lapses() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -31,6 +31,8 @@ pub mod meta;
|
||||
pub mod ops;
|
||||
pub mod passphrase;
|
||||
pub mod reconcilable;
|
||||
#[cfg(test)]
|
||||
mod refuse;
|
||||
pub mod root;
|
||||
pub mod saving;
|
||||
pub mod schema;
|
||||
|
||||
@@ -288,6 +288,72 @@ mod tests {
|
||||
assert!(verify(dir.path(), FIRST).unwrap());
|
||||
}
|
||||
|
||||
/// A record of `FIRST` hashed with the plan's parameters but this salt
|
||||
/// and output length, as a hand could write it.
|
||||
fn record_with(dir: &Path, salt: &[u8], out: usize) {
|
||||
let hasher = Argon2::new(
|
||||
Algorithm::Argon2id,
|
||||
Version::V0x13,
|
||||
Params::new(M_COST, T_COST, P_COST, Some(out)).unwrap(),
|
||||
);
|
||||
let owner = Owner {
|
||||
argon2id: hasher
|
||||
.hash_password_with_salt(FIRST.as_bytes(), salt)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
set: "2026-10-19T00:00:00Z".into(),
|
||||
};
|
||||
std::fs::write(dir.join(OWNER_FILE), serde_json::to_string(&owner).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_salt_shorter_than_sixteen_bytes_or_an_output_not_thirty_two_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// The plan's own: a salt of 16 bytes or more, an output of 32.
|
||||
for salt in [16, 17, 32, 48] {
|
||||
record_with(dir.path(), &vec![7u8; salt], 32);
|
||||
assert_eq!(verify(dir.path(), FIRST), Ok(true), "a {salt}-byte salt");
|
||||
}
|
||||
// Each right but for the salt's length, or the output's.
|
||||
for (salt, out) in [(8, 32), (15, 32), (16, 16), (16, 64)] {
|
||||
record_with(dir.path(), &vec![7u8; salt], out);
|
||||
assert!(
|
||||
matches!(verify(dir.path(), FIRST), Err(PassError::Record(_))),
|
||||
"a {salt}-byte salt, a {out}-byte output"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_the_disk_wont_read_is_never_taken_for_none() {
|
||||
// A folder where the record should be: no passphrase can be set
|
||||
// as if none were, and the refusal is the disk's own.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join(OWNER_FILE)).unwrap();
|
||||
assert!(matches!(is_set(dir.path()), Err(PassError::Io(_))));
|
||||
assert!(matches!(verify(dir.path(), FIRST), Err(PassError::Io(_))));
|
||||
assert!(matches!(
|
||||
set(dir.path(), FIRST, None),
|
||||
Err(PassError::Io(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_leftover_that_cant_be_cleared_stops_a_change_saying_why() {
|
||||
// A folder (with something in it) left where the record is written
|
||||
// aside: removing it fails, and that failure is what is reported.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let aside = dir.path().join(".owner.json.new");
|
||||
std::fs::create_dir(&aside).unwrap();
|
||||
std::fs::write(aside.join("x"), "x").unwrap();
|
||||
assert_eq!(
|
||||
set(dir.path(), FIRST, None),
|
||||
Err(PassError::Io(std::io::ErrorKind::IsADirectory))
|
||||
);
|
||||
assert!(!is_set(dir.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_name_no_path_and_repeat_no_passphrase() {
|
||||
for e in [
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Tests only: system calls refused, as a disk or a file system can
|
||||
//! refuse them, in one thread of the test's own. A seccomp filter makes
|
||||
//! each named call fail at once with the error chosen, before the kernel
|
||||
//! looks at any path, so a refusal no real folder can be made to give
|
||||
//! (a lookup that fails where making the folder would not; a link
|
||||
//! refused where the name is already taken) is given every time. The
|
||||
//! filter holds the calling thread only and ends with it: the rest of
|
||||
//! the test process is untouched.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
/// Run `f` in a thread of its own in which each call in `calls` fails
|
||||
/// with `errno`, and give back what it returns; None where the system
|
||||
/// won't put the filter on (the test then has nothing to show).
|
||||
pub(crate) fn refusing<T: Send>(
|
||||
calls: &[libc::c_long],
|
||||
errno: i32,
|
||||
f: impl FnOnce() -> T + Send,
|
||||
) -> Option<T> {
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
// Looked up once before the filter, so std knows statx is
|
||||
// there and never mistakes a refused one for a missing one.
|
||||
let _ = std::fs::symlink_metadata("/");
|
||||
filter(calls, errno).then(f)
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// Put the filter on this thread: the call's number checked against
|
||||
/// each of `calls`, a match refused with `errno`, anything else let be.
|
||||
fn filter(calls: &[libc::c_long], errno: i32) -> bool {
|
||||
use libc::{
|
||||
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, SECCOMP_RET_ALLOW,
|
||||
SECCOMP_RET_ERRNO, sock_filter,
|
||||
};
|
||||
let stmt = |code: u32, k: u32| sock_filter {
|
||||
code: code as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k,
|
||||
};
|
||||
let n = calls.len();
|
||||
let mut prog = vec![stmt(
|
||||
BPF_LD | BPF_W | BPF_ABS,
|
||||
std::mem::offset_of!(libc::seccomp_data, nr) as u32,
|
||||
)];
|
||||
for (i, call) in calls.iter().enumerate() {
|
||||
prog.push(sock_filter {
|
||||
code: (BPF_JMP | BPF_JEQ | BPF_K) as u16,
|
||||
// To the refusal, past the other checks and the allow.
|
||||
jt: u8::try_from(n - i).unwrap(),
|
||||
jf: 0,
|
||||
k: u32::try_from(*call).unwrap(),
|
||||
});
|
||||
}
|
||||
prog.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
prog.push(stmt(
|
||||
BPF_RET | BPF_K,
|
||||
SECCOMP_RET_ERRNO | u32::try_from(errno).unwrap(),
|
||||
));
|
||||
let fprog = libc::sock_fprog {
|
||||
len: u16::try_from(prog.len()).unwrap(),
|
||||
filter: prog.as_mut_ptr(),
|
||||
};
|
||||
// SAFETY: prctl with a filter program that outlives the call (the
|
||||
// kernel copies it); without TSYNC it binds this thread alone.
|
||||
unsafe {
|
||||
libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0
|
||||
&& libc::prctl(
|
||||
libc::PR_SET_SECCOMP,
|
||||
libc::SECCOMP_MODE_FILTER,
|
||||
&fprog as *const libc::sock_fprog,
|
||||
) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The calls that look a name up without following it (std's
|
||||
/// `symlink_metadata`): statx, and what std and libc fall back to.
|
||||
pub(crate) const LOOKUPS: &[libc::c_long] = &[
|
||||
libc::SYS_statx,
|
||||
libc::SYS_newfstatat,
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
libc::SYS_lstat,
|
||||
];
|
||||
|
||||
/// The calls that make a hard link (std's `hard_link`).
|
||||
pub(crate) const LINKS: &[libc::c_long] = &[
|
||||
libc::SYS_linkat,
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
libc::SYS_link,
|
||||
];
|
||||
@@ -1711,4 +1711,29 @@ mod unit {
|
||||
let (_, login) = unix_account().unwrap();
|
||||
assert!(!login.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_folder_the_disk_wont_look_up_is_never_made_over() {
|
||||
// The disk can't say what is under the name (an I/O error, where
|
||||
// making the folder would go through): refused with the disk's
|
||||
// own error, and no folder made over whatever is there.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let book = dir.path();
|
||||
let Some(got) = crate::refuse::refusing(crate::refuse::LOOKUPS, libc::EIO, || {
|
||||
private_book_folder(book, "keys")
|
||||
}) else {
|
||||
eprintln!("skipped: no seccomp here");
|
||||
return;
|
||||
};
|
||||
let Err(RootError::Io(why, e)) = got else {
|
||||
panic!("refused as an I/O error: {got:?}")
|
||||
};
|
||||
assert_eq!(e.raw_os_error(), Some(libc::EIO));
|
||||
assert!(!why.contains(&book.display().to_string()), "{why}");
|
||||
assert!(!book.join("keys").exists());
|
||||
// Looked up as it should be: made, private.
|
||||
let keys = private_book_folder(book, "keys").unwrap();
|
||||
assert_eq!(keys, book.join("keys"));
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,170 @@ pub fn save_new(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_hidden_name_keeps_its_dot_when_numbered() {
|
||||
// `.env` is a stem, not an extension: `.env (1)`, never ` (1).env`.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
assert_eq!(
|
||||
save_new(d, ".env", "attachment-7", b"a").unwrap(),
|
||||
d.join(".env")
|
||||
);
|
||||
assert_eq!(
|
||||
save_new(d, ".env", "attachment-7", b"b").unwrap(),
|
||||
d.join(".env (1)")
|
||||
);
|
||||
assert_eq!(
|
||||
save_new(d, "r.tar.gz", "attachment-7", b"c").unwrap(),
|
||||
d.join("r.tar.gz")
|
||||
);
|
||||
assert_eq!(
|
||||
save_new(d, "r.tar.gz", "attachment-7", b"d").unwrap(),
|
||||
d.join("r.tar (1).gz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn when_no_name_is_taken_the_folders_own_refusal_is_said() {
|
||||
// Both names too long for the folder: its refusal, not "no free
|
||||
// name", and nothing left behind.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let e = save_new(d, &"x".repeat(300), &"y".repeat(300), b"ours").unwrap_err();
|
||||
assert!(!e.contains("no free name"), "{e}");
|
||||
assert!(
|
||||
e.ends_with(&std::io::ErrorKind::InvalidFilename.to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(d).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn when_a_folder_refuses_the_copy_written_in_place_its_refusal_is_said() {
|
||||
// Both names in a folder that can't be written: the link is
|
||||
// refused (permission denied, so the copy is tried in place), and
|
||||
// so is the file in place. That refusal is said, not "no free
|
||||
// name", and the next name is never tried for it.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let ro = d.join("ro");
|
||||
std::fs::create_dir(&ro).unwrap();
|
||||
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
// (Skipped where nothing is refused, as for root.)
|
||||
if std::fs::write(ro.join("probe"), b"").is_err() {
|
||||
let e = save_new(d, "ro/r.pdf", "ro/attachment-7", b"ours").unwrap_err();
|
||||
assert!(!e.contains("no free name"), "{e}");
|
||||
assert!(
|
||||
e.ends_with(&std::io::ErrorKind::PermissionDenied.to_string()),
|
||||
"{e}"
|
||||
);
|
||||
// A name of its own that the folder takes: that one.
|
||||
assert_eq!(
|
||||
save_new(d, "ro/r.pdf", "attachment-7", b"ours").unwrap(),
|
||||
d.join("attachment-7")
|
||||
);
|
||||
}
|
||||
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_name_refused_for_another_reason_than_links_moves_on_never_written_in_place() {
|
||||
// A name reaching onto another file system: the link is refused
|
||||
// (it crosses devices), and the copy is never written there in
|
||||
// place as if the folder took no links; the fallback is used.
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let Ok(other) = tempfile::tempdir_in("/dev/shm") else {
|
||||
return;
|
||||
};
|
||||
let dev = |p: &std::path::Path| std::fs::metadata(p).unwrap().dev();
|
||||
if dev(d) == dev(other.path()) {
|
||||
return;
|
||||
}
|
||||
std::os::unix::fs::symlink(other.path(), d.join("elsewhere")).unwrap();
|
||||
assert_eq!(
|
||||
save_new(d, "elsewhere/r.pdf", "attachment-7", b"ours").unwrap(),
|
||||
d.join("attachment-7")
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(other.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_folder_without_links_gets_the_copy_written_in_place_never_over_a_file() {
|
||||
// Links refused as FAT refuses them (not supported, or not
|
||||
// permitted), and before the folder looks at the name: a name
|
||||
// already taken is then found only when the copy is written in
|
||||
// place, and the next name is tried, never the fallback, never
|
||||
// over the file.
|
||||
for errno in [libc::EOPNOTSUPP, libc::EPERM] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
std::fs::write(d.join("theirs.pdf"), b"theirs").unwrap();
|
||||
let Some(saved) = crate::refuse::refusing(crate::refuse::LINKS, errno, || {
|
||||
[
|
||||
save_new(d, "r.pdf", "attachment-7", b"ours"),
|
||||
save_new(d, "r.pdf", "attachment-7", b"again"),
|
||||
save_new(d, "theirs.pdf", "attachment-7", b"mine"),
|
||||
]
|
||||
}) else {
|
||||
eprintln!("skipped: no seccomp here");
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
saved,
|
||||
[
|
||||
Ok(d.join("r.pdf")),
|
||||
Ok(d.join("r (1).pdf")),
|
||||
Ok(d.join("theirs (1).pdf")),
|
||||
],
|
||||
"errno {errno}"
|
||||
);
|
||||
assert_eq!(std::fs::read(d.join("r.pdf")).unwrap(), b"ours");
|
||||
assert_eq!(std::fs::read(d.join("r (1).pdf")).unwrap(), b"again");
|
||||
assert_eq!(std::fs::read(d.join("theirs.pdf")).unwrap(), b"theirs");
|
||||
assert_eq!(std::fs::read(d.join("theirs (1).pdf")).unwrap(), b"mine");
|
||||
// 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,
|
||||
["r (1).pdf", "r.pdf", "theirs (1).pdf", "theirs.pdf"],
|
||||
"errno {errno}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_link_refused_for_any_other_reason_is_never_written_in_place() {
|
||||
// Every link refused as crossing devices: no copy is written in
|
||||
// place as if the folder took no links; the folder's refusal is
|
||||
// said, and nothing is left behind.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let Some(saved) = crate::refuse::refusing(crate::refuse::LINKS, libc::EXDEV, || {
|
||||
save_new(d, "r.pdf", "attachment-7", b"ours")
|
||||
}) else {
|
||||
eprintln!("skipped: no seccomp here");
|
||||
return;
|
||||
};
|
||||
let e = saved.unwrap_err();
|
||||
assert!(!e.contains("no free name"), "{e}");
|
||||
assert!(
|
||||
e.ends_with(&std::io::ErrorKind::CrossesDevices.to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(d).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_copy_is_new_and_whole_beside_what_is_there() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -907,6 +907,135 @@ mod tests {
|
||||
assert!(!folder.join(format!("{}.part", g.id)).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_finish_after_anothers_were_kept_or_held_are_not_kept() {
|
||||
// A send is under way when the books are opened again (its `.part`
|
||||
// dropped as a stopped server's half send) and another send of the
|
||||
// same bytes finishes first: those are kept, or held by a call that
|
||||
// took them. The first send, finishing, finds that and keeps
|
||||
// nothing.
|
||||
for hold in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let u = Uploads::open(dir.path(), NOW).unwrap();
|
||||
let bytes = b"%PDF-1.4 twice".to_vec();
|
||||
let g = grant_for(&u, "tok_a", &bytes);
|
||||
let held = std::cell::RefCell::new(None);
|
||||
let mut body = Forgetting {
|
||||
bytes: &bytes,
|
||||
then: Some(Box::new(|| {
|
||||
let again = Uploads::open(dir.path(), NOW).unwrap();
|
||||
again.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
||||
if hold {
|
||||
*held.borrow_mut() = Some(again.take(&g.id, "tok_a", NOW).unwrap());
|
||||
}
|
||||
})),
|
||||
};
|
||||
assert_eq!(
|
||||
u.receive(&g.id, "tok_a", &mut body, NOW).unwrap_err(),
|
||||
UploadError::AlreadySent,
|
||||
"held: {hold}"
|
||||
);
|
||||
drop(body);
|
||||
// The other send's bytes are what the grant has.
|
||||
let r = held
|
||||
.into_inner()
|
||||
.unwrap_or_else(|| u.take(&g.id, "tok_a", NOW).unwrap());
|
||||
assert_eq!(r.bytes, bytes);
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join(UPLOADS_DIR)
|
||||
.join(format!("{}.part", g.id))
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_lasts_fifteen_minutes() {
|
||||
// Plan 4.7: "valid 15 minutes".
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let u = Uploads::open(dir.path(), NOW).unwrap();
|
||||
let bytes = b"%PDF-1.4 in time".to_vec();
|
||||
let g = grant_for(&u, "tok_a", &bytes);
|
||||
assert_eq!(g.expires, NOW + 15 * 60);
|
||||
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW + 15 * 60 - 1)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
u.take(&g.id, "tok_a", NOW + 15 * 60).unwrap_err(),
|
||||
UploadError::Unknown(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_counts_against_its_token_until_the_moment_it_lapses() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let u = Uploads::open(dir.path(), NOW).unwrap();
|
||||
let g = grant_for(&u, "tok_a", b"x");
|
||||
assert_eq!(u.live_for("tok_a", g.expires - 1), 1);
|
||||
// Lapsed at its expiry, as a send then finds (not yet swept).
|
||||
assert_eq!(u.live_for("tok_a", g.expires), 0);
|
||||
assert_eq!(u.live_for("tok_b", NOW), 0);
|
||||
}
|
||||
|
||||
/// A body that must never be read.
|
||||
struct Unread;
|
||||
|
||||
impl std::io::Read for Unread {
|
||||
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
|
||||
panic!("bytes already sent are never read again");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_sent_or_held_are_refused_before_anything_more_is_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let u = Uploads::open(dir.path(), NOW).unwrap();
|
||||
let bytes = b"%PDF-1.4 once".to_vec();
|
||||
let g = grant_for(&u, "tok_a", &bytes);
|
||||
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
||||
// Kept: refused at once, whatever comes.
|
||||
assert_eq!(
|
||||
u.receive(&g.id, "tok_a", &mut Unread, NOW).unwrap_err(),
|
||||
UploadError::AlreadySent
|
||||
);
|
||||
// Held by a call: the same.
|
||||
let held = u.take(&g.id, "tok_a", NOW).unwrap();
|
||||
assert_eq!(
|
||||
u.receive(&g.id, "tok_a", &mut Unread, NOW).unwrap_err(),
|
||||
UploadError::AlreadySent
|
||||
);
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join(UPLOADS_DIR)
|
||||
.join(format!("{}.part", g.id))
|
||||
.exists()
|
||||
);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_send_the_disk_refuses_says_so_not_that_another_is_under_way() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let u = Uploads::open(dir.path(), NOW).unwrap();
|
||||
let bytes = b"%PDF-1.4 refused".to_vec();
|
||||
let g = grant_for(&u, "tok_a", &bytes);
|
||||
let folder = dir.path().join(UPLOADS_DIR);
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
// (Skipped where nothing is refused, as for root.)
|
||||
if std::fs::write(folder.join("probe"), b"").is_err() {
|
||||
assert_eq!(
|
||||
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap_err(),
|
||||
UploadError::Io(std::io::ErrorKind::PermissionDenied)
|
||||
);
|
||||
}
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
// The grant still stands.
|
||||
let _ = std::fs::remove_file(folder.join("probe"));
|
||||
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_is_plain_and_visible() {
|
||||
for ok in ["r.pdf", "Résumé 2026 (final).pdf", "收据.png", "a"] {
|
||||
|
||||
Reference in new issue
Block a user