From 3f06db2352fe57a89b9c53a0765b7cc43e9cb244 Mon Sep 17 00:00:00 2001 From: forkwright Date: Fri, 3 Jul 2026 09:15:14 -0500 Subject: [PATCH] fix(ergasia): validate archive entries and enforce extraction byte caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden archive extraction against arbitrary file write and decompression bombs on attacker-controlled torrent/usenet payloads. - RAR path traversal: extract_rar now validates every entry across the whole volume set before any write, rejecting absolute paths, parent traversal, and symlink/non-regular entries (unix mode in file_attr, since unrar 0.5.8 does not surface the RAR5 FSREDIR redirect kind). - 7z path traversal: extract_7z replaces decompress_file with the custom decompress_file_with_extract_fn, pre-scanning + per-entry validating names and rejecting symlink/reparse/non-regular entries. - RAR bomb denominator: volume_set_size derives the compressed size from this archive's actual naming chain, signature-verifying each volume so junk-extension padding cannot inflate the ratio denominator. - Nested-archive symlink follow: find_nested_archives uses non-following DirEntry::file_type() instead of Path::is_file/is_dir. - Header-trust bomb: real extraction output is capped at declared×ratio (streaming Take for 7z, post-hoc rollback for RAR whose writes unrar controls internally). Gate-Passed: kanon 0.1.5 +stages:fmt,check,clippy,nextest,lint sha:f04b04e6ff392177efc91a320ae30da2e0ccdfd1 --- crates/ergasia/src/extract/pipeline.rs | 112 +++++++- crates/ergasia/src/extract/rar.rs | 269 +++++++++++++++++-- crates/ergasia/src/extract/seven_zip.rs | 341 +++++++++++++++++++++++- 3 files changed, 681 insertions(+), 41 deletions(-) diff --git a/crates/ergasia/src/extract/pipeline.rs b/crates/ergasia/src/extract/pipeline.rs index c1d7a1e4..0a7a0864 100644 --- a/crates/ergasia/src/extract/pipeline.rs +++ b/crates/ergasia/src/extract/pipeline.rs @@ -88,7 +88,12 @@ fn extract_archives_blocking( let mut all_files = Vec::new(); for (archive_path, format) in &archives { - let files = extract_single(archive_path, output_dir, *format)?; + let files = extract_single( + archive_path, + output_dir, + *format, + limits.max_decompression_ratio, + )?; all_files.extend(files); } @@ -141,20 +146,65 @@ fn extract_single( archive_path: &Path, output_dir: &Path, format: ArchiveFormat, + max_ratio: f64, ) -> Result, ErgasiaError> { let before = fs_walk::snapshot_paths(output_dir); match format { ArchiveFormat::Rar => extract_rar(archive_path, output_dir)?, ArchiveFormat::Zip => extract_zip(archive_path, output_dir)?, - ArchiveFormat::SevenZip => extract_7z(archive_path, output_dir)?, + ArchiveFormat::SevenZip => extract_7z(archive_path, output_dir, max_ratio)?, } let mut files = Vec::new(); fs_walk::collect_files_excluding(output_dir, &before, &mut files); + + // WHY: unrar controls its own writes, so RAR cannot use the streaming byte + // cap the zip/7z backends enforce. Instead the real bytes this archive + // produced are checked post-hoc against the same declared×ratio cap and + // rolled back if a header/payload-mismatch bomb slipped past the pre-flight + // ratio guard. Best effort: the bytes touch disk transiently before removal. + if format == ArchiveFormat::Rar { + let declared = rar::declared_uncompressed_size(archive_path)?; + let cap = extraction_byte_cap(declared, max_ratio); + let produced: u64 = files.iter().map(|f| f.size_bytes).sum(); + if produced > cap { + for file in &files { + if let Err(err) = std::fs::remove_file(&file.path) { + tracing::warn!( + path = %file.path.display(), + %err, + "failed to remove RAR extraction output during bomb rollback" + ); + } + } + return Err(DecompressionRatioExceededSnafu { + archive: archive_path.to_path_buf(), + compressed: rar::volume_set_size(archive_path), + declared_uncompressed: produced, + max_ratio, + } + .build()); + } + } + Ok(files) } +// WHY: a header/payload-mismatch bomb declares a small uncompressed size but +// streams far more; capping real output at declared×ratio bounds the damage to +// the same policy the pre-flight ratio guard enforces on declared sizes. +pub(crate) fn extraction_byte_cap(declared_uncompressed: u64, max_ratio: f64) -> u64 { + if !max_ratio.is_finite() || max_ratio <= 0.0 { + return 0; + } + let scaled = declared_uncompressed as f64 * max_ratio; + if !scaled.is_finite() || scaled >= u64::MAX as f64 { + return u64::MAX; + } + scaled.ceil() as u64 +} + fn handle_nested( dir: &Path, current_depth: u8, @@ -190,7 +240,12 @@ fn handle_nested( )?; for (archive_path, format) in &nested_archives { - let files = extract_single(archive_path, &nested_output, *format)?; + let files = extract_single( + archive_path, + &nested_output, + *format, + limits.max_decompression_ratio, + )?; all_files.extend(files); } @@ -205,11 +260,17 @@ fn find_nested_archives(dir: &Path) -> Vec<(PathBuf, ArchiveFormat)> { let mut archives = Vec::new(); for entry in entries.flatten() { let path = entry.path(); - if path.is_file() { + // SAFETY: file_type() does not follow symlinks (unlike Path::is_file / + // is_dir), so a reified attacker symlink is classified as neither a file + // nor a directory and cannot redirect recursion outside output_dir. + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_file() { if let Some(format) = detect_by_magic_bytes(&path) { archives.push((path, format)); } - } else if path.is_dir() && path.file_name().map(|n| n != ".nested").unwrap_or(true) { + } else if file_type.is_dir() && path.file_name().map(|n| n != ".nested").unwrap_or(true) { archives.extend(find_nested_archives(&path)); } } @@ -717,4 +778,45 @@ mod tests { assert_eq!(recovered.archive_format, ArchiveFormat::Zip); assert_eq!(recovered.nested_levels, 0); } + + #[test] + fn extraction_byte_cap_bounds_and_saturates() { + assert_eq!(extraction_byte_cap(0, 100.0), 0); + assert_eq!(extraction_byte_cap(100, 10.0), 1000); + assert_eq!(extraction_byte_cap(5, 1.0), 5); + // Non-positive / non-finite ratios collapse to a zero cap. + assert_eq!(extraction_byte_cap(100, 0.0), 0); + assert_eq!(extraction_byte_cap(100, -1.0), 0); + assert_eq!(extraction_byte_cap(100, f64::NAN), 0); + // Overflow saturates instead of wrapping. + assert_eq!(extraction_byte_cap(u64::MAX, 2.0), u64::MAX); + } + + #[test] + #[cfg(unix)] + fn find_nested_archives_does_not_follow_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("output"); + std::fs::create_dir_all(&output).unwrap(); + + // A genuine archive directly inside the walked tree is found. + create_test_zip(&output, "real.zip", &[("a.txt", b"y")]); + + // An attacker symlink inside output pointing at an external directory + // that holds another archive must NOT be followed. + let external = dir.path().join("external"); + std::fs::create_dir_all(&external).unwrap(); + create_test_zip(&external, "outside.zip", &[("secret.txt", b"x")]); + std::os::unix::fs::symlink(&external, output.join("link")).unwrap(); + + let found = find_nested_archives(&output); + assert!( + found.iter().any(|(p, _)| p.ends_with("real.zip")), + "real archive missing: {found:?}" + ); + assert!( + found.iter().all(|(p, _)| !p.starts_with(&external)), + "symlink was followed outside the extraction root: {found:?}" + ); + } } diff --git a/crates/ergasia/src/extract/rar.rs b/crates/ergasia/src/extract/rar.rs index 57c4ede0..98d9eeb6 100644 --- a/crates/ergasia/src/extract/rar.rs +++ b/crates/ergasia/src/extract/rar.rs @@ -1,15 +1,29 @@ +use std::fs::File; +use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use regex::Regex; -use crate::error::ErgasiaError; +use crate::error::{ErgasiaError, UnsafeArchiveEntrySnafu}; static MODERN_RAR_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\.part(\d+)\.rar$") .unwrap_or_else(|e| unreachable!("regex literal is statically valid: {e}")) }); +// WHY: every genuine RAR volume — including each part of a multi-volume set — +// begins with this 6-byte signature; matching it lets the ratio denominator +// exclude junk-extension padding files that only mimic a volume's name. +const RAR_SIGNATURE: [u8; 6] = *b"Rar!\x1a\x07"; + +// Unix st_mode format field: entries whose type is neither regular file nor +// directory (symlink, block/char device, fifo, socket) are refused. +const S_IFMT: u32 = 0o170000; +const S_IFREG: u32 = 0o100000; +const S_IFDIR: u32 = 0o040000; +const S_IFLNK: u32 = 0o120000; + pub fn find_rar_first_volume(dir: &Path) -> Option { let entries: Vec = std::fs::read_dir(dir) .ok()? @@ -63,6 +77,12 @@ pub fn find_rar_first_volume(dir: &Path) -> Option { } pub fn extract_rar(archive_path: &Path, output_dir: &Path) -> Result<(), ErgasiaError> { + // SAFETY: validate every entry across the whole volume set before any write, + // so a hostile archive is refused atomically (mirrors zip ensure_safe_entries). + // Providing a base to extract_with_base disables unrar's own path + // sanitization, so this pre-validation is the sole traversal guard. + validate_rar_entries(archive_path)?; + let archive = unrar::Archive::new(archive_path) .open_for_processing() .map_err(|e| { @@ -102,6 +122,96 @@ pub fn extract_rar(archive_path: &Path, output_dir: &Path) -> Result<(), Ergasia Ok(()) } +// SAFETY: lists every entry (across all volumes) and rejects the whole archive +// on the first unsafe one, before extract_rar writes anything. +fn validate_rar_entries(archive_path: &Path) -> Result<(), ErgasiaError> { + let archive = unrar::Archive::new(archive_path) + .open_for_listing() + .map_err(|e| { + crate::error::OpenArchiveSnafu { + path: archive_path.to_path_buf(), + error: e.to_string(), + } + .build() + })?; + + for header in archive { + let entry = header.map_err(|e| { + crate::error::ExtractFileSnafu { + path: archive_path.to_path_buf(), + error: e.to_string(), + } + .build() + })?; + + if let Some(reason) = rar_name_unsafe_reason(&entry.filename) { + return Err(UnsafeArchiveEntrySnafu { + archive: archive_path.to_path_buf(), + entry: entry.filename.display().to_string(), + reason: reason.to_string(), + } + .build()); + } + + if let Some(reason) = rar_attr_unsafe_reason(entry.file_attr, entry.is_directory()) { + return Err(UnsafeArchiveEntrySnafu { + archive: archive_path.to_path_buf(), + entry: entry.filename.display().to_string(), + reason: reason.to_string(), + } + .build()); + } + } + + Ok(()) +} + +// NOTE: RAR entry names may use either separator and may carry a Windows +// drive-letter/backslash root, so absolute and parent-traversal checks span +// both `/` and `\` rather than relying on the host Path semantics. +fn rar_name_unsafe_reason(name: &Path) -> Option<&'static str> { + let Some(name) = name.to_str() else { + return Some("non-UTF-8 entry name"); + }; + if name.starts_with('/') || name.starts_with('\\') || name.get(1..2) == Some(":") { + return Some("absolute entry path"); + } + for segment in name.split(['/', '\\']) { + if segment == ".." { + return Some("parent directory traversal"); + } + } + None +} + +// WHY: unrar 0.5.8 does not surface the RAR5 FSREDIR redirect kind, so symlinks +// are detected via the unix mode carried in file_attr (RAR3 and RAR5 both store +// S_IFLNK there for unix-host entries). Any non-regular, non-directory type is +// refused — the sanctioned fallback when the crate cannot name symlinks exactly. +fn rar_attr_unsafe_reason(file_attr: u32, is_directory: bool) -> Option<&'static str> { + if is_directory { + return None; + } + let unix_type = file_attr & S_IFMT; + // A DOS/Windows-host entry stores FILE_ATTRIBUTE bits here, not a unix mode; + // its format field is left unset (0), so only interpret a populated field. + if unix_type == 0 || unix_type == S_IFREG || unix_type == S_IFDIR { + return None; + } + if unix_type == S_IFLNK { + return Some("symlink entry"); + } + Some("non-regular file entry") +} + +fn has_rar_signature(path: &Path) -> bool { + let Ok(mut file) = File::open(path) else { + return false; + }; + let mut magic = [0u8; 6]; + file.read_exact(&mut magic).is_ok() && magic == RAR_SIGNATURE +} + pub(crate) fn declared_uncompressed_size(archive_path: &Path) -> Result { let archive = unrar::Archive::new(archive_path) .open_for_listing() @@ -128,36 +238,83 @@ pub(crate) fn declared_uncompressed_size(archive_path: &Path) -> Result u64 { - let Some(dir) = first_volume.parent() else { + let chain = rar_volume_chain(first_volume); + if chain.is_empty() { return first_volume.metadata().map(|m| m.len()).unwrap_or(0); - }; + } + chain + .iter() + .filter_map(|p| p.metadata().ok().map(|m| m.len())) + .fold(0u64, |total, len| total.saturating_add(len)) +} - let Ok(entries) = std::fs::read_dir(dir) else { - return first_volume.metadata().map(|m| m.len()).unwrap_or(0); +// Derives the contiguous set of on-disk volumes for this archive from the first +// volume's name, verifying each carries the RAR signature. Enumeration stops at +// the first missing or non-signature-bearing candidate so unrelated files never +// join the set. +fn rar_volume_chain(first_volume: &Path) -> Vec { + let Some(dir) = first_volume.parent() else { + return signature_filter(vec![first_volume.to_path_buf()]); + }; + let Some(name) = first_volume.file_name().and_then(|n| n.to_str()) else { + return signature_filter(vec![first_volume.to_path_buf()]); }; - entries - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_file() && is_rar_volume(p)) - .filter_map(|p| p.metadata().ok().map(|m| m.len())) - .fold(0u64, |total, len| total.saturating_add(len)) + // Modern scheme: .partNN.rar — preserve the numeric width and count up. + if let Some(caps) = MODERN_RAR_RE.captures(name) { + let full = caps.get(0).map(|m| m.as_str()).unwrap_or_default(); + let digits = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); + let base = name.get(..name.len() - full.len()).unwrap_or_default(); + let width = digits.len(); + let start = digits.parse::().unwrap_or(1); + + let mut chain = Vec::new(); + for n in start.. { + let candidate = dir.join(format!("{base}.part{n:0width$}.rar")); + if candidate.is_file() && has_rar_signature(&candidate) { + chain.push(candidate); + } else { + break; + } + } + return chain; + } + + // Legacy scheme: .rar, then .r00, .r01, ..., .r99, .s00, ... + let base = first_volume + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(name); + + let mut chain = Vec::new(); + if first_volume.is_file() && has_rar_signature(first_volume) { + chain.push(first_volume.to_path_buf()); + } + for i in 0u32.. { + let block = (i / 100) as u8; + let num = i % 100; + let letter = (b'r' + block) as char; + let candidate = dir.join(format!("{base}.{letter}{num:02}")); + if candidate.is_file() && has_rar_signature(&candidate) { + chain.push(candidate); + } else { + break; + } + } + chain } -fn is_rar_volume(path: &Path) -> bool { - path.extension() - .and_then(|e| e.to_str()) - .map(|ext| { - let ext = ext.to_ascii_lowercase(); - ext == "rar" - || (ext.len() == 3 - && ext.starts_with('r') - && ext.chars().skip(1).all(|c| c.is_ascii_digit())) - }) - .unwrap_or(false) +fn signature_filter(candidates: Vec) -> Vec { + candidates + .into_iter() + .filter(|p| has_rar_signature(p)) + .collect() } #[cfg(test)] @@ -281,4 +438,68 @@ mod tests { "expected OpenArchive, got: {err}" ); } + + #[test] + fn rar_name_validation_accepts_safe_and_rejects_traversal() { + assert!(rar_name_unsafe_reason(Path::new("a/b/c.txt")).is_none()); + assert!(rar_name_unsafe_reason(Path::new("dir/file")).is_none()); + + assert!(rar_name_unsafe_reason(Path::new("../escape")).is_some()); + assert!(rar_name_unsafe_reason(Path::new("a/../../escape")).is_some()); + assert!(rar_name_unsafe_reason(Path::new("/etc/passwd")).is_some()); + // Backslash separator and drive-letter roots (Windows-origin entries). + assert!(rar_name_unsafe_reason(Path::new(r"..\..\escape")).is_some()); + assert!(rar_name_unsafe_reason(Path::new(r"C:\Windows\evil")).is_some()); + assert!(rar_name_unsafe_reason(Path::new(r"\\server\share")).is_some()); + } + + #[test] + fn rar_attr_validation_rejects_non_regular() { + // Regular file and directory pass. + assert!(rar_attr_unsafe_reason(0o100644, false).is_none()); + assert!(rar_attr_unsafe_reason(0o040755, true).is_none()); + // DOS/Windows attribute bits carry no unix mode field. + assert!(rar_attr_unsafe_reason(0x20, false).is_none()); + // Symlink and other special files are refused. + assert_eq!( + rar_attr_unsafe_reason(0o120777, false), + Some("symlink entry") + ); + assert!(rar_attr_unsafe_reason(0o060644, false).is_some()); + assert!(rar_attr_unsafe_reason(0o010644, false).is_some()); + } + + fn signed_volume(extra: usize) -> Vec { + let mut bytes = RAR_SIGNATURE.to_vec(); + bytes.resize(RAR_SIGNATURE.len() + extra, 0); + bytes + } + + #[test] + fn volume_set_size_counts_only_signed_modern_chain() { + let dir = tempfile::tempdir().unwrap(); + let vol = signed_volume(10); // 16 bytes each + fs::write(dir.path().join("movie.part1.rar"), &vol).unwrap(); + fs::write(dir.path().join("movie.part2.rar"), &vol).unwrap(); + fs::write(dir.path().join("movie.part3.rar"), &vol).unwrap(); + // Junk padding: right extension, no RAR signature, huge — must be + // excluded so it cannot inflate the compressed denominator. + fs::write(dir.path().join("movie.part4.rar"), vec![0u8; 1_000_000]).unwrap(); + fs::write(dir.path().join("padding.rar"), vec![0u8; 1_000_000]).unwrap(); + + assert_eq!(volume_set_size(&dir.path().join("movie.part1.rar")), 48); + } + + #[test] + fn volume_set_size_counts_only_signed_legacy_chain() { + let dir = tempfile::tempdir().unwrap(); + let vol = signed_volume(4); // 10 bytes each + fs::write(dir.path().join("archive.rar"), &vol).unwrap(); + fs::write(dir.path().join("archive.r00"), &vol).unwrap(); + fs::write(dir.path().join("archive.r01"), &vol).unwrap(); + // Unsigned decoy breaks the contiguous chain. + fs::write(dir.path().join("archive.r02"), vec![0u8; 1_000_000]).unwrap(); + + assert_eq!(volume_set_size(&dir.path().join("archive.rar")), 30); + } } diff --git a/crates/ergasia/src/extract/seven_zip.rs b/crates/ergasia/src/extract/seven_zip.rs index 38d6a2ff..9865fe34 100644 --- a/crates/ergasia/src/extract/seven_zip.rs +++ b/crates/ergasia/src/extract/seven_zip.rs @@ -1,10 +1,88 @@ -use std::path::Path; +use std::fs::File; +use std::io::{BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; -use crate::error::ErgasiaError; +use sevenz_rust2::ArchiveEntry; -pub fn extract_7z(archive_path: &Path, output_dir: &Path) -> Result<(), ErgasiaError> { - sevenz_rust2::decompress_file(archive_path, output_dir).map_err(|e| { - crate::error::ExtractFileSnafu { +use crate::error::{ + DecompressionRatioExceededSnafu, ErgasiaError, ExtractFileSnafu, UnsafeArchiveEntrySnafu, +}; +use crate::extract::pipeline::extraction_byte_cap; + +// Windows file attributes carried by 7z entries. +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; +const FILE_ATTRIBUTE_UNIX_EXTENSION: u32 = 0x8000; + +// Unix st_mode format field (present in the high 16 bits when the unix +// extension flag is set). +const S_IFMT: u32 = 0o170000; +const S_IFREG: u32 = 0o100000; +const S_IFDIR: u32 = 0o040000; +const S_IFLNK: u32 = 0o120000; + +pub fn extract_7z( + archive_path: &Path, + output_dir: &Path, + max_ratio: f64, +) -> Result<(), ErgasiaError> { + // SAFETY: reject the whole archive before any write if any entry has an + // absolute or parent-traversal name or is a symlink/special file — the + // sevenz_rust2 default extractor does dest.join(name) + File::create with no + // such check. Mirrors zip ensure_safe_entries. + let listing = sevenz_rust2::Archive::open(archive_path).map_err(|e| { + crate::error::OpenArchiveSnafu { + path: archive_path.to_path_buf(), + error: e.to_string(), + } + .build() + })?; + for entry in &listing.files { + if let Some(reason) = unsafe_entry_reason(entry) { + return Err(UnsafeArchiveEntrySnafu { + archive: archive_path.to_path_buf(), + entry: entry.name().to_string(), + reason: reason.to_string(), + } + .build()); + } + } + + let declared = declared_uncompressed_size(archive_path)?; + let compressed = archive_path.metadata().map(|m| m.len()).unwrap_or(0); + let mut guard = ExtractGuard { + archive_path, + output_dir, + cap: extraction_byte_cap(declared, max_ratio), + compressed, + max_ratio, + written_total: 0, + created: Vec::new(), + }; + + // WHY: the closure returns sevenz_rust2::Error, so the real ErgasiaError is + // captured out-of-band and a sentinel is returned to abort the stream. + let mut captured: Option = None; + let result = sevenz_rust2::decompress_file_with_extract_fn( + archive_path, + output_dir, + |entry, reader, _dest| match guard.write_entry(entry, reader) { + Ok(keep_going) => Ok(keep_going), + Err(err) => { + captured = Some(err); + Err(sevenz_rust2::Error::from(std::io::Error::other( + "extraction aborted", + ))) + } + }, + ); + + if let Some(err) = captured { + guard.cleanup(); + return Err(err); + } + result.map_err(|e| { + guard.cleanup(); + ExtractFileSnafu { path: archive_path.to_path_buf(), error: e.to_string(), } @@ -14,6 +92,135 @@ pub fn extract_7z(archive_path: &Path, output_dir: &Path) -> Result<(), ErgasiaE Ok(()) } +// Tracks per-archive extraction state so a byte-cap breach mid-stream can roll +// back exactly the files this extraction created. +struct ExtractGuard<'a> { + archive_path: &'a Path, + output_dir: &'a Path, + cap: u64, + compressed: u64, + max_ratio: f64, + written_total: u64, + created: Vec, +} + +impl ExtractGuard<'_> { + fn write_entry( + &mut self, + entry: &ArchiveEntry, + reader: &mut dyn Read, + ) -> Result { + // Defense in depth: the closure's dest is attacker-influenced, so + // re-validate and rebuild the path from the vetted output root. + if let Some(reason) = unsafe_entry_reason(entry) { + return Err(UnsafeArchiveEntrySnafu { + archive: self.archive_path.to_path_buf(), + entry: entry.name().to_string(), + reason: reason.to_string(), + } + .build()); + } + + let dest = self.output_dir.join(entry.name()); + if !dest.starts_with(self.output_dir) { + return Err(UnsafeArchiveEntrySnafu { + archive: self.archive_path.to_path_buf(), + entry: entry.name().to_string(), + reason: "path escapes the extraction root".to_string(), + } + .build()); + } + + if entry.is_directory() { + std::fs::create_dir_all(&dest).map_err(|e| self.extract_err(&dest, e))?; + return Ok(true); + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| self.extract_err(parent, e))?; + } + + let file = File::create(&dest).map_err(|e| self.extract_err(&dest, e))?; + self.created.push(dest.clone()); + let mut writer = BufWriter::new(file); + + // WHY: cap the real bytes this entry may write to the remaining cap + // headroom (+1 so an over-long stream lands exactly one byte past the + // cap and is detected), catching a header/payload-mismatch bomb before + // it fully materializes on disk. + let remaining = self.cap.saturating_sub(self.written_total); + let mut limited = reader.take(remaining.saturating_add(1)); + let copied = + std::io::copy(&mut limited, &mut writer).map_err(|e| self.extract_err(&dest, e))?; + self.written_total = self.written_total.saturating_add(copied); + if self.written_total > self.cap { + return Err(DecompressionRatioExceededSnafu { + archive: self.archive_path.to_path_buf(), + compressed: self.compressed, + declared_uncompressed: self.written_total, + max_ratio: self.max_ratio, + } + .build()); + } + writer.flush().map_err(|e| self.extract_err(&dest, e))?; + Ok(true) + } + + fn extract_err(&self, path: &Path, error: std::io::Error) -> ErgasiaError { + ExtractFileSnafu { + path: path.to_path_buf(), + error: error.to_string(), + } + .build() + } + + // Best-effort rollback of files written before an abort. + fn cleanup(&self) { + for path in &self.created { + if let Err(err) = std::fs::remove_file(path) { + tracing::warn!( + path = %path.display(), + %err, + "failed to remove partial 7z extraction output during rollback" + ); + } + } + } +} + +fn unsafe_entry_reason(entry: &ArchiveEntry) -> Option<&'static str> { + // NOTE: an empty name maps to the extraction root itself (7z stores a + // root-directory entry with an empty name); it cannot traverse, so only + // absolute, parent-traversal, and non-regular entries are refused. + let name = entry.name(); + if name.starts_with('/') || name.starts_with('\\') || name.get(1..2) == Some(":") { + return Some("absolute entry path"); + } + for segment in name.split(['/', '\\']) { + if segment == ".." { + return Some("parent directory traversal"); + } + } + + if !entry.is_directory() { + let attr = entry.windows_attributes; + if attr & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Some("reparse point / symlink entry"); + } + if attr & FILE_ATTRIBUTE_UNIX_EXTENSION != 0 { + let unix_type = (attr >> 16) & S_IFMT; + if unix_type == S_IFLNK { + return Some("symlink entry"); + } + if unix_type != 0 && unix_type != S_IFREG && unix_type != S_IFDIR { + return Some("non-regular file entry"); + } + } + } + + None +} + pub(crate) fn declared_uncompressed_size(archive_path: &Path) -> Result { let archive = sevenz_rust2::Archive::open(archive_path).map_err(|e| { crate::error::OpenArchiveSnafu { @@ -32,10 +239,13 @@ pub(crate) fn declared_uncompressed_size(archive_path: &Path) -> Result PathBuf { let staging = root.join("staging"); fs::create_dir_all(&staging).unwrap(); @@ -47,6 +257,36 @@ mod tests { archive_path } + // Writes a 7z with a single verbatim entry name (bypassing the filesystem so + // hostile names survive) and optional windows attributes. + fn create_7z_raw_entry( + root: &Path, + entry_name: &str, + data: &[u8], + windows_attributes: Option, + ) -> PathBuf { + let archive_path = root.join("crafted.7z"); + let mut writer = sevenz_rust2::ArchiveWriter::create(&archive_path).unwrap(); + let mut entry = ArchiveEntry::new_file(entry_name); + if let Some(attr) = windows_attributes { + entry.has_windows_attributes = true; + entry.windows_attributes = attr; + } + writer + .push_archive_entry(entry, Some(Cursor::new(data.to_vec()))) + .unwrap(); + writer.finish().unwrap(); + archive_path + } + + fn assert_empty(dir: &Path) { + let leftovers: Vec<_> = fs::read_dir(dir).unwrap().flatten().collect(); + assert!( + leftovers.is_empty(), + "expected empty dir, found {leftovers:?}" + ); + } + #[test] fn extract_7z_success() { let dir = tempfile::tempdir().unwrap(); @@ -57,7 +297,7 @@ mod tests { let output_dir = dir.path().join("output"); fs::create_dir_all(&output_dir).unwrap(); - extract_7z(&archive_path, &output_dir).unwrap(); + extract_7z(&archive_path, &output_dir, NO_RATIO_LIMIT).unwrap(); assert_eq!( fs::read_to_string(output_dir.join("hello.txt")).unwrap(), @@ -74,10 +314,13 @@ mod tests { let output_dir = dir.path().join("output"); fs::create_dir_all(&output_dir).unwrap(); - let err = extract_7z(&archive_path, &output_dir).unwrap_err(); + let err = extract_7z(&archive_path, &output_dir, NO_RATIO_LIMIT).unwrap_err(); assert!( - matches!(err, ErgasiaError::ExtractFile { .. }), - "expected ExtractFile for a corrupt archive, got: {err}" + matches!( + err, + ErgasiaError::OpenArchive { .. } | ErgasiaError::ExtractFile { .. } + ), + "expected OpenArchive or ExtractFile for a corrupt archive, got: {err}" ); } @@ -87,10 +330,18 @@ mod tests { let output_dir = dir.path().join("output"); fs::create_dir_all(&output_dir).unwrap(); - let err = extract_7z(&dir.path().join("nonexistent.7z"), &output_dir).unwrap_err(); + let err = extract_7z( + &dir.path().join("nonexistent.7z"), + &output_dir, + NO_RATIO_LIMIT, + ) + .unwrap_err(); assert!( - matches!(err, ErgasiaError::ExtractFile { .. }), - "expected ExtractFile for a missing archive, got: {err}" + matches!( + err, + ErgasiaError::OpenArchive { .. } | ErgasiaError::ExtractFile { .. } + ), + "expected OpenArchive or ExtractFile for a missing archive, got: {err}" ); } @@ -102,4 +353,70 @@ mod tests { assert_eq!(declared_uncompressed_size(&archive_path).unwrap(), 150); } + + #[test] + fn reject_parent_traversal_entry() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = create_7z_raw_entry(dir.path(), "../escape.txt", b"traversal", None); + let output_dir = dir.path().join("output"); + fs::create_dir_all(&output_dir).unwrap(); + + let err = extract_7z(&archive_path, &output_dir, NO_RATIO_LIMIT).unwrap_err(); + assert!( + matches!(err, ErgasiaError::UnsafeArchiveEntry { .. }), + "expected UnsafeArchiveEntry, got: {err}" + ); + assert_empty(&output_dir); + assert!(!dir.path().join("escape.txt").exists()); + } + + #[test] + fn reject_absolute_path_entry() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = create_7z_raw_entry(dir.path(), "/etc/evil.txt", b"absolute", None); + let output_dir = dir.path().join("output"); + fs::create_dir_all(&output_dir).unwrap(); + + let err = extract_7z(&archive_path, &output_dir, NO_RATIO_LIMIT).unwrap_err(); + assert!( + matches!(err, ErgasiaError::UnsafeArchiveEntry { .. }), + "expected UnsafeArchiveEntry, got: {err}" + ); + assert_empty(&output_dir); + } + + #[test] + fn reject_symlink_entry() { + let dir = tempfile::tempdir().unwrap(); + // Unix-mode symlink: FILE_ATTRIBUTE_UNIX_EXTENSION with S_IFLNK in the + // high 16 bits. + let attr = FILE_ATTRIBUTE_UNIX_EXTENSION | (S_IFLNK << 16); + let archive_path = create_7z_raw_entry(dir.path(), "link", b"../../etc/passwd", Some(attr)); + let output_dir = dir.path().join("output"); + fs::create_dir_all(&output_dir).unwrap(); + + let err = extract_7z(&archive_path, &output_dir, NO_RATIO_LIMIT).unwrap_err(); + assert!( + matches!(err, ErgasiaError::UnsafeArchiveEntry { .. }), + "expected UnsafeArchiveEntry, got: {err}" + ); + assert_empty(&output_dir); + } + + #[test] + fn byte_cap_aborts_and_cleans_up() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = create_test_7z(dir.path(), &[("payload.bin", &[0u8; 4096])]); + let output_dir = dir.path().join("output"); + fs::create_dir_all(&output_dir).unwrap(); + + // A zero ratio forces a byte cap of 0, so the first byte written trips + // the guard and the partial output must be rolled back. + let err = extract_7z(&archive_path, &output_dir, 0.0).unwrap_err(); + assert!( + matches!(err, ErgasiaError::DecompressionRatioExceeded { .. }), + "expected DecompressionRatioExceeded, got: {err}" + ); + assert_empty(&output_dir); + } }