From 4748aa68891d7924fc30504e605e081d325b5f55 Mon Sep 17 00:00:00 2001 From: BootIntel Agent Date: Wed, 7 Oct 2026 07:52:36 +0000 Subject: [PATCH] Add `bootintel submit` to fetch server-side artifacts The CLI could reach server analysis but not anything the server builds from it. `scan --api` posts to `/analysis/scan`, which the server documents as "stateless inline log analysis without device_id/database persistence" and which returns a freshly minted uuid as its scan_id, naming no row. The SBOM, evidence pack, PDF and JSON report are all built from a real ScanSession, so there was nothing for the CLI to export from. SBOM-in-CI is the canonical use case for the format and it was the one place the product could not do it. `bootintel submit ` creates the persisted scan and downloads whichever artifacts are asked for: --sbom, --evidence, --pdf, --json-report, with --json printing the scan id and written paths for scripting. A separate subcommand rather than a flag on `scan`, because it costs something `scan` does not: a saved scan consumes the account's monthly quota. The notice is printed immediately before that spend rather than at the top of the run, since printing it first meant a 403 from the device lookup was preceded by a claim about a charge that never happened. Requires PRO, and the two gates are easy to conflate. The export endpoints are gated at Researcher, but get_current_user refuses any X-API-Key request below Pro, so a Researcher can download these from the dashboard and not from here. The help text first said "Researcher or higher" by quoting the endpoint's own gate, which would have told a Researcher this command works for them. An existing device with the same name is reused instead of creating one per run. POST /devices/ accepts duplicates, so a nightly CI job would otherwise add a device a day and nothing server-side would have complained. Verified end to end against the live API with a real Pro account, through a loopback forwarder because the droplet's /etc/hosts points bootintel.com at a local nginx whose origin certificate the system store does not trust. All four artifacts came back genuine: a 25-page PDF with an intact %PDF-1.4 header, a valid zip containing manifest/findings/analysis/raw-log, and a CycloneDX 1.6 SBOM naming the real board with 6 components and 73 vulnerabilities that validates clean against the vendored upstream schema. A second run produced two scans and one device, confirming reuse. 403, missing-key and plaintext-base all exit 77 with the server's own reason shown. Seven integration tests drive the compiled binary against a mock server, since the exit code, the request sequence and byte fidelity are properties of the process. The binary-artifact test ships bytes that are not valid UTF-8, because a String round-trip would corrupt a PDF silently and a corrupt PDF still looks like a file on disk. 391 tests pass; fmt and clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 21 ++ README.md | 1 + crates/cli/src/api/endpoints.rs | 30 +++ crates/cli/src/cmd/mod.rs | 1 + crates/cli/src/cmd/submit.rs | 454 ++++++++++++++++++++++++++++++++ crates/cli/src/main.rs | 8 + crates/cli/tests/submit_cli.rs | 330 +++++++++++++++++++++++ 7 files changed, 845 insertions(+) create mode 100644 crates/cli/src/cmd/submit.rs create mode 100644 crates/cli/tests/submit_cli.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c39e9d5..0de7ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +### Added +- **`bootintel submit`**: save a boot log to your BootIntel account as a persisted + scan and download the server-side artifacts in one step: the CycloneDX 1.6 SBOM + (`--sbom`), evidence pack (`--evidence`), PDF report (`--pdf`) and JSON report + (`--json-report`). `--json` prints the scan id and written paths for scripting. + + Separate from `scan --api` on purpose. That posts to `/analysis/scan`, which the + server documents as stateless and returns a freshly minted uuid naming no row, + so there is nothing to export from; artifacts are built from a real scan, which + only `POST /scans/` creates. A saved scan consumes the monthly quota, so this is + opt-in rather than a flag, and the command says what it is about to spend + immediately before spending it. + + Requires **Pro** or higher. The export endpoints are gated at Researcher, but + API-key authentication is itself Pro-gated, so a Researcher account can download + these from the dashboard and not from here. + + Reuses an existing device with the same name rather than creating one per run, + because the server accepts duplicates and a nightly CI job would otherwise add + a device a day. + ## [0.13.0] — 2026-09-29 — read boot logs from your own assistant ### Added diff --git a/README.md b/README.md index 8296da1..6253677 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Use `bootintel term` when you want a clean terminal and `bootintel analyze` when | Assess what the boot chain permits | `bootintel verdict session.log` | Reads a `printenv` dump taken at the U-Boot prompt and says what it permits. Entirely offline. | | Compare firmware boots | `bootintel diff before.log after.log` | Shows meaningful boot-log changes between two captures. | | Gate a build artifact | `bootintel scan boot.log --format sarif --gate-critical` | Emits CI-friendly output and exits non-zero for critical findings. | +| Produce an SBOM for a boot log | `bootintel submit boot.log --sbom device.cdx.json` | Saves the scan to your account and downloads a CycloneDX 1.6 SBOM built from the server's CVE and KEV data. Needs a Pro plan (API-key auth is Pro-gated) and uses one scan from the monthly quota. `--evidence`, `--pdf` and `--json-report` fetch the other artifacts in the same run. | | Request richer analysis | `bootintel scan --api --preview boot.log` | Explicitly sends the log to BootIntel's API using the anonymous preview quota. | ## Using it from an assistant diff --git a/crates/cli/src/api/endpoints.rs b/crates/cli/src/api/endpoints.rs index 6758bc9..b3c3d1b 100644 --- a/crates/cli/src/api/endpoints.rs +++ b/crates/cli/src/api/endpoints.rs @@ -43,6 +43,36 @@ pub fn scan_url(base: &str) -> String { ) } +/// Saved-scan endpoints. +/// +/// Distinct from `scan_url` above, and the difference matters: that one is +/// `/analysis/scan`, documented server-side as "stateless inline log analysis +/// without device_id/database persistence". It returns a freshly minted uuid as +/// its `scan_id` which names no row, so there is nothing to export from. These +/// routes create and read a real ScanSession, which is what the server-side +/// artifacts (SBOM, evidence pack, PDF, JSON report) are built from. +/// +/// A saved scan consumes the account's monthly scan quota. The stateless route +/// does not. Callers must not reach for these silently. +pub fn devices_url(base: &str) -> String { + format!("{}{}/devices/", base.trim_end_matches('/'), path_prefix()) +} + +pub fn scans_url(base: &str) -> String { + format!("{}{}/scans/", base.trim_end_matches('/'), path_prefix()) +} + +/// `/scans/{id}/{artifact}` where artifact is e.g. `sbom.json`. +pub fn scan_artifact_url(base: &str, scan_id: &str, artifact: &str) -> String { + format!( + "{}{}/scans/{}/{}", + base.trim_end_matches('/'), + path_prefix(), + scan_id, + artifact + ) +} + /// Result of `check_plaintext_base`. Callers use this to decide whether /// to abort (auth path) or emit a warning (preview path). #[derive(Debug, PartialEq, Eq)] diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs index 14c71ca..74a7aec 100644 --- a/crates/cli/src/cmd/mod.rs +++ b/crates/cli/src/cmd/mod.rs @@ -21,6 +21,7 @@ pub mod replay; pub mod scan; pub mod schema; pub mod share; +pub mod submit; pub mod term; pub mod verdict; pub mod version; diff --git a/crates/cli/src/cmd/submit.rs b/crates/cli/src/cmd/submit.rs new file mode 100644 index 0000000..f739868 --- /dev/null +++ b/crates/cli/src/cmd/submit.rs @@ -0,0 +1,454 @@ +//! `bootintel submit ` — save a scan to your account and download the +//! server-side artifacts: CycloneDX SBOM, evidence pack, PDF and JSON report. +//! +//! Why this is a separate command from `scan --api`. +//! +//! `scan --api` posts to `/analysis/scan`, which the server documents as +//! "stateless inline log analysis without device_id/database persistence". It +//! returns a freshly minted uuid as its `scan_id`, naming no row, so there is +//! nothing on the server to export. Artifacts are built from a real +//! ScanSession, which only `POST /scans/` creates. +//! +//! That distinction has a price attached, which is the reason this is opt-in +//! rather than a flag on `scan`: a saved scan consumes the account's monthly +//! quota (5/month on Free, 15 on Researcher, unlimited above). The command +//! says what it is about to spend before it spends it. +//! +//! The plan requirement is PRO, not Researcher, and the two gates are easy to +//! conflate. The export endpoints are gated at Researcher, but +//! get_current_user refuses any X-API-Key request below Pro with "API access +//! requires Pro plan or higher", so a Researcher can download these artifacts +//! from the dashboard and not from here. Quoting the endpoint's own gate would +//! have told a Researcher this command works for them. +//! +//! Device handling reuses an existing device with the same name instead of +//! creating one per invocation. Without that, a nightly CI job would add a +//! device a day and the fleet view would become useless; the server is happy +//! to hold duplicates, so nothing else would have complained. +//! +//! Exit codes follow the rest of the CLI (sysexits.h): +//! 0 — scan saved, every requested artifact written +//! 65 — EX_DATAERR — server rejected the request (400/413/422) +//! 69 — EX_UNAVAILABLE — network failure +//! 77 — EX_NOPERM — 401 (no/invalid key) or 403 (plan or quota) + +use anyhow::{bail, Context, Result}; +use clap::Args as ClapArgs; +use serde::Deserialize; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::api::endpoints::{ + check_plaintext_base, devices_url, scan_artifact_url, scans_url, PlaintextCheck, +}; + +const EX_DATAERR: i32 = 65; +const EX_UNAVAILABLE: i32 = 69; +const EX_NOPERM: i32 = 77; + +/// Generous: a PDF or evidence pack for a long log takes the server real time +/// to build, and this is a one-shot CI step rather than an interactive command. +const TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(ClapArgs, Debug)] +pub struct Args { + /// Log file (or `-` for stdin). + #[arg(value_name = "LOG")] + log: PathBuf, + + /// Device to attach the scan to, by name. Reused if it already exists, + /// created otherwise. Defaults to the log's file name. + #[arg(long, value_name = "NAME")] + device_name: Option, + + /// Attach to this exact device id, skipping name lookup and creation. + #[arg(long, value_name = "UUID", conflicts_with = "device_name")] + device_id: Option, + + /// Write the CycloneDX 1.6 SBOM here. + #[arg(long, value_name = "PATH")] + sbom: Option, + + /// Write the evidence pack (zip) here. + #[arg(long, value_name = "PATH")] + evidence: Option, + + /// Write the PDF report here. + #[arg(long, value_name = "PATH")] + pdf: Option, + + /// Write the JSON report here. + #[arg(long, value_name = "PATH")] + json_report: Option, + + /// Do not persist the raw log server-side; keep findings and artifacts + /// only. The SBOM and reports are still produced. + #[arg(long)] + no_store_log: bool, + + /// Override the API base URL. Defaults to + /// (or $BOOTINTEL_API_BASE, or the config file's api_base). + #[arg(long, value_name = "URL")] + api_base: Option, + + /// Machine-readable result on stdout instead of the human summary. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Deserialize)] +struct DeviceRow { + id: String, + name: Option, +} + +#[derive(Debug, Deserialize)] +struct ScanRow { + id: String, +} + +/// One requested artifact: the server path and where to put it. +struct Artifact<'a> { + flag: &'static str, + route: &'static str, + dest: &'a Path, + binary: bool, +} + +pub fn run(args: Args) -> Result<()> { + let base = crate::config::resolve_api_base(args.api_base.as_deref()); + + // Same rule as the rest of the authenticated surface: an API key must not + // cross a plaintext hop. Loopback is exempt so a local mock still works. + if check_plaintext_base(&base) == PlaintextCheck::UnsafePlaintext { + eprintln!("refusing to send an API key over plaintext http:// to {base}"); + eprintln!("use https://, or point --api-base at a loopback address for local testing."); + std::process::exit(EX_NOPERM); + } + + let key = match crate::config::resolve_api_key() { + Some(k) if !k.trim().is_empty() => k, + _ => { + eprintln!("no API key configured. Run `bootintel login`, or set BOOTINTEL_API_KEY."); + eprintln!( + "a saved scan needs a Pro account: `bootintel scan` works offline without one." + ); + std::process::exit(EX_NOPERM); + } + }; + + let raw = read_input(&args.log)?; + if raw.trim().is_empty() { + bail!("{} is empty; nothing to submit", args.log.display()); + } + + let wanted = requested_artifacts(&args); + let agent = ureq::AgentBuilder::new() + .timeout_connect(TIMEOUT) + .timeout_read(TIMEOUT) + .build(); + + let device_id = resolve_device(&agent, &base, &key, &args)?; + + // Announced immediately before the spend, not at the top of the run. + // Printing it earlier meant a 403 from the device lookup was preceded by + // "this uses one scan from your monthly quota", which was a claim about a + // charge that never happened. Suppressed under --json, where stdout is + // being parsed and stderr noise is still noise. + if !args.json { + eprintln!("saving a scan to {base} (this uses one scan from your monthly quota)"); + } + let scan_id = create_scan(&agent, &base, &key, &raw, &device_id, args.no_store_log)?; + + let mut written: Vec<(&'static str, String)> = Vec::new(); + for artifact in &wanted { + let url = scan_artifact_url(&base, &scan_id, artifact.route); + crate::vinfo!("GET {url}"); + let bytes = fetch_artifact(&agent, &url, &key, artifact)?; + write_out(artifact.dest, &bytes) + .with_context(|| format!("writing {}", artifact.dest.display()))?; + written.push((artifact.flag, artifact.dest.display().to_string())); + } + + if args.json { + let obj = serde_json::json!({ + "scan_id": scan_id, + "device_id": device_id, + "artifacts": written.iter().map(|(k, v)| serde_json::json!({"kind": k, "path": v})) + .collect::>(), + }); + let mut out = io::stdout().lock(); + serde_json::to_writer_pretty(&mut out, &obj)?; + writeln!(out)?; + } else { + println!("saved scan {scan_id}"); + for (kind, path) in &written { + println!(" {kind:<9} {path}"); + } + if written.is_empty() { + println!( + " (no artifacts requested; pass --sbom / --evidence / --pdf / --json-report)" + ); + } + println!(" dashboard {}/dashboard", base.trim_end_matches('/')); + } + Ok(()) +} + +fn requested_artifacts(args: &Args) -> Vec> { + let mut v = Vec::new(); + if let Some(p) = args.sbom.as_deref() { + v.push(Artifact { + flag: "sbom", + route: "sbom.json", + dest: p, + binary: false, + }); + } + if let Some(p) = args.json_report.as_deref() { + v.push(Artifact { + flag: "json", + route: "report.json", + dest: p, + binary: false, + }); + } + if let Some(p) = args.evidence.as_deref() { + v.push(Artifact { + flag: "evidence", + route: "evidence.zip", + dest: p, + binary: true, + }); + } + if let Some(p) = args.pdf.as_deref() { + v.push(Artifact { + flag: "pdf", + route: "report.pdf", + dest: p, + binary: true, + }); + } + v +} + +/// An existing device with this name, or a new one. +/// +/// Reuse is the point. `POST /devices/` happily creates a second device with +/// the same name, so a CI job without this would add one per run. +fn resolve_device(agent: &ureq::Agent, base: &str, key: &str, args: &Args) -> Result { + if let Some(id) = args.device_id.as_deref() { + return Ok(id.to_string()); + } + let name = args + .device_name + .clone() + .unwrap_or_else(|| default_device_name(&args.log)); + + let list_url = devices_url(base); + crate::vinfo!("GET {list_url}"); + match agent + .get(&list_url) + .set("X-API-Key", key) + .set("Accept", "application/json") + .call() + { + Ok(resp) => { + let rows: Vec = resp.into_json().unwrap_or_default(); + if let Some(hit) = rows + .iter() + .find(|d| d.name.as_deref().map(str::trim) == Some(name.trim())) + { + crate::vinfo!("reusing device {} ({name})", hit.id); + return Ok(hit.id.clone()); + } + } + Err(err) => return Err(api_exit("listing devices", err)), + } + + let create_url = devices_url(base); + crate::vinfo!("POST {create_url} (creating device {name})"); + let body = serde_json::json!({ "name": name }); + match agent + .post(&create_url) + .set("X-API-Key", key) + .set("Accept", "application/json") + .send_json(body) + { + Ok(resp) => { + let row: DeviceRow = resp.into_json().context("parsing created device")?; + Ok(row.id) + } + Err(err) => Err(api_exit("creating a device", err)), + } +} + +fn create_scan( + agent: &ureq::Agent, + base: &str, + key: &str, + raw_log: &str, + device_id: &str, + no_store_log: bool, +) -> Result { + let url = scans_url(base); + crate::vinfo!("POST {url} ({} bytes)", raw_log.len()); + let body = serde_json::json!({ + "device_id": device_id, + "raw_log": raw_log, + "store_raw_log": !no_store_log, + }); + match agent + .post(&url) + .set("X-API-Key", key) + .set("Accept", "application/json") + .send_json(body) + { + // POST /scans/ runs the analysis inline and returns a completed scan, + // so there is nothing to poll for before fetching artifacts. + Ok(resp) => { + let row: ScanRow = resp.into_json().context("parsing created scan")?; + Ok(row.id) + } + Err(err) => Err(api_exit("saving the scan", err)), + } +} + +fn fetch_artifact( + agent: &ureq::Agent, + url: &str, + key: &str, + artifact: &Artifact<'_>, +) -> Result> { + match agent.get(url).set("X-API-Key", key).call() { + Ok(resp) => { + let mut buf = Vec::new(); + resp.into_reader() + .read_to_end(&mut buf) + .context("reading artifact body")?; + if !artifact.binary && buf.is_empty() { + bail!("server returned an empty {} artifact", artifact.flag); + } + Ok(buf) + } + Err(err) => Err(api_exit( + &format!("fetching the {} artifact", artifact.flag), + err, + )), + } +} + +/// Turn a ureq failure into an exit, with the server's own reason. +/// +/// Exits rather than returning, because the status IS the outcome and every +/// caller here would do the same thing. 403 carries the two cases a user most +/// needs told apart: the plan does not include exports, or the monthly scan +/// ceiling is reached. The server states which in `detail`, so that is printed +/// verbatim instead of being guessed at. +fn api_exit(what: &str, err: ureq::Error) -> anyhow::Error { + match err { + ureq::Error::Status(status, resp) => { + let detail = resp + .into_json::() + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + eprintln!( + "{what} failed: HTTP {status}{}", + detail.map(|d| format!(" - {d}")).unwrap_or_default() + ); + let code = match status { + 401 | 403 => EX_NOPERM, + 400 | 413 | 422 => EX_DATAERR, + _ => EX_UNAVAILABLE, + }; + std::process::exit(code); + } + ureq::Error::Transport(t) => { + eprintln!("{what} failed: {t}"); + std::process::exit(EX_UNAVAILABLE); + } + } +} + +/// `boot-2026-10-07.log` -> `boot-2026-10-07`. Stdin has no name to borrow. +fn default_device_name(log: &Path) -> String { + if log.as_os_str() == "-" { + return "bootintel-cli".to_string(); + } + log.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "bootintel-cli".to_string()) +} + +fn write_out(dest: &Path, bytes: &[u8]) -> Result<()> { + if dest.as_os_str() == "-" { + io::stdout().lock().write_all(bytes)?; + return Ok(()); + } + std::fs::write(dest, bytes)?; + Ok(()) +} + +fn read_input(path: &Path) -> Result { + if path.as_os_str() == "-" { + let mut s = String::new(); + io::stdin().read_to_string(&mut s)?; + return Ok(s); + } + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_name_defaults_to_the_log_stem() { + assert_eq!(default_device_name(Path::new("/tmp/boot-9.log")), "boot-9"); + assert_eq!(default_device_name(Path::new("-")), "bootintel-cli"); + } + + #[test] + fn artifacts_are_only_requested_when_a_path_is_given() { + let args = Args { + log: PathBuf::from("x.log"), + device_name: None, + device_id: None, + sbom: Some(PathBuf::from("s.json")), + evidence: None, + pdf: None, + json_report: None, + no_store_log: false, + api_base: None, + json: false, + }; + let got = requested_artifacts(&args); + assert_eq!(got.len(), 1); + assert_eq!(got[0].route, "sbom.json"); + assert!( + !got[0].binary, + "the SBOM is text and must not be flagged binary" + ); + } + + #[test] + fn binary_artifacts_are_marked_as_such() { + let args = Args { + log: PathBuf::from("x.log"), + device_name: None, + device_id: None, + sbom: None, + evidence: Some(PathBuf::from("e.zip")), + pdf: Some(PathBuf::from("r.pdf")), + json_report: None, + no_store_log: false, + api_base: None, + json: false, + }; + for a in requested_artifacts(&args) { + assert!(a.binary, "{} must be written as bytes", a.flag); + } + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6fcae8d..4e137e1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -147,6 +147,13 @@ enum Cmd { /// bug reports / support tickets. No PII is collected — output /// only carries what a support engineer needs to reproduce. Export(cmd::export::Args), + /// Save a scan to your BootIntel account and download its server-side + /// artifacts: CycloneDX SBOM, evidence pack, PDF and JSON report. + /// Needs a Pro plan or higher: API-key authentication is itself Pro-gated, + /// so a Researcher account can reach the artifacts from the dashboard but + /// not from here. Consumes one scan from your monthly quota; `scan` stays + /// offline and free. + Submit(cmd::submit::Args), /// Re-render an archived `scan --format json` (or `export` /// bundle) in any output format. Handy when you kept the JSON /// but not the original log. @@ -212,6 +219,7 @@ fn main() -> Result<()> { Cmd::EncodeShare(args) => cmd::encode_share::run(args), Cmd::DecodeShare(args) => cmd::decode_share::run(args), Cmd::Export(args) => cmd::export::run(args), + Cmd::Submit(args) => cmd::submit::run(args), Cmd::View(args) => cmd::view::run(args), Cmd::Demo(args) => cmd::demo::run(args), Cmd::Init(args) => cmd::init::run(args), diff --git a/crates/cli/tests/submit_cli.rs b/crates/cli/tests/submit_cli.rs new file mode 100644 index 0000000..0746b5a --- /dev/null +++ b/crates/cli/tests/submit_cli.rs @@ -0,0 +1,330 @@ +//! `bootintel submit` against a mock server. +//! +//! Drives the compiled binary, because every property worth asserting here is +//! a property of the process: the exit code, which requests it makes in what +//! order, and whether an artifact reaches disk byte-for-byte. +//! +//! The behaviours under test and why each one earns a test: +//! +//! * Device REUSE. `POST /devices/` will happily create a second device with +//! the same name, so a nightly CI job without the lookup would add a +//! device a day and nothing server-side would complain. The test asserts +//! no device is created when one already matches. +//! * Byte fidelity. The evidence pack and PDF are binary. Routing them +//! through a String would corrupt them silently, and a corrupt PDF still +//! looks like a file on disk, so the test ships bytes that are not valid +//! UTF-8 and compares them exactly. +//! * Exit codes. A CI step's only signal. 403 has to be 77 (EX_NOPERM) and +//! not a generic failure, because the plan/quota case is the one a user +//! has to act on. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +const BIN: &str = env!("CARGO_BIN_EXE_bootintel"); + +const SCAN_ID: &str = "11111111-2222-3333-4444-555555555555"; +const DEVICE_ID: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + +fn tmpdir() -> PathBuf { + let base = std::env::var("CARGO_TARGET_TMPDIR").unwrap_or_else(|_| "/tmp".into()); + let dir = PathBuf::from(base).join("submit_cli"); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Named rather than inlined: clippy flags the bare +/// `Arc Vec + Send + Sync>` as a very complex type, +/// and the in-crate mock in src/api/client.rs already uses an alias for the +/// same shape. +type Responder = Arc Vec + Send + Sync>; + +struct Mock { + base: String, + seen: Arc>>, +} + +/// Serve `max_conns` requests, recording "METHOD PATH" for each. +fn spawn_mock(responder: F, max_conns: usize) -> Mock +where + F: Fn(&str, &str) -> Vec + Send + Sync + 'static, +{ + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock"); + let addr = listener.local_addr().unwrap(); + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen_w = seen.clone(); + let responder = Arc::new(responder); + let served = Arc::new(AtomicUsize::new(0)); + thread::spawn(move || { + for stream in listener.incoming().take(max_conns).flatten() { + let r = responder.clone(); + let s = seen_w.clone(); + served.fetch_add(1, Ordering::SeqCst); + let _ = handle(stream, r, s); + } + }); + thread::sleep(Duration::from_millis(80)); + Mock { + base: format!("http://{addr}"), + seen, + } +} + +fn handle(stream: TcpStream, responder: Responder, seen: Arc>>) -> Option<()> { + let mut reader = BufReader::new(stream.try_clone().ok()?); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let path = parts.next()?.to_string(); + let mut content_length = 0usize; + loop { + let mut h = String::new(); + reader.read_line(&mut h).ok()?; + let t = h.trim_end(); + if t.is_empty() { + break; + } + if let Some((k, v)) = t.split_once(':') { + if k.trim().eq_ignore_ascii_case("content-length") { + content_length = v.trim().parse().unwrap_or(0); + } + } + } + if content_length > 0 { + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).ok()?; + } + seen.lock().unwrap().push(format!("{method} {path}")); + let resp = responder(&method, &path); + let mut stream = stream; + stream.write_all(&resp).ok()?; + stream.flush().ok() +} + +fn http(status: u16, content_type: &str, body: &[u8]) -> Vec { + let mut out = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + out.extend_from_slice(body); + out +} + +fn log_file() -> PathBuf { + let p = tmpdir().join("boot.log"); + std::fs::write(&p, "U-Boot 2016.01\nLinux version 4.4.60\n").unwrap(); + p +} + +fn run(base: &str, extra: &[&str]) -> std::process::Output { + let log = log_file(); + let mut cmd = Command::new(BIN); + cmd.arg("submit") + .arg(&log) + .arg("--api-base") + .arg(base) + .env("BOOTINTEL_API_KEY", "bik_test_key") + // The mock serves FastAPI-direct paths, with no /api prefix. + .env("BOOTINTEL_API_PATH_PREFIX", "/"); + for a in extra { + cmd.arg(a); + } + cmd.output().expect("running bootintel submit") +} + +#[test] +fn an_existing_device_with_the_same_name_is_reused() { + let mock = spawn_mock( + |method, path| match (method, path) { + ("GET", "/devices/") => http( + 200, + "application/json", + format!(r#"[{{"id":"{DEVICE_ID}","name":"lab-router"}}]"#).as_bytes(), + ), + ("POST", "/scans/") => http( + 201, + "application/json", + format!(r#"{{"id":"{SCAN_ID}"}}"#).as_bytes(), + ), + _ => http(404, "application/json", br#"{"detail":"unexpected"}"#), + }, + 4, + ); + let out = run(&mock.base, &["--device-name", "lab-router"]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let seen = mock.seen.lock().unwrap().clone(); + assert!( + !seen.iter().any(|r| r == "POST /devices/"), + "a device was created despite one matching by name: {seen:?}" + ); + assert!(seen.iter().any(|r| r == "POST /scans/"), "{seen:?}"); +} + +#[test] +fn a_device_is_created_when_none_matches() { + let mock = spawn_mock( + |method, path| match (method, path) { + ("GET", "/devices/") => http(200, "application/json", b"[]"), + ("POST", "/devices/") => http( + 201, + "application/json", + format!(r#"{{"id":"{DEVICE_ID}","name":"new-board"}}"#).as_bytes(), + ), + ("POST", "/scans/") => http( + 201, + "application/json", + format!(r#"{{"id":"{SCAN_ID}"}}"#).as_bytes(), + ), + _ => http(404, "application/json", br#"{"detail":"unexpected"}"#), + }, + 5, + ); + let out = run(&mock.base, &["--device-name", "new-board"]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let seen = mock.seen.lock().unwrap().clone(); + assert!(seen.iter().any(|r| r == "POST /devices/"), "{seen:?}"); +} + +#[test] +fn a_binary_artifact_reaches_disk_byte_for_byte() { + // Deliberately not valid UTF-8: a PDF or zip is not, and a String + // round-trip would mangle it into replacement characters. + const RAW: &[u8] = &[ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0xff, 0xfe, 0x00, 0x80, + ]; + let dest = tmpdir().join("out.pdf"); + let _ = std::fs::remove_file(&dest); + let mock = spawn_mock( + move |method, path| match (method, path) { + ("GET", "/devices/") => http(200, "application/json", b"[]"), + ("POST", "/devices/") => http( + 201, + "application/json", + format!(r#"{{"id":"{DEVICE_ID}","name":"b"}}"#).as_bytes(), + ), + ("POST", "/scans/") => http( + 201, + "application/json", + format!(r#"{{"id":"{SCAN_ID}"}}"#).as_bytes(), + ), + ("GET", p) if p.ends_with("/report.pdf") => http(200, "application/pdf", RAW), + _ => http(404, "application/json", br#"{"detail":"unexpected"}"#), + }, + 6, + ); + let out = run(&mock.base, &["--pdf", dest.to_str().unwrap()]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let got = std::fs::read(&dest).expect("artifact written"); + assert_eq!(got, RAW, "the PDF was altered on the way to disk"); +} + +#[test] +fn a_403_exits_77_and_surfaces_the_servers_reason() { + let mock = spawn_mock( + |_method, _path| { + http( + 403, + "application/json", + br#"{"detail":"API access requires Pro plan or higher."}"#, + ) + }, + 3, + ); + let out = run( + &mock.base, + &["--sbom", tmpdir().join("x.json").to_str().unwrap()], + ); + assert_eq!(out.status.code(), Some(77), "403 must map to EX_NOPERM"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("Pro plan or higher"), + "the server's own reason must be shown, got: {err}" + ); +} + +#[test] +fn the_quota_notice_is_not_printed_when_the_run_fails_before_the_spend() { + // It used to print at the top of the run, so a 403 from the device lookup + // was preceded by a claim about a charge that never happened. + let mock = spawn_mock( + |_method, _path| http(403, "application/json", br#"{"detail":"nope"}"#), + 3, + ); + let out = run( + &mock.base, + &["--sbom", tmpdir().join("y.json").to_str().unwrap()], + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + !err.contains("monthly quota"), + "claimed a quota spend that did not happen: {err}" + ); +} + +#[test] +fn json_mode_emits_parseable_stdout() { + let dest = tmpdir().join("sbom.json"); + let mock = spawn_mock( + |method, path| match (method, path) { + ("GET", "/devices/") => http(200, "application/json", b"[]"), + ("POST", "/devices/") => http( + 201, + "application/json", + format!(r#"{{"id":"{DEVICE_ID}","name":"j"}}"#).as_bytes(), + ), + ("POST", "/scans/") => http( + 201, + "application/json", + format!(r#"{{"id":"{SCAN_ID}"}}"#).as_bytes(), + ), + ("GET", p) if p.ends_with("/sbom.json") => http( + 200, + "application/vnd.cyclonedx+json", + br#"{"bomFormat":"CycloneDX"}"#, + ), + _ => http(404, "application/json", br#"{"detail":"unexpected"}"#), + }, + 6, + ); + let out = run(&mock.base, &["--sbom", dest.to_str().unwrap(), "--json"]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert_eq!(parsed["scan_id"], SCAN_ID); + assert_eq!(parsed["artifacts"][0]["kind"], "sbom"); +} + +#[test] +fn plaintext_non_loopback_is_refused_before_any_request() { + let out = run( + "http://example.invalid", + &["--sbom", tmpdir().join("z.json").to_str().unwrap()], + ); + assert_eq!(out.status.code(), Some(77)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("plaintext"), "got: {err}"); +}