Skip to main content

clawft_kernel/model_manifest/
resolve.rs

1//! Trust, resolution and consistency for [`ModelRegistry`] entries: what a
2//! caller may rely on when `resolve` returns, the lazy-check window, and the
3//! structural checks that guard a tampered registry file.
4
5use super::adopt::{FileRole, allowed_roots, contained};
6use super::body::{ModelError, ModelPackageBody, verify_model};
7use super::check::{CheckMode, ModelState};
8use super::registry::{LocalFile, ModelEntry, ModelRegistry, ModelTrust, ResolvedModel};
9use crate::workload_pkg::codec::hex_encode;
10
11impl ModelRegistry {
12    /// Hide or advertise a model in node facts (local, unsigned opt-out:
13    /// shard hashes fingerprint which models this node holds).
14    pub fn set_advertised(&self, id_or_name: &str, advertised: bool) -> Result<(), ModelError> {
15        let mut g = self.lock();
16        let id = Self::find(&g, id_or_name)?;
17        if let Some(e) = g.models.get_mut(&id) {
18            e.hidden = !advertised;
19        }
20        self.save(&g)
21    }
22
23    /// The trust problem with an entry, if any: its signature no longer
24    /// verifies against the pinned anchors (key removed, body edited), its
25    /// package id is not the registry key, its files disagree with its body,
26    /// or the package, a signer or a shard hash is revoked.
27    pub(crate) fn trust_problem(&self, id: &str, entry: &ModelEntry, trust: &ModelTrust) -> Option<String> {
28        if let Err(why) = consistency(id, entry) {
29            return Some(why);
30        }
31        let verified = match verify_model(&entry.envelope, &trust.anchors) {
32            Ok(v) => v,
33            Err(e) => return Some(format!("attestation does not verify: {e}")),
34        };
35        if verified.package_id != id {
36            return Some("package id differs from the registry key".into());
37        }
38        let list = trust.revocations.as_ref()?;
39        let signers: Vec<String> = verified
40            .signers
41            .iter()
42            .filter_map(|s| trust.anchors.signers.iter().find(|k| k.key_id == s.key_id))
43            .map(|k| hex_encode(&k.public_key))
44            .collect();
45        let hashes: Vec<String> = verified.body.shard_hashes().map(String::from).collect();
46        list.first_revoked(Some(id), signers.iter(), hashes.iter())
47            .map(|r| format!("revoked: {r:?}"))
48    }
49
50    /// True when a trust is configured and the entry fails it. Without a
51    /// configured trust this is false (advertising then relies on the
52    /// structural checks and the file checks alone; `resolve` still refuses).
53    pub(crate) fn untrusted(&self, id: &str, entry: &ModelEntry) -> bool {
54        let trust = self.trust.lock().unwrap_or_else(|p| p.into_inner()).clone();
55        match trust {
56            Some(t) => self.trust_problem(id, entry, &t).is_some(),
57            None => consistency(id, entry).is_err(),
58        }
59    }
60
61    /// The model for an adapter to load, verified against the registry's
62    /// trust (see [`with_trust`](Self::with_trust)); refused with
63    /// [`ModelError::NotReady`] when none is set.
64    ///
65    /// What a caller may rely on when this returns `Ok`:
66    /// - the attestation verifies against the pinned anchors now, and the
67    ///   package, its signers and its shard hashes are not revoked;
68    /// - the registry's file list equals the attested body;
69    /// - every file resolves inside the model root (or its HF `blobs/`),
70    ///   and the returned paths are those resolved locations;
71    /// - the first resolve of a model in this process hashed every byte
72    ///   (a full check, which on a 45 GB model takes minutes); later
73    ///   resolves re-hash only files whose size or mtime changed.
74    ///
75    /// The lazy window: after the first resolve, a file replaced with the
76    /// same size and the same mtime is not noticed until the next process
77    /// start or an explicit [`CheckMode::Full`] check. Adapters that must
78    /// close that window call `check(.., CheckMode::Full)` before loading.
79    /// Paths are returned, not handles: a file can still change between
80    /// this call and the adapter opening it.
81    pub fn resolve(&self, id_or_name: &str) -> Result<ResolvedModel, ModelError> {
82        let trust = self.trust.lock().unwrap_or_else(|p| p.into_inner()).clone();
83        let id = self.get(id_or_name).map(|(id, _)| id)?;
84        let Some(trust) = trust else {
85            return Err(ModelError::NotReady {
86                id,
87                reason: "the registry has no trust anchors configured".into(),
88            });
89        };
90        self.resolve_with(id_or_name, &trust)
91    }
92
93    /// [`resolve`](Self::resolve) against an explicit trust (the sharing
94    /// gate passes the anchors the caller holds right now).
95    pub fn resolve_with(&self, id_or_name: &str, trust: &ModelTrust) -> Result<ResolvedModel, ModelError> {
96        let (id, entry) = self.get(id_or_name)?;
97        let not_ready = |reason: String| ModelError::NotReady { id: id.clone(), reason };
98        if let Some(why) = self.trust_problem(&id, &entry, trust) {
99            return Err(not_ready(format!("untrusted: {why}")));
100        }
101        let first = !self
102            .fully_verified
103            .lock()
104            .unwrap_or_else(|p| p.into_inner())
105            .contains(&id);
106        let mode = if first { CheckMode::Full } else { CheckMode::Lazy };
107        let check = self.check(&id, mode)?;
108        let entry = self.get(&id)?.1;
109        if let Some(why) = &entry.refused {
110            return Err(not_ready(format!("refused: {why}")));
111        }
112        match &check.state {
113            ModelState::Ready => {}
114            ModelState::Degraded { reason, .. } => return Err(not_ready(format!("degraded: {reason}"))),
115            ModelState::Refused { reason } => return Err(not_ready(format!("refused: {reason}"))),
116        }
117        if first {
118            self.fully_verified
119                .lock()
120                .unwrap_or_else(|p| p.into_inner())
121                .insert(id.clone());
122        }
123        let body = entry.body()?;
124        let allowed = allowed_roots(&entry.root);
125        let real = |rel: &str| contained(&entry.root.join(rel), &allowed);
126        let shards = body
127            .shards
128            .iter()
129            .map(|s| real(&s.path))
130            .collect::<Result<Vec<_>, _>>()?;
131        let tokenizer = body.tokenizer_path.as_deref().map(real).transpose()?;
132        Ok(ResolvedModel {
133            package_id: id,
134            name: body.name.clone(),
135            format: body.format,
136            shards,
137            root: entry.root.clone(),
138            tokenizer,
139            body,
140        })
141    }
142}
143
144/// The local file list a body implies: shards, plus tokenizer and template.
145pub(crate) fn files_of_body(body: &ModelPackageBody) -> Vec<LocalFile> {
146    let mut v: Vec<LocalFile> = body
147        .shards
148        .iter()
149        .map(|s| LocalFile {
150            path: s.path.clone(),
151            role: FileRole::Shard,
152            size: s.size,
153            blake3: s.blake3.clone(),
154            verified: None,
155        })
156        .collect();
157    for (path, hash, role) in [
158        (&body.tokenizer_path, &body.tokenizer_blake3, FileRole::Tokenizer),
159        (&body.template_path, &body.template_blake3, FileRole::Template),
160    ] {
161        if let (Some(p), Some(h)) = (path, hash) {
162            v.push(LocalFile {
163                path: p.clone(),
164                role,
165                size: 0,
166                blake3: h.clone(),
167                verified: None,
168            });
169        }
170    }
171    v
172}
173
174/// Structural consistency: the entry's key is its envelope's package id and
175/// its file list is exactly what the attested body lists (sizes of shards
176/// and every hash). Needs no anchors, so it also guards `open`.
177pub(crate) fn consistency(id: &str, e: &ModelEntry) -> Result<(), String> {
178    let pid = e.envelope.package_id().map_err(|x| x.to_string())?;
179    if pid != id {
180        return Err("registry key is not the envelope's package id".into());
181    }
182    let body = e.body().map_err(|x| x.to_string())?;
183    body.validate().map_err(|x| x.to_string())?;
184    let want = files_of_body(&body);
185    if want.len() != e.files.len() {
186        return Err("file list differs from the attested body".into());
187    }
188    for (w, f) in want.iter().zip(&e.files) {
189        let size_ok = w.role != FileRole::Shard || w.size == f.size;
190        if w.path != f.path || w.role != f.role || w.blake3 != f.blake3 || !size_ok {
191            return Err(format!("file {} differs from the attested body", f.path));
192        }
193    }
194    Ok(())
195}