Skip to main content

sbom_tools/cli/
verify.rs

1//! CLI handler for the `verify` command.
2//!
3//! Provides file hash verification and component hash auditing.
4
5use std::path::PathBuf;
6
7use anyhow::Result;
8
9use crate::parsers::parse_sbom;
10use crate::pipeline::exit_codes;
11use crate::verification::{
12    ModelVerifyResult, audit_component_hashes, verify_file_hash, verify_model_dir,
13};
14
15/// Verify action to perform
16#[derive(Debug, Clone, clap::Subcommand)]
17pub enum VerifyAction {
18    /// Verify file integrity against a hash value
19    Hash {
20        /// SBOM file to verify
21        file: PathBuf,
22        /// Expected hash (sha256:<hex>, sha512:<hex>, or bare hex)
23        #[arg(long)]
24        expected: Option<String>,
25        /// Read expected hash from a file (e.g., sbom.json.sha256)
26        #[arg(long, conflicts_with = "expected")]
27        hash_file: Option<PathBuf>,
28    },
29    /// Audit component hashes within an SBOM
30    AuditHashes {
31        /// SBOM file to audit
32        file: PathBuf,
33        /// Output format (table or json)
34        #[arg(
35            short = 'f',
36            long = "output",
37            alias = "format",
38            default_value = "table"
39        )]
40        format: String,
41    },
42    /// Verify ML-model weight files against the hashes recorded in an SBOM
43    ModelWeights {
44        /// SBOM file describing the model(s)
45        file: PathBuf,
46        /// Directory holding the weight files (supports the HuggingFace cache
47        /// snapshot layout where blobs are named by their SHA-256)
48        #[arg(long = "model-dir")]
49        model_dir: PathBuf,
50        /// Output format (table or json)
51        #[arg(
52            short = 'f',
53            long = "output",
54            alias = "format",
55            default_value = "table"
56        )]
57        format: String,
58    },
59}
60
61/// Run the verify command.
62pub fn run_verify(action: VerifyAction, quiet: bool) -> Result<i32> {
63    match action {
64        VerifyAction::Hash {
65            file,
66            expected,
67            hash_file,
68        } => {
69            let expected_hash = if let Some(e) = expected {
70                e
71            } else if let Some(hf) = hash_file {
72                crate::verification::read_hash_file(&hf)?
73            } else {
74                // Try to find a sidecar hash file
75                let sha_path = file.with_extension(
76                    file.extension()
77                        .map(|e| format!("{}.sha256", e.to_string_lossy()))
78                        .unwrap_or_else(|| "sha256".to_string()),
79                );
80                if sha_path.exists() {
81                    if !quiet {
82                        eprintln!("Using sidecar hash file: {}", sha_path.display());
83                    }
84                    crate::verification::read_hash_file(&sha_path)?
85                } else {
86                    anyhow::bail!(
87                        "no hash provided. Use --expected <hash> or --hash-file <path>, \
88                         or place a .sha256 sidecar file alongside the SBOM"
89                    );
90                }
91            };
92
93            let result = verify_file_hash(&file, &expected_hash)?;
94
95            if !quiet {
96                println!("{result}");
97            }
98
99            if result.verified {
100                Ok(exit_codes::SUCCESS)
101            } else {
102                // A failed verification is a VERDICT, not an operational
103                // error — exit 1 so scripts can distinguish "hash mismatch"
104                // from "could not verify" (exit 3).
105                Ok(exit_codes::CHANGES_DETECTED)
106            }
107        }
108        VerifyAction::AuditHashes { file, format } => {
109            let sbom = parse_sbom(&file)?;
110            let report = audit_component_hashes(&sbom);
111
112            if format == "json" {
113                println!("{}", serde_json::to_string_pretty(&report)?);
114            } else {
115                println!("Component Hash Audit");
116                println!("====================");
117                println!(
118                    "Total: {}  Strong: {}  Weak-only: {}  Missing: {}",
119                    report.total_components,
120                    report.strong_count,
121                    report.weak_only_count,
122                    report.missing_count
123                );
124                println!("Pass rate: {:.1}%\n", report.pass_rate());
125
126                if report.weak_only_count > 0 || report.missing_count > 0 {
127                    println!("Issues:");
128                    for comp in &report.components {
129                        match comp.result {
130                            crate::verification::HashAuditResult::WeakOnly => {
131                                println!(
132                                    "  WEAK   {} {} ({})",
133                                    comp.name,
134                                    comp.version.as_deref().unwrap_or(""),
135                                    comp.algorithms.join(", ")
136                                );
137                            }
138                            crate::verification::HashAuditResult::Missing => {
139                                println!(
140                                    "  MISSING {} {}",
141                                    comp.name,
142                                    comp.version.as_deref().unwrap_or("")
143                                );
144                            }
145                            crate::verification::HashAuditResult::Strong => {}
146                        }
147                    }
148                }
149            }
150
151            if report.missing_count > 0 || report.weak_only_count > 0 {
152                Ok(exit_codes::CHANGES_DETECTED) // non-zero for CI gating
153            } else {
154                Ok(exit_codes::SUCCESS)
155            }
156        }
157        VerifyAction::ModelWeights {
158            file,
159            model_dir,
160            format,
161        } => {
162            let sbom = parse_sbom(&file)?;
163            let report = verify_model_dir(&sbom, &model_dir);
164
165            // A verification pass over zero models is vacuous — exiting 0
166            // would let CI believe weights were verified when nothing was.
167            if report.total_models == 0 {
168                anyhow::bail!("SBOM contains no model components; nothing to verify");
169            }
170
171            if format == "json" {
172                println!("{}", serde_json::to_string_pretty(&report)?);
173            } else {
174                println!("Model Weight Verification");
175                println!("=========================");
176                println!("Model dir: {}", report.model_dir);
177                println!(
178                    "Models: {}  Verified: {}  Mismatch: {}  Missing: {}  No-hash: {}",
179                    report.total_models,
180                    report.verified_count,
181                    report.mismatch_count,
182                    report.missing_count,
183                    report.no_hash_count,
184                );
185
186                for comp in &report.components {
187                    // A verified component is reported succinctly; everything
188                    // else (the actionable cases) gets its located file/hash.
189                    match comp.result {
190                        ModelVerifyResult::Verified => {
191                            println!(
192                                "  {} {} {} -> {}",
193                                comp.result.label(),
194                                comp.name,
195                                comp.version.as_deref().unwrap_or(""),
196                                comp.file.as_deref().unwrap_or("?"),
197                            );
198                        }
199                        _ => {
200                            println!(
201                                "  {} {} {}{}",
202                                comp.result.label(),
203                                comp.name,
204                                comp.version.as_deref().unwrap_or(""),
205                                comp.hash
206                                    .as_deref()
207                                    .map(|h| format!(" ({h})"))
208                                    .unwrap_or_default(),
209                            );
210                        }
211                    }
212                }
213            }
214
215            if report.has_failures() {
216                // Verdict, not operational error: exit 1 (see verify hash).
217                Ok(exit_codes::CHANGES_DETECTED)
218            } else {
219                Ok(exit_codes::SUCCESS)
220            }
221        }
222    }
223}