Files
vault/src/absorb.rs
T
Andy DixonandClaude a98931779a 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>
2026-10-08 10:36:20 +01:00

273 lines
10 KiB
Rust

// 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);
}
}