Skip to main content

clawft_kernel/model_manifest/
check.rs

1//! Checking an adopted model's files against its attestation (stat-only,
2//! lazy or full), and the resulting [`ModelState`].
3
4use super::adopt::{FileRole, allowed_roots, hash_file, stamp};
5use super::body::ModelError;
6use super::registry::ModelEntry;
7
8/// How hard to look.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CheckMode {
11    /// Existence and size only. Never reads file content: used when
12    /// advertising facts.
13    Stat,
14    /// Re-hash only files whose stamp changed or is missing.
15    Lazy,
16    /// Re-hash every file.
17    Full,
18}
19
20/// Result for one file.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum FileOutcome {
23    /// Matches the attestation.
24    Ok {
25        /// True when the content was hashed on this check.
26        rehashed: bool,
27    },
28    /// Not on disk.
29    Missing,
30    /// A symlink now resolves outside the model root (or the HF blobs
31    /// directory of its cache). Never read; the model is refused.
32    Escapes,
33    /// Present but differs from the attestation.
34    Mismatch {
35        /// What was found (a hash, or a byte count for a size mismatch).
36        actual: String,
37    },
38}
39
40/// Usability of a model right now.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ModelState {
43    /// Every file is present and matches.
44    Ready,
45    /// Files are unavailable but not wrong: a detached drive or deleted
46    /// shards. The workload is `Degraded`, not silently broken.
47    Degraded {
48        /// Why.
49        reason: String,
50        /// True when the whole root is gone (an unmounted drive).
51        detached: bool,
52    },
53    /// A file differs from the attested hash. Never used.
54    Refused {
55        /// Why.
56        reason: String,
57    },
58}
59
60impl ModelState {
61    /// True for [`ModelState::Ready`].
62    pub fn is_ready(&self) -> bool {
63        matches!(self, Self::Ready)
64    }
65}
66
67/// Outcome of a check.
68#[derive(Debug, Clone)]
69pub struct ModelCheck {
70    /// Package id.
71    pub package_id: String,
72    /// Model name.
73    pub name: String,
74    /// Overall state.
75    pub state: ModelState,
76    /// Per-file outcomes in file order.
77    pub files: Vec<(String, FileOutcome)>,
78    /// Shard hashes that are present and (at least) size-correct.
79    pub present_shards: Vec<String>,
80    /// Number of shards the manifest lists.
81    pub total_shards: usize,
82}
83
84pub(super) type Stamps = Vec<Option<(u64, u64)>>;
85
86pub(super) fn check_entry(
87    id: &str,
88    entry: &ModelEntry,
89    mode: CheckMode,
90) -> Result<(ModelCheck, Stamps), ModelError> {
91    let body = entry.body()?;
92    let mut outcomes = Vec::new();
93    let mut stamps = Vec::new();
94    let allowed = allowed_roots(&entry.root);
95    for f in &entry.files {
96        // Containment is re-checked on every check: a link swapped after
97        // adoption must not be hashed or served. Only the resolved path is
98        // read from here on.
99        let abs = match entry.root.join(&f.path).canonicalize() {
100            Ok(real) if allowed.iter().any(|a| real.starts_with(a)) => real,
101            Ok(_) => {
102                outcomes.push((f.path.clone(), FileOutcome::Escapes));
103                stamps.push(None);
104                continue;
105            }
106            Err(_) => {
107                outcomes.push((f.path.clone(), FileOutcome::Missing));
108                stamps.push(None);
109                continue;
110            }
111        };
112        let (outcome, st) = match stamp(&abs) {
113            Err(_) => (FileOutcome::Missing, None),
114            Ok((size, _)) if (f.role == FileRole::Shard || f.size != 0) && size != f.size => {
115                (FileOutcome::Mismatch { actual: format!("{size} bytes") }, None)
116            }
117            Ok(s) => match mode {
118                CheckMode::Stat => (FileOutcome::Ok { rehashed: false }, f.verified),
119                CheckMode::Lazy if f.verified == Some(s) => {
120                    (FileOutcome::Ok { rehashed: false }, f.verified)
121                }
122                _ => match hash_file(&abs) {
123                    Ok(h) if h == f.blake3 => (FileOutcome::Ok { rehashed: true }, Some(s)),
124                    Ok(h) => (FileOutcome::Mismatch { actual: h }, None),
125                    Err(_) => (FileOutcome::Missing, None),
126                },
127            },
128        };
129        outcomes.push((f.path.clone(), outcome));
130        stamps.push(st);
131    }
132    if let Some((p, _)) = outcomes.iter().find(|(_, o)| *o == FileOutcome::Escapes) {
133        let state = ModelState::Refused {
134            reason: format!("{p}: resolves outside the model root"),
135        };
136        return Ok((finish_check(id, &body, outcomes, state), stamps));
137    }
138    let mismatch = outcomes
139        .iter()
140        .find(|(_, o)| matches!(o, FileOutcome::Mismatch { .. }));
141    let missing = outcomes.iter().filter(|(_, o)| *o == FileOutcome::Missing).count();
142    let state = if let Some((p, o)) = mismatch {
143        let FileOutcome::Mismatch { actual } = o else { unreachable!() };
144        ModelState::Refused { reason: format!("{p}: expected hash or size differs (found {actual})") }
145    } else if !entry.root.exists() {
146        ModelState::Degraded {
147            reason: "model store is detached (root directory not present)".into(),
148            detached: true,
149        }
150    } else if missing > 0 {
151        ModelState::Degraded { reason: format!("{missing} file(s) missing"), detached: false }
152    } else if let Some(why) = &entry.refused {
153        // Cheap modes cannot clear a refusal; only a passing full check does.
154        if mode == CheckMode::Full {
155            ModelState::Ready
156        } else {
157            ModelState::Refused { reason: why.clone() }
158        }
159    } else {
160        ModelState::Ready
161    };
162    Ok((finish_check(id, &body, outcomes, state), stamps))
163}
164
165fn finish_check(
166    id: &str,
167    body: &super::body::ModelPackageBody,
168    outcomes: Vec<(String, FileOutcome)>,
169    state: ModelState,
170) -> ModelCheck {
171    let present_shards = body
172        .shards
173        .iter()
174        .filter(|s| {
175            outcomes
176                .iter()
177                .any(|(p, o)| *p == s.path && matches!(o, FileOutcome::Ok { .. }))
178        })
179        .map(|s| s.blake3.clone())
180        .collect();
181    ModelCheck {
182        package_id: id.to_string(),
183        name: body.name.clone(),
184        state,
185        files: outcomes,
186        present_shards,
187        total_shards: body.shards.len(),
188    }
189}