//! 6-layer malware scanning pipeline for file uploads. //! //! Layers 1-4 always run (in-process, deterministic). Layers 5-6 are optional //! (external services). //! //! **Error policy is per-layer**, declared at each layer's source file as //! `pub const ERROR_POLICY`. The aggregator `final_status` consults each //! layer's policy via `error_policy_for`. In-process layers are `FailClosed` //! (an error is a structural defect); external layers are `FailOpen` (an //! error is an outage that must not block the platform). See //! `docs/scan-pipeline-audit.md` for the rationale. //! //! See also: `/docs/tech/content-protection` pub mod archive; pub mod clamav; pub mod content_type; pub mod hash_lookup; pub mod metadefender; pub mod signing_linux; pub mod signing_macos; pub mod signing_windows; pub mod spool; pub mod structural; pub mod urlhaus; pub mod worker; pub mod yara; use serde::Serialize; use sha2::{Digest, Sha256}; use crate::config::ScanConfig; use crate::db::FileScanStatus; use crate::storage::FileType; /// Per-layer scan verdict #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum LayerVerdict { Pass, Fail, Skip, Error, } /// Policy for how a layer's `Error` verdict feeds into the pipeline's final status. /// /// - `FailClosed` — an `Error` from this layer holds the upload for admin review. /// Appropriate for deterministic in-process layers where an `Error` indicates a /// real bug or a structurally suspicious file. /// - `FailOpen` — an `Error` from this layer is treated as `Skip` for aggregation. /// Appropriate for external services (network, daemons) where an outage on a /// third party must not take down the platform's upload pipeline. /// /// Each layer declares its own `ERROR_POLICY` const; the aggregator in /// `ScanPipeline::final_status` consults the declaration via `error_policy_for`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ErrorPolicy { FailClosed, FailOpen, } /// Result from a single scanning layer #[derive(Debug, Clone, Serialize)] pub struct LayerResult { pub layer: &'static str, pub verdict: LayerVerdict, pub detail: Option, } /// Look up a layer's declared error policy by name. Defaults to `FailClosed` /// for unknown layers — a defensive choice that surfaces uninstrumented /// additions during testing rather than silently fail-opening them. fn error_policy_for(layer: &str) -> ErrorPolicy { match layer { "content_type" => content_type::ERROR_POLICY, "structural" => structural::ERROR_POLICY, "archive" => archive::ERROR_POLICY, "yara" => yara::ERROR_POLICY, "clamav" => clamav::ERROR_POLICY, "malwarebazaar" => hash_lookup::ERROR_POLICY, "urlhaus" => urlhaus::ERROR_POLICY, "signing_macos" => signing_macos::ERROR_POLICY, "signing_windows" => signing_windows::ERROR_POLICY, "signing_linux" => signing_linux::ERROR_POLICY, "metadefender" => metadefender::ERROR_POLICY, other => { tracing::error!(layer = other, "unknown scan layer; defaulting to FailClosed"); ErrorPolicy::FailClosed } } } /// Decide whether the second-opinion (MetaDefender) layer should run, based /// on the verdicts of layers that have already completed. Any `Fail`, or any /// `Error` from a fail-closed in-process layer, counts as "suspicious enough /// to escalate". Pure `Error`s from fail-open external layers do not — those /// are operational noise, not malware signals. fn suspicion_present(layers: &[LayerResult]) -> bool { layers.iter().any(|l| { match l.verdict { LayerVerdict::Fail => true, LayerVerdict::Error => error_policy_for(l.layer) == ErrorPolicy::FailClosed, _ => false, } }) } /// Aggregate per-layer results into a final scan status. /// /// - Any layer `Fail` → `Quarantined` (terminal). /// - Any layer `Error` whose policy is `FailClosed` → `HeldForReview`. /// - `FailOpen` errors are treated as Skip-equivalent for aggregation; they're /// still surfaced in the per-layer detail so admins and PoM can see degraded /// layers in the dashboard. /// - Otherwise → `Clean`. fn final_status(layers: &[LayerResult]) -> FileScanStatus { if layers.iter().any(|l| l.verdict == LayerVerdict::Fail) { return FileScanStatus::Quarantined; } let has_fail_closed_error = layers.iter().any(|l| { l.verdict == LayerVerdict::Error && error_policy_for(l.layer) == ErrorPolicy::FailClosed }); if has_fail_closed_error { FileScanStatus::HeldForReview } else { FileScanStatus::Clean } } /// Aggregate scan result across all layers #[derive(Debug, Clone)] pub struct ScanResult { pub status: FileScanStatus, pub layers: Vec, pub sha256: String, pub file_size: u64, } /// Pre-compiled scanning pipeline. Initialized once at startup and shared via Arc. pub struct ScanPipeline { yara_rules: Option, /// Number of YARA rule files that compiled, and the configured health floor. yara_rule_count: usize, yara_min_rule_files: usize, clamav_socket: Option, malwarebazaar_enabled: bool, urlhaus_enabled: bool, abuse_ch_auth_key: Option, metadefender_api_key: Option, } impl ScanPipeline { /// Create a new pipeline, compiling YARA rules from the configured directory. pub fn new(config: &ScanConfig) -> Result { let (yara_rules, yara_rule_count) = yara::compile_rules_from_dir(&config.yara_rules_dir)?; Ok(ScanPipeline { yara_rules, yara_rule_count, yara_min_rule_files: config.yara_min_rule_files, clamav_socket: config.clamav_socket.clone(), malwarebazaar_enabled: config.malwarebazaar_enabled, urlhaus_enabled: config.urlhaus_enabled, abuse_ch_auth_key: config.abuse_ch_auth_key.clone(), metadefender_api_key: config.metadefender_api_key.clone(), }) } /// Assert at startup that at least one real AV layer is live. Refuse to /// boot otherwise — ClamAV's FailOpen policy means a dead clamd /// silently passes every upload as Clean, and a YARA-rules-empty deploy /// gives the same false sense of coverage. If the operator configured /// scanning, a misconfiguration must be loud at boot, not silent at runtime. pub async fn assert_live(&self) -> Result<(), String> { let mut live_layers: Vec<&str> = Vec::new(); if let Some(ref socket) = self.clamav_socket { match clamav::ping(socket).await { Ok(()) => live_layers.push("clamav"), Err(e) => { return Err(format!("ClamAV socket {socket} unreachable: {e}")); } } } if self.yara_rules.is_some() { // Expected-rule-count floor: a corpus that quietly dropped below the // operator-declared size (e.g. a yara-x upgrade made N rules // uncompilable) is degraded coverage masquerading as a live layer. // Fail boot loudly when a floor is set and we're under it. if self.yara_min_rule_files > 0 && self.yara_rule_count < self.yara_min_rule_files { return Err(format!( "YARA corpus degraded: {} rule files compiled, below the configured \ floor of {} (YARA_MIN_RULE_FILES). Refusing to boot — a silently \ shrunken rule set is false coverage.", self.yara_rule_count, self.yara_min_rule_files, )); } live_layers.push("yara"); } if self.malwarebazaar_enabled { live_layers.push("malwarebazaar"); } if self.urlhaus_enabled { live_layers.push("urlhaus"); } if self.metadefender_api_key.is_some() { live_layers.push("metadefender"); } if live_layers.is_empty() { return Err( "Scanning configured but no AV layer is live (no ClamAV socket, \ no YARA rules, no remote API keys). Refusing to boot — the \ FailOpen policy would pass every upload as Clean." .to_string(), ); } tracing::info!(layers = ?live_layers, "scan pipeline live layers asserted"); Ok(()) } /// Run all applicable scanning layers against file data. /// /// CPU-bound layers (sha256, content-type, structural, archive, yara) run /// on a blocking-pool thread via `spawn_blocking` so they don't stall the /// tokio runtime — the 4 in-process layers can each take seconds on a /// 100 MB file. Network-bound layers (ClamAV, MalwareBazaar) run /// concurrently with the sync block via `tokio::join!`. pub async fn scan(self: std::sync::Arc, data: Vec, file_type: FileType) -> ScanResult { let file_size = data.len() as u64; let data = std::sync::Arc::<[u8]>::from(data); // Sync layers + hash, off the runtime let sync_data = std::sync::Arc::clone(&data); let sync_self = std::sync::Arc::clone(&self); let sync_fut = tokio::task::spawn_blocking(move || sync_self.run_sync_layers(&sync_data, file_type)); // Async layers — ClamAV needs the bytes, MalwareBazaar needs only the hash // but the hash is computed in the sync block, so we run MalwareBazaar // after the sync block returns (its endpoint is fast). ClamAV can run // concurrently with the sync block. let clamav_data = std::sync::Arc::clone(&data); let clamav_socket = self.clamav_socket.clone(); let clamav_fut = async move { match clamav_socket { Some(socket) => clamav::scan_with_clamav(&socket, &clamav_data).await, None => LayerResult { layer: "clamav", verdict: LayerVerdict::Skip, detail: Some("ClamAV not configured".to_string()), }, } }; // Layer 7: URLhaus — extract URLs from the bytes, query hosts. // Runs concurrently with the sync block (independent of the hash). let urlhaus_data = std::sync::Arc::clone(&data); let urlhaus_enabled = self.urlhaus_enabled; let urlhaus_key = self.abuse_ch_auth_key.clone(); let urlhaus_fut = async move { if urlhaus_enabled { urlhaus::check_urlhaus(&urlhaus_data, urlhaus_key.as_deref()).await } else { LayerResult { layer: "urlhaus", verdict: LayerVerdict::Skip, detail: Some("URLhaus lookups disabled".to_string()), } } }; let (sync_result, clamav_result, urlhaus_result) = tokio::join!(sync_fut, clamav_fut, urlhaus_fut); let (mut layers, sha256) = sync_result .expect("scan_sync spawn_blocking panicked"); layers.push(clamav_result); layers.push(urlhaus_result); // Layer 6: MalwareBazaar — needs the hash from the sync block layers.push(if self.malwarebazaar_enabled { hash_lookup::check_malwarebazaar(&sha256, self.abuse_ch_auth_key.as_deref()).await } else { LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Skip, detail: Some("MalwareBazaar lookups disabled".to_string()), } }); // Layer 9: MetaDefender (second-opinion). Only invoked when a prior // layer flagged the file as suspicious — keeps us within the free-tier // quota and avoids spending budget on uncontroversial uploads. layers.push(if suspicion_present(&layers) { metadefender::check_metadefender(&sha256, self.metadefender_api_key.as_deref()).await } else { LayerResult { layer: "metadefender", verdict: LayerVerdict::Skip, detail: Some("No prior suspicion; second-opinion not invoked".to_string()), } }); let status = final_status(&layers); ScanResult { status, layers, sha256, file_size, } } /// Streaming counterpart to `scan`. Runs against a spooled tempfile so /// the >100 MB case doesn't hold the whole object in RAM. The CPU /// layers operate on a memory mapping (`spool::mmap_read`) — pages are /// demand-paged by the kernel as goblin / yara-x / archive walk them — /// and ClamAV streams the file via INSTREAM frames. pub async fn scan_stream( self: std::sync::Arc, spool: spool::SpoolHandle, file_type: FileType, ) -> ScanResult { let file_size = std::fs::metadata(spool.path()) .map(|m| m.len()) .unwrap_or(0); let map = match spool::mmap_read(spool.path()) { Ok(m) => std::sync::Arc::new(m), Err(e) => { let layer = LayerResult { layer: "spool", verdict: LayerVerdict::Error, detail: Some(e), }; return ScanResult { status: final_status(std::slice::from_ref(&layer)), layers: vec![layer], sha256: String::new(), file_size, }; } }; let sync_map = std::sync::Arc::clone(&map); let sync_self = std::sync::Arc::clone(&self); let sync_fut = tokio::task::spawn_blocking(move || sync_self.run_sync_layers(&sync_map, file_type)); let clamav_socket = self.clamav_socket.clone(); let clamav_path = spool.path().to_path_buf(); let clamav_fut = async move { match clamav_socket { Some(socket) => match tokio::fs::File::open(&clamav_path).await { Ok(file) => clamav::scan_with_clamav_stream(&socket, file).await, Err(e) => LayerResult { layer: "clamav", verdict: LayerVerdict::Error, detail: Some(format!("open spool for clamav: {e}")), }, }, None => LayerResult { layer: "clamav", verdict: LayerVerdict::Skip, detail: Some("ClamAV not configured".to_string()), }, } }; let urlhaus_map = std::sync::Arc::clone(&map); let urlhaus_enabled = self.urlhaus_enabled; let urlhaus_key = self.abuse_ch_auth_key.clone(); let urlhaus_fut = async move { if urlhaus_enabled { urlhaus::check_urlhaus(&urlhaus_map, urlhaus_key.as_deref()).await } else { LayerResult { layer: "urlhaus", verdict: LayerVerdict::Skip, detail: Some("URLhaus lookups disabled".to_string()), } } }; let (sync_result, clamav_result, urlhaus_result) = tokio::join!(sync_fut, clamav_fut, urlhaus_fut); let (mut layers, sha256) = sync_result .expect("scan_stream sync spawn_blocking panicked"); layers.push(clamav_result); layers.push(urlhaus_result); layers.push(if self.malwarebazaar_enabled { hash_lookup::check_malwarebazaar(&sha256, self.abuse_ch_auth_key.as_deref()).await } else { LayerResult { layer: "malwarebazaar", verdict: LayerVerdict::Skip, detail: Some("MalwareBazaar lookups disabled".to_string()), } }); layers.push(if suspicion_present(&layers) { metadefender::check_metadefender(&sha256, self.metadefender_api_key.as_deref()).await } else { LayerResult { layer: "metadefender", verdict: LayerVerdict::Skip, detail: Some("No prior suspicion; second-opinion not invoked".to_string()), } }); let status = final_status(&layers); drop(map); drop(spool); ScanResult { status, layers, sha256, file_size, } } /// CPU-bound layers + SHA-256. Pure sync; safe to call from `spawn_blocking`. fn run_sync_layers(&self, data: &[u8], file_type: FileType) -> (Vec, String) { let mut layers = Vec::with_capacity(5); // SHA-256 hash for audit + MalwareBazaar lookup let sha256 = { let mut hasher = Sha256::new(); hasher.update(data); hex::encode(hasher.finalize()) }; layers.push(content_type::verify_content_type(data, file_type)); layers.push(structural::analyze_binary(data, file_type)); layers.push(archive::check_archive_safety(data, file_type)); layers.push(match self.yara_rules { Some(ref rules) => yara::scan_with_yara(rules, data), None => LayerResult { layer: "yara", verdict: LayerVerdict::Skip, detail: Some("No YARA rules loaded".to_string()), }, }); layers.push(signing_macos::verify_apple_signature(data, file_type)); layers.push(signing_windows::verify_authenticode(data, file_type)); layers.push(signing_linux::verify_appimage_signature(data, file_type)); (layers, sha256) } } #[cfg(test)] mod tests { use super::*; #[test] fn layer_verdict_serializes_lowercase() { assert_eq!( serde_json::to_string(&LayerVerdict::Pass).unwrap(), "\"pass\"" ); assert_eq!( serde_json::to_string(&LayerVerdict::Fail).unwrap(), "\"fail\"" ); assert_eq!( serde_json::to_string(&LayerVerdict::Skip).unwrap(), "\"skip\"" ); assert_eq!( serde_json::to_string(&LayerVerdict::Error).unwrap(), "\"error\"" ); } #[test] fn scan_result_quarantined_on_any_fail() { let layers = [ LayerResult { layer: "test1", verdict: LayerVerdict::Pass, detail: None, }, LayerResult { layer: "test2", verdict: LayerVerdict::Fail, detail: Some("bad".to_string()), }, ]; let has_fail = layers.iter().any(|l| l.verdict == LayerVerdict::Fail); assert!(has_fail); } #[test] fn sha256_computation() { let mut hasher = Sha256::new(); hasher.update(b"hello"); let hash = hex::encode(hasher.finalize()); assert_eq!( hash, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ); } // -- Pipeline integration tests -- /// Create a minimal ScanPipeline with no external deps (no YARA, no ClamAV, no MalwareBazaar). /// Wrapped in `Arc` because `scan` consumes `Arc` (see `pub async fn scan`). fn make_pipeline() -> std::sync::Arc { std::sync::Arc::new(ScanPipeline { yara_rules: None, yara_rule_count: 0, yara_min_rule_files: 0, clamav_socket: None, malwarebazaar_enabled: false, urlhaus_enabled: false, abuse_ch_auth_key: None, metadefender_api_key: None, }) } #[tokio::test] async fn pipeline_clean_download_passes() { let pipeline = make_pipeline(); let result = pipeline.clone().scan(b"just some file content".to_vec(), FileType::Download).await; assert_eq!(result.status, FileScanStatus::Clean); assert_eq!(result.file_size, 22); assert!(!result.sha256.is_empty()); assert_eq!(result.layers.len(), 11); } #[tokio::test] async fn pipeline_unrecognized_audio_quarantined() { let pipeline = make_pipeline(); // Unrecognized data claimed as audio should be rejected by content_type layer let result = pipeline.clone().scan(b"audio data here".to_vec(), FileType::Audio).await; assert_eq!(result.status, FileScanStatus::Quarantined); } #[tokio::test] async fn pipeline_clean_cover_passes() { let pipeline = make_pipeline(); // PNG magic bytes let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; let result = pipeline.clone().scan(png.to_vec(), FileType::Cover).await; assert_eq!(result.status, FileScanStatus::Clean); } #[tokio::test] async fn pipeline_pe_as_audio_quarantined() { let pipeline = make_pipeline(); // PE magic bytes — content-type layer should detect application/* and fail let pe_header = b"MZ\x90\x00\x03\x00\x00\x00"; let result = pipeline.clone().scan(pe_header.to_vec(), FileType::Audio).await; assert_eq!(result.status, FileScanStatus::Quarantined); // Verify content_type layer produced the fail let content_type_layer = result.layers.iter().find(|l| l.layer == "content_type").unwrap(); assert_eq!(content_type_layer.verdict, LayerVerdict::Fail); } #[tokio::test] async fn pipeline_pe_as_cover_quarantined() { let pipeline = make_pipeline(); let pe_header = b"MZ\x90\x00\x03\x00\x00\x00"; let result = pipeline.clone().scan(pe_header.to_vec(), FileType::Cover).await; assert_eq!(result.status, FileScanStatus::Quarantined); } #[tokio::test] async fn pipeline_sha256_is_deterministic() { let pipeline = make_pipeline(); let data = b"deterministic hash test"; let r1 = pipeline.clone().scan(data.to_vec(), FileType::Download).await; let r2 = pipeline.clone().scan(data.to_vec(), FileType::Download).await; assert_eq!(r1.sha256, r2.sha256); } #[tokio::test] async fn pipeline_skips_optional_layers_when_unconfigured() { let pipeline = make_pipeline(); let result = pipeline.clone().scan(b"test".to_vec(), FileType::Download).await; let yara = result.layers.iter().find(|l| l.layer == "yara").unwrap(); assert_eq!(yara.verdict, LayerVerdict::Skip); let clamav = result.layers.iter().find(|l| l.layer == "clamav").unwrap(); assert_eq!(clamav.verdict, LayerVerdict::Skip); let mb = result.layers.iter().find(|l| l.layer == "malwarebazaar").unwrap(); assert_eq!(mb.verdict, LayerVerdict::Skip); let uh = result.layers.iter().find(|l| l.layer == "urlhaus").unwrap(); assert_eq!(uh.verdict, LayerVerdict::Skip); } #[tokio::test] async fn pipeline_always_produces_11_layers() { let pipeline = make_pipeline(); for file_type in [FileType::Audio, FileType::Cover, FileType::Download] { let result = pipeline.clone().scan(b"data".to_vec(), file_type).await; assert_eq!(result.layers.len(), 11, "Expected 11 layers for {:?}", file_type); } } #[test] fn suspicion_present_on_fail() { let layers = vec![pass("content_type"), fail("yara")]; assert!(suspicion_present(&layers)); } #[test] fn suspicion_present_on_fail_closed_error() { let layers = vec![pass("content_type"), err("archive")]; assert!(suspicion_present(&layers)); } #[test] fn no_suspicion_when_fail_open_error_only() { // External-layer errors are operational noise, not malware signals; // they must not invoke MetaDefender. let layers = vec![pass("content_type"), err("malwarebazaar"), err("urlhaus")]; assert!(!suspicion_present(&layers)); } #[test] fn no_suspicion_when_all_clean() { let layers = vec![pass("content_type"), skip("yara"), pass("structural")]; assert!(!suspicion_present(&layers)); } #[tokio::test] async fn pipeline_errors_held_for_review() { // Errors from fail-closed layers (archive is in-process deterministic) // should hold the file for admin review. let pipeline = make_pipeline(); // Corrupted ZIP magic bytes — archive layer returns Error let mut data = vec![0x50, 0x4B, 0x03, 0x04]; data.extend_from_slice(&[0xFF; 100]); let result = pipeline.clone().scan(data, FileType::Download).await; let archive = result.layers.iter().find(|l| l.layer == "archive").unwrap(); assert_eq!(archive.verdict, LayerVerdict::Error); assert_eq!(result.status, FileScanStatus::HeldForReview); } // -- Per-layer fail policy tests -- fn err(layer: &'static str) -> LayerResult { LayerResult { layer, verdict: LayerVerdict::Error, detail: None } } fn pass(layer: &'static str) -> LayerResult { LayerResult { layer, verdict: LayerVerdict::Pass, detail: None } } fn skip(layer: &'static str) -> LayerResult { LayerResult { layer, verdict: LayerVerdict::Skip, detail: None } } fn fail(layer: &'static str) -> LayerResult { LayerResult { layer, verdict: LayerVerdict::Fail, detail: None } } #[test] fn final_status_clean_when_all_pass() { let layers = vec![pass("content_type"), pass("structural"), pass("archive"), skip("yara"), skip("clamav"), skip("malwarebazaar")]; assert_eq!(final_status(&layers), FileScanStatus::Clean); } #[test] fn final_status_quarantined_on_any_fail() { let layers = vec![pass("content_type"), fail("yara"), skip("clamav")]; assert_eq!(final_status(&layers), FileScanStatus::Quarantined); } #[test] fn final_status_fail_beats_error() { // A Fail anywhere supersedes any Error, regardless of policy. let layers = vec![err("malwarebazaar"), fail("yara")]; assert_eq!(final_status(&layers), FileScanStatus::Quarantined); } #[test] fn final_status_held_on_fail_closed_error() { // archive is FailClosed — its Error must hold the file. let layers = vec![pass("content_type"), err("archive"), skip("clamav")]; assert_eq!(final_status(&layers), FileScanStatus::HeldForReview); } #[test] fn final_status_clean_on_fail_open_error_only() { // malwarebazaar is FailOpen — its Error must NOT hold the file. // This is the regression of 2026-05-10 that motivated the audit. let layers = vec![pass("content_type"), pass("structural"), pass("archive"), skip("yara"), skip("clamav"), err("malwarebazaar")]; assert_eq!(final_status(&layers), FileScanStatus::Clean); } #[test] fn final_status_clean_when_all_external_layers_error() { // Worst-case external-services outage: every network/daemon layer // erroring at once. As long as the in-process layers pass, the file // is Clean. Health is surfaced separately via per-layer monitoring. let layers = vec![pass("content_type"), pass("structural"), pass("archive"), skip("yara"), err("clamav"), err("malwarebazaar")]; assert_eq!(final_status(&layers), FileScanStatus::Clean); } #[test] fn final_status_held_on_unknown_layer_error() { // Defensive default: an unknown layer name that errors falls through // to FailClosed. This is what catches a new layer added without // wiring its policy into `error_policy_for`. let layers = vec![pass("content_type"), err("brand_new_layer_someone_forgot_to_register")]; assert_eq!(final_status(&layers), FileScanStatus::HeldForReview); } #[test] fn error_policy_for_all_known_layers() { // Every layer name produced by the pipeline must have an explicit // declaration in `error_policy_for`. The default branch is reserved // for genuine programmer error (new layer, forgot to register). for name in ["content_type", "structural", "archive", "yara", "clamav", "malwarebazaar"] { let policy = error_policy_for(name); // Both values are valid; we just want this to not hit the default. // If a layer is renamed without updating `error_policy_for`, this // test still passes (the rename produces a new unknown name) // — but the per-layer name tests below catch that. let _ = policy; } } #[test] fn content_type_is_fail_closed() { assert_eq!(error_policy_for("content_type"), ErrorPolicy::FailClosed); } #[test] fn structural_is_fail_closed() { assert_eq!(error_policy_for("structural"), ErrorPolicy::FailClosed); } #[test] fn archive_is_fail_closed() { assert_eq!(error_policy_for("archive"), ErrorPolicy::FailClosed); } #[test] fn yara_is_fail_closed() { assert_eq!(error_policy_for("yara"), ErrorPolicy::FailClosed); } #[test] fn clamav_is_fail_open() { assert_eq!(error_policy_for("clamav"), ErrorPolicy::FailOpen); } #[test] fn malwarebazaar_is_fail_open() { assert_eq!(error_policy_for("malwarebazaar"), ErrorPolicy::FailOpen); } #[test] fn urlhaus_is_fail_open() { assert_eq!(error_policy_for("urlhaus"), ErrorPolicy::FailOpen); } #[test] fn signing_macos_is_fail_open() { assert_eq!(error_policy_for("signing_macos"), ErrorPolicy::FailOpen); } #[test] fn metadefender_is_fail_open() { assert_eq!(error_policy_for("metadefender"), ErrorPolicy::FailOpen); } #[test] fn signing_windows_is_fail_open() { assert_eq!(error_policy_for("signing_windows"), ErrorPolicy::FailOpen); } #[test] fn signing_linux_is_fail_open() { assert_eq!(error_policy_for("signing_linux"), ErrorPolicy::FailOpen); } }