// SPDX-License-Identifier: GPL-3.0-or-later //! The real binaries, end to end: the Rust CLI on its own, and the Rust //! and Go CLIs sharing one key, one vault, one lock and one agent. // The lock and agent tests are Unix-only; their imports are unused elsewhere. #![cfg_attr(not(unix), allow(unused_imports))] mod common; use std::io::Write; #[cfg(unix)] use std::path::Path; use std::path::PathBuf; use std::process::{Child, Command, Output, Stdio}; use std::time::{Duration, Instant}; const RUST: &str = env!("CARGO_BIN_EXE_vault"); const REAL: &str = "real passphrase for tests"; const DECOY: &str = "decoy passphrase for tests"; struct Env { dir: PathBuf, } impl Env { fn new(tag: &str) -> Env { // Not temp_dir(): macOS's long $TMPDIR pushes the agent socket path // past SUN_LEN. #[cfg(unix)] let base = PathBuf::from("/tmp"); #[cfg(not(unix))] let base = std::env::temp_dir(); let dir = base.join(format!( "vault-cli-{tag}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(dir.join("run")).unwrap(); Env { dir } } fn cmd(&self, bin: &str, pass: Option<&str>) -> Command { let mut c = Command::new(bin); c.current_dir(&self.dir) .env_remove("VAULT_PASSPHRASE") .env_remove("VAULT_DECOY_PASSPHRASE") .env("VAULT_KEY", self.dir.join("key")) .env("VAULT_DIR", self.dir.join("store")) .env("XDG_RUNTIME_DIR", self.dir.join("run")) .stdin(Stdio::null()); if let Some(p) = pass { c.env("VAULT_PASSPHRASE", p); } c } fn run(&self, bin: &str, pass: Option<&str>, args: &[&str]) -> Output { self.cmd(bin, pass).args(args).output().unwrap() } /// Runs and insists on success, returning stdout. fn ok(&self, bin: &str, pass: Option<&str>, args: &[&str]) -> String { let o = self.run(bin, pass, args); assert!(o.status.success(), "{bin} {args:?} failed: {}", String::from_utf8_lossy(&o.stderr)); String::from_utf8(o.stdout).unwrap() } fn names(&self, bin: &str, pass: &str) -> Vec { self.ok(bin, Some(pass), &["list"]).lines().map(|l| l[30..].to_string()).collect() } fn write(&self, rel: &str, body: &[u8]) -> PathBuf { let p = self.dir.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, body).unwrap(); p } fn get(&self, bin: &str, pass: &str, name: &str) -> Vec { let out = format!("out-{}", self.dir.read_dir().unwrap().count()); self.ok(bin, Some(pass), &["get", name, "-o", &out]); std::fs::read(self.dir.join(out)).unwrap() } } impl Drop for Env { fn drop(&mut self) { let _ = Command::new(RUST) .arg("lock") .env("VAULT_KEY", self.dir.join("key")) .env("XDG_RUNTIME_DIR", self.dir.join("run")) .output(); let _ = std::fs::remove_dir_all(&self.dir); } } fn genkey(e: &Env, bin: &str, decoy: bool) { let mut c = e.cmd(bin, Some(REAL)); if decoy { c.env("VAULT_DECOY_PASSPHRASE", DECOY); } let o = c.arg("genkey").output().unwrap(); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); } fn pattern(n: usize, salt: u8) -> Vec { (0..n).map(|i| (i as u8).wrapping_mul(31) ^ salt).collect() } /// One implementation creates the key and vault and adds files; the other /// lists, reads, copies, moves and removes them, and the first checks the /// result. Decoy files stay invisible to the real passphrase and back. fn lifecycle(maker: &str, user: &str) { let e = Env::new("life"); genkey(&e, maker, true); e.ok(maker, Some(REAL), &["init"]); let big = pattern(4 << 20, 1); // two objects e.write("in/big.bin", &big); e.write("in/sub/small.txt", b"small"); e.write("in/sub/empty", b""); e.ok(maker, Some(REAL), &["add", "in", "docs"]); e.write("decoy.txt", b"shopping"); e.ok(maker, Some(DECOY), &["add", "decoy.txt"]); assert_eq!( e.names(user, REAL), ["docs/in/big.bin", "docs/in/sub/empty", "docs/in/sub/small.txt"] ); assert_eq!(e.names(user, DECOY), ["decoy.txt"]); assert_eq!(e.get(user, REAL, "docs/in/big.bin"), big); assert_eq!(e.get(user, REAL, "docs/in/sub/empty"), b""); e.ok(user, Some(REAL), &["cp", "-r", "docs/in", "copy"]); e.ok(user, Some(REAL), &["mv", "docs/in/sub/small.txt", "moved.txt"]); e.ok(user, Some(REAL), &["rm", "-rf", "docs"]); e.write("new.txt", b"from the other side"); e.ok(user, Some(REAL), &["add", "new.txt", "/x/"]); // The copies still hold data whose originals were removed. assert_eq!( e.names(maker, REAL), ["copy/big.bin", "copy/sub/empty", "copy/sub/small.txt", "moved.txt", "x/new.txt"] ); assert_eq!(e.get(maker, REAL, "copy/big.bin"), big); assert_eq!(e.get(maker, REAL, "moved.txt"), b"small"); assert_eq!(e.get(maker, REAL, "x/new.txt"), b"from the other side"); assert_eq!(e.names(maker, DECOY), ["decoy.txt"]); assert_eq!(e.get(maker, DECOY, "decoy.txt"), b"shopping"); } #[test] fn rust_lifecycle() { lifecycle(RUST, RUST); } #[test] fn go_made_rust_used() { let Some(go) = common::go_oracle() else { return common::skip("go not installed"); }; lifecycle(go.to_str().unwrap(), RUST); } #[test] fn rust_made_go_used() { let Some(go) = common::go_oracle() else { return common::skip("go not installed"); }; lifecycle(RUST, go.to_str().unwrap()); } /// Boundary sizes written by one implementation read back by the other. #[test] fn boundary_sizes_interop() { let Some(go) = common::go_oracle() else { return common::skip("go not installed"); }; let go = go.to_str().unwrap(); let e = Env::new("bounds"); genkey(&e, RUST, false); e.ok(go, Some(REAL), &["init"]); let data = (4 << 20) - 16; let sizes = [0, 1, 65535, 65536 - 16, 65536, data - 1, data, data + 1]; for (i, &n) in sizes.iter().enumerate() { let bin = if i % 2 == 0 { RUST } else { go }; let p = e.write(&format!("f{i}"), &pattern(n, i as u8)); e.ok(bin, Some(REAL), &["add", p.to_str().unwrap()]); } for (i, &n) in sizes.iter().enumerate() { let bin = if i % 2 == 0 { go } else { RUST }; assert_eq!(e.get(bin, REAL, &format!("f{i}")), pattern(n, i as u8), "size {n}"); } } #[test] fn errors_and_messages() { let e = Env::new("errors"); let o = e.run(RUST, Some(REAL), &["list"]); assert_eq!(o.status.code(), Some(1)); let msg = String::from_utf8_lossy(&o.stderr); assert!(msg.starts_with("vault: open "), "{msg}"); genkey(&e, RUST, false); let o = e.run(RUST, Some("wrong"), &["list"]); assert_eq!(String::from_utf8_lossy(&o.stderr), "vault: incorrect passphrase\n"); let o = e.run(RUST, Some(REAL), &["list"]); assert!(String::from_utf8_lossy(&o.stderr).contains("is not a vault; run vault init")); e.ok(RUST, Some(REAL), &["init"]); let o = e.run(RUST, Some(REAL), &["init"]); assert!(String::from_utf8_lossy(&o.stderr).contains("is already a vault")); let o = e.run(RUST, None, &[]); assert_eq!(o.status.code(), Some(2)); let o = e.run(RUST, Some(REAL), &["get"]); assert!(String::from_utf8_lossy(&o.stderr).starts_with("vault: wrong number of arguments for get\n\nUsage:")); let o = e.run(RUST, Some(REAL), &["frobnicate"]); assert!(String::from_utf8_lossy(&o.stderr).starts_with("vault: unknown command \"frobnicate\"")); let o = e.run(RUST, Some(""), &["list"]); assert_eq!(String::from_utf8_lossy(&o.stderr), "vault: VAULT_PASSPHRASE is set but empty\n"); let o = e.run(RUST, Some(REAL), &["get", "missing"]); assert_eq!(String::from_utf8_lossy(&o.stderr), "vault: missing: not in the vault\n"); let p = e.write("f", b"data"); e.ok(RUST, Some(REAL), &["add", p.to_str().unwrap()]); e.write("exists", b"keep"); let o = e.run(RUST, Some(REAL), &["get", "f", "-o", "exists"]); assert_eq!(String::from_utf8_lossy(&o.stderr), "vault: exists already exists\n"); assert_eq!(std::fs::read(e.dir.join("exists")).unwrap(), b"keep"); // Diagnostics are lower-case and never end in a full stop. for args in [ &["rm", "nope"][..], &["mv", "a", "b"], &["ls", "nope"], &["--workers", "0", "list"], &["init", "--filler", "2X"], ] { let o = e.run(RUST, Some(REAL), args); let m = String::from_utf8_lossy(&o.stderr).lines().next().unwrap_or_default().to_string(); assert!( m.starts_with("vault: ") && !m.ends_with('.') && m.chars().nth(7).is_some_and(|c| !c.is_uppercase()), "{args:?}: {m}" ); } } #[test] fn migrate_deletes_only_stored_originals() { let e = Env::new("migrate"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); e.write("tree/a/x", b"x"); e.write("tree/y", b"y"); e.ok(RUST, Some(REAL), &["migrate", "tree", "m"]); assert!(!e.dir.join("tree").exists(), "emptied directories should go"); assert_eq!(e.names(RUST, REAL), ["m/tree/a/x", "m/tree/y"]); // A second migrate over existing names, without a terminal, is refused. e.write("tree/y", b"y2"); let o = e.run(RUST, Some(REAL), &["migrate", "tree", "m"]); assert!(String::from_utf8_lossy(&o.stderr).contains("migrate cancelled; nothing changed")); assert!(e.dir.join("tree/y").exists()); e.ok(RUST, Some(REAL), &["migrate", "--yes", "tree", "m"]); assert_eq!(e.get(RUST, REAL, "m/tree/y"), b"y2"); // Never the vault or the key. let o = e.run(RUST, Some(REAL), &["add", "store"]); assert!(String::from_utf8_lossy(&o.stderr).contains("is the vault or its key")); // Nor anything inside the vault: migrating its records would delete them. let before = e.names(RUST, REAL); let o = e.run(RUST, Some(REAL), &["migrate", "--yes", "store/records"]); assert!( String::from_utf8_lossy(&o.stderr).contains("is inside the vault"), "{}", String::from_utf8_lossy(&o.stderr) ); assert_eq!(e.names(RUST, REAL), before); } #[cfg(unix)] #[test] fn absorb_from_stdin() { let e = Env::new("absorb"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); let mut tw = tar::Builder::new(Vec::new()); let mut h = tar::Header::new_ustar(); h.set_size(5); h.set_mode(0o640); h.set_mtime(1_600_000_000); tw.append_data(&mut h, "dir/a.txt", &b"hello"[..]).unwrap(); let t = tw.into_inner().unwrap(); let mut c = e .cmd(RUST, Some(REAL)) .args(["absorb", "in"]) .stdin(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); c.stdin.take().unwrap().write_all(&t).unwrap(); let o = c.wait_with_output().unwrap(); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); assert_eq!(String::from_utf8_lossy(&o.stderr), "added in/dir/a.txt\n"); assert_eq!(e.get(RUST, REAL, "in/dir/a.txt"), b"hello"); let o = e.run(RUST, Some(REAL), &["absorb"]); assert!(String::from_utf8_lossy(&o.stderr).contains("nothing to absorb")); } /// Starts `absorb` and feeds it one byte: it takes the exclusive lock and /// then holds it until its standard input closes or it is killed. The /// caller kills and waits for it. #[cfg(unix)] #[allow(clippy::zombie_processes)] fn hold_lock(e: &Env, bin: &str) -> Child { let mut c = e .cmd(bin, Some(REAL)) .arg("absorb") .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .unwrap(); c.stdin.as_mut().unwrap().write_all(b"\0").unwrap(); // Wait until the lock file is held exclusively: probe with our own // fcntl lock attempt from this process. let deadline = Instant::now() + Duration::from_secs(120); loop { if locked_exclusively(&e.dir.join("store/.lock")) { return c; } assert!(Instant::now() < deadline, "holder never took the lock"); std::thread::sleep(Duration::from_millis(50)); } } #[cfg(unix)] fn locked_exclusively(p: &Path) -> bool { use rustix::fs::{FlockOperation, fcntl_lock}; let Ok(f) = std::fs::OpenOptions::new().read(true).write(true).open(p) else { return false; }; match fcntl_lock(&f, FlockOperation::NonBlockingLockShared) { Ok(()) => false, Err(_) => true, } } /// A reader of one implementation waits for a writer of the other, and /// proceeds once the writer dies. #[cfg(unix)] fn lock_contention(holder: &str, waiter: &str) { let e = Env::new("lock"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); let mut h = hold_lock(&e, holder); let mut w = e .cmd(waiter, Some(REAL)) .arg("list") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); // Wait (however long unlocking takes on a busy machine) until the // reader says it is blocked on the lock, and is still running. let mut err = w.stderr.take().unwrap(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { use std::io::Read; let mut all = Vec::new(); let mut buf = [0u8; 256]; while let Ok(n) = err.read(&mut buf) { if n == 0 { break; } all.extend_from_slice(&buf[..n]); if String::from_utf8_lossy(&all).contains("waiting for another vault process to finish") { let _ = tx.send(()); } } }); rx.recv_timeout(Duration::from_secs(120)) .expect("reader never reported waiting for the lock"); std::thread::sleep(Duration::from_millis(300)); assert!(w.try_wait().unwrap().is_none(), "reader did not wait for the writer"); h.kill().unwrap(); // death releases the lock let _ = h.wait(); let o = w.wait_with_output().unwrap(); assert!(o.status.success(), "reader failed after the writer died"); } #[cfg(unix)] #[test] fn lock_rust_waits_for_go() { let Some(go) = common::go_oracle() else { return common::skip("go not installed"); }; lock_contention(go.to_str().unwrap(), RUST); } #[cfg(unix)] #[test] fn lock_go_waits_for_rust() { let Some(go) = common::go_oracle() else { return common::skip("go not installed"); }; lock_contention(RUST, go.to_str().unwrap()); } #[cfg(unix)] #[test] fn lock_rust_waits_for_rust() { lock_contention(RUST, RUST); } /// `unlock` re-executes the binary as an agent that serves the key /// without the passphrase, to either implementation. #[cfg(unix)] fn agent_roundtrip(starter: &str, client: &str) { let e = Env::new("agent"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); e.ok(starter, Some(REAL), &["unlock", "--for", "1m"]); // No passphrase in the environment, no terminal: only the agent can help. let out = e.ok(client, None, &["list"]); assert_eq!(out, ""); let o = e.run(client, None, &["lock"]); assert_eq!(String::from_utf8_lossy(&o.stderr), "locked\n"); let o = e.run(client, None, &["list"]); assert!(!o.status.success(), "key still available after lock"); let o = e.run(client, None, &["lock"]); assert_eq!(String::from_utf8_lossy(&o.stderr), "nothing was unlocked\n"); } #[cfg(unix)] #[test] fn agent_rust() { agent_roundtrip(RUST, RUST); } #[cfg(unix)] #[test] fn agent_go_client_rust_agent() { let Some(go) = common::go_oracle() else { return common::skip("go not installed"); }; agent_roundtrip(RUST, go.to_str().unwrap()); } #[cfg(unix)] #[test] fn agent_rust_client_go_agent() { let Some(go) = common::go_oracle() else { return common::skip("go not installed"); }; agent_roundtrip(go.to_str().unwrap(), RUST); } #[cfg(unix)] fn agent_pids(sock_dir: &Path) -> Vec { // pgrep, not /proc: macOS has no /proc. let o = Command::new("pgrep") .args(["-f", &format!("__agent {}", sock_dir.display())]) .output() .unwrap(); String::from_utf8_lossy(&o.stdout).lines().filter_map(|l| l.parse().ok()).collect() } #[cfg(target_os = "linux")] #[test] fn agent_hardening_and_expiry() { let e = Env::new("agenthard"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["unlock", "--for", "3s"]); let pids = agent_pids(&e.dir.join("run")); assert_eq!(pids.len(), 1, "exactly one agent process"); let pid = pids[0]; // Undumpable: its memory and environment are closed even to us. let environ = std::fs::read(format!("/proc/{pid}/environ")); assert!(environ.is_err(), "agent environment readable: it is still dumpable"); assert!(std::fs::read(format!("/proc/{pid}/mem")).is_err()); // Fixed expiry: gone after its time, with nobody connecting. let deadline = Instant::now() + Duration::from_secs(10); while std::path::Path::new(&format!("/proc/{pid}")).exists() && !std::fs::read_to_string(format!("/proc/{pid}/stat")) .unwrap_or_default() .contains(") Z") { assert!(Instant::now() < deadline, "agent outlived its fixed time"); std::thread::sleep(Duration::from_millis(100)); } let sock_dir = e.dir.join("run").join(format!("vault-{}", rustix::process::getuid().as_raw())); assert_eq!(std::fs::read_dir(&sock_dir).unwrap().count(), 0, "socket left behind"); } #[cfg(unix)] #[test] fn agent_idle_extends_and_expires() { let e = Env::new("idle"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); e.ok(RUST, Some(REAL), &["unlock", "--for", "2s", "--idle"]); // Fetch the key and stop: with no vault there, a command that got the // key from the agent fails at once with "not a vault"; one that did not // fails asking for the passphrase. This keeps slow (debug) builds from // stretching the gaps between uses. let use_key = || { let o = e .cmd(RUST, None) .env("VAULT_DIR", e.dir.join("nowhere")) .arg("list") .output() .unwrap(); String::from_utf8_lossy(&o.stderr).contains("is not a vault") }; for i in 0..4 { std::thread::sleep(Duration::from_millis(900)); assert!(use_key(), "idle agent expired while in use (use {i})"); // each use pushes expiry back } std::thread::sleep(Duration::from_millis(3500)); assert!(!use_key(), "idle agent outlived its idle time"); } #[cfg(unix)] #[test] fn agent_start_failures() { let e = Env::new("agentfail"); let sock = e.dir.join("run").join("s"); // No key on standard input: refuses, says why, exits. let o = Command::new(RUST) .args(["__agent", sock.to_str().unwrap(), "1m", "fixed"]) .stdin(Stdio::null()) .output() .unwrap(); assert_eq!(String::from_utf8_lossy(&o.stdout), "no key received"); let o = Command::new(RUST) .args(["__agent", sock.to_str().unwrap(), "soon", "fixed"]) .stdin(Stdio::null()) .output() .unwrap(); assert_eq!(String::from_utf8_lossy(&o.stdout), "bad duration or mode"); assert!(!sock.exists()); // A stale socket from a dead agent does not stop a new one. genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); e.ok(RUST, Some(REAL), &["unlock"]); let pids = agent_pids(&e.dir.join("run")); for p in &pids { Command::new("kill").args(["-9", &p.to_string()]).status().unwrap(); } std::thread::sleep(Duration::from_millis(200)); assert!(!e.run(RUST, None, &["list"]).status.success(), "dead agent still answered"); e.ok(RUST, Some(REAL), &["unlock"]); e.ok(RUST, None, &["list"]); // A wrong passphrase starts nothing. e.ok(RUST, None, &["lock"]); assert!(!e.run(RUST, Some("wrong"), &["unlock"]).status.success()); assert!(agent_pids(&e.dir.join("run")).is_empty()); // A socket directory that is not private is refused. let run2 = e.dir.join("run2"); std::fs::create_dir_all(run2.join(format!("vault-{}", rustix::process::getuid().as_raw()))).unwrap(); std::fs::set_permissions( run2.join(format!("vault-{}", rustix::process::getuid().as_raw())), std::os::unix::fs::PermissionsExt::from_mode(0o755), ) .unwrap(); let o = e .cmd(RUST, Some(REAL)) .env("XDG_RUNTIME_DIR", &run2) .arg("unlock") .output() .unwrap(); assert!(String::from_utf8_lossy(&o.stderr).contains("is not a private directory of this user")); } #[test] fn get_recursive_confined() { let e = Env::new("getr"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); e.write("t/a/b/c.txt", b"c"); e.write("t/a/d.txt", b"d"); e.ok(RUST, Some(REAL), &["add", "t", "f"]); e.ok(RUST, Some(REAL), &["get", "-r", "f/t/a", "-o", "out"]); assert_eq!(std::fs::read(e.dir.join("out/b/c.txt")).unwrap(), b"c"); assert_eq!(std::fs::read(e.dir.join("out/d.txt")).unwrap(), b"d"); #[cfg(unix)] { // A symlink planted inside the destination is not followed out. let outside = e.dir.join("outside"); std::fs::create_dir_all(&outside).unwrap(); std::fs::create_dir_all(e.dir.join("out2")).unwrap(); std::os::unix::fs::symlink(&outside, e.dir.join("out2/b")).unwrap(); let o = e.run(RUST, Some(REAL), &["get", "-r", "f/t/a", "-o", "out2"]); assert!(!o.status.success()); assert!( String::from_utf8_lossy(&o.stderr).contains("refusing to extract through it"), "{}", String::from_utf8_lossy(&o.stderr) ); assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0, "wrote through the symlink"); } } /// Piping `list` into a reader that stops early ends quietly. #[cfg(unix)] #[test] fn broken_pipe_is_quiet() { let e = Env::new("pipe"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); for i in 0..30 { let p = e.write(&format!("f/{i:03}"), b"x"); let _ = p; } e.ok(RUST, Some(REAL), &["add", "f"]); let mut c = e .cmd(RUST, Some(REAL)) .arg("list") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); drop(c.stdout.take()); let o = c.wait_with_output().unwrap(); assert!( !String::from_utf8_lossy(&o.stderr).contains("panicked"), "{}", String::from_utf8_lossy(&o.stderr) ); } /// A crash part-way through writing (the kernel kills vault with SIGXFSZ /// once it passes a file-size limit) leaves the vault consistent: a file /// being replaced keeps its old contents, nothing half-written appears, and /// migrate deletes no original it had not finished storing. #[cfg(target_os = "linux")] #[test] fn crash_mid_write_keeps_the_vault_consistent() { if Command::new("prlimit").arg("--version").output().is_err() { return common::skip("prlimit not installed"); } let e = Env::new("crash"); genkey(&e, RUST, false); e.ok(RUST, Some(REAL), &["init"]); e.write("v1/f", b"version one"); e.ok(RUST, Some(REAL), &["add", "v1/f"]); let big = pattern(6 << 20, 9); e.write("v2/f", &big); e.write("tree/a", b"a"); e.write("tree/b", &big); for args in [vec!["add", "v2/f"], vec!["migrate", "--yes", "tree"]] { // Objects are about 4 MiB; a 2 MiB file-size limit kills vault while // it writes the first one. let o = Command::new("prlimit") .arg("--fsize=2000000") .arg(RUST) .args(&args) .current_dir(&e.dir) .env("VAULT_KEY", e.dir.join("key")) .env("VAULT_DIR", e.dir.join("store")) .env("VAULT_PASSPHRASE", REAL) .output() .unwrap(); assert!(!o.status.success(), "{args:?} survived the size limit"); } assert_eq!(e.get(RUST, REAL, "f"), b"version one", "replace interrupted, old version lost"); let names = e.names(RUST, REAL); assert!(names.iter().all(|n| n == "f" || n == "tree/a"), "half-written entries: {names:?}"); assert_eq!( std::fs::read(e.dir.join("tree/b")).unwrap(), big, "original deleted before it was stored" ); if names.iter().any(|n| n == "tree/a") { assert_eq!(e.get(RUST, REAL, "tree/a"), b"a"); } else { assert!(e.dir.join("tree/a").exists()); } // The vault still works normally afterwards. e.ok(RUST, Some(REAL), &["add", "v2/f"]); assert_eq!(e.get(RUST, REAL, "f"), big); }