use super::adopt::{FileRole, allowed_roots, hash_file, stamp};
use super::body::ModelError;
use super::registry::ModelEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckMode {
Stat,
Lazy,
Full,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileOutcome {
Ok {
rehashed: bool,
},
Missing,
Escapes,
Mismatch {
actual: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelState {
Ready,
Degraded {
reason: String,
detached: bool,
},
Refused {
reason: String,
},
}
impl ModelState {
pub fn is_ready(&self) -> bool {
matches!(self, Self::Ready)
}
}
#[derive(Debug, Clone)]
pub struct ModelCheck {
pub package_id: String,
pub name: String,
pub state: ModelState,
pub files: Vec<(String, FileOutcome)>,
pub present_shards: Vec<String>,
pub total_shards: usize,
}
pub(super) type Stamps = Vec<Option<(u64, u64)>>;
pub(super) fn check_entry(
id: &str,
entry: &ModelEntry,
mode: CheckMode,
) -> Result<(ModelCheck, Stamps), ModelError> {
let body = entry.body()?;
let mut outcomes = Vec::new();
let mut stamps = Vec::new();
let allowed = allowed_roots(&entry.root);
for f in &entry.files {
let abs = match entry.root.join(&f.path).canonicalize() {
Ok(real) if allowed.iter().any(|a| real.starts_with(a)) => real,
Ok(_) => {
outcomes.push((f.path.clone(), FileOutcome::Escapes));
stamps.push(None);
continue;
}
Err(_) => {
outcomes.push((f.path.clone(), FileOutcome::Missing));
stamps.push(None);
continue;
}
};
let (outcome, st) = match stamp(&abs) {
Err(_) => (FileOutcome::Missing, None),
Ok((size, _)) if (f.role == FileRole::Shard || f.size != 0) && size != f.size => {
(FileOutcome::Mismatch { actual: format!("{size} bytes") }, None)
}
Ok(s) => match mode {
CheckMode::Stat => (FileOutcome::Ok { rehashed: false }, f.verified),
CheckMode::Lazy if f.verified == Some(s) => {
(FileOutcome::Ok { rehashed: false }, f.verified)
}
_ => match hash_file(&abs) {
Ok(h) if h == f.blake3 => (FileOutcome::Ok { rehashed: true }, Some(s)),
Ok(h) => (FileOutcome::Mismatch { actual: h }, None),
Err(_) => (FileOutcome::Missing, None),
},
},
};
outcomes.push((f.path.clone(), outcome));
stamps.push(st);
}
if let Some((p, _)) = outcomes.iter().find(|(_, o)| *o == FileOutcome::Escapes) {
let state = ModelState::Refused {
reason: format!("{p}: resolves outside the model root"),
};
return Ok((finish_check(id, &body, outcomes, state), stamps));
}
let mismatch = outcomes
.iter()
.find(|(_, o)| matches!(o, FileOutcome::Mismatch { .. }));
let missing = outcomes.iter().filter(|(_, o)| *o == FileOutcome::Missing).count();
let state = if let Some((p, o)) = mismatch {
let FileOutcome::Mismatch { actual } = o else { unreachable!() };
ModelState::Refused { reason: format!("{p}: expected hash or size differs (found {actual})") }
} else if !entry.root.exists() {
ModelState::Degraded {
reason: "model store is detached (root directory not present)".into(),
detached: true,
}
} else if missing > 0 {
ModelState::Degraded { reason: format!("{missing} file(s) missing"), detached: false }
} else if let Some(why) = &entry.refused {
if mode == CheckMode::Full {
ModelState::Ready
} else {
ModelState::Refused { reason: why.clone() }
}
} else {
ModelState::Ready
};
Ok((finish_check(id, &body, outcomes, state), stamps))
}
fn finish_check(
id: &str,
body: &super::body::ModelPackageBody,
outcomes: Vec<(String, FileOutcome)>,
state: ModelState,
) -> ModelCheck {
let present_shards = body
.shards
.iter()
.filter(|s| {
outcomes
.iter()
.any(|(p, o)| *p == s.path && matches!(o, FileOutcome::Ok { .. }))
})
.map(|s| s.blake3.clone())
.collect();
ModelCheck {
package_id: id.to_string(),
name: body.name.clone(),
state,
files: outcomes,
present_shards,
total_shards: body.shards.len(),
}
}