use std::path::Path;
use flate2::write::GzEncoder;
use flate2::Compression;
use crate::error::{ComposeError, Result};
pub(super) fn pack_path(
src: &Path,
follow_link: bool,
name_override: Option<&str>,
) -> Result<Vec<u8>> {
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = tar::Builder::new(encoder);
tar.follow_symlinks(follow_link);
if src.is_dir() {
let default = src.file_name().unwrap_or(std::ffi::OsStr::new("."));
let name: &std::ffi::OsStr = name_override.map(std::ffi::OsStr::new).unwrap_or(default);
tar.append_dir_all(name, src)
.map_err(|e| ComposeError::Copy(format!("cp: {e}")))?;
} else {
let default = src.file_name().unwrap_or(std::ffi::OsStr::new("file"));
let name: &std::ffi::OsStr = name_override.map(std::ffi::OsStr::new).unwrap_or(default);
tar.append_path_with_name(src, name)
.map_err(|e| ComposeError::Copy(format!("cp: {e}")))?;
}
let gz = tar
.into_inner()
.map_err(|e| ComposeError::Copy(format!("cp: {e}")))?;
gz.finish()
.map_err(|e| ComposeError::Copy(format!("cp: {e}")))
}
pub(super) fn extract_archive(tar_bytes: &[u8], dst: &Path) -> Result<()> {
if dst.is_dir() {
return extract_tar_guarded(tar_bytes, dst);
}
if !archive_contains_dir(tar_bytes)? {
if has_trailing_separator(dst) {
return Err(ComposeError::Copy(format!(
"cp: no such directory: {}",
dst.display()
)));
}
if let Some(parent) = dst.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(ComposeError::Copy(format!(
"cp: no such directory: {}",
parent.display()
)));
}
}
return write_single_entry_to(tar_bytes, dst);
}
if dst.exists() {
return Err(ComposeError::Copy(format!(
"cp: cannot copy a directory onto the existing file {}",
dst.display()
)));
}
if let Some(parent) = dst.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(ComposeError::Copy(format!(
"cp: no such directory: {}",
parent.display()
)));
}
}
std::fs::create_dir_all(dst).map_err(ComposeError::Io)?;
extract_tar_guarded(tar_bytes, dst)?;
flatten_single_wrapper_dir(dst)
}
fn has_trailing_separator(p: &Path) -> bool {
p.as_os_str()
.to_string_lossy()
.chars()
.last()
.is_some_and(std::path::is_separator)
}
fn archive_contains_dir(tar_bytes: &[u8]) -> Result<bool> {
let cursor = std::io::Cursor::new(tar_bytes);
let mut archive = tar::Archive::new(cursor);
for entry in archive.entries().map_err(ComposeError::Io)? {
let entry = entry.map_err(ComposeError::Io)?;
if entry.header().entry_type() == tar::EntryType::Directory {
return Ok(true);
}
}
Ok(false)
}
fn flatten_single_wrapper_dir(dst: &Path) -> Result<()> {
let mut children: Vec<std::path::PathBuf> = std::fs::read_dir(dst)
.map_err(ComposeError::Io)?
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
if children.len() != 1 || !children[0].is_dir() {
return Ok(());
}
let wrapper = children.remove(0);
for entry in std::fs::read_dir(&wrapper).map_err(ComposeError::Io)? {
let from = entry.map_err(ComposeError::Io)?.path();
let name = from
.file_name()
.ok_or_else(|| ComposeError::Build("cp: archive entry has no name".into()))?;
std::fs::rename(&from, dst.join(name)).map_err(ComposeError::Io)?;
}
std::fs::remove_dir(&wrapper).map_err(ComposeError::Io)?;
Ok(())
}
fn extract_tar_guarded(tar_bytes: &[u8], dst_dir: &Path) -> Result<()> {
let cursor = std::io::Cursor::new(tar_bytes);
let mut archive = tar::Archive::new(cursor);
for entry in archive.entries().map_err(ComposeError::Io)? {
let mut entry = entry.map_err(ComposeError::Io)?;
let rel = entry.path().map(|p| p.into_owned()).ok();
let mode = entry.header().mode().ok();
if !entry.unpack_in(dst_dir).map_err(ComposeError::Io)? {
let p = entry
.path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<unprintable>".into());
return Err(ComposeError::Build(format!(
"cp: refusing archive entry that escapes destination: {p}"
)));
}
if let (Some(rel), Some(mode)) = (rel, mode) {
sanitize_extracted_mode(&dst_dir.join(rel), mode);
}
}
Ok(())
}
fn write_single_entry_to(tar_bytes: &[u8], dst: &Path) -> Result<()> {
use std::io::Read;
let cursor = std::io::Cursor::new(tar_bytes);
let mut archive = tar::Archive::new(cursor);
let mut written = false;
for entry in archive.entries().map_err(ComposeError::Io)? {
let mut entry = entry.map_err(ComposeError::Io)?;
if entry.header().entry_type() != tar::EntryType::Regular {
return Err(ComposeError::Unsupported(format!(
"cp: destination {} is not a directory but the source is not a single file",
dst.display()
)));
}
if written {
return Err(ComposeError::Unsupported(format!(
"cp: destination {} is not a directory but the source has multiple entries",
dst.display()
)));
}
let mode = entry.header().mode().ok();
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(ComposeError::Io)?;
std::fs::write(dst, &buf).map_err(ComposeError::Io)?;
if let Some(mode) = mode {
sanitize_extracted_mode(dst, mode);
}
written = true;
}
if !written {
return Err(ComposeError::Build(
"cp: container archive was empty".into(),
));
}
Ok(())
}
#[cfg(unix)]
fn sanitize_extracted_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::symlink_metadata(path) else {
return;
};
if !meta.is_file() {
return;
}
let masked = mode & 0o7777 & !0o022 & !0o7000;
if let Err(e) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(masked)) {
tracing::warn!("cp: could not set permissions on {}: {e}", path.display());
}
}
#[cfg(not(unix))]
fn sanitize_extracted_mode(_path: &Path, _mode: u32) {}
#[cfg(test)]
mod tests {
#[test]
fn pack_path_single_file() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("data.txt");
std::fs::write(&file, b"hello").expect("write");
let result = super::pack_path(&file, false, None);
assert!(result.is_ok());
let bytes = result.unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn pack_path_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let subdir = dir.path().join("mydir");
std::fs::create_dir(&subdir).expect("mkdir");
std::fs::write(subdir.join("a.txt"), b"aaa").expect("write");
std::fs::write(subdir.join("b.txt"), b"bbb").expect("write");
let result = super::pack_path(&subdir, false, None);
assert!(result.is_ok());
assert!(!result.unwrap().is_empty());
}
#[test]
fn pack_path_missing_source_is_a_cp_error() {
let missing = std::path::Path::new("/nonexistent-host-source-xyz");
let err = super::pack_path(missing, false, None).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cp error"), "wrong category: {msg:?}");
assert!(
!msg.contains("build error"),
"must not be a build error: {msg:?}"
);
}
fn tar_bytes_with(path: &str, data: &[u8]) -> Vec<u8> {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
let name = path.as_bytes();
header.as_gnu_mut().expect("gnu header").name[..name.len()].copy_from_slice(name);
header.set_cksum();
let mut builder = tar::Builder::new(Vec::new());
builder.append(&header, data).expect("append");
builder.into_inner().expect("finish")
}
#[test]
fn extract_tar_guarded_writes_benign_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let bytes = tar_bytes_with("hello.txt", b"hi");
super::extract_tar_guarded(&bytes, dir.path()).expect("extract");
assert_eq!(
std::fs::read(dir.path().join("hello.txt")).expect("read"),
b"hi"
);
}
#[test]
fn extract_archive_to_file_honors_user_filename() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("myname.txt");
let bytes = tar_bytes_with("evil-name", b"payload");
super::extract_archive(&bytes, &dst).expect("extract");
assert_eq!(std::fs::read(&dst).expect("read"), b"payload");
assert!(
!dir.path().join("evil-name").exists(),
"daemon entry name must not be used as the on-host filename"
);
}
#[test]
fn extract_archive_to_file_rejects_multiple_entries() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("out.txt");
let mut builder = tar::Builder::new(Vec::new());
for n in ["a.txt", "b.txt"] {
let mut h = tar::Header::new_gnu();
h.set_size(1);
h.set_mode(0o644);
h.set_entry_type(tar::EntryType::Regular);
h.set_path(n).expect("path");
h.set_cksum();
builder.append(&h, &b"x"[..]).expect("append");
}
let bytes = builder.into_inner().expect("finish");
assert!(super::extract_archive(&bytes, &dst).is_err());
}
fn tar_dir_with(wrapper: &str, files: &[(&str, &[u8])]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let mut d = tar::Header::new_gnu();
d.set_size(0);
d.set_mode(0o755);
d.set_entry_type(tar::EntryType::Directory);
d.set_path(format!("{wrapper}/")).expect("path");
d.set_cksum();
builder.append(&d, std::io::empty()).expect("dir");
for (name, data) in files {
let mut h = tar::Header::new_gnu();
h.set_size(data.len() as u64);
h.set_mode(0o644);
h.set_entry_type(tar::EntryType::Regular);
h.set_path(format!("{wrapper}/{name}")).expect("path");
h.set_cksum();
builder.append(&h, *data).expect("file");
}
builder.into_inner().expect("finish")
}
#[test]
fn extract_dir_into_missing_dest_creates_and_flattens() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("newdir");
let bytes = tar_dir_with("srcdir", &[("a.txt", b"aaa"), ("b.txt", b"bbb")]);
super::extract_archive(&bytes, &dst).expect("extract");
assert!(dst.is_dir());
assert_eq!(std::fs::read(dst.join("a.txt")).expect("read"), b"aaa");
assert_eq!(std::fs::read(dst.join("b.txt")).expect("read"), b"bbb");
assert!(
!dst.join("srcdir").exists(),
"the wrapper directory level must be collapsed"
);
}
#[test]
fn extract_single_file_into_missing_dest_still_writes_exact_name() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("renamed.txt");
let bytes = tar_bytes_with("original.txt", b"data");
super::extract_archive(&bytes, &dst).expect("extract");
assert_eq!(std::fs::read(&dst).expect("read"), b"data");
}
#[cfg(unix)]
#[test]
fn extract_strips_group_other_write_and_special_bits() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let mut h = tar::Header::new_gnu();
h.set_size(2);
h.set_mode(0o4777);
h.set_entry_type(tar::EntryType::Regular);
h.set_path("f").expect("path");
h.set_cksum();
let mut builder = tar::Builder::new(Vec::new());
builder.append(&h, &b"hi"[..]).expect("append");
let bytes = builder.into_inner().expect("finish");
super::extract_tar_guarded(&bytes, dir.path()).expect("extract");
let mode = std::fs::metadata(dir.path().join("f"))
.expect("meta")
.permissions()
.mode() & 0o7777;
assert_eq!(mode & 0o022, 0, "group/other write must be stripped");
assert_eq!(mode & 0o7000, 0, "setuid/setgid/sticky must be stripped");
}
#[test]
fn extract_tar_guarded_rejects_parent_traversal() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("dest");
std::fs::create_dir(&dst).expect("mkdir");
let bytes = tar_bytes_with("../evil.txt", b"pwned");
let err = super::extract_tar_guarded(&bytes, &dst).unwrap_err();
assert!(
format!("{err}").contains("escapes destination"),
"expected a zip-slip refusal, got: {err}"
);
assert!(
!dir.path().join("evil.txt").exists(),
"traversal entry must not be written outside the destination"
);
}
#[test]
fn extract_archive_to_file_rejects_empty_archive() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("out.txt");
let bytes = tar::Builder::new(Vec::new()).into_inner().expect("finish");
let err = super::extract_archive(&bytes, &dst).unwrap_err();
assert!(format!("{err}").contains("empty"), "got: {err}");
}
#[test]
fn extract_archive_dir_source_creates_non_existent_dest() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("out.txt");
let mut h = tar::Header::new_gnu();
h.set_size(0);
h.set_mode(0o755);
h.set_entry_type(tar::EntryType::Directory);
h.set_path("subdir/").expect("path");
h.set_cksum();
let mut builder = tar::Builder::new(Vec::new());
builder.append(&h, std::io::empty()).expect("append");
let bytes = builder.into_inner().expect("finish");
super::extract_archive(&bytes, &dst).expect("extract");
assert!(dst.is_dir());
}
#[test]
fn extract_archive_to_file_errors_on_missing_parent() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("new").join("nested").join("file.txt");
let bytes = tar_bytes_with("ignored-name", b"data");
let err = super::extract_archive(&bytes, &dst).unwrap_err();
assert!(format!("{err}").contains("no such directory"), "got: {err}");
assert!(!dst.exists(), "nothing must be created on a missing parent");
assert!(
!dst.parent().unwrap().exists(),
"the parent chain must not be created"
);
}
#[test]
fn extract_archive_to_trailing_slash_missing_dir_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = std::path::PathBuf::from(format!("{}/newdir/", dir.path().display()));
let bytes = tar_bytes_with("hostname", b"data");
let err = super::extract_archive(&bytes, &dst).unwrap_err();
assert!(format!("{err}").contains("no such directory"), "got: {err}");
assert!(!dst.exists(), "nothing must be created");
}
#[test]
fn extract_archive_dir_source_onto_existing_file_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let dst = dir.path().join("existing");
std::fs::write(&dst, b"keep").expect("write");
let bytes = tar_dir_with("srcdir", &[("a.txt", b"aaa")]);
let err = super::extract_archive(&bytes, &dst).unwrap_err();
assert!(
format!("{err}").contains("cannot copy a directory"),
"got: {err}"
);
assert_eq!(
std::fs::read(&dst).expect("read"),
b"keep",
"the existing file must be left untouched"
);
}
}