Skip to main content

caboodle/
embedding.rs

1//! Checksum-pinned embedding-model artifact provisioning.
2//!
3//! Quipu deliberately bundles no model weights (its onnx feature ships the
4//! runtime only), so the first hour of a box needs the artifacts fetched from
5//! somewhere — and a fetch trusted on its exit code is exactly the banner
6//! this repo exists to refuse. Every artifact is pinned by sha256 in the
7//! reviewed plan; a mismatched download is deleted, named, and fatal.
8
9use 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    /// Directory that receives every artifact; created on demand.
23    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            // A name is a file name, never a path: a plan that says
55            // "../../etc/shadow" must fail at review time, not at fetch time.
56            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    /// Downloaded this run and proven against the pinned digest.
95    Fetched,
96    /// Already on disk with a matching digest; recorded, not re-downloaded.
97    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
108/// Fetch every artifact through `fetch`, refusing checksum mismatches. An
109/// artifact already on disk with a matching digest is a recorded no-op, so a
110/// rerun converges instead of re-downloading model weights.
111pub 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        // Download beside the destination so the final rename is atomic and a
135        // mismatched download can never be observed at the artifact path.
136        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            // Dropping `staged` deletes the mismatched bytes; nothing
147            // untrusted remains on disk after this refusal.
148            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
169/// Re-hash every provisioned artifact against the plan's pinned digest.
170///
171/// This proves the bytes on disk are still the reviewed bytes. It does NOT
172/// prove an embed-and-search round-trip: the installed Quipu exposes no
173/// reviewed embeddings contract for caboodle to drive yet, and a proof that
174/// cannot fail would be banner inflation. The book states exactly this split.
175pub 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        // FIPS 180-2 vector for sha256("abc"): the other tests here derive
229        // expectations from this same hasher, so one external anchor keeps a
230        // hasher bug from validating itself.
231        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        // The refused bytes must be gone: neither the artifact path nor a
273        // stray staging file may survive the failure.
274        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}