//! Firmware SBOM via tramiton. //! //! Phase 2 (full, the default): drive a **reproducible build** with tramiton's //! `NixBackend` — `analyze` → `seal_and_build` → a sealed lock whose libraries //! are pinned and whose firmware artifact carries a content hash — then render //! the SBOM from the lock plus deep binary SCA of pre-compiled inputs. This is //! the complete bill of materials (toolchain + every fetched library + the //! firmware image), the same one `tramiton sbom` produces. //! //! Phase 1 fallback (analysis-only): when no nix backend is available or the //! build fails, fall back to the resolvable libraries + toolchain from the build //! plan alone (no build). A scan therefore always yields *something*, and a nix //! that can't run in the deployment never breaks a scan. use std::path::Path; use compliance_core::models::{SbomEntry, TargetType}; use tramiton_repro::ReproBackend; use tramiton_sbom::ComponentKind; /// Whether firmware SBOM applies to this target family. pub fn is_firmware_target(target_type: TargetType) -> bool { matches!( target_type, TargetType::FirmwareBareMetal | TargetType::FirmwareRtos | TargetType::EmbeddedLinuxYocto ) } /// Build SBOM entries for a firmware target from its source tree. Prefers a full /// reproducible build (sealed lock); falls back to analysis-only. Returns an /// empty vector when tramiton cannot even form a build plan. pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec { let p = path.to_path_buf(); let repo = repo_id.to_string(); // The whole analyze → seal → build → render sequence is blocking (it shells // out to nix), so keep it off the async runtime. Bound it: a firmware build // that hangs must not wedge the scan (the orphaned task is abandoned). let handle = tokio::task::spawn_blocking(move || build_sbom_blocking(&p, &repo)); match tokio::time::timeout(std::time::Duration::from_secs(900), handle).await { Ok(Ok(entries)) => entries, Ok(Err(e)) => { tracing::warn!(repo_id, error = %e, "Firmware SBOM: task join error"); Vec::new() } Err(_) => { tracing::warn!(repo_id, "Firmware SBOM: build exceeded 15m; skipping"); Vec::new() } } } fn build_sbom_blocking(path: &Path, repo_id: &str) -> Vec { let repo = tramiton_core::Repo::new(path); let plan = match tramiton_core::provider::analyze(&repo) { Ok(Some(bp)) => bp, Ok(None) => return Vec::new(), Err(e) => { tracing::warn!(repo_id, error = %e, "Firmware SBOM: tramiton analyze failed"); return Vec::new(); } }; // Phase 2: reproducible build → sealed lock → complete SBOM. if let Some(backend) = tramiton_repro::NixBackend::detect() { match tramiton_repro::seal_and_build(&backend, &plan, path) { Ok(lock) => { let mut sbom = tramiton_sbom::Sbom::from_lock(&lock, repo_id); // Deep binary SCA of any pre-compiled inputs in the tree. sbom.components.extend(tramiton_sbom::binary::scan(path)); let entries = sbom_to_entries(&sbom, repo_id); tracing::info!( repo_id, backend = backend.name(), count = entries.len(), "Firmware SBOM: sealed reproducible build" ); return entries; } Err(e) => { tracing::warn!(repo_id, error = %e, "Firmware SBOM: reproducible build failed; falling back to analysis-only") } } } else { tracing::info!( repo_id, "Firmware SBOM: no nix backend available; analysis-only SBOM" ); } // Phase 1 fallback: analysis-only (toolchain + resolvable libraries). analysis_entries(&plan, repo_id) } /// Map a rendered [`tramiton_sbom::Sbom`] (primary firmware + components) into /// our [`SbomEntry`] rows. Source-file (`File`) components are dropped — they are /// build inputs, not a dependency inventory. fn sbom_to_entries(sbom: &tramiton_sbom::Sbom, repo_id: &str) -> Vec { let mut entries = Vec::new(); if let Some(primary) = &sbom.primary { entries.push(component_to_entry(primary, repo_id)); } for c in &sbom.components { if matches!(c.kind, ComponentKind::File) { continue; } entries.push(component_to_entry(c, repo_id)); } entries } fn component_to_entry(c: &tramiton_sbom::Component, repo_id: &str) -> SbomEntry { let manager = match c.kind { ComponentKind::Firmware => "firmware", ComponentKind::Library => "library", ComponentKind::Toolchain => "toolchain", ComponentKind::File => "file", }; let mut entry = SbomEntry::new( repo_id.to_string(), c.name.clone(), c.version.clone().unwrap_or_default(), manager.to_string(), ); entry.purl = c.source.clone(); entry } /// Analysis-only components from the build plan: the cross-toolchain plus the /// resolvable fetched libraries, without a build. fn analysis_entries(bp: &tramiton_core::BuildPlan, repo_id: &str) -> Vec { let mut entries = Vec::new(); if let Some(id) = bp.toolchain.id.clone() { let version = bp.toolchain.version.clone().unwrap_or_default(); entries.push(SbomEntry::new( repo_id.to_string(), id, version, "toolchain".to_string(), )); } for lib in tramiton_repro::lock::libraries_from_inputs(&bp.inputs) { let mut entry = SbomEntry::new( repo_id.to_string(), lib.name, lib.revision, "library".to_string(), ); entry.purl = lib.source; entries.push(entry); } entries }