1use 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
18pub fn sha256_hex(bytes: &[u8]) -> String {
20 Sha256::digest(bytes)
21 .iter()
22 .map(|b| format!("{b:02x}"))
23 .collect()
24}
25
26#[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#[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 pub license_accepted: bool,
44 pub format: ModelFormat,
45 pub files: Vec<ManifestFile>,
46}
47
48pub struct GuardStore {
50 root: PathBuf,
51}
52
53impl GuardStore {
54 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 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 pub fn is_installed(&self, name: &str) -> bool {
80 self.manifest_path(name).is_file()
81 }
82
83 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 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 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 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 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 let stub = payload(&[
254 ("model.onnx", b"x"),
255 ("tokenizer.json", b"y"),
256 ("config.json", b"z"),
257 ]);
258 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 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 fs::write(store.model_dir(model.name).join("config.json"), b"tampered").unwrap();
326 assert!(!store.verify_installed(model.name).unwrap());
327 }
328}