use std::path::{Component, Path};
#[cfg_attr(test, mockall::automock)]
pub trait ArtefactExtractor {
fn extract(&self, archive_path: &Path, dest_dir: &Path)
-> Result<Vec<String>, ExtractionError>;
}
#[derive(Debug, thiserror::Error)]
pub enum ExtractionError {
#[error("extraction I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("path traversal detected: {path}")]
PathTraversal {
path: String,
},
#[error("archive contains no library files")]
EmptyArchive,
}
pub struct ZstdExtractor;
impl ArtefactExtractor for ZstdExtractor {
fn extract(
&self,
archive_path: &Path,
dest_dir: &Path,
) -> Result<Vec<String>, ExtractionError> {
let extracted = collect_entry_filenames(archive_path)?;
if extracted.is_empty() {
return Err(ExtractionError::EmptyArchive);
}
let file = std::fs::File::open(archive_path)?;
let decoder = zstd::Decoder::new(file)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest_dir)?;
Ok(extracted)
}
}
fn collect_entry_filenames(archive_path: &Path) -> Result<Vec<String>, ExtractionError> {
let file = std::fs::File::open(archive_path)?;
let decoder = zstd::Decoder::new(file)?;
let mut archive = tar::Archive::new(decoder);
let mut extracted = Vec::new();
for entry_result in archive.entries()? {
let entry = entry_result?;
let entry_path = entry.path()?.into_owned();
validate_entry_path(&entry_path)?;
if let Some(name) = entry_path.file_name() {
extracted.push(name.to_string_lossy().into_owned());
}
}
Ok(extracted)
}
fn validate_entry_path(path: &Path) -> Result<(), ExtractionError> {
for component in path.components() {
if matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
) {
return Err(ExtractionError::PathTraversal {
path: path.display().to_string(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::path::PathBuf;
#[test]
fn extract_real_archive() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let archive_path = temp_dir.path().join("test.tar.zst");
let dest_dir = temp_dir.path().join("out");
std::fs::create_dir_all(&dest_dir).expect("create dest");
let source_file = temp_dir.path().join("hello.txt");
std::fs::write(&source_file, b"hello world").expect("write source");
let output_file = std::fs::File::create(&archive_path).expect("create archive");
let encoder = zstd::Encoder::new(output_file, 0).expect("zstd encoder");
let mut builder = tar::Builder::new(encoder);
builder
.append_path_with_name(&source_file, "hello.txt")
.expect("append");
let encoder = builder.into_inner().expect("tar finish");
encoder.finish().expect("zstd finish");
let extractor = ZstdExtractor;
let files = extractor
.extract(&archive_path, &dest_dir)
.expect("extract");
assert_eq!(files, vec!["hello.txt"]);
assert!(dest_dir.join("hello.txt").exists());
}
#[rstest]
#[case::parent_dir("../escape.txt")]
#[case::nested_parent("foo/../../escape.txt")]
fn rejects_path_traversal(#[case] bad_path: &str) {
let path = PathBuf::from(bad_path);
let result = validate_entry_path(&path);
assert!(
matches!(result, Err(ExtractionError::PathTraversal { .. })),
"expected PathTraversal for {bad_path}"
);
}
#[test]
fn accepts_normal_paths() {
let path = PathBuf::from("lib/libfoo.so");
assert!(validate_entry_path(&path).is_ok());
}
#[test]
fn rejects_absolute_path() {
let path = PathBuf::from("/etc/passwd");
let result = validate_entry_path(&path);
assert!(matches!(result, Err(ExtractionError::PathTraversal { .. })));
}
#[test]
fn extract_empty_archive() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let archive_path = temp_dir.path().join("empty.tar.zst");
let dest_dir = temp_dir.path().join("out");
std::fs::create_dir_all(&dest_dir).expect("create dest");
let output_file = std::fs::File::create(&archive_path).expect("create");
let encoder = zstd::Encoder::new(output_file, 0).expect("zstd");
let builder = tar::Builder::new(encoder);
let encoder = builder.into_inner().expect("tar finish");
encoder.finish().expect("zstd finish");
let extractor = ZstdExtractor;
let result = extractor.extract(&archive_path, &dest_dir);
assert!(matches!(result, Err(ExtractionError::EmptyArchive)));
}
}