clawft_kernel/model_manifest/
body.rs1use serde::{Deserialize, Serialize};
11use serde_json::json;
12
13use crate::workload_pkg::codec::is_lower_hex;
14use crate::workload_pkg::manifest::{valid_token, validate_rel_path};
15use crate::workload_pkg::verify::{AcceptedSigner, VerifyError, check_signatures};
16use crate::workload_pkg::{
17 FileRef, ManifestEnvelope, ManifestError, TrustAnchors, sign_envelope,
18};
19
20pub const KIND_MODEL: &str = "model";
22pub const MAX_SHARDS: usize = 255;
26pub const MODEL_MARKER_PREFIX: &str = "model:";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ModelFormat {
33 Mlx,
35 Gguf,
37 Safetensors,
39 Ollama,
41}
42
43impl ModelFormat {
44 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Mlx => "mlx",
48 Self::Gguf => "gguf",
49 Self::Safetensors => "safetensors",
50 Self::Ollama => "ollama",
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ModelSource {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub hf_repo: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub hf_revision: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub ollama_tag: Option<String>,
68}
69
70impl ModelSource {
71 fn validate(&self) -> Result<(), ManifestError> {
72 if self.hf_repo.is_none() && self.ollama_tag.is_none() {
73 return Err(invalid("source needs an hf_repo or an ollama_tag"));
74 }
75 if self.hf_revision.is_some() && self.hf_repo.is_none() {
76 return Err(invalid("hf_revision needs an hf_repo"));
77 }
78 for s in [&self.hf_repo, &self.hf_revision, &self.ollama_tag]
79 .into_iter()
80 .flatten()
81 {
82 if s.is_empty() || s.len() > 256 || s.chars().any(|c| c.is_control() || c == ' ') {
83 return Err(invalid("source strings must be 1..256 printable chars"));
84 }
85 }
86 Ok(())
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct ModelPackageBody {
94 pub name: String,
97 pub format: ModelFormat,
99 pub shards: Vec<FileRef>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub tokenizer_blake3: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub tokenizer_path: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub template_blake3: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub template_path: Option<String>,
116 pub source: ModelSource,
118 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122 pub redistributable: bool,
123}
124
125fn invalid(msg: impl Into<String>) -> ManifestError {
126 ManifestError::Invalid(msg.into())
127}
128
129impl ModelPackageBody {
130 pub fn validate(&self) -> Result<(), ManifestError> {
132 if !valid_token(&self.name, 64) {
133 return Err(invalid(format!("bad model name {:?}", self.name)));
134 }
135 if self.shards.is_empty() || self.shards.len() > MAX_SHARDS {
136 return Err(invalid(format!("a model lists 1..={MAX_SHARDS} shards")));
137 }
138 for s in &self.shards {
139 validate_rel_path(&s.path)?;
140 if !is_lower_hex(&s.blake3, 64) {
141 return Err(invalid(format!("{}: blake3 must be 64 lower-case hex", s.path)));
142 }
143 }
144 if self.shards.windows(2).any(|w| w[0].path >= w[1].path) {
145 return Err(invalid("shards must be sorted by path with no duplicates"));
146 }
147 for h in [&self.tokenizer_blake3, &self.template_blake3].into_iter().flatten() {
148 if !is_lower_hex(h, 64) {
149 return Err(invalid("tokenizer/template hashes are 64 lower-case hex"));
150 }
151 }
152 for (hash, path) in [
153 (&self.tokenizer_blake3, &self.tokenizer_path),
154 (&self.template_blake3, &self.template_path),
155 ] {
156 match (hash, path) {
157 (None, None) => {}
158 (Some(_), Some(p)) => validate_rel_path(p)?,
159 _ => return Err(invalid("a tokenizer/template hash and path come together")),
160 }
161 }
162 self.source.validate()
163 }
164
165 pub fn total_bytes(&self) -> u64 {
167 self.shards.iter().map(|s| s.size).fold(0, u64::saturating_add)
168 }
169
170 pub fn shard_hashes(&self) -> impl Iterator<Item = &str> {
172 self.shards.iter().map(|s| s.blake3.as_str())
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct VerifiedModel {
179 pub package_id: String,
181 pub envelope: ManifestEnvelope,
183 pub body: ModelPackageBody,
185 pub signers: Vec<AcceptedSigner>,
187}
188
189#[derive(Debug, thiserror::Error)]
191pub enum ModelError {
192 #[error(transparent)]
194 Manifest(#[from] ManifestError),
195 #[error(transparent)]
197 Verify(#[from] VerifyError),
198 #[error("{path}: {msg}")]
200 Io {
201 path: String,
203 msg: String,
205 },
206 #[error("{path}: hash mismatch (expected {expected}, found {actual})")]
208 HashMismatch {
209 path: String,
211 expected: String,
213 actual: String,
215 },
216 #[error("model name {0:?} is already adopted with a different manifest")]
218 NameConflict(String),
219 #[error("no adopted model {0:?}")]
221 Unknown(String),
222 #[error("model {id} is not ready: {reason}")]
224 NotReady {
225 id: String,
227 reason: String,
229 },
230 #[error("model registry: {0}")]
232 Registry(String),
233 #[error("{0}")]
235 Scan(String),
236}
237
238pub fn attest(
242 body: &ModelPackageBody,
243 key: &ed25519_dalek::SigningKey,
244 key_id: &str,
245) -> Result<ManifestEnvelope, ModelError> {
246 body.validate()?;
247 let value = serde_json::to_value(body).map_err(|e| ManifestError::Parse(e.to_string()))?;
248 let mut env = ManifestEnvelope::new(KIND_MODEL, json!(value));
249 sign_envelope(&mut env, key, key_id)?;
250 Ok(env)
251}
252
253pub fn verify_model(
257 envelope: &ManifestEnvelope,
258 anchors: &TrustAnchors,
259) -> Result<VerifiedModel, ModelError> {
260 if envelope.kind != KIND_MODEL {
261 return Err(invalid(format!("kind {:?} is not a model", envelope.kind)).into());
262 }
263 let body: ModelPackageBody = serde_json::from_value(envelope.body.clone())
264 .map_err(|e| ManifestError::Parse(format!("model body: {e}")))?;
265 body.validate()?;
266 let signers = check_signatures(envelope, anchors)?;
267 if signers.is_empty() {
268 return Err(if envelope.signatures.is_empty() {
269 VerifyError::MissingSignature
270 } else {
271 VerifyError::UntrustedSigner {
272 key_ids: envelope
273 .signatures
274 .iter()
275 .map(|s| s.key_id.as_str())
276 .collect::<Vec<_>>()
277 .join(","),
278 }
279 }
280 .into());
281 }
282 Ok(VerifiedModel {
283 package_id: envelope.package_id()?,
284 envelope: envelope.clone(),
285 body,
286 signers,
287 })
288}