Skip to main content

rill_runtime/
package.rs

1//! Signed model-pack (`.rillpack`) construction and verification.
2
3use 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
15const MODEL_PATH: &str = "model.json";
16
17const MODEL_PACK_LIMITS: ArchiveLimits = ArchiveLimits {
18    max_files: 8,
19    max_file_bytes: 256 * 1024,
20    max_total_bytes: 1024 * 1024,
21    max_compressed_total_bytes: 8 * 1024 * 1024,
22    max_compression_ratio: 100,
23};
24
25const MODEL_PACK_ALLOWED: &[&str] = &[
26    "manifest.json",
27    MODEL_PATH,
28    "checksums.json",
29    "META-INF/signature.ed25519",
30];
31
32#[derive(Debug, Clone)]
33pub struct LoadedModelPack {
34    pub manifest: ModelPackManifest,
35    pub model: serde_json::Value,
36}
37
38#[derive(Debug, Clone, Serialize)]
39#[serde(rename_all = "camelCase")]
40pub struct ModelPackInspection {
41    pub id: String,
42    pub version: String,
43    pub publisher_key_id: String,
44    pub runtime_api_version: u32,
45    pub capabilities: Vec<String>,
46    pub signature_verified: bool,
47}
48
49#[derive(Debug, Error)]
50#[non_exhaustive]
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 crate::archive::{ReleaseIndexError, sign_release_index, verify_release_index};
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                target_libc: None,
188                handler_api_version: None,
189                min_runtime_version: None,
190                pm_adapter_protocol_version: None,
191                url: "https://example.invalid/rill-runtime".into(),
192                sha256: "ab".repeat(32),
193                size: 1024,
194            }],
195        };
196        let mut index = sign_release_index(payload, &signing).unwrap();
197        let trust = TrustStore(BTreeMap::from([(
198            "release-test".into(),
199            signing.verifying_key(),
200        )]));
201        verify_release_index(&index, &trust).unwrap();
202        index.payload.artifacts[0].sha256 = "cd".repeat(32);
203        assert!(matches!(
204            verify_release_index(&index, &trust),
205            Err(ReleaseIndexError::Signature)
206        ));
207    }
208
209    #[test]
210    fn release_index_supports_handler_artifact() {
211        use rill_runtime_protocol::{
212            HANDLER_API_VERSION, RELEASE_INDEX_SCHEMA_VERSION, ReleaseArtifact,
213            ReleaseArtifactKind, ReleaseIndexPayload,
214        };
215
216        let signing = SigningKey::from_bytes(&[9; 32]);
217        let payload = ReleaseIndexPayload {
218            schema_version: RELEASE_INDEX_SCHEMA_VERSION,
219            channel: "stable".into(),
220            generated_at: "2026-07-15T00:00:00Z".into(),
221            publisher_key_id: "release-test".into(),
222            artifacts: vec![ReleaseArtifact {
223                kind: ReleaseArtifactKind::Handler,
224                id: "org.example.handler".into(),
225                version: "1.0.0".into(),
226                runtime_api_version: RUNTIME_API_VERSION,
227                target_os: None,
228                target_arch: None,
229                target_libc: None,
230                handler_api_version: Some(HANDLER_API_VERSION),
231                min_runtime_version: Some("0.7.0".into()),
232                pm_adapter_protocol_version: None,
233                url: "https://example.invalid/handler.wasm".into(),
234                sha256: "ef".repeat(32),
235                size: 2048,
236            }],
237        };
238        let index = sign_release_index(payload, &signing).unwrap();
239        let trust = TrustStore(BTreeMap::from([(
240            "release-test".into(),
241            signing.verifying_key(),
242        )]));
243        assert!(verify_release_index(&index, &trust).is_ok());
244    }
245
246    // ----- R-021: compatibility tests -----
247
248    #[test]
249    fn release_index_rejects_v1_schema() {
250        // A release index using the legacy v1 schema (schema_version=1) must be
251        // rejected. Only schema_version=3 (RELEASE_INDEX_SCHEMA_VERSION) is
252        // accepted by the current runtime.
253        use rill_runtime_protocol::{
254            RUNTIME_API_VERSION, RUNTIME_ARTIFACT_ID, ReleaseArtifact, ReleaseArtifactKind,
255            ReleaseIndexPayload,
256        };
257
258        let signing = SigningKey::from_bytes(&[42; 32]);
259        let payload = ReleaseIndexPayload {
260            schema_version: 1, // legacy v1 schema
261            channel: "stable".into(),
262            generated_at: "2026-07-15T00:00:00Z".into(),
263            publisher_key_id: "v1-schema-test".into(),
264            artifacts: vec![ReleaseArtifact {
265                kind: ReleaseArtifactKind::Runtime,
266                id: RUNTIME_ARTIFACT_ID.into(),
267                version: "0.6.0".into(),
268                runtime_api_version: RUNTIME_API_VERSION,
269                target_os: Some("macos".into()),
270                target_arch: Some("aarch64".into()),
271                target_libc: None,
272                handler_api_version: None,
273                min_runtime_version: None,
274                pm_adapter_protocol_version: None,
275                url: "https://example.invalid/rill-runtime".into(),
276                sha256: "ab".repeat(32),
277                size: 1024,
278            }],
279        };
280        // sign_release_index calls validate_release_payload which rejects v1.
281        assert!(sign_release_index(payload, &signing).is_err());
282    }
283}