use std::io;
use std::path::{Path, PathBuf};
use crate::error::FetcherError;
pub(crate) async fn extract(
archive_path: &Path,
dest_dir: &Path,
expected_top_prefix: Option<&str>,
) -> Result<(), FetcherError> {
let archive_path = archive_path.to_path_buf();
let dest_dir = dest_dir.to_path_buf();
let expected_top_prefix = expected_top_prefix.map(str::to_owned);
tokio::task::spawn_blocking(move || {
extract_blocking(&archive_path, &dest_dir, expected_top_prefix.as_deref())
})
.await
.map_err(|e| FetcherError::Extraction(format!("join error: {e}")))?
}
fn extract_blocking(
archive_path: &Path,
dest_dir: &Path,
expected_top_prefix: Option<&str>,
) -> Result<(), FetcherError> {
let file = std::fs::File::open(archive_path)?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| FetcherError::Extraction(e.to_string()))?;
let dest_canonical = std::fs::canonicalize(dest_dir)
.map_err(|e| FetcherError::Extraction(format!("dest_dir canonicalize: {e}")))?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| FetcherError::Extraction(e.to_string()))?;
let Some(rel_path) = entry.enclosed_name() else {
return Err(FetcherError::Extraction(format!(
"zip entry has unsafe path: {}",
entry.name()
)));
};
if let Some(prefix) = expected_top_prefix {
let top = rel_path.components().next().and_then(|c| match c {
std::path::Component::Normal(s) => s.to_str(),
_ => None,
});
if top != Some(prefix) {
return Err(FetcherError::Extraction(format!(
"zip entry {:?} not under expected top-level {:?}",
rel_path, prefix
)));
}
}
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
const S_IFMT: u32 = 0o170_000;
const S_IFLNK: u32 = 0o120_000;
if mode & S_IFMT == S_IFLNK {
return Err(FetcherError::Extraction(format!(
"zip entry {:?} is a symlink; refusing for safety",
rel_path
)));
}
}
let out_path: PathBuf = dest_dir.join(&rel_path);
if entry.is_dir() {
std::fs::create_dir_all(&out_path)?;
assert_under_dest(&out_path, &dest_canonical)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut probe_path = out_path.clone();
while !probe_path.exists() {
if !probe_path.pop() {
break;
}
}
assert_under_dest(&probe_path, &dest_canonical)?;
let mut out_file = std::fs::File::create(&out_path)?;
io::copy(&mut entry, &mut out_file)?;
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
Ok(())
}
fn assert_under_dest(path: &Path, dest_canonical: &Path) -> Result<(), FetcherError> {
let resolved = std::fs::canonicalize(path)
.map_err(|e| FetcherError::Extraction(format!("path canonicalize: {e}")))?;
if !resolved.starts_with(dest_canonical) {
return Err(FetcherError::Extraction(format!(
"zip entry resolves to {:?} which is outside dest_dir {:?}",
resolved, dest_canonical
)));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use std::io::Write as _;
#[tokio::test]
async fn extract_recovers_single_file_contents() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("test.zip");
let dest_dir = dir.path().join("out");
std::fs::create_dir_all(&dest_dir).unwrap();
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut writer = zip::ZipWriter::new(&mut buf);
writer
.start_file("test.txt", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"hello world").unwrap();
writer.finish().unwrap();
}
std::fs::write(&zip_path, buf.into_inner()).unwrap();
extract(&zip_path, &dest_dir, None).await.unwrap();
let extracted = std::fs::read(dest_dir.join("test.txt")).unwrap();
assert_eq!(extracted, b"hello world");
}
#[tokio::test]
async fn extract_rejects_entries_outside_expected_top_prefix() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("badroot.zip");
let dest_dir = dir.path().join("out");
std::fs::create_dir_all(&dest_dir).unwrap();
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut writer = zip::ZipWriter::new(&mut buf);
writer
.start_file(
"not-chrome/chrome",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
writer.write_all(b"x").unwrap();
writer.finish().unwrap();
}
std::fs::write(&zip_path, buf.into_inner()).unwrap();
let err = extract(&zip_path, &dest_dir, Some("chrome-linux64"))
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("expected top-level"),
"unexpected error: {msg}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn extract_rejects_symlink_entries() {
use zip::write::SimpleFileOptions;
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("symlink.zip");
let dest_dir = dir.path().join("out");
std::fs::create_dir_all(&dest_dir).unwrap();
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut writer = zip::ZipWriter::new(&mut buf);
let opts = SimpleFileOptions::default().unix_permissions(0o777);
writer
.add_symlink("chrome-linux64/link", "../escape", opts)
.unwrap();
writer.finish().unwrap();
}
std::fs::write(&zip_path, buf.into_inner()).unwrap();
let err = extract(&zip_path, &dest_dir, Some("chrome-linux64"))
.await
.unwrap_err();
assert!(
err.to_string().contains("symlink"),
"unexpected error: {err}"
);
}
}