//! Tests for [`super`]. use super::*; use zip::write::SimpleFileOptions; fn make_zip(entries: &[(&str, &[u8])]) -> Vec { let buf = Vec::new(); let cursor = Cursor::new(buf); let mut writer = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, data) in entries { writer.start_file(*name, options).unwrap(); std::io::Write::write_all(&mut writer, data).unwrap(); } writer.finish().unwrap().into_inner() } fn make_compressed_zip(entries: &[(&str, &[u8])]) -> Vec { let buf = Vec::new(); let cursor = Cursor::new(buf); let mut writer = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); for (name, data) in entries { writer.start_file(*name, options).unwrap(); std::io::Write::write_all(&mut writer, data).unwrap(); } writer.finish().unwrap().into_inner() } // Skip behavior #[test] fn non_zip_skipped() { let result = check_archive_safety(b"not a zip file", FileType::Download); assert_eq!(result.verdict, LayerVerdict::Skip); } #[test] fn audio_non_zip_skipped() { let result = check_archive_safety(b"audio data", FileType::Audio); assert_eq!(result.verdict, LayerVerdict::Skip); } // 7z / RAR: no pure-Rust bomb checker (R6-Sec-L2) #[test] fn sevenzip_rejected_for_non_download() { let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0]; let result = check_archive_safety(&data, FileType::Cover); assert_eq!(result.verdict, LayerVerdict::Error); } #[test] fn sevenzip_allowed_for_download() { // Download keeps the ClamAV backstop; the in-process layer doesn't reject. let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 0, 0, 0]; let result = check_archive_safety(&data, FileType::Download); assert_ne!(result.verdict, LayerVerdict::Error); } #[test] fn rar_rejected_for_non_download() { let data = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0, 0, 0]; let result = check_archive_safety(&data, FileType::MediaImage); assert_eq!(result.verdict, LayerVerdict::Error); } #[test] fn cover_zip_skipped() { // A ZIP file claimed as cover should be skipped (layer 1 handles type mismatch) let data = make_zip(&[("test.txt", b"hello")]); let result = check_archive_safety(&data, FileType::Cover); assert_eq!(result.verdict, LayerVerdict::Skip); } // Valid archives #[test] fn valid_zip_passes() { let data = make_zip(&[("test.txt", b"hello world")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); } #[test] fn empty_zip_passes() { let buf = Vec::new(); let cursor = Cursor::new(buf); let writer = zip::ZipWriter::new(cursor); let data = writer.finish().unwrap().into_inner(); // Empty ZIPs may not have the PK magic at offset 0, they'd just be // an end-of-central-directory record. If it doesn't start with PK 03 04, // we'll skip it. That's fine. let result = check_archive_safety(&data, FileType::Download); // Either Skip (no local file header) or Pass (valid empty ZIP) assert!( result.verdict == LayerVerdict::Skip || result.verdict == LayerVerdict::Pass, "unexpected verdict: {:?}", result.verdict ); } #[test] fn multi_entry_zip_passes() { let data = make_zip(&[ ("file1.txt", b"content one"), ("subdir/file2.txt", b"content two"), ("readme.md", b"# hello"), ]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); assert!(result.detail.unwrap().contains("3 entries")); } // Path traversal #[test] fn zip_with_forward_slash_traversal_fails() { let data = make_zip(&[("../../../etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_backslash_traversal_fails() { let data = make_zip(&[("..\\..\\Windows\\System32\\config", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_mid_path_traversal_fails() { let data = make_zip(&[("safe/../../etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); } #[test] fn zip_with_url_encoded_traversal_fails() { // %2e%2e is URL-encoded "..". The check is case-insensitive on the encoding. let data = make_zip(&[("%2E%2E/secrets", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_absolute_path_fails() { let data = make_zip(&[("/etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } #[test] fn zip_with_null_byte_in_name_fails() { let data = make_zip(&[("legit.txt\0../escape", b"pwned")]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Path traversal")); } // Nesting detection // Nested-archive interior scanning (`scan_nested_contents`) // // The old behavior here merely *counted* nested-archive entries and failed a // ZIP with more than `SCAN_ZIP_MAX_DEPTH` of them, never inspecting their // contents, counting is not scanning, so a payload in a zip-in-a-zip passed // Clean (the run #20→#22 chronic). `check_archive_safety` no longer counts; // interior coverage is `scan_nested_contents`, exercised below with the real // rule set so an actual signature in a nested archive is caught or held. /// The compiled production YARA rules (includes the EICAR test signature). fn test_yara_rules() -> yara_x::Rules { super::super::yara::compile_rules_from_dir("yara-rules") .expect("compile yara-rules") .0 .expect("yara-rules dir has rules") } /// EICAR antivirus test string, matched by `yara-rules/mnw_test_files.yar`. const EICAR: &[u8] = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; #[test] fn benign_nested_zip_passes() { let inner = make_zip(&[("hello.txt", b"hello world")]); let outer = make_zip(&[("inner.zip", &inner), ("notes.txt", b"readme")]); let result = scan_nested_contents(&outer, Some(&test_yara_rules())); assert_eq!(result.verdict, LayerVerdict::Pass, "{:?}", result.detail); } #[test] fn eicar_in_zip_in_zip_is_caught() { // outer.zip -> inner.zip -> evil.txt(EICAR). The interior bytes must // traverse YARA exactly as a top-level file would: not Clean. let inner = make_zip(&[("evil.txt", EICAR)]); let outer = make_zip(&[("inner.zip", &inner)]); let result = scan_nested_contents(&outer, Some(&test_yara_rules())); assert_eq!( result.verdict, LayerVerdict::Fail, "EICAR nested two zips deep must be caught, got {:?}", result.detail ); } #[test] fn eicar_in_single_gzip_is_caught() { // A standalone gzip member is one logical entry; its decompressed bytes // must be scanned. let gz = gzip(EICAR); let result = scan_nested_contents(&gz, Some(&test_yara_rules())); assert_eq!(result.verdict, LayerVerdict::Fail, "{:?}", result.detail); } #[test] fn nesting_beyond_scan_depth_is_held_not_passed() { // SCAN_ZIP_MAX_DEPTH = 2. Wrap a benign file in enough ZIP layers that // the innermost archive sits past the descent budget; the interior is // not fully scanned, so it must fail closed (Error -> held), never Clean. let mut nested = make_zip(&[("leaf.txt", b"benign")]); for _ in 0..4 { nested = make_zip(&[("inner.zip", &nested)]); } let result = scan_nested_contents(&nested, Some(&test_yara_rules())); assert_eq!( result.verdict, LayerVerdict::Error, "a nest deeper than the scan depth must be held, got {:?}", result.detail ); assert_eq!( super::super::error_policy_for(result.layer), ErrorPolicy::FailClosed ); } #[test] fn non_archive_has_no_interior() { let result = scan_nested_contents(b"just some plain bytes", Some(&test_yara_rules())); assert_eq!(result.verdict, LayerVerdict::Skip); } #[test] fn non_archive_extensions_ignored() { let data = make_zip(&[ ("app.exe", b"binary"), ("readme.txt", b"hello"), ("image.png", b"pixels"), ]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); } // Compression ratio (ZIP bomb detection) #[test] fn high_compression_ratio_fails() { // Create highly compressible data: repeating zeros compress extremely well // 1MB of zeros should compress to ~1KB with deflate, giving ratio ~1000x let zeros = vec![0u8; 1024 * 1024]; let data = make_compressed_zip(&[("bomb.bin", &zeros)]); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "Expected Fail for high compression ratio, got: {:?}", result.detail ); assert!(result.detail.unwrap().contains("ZIP bomb")); } #[test] fn normal_compression_ratio_passes() { // Random-ish data doesn't compress well, ratio should be ~1x let data_bytes: Vec = (0..10000).map(|i| (i * 37 + 13) as u8).collect(); let data = make_compressed_zip(&[("normal.bin", &data_bytes)]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Pass); } // Audio file with ZIP magic (disguised archive) #[test] fn zip_disguised_as_audio_checked() { // A ZIP file claimed as Audio should still be checked (not skipped) let data = make_zip(&[("test.txt", b"hello")]); let result = check_archive_safety(&data, FileType::Audio); assert_eq!(result.verdict, LayerVerdict::Pass); } #[test] fn zip_disguised_as_audio_with_traversal_fails() { let data = make_zip(&[("../../../etc/passwd", b"pwned")]); let result = check_archive_safety(&data, FileType::Audio); assert_eq!(result.verdict, LayerVerdict::Fail); } // Corrupted ZIP #[test] fn corrupted_zip_magic_returns_error() { // Valid ZIP magic bytes but garbage after let mut data = vec![0x50, 0x4B, 0x03, 0x04]; data.extend_from_slice(&[0xFF; 100]); let result = check_archive_safety(&data, FileType::Download); assert_eq!(result.verdict, LayerVerdict::Error); assert!(result.detail.unwrap().contains("Failed to parse ZIP")); } #[test] fn path_entry_matches_buffered_for_non_zip() { let data = b"not a zip at all"; let buffered = check_archive_safety(data, FileType::Download); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), data).unwrap(); let path_based = check_archive_safety_path(tmp.path(), FileType::Download); assert_eq!(buffered.verdict, path_based.verdict); assert_eq!(buffered.verdict, LayerVerdict::Skip); } #[test] fn path_entry_matches_buffered_for_cover_skip() { let mut data = vec![0x50, 0x4B, 0x03, 0x04]; data.extend_from_slice(&[0xFF; 100]); let buffered = check_archive_safety(&data, FileType::Cover); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &data).unwrap(); let path_based = check_archive_safety_path(tmp.path(), FileType::Cover); assert_eq!(buffered.verdict, path_based.verdict); assert_eq!(buffered.verdict, LayerVerdict::Skip); } // Single-stream decompression bombs (gzip / bzip2 / xz / zstd) use std::io::Write; fn gzip(data: &[u8]) -> Vec { let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); e.write_all(data).unwrap(); e.finish().unwrap() } fn bzip2_compress(data: &[u8]) -> Vec { let mut e = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(9)); e.write_all(data).unwrap(); e.finish().unwrap() } fn xz(data: &[u8]) -> Vec { let mut e = xz2::write::XzEncoder::new(Vec::new(), 9); e.write_all(data).unwrap(); e.finish().unwrap() } fn zstd_compress(data: &[u8]) -> Vec { zstd::encode_all(data, 19).unwrap() } /// 8 MiB of zeros, compresses to a tiny stream at a ratio far above the /// 100x cap, the canonical decompression-bomb shape. fn bomb_payload() -> Vec { vec![0u8; 8 * 1024 * 1024] } /// Moderately-incompressible data: stays well under the ratio cap, so a /// legitimate compressed download passes. fn benign_payload() -> Vec { (0..200_000u32) .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8) .collect() } #[test] fn gzip_bomb_fails() { let data = gzip(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); assert!(result.detail.unwrap().to_lowercase().contains("bomb")); } #[test] fn benign_gzip_passes() { let data = gzip(&benign_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Pass, "detail: {:?}", result.detail ); } #[test] fn bzip2_bomb_fails() { let data = bzip2_compress(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn xz_bomb_fails() { let data = xz(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn zstd_bomb_fails() { let data = zstd_compress(&bomb_payload()); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn gzip_bomb_caught_on_path_variant_too() { let data = gzip(&bomb_payload()); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &data).unwrap(); let result = check_archive_safety_path(tmp.path(), FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn gzip_bomb_skipped_for_cover() { // Type mismatch is layer 1's job; the archive layer skips covers. let data = gzip(&bomb_payload()); let result = check_archive_safety(&data, FileType::Cover); assert_eq!(result.verdict, LayerVerdict::Skip); } // Prefixed / self-extracting ZIP (no offset-0 magic) #[test] fn prefixed_zip_is_not_silently_skipped() { // A real ZIP with arbitrary bytes prepended (the self-extracting-stub // shape). It lacks the offset-0 PK\x03\x04 magic, so the old offset-0 // gate would Skip it. The tail EOCD scan must catch it and hand it to // inspect_zip, the security property is that it is NOT Skipped. let zip = make_zip(&[("readme.txt", b"hello")]); let mut data = b"MZ\x90\x00 this is a self-extracting stub padding ".to_vec(); data.extend_from_slice(&zip); let result = check_archive_safety(&data, FileType::Download); assert_ne!( result.verdict, LayerVerdict::Skip, "prefixed ZIP must be inspected, not skipped; got {:?}", result.detail ); // And on the path variant. let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &data).unwrap(); let path_based = check_archive_safety_path(tmp.path(), FileType::Download); assert_ne!( path_based.verdict, LayerVerdict::Skip, "detail: {:?}", path_based.detail ); } #[test] fn prefixed_zip_bomb_fails() { // Prepend a stub to a high-ratio ZIP; it must still be caught. let zeros = vec![0u8; 1024 * 1024]; let zip = make_compressed_zip(&[("bomb.bin", &zeros)]); let mut data = b"self-extracting stub ".to_vec(); data.extend_from_slice(&zip); let result = check_archive_safety(&data, FileType::Download); assert_eq!( result.verdict, LayerVerdict::Fail, "detail: {:?}", result.detail ); } #[test] fn prefixed_7z_polyglot_rejected_for_non_download() { // [PNG header][7z magic][junk]: sniffs as a PNG (passing the content-type // layer) but carries a 7z payload past offset 0. The offset-0-only magic // check missed this and fell through to ClamAV FailOpen (Sec-S1); the // whole-buffer window scan now rejects it for non-Download uploads. let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; data.extend_from_slice(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]); // 7z magic data.extend_from_slice(&[0u8; 64]); // Cover / image: held for review (Error), not passed to ClamAV alone. let cover = check_archive_safety(&data, FileType::Cover); assert_eq!( cover.verdict, LayerVerdict::Error, "detail: {:?}", cover.detail ); assert!(cover.detail.unwrap().contains("7z")); // Download keeps the ClamAV backstop, this layer must not reject it. let download = check_archive_safety(&data, FileType::Download); assert_ne!( download.verdict, LayerVerdict::Error, "Download must keep the ClamAV backstop, not be rejected by the container check" ); } #[test] fn prefixed_rar_polyglot_rejected_for_non_download() { // Same evasion shape with a RAR signature embedded after a JPEG header. let mut data = vec![0xFF, 0xD8, 0xFF, 0xE0]; // JPEG SOI + APP0 data.extend_from_slice(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]); // "Rar!\x1a\x07" data.extend_from_slice(&[0u8; 64]); let cover = check_archive_safety(&data, FileType::Cover); assert_eq!( cover.verdict, LayerVerdict::Error, "detail: {:?}", cover.detail ); assert!(cover.detail.unwrap().contains("RAR")); }