Skip to main content

rill_runtime/
server.rs

1use std::sync::Arc;
2
3use rill_runtime_protocol::{
4    MIN_RUNTIME_API_VERSION, RUNTIME_API_VERSION, RuntimeRequest, RuntimeResponse,
5    RuntimeResponseV2,
6};
7use serde_json::Value;
8
9use crate::handler::HandlerIdentity;
10use crate::package::LoadedModelPack;
11
12/// Typed invoke error.
13///
14/// Replaces the previous `Result<Value, String>` contract. The `kind`
15/// selects a stable IPC error code and a fixed public message; `detail`
16/// carries host-only diagnostic text (e.g. for stderr logs) and is **never**
17/// forwarded to IPC clients, so guests cannot exfiltrate arbitrary content
18/// through the error path.
19#[derive(Debug, Clone)]
20pub struct InvokeError {
21    kind: InvokeErrorKind,
22    detail: Option<String>,
23}
24
25/// Maximum byte length of the host-only `detail` string. Guests can fully
26/// control this payload via the WIT `handler-error` variant, so the host
27/// truncates it to bound memory and stderr noise. The limit is enforced
28/// on a UTF-8 char boundary so the stored string stays valid.
29pub const MAX_DETAIL_BYTES: usize = 4 * 1024;
30
31/// Stable categorisation of invoke failures.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum InvokeErrorKind {
34    /// Host-side input serialisation or size check failed.
35    Internal,
36    /// Fuel budget or epoch deadline was hit. Retryable.
37    Timeout,
38    /// Wasmtime trap (unreachable, OOB, stack overflow, …).
39    Trap,
40    /// Handler output exceeded [`MAX_IO_BYTES`](crate::handler::wasm::MAX_IO_BYTES).
41    OutputTooLarge,
42    /// Handler output failed JSON deserialisation on the host side.
43    InvalidOutput,
44    /// Guest reported `invalid-model` / `invalid-input` /
45    /// `unsupported-capability` / `execution-failed` via the WIT
46    /// `handler-error` variant. The variant detail is stored in
47    /// [`InvokeError::detail`] for host logs only.
48    ExecutionFailed,
49}
50
51impl InvokeError {
52    /// Create a new typed error with no host detail.
53    pub const fn new(kind: InvokeErrorKind) -> Self {
54        Self { kind, detail: None }
55    }
56
57    /// Create a new typed error carrying host-only diagnostic text.
58    ///
59    /// `detail` is intended for `eprintln!` logs and **must not** be sent
60    /// to IPC clients. Guests can fully control this string via the WIT
61    /// `handler-error` payload, so it cannot be trusted for security
62    /// decisions. It is truncated to [`MAX_DETAIL_BYTES`] on a UTF-8 char
63    /// boundary so a malicious guest cannot grow host memory unboundedly
64    /// through the error path.
65    pub fn with_detail(kind: InvokeErrorKind, detail: impl Into<String>) -> Self {
66        Self {
67            kind,
68            detail: Some(truncate_to_bytes(detail.into(), MAX_DETAIL_BYTES)),
69        }
70    }
71
72    /// Error category.
73    pub const fn kind(&self) -> InvokeErrorKind {
74        self.kind
75    }
76
77    /// Host-only diagnostic text. Never sent to IPC clients.
78    pub fn detail(&self) -> Option<&str> {
79        self.detail.as_deref()
80    }
81
82    /// Stable IPC error code. Backwards-compatible with the v1/v2 wire
83    /// format produced by the previous `map_invoke_error` string matching.
84    pub const fn stable_code(&self) -> &'static str {
85        match self.kind {
86            InvokeErrorKind::Internal => "handlerInternalError",
87            InvokeErrorKind::Timeout => "handlerTimeout",
88            InvokeErrorKind::Trap => "handlerTrap",
89            InvokeErrorKind::OutputTooLarge => "handlerOutputTooLarge",
90            InvokeErrorKind::InvalidOutput => "handlerInvalidOutput",
91            // Guest-reported WIT `handler-error` variants all collapse to
92            // `handlerInternalError` on the wire, matching the previous
93            // `map_invoke_error` behaviour that mapped
94            // `handlerExecutionFailed: ...` to `handlerInternalError`.
95            InvokeErrorKind::ExecutionFailed => "handlerInternalError",
96        }
97    }
98
99    /// Fixed public message. Never contains guest-supplied content.
100    pub const fn public_message(&self) -> &'static str {
101        match self.kind {
102            InvokeErrorKind::Internal => "internal runtime error",
103            InvokeErrorKind::Timeout => "handler exceeded the wall-clock deadline",
104            InvokeErrorKind::Trap => "handler trapped",
105            InvokeErrorKind::OutputTooLarge => "handler output exceeded the size limit",
106            InvokeErrorKind::InvalidOutput => "handler output was not valid JSON",
107            InvokeErrorKind::ExecutionFailed => "handler execution failed",
108        }
109    }
110
111    /// Whether the caller may retry the same request on a fresh handler.
112    pub const fn retryable(&self) -> bool {
113        matches!(self.kind, InvokeErrorKind::Timeout)
114    }
115}
116
117impl std::fmt::Display for InvokeError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match &self.detail {
120            Some(detail) => write!(f, "{}: {}", self.stable_code(), detail),
121            None => f.write_str(self.stable_code()),
122        }
123    }
124}
125
126impl std::error::Error for InvokeError {}
127
128/// Truncate `s` to at most `max_bytes` on a UTF-8 char boundary.
129///
130/// `String::truncate` panics on a non-char boundary, so we walk backwards
131/// from `max_bytes` until `is_char_boundary` succeeds. The result is always
132/// valid UTF-8 and never longer than `max_bytes`.
133fn truncate_to_bytes(s: String, max_bytes: usize) -> String {
134    if s.len() <= max_bytes {
135        return s;
136    }
137    let mut end = max_bytes;
138    while end > 0 && !s.is_char_boundary(end) {
139        end -= 1;
140    }
141    let mut truncated = s;
142    truncated.truncate(end);
143    truncated
144}
145
146/// Consumers can implement this trait to add business-specific invocation logic.
147pub trait InvokeHandler: Send + Sync + std::fmt::Debug {
148    fn invoke(&self, capability: &str, input: &Value) -> Result<Value, InvokeError>;
149}
150
151/// Internal response type produced by [`RuntimeEngine`]. The IPC layer converts
152/// this to a v1 [`RuntimeResponse`] or v2 [`RuntimeResponseV2`] based on the
153/// request's `api_version`.
154#[derive(Debug, Clone)]
155pub enum EngineResponse {
156    Handshake {
157        request_id: String,
158        runtime_version: String,
159        model_pack_id: String,
160        model_pack_version: String,
161        capabilities: Vec<String>,
162        handler: Option<HandlerIdentity>,
163    },
164    Health {
165        request_id: String,
166        healthy: bool,
167        model_pack_id: String,
168        model_pack_version: String,
169    },
170    Result {
171        request_id: String,
172        output: Value,
173    },
174    Error {
175        request_id: String,
176        code: String,
177        message: String,
178        retryable: bool,
179    },
180}
181
182impl EngineResponse {
183    /// Convert to a v1 wire response. Handler identity fields are dropped.
184    pub fn to_v1(&self, api_version: u32) -> RuntimeResponse {
185        match self {
186            Self::Handshake {
187                request_id,
188                runtime_version,
189                model_pack_id,
190                model_pack_version,
191                capabilities,
192                ..
193            } => RuntimeResponse::Handshake {
194                request_id: request_id.clone(),
195                api_version,
196                runtime_version: runtime_version.clone(),
197                model_pack_id: model_pack_id.clone(),
198                model_pack_version: model_pack_version.clone(),
199                capabilities: capabilities.clone(),
200            },
201            Self::Health {
202                request_id,
203                healthy,
204                model_pack_id,
205                model_pack_version,
206            } => RuntimeResponse::Health {
207                request_id: request_id.clone(),
208                api_version,
209                healthy: *healthy,
210                model_pack_id: model_pack_id.clone(),
211                model_pack_version: model_pack_version.clone(),
212            },
213            Self::Result { request_id, output } => RuntimeResponse::Result {
214                request_id: request_id.clone(),
215                api_version,
216                output: output.clone(),
217            },
218            Self::Error {
219                request_id,
220                code,
221                message,
222                retryable,
223            } => RuntimeResponse::Error {
224                request_id: request_id.clone(),
225                api_version,
226                code: code.clone(),
227                message: message.clone(),
228                retryable: *retryable,
229            },
230        }
231    }
232
233    /// Convert to a v2 wire response. If no handler is loaded, handler fields
234    /// are filled with empty/zero values and effective_capabilities equals the
235    /// model capabilities.
236    pub fn to_v2(&self, api_version: u32) -> RuntimeResponseV2 {
237        match self {
238            Self::Handshake {
239                request_id,
240                runtime_version,
241                model_pack_id,
242                model_pack_version,
243                capabilities,
244                handler,
245            } => {
246                let (handler_id, handler_version, handler_api_version, effective) = match handler {
247                    Some(h) => (
248                        h.handler_id.clone(),
249                        h.handler_version.clone(),
250                        h.handler_api_version,
251                        h.effective_capabilities.clone(),
252                    ),
253                    None => (String::new(), String::new(), 0, capabilities.clone()),
254                };
255                RuntimeResponseV2::Handshake {
256                    request_id: request_id.clone(),
257                    api_version,
258                    runtime_version: runtime_version.clone(),
259                    model_pack_id: model_pack_id.clone(),
260                    model_pack_version: model_pack_version.clone(),
261                    capabilities: capabilities.clone(),
262                    handler_id,
263                    handler_version,
264                    handler_api_version,
265                    effective_capabilities: effective,
266                }
267            }
268            Self::Health {
269                request_id,
270                healthy,
271                model_pack_id,
272                model_pack_version,
273            } => RuntimeResponseV2::Health {
274                request_id: request_id.clone(),
275                api_version,
276                healthy: *healthy,
277                model_pack_id: model_pack_id.clone(),
278                model_pack_version: model_pack_version.clone(),
279            },
280            Self::Result { request_id, output } => RuntimeResponseV2::Result {
281                request_id: request_id.clone(),
282                api_version,
283                output: output.clone(),
284            },
285            Self::Error {
286                request_id,
287                code,
288                message,
289                retryable,
290            } => RuntimeResponseV2::Error {
291                request_id: request_id.clone(),
292                api_version,
293                code: code.clone(),
294                message: message.clone(),
295                retryable: *retryable,
296            },
297        }
298    }
299}
300
301#[derive(Debug, Clone)]
302pub struct RuntimeEngine {
303    pack: LoadedModelPack,
304    invoke_handler: Option<Arc<dyn InvokeHandler>>,
305    handler_identity: Option<HandlerIdentity>,
306    effective_capabilities: Vec<String>,
307}
308
309impl RuntimeEngine {
310    pub fn new(pack: LoadedModelPack) -> Self {
311        Self {
312            pack,
313            invoke_handler: None,
314            handler_identity: None,
315            effective_capabilities: Vec::new(),
316        }
317    }
318
319    pub fn with_invoke_handler(mut self, handler: Arc<dyn InvokeHandler>) -> Self {
320        self.invoke_handler = Some(handler);
321        self
322    }
323
324    /// Attach handler identity and effective capabilities for IPC v2 handshake.
325    pub fn with_handler_identity(mut self, identity: HandlerIdentity) -> Self {
326        self.effective_capabilities = identity.effective_capabilities.clone();
327        self.handler_identity = Some(identity);
328        self
329    }
330
331    /// Effective capability set (intersection of model and handler). Empty when
332    /// no handler is loaded.
333    pub fn effective_capabilities(&self) -> &[String] {
334        &self.effective_capabilities
335    }
336
337    /// Handler identity if a handler was loaded.
338    pub fn handler_identity(&self) -> Option<&HandlerIdentity> {
339        self.handler_identity.as_ref()
340    }
341
342    pub fn handle(&self, request: RuntimeRequest) -> EngineResponse {
343        let request_id = request.request_id().to_string();
344        if request_id.is_empty() || request_id.len() > 128 {
345            return self.error(request_id, "invalidRequestId", "invalid request id", false);
346        }
347        let api_version = request.api_version();
348        if !(MIN_RUNTIME_API_VERSION..=RUNTIME_API_VERSION).contains(&api_version) {
349            return self.error(
350                request_id,
351                "incompatibleApiVersion",
352                "runtime API version is not supported",
353                false,
354            );
355        }
356
357        match request {
358            RuntimeRequest::Handshake {
359                request_id,
360                client_name,
361                client_version,
362                ..
363            } => {
364                if client_name.is_empty()
365                    || client_name.len() > 96
366                    || client_version.is_empty()
367                    || client_version.len() > 48
368                {
369                    return self.error(
370                        request_id,
371                        "invalidClientIdentity",
372                        "invalid client identity",
373                        false,
374                    );
375                }
376                EngineResponse::Handshake {
377                    request_id,
378                    runtime_version: env!("CARGO_PKG_VERSION").into(),
379                    model_pack_id: self.pack.manifest.id.clone(),
380                    model_pack_version: self.pack.manifest.version.clone(),
381                    capabilities: self.pack.manifest.capabilities.clone(),
382                    handler: self.handler_identity.clone(),
383                }
384            }
385            RuntimeRequest::Health { request_id, .. } => EngineResponse::Health {
386                request_id,
387                healthy: true,
388                model_pack_id: self.pack.manifest.id.clone(),
389                model_pack_version: self.pack.manifest.version.clone(),
390            },
391            RuntimeRequest::Invoke {
392                request_id,
393                capability,
394                input,
395                ..
396            } => {
397                if !self.is_capability_allowed(&capability) {
398                    return self.error(
399                        request_id,
400                        "unsupportedCapability",
401                        "capability is not in the effective set",
402                        false,
403                    );
404                }
405                let Some(handler) = &self.invoke_handler else {
406                    return self.error(
407                        request_id,
408                        "noInvokeHandler",
409                        "no invoke handler registered",
410                        false,
411                    );
412                };
413                match handler.invoke(&capability, &input) {
414                    Ok(output) => EngineResponse::Result { request_id, output },
415                    Err(invoke_err) => {
416                        // Log host-side detail (if any) for debugging; the
417                        // IPC message is always the fixed public string so
418                        // guests cannot exfiltrate content via the error
419                        // payload.
420                        if let Some(detail) = invoke_err.detail() {
421                            eprintln!(
422                                "rill-runtime: invoke {} -> {} (detail: {})",
423                                capability,
424                                invoke_err.stable_code(),
425                                detail
426                            );
427                        }
428                        self.error(
429                            request_id,
430                            invoke_err.stable_code(),
431                            invoke_err.public_message(),
432                            invoke_err.retryable(),
433                        )
434                    }
435                }
436            }
437        }
438    }
439
440    /// Checks the capability against the effective set when a handler is loaded,
441    /// or against the model pack's declared capabilities when no handler is
442    /// loaded (for backwards compatibility with built-in handlers selected by
443    /// the binary).
444    fn is_capability_allowed(&self, capability: &str) -> bool {
445        if !self.effective_capabilities.is_empty() {
446            self.effective_capabilities.iter().any(|c| c == capability)
447        } else {
448            self.pack
449                .manifest
450                .capabilities
451                .iter()
452                .any(|c| c == capability)
453        }
454    }
455
456    fn error(
457        &self,
458        request_id: String,
459        code: &str,
460        message: &str,
461        retryable: bool,
462    ) -> EngineResponse {
463        EngineResponse::Error {
464            request_id,
465            code: code.into(),
466            message: message.into(),
467            retryable,
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use rill_runtime_protocol::{MODEL_PACK_FORMAT_VERSION, ModelPackManifest};
475
476    use super::*;
477    use crate::handler::builtin::LINEAR_REGRESSION_CAPABILITY;
478
479    fn engine() -> RuntimeEngine {
480        RuntimeEngine::new(LoadedModelPack {
481            manifest: ModelPackManifest {
482                format_version: MODEL_PACK_FORMAT_VERSION,
483                id: "rillml.example.default".into(),
484                version: "0.7.0".into(),
485                runtime_api_version: RUNTIME_API_VERSION,
486                min_runtime_version: "0.7.0".into(),
487                publisher_key_id: "test".into(),
488                capabilities: vec!["rillml.example".into()],
489            },
490            model: serde_json::json!({}),
491        })
492    }
493
494    #[test]
495    fn handshake_reports_loaded_pack() {
496        let response = engine().handle(RuntimeRequest::Handshake {
497            request_id: "hello".into(),
498            api_version: RUNTIME_API_VERSION,
499            client_name: "example-host".into(),
500            client_version: "0.9.0".into(),
501        });
502        assert!(matches!(
503            response,
504            EngineResponse::Handshake { model_pack_id, .. }
505                if model_pack_id == "rillml.example.default"
506        ));
507    }
508
509    #[test]
510    fn incompatible_api_is_a_typed_error() {
511        let response = engine().handle(RuntimeRequest::Health {
512            request_id: "health".into(),
513            api_version: RUNTIME_API_VERSION + 1,
514        });
515        assert!(matches!(
516            response,
517            EngineResponse::Error { code, .. } if code == "incompatibleApiVersion"
518        ));
519    }
520
521    #[test]
522    fn invoke_without_handler_returns_no_invoke_handler_error() {
523        let response = engine().handle(RuntimeRequest::Invoke {
524            request_id: "invoke-1".into(),
525            api_version: RUNTIME_API_VERSION,
526            capability: "rillml.example".into(),
527            input: serde_json::json!({}),
528        });
529        assert!(matches!(
530            response,
531            EngineResponse::Error { code, .. } if code == "noInvokeHandler"
532        ));
533    }
534
535    #[test]
536    fn invoke_rejects_capability_not_declared_by_signed_manifest() {
537        let response = engine().handle(RuntimeRequest::Invoke {
538            request_id: "invoke-undeclared".into(),
539            api_version: RUNTIME_API_VERSION,
540            capability: "undeclared.capability".into(),
541            input: serde_json::json!({}),
542        });
543        assert!(matches!(
544            response,
545            EngineResponse::Error { code, .. } if code == "unsupportedCapability"
546        ));
547    }
548
549    #[test]
550    fn v1_handshake_omits_handler_fields() {
551        let identity = HandlerIdentity {
552            handler_id: "org.example.handler".into(),
553            handler_version: "1.0.0".into(),
554            handler_api_version: 1,
555            effective_capabilities: vec!["rillml.example".into()],
556        };
557        let engine = engine().with_handler_identity(identity);
558        let response = engine.handle(RuntimeRequest::Handshake {
559            request_id: "v1-test".into(),
560            api_version: 1,
561            client_name: "v1-host".into(),
562            client_version: "0.6.0".into(),
563        });
564        let v1 = response.to_v1(1);
565        let json = serde_json::to_string(&v1).unwrap();
566        assert!(!json.contains("handlerId"));
567        assert!(!json.contains("effectiveCapabilities"));
568    }
569
570    #[test]
571    fn v2_handshake_includes_handler_fields() {
572        let identity = HandlerIdentity {
573            handler_id: "org.example.handler".into(),
574            handler_version: "1.0.0".into(),
575            handler_api_version: 1,
576            effective_capabilities: vec!["rillml.example".into()],
577        };
578        let engine = engine().with_handler_identity(identity);
579        let response = engine.handle(RuntimeRequest::Handshake {
580            request_id: "v2-test".into(),
581            api_version: 2,
582            client_name: "v2-host".into(),
583            client_version: "0.7.0".into(),
584        });
585        let v2 = response.to_v2(2);
586        let json = serde_json::to_string(&v2).unwrap();
587        assert!(json.contains("\"handlerId\":\"org.example.handler\""));
588        assert!(json.contains("\"handlerApiVersion\":1"));
589        assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
590    }
591
592    #[test]
593    fn v2_handshake_without_handler_has_empty_fields() {
594        let response = engine().handle(RuntimeRequest::Handshake {
595            request_id: "v2-no-handler".into(),
596            api_version: 2,
597            client_name: "v2-host".into(),
598            client_version: "0.7.0".into(),
599        });
600        let v2 = response.to_v2(2);
601        match v2 {
602            RuntimeResponseV2::Handshake {
603                handler_id,
604                handler_version,
605                handler_api_version,
606                effective_capabilities,
607                ..
608            } => {
609                assert!(handler_id.is_empty());
610                assert!(handler_version.is_empty());
611                assert_eq!(handler_api_version, 0);
612                assert_eq!(effective_capabilities, vec!["rillml.example"]);
613            }
614            _ => panic!("expected handshake"),
615        }
616    }
617
618    #[test]
619    fn linear_regression_handler_validates_and_predicts() {
620        use crate::handler::builtin::LinearRegressionInvokeHandler;
621
622        let pack = LoadedModelPack {
623            manifest: ModelPackManifest {
624                format_version: MODEL_PACK_FORMAT_VERSION,
625                id: "rillml.example.default".into(),
626                version: "0.7.0".into(),
627                runtime_api_version: RUNTIME_API_VERSION,
628                min_runtime_version: "0.7.0".into(),
629                publisher_key_id: "test".into(),
630                capabilities: vec![LINEAR_REGRESSION_CAPABILITY.into()],
631            },
632            model: serde_json::json!({
633                "kind": "linearRegression",
634                "weights": [0.5, -0.25],
635                "intercept": 1.0
636            }),
637        };
638        let handler = LinearRegressionInvokeHandler::from_pack(&pack).unwrap();
639        let engine = RuntimeEngine::new(pack).with_invoke_handler(Arc::new(handler));
640        let response = engine.handle(RuntimeRequest::Invoke {
641            request_id: "invoke-linear".into(),
642            api_version: RUNTIME_API_VERSION,
643            capability: LINEAR_REGRESSION_CAPABILITY.into(),
644            input: serde_json::json!({"features": [4.0, 2.0]}),
645        });
646        assert!(matches!(
647            response,
648            EngineResponse::Result { output, .. } if output["prediction"] == 2.5
649        ));
650    }
651
652    #[test]
653    fn invoke_error_stable_codes_match_wire_format() {
654        // Every kind must map to the exact IPC code expected by v1/v2
655        // clients, preserving backwards compatibility with the previous
656        // `map_invoke_error` string matching.
657        assert_eq!(
658            InvokeError::new(InvokeErrorKind::Trap).stable_code(),
659            "handlerTrap"
660        );
661        assert_eq!(
662            InvokeError::new(InvokeErrorKind::Timeout).stable_code(),
663            "handlerTimeout"
664        );
665        assert_eq!(
666            InvokeError::new(InvokeErrorKind::OutputTooLarge).stable_code(),
667            "handlerOutputTooLarge"
668        );
669        assert_eq!(
670            InvokeError::new(InvokeErrorKind::InvalidOutput).stable_code(),
671            "handlerInvalidOutput"
672        );
673        assert_eq!(
674            InvokeError::new(InvokeErrorKind::Internal).stable_code(),
675            "handlerInternalError"
676        );
677        // Guest-reported WIT handler-error variants collapse to
678        // handlerInternalError on the wire.
679        assert_eq!(
680            InvokeError::new(InvokeErrorKind::ExecutionFailed).stable_code(),
681            "handlerInternalError"
682        );
683    }
684
685    #[test]
686    fn invoke_error_retryable_only_for_timeout() {
687        assert!(InvokeError::new(InvokeErrorKind::Timeout).retryable());
688        assert!(!InvokeError::new(InvokeErrorKind::Trap).retryable());
689        assert!(!InvokeError::new(InvokeErrorKind::OutputTooLarge).retryable());
690        assert!(!InvokeError::new(InvokeErrorKind::InvalidOutput).retryable());
691        assert!(!InvokeError::new(InvokeErrorKind::Internal).retryable());
692        assert!(!InvokeError::new(InvokeErrorKind::ExecutionFailed).retryable());
693    }
694
695    #[test]
696    fn invoke_error_public_message_never_contains_detail() {
697        // Guest can fully control the detail string; the public message
698        // must always be the fixed constant.
699        let err = InvokeError::with_detail(
700            InvokeErrorKind::ExecutionFailed,
701            "SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload",
702        );
703        assert_eq!(err.public_message(), "handler execution failed");
704        assert_eq!(err.stable_code(), "handlerInternalError");
705        assert_eq!(
706            err.detail(),
707            Some("SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload")
708        );
709        // The Display impl is for host logs only; the IPC layer must
710        // never send `err.to_string()` to clients.
711        assert!(err.to_string().contains("SECRET-TOKEN-LEAK-ATTEMPT"));
712        // The public_message is what the IPC layer actually sends.
713        assert!(!err.public_message().contains("SECRET"));
714    }
715
716    #[test]
717    fn invoke_error_without_detail_has_no_detail() {
718        let err = InvokeError::new(InvokeErrorKind::Trap);
719        assert_eq!(err.kind(), InvokeErrorKind::Trap);
720        assert_eq!(err.detail(), None);
721        assert_eq!(err.stable_code(), "handlerTrap");
722        assert_eq!(err.to_string(), "handlerTrap");
723    }
724
725    #[test]
726    fn invoke_error_detail_is_truncated_to_4kib_on_char_boundary() {
727        // A malicious guest tries to grow host memory via an oversized
728        // error payload. The host must truncate to MAX_DETAIL_BYTES on a
729        // UTF-8 char boundary.
730        let huge = "A".repeat(MAX_DETAIL_BYTES * 4);
731        let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, huge);
732        let detail = err.detail().expect("detail must be stored");
733        assert!(
734            detail.len() <= MAX_DETAIL_BYTES,
735            "detail length {} must not exceed {}",
736            detail.len(),
737            MAX_DETAIL_BYTES
738        );
739        // Truncation must land on a char boundary (the string is valid UTF-8
740        // by construction, but the test guards against a future unsafe path).
741        assert!(detail.chars().all(|c| c == 'A'));
742    }
743
744    #[test]
745    fn invoke_error_detail_truncation_respects_multibyte_chars() {
746        // Multi-byte UTF-8 must not be split mid-codepoint. Use 3-byte
747        // CJK characters so the MAX_DETAIL_BYTES boundary lands inside a
748        // character; the result must back up to the previous char boundary.
749        let emoji = "🌟".repeat(MAX_DETAIL_BYTES); // each '🌟' is 4 bytes
750        let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, emoji);
751        let detail = err.detail().expect("detail must be stored");
752        assert!(detail.len() <= MAX_DETAIL_BYTES);
753        // Every stored character must be a complete '🌟'.
754        for c in detail.chars() {
755            assert_eq!(c, '🌟');
756        }
757    }
758
759    /// Minimal handler that always returns the supplied error, for
760    /// exercising the engine's invoke error path without a WASM sandbox.
761    #[derive(Debug)]
762    struct FailingHandler {
763        err: InvokeError,
764    }
765
766    impl InvokeHandler for FailingHandler {
767        fn invoke(&self, _capability: &str, _input: &Value) -> Result<Value, InvokeError> {
768            Err(self.err.clone())
769        }
770    }
771
772    #[test]
773    fn engine_invoke_error_does_not_leak_guest_detail_in_message() {
774        // A malicious guest tries to exfiltrate a token via the WIT
775        // handler-error payload. The IPC Error.message field must be
776        // the fixed public string, not the guest-supplied detail.
777        let err = InvokeError::with_detail(
778            InvokeErrorKind::ExecutionFailed,
779            "leak-attempt:SECRET-TOKEN",
780        );
781        let pack = LoadedModelPack {
782            manifest: ModelPackManifest {
783                format_version: MODEL_PACK_FORMAT_VERSION,
784                id: "rillml.example.default".into(),
785                version: "0.7.0".into(),
786                runtime_api_version: RUNTIME_API_VERSION,
787                min_runtime_version: "0.7.0".into(),
788                publisher_key_id: "test".into(),
789                capabilities: vec!["rillml.example".into()],
790            },
791            model: serde_json::json!({}),
792        };
793        let engine = RuntimeEngine::new(pack).with_invoke_handler(Arc::new(FailingHandler { err }));
794        let response = engine.handle(RuntimeRequest::Invoke {
795            request_id: "leak-test".into(),
796            api_version: RUNTIME_API_VERSION,
797            capability: "rillml.example".into(),
798            input: serde_json::json!({}),
799        });
800        match response {
801            EngineResponse::Error {
802                code,
803                message,
804                retryable,
805                ..
806            } => {
807                assert_eq!(code, "handlerInternalError");
808                assert_eq!(message, "handler execution failed");
809                assert!(!retryable);
810                // The guest-supplied detail must NOT appear anywhere in
811                // the IPC response fields.
812                assert!(!message.contains("SECRET"));
813                assert!(!message.contains("leak-attempt"));
814            }
815            _ => panic!("expected EngineResponse::Error"),
816        }
817    }
818}