// --------------------------------------------------------------------------- // Pipeline integration tests // --------------------------------------------------------------------------- use std::path::PathBuf; use corbel_purge::{Config, CleanseMode, DocumentFormat, Pipeline}; use corbel_purge::core::types::ThreatClassification; use corbel_purge::parsers::{Dispatcher, DocumentParser}; use tempfile::tempdir; fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } fn fixture(name: &str) -> PathBuf { fixtures_dir().join(name) } // --------------------------------------------------------------------------- // Benign documents: no findings, no quarantine, no cleanse // --------------------------------------------------------------------------- #[test] fn benign_pdf_produces_no_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("benign.pdf")).unwrap(); assert_eq!(result.scan_report.malicious_count(), 0); assert!(result.quarantine_path.is_none()); assert!(result.cleansed_path.is_none()); } #[test] fn benign_epub_produces_no_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("benign.epub")).unwrap(); assert_eq!(result.scan_report.malicious_count(), 0); assert!(result.quarantine_path.is_none()); } #[test] fn benign_docx_produces_no_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("benign.docx")).unwrap(); assert_eq!(result.scan_report.malicious_count(), 0); assert!(result.quarantine_path.is_none()); } #[test] fn benign_markdown_produces_no_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("benign.md")).unwrap(); assert_eq!(result.scan_report.malicious_count(), 0); assert!(result.quarantine_path.is_none()); } // --------------------------------------------------------------------------- // Malicious documents: findings, quarantine, and cleanse // --------------------------------------------------------------------------- #[test] fn malicious_pdf_js_produces_findings_and_quarantine() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious_js.pdf")).unwrap(); assert!(result.scan_report.malicious_count() > 0); assert!(result.quarantine_path.is_some()); assert!(result.cleansed_path.is_some()); } #[test] fn malicious_pdf_launch_produces_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious_launch.pdf")).unwrap(); assert!(result.scan_report.malicious_count() > 0); assert!(result.quarantine_path.is_some()); } #[test] fn malicious_epub_script_produces_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious.epub")).unwrap(); assert!(result.scan_report.malicious_count() > 0); assert!(result.quarantine_path.is_some()); } #[test] fn malicious_md_xss_produces_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious.md")).unwrap(); assert!(result.scan_report.malicious_count() > 0); } #[test] fn malicious_docx_macro_produces_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious_macro.docx")).unwrap(); assert!(result.scan_report.malicious_count() > 0); assert!(result.quarantine_path.is_some()); } #[test] fn malicious_docx_ole_produces_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious_ole.docx")).unwrap(); assert!(result.scan_report.malicious_count() > 0); assert!(result.quarantine_path.is_some()); } #[test] fn malicious_docx_link_produces_findings() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious_link.docx")).unwrap(); assert!(result.scan_report.malicious_count() > 0); } // --------------------------------------------------------------------------- // PreserveFormat repackage // --------------------------------------------------------------------------- #[test] fn preserve_format_epub_strips_script_entry() { let tmp = tempdir().unwrap(); let mut config = Config::with_workspace(tmp.path()); config.cleanse_mode = CleanseMode::PreserveFormat; let pipeline = Pipeline::with_config(config); let result = pipeline.run(fixture("malicious.epub")).unwrap(); assert!(result.scan_report.malicious_count() > 0); let cleansed = result.cleansed_path.expect("cleansed EPUB should exist"); let content = std::fs::read(&cleansed).unwrap(); let content_str = String::from_utf8_lossy(&content); // The repackaged EPUB should not contain the malicious script. assert!( !content_str.contains("alert"), "repackaged EPUB should not contain the script payload" ); // Output should be a valid ZIP. let _archive = zip::ZipArchive::new(std::io::Cursor::new(&content)) .expect("cleansed EPUB should be a valid ZIP"); } #[test] fn preserve_format_docx_strips_macro() { let tmp = tempdir().unwrap(); let mut config = Config::with_workspace(tmp.path()); config.cleanse_mode = CleanseMode::PreserveFormat; let pipeline = Pipeline::with_config(config); let result = pipeline.run(fixture("malicious_macro.docx")).unwrap(); assert!(result.scan_report.malicious_count() > 0); let cleansed = result.cleansed_path.expect("cleansed DOCX should exist"); let content = std::fs::read(&cleansed).unwrap(); let content_str = String::from_utf8_lossy(&content); assert!( !content_str.contains("vbaProject"), "repackaged DOCX should not contain VBA project" ); } // --------------------------------------------------------------------------- // PDF PreserveFormat repackage tests (v0.3.0) // --------------------------------------------------------------------------- #[test] fn preserve_format_pdf_strips_javascript() { let tmp = tempdir().unwrap(); let mut config = Config::with_workspace(tmp.path()); config.cleanse_mode = corbel_purge::CleanseMode::PreserveFormat; let pipeline = Pipeline::with_config(config); let result = pipeline.run(fixture("malicious_js.pdf")).unwrap(); assert!(result.scan_report.malicious_count() > 0); let cleansed = result.cleansed_path.expect("cleansed PDF should exist"); let content = std::fs::read(&cleansed).unwrap(); let content_str = String::from_utf8_lossy(&content); // The JavaScript action payload should be gone. assert!( !content_str.contains("app.alert"), "repackaged PDF should not contain the JS payload, got:\n{}", content_str ); } #[test] fn preserve_format_pdf_strips_launch() { let tmp = tempdir().unwrap(); let mut config = Config::with_workspace(tmp.path()); config.cleanse_mode = corbel_purge::CleanseMode::PreserveFormat; let pipeline = Pipeline::with_config(config); let result = pipeline.run(fixture("malicious_launch.pdf")).unwrap(); assert!(result.scan_report.malicious_count() > 0); let cleansed = result.cleansed_path.expect("cleansed PDF should exist"); let content = std::fs::read(&cleansed).unwrap(); let content_str = String::from_utf8_lossy(&content); // The /Launch action should be removed from the catalog. assert!( !content_str.contains("/Launch"), "repackaged PDF should not contain /Launch action" ); } // --------------------------------------------------------------------------- // Abort-on-threat // --------------------------------------------------------------------------- #[test] fn abort_on_threat_returns_error_for_malicious() { let tmp = tempdir().unwrap(); let mut config = Config::with_workspace(tmp.path()); config.abort_on_threat = true; let pipeline = Pipeline::with_config(config); let result = pipeline.run(fixture("malicious_js.pdf")); assert!(result.is_err(), "abort-on-threat should return Err for malicious PDF"); let err = result.unwrap_err().to_string(); assert!( err.contains("threat detected"), "error should mention threat, got: {err}" ); } #[test] fn abort_on_threat_succeeds_for_benign() { let tmp = tempdir().unwrap(); let mut config = Config::with_workspace(tmp.path()); config.abort_on_threat = true; let pipeline = Pipeline::with_config(config); let result = pipeline.run(fixture("benign.pdf")); assert!(result.is_ok(), "abort-on-threat should succeed for benign PDF"); } // --------------------------------------------------------------------------- // Educational whitelisting // --------------------------------------------------------------------------- #[test] fn cve_writeup_md_whitelisted_as_educational() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("cve_writeup.md")).unwrap(); // CVE writeups should produce educational findings, not malicious. let has_malicious = result .scan_report .findings .iter() .any(|f| matches!(f.classification, ThreatClassification::Malicious(_))); assert!(!has_malicious, "CVE writeup should not produce malicious findings"); } #[test] fn cve_writeup_pdf_whitelisted_as_educational() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("cve_writeup.pdf")).unwrap(); let has_malicious = result .scan_report .findings .iter() .any(|f| matches!(f.classification, ThreatClassification::Malicious(_))); assert!(!has_malicious, "CVE writeup PDF should not produce malicious findings"); } // --------------------------------------------------------------------------- // Report content verification // --------------------------------------------------------------------------- #[test] fn json_report_contains_schema_version() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious_js.pdf")).unwrap(); let report_path = result .json_report_path .expect("JSON report should exist for malicious PDF"); let report_str = std::fs::read_to_string(&report_path).unwrap(); let report: serde_json::Value = serde_json::from_str(&report_str).unwrap(); assert_eq!( report["schema_version"], 1, "report should have schema_version = 1" ); assert!( report["findings"].as_array().unwrap().len() > 0, "report should have at least one finding" ); assert!( report["source"].as_object().is_some(), "report should have a source object" ); } #[test] fn quarantine_tarball_contains_original_file() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let result = pipeline.run(fixture("malicious_js.pdf")).unwrap(); let tarball_path = result .quarantine_path .expect("quarantine tarball should exist"); let tarball_gz = std::fs::File::open(&tarball_path).unwrap(); let decoder = flate2::read::GzDecoder::new(tarball_gz); let mut archive = tar::Archive::new(decoder); let entries: Vec = archive .entries() .unwrap() .filter_map(|e| e.ok()) .map(|e| e.path().unwrap().to_string_lossy().to_string()) .collect(); assert!( entries.iter().any(|e| e.starts_with("original.")), "quarantine tarball should contain original.pdf, entries: {entries:?}" ); assert!( entries.iter().any(|e| e == "report.json"), "quarantine tarball should contain report.json, entries: {entries:?}" ); } // --------------------------------------------------------------------------- // Total-archive-scan-cap (TODO #8) // --------------------------------------------------------------------------- #[test] fn total_archive_cap_limits_cumulative_reads() { // Parse an EPUB with a very low total cap. let bytes = std::fs::read(fixture("benign.epub")).unwrap(); let mut config = Config::default(); config.total_archive_scan_cap = 100; // 100 bytes — absurdly low. let doc = Dispatcher::parse(&bytes, DocumentFormat::Epub, None, &config).unwrap(); // Some entries should have been skipped due to budget exhaustion. let has_budget_msg = doc .executable_vectors .iter() .any(|v| { v.decoded_preview .as_deref() .map(|p| p.contains("total-archive-budget exhausted")) .unwrap_or(false) }); assert!( has_budget_msg, "with 100-byte total cap, some entries should be budget-exhausted" ); } // --------------------------------------------------------------------------- // Payload carving v2: .hex and .info files // --------------------------------------------------------------------------- #[test] fn quarantine_produces_hex_and_info_files() { let tmp = tempdir().unwrap(); let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path())); let _result = pipeline.run(fixture("malicious_js.pdf")).unwrap(); let q_dir = tmp.path().join("corbel_quarantine"); let hex_files: Vec<_> = std::fs::read_dir(&q_dir) .unwrap() .filter_map(|e| e.ok()) .filter(|e| { e.path() .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext == "hex") .unwrap_or(false) }) .collect(); assert!( !hex_files.is_empty(), "quarantine should produce at least one .hex file" ); // Verify the .hex file has the xxd-style format. let first_hex = std::fs::read_to_string(hex_files[0].path()).unwrap(); assert!( first_hex.starts_with("00000000:"), ".hex file should start with offset, got: {first_hex:?}" ); // Verify .info files exist too. let info_files: Vec<_> = std::fs::read_dir(&q_dir) .unwrap() .filter_map(|e| e.ok()) .filter(|e| { e.path() .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext == "info") .unwrap_or(false) }) .collect(); assert!( !info_files.is_empty(), "quarantine should produce at least one .info file" ); // Verify the .info file is valid JSON with required fields. let first_info_str = std::fs::read_to_string(info_files[0].path()).unwrap(); let info: serde_json::Value = serde_json::from_str(&first_info_str).unwrap(); assert!(info.get("filename").is_some(), ".info should have filename"); assert!( info.get("payload_sha256").is_some(), ".info should have payload_sha256" ); assert!( info.get("payload_size_bytes").is_some(), ".info should have payload_size_bytes" ); assert!( info.get("classification").is_some(), ".info should have classification" ); }