//! Heuristic target-type classification from artifact kinds and source markers. //! //! Complements the tramiton firmware detector: this handles web / backend / //! mobile / desktop / PLC by sniffing manifest files and file extensions in the //! ingested code trees, plus strong priors from the artifact kinds themselves //! (a PLC-project artifact is a PLC target; an `.ipa` is an iOS app). use std::collections::HashSet; use std::fs; use std::path::Path; use compliance_core::error::CoreError; use compliance_core::models::{ArtifactKind, DetectedFact, TargetType}; use compliance_core::traits::{ClassificationInput, ClassifierVerdict, TargetClassifier}; /// Max directory depth scanned for marker files. const SCAN_DEPTH: usize = 2; /// Markers collected from a code tree. #[derive(Default)] struct Markers { files: HashSet, dirs: HashSet, exts: HashSet, } impl Markers { fn has_file(&self, name: &str) -> bool { self.files.contains(name) } fn has_ext(&self, ext: &str) -> bool { self.exts.contains(ext) } fn any_dir_ends_with(&self, suffix: &str) -> bool { self.dirs.iter().any(|d| d.ends_with(suffix)) } } /// Recursively collect marker file/dir/extension names up to [`SCAN_DEPTH`]. fn collect_markers(root: &Path) -> Markers { let mut m = Markers::default(); scan_dir(root, 0, &mut m); m } fn scan_dir(dir: &Path, depth: usize, m: &mut Markers) { let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().to_lowercase(); if path.is_dir() { m.dirs.insert(name); if depth < SCAN_DEPTH { scan_dir(&path, depth + 1, m); } } else { if let Some(ext) = path.extension() { m.exts.insert(ext.to_string_lossy().to_lowercase()); } m.files.insert(name); } } } /// Whether a `package.json` at `root` looks like a front-end app. fn package_json_is_frontend(root: &Path) -> bool { let Ok(content) = fs::read_to_string(root.join("package.json")) else { return false; }; let c = content.to_lowercase(); ["react", "next", "vue", "@angular", "svelte", "vite"] .iter() .any(|f| c.contains(f)) } /// The heuristic classifier: artifact-kind priors + source-tree markers. pub struct HeuristicClassifier; impl HeuristicClassifier { /// Verdicts from the artifact kinds alone (no filesystem needed). fn kind_priors(&self, input: &ClassificationInput<'_>) -> Vec { let mut out = Vec::new(); for a in input.artifacts { let lower = a.source_ref.to_lowercase(); match a.kind { ArtifactKind::PlcProject => out.push(verdict( TargetType::PlcSps, 0.85, "PLC project artifact", vec![], )), ArtifactKind::MobilePackage => { let (tt, why) = if lower.ends_with(".ipa") { (TargetType::IosApp, "iOS package (.ipa)") } else { (TargetType::AndroidApp, "Android package (.apk/.aab)") }; out.push(verdict(tt, 0.85, why, vec![])); } ArtifactKind::ContainerImage => out.push(verdict( TargetType::BackendService, 0.4, "container image", vec![], )), ArtifactKind::FirmwareImage => out.push(verdict( TargetType::FirmwareBareMetal, 0.35, "firmware image (pending tramiton detection)", vec![], )), ArtifactKind::LiveUrl if input.artifacts.len() == 1 => { out.push(verdict(TargetType::WebApp, 0.3, "live URL only", vec![])) } _ => {} } } out } /// Verdicts from scanning the ingested code trees for manifest markers. fn source_verdicts(&self, input: &ClassificationInput<'_>) -> Vec { let mut out = Vec::new(); for a in input.artifacts { if !matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive) { continue; } let Some(path) = input.working_paths.get(&a.id) else { continue; }; let m = collect_markers(path); // Mobile (checked first — strongest signal). if m.has_file("androidmanifest.xml") || m.has_ext("apk") || m.has_ext("aab") { out.push(verdict( TargetType::AndroidApp, 0.8, "Android manifest / gradle", facts_lang("kotlin/java"), )); } if m.any_dir_ends_with(".xcodeproj") || m.has_file("info.plist") || m.has_file("podfile") || m.has_ext("ipa") { out.push(verdict( TargetType::IosApp, 0.8, "Xcode project / Info.plist", facts_lang("swift/objc"), )); } // Desktop. if m.has_ext("sln") || m.has_ext("csproj") || m.has_ext("vcxproj") || m.has_ext("desktop") { out.push(verdict( TargetType::DesktopApp, 0.7, "desktop project files", facts_lang("dotnet/native"), )); } // PLC. if m.has_ext("st") { out.push(verdict( TargetType::PlcSps, 0.8, "Structured Text sources", facts_lang("iec-61131-3"), )); } // Web vs backend from package.json. if m.has_file("package.json") { if package_json_is_frontend(path) { out.push(verdict( TargetType::WebApp, 0.65, "package.json with a front-end framework", facts_lang("javascript"), )); } else { out.push(verdict( TargetType::BackendService, 0.55, "package.json (no front-end framework)", facts_lang("javascript"), )); } } // Backend languages. for (file, lang) in [ ("cargo.toml", "rust"), ("go.mod", "go"), ("pom.xml", "java"), ("requirements.txt", "python"), ("pyproject.toml", "python"), ] { if m.has_file(file) { out.push(verdict( TargetType::BackendService, 0.6, "backend build manifest", facts_lang(lang), )); } } // Container-only. if m.has_file("dockerfile") && out.is_empty() { out.push(verdict( TargetType::BackendService, 0.4, "Dockerfile", facts_lang("container"), )); } } out } } impl TargetClassifier for HeuristicClassifier { fn name(&self) -> &str { "heuristic" } async fn classify( &self, input: &ClassificationInput<'_>, ) -> Result, CoreError> { let mut out = self.kind_priors(input); out.extend(self.source_verdicts(input)); Ok(out) } } fn verdict( target_type: TargetType, confidence: f32, rationale: &str, facts: Vec, ) -> ClassifierVerdict { ClassifierVerdict { target_type, confidence, facts, rationale: rationale.to_string(), } } fn facts_lang(lang: &str) -> Vec { vec![DetectedFact::new("language", lang, "heuristic")] } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; use compliance_core::models::Artifact; use std::collections::HashMap; use std::path::PathBuf; struct Scratch(PathBuf); impl Scratch { fn new() -> Self { let p = std::env::temp_dir().join(format!("cs-classify-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&p).expect("mkdir"); Self(p) } } impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } async fn classify_tree(setup: impl FnOnce(&Path)) -> Vec { let scratch = Scratch::new(); setup(&scratch.0); let artifact = Artifact::git_repo("https://git/x", "main"); let mut wp = HashMap::new(); wp.insert(artifact.id.clone(), scratch.0.clone()); let artifacts = vec![artifact]; let input = ClassificationInput { artifacts: &artifacts, working_paths: &wp, description: None, }; HeuristicClassifier .classify(&input) .await .expect("classify") } #[tokio::test] async fn frontend_package_json_is_webapp() { let v = classify_tree(|root| { fs::write( root.join("package.json"), r#"{"dependencies":{"react":"18"}}"#, ) .unwrap(); }) .await; assert!(v.iter().any(|x| x.target_type == TargetType::WebApp)); } #[tokio::test] async fn cargo_toml_is_backend() { let v = classify_tree(|root| { fs::write(root.join("Cargo.toml"), "[package]\nname='x'").unwrap(); }) .await; assert!(v .iter() .any(|x| x.target_type == TargetType::BackendService)); } #[tokio::test] async fn android_manifest_is_android() { let v = classify_tree(|root| { fs::write(root.join("AndroidManifest.xml"), "").unwrap(); }) .await; assert!(v.iter().any(|x| x.target_type == TargetType::AndroidApp)); } #[tokio::test] async fn structured_text_is_plc() { let v = classify_tree(|root| { fs::write(root.join("main.st"), "PROGRAM main END_PROGRAM").unwrap(); }) .await; assert!(v.iter().any(|x| x.target_type == TargetType::PlcSps)); } #[tokio::test] async fn ipa_artifact_prior_is_ios() { let artifacts = vec![Artifact::mobile_package("app.ipa")]; let wp = HashMap::new(); let input = ClassificationInput { artifacts: &artifacts, working_paths: &wp, description: None, }; let v = HeuristicClassifier .classify(&input) .await .expect("classify"); assert!(v.iter().any(|x| x.target_type == TargetType::IosApp)); } }