Windows checkouts converted LF vectors to CRLF; SystemTime there has 100 ns resolution. macOS's long $TMPDIR pushed the agent socket past SUN_LEN.
687 lines
25 KiB
Rust
687 lines
25 KiB
Rust
// 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<String> {
|
|
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<u8> {
|
|
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<u8> {
|
|
(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<u32> {
|
|
let mut pids = Vec::new();
|
|
for e in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
|
|
let Ok(pid) = e.file_name().to_string_lossy().parse::<u32>() else {
|
|
continue;
|
|
};
|
|
let Ok(cmd) = std::fs::read(e.path().join("cmdline")) else {
|
|
continue;
|
|
};
|
|
let cmd = String::from_utf8_lossy(&cmd);
|
|
if cmd.contains("__agent") && cmd.contains(&*sock_dir.to_string_lossy()) {
|
|
pids.push(pid);
|
|
}
|
|
}
|
|
pids
|
|
}
|
|
|
|
#[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);
|
|
}
|