1use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21use crate::model::{Component, ComponentType, HashAlgorithm, NormalizedSbom};
22use crate::verification::verify_file_hash;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ModelVerifyResult {
27 Verified,
29 Mismatch,
31 Missing,
33 NoHash,
35}
36
37impl ModelVerifyResult {
38 #[must_use]
40 pub const fn label(&self) -> &'static str {
41 match self {
42 Self::Verified => "VERIFIED",
43 Self::Mismatch => "MISMATCH",
44 Self::Missing => "MISSING",
45 Self::NoHash => "NO-HASH",
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ComponentModelVerification {
53 pub name: String,
55 pub version: Option<String>,
57 pub result: ModelVerifyResult,
59 pub hash: Option<String>,
61 pub file: Option<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ModelVerifyReport {
69 pub model_dir: String,
71 pub total_models: usize,
73 pub verified_count: usize,
75 pub mismatch_count: usize,
77 pub missing_count: usize,
79 pub no_hash_count: usize,
81 pub components: Vec<ComponentModelVerification>,
83}
84
85impl ModelVerifyReport {
86 #[must_use]
88 pub const fn has_failures(&self) -> bool {
89 self.mismatch_count > 0 || self.missing_count > 0
90 }
91}
92
93const fn is_verifiable(alg: &HashAlgorithm) -> bool {
99 matches!(alg, HashAlgorithm::Sha256 | HashAlgorithm::Sha512)
100}
101
102#[must_use]
105pub fn verify_model_dir(sbom: &NormalizedSbom, model_dir: &Path) -> ModelVerifyReport {
106 let root = std::fs::canonicalize(model_dir).unwrap_or_else(|_| model_dir.to_path_buf());
111
112 let index = FileIndex::build(&root);
116
117 let mut report = ModelVerifyReport {
118 model_dir: model_dir.display().to_string(),
119 total_models: 0,
120 verified_count: 0,
121 mismatch_count: 0,
122 missing_count: 0,
123 no_hash_count: 0,
124 components: Vec::new(),
125 };
126
127 for component in sbom.components.values() {
128 if !is_model_like(component) {
129 continue;
130 }
131 report.total_models += 1;
132
133 let record = verify_component(component, &root, &index);
134 match record.result {
135 ModelVerifyResult::Verified => report.verified_count += 1,
136 ModelVerifyResult::Mismatch => report.mismatch_count += 1,
137 ModelVerifyResult::Missing => report.missing_count += 1,
138 ModelVerifyResult::NoHash => report.no_hash_count += 1,
139 }
140 report.components.push(record);
141 }
142
143 report
144}
145
146fn is_model_like(component: &Component) -> bool {
148 matches!(
149 component.component_type,
150 ComponentType::MachineLearningModel | ComponentType::Data
151 )
152}
153
154fn verify_component(
156 component: &Component,
157 model_dir: &Path,
158 index: &FileIndex,
159) -> ComponentModelVerification {
160 let make = |result, hash: Option<String>, file: Option<String>| ComponentModelVerification {
161 name: component.name.clone(),
162 version: component.version.clone(),
163 result,
164 hash,
165 file,
166 };
167
168 let verifiable: Vec<_> = component
174 .hashes
175 .iter()
176 .filter(|h| {
177 is_verifiable(&h.algorithm) && h.provenance == crate::model::HashProvenance::Authored
178 })
179 .collect();
180
181 if verifiable.is_empty() {
182 return make(ModelVerifyResult::NoHash, None, None);
183 }
184
185 let name_candidates = filename_candidates(component);
190
191 let mut last_missing_hash: Option<String> = None;
192
193 for hash in verifiable {
194 let hash_hex = hash.value.to_lowercase();
195 last_missing_hash = Some(hash_hex.clone());
196
197 if let Some(path) = index.by_basename(&hash_hex) {
200 return verify_against(component, &hash_hex, path, model_dir);
201 }
202
203 for candidate in &name_candidates {
205 if let Some(path) = index.by_basename(candidate) {
206 return verify_against(component, &hash_hex, path, model_dir);
207 }
208 }
209 }
210
211 make(ModelVerifyResult::Missing, last_missing_hash, None)
212}
213
214fn verify_against(
216 component: &Component,
217 hash_hex: &str,
218 path: &Path,
219 model_dir: &Path,
220) -> ComponentModelVerification {
221 let rel = path
222 .strip_prefix(model_dir)
223 .unwrap_or(path)
224 .display()
225 .to_string();
226 let make = |result| ComponentModelVerification {
227 name: component.name.clone(),
228 version: component.version.clone(),
229 result,
230 hash: Some(hash_hex.to_string()),
231 file: Some(rel.clone()),
232 };
233
234 match verify_file_hash(path, hash_hex) {
235 Ok(r) if r.verified => make(ModelVerifyResult::Verified),
236 Ok(_) => make(ModelVerifyResult::Mismatch),
237 Err(_) => make(ModelVerifyResult::Mismatch),
241 }
242}
243
244fn filename_candidates(component: &Component) -> Vec<String> {
251 let exts = [
252 "safetensors",
253 "bin",
254 "pt",
255 "pth",
256 "onnx",
257 "gguf",
258 "ggml",
259 "h5",
260 "pb",
261 "tflite",
262 ];
263 let stems = ["model", "pytorch_model", component.name.as_str()];
264
265 let mut out = Vec::new();
266 for stem in stems {
267 if stem.is_empty() {
268 continue;
269 }
270 for ext in exts {
271 out.push(format!("{stem}.{ext}"));
272 }
273 }
274 out
275}
276
277struct FileIndex {
291 by_name: HashMap<String, PathBuf>,
292}
293
294impl FileIndex {
295 fn build(root: &Path) -> Self {
299 let mut by_name = HashMap::new();
300 let mut stack = vec![root.to_path_buf()];
301 let mut visited: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
304
305 while let Some(dir) = stack.pop() {
306 if !visited.insert(dir.clone()) {
307 continue;
308 }
309 let Ok(entries) = std::fs::read_dir(&dir) else {
310 continue;
311 };
312 for entry in entries.flatten() {
313 let path = entry.path();
314 let Ok(resolved) = std::fs::canonicalize(&path) else {
317 continue;
318 };
319 if !resolved.starts_with(root) {
324 continue;
325 }
326 let meta = match std::fs::metadata(&resolved) {
327 Ok(m) => m,
328 Err(_) => continue,
329 };
330 if meta.is_dir() {
331 stack.push(resolved);
332 } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
333 by_name
337 .entry(name.to_lowercase())
338 .or_insert_with(|| resolved.clone());
339 }
340 }
341 }
342
343 Self { by_name }
344 }
345
346 fn by_basename(&self, name: &str) -> Option<&Path> {
348 self.by_name.get(&name.to_lowercase()).map(PathBuf::as_path)
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::model::{DocumentMetadata, Hash};
356 use sha2::{Digest, Sha256};
357 use std::fs;
358
359 fn sha256_hex(bytes: &[u8]) -> String {
360 let mut h = Sha256::new();
361 h.update(bytes);
362 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
363 }
364
365 fn model_component(name: &str, hash_hex: &str) -> Component {
366 let mut c = Component::new(name.to_string(), format!("{name}-ref"))
367 .with_version("1.0.0".to_string());
368 c.component_type = ComponentType::MachineLearningModel;
369 c.hashes
370 .push(Hash::new(HashAlgorithm::Sha256, hash_hex.to_string()));
371 c
372 }
373
374 #[test]
375 fn verifies_against_hf_blob_named_by_sha256() {
376 let dir = tempfile::tempdir().unwrap();
377 let weights = b"fake model weights";
378 let hex = sha256_hex(weights);
379
380 let blobs = dir.path().join("blobs");
382 fs::create_dir_all(&blobs).unwrap();
383 fs::write(blobs.join(&hex), weights).unwrap();
384
385 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
386 sbom.add_component(model_component("bert", &hex));
387
388 let report = verify_model_dir(&sbom, dir.path());
389 assert_eq!(report.total_models, 1);
390 assert_eq!(report.verified_count, 1);
391 assert_eq!(report.components[0].result, ModelVerifyResult::Verified);
392 assert!(!report.has_failures());
393 }
394
395 #[test]
399 fn enriched_hash_is_not_a_verify_baseline() {
400 let dir = tempfile::tempdir().unwrap();
401 let weights = b"fake model weights";
402 let hex = sha256_hex(weights);
403 let blobs = dir.path().join("blobs");
404 fs::create_dir_all(&blobs).unwrap();
405 fs::write(blobs.join(&hex), weights).unwrap();
406
407 let mut c = Component::new("bert".to_string(), "bert-ref".to_string())
408 .with_version("1.0.0".to_string());
409 c.component_type = ComponentType::MachineLearningModel;
410 c.hashes
412 .push(Hash::enriched(HashAlgorithm::Sha256, hex.clone()));
413
414 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
415 sbom.add_component(c);
416
417 let report = verify_model_dir(&sbom, dir.path());
418 assert_eq!(
419 report.components[0].result,
420 ModelVerifyResult::NoHash,
421 "an enriched hash must not be trusted as a verify baseline"
422 );
423
424 let mut sbom2 = NormalizedSbom::new(DocumentMetadata::default());
427 sbom2.add_component(model_component("bert", &hex));
428 assert_eq!(
429 verify_model_dir(&sbom2, dir.path()).components[0].result,
430 ModelVerifyResult::Verified
431 );
432 }
433
434 #[test]
435 fn verifies_against_direct_filename() {
436 let dir = tempfile::tempdir().unwrap();
437 let weights = b"safetensors bytes";
438 let hex = sha256_hex(weights);
439 fs::write(dir.path().join("model.safetensors"), weights).unwrap();
440
441 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
442 sbom.add_component(model_component("bert", &hex));
443
444 let report = verify_model_dir(&sbom, dir.path());
445 assert_eq!(report.verified_count, 1);
446 assert_eq!(
447 report.components[0].file.as_deref(),
448 Some("model.safetensors")
449 );
450 }
451
452 #[test]
453 fn detects_tampering_as_mismatch() {
454 let dir = tempfile::tempdir().unwrap();
455 fs::write(dir.path().join("model.safetensors"), b"tampered bytes").unwrap();
457 let claimed = sha256_hex(b"original bytes");
458
459 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
460 sbom.add_component(model_component("bert", &claimed));
461
462 let report = verify_model_dir(&sbom, dir.path());
463 assert_eq!(report.mismatch_count, 1);
464 assert_eq!(report.components[0].result, ModelVerifyResult::Mismatch);
465 assert!(report.has_failures());
466 }
467
468 #[test]
469 fn reports_missing_when_no_file_found() {
470 let dir = tempfile::tempdir().unwrap();
471 let hex = sha256_hex(b"weights that are not on disk");
472
473 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
474 sbom.add_component(model_component("bert", &hex));
475
476 let report = verify_model_dir(&sbom, dir.path());
477 assert_eq!(report.missing_count, 1);
478 assert_eq!(report.components[0].result, ModelVerifyResult::Missing);
479 }
480
481 #[test]
482 fn reports_no_hash_when_only_weak_hash_present() {
483 let dir = tempfile::tempdir().unwrap();
484 let mut c = Component::new("bert".to_string(), "bert-ref".to_string());
485 c.component_type = ComponentType::MachineLearningModel;
486 c.hashes
487 .push(Hash::new(HashAlgorithm::Md5, "deadbeef".to_string()));
488
489 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
490 sbom.add_component(c);
491
492 let report = verify_model_dir(&sbom, dir.path());
493 assert_eq!(report.no_hash_count, 1);
494 assert_eq!(report.components[0].result, ModelVerifyResult::NoHash);
495 }
496
497 #[cfg(unix)]
498 #[test]
499 fn does_not_follow_symlink_escaping_model_dir() {
500 use std::os::unix::fs::symlink;
501
502 let outside = tempfile::tempdir().unwrap();
504 let weights = b"weights that live outside the model dir";
505 let hex = sha256_hex(weights);
506 let secret = outside.path().join("model.safetensors");
507 fs::write(&secret, weights).unwrap();
508
509 let model_dir = tempfile::tempdir().unwrap();
513 symlink(&secret, model_dir.path().join("model.safetensors")).unwrap();
514
515 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
516 sbom.add_component(model_component("escape", &hex));
517
518 let report = verify_model_dir(&sbom, model_dir.path());
519 assert_eq!(report.total_models, 1);
520 assert_eq!(
521 report.verified_count, 0,
522 "a symlink escaping the model dir must not be followed/verified"
523 );
524 assert_eq!(
525 report.components[0].result,
526 ModelVerifyResult::Missing,
527 "out-of-tree symlink target is treated as no in-tree file found"
528 );
529 }
530
531 #[cfg(unix)]
532 #[test]
533 fn follows_intra_tree_symlink_like_hf_cache() {
534 use std::os::unix::fs::symlink;
535
536 let dir = tempfile::tempdir().unwrap();
540 let weights = b"in-tree hf blob bytes";
541 let hex = sha256_hex(weights);
542
543 let blobs = dir.path().join("blobs");
544 let snapshots = dir.path().join("snapshots").join("main");
545 fs::create_dir_all(&blobs).unwrap();
546 fs::create_dir_all(&snapshots).unwrap();
547 let blob = blobs.join(&hex);
548 fs::write(&blob, weights).unwrap();
549 symlink(&blob, snapshots.join("model.safetensors")).unwrap();
550
551 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
552 sbom.add_component(model_component("bert", &hex));
553
554 let report = verify_model_dir(&sbom, dir.path());
555 assert_eq!(
556 report.verified_count, 1,
557 "intra-tree HF snapshot→blob symlink must still verify"
558 );
559 }
560
561 #[test]
562 fn ignores_non_model_components() {
563 let dir = tempfile::tempdir().unwrap();
564 let mut c = Component::new("lib".to_string(), "lib-ref".to_string());
565 c.component_type = ComponentType::Library;
566 c.hashes
567 .push(Hash::new(HashAlgorithm::Sha256, "a".repeat(64)));
568
569 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
570 sbom.add_component(c);
571
572 let report = verify_model_dir(&sbom, dir.path());
573 assert_eq!(report.total_models, 0, "library components are not models");
574 }
575}