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:
+272
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user