From a98931779ac49c32b2eb2e7c90117ed8d7b381b0 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 10:36:20 +0100 Subject: [PATCH] CLI, agent and absorb; stop tracking target/ Every Go command with the same flags, prompts and messages; the agent is wire-compatible with Go's; tests drive the real Rust and Go binaries. Co-Authored-By: Claude --- .gitignore | 1 + src/absorb.rs | 272 +++++++++++++ src/agent.rs | 391 ++++++++++++++++++ src/cli.rs | 964 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 7 +- src/main.rs | 8 +- src/platform/fs.rs | 8 +- src/store/mod.rs | 16 +- src/store/tests.rs | 41 +- src/testutil.rs | 46 +++ tests/cli.rs | 582 +++++++++++++++++++++++++++ 11 files changed, 2284 insertions(+), 52 deletions(-) create mode 100644 src/absorb.rs create mode 100644 src/agent.rs create mode 100644 src/cli.rs create mode 100644 src/testutil.rs create mode 100644 tests/cli.rs diff --git a/.gitignore b/.gitignore index 3c65cbb..e5098ad 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /docs/guide/version.tex /docs/guide/vault-guide.pdf /docs/guide/*.log +/target diff --git a/src/absorb.rs b/src/absorb.rs new file mode 100644 index 0000000..9d2d1bb --- /dev/null +++ b/src/absorb.rs @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! `vault absorb`: add every regular file of a tar archive (optionally +//! gzipped, possibly in several gzip members) read from a stream, each +//! streamed straight into the vault so no plaintext touches the disk. +//! Other entry types, and names that would escape the destination folder, +//! are reported and skipped, as in the Go version. + +use crate::error::{Error, Kind, Result}; +use crate::store::{Catalogue, Store, VaultName}; +use crate::util::{Instant, clean, join}; +use std::io::{BufRead, Read}; + +pub fn absorb( + store: &Store, + cat: &mut Catalogue, + mut input: impl BufRead, + dest: &str, + report: &mut dyn FnMut(&VaultName, bool), +) -> Result<()> { + let gz = input + .fill_buf() + .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))? + .starts_with(&[0x1f, 0x8b]); + let r: Box = if gz { + Box::new(flate2::bufread::MultiGzDecoder::new(input)) + } else { + Box::new(input) + }; + let mut archive = tar::Archive::new(r); + let entries = archive + .entries() + .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; + let mut n = 0u64; + for entry in entries { + let mut entry = entry.map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; + let raw = entry.path_bytes().into_owned(); + let shown = String::from_utf8_lossy(&raw).into_owned(); + let kind = entry.header().entry_type(); + if !kind.is_file() { + if !kind.is_dir() { + eprintln!("skipping {shown}: not a regular file"); + } + continue; + } + let Ok(text) = std::str::from_utf8(&raw) else { + crate::bail!("invalid vault path {shown:?}"); + }; + let Some(rel) = safe_name(text) else { + eprintln!("skipping {shown}: unsafe name"); + continue; + }; + let name = join(&[dest, &rel]); + let name = VaultName::new(&name).ok_or_else(|| Error::new(format!("invalid vault path {name:?}")))?; + let size = entry + .header() + .size() + .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; + let mode = entry.header().mode().unwrap_or(0) & 0o777; + let mtime = mtime(&mut entry)?; + match store.add_reader(cat, &mut entry, size, mode, mtime, &name) { + Ok(a) => report(&name, a.replaced), + Err(e) if e.kind == Kind::SourceShort => crate::bail!("{shown}: archive ends early"), + Err(e) => return Err(e), + } + n += 1; + } + if n == 0 { + crate::bail!("no files in archive"); + } + Ok(()) +} + +/// Go's check: the name, cleaned as if rooted, must equal the name with a +/// leading "./" or "/" removed and cleaned, and contain no backslash. +pub fn safe_name(name: &str) -> Option { + let rooted = clean(&format!("/{name}")); + let n = rooted.strip_prefix('/').unwrap_or(&rooted).to_string(); + let plain = name.strip_prefix("./").unwrap_or(name); + let plain = plain.strip_prefix('/').unwrap_or(plain); + if name.contains('\\') || n.is_empty() || n != clean(plain) { + return None; + } + Some(n) +} + +/// The entry's modification time: the PAX `mtime` (with its fraction) if +/// present, else the header's whole seconds. +fn mtime(entry: &mut tar::Entry) -> Result { + if let Ok(Some(exts)) = entry.pax_extensions() { + for ext in exts.flatten() { + if ext.key_bytes() == b"mtime" + && let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time) { + return Ok(t); + } + } + } + let secs = entry + .header() + .mtime() + .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; + Ok(Instant { + secs: secs.min(i64::MAX as u64) as i64, + nanos: 0, + }) +} + +/// A PAX time: optional sign, decimal seconds, optional fraction. +fn parse_pax_time(s: &str) -> Option { + let (neg, s) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s), + }; + let (whole, frac) = s.split_once('.').unwrap_or((s, "")); + if whole.is_empty() || !whole.bytes().all(|c| c.is_ascii_digit()) || !frac.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + let secs: i64 = whole.parse().ok()?; + let mut nanos: u32 = format!("{:0<9}", &frac[..frac.len().min(9)]).parse().ok()?; + let mut secs = if neg { -secs } else { secs }; + if neg && nanos > 0 { + secs -= 1; + nanos = 1_000_000_000 - nanos; + } + Some(Instant { secs, nanos }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::kem::{Identity, SUITE_ID}; + use crate::pipeline::Pool; + use crate::testutil::TempDir; + use std::sync::Arc; + + fn store(tag: &str) -> (TempDir, Store) { + let d = TempDir::new(tag); + let root = d.0.join("v"); + Store::init(&root, 0, Arc::new(Pool::new(2))).unwrap(); + let s = Store::open( + &root, + Identity::from_suite_seed(SUITE_ID, &[1; 32]).unwrap(), + Arc::new(Pool::new(2)), + ) + .unwrap(); + (d, s) + } + + fn header(name: &str, kind: tar::EntryType, size: u64, mode: u32, mtime: u64) -> tar::Header { + let mut h = tar::Header::new_gnu(); + h.set_entry_type(kind); + h.set_size(size); + h.set_mode(mode); + h.set_mtime(mtime); + // Raw bytes, so unsafe names reach the reader unaltered. + let n = name.as_bytes(); + h.as_old_mut().name[..n.len()].copy_from_slice(n); + h.set_cksum(); + h + } + + #[test] + fn absorbs_regular_files_only() { + let (d, s) = store("absorb"); + let mut tw = tar::Builder::new(Vec::new()); + for (name, kind, body) in [ + ("./d/", tar::EntryType::Directory, &b""[..]), + ("./d/a.txt", tar::EntryType::Regular, b"hello"), + ("../escape", tar::EntryType::Regular, b"nope!"), + ("/abs", tar::EntryType::Regular, b"abs"), + ("a\\b", tar::EntryType::Regular, b"bs"), + ("./d/link", tar::EntryType::Symlink, b""), + ] { + let h = header(name, kind, body.len() as u64, 0o600, 1577934245); + tw.append(&h, body).unwrap(); + } + let tarball = tw.into_inner().unwrap(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut gz, &tarball).unwrap(); + let gz = gz.finish().unwrap(); + let mut cat = s.catalogue().unwrap(); + let mut added = Vec::new(); + absorb(&s, &mut cat, &gz[..], "in", &mut |n, _| added.push(n.to_string())).unwrap(); + assert_eq!(added, ["in/d/a.txt", "in/abs"]); + let r = cat.current("in/d/a.txt").unwrap(); + assert_eq!( + (r.mode, r.modified), + ( + 0o600, + Instant { + secs: 1577934245, + nanos: 0 + } + ) + ); + let dir = crate::platform::fs::Dir::open(&d.0).unwrap(); + s.extract(r, &dir, std::ffi::OsStr::new("out")).unwrap(); + assert_eq!(std::fs::read(d.0.join("out")).unwrap(), b"hello"); + // Plain tar works too, and an archive with nothing to add fails. + absorb(&s, &mut cat, &tarball[..], "", &mut |_, _| {}).unwrap(); + let mut empty = tar::Builder::new(Vec::new()); + empty + .append(&header("dir/", tar::EntryType::Directory, 0, 0o755, 0), &b""[..]) + .unwrap(); + let e = absorb(&s, &mut cat, &empty.into_inner().unwrap()[..], "", &mut |_, _| {}).unwrap_err(); + assert_eq!(e.msg, "no files in archive"); + } + + #[test] + fn truncated_and_malicious_archives() { + let (_d, s) = store("absorb-bad"); + let mut tw = tar::Builder::new(Vec::new()); + tw.append(&header("big", tar::EntryType::Regular, 100_000, 0o600, 0), &vec![7u8; 100_000][..]) + .unwrap(); + let t = tw.into_inner().unwrap(); + let before = s.catalogue().unwrap().len(); + let mut cat = s.catalogue().unwrap(); + let e = absorb(&s, &mut cat, &t[..60_000], "", &mut |_, _| {}).unwrap_err(); + assert!(e.msg.contains("archive ends early") || e.msg.starts_with("reading archive"), "{e}"); + assert_eq!(s.catalogue().unwrap().len(), before, "a truncated entry stored something"); + for junk in [ + &b"not a tar archive at all, but long enough to fill a header block"[..], + &[0x1f, 0x8b, 0, 0][..], + &[0u8; 1024][..], + ] { + assert!(absorb(&s, &mut cat, junk, "", &mut |_, _| {}).is_err()); + } + // A corrupted gzip stream fails rather than storing a partial file. + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut gz, &t).unwrap(); + let mut g = gz.finish().unwrap(); + let mid = g.len() / 2; + g[mid] ^= 0xff; + assert!(absorb(&s, &mut cat, &g[..], "", &mut |_, _| {}).is_err()); + assert_eq!(s.catalogue().unwrap().len(), before); + } + + #[test] + fn names_and_times() { + // As in Go, only names that would climb out are refused; others are + // cleaned. + for (n, want) in [ + ("./a/b", Some("a/b")), + ("a/../b", Some("b")), + ("../x", None), + ("a/../../x", None), + ("/x", Some("x")), + ("a//b", Some("a/b")), + ("a\\b", None), + ("", None), + (".", None), + ("a/", Some("a")), + ] { + assert_eq!(safe_name(n).as_deref(), want, "{n:?}"); + } + assert_eq!( + parse_pax_time("1577934245.5"), + Some(Instant { + secs: 1577934245, + nanos: 500_000_000 + }) + ); + assert_eq!( + parse_pax_time("-1.25"), + Some(Instant { + secs: -2, + nanos: 750_000_000 + }) + ); + assert_eq!(parse_pax_time("x"), None); + } +} diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..af9b25b --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The key agent, wire-compatible with the Go version: `vault unlock` +//! starts a copy of this binary as a hidden `__agent` process, hands it the +//! suite ID and master seed over a pipe (never argv, never disk), and the +//! agent serves them on a Unix socket in a per-user 0700 directory until a +//! deadline, fixed or (with `--idle`) moved on each use, or `vault lock`. +//! +//! Protocol: the client connects and sends one byte, `g` (get: the agent +//! replies with the 2-byte big-endian suite ID and 32-byte seed) or `q` +//! (quit: the agent wipes the key and closes). Every request has a +//! two-second deadline. The socket is +//! `$XDG_RUNTIME_DIR` (else the temp dir) `/vault-UID/agent-` + the first 8 +//! bytes of SHA-256(absolute key path) in hex, exactly as Go computes it, +//! so a Go client can use a Rust agent and the other way round. +//! +//! Before it accepts the secret, the agent disables core dumps, makes +//! itself undumpable (Linux) and locks the page that will hold the secret; +//! if any of that fails it reports why and exits without the key. + +use crate::crypto::kem::Identity; +use crate::error::{Error, Result}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub const AGENT_CMD: &str = "__agent"; +const GET: u8 = b'g'; +const QUIT: u8 = b'q'; +const REPLY: usize = 2 + crate::crypto::kem::SEED_SIZE; +const TIMEOUT: Duration = Duration::from_secs(2); +/// How long `unlock` waits for a starting agent to say it is ready. +const START_TIMEOUT: Duration = Duration::from_secs(10); +const SECRET_ENVS: [&str; 2] = ["VAULT_PASSPHRASE", "VAULT_DECOY_PASSPHRASE"]; + +/// Go's `filepath.Abs`: lexical, with `os.Getwd`'s preference for `$PWD`. +pub fn go_abs(p: &Path) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let joined = if p.is_absolute() { p.to_path_buf() } else { go_getwd()?.join(p) }; + let s = std::str::from_utf8(joined.as_os_str().as_bytes()).map_err(|_| std::io::Error::other("key path is not valid UTF-8"))?; + Ok(PathBuf::from(crate::util::clean(s))) + } + #[cfg(not(unix))] + { + std::path::absolute(p) + } +} + +#[cfg(unix)] +fn go_getwd() -> std::io::Result { + use std::os::unix::fs::MetadataExt; + let dot = std::fs::metadata(".")?; + if let Some(pwd) = std::env::var_os("PWD").map(PathBuf::from) + && pwd.is_absolute() + && let Ok(m) = std::fs::metadata(&pwd) + && m.dev() == dot.dev() && m.ino() == dot.ino() { + return Ok(pwd); + } + std::env::current_dir() +} + +/// The agent socket for the key file at `key`, creating the private +/// per-user directory if needed and refusing one that is not private. +pub fn socket_path(key: &Path) -> Result { + use sha2::Digest; + let abs = go_abs(key).map_err(|e| Error::io("abs", key, &e))?; + let base = std::env::var_os("XDG_RUNTIME_DIR") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(go_temp_dir); + let dir = base.join(format!("vault-{}", uid())); + match crate::platform::fs::create_private_dir(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(Error::io("mkdir", &dir, &e)), + } + private_dir(&dir)?; + let sum = sha2::Sha256::digest(abs.as_os_str().as_encoded_bytes()); + Ok(dir.join(format!("agent-{}", crate::store::hex(&sum[..8])))) +} + +/// Go's `os.TempDir`: `$TMPDIR` or /tmp on Unix. +fn go_temp_dir() -> PathBuf { + #[cfg(unix)] + { + std::env::var_os("TMPDIR") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")) + } + #[cfg(not(unix))] + { + std::env::temp_dir() + } +} + +fn uid() -> u32 { + #[cfg(unix)] + { + rustix::process::getuid().as_raw() + } + #[cfg(not(unix))] + { + // Go's os.Getuid returns -1 on Windows. + u32::MAX + } +} + +/// A real directory, owned by this user, with mode exactly 0700. +#[cfg(unix)] +fn private_dir(dir: &Path) -> Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let m = std::fs::symlink_metadata(dir).map_err(|e| Error::io("lstat", dir, &e))?; + if !m.is_dir() || m.uid() != uid() || m.permissions().mode() & 0o777 != 0o700 { + crate::bail!("{} is not a private directory of this user", dir.display()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn private_dir(_: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +mod imp { + use super::*; + use crate::platform::memory::{LockedBuf, harden_process}; + use std::io::{Read, Write}; + use std::os::unix::net::{UnixListener, UnixStream}; + + fn dial(key: &Path, op: u8) -> Result { + let sock = socket_path(key)?; + let c = UnixStream::connect(&sock).map_err(|e| Error::io("dial", &sock, &e))?; + c.set_read_timeout(Some(TIMEOUT))?; + c.set_write_timeout(Some(TIMEOUT))?; + (&c).write_all(&[op])?; + Ok(c) + } + + /// The identity a running agent holds for `key`. + pub fn identity(key: &Path) -> Result { + let mut c = dial(key, GET)?; + let mut buf = crate::secret::SecretArray::::zero(); + c.read_exact(buf.expose_mut())?; + let b = buf.expose(); + Identity::from_suite_seed(u16::from_be_bytes([b[0], b[1]]), &b[2..]).map_err(Error::from) + } + + /// Tells the agent for `key` to forget it; whether one was running. + pub fn stop(key: &Path) -> bool { + let Ok(mut c) = dial(key, QUIT) else { return false }; + // Returns once the agent has wiped the key and closed. + let _ = std::io::copy(&mut c, &mut std::io::sink()); + true + } + + /// The command that starts an agent: this binary, with passphrase + /// variables removed from its environment and nothing secret in argv. + pub fn command(sock: &Path, ttl: Duration, idle: bool) -> Result { + let exe = + std::env::current_exe().map_err(|e| Error::new(format!("cannot find this executable: {}", crate::util::io_reason(&e))))?; + let mut c = std::process::Command::new(exe); + c.arg(AGENT_CMD) + .arg(sock) + .arg(crate::util::format_duration(ttl)) + .arg(if idle { "idle" } else { "fixed" }); + for v in SECRET_ENVS { + c.env_remove(v); + } + c.current_dir("/") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + Ok(c) + } + + /// Replaces any agent for `key` with one holding `id` for `ttl`. + pub fn start(key: &Path, id: &Identity, ttl: Duration, idle: bool) -> Result<()> { + let sock = socket_path(key)?; + stop(key); + let mut child = command(&sock, ttl, idle)? + .spawn() + .map_err(|e| Error::new(format!("cannot start the agent: {}", crate::util::io_reason(&e))))?; + let mut secret = crate::secret::SecretArray::::zero(); + secret.expose_mut()[..2].copy_from_slice(&id.suite_id().to_be_bytes()); + secret.expose_mut()[2..].copy_from_slice(id.seed()); + let mut stdin = child.stdin.take().expect("piped"); + let wrote = stdin.write_all(secret.expose()); + drop(secret); + drop(stdin); + let mut stdout = child.stdout.take().expect("piped"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut msg = Vec::new(); + let _ = stdout.by_ref().take(4096).read_to_end(&mut msg); + let _ = tx.send(msg); + }); + // The agent closes its standard output once listening, after saying + // "ok" or why not. + let msg = rx.recv_timeout(START_TIMEOUT); + let fail = |child: &mut std::process::Child, why: String| { + let _ = child.kill(); + let _ = child.wait(); + Err(Error::new(why)) + }; + if let Err(e) = wrote { + return fail( + &mut child, + format!("cannot hand the key to the agent: {}", crate::util::io_reason(&e)), + ); + } + match msg { + Ok(m) if m == b"ok" => Ok(()), + Ok(m) => fail(&mut child, format!("agent did not start: {}", String::from_utf8_lossy(&m).trim())), + Err(_) => fail(&mut child, "agent did not start in time".into()), + } + } + + /// The agent process. Returns when it expires or is told to quit. + pub fn run(sock: &Path, ttl: &str, mode: &str) { + let mut out = std::io::stdout(); + let report = |out: &mut std::io::Stdout, msg: &str| { + let _ = out.write_all(msg.as_bytes()); + let _ = out.flush(); + // Closing standard output (by pointing it at /dev/null) tells + // `unlock` the outcome is final. + if let Ok(null) = rustix::fs::open("/dev/null", rustix::fs::OFlags::WRONLY, rustix::fs::Mode::empty()) { + let _ = rustix::stdio::dup2_stdout(&null); + } + }; + let ttl = match crate::util::parse_duration(ttl) { + Ok(n) if n > 0 && (mode == "fixed" || mode == "idle") => Duration::from_nanos(n as u64), + _ => return report(&mut out, "bad duration or mode"), + }; + if let Err(e) = harden_process() { + return report(&mut out, &e.to_string()); + } + let mut secret = match LockedBuf::new(REPLY) { + Ok(b) => b, + Err(e) => return report(&mut out, &e.to_string()), + }; + if std::io::stdin().lock().read_exact(secret.as_mut_slice()).is_err() { + return report(&mut out, "no key received"); + } + { + let s = secret.as_slice(); + if let Err(e) = Identity::from_suite_seed(u16::from_be_bytes([s[0], s[1]]), &s[2..]) { + return report(&mut out, &e); + } + } + // A socket left by an agent that died stops bind; start has already + // stopped any live one. + let _ = std::fs::remove_file(sock); + let l = match UnixListener::bind(sock) { + Ok(l) => l, + Err(e) => return report(&mut out, &crate::util::io_msg("listen unix", sock, &e)), + }; + let ours = match std::fs::symlink_metadata(sock) { + Ok(m) => m, + Err(e) => return report(&mut out, &crate::util::io_msg("stat", sock, &e)), + }; + let cleanup = || { + // Remove the socket only if it is still the one this agent made. + use std::os::unix::fs::MetadataExt; + if let Ok(m) = std::fs::symlink_metadata(sock) + && m.ino() == ours.ino() && m.dev() == ours.dev() { + let _ = std::fs::remove_file(sock); + } + }; + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = std::fs::set_permissions(sock, std::fs::Permissions::from_mode(0o600)).and_then(|_| l.set_nonblocking(true)) { + cleanup(); + return report(&mut out, &crate::util::io_msg("chmod", sock, &e)); + } + } + report(&mut out, "ok"); + serve(&l, &mut secret, ttl, mode == "idle"); + secret.clear(); + drop(secret); + cleanup(); + } + + /// Serves until the deadline. The listener is polled with a timeout, so + /// expiry never waits for another client; each request is bounded by + /// two seconds and by the time left. + fn serve(l: &UnixListener, secret: &mut LockedBuf, ttl: Duration, idle: bool) { + use rustix::event::{PollFd, PollFlags, Timespec, poll}; + let mut deadline = std::time::Instant::now() + ttl; + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + return; + } + let t = Timespec { + tv_sec: left.as_secs() as i64, + tv_nsec: left.subsec_nanos() as _, + }; + let mut fds = [PollFd::new(l, PollFlags::IN)]; + match poll(&mut fds, Some(&t)) { + Ok(0) => continue, + Ok(_) => {} + Err(rustix::io::Errno::INTR) => continue, + Err(_) => return, + } + let Ok((c, _)) = l.accept() else { continue }; + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + return; + } + let limit = left.min(TIMEOUT); + if c.set_nonblocking(false) + .and_then(|_| c.set_read_timeout(Some(limit))) + .and_then(|_| c.set_write_timeout(Some(limit))) + .is_err() + { + continue; + } + let mut op = [0u8; 1]; + if (&c).read_exact(&mut op).is_err() { + continue; + } + match op[0] { + GET => { + if std::time::Instant::now() < deadline { + let _ = (&c).write_all(secret.as_slice()); + if idle { + deadline = std::time::Instant::now() + ttl; + } + } + } + QUIT => { + secret.clear(); + drop(c); + return; + } + _ => {} + } + } + } +} + +#[cfg(unix)] +pub use imp::{command, identity, run, start, stop}; + +#[cfg(not(unix))] +mod imp { + use super::*; + const UNSUPPORTED: &str = "vault unlock is not supported on this platform; use the passphrase each time"; + + pub fn identity(_: &Path) -> Result { + Err(Error::new(UNSUPPORTED)) + } + pub fn stop(_: &Path) -> bool { + false + } + pub fn start(_: &Path, _: &Identity, _: Duration, _: bool) -> Result<()> { + Err(Error::new(UNSUPPORTED)) + } + pub fn run(_: &Path, _: &str, _: &str) {} +} + +#[cfg(not(unix))] +pub use imp::{identity, run, start, stop}; + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn command_strips_secrets_from_the_environment() { + let c = command(Path::new("/x/sock"), Duration::from_secs(300), true).unwrap(); + let removed: Vec<_> = c + .get_envs() + .filter(|(_, v)| v.is_none()) + .map(|(k, _)| k.to_string_lossy().into_owned()) + .collect(); + for v in SECRET_ENVS { + assert!(removed.iter().any(|r| r == v), "{v} not removed"); + } + let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args, [AGENT_CMD, "/x/sock", "5m0s", "idle"]); + } + + #[test] + fn abs_is_lexical() { + assert_eq!(go_abs(Path::new("/a/b/../c/./d")).unwrap(), PathBuf::from("/a/c/d")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..c2e749a --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,964 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The command line, matching the Go version's commands, flags (Go `flag` +//! package syntax, flags before, between or after arguments), prompts, +//! messages and exit statuses. + +use crate::agent; +use crate::error::{Error, Result}; +use crate::keyfile; +use crate::pipeline::{MAX_WORKERS, Pool}; +use crate::platform::{fs as pfs, lock, term}; +use crate::secret::SecretVec; +use crate::store::{Catalogue, FILE_LIMIT, SourceStamp, Store, VaultName}; +use crate::util::{self, human_size}; +use std::ffi::OsString; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +pub const USAGE: &str = "Usage: + vault genkey create a new passphrase-protected key + vault init [--filler SIZE] create a new, empty vault, with at least + SIZE of filler (e.g. 500M, 2G) + vault add FILE|DIR|URL [FOLDER] encrypt a file, a directory (recursively) + or a downloaded URL into the vault, + optionally under a folder inside the vault + vault migrate FILE|DIR [FOLDER] like add, then delete each original once + [--yes] it is stored; asks before overwriting + anything already in the vault, unless + --yes is given + vault mirror URL [FOLDER] crawl a website (same host only, one request + at a time) into FOLDER/HOST, rewriting its + links to relative paths + vault absorb [FOLDER] read a tar archive (optionally gzipped) from + standard input and add every file in it, + optionally under a folder inside the vault + vault ls [FOLDER] list one folder: its files, and its + sub-folders (ending in /) with their + total size; -r lists everything under it + vault list [FOLDER] list every stored file under FOLDER, + with full names + vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name) + vault get -r FOLDER [-o DIR] decrypt a folder and everything under it + into DIR (default: the folder's base name) + vault mv OLD NEW rename or move a file or folder; NEW + ending in / or naming an existing folder + moves OLD into it + vault cp [-r] OLD NEW copy a file, or with -r a folder, like mv; + copies share their encrypted data + vault rm NAME delete a stored file + vault rm -r FOLDER [-f] delete a folder and everything under it; + asks first unless -f (or -rf) is given + vault unlock [--for DURATION] remember the passphrase for DURATION + [--idle] (default 5m), so commands skip asking; + with --idle, DURATION since last used + vault lock forget it now + +Options (any command): + --key PATH key file (default $VAULT_KEY, else ~/.vault/key) + --vault DIR vault directory (default $VAULT_DIR, else ~/.vault/store) + --workers N encryption and I/O threads, 1 to 256 (default: CPUs, at + most 8) + +Options for add URL and mirror: + --max-bytes SIZE largest download (add) or total (mirror); defaults + 16G and 4G, at most 1T + --timeout DURATION deadline for each request; defaults 2h and 5m + --max-pages N pages and assets mirror fetches (default 10000) + +The passphrase is read from the terminal, or from $VAULT_PASSPHRASE. After +vault unlock, commands use the remembered key instead, unless +$VAULT_PASSPHRASE is set. + +Decoy passphrase: genkey offers a second, optional passphrase +($VAULT_DECOY_PASSPHRASE when scripted). It unlocks a separate key, so +every command run with it sees and changes only the decoy files, and the +real files stay invisible. The key file does not reveal whether a decoy +exists, and init fills each vault with unreadable filler, so hidden +files look like filler to anyone holding only the decoy passphrase."; + +const PASSPHRASE_ENV: &str = "VAULT_PASSPHRASE"; +const DECOY_ENV: &str = "VAULT_DECOY_PASSPHRASE"; +/// Exit status when `mirror` stored some resources but skipped others. +pub const EXIT_PARTIAL: i32 = 3; + +/// Prints to standard error, ignoring failure (as Go's Fprintf does). +macro_rules! say { + ($($t:tt)*) => {{ let _ = writeln!(std::io::stderr(), $($t)*); }}; +} + +/// Prints to standard output; a closed pipe ends the program quietly, as +/// Go's SIGPIPE on standard output does. +fn out(line: &str) { + let mut o = std::io::stdout().lock(); + if let Err(e) = o.write_all(line.as_bytes()).and_then(|_| o.flush()) + && e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(128 + 13); + } +} + +#[derive(Default)] +struct Flags { + key: Option, + vault: Option, + output: Option, + filler: Option, + yes: bool, + ttl: Option, + idle: bool, + r: bool, + f: bool, + rf: bool, + fr: bool, + workers: Option, + max_bytes: Option, + timeout: Option, + max_pages: Option, +} + +enum FlagKind { + Str, + Bool, + Dur, +} + +fn flag_kind(name: &str) -> Option { + Some(match name { + "key" | "vault" | "o" | "filler" | "workers" | "max-bytes" | "max-pages" => FlagKind::Str, + "yes" | "idle" | "r" | "f" | "rf" | "fr" => FlagKind::Bool, + "for" | "timeout" => FlagKind::Dur, + _ => return None, + }) +} + +fn go_parse_bool(v: &str) -> Option { + match v { + "1" | "t" | "T" | "TRUE" | "true" | "True" => Some(true), + "0" | "f" | "F" | "FALSE" | "false" | "False" => Some(false), + _ => None, + } +} + +impl Flags { + fn set(&mut self, name: &str, value: &str) -> std::result::Result<(), String> { + match flag_kind(name).expect("known flag") { + FlagKind::Bool => { + let b = go_parse_bool(value).ok_or_else(|| format!("invalid boolean value {value:?} for -{name}: parse error"))?; + *match name { + "yes" => &mut self.yes, + "idle" => &mut self.idle, + "r" => &mut self.r, + "f" => &mut self.f, + "rf" => &mut self.rf, + _ => &mut self.fr, + } = b; + } + FlagKind::Dur => { + let d = util::parse_duration(value).map_err(|_| format!("invalid value {value:?} for flag -{name}: parse error"))?; + *if name == "for" { &mut self.ttl } else { &mut self.timeout } = Some(d); + } + FlagKind::Str => { + let v = Some(value.to_string()); + match name { + "key" => self.key = v, + "vault" => self.vault = v, + "o" => self.output = v, + "filler" => self.filler = v, + "workers" => self.workers = v, + "max-bytes" => self.max_bytes = v, + _ => self.max_pages = v, + } + } + } + Ok(()) + } +} + +/// Go's `flag.FlagSet.Parse`: consumes flags from the front of `args`, +/// returning the remaining arguments. +fn parse_flags(fl: &mut Flags, mut args: &[String]) -> std::result::Result, String> { + loop { + let Some(s) = args.first() else { return Ok(Vec::new()) }; + let b = s.as_bytes(); + if b.len() < 2 || b[0] != b'-' { + return Ok(args.to_vec()); + } + let mut minuses = 1; + if b[1] == b'-' { + minuses = 2; + if b.len() == 2 { + return Ok(args[1..].to_vec()); + } + } + let mut name = &s[minuses..]; + if name.is_empty() || name.starts_with('-') || name.starts_with('=') { + return Err(format!("bad flag syntax: {s}")); + } + args = &args[1..]; + let mut value = None; + if let Some(i) = name[1..].find('=') { + value = Some(&name[i + 2..]); + name = &name[..i + 1]; + } + let Some(kind) = flag_kind(name) else { + if name == "help" || name == "h" { + return Err("flag: help requested".into()); + } + return Err(format!("flag provided but not defined: -{name}")); + }; + match (kind, value) { + (FlagKind::Bool, None) => fl.set(name, "true")?, + (_, Some(v)) => fl.set(name, v)?, + (_, None) => { + let Some(v) = args.first() else { + return Err(format!("flag needs an argument: -{name}")); + }; + fl.set(name, v)?; + args = &args[1..]; + } + } + } +} + +/// Flags may come before, between or after positional arguments. +fn parse_interleaved(fl: &mut Flags, args: &[String]) -> std::result::Result, String> { + let mut pos = Vec::new(); + let mut rest = args.to_vec(); + loop { + let left = parse_flags(fl, &rest)?; + let Some(first) = left.first() else { return Ok(pos) }; + pos.push(first.clone()); + rest = left[1..].to_vec(); + } +} + +fn env_bytes(name: &str) -> Option> { + let v = std::env::var_os(name)?; + #[cfg(unix)] + { + Some(std::os::unix::ffi::OsStringExt::into_vec(v)) + } + #[cfg(not(unix))] + { + Some(v.to_string_lossy().into_owned().into_bytes()) + } +} + +fn read_passphrase(prompt: &str) -> Result { + match env_bytes(PASSPHRASE_ENV) { + Some(v) if v.is_empty() => Err(Error::new(format!("{PASSPHRASE_ENV} is set but empty"))), + Some(v) => Ok(SecretVec::from_vec(v)), + None => term::read_secret(prompt).map_err(|e| { + Error::new(format!( + "could not read passphrase (set {PASSPHRASE_ENV} for non-interactive use): {}", + util::io_reason(&e) + )) + }), + } +} + +fn new_passphrase(what: &str) -> Result { + if let Some(v) = env_bytes(PASSPHRASE_ENV).filter(|v| !v.is_empty()) { + return Ok(SecretVec::from_vec(v)); + } + let p = read_passphrase(&format!("Enter {what}:"))?; + if p.is_empty() { + crate::bail!("{what} must not be empty"); + } + let again = read_passphrase(&format!("Confirm {what}:"))?; + if !(p == *again.expose()) { + crate::bail!("{what}s do not match"); + } + Ok(p) +} + +/// An optional second passphrase; empty means none. +fn decoy_passphrase() -> Result { + if let Some(v) = env_bytes(DECOY_ENV) { + return Ok(SecretVec::from_vec(v)); + } + if std::env::var_os(PASSPHRASE_ENV).is_some() { + return Ok(SecretVec::from_vec(Vec::new())); // scripted, no decoy + } + let read = |p: &str| term::read_secret(p).map_err(|e| Error::new(util::io_reason(&e))); + let p = read("Enter decoy passphrase (empty for none):")?; + if p.is_empty() { + return Ok(p); + } + let again = read("Confirm decoy passphrase:")?; + if !(p == *again.expose()) { + crate::bail!("decoy passphrases do not match"); + } + Ok(p) +} + +fn home() -> Option { + let v = if cfg!(windows) { + std::env::var_os("USERPROFILE") + } else { + std::env::var_os("HOME") + }; + v.filter(|h| !h.is_empty()).map(PathBuf::from) +} + +fn default_path(flag: &Option, env: &str, name: &str) -> Result { + if let Some(f) = flag { + return Ok(PathBuf::from(f)); + } + if let Some(v) = std::env::var_os(env).filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(v)); + } + home() + .map(|h| h.join(".vault").join(name)) + .ok_or_else(|| Error::new(format!("cannot find home directory; set {env}"))) +} + +/// The key: from a running agent unless a passphrase is in the +/// environment, else from the key file with the passphrase. +fn key_identity(key: &Path) -> Result { + if std::env::var_os(PASSPHRASE_ENV).is_none() + && let Ok(id) = agent::identity(key) { + return Ok(id); + } + let pass = read_passphrase("Enter passphrase:")?; + keyfile::unlock(key, pass.expose()).map_err(|e| Error::new(e.to_string())) +} + +struct Ctx { + flags: Flags, + pool: Option>, +} + +impl Ctx { + fn key(&self) -> Result { + default_path(&self.flags.key, "VAULT_KEY", "key") + } + + fn vault_dir(&self) -> Result { + default_path(&self.flags.vault, "VAULT_DIR", "store") + } + + fn pool(&mut self) -> Result> { + if self.pool.is_none() { + let n = match &self.flags.workers { + None => Pool::default_workers(), + Some(w) => match w.parse::() { + Ok(n) if (1..=MAX_WORKERS).contains(&n) => n, + _ => crate::bail!("--workers needs a number from 1 to {MAX_WORKERS}"), + }, + }; + self.pool = Some(Arc::new(Pool::new(n))); + } + Ok(Arc::clone(self.pool.as_ref().expect("set"))) + } + + /// Unlocks the key, opens the vault, then takes the vault lock: + /// exclusive for writers, shared for readers. + fn open(&mut self, exclusive: bool) -> Result<(Store, lock::Lock)> { + let key = self.key()?; + let dir = self.vault_dir()?; + let id = key_identity(&key)?; + let s = Store::open(&dir, id, self.pool()?)?; + let l = lock::lock_vault(&dir, exclusive)?; + Ok((s, l)) + } + + fn net_limits(&self, mirror: bool) -> Result { + let mut l = if mirror { + crate::net::Limits::mirror_defaults() + } else { + crate::net::Limits::add_defaults() + }; + if let Some(b) = &self.flags.max_bytes { + l.max_bytes = util::parse_size(b, FILE_LIMIT)?; + } + if let Some(t) = self.flags.timeout { + if t <= 0 { + crate::bail!("--timeout needs a positive duration, such as 30s or 1h"); + } + l.request = Duration::from_nanos(t as u64); + } + if let Some(p) = &self.flags.max_pages { + l.max_pages = p + .parse() + .ok() + .filter(|&n| n > 0) + .ok_or_else(|| Error::new("--max-pages needs a positive number"))?; + } + Ok(l) + } +} + +fn report(verb: &str) -> impl FnMut(&VaultName, bool) + '_ { + move |n, replaced| say!("{} {n}", if replaced { "replaced" } else { verb }) +} + +/// Runs the command line; returns the exit status. +pub fn main() -> i32 { + let args: Vec = std::env::args_os().collect(); + if args.len() < 2 { + say!("{USAGE}"); + return 2; + } + if args[1] == agent::AGENT_CMD && args.len() == 5 { + agent::run(Path::new(&args[2]), &args[3].to_string_lossy(), &args[4].to_string_lossy()); + return 0; + } + let mut strs = Vec::with_capacity(args.len()); + for a in &args[1..] { + match a.to_str() { + Some(s) => strs.push(s.to_string()), + None => { + say!("vault: arguments must be valid UTF-8: {}", a.to_string_lossy()); + return 1; + } + } + } + match run(&strs[0], &strs[1..]) { + Ok(code) => code, + Err(e) => { + say!("vault: {e}"); + 1 + } + } +} + +fn run(cmd: &str, args: &[String]) -> Result { + let mut flags = Flags::default(); + let pos = parse_interleaved(&mut flags, args).map_err(|e| Error::new(format!("{e}\n\n{USAGE}")))?; + let want = |min: usize, max: usize| -> Result<()> { + if pos.len() < min || pos.len() > max { + crate::bail!("wrong number of arguments for {cmd}\n\n{USAGE}"); + } + Ok(()) + }; + let mut ctx = Ctx { flags, pool: None }; + match cmd { + "genkey" => { + want(0, 0)?; + let key = ctx.key()?; + if std::fs::metadata(&key).is_ok() { + crate::bail!("{} already exists", key.display()); + } + let pass = new_passphrase("passphrase")?; + let decoy = decoy_passphrase()?; + if pass == *decoy.expose() { + crate::bail!("decoy passphrase must differ from the passphrase"); + } + keyfile::write(&key, pass.expose(), decoy.expose()).map_err(|e| Error::new(e.to_string()))?; + // One left from an old key at this path would serve it. + agent::stop(&key); + say!("key written to {}", key.display()); + } + "init" => { + want(0, 0)?; + let size = util::parse_size(ctx.flags.filler.as_deref().unwrap_or("0"), FILE_LIMIT)?; + let dir = ctx.vault_dir()?; + Store::init(&dir, size, ctx.pool()?)?; + say!("vault created at {}", dir.display()); + } + "add" | "migrate" => { + want(1, 2)?; + let dest = pos.get(1).map(|d| d.trim_matches('/').to_string()).unwrap_or_default(); + let migrate = cmd == "migrate"; + if crate::net::is_url(&pos[0]) { + if migrate { + crate::bail!("cannot migrate a URL; use add"); + } + let limits = ctx.net_limits(false)?; + let (s, _lock) = ctx.open(true)?; + let mut cat = s.catalogue()?; + crate::net::add_url(&s, &mut cat, &pos[0], &dest, &limits, &mut report("added"))?; + return Ok(0); + } + let (s, _lock) = ctx.open(true)?; + let mut cat = s.catalogue()?; + // Never ingest, or delete, the vault or its key. + let skip: Vec = [ctx.vault_dir()?, ctx.key()?].iter().filter_map(|p| FileId::of(p, true)).collect(); + let yes = ctx.flags.yes; + add_path(&s, &mut cat, Path::new(&pos[0]), &dest, migrate, &skip, &mut |names| { + yes || confirm_overwrite(names) + })?; + } + "mirror" => { + want(1, 2)?; + if !crate::net::is_url(&pos[0]) { + crate::bail!("mirror needs an http:// or https:// URL"); + } + let dest = pos.get(1).map(|d| d.trim_matches('/').to_string()).unwrap_or_default(); + let limits = ctx.net_limits(true)?; + let (s, _lock) = ctx.open(true)?; + let mut cat = s.catalogue()?; + match crate::net::mirror(&s, &mut cat, &pos[0], &dest, &limits, &mut report("added"))? { + crate::net::MirrorOutcome::Complete { .. } => {} + crate::net::MirrorOutcome::Partial { stored, skipped } => { + say!("vault: mirror incomplete: stored {stored}, skipped {skipped}"); + return Ok(EXIT_PARTIAL); + } + } + } + "absorb" => { + want(0, 1)?; + if stdin_is_terminal() { + crate::bail!("absorb reads a tar archive from standard input; pipe one in"); + } + let dest = pos.first().map(|d| d.trim_matches('/').to_string()).unwrap_or_default(); + // Wait for the archive to start arriving before asking for the + // passphrase, so a program feeding the pipe (gpg, ssh) has + // finished with the terminal and cannot bury the prompt. + let mut input = std::io::BufReader::with_capacity(1 << 16, std::io::stdin().lock()); + if std::io::BufRead::fill_buf(&mut input).map_or(true, |b| b.is_empty()) { + crate::bail!("nothing to absorb on standard input"); + } + let (s, _lock) = ctx.open(true)?; + let mut cat = s.catalogue()?; + crate::absorb::absorb(&s, &mut cat, input, &dest, &mut report("added"))?; + } + "list" | "ls" => { + want(0, 1)?; + let prefix = pos.first().map(|d| d.trim_matches('/').to_string()).unwrap_or_default(); + let (s, _lock) = ctx.open(false)?; + let cat = s.catalogue()?; + if cmd == "ls" && !ctx.flags.r { + for line in ls(&cat, &prefix)? { + out(&line); + } + } else { + let names: Vec = if prefix.is_empty() { + cat.names().map(String::from).collect() + } else { + cat.under(&prefix) + }; + for n in names { + let r = cat.current(&n).expect("listed"); + out(&format!("{:>10} {} {n}\n", human_size(r.size), r.modified.local_minute())); + } + } + } + "get" => { + want(1, 1)?; + let out_path = ctx.flags.output.clone().unwrap_or_else(|| util::base(pos[0].trim_end_matches('/'))); + let (s, _lock) = ctx.open(false)?; + let cat = s.catalogue()?; + if !ctx.flags.r { + let r = cat + .current(&pos[0]) + .ok_or_else(|| Error::new(format!("{}: not in the vault", pos[0])))?; + let p = PathBuf::from(&out_path); + let Some(fname) = p.file_name() else { + crate::bail!("{out_path} already exists"); + }; + let parent = p.parent().unwrap_or(Path::new("")); + let dir = pfs::Dir::open(parent).map_err(|e| Error::io("open", parent, &e))?; + s.extract(r, &dir, fname)?; + return Ok(0); + } + let folder = pos[0].trim_matches('/'); + let names = cat.under(folder); + if folder.is_empty() || names.is_empty() || cat.contains(folder) { + crate::bail!("{}: not a folder in the vault", pos[0]); + } + let root = PathBuf::from(&out_path); + pfs::create_private_dir_all(&root).map_err(|e| Error::io("mkdir", &root, &e))?; + let top = pfs::Dir::open(&root).map_err(|e| Error::io("open", &root, &e))?; + for n in names { + let rel = &n[folder.len() + 1..]; + let parts: Vec<&str> = rel.split('/').collect(); + let mut dir = None::; + for p in &parts[..parts.len() - 1] { + let next = dir + .as_ref() + .unwrap_or(&top) + .subdir(p.as_ref()) + .map_err(|e| Error::new(util::io_reason(&e)))?; + dir = Some(next); + } + let d = dir.as_ref().unwrap_or(&top); + s.extract(cat.current(&n).expect("listed"), d, parts[parts.len() - 1].as_ref())?; + say!("extracted {}", root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)).display()); + } + } + "mv" | "rename" | "cp" => { + want(2, 2)?; + let (s, _lock) = ctx.open(true)?; + let mut cat = s.catalogue()?; + let pairs = transfers(&cat, &pos[0], &pos[1], cmd != "cp" || ctx.flags.r)?; + let verb = if cmd == "cp" { "copied" } else { "moved" }; + s.copy_all(&mut cat, &pairs, cmd != "cp", &mut |from, to| say!("{verb} {from} -> {to}"))?; + } + "rm" | "delete" => { + want(1, 1)?; + let f = &ctx.flags; + let (recursive, forced) = (f.r || f.rf || f.fr, f.f || f.rf || f.fr); + let mut name = pos[0].clone(); + if recursive { + name = name.trim_matches('/').to_string(); + if name.is_empty() { + crate::bail!("refusing to remove the whole vault"); + } + } + let (s, _lock) = ctx.open(true)?; + let mut cat = s.catalogue()?; + let names = if recursive { + cat.under(&name) + } else if cat.contains(&name) { + vec![name.clone()] + } else { + Vec::new() + }; + if names.is_empty() { + if forced { + return Ok(0); + } + crate::bail!("{name}: not in the vault"); + } + let size: u64 = names.iter().map(|n| cat.current(n).expect("listed").size).sum(); + if recursive + && !forced + && !confirm( + "these files will be deleted, with no undo:", + &names, + &format!("delete {} file(s), {}? [y/N]", names.len(), human_size(size)), + ) + { + crate::bail!("rm cancelled; nothing changed (-f skips the question)"); + } + s.remove_all(&mut cat, &names, &mut |n| say!("removed {n}"))?; + if recursive { + say!("removed {} file(s), {}", names.len(), human_size(size)); + } + } + "unlock" => { + want(0, 0)?; + let ttl = ctx.flags.ttl.unwrap_or(5 * 60 * 1_000_000_000); + if ttl <= 0 { + crate::bail!("--for needs a positive duration, such as 5m or 1h"); + } + let ttl = Duration::from_nanos(ttl as u64); + let key = ctx.key()?; + let pass = read_passphrase("Enter passphrase:")?; + let id = keyfile::unlock(&key, pass.expose()).map_err(|e| Error::new(e.to_string()))?; + drop(pass); + agent::start(&key, &id, ttl, ctx.flags.idle)?; + id.close(); + if ctx.flags.idle { + say!( + "unlocked until unused for {}; vault lock forgets the key sooner", + util::format_duration(ttl) + ); + } else { + let until = util::Instant::from_system(std::time::SystemTime::now() + ttl); + say!("unlocked until {}; vault lock forgets the key sooner", until.local_clock()); + } + } + "lock" => { + want(0, 0)?; + if agent::stop(&ctx.key()?) { + say!("locked"); + } else { + say!("nothing was unlocked"); + } + } + "help" | "-h" | "--help" => out(&format!("{USAGE}\n")), + _ => crate::bail!("unknown command {cmd:?}\n\n{USAGE}"), + } + Ok(0) +} + +fn stdin_is_terminal() -> bool { + use std::io::IsTerminal; + std::io::stdin().is_terminal() +} + +/// Works out, like mv(1) and cp(1), what `src` and `dst` mean: each file +/// to copy or move, with its new name. `src` is a file, or with +/// `recursive` a folder; `dst` ending in / or naming an existing folder +/// means into it. No new name may already exist. +pub fn transfers(cat: &Catalogue, src: &str, dst: &str, recursive: bool) -> Result> { + let mut into = dst.is_empty() || dst.ends_with('/'); + let (src, dst) = (src.trim_matches('/'), dst.trim_matches('/')); + if src.is_empty() { + crate::bail!("name a file or folder to copy or move"); + } + if dst.is_empty() || (!cat.contains(dst) && !cat.under(dst).is_empty()) { + into = true; + } + let dst = if into { + util::join(&[dst, &util::base(src)]) + } else { + dst.to_string() + }; + let raw: Vec<(String, String)> = if cat.contains(src) { + vec![(src.to_string(), dst)] + } else { + let names = cat.under(src); + if names.is_empty() { + crate::bail!("{src}: not in the vault"); + } + if !recursive { + crate::bail!("{src} is a folder; use -r"); + } + if dst == src || dst.starts_with(&format!("{src}/")) { + crate::bail!("cannot put {src} inside itself"); + } + names + .into_iter() + .map(|n| (n.clone(), format!("{dst}{}", &n[src.len()..]))) + .collect() + }; + let mut pairs = Vec::with_capacity(raw.len()); + for (from, to) in raw { + let Some(name) = VaultName::new(&to) else { + crate::bail!("invalid vault path {to:?}") + }; + if cat.contains(&to) { + crate::bail!("{to} already exists"); + } + pairs.push((from, name)); + } + Ok(pairs) +} + +/// One level of the vault, like ls(1): the files directly in `folder` +/// and each sub-folder once, with a trailing slash, the total size and +/// the newest date beneath it. Folders first. A file's own name lists it. +pub fn ls(cat: &Catalogue, folder: &str) -> Result> { + let mut entries: std::collections::BTreeMap)> = Default::default(); + for (name, r) in cat.iter() { + let rel = if folder.is_empty() { + name.to_string() + } else if name == folder { + util::base(name) + } else if let Some(rest) = name.strip_prefix(folder).and_then(|r| r.strip_prefix('/')) { + rest.to_string() + } else { + continue; + }; + let rel = match rel.find('/') { + Some(i) => rel[..i + 1].to_string(), + None => rel, + }; + let e = entries.entry(rel).or_default(); + e.0 += r.size; + if e.1.is_none_or(|m| r.modified > m) { + e.1 = Some(r.modified); + } + } + if entries.is_empty() && !folder.is_empty() { + crate::bail!("{folder}: not in the vault"); + } + let mut names: Vec<_> = entries.into_iter().collect(); + names.sort_by(|a, b| b.0.ends_with('/').cmp(&a.0.ends_with('/')).then(a.0.cmp(&b.0))); + Ok(names + .into_iter() + .map(|(n, (size, m))| { + format!( + "{:>10} {} {n}\n", + human_size(size), + m.unwrap_or(util::Instant { secs: 0, nanos: 0 }).local_minute() + ) + }) + .collect()) +} + +fn confirm_overwrite(names: &[String]) -> bool { + confirm( + "these files are already in the vault and would be overwritten:", + names, + &format!("overwrite {} file(s)? [y/N]", names.len()), + ) +} + +/// Shows `intro` and the first 20 names, then asks on the terminal. No +/// terminal means no. +fn confirm(intro: &str, names: &[String], question: &str) -> bool { + say!("{intro}"); + for (i, n) in names.iter().enumerate() { + if i == 20 { + say!(" … and {} more", names.len() - i); + break; + } + say!(" {n}"); + } + match term::confirm(question) { + Ok(b) => b, + Err(e) => { + say!("cannot ask for confirmation: {}", util::io_reason(&e)); + false + } + } +} + +/// Identifies a file for Go's `os.SameFile`. +#[derive(PartialEq, Eq)] +struct FileId(u64, u64, PathBuf); + +impl FileId { + fn of(p: &Path, follow: bool) -> Option { + let m = if follow { + std::fs::metadata(p) + } else { + std::fs::symlink_metadata(p) + } + .ok()?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let _ = &m; + Some(FileId(m.dev(), m.ino(), PathBuf::new())) + } + #[cfg(not(unix))] + { + let _ = m; + Some(FileId(0, 0, std::fs::canonicalize(p).ok()?)) + } + } +} + +fn utf8_rel(p: &Path) -> Result { + let mut parts = Vec::new(); + for c in p.components() { + let s = c + .as_os_str() + .to_str() + .ok_or_else(|| Error::new(format!("invalid vault path {:?}", p.to_string_lossy())))?; + parts.push(s.to_string()); + } + Ok(parts.join("/")) +} + +/// Adds a file, or every regular file under a directory, beneath `dest`, +/// keeping the structure. A directory named "." or ".." adds its contents +/// directly. With `migrate`, nothing is stored unless `overwrite` approves +/// the names it would replace, each original is deleted once stored and +/// checked unchanged, then emptied directories go. +fn add_path( + s: &Store, + cat: &mut Catalogue, + src: &Path, + dest: &str, + migrate: bool, + skip: &[FileId], + overwrite: &mut dyn FnMut(&[String]) -> bool, +) -> Result<()> { + let info = std::fs::metadata(src).map_err(|e| Error::io("stat", src, &e))?; + let skipped = |p: &Path, follow: bool| FileId::of(p, follow).is_some_and(|id| skip.contains(&id)); + if skipped(src, true) { + crate::bail!("{} is the vault or its key", src.display()); + } + let mut items: Vec<(PathBuf, String)> = Vec::new(); + let mut dirs = Vec::new(); + if !info.is_dir() { + let base = src.file_name().map(|b| utf8_rel(Path::new(b))).transpose()?.unwrap_or_default(); + items.push((src.to_path_buf(), util::join(&[dest, &base]))); + } else { + let base = match src.components().next_back() { + Some(std::path::Component::Normal(b)) => utf8_rel(Path::new(b))?, + _ => String::new(), + }; + walk(src, src, &mut |p, rel, ft| { + if skipped(p, false) { + say!("skipping {}: the vault or its key", p.display()); + return Ok(false); + } + if ft.is_dir() { + dirs.push(p.to_path_buf()); + return Ok(true); + } + if !ft.is_file() { + say!("skipping {}: not a regular file", p.display()); + return Ok(false); + } + items.push((p.to_path_buf(), util::join(&[dest, &base, &utf8_rel(rel)?]))); + Ok(false) + })?; + } + if migrate { + let existing: Vec = items.iter().filter(|(_, n)| cat.contains(n)).map(|(_, n)| n.clone()).collect(); + if !existing.is_empty() && !overwrite(&existing) { + crate::bail!("migrate cancelled; nothing changed"); + } + } + for (p, n) in &items { + let name = VaultName::new(n).ok_or_else(|| Error::new(format!("invalid vault path {n:?}")))?; + let (added, stamp) = s.add_file(cat, p, &name)?; + let mut verb = if added.replaced { "replaced" } else { "added" }; + if migrate { + remove_source(p, stamp)?; + verb = "migrated"; + } + say!("{verb} {n}"); + } + if migrate { + // Children come after their parents in walk order, so go backwards. + // Directories still holding skipped files stay. + for d in dirs.iter().rev() { + let _ = std::fs::remove_dir(d); + } + } + Ok(()) +} + +/// Deletes a migrated original, but only if it is still the very file +/// that was stored (same inode, size and modification time). +fn remove_source(p: &Path, stamp: SourceStamp) -> Result<()> { + match std::fs::metadata(p) { + Ok(m) if SourceStamp::of(&m) == stamp => std::fs::remove_file(p).map_err(|e| Error::io("remove", p, &e)), + _ => crate::bail!("{} changed after it was stored; not deleting it", p.display()), + } +} + +/// Go's `filepath.WalkDir`: lexical order, the root first, symlinks not +/// followed. `visit` returns whether to descend into a directory. +fn walk(root: &Path, p: &Path, visit: &mut dyn FnMut(&Path, &Path, &std::fs::FileType) -> Result) -> Result<()> { + let ft = std::fs::symlink_metadata(p).map_err(|e| Error::io("lstat", p, &e))?.file_type(); + let rel = p.strip_prefix(root).unwrap_or(Path::new("")); + if !visit(p, rel, &ft)? || !ft.is_dir() { + return Ok(()); + } + let mut entries: Vec<_> = std::fs::read_dir(p) + .map_err(|e| Error::io("open", p, &e))? + .map(|e| e.map(|e| e.file_name())) + .collect::>() + .map_err(|e| Error::io("readdir", p, &e))?; + entries.sort(); + for e in entries { + walk(root, &p.join(e), visit)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn go_flag_syntax() { + let mut f = Flags::default(); + let pos = parse_interleaved(&mut f, &s(&["a", "-o", "out", "--yes", "b", "-for=1h", "-r=false"])).unwrap(); + assert_eq!(pos, ["a", "b"]); + assert_eq!(f.output.as_deref(), Some("out")); + assert!(f.yes && !f.r); + assert_eq!(f.ttl, Some(3_600_000_000_000)); + let mut f = Flags::default(); + assert_eq!(parse_interleaved(&mut f, &s(&["--", "-x", "y"])).unwrap(), ["-x", "y"]); + assert_eq!(parse_interleaved(&mut f, &s(&["-", "y"])).unwrap(), ["-", "y"]); + for (args, err) in [ + (vec!["-x"], "flag provided but not defined: -x"), + (vec!["-o"], "flag needs an argument: -o"), + (vec!["---o"], "bad flag syntax: ---o"), + (vec!["-=x"], "bad flag syntax: -=x"), + (vec!["-for", "soon"], "invalid value \"soon\" for flag -for: parse error"), + (vec!["-yes=maybe"], "invalid boolean value \"maybe\" for -yes: parse error"), + (vec!["-h"], "flag: help requested"), + ] { + assert_eq!(parse_interleaved(&mut Flags::default(), &s(&args)).err().as_deref(), Some(err)); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index b316d1e..1950799 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,13 +6,18 @@ #![deny(unsafe_op_in_unsafe_fn)] +pub mod absorb; +pub mod agent; +pub mod cli; pub mod crypto; pub mod error; pub mod keyfile; +pub mod net; pub mod pipeline; pub mod platform; pub mod rng; pub mod secret; pub mod store; +#[cfg(test)] +mod testutil; pub mod util; -pub mod net; diff --git a/src/main.rs b/src/main.rs index f328e4d..fc00a8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1 +1,7 @@ -fn main() {} +// SPDX-License-Identifier: GPL-3.0-or-later + +//! vault: a post-quantum encrypted file vault. + +fn main() { + std::process::exit(vault::cli::main()); +} diff --git a/src/platform/fs.rs b/src/platform/fs.rs index d904ec8..7191286 100644 --- a/src/platform/fs.rs +++ b/src/platform/fs.rs @@ -207,12 +207,14 @@ pub struct Dir { impl Dir { /// Opens the chosen destination itself; a symlink here is followed, - /// because the user named it. + /// because the user named it. An empty path is the current directory + /// (and names inside it are shown without a "./"). pub fn open(path: &Path) -> io::Result { + let real = if path.as_os_str().is_empty() { Path::new(".") } else { path }; #[cfg(unix)] { use rustix::fs::{CWD, Mode, OFlags, openat}; - let fd = openat(CWD, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty())?; + let fd = openat(CWD, real, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty())?; Ok(Dir { path: path.to_path_buf(), fd, @@ -220,7 +222,7 @@ impl Dir { } #[cfg(not(unix))] { - if !std::fs::metadata(path)?.is_dir() { + if !std::fs::metadata(real)?.is_dir() { return Err(io::Error::new( io::ErrorKind::NotADirectory, format!("{} is not a directory", path.display()), diff --git a/src/store/mod.rs b/src/store/mod.rs index a05dfca..072a6a2 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -215,9 +215,10 @@ impl Store { let sp = dir.join(&shard.0); for (name, _) in sorted_dir(&sp)? { if let Some(id) = name.to_str().and_then(Id::parse) - && id.shard() == shard.0.to_string_lossy() { - ids.push(id); - } + && id.shard() == shard.0.to_string_lossy() + { + ids.push(id); + } } } let mut cat = Catalogue::default(); @@ -337,10 +338,11 @@ impl Store { let mut remaining = size; for i in 0..object_count(size) { if pending.len() == OBJECT_BUFFERS - && let Err(e) = pending.pop_front().expect("non-empty").wait().map_err(Error::new).and_then(|r| r) { - first_err = Some(e); - break; - } + && let Err(e) = pending.pop_front().expect("non-empty").wait().map_err(Error::new).and_then(|r| r) + { + first_err = Some(e); + break; + } let id = seed.object_id(i); let part = remaining.min(CHUNK_DATA) as usize; remaining -= part as u64; diff --git a/src/store/tests.rs b/src/store/tests.rs index 2d73daa..2d7331d 100644 --- a/src/store/tests.rs +++ b/src/store/tests.rs @@ -2,48 +2,9 @@ use super::*; use crate::crypto::kem::SUITE_ID; +use crate::testutil::{TempDir, walk}; use std::sync::atomic::Ordering; -pub(crate) struct TempDir(pub PathBuf); - -impl TempDir { - pub fn new(tag: &str) -> TempDir { - let r: [u8; 8] = crate::rng::os().array(); - let p = std::env::temp_dir().join(format!("vault-store-{tag}-{}-{}", std::process::id(), hex(&r))); - std::fs::create_dir_all(&p).unwrap(); - TempDir(p) - } -} - -impl Drop for TempDir { - fn drop(&mut self) { - // Read-only test directories must be writable again to delete. - #[cfg(unix)] - for e in walk(&self.0) { - use std::os::unix::fs::PermissionsExt; - if e.is_dir() { - let _ = std::fs::set_permissions(&e, std::fs::Permissions::from_mode(0o700)); - } - } - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn walk(p: &Path) -> Vec { - let mut out = vec![p.to_path_buf()]; - if let Ok(rd) = std::fs::read_dir(p) { - for e in rd.flatten() { - let path = e.path(); - if e.file_type().map(|t| t.is_dir()).unwrap_or(false) { - out.extend(walk(&path)); - } else { - out.push(path); - } - } - } - out -} - fn files(p: &Path) -> Vec { let mut v: Vec<_> = walk(p).into_iter().filter(|p| p.is_file()).collect(); v.sort(); diff --git a/src/testutil.rs b/src/testutil.rs new file mode 100644 index 0000000..a8e2855 --- /dev/null +++ b/src/testutil.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Shared helpers for unit tests. + +use crate::store::hex; +use std::path::{Path, PathBuf}; + +pub(crate) struct TempDir(pub PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> TempDir { + let r: [u8; 8] = crate::rng::os().array(); + let p = std::env::temp_dir().join(format!("vault-store-{tag}-{}-{}", std::process::id(), hex(&r))); + std::fs::create_dir_all(&p).unwrap(); + TempDir(p) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Read-only test directories must be writable again to delete. + #[cfg(unix)] + for e in walk(&self.0) { + use std::os::unix::fs::PermissionsExt; + if e.is_dir() { + let _ = std::fs::set_permissions(&e, std::fs::Permissions::from_mode(0o700)); + } + } + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub fn walk(p: &Path) -> Vec { + let mut out = vec![p.to_path_buf()]; + if let Ok(rd) = std::fs::read_dir(p) { + for e in rd.flatten() { + let path = e.path(); + if e.file_type().map(|t| t.is_dir()).unwrap_or(false) { + out.extend(walk(&path)); + } else { + out.push(path); + } + } + } + out +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..773fabc --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,582 @@ +// 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. + +mod common; + +use std::io::Write; +use std::path::{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 { + let dir = std::env::temp_dir().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")); +} + +#[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. +#[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(20); + 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(); + std::thread::sleep(Duration::from_millis(1500)); + 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()); + assert!(String::from_utf8_lossy(&o.stderr).contains("waiting for another vault process to finish")); +} + +#[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 { + 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::() 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"]); + for _ in 0..4 { + std::thread::sleep(Duration::from_millis(900)); + e.ok(RUST, None, &["list"]); // each use pushes expiry back + } + std::thread::sleep(Duration::from_millis(3500)); + assert!(!e.run(RUST, None, &["list"]).status.success(), "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) + ); +}