clawft_kernel/model_manifest/
check.rs1use super::adopt::{FileRole, allowed_roots, hash_file, stamp};
5use super::body::ModelError;
6use super::registry::ModelEntry;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CheckMode {
11 Stat,
14 Lazy,
16 Full,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum FileOutcome {
23 Ok {
25 rehashed: bool,
27 },
28 Missing,
30 Escapes,
33 Mismatch {
35 actual: String,
37 },
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ModelState {
43 Ready,
45 Degraded {
48 reason: String,
50 detached: bool,
52 },
53 Refused {
55 reason: String,
57 },
58}
59
60impl ModelState {
61 pub fn is_ready(&self) -> bool {
63 matches!(self, Self::Ready)
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct ModelCheck {
70 pub package_id: String,
72 pub name: String,
74 pub state: ModelState,
76 pub files: Vec<(String, FileOutcome)>,
78 pub present_shards: Vec<String>,
80 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 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 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}