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