Skip to main content

rill_runtime_protocol/
lib.rs

1//! Stable, versioned contracts shared by Rill Runtime and its hosts.
2//!
3//! ## IPC API versions
4//!
5//! | Version | Introduced in | Changes |
6//! |---|---|---|
7//! | 1 | 0.5.0 | Original handshake, health, invoke |
8//! | 2 | 0.7.0 | Handshake response gains handler identity and effective capabilities |
9//!
10//! The runtime accepts both v1 and v2 requests. v1 clients receive
11//! [`RuntimeResponse`] (no handler fields). v2 clients receive
12//! [`RuntimeResponseV2`] (with handler identity). The two wire schemas are
13//! independently frozen with fixture tests.
14
15use serde::{Deserialize, Serialize};
16
17/// Preview IPC v3 types. This module is independent from the frozen v1/v2
18/// request and response types below.
19pub mod v3;
20
21/// Minimum IPC API version the runtime still accepts.
22pub const MIN_RUNTIME_API_VERSION: u32 = 1;
23/// Latest IPC API version supported by this crate.
24pub const RUNTIME_API_VERSION: u32 = 2;
25/// Signed model-pack container version.
26pub const MODEL_PACK_FORMAT_VERSION: u32 = 1;
27/// Signed handler-pack container version.
28pub const HANDLER_PACKAGE_FORMAT_VERSION: u32 = 1;
29/// Handler ABI version (independent of IPC API version).
30pub const HANDLER_API_VERSION: u32 = 1;
31/// Persisted host/runtime state envelope version.
32pub const RUNTIME_STATE_FORMAT_VERSION: u32 = 1;
33/// Signed release-index schema understood by independent updaters.
34///
35/// v2 is the frozen stable schema. Linux GNU and musl runtime builds of the
36/// same OS+arch are distinguished by the stable artifact ``id`` (see
37/// [`RUNTIME_ARTIFACT_ID_MUSL`]) rather than by a new field, so the public
38/// struct is unchanged and the release-index wire contract stays v2.
39pub const RELEASE_INDEX_SCHEMA_VERSION: u32 = 2;
40/// Hard upper bound for one newline-delimited IPC message.
41pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
42
43/// Stable artifact id for the GNU (default) runtime build.
44pub const RUNTIME_ARTIFACT_ID: &str = "rill-runtime";
45/// Stable artifact id for the musl runtime build. The libc variant is part of
46/// the stable asset identity so gnu and musl builds of the same OS+arch do not
47/// collide in a v2 release index.
48pub const RUNTIME_ARTIFACT_ID_MUSL: &str = "rill-runtime-musl";
49
50// ---------------------------------------------------------------------------
51// Stable IPC error codes
52// ---------------------------------------------------------------------------
53
54/// Stable IPC error code constants.
55///
56/// Every `RuntimeResponse::Error` / `RuntimeResponseV2::Error` `code` field
57/// produced by the runtime is one of the constants in this module. The codes
58/// are frozen for the entire 1.x cycle: existing codes are never renamed, and
59/// new codes may only be added (additive).
60///
61/// The runtime constructs error responses exclusively from these constants.
62/// Hosts and clients may switch on the string values; the constants are
63/// exported so that downstream Rust code does not have to inline string
64/// literals.
65pub mod error_code {
66    /// Request body was not valid protocol JSON.
67    pub const INVALID_JSON: &str = "invalidJson";
68    /// `requestId` was missing, empty, or longer than 128 characters.
69    pub const INVALID_REQUEST_ID: &str = "invalidRequestId";
70    /// `apiVersion` was outside `[MIN_RUNTIME_API_VERSION, RUNTIME_API_VERSION]`.
71    pub const INCOMPATIBLE_API_VERSION: &str = "incompatibleApiVersion";
72    /// `clientName` / `clientVersion` failed length or emptiness checks.
73    pub const INVALID_CLIENT_IDENTITY: &str = "invalidClientIdentity";
74    /// `Invoke` capability is not in the effective capability set.
75    pub const UNSUPPORTED_CAPABILITY: &str = "unsupportedCapability";
76    /// `Invoke` was issued but no handler is registered.
77    pub const NO_INVOKE_HANDLER: &str = "noInvokeHandler";
78    /// Handler exceeded the wall-clock deadline. Retryable.
79    pub const HANDLER_TIMEOUT: &str = "handlerTimeout";
80    /// Handler trapped (unreachable, out-of-bounds, stack overflow, …).
81    pub const HANDLER_TRAP: &str = "handlerTrap";
82    /// Handler output exceeded the host-side size limit.
83    pub const HANDLER_OUTPUT_TOO_LARGE: &str = "handlerOutputTooLarge";
84    /// Handler output was not valid JSON.
85    pub const HANDLER_INVALID_OUTPUT: &str = "handlerInvalidOutput";
86    /// Handler reported an internal error (covers all four WIT
87    /// `handler-error` variants on the wire for backwards compatibility).
88    pub const HANDLER_INTERNAL_ERROR: &str = "handlerInternalError";
89
90    /// All frozen error codes in alphabetical order.
91    ///
92    /// This slice is used by tests and by the runtime's error-code allowlist
93    /// check. Adding a new code requires appending to this slice; the order
94    /// is part of the frozen surface so test fixtures remain stable.
95    pub const FROZEN_CODES: &[&str] = &[
96        HANDLER_INTERNAL_ERROR,
97        HANDLER_INVALID_OUTPUT,
98        HANDLER_OUTPUT_TOO_LARGE,
99        HANDLER_TIMEOUT,
100        HANDLER_TRAP,
101        INCOMPATIBLE_API_VERSION,
102        INVALID_CLIENT_IDENTITY,
103        INVALID_JSON,
104        INVALID_REQUEST_ID,
105        NO_INVOKE_HANDLER,
106        UNSUPPORTED_CAPABILITY,
107    ];
108
109    /// Returns `true` if `code` is one of the frozen 1.x error codes.
110    pub fn is_frozen(code: &str) -> bool {
111        FROZEN_CODES.contains(&code)
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Model pack manifest
117// ---------------------------------------------------------------------------
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
120#[serde(rename_all = "camelCase", deny_unknown_fields)]
121pub struct ModelPackManifest {
122    pub format_version: u32,
123    pub id: String,
124    pub version: String,
125    pub runtime_api_version: u32,
126    pub min_runtime_version: String,
127    pub publisher_key_id: String,
128    pub capabilities: Vec<String>,
129}
130
131impl ModelPackManifest {
132    pub fn validate_shape(&self) -> Result<(), &'static str> {
133        if self.format_version != MODEL_PACK_FORMAT_VERSION {
134            return Err("unsupported model-pack format version");
135        }
136        if self.runtime_api_version != RUNTIME_API_VERSION {
137            return Err("unsupported runtime API version");
138        }
139        if self.id.is_empty() || self.id.len() > 96 {
140            return Err("invalid model-pack id");
141        }
142        if self.version.is_empty() || self.version.len() > 48 {
143            return Err("invalid model-pack version");
144        }
145        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
146            return Err("invalid publisher key id");
147        }
148        Self::validate_capabilities(&self.capabilities)?;
149        Ok(())
150    }
151
152    pub fn validate_capabilities(capabilities: &[String]) -> Result<(), &'static str> {
153        if capabilities.is_empty() || capabilities.len() > 32 {
154            return Err("invalid capabilities list");
155        }
156        if capabilities
157            .iter()
158            .any(|capability| capability.is_empty() || capability.len() > 96)
159        {
160            return Err("invalid capability string");
161        }
162        let mut seen = std::collections::HashSet::new();
163        if !capabilities
164            .iter()
165            .all(|capability| seen.insert(capability.clone()))
166        {
167            return Err("duplicate capability");
168        }
169        Ok(())
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Handler pack manifest
175// ---------------------------------------------------------------------------
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
178#[serde(rename_all = "camelCase", deny_unknown_fields)]
179pub struct HandlerPackManifest {
180    pub format_version: u32,
181    pub id: String,
182    pub version: String,
183    pub handler_api_version: u32,
184    pub min_runtime_version: String,
185    pub publisher_key_id: String,
186    pub capabilities: Vec<String>,
187    pub module_sha256: String,
188    pub module_size: u64,
189}
190
191impl HandlerPackManifest {
192    pub fn validate_shape(&self) -> Result<(), &'static str> {
193        if self.format_version != HANDLER_PACKAGE_FORMAT_VERSION {
194            return Err("unsupported handler-pack format version");
195        }
196        if self.handler_api_version != HANDLER_API_VERSION {
197            return Err("unsupported handler API version");
198        }
199        if self.id.is_empty() || self.id.len() > 96 {
200            return Err("invalid handler id");
201        }
202        if self.version.is_empty() || self.version.len() > 48 {
203            return Err("invalid handler version");
204        }
205        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
206            return Err("invalid handler publisher key id");
207        }
208        if self.min_runtime_version.is_empty() || self.min_runtime_version.len() > 48 {
209            return Err("invalid minimum runtime version");
210        }
211        ModelPackManifest::validate_capabilities(&self.capabilities)?;
212        if self.module_sha256.len() != 64
213            || !self
214                .module_sha256
215                .bytes()
216                .all(|byte| byte.is_ascii_hexdigit())
217        {
218            return Err("invalid module SHA-256");
219        }
220        if self.module_size == 0 || self.module_size > 4 * 1024 * 1024 {
221            return Err("invalid module size");
222        }
223        Ok(())
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Release index
229// ---------------------------------------------------------------------------
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
232#[serde(rename_all = "camelCase")]
233pub enum ReleaseArtifactKind {
234    Runtime,
235    Model,
236    Handler,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240#[serde(rename_all = "camelCase", deny_unknown_fields)]
241pub struct ReleaseArtifact {
242    pub kind: ReleaseArtifactKind,
243    pub id: String,
244    pub version: String,
245    pub runtime_api_version: u32,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub target_os: Option<String>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub target_arch: Option<String>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub handler_api_version: Option<u32>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub min_runtime_version: Option<String>,
254    pub url: String,
255    pub sha256: String,
256    pub size: u64,
257}
258
259impl ReleaseArtifact {
260    pub fn validate_shape(&self) -> Result<(), &'static str> {
261        if self.id.is_empty() || self.id.len() > 96 {
262            return Err("invalid artifact id");
263        }
264        if self.version.is_empty() || self.version.len() > 48 {
265            return Err("invalid artifact version");
266        }
267        if self.url.is_empty() || self.url.len() > 2048 {
268            return Err("invalid artifact URL");
269        }
270        if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
271            return Err("invalid artifact SHA-256");
272        }
273        if self.size == 0 || self.size > 128 * 1024 * 1024 {
274            return Err("invalid artifact size");
275        }
276        match self.kind {
277            ReleaseArtifactKind::Runtime => {
278                if self.runtime_api_version != RUNTIME_API_VERSION {
279                    return Err("unsupported artifact runtime API version");
280                }
281                // The artifact ``id`` is part of the stable asset identity. On
282                // Linux, the libc/ABI variant (gnu vs musl) is encoded in the
283                // ``id`` so that both builds of the same OS+arch coexist in a
284                // single v2 index without a schema bump.
285                if (self.id != RUNTIME_ARTIFACT_ID && self.id != RUNTIME_ARTIFACT_ID_MUSL)
286                    || self.target_os.as_deref().is_none_or(str::is_empty)
287                    || self.target_arch.as_deref().is_none_or(str::is_empty)
288                {
289                    return Err("runtime artifact requires a target OS and architecture");
290                }
291                if self.handler_api_version.is_some() || self.min_runtime_version.is_some() {
292                    return Err("runtime artifact must not carry handler fields");
293                }
294            }
295            ReleaseArtifactKind::Model => {
296                if self.runtime_api_version != RUNTIME_API_VERSION {
297                    return Err("unsupported artifact runtime API version");
298                }
299                if self.target_os.is_some()
300                    || self.target_arch.is_some()
301                    || self.handler_api_version.is_some()
302                    || self.min_runtime_version.is_some()
303                {
304                    return Err("model artifact must be platform independent");
305                }
306            }
307            ReleaseArtifactKind::Handler => {
308                if self.runtime_api_version != RUNTIME_API_VERSION {
309                    return Err("unsupported artifact runtime API version");
310                }
311                if self.target_os.is_some() || self.target_arch.is_some() {
312                    return Err("handler artifact must be platform independent");
313                }
314                let handler_api = self
315                    .handler_api_version
316                    .ok_or("handler artifact requires handler API version")?;
317                if handler_api != HANDLER_API_VERSION {
318                    return Err("unsupported handler API version");
319                }
320                let min_runtime = self
321                    .min_runtime_version
322                    .as_deref()
323                    .ok_or("handler artifact requires minimum runtime version")?;
324                if min_runtime.is_empty() || min_runtime.len() > 48 {
325                    return Err("invalid minimum runtime version");
326                }
327            }
328        }
329        Ok(())
330    }
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(rename_all = "camelCase", deny_unknown_fields)]
335pub struct ReleaseIndexPayload {
336    pub schema_version: u32,
337    pub channel: String,
338    pub generated_at: String,
339    pub publisher_key_id: String,
340    pub artifacts: Vec<ReleaseArtifact>,
341}
342
343impl ReleaseIndexPayload {
344    pub fn validate_shape(&self) -> Result<(), &'static str> {
345        if self.schema_version != RELEASE_INDEX_SCHEMA_VERSION {
346            return Err("unsupported release-index schema");
347        }
348        if !matches!(self.channel.as_str(), "stable" | "candidate") {
349            return Err("unsupported release channel");
350        }
351        if self.generated_at.is_empty() || self.generated_at.len() > 64 {
352            return Err("invalid release-index timestamp");
353        }
354        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
355            return Err("invalid release-index publisher");
356        }
357        if self.artifacts.is_empty() || self.artifacts.len() > 64 {
358            return Err("invalid release-index artifact count");
359        }
360        for artifact in &self.artifacts {
361            artifact.validate_shape()?;
362        }
363        Ok(())
364    }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
368#[serde(rename_all = "camelCase", deny_unknown_fields)]
369pub struct SignedReleaseIndex {
370    pub payload: ReleaseIndexPayload,
371    /// Lowercase hexadecimal Ed25519 signature over canonical payload JSON.
372    pub signature: String,
373}
374
375// ---------------------------------------------------------------------------
376// IPC requests (shared by v1 and v2)
377// ---------------------------------------------------------------------------
378
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
380#[serde(
381    tag = "method",
382    rename_all = "camelCase",
383    rename_all_fields = "camelCase",
384    deny_unknown_fields
385)]
386pub enum RuntimeRequest {
387    Handshake {
388        request_id: String,
389        api_version: u32,
390        client_name: String,
391        client_version: String,
392    },
393    Health {
394        request_id: String,
395        api_version: u32,
396    },
397    Invoke {
398        request_id: String,
399        api_version: u32,
400        capability: String,
401        input: serde_json::Value,
402    },
403}
404
405impl RuntimeRequest {
406    pub fn request_id(&self) -> &str {
407        match self {
408            Self::Handshake { request_id, .. }
409            | Self::Health { request_id, .. }
410            | Self::Invoke { request_id, .. } => request_id,
411        }
412    }
413
414    pub fn api_version(&self) -> u32 {
415        match self {
416            Self::Handshake { api_version, .. }
417            | Self::Health { api_version, .. }
418            | Self::Invoke { api_version, .. } => *api_version,
419        }
420    }
421}
422
423// ---------------------------------------------------------------------------
424// IPC v1 responses (frozen since 0.5.0)
425// ---------------------------------------------------------------------------
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
428#[serde(
429    tag = "kind",
430    rename_all = "camelCase",
431    rename_all_fields = "camelCase",
432    deny_unknown_fields
433)]
434pub enum RuntimeResponse {
435    Handshake {
436        request_id: String,
437        api_version: u32,
438        runtime_version: String,
439        model_pack_id: String,
440        model_pack_version: String,
441        capabilities: Vec<String>,
442    },
443    Health {
444        request_id: String,
445        api_version: u32,
446        healthy: bool,
447        model_pack_id: String,
448        model_pack_version: String,
449    },
450    Result {
451        request_id: String,
452        api_version: u32,
453        output: serde_json::Value,
454    },
455    Error {
456        request_id: String,
457        api_version: u32,
458        code: String,
459        message: String,
460        retryable: bool,
461    },
462}
463
464// ---------------------------------------------------------------------------
465// IPC v2 responses (introduced in 0.7.0)
466// ---------------------------------------------------------------------------
467
468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
469#[serde(
470    tag = "kind",
471    rename_all = "camelCase",
472    rename_all_fields = "camelCase",
473    deny_unknown_fields
474)]
475pub enum RuntimeResponseV2 {
476    Handshake {
477        request_id: String,
478        api_version: u32,
479        runtime_version: String,
480        model_pack_id: String,
481        model_pack_version: String,
482        capabilities: Vec<String>,
483        handler_id: String,
484        handler_version: String,
485        handler_api_version: u32,
486        effective_capabilities: Vec<String>,
487    },
488    Health {
489        request_id: String,
490        api_version: u32,
491        healthy: bool,
492        model_pack_id: String,
493        model_pack_version: String,
494    },
495    Result {
496        request_id: String,
497        api_version: u32,
498        output: serde_json::Value,
499    },
500    Error {
501        request_id: String,
502        api_version: u32,
503        code: String,
504        message: String,
505        retryable: bool,
506    },
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn protocol_v1_roundtrip_is_tagged_and_strict() {
515        let request = RuntimeRequest::Health {
516            request_id: "health-1".into(),
517            api_version: 1,
518        };
519        let json = serde_json::to_string(&request).unwrap();
520        assert!(json.contains("\"method\":\"health\""));
521        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
522        assert_eq!(restored, request);
523        assert!(
524            serde_json::from_str::<RuntimeRequest>(
525                r#"{"method":"health","requestId":"x","apiVersion":1,"extra":true}"#
526            )
527            .is_err()
528        );
529    }
530
531    #[test]
532    fn v1_handshake_fixture_is_stable() {
533        let request = RuntimeRequest::Handshake {
534            request_id: "fixture".into(),
535            api_version: 1,
536            client_name: "example-host".into(),
537            client_version: "0.6.10".into(),
538        };
539        assert_eq!(
540            serde_json::to_string(&request).unwrap(),
541            r#"{"method":"handshake","requestId":"fixture","apiVersion":1,"clientName":"example-host","clientVersion":"0.6.10"}"#
542        );
543    }
544
545    #[test]
546    fn v1_handshake_response_fixture_is_stable() {
547        let response = RuntimeResponse::Handshake {
548            request_id: "fixture".into(),
549            api_version: 1,
550            runtime_version: "0.6.0".into(),
551            model_pack_id: "rillml.example.default".into(),
552            model_pack_version: "0.6.0".into(),
553            capabilities: vec!["rillml.example".into()],
554        };
555        assert_eq!(
556            serde_json::to_string(&response).unwrap(),
557            r#"{"kind":"handshake","requestId":"fixture","apiVersion":1,"runtimeVersion":"0.6.0","modelPackId":"rillml.example.default","modelPackVersion":"0.6.0","capabilities":["rillml.example"]}"#
558        );
559    }
560
561    #[test]
562    fn v2_handshake_response_fixture_is_stable() {
563        let response = RuntimeResponseV2::Handshake {
564            request_id: "v2-fixture".into(),
565            api_version: 2,
566            runtime_version: "0.7.0".into(),
567            model_pack_id: "rillml.example.default".into(),
568            model_pack_version: "0.7.0".into(),
569            capabilities: vec!["rillml.example".into()],
570            handler_id: "org.example.handler".into(),
571            handler_version: "1.0.0".into(),
572            handler_api_version: 1,
573            effective_capabilities: vec!["rillml.example".into()],
574        };
575        let json = serde_json::to_string(&response).unwrap();
576        assert!(json.contains("\"handlerId\":\"org.example.handler\""));
577        assert!(json.contains("\"handlerApiVersion\":1"));
578        assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
579        // Mutating the response produces a different JSON, proving the fixture
580        // is fully serialised and not relying on default values.
581        let mut bad = serde_json::from_str::<RuntimeResponseV2>(&json).unwrap();
582        if let RuntimeResponseV2::Handshake { handler_id, .. } = &mut bad {
583            handler_id.push('x');
584        }
585        let bad_json = serde_json::to_string(&bad).unwrap();
586        assert_ne!(bad_json, json);
587    }
588
589    #[test]
590    fn v1_response_rejects_handler_fields() {
591        let json = r#"{"kind":"handshake","requestId":"x","apiVersion":1,"runtimeVersion":"0.7.0","modelPackId":"m","modelPackVersion":"1","capabilities":["c"],"handlerId":"h"}"#;
592        assert!(serde_json::from_str::<RuntimeResponse>(json).is_err());
593    }
594
595    #[test]
596    fn invoke_roundtrip_preserves_capability_and_input() {
597        let request = RuntimeRequest::Invoke {
598            request_id: "invoke-1".into(),
599            api_version: 2,
600            capability: "rillml.example".into(),
601            input: serde_json::json!({"samples": []}),
602        };
603        let json = serde_json::to_string(&request).unwrap();
604        assert!(json.contains("\"method\":\"invoke\""));
605        assert!(json.contains("\"capability\":\"rillml.example\""));
606        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
607        assert_eq!(restored, request);
608    }
609
610    #[test]
611    fn release_artifacts_enforce_platform_boundaries() {
612        let runtime = ReleaseArtifact {
613            kind: ReleaseArtifactKind::Runtime,
614            id: RUNTIME_ARTIFACT_ID.into(),
615            version: "0.7.0".into(),
616            runtime_api_version: RUNTIME_API_VERSION,
617            target_os: Some("macos".into()),
618            target_arch: Some("aarch64".into()),
619            handler_api_version: None,
620            min_runtime_version: None,
621            url: "https://example.invalid/rill-runtime".into(),
622            sha256: "ab".repeat(32),
623            size: 1024,
624        };
625        assert!(runtime.validate_shape().is_ok());
626
627        let mut model = runtime.clone();
628        model.kind = ReleaseArtifactKind::Model;
629        model.id = "rillml.example.default".into();
630        model.target_os = None;
631        model.target_arch = None;
632        assert!(model.validate_shape().is_ok());
633
634        let mut handler = runtime.clone();
635        handler.kind = ReleaseArtifactKind::Handler;
636        handler.id = "org.example.handler".into();
637        handler.target_os = None;
638        handler.target_arch = None;
639        handler.handler_api_version = Some(HANDLER_API_VERSION);
640        handler.min_runtime_version = Some("0.7.0".into());
641        assert!(handler.validate_shape().is_ok());
642
643        // Handler with platform fields is rejected.
644        handler.target_os = Some("linux".into());
645        assert!(handler.validate_shape().is_err());
646        handler.target_os = None;
647
648        // Handler without handler_api_version is rejected.
649        handler.handler_api_version = None;
650        assert!(handler.validate_shape().is_err());
651        handler.handler_api_version = Some(HANDLER_API_VERSION);
652
653        // Handler without min_runtime_version is rejected.
654        handler.min_runtime_version = None;
655        assert!(handler.validate_shape().is_err());
656    }
657
658    #[test]
659    fn handler_manifest_validates_shape() {
660        let manifest = HandlerPackManifest {
661            format_version: HANDLER_PACKAGE_FORMAT_VERSION,
662            id: "org.example.handler".into(),
663            version: "1.0.0".into(),
664            handler_api_version: HANDLER_API_VERSION,
665            min_runtime_version: "0.7.0".into(),
666            publisher_key_id: "test-key".into(),
667            capabilities: vec!["org.example.predict".into()],
668            module_sha256: "ab".repeat(32),
669            module_size: 1024,
670        };
671        assert!(manifest.validate_shape().is_ok());
672
673        let mut bad = manifest.clone();
674        bad.format_version = 99;
675        assert!(bad.validate_shape().is_err());
676
677        let mut bad = manifest.clone();
678        bad.handler_api_version = 99;
679        assert!(bad.validate_shape().is_err());
680
681        let mut bad = manifest.clone();
682        bad.capabilities = vec![];
683        assert!(bad.validate_shape().is_err());
684
685        let mut bad = manifest.clone();
686        bad.module_sha256 = "short".into();
687        assert!(bad.validate_shape().is_err());
688
689        let mut bad = manifest.clone();
690        bad.module_size = 0;
691        assert!(bad.validate_shape().is_err());
692    }
693}