1use std::io::{Read, Seek};
4
5use ed25519_dalek::SigningKey;
6use rill_runtime_protocol::ModelPackManifest;
7use serde::Serialize;
8use thiserror::Error;
9
10use crate::archive::{
11 ArchiveError, ArchiveLimits, DEFAULT_PATHS, TrustStore, build_signed_archive, canonical_json,
12 read_archive, verify_checksums_and_signature,
13};
14
15pub use crate::archive::{ReleaseIndexError, sign_release_index, verify_release_index};
16
17const MODEL_PATH: &str = "model.json";
18
19const MODEL_PACK_LIMITS: ArchiveLimits = ArchiveLimits {
20 max_files: 8,
21 max_file_bytes: 256 * 1024,
22 max_total_bytes: 1024 * 1024,
23 max_compression_ratio: 100,
24};
25
26const MODEL_PACK_ALLOWED: &[&str] = &[
27 "manifest.json",
28 MODEL_PATH,
29 "checksums.json",
30 "META-INF/signature.ed25519",
31];
32
33#[derive(Debug, Clone)]
34pub struct LoadedModelPack {
35 pub manifest: ModelPackManifest,
36 pub model: serde_json::Value,
37}
38
39#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ModelPackInspection {
42 pub id: String,
43 pub version: String,
44 pub publisher_key_id: String,
45 pub runtime_api_version: u32,
46 pub capabilities: Vec<String>,
47 pub signature_verified: bool,
48}
49
50#[derive(Debug, Error)]
51pub enum ModelPackError {
52 #[error(transparent)]
53 Archive(#[from] ArchiveError),
54 #[error(transparent)]
55 Json(#[from] serde_json::Error),
56 #[error("invalid model manifest: {0}")]
57 Manifest(String),
58 #[error("runtime {actual} is older than model requirement {minimum}")]
59 RuntimeTooOld { minimum: String, actual: String },
60}
61
62pub fn load_model_pack<R: Read + Seek>(
63 reader: R,
64 trust: &TrustStore,
65) -> Result<(LoadedModelPack, ModelPackInspection), ModelPackError> {
66 let files = read_archive(reader, MODEL_PACK_ALLOWED, MODEL_PACK_LIMITS)?;
67 let manifest_bytes = files
68 .get(DEFAULT_PATHS.manifest)
69 .ok_or(ArchiveError::Missing(DEFAULT_PATHS.manifest))?;
70 let manifest: ModelPackManifest = serde_json::from_slice(manifest_bytes)?;
71 manifest
72 .validate_shape()
73 .map_err(|message| ModelPackError::Manifest(message.into()))?;
74 semver::Version::parse(&manifest.version)
75 .map_err(|error| ModelPackError::Manifest(format!("invalid pack version: {error}")))?;
76 let minimum = semver::Version::parse(&manifest.min_runtime_version)
77 .map_err(|error| ModelPackError::Manifest(format!("invalid minimum runtime: {error}")))?;
78 let runtime = semver::Version::parse(env!("CARGO_PKG_VERSION"))
79 .map_err(|error| ModelPackError::Manifest(format!("invalid runtime version: {error}")))?;
80 if runtime < minimum {
81 return Err(ModelPackError::RuntimeTooOld {
82 minimum: minimum.to_string(),
83 actual: runtime.to_string(),
84 });
85 }
86 verify_checksums_and_signature(
87 &files,
88 &DEFAULT_PATHS,
89 &[DEFAULT_PATHS.manifest, MODEL_PATH],
90 &manifest.publisher_key_id,
91 trust,
92 )?;
93
94 let model: serde_json::Value = serde_json::from_slice(
95 files
96 .get(MODEL_PATH)
97 .ok_or(ArchiveError::Missing(MODEL_PATH))?,
98 )?;
99 let inspection = ModelPackInspection {
100 id: manifest.id.clone(),
101 version: manifest.version.clone(),
102 publisher_key_id: manifest.publisher_key_id.clone(),
103 runtime_api_version: manifest.runtime_api_version,
104 capabilities: manifest.capabilities.clone(),
105 signature_verified: true,
106 };
107 Ok((LoadedModelPack { manifest, model }, inspection))
108}
109
110pub fn build_signed_model_pack(
111 manifest: &ModelPackManifest,
112 model: &serde_json::Value,
113 signing_key: &SigningKey,
114) -> Result<Vec<u8>, ModelPackError> {
115 manifest
116 .validate_shape()
117 .map_err(|message| ModelPackError::Manifest(message.into()))?;
118 let manifest_bytes = serde_json::to_vec_pretty(manifest)?;
119 let model_bytes = serde_json::to_vec_pretty(model)?;
120 let _ = canonical_json(&manifest_bytes)?;
121 let archive = build_signed_archive(&manifest_bytes, MODEL_PATH, &model_bytes, signing_key)?;
122 Ok(archive)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use rill_runtime_protocol::{MODEL_PACK_FORMAT_VERSION, RUNTIME_API_VERSION};
129 use std::collections::BTreeMap;
130
131 fn manifest(key_id: &str) -> ModelPackManifest {
132 ModelPackManifest {
133 format_version: MODEL_PACK_FORMAT_VERSION,
134 id: "rillml.example.default".into(),
135 version: "0.7.0".into(),
136 runtime_api_version: RUNTIME_API_VERSION,
137 min_runtime_version: "0.7.0".into(),
138 publisher_key_id: key_id.into(),
139 capabilities: vec!["rillml.example".into()],
140 }
141 }
142
143 #[test]
144 fn signed_pack_roundtrip_and_tamper_rejection() {
145 let signing = SigningKey::from_bytes(&[7; 32]);
146 let key_id = "test-key";
147 let bytes = build_signed_model_pack(
148 &manifest(key_id),
149 &serde_json::json!({"description": "test"}),
150 &signing,
151 )
152 .unwrap();
153 let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
154 let (loaded, inspection) = load_model_pack(std::io::Cursor::new(&bytes), &trust).unwrap();
155 assert_eq!(loaded.manifest.id, "rillml.example.default");
156 assert!(inspection.signature_verified);
157
158 let wrong = SigningKey::from_bytes(&[8; 32]);
159 let wrong_trust = TrustStore(BTreeMap::from([(key_id.into(), wrong.verifying_key())]));
160 assert!(matches!(
161 load_model_pack(std::io::Cursor::new(bytes), &wrong_trust),
162 Err(ModelPackError::Archive(ArchiveError::Signature))
163 ));
164 }
165
166 #[test]
167 fn release_index_signature_covers_artifact_hashes() {
168 use rill_runtime_protocol::{
169 RELEASE_INDEX_SCHEMA_VERSION, RUNTIME_ARTIFACT_ID, ReleaseArtifact,
170 ReleaseArtifactKind, ReleaseIndexPayload,
171 };
172
173 let signing = SigningKey::from_bytes(&[6; 32]);
174 let payload = ReleaseIndexPayload {
175 schema_version: RELEASE_INDEX_SCHEMA_VERSION,
176 channel: "stable".into(),
177 generated_at: "2026-07-15T00:00:00Z".into(),
178 publisher_key_id: "release-test".into(),
179 artifacts: vec![ReleaseArtifact {
180 kind: ReleaseArtifactKind::Runtime,
181 id: RUNTIME_ARTIFACT_ID.into(),
182 version: "0.7.0".into(),
183 runtime_api_version: RUNTIME_API_VERSION,
184 target_os: Some("macos".into()),
185 target_arch: Some("aarch64".into()),
186 handler_api_version: None,
187 min_runtime_version: None,
188 url: "https://example.invalid/rill-runtime".into(),
189 sha256: "ab".repeat(32),
190 size: 1024,
191 }],
192 };
193 let mut index = sign_release_index(payload, &signing).unwrap();
194 let trust = TrustStore(BTreeMap::from([(
195 "release-test".into(),
196 signing.verifying_key(),
197 )]));
198 verify_release_index(&index, &trust).unwrap();
199 index.payload.artifacts[0].sha256 = "cd".repeat(32);
200 assert!(matches!(
201 verify_release_index(&index, &trust),
202 Err(ReleaseIndexError::Signature)
203 ));
204 }
205
206 #[test]
207 fn release_index_supports_handler_artifact() {
208 use rill_runtime_protocol::{
209 HANDLER_API_VERSION, RELEASE_INDEX_SCHEMA_VERSION, ReleaseArtifact,
210 ReleaseArtifactKind, ReleaseIndexPayload,
211 };
212
213 let signing = SigningKey::from_bytes(&[9; 32]);
214 let payload = ReleaseIndexPayload {
215 schema_version: RELEASE_INDEX_SCHEMA_VERSION,
216 channel: "stable".into(),
217 generated_at: "2026-07-15T00:00:00Z".into(),
218 publisher_key_id: "release-test".into(),
219 artifacts: vec![ReleaseArtifact {
220 kind: ReleaseArtifactKind::Handler,
221 id: "org.example.handler".into(),
222 version: "1.0.0".into(),
223 runtime_api_version: RUNTIME_API_VERSION,
224 target_os: None,
225 target_arch: None,
226 handler_api_version: Some(HANDLER_API_VERSION),
227 min_runtime_version: Some("0.7.0".into()),
228 url: "https://example.invalid/handler.wasm".into(),
229 sha256: "ef".repeat(32),
230 size: 2048,
231 }],
232 };
233 let index = sign_release_index(payload, &signing).unwrap();
234 let trust = TrustStore(BTreeMap::from([(
235 "release-test".into(),
236 signing.verifying_key(),
237 )]));
238 assert!(verify_release_index(&index, &trust).is_ok());
239 }
240}