Skip to main content

cyberbrain_embed/
artefact.rs

1//! Local model artefacts and their verification (SPEC §6: "content-addressed by hash,
2//! verified on every load; a hash mismatch is a hard failure").
3//!
4//! Nothing in this module can reach the network. It takes paths and returns bytes.
5
6use cyberbrain_core::{Error, Result, Slash};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10/// Length of a blake3 digest rendered as lowercase hex.
11const HEX_LEN: usize = 64;
12
13/// Where the two files of a model2vec-format model live.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ModelPaths {
16    /// `model.safetensors`: one tensor named `embeddings`, shape `[vocab, dim]`.
17    pub weights: PathBuf,
18    /// `tokenizer.json` in HuggingFace tokenizers format.
19    pub tokenizer: PathBuf,
20}
21
22impl ModelPaths {
23    /// The conventional layout: `<dir>/model.safetensors` and `<dir>/tokenizer.json`.
24    pub fn in_dir(dir: impl AsRef<Path>) -> Self {
25        let dir = dir.as_ref();
26        Self {
27            weights: dir.join("model.safetensors"),
28            tokenizer: dir.join("tokenizer.json"),
29        }
30    }
31}
32
33/// The expected blake3 digests of both files, lowercase hex. Produced once by whoever
34/// obtained the artefact (the policy crate, after its registered download) and stored in
35/// configuration; checked on every load.
36///
37/// Both files are covered because both change the meaning of a vector: a different
38/// tokenizer maps the same text to different rows of the same matrix.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct ArtefactManifest {
41    pub weights_blake3: String,
42    pub tokenizer_blake3: String,
43}
44
45impl ArtefactManifest {
46    /// Rejects anything that is not exactly 64 lowercase hex characters per digest. A
47    /// manifest that cannot match anything is a configuration error, not a load failure.
48    pub fn validate(&self) -> Result<()> {
49        for (what, hex) in [
50            ("weights_blake3", &self.weights_blake3),
51            ("tokenizer_blake3", &self.tokenizer_blake3),
52        ] {
53            if hex.len() != HEX_LEN
54                || !hex
55                    .bytes()
56                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
57            {
58                return Err(Error::Config(format!(
59                    "model manifest: {what} must be {HEX_LEN} lowercase hex characters, \
60                     got {:?}",
61                    hex
62                )));
63            }
64        }
65        Ok(())
66    }
67}
68
69/// blake3 of a byte slice, lowercase hex.
70pub fn hash_bytes(bytes: &[u8]) -> String {
71    blake3::hash(bytes).to_hex().to_string()
72}
73
74/// blake3 of a file's contents, lowercase hex. This is what the policy crate calls after a
75/// download to fill an [`ArtefactManifest`].
76pub fn hash_file(path: impl AsRef<Path>) -> Result<String> {
77    let bytes = read(path.as_ref())?;
78    Ok(hash_bytes(&bytes))
79}
80
81pub(crate) fn read(path: &Path) -> Result<Vec<u8>> {
82    std::fs::read(path).map_err(|source| Error::Io {
83        path: path.to_path_buf(),
84        source,
85    })
86}
87
88/// Reads a file and verifies its digest. Returns the bytes only when they match, so the
89/// bytes that were hashed are the bytes that get parsed: no window between check and use.
90pub(crate) fn read_verified(path: &Path, expected_hex: &str, what: &str) -> Result<Vec<u8>> {
91    let bytes = read(path)?;
92    let actual = hash_bytes(&bytes);
93    if actual != expected_hex {
94        return Err(Error::Embed(format!(
95            "{what} artefact {} does not match its manifest: expected blake3 {expected_hex}, \
96             file has {actual}; refusing to load (SPEC §6: a hash mismatch is a hard failure)",
97            Slash(path)
98        )));
99    }
100    Ok(bytes)
101}
102
103/// Name of the verification record next to the model files.
104pub const VERIFIED_FILE: &str = ".verified";
105
106/// How old a file's mtime must be before its stamp is recorded (see `record_verified`).
107const RACY_NS: u128 = 2_000_000_000;
108
109/// What identifies a file on disk without reading it: length, modification time and inode.
110#[derive(Debug, Clone, PartialEq, Eq)]
111struct Stamp {
112    len: u64,
113    mtime_ns: u128,
114    ino: u64,
115}
116
117fn stamp(path: &Path) -> Option<Stamp> {
118    let meta = std::fs::metadata(path).ok()?;
119    let mtime_ns = meta
120        .modified()
121        .ok()?
122        .duration_since(std::time::UNIX_EPOCH)
123        .ok()?
124        .as_nanos();
125    #[cfg(unix)]
126    let ino = std::os::unix::fs::MetadataExt::ino(&meta);
127    #[cfg(not(unix))]
128    let ino = 0;
129    Some(Stamp {
130        len: meta.len(),
131        mtime_ns,
132        ino,
133    })
134}
135
136/// Maps a file and makes sure it is the one the manifest names.
137///
138/// SPEC §6.5: "verification is staleness-checked, not repeated: a digest is recomputed only
139/// when size or mtime changed". Until 2026-09-25 every load read and hashed the whole file
140/// (512 MB, 0.39 s). Now a file whose length, mtime and inode match the `.verified` record,
141/// AND whose recorded digest is the one the manifest expects, is taken as verified without
142/// being read. Anything else is hashed in full, on every core. The second value says which
143/// happened: `true` means the file passed a full load before and needs no content checks.
144///
145/// What this trades: a file rewritten in place with the same length and a restored mtime
146/// would pass. That takes deliberate work by somebody who can also edit the manifest, which
147/// is where the trust already sits. Replacing a model the ordinary way — new files, new
148/// manifest — changes the inode or the digest and is caught. The accidental version of the
149/// same thing, a rewrite within one mtime tick, is closed by `RACY_NS` in `record_verified`.
150pub(crate) fn map_verified(
151    path: &Path,
152    expected_hex: &str,
153    what: &str,
154) -> Result<(memmap2::Mmap, bool)> {
155    let file = std::fs::File::open(path).map_err(|source| Error::Io {
156        path: path.to_path_buf(),
157        source,
158    })?;
159    // SAFETY: the mapping is read-only. A model file truncated underneath a running process
160    // would fault on access; model files are replaced, not rewritten, and the manifest
161    // names the digest that a replacement must carry.
162    let map = unsafe { memmap2::Mmap::map(&file) }.map_err(|source| Error::Io {
163        path: path.to_path_buf(),
164        source,
165    })?;
166    if recorded(path).is_some_and(|d| d == expected_hex) {
167        return Ok((map, true));
168    }
169    let actual = blake3::Hasher::new()
170        .update_rayon(&map)
171        .finalize()
172        .to_hex()
173        .to_string();
174    if actual != expected_hex {
175        return Err(Error::Embed(format!(
176            "{what} artefact {} does not match its manifest: expected blake3 {expected_hex}, \
177             file has {actual}; refusing to load (SPEC §6: a hash mismatch is a hard failure)",
178            Slash(path)
179        )));
180    }
181    Ok((map, false))
182}
183
184/// The digest recorded for this file, if its stamp still matches.
185fn recorded(path: &Path) -> Option<String> {
186    let name = path.file_name()?.to_str()?;
187    let now = stamp(path)?;
188    let text = std::fs::read_to_string(path.with_file_name(VERIFIED_FILE)).ok()?;
189    text.lines().find_map(|line| {
190        let f: Vec<&str> = line.split('\t').collect();
191        let [n, digest, len, mtime, ino] = f.as_slice() else {
192            return None;
193        };
194        let then = Stamp {
195            len: len.parse().ok()?,
196            mtime_ns: mtime.parse().ok()?,
197            ino: ino.parse().ok()?,
198        };
199        (*n == name && then == now && digest.len() == HEX_LEN).then(|| digest.to_string())
200    })
201}
202
203/// Records that `path`, as it is now, passed a full load with this digest. Best effort: a
204/// model directory the process may not write to only means the next load verifies again.
205pub(crate) fn record_verified(path: &Path, digest: &str) {
206    let (Some(name), Some(now)) = (path.file_name().and_then(|n| n.to_str()), stamp(path)) else {
207        return;
208    };
209    // Racy files are not recorded (the problem git calls "racily clean"). The kernel keeps
210    // mtime at a coarse tick of a few milliseconds, so a file rewritten in place right after
211    // it was loaded can keep the very stamp the record holds — the embed tests did exactly
212    // that on the first run. A file must be older than this before a stamp can stand for its
213    // contents; until then every load hashes it in full.
214    let since = std::time::SystemTime::now()
215        .duration_since(std::time::UNIX_EPOCH)
216        .map(|d| d.as_nanos())
217        .unwrap_or(0);
218    if since.saturating_sub(now.mtime_ns) < RACY_NS {
219        return;
220    }
221    let record = path.with_file_name(VERIFIED_FILE);
222    let mut lines: Vec<String> = std::fs::read_to_string(&record)
223        .unwrap_or_default()
224        .lines()
225        .filter(|l| l.split('\t').next() != Some(name))
226        .map(str::to_string)
227        .collect();
228    lines.push(format!(
229        "{name}\t{digest}\t{}\t{}\t{}",
230        now.len, now.mtime_ns, now.ino
231    ));
232    let tmp = record.with_extension("tmp");
233    if std::fs::write(&tmp, lines.join("\n") + "\n").is_ok() {
234        let _ = std::fs::rename(&tmp, &record);
235    }
236}