Skip to main content

sbom_tools/verification/
model_dir.rs

1//! Model-weight integrity verification.
2//!
3//! Verifies the on-disk weight files of `MachineLearningModel` / `Data`
4//! components against the hashes recorded in an SBOM (typically injected by the
5//! HuggingFace enricher). For each such component this:
6//!
7//! 1. locates candidate weight files under a model directory, looking both for
8//!    direct filenames AND the HuggingFace cache snapshot layout where blob
9//!    files are named by their SHA-256 content hash, then
10//! 2. verifies the located file against the component's hash via the shared
11//!    [`verify_file_hash`](crate::verification::verify_file_hash).
12//!
13//! The result is a per-component pass / fail / missing report suitable for CI
14//! gating.
15
16use 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/// Outcome of verifying a single model component's weights.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ModelVerifyResult {
27    /// A weight file was located and its hash matched the SBOM.
28    Verified,
29    /// A weight file was located but its hash did NOT match (possible tampering).
30    Mismatch,
31    /// The component declares hashes but no matching weight file was found.
32    Missing,
33    /// The component declares no usable (SHA-256/384/512) hash to verify against.
34    NoHash,
35}
36
37impl ModelVerifyResult {
38    /// Short status label.
39    #[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/// Per-component model-weight verification record.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ComponentModelVerification {
53    /// Component name.
54    pub name: String,
55    /// Component version.
56    pub version: Option<String>,
57    /// Verification outcome.
58    pub result: ModelVerifyResult,
59    /// Hash value (hex) that was checked, when applicable.
60    pub hash: Option<String>,
61    /// Path of the weight file that was located, when applicable (relative to
62    /// the model directory for readability).
63    pub file: Option<String>,
64}
65
66/// Aggregate model-weight verification report.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ModelVerifyReport {
69    /// Model directory that was searched.
70    pub model_dir: String,
71    /// ML-model / dataset components inspected.
72    pub total_models: usize,
73    /// Components verified successfully.
74    pub verified_count: usize,
75    /// Components whose located weight file mismatched.
76    pub mismatch_count: usize,
77    /// Components with hashes but no located weight file.
78    pub missing_count: usize,
79    /// Components without a usable hash to verify.
80    pub no_hash_count: usize,
81    /// Per-component records.
82    pub components: Vec<ComponentModelVerification>,
83}
84
85impl ModelVerifyReport {
86    /// Whether the run had any failing component (mismatch or missing).
87    #[must_use]
88    pub const fn has_failures(&self) -> bool {
89        self.mismatch_count > 0 || self.missing_count > 0
90    }
91}
92
93/// Whether a hash algorithm is one we can verify a located file against.
94///
95/// We compute SHA-256 / SHA-512 over candidate files; SHA-384 shares SHA-512's
96/// preimage but a distinct digest, so only the two directly-computable forms are
97/// treated as verifiable here (matching `verify_file_hash`).
98const fn is_verifiable(alg: &HashAlgorithm) -> bool {
99    matches!(alg, HashAlgorithm::Sha256 | HashAlgorithm::Sha512)
100}
101
102/// Verify the weight files of all model/dataset components in `sbom` against the
103/// files found under `model_dir`.
104#[must_use]
105pub fn verify_model_dir(sbom: &NormalizedSbom, model_dir: &Path) -> ModelVerifyReport {
106    // Canonicalize the model-dir root once so symlink-escape detection (below)
107    // compares against a fully-resolved root. If the root itself can't be
108    // canonicalized (e.g. it does not exist), fall back to the path as given;
109    // the walk will simply find nothing.
110    let root = std::fs::canonicalize(model_dir).unwrap_or_else(|_| model_dir.to_path_buf());
111
112    // Index files by basename (for direct-filename matches) once, so a large
113    // model directory is walked a single time. Paths that resolve outside the
114    // root (via symlinks) are excluded by the index.
115    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
146/// Components whose weights we attempt to verify: trained models and datasets.
147fn is_model_like(component: &Component) -> bool {
148    matches!(
149        component.component_type,
150        ComponentType::MachineLearningModel | ComponentType::Data
151    )
152}
153
154/// Verify a single component, returning its record.
155fn 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    // Only consider hashes we can recompute over a file AND that are
169    // author-attested. An enrichment-sourced hash (fetched by this tool from
170    // HuggingFace / served from cache) must NOT be the baseline we verify
171    // local files against — that is circular trust (a poisoned cache or a
172    // config-overridden URL would set the very hash it is checked against).
173    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    // Candidate filenames to look for, in addition to sha256-named blobs:
186    // any external-reference / model-card filename heuristics would be noisy, so
187    // we rely on (a) the hash-named blob (HF cache layout) and (b) the
188    // component name as a filename stem.
189    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        // 1. HuggingFace cache layout: a blob file is literally named by its
198        //    sha256. A direct hit means the bytes are present under that name.
199        if let Some(path) = index.by_basename(&hash_hex) {
200            return verify_against(component, &hash_hex, path, model_dir);
201        }
202
203        // 2. Direct filenames (e.g. `model.safetensors`, `<name>.safetensors`).
204        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
214/// Run `verify_file_hash` for a located file and build the record.
215fn 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        // An I/O error on a located file is treated as a mismatch: the file is
238        // present (it was indexed) but unreadable, which is a verification
239        // failure, not a clean "missing".
240        Err(_) => make(ModelVerifyResult::Mismatch),
241    }
242}
243
244/// Candidate weight filenames for a component, by name.
245///
246/// Real weight files are not named after the component in the HF layout (they
247/// are sha256-named blobs, handled separately), but locally-laid-out model
248/// directories often use `model.*` or `<name>.*`. These are basename matches,
249/// so the directory walk handles any nesting.
250fn 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
277/// A flat index of every file under a directory, keyed by basename.
278///
279/// The HuggingFace cache stores weight bytes as `blobs/<sha256>` with
280/// human-named symlinks under `snapshots/<rev>/`; indexing by basename lets us
281/// match both the sha256-named blob and a plain `model.safetensors` regardless
282/// of nesting. When several files share a basename the first seen wins; that is
283/// acceptable because hash verification still rejects a wrong file.
284///
285/// Indexed paths are stored in canonicalized form and are guaranteed to resolve
286/// *inside* the model-dir root: a symlink (or a `..` segment) that escapes the
287/// root is skipped, so `verify --model-dir` can never be tricked into reading a
288/// file outside the tree it was pointed at. HuggingFace's intra-tree
289/// `snapshots → blobs` symlinks still resolve fine because they stay under root.
290struct FileIndex {
291    by_name: HashMap<String, PathBuf>,
292}
293
294impl FileIndex {
295    /// Build the index from a *canonicalized* `root`. Every candidate path is
296    /// itself canonicalized (which follows symlinks) and only retained when the
297    /// resolved path is still within `root`; this is the symlink-escape bound.
298    fn build(root: &Path) -> Self {
299        let mut by_name = HashMap::new();
300        let mut stack = vec![root.to_path_buf()];
301        // Directories are canonical here, so a `visited` set makes the walk
302        // robust against symlinked-directory cycles within the tree.
303        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                // Resolve the entry fully (follows symlinks, normalizes `..`).
315                // A path that fails to resolve (dangling symlink) is skipped.
316                let Ok(resolved) = std::fs::canonicalize(&path) else {
317                    continue;
318                };
319                // Reject anything that escapes the model-dir root. Without this a
320                // crafted `model.safetensors -> /etc/passwd` (or `../secret`)
321                // symlink would let an attacker have the verifier read an
322                // arbitrary file outside the directory under audit.
323                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                    // Key on the on-disk basename (e.g. the human-readable
334                    // snapshot name), but store the bounded, resolved path so the
335                    // subsequent hash read targets the in-tree bytes.
336                    by_name
337                        .entry(name.to_lowercase())
338                        .or_insert_with(|| resolved.clone());
339                }
340            }
341        }
342
343        Self { by_name }
344    }
345
346    /// Look up a file by basename (case-insensitive).
347    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        // HuggingFace cache layout: blobs/<sha256>.
381        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    /// An ENRICHED (network/cache-sourced) hash must NOT be used as the verify
396    /// baseline — that would be circular trust. A component with only an
397    /// enriched hash verifies as NoHash even though the blob is present.
398    #[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        // Only an enrichment-sourced hash — not author-attested.
411        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        // Sanity: the SAME hash marked Authored DOES verify (proves the file
425        // is present and the only difference is provenance).
426        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        // The file's real content does not match the SBOM hash → tampering.
456        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        // The real weight bytes live OUTSIDE the model directory.
503        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        // Inside the model dir, a symlink with a plausible weight name points at
510        // the out-of-tree file. A naive verifier would follow it and report
511        // VERIFIED, leaking the result of reading an arbitrary path.
512        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        // HuggingFace layout: blobs/<sha256> with a snapshots/ symlink that stays
537        // WITHIN the model dir. This must still verify (the escape guard only
538        // rejects targets that leave the root).
539        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}