// SPDX-License-Identifier: GPL-3.0-or-later //! `vault absorb`: add every regular file of a tar archive (optionally //! gzipped, possibly in several gzip members) read from a stream, each //! streamed straight into the vault so no plaintext touches the disk. //! Other entry types, and names that would escape the destination folder, //! are reported and skipped, as in the Go version. use crate::error::{Error, Kind, Result}; use crate::store::{Catalogue, Store, VaultName}; use crate::util::{Instant, clean, join}; use std::io::{BufRead, Read}; pub fn absorb( store: &Store, cat: &mut Catalogue, mut input: impl BufRead, dest: &str, report: &mut dyn FnMut(&VaultName, bool), ) -> Result<()> { let gz = input .fill_buf() .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))? .starts_with(&[0x1f, 0x8b]); let r: Box = if gz { Box::new(flate2::bufread::MultiGzDecoder::new(input)) } else { Box::new(input) }; let mut archive = tar::Archive::new(r); let entries = archive .entries() .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; let mut n = 0u64; for entry in entries { let mut entry = entry.map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; let raw = entry.path_bytes().into_owned(); let shown = String::from_utf8_lossy(&raw).into_owned(); let kind = entry.header().entry_type(); if !kind.is_file() { if !kind.is_dir() { eprintln!("skipping {shown}: not a regular file"); } continue; } let Ok(text) = std::str::from_utf8(&raw) else { crate::bail!("invalid vault path {shown:?}"); }; let Some(rel) = safe_name(text) else { eprintln!("skipping {shown}: unsafe name"); continue; }; let name = join(&[dest, &rel]); let name = VaultName::new(&name).ok_or_else(|| Error::new(format!("invalid vault path {name:?}")))?; let size = entry .header() .size() .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; let mode = entry.header().mode().unwrap_or(0) & 0o777; let mtime = mtime(&mut entry)?; match store.add_reader(cat, &mut entry, size, mode, mtime, &name) { Ok(a) => report(&name, a.replaced), Err(e) if e.kind == Kind::SourceShort => crate::bail!("{shown}: archive ends early"), Err(e) => return Err(e), } n += 1; } if n == 0 { crate::bail!("no files in archive"); } Ok(()) } /// Go's check: the name, cleaned as if rooted, must equal the name with a /// leading "./" or "/" removed and cleaned, and contain no backslash. pub fn safe_name(name: &str) -> Option { let rooted = clean(&format!("/{name}")); let n = rooted.strip_prefix('/').unwrap_or(&rooted).to_string(); let plain = name.strip_prefix("./").unwrap_or(name); let plain = plain.strip_prefix('/').unwrap_or(plain); if name.contains('\\') || n.is_empty() || n != clean(plain) { return None; } Some(n) } /// The entry's modification time: the PAX `mtime` (with its fraction) if /// present, else the header's whole seconds. fn mtime(entry: &mut tar::Entry) -> Result { if let Ok(Some(exts)) = entry.pax_extensions() { for ext in exts.flatten() { if ext.key_bytes() == b"mtime" && let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time) { return Ok(t); } } } let secs = entry .header() .mtime() .map_err(|e| Error::new(format!("reading archive: {}", crate::util::io_reason(&e))))?; Ok(Instant { secs: secs.min(i64::MAX as u64) as i64, nanos: 0, }) } /// A PAX time: optional sign, decimal seconds, optional fraction. fn parse_pax_time(s: &str) -> Option { let (neg, s) = match s.strip_prefix('-') { Some(r) => (true, r), None => (false, s), }; let (whole, frac) = s.split_once('.').unwrap_or((s, "")); if whole.is_empty() || !whole.bytes().all(|c| c.is_ascii_digit()) || !frac.bytes().all(|c| c.is_ascii_digit()) { return None; } let secs: i64 = whole.parse().ok()?; let mut nanos: u32 = format!("{:0<9}", &frac[..frac.len().min(9)]).parse().ok()?; let mut secs = if neg { -secs } else { secs }; if neg && nanos > 0 { secs -= 1; nanos = 1_000_000_000 - nanos; } Some(Instant { secs, nanos }) } #[cfg(test)] mod tests { use super::*; use crate::crypto::kem::{Identity, SUITE_ID}; use crate::pipeline::Pool; use crate::testutil::TempDir; use std::sync::Arc; fn store(tag: &str) -> (TempDir, Store) { let d = TempDir::new(tag); let root = d.0.join("v"); Store::init(&root, 0, Arc::new(Pool::new(2))).unwrap(); let s = Store::open( &root, Identity::from_suite_seed(SUITE_ID, &[1; 32]).unwrap(), Arc::new(Pool::new(2)), ) .unwrap(); (d, s) } fn header(name: &str, kind: tar::EntryType, size: u64, mode: u32, mtime: u64) -> tar::Header { let mut h = tar::Header::new_gnu(); h.set_entry_type(kind); h.set_size(size); h.set_mode(mode); h.set_mtime(mtime); // Raw bytes, so unsafe names reach the reader unaltered. let n = name.as_bytes(); h.as_old_mut().name[..n.len()].copy_from_slice(n); h.set_cksum(); h } #[test] fn absorbs_regular_files_only() { let (d, s) = store("absorb"); let mut tw = tar::Builder::new(Vec::new()); for (name, kind, body) in [ ("./d/", tar::EntryType::Directory, &b""[..]), ("./d/a.txt", tar::EntryType::Regular, b"hello"), ("../escape", tar::EntryType::Regular, b"nope!"), ("/abs", tar::EntryType::Regular, b"abs"), ("a\\b", tar::EntryType::Regular, b"bs"), ("./d/link", tar::EntryType::Symlink, b""), ] { let h = header(name, kind, body.len() as u64, 0o600, 1577934245); tw.append(&h, body).unwrap(); } let tarball = tw.into_inner().unwrap(); let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); std::io::Write::write_all(&mut gz, &tarball).unwrap(); let gz = gz.finish().unwrap(); let mut cat = s.catalogue().unwrap(); let mut added = Vec::new(); absorb(&s, &mut cat, &gz[..], "in", &mut |n, _| added.push(n.to_string())).unwrap(); assert_eq!(added, ["in/d/a.txt", "in/abs"]); let r = cat.current("in/d/a.txt").unwrap(); assert_eq!( (r.mode, r.modified), ( 0o600, Instant { secs: 1577934245, nanos: 0 } ) ); let dir = crate::platform::fs::Dir::open(&d.0).unwrap(); s.extract(r, &dir, std::ffi::OsStr::new("out")).unwrap(); assert_eq!(std::fs::read(d.0.join("out")).unwrap(), b"hello"); // Plain tar works too, and an archive with nothing to add fails. absorb(&s, &mut cat, &tarball[..], "", &mut |_, _| {}).unwrap(); let mut empty = tar::Builder::new(Vec::new()); empty .append(&header("dir/", tar::EntryType::Directory, 0, 0o755, 0), &b""[..]) .unwrap(); let e = absorb(&s, &mut cat, &empty.into_inner().unwrap()[..], "", &mut |_, _| {}).unwrap_err(); assert_eq!(e.msg, "no files in archive"); } #[test] fn truncated_and_malicious_archives() { let (_d, s) = store("absorb-bad"); let mut tw = tar::Builder::new(Vec::new()); tw.append(&header("big", tar::EntryType::Regular, 100_000, 0o600, 0), &vec![7u8; 100_000][..]) .unwrap(); let t = tw.into_inner().unwrap(); let before = s.catalogue().unwrap().len(); let mut cat = s.catalogue().unwrap(); let e = absorb(&s, &mut cat, &t[..60_000], "", &mut |_, _| {}).unwrap_err(); assert!(e.msg.contains("archive ends early") || e.msg.starts_with("reading archive"), "{e}"); assert_eq!(s.catalogue().unwrap().len(), before, "a truncated entry stored something"); for junk in [ &b"not a tar archive at all, but long enough to fill a header block"[..], &[0x1f, 0x8b, 0, 0][..], &[0u8; 1024][..], ] { assert!(absorb(&s, &mut cat, junk, "", &mut |_, _| {}).is_err()); } // A corrupted gzip stream fails rather than storing a partial file. let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); std::io::Write::write_all(&mut gz, &t).unwrap(); let mut g = gz.finish().unwrap(); let mid = g.len() / 2; g[mid] ^= 0xff; assert!(absorb(&s, &mut cat, &g[..], "", &mut |_, _| {}).is_err()); assert_eq!(s.catalogue().unwrap().len(), before); } #[test] fn names_and_times() { // As in Go, only names that would climb out are refused; others are // cleaned. for (n, want) in [ ("./a/b", Some("a/b")), ("a/../b", Some("b")), ("../x", None), ("a/../../x", None), ("/x", Some("x")), ("a//b", Some("a/b")), ("a\\b", None), ("", None), (".", None), ("a/", Some("a")), ] { assert_eq!(safe_name(n).as_deref(), want, "{n:?}"); } assert_eq!( parse_pax_time("1577934245.5"), Some(Instant { secs: 1577934245, nanos: 500_000_000 }) ); assert_eq!( parse_pax_time("-1.25"), Some(Instant { secs: -2, nanos: 750_000_000 }) ); assert_eq!(parse_pax_time("x"), None); } }