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 <noreply@anthropic.com>
This commit is contained in:
Andy Dixon
2026-10-08 10:36:20 +01:00
co-authored by Claude
parent aedc605a82
commit a98931779a
11 changed files with 2284 additions and 52 deletions
+1
View File
@@ -6,3 +6,4 @@
/docs/guide/version.tex
/docs/guide/vault-guide.pdf
/docs/guide/*.log
/target
+272
View File
@@ -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<dyn Read> = 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<String> {
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<R: Read>(entry: &mut tar::Entry<R>) -> Result<Instant> {
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<Instant> {
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);
}
}
+391
View File
@@ -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<PathBuf> {
#[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<PathBuf> {
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<PathBuf> {
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<UnixStream> {
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<Identity> {
let mut c = dial(key, GET)?;
let mut buf = crate::secret::SecretArray::<REPLY>::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<std::process::Command> {
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::<REPLY>::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<Identity> {
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"));
}
}
+964
View File
@@ -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<String>,
vault: Option<String>,
output: Option<String>,
filler: Option<String>,
yes: bool,
ttl: Option<i64>,
idle: bool,
r: bool,
f: bool,
rf: bool,
fr: bool,
workers: Option<String>,
max_bytes: Option<String>,
timeout: Option<i64>,
max_pages: Option<String>,
}
enum FlagKind {
Str,
Bool,
Dur,
}
fn flag_kind(name: &str) -> Option<FlagKind> {
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<bool> {
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<Vec<String>, 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<Vec<String>, 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<Vec<u8>> {
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<SecretVec> {
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<SecretVec> {
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<SecretVec> {
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<PathBuf> {
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<String>, env: &str, name: &str) -> Result<PathBuf> {
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<crate::crypto::kem::Identity> {
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<Arc<Pool>>,
}
impl Ctx {
fn key(&self) -> Result<PathBuf> {
default_path(&self.flags.key, "VAULT_KEY", "key")
}
fn vault_dir(&self) -> Result<PathBuf> {
default_path(&self.flags.vault, "VAULT_DIR", "store")
}
fn pool(&mut self) -> Result<Arc<Pool>> {
if self.pool.is_none() {
let n = match &self.flags.workers {
None => Pool::default_workers(),
Some(w) => match w.parse::<usize>() {
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<crate::net::Limits> {
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<OsString> = 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<i32> {
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<FileId> = [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<String> = 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::<pfs::Dir>;
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<Vec<(String, VaultName)>> {
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<Vec<String>> {
let mut entries: std::collections::BTreeMap<String, (u64, Option<util::Instant>)> = 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<FileId> {
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<String> {
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<String> = 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<bool>) -> 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::<std::io::Result<_>>()
.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<String> {
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));
}
}
}
+6 -1
View File
@@ -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;
+7 -1
View File
@@ -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());
}
+5 -3
View File
@@ -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<Dir> {
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()),
+9 -7
View File
@@ -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;
+1 -40
View File
@@ -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<PathBuf> {
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<PathBuf> {
let mut v: Vec<_> = walk(p).into_iter().filter(|p| p.is_file()).collect();
v.sort();
+46
View File
@@ -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<PathBuf> {
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
}
+582
View File
@@ -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<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"));
}
#[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<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"]);
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)
);
}