use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::workload_pkg::codec::is_lower_hex;
use crate::workload_pkg::manifest::{valid_token, validate_rel_path};
use crate::workload_pkg::verify::{AcceptedSigner, VerifyError, check_signatures};
use crate::workload_pkg::{
FileRef, ManifestEnvelope, ManifestError, TrustAnchors, sign_envelope,
};
pub const KIND_MODEL: &str = "model";
pub const MAX_SHARDS: usize = 255;
pub const MODEL_MARKER_PREFIX: &str = "model:";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelFormat {
Mlx,
Gguf,
Safetensors,
Ollama,
}
impl ModelFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Mlx => "mlx",
Self::Gguf => "gguf",
Self::Safetensors => "safetensors",
Self::Ollama => "ollama",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelSource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hf_repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hf_revision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ollama_tag: Option<String>,
}
impl ModelSource {
fn validate(&self) -> Result<(), ManifestError> {
if self.hf_repo.is_none() && self.ollama_tag.is_none() {
return Err(invalid("source needs an hf_repo or an ollama_tag"));
}
if self.hf_revision.is_some() && self.hf_repo.is_none() {
return Err(invalid("hf_revision needs an hf_repo"));
}
for s in [&self.hf_repo, &self.hf_revision, &self.ollama_tag]
.into_iter()
.flatten()
{
if s.is_empty() || s.len() > 256 || s.chars().any(|c| c.is_control() || c == ' ') {
return Err(invalid("source strings must be 1..256 printable chars"));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelPackageBody {
pub name: String,
pub format: ModelFormat,
pub shards: Vec<FileRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokenizer_blake3: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokenizer_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_blake3: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_path: Option<String>,
pub source: ModelSource,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub redistributable: bool,
}
fn invalid(msg: impl Into<String>) -> ManifestError {
ManifestError::Invalid(msg.into())
}
impl ModelPackageBody {
pub fn validate(&self) -> Result<(), ManifestError> {
if !valid_token(&self.name, 64) {
return Err(invalid(format!("bad model name {:?}", self.name)));
}
if self.shards.is_empty() || self.shards.len() > MAX_SHARDS {
return Err(invalid(format!("a model lists 1..={MAX_SHARDS} shards")));
}
for s in &self.shards {
validate_rel_path(&s.path)?;
if !is_lower_hex(&s.blake3, 64) {
return Err(invalid(format!("{}: blake3 must be 64 lower-case hex", s.path)));
}
}
if self.shards.windows(2).any(|w| w[0].path >= w[1].path) {
return Err(invalid("shards must be sorted by path with no duplicates"));
}
for h in [&self.tokenizer_blake3, &self.template_blake3].into_iter().flatten() {
if !is_lower_hex(h, 64) {
return Err(invalid("tokenizer/template hashes are 64 lower-case hex"));
}
}
for (hash, path) in [
(&self.tokenizer_blake3, &self.tokenizer_path),
(&self.template_blake3, &self.template_path),
] {
match (hash, path) {
(None, None) => {}
(Some(_), Some(p)) => validate_rel_path(p)?,
_ => return Err(invalid("a tokenizer/template hash and path come together")),
}
}
self.source.validate()
}
pub fn total_bytes(&self) -> u64 {
self.shards.iter().map(|s| s.size).fold(0, u64::saturating_add)
}
pub fn shard_hashes(&self) -> impl Iterator<Item = &str> {
self.shards.iter().map(|s| s.blake3.as_str())
}
}
#[derive(Debug, Clone)]
pub struct VerifiedModel {
pub package_id: String,
pub envelope: ManifestEnvelope,
pub body: ModelPackageBody,
pub signers: Vec<AcceptedSigner>,
}
#[derive(Debug, thiserror::Error)]
pub enum ModelError {
#[error(transparent)]
Manifest(#[from] ManifestError),
#[error(transparent)]
Verify(#[from] VerifyError),
#[error("{path}: {msg}")]
Io {
path: String,
msg: String,
},
#[error("{path}: hash mismatch (expected {expected}, found {actual})")]
HashMismatch {
path: String,
expected: String,
actual: String,
},
#[error("model name {0:?} is already adopted with a different manifest")]
NameConflict(String),
#[error("no adopted model {0:?}")]
Unknown(String),
#[error("model {id} is not ready: {reason}")]
NotReady {
id: String,
reason: String,
},
#[error("model registry: {0}")]
Registry(String),
#[error("{0}")]
Scan(String),
}
pub fn attest(
body: &ModelPackageBody,
key: &ed25519_dalek::SigningKey,
key_id: &str,
) -> Result<ManifestEnvelope, ModelError> {
body.validate()?;
let value = serde_json::to_value(body).map_err(|e| ManifestError::Parse(e.to_string()))?;
let mut env = ManifestEnvelope::new(KIND_MODEL, json!(value));
sign_envelope(&mut env, key, key_id)?;
Ok(env)
}
pub fn verify_model(
envelope: &ManifestEnvelope,
anchors: &TrustAnchors,
) -> Result<VerifiedModel, ModelError> {
if envelope.kind != KIND_MODEL {
return Err(invalid(format!("kind {:?} is not a model", envelope.kind)).into());
}
let body: ModelPackageBody = serde_json::from_value(envelope.body.clone())
.map_err(|e| ManifestError::Parse(format!("model body: {e}")))?;
body.validate()?;
let signers = check_signatures(envelope, anchors)?;
if signers.is_empty() {
return Err(if envelope.signatures.is_empty() {
VerifyError::MissingSignature
} else {
VerifyError::UntrustedSigner {
key_ids: envelope
.signatures
.iter()
.map(|s| s.key_id.as_str())
.collect::<Vec<_>>()
.join(","),
}
}
.into());
}
Ok(VerifiedModel {
package_id: envelope.package_id()?,
envelope: envelope.clone(),
body,
signers,
})
}