Skip to main content

harn_guard/
store.rs

1//! The on-disk model store under `<state>/guard/`.
2//!
3//! Layout: `<state>/guard/<model-name>/{<files...>, manifest.json}`. Installs are
4//! staged in a sibling temp directory and renamed into place, so a partial or
5//! failed download never leaves a half-written model the resolver would pick up.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::catalog::{CatalogModel, ModelFormat};
14use crate::error::{GuardError, Result};
15
16const MANIFEST_NAME: &str = "manifest.json";
17
18/// SHA-256 of `bytes` as lowercase hex.
19pub fn sha256_hex(bytes: &[u8]) -> String {
20    Sha256::digest(bytes)
21        .iter()
22        .map(|b| format!("{b:02x}"))
23        .collect()
24}
25
26/// A file recorded in an installed model's manifest.
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct ManifestFile {
29    pub name: String,
30    pub sha256: String,
31    pub size: u64,
32}
33
34/// On-disk record of an installed model package (`manifest.json`).
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Manifest {
37    pub name: String,
38    pub display_name: String,
39    pub repo: String,
40    pub license_id: String,
41    pub license_url: String,
42    /// The user explicitly accepted the upstream license at install time.
43    pub license_accepted: bool,
44    pub format: ModelFormat,
45    pub files: Vec<ManifestFile>,
46}
47
48/// The model store rooted at `<state>/guard/`.
49pub struct GuardStore {
50    root: PathBuf,
51}
52
53impl GuardStore {
54    /// Open the store under `<state_root(base_dir)>/guard`.
55    pub fn new(base_dir: &Path) -> Self {
56        Self {
57            root: harn_vm::runtime_paths::state_root(base_dir).join("guard"),
58        }
59    }
60
61    /// Open a store at an explicit root (used by tests).
62    pub fn at_root(root: impl Into<PathBuf>) -> Self {
63        Self { root: root.into() }
64    }
65
66    pub fn root(&self) -> &Path {
67        &self.root
68    }
69
70    pub fn model_dir(&self, name: &str) -> PathBuf {
71        self.root.join(name)
72    }
73
74    fn manifest_path(&self, name: &str) -> PathBuf {
75        self.model_dir(name).join(MANIFEST_NAME)
76    }
77
78    /// Whether `name` is installed (has a manifest).
79    pub fn is_installed(&self, name: &str) -> bool {
80        self.manifest_path(name).is_file()
81    }
82
83    /// Read an installed model's manifest.
84    pub fn read_manifest(&self, name: &str) -> Result<Manifest> {
85        let path = self.manifest_path(name);
86        let bytes = fs::read(&path).map_err(|source| GuardError::Io {
87            path: path.clone(),
88            source,
89        })?;
90        serde_json::from_slice(&bytes).map_err(|source| GuardError::Manifest { path, source })
91    }
92
93    /// List installed models (directories holding a readable manifest), sorted
94    /// by name.
95    pub fn installed(&self) -> Vec<Manifest> {
96        let Ok(entries) = fs::read_dir(&self.root) else {
97            return Vec::new();
98        };
99        let mut out: Vec<Manifest> = entries
100            .filter_map(std::result::Result::ok)
101            .filter(|entry| entry.path().is_dir())
102            .filter_map(|entry| entry.file_name().into_string().ok())
103            .filter_map(|name| self.read_manifest(&name).ok())
104            .collect();
105        out.sort_by(|a, b| a.name.cmp(&b.name));
106        out
107    }
108
109    /// Verify, then atomically install, a downloaded model `payload` (a map of
110    /// each catalog file's `dest` to its bytes). Every catalog file must be
111    /// present; any file with a pinned digest must match it; `license_accepted`
112    /// must be true (the caller surfaces the prompt/flag). The staged directory
113    /// is renamed into place so a failure never publishes a partial model.
114    pub fn install(
115        &self,
116        model: &CatalogModel,
117        payload: &[(String, Vec<u8>)],
118        license_accepted: bool,
119    ) -> Result<Manifest> {
120        if !license_accepted {
121            return Err(GuardError::LicenseNotAccepted {
122                model: model.name.to_owned(),
123                license: model.license_id.to_owned(),
124                license_url: model.license_url.to_owned(),
125            });
126        }
127
128        let mut files = Vec::with_capacity(model.files.len());
129        for catalog_file in model.files {
130            let bytes = payload
131                .iter()
132                .find(|(dest, _)| dest == catalog_file.dest)
133                .map(|(_, bytes)| bytes)
134                .ok_or_else(|| GuardError::MissingFile(catalog_file.dest.to_owned()))?;
135            let actual = sha256_hex(bytes);
136            if let Some(expected) = catalog_file.sha256 {
137                if actual != expected {
138                    return Err(GuardError::ChecksumMismatch {
139                        file: catalog_file.dest.to_owned(),
140                        expected: expected.to_owned(),
141                        actual,
142                    });
143                }
144            }
145            files.push(ManifestFile {
146                name: catalog_file.dest.to_owned(),
147                sha256: actual,
148                size: bytes.len() as u64,
149            });
150        }
151
152        let manifest = Manifest {
153            name: model.name.to_owned(),
154            display_name: model.display_name.to_owned(),
155            repo: model.repo.to_owned(),
156            license_id: model.license_id.to_owned(),
157            license_url: model.license_url.to_owned(),
158            license_accepted,
159            format: model.format,
160            files,
161        };
162
163        let staging = self.root.join(format!(".{}.staging", model.name));
164        let _ = fs::remove_dir_all(&staging);
165        fs::create_dir_all(&staging).map_err(|source| GuardError::Io {
166            path: staging.clone(),
167            source,
168        })?;
169        for (dest, bytes) in payload {
170            let file_path = staging.join(dest);
171            fs::write(&file_path, bytes).map_err(|source| GuardError::Io {
172                path: file_path,
173                source,
174            })?;
175        }
176        let manifest_path = staging.join(MANIFEST_NAME);
177        let manifest_bytes =
178            serde_json::to_vec_pretty(&manifest).map_err(|source| GuardError::Manifest {
179                path: manifest_path.clone(),
180                source,
181            })?;
182        fs::write(&manifest_path, manifest_bytes).map_err(|source| GuardError::Io {
183            path: manifest_path,
184            source,
185        })?;
186
187        let dir = self.model_dir(model.name);
188        let _ = fs::remove_dir_all(&dir);
189        fs::rename(&staging, &dir).map_err(|source| GuardError::Io { path: dir, source })?;
190        Ok(manifest)
191    }
192
193    /// Remove an installed model. Returns whether it existed.
194    pub fn remove(&self, name: &str) -> Result<bool> {
195        let dir = self.model_dir(name);
196        if !dir.exists() {
197            return Ok(false);
198        }
199        fs::remove_dir_all(&dir).map_err(|source| GuardError::Io { path: dir, source })?;
200        Ok(true)
201    }
202
203    /// Re-hash an installed model's files and report whether they still match the
204    /// digests recorded at install time (detects on-disk corruption).
205    pub fn verify_installed(&self, name: &str) -> Result<bool> {
206        let manifest = self.read_manifest(name)?;
207        for file in &manifest.files {
208            let path = self.model_dir(name).join(&file.name);
209            let bytes = fs::read(&path).map_err(|source| GuardError::Io { path, source })?;
210            if sha256_hex(&bytes) != file.sha256 {
211                return Ok(false);
212            }
213        }
214        Ok(true)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::catalog;
222    use tempfile::TempDir;
223
224    fn temp_store() -> (TempDir, GuardStore) {
225        let temp_dir = TempDir::new().unwrap();
226        let store = GuardStore::at_root(temp_dir.path().join("guard"));
227        (temp_dir, store)
228    }
229
230    fn payload(files: &[(&str, &[u8])]) -> Vec<(String, Vec<u8>)> {
231        files
232            .iter()
233            .map(|(dest, bytes)| ((*dest).to_owned(), (*bytes).to_vec()))
234            .collect()
235    }
236
237    #[test]
238    fn sha256_hex_is_lowercase_64() {
239        let h = sha256_hex(b"hello");
240        assert_eq!(
241            h,
242            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
243        );
244    }
245
246    #[test]
247    fn install_then_read_and_list_roundtrips() {
248        let (_temp_dir, store) = temp_store();
249        let model = catalog::default_model();
250        // Provide bytes for every catalog file; the pinned model.onnx digest will
251        // NOT match these stub bytes, so use a model with no pinned digests for a
252        // clean roundtrip and assert the pin separately below.
253        let stub = payload(&[
254            ("model.onnx", b"x"),
255            ("tokenizer.json", b"y"),
256            ("config.json", b"z"),
257        ]);
258        // default model pins model.onnx -> mismatch expected.
259        let err = store.install(model, &stub, true).unwrap_err();
260        assert!(matches!(err, GuardError::ChecksumMismatch { .. }));
261    }
262
263    #[test]
264    fn install_requires_license_acceptance() {
265        let (_temp_dir, store) = temp_store();
266        let model = catalog::find("llama-prompt-guard-2-86m").unwrap();
267        let stub = payload(&[
268            ("model.onnx", b"a"),
269            ("tokenizer.json", b"b"),
270            ("config.json", b"c"),
271        ]);
272        let err = store.install(model, &stub, false).unwrap_err();
273        assert!(matches!(err, GuardError::LicenseNotAccepted { .. }));
274    }
275
276    #[test]
277    fn install_unpinned_model_roundtrips_and_verifies() {
278        let (_temp_dir, store) = temp_store();
279        // The gated entry pins nothing, so stub bytes install cleanly (TOFU).
280        let model = catalog::find("llama-prompt-guard-2-86m").unwrap();
281        let files = payload(&[
282            ("model.onnx", b"weights"),
283            ("tokenizer.json", b"tok"),
284            ("config.json", b"cfg"),
285        ]);
286        let manifest = store.install(model, &files, true).unwrap();
287        assert_eq!(manifest.name, "llama-prompt-guard-2-86m");
288        assert!(manifest.license_accepted);
289        assert_eq!(manifest.files.len(), 3);
290
291        assert!(store.is_installed("llama-prompt-guard-2-86m"));
292        let read = store.read_manifest("llama-prompt-guard-2-86m").unwrap();
293        assert_eq!(read, manifest);
294        assert!(store.verify_installed("llama-prompt-guard-2-86m").unwrap());
295
296        let listed = store.installed();
297        assert_eq!(listed.len(), 1);
298        assert_eq!(listed[0].name, "llama-prompt-guard-2-86m");
299
300        assert!(store.remove("llama-prompt-guard-2-86m").unwrap());
301        assert!(!store.is_installed("llama-prompt-guard-2-86m"));
302        assert!(!store.remove("llama-prompt-guard-2-86m").unwrap());
303    }
304
305    #[test]
306    fn missing_file_in_payload_errors() {
307        let (_temp_dir, store) = temp_store();
308        let model = catalog::find("llama-prompt-guard-2-86m").unwrap();
309        let incomplete = payload(&[("model.onnx", b"weights")]);
310        let err = store.install(model, &incomplete, true).unwrap_err();
311        assert!(matches!(err, GuardError::MissingFile(f) if f == "tokenizer.json"));
312    }
313
314    #[test]
315    fn verify_detects_corruption() {
316        let (_temp_dir, store) = temp_store();
317        let model = catalog::find("llama-prompt-guard-2-86m").unwrap();
318        let files = payload(&[
319            ("model.onnx", b"weights"),
320            ("tokenizer.json", b"tok"),
321            ("config.json", b"cfg"),
322        ]);
323        store.install(model, &files, true).unwrap();
324        // Corrupt a file on disk.
325        fs::write(store.model_dir(model.name).join("config.json"), b"tampered").unwrap();
326        assert!(!store.verify_installed(model.name).unwrap());
327    }
328}