Skip to main content

rill_runtime/
server.rs

1use std::sync::{Arc, Mutex};
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///
33/// The four guest-reported variants (`InvalidModel`, `InvalidInput`,
34/// `UnsupportedCapability`, `ExecutionFailed`) correspond 1:1 to the
35/// WIT `handler-error` variants. They share the same stable IPC code
36/// (`handlerInternalError`) for backwards compatibility with v1/v2
37/// clients, but carry distinct fixed public messages and are
38/// distinguishable host-side for logging and diagnostics.
39///
40/// Marked `#[non_exhaustive]` so future variants (e.g. for new WIT
41/// `handler-error` entries or host-side failure modes) can be added
42/// without breaking downstream exhaustive `match` arms. This preserves
43/// the patch-level version guarantee even though the enum is part of
44/// the crate's public API surface.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum InvokeErrorKind {
48    /// Host-side input serialisation or size check failed.
49    Internal,
50    /// Fuel budget or epoch deadline was hit. Retryable.
51    Timeout,
52    /// Wasmtime trap (unreachable, OOB, stack overflow, …).
53    Trap,
54    /// Handler output exceeded [`MAX_IO_BYTES`](crate::handler::wasm::MAX_IO_BYTES).
55    OutputTooLarge,
56    /// Handler output failed JSON deserialisation on the host side.
57    InvalidOutput,
58    /// Guest reported `invalid-model` via the WIT `handler-error`
59    /// variant. The variant detail is stored in [`InvokeError::detail`]
60    /// for host logs only.
61    InvalidModel,
62    /// Guest reported `invalid-input` via the WIT `handler-error`
63    /// variant. The variant detail is stored in [`InvokeError::detail`]
64    /// for host logs only.
65    InvalidInput,
66    /// Guest reported `unsupported-capability` via the WIT
67    /// `handler-error` variant. The variant detail is stored in
68    /// [`InvokeError::detail`] for host logs only.
69    UnsupportedCapability,
70    /// Guest reported `execution-failed` via the WIT `handler-error`
71    /// variant. The variant detail is stored in [`InvokeError::detail`]
72    /// for host logs only.
73    ExecutionFailed,
74}
75
76impl InvokeError {
77    /// Create a new typed error with no host detail.
78    pub const fn new(kind: InvokeErrorKind) -> Self {
79        Self { kind, detail: None }
80    }
81
82    /// Create a new typed error carrying host-only diagnostic text.
83    ///
84    /// `detail` is intended for `eprintln!` logs and **must not** be sent
85    /// to IPC clients. Guests can fully control this string via the WIT
86    /// `handler-error` payload, so it cannot be trusted for security
87    /// decisions. It is truncated to [`MAX_DETAIL_BYTES`] on a UTF-8 char
88    /// boundary so a malicious guest cannot grow host memory unboundedly
89    /// through the error path.
90    pub fn with_detail(kind: InvokeErrorKind, detail: impl Into<String>) -> Self {
91        Self {
92            kind,
93            detail: Some(truncate_to_bytes(detail.into(), MAX_DETAIL_BYTES)),
94        }
95    }
96
97    /// Error category.
98    pub const fn kind(&self) -> InvokeErrorKind {
99        self.kind
100    }
101
102    /// Host-only diagnostic text. Never sent to IPC clients.
103    pub fn detail(&self) -> Option<&str> {
104        self.detail.as_deref()
105    }
106
107    /// Stable IPC error code. Backwards-compatible with the v1/v2 wire
108    /// format produced by the previous `map_invoke_error` string matching.
109    ///
110    /// All four guest-reported WIT `handler-error` variants
111    /// (`invalid-model`, `invalid-input`, `unsupported-capability`,
112    /// `execution-failed`) collapse to `handlerInternalError` on the wire
113    /// to preserve compatibility with v1/v2 clients. The host still
114    /// distinguishes them internally via [`InvokeError::kind`] for
115    /// logging and diagnostics.
116    pub const fn stable_code(&self) -> &'static str {
117        match self.kind {
118            InvokeErrorKind::Internal => "handlerInternalError",
119            InvokeErrorKind::Timeout => "handlerTimeout",
120            InvokeErrorKind::Trap => "handlerTrap",
121            InvokeErrorKind::OutputTooLarge => "handlerOutputTooLarge",
122            InvokeErrorKind::InvalidOutput => "handlerInvalidOutput",
123            // Guest-reported WIT `handler-error` variants all collapse to
124            // `handlerInternalError` on the wire, matching the previous
125            // `map_invoke_error` behaviour that mapped
126            // `handlerExecutionFailed: ...` to `handlerInternalError`.
127            InvokeErrorKind::InvalidModel
128            | InvokeErrorKind::InvalidInput
129            | InvokeErrorKind::UnsupportedCapability
130            | InvokeErrorKind::ExecutionFailed => "handlerInternalError",
131        }
132    }
133
134    /// Fixed public message. Never contains guest-supplied content.
135    pub const fn public_message(&self) -> &'static str {
136        match self.kind {
137            InvokeErrorKind::Internal => "internal runtime error",
138            InvokeErrorKind::Timeout => "handler exceeded the wall-clock deadline",
139            InvokeErrorKind::Trap => "handler trapped",
140            InvokeErrorKind::OutputTooLarge => "handler output exceeded the size limit",
141            InvokeErrorKind::InvalidOutput => "handler output was not valid JSON",
142            InvokeErrorKind::InvalidModel => "handler rejected the model configuration",
143            InvokeErrorKind::InvalidInput => "handler rejected the input",
144            InvokeErrorKind::UnsupportedCapability => "handler does not support the capability",
145            InvokeErrorKind::ExecutionFailed => "handler execution failed",
146        }
147    }
148
149    /// Whether the caller may retry the same request on a fresh handler.
150    pub const fn retryable(&self) -> bool {
151        matches!(self.kind, InvokeErrorKind::Timeout)
152    }
153}
154
155impl std::fmt::Display for InvokeError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match &self.detail {
158            Some(detail) => write!(f, "{}: {}", self.stable_code(), detail),
159            None => f.write_str(self.stable_code()),
160        }
161    }
162}
163
164impl std::error::Error for InvokeError {}
165
166/// Truncate `s` to at most `max_bytes` on a UTF-8 char boundary.
167///
168/// `String::truncate` panics on a non-char boundary, so we walk backwards
169/// from `max_bytes` until `is_char_boundary` succeeds. The result is always
170/// valid UTF-8 and never longer than `max_bytes`.
171fn truncate_to_bytes(s: String, max_bytes: usize) -> String {
172    if s.len() <= max_bytes {
173        return s;
174    }
175    let mut end = max_bytes;
176    while end > 0 && !s.is_char_boundary(end) {
177        end -= 1;
178    }
179    let mut truncated = s;
180    truncated.truncate(end);
181    truncated
182}
183
184/// Minimal host-side log sink for invoke diagnostics.
185///
186/// Production code uses [`StderrLogSink`]; tests inject
187/// [`CapturingLogSink`] to verify log content and bounds without capturing
188/// stderr. Keeping this trait tiny avoids pulling in a full logging
189/// framework while still making the runtime's only log call testable.
190///
191/// The sink receives a single pre-formatted message per invoke error. The
192/// message is constructed from the already-truncated
193/// [`InvokeError::detail`], so a malicious 16 KiB guest error payload can
194/// never produce a 16 KiB log line.
195pub trait HostLogSink: Send + Sync + std::fmt::Debug {
196    /// Emit a single log line. The implementation decides where it goes.
197    fn emit(&self, message: &str);
198}
199
200/// Default [`HostLogSink`] writing to stderr via `eprintln!`.
201#[derive(Debug, Default, Clone)]
202pub struct StderrLogSink;
203
204impl HostLogSink for StderrLogSink {
205    fn emit(&self, message: &str) {
206        eprintln!("{message}");
207    }
208}
209
210/// Test-only [`HostLogSink`] that captures every emitted message in a
211/// `Mutex<Vec<String>>`. Tests inspect the captured messages to verify
212/// log bounds, content, and deduplication without touching stderr.
213#[derive(Debug, Default)]
214pub struct CapturingLogSink {
215    messages: Mutex<Vec<String>>,
216}
217
218impl CapturingLogSink {
219    /// Create an empty capturing sink.
220    pub fn new() -> Self {
221        Self::default()
222    }
223
224    /// Return a snapshot of all captured messages in emission order.
225    pub fn messages(&self) -> Vec<String> {
226        self.messages
227            .lock()
228            .expect("CapturingLogSink poisoned")
229            .clone()
230    }
231
232    /// Total byte length of all captured messages. Useful for asserting
233    /// that a 16 KiB guest error did not produce a 16 KiB log.
234    pub fn total_bytes(&self) -> usize {
235        self.messages
236            .lock()
237            .expect("CapturingLogSink poisoned")
238            .iter()
239            .map(String::len)
240            .sum()
241    }
242
243    /// Drop all captured messages.
244    pub fn clear(&self) {
245        self.messages
246            .lock()
247            .expect("CapturingLogSink poisoned")
248            .clear();
249    }
250}
251
252impl HostLogSink for CapturingLogSink {
253    fn emit(&self, message: &str) {
254        self.messages
255            .lock()
256            .expect("CapturingLogSink poisoned")
257            .push(message.to_string());
258    }
259}
260
261/// Consumers can implement this trait to add business-specific invocation logic.
262pub trait InvokeHandler: Send + Sync + std::fmt::Debug {
263    fn invoke(&self, capability: &str, input: &Value) -> Result<Value, InvokeError>;
264}
265
266/// Internal response type produced by [`RuntimeEngine`]. The IPC layer converts
267/// this to a v1 [`RuntimeResponse`] or v2 [`RuntimeResponseV2`] based on the
268/// request's `api_version`.
269#[derive(Debug, Clone)]
270pub enum EngineResponse {
271    Handshake {
272        request_id: String,
273        runtime_version: String,
274        model_pack_id: String,
275        model_pack_version: String,
276        capabilities: Vec<String>,
277        handler: Option<HandlerIdentity>,
278    },
279    Health {
280        request_id: String,
281        healthy: bool,
282        model_pack_id: String,
283        model_pack_version: String,
284    },
285    Result {
286        request_id: String,
287        output: Value,
288    },
289    Error {
290        request_id: String,
291        code: String,
292        message: String,
293        retryable: bool,
294    },
295}
296
297impl EngineResponse {
298    /// Convert to a v1 wire response. Handler identity fields are dropped.
299    pub fn to_v1(&self, api_version: u32) -> RuntimeResponse {
300        match self {
301            Self::Handshake {
302                request_id,
303                runtime_version,
304                model_pack_id,
305                model_pack_version,
306                capabilities,
307                ..
308            } => RuntimeResponse::Handshake {
309                request_id: request_id.clone(),
310                api_version,
311                runtime_version: runtime_version.clone(),
312                model_pack_id: model_pack_id.clone(),
313                model_pack_version: model_pack_version.clone(),
314                capabilities: capabilities.clone(),
315            },
316            Self::Health {
317                request_id,
318                healthy,
319                model_pack_id,
320                model_pack_version,
321            } => RuntimeResponse::Health {
322                request_id: request_id.clone(),
323                api_version,
324                healthy: *healthy,
325                model_pack_id: model_pack_id.clone(),
326                model_pack_version: model_pack_version.clone(),
327            },
328            Self::Result { request_id, output } => RuntimeResponse::Result {
329                request_id: request_id.clone(),
330                api_version,
331                output: output.clone(),
332            },
333            Self::Error {
334                request_id,
335                code,
336                message,
337                retryable,
338            } => RuntimeResponse::Error {
339                request_id: request_id.clone(),
340                api_version,
341                code: code.clone(),
342                message: message.clone(),
343                retryable: *retryable,
344            },
345        }
346    }
347
348    /// Convert to a v2 wire response. If no handler is loaded, handler fields
349    /// are filled with empty/zero values and effective_capabilities equals the
350    /// model capabilities.
351    pub fn to_v2(&self, api_version: u32) -> RuntimeResponseV2 {
352        match self {
353            Self::Handshake {
354                request_id,
355                runtime_version,
356                model_pack_id,
357                model_pack_version,
358                capabilities,
359                handler,
360            } => {
361                let (handler_id, handler_version, handler_api_version, effective) = match handler {
362                    Some(h) => (
363                        h.handler_id.clone(),
364                        h.handler_version.clone(),
365                        h.handler_api_version,
366                        h.effective_capabilities.clone(),
367                    ),
368                    None => (String::new(), String::new(), 0, capabilities.clone()),
369                };
370                RuntimeResponseV2::Handshake {
371                    request_id: request_id.clone(),
372                    api_version,
373                    runtime_version: runtime_version.clone(),
374                    model_pack_id: model_pack_id.clone(),
375                    model_pack_version: model_pack_version.clone(),
376                    capabilities: capabilities.clone(),
377                    handler_id,
378                    handler_version,
379                    handler_api_version,
380                    effective_capabilities: effective,
381                }
382            }
383            Self::Health {
384                request_id,
385                healthy,
386                model_pack_id,
387                model_pack_version,
388            } => RuntimeResponseV2::Health {
389                request_id: request_id.clone(),
390                api_version,
391                healthy: *healthy,
392                model_pack_id: model_pack_id.clone(),
393                model_pack_version: model_pack_version.clone(),
394            },
395            Self::Result { request_id, output } => RuntimeResponseV2::Result {
396                request_id: request_id.clone(),
397                api_version,
398                output: output.clone(),
399            },
400            Self::Error {
401                request_id,
402                code,
403                message,
404                retryable,
405            } => RuntimeResponseV2::Error {
406                request_id: request_id.clone(),
407                api_version,
408                code: code.clone(),
409                message: message.clone(),
410                retryable: *retryable,
411            },
412        }
413    }
414}
415
416#[derive(Debug, Clone)]
417pub struct RuntimeEngine {
418    pack: LoadedModelPack,
419    invoke_handler: Option<Arc<dyn InvokeHandler>>,
420    handler_identity: Option<HandlerIdentity>,
421    effective_capabilities: Vec<String>,
422    log_sink: Arc<dyn HostLogSink>,
423}
424
425impl RuntimeEngine {
426    pub fn new(pack: LoadedModelPack) -> Self {
427        Self {
428            pack,
429            invoke_handler: None,
430            handler_identity: None,
431            effective_capabilities: Vec::new(),
432            log_sink: Arc::new(StderrLogSink),
433        }
434    }
435
436    pub fn with_invoke_handler(mut self, handler: Arc<dyn InvokeHandler>) -> Self {
437        self.invoke_handler = Some(handler);
438        self
439    }
440
441    /// Replace the default [`StderrLogSink`] with a custom sink. Tests
442    /// inject a [`CapturingLogSink`] to verify log bounds and content
443    /// without capturing stderr.
444    pub fn with_log_sink(mut self, sink: Arc<dyn HostLogSink>) -> Self {
445        self.log_sink = sink;
446        self
447    }
448
449    /// Attach handler identity and effective capabilities for IPC v2 handshake.
450    pub fn with_handler_identity(mut self, identity: HandlerIdentity) -> Self {
451        self.effective_capabilities = identity.effective_capabilities.clone();
452        self.handler_identity = Some(identity);
453        self
454    }
455
456    /// Effective capability set (intersection of model and handler). Empty when
457    /// no handler is loaded.
458    pub fn effective_capabilities(&self) -> &[String] {
459        &self.effective_capabilities
460    }
461
462    /// Handler identity if a handler was loaded.
463    pub fn handler_identity(&self) -> Option<&HandlerIdentity> {
464        self.handler_identity.as_ref()
465    }
466
467    pub fn handle(&self, request: RuntimeRequest) -> EngineResponse {
468        let request_id = request.request_id().to_string();
469        if request_id.is_empty() || request_id.len() > 128 {
470            return self.error(request_id, "invalidRequestId", "invalid request id", false);
471        }
472        let api_version = request.api_version();
473        if !(MIN_RUNTIME_API_VERSION..=RUNTIME_API_VERSION).contains(&api_version) {
474            return self.error(
475                request_id,
476                "incompatibleApiVersion",
477                "runtime API version is not supported",
478                false,
479            );
480        }
481
482        match request {
483            RuntimeRequest::Handshake {
484                request_id,
485                client_name,
486                client_version,
487                ..
488            } => {
489                if client_name.is_empty()
490                    || client_name.len() > 96
491                    || client_version.is_empty()
492                    || client_version.len() > 48
493                {
494                    return self.error(
495                        request_id,
496                        "invalidClientIdentity",
497                        "invalid client identity",
498                        false,
499                    );
500                }
501                EngineResponse::Handshake {
502                    request_id,
503                    runtime_version: env!("CARGO_PKG_VERSION").into(),
504                    model_pack_id: self.pack.manifest.id.clone(),
505                    model_pack_version: self.pack.manifest.version.clone(),
506                    capabilities: self.pack.manifest.capabilities.clone(),
507                    handler: self.handler_identity.clone(),
508                }
509            }
510            RuntimeRequest::Health { request_id, .. } => EngineResponse::Health {
511                request_id,
512                healthy: true,
513                model_pack_id: self.pack.manifest.id.clone(),
514                model_pack_version: self.pack.manifest.version.clone(),
515            },
516            RuntimeRequest::Invoke {
517                request_id,
518                capability,
519                input,
520                ..
521            } => {
522                if !self.is_capability_allowed(&capability) {
523                    return self.error(
524                        request_id,
525                        "unsupportedCapability",
526                        "capability is not in the effective set",
527                        false,
528                    );
529                }
530                let Some(handler) = &self.invoke_handler else {
531                    return self.error(
532                        request_id,
533                        "noInvokeHandler",
534                        "no invoke handler registered",
535                        false,
536                    );
537                };
538                match handler.invoke(&capability, &input) {
539                    Ok(output) => EngineResponse::Result { request_id, output },
540                    Err(invoke_err) => {
541                        // Log host-side detail (if any) for debugging; the
542                        // IPC message is always the fixed public string so
543                        // guests cannot exfiltrate content via the error
544                        // payload. The detail is already truncated to
545                        // [`MAX_DETAIL_BYTES`] by [`InvokeError::with_detail`],
546                        // so a 16 KiB guest payload can never produce a
547                        // 16 KiB log line. This is the single log call for
548                        // invoke errors; the WASM adapter must not also
549                        // log the same error (see audit 5.2).
550                        if let Some(detail) = invoke_err.detail() {
551                            self.log_sink.emit(&format!(
552                                "rill-runtime: invoke {} -> {} (detail: {})",
553                                capability,
554                                invoke_err.stable_code(),
555                                detail
556                            ));
557                        }
558                        self.error(
559                            request_id,
560                            invoke_err.stable_code(),
561                            invoke_err.public_message(),
562                            invoke_err.retryable(),
563                        )
564                    }
565                }
566            }
567        }
568    }
569
570    /// Checks the capability against the effective set when a handler is loaded,
571    /// or against the model pack's declared capabilities when no handler is
572    /// loaded (for backwards compatibility with built-in handlers selected by
573    /// the binary).
574    fn is_capability_allowed(&self, capability: &str) -> bool {
575        if !self.effective_capabilities.is_empty() {
576            self.effective_capabilities.iter().any(|c| c == capability)
577        } else {
578            self.pack
579                .manifest
580                .capabilities
581                .iter()
582                .any(|c| c == capability)
583        }
584    }
585
586    fn error(
587        &self,
588        request_id: String,
589        code: &str,
590        message: &str,
591        retryable: bool,
592    ) -> EngineResponse {
593        EngineResponse::Error {
594            request_id,
595            code: code.into(),
596            message: message.into(),
597            retryable,
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use rill_runtime_protocol::{MODEL_PACK_FORMAT_VERSION, ModelPackManifest};
605
606    use super::*;
607    use crate::handler::builtin::LINEAR_REGRESSION_CAPABILITY;
608
609    fn engine() -> RuntimeEngine {
610        RuntimeEngine::new(LoadedModelPack {
611            manifest: ModelPackManifest {
612                format_version: MODEL_PACK_FORMAT_VERSION,
613                id: "rillml.example.default".into(),
614                version: "0.7.0".into(),
615                runtime_api_version: RUNTIME_API_VERSION,
616                min_runtime_version: "0.7.0".into(),
617                publisher_key_id: "test".into(),
618                capabilities: vec!["rillml.example".into()],
619            },
620            model: serde_json::json!({}),
621        })
622    }
623
624    #[test]
625    fn handshake_reports_loaded_pack() {
626        let response = engine().handle(RuntimeRequest::Handshake {
627            request_id: "hello".into(),
628            api_version: RUNTIME_API_VERSION,
629            client_name: "example-host".into(),
630            client_version: "0.9.0".into(),
631        });
632        assert!(matches!(
633            response,
634            EngineResponse::Handshake { model_pack_id, .. }
635                if model_pack_id == "rillml.example.default"
636        ));
637    }
638
639    #[test]
640    fn incompatible_api_is_a_typed_error() {
641        let response = engine().handle(RuntimeRequest::Health {
642            request_id: "health".into(),
643            api_version: RUNTIME_API_VERSION + 1,
644        });
645        assert!(matches!(
646            response,
647            EngineResponse::Error { code, .. } if code == "incompatibleApiVersion"
648        ));
649    }
650
651    #[test]
652    fn invoke_without_handler_returns_no_invoke_handler_error() {
653        let response = engine().handle(RuntimeRequest::Invoke {
654            request_id: "invoke-1".into(),
655            api_version: RUNTIME_API_VERSION,
656            capability: "rillml.example".into(),
657            input: serde_json::json!({}),
658        });
659        assert!(matches!(
660            response,
661            EngineResponse::Error { code, .. } if code == "noInvokeHandler"
662        ));
663    }
664
665    #[test]
666    fn invoke_rejects_capability_not_declared_by_signed_manifest() {
667        let response = engine().handle(RuntimeRequest::Invoke {
668            request_id: "invoke-undeclared".into(),
669            api_version: RUNTIME_API_VERSION,
670            capability: "undeclared.capability".into(),
671            input: serde_json::json!({}),
672        });
673        assert!(matches!(
674            response,
675            EngineResponse::Error { code, .. } if code == "unsupportedCapability"
676        ));
677    }
678
679    #[test]
680    fn v1_handshake_omits_handler_fields() {
681        let identity = HandlerIdentity {
682            handler_id: "org.example.handler".into(),
683            handler_version: "1.0.0".into(),
684            handler_api_version: 1,
685            effective_capabilities: vec!["rillml.example".into()],
686        };
687        let engine = engine().with_handler_identity(identity);
688        let response = engine.handle(RuntimeRequest::Handshake {
689            request_id: "v1-test".into(),
690            api_version: 1,
691            client_name: "v1-host".into(),
692            client_version: "0.6.0".into(),
693        });
694        let v1 = response.to_v1(1);
695        let json = serde_json::to_string(&v1).unwrap();
696        assert!(!json.contains("handlerId"));
697        assert!(!json.contains("effectiveCapabilities"));
698    }
699
700    #[test]
701    fn v2_handshake_includes_handler_fields() {
702        let identity = HandlerIdentity {
703            handler_id: "org.example.handler".into(),
704            handler_version: "1.0.0".into(),
705            handler_api_version: 1,
706            effective_capabilities: vec!["rillml.example".into()],
707        };
708        let engine = engine().with_handler_identity(identity);
709        let response = engine.handle(RuntimeRequest::Handshake {
710            request_id: "v2-test".into(),
711            api_version: 2,
712            client_name: "v2-host".into(),
713            client_version: "0.7.0".into(),
714        });
715        let v2 = response.to_v2(2);
716        let json = serde_json::to_string(&v2).unwrap();
717        assert!(json.contains("\"handlerId\":\"org.example.handler\""));
718        assert!(json.contains("\"handlerApiVersion\":1"));
719        assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
720    }
721
722    #[test]
723    fn v2_handshake_without_handler_has_empty_fields() {
724        let response = engine().handle(RuntimeRequest::Handshake {
725            request_id: "v2-no-handler".into(),
726            api_version: 2,
727            client_name: "v2-host".into(),
728            client_version: "0.7.0".into(),
729        });
730        let v2 = response.to_v2(2);
731        match v2 {
732            RuntimeResponseV2::Handshake {
733                handler_id,
734                handler_version,
735                handler_api_version,
736                effective_capabilities,
737                ..
738            } => {
739                assert!(handler_id.is_empty());
740                assert!(handler_version.is_empty());
741                assert_eq!(handler_api_version, 0);
742                assert_eq!(effective_capabilities, vec!["rillml.example"]);
743            }
744            _ => panic!("expected handshake"),
745        }
746    }
747
748    #[test]
749    fn linear_regression_handler_validates_and_predicts() {
750        use crate::handler::builtin::LinearRegressionInvokeHandler;
751
752        let pack = LoadedModelPack {
753            manifest: ModelPackManifest {
754                format_version: MODEL_PACK_FORMAT_VERSION,
755                id: "rillml.example.default".into(),
756                version: "0.7.0".into(),
757                runtime_api_version: RUNTIME_API_VERSION,
758                min_runtime_version: "0.7.0".into(),
759                publisher_key_id: "test".into(),
760                capabilities: vec![LINEAR_REGRESSION_CAPABILITY.into()],
761            },
762            model: serde_json::json!({
763                "kind": "linearRegression",
764                "weights": [0.5, -0.25],
765                "intercept": 1.0
766            }),
767        };
768        let handler = LinearRegressionInvokeHandler::from_pack(&pack).unwrap();
769        let engine = RuntimeEngine::new(pack).with_invoke_handler(Arc::new(handler));
770        let response = engine.handle(RuntimeRequest::Invoke {
771            request_id: "invoke-linear".into(),
772            api_version: RUNTIME_API_VERSION,
773            capability: LINEAR_REGRESSION_CAPABILITY.into(),
774            input: serde_json::json!({"features": [4.0, 2.0]}),
775        });
776        assert!(matches!(
777            response,
778            EngineResponse::Result { output, .. } if output["prediction"] == 2.5
779        ));
780    }
781
782    #[test]
783    fn invoke_error_stable_codes_match_wire_format() {
784        // Every kind must map to the exact IPC code expected by v1/v2
785        // clients, preserving backwards compatibility with the previous
786        // `map_invoke_error` string matching.
787        assert_eq!(
788            InvokeError::new(InvokeErrorKind::Trap).stable_code(),
789            "handlerTrap"
790        );
791        assert_eq!(
792            InvokeError::new(InvokeErrorKind::Timeout).stable_code(),
793            "handlerTimeout"
794        );
795        assert_eq!(
796            InvokeError::new(InvokeErrorKind::OutputTooLarge).stable_code(),
797            "handlerOutputTooLarge"
798        );
799        assert_eq!(
800            InvokeError::new(InvokeErrorKind::InvalidOutput).stable_code(),
801            "handlerInvalidOutput"
802        );
803        assert_eq!(
804            InvokeError::new(InvokeErrorKind::Internal).stable_code(),
805            "handlerInternalError"
806        );
807        // All four guest-reported WIT handler-error variants collapse to
808        // handlerInternalError on the wire, matching the previous
809        // `map_invoke_error` behaviour. The host distinguishes them
810        // internally via `kind()` for logging, but v1/v2 clients see
811        // the same code.
812        for kind in [
813            InvokeErrorKind::InvalidModel,
814            InvokeErrorKind::InvalidInput,
815            InvokeErrorKind::UnsupportedCapability,
816            InvokeErrorKind::ExecutionFailed,
817        ] {
818            assert_eq!(
819                InvokeError::new(kind).stable_code(),
820                "handlerInternalError",
821                "{kind:?} must map to handlerInternalError for v1/v2 compat"
822            );
823        }
824    }
825
826    #[test]
827    fn invoke_error_retryable_only_for_timeout() {
828        assert!(InvokeError::new(InvokeErrorKind::Timeout).retryable());
829        for kind in [
830            InvokeErrorKind::Trap,
831            InvokeErrorKind::OutputTooLarge,
832            InvokeErrorKind::InvalidOutput,
833            InvokeErrorKind::Internal,
834            InvokeErrorKind::InvalidModel,
835            InvokeErrorKind::InvalidInput,
836            InvokeErrorKind::UnsupportedCapability,
837            InvokeErrorKind::ExecutionFailed,
838        ] {
839            assert!(
840                !InvokeError::new(kind).retryable(),
841                "{kind:?} must not be retryable"
842            );
843        }
844    }
845
846    #[test]
847    fn invoke_error_guest_variants_have_distinct_public_messages() {
848        // Each guest variant carries a fixed public message that never
849        // contains guest-supplied content. The messages are distinct so
850        // operators can distinguish variants in host logs.
851        let messages = [
852            InvokeError::new(InvokeErrorKind::InvalidModel).public_message(),
853            InvokeError::new(InvokeErrorKind::InvalidInput).public_message(),
854            InvokeError::new(InvokeErrorKind::UnsupportedCapability).public_message(),
855            InvokeError::new(InvokeErrorKind::ExecutionFailed).public_message(),
856        ];
857        // All distinct.
858        for i in 0..messages.len() {
859            for j in (i + 1)..messages.len() {
860                assert_ne!(messages[i], messages[j], "public messages must be distinct");
861            }
862        }
863        // None contain guest content markers.
864        for msg in messages {
865            assert!(!msg.contains("detail"));
866            assert!(!msg.contains("guest"));
867        }
868    }
869
870    #[test]
871    fn invoke_error_public_message_never_contains_detail() {
872        // Guest can fully control the detail string; the public message
873        // must always be the fixed constant.
874        let err = InvokeError::with_detail(
875            InvokeErrorKind::ExecutionFailed,
876            "SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload",
877        );
878        assert_eq!(err.public_message(), "handler execution failed");
879        assert_eq!(err.stable_code(), "handlerInternalError");
880        assert_eq!(
881            err.detail(),
882            Some("SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload")
883        );
884        // The Display impl is for host logs only; the IPC layer must
885        // never send `err.to_string()` to clients.
886        assert!(err.to_string().contains("SECRET-TOKEN-LEAK-ATTEMPT"));
887        // The public_message is what the IPC layer actually sends.
888        assert!(!err.public_message().contains("SECRET"));
889    }
890
891    #[test]
892    fn invoke_error_without_detail_has_no_detail() {
893        let err = InvokeError::new(InvokeErrorKind::Trap);
894        assert_eq!(err.kind(), InvokeErrorKind::Trap);
895        assert_eq!(err.detail(), None);
896        assert_eq!(err.stable_code(), "handlerTrap");
897        assert_eq!(err.to_string(), "handlerTrap");
898    }
899
900    #[test]
901    fn invoke_error_detail_is_truncated_to_4kib_on_char_boundary() {
902        // A malicious guest tries to grow host memory via an oversized
903        // error payload. The host must truncate to MAX_DETAIL_BYTES on a
904        // UTF-8 char boundary.
905        let huge = "A".repeat(MAX_DETAIL_BYTES * 4);
906        let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, huge);
907        let detail = err.detail().expect("detail must be stored");
908        assert!(
909            detail.len() <= MAX_DETAIL_BYTES,
910            "detail length {} must not exceed {}",
911            detail.len(),
912            MAX_DETAIL_BYTES
913        );
914        // Truncation must land on a char boundary (the string is valid UTF-8
915        // by construction, but the test guards against a future unsafe path).
916        assert!(detail.chars().all(|c| c == 'A'));
917    }
918
919    #[test]
920    fn invoke_error_detail_truncation_respects_multibyte_chars() {
921        // Multi-byte UTF-8 must not be split mid-codepoint. Use 3-byte
922        // CJK characters so the MAX_DETAIL_BYTES boundary lands inside a
923        // character; the result must back up to the previous char boundary.
924        let emoji = "🌟".repeat(MAX_DETAIL_BYTES); // each '🌟' is 4 bytes
925        let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, emoji);
926        let detail = err.detail().expect("detail must be stored");
927        assert!(detail.len() <= MAX_DETAIL_BYTES);
928        // Every stored character must be a complete '🌟'.
929        for c in detail.chars() {
930            assert_eq!(c, '🌟');
931        }
932    }
933
934    /// Minimal handler that always returns the supplied error, for
935    /// exercising the engine's invoke error path without a WASM sandbox.
936    #[derive(Debug)]
937    struct FailingHandler {
938        err: InvokeError,
939    }
940
941    impl InvokeHandler for FailingHandler {
942        fn invoke(&self, _capability: &str, _input: &Value) -> Result<Value, InvokeError> {
943            Err(self.err.clone())
944        }
945    }
946
947    #[test]
948    fn engine_invoke_error_does_not_leak_guest_detail_in_message() {
949        // A malicious guest tries to exfiltrate a token via the WIT
950        // handler-error payload. The IPC Error.message field must be
951        // the fixed public string, not the guest-supplied detail.
952        let err = InvokeError::with_detail(
953            InvokeErrorKind::ExecutionFailed,
954            "leak-attempt:SECRET-TOKEN",
955        );
956        let pack = LoadedModelPack {
957            manifest: ModelPackManifest {
958                format_version: MODEL_PACK_FORMAT_VERSION,
959                id: "rillml.example.default".into(),
960                version: "0.7.0".into(),
961                runtime_api_version: RUNTIME_API_VERSION,
962                min_runtime_version: "0.7.0".into(),
963                publisher_key_id: "test".into(),
964                capabilities: vec!["rillml.example".into()],
965            },
966            model: serde_json::json!({}),
967        };
968        let sink = Arc::new(CapturingLogSink::new());
969        let engine = RuntimeEngine::new(pack)
970            .with_invoke_handler(Arc::new(FailingHandler { err }))
971            .with_log_sink(sink.clone());
972        let response = engine.handle(RuntimeRequest::Invoke {
973            request_id: "leak-test".into(),
974            api_version: RUNTIME_API_VERSION,
975            capability: "rillml.example".into(),
976            input: serde_json::json!({}),
977        });
978        match response {
979            EngineResponse::Error {
980                code,
981                message,
982                retryable,
983                ..
984            } => {
985                assert_eq!(code, "handlerInternalError");
986                assert_eq!(message, "handler execution failed");
987                assert!(!retryable);
988                // The guest-supplied detail must NOT appear anywhere in
989                // the IPC response fields.
990                assert!(!message.contains("SECRET"));
991                assert!(!message.contains("leak-attempt"));
992            }
993            _ => panic!("expected EngineResponse::Error"),
994        }
995        // The host log line does contain the (truncated) detail for
996        // operator diagnostics, but the detail is host-only — it never
997        // reaches the IPC `message` field. This assertion documents that
998        // the log sink received exactly one message referencing the
999        // secret, proving the detail was captured host-side.
1000        let messages = sink.messages();
1001        assert_eq!(
1002            messages.len(),
1003            1,
1004            "the engine must log the invoke error exactly once"
1005        );
1006        assert!(messages[0].contains("SECRET-TOKEN"));
1007    }
1008
1009    /// Verifies audit 5.2: a 16 KiB guest error payload must not produce
1010    /// a 16 KiB log line. The host constructs `InvokeError::with_detail`
1011    /// (which truncates to `MAX_DETAIL_BYTES`) before logging, so the
1012    /// captured log message must be well under 16 KiB.
1013    #[test]
1014    fn engine_log_does_not_emit_oversized_guest_detail() {
1015        let huge_detail = "X".repeat(MAX_DETAIL_BYTES * 4); // 16 KiB
1016        let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, huge_detail);
1017        let pack = LoadedModelPack {
1018            manifest: ModelPackManifest {
1019                format_version: MODEL_PACK_FORMAT_VERSION,
1020                id: "rillml.example.default".into(),
1021                version: "0.7.0".into(),
1022                runtime_api_version: RUNTIME_API_VERSION,
1023                min_runtime_version: "0.7.0".into(),
1024                publisher_key_id: "test".into(),
1025                capabilities: vec!["rillml.example".into()],
1026            },
1027            model: serde_json::json!({}),
1028        };
1029        let sink = Arc::new(CapturingLogSink::new());
1030        let engine = RuntimeEngine::new(pack)
1031            .with_invoke_handler(Arc::new(FailingHandler { err }))
1032            .with_log_sink(sink.clone());
1033        let _ = engine.handle(RuntimeRequest::Invoke {
1034            request_id: "oversized".into(),
1035            api_version: RUNTIME_API_VERSION,
1036            capability: "rillml.example".into(),
1037            input: serde_json::json!({}),
1038        });
1039        let messages = sink.messages();
1040        assert_eq!(messages.len(), 1, "exactly one log line expected");
1041        let log_line = &messages[0];
1042        // The log line consists of a fixed prefix + the truncated detail.
1043        // The detail is at most MAX_DETAIL_BYTES; the prefix is small.
1044        // 16 KiB must never appear in the log.
1045        assert!(
1046            log_line.len() < MAX_DETAIL_BYTES * 2,
1047            "log line length {} must be well under 2x MAX_DETAIL_BYTES ({}); \
1048             a 16 KiB guest payload must not produce a 16 KiB log",
1049            log_line.len(),
1050            MAX_DETAIL_BYTES * 2
1051        );
1052        // The detail portion (after the prefix) must not exceed the cap.
1053        assert!(
1054            log_line.len() < MAX_DETAIL_BYTES + 256,
1055            "log line length {} must be < MAX_DETAIL_BYTES + prefix overhead",
1056            log_line.len()
1057        );
1058    }
1059
1060    /// Verifies audit 5.2: the same invoke error must not be logged
1061    /// twice. The WASM adapter must not log the error if the engine
1062    /// already logs it; this test uses a `FailingHandler` (no WASM
1063    /// adapter) and confirms exactly one log line per invoke.
1064    #[test]
1065    fn engine_logs_invoke_error_exactly_once() {
1066        let err = InvokeError::with_detail(
1067            InvokeErrorKind::UnsupportedCapability,
1068            "capability foo not supported",
1069        );
1070        let pack = LoadedModelPack {
1071            manifest: ModelPackManifest {
1072                format_version: MODEL_PACK_FORMAT_VERSION,
1073                id: "rillml.example.default".into(),
1074                version: "0.7.0".into(),
1075                runtime_api_version: RUNTIME_API_VERSION,
1076                min_runtime_version: "0.7.0".into(),
1077                publisher_key_id: "test".into(),
1078                capabilities: vec!["rillml.example".into()],
1079            },
1080            model: serde_json::json!({}),
1081        };
1082        let sink = Arc::new(CapturingLogSink::new());
1083        let engine = RuntimeEngine::new(pack)
1084            .with_invoke_handler(Arc::new(FailingHandler { err }))
1085            .with_log_sink(sink.clone());
1086        let _ = engine.handle(RuntimeRequest::Invoke {
1087            request_id: "once".into(),
1088            api_version: RUNTIME_API_VERSION,
1089            capability: "rillml.example".into(),
1090            input: serde_json::json!({}),
1091        });
1092        assert_eq!(
1093            sink.messages().len(),
1094            1,
1095            "the engine must log the invoke error exactly once, not twice"
1096        );
1097    }
1098
1099    /// Verifies audit 5.2: a trap backtrace (which can be very long)
1100    /// must be truncated before logging. The `FailingHandler` simulates
1101    /// a trap with a long backtrace-like detail string.
1102    #[test]
1103    fn engine_log_traps_backtrace_is_truncated() {
1104        let fake_backtrace = "trap: unreachable\n".repeat(1024); // ~17 KiB
1105        let err = InvokeError::with_detail(InvokeErrorKind::Trap, fake_backtrace);
1106        let pack = LoadedModelPack {
1107            manifest: ModelPackManifest {
1108                format_version: MODEL_PACK_FORMAT_VERSION,
1109                id: "rillml.example.default".into(),
1110                version: "0.7.0".into(),
1111                runtime_api_version: RUNTIME_API_VERSION,
1112                min_runtime_version: "0.7.0".into(),
1113                publisher_key_id: "test".into(),
1114                capabilities: vec!["rillml.example".into()],
1115            },
1116            model: serde_json::json!({}),
1117        };
1118        let sink = Arc::new(CapturingLogSink::new());
1119        let engine = RuntimeEngine::new(pack)
1120            .with_invoke_handler(Arc::new(FailingHandler { err }))
1121            .with_log_sink(sink.clone());
1122        let _ = engine.handle(RuntimeRequest::Invoke {
1123            request_id: "trap-trunc".into(),
1124            api_version: RUNTIME_API_VERSION,
1125            capability: "rillml.example".into(),
1126            input: serde_json::json!({}),
1127        });
1128        let messages = sink.messages();
1129        assert_eq!(messages.len(), 1);
1130        let log_line = &messages[0];
1131        assert!(
1132            log_line.len() < MAX_DETAIL_BYTES + 256,
1133            "trap backtrace log must be truncated; got {} bytes",
1134            log_line.len()
1135        );
1136    }
1137
1138    /// Verifies that all four guest WIT variants flow through the engine
1139    /// with the correct `kind()` and fixed public message, while the
1140    /// stable IPC code stays `handlerInternalError` for v1/v2 compat.
1141    #[test]
1142    fn engine_preserves_guest_variant_kind_for_all_wit_variants() {
1143        for (kind, expected_message) in [
1144            (
1145                InvokeErrorKind::InvalidModel,
1146                "handler rejected the model configuration",
1147            ),
1148            (InvokeErrorKind::InvalidInput, "handler rejected the input"),
1149            (
1150                InvokeErrorKind::UnsupportedCapability,
1151                "handler does not support the capability",
1152            ),
1153            (InvokeErrorKind::ExecutionFailed, "handler execution failed"),
1154        ] {
1155            let err = InvokeError::with_detail(kind, "guest detail");
1156            let pack = LoadedModelPack {
1157                manifest: ModelPackManifest {
1158                    format_version: MODEL_PACK_FORMAT_VERSION,
1159                    id: "rillml.example.default".into(),
1160                    version: "0.7.0".into(),
1161                    runtime_api_version: RUNTIME_API_VERSION,
1162                    min_runtime_version: "0.7.0".into(),
1163                    publisher_key_id: "test".into(),
1164                    capabilities: vec!["rillml.example".into()],
1165                },
1166                model: serde_json::json!({}),
1167            };
1168            let engine =
1169                RuntimeEngine::new(pack).with_invoke_handler(Arc::new(FailingHandler { err }));
1170            let response = engine.handle(RuntimeRequest::Invoke {
1171                request_id: "variant".into(),
1172                api_version: RUNTIME_API_VERSION,
1173                capability: "rillml.example".into(),
1174                input: serde_json::json!({}),
1175            });
1176            match response {
1177                EngineResponse::Error { code, message, .. } => {
1178                    assert_eq!(
1179                        code, "handlerInternalError",
1180                        "{kind:?}: stable code must stay handlerInternalError"
1181                    );
1182                    assert_eq!(
1183                        message, expected_message,
1184                        "{kind:?}: public message mismatch"
1185                    );
1186                }
1187                _ => panic!("{kind:?}: expected EngineResponse::Error"),
1188            }
1189        }
1190    }
1191}