use std::fs::File;
use std::io::{self, BufReader, Read};
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
pub fn materialize(archive: &Path, into: &Path) -> io::Result<()> {
let Some(name) = into.file_name() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"{}: not a directory name to materialize into",
into.display()
),
));
};
let staging = into.with_file_name(format!(
".{}.partial-{}",
name.to_string_lossy(),
std::process::id()
));
let _ = std::fs::remove_dir_all(&staging);
let result = unpack(archive, &staging).and_then(|()| {
std::fs::rename(&staging, into).map_err(|e| {
io::Error::new(
e.kind(),
format!("{}: cannot put the sysroot in place: {e}", into.display()),
)
})
});
if result.is_err() {
let _ = std::fs::remove_dir_all(&staging);
}
result
}
fn unpack(archive: &Path, into: &Path) -> io::Result<()> {
let mut file = BufReader::new(File::open(archive)?);
let mut magic = [0u8; 4];
let n = file.read(&mut magic)?;
let reader: Box<dyn Read> = if n == 4 && magic == ZSTD_MAGIC {
let file = File::open(archive)?;
Box::new(zstd::stream::read::Decoder::new(file)?)
} else {
Box::new(BufReader::new(File::open(archive)?))
};
std::fs::create_dir_all(into)?;
let mut deferred: Vec<(PathBuf, u32)> = Vec::new();
let mut tar = tar::Archive::new(reader);
tar.set_preserve_permissions(true);
tar.set_preserve_ownerships(false);
tar.set_unpack_xattrs(false);
for entry in tar.entries()? {
let mut entry = entry?;
let path = entry.path()?.into_owned();
if !is_contained(&path) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: entry escapes the target directory", path.display()),
));
}
let placed = entry
.unpack_in(into)
.map_err(|e| io::Error::new(e.kind(), format!("{}: {}", path.display(), chain(&e))))?;
if !placed {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: entry could not be placed", path.display()),
));
}
let unpacked = into.join(&path);
let Ok(md) = std::fs::symlink_metadata(&unpacked) else {
continue;
};
let mode = md.permissions().mode();
if md.file_type().is_dir() && mode & 0o300 != 0o300 {
std::fs::set_permissions(&unpacked, std::fs::Permissions::from_mode(mode | 0o300))?;
deferred.push((unpacked, mode & 0o7777));
} else if md.file_type().is_file() && mode & 0o6000 != 0 {
let stripped = (mode & 0o777) | 0o400;
std::fs::set_permissions(&unpacked, std::fs::Permissions::from_mode(stripped))?;
}
}
deferred.sort_by(|a, b| b.0.cmp(&a.0));
for (dir, mode) in deferred {
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(mode))?;
}
Ok(())
}
fn chain(e: &dyn std::error::Error) -> String {
let mut parts = vec![e.to_string()];
let mut source = e.source();
while let Some(inner) = source {
parts.push(inner.to_string());
source = inner.source();
}
parts.join(": ")
}
fn is_contained(path: &Path) -> bool {
path.components().all(|c| match c {
Component::Normal(_) | Component::CurDir => true,
Component::ParentDir | Component::RootDir | Component::Prefix(_) => false,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::fixture::temp_root;
fn archive_with(entries: &[(&str, &[u8], u32)], symlinks: &[(&str, &str)]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for (path, data, mode) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(*mode);
header.set_uid(0);
header.set_gid(0);
if path.contains("..") {
let name = &mut header.as_old_mut().name;
name[..path.len()].copy_from_slice(path.as_bytes());
header.set_cksum();
builder.append(&header, *data).unwrap();
} else {
header.set_cksum();
builder.append_data(&mut header, path, *data).unwrap();
}
}
for (path, target) in symlinks {
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_mode(0o777);
header.set_cksum();
builder.append_link(&mut header, path, target).unwrap();
}
builder.into_inner().unwrap()
}
#[test]
fn a_setuid_only_binary_is_left_readable() {
let root = temp_root("tarsuid");
let bytes = archive_with(
&[("usr/lib/dbus-daemon-launch-helper", b"elf", 0o4110)],
&[],
);
let archive = root.join("root.tar");
std::fs::write(&archive, &bytes).unwrap();
let into = root.join("sysroot");
materialize(&archive, &into).unwrap();
let helper = into.join("usr/lib/dbus-daemon-launch-helper");
assert_eq!(std::fs::read(&helper).unwrap(), b"elf");
let mode = std::fs::metadata(&helper).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o510, "setuid dropped, owner read added");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_read_only_directory_still_receives_its_contents() {
let root = temp_root("tarro");
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
header.set_mode(0o555);
header.set_cksum();
builder
.append_data(&mut header, "etc/certs/", &[][..])
.unwrap();
let mut header = tar::Header::new_gnu();
header.set_size(3);
header.set_mode(0o444);
header.set_cksum();
builder
.append_data(&mut header, "etc/certs/ca.pem", &b"pem"[..])
.unwrap();
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_mode(0o777);
header.set_cksum();
builder
.append_link(&mut header, "etc/certs/0a1b2c3d.0", "ca.pem")
.unwrap();
let archive = root.join("root.tar");
std::fs::write(&archive, builder.into_inner().unwrap()).unwrap();
let into = root.join("sysroot");
materialize(&archive, &into).unwrap();
assert_eq!(
std::fs::read(into.join("etc/certs/ca.pem")).unwrap(),
b"pem"
);
assert!(into.join("etc/certs/0a1b2c3d.0").is_symlink());
let mode = std::fs::metadata(into.join("etc/certs"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o555, "the archived mode is restored");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn unpacks_files_modes_and_symlinks_without_ownership() {
let root = temp_root("tar");
let bytes = archive_with(
&[
("usr/bin/app", b"#!/bin/sh\n", 0o4755),
("usr/lib/libfoo.so.1", b"elf", 0o644),
],
&[("usr/lib/libfoo.so", "libfoo.so.1")],
);
let archive = root.join("root.tar");
std::fs::write(&archive, &bytes).unwrap();
let into = root.join("sysroot");
materialize(&archive, &into).unwrap();
assert_eq!(
std::fs::read(into.join("usr/bin/app")).unwrap(),
b"#!/bin/sh\n"
);
let mode = std::fs::metadata(into.join("usr/bin/app"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(mode, 0o755, "setuid is dropped, the rest is kept");
assert_eq!(
std::fs::read_link(into.join("usr/lib/libfoo.so")).unwrap(),
Path::new("libfoo.so.1")
);
let zst = root.join("root.tar.zst");
std::fs::write(&zst, zstd::encode_all(&bytes[..], 3).unwrap()).unwrap();
let into = root.join("sysroot2");
materialize(&zst, &into).unwrap();
assert!(into.join("usr/lib/libfoo.so.1").is_file());
}
#[test]
fn a_traversing_entry_is_refused_before_anything_is_written() {
let root = temp_root("tarescape");
let bytes = archive_with(&[("../escape", b"x", 0o644)], &[]);
let archive = root.join("bad.tar");
std::fs::write(&archive, &bytes).unwrap();
let into = root.join("sysroot");
let err = materialize(&archive, &into).unwrap_err();
assert!(err.to_string().contains("escape"), "{err}");
assert!(!root.join("escape").exists());
}
#[test]
fn a_failed_archive_leaves_nothing_at_the_target() {
let root = temp_root("tarpartial");
let bytes = archive_with(
&[("usr/bin/app", b"elf", 0o755), ("../escape", b"x", 0o644)],
&[],
);
let archive = root.join("bad.tar");
std::fs::write(&archive, &bytes).unwrap();
let into = root.join("sysroot");
assert!(materialize(&archive, &into).is_err());
assert!(!into.exists(), "nothing is left at the target");
let left: Vec<_> = std::fs::read_dir(&*root)
.unwrap()
.flatten()
.map(|e| e.file_name())
.collect();
assert_eq!(left, ["bad.tar"], "the staging directory is gone");
}
}