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
@@ -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