From 4eff7f73a14629d3dbaf3d59caeb775cadeff2cd Mon Sep 17 00:00:00 2001 From: Hiago De Franco Date: Tue, 22 Sep 2026 10:08:03 -0300 Subject: [PATCH 1/2] commands/sbom: attach image ids to extension scopes (ENG-2199) Extension scopes carry the runtime manifest's image_id as an externalIdentifier and its sha256 as verifiedUsing; rootfs and initramfs carry os_build_id and initramfs_build_id. This gives a device-reported image set something to join against. spdxIds are unchanged. avocado sbom reads the manifests from the volume, connect upload passes the one it already has. A missing manifest only warns. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 + src/commands/connect/upload.rs | 22 +- src/commands/sbom/generate.rs | 577 ++++++++++++++++++++++++++++++--- 3 files changed, 564 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bac11b..e686ad1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **SBOM extension scopes carry a stable image id.** (ENG-2199) + `ext:/` scope elements now carry `externalIdentifier`/ + `verifiedUsing` from the runtime's build manifest, and `rootfs`/ + `initramfs` carry `os_build_id`/`initramfs_build_id` once `runtime + var-image` has run — so a device-reported image can be joined back onto + the scope that describes it. `spdxId`s and the namespace are unchanged. + ## [1.0.0-rc.5] - 2026-09-17 ### Changed diff --git a/src/commands/connect/upload.rs b/src/commands/connect/upload.rs index ed4cc9f3..6dad6e23 100644 --- a/src/commands/connect/upload.rs +++ b/src/commands/connect/upload.rs @@ -10,7 +10,7 @@ use crate::commands::connect::client::{ ConnectClient, ContainerDiscoveryResult, CreateRuntimeRequest, HttpStatus, RuntimeParams, UploadPartError, }; -use crate::commands::sbom::generate::{in_runtime, runtime_has_packages, SbomCommand}; +use crate::commands::sbom::generate::{in_runtime, runtime_has_packages, ImageIds, SbomCommand}; use crate::utils::config::{load_config, Config}; use crate::utils::container::{RunConfig, SdkContainer}; use crate::utils::output::{ @@ -308,7 +308,7 @@ impl ConnectUploadCommand { // Phase B: Create runtime via API. The SBOM (ENG-2219) is built in // this phase rather than one of its own. let (runtime, num_artifacts) = run_phase(PHASE_CREATE, async { - let sbom = self.build_sbom().await; + let sbom = self.build_sbom(manifest).await; self.create_runtime_api( connect, version, @@ -464,11 +464,14 @@ impl ConnectUploadCommand { /// contract `read_config_and_lockfile` already has for the lockfile. /// /// `AVOCADO_UPLOAD_NO_SBOM=1` skips the build outright. - async fn build_sbom(&self) -> Option { + /// + /// `manifest` is discovery's manifest.json, passed through so extension + /// scopes carry the `image_id`s this upload is about to publish. + async fn build_sbom(&self, manifest: &serde_json::Value) -> Option { if std::env::var("AVOCADO_UPLOAD_NO_SBOM").as_deref() == Ok("1") { return None; } - match self.scan_runtime_sbom().await { + match self.scan_runtime_sbom(manifest).await { Ok(doc) => Some(doc), Err(e) => { // Not `print_warning`: that one is suppressed under @@ -483,7 +486,7 @@ impl ConnectUploadCommand { } /// `avocado sbom`'s document, filtered to this runtime by `in_runtime`. - async fn scan_runtime_sbom(&self) -> Result { + async fn scan_runtime_sbom(&self, manifest: &serde_json::Value) -> Result { let cmd = SbomCommand::new( self.config_path.clone(), self.target.clone(), @@ -510,7 +513,14 @@ impl ConnectUploadCommand { self.runtime ); } - Ok(cmd.build_document(&kept, &target, snapshot.as_ref(), Some(&self.runtime))) + let images = ImageIds::from_manifest(manifest, &self.runtime); + Ok(cmd.build_document( + &kept, + &target, + snapshot.as_ref(), + Some(&self.runtime), + Some(&images), + )) } /// Handle the case where the runtime is already in draft status (full dedup). diff --git a/src/commands/sbom/generate.rs b/src/commands/sbom/generate.rs index e42de84d..5ff5432c 100644 --- a/src/commands/sbom/generate.rs +++ b/src/commands/sbom/generate.rs @@ -12,12 +12,14 @@ //! element every `software_Sbom` references by id. use anyhow::{Context, Result}; +use base64::prelude::*; use sha2::{Digest, Sha256}; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use crate::utils::config::{ComposedConfig, Config}; +use crate::utils::container::{RunConfig, SdkContainer}; use crate::utils::lockfile::{LockFile, RepoSnapshot, RPM_SBOM_FIELDS, RPM_SBOM_FORMAT}; use crate::utils::output::{print_info, print_success, OutputLevel}; use crate::utils::output_format::{emit_json_object, JsonOutputGuard, OutputFormat}; @@ -155,6 +157,128 @@ pub(crate) struct Scope { unreadable: usize, } +/// A scope's join key onto the runtime build manifest, so a consumer that +/// already stores that manifest can match a device-reported image to the +/// scope that describes it. Not folded into `namespace_digest` or any +/// `spdxId`, which must stay stable across a rebuild that only moves image +/// bytes. +#[derive(Debug, Clone)] +pub(crate) struct ImageEntry { + pub(crate) image_id: String, + /// Tells a consumer which kind of id `image_id` is. + pub(crate) authority: &'static str, + /// Absent when the manifest has none, or when `image_id` isn't a hash + /// of the scope's own bytes (see `rootfs`/`initramfs` below). + pub(crate) sha256: Option, +} + +/// Uuid5 of an uploaded image file's sha256. +const IMAGE_ID_AUTHORITY: &str = "https://avocadolinux.org/image-id"; +/// Build id baked into `os-release`; never equals any uploaded image's id. +const OS_BUILD_ID_AUTHORITY: &str = "https://avocadolinux.org/os-build-id"; + +/// Image ids keyed by scope name (`ext:/`, `rootfs`, +/// `initramfs`), matching the names `build_document` uses. +#[derive(Debug, Default, Clone)] +pub(crate) struct ImageIds { + entries: BTreeMap, + /// Shared scopes whose runtimes' manifests disagreed; kept so a later + /// runtime can't re-add them. + conflicts: BTreeSet, +} + +impl ImageIds { + /// `rootfs`/`initramfs` use `os_bundle.os_build_id`/`initramfs_build_id`, + /// not the manifest's top-level `image_id` fields: `os_build_id` is + /// written into the shipped rootfs's own `os-release`, so it's the only + /// id a device can echo back. `os_bundle` only exists once `runtime + /// var-image` has run, so a manifest without it leaves these unmapped. + pub(crate) fn from_manifest(manifest: &serde_json::Value, runtime: &str) -> Self { + let mut map = BTreeMap::new(); + + if let Some(extensions) = manifest.get("extensions").and_then(|v| v.as_array()) { + for ext in extensions { + let (Some(name), Some(image_id)) = ( + ext.get("name").and_then(|v| v.as_str()), + ext.get("image_id").and_then(|v| v.as_str()), + ) else { + continue; + }; + let sha256 = ext + .get("sha256") + .and_then(|v| v.as_str()) + .map(str::to_string); + map.insert( + format!("ext:{runtime}/{name}"), + ImageEntry { + image_id: image_id.to_string(), + authority: IMAGE_ID_AUTHORITY, + sha256, + }, + ); + } + } + + if let Some(os_bundle) = manifest.get("os_bundle") { + if let Some(id) = os_bundle.get("os_build_id").and_then(|v| v.as_str()) { + map.insert( + "rootfs".to_string(), + ImageEntry { + image_id: id.to_string(), + authority: OS_BUILD_ID_AUTHORITY, + sha256: None, + }, + ); + } + if let Some(id) = os_bundle.get("initramfs_build_id").and_then(|v| v.as_str()) { + map.insert( + "initramfs".to_string(), + ImageEntry { + image_id: id.to_string(), + authority: OS_BUILD_ID_AUTHORITY, + sha256: None, + }, + ); + } + } + + Self { + entries: map, + conflicts: BTreeSet::new(), + } + } + + /// Fold another runtime's mapping in, for a document covering several + /// runtimes. A shared scope (`rootfs`/`initramfs`) whose ids disagree is + /// dropped, since no single id describes the scanned sysroot; returns the + /// scopes newly dropped. + pub(crate) fn merge(&mut self, other: ImageIds) -> Vec { + let mut dropped = Vec::new(); + self.conflicts.extend(other.conflicts); + for (scope, entry) in other.entries { + if self.conflicts.contains(&scope) { + continue; + } + match self.entries.get(&scope) { + Some(existing) if existing.image_id != entry.image_id => { + self.entries.remove(&scope); + self.conflicts.insert(scope.clone()); + dropped.push(scope); + } + Some(_) => {} + None => { + self.entries.insert(scope, entry); + } + } + } + dropped + } + + fn get(&self, scope: &str) -> Option<&ImageEntry> { + self.entries.get(scope) + } +} + /// Whether a scope's installroot was seeded with a copy of the rootfs RPM /// database, and so needs the seed subtracted before its packages can be read /// as its own content. See `parse_scopes` for what the seed is and why. @@ -255,6 +379,54 @@ pub(crate) fn runtime_has_packages(scopes: &[Scope], runtime: &str) -> bool { .any(|s| s.name == format!("runtime:{runtime}") && !s.packages.is_empty()) } +/// Finds each runtime's active manifest.json and prints it as +/// `##MANIFEST\t\t`. Base64 because the manifest is +/// pretty-printed, and raw newlines would break the line-oriented output. +const MANIFEST_SCRIPT: &str = r#" +set -u +for runtime_dir in "$AVOCADO_PREFIX"/runtimes/*/; do + [ -d "$runtime_dir" ] || continue + name=$(basename "$runtime_dir") + staging="${runtime_dir}var-staging/lib/avocado" + manifest="" + if [ -f "$staging/active/manifest.json" ]; then + manifest="$staging/active/manifest.json" + else + for d in "$staging"/runtimes/*/; do + [ -f "${d}manifest.json" ] || continue + manifest="${d}manifest.json" + break + done + fi + [ -n "$manifest" ] || continue + printf '##MANIFEST\t%s\t' "$name" + base64 "$manifest" | tr -d '\n' + printf '\n' +done +"#; + +/// Parse `MANIFEST_SCRIPT`'s output into runtime name -> manifest JSON. +/// Unparseable lines are dropped rather than failing the whole read. +fn parse_manifests(output: &str) -> BTreeMap { + let mut manifests = BTreeMap::new(); + for line in output.lines() { + let Some(rest) = line.strip_prefix("##MANIFEST\t") else { + continue; + }; + let Some((name, b64)) = rest.split_once('\t') else { + continue; + }; + let Ok(bytes) = BASE64_STANDARD.decode(b64) else { + continue; + }; + let Ok(value) = serde_json::from_slice::(&bytes) else { + continue; + }; + manifests.insert(name.to_string(), value); + } + manifests +} + /// Tripwires on the seeded-scope subtraction, which is a heuristic and has been /// wrong before. An installroot seeded from the rootfs holds the whole base, so /// the last time the subtraction failed it did so silently, on every seeded @@ -593,8 +765,10 @@ impl SbomCommand { let _json_guard = self.output.is_json().then(JsonOutputGuard::enable); - let (scopes, target, snapshot) = self.scan(|m| eprintln!("[WARN] {m}")).await?; - let doc = self.build_document(&scopes, &target, snapshot.as_ref(), None); + let warn: fn(&str) = |m| eprintln!("[WARN] {m}"); + let (scopes, target, snapshot) = self.scan(warn).await?; + let images = self.read_images(&scopes, &target, warn).await; + let doc = self.build_document(&scopes, &target, snapshot.as_ref(), None, images.as_ref()); match &self.output_path { Some(path) => { @@ -611,6 +785,18 @@ impl SbomCommand { Ok(()) } + /// The project's composed config, reused if `with_composed_config` + /// already set one. Shared by `scan` and `read_images`. + fn composed_config(&self) -> Result> { + match &self.composed_config { + Some(cc) => Ok(Arc::clone(cc)), + None => Ok(Arc::new( + Config::load_composed(&self.config_path, self.target.as_deref()) + .context("Failed to load composed config")?, + )), + } + } + /// Scan every sysroot and return the parsed scopes, the resolved target, /// and the feed snapshot they were resolved from. /// @@ -630,13 +816,7 @@ impl SbomCommand { &self, warn: fn(&str), ) -> Result<(Vec, String, Option)> { - let composed = match &self.composed_config { - Some(cc) => Arc::clone(cc), - None => Arc::new( - Config::load_composed(&self.config_path, self.target.as_deref()) - .context("Failed to load composed config")?, - ), - }; + let composed = self.composed_config()?; let config = &composed.config; let target = resolve_target_required(self.target.as_deref(), config)?; @@ -738,6 +918,94 @@ impl SbomCommand { Ok((scopes, target, snapshot)) } + /// Image ids for this document's scopes, read from each runtime's build + /// manifest in the container volume. `connect upload` skips this and + /// calls `ImageIds::from_manifest` directly, since it already has the + /// one manifest it needs; this covers `avocado sbom`'s document, which + /// may span several runtimes. + /// + /// Never fails `avocado sbom`: a project with no build yet just gets a + /// document without image ids, reported through `warn`. + async fn read_images( + &self, + scopes: &[Scope], + target: &str, + warn: fn(&str), + ) -> Option { + let runtimes: Vec<&str> = scopes + .iter() + .filter_map(|s| s.name.strip_prefix("runtime:")) + .collect(); + if runtimes.is_empty() { + return None; + } + + let manifests = match self.fetch_manifests(target).await { + Ok(m) => m, + Err(e) => { + warn(&format!( + "no build manifest: extension scopes carry no image id ({e:#})" + )); + return None; + } + }; + + let missing: Vec<&str> = runtimes + .iter() + .filter(|r| !manifests.contains_key(**r)) + .copied() + .collect(); + if !missing.is_empty() { + warn(&format!( + "no build manifest for runtime(s) {}: extension scopes carry no image id", + missing.join(", ") + )); + } + + let mut images = ImageIds::default(); + for (name, manifest) in &manifests { + for scope in images.merge(ImageIds::from_manifest(manifest, name)) { + warn(&format!( + "runtimes' build manifests disagree on {scope}: it carries no build id (rebuild the stale runtime)" + )); + } + } + Some(images) + } + + /// Run `MANIFEST_SCRIPT` in the SDK container and parse its output. + async fn fetch_manifests(&self, target: &str) -> Result> { + let composed = self.composed_config()?; + let config = &composed.config; + let container_image = config.get_sdk_image().cloned().ok_or_else(|| { + anyhow::anyhow!("No container image specified in config under 'sdk.image'.") + })?; + + let container = SdkContainer::from_config(&self.config_path, config)?; + let run_config = RunConfig { + container_image, + target: target.to_string(), + command: MANIFEST_SCRIPT.to_string(), + source_environment: false, + use_entrypoint: true, + interactive: false, + repo_url: config.get_sdk_repo_url(), + repo_release: config.get_sdk_repo_release(), + container_args: config.merge_sdk_container_args(self.container_args.as_ref()), + sdk_arch: self.sdk_arch.clone(), + ..Default::default() + }; + + let out = container.run_in_container_capture(run_config).await?; + if !out.success { + anyhow::bail!( + "the manifest discovery script exited non-zero inside the SDK container.{}", + sysroot_scan::stderr_tail(&out.stderr) + ); + } + Ok(parse_manifests(&out.stdout)) + } + /// Map the raw dump onto packages, dropping what a scope only sees because /// its installroot was seeded from the rootfs. fn parse_scopes(&self, output: &str) -> Vec { @@ -935,6 +1203,7 @@ impl SbomCommand { target: &str, snapshot: Option<&RepoSnapshot>, runtime: Option<&str>, + images: Option<&ImageIds>, ) -> serde_json::Value { let ns = format!( "https://avocadolinux.org/spdx/{}/{}", @@ -1012,14 +1281,43 @@ impl SbomCommand { // "scanned and empty" stays visible where it belongs. for scope in scopes.iter().filter(|s| !s.packages.is_empty()) { let scope_id = format!("{ns}/scope/{}", slug_id(&scope.name)); - graph.push(serde_json::json!({ + let mut scope_element = serde_json::json!({ "type": "software_Package", "spdxId": scope_id, "creationInfo": creation_id, "name": scope.name, "software_primaryPurpose": "archive", "comment": format!("avocado sysroot at {}", scope.root), - })); + }); + + // Not folded into scope_id/ns: those feed namespace_digest, which + // must stay stable across a rebuild that only moves image bytes. + if let Some(entry) = images.and_then(|images| images.get(&scope.name)) { + let obj = scope_element + .as_object_mut() + .expect("json! built an object"); + obj.insert( + "externalIdentifier".into(), + serde_json::json!([{ + "type": "ExternalIdentifier", + "externalIdentifierType": "other", + "identifier": entry.image_id, + "issuingAuthority": entry.authority, + }]), + ); + if let Some(sha256) = &entry.sha256 { + obj.insert( + "verifiedUsing".into(), + serde_json::json!([{ + "type": "Hash", + "algorithm": "sha256", + "hashValue": sha256, + }]), + ); + } + } + + graph.push(scope_element); scope_ids.push((scope.name.clone(), scope_id.clone())); let mut members: Vec = Vec::new(); @@ -1774,7 +2072,7 @@ mod tests { let mut scopes = c.parse_scopes(&dump); scopes[0].packages[0].sourcerpm = "(none)".to_string(); - let doc = c.build_document(&scopes, "qemuarm64", None, None); + let doc = c.build_document(&scopes, "qemuarm64", None, None, None); let pkg = doc["@graph"] .as_array() .unwrap() @@ -1841,7 +2139,7 @@ mod tests { let scopes = c.parse_scopes(&dump); assert_eq!(scopes.len(), 2); - let doc = c.build_document(&scopes, "qemuarm64", None, None); + let doc = c.build_document(&scopes, "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let packages: Vec<&str> = graph @@ -1919,7 +2217,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let runtime = ids_named(graph, "runtime:dev").remove(0); @@ -1960,7 +2258,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let ext = ids_named(graph, "ext:app").remove(0); @@ -1985,7 +2283,7 @@ mod tests { created: Some("2026-07-08T02:17:53Z".into()), }; - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", Some(&snap), None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", Some(&snap), None, None); let graph = doc["@graph"].as_array().unwrap(); let sbom = graph.iter().find(|e| e["type"] == "software_Sbom").unwrap(); @@ -2002,7 +2300,7 @@ mod tests { // Unpinned, the document says nothing rather than repeating the // release and channel the config asked for: the channel head moves, so // that would be provenance the document cannot stand behind. - let bare = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let bare = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let bare = bare["@graph"] .as_array() .unwrap() @@ -2028,7 +2326,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let spdx_doc = graph.iter().find(|e| e["type"] == "SpdxDocument").unwrap(); @@ -2137,7 +2435,7 @@ mod tests { let scopes = c.parse_scopes(&dump); assert_eq!(scopes.len(), 2, "still scanned, and still summarised"); - let doc = c.build_document(&scopes, "qemuarm64", None, None); + let doc = c.build_document(&scopes, "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); assert!(!graph.iter().any(|e| e["name"] == "includes:etc")); assert!(graph.iter().all( @@ -2199,8 +2497,8 @@ mod tests { ); let c = cmd(false); - let doc_a = c.build_document(&c.parse_scopes(&a), "qemuarm64", None, None); - let doc_b = c.build_document(&c.parse_scopes(&b), "qemuarm64", None, None); + let doc_a = c.build_document(&c.parse_scopes(&a), "qemuarm64", None, None, None); + let doc_b = c.build_document(&c.parse_scopes(&b), "qemuarm64", None, None, None); let id = |d: &serde_json::Value| { d["@graph"] @@ -2219,7 +2517,7 @@ mod tests { // the namespace becoming content-derived. assert_eq!( id(&doc_a), - id(&c.build_document(&c.parse_scopes(&a), "qemuarm64", None, None)) + id(&c.build_document(&c.parse_scopes(&a), "qemuarm64", None, None, None)) ); } @@ -2321,7 +2619,7 @@ mod tests { assert!(ext.packages.is_empty(), "the known limitation"); // Still in the inventory, under the rootfs. - let doc = c.build_document(&scopes, "qemuarm64", None, None); + let doc = c.build_document(&scopes, "qemuarm64", None, None, None); assert!(doc["@graph"] .as_array() .unwrap() @@ -2481,7 +2779,7 @@ mod tests { row("libc6", "2.39", "r0.2", "cortexa57", "MIT") ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let sbom = doc["@graph"] .as_array() .unwrap() @@ -2514,7 +2812,7 @@ mod tests { .replace("Avocado Developers ", "avocado") ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let mut ids: Vec<&str> = doc["@graph"] .as_array() @@ -2599,8 +2897,8 @@ mod tests { ) }; assert_eq!( - strip(c.build_document(&scopes, "qemuarm64", None, None)), - strip(c.build_document(&scopes, "qemuarm64", None, None)) + strip(c.build_document(&scopes, "qemuarm64", None, None, None)), + strip(c.build_document(&scopes, "qemuarm64", None, None, None)) ); } @@ -2773,7 +3071,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let names: Vec<&str> = graph @@ -2819,7 +3117,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let names: Vec<&str> = graph @@ -2856,7 +3154,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let sboms: Vec<&serde_json::Value> = graph @@ -2937,7 +3235,7 @@ mod tests { // include_sdk=true so the build-host group is in the slice at all. let c = cmd(true); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let runtime_sbom = graph @@ -2992,7 +3290,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let sbom_positions: Vec = graph @@ -3024,7 +3322,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, Some("dev")); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, Some("dev"), None); let names: Vec<&str> = doc["@graph"] .as_array() .unwrap() @@ -3058,8 +3356,8 @@ mod tests { let c = cmd(false); let scopes = c.parse_scopes(&dump); - let device = c.build_document(&scopes, "qemuarm64", None, None); - let runtime = c.build_document(&scopes, "qemuarm64", None, Some("dev")); + let device = c.build_document(&scopes, "qemuarm64", None, None, None); + let runtime = c.build_document(&scopes, "qemuarm64", None, Some("dev"), None); let doc_id = |d: &serde_json::Value| { d["@graph"] @@ -3077,7 +3375,7 @@ mod tests { // Still byte-stable: the same runtime slice twice is the same document. assert_eq!( doc_id(&runtime), - doc_id(&c.build_document(&scopes, "qemuarm64", None, Some("dev"))) + doc_id(&c.build_document(&scopes, "qemuarm64", None, Some("dev"), None)) ); } @@ -3099,7 +3397,7 @@ mod tests { ); let c = cmd(false); - let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None); + let doc = c.build_document(&c.parse_scopes(&dump), "qemuarm64", None, None, None); let graph = doc["@graph"].as_array().unwrap(); let includes_id = graph .iter() @@ -3132,4 +3430,209 @@ mod tests { assert!(!scopes.is_empty()); assert!(!runtime_has_packages(&scopes, "dev")); } + + #[test] + fn from_manifest_maps_extensions_by_name_and_skips_entries_without_an_id() { + let manifest = serde_json::json!({ + "extensions": [ + {"name": "app", "version": "1.0", "image_id": "img-app", "sha256": "sha-app"}, + {"name": "no-id", "version": "1.0"}, + ], + }); + + let images = ImageIds::from_manifest(&manifest, "dev"); + let app = images.get("ext:dev/app").expect("app was mapped"); + assert_eq!(app.image_id, "img-app"); + assert_eq!(app.sha256.as_deref(), Some("sha-app")); + assert!(images.get("ext:dev/no-id").is_none()); + assert!(images.get("ext:other/app").is_none()); + assert!(images.get("app").is_none()); + } + + #[test] + fn from_manifest_maps_os_bundle_build_ids_onto_rootfs_and_initramfs() { + let manifest = serde_json::json!({ + "os_bundle": { + "image_id": "aos-bundle-id", + "sha256": "aos-bundle-sha", + "os_build_id": "os-build-id", + "initramfs_build_id": "initramfs-build-id", + }, + }); + + let images = ImageIds::from_manifest(&manifest, "dev"); + let rootfs = images.get("rootfs").expect("rootfs was mapped"); + assert_eq!(rootfs.image_id, "os-build-id"); + assert!(rootfs.sha256.is_none()); + let initramfs = images.get("initramfs").expect("initramfs was mapped"); + assert_eq!(initramfs.image_id, "initramfs-build-id"); + assert!(initramfs.sha256.is_none()); + assert_ne!(rootfs.image_id, "aos-bundle-id"); + assert_ne!(initramfs.image_id, "aos-bundle-id"); + } + + #[test] + fn from_manifest_leaves_rootfs_and_initramfs_unmapped_without_an_os_bundle() { + let manifest = serde_json::json!({ + "extensions": [], + }); + let images = ImageIds::from_manifest(&manifest, "dev"); + assert!(images.get("rootfs").is_none()); + assert!(images.get("initramfs").is_none()); + } + + #[test] + fn merge_drops_a_shared_scope_the_runtimes_disagree_on() { + let bundle = |id: &str| { + ImageIds::from_manifest( + &serde_json::json!({"os_bundle": {"os_build_id": id, "initramfs_build_id": "same"}}), + "x", + ) + }; + let mut images = bundle("first"); + assert_eq!(images.merge(bundle("second")), vec!["rootfs".to_string()]); + assert!(images.get("rootfs").is_none()); + assert_eq!(images.get("initramfs").unwrap().image_id, "same"); + + // A third runtime agreeing with either side doesn't bring it back. + assert!(images.merge(bundle("first")).is_empty()); + assert!(images.get("rootfs").is_none()); + } + + #[test] + fn manifest_script_output_decodes_one_runtime_per_line_and_drops_the_rest() { + let dev = serde_json::json!({"extensions": [{"name": "app", "image_id": "a"}]}); + let prod = serde_json::json!({"extensions": []}); + let output = format!( + "entrypoint noise before any marker\n\ + ##MANIFEST\tdev\t{}\n\ + not a marker at all\n\ + ##MANIFEST\tprod\t{}\n\ + ##MANIFEST\tbroken\tnot-valid-base64!!\n", + BASE64_STANDARD.encode(dev.to_string()), + BASE64_STANDARD.encode(prod.to_string()), + ); + + let manifests = parse_manifests(&output); + assert_eq!(manifests.len(), 2); + assert_eq!(manifests["dev"], dev); + assert_eq!(manifests["prod"], prod); + assert!(!manifests.contains_key("broken")); + } + + #[test] + fn a_scope_with_an_image_entry_carries_identifiers_one_without_does_not() { + let dump = format!( + "##SCOPE\trootfs\t/rootfs\n{}\ + ##SCOPE\text:dev/app\t/runtimes/dev/extensions/app\n{}", + row_full("libc6", "2.39", "r0.2", "cortexa57", "MIT", "(none)", 1000), + row_full("curl", "8.7.1", "r0.2", "cortexa57", "MIT", "(none)", 2000), + ); + let c = cmd(false); + let scopes = c.parse_scopes(&dump); + + let manifest = serde_json::json!({ + "extensions": [ + {"name": "app", "version": "1.0", "image_id": "img-app-id", "sha256": "img-app-sha"}, + ], + }); + let images = ImageIds::from_manifest(&manifest, "dev"); + + let doc = c.build_document(&scopes, "qemuarm64", None, None, Some(&images)); + let graph = doc["@graph"].as_array().unwrap(); + + let ext_scope = graph + .iter() + .find(|e| e["name"] == "ext:dev/app") + .expect("the extension scope element exists"); + assert_eq!( + ext_scope["externalIdentifier"], + serde_json::json!([{ + "type": "ExternalIdentifier", + "externalIdentifierType": "other", + "identifier": "img-app-id", + "issuingAuthority": "https://avocadolinux.org/image-id", + }]) + ); + assert_eq!( + ext_scope["verifiedUsing"], + serde_json::json!([{ + "type": "Hash", + "algorithm": "sha256", + "hashValue": "img-app-sha", + }]) + ); + + let rootfs_scope = graph + .iter() + .find(|e| e["name"] == "rootfs") + .expect("the rootfs scope element exists"); + assert!(rootfs_scope.get("externalIdentifier").is_none()); + assert!(rootfs_scope.get("verifiedUsing").is_none()); + } + + #[test] + fn an_image_entry_with_no_sha256_gets_no_verifiedusing() { + let dump = format!( + "##SCOPE\trootfs\t/rootfs\n{}", + row_full("libc6", "2.39", "r0.2", "cortexa57", "MIT", "(none)", 1000), + ); + let c = cmd(false); + let scopes = c.parse_scopes(&dump); + + let manifest = serde_json::json!({"os_bundle": {"os_build_id": "os-build-id"}}); + let images = ImageIds::from_manifest(&manifest, "dev"); + + let doc = c.build_document(&scopes, "qemuarm64", None, None, Some(&images)); + let rootfs_scope = doc["@graph"] + .as_array() + .unwrap() + .iter() + .find(|e| e["name"] == "rootfs") + .unwrap(); + assert_eq!( + rootfs_scope["externalIdentifier"][0]["identifier"], + "os-build-id" + ); + assert_eq!( + rootfs_scope["externalIdentifier"][0]["issuingAuthority"], + "https://avocadolinux.org/os-build-id" + ); + assert!(rootfs_scope.get("verifiedUsing").is_none()); + } + + #[test] + fn image_ids_never_move_the_namespace_or_any_spdxid() { + let dump = format!( + "##SCOPE\trootfs\t/rootfs\n{}\ + ##SCOPE\text:dev/app\t/runtimes/dev/extensions/app\n{}", + row_full("libc6", "2.39", "r0.2", "cortexa57", "MIT", "(none)", 1000), + row_full("curl", "8.7.1", "r0.2", "cortexa57", "MIT", "(none)", 2000), + ); + let c = cmd(false); + let scopes = c.parse_scopes(&dump); + + let manifest = serde_json::json!({ + "extensions": [ + {"name": "app", "version": "1.0", "image_id": "img-app-id", "sha256": "img-app-sha"}, + ], + }); + let images = ImageIds::from_manifest(&manifest, "dev"); + + let without = c.build_document(&scopes, "qemuarm64", None, None, None); + let with = c.build_document(&scopes, "qemuarm64", None, None, Some(&images)); + + let strip_image_props = |mut v: serde_json::Value| { + if let Some(graph) = v["@graph"].as_array_mut() { + for el in graph.iter_mut() { + if let Some(obj) = el.as_object_mut() { + obj.remove("externalIdentifier"); + obj.remove("verifiedUsing"); + } + } + } + v + }; + assert_eq!(strip_image_props(without), strip_image_props(with)); + } } From 59d2053162e0062396359a3b21bf161fdbff9fe0 Mon Sep 17 00:00:00 2001 From: Hiago De Franco Date: Tue, 22 Sep 2026 11:17:14 -0300 Subject: [PATCH 2/2] commands/sbom: add --device to describe a running device (ENG-2199) avocado sbom --device [user@]host[:port] asks avocadoctl on the device for the active runtime and its merged extensions, and emits the build SBOM filtered to that set. Merged images the build does not cover (loose .raw, HITL, unknown image ids) are listed without contents. Runtime or OS build mismatches with the local build are warned about. ssh runs in the SDK container, as in runtime deploy; DeviceSpec moves to utils/device.rs so both share it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 + src/commands/runtime/deploy.rs | 134 +------ src/commands/sbom/device.rs | 661 +++++++++++++++++++++++++++++++++ src/commands/sbom/generate.rs | 634 ++++++++++++++++++++++++++++++- src/commands/sbom/mod.rs | 1 + src/main.rs | 7 +- src/utils/device.rs | 140 +++++++ src/utils/mod.rs | 1 + src/utils/sysroot_scan.rs | 7 +- 9 files changed, 1446 insertions(+), 145 deletions(-) create mode 100644 src/commands/sbom/device.rs create mode 100644 src/utils/device.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e686ad1d..c6a0bd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `initramfs` carry `os_build_id`/`initramfs_build_id` once `runtime var-image` has run — so a device-reported image can be joined back onto the scope that describes it. `spdxId`s and the namespace are unchanged. +- **`avocado sbom --device [user@]host[:port]` describes a running + device.** (ENG-2199) Reads the device's active runtime and merged + extensions over SSH and filters the build SBOM to that set. Anything + merged that the build doesn't cover is listed as an uncovered element + instead of dropped, and a runtime/OS build id that disagrees with the + device's is warned about rather than failed on. ## [1.0.0-rc.5] - 2026-09-17 diff --git a/src/commands/runtime/deploy.rs b/src/commands/runtime/deploy.rs index b4db0535..4e0c800d 100644 --- a/src/commands/runtime/deploy.rs +++ b/src/commands/runtime/deploy.rs @@ -2,6 +2,7 @@ use crate::commands::connect::client::{self as connect_client, ConnectClient}; use crate::utils::{ config::{ComposedConfig, Config}, container::{is_docker_desktop, is_vm_routing_active, RunConfig, SdkContainer}, + device::DeviceSpec, lockfile::LockFile, output::{print_info, print_success, print_warning, OutputLevel}, output_format::{emit_json_event, is_json_output_active}, @@ -16,64 +17,6 @@ use std::sync::Arc; const DEFAULT_DEPLOY_REPO_PORT: u16 = 8585; const DEPLOY_STAGING_DIR: &str = ".avocado/deploy-staging"; -/// Parsed representation of a device connection string. -/// -/// Accepts formats: `host`, `user@host`, `host:port`, `user@host:port` -#[derive(Debug, Clone, PartialEq)] -struct DeviceSpec { - user: String, - host: String, - port: Option, -} - -impl DeviceSpec { - fn parse(device: &str) -> Result { - let (user, host_port) = if let Some(at_pos) = device.find('@') { - let user = &device[..at_pos]; - anyhow::ensure!(!user.is_empty(), "Empty user in device string '{device}'"); - (user.to_string(), &device[at_pos + 1..]) - } else { - ("root".to_string(), device) - }; - - anyhow::ensure!( - !host_port.is_empty(), - "Empty host in device string '{device}'" - ); - - let (host, port) = if let Some(colon_pos) = host_port.rfind(':') { - let maybe_port = &host_port[colon_pos + 1..]; - match maybe_port.parse::() { - Ok(p) => { - let h = &host_port[..colon_pos]; - anyhow::ensure!(!h.is_empty(), "Empty host in device string '{device}'"); - (h.to_string(), Some(p)) - } - // Not a valid port number -- treat the whole thing as a hostname - // (e.g. IPv6 addresses like ::1) - Err(_) => (host_port.to_string(), None), - } - } else { - (host_port.to_string(), None) - }; - - Ok(Self { user, host, port }) - } - - /// SSH destination in `user@host` form. - fn ssh_destination(&self) -> String { - format!("{}@{}", self.user, self.host) - } - - /// SSH port arguments: `["-p", ""]` if a port was specified, empty otherwise. - fn ssh_port_args(&self) -> String { - match self.port { - Some(p) => format!("-p {p}"), - None => String::new(), - } - } -} - pub struct RuntimeDeployCommand { runtime_name: String, config_path: String, @@ -1269,81 +1212,6 @@ mod tests { ); } - // --- DeviceSpec parsing tests --- - - #[test] - fn test_device_spec_bare_host() { - let spec = DeviceSpec::parse("192.168.1.100").unwrap(); - assert_eq!(spec.user, "root"); - assert_eq!(spec.host, "192.168.1.100"); - assert_eq!(spec.port, None); - assert_eq!(spec.ssh_destination(), "root@192.168.1.100"); - assert_eq!(spec.ssh_port_args(), ""); - } - - #[test] - fn test_device_spec_user_at_host() { - let spec = DeviceSpec::parse("admin@10.0.0.1").unwrap(); - assert_eq!(spec.user, "admin"); - assert_eq!(spec.host, "10.0.0.1"); - assert_eq!(spec.port, None); - assert_eq!(spec.ssh_destination(), "admin@10.0.0.1"); - } - - #[test] - fn test_device_spec_host_with_port() { - let spec = DeviceSpec::parse("127.0.0.1:2222").unwrap(); - assert_eq!(spec.user, "root"); - assert_eq!(spec.host, "127.0.0.1"); - assert_eq!(spec.port, Some(2222)); - assert_eq!(spec.ssh_destination(), "root@127.0.0.1"); - assert_eq!(spec.ssh_port_args(), "-p 2222"); - } - - #[test] - fn test_device_spec_user_host_port() { - let spec = DeviceSpec::parse("root@127.0.0.1:2222").unwrap(); - assert_eq!(spec.user, "root"); - assert_eq!(spec.host, "127.0.0.1"); - assert_eq!(spec.port, Some(2222)); - assert_eq!(spec.ssh_destination(), "root@127.0.0.1"); - assert_eq!(spec.ssh_port_args(), "-p 2222"); - } - - #[test] - fn test_device_spec_hostname_no_port() { - let spec = DeviceSpec::parse("device.local").unwrap(); - assert_eq!(spec.user, "root"); - assert_eq!(spec.host, "device.local"); - assert_eq!(spec.port, None); - } - - #[test] - fn test_device_spec_hostname_with_port() { - let spec = DeviceSpec::parse("device.local:22").unwrap(); - assert_eq!(spec.user, "root"); - assert_eq!(spec.host, "device.local"); - assert_eq!(spec.port, Some(22)); - } - - #[test] - fn test_device_spec_fqdn_user_port() { - let spec = DeviceSpec::parse("deploy@edge.company.com:2200").unwrap(); - assert_eq!(spec.user, "deploy"); - assert_eq!(spec.host, "edge.company.com"); - assert_eq!(spec.port, Some(2200)); - } - - #[test] - fn test_device_spec_empty_fails() { - assert!(DeviceSpec::parse("").is_err()); - } - - #[test] - fn test_device_spec_empty_user_fails() { - assert!(DeviceSpec::parse("@host").is_err()); - } - // --- RuntimeDeployCommand tests --- #[test] diff --git a/src/commands/sbom/device.rs b/src/commands/sbom/device.rs new file mode 100644 index 00000000..8c1af4c1 --- /dev/null +++ b/src/commands/sbom/device.rs @@ -0,0 +1,661 @@ +//! `avocado sbom --device`: reconcile the build SBOM against what a running +//! device reports as actually merged. +//! +//! A device has no rpmdb, so it can only report which images are merged +//! (over `avocadoctl`). This joins that report against the build's own +//! manifest (`ImageIds`) to decide which scopes describe what the device +//! has, and turns anything merged that no scope can account for into an +//! explicit "uncovered" entry instead of dropping it. +//! +//! Kept separate from `generate.rs`: everything here is a plain data shape +//! or a pure function over it, with no SPDX knowledge. Rewriting +//! `build_document`'s output stays in `generate.rs`. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::BTreeSet; + +use crate::commands::sbom::generate::ImageIds; +use crate::utils::device::DeviceSpec; + +/// `avocadoctl -o json runtime inspect`'s `RuntimeInfo`: the runtime's +/// declared name/version, as opposed to `DeviceRuntime::id`, the build id. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeviceRuntimeInfo { + pub(crate) name: String, +} + +/// One entry of `runtime inspect`'s `extensions[]`, carrying the full image +/// id. `ext status`'s own `imageId` is an 8-char prefix; don't join on it. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeviceManifestExtension { + pub(crate) name: String, + pub(crate) version: String, + #[serde(default)] + pub(crate) image_id: Option, +} + +/// `avocadoctl -o json runtime inspect`'s top-level `Runtime`. No id means +/// the active runtime. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeviceRuntime { + /// The build id (`manifest.json`'s top-level `id`). + pub(crate) id: String, + pub(crate) runtime: DeviceRuntimeInfo, + #[serde(default)] + pub(crate) extensions: Vec, + #[serde(default)] + pub(crate) os_build_id: Option, + #[serde(default)] + pub(crate) initramfs_build_id: Option, +} + +/// One entry of `avocadoctl -o json ext status`. Its `imageId` (an 8-char +/// prefix) is not modeled here either, for the same reason. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeviceExtensionStatus { + pub(crate) name: String, + #[serde(default)] + pub(crate) version: Option, + pub(crate) is_merged: bool, + #[serde(default)] + pub(crate) origin: Option, +} + +/// Both device queries, parsed. +#[derive(Debug)] +pub(crate) struct DeviceReport { + pub(crate) runtime: DeviceRuntime, + pub(crate) statuses: Vec, +} + +/// One `ext status` entry with `isMerged == true`, joined against the +/// runtime manifest's full image id where that join is meaningful. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MergedExtension { + pub(crate) name: String, + pub(crate) version: Option, + pub(crate) origin: Option, + /// `None` when there is nothing to join: an ad hoc `HITL` mount, a + /// stale version, or something merged outside the manifest entirely. + pub(crate) image_id: Option, +} + +/// One extension merged on the device that no scope in the build SBOM can +/// account for. Emitted as its own `software_Package` rather than dropped. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UncoveredEntry { + pub(crate) name: String, + pub(crate) version: Option, + pub(crate) origin: Option, +} + +impl UncoveredEntry { + /// `name [version] (origin: …)`, for the warning and the summary. + pub(crate) fn label(&self) -> String { + let version = self + .version + .as_deref() + .map(|v| format!(" {v}")) + .unwrap_or_default(); + let origin = self.origin.as_deref().unwrap_or("unknown"); + format!("{}{version} (origin: {origin})", self.name) + } +} + +/// The result of reconciling a device's merged set against the local +/// build's `ImageIds`. +pub(crate) struct KeptState { + /// Scope names to keep in the device document. `rootfs`, `initramfs`, + /// `includes` and `runtime:` are always members. + pub(crate) kept_scopes: BTreeSet, + pub(crate) uncovered: Vec, +} + +/// `ext status`'s merged entries, joined against `runtime inspect`'s +/// `extensions[]` by name and version (a version mismatch gives no id, not +/// a stale match). `origin == "HITL"` always forfeits the id. +pub(crate) fn joined_merged_extensions( + runtime: &DeviceRuntime, + statuses: &[DeviceExtensionStatus], +) -> Vec { + statuses + .iter() + .filter(|s| s.is_merged) + .map(|s| { + let image_id = if s.origin.as_deref() == Some("HITL") { + None + } else { + runtime + .extensions + .iter() + .find(|e| e.name == s.name && Some(e.version.as_str()) == s.version.as_deref()) + .and_then(|e| e.image_id.clone()) + }; + MergedExtension { + name: s.name.clone(), + version: s.version.clone(), + origin: s.origin.clone(), + image_id, + } + }) + .collect() +} + +/// Which scopes describe what the device actually has merged, and which +/// merged extensions no scope can account for. A merged extension is kept +/// only when its device-reported image id equals the local manifest's id +/// for `ext:/` and that scope was scanned. A nested remote +/// extension's id can match with its packages only under `includes`. +pub(crate) fn resolve_kept_and_uncovered( + merged: &[MergedExtension], + runtime: &str, + local_images: &ImageIds, + scanned_scopes: &BTreeSet, +) -> KeptState { + let mut kept_scopes: BTreeSet = BTreeSet::new(); + kept_scopes.insert("rootfs".to_string()); + kept_scopes.insert("initramfs".to_string()); + kept_scopes.insert("includes".to_string()); + kept_scopes.insert(format!("runtime:{runtime}")); + + let mut uncovered = Vec::new(); + for entry in merged { + let scope_name = format!("ext:{runtime}/{}", entry.name); + let covered = scanned_scopes.contains(&scope_name) + && match (&entry.image_id, local_images.get(&scope_name)) { + (Some(device_id), Some(local)) => *device_id == local.image_id, + _ => false, + }; + if covered { + kept_scopes.insert(scope_name); + } else { + uncovered.push(UncoveredEntry { + name: entry.name.clone(), + version: entry.version.clone(), + origin: entry.origin.clone(), + }); + } + } + + KeptState { + kept_scopes, + uncovered, + } +} + +/// The device's active runtime build id disagreeing with the local +/// project's is routine (any rebuild or OTA moves it), so this warns rather +/// than fails. `None` when there's no local manifest to compare against. +pub(crate) fn runtime_id_warning(device_id: &str, local_build_id: Option<&str>) -> Option { + let local_id = local_build_id?; + if local_id == device_id { + return None; + } + Some(format!( + "the device's active runtime build id ({device_id}) differs from this project's build \ + ({local_id}): one of them was rebuilt or updated since the other. Extensions are \ + still matched by image id; the rootfs, initramfs and runtime package lists below \ + are the local build's." + )) +} + +/// The device's reported `osBuildId`/`initramfsBuildId` against the local +/// build's own. `rootfs`/`initramfs` stay in the kept set regardless; a +/// mismatch only warns. +pub(crate) fn build_id_warnings(device: &DeviceRuntime, local_images: &ImageIds) -> Vec { + let mut warnings = Vec::new(); + for (scope, device_id, label) in [ + ("rootfs", device.os_build_id.as_deref(), "rootfs build id"), + ( + "initramfs", + device.initramfs_build_id.as_deref(), + "initramfs build id", + ), + ] { + let (Some(device_id), Some(local)) = (device_id, local_images.get(scope)) else { + continue; + }; + if device_id != local.image_id { + warnings.push(format!( + "the device's {label} ({device_id}) differs from this project's build \ + ({}); the {scope} package list is the local build's, not necessarily what is \ + running on the device.", + local.image_id + )); + } + } + warnings +} + +/// Marks the start of one query's output within the combined SSH session. +const RUNTIME_BEGIN: &str = "##AVOCADO-SBOM-DEVICE-RUNTIME-BEGIN##"; +const RUNTIME_RC_PREFIX: &str = "##AVOCADO-SBOM-DEVICE-RUNTIME-RC:"; +const EXT_BEGIN: &str = "##AVOCADO-SBOM-DEVICE-EXT-BEGIN##"; +const EXT_RC_PREFIX: &str = "##AVOCADO-SBOM-DEVICE-EXT-RC:"; + +/// One SSH session running both `avocadoctl` queries, each wrapped in +/// markers carrying its own exit code, so a connection failure, an +/// unsupported `avocadoctl`, and unparseable output can each be told apart. +/// Same `ssh` flags as `runtime deploy`'s own script. +pub(crate) fn ssh_query_script(spec: &DeviceSpec) -> String { + format!( + r#" +set -u +SSH_DEST="{ssh_dest}" +SSH_PORT_ARGS="{ssh_port_args}" +ssh -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + $SSH_PORT_ARGS \ + "$SSH_DEST" ' +set -u +echo "{runtime_begin}" +avocadoctl -o json runtime inspect +echo "{runtime_rc_prefix}$?##" +echo "{ext_begin}" +avocadoctl -o json ext status +echo "{ext_rc_prefix}$?##" +' +"#, + ssh_dest = spec.ssh_destination(), + ssh_port_args = spec.ssh_port_args(), + runtime_begin = RUNTIME_BEGIN, + runtime_rc_prefix = RUNTIME_RC_PREFIX, + ext_begin = EXT_BEGIN, + ext_rc_prefix = EXT_RC_PREFIX, + ) +} + +/// Pulls the payload and exit code between a `begin` marker and its `rc` +/// marker out of `output`. `None` if `begin` never appears (a broken SSH +/// session never printed any markers). +fn extract_block(output: &str, begin: &str, rc_prefix: &str) -> Option<(String, i32)> { + let after_begin = output.find(begin)?; + let rest = &output[after_begin + begin.len()..]; + let rc_pos = rest.find(rc_prefix)?; + let payload = rest[..rc_pos].trim_matches('\n').to_string(); + let after_rc = &rest[rc_pos + rc_prefix.len()..]; + let rc_str = after_rc.split("##").next()?; + let rc = rc_str.trim().parse::().ok()?; + Some((payload, rc)) +} + +/// Parses [`ssh_query_script`]'s combined output into both device queries. +/// A non-zero exit or unparseable output is a hard error naming the +/// specific `avocadoctl` command at fault. +pub(crate) fn parse_device_report(output: &str) -> Result { + let (runtime_payload, runtime_rc) = extract_block(output, RUNTIME_BEGIN, RUNTIME_RC_PREFIX) + .ok_or_else(|| { + anyhow::anyhow!( + "the SSH session never ran `avocadoctl runtime inspect`; check that ssh can \ + reach the device" + ) + })?; + anyhow::ensure!( + runtime_rc == 0, + "`avocadoctl -o json runtime inspect` exited {runtime_rc} on the device (an avocadoctl \ + older than `-o json` fails here): {}", + runtime_payload.trim() + ); + let runtime: DeviceRuntime = + serde_json::from_str(runtime_payload.trim()).with_context(|| { + format!( + "could not parse `avocadoctl runtime inspect` output as JSON: {}", + runtime_payload.trim() + ) + })?; + + let (ext_payload, ext_rc) = + extract_block(output, EXT_BEGIN, EXT_RC_PREFIX).ok_or_else(|| { + anyhow::anyhow!("the SSH session ended before `avocadoctl ext status` ran") + })?; + anyhow::ensure!( + ext_rc == 0, + "`avocadoctl ext status` exited {ext_rc} on the device: {}", + ext_payload.trim() + ); + let statuses: Vec = serde_json::from_str(ext_payload.trim()) + .with_context(|| { + format!( + "could not parse `avocadoctl ext status` output as JSON: {}", + ext_payload.trim() + ) + })?; + + Ok(DeviceReport { runtime, statuses }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::sbom::generate::ImageIds; + + fn images(entries: &[(&str, &str)]) -> ImageIds { + let manifest = serde_json::json!({ + "extensions": entries + .iter() + .map(|(name, image_id)| serde_json::json!({ + "name": name, + "version": "1.0", + "image_id": image_id, + })) + .collect::>(), + }); + ImageIds::from_manifest(&manifest, "dev") + } + + fn scanned(names: &[&str]) -> BTreeSet { + names.iter().map(|n| n.to_string()).collect() + } + + #[test] + fn an_unmerged_extension_is_dropped() { + let runtime: DeviceRuntime = serde_json::from_str( + r#"{"id":"build-1","manifestVersion":2,"builtAt":"now", + "runtime":{"name":"dev","version":"1.0"}, + "extensions":[{"name":"app","version":"1.0","imageId":"full-app-id", + "imageType":null,"sha256":"abc"}], + "active":true,"osBuildId":null,"initramfsBuildId":null}"#, + ) + .unwrap(); + let statuses: Vec = serde_json::from_str( + r#"[{"name":"app","version":"1.0","isSysext":true,"isConfext":false, + "isMerged":false,"origin":"Dir","imageId":"abcd1234","imageType":null}]"#, + ) + .unwrap(); + + let merged = joined_merged_extensions(&runtime, &statuses); + assert!(merged.is_empty(), "isMerged: false must not appear at all"); + } + + #[test] + fn the_full_id_comes_from_inspect_never_from_the_8_char_status_id() { + let runtime: DeviceRuntime = serde_json::from_str( + r#"{"id":"build-1","manifestVersion":2,"builtAt":"now", + "runtime":{"name":"dev","version":"1.0"}, + "extensions":[{"name":"app","version":"1.0", + "imageId":"11111111-2222-3333-4444-555555555555", + "imageType":null,"sha256":"abc"}], + "active":true,"osBuildId":null,"initramfsBuildId":null}"#, + ) + .unwrap(); + let statuses: Vec = serde_json::from_str( + r#"[{"name":"app","version":"1.0","isSysext":true,"isConfext":false, + "isMerged":true,"origin":"Dir","imageId":"11111111","imageType":null}]"#, + ) + .unwrap(); + + let merged = joined_merged_extensions(&runtime, &statuses); + assert_eq!(merged.len(), 1); + assert_eq!( + merged[0].image_id.as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); + } + + #[test] + fn a_name_match_at_a_different_version_gives_no_id() { + let runtime: DeviceRuntime = serde_json::from_str( + r#"{"id":"build-1","manifestVersion":2,"builtAt":"now", + "runtime":{"name":"dev","version":"1.0"}, + "extensions":[{"name":"app","version":"1.0","imageId":"full-id-v1", + "imageType":null,"sha256":"abc"}], + "active":true,"osBuildId":null,"initramfsBuildId":null}"#, + ) + .unwrap(); + let statuses: Vec = serde_json::from_str( + r#"[{"name":"app","version":"2.0","isSysext":true,"isConfext":false, + "isMerged":true,"origin":"Dir","imageId":null,"imageType":null}]"#, + ) + .unwrap(); + + let merged = joined_merged_extensions(&runtime, &statuses); + assert_eq!(merged.len(), 1); + assert!(merged[0].image_id.is_none()); + } + + #[test] + fn hitl_is_always_uncovered_even_with_a_matching_name_and_version() { + let runtime: DeviceRuntime = serde_json::from_str( + r#"{"id":"build-1","manifestVersion":2,"builtAt":"now", + "runtime":{"name":"dev","version":"1.0"}, + "extensions":[{"name":"app","version":"1.0","imageId":"full-id", + "imageType":null,"sha256":"abc"}], + "active":true,"osBuildId":null,"initramfsBuildId":null}"#, + ) + .unwrap(); + let statuses: Vec = serde_json::from_str( + r#"[{"name":"app","version":"1.0","isSysext":true,"isConfext":false, + "isMerged":true,"origin":"HITL","imageId":null,"imageType":null}]"#, + ) + .unwrap(); + + let merged = joined_merged_extensions(&runtime, &statuses); + assert_eq!(merged.len(), 1); + assert!(merged[0].image_id.is_none()); + } + + #[test] + fn a_loose_raw_with_no_manifest_entry_gives_no_id() { + let runtime: DeviceRuntime = serde_json::from_str( + r#"{"id":"build-1","manifestVersion":2,"builtAt":"now", + "runtime":{"name":"dev","version":"1.0"}, + "extensions":[], + "active":true,"osBuildId":null,"initramfsBuildId":null}"#, + ) + .unwrap(); + let statuses: Vec = serde_json::from_str( + r#"[{"name":"sideloaded","version":null,"isSysext":true,"isConfext":false, + "isMerged":true,"origin":"Loop:/root/sideloaded.raw","imageId":null, + "imageType":null}]"#, + ) + .unwrap(); + + let merged = joined_merged_extensions(&runtime, &statuses); + assert_eq!(merged.len(), 1); + assert!(merged[0].image_id.is_none()); + } + + #[test] + fn kept_scopes_are_exactly_the_base_and_the_covered_extensions() { + let merged = vec![ + MergedExtension { + name: "app".to_string(), + version: Some("1.0".to_string()), + origin: Some("Dir".to_string()), + image_id: Some("id-app".to_string()), + }, + MergedExtension { + name: "stale".to_string(), + version: Some("0.9".to_string()), + origin: Some("Dir".to_string()), + image_id: None, + }, + ]; + let local = images(&[("app", "id-app"), ("stale", "id-stale-current")]); + + let state = resolve_kept_and_uncovered(&merged, "dev", &local, &scanned(&["ext:dev/app"])); + + assert_eq!( + state.kept_scopes, + BTreeSet::from([ + "rootfs".to_string(), + "initramfs".to_string(), + "includes".to_string(), + "runtime:dev".to_string(), + "ext:dev/app".to_string(), + ]) + ); + assert_eq!(state.uncovered.len(), 1); + assert_eq!(state.uncovered[0].name, "stale"); + } + + #[test] + fn an_extension_disabled_by_override_on_the_device_never_reaches_this_function() { + let merged = vec![MergedExtension { + name: "kept-app".to_string(), + version: Some("1.0".to_string()), + origin: Some("Dir".to_string()), + image_id: Some("id-kept".to_string()), + }]; + let local = images(&[("kept-app", "id-kept"), ("disabled-app", "id-disabled")]); + + let state = + resolve_kept_and_uncovered(&merged, "dev", &local, &scanned(&["ext:dev/kept-app"])); + + assert!(state.kept_scopes.contains("ext:dev/kept-app")); + assert!(!state.kept_scopes.contains("ext:dev/disabled-app")); + assert!(state.uncovered.is_empty()); + } + + #[test] + fn an_image_id_the_local_build_does_not_recognise_is_uncovered() { + let merged = vec![MergedExtension { + name: "app".to_string(), + version: Some("1.0".to_string()), + origin: Some("Dir".to_string()), + image_id: Some("id-from-a-different-build".to_string()), + }]; + let local = images(&[("app", "id-current-build")]); + + let state = resolve_kept_and_uncovered(&merged, "dev", &local, &scanned(&["ext:dev/app"])); + + assert!(!state.kept_scopes.contains("ext:dev/app")); + assert_eq!(state.uncovered.len(), 1); + assert_eq!(state.uncovered[0].name, "app"); + } + + #[test] + fn an_extension_with_a_matching_id_but_no_scanned_scope_is_uncovered() { + let merged = vec![MergedExtension { + name: "app".to_string(), + version: Some("1.0".to_string()), + origin: Some("Dir".to_string()), + image_id: Some("id-app".to_string()), + }]; + let local = images(&[("app", "id-app")]); + + let state = resolve_kept_and_uncovered(&merged, "dev", &local, &scanned(&[])); + + assert!(!state.kept_scopes.contains("ext:dev/app")); + assert_eq!(state.uncovered.len(), 1); + assert_eq!(state.uncovered[0].name, "app"); + } + + #[test] + fn the_shared_includes_root_is_always_kept() { + let local = images(&[]); + let state = resolve_kept_and_uncovered(&[], "dev", &local, &scanned(&["includes"])); + + assert!(state.kept_scopes.contains("includes")); + } + + #[test] + fn a_runtime_id_mismatch_warns() { + assert!(runtime_id_warning("device-build", Some("local-build")).is_some()); + assert!(runtime_id_warning("same-build", Some("same-build")).is_none()); + assert!(runtime_id_warning("device-build", None).is_none()); + } + + #[test] + fn a_rootfs_build_id_mismatch_warns() { + let manifest = serde_json::json!({"os_bundle": {"os_build_id": "local-rootfs-id"}}); + let local = ImageIds::from_manifest(&manifest, "dev"); + + let mismatched: DeviceRuntime = serde_json::from_str( + r#"{"id":"b","manifestVersion":2,"builtAt":"now", + "runtime":{"name":"dev","version":"1.0"},"extensions":[], + "active":true,"osBuildId":"device-rootfs-id","initramfsBuildId":null}"#, + ) + .unwrap(); + assert_eq!(build_id_warnings(&mismatched, &local).len(), 1); + + let matched: DeviceRuntime = serde_json::from_str( + r#"{"id":"b","manifestVersion":2,"builtAt":"now", + "runtime":{"name":"dev","version":"1.0"},"extensions":[], + "active":true,"osBuildId":"local-rootfs-id","initramfsBuildId":null}"#, + ) + .unwrap(); + assert!(build_id_warnings(&matched, &local).is_empty()); + + // No local rootfs id: nothing to compare against, no warning. + let no_local = ImageIds::default(); + assert!(build_id_warnings(&mismatched, &no_local).is_empty()); + } + + #[test] + fn ssh_query_script_matches_deploys_own_ssh_options() { + let spec = DeviceSpec::parse("admin@10.0.0.5:2222").unwrap(); + let script = ssh_query_script(&spec); + assert!(script.contains("ssh -o StrictHostKeyChecking=no")); + assert!(script.contains("-o UserKnownHostsFile=/dev/null")); + assert!(script.contains("-o ConnectTimeout=10")); + assert!(script.contains("-o LogLevel=ERROR")); + assert!(script.contains("SSH_DEST=\"admin@10.0.0.5\"")); + assert!(script.contains("SSH_PORT_ARGS=\"-p 2222\"")); + assert!(script.contains("avocadoctl -o json runtime inspect")); + assert!(script.contains("avocadoctl -o json ext status")); + } + + #[test] + fn parse_device_report_reads_both_queries_from_one_session() { + let runtime_json = serde_json::json!({ + "id": "build-1", "manifestVersion": 2, "builtAt": "now", + "runtime": {"name": "dev", "version": "1.0"}, + "extensions": [], "active": true, + "osBuildId": null, "initramfsBuildId": null, + }) + .to_string(); + let ext_json = serde_json::json!([{ + "name": "app", "version": "1.0", "isSysext": true, "isConfext": false, + "isMerged": true, "origin": "Dir", "imageId": "abcd1234", "imageType": null, + }]) + .to_string(); + let output = format!( + "{RUNTIME_BEGIN}\n{runtime_json}\n{RUNTIME_RC_PREFIX}0##\n\ + {EXT_BEGIN}\n{ext_json}\n{EXT_RC_PREFIX}0##\n" + ); + + let report = parse_device_report(&output).unwrap(); + assert_eq!(report.runtime.id, "build-1"); + assert_eq!(report.runtime.runtime.name, "dev"); + assert_eq!(report.statuses.len(), 1); + assert_eq!(report.statuses[0].name, "app"); + } + + #[test] + fn parse_device_report_on_empty_output_is_an_error_not_an_empty_document() { + assert!(parse_device_report("").is_err()); + } + + #[test] + fn parse_device_report_on_garbage_output_is_an_error() { + assert!(parse_device_report("connection reset by peer\n").is_err()); + } + + #[test] + fn parse_device_report_surfaces_a_nonzero_avocadoctl_exit() { + let output = + format!("{RUNTIME_BEGIN}\nerror: unrecognized argument '-o'\n{RUNTIME_RC_PREFIX}2##\n"); + let err = parse_device_report(&output).unwrap_err(); + assert!( + err.to_string().contains("runtime inspect") && err.to_string().contains('2'), + "error should name the command and its exit code: {err}" + ); + } + + #[test] + fn parse_device_report_on_unparseable_json_is_an_error() { + let output = format!( + "{RUNTIME_BEGIN}\nnot json at all\n{RUNTIME_RC_PREFIX}0##\n\ + {EXT_BEGIN}\n[]\n{EXT_RC_PREFIX}0##\n" + ); + assert!(parse_device_report(&output).is_err()); + } +} diff --git a/src/commands/sbom/generate.rs b/src/commands/sbom/generate.rs index 5ff5432c..dab735a7 100644 --- a/src/commands/sbom/generate.rs +++ b/src/commands/sbom/generate.rs @@ -18,8 +18,10 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; +use super::device; use crate::utils::config::{ComposedConfig, Config}; use crate::utils::container::{RunConfig, SdkContainer}; +use crate::utils::device::DeviceSpec; use crate::utils::lockfile::{LockFile, RepoSnapshot, RPM_SBOM_FIELDS, RPM_SBOM_FORMAT}; use crate::utils::output::{print_info, print_success, OutputLevel}; use crate::utils::output_format::{emit_json_object, JsonOutputGuard, OutputFormat}; @@ -274,7 +276,7 @@ impl ImageIds { dropped } - fn get(&self, scope: &str) -> Option<&ImageEntry> { + pub(crate) fn get(&self, scope: &str) -> Option<&ImageEntry> { self.entries.get(scope) } } @@ -690,6 +692,91 @@ fn spdx_time(epoch: &str) -> Option { Some(dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) } +/// A deterministic id for an uncovered device entry, keyed on its own +/// identity (name/version/origin) rather than encounter order. +fn uncovered_id(ns: &str, entry: &device::UncoveredEntry) -> String { + let version = entry.version.as_deref().unwrap_or("(none)"); + let origin = entry.origin.as_deref().unwrap_or("(none)"); + format!( + "{ns}/uncovered/{}", + slug_id(&format!("{}\t{version}\t{origin}", entry.name)) + ) +} + +/// Rewrites the first `software_Sbom` node `build_document` just built into +/// a device-identified one, and appends one `software_Package` per +/// uncovered device entry. Every scope/package id it minted is left as-is, +/// so this document can still be diffed against a plain one built from the +/// same scopes. +fn attach_device_identity( + doc: &mut serde_json::Value, + ns: &str, + creation_id: &str, + target: &str, + host: &str, + uncovered: &[device::UncoveredEntry], +) { + // Per-host ids: `ns` doesn't include the host, so `{ns}/sbom` and + // `{ns}/document` would collide with other devices and the runtime + // document. + let device_sbom_id = format!("{ns}/sbom/device/{}", slug_id(host)); + let device_doc_id = format!("{ns}/document/device/{}", slug_id(host)); + let uncovered_ids: Vec = uncovered.iter().map(|u| uncovered_id(ns, u)).collect(); + + let graph = doc["@graph"] + .as_array_mut() + .expect("build_document always emits an @graph array"); + + { + let sbom = graph + .iter_mut() + .find(|e| e["type"] == "software_Sbom") + .expect("build_document always emits a software_Sbom, and it is first"); + sbom["spdxId"] = serde_json::json!(device_sbom_id); + sbom["name"] = serde_json::json!(format!("avocado {target} device {host} SBOM")); + // "runtime": this composition was observed on a running system, not + // just declared by avocado.yaml. + sbom["software_sbomType"] = serde_json::json!(["deployed", "runtime"]); + if !uncovered_ids.is_empty() { + let element = sbom["element"] + .as_array_mut() + .expect("build_document's element is always an array"); + element.extend(uncovered_ids.iter().cloned().map(serde_json::Value::String)); + // Uncovered entries are roots too: no parent scope contains them. + let root_element = sbom["rootElement"] + .as_array_mut() + .expect("build_document's rootElement is always an array"); + root_element.extend(uncovered_ids.iter().cloned().map(serde_json::Value::String)); + } + } + + if let Some(spdx_doc) = graph.iter_mut().find(|e| e["type"] == "SpdxDocument") { + spdx_doc["spdxId"] = serde_json::json!(device_doc_id); + spdx_doc["rootElement"] = serde_json::json!([device_sbom_id]); + } + + for entry in uncovered { + let mut node = serde_json::json!({ + "type": "software_Package", + "spdxId": uncovered_id(ns, entry), + "creationInfo": creation_id, + "name": entry.name, + "software_primaryPurpose": "archive", + // No `contains` edge: unlike a kept scope, there is no rpmdb or + // manifest behind this entry to say what packages it holds. + "comment": format!( + "Merged on the device with origin {}, not covered by this build's SBOM: no \ + scope in this document carries a matching image id.", + entry.origin.as_deref().unwrap_or("unknown"), + ), + }); + if let Some(version) = &entry.version { + node["software_packageVersion"] = serde_json::json!(version); + } + graph.push(node); + } +} + pub struct SbomCommand { config_path: String, /// The global `--runs-on`, carried only so the command can refuse it. @@ -705,6 +792,9 @@ pub struct SbomCommand { output: OutputFormat, sdk_arch: Option, composed_config: Option>, + /// `-d/--device`: read the merged extension set from a running device + /// and filter the build SBOM to exactly that. + device: Option, } impl SbomCommand { @@ -729,9 +819,16 @@ impl SbomCommand { runs_on: None, sdk_arch: None, composed_config: None, + device: None, } } + /// Record `-d/--device`. `None` is byte-identical to a plain `avocado sbom`. + pub fn with_device(mut self, device: Option) -> Self { + self.device = device; + self + } + /// Record the global `--runs-on` so `execute` can refuse it rather than /// silently describing the local machine. pub fn with_runs_on(mut self, runs_on: Option) -> Self { @@ -763,18 +860,40 @@ impl SbomCommand { ); } + // The SDK and target sysroot never run on a device. + if self.device.is_some() && self.include_sdk { + anyhow::bail!( + "--device and --include-sdk cannot be combined: the SDK and target sysroot run \ + on the build host, not the device, so there is nothing on the device for \ + --include-sdk to describe." + ); + } + let _json_guard = self.output.is_json().then(JsonOutputGuard::enable); let warn: fn(&str) = |m| eprintln!("[WARN] {m}"); let (scopes, target, snapshot) = self.scan(warn).await?; - let images = self.read_images(&scopes, &target, warn).await; - let doc = self.build_document(&scopes, &target, snapshot.as_ref(), None, images.as_ref()); + + let (doc, summary_scopes, uncovered) = match &self.device { + Some(device_str) => { + let (doc, kept_scopes, uncovered) = self + .build_device_document(scopes, &target, snapshot.as_ref(), device_str, warn) + .await?; + (doc, kept_scopes, Some(uncovered)) + } + None => { + let images = self.read_images(&scopes, &target, warn).await; + let doc = + self.build_document(&scopes, &target, snapshot.as_ref(), None, images.as_ref()); + (doc, scopes, None) + } + }; match &self.output_path { Some(path) => { std::fs::write(path, serde_json::to_string_pretty(&doc)?) .with_context(|| format!("Failed to write SBOM to '{path}'"))?; - self.print_summary(&scopes, Some(path)); + self.print_summary(&summary_scopes, Some(path), uncovered.as_deref()); } None if self.output.is_json() => emit_json_object(&doc), None => { @@ -1000,12 +1119,155 @@ impl SbomCommand { if !out.success { anyhow::bail!( "the manifest discovery script exited non-zero inside the SDK container.{}", - sysroot_scan::stderr_tail(&out.stderr) + sysroot_scan::stderr_tail_from(&out.stderr, "the SDK container") ); } Ok(parse_manifests(&out.stdout)) } + /// Runs `avocadoctl` queries over SSH, inside the SDK container — as + /// `runtime deploy` does, since only the container is guaranteed a + /// route to the device (e.g. macOS/Windows, where it runs in a VM). + async fn fetch_device_report( + &self, + target: &str, + spec: &DeviceSpec, + ) -> Result { + let composed = self.composed_config()?; + let config = &composed.config; + let container_image = config.get_sdk_image().cloned().ok_or_else(|| { + anyhow::anyhow!("No container image specified in config under 'sdk.image'.") + })?; + + let container = SdkContainer::from_config(&self.config_path, config)?; + let run_config = RunConfig { + container_image, + target: target.to_string(), + command: device::ssh_query_script(spec), + // The SDK environment is what puts `ssh` on PATH, as for deploy. + source_environment: true, + use_entrypoint: true, + interactive: false, + repo_url: config.get_sdk_repo_url(), + repo_release: config.get_sdk_repo_release(), + container_args: config.merge_sdk_container_args(self.container_args.as_ref()), + sdk_arch: self.sdk_arch.clone(), + ..Default::default() + }; + + let out = container.run_in_container_capture(run_config).await?; + device::parse_device_report(&out.stdout).with_context(|| { + format!( + "querying {} over SSH failed.{}", + spec.ssh_destination(), + sysroot_scan::stderr_tail_from(&out.stderr, "ssh") + ) + }) + } + + /// Queries the device, reconciles its merged set against this project's + /// build manifest, and builds the document from the matching scopes. + /// Returns the document, the scopes it was built from, and the + /// merged-but-unmatched entries. + async fn build_device_document( + &self, + scopes: Vec, + target: &str, + snapshot: Option<&RepoSnapshot>, + device_str: &str, + warn: fn(&str), + ) -> Result<(serde_json::Value, Vec, Vec)> { + let spec = DeviceSpec::parse(device_str) + .with_context(|| format!("invalid --device value '{device_str}'"))?; + let report = self.fetch_device_report(target, &spec).await?; + + // Matched by name, not build id: the id is expected to move across + // an OTA (see `runtime_id_warning` below). + let rt_name = report.runtime.runtime.name.clone(); + let rt_scope = format!("runtime:{rt_name}"); + if !scopes.iter().any(|s| s.name == rt_scope) { + anyhow::bail!( + "the device's active runtime '{rt_name}' has no matching runtime in this \ + project; run `avocado build` for a runtime named '{rt_name}', or point \ + --device at a device running a runtime this project declares." + ); + } + + // A missing manifest doesn't fail the command: every merged + // extension just ends up uncovered instead. + let manifests = match self.fetch_manifests(target).await { + Ok(m) => m, + Err(e) => { + warn(&format!( + "no build manifest: every extension merged on the device will be reported \ + as uncovered ({e:#})" + )); + BTreeMap::new() + } + }; + let local_manifest = manifests.get(&rt_name); + let local_images = local_manifest + .map(|m| ImageIds::from_manifest(m, &rt_name)) + .unwrap_or_default(); + + if let Some(warning) = device::runtime_id_warning( + &report.runtime.id, + local_manifest + .and_then(|m| m.get("id")) + .and_then(|v| v.as_str()), + ) { + warn(&warning); + } + for warning in device::build_id_warnings(&report.runtime, &local_images) { + warn(&warning); + } + + let merged = device::joined_merged_extensions(&report.runtime, &report.statuses); + let scanned_scopes: BTreeSet = scopes.iter().map(|s| s.name.clone()).collect(); + let state = + device::resolve_kept_and_uncovered(&merged, &rt_name, &local_images, &scanned_scopes); + + for entry in &state.uncovered { + warn(&format!( + "merged on the device but not covered by this build's SBOM: {}", + entry.label() + )); + } + + let kept_scopes: Vec = scopes + .into_iter() + .filter(|s| state.kept_scopes.contains(&s.name)) + .collect(); + + // Computed the same way `build_document` computes it internally, so + // ids from `kept_scopes` match a plain document built from the same + // scope list (only when nothing was dropped — see the stability test). + let ns = format!( + "https://avocadolinux.org/spdx/{}/{}", + slug_id(target), + Self::namespace_digest(&kept_scopes, Some(&rt_name)), + ); + let creation_id = format!("{ns}/creationinfo/1"); + + let mut doc = self.build_document( + &kept_scopes, + target, + snapshot, + Some(&rt_name), + Some(&local_images), + ); + attach_device_identity( + &mut doc, + &ns, + &creation_id, + target, + &spec.host, + &state.uncovered, + ); + + Ok((doc, kept_scopes, state.uncovered)) + } + /// Map the raw dump onto packages, dropping what a scope only sees because /// its installroot was seeded from the rootfs. fn parse_scopes(&self, output: &str) -> Vec { @@ -1605,8 +1867,9 @@ impl SbomCommand { path: Option<&str>, packages: usize, occurrences: usize, + uncovered: Option<&[device::UncoveredEntry]>, ) -> serde_json::Value { - serde_json::json!({ + let mut obj = serde_json::json!({ "output_path": path, "packages": packages, "occurrences": occurrences, @@ -1615,10 +1878,29 @@ impl SbomCommand { .iter() .map(|s| serde_json::json!({ "name": s.name, "packages": s.packages.len() })) .collect::>(), - }) + }); + // Only present under `--device`, so a plain summary is unchanged. + if let Some(uncovered) = uncovered { + obj["uncovered"] = serde_json::json!(uncovered + .iter() + .map(|u| serde_json::json!({ + "name": u.name, + "version": u.version, + "origin": u.origin, + })) + .collect::>()); + } + obj } - fn print_summary(&self, scopes: &[Scope], path: Option<&str>) { + /// `uncovered` is `Some` only under `--device`: merged device entries no + /// kept scope could account for. + fn print_summary( + &self, + scopes: &[Scope], + path: Option<&str>, + uncovered: Option<&[device::UncoveredEntry]>, + ) { let mut distinct: BTreeSet<(&str, &str, &str, &str, &str)> = BTreeSet::new(); for scope in scopes { for pkg in &scope.packages { @@ -1632,7 +1914,13 @@ impl SbomCommand { // to parse. Printing the human table here would put unparseable lines // on a stream a consumer reads as JSON. if self.output.is_json() { - emit_json_object(&self.summary_json(scopes, path, distinct.len(), occurrences)); + emit_json_object(&self.summary_json( + scopes, + path, + distinct.len(), + occurrences, + uncovered, + )); return; } @@ -1657,6 +1945,17 @@ impl SbomCommand { OutputLevel::Normal, ); } + if let Some(uncovered) = uncovered.filter(|u| !u.is_empty()) { + println!(); + print_info( + "Merged on the device but not covered by this build's SBOM (no scope carries \ + a matching image id — see the uncovered package(s) in the document itself):", + OutputLevel::Normal, + ); + for entry in uncovered { + println!(" {}", entry.label()); + } + } if let Some(path) = path { print_success( &format!("{} package(s) written to {path}.", distinct.len()), @@ -2402,7 +2701,7 @@ mod tests { let c = cmd(false); let scopes = c.parse_scopes(&dump); - let summary = c.summary_json(&scopes, Some("sbom.json"), 2, 2); + let summary = c.summary_json(&scopes, Some("sbom.json"), 2, 2, None); assert_eq!(summary["output_path"], "sbom.json"); assert_eq!(summary["packages"], 2); @@ -3635,4 +3934,319 @@ mod tests { }; assert_eq!(strip_image_props(without), strip_image_props(with)); } + + fn dev_local_images() -> ImageIds { + let manifest = serde_json::json!({ + "extensions": [ + {"name": "app", "version": "1.0", "image_id": "id-app-current", "sha256": "sha-app"}, + {"name": "other", "version": "1.0", "image_id": "id-other-current", "sha256": "sha-other"}, + ], + }); + ImageIds::from_manifest(&manifest, "dev") + } + + fn merged_app(image_id: &str) -> device::MergedExtension { + device::MergedExtension { + name: "app".to_string(), + version: Some("1.0".to_string()), + origin: Some("Dir".to_string()), + image_id: Some(image_id.to_string()), + } + } + + #[test] + fn the_kept_scopes_are_exactly_the_base_the_runtime_and_the_merged_extensions() { + let dump = format!( + "##SCOPE\trootfs\t/rootfs\n{}\ + ##SCOPE\truntime:dev\t/runtimes/dev\n{}\ + ##SCOPE\text:dev/app\t/runtimes/dev/extensions/app\n{}\ + ##SCOPE\text:dev/other\t/runtimes/dev/extensions/other\n{}", + row_full("libc6", "2.39", "r0.2", "cortexa57", "MIT", "(none)", 1000), + row_full("curl", "8.7.1", "r0.2", "cortexa57", "MIT", "(none)", 2000), + row_full("app-bin", "1.0", "r0", "cortexa57", "MIT", "(none)", 3000), + row_full("other-bin", "1.0", "r0", "cortexa57", "MIT", "(none)", 4000), + ); + let c = cmd(false); + let scopes = c.parse_scopes(&dump); + let local_images = dev_local_images(); + + let scanned_scopes: BTreeSet = scopes.iter().map(|s| s.name.clone()).collect(); + let merged = vec![merged_app("id-app-current")]; + let state = + device::resolve_kept_and_uncovered(&merged, "dev", &local_images, &scanned_scopes); + + assert_eq!( + state.kept_scopes, + BTreeSet::from([ + "rootfs".to_string(), + "initramfs".to_string(), + "includes".to_string(), + "runtime:dev".to_string(), + "ext:dev/app".to_string(), + ]) + ); + assert!(state.uncovered.is_empty()); + + let kept: Vec = scopes + .into_iter() + .filter(|s| state.kept_scopes.contains(&s.name)) + .collect(); + let doc = c.build_document(&kept, "qemuarm64", None, Some("dev"), Some(&local_images)); + let names: BTreeSet<&str> = doc["@graph"] + .as_array() + .unwrap() + .iter() + .filter(|e| { + e["type"] == "software_Package" && e["software_primaryPurpose"] == "archive" + }) + .filter_map(|e| e["name"].as_str()) + .collect(); + + assert!(names.contains("rootfs")); + assert!(names.contains("runtime:dev")); + assert!(names.contains("ext:dev/app")); + assert!( + !names.contains("ext:dev/other"), + "an extension present locally but never reported merged must not appear: {names:?}" + ); + } + + #[test] + fn a_device_image_id_the_local_build_does_not_recognise_is_dropped_and_uncovered() { + let local_images = dev_local_images(); + let merged = vec![merged_app("id-app-stale")]; + let scanned_scopes = BTreeSet::from(["ext:dev/app".to_string()]); + let state = + device::resolve_kept_and_uncovered(&merged, "dev", &local_images, &scanned_scopes); + + assert!(!state.kept_scopes.contains("ext:dev/app")); + assert_eq!(state.uncovered.len(), 1); + assert_eq!(state.uncovered[0].name, "app"); + } + + #[test] + fn kept_scope_ids_are_identical_to_a_plain_runtime_scoped_document_when_nothing_was_dropped() { + let dump = format!( + "##SCOPE\trootfs\t/rootfs\n{}\ + ##SCOPE\truntime:dev\t/runtimes/dev\n{}\ + ##SCOPE\text:dev/app\t/runtimes/dev/extensions/app\n{}", + row_full("libc6", "2.39", "r0.2", "cortexa57", "MIT", "(none)", 1000), + row_full("curl", "8.7.1", "r0.2", "cortexa57", "MIT", "(none)", 2000), + row_full("app-bin", "1.0", "r0", "cortexa57", "MIT", "(none)", 3000), + ); + let c = cmd(false); + let scopes = c.parse_scopes(&dump); + let local_images = dev_local_images(); + + let scanned_scopes: BTreeSet = scopes.iter().map(|s| s.name.clone()).collect(); + let merged = vec![merged_app("id-app-current")]; + let state = + device::resolve_kept_and_uncovered(&merged, "dev", &local_images, &scanned_scopes); + assert!(state.uncovered.is_empty(), "nothing should be dropped here"); + + let device_kept: Vec = scopes + .into_iter() + .filter(|s| state.kept_scopes.contains(&s.name)) + .collect(); + + let plain_dump = dump.clone(); + let plain_scopes = c.parse_scopes(&plain_dump); + let plain_kept: Vec = plain_scopes + .into_iter() + .filter(|s| in_runtime(&s.name, "dev")) + .collect(); + + let device_doc = c.build_document( + &device_kept, + "qemuarm64", + None, + Some("dev"), + Some(&local_images), + ); + let plain_doc = c.build_document( + &plain_kept, + "qemuarm64", + None, + Some("dev"), + Some(&local_images), + ); + + let scope_id = |doc: &serde_json::Value, name: &str| { + doc["@graph"] + .as_array() + .unwrap() + .iter() + .find(|e| e["type"] == "software_Package" && e["name"] == name) + .map(|e| e["spdxId"].as_str().unwrap().to_string()) + }; + for name in ["rootfs", "runtime:dev", "ext:dev/app"] { + assert_eq!( + scope_id(&device_doc, name), + scope_id(&plain_doc, name), + "scope '{name}' must carry the same spdxId in both documents" + ); + } + } + + #[test] + fn attach_device_identity_renames_the_first_sbom_and_stays_first() { + // A merged extension so build_document emits a second software_Sbom + // too, otherwise "stays first" holds trivially. + let dump = format!( + "##SCOPE\trootfs\t/rootfs\n{}\ + ##SCOPE\truntime:dev\t/runtimes/dev\n{}\ + ##SCOPE\text:dev/app\t/runtimes/dev/extensions/app\n{}", + row_full("libc6", "2.39", "r0.2", "cortexa57", "MIT", "(none)", 1000), + row_full("curl", "8.7.1", "r0.2", "cortexa57", "MIT", "(none)", 2000), + row_full("app-bin", "1.0", "r0", "cortexa57", "MIT", "(none)", 3000), + ); + let c = cmd(false); + let scopes = c.parse_scopes(&dump); + let mut doc = c.build_document(&scopes, "qemuarm64", None, Some("dev"), None); + + let ns = format!( + "https://avocadolinux.org/spdx/{}/{}", + slug_id("qemuarm64"), + SbomCommand::namespace_digest(&scopes, Some("dev")), + ); + let creation_id = format!("{ns}/creationinfo/1"); + let uncovered = vec![device::UncoveredEntry { + name: "sideloaded".to_string(), + version: None, + origin: Some("Loop:/root/sideloaded.raw".to_string()), + }]; + + attach_device_identity( + &mut doc, + &ns, + &creation_id, + "qemuarm64", + "192.168.1.50", + &uncovered, + ); + + let graph = doc["@graph"].as_array().unwrap(); + let first_sbom = graph.iter().find(|e| e["type"] == "software_Sbom").unwrap(); + assert_eq!( + first_sbom["name"], + "avocado qemuarm64 device 192.168.1.50 SBOM" + ); + assert_eq!( + first_sbom["software_sbomType"], + serde_json::json!(["deployed", "runtime"]) + ); + assert_ne!(first_sbom["spdxId"].as_str().unwrap(), format!("{ns}/sbom")); + + let device_sbom_id = first_sbom["spdxId"].as_str().unwrap().to_string(); + let spdx_doc = graph.iter().find(|e| e["type"] == "SpdxDocument").unwrap(); + assert_eq!(spdx_doc["rootElement"], serde_json::json!([device_sbom_id])); + + let uncovered_node = graph + .iter() + .find(|e| e["name"] == "sideloaded") + .expect("the uncovered entry got its own element"); + assert_eq!(uncovered_node["software_primaryPurpose"], "archive"); + assert!( + uncovered_node.get("software_packageVersion").is_none(), + "no version was known, so none should be asserted" + ); + assert!(uncovered_node["comment"] + .as_str() + .unwrap() + .contains("Loop:/root/sideloaded.raw")); + let uncovered_id_str = uncovered_node["spdxId"].as_str().unwrap().to_string(); + assert!(first_sbom["element"] + .as_array() + .unwrap() + .iter() + .any(|v| v == &uncovered_id_str)); + assert!(first_sbom["rootElement"] + .as_array() + .unwrap() + .iter() + .any(|v| v == &uncovered_id_str)); + + assert!(!graph.iter().any(|e| e["type"] == "Relationship" + && e["to"] + .as_array() + .is_some_and(|to| to.iter().any(|v| v == &uncovered_id_str)))); + + let sbom_names: Vec<&str> = graph + .iter() + .filter(|e| e["type"] == "software_Sbom") + .map(|e| e["name"].as_str().unwrap()) + .collect(); + assert!( + sbom_names.len() >= 2, + "fixture should produce more than one software_Sbom: {sbom_names:?}" + ); + assert_eq!(sbom_names[0], "avocado qemuarm64 device 192.168.1.50 SBOM"); + } + + #[test] + fn attach_device_identity_gives_the_spdx_document_a_per_host_id() { + let dump = format!( + "##SCOPE\trootfs\t/rootfs\n{}", + row_full("libc6", "2.39", "r0.2", "cortexa57", "MIT", "(none)", 1000), + ); + let c = cmd(false); + let scopes = c.parse_scopes(&dump); + let ns = format!( + "https://avocadolinux.org/spdx/{}/{}", + slug_id("qemuarm64"), + SbomCommand::namespace_digest(&scopes, Some("dev")), + ); + let creation_id = format!("{ns}/creationinfo/1"); + + let doc_id = |host: &str| { + let mut doc = c.build_document(&scopes, "qemuarm64", None, Some("dev"), None); + attach_device_identity(&mut doc, &ns, &creation_id, "qemuarm64", host, &[]); + doc["@graph"] + .as_array() + .unwrap() + .iter() + .find(|e| e["type"] == "SpdxDocument") + .unwrap()["spdxId"] + .as_str() + .unwrap() + .to_string() + }; + + let device_doc_id = doc_id("192.168.1.50"); + assert_ne!(device_doc_id, format!("{ns}/document")); + assert!(device_doc_id.contains(&slug_id("192.168.1.50"))); + + let other_host_doc_id = doc_id("192.168.1.51"); + assert_ne!(device_doc_id, other_host_doc_id); + } + + #[test] + fn uncovered_ids_are_deterministic_not_assigned_in_encounter_order() { + let entry = device::UncoveredEntry { + name: "app".to_string(), + version: Some("1.0".to_string()), + origin: Some("HITL".to_string()), + }; + let ns = "https://avocadolinux.org/spdx/qemuarm64/deadbeef"; + assert_eq!(uncovered_id(ns, &entry), uncovered_id(ns, &entry)); + + let different_origin = device::UncoveredEntry { + origin: Some("Dir".to_string()), + ..entry.clone() + }; + assert_ne!( + uncovered_id(ns, &entry), + uncovered_id(ns, &different_origin) + ); + } + + #[tokio::test] + async fn device_and_include_sdk_are_refused_together() { + let c = cmd(true).with_device(Some("root@192.168.1.50".to_string())); + let err = c.execute().await.unwrap_err(); + assert!( + err.to_string().contains("--include-sdk"), + "error should name the refused combination: {err}" + ); + } } diff --git a/src/commands/sbom/mod.rs b/src/commands/sbom/mod.rs index 57e6a9d2..f19befd2 100644 --- a/src/commands/sbom/mod.rs +++ b/src/commands/sbom/mod.rs @@ -1 +1,2 @@ +pub(crate) mod device; pub mod generate; diff --git a/src/main.rs b/src/main.rs index 0750be86..7598143b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,6 +176,9 @@ enum Commands { /// controls whether the summary lines accompany it. #[arg(long, value_enum, default_value_t = OutputFormat::Human)] output: OutputFormat, + /// Describe a running device instead: [user@]host[:port] + #[arg(short = 'd', long = "device")] + device: Option, }, /// Initialize a new avocado project Init { @@ -3353,6 +3356,7 @@ async fn main() -> Result<()> { verbose, container_args, output, + device, } => { let cmd = SbomCommand::new( config, @@ -3364,7 +3368,8 @@ async fn main() -> Result<()> { output, ) .with_runs_on(cli.runs_on.clone()) - .with_sdk_arch(cli.sdk_arch.clone()); + .with_sdk_arch(cli.sdk_arch.clone()) + .with_device(device); cmd.execute().await?; Ok(()) } diff --git a/src/utils/device.rs b/src/utils/device.rs new file mode 100644 index 00000000..e2bc0f4b --- /dev/null +++ b/src/utils/device.rs @@ -0,0 +1,140 @@ +//! Parses `[user@]host[:port]` device connection strings, shared by +//! `runtime deploy` and `sbom --device` so both reach a device the same way. + +use anyhow::Result; + +/// Parsed representation of a device connection string. +/// +/// Accepts formats: `host`, `user@host`, `host:port`, `user@host:port` +#[derive(Debug, Clone, PartialEq)] +pub struct DeviceSpec { + pub user: String, + pub host: String, + pub port: Option, +} + +impl DeviceSpec { + pub fn parse(device: &str) -> Result { + let (user, host_port) = if let Some(at_pos) = device.find('@') { + let user = &device[..at_pos]; + anyhow::ensure!(!user.is_empty(), "Empty user in device string '{device}'"); + (user.to_string(), &device[at_pos + 1..]) + } else { + ("root".to_string(), device) + }; + + anyhow::ensure!( + !host_port.is_empty(), + "Empty host in device string '{device}'" + ); + + let (host, port) = if let Some(colon_pos) = host_port.rfind(':') { + let maybe_port = &host_port[colon_pos + 1..]; + match maybe_port.parse::() { + Ok(p) => { + let h = &host_port[..colon_pos]; + anyhow::ensure!(!h.is_empty(), "Empty host in device string '{device}'"); + (h.to_string(), Some(p)) + } + // Not a valid port number -- treat the whole thing as a hostname + // (e.g. IPv6 addresses like ::1) + Err(_) => (host_port.to_string(), None), + } + } else { + (host_port.to_string(), None) + }; + + Ok(Self { user, host, port }) + } + + /// SSH destination in `user@host` form. + pub fn ssh_destination(&self) -> String { + format!("{}@{}", self.user, self.host) + } + + /// `-p `, or empty if no port was specified. + pub fn ssh_port_args(&self) -> String { + match self.port { + Some(p) => format!("-p {p}"), + None => String::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_spec_bare_host() { + let spec = DeviceSpec::parse("192.168.1.100").unwrap(); + assert_eq!(spec.user, "root"); + assert_eq!(spec.host, "192.168.1.100"); + assert_eq!(spec.port, None); + assert_eq!(spec.ssh_destination(), "root@192.168.1.100"); + assert_eq!(spec.ssh_port_args(), ""); + } + + #[test] + fn test_device_spec_user_at_host() { + let spec = DeviceSpec::parse("admin@10.0.0.1").unwrap(); + assert_eq!(spec.user, "admin"); + assert_eq!(spec.host, "10.0.0.1"); + assert_eq!(spec.port, None); + assert_eq!(spec.ssh_destination(), "admin@10.0.0.1"); + } + + #[test] + fn test_device_spec_host_with_port() { + let spec = DeviceSpec::parse("127.0.0.1:2222").unwrap(); + assert_eq!(spec.user, "root"); + assert_eq!(spec.host, "127.0.0.1"); + assert_eq!(spec.port, Some(2222)); + assert_eq!(spec.ssh_destination(), "root@127.0.0.1"); + assert_eq!(spec.ssh_port_args(), "-p 2222"); + } + + #[test] + fn test_device_spec_user_host_port() { + let spec = DeviceSpec::parse("root@127.0.0.1:2222").unwrap(); + assert_eq!(spec.user, "root"); + assert_eq!(spec.host, "127.0.0.1"); + assert_eq!(spec.port, Some(2222)); + assert_eq!(spec.ssh_destination(), "root@127.0.0.1"); + assert_eq!(spec.ssh_port_args(), "-p 2222"); + } + + #[test] + fn test_device_spec_hostname_no_port() { + let spec = DeviceSpec::parse("device.local").unwrap(); + assert_eq!(spec.user, "root"); + assert_eq!(spec.host, "device.local"); + assert_eq!(spec.port, None); + } + + #[test] + fn test_device_spec_hostname_with_port() { + let spec = DeviceSpec::parse("device.local:22").unwrap(); + assert_eq!(spec.user, "root"); + assert_eq!(spec.host, "device.local"); + assert_eq!(spec.port, Some(22)); + } + + #[test] + fn test_device_spec_fqdn_user_port() { + let spec = DeviceSpec::parse("deploy@edge.company.com:2200").unwrap(); + assert_eq!(spec.user, "deploy"); + assert_eq!(spec.host, "edge.company.com"); + assert_eq!(spec.port, Some(2200)); + } + + #[test] + fn test_device_spec_empty_fails() { + assert!(DeviceSpec::parse("").is_err()); + } + + #[test] + fn test_device_spec_empty_user_fails() { + assert!(DeviceSpec::parse("@host").is_err()); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 09c3ddc0..ba54c837 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,7 @@ pub mod config; pub mod config_edit; pub mod container; pub mod container_dev; +pub mod device; pub mod device_tree_overlay; #[cfg(target_os = "macos")] pub mod disk_writer; diff --git a/src/utils/sysroot_scan.rs b/src/utils/sysroot_scan.rs index deaf7b53..7c49afae 100644 --- a/src/utils/sysroot_scan.rs +++ b/src/utils/sysroot_scan.rs @@ -267,6 +267,11 @@ pub async fn run_discovery(config: &Config, req: ScanRequest<'_>) -> Result String { + stderr_tail_from(stderr, "rpm") +} + +/// [`stderr_tail`] for stderr that isn't rpm's, labelled with `source`. +pub fn stderr_tail_from(stderr: &str, source: &str) -> String { const LINES: usize = 10; let lines: Vec<&str> = stderr .lines() @@ -277,7 +282,7 @@ pub fn stderr_tail(stderr: &str) -> String { return String::new(); } let tail = lines[lines.len().saturating_sub(LINES)..].join("\n "); - format!(" rpm reported:\n {tail}") + format!(" {source} reported:\n {tail}") } /// The discovery script, with its placeholders left in. Exposed so tests can