use std::{
env,
io::{Read, Seek},
path::{Component, Path, PathBuf},
};
use thiserror::Error;
use zip::ZipArchive;
pub const DEFAULT_MAX_ARTIFACT_DECOMPRESSED_BYTES: u64 = 500 * 1024 * 1024;
pub const MAX_ARTIFACT_DECOMPRESSED_BYTES_ENV: &str = "MURMUR_MAX_ARTIFACT_DECOMPRESSED_BYTES";
#[must_use]
pub fn max_artifact_decompressed_bytes() -> u64 {
env::var(MAX_ARTIFACT_DECOMPRESSED_BYTES_ENV)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(DEFAULT_MAX_ARTIFACT_DECOMPRESSED_BYTES)
}
#[derive(Debug, Error)]
pub enum ZipGuardError {
#[error("zip entry '{name}' has an unsafe path: {reason}")]
UnsafeEntryPath { name: String, reason: String },
#[error("zip entry '{name}' resolves outside the target directory '{base}'")]
PathEscapesTarget { name: String, base: String },
#[error("zip entry '{name}' exceeds the {limit}-byte decompression ceiling")]
DecompressionCeilingExceeded { name: String, limit: u64 },
#[error("zip entry '{name}' is not valid UTF-8")]
InvalidUtf8 { name: String },
#[error("zip archive error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("io error reading zip entry: {0}")]
Io(#[from] std::io::Error),
}
pub fn sanitize_entry_path(raw_name: &str) -> Result<PathBuf, ZipGuardError> {
let stripped = raw_name.trim_start_matches('/');
let path = Path::new(stripped);
for component in path.components() {
match component {
Component::ParentDir => {
return Err(ZipGuardError::UnsafeEntryPath {
name: raw_name.to_string(),
reason: "contains a '..' component".to_string(),
});
}
Component::Prefix(_) | Component::RootDir => {
return Err(ZipGuardError::UnsafeEntryPath {
name: raw_name.to_string(),
reason: "contains an absolute path component".to_string(),
});
}
Component::CurDir | Component::Normal(_) => {}
}
}
Ok(path.to_path_buf())
}
pub fn resolve_within(base: &Path, raw_name: &str) -> Result<PathBuf, ZipGuardError> {
let relative = sanitize_entry_path(raw_name)?;
let joined = base.join(&relative);
if !joined.starts_with(base) {
return Err(ZipGuardError::PathEscapesTarget {
name: raw_name.to_string(),
base: base.display().to_string(),
});
}
Ok(joined)
}
pub fn read_zip_entry_capped<R: Read + Seek>(
archive: &mut ZipArchive<R>,
entry_name: &str,
max_bytes: u64,
) -> Result<Vec<u8>, ZipGuardError> {
let file = archive.by_name(entry_name)?;
read_capped(file, entry_name, max_bytes)
}
pub fn read_zip_entry_to_string_capped<R: Read + Seek>(
archive: &mut ZipArchive<R>,
entry_name: &str,
max_bytes: u64,
) -> Result<String, ZipGuardError> {
let bytes = read_zip_entry_capped(archive, entry_name, max_bytes)?;
String::from_utf8(bytes).map_err(|_| ZipGuardError::InvalidUtf8 {
name: entry_name.to_string(),
})
}
fn read_capped<R: Read>(
mut reader: R,
entry_name: &str,
max_bytes: u64,
) -> Result<Vec<u8>, ZipGuardError> {
let mut buf = Vec::new();
let mut limited = (&mut reader).take(max_bytes.saturating_add(1));
limited.read_to_end(&mut buf)?;
if buf.len() as u64 > max_bytes {
return Err(ZipGuardError::DecompressionCeilingExceeded {
name: entry_name.to_string(),
limit: max_bytes,
});
}
Ok(buf)
}
#[cfg(test)]
pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use std::io::Write;
use zip::write::SimpleFileOptions;
use super::*;
fn archive_with_files(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
{
let mut zip = zip::ZipWriter::new(&mut cursor);
let options = SimpleFileOptions::default();
for (name, bytes) in files {
zip.start_file(*name, options).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
}
cursor.into_inner()
}
#[test]
fn sanitize_entry_path_strips_leading_slash() {
let path = sanitize_entry_path("/capsule.wasm").unwrap();
assert_eq!(path, Path::new("capsule.wasm"));
}
#[test]
fn sanitize_entry_path_rejects_parent_dir_component() {
let err = sanitize_entry_path("../../evil.wasm").unwrap_err();
assert!(matches!(err, ZipGuardError::UnsafeEntryPath { .. }));
}
#[test]
fn sanitize_entry_path_rejects_embedded_parent_dir_component() {
let err = sanitize_entry_path("sub/../../evil.wasm").unwrap_err();
assert!(matches!(err, ZipGuardError::UnsafeEntryPath { .. }));
}
#[test]
fn sanitize_entry_path_allows_nested_normal_path() {
let path = sanitize_entry_path("tools/echo/murmur.yaml").unwrap();
assert_eq!(path, Path::new("tools/echo/murmur.yaml"));
}
#[test]
fn resolve_within_stays_inside_base() {
let base = Path::new("/tmp/workdir/tools/echo");
let resolved = resolve_within(base, "murmur.yaml").unwrap();
assert_eq!(resolved, base.join("murmur.yaml"));
}
#[test]
fn resolve_within_rejects_traversal() {
let base = Path::new("/tmp/workdir/tools/echo");
let err = resolve_within(base, "../../../etc/passwd").unwrap_err();
assert!(matches!(err, ZipGuardError::UnsafeEntryPath { .. }));
}
#[test]
fn read_zip_entry_capped_reads_small_entry() {
let bytes = archive_with_files(&[("murmur.yaml", b"name: demo\n")]);
let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let content = read_zip_entry_capped(&mut archive, "murmur.yaml", 1024).unwrap();
assert_eq!(content, b"name: demo\n");
}
#[test]
fn read_zip_entry_capped_rejects_oversized_entry() {
let big = vec![b'a'; 2048];
let bytes = archive_with_files(&[("murmur.yaml", &big)]);
let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let err = read_zip_entry_capped(&mut archive, "murmur.yaml", 1024).unwrap_err();
assert!(matches!(
err,
ZipGuardError::DecompressionCeilingExceeded { limit: 1024, .. }
));
}
#[test]
fn read_zip_entry_to_string_capped_reads_text() {
let bytes = archive_with_files(&[("murmur.yaml", b"name: demo\nversion: 0.1.0\n")]);
let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let content = read_zip_entry_to_string_capped(&mut archive, "murmur.yaml", 1024).unwrap();
assert!(content.contains("name: demo"));
}
#[test]
fn max_artifact_decompressed_bytes_defaults_without_env() {
let _guard = ENV_TEST_LOCK.lock().unwrap();
env::remove_var(MAX_ARTIFACT_DECOMPRESSED_BYTES_ENV);
assert_eq!(
max_artifact_decompressed_bytes(),
DEFAULT_MAX_ARTIFACT_DECOMPRESSED_BYTES
);
}
#[test]
fn max_artifact_decompressed_bytes_honors_env_override() {
let _guard = ENV_TEST_LOCK.lock().unwrap();
env::set_var(MAX_ARTIFACT_DECOMPRESSED_BYTES_ENV, "12345");
let result = max_artifact_decompressed_bytes();
env::remove_var(MAX_ARTIFACT_DECOMPRESSED_BYTES_ENV);
assert_eq!(result, 12345);
}
}