Skip to main content

aurum_core/
cache.rs

1//! Cache inventory, verification, quarantine, and repair (JOE-1592).
2
3use 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/// Verification state for a cached artifact.
10#[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 on disk; cheap inventory only (use `cache verify` for digests).
19    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
37/// Cheap status (existence + size) without full hashing.
38pub 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                    // Cheap status never claims Healthy (requires digest verify) — JOE-1645.
48                    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                            // Present + size OK; full integrity is `aurum cache verify`.
53                            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
80/// Full verify with SHA-256 when pins exist (no network).
81/// Invalid on-disk artifacts are moved to quarantine (not deleted).
82pub 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    // Prefer reviewed exact size; fall back to approx only when no pin exists.
153    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
160/// Move an invalid artifact into quarantine with a reason file (no network).
161pub 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
185/// Format human-readable status table.
186pub 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
210/// JSON for host health checks.
211pub 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}