1use crate::error::{EnvironmentError, Result, UserError};
4use crate::model::{self, ModelInfo, ARTIFACT_MANIFEST_VERSION};
5use serde::Serialize;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
11#[serde(rename_all = "snake_case")]
12pub enum VerifyState {
13 Healthy,
14 Missing,
15 SizeMismatch,
16 DigestMismatch,
17 Unpinned,
18 Present,
20 Quarantined,
21 Error,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct CacheEntry {
26 pub kind: &'static str,
27 pub id: String,
28 pub path: String,
29 pub manifest_version: &'static str,
30 pub expected_bytes: Option<u64>,
31 pub actual_bytes: Option<u64>,
32 pub expected_sha256: Option<String>,
33 pub state: VerifyState,
34 pub last_error: Option<String>,
35}
36
37pub fn status_stt(cache_dir: &Path) -> Vec<CacheEntry> {
39 model::list_models(cache_dir)
40 .into_iter()
41 .map(|row| {
42 let expected = model_exact_or_approx(row.info);
43 let pin = model::pinned_sha256(row.info.filename);
44 let (actual, state) = match fs::metadata(&row.path) {
45 Ok(m) if m.len() > 1_000_000 => {
46 let actual = m.len();
47 let state = if pin.is_none() {
49 VerifyState::Unpinned
50 } else if let Some(exp) = model::pinned_exact_bytes(row.info.filename) {
51 if actual == exp {
52 VerifyState::Present
54 } else {
55 VerifyState::SizeMismatch
56 }
57 } else {
58 VerifyState::Present
59 };
60 (Some(actual), state)
61 }
62 Ok(m) if m.len() > 0 => (Some(m.len()), VerifyState::Present),
63 _ => (None, VerifyState::Missing),
64 };
65 CacheEntry {
66 kind: "stt",
67 id: row.info.name.to_string(),
68 path: row.path.display().to_string(),
69 manifest_version: ARTIFACT_MANIFEST_VERSION,
70 expected_bytes: expected,
71 actual_bytes: actual,
72 expected_sha256: pin.map(|s| s.to_string()),
73 state,
74 last_error: None,
75 }
76 })
77 .collect()
78}
79
80pub fn verify_stt(cache_dir: &Path) -> Vec<CacheEntry> {
83 model::list_models(cache_dir)
84 .into_iter()
85 .map(|row| verify_one_stt(cache_dir, row.info, &row.path))
86 .collect()
87}
88
89fn verify_one_stt(cache_dir: &Path, info: &ModelInfo, path: &Path) -> CacheEntry {
90 let qdir = quarantine_dir(cache_dir);
91 let qpath = qdir.join(info.filename);
92 if qpath.exists() && !path.exists() {
93 return CacheEntry {
94 kind: "stt",
95 id: info.name.to_string(),
96 path: qpath.display().to_string(),
97 manifest_version: ARTIFACT_MANIFEST_VERSION,
98 expected_bytes: model_exact_or_approx(info),
99 actual_bytes: fs::metadata(&qpath).ok().map(|m| m.len()),
100 expected_sha256: None,
101 state: VerifyState::Quarantined,
102 last_error: Some("artifact is in quarantine".into()),
103 };
104 }
105
106 let mut entry = CacheEntry {
107 kind: "stt",
108 id: info.name.to_string(),
109 path: path.display().to_string(),
110 manifest_version: ARTIFACT_MANIFEST_VERSION,
111 expected_bytes: model_exact_or_approx(info),
112 actual_bytes: None,
113 expected_sha256: None,
114 state: VerifyState::Missing,
115 last_error: None,
116 };
117
118 entry.expected_sha256 = model::pinned_sha256(info.filename).map(|s| s.to_string());
119 match model::ensure_model_verified_local(path, info) {
120 Ok(()) => {
121 entry.actual_bytes = fs::metadata(path).ok().map(|m| m.len());
122 entry.state = if entry.expected_sha256.is_some() {
123 VerifyState::Healthy
124 } else {
125 VerifyState::Unpinned
126 };
127 }
128 Err(e) => {
129 entry.last_error = Some(e.to_string());
130 if path.exists() {
131 entry.actual_bytes = fs::metadata(path).ok().map(|m| m.len());
132 match quarantine_file(cache_dir, path, &e.to_string()) {
133 Ok(dest) => {
134 entry.path = dest.display().to_string();
135 entry.state = VerifyState::Quarantined;
136 }
137 Err(qe) => {
138 entry.state = VerifyState::DigestMismatch;
139 entry.last_error =
140 Some(format!("verify failed ({e}); quarantine failed ({qe})"));
141 }
142 }
143 } else {
144 entry.state = VerifyState::Missing;
145 }
146 }
147 }
148 entry
149}
150
151fn model_exact_or_approx(info: &ModelInfo) -> Option<u64> {
152 model::pinned_exact_bytes(info.filename).or(Some(info.approx_bytes))
154}
155
156pub fn quarantine_dir(cache_dir: &Path) -> PathBuf {
157 cache_dir.join("quarantine")
158}
159
160pub fn quarantine_file(cache_dir: &Path, path: &Path, reason: &str) -> Result<PathBuf> {
162 let qdir = quarantine_dir(cache_dir);
163 fs::create_dir_all(&qdir).map_err(|e| EnvironmentError::DirectoryAccess {
164 path: qdir.display().to_string(),
165 reason: e.to_string(),
166 })?;
167 let name = path
168 .file_name()
169 .map(|s| s.to_os_string())
170 .ok_or_else(|| UserError::Other {
171 message: "cannot quarantine path without file name".into(),
172 })?;
173 let dest = qdir.join(&name);
174 if path.exists() {
175 fs::rename(path, &dest).map_err(|e| EnvironmentError::DirectoryAccess {
176 path: dest.display().to_string(),
177 reason: e.to_string(),
178 })?;
179 }
180 let reason_path = dest.with_extension("quarantine-reason.txt");
181 fs::write(&reason_path, format!("{reason}\n")).map_err(EnvironmentError::Io)?;
182 Ok(dest)
183}
184
185pub fn format_status(entries: &[CacheEntry]) -> String {
187 let mut out = String::from("Aurum cache inventory\n\n");
188 out.push_str(&format!(
189 "{:<8} {:<22} {:<12} {:>12} {}\n",
190 "KIND", "ID", "STATE", "BYTES", "PATH"
191 ));
192 for e in entries {
193 out.push_str(&format!(
194 "{:<8} {:<22} {:<12} {:>12} {}\n",
195 e.kind,
196 e.id,
197 format!("{:?}", e.state).to_ascii_lowercase(),
198 e.actual_bytes
199 .map(|b| b.to_string())
200 .unwrap_or_else(|| "—".into()),
201 e.path
202 ));
203 }
204 out.push_str(
205 "\nNote: `status` is cheap (size/existence). Use `aurum cache verify` for full digests.\n",
206 );
207 out
208}
209
210pub fn status_json(entries: &[CacheEntry]) -> Result<String> {
212 serde_json::to_string_pretty(entries)
213 .map_err(|e| crate::error::TranscriptionError::internal(format!("cache status json: {e}")))
214}