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                Ok(exit_codes::ERROR)
103            }
104        }
105        VerifyAction::AuditHashes { file, format } => {
106            let sbom = parse_sbom(&file)?;
107            let report = audit_component_hashes(&sbom);
108
109            if format == "json" {
110                println!("{}", serde_json::to_string_pretty(&report)?);
111            } else {
112                println!("Component Hash Audit");
113                println!("====================");
114                println!(
115                    "Total: {}  Strong: {}  Weak-only: {}  Missing: {}",
116                    report.total_components,
117                    report.strong_count,
118                    report.weak_only_count,
119                    report.missing_count
120                );
121                println!("Pass rate: {:.1}%\n", report.pass_rate());
122
123                if report.weak_only_count > 0 || report.missing_count > 0 {
124                    println!("Issues:");
125                    for comp in &report.components {
126                        match comp.result {
127                            crate::verification::HashAuditResult::WeakOnly => {
128                                println!(
129                                    "  WEAK   {} {} ({})",
130                                    comp.name,
131                                    comp.version.as_deref().unwrap_or(""),
132                                    comp.algorithms.join(", ")
133                                );
134                            }
135                            crate::verification::HashAuditResult::Missing => {
136                                println!(
137                                    "  MISSING {} {}",
138                                    comp.name,
139                                    comp.version.as_deref().unwrap_or("")
140                                );
141                            }
142                            crate::verification::HashAuditResult::Strong => {}
143                        }
144                    }
145                }
146            }
147
148            if report.missing_count > 0 || report.weak_only_count > 0 {
149                Ok(exit_codes::CHANGES_DETECTED) // non-zero for CI gating
150            } else {
151                Ok(exit_codes::SUCCESS)
152            }
153        }
154        VerifyAction::ModelWeights {
155            file,
156            model_dir,
157            format,
158        } => {
159            let sbom = parse_sbom(&file)?;
160            let report = verify_model_dir(&sbom, &model_dir);
161
162            if format == "json" {
163                println!("{}", serde_json::to_string_pretty(&report)?);
164            } else {
165                println!("Model Weight Verification");
166                println!("=========================");
167                println!("Model dir: {}", report.model_dir);
168                println!(
169                    "Models: {}  Verified: {}  Mismatch: {}  Missing: {}  No-hash: {}",
170                    report.total_models,
171                    report.verified_count,
172                    report.mismatch_count,
173                    report.missing_count,
174                    report.no_hash_count,
175                );
176
177                for comp in &report.components {
178                    // A verified component is reported succinctly; everything
179                    // else (the actionable cases) gets its located file/hash.
180                    match comp.result {
181                        ModelVerifyResult::Verified => {
182                            println!(
183                                "  {} {} {} -> {}",
184                                comp.result.label(),
185                                comp.name,
186                                comp.version.as_deref().unwrap_or(""),
187                                comp.file.as_deref().unwrap_or("?"),
188                            );
189                        }
190                        _ => {
191                            println!(
192                                "  {} {} {}{}",
193                                comp.result.label(),
194                                comp.name,
195                                comp.version.as_deref().unwrap_or(""),
196                                comp.hash
197                                    .as_deref()
198                                    .map(|h| format!(" ({h})"))
199                                    .unwrap_or_default(),
200                            );
201                        }
202                    }
203                }
204            }
205
206            if report.has_failures() {
207                Ok(exit_codes::ERROR) // mismatch/missing weights → fail for CI gating
208            } else {
209                Ok(exit_codes::SUCCESS)
210            }
211        }
212    }
213}