diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c973a8a..37c37ef 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,6 +13,9 @@ env: # both --features server and --features web shares common crate work. RUSTC_WRAPPER: /usr/local/bin/sccache SCCACHE_DIR: /tmp/sccache + # compliance-agent depends on tramiton-core via git; use the system git so the + # credential rewrite below (see "Configure git auth ...") is honored on fetch. + CARGO_NET_GIT_FETCH_WITH_CLI: "true" # Cancel in-progress runs for the same branch/PR concurrency: @@ -46,6 +49,18 @@ jobs: env: RUSTC_WRAPPER: "" + # compliance-agent has a git dependency on tramiton-core (a private repo on + # this Gitea instance). Rewrite its SSH URL to HTTPS + a read token so the + # runner can fetch it. Requires a repo secret TRAMITON_FETCH_TOKEN — a + # Gitea PAT for a user with read access to sharang/tramiton. + - name: Configure git auth for private tramiton dependency + run: | + git config --global \ + url."https://sharang:${{ secrets.TRAMITON_FETCH_TOKEN }}@gitea.meghsakha.com/".insteadOf \ + "ssh://git@gitea.meghsakha.com:22222/" + env: + RUSTC_WRAPPER: "" + # Format (no compilation needed) - name: Format run: cargo fmt --all --check diff --git a/Cargo.lock b/Cargo.lock index 2c4addf..8d9d782 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -692,6 +692,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "tramiton-core", "urlencoding", "uuid", "walkdir", @@ -4197,7 +4198,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -4996,6 +4997,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5810,6 +5820,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -5819,6 +5850,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_edit" version = "0.23.10+spec-1.0.0" @@ -5826,7 +5871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap 2.13.0", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] @@ -5840,6 +5885,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tonic" version = "0.12.3" @@ -6086,6 +6137,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "tramiton-core" +version = "0.4.0" +source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3" +dependencies = [ + "serde", + "tempfile", + "thiserror 1.0.69", + "toml", + "walkdir", +] + [[package]] name = "tree-sitter" version = "0.24.7" diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index d005516..4637751 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -10,6 +10,11 @@ workspace = true compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] } compliance-graph = { path = "../compliance-graph" } compliance-dast = { path = "../compliance-dast" } +# Native firmware build/target detection for bare-metal & RTOS artifacts. +# Same-company IP, used directly (not via CLI) so the whole tramiton suite is +# available to the onboarding classifier. NOTE: CI must be able to fetch this +# private repo (see the git-auth step in .gitea/workflows/ci.yml). +tramiton-core = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.0" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/compliance-agent/src/classify/firmware.rs b/compliance-agent/src/classify/firmware.rs index e6fb533..818cf29 100644 --- a/compliance-agent/src/classify/firmware.rs +++ b/compliance-agent/src/classify/firmware.rs @@ -1,55 +1,40 @@ //! Firmware classification via tramiton. //! //! tramiton is the company's firmware build/repro engine; we do not re-implement -//! its detection. This module shells out to `tramiton detect --json` behind a -//! [`FirmwareDetector`] port (so a future in-process or cloud impl can slot in) -//! and maps the resulting build plan onto a [`TargetType`]. A deterministic -//! [`MockFirmwareDetector`] backs the tests so CI never needs the binary. -//! -//! The parsed structs mirror a *subset* of tramiton's `BuildPlan` JSON — we -//! deliberately do not depend on the proprietary `tramiton-core` crate. +//! 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 serde::Deserialize; - use compliance_core::error::CoreError; use compliance_core::models::{DetectedFact, TargetType}; use compliance_core::traits::ClassifierVerdict; -/// The top-level `tramiton detect --json` document (fields we use). -#[derive(Debug, Clone, Deserialize)] -pub struct TramitonDetect { - /// The selected build plan, if tramiton could form one. - #[serde(default)] - pub plan: Option, -} - -/// The subset of tramiton's `BuildPlan` we consume for classification. -#[derive(Debug, Clone, Default, Deserialize)] -pub struct TramitonBuildPlan { +/// 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`). - #[serde(default)] pub provider: String, /// Detection confidence: `low` | `medium` | `high`. - #[serde(default)] pub confidence: String, - /// Build system (kebab-case: `zephyr`, `esp-idf`, `cmake`, `make`, ...). - #[serde(default)] + /// Build-system label (e.g. `Zephyr`, `ESP-IDF`, `CMake`). pub build_system: String, - /// Framework, when known (`zephyr`, `esp-idf`, `mbed`, `bare-metal`, ...). + /// Framework, when known (`zephyr`, `esp-idf`, `bare-metal`, ...). pub framework: Option, /// Target board / MCU / arch. - #[serde(default)] - pub target: TramitonTarget, + pub target: FirmwareTarget, /// Unresolved gaps in the plan. - #[serde(default)] pub gaps: Vec, } -/// tramiton's target descriptor. -#[derive(Debug, Clone, Default, Deserialize)] -pub struct TramitonTarget { +/// The detected firmware target (board / MCU / arch). +#[derive(Debug, Clone, Default)] +pub struct FirmwareTarget { /// Board name. pub board: Option, /// MCU part. @@ -61,57 +46,52 @@ pub struct TramitonTarget { /// A source of tramiton firmware detection. #[allow(async_fn_in_trait)] pub trait FirmwareDetector: Send + Sync { - /// Run detection over a path, returning tramiton's build plan if any. - async fn detect(&self, path: &Path) -> Result, CoreError>; + /// Run detection over a path, returning a firmware detection if tramiton + /// could form a build plan. + async fn detect(&self, path: &Path) -> Result, CoreError>; } -/// Shells out to the `tramiton` CLI. A missing binary or a non-zero exit is -/// treated as "no detection" rather than an error, so firmware classification -/// degrades gracefully when tramiton is not installed. -pub struct TramitonCli { - /// The `tramiton` binary to invoke. - pub bin: String, -} +/// 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 TramitonCli { - /// Construct from `TRAMITON_BIN` (default `tramiton`). - pub fn from_env() -> Self { - Self { - bin: std::env::var("TRAMITON_BIN").unwrap_or_else(|_| "tramiton".to_string()), - } +impl FirmwareDetector for TramitonNative { + async fn detect(&self, path: &Path) -> Result, 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))) } } -impl FirmwareDetector for TramitonCli { - async fn detect(&self, path: &Path) -> Result, CoreError> { - let output = tokio::process::Command::new(&self.bin) - .arg("detect") - .arg("--json") - .arg(path) - .output() - .await; - match output { - Ok(o) if o.status.success() => { - let parsed: TramitonDetect = serde_json::from_slice(&o.stdout)?; - Ok(parsed.plan) - } - // Non-zero exit: tramiton ran but formed no plan. - Ok(_) => Ok(None), - // Binary not found / not executable: degrade gracefully. - Err(_) => Ok(None), - } +/// 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 tramiton build plan to a target type. Framework/build-system signals +/// Map a firmware detection to a target type. Framework/build-system signals /// distinguish RTOS from bare-metal from Yocto. -pub fn plan_to_target_type(plan: &TramitonBuildPlan) -> TargetType { - let framework = plan.framework.as_deref().unwrap_or("").to_lowercase(); - let build_system = plan.build_system.to_lowercase(); - let signal = format!( - "{framework} {build_system} {}", - plan.provider.to_lowercase() - ); +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") { @@ -133,35 +113,35 @@ fn confidence_score(label: &str) -> f32 { } } -/// Turn a tramiton build plan into a classifier verdict, carrying the MCU / board +/// Turn a firmware detection into a classifier verdict, carrying the MCU / board /// / build-system as facts. -pub fn plan_to_verdict(plan: &TramitonBuildPlan) -> ClassifierVerdict { - let target_type = plan_to_target_type(plan); +pub fn detection_to_verdict(det: &FirmwareDetection) -> ClassifierVerdict { + let target_type = detection_to_target_type(det); let mut facts = vec![DetectedFact::new( "build_system", - plan.build_system.clone(), + det.build_system.clone(), "tramiton", )]; - if let Some(fw) = &plan.framework { + if let Some(fw) = &det.framework { facts.push(DetectedFact::new("framework", fw.clone(), "tramiton")); } - if let Some(mcu) = &plan.target.mcu { + if let Some(mcu) = &det.target.mcu { facts.push(DetectedFact::new("mcu", mcu.clone(), "tramiton")); } - if let Some(board) = &plan.target.board { + if let Some(board) = &det.target.board { facts.push(DetectedFact::new("board", board.clone(), "tramiton")); } - if let Some(arch) = &plan.target.arch { + if let Some(arch) = &det.target.arch { facts.push(DetectedFact::new("arch", arch.clone(), "tramiton")); } ClassifierVerdict { target_type, - confidence: confidence_score(&plan.confidence), + confidence: confidence_score(&det.confidence), facts, rationale: format!( "tramiton detected build system '{}'{}", - plan.build_system, - plan.framework + det.build_system, + det.framework .as_ref() .map(|f| format!(" (framework {f})")) .unwrap_or_default() @@ -169,15 +149,15 @@ pub fn plan_to_verdict(plan: &TramitonBuildPlan) -> ClassifierVerdict { } } -/// A deterministic [`FirmwareDetector`] for tests — returns a preset plan. +/// A deterministic [`FirmwareDetector`] for tests — returns a preset detection. pub struct MockFirmwareDetector { - /// The plan to return (or `None` for "no detection"). - pub plan: Option, + /// The detection to return (or `None` for "no detection"). + pub detection: Option, } impl FirmwareDetector for MockFirmwareDetector { - async fn detect(&self, _path: &Path) -> Result, CoreError> { - Ok(self.plan.clone()) + async fn detect(&self, _path: &Path) -> Result, CoreError> { + Ok(self.detection.clone()) } } @@ -186,13 +166,13 @@ impl FirmwareDetector for MockFirmwareDetector { mod tests { use super::*; - fn plan(build_system: &str, framework: Option<&str>) -> TramitonBuildPlan { - TramitonBuildPlan { + 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: TramitonTarget { + target: FirmwareTarget { mcu: Some("stm32f429".to_string()), ..Default::default() }, @@ -203,7 +183,7 @@ mod tests { #[test] fn zephyr_maps_to_rtos() { assert_eq!( - plan_to_target_type(&plan("zephyr", Some("zephyr"))), + detection_to_target_type(&detection("zephyr", Some("zephyr"))), TargetType::FirmwareRtos ); } @@ -211,7 +191,7 @@ mod tests { #[test] fn bare_cmake_maps_to_bare_metal() { assert_eq!( - plan_to_target_type(&plan("cmake", Some("bare-metal"))), + detection_to_target_type(&detection("cmake", Some("bare-metal"))), TargetType::FirmwareBareMetal ); } @@ -219,14 +199,14 @@ mod tests { #[test] fn bitbake_maps_to_yocto() { assert_eq!( - plan_to_target_type(&plan("bitbake", None)), + detection_to_target_type(&detection("bitbake", None)), TargetType::EmbeddedLinuxYocto ); } #[test] fn verdict_carries_mcu_fact_and_confidence() { - let v = plan_to_verdict(&plan("esp-idf", Some("esp-idf"))); + 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 @@ -234,13 +214,4 @@ mod tests { .iter() .any(|f| f.key == "mcu" && f.value == "stm32f429")); } - - #[test] - fn detect_json_parses() { - let json = r#"{"repo":"/x","detections":[],"plan":{"provider":"zephyr","confidence":"high","build_system":"zephyr","framework":"zephyr","target":{"mcu":"nrf52840","board":"nrf52840dk","arch":"arm"},"gaps":[]}}"#; - let parsed: TramitonDetect = serde_json::from_str(json).expect("parse"); - let plan = parsed.plan.expect("plan present"); - assert_eq!(plan.target.mcu.as_deref(), Some("nrf52840")); - assert_eq!(plan_to_target_type(&plan), TargetType::FirmwareRtos); - } } diff --git a/compliance-agent/src/classify/mod.rs b/compliance-agent/src/classify/mod.rs index 3d0b344..019c9b0 100644 --- a/compliance-agent/src/classify/mod.rs +++ b/compliance-agent/src/classify/mod.rs @@ -9,8 +9,7 @@ mod firmware; mod language; pub use firmware::{ - FirmwareDetector, MockFirmwareDetector, TramitonBuildPlan, TramitonCli, TramitonDetect, - TramitonTarget, + FirmwareDetection, FirmwareDetector, FirmwareTarget, MockFirmwareDetector, TramitonNative, }; pub use language::HeuristicClassifier; @@ -23,7 +22,7 @@ use compliance_core::models::{ }; use compliance_core::traits::{ClassificationInput, ClassifierVerdict, TargetClassifier}; -use firmware::plan_to_verdict; +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 @@ -60,8 +59,8 @@ pub async fn classify_target( let Some(path) = working_paths.get(&artifact.id) else { continue; }; - if let Some(plan) = firmware_detector.detect(path).await? { - verdicts.push(plan_to_verdict(&plan)); + if let Some(detection) = firmware_detector.detect(path).await? { + verdicts.push(detection_to_verdict(&detection)); tramiton_used = true; } } @@ -155,7 +154,7 @@ mod tests { } fn no_firmware() -> MockFirmwareDetector { - MockFirmwareDetector { plan: None } + MockFirmwareDetector { detection: None } } #[tokio::test] @@ -190,12 +189,12 @@ mod tests { target.artifacts.push(artifact); let detector = MockFirmwareDetector { - plan: Some(TramitonBuildPlan { + detection: Some(FirmwareDetection { provider: "zephyr".to_string(), confidence: "high".to_string(), build_system: "zephyr".to_string(), framework: Some("zephyr".to_string()), - target: TramitonTarget { + target: FirmwareTarget { mcu: Some("nrf52840".to_string()), ..Default::default() },