feat(onboarding): artifact ingest + classifier + native tramiton + suite seams (#138)
This commit was merged in pull request #138.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
//! Firmware classification via tramiton.
|
||||
//!
|
||||
//! tramiton is the company's firmware build/repro engine; we do not re-implement
|
||||
//! its detection. We depend on `tramiton-core` directly (same-company IP) and run
|
||||
//! its provider analysis in-process behind a [`FirmwareDetector`] port, mapping
|
||||
//! tramiton's `BuildPlan` onto a [`TargetType`]. A deterministic
|
||||
//! [`MockFirmwareDetector`] backs the tests so CI unit tests need neither the
|
||||
//! tramiton sources nor a real firmware tree.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{DetectedFact, TargetType};
|
||||
use compliance_core::traits::ClassifierVerdict;
|
||||
|
||||
/// A minimal firmware-detection summary, mapped from tramiton's `BuildPlan`.
|
||||
/// Kept small and tramiton-independent so the classifier and the test mock don't
|
||||
/// need to construct a full tramiton plan.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FirmwareDetection {
|
||||
/// The detecting provider (e.g. `zephyr`, `cmake`, `source-archaeology`).
|
||||
pub provider: String,
|
||||
/// Detection confidence: `low` | `medium` | `high`.
|
||||
pub confidence: String,
|
||||
/// Build-system label (e.g. `Zephyr`, `ESP-IDF`, `CMake`).
|
||||
pub build_system: String,
|
||||
/// Framework, when known (`zephyr`, `esp-idf`, `bare-metal`, ...).
|
||||
pub framework: Option<String>,
|
||||
/// Target board / MCU / arch.
|
||||
pub target: FirmwareTarget,
|
||||
/// Unresolved gaps in the plan.
|
||||
pub gaps: Vec<String>,
|
||||
}
|
||||
|
||||
/// The detected firmware target (board / MCU / arch).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FirmwareTarget {
|
||||
/// Board name.
|
||||
pub board: Option<String>,
|
||||
/// MCU part.
|
||||
pub mcu: Option<String>,
|
||||
/// Architecture.
|
||||
pub arch: Option<String>,
|
||||
}
|
||||
|
||||
/// A source of tramiton firmware detection.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait FirmwareDetector: Send + Sync {
|
||||
/// Run detection over a path, returning a firmware detection if tramiton
|
||||
/// could form a build plan.
|
||||
async fn detect(&self, path: &Path) -> Result<Option<FirmwareDetection>, CoreError>;
|
||||
}
|
||||
|
||||
/// Uses `tramiton-core` in-process. The analysis is blocking (filesystem walk),
|
||||
/// so it runs on a blocking thread to avoid stalling the async runtime. A path
|
||||
/// with no recognizable build system yields `Ok(None)`.
|
||||
pub struct TramitonNative;
|
||||
|
||||
impl FirmwareDetector for TramitonNative {
|
||||
async fn detect(&self, path: &Path) -> Result<Option<FirmwareDetection>, CoreError> {
|
||||
let path = path.to_path_buf();
|
||||
let plan = tokio::task::spawn_blocking(move || {
|
||||
let repo = tramiton_core::Repo::new(&path);
|
||||
tramiton_core::provider::analyze(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CoreError::Other(format!("tramiton detect task join error: {e}")))?
|
||||
.map_err(|e| CoreError::Other(format!("tramiton analyze error: {e}")))?;
|
||||
Ok(plan.map(|bp| detection_from_build_plan(&bp)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map tramiton's `BuildPlan` onto our minimal detection summary.
|
||||
fn detection_from_build_plan(bp: &tramiton_core::BuildPlan) -> FirmwareDetection {
|
||||
FirmwareDetection {
|
||||
provider: bp.provider.clone(),
|
||||
confidence: bp.confidence.to_string(),
|
||||
build_system: bp.build_system.label().to_string(),
|
||||
framework: bp.framework.clone(),
|
||||
target: FirmwareTarget {
|
||||
board: bp.target.board.clone(),
|
||||
mcu: bp.target.mcu.clone(),
|
||||
arch: bp.target.arch.clone(),
|
||||
},
|
||||
gaps: bp.gaps.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a firmware detection to a target type. Framework/build-system signals
|
||||
/// distinguish RTOS from bare-metal from Yocto.
|
||||
pub fn detection_to_target_type(det: &FirmwareDetection) -> TargetType {
|
||||
let framework = det.framework.as_deref().unwrap_or("").to_lowercase();
|
||||
let build_system = det.build_system.to_lowercase();
|
||||
let signal = format!("{framework} {build_system} {}", det.provider.to_lowercase());
|
||||
|
||||
const RTOS: [&str; 6] = ["zephyr", "esp-idf", "freertos", "nuttx", "riot", "chibios"];
|
||||
if signal.contains("bitbake") || signal.contains("yocto") || signal.contains("openembedded") {
|
||||
TargetType::EmbeddedLinuxYocto
|
||||
} else if RTOS.iter().any(|k| signal.contains(k)) {
|
||||
TargetType::FirmwareRtos
|
||||
} else {
|
||||
TargetType::FirmwareBareMetal
|
||||
}
|
||||
}
|
||||
|
||||
/// Map tramiton's confidence label to a `[0,1]` score.
|
||||
fn confidence_score(label: &str) -> f32 {
|
||||
match label.to_lowercase().as_str() {
|
||||
"high" => 0.9,
|
||||
"medium" => 0.6,
|
||||
"low" => 0.3,
|
||||
_ => 0.4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a firmware detection into a classifier verdict, carrying the MCU / board
|
||||
/// / build-system as facts.
|
||||
pub fn detection_to_verdict(det: &FirmwareDetection) -> ClassifierVerdict {
|
||||
let target_type = detection_to_target_type(det);
|
||||
let mut facts = vec![DetectedFact::new(
|
||||
"build_system",
|
||||
det.build_system.clone(),
|
||||
"tramiton",
|
||||
)];
|
||||
if let Some(fw) = &det.framework {
|
||||
facts.push(DetectedFact::new("framework", fw.clone(), "tramiton"));
|
||||
}
|
||||
if let Some(mcu) = &det.target.mcu {
|
||||
facts.push(DetectedFact::new("mcu", mcu.clone(), "tramiton"));
|
||||
}
|
||||
if let Some(board) = &det.target.board {
|
||||
facts.push(DetectedFact::new("board", board.clone(), "tramiton"));
|
||||
}
|
||||
if let Some(arch) = &det.target.arch {
|
||||
facts.push(DetectedFact::new("arch", arch.clone(), "tramiton"));
|
||||
}
|
||||
ClassifierVerdict {
|
||||
target_type,
|
||||
confidence: confidence_score(&det.confidence),
|
||||
facts,
|
||||
rationale: format!(
|
||||
"tramiton detected build system '{}'{}",
|
||||
det.build_system,
|
||||
det.framework
|
||||
.as_ref()
|
||||
.map(|f| format!(" (framework {f})"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic [`FirmwareDetector`] for tests — returns a preset detection.
|
||||
pub struct MockFirmwareDetector {
|
||||
/// The detection to return (or `None` for "no detection").
|
||||
pub detection: Option<FirmwareDetection>,
|
||||
}
|
||||
|
||||
impl FirmwareDetector for MockFirmwareDetector {
|
||||
async fn detect(&self, _path: &Path) -> Result<Option<FirmwareDetection>, CoreError> {
|
||||
Ok(self.detection.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn detection(build_system: &str, framework: Option<&str>) -> FirmwareDetection {
|
||||
FirmwareDetection {
|
||||
provider: build_system.to_string(),
|
||||
confidence: "high".to_string(),
|
||||
build_system: build_system.to_string(),
|
||||
framework: framework.map(|s| s.to_string()),
|
||||
target: FirmwareTarget {
|
||||
mcu: Some("stm32f429".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
gaps: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zephyr_maps_to_rtos() {
|
||||
assert_eq!(
|
||||
detection_to_target_type(&detection("zephyr", Some("zephyr"))),
|
||||
TargetType::FirmwareRtos
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_cmake_maps_to_bare_metal() {
|
||||
assert_eq!(
|
||||
detection_to_target_type(&detection("cmake", Some("bare-metal"))),
|
||||
TargetType::FirmwareBareMetal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitbake_maps_to_yocto() {
|
||||
assert_eq!(
|
||||
detection_to_target_type(&detection("bitbake", None)),
|
||||
TargetType::EmbeddedLinuxYocto
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_carries_mcu_fact_and_confidence() {
|
||||
let v = detection_to_verdict(&detection("esp-idf", Some("esp-idf")));
|
||||
assert_eq!(v.target_type, TargetType::FirmwareRtos);
|
||||
assert!((v.confidence - 0.9).abs() < f32::EPSILON);
|
||||
assert!(v
|
||||
.facts
|
||||
.iter()
|
||||
.any(|f| f.key == "mcu" && f.value == "stm32f429"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! 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<String>,
|
||||
dirs: HashSet<String>,
|
||||
exts: HashSet<String>,
|
||||
}
|
||||
|
||||
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<ClassifierVerdict> {
|
||||
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<ClassifierVerdict> {
|
||||
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<Vec<ClassifierVerdict>, 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<DetectedFact>,
|
||||
) -> ClassifierVerdict {
|
||||
ClassifierVerdict {
|
||||
target_type,
|
||||
confidence,
|
||||
facts,
|
||||
rationale: rationale.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn facts_lang(lang: &str) -> Vec<DetectedFact> {
|
||||
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<ClassifierVerdict> {
|
||||
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"), "<manifest/>").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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Target classification.
|
||||
//!
|
||||
//! Runs the classifier registry over a target's artifacts and their ingested
|
||||
//! working paths, then merges and ranks the verdicts into a [`Classification`].
|
||||
//! The registry is the heuristic classifier (artifact kinds + source markers)
|
||||
//! plus the tramiton firmware detector (behind a [`FirmwareDetector`] port).
|
||||
|
||||
mod firmware;
|
||||
mod language;
|
||||
|
||||
pub use firmware::{
|
||||
FirmwareDetection, FirmwareDetector, FirmwareTarget, MockFirmwareDetector, TramitonNative,
|
||||
};
|
||||
pub use language::HeuristicClassifier;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{
|
||||
ArtifactKind, Classification, DetectedFact, OnboardedTarget, TargetType, TargetTypeCandidate,
|
||||
};
|
||||
use compliance_core::traits::{ClassificationInput, ClassifierVerdict, TargetClassifier};
|
||||
|
||||
use firmware::detection_to_verdict;
|
||||
|
||||
/// Classify a target from its artifacts and their ingested working paths, using
|
||||
/// the heuristic classifier plus the tramiton firmware detector. Verdicts are
|
||||
/// merged (max confidence per target type) and ranked into a [`Classification`].
|
||||
pub async fn classify_target<D: FirmwareDetector>(
|
||||
target: &OnboardedTarget,
|
||||
working_paths: &HashMap<String, PathBuf>,
|
||||
firmware_detector: &D,
|
||||
) -> Result<Classification, CoreError> {
|
||||
let input = ClassificationInput {
|
||||
artifacts: &target.artifacts,
|
||||
working_paths,
|
||||
description: target.description.as_deref(),
|
||||
};
|
||||
|
||||
let mut verdicts = Vec::new();
|
||||
let mut detected_by = Vec::new();
|
||||
|
||||
let heuristic = HeuristicClassifier.classify(&input).await?;
|
||||
if !heuristic.is_empty() {
|
||||
detected_by.push("heuristic".to_string());
|
||||
}
|
||||
verdicts.extend(heuristic);
|
||||
|
||||
// Tramiton firmware detection over firmware / code working paths.
|
||||
let mut tramiton_used = false;
|
||||
for artifact in &target.artifacts {
|
||||
if !matches!(
|
||||
artifact.kind,
|
||||
ArtifactKind::FirmwareImage | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = working_paths.get(&artifact.id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(detection) = firmware_detector.detect(path).await? {
|
||||
verdicts.push(detection_to_verdict(&detection));
|
||||
tramiton_used = true;
|
||||
}
|
||||
}
|
||||
if tramiton_used {
|
||||
detected_by.push("tramiton".to_string());
|
||||
}
|
||||
|
||||
Ok(rank(verdicts, detected_by, target.target_type))
|
||||
}
|
||||
|
||||
/// Merge verdicts by target type (keeping the max confidence and its rationale),
|
||||
/// dedupe facts, rank by descending confidence, and assemble a [`Classification`].
|
||||
/// Falls back to the declared type when no verdict is produced.
|
||||
fn rank(
|
||||
verdicts: Vec<ClassifierVerdict>,
|
||||
detected_by: Vec<String>,
|
||||
fallback: TargetType,
|
||||
) -> Classification {
|
||||
let mut best: HashMap<TargetType, (f32, String)> = HashMap::new();
|
||||
let mut facts: Vec<DetectedFact> = Vec::new();
|
||||
for verdict in verdicts {
|
||||
for fact in verdict.facts {
|
||||
if !facts
|
||||
.iter()
|
||||
.any(|e| e.key == fact.key && e.value == fact.value)
|
||||
{
|
||||
facts.push(fact);
|
||||
}
|
||||
}
|
||||
let entry = best
|
||||
.entry(verdict.target_type)
|
||||
.or_insert((0.0, String::new()));
|
||||
if verdict.confidence > entry.0 {
|
||||
*entry = (verdict.confidence, verdict.rationale);
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates: Vec<TargetTypeCandidate> = best
|
||||
.into_iter()
|
||||
.map(
|
||||
|(target_type, (confidence, rationale))| TargetTypeCandidate {
|
||||
target_type,
|
||||
confidence,
|
||||
rationale,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
// Descending confidence; ties broken by type name for deterministic ordering.
|
||||
candidates.sort_by(|a, b| {
|
||||
b.confidence
|
||||
.partial_cmp(&a.confidence)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.target_type.to_string().cmp(&b.target_type.to_string()))
|
||||
});
|
||||
|
||||
let suggested = candidates
|
||||
.first()
|
||||
.map(|c| c.target_type)
|
||||
.unwrap_or(fallback);
|
||||
|
||||
Classification {
|
||||
suggested,
|
||||
candidates,
|
||||
facts,
|
||||
detected_by,
|
||||
detected_at: chrono::Utc::now(),
|
||||
confirmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::Artifact;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("cs-classify-mod-{}", 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);
|
||||
}
|
||||
}
|
||||
|
||||
fn no_firmware() -> MockFirmwareDetector {
|
||||
MockFirmwareDetector { detection: None }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_repo_classifies_as_backend() {
|
||||
let scratch = Scratch::new();
|
||||
fs::write(scratch.0.join("go.mod"), "module x").unwrap();
|
||||
|
||||
let artifact = Artifact::git_repo("https://git/x", "main");
|
||||
let mut wp = HashMap::new();
|
||||
wp.insert(artifact.id.clone(), scratch.0.clone());
|
||||
let mut target = OnboardedTarget::new("x".to_string(), TargetType::WebApp);
|
||||
target.artifacts.push(artifact);
|
||||
|
||||
let c = classify_target(&target, &wp, &no_firmware())
|
||||
.await
|
||||
.expect("classify");
|
||||
assert_eq!(c.suggested, TargetType::BackendService);
|
||||
assert!(c.detected_by.contains(&"heuristic".to_string()));
|
||||
assert!(!c.confirmed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn firmware_detector_verdict_ranks_top() {
|
||||
let scratch = Scratch::new();
|
||||
fs::write(scratch.0.join("fw.bin"), b"x").unwrap();
|
||||
|
||||
let artifact =
|
||||
Artifact::firmware_image(scratch.0.join("fw.bin").to_string_lossy().to_string());
|
||||
let mut wp = HashMap::new();
|
||||
wp.insert(artifact.id.clone(), scratch.0.clone());
|
||||
let mut target = OnboardedTarget::new("fw".to_string(), TargetType::FirmwareBareMetal);
|
||||
target.artifacts.push(artifact);
|
||||
|
||||
let detector = MockFirmwareDetector {
|
||||
detection: Some(FirmwareDetection {
|
||||
provider: "zephyr".to_string(),
|
||||
confidence: "high".to_string(),
|
||||
build_system: "zephyr".to_string(),
|
||||
framework: Some("zephyr".to_string()),
|
||||
target: FirmwareTarget {
|
||||
mcu: Some("nrf52840".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
gaps: vec![],
|
||||
}),
|
||||
};
|
||||
|
||||
let c = classify_target(&target, &wp, &detector)
|
||||
.await
|
||||
.expect("classify");
|
||||
// tramiton's high-confidence RTOS verdict beats the weak firmware prior.
|
||||
assert_eq!(c.suggested, TargetType::FirmwareRtos);
|
||||
assert!(c.detected_by.contains(&"tramiton".to_string()));
|
||||
assert!(c.facts.iter().any(|f| f.key == "mcu"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_signal_falls_back_to_declared_type() {
|
||||
let scratch = Scratch::new();
|
||||
let _ = Path::new(&scratch.0);
|
||||
let target = OnboardedTarget::new("empty".to_string(), TargetType::DesktopApp);
|
||||
let wp = HashMap::new();
|
||||
let c = classify_target(&target, &wp, &no_firmware())
|
||||
.await
|
||||
.expect("classify");
|
||||
assert_eq!(c.suggested, TargetType::DesktopApp);
|
||||
assert!(c.candidates.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user