1use std::{
10 collections::BTreeSet,
11 fs, io,
12 path::{Path, PathBuf},
13};
14
15use anyhow::{bail, Context, Result};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct EmbeddingModel {
22 pub destination: PathBuf,
24 pub artifacts: Vec<ModelArtifact>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct ModelArtifact {
30 pub name: String,
31 pub url: String,
32 pub sha256: String,
33}
34
35impl EmbeddingModel {
36 pub fn read(path: &Path) -> Result<Self> {
37 let body = fs::read_to_string(path)
38 .with_context(|| format!("read embedding model spec {}", path.display()))?;
39 let model: Self = toml::from_str(&body)
40 .with_context(|| format!("parse embedding model spec {}", path.display()))?;
41 model.validate()?;
42 Ok(model)
43 }
44
45 pub fn validate(&self) -> Result<()> {
46 if self.destination.as_os_str().is_empty() {
47 bail!("embedding model destination must not be empty");
48 }
49 if self.artifacts.is_empty() {
50 bail!("embedding model section names no artifacts");
51 }
52 let mut names = BTreeSet::new();
53 for artifact in &self.artifacts {
54 if artifact.name.is_empty()
57 || artifact.name.contains('/')
58 || artifact.name.contains('\\')
59 || artifact.name == "."
60 || artifact.name == ".."
61 {
62 bail!(
63 "embedding model artifact name {:?} must be a bare file name",
64 artifact.name
65 );
66 }
67 if !names.insert(artifact.name.clone()) {
68 bail!("duplicate embedding model artifact {:?}", artifact.name);
69 }
70 if !artifact.url.starts_with("https://") {
71 bail!(
72 "embedding model artifact {} must use an https:// url",
73 artifact.name
74 );
75 }
76 if artifact.sha256.len() != 64
77 || !artifact
78 .sha256
79 .bytes()
80 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
81 {
82 bail!(
83 "embedding model artifact {} sha256 must be 64 lowercase hex characters",
84 artifact.name
85 );
86 }
87 }
88 Ok(())
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ArtifactOutcome {
94 Fetched,
96 Current,
98}
99
100#[derive(Debug)]
101pub struct ProvisionedArtifact {
102 pub name: String,
103 pub path: PathBuf,
104 pub sha256: String,
105 pub outcome: ArtifactOutcome,
106}
107
108pub fn provision<F>(model: &EmbeddingModel, mut fetch: F) -> Result<Vec<ProvisionedArtifact>>
112where
113 F: FnMut(&str, &Path) -> Result<()>,
114{
115 model.validate()?;
116 fs::create_dir_all(&model.destination).with_context(|| {
117 format!(
118 "create embedding model destination {}",
119 model.destination.display()
120 )
121 })?;
122 let mut provisioned = Vec::new();
123 for artifact in &model.artifacts {
124 let path = model.destination.join(&artifact.name);
125 if path.is_file() && sha256_file(&path)? == artifact.sha256 {
126 provisioned.push(ProvisionedArtifact {
127 name: artifact.name.clone(),
128 path,
129 sha256: artifact.sha256.clone(),
130 outcome: ArtifactOutcome::Current,
131 });
132 continue;
133 }
134 let staged = tempfile::NamedTempFile::new_in(&model.destination).with_context(|| {
137 format!(
138 "stage embedding model download in {}",
139 model.destination.display()
140 )
141 })?;
142 fetch(&artifact.url, staged.path())
143 .with_context(|| format!("fetch embedding model artifact {}", artifact.name))?;
144 let downloaded = sha256_file(staged.path())?;
145 if downloaded != artifact.sha256 {
146 bail!(
149 "embedding model artifact {} checksum mismatch: plan pins {}, download hashed {}; the download was deleted",
150 artifact.name,
151 artifact.sha256,
152 downloaded
153 );
154 }
155 staged
156 .persist(&path)
157 .map_err(|error| error.error)
158 .with_context(|| format!("place embedding model artifact {}", path.display()))?;
159 provisioned.push(ProvisionedArtifact {
160 name: artifact.name.clone(),
161 path,
162 sha256: artifact.sha256.clone(),
163 outcome: ArtifactOutcome::Fetched,
164 });
165 }
166 Ok(provisioned)
167}
168
169pub fn verify(model: &EmbeddingModel) -> Result<()> {
176 model.validate()?;
177 for artifact in &model.artifacts {
178 let path = model.destination.join(&artifact.name);
179 if !path.is_file() {
180 bail!(
181 "embedding model artifact {} is missing from {}",
182 artifact.name,
183 model.destination.display()
184 );
185 }
186 let found = sha256_file(&path)?;
187 if found != artifact.sha256 {
188 bail!(
189 "embedding model artifact {} drifted on disk: plan pins {}, file hashed {}",
190 artifact.name,
191 artifact.sha256,
192 found
193 );
194 }
195 }
196 Ok(())
197}
198
199pub fn sha256_file(path: &Path) -> Result<String> {
200 let mut file =
201 fs::File::open(path).with_context(|| format!("open {} for hashing", path.display()))?;
202 let mut hasher = Sha256::new();
203 io::copy(&mut file, &mut hasher).with_context(|| format!("hash {}", path.display()))?;
204 Ok(format!("{:x}", hasher.finalize()))
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn fixture_model(destination: &Path, sha256: &str) -> EmbeddingModel {
212 EmbeddingModel {
213 destination: destination.to_path_buf(),
214 artifacts: vec![ModelArtifact {
215 name: "model.onnx".to_owned(),
216 url: "https://models.test/model.onnx".to_owned(),
217 sha256: sha256.to_owned(),
218 }],
219 }
220 }
221
222 fn digest_of(bytes: &[u8]) -> String {
223 format!("{:x}", Sha256::digest(bytes))
224 }
225
226 #[test]
227 fn sha256_file_matches_the_published_test_vector() {
228 let root = tempfile::tempdir().unwrap();
232 let path = root.path().join("abc");
233 fs::write(&path, b"abc").unwrap();
234 assert_eq!(
235 sha256_file(&path).unwrap(),
236 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
237 );
238 }
239
240 #[test]
241 fn provision_fetches_once_and_is_idempotent_afterwards() {
242 let root = tempfile::tempdir().unwrap();
243 let model = fixture_model(&root.path().join("dest"), &digest_of(b"weights"));
244 let mut fetches = 0;
245 let mut fetch = |_: &str, path: &Path| {
246 fetches += 1;
247 fs::write(path, b"weights").map_err(Into::into)
248 };
249 let first = provision(&model, &mut fetch).unwrap();
250 assert_eq!(first[0].outcome, ArtifactOutcome::Fetched);
251 let second = provision(&model, &mut fetch).unwrap();
252 assert_eq!(second[0].outcome, ArtifactOutcome::Current);
253 assert_eq!(fetches, 1);
254 assert_eq!(
255 fs::read(root.path().join("dest/model.onnx")).unwrap(),
256 b"weights"
257 );
258 }
259
260 #[test]
261 fn provision_refuses_a_checksum_mismatch_and_deletes_the_download() {
262 let root = tempfile::tempdir().unwrap();
263 let destination = root.path().join("dest");
264 let model = fixture_model(&destination, &digest_of(b"expected"));
265 let error = provision(&model, |_, path| {
266 fs::write(path, b"tampered").map_err(Into::into)
267 })
268 .unwrap_err()
269 .to_string();
270 assert!(error.contains("model.onnx checksum mismatch"), "{error}");
271 assert!(error.contains(&digest_of(b"expected")), "{error}");
272 assert!(!destination.join("model.onnx").exists());
275 assert_eq!(fs::read_dir(&destination).unwrap().count(), 0);
276 }
277
278 #[test]
279 fn provision_replaces_a_drifted_artifact_atomically() {
280 let root = tempfile::tempdir().unwrap();
281 let destination = root.path().join("dest");
282 fs::create_dir_all(&destination).unwrap();
283 fs::write(destination.join("model.onnx"), b"stale").unwrap();
284 let model = fixture_model(&destination, &digest_of(b"weights"));
285 let provisioned = provision(&model, |_, path| {
286 fs::write(path, b"weights").map_err(Into::into)
287 })
288 .unwrap();
289 assert_eq!(provisioned[0].outcome, ArtifactOutcome::Fetched);
290 assert_eq!(
291 fs::read(destination.join("model.onnx")).unwrap(),
292 b"weights"
293 );
294 }
295
296 #[test]
297 fn verify_goes_red_on_drift_or_absence_and_green_on_pinned_bytes() {
298 let root = tempfile::tempdir().unwrap();
299 let destination = root.path().join("dest");
300 let model = fixture_model(&destination, &digest_of(b"weights"));
301 let missing = verify(&model).unwrap_err().to_string();
302 assert!(missing.contains("model.onnx is missing"), "{missing}");
303
304 fs::create_dir_all(&destination).unwrap();
305 fs::write(destination.join("model.onnx"), b"weights").unwrap();
306 verify(&model).unwrap();
307
308 fs::write(destination.join("model.onnx"), b"weightz").unwrap();
309 let drifted = verify(&model).unwrap_err().to_string();
310 assert!(drifted.contains("model.onnx drifted on disk"), "{drifted}");
311 }
312
313 #[test]
314 fn validation_refuses_paths_plain_http_and_unpinned_digests() {
315 let sha = digest_of(b"weights");
316 let mut traversal = fixture_model(Path::new("dest"), &sha);
317 traversal.artifacts[0].name = "../escape.onnx".to_owned();
318 assert!(traversal.validate().is_err());
319
320 let mut insecure = fixture_model(Path::new("dest"), &sha);
321 insecure.artifacts[0].url = "http://models.test/model.onnx".to_owned();
322 assert!(insecure.validate().is_err());
323
324 let short = fixture_model(Path::new("dest"), "abc123");
325 assert!(short.validate().is_err());
326 let uppercase = fixture_model(Path::new("dest"), &sha.to_ascii_uppercase());
327 assert!(uppercase.validate().is_err());
328
329 let empty = EmbeddingModel {
330 destination: PathBuf::from("dest"),
331 artifacts: Vec::new(),
332 };
333 assert!(empty.validate().is_err());
334 }
335}