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    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
36/// Cheap status (existence + size) without full hashing.
37pub 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                    // Cheap status never claims Healthy (requires digest verify) — JOE-1645.
47                    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                            // Present + size OK; full integrity is `aurum cache verify`.
52                            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
79/// Full verify with SHA-256 when pins exist (no network).
80/// Invalid on-disk artifacts are moved to quarantine (not deleted).
81pub 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    // Prefer reviewed exact size; fall back to approx only when no pin exists.
152    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
159/// Move an invalid artifact into quarantine with a reason file (no network).
160pub 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
184/// Format human-readable status table.
185pub 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
209/// JSON for host health checks.
210pub 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}