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