Skip to main content

redevplugin_worker_sdk/
hostcalls.rs

1use crate::{MAX_HOSTCALL_RESPONSE_BYTES, WorkerError};
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::collections::BTreeMap;
5
6type Hostcall = unsafe extern "C" fn(i32, i32, i32, i32) -> i32;
7
8#[derive(Debug, Deserialize)]
9struct HostcallResponseDiscriminator {
10    ok: bool,
11}
12
13#[derive(Debug, Deserialize)]
14#[serde(deny_unknown_fields)]
15struct HostcallFailure {
16    ok: bool,
17    code: String,
18    message: String,
19    error_origin: String,
20}
21
22fn call_host<Request, Response>(
23    request: &Request,
24    hostcall: Hostcall,
25) -> Result<Response, WorkerError>
26where
27    Request: Serialize,
28    Response: DeserializeOwned,
29{
30    let request_bytes = serde_json::to_vec(request)
31        .map_err(|err| WorkerError::hostcall(format!("encode hostcall request: {err}")))?;
32    let mut response = vec![0_u8; MAX_HOSTCALL_RESPONSE_BYTES];
33    let written = unsafe {
34        hostcall(
35            request_bytes.as_ptr() as i32,
36            request_bytes.len() as i32,
37            response.as_mut_ptr() as i32,
38            response.len() as i32,
39        )
40    };
41    if written < 0 {
42        return Err(WorkerError::hostcall(format!(
43            "hostcall failed with ABI code {written}"
44        )));
45    }
46    let written = usize::try_from(written)
47        .map_err(|_| WorkerError::hostcall("hostcall response length is invalid"))?;
48    if written > response.len() {
49        return Err(WorkerError::hostcall(
50            "hostcall response exceeded the provided buffer",
51        ));
52    }
53    response.truncate(written);
54    decode_hostcall_response(&response)
55}
56
57fn decode_hostcall_response<Response>(response: &[u8]) -> Result<Response, WorkerError>
58where
59    Response: DeserializeOwned,
60{
61    let discriminator: HostcallResponseDiscriminator =
62        serde_json::from_slice(response).map_err(|err| {
63            WorkerError::hostcall(format!("decode hostcall response discriminator: {err}"))
64        })?;
65    if discriminator.ok {
66        return serde_json::from_slice(response).map_err(|err| {
67            WorkerError::hostcall(format!("decode typed hostcall response: {err}"))
68        });
69    }
70    let failure: HostcallFailure = serde_json::from_slice(response)
71        .map_err(|err| WorkerError::hostcall(format!("decode hostcall failure: {err}")))?;
72    let code = failure.code.trim();
73    let message = failure.message.trim();
74    if failure.ok
75        || failure.error_origin != "hostcall"
76        || !stable_error_code(code)
77        || message.is_empty()
78        || message.chars().count() > 4096
79    {
80        return Err(WorkerError::hostcall(
81            "hostcall failure response violates the closed contract",
82        ));
83    }
84    Err(WorkerError::new(code, message))
85}
86
87fn stable_error_code(value: &str) -> bool {
88    !value.is_empty()
89        && value.len() <= 128
90        && value.bytes().enumerate().all(|(index, byte)| {
91            byte.is_ascii_uppercase() || byte.is_ascii_digit() || (index > 0 && byte == b'_')
92        })
93        && value.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct Usage {
99    pub plugin_instance_id: String,
100    pub store_id: String,
101    pub usage_bytes: i64,
102    pub quota_bytes: i64,
103    pub usage_files: i64,
104    pub quota_files: i64,
105}
106
107#[derive(Serialize)]
108struct OperationRequest<'a, Request> {
109    operation: &'static str,
110    #[serde(flatten)]
111    request: &'a Request,
112}
113
114#[link(wasm_import_module = "redevplugin.storage")]
115unsafe extern "C" {
116    #[link_name = "files"]
117    fn storage_files_hostcall(
118        request_ptr: i32,
119        request_len: i32,
120        response_ptr: i32,
121        response_len: i32,
122    ) -> i32;
123    #[link_name = "kv"]
124    fn storage_kv_hostcall(
125        request_ptr: i32,
126        request_len: i32,
127        response_ptr: i32,
128        response_len: i32,
129    ) -> i32;
130    #[link_name = "sqlite"]
131    fn storage_sqlite_hostcall(
132        request_ptr: i32,
133        request_len: i32,
134        response_ptr: i32,
135        response_len: i32,
136    ) -> i32;
137}
138
139#[link(wasm_import_module = "redevplugin.network")]
140unsafe extern "C" {
141    #[link_name = "execute"]
142    fn network_execute_hostcall(
143        request_ptr: i32,
144        request_len: i32,
145        response_ptr: i32,
146        response_len: i32,
147    ) -> i32;
148}
149
150pub mod storage {
151    use super::*;
152
153    pub mod files {
154        use super::*;
155
156        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157        #[serde(deny_unknown_fields)]
158        pub struct ReadRequest {
159            pub store_id: String,
160            pub path: String,
161            #[serde(default, skip_serializing_if = "Option::is_none")]
162            pub max_bytes: Option<u64>,
163        }
164
165        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166        #[serde(deny_unknown_fields)]
167        pub struct ReadResponse {
168            pub ok: bool,
169            pub path: String,
170            pub data_base64: String,
171            pub size_bytes: i64,
172            pub usage: Usage,
173        }
174
175        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176        #[serde(deny_unknown_fields)]
177        pub struct WriteRequest {
178            pub store_id: String,
179            pub path: String,
180            pub data_base64: String,
181        }
182
183        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184        #[serde(deny_unknown_fields)]
185        pub struct WriteResponse {
186            pub ok: bool,
187            pub path: String,
188            pub size_bytes: i64,
189            pub usage: Usage,
190        }
191
192        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193        #[serde(deny_unknown_fields)]
194        pub struct DeleteRequest {
195            pub store_id: String,
196            pub path: String,
197            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
198            pub recursive: bool,
199        }
200
201        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202        #[serde(deny_unknown_fields)]
203        pub struct DeleteResponse {
204            pub ok: bool,
205            pub path: String,
206        }
207
208        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209        #[serde(deny_unknown_fields)]
210        pub struct ListRequest {
211            pub store_id: String,
212            #[serde(default, skip_serializing_if = "String::is_empty")]
213            pub path: String,
214            #[serde(default, skip_serializing_if = "Option::is_none")]
215            pub max_entries: Option<u32>,
216        }
217
218        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219        #[serde(deny_unknown_fields)]
220        pub struct Entry {
221            pub path: String,
222            pub dir: bool,
223            #[serde(default)]
224            pub size_bytes: i64,
225            pub updated_at: String,
226        }
227
228        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229        #[serde(deny_unknown_fields)]
230        pub struct ListResponse {
231            pub ok: bool,
232            pub path: String,
233            pub entries: Vec<Entry>,
234            pub usage: Usage,
235        }
236
237        pub fn read(request: ReadRequest) -> Result<ReadResponse, WorkerError> {
238            call_host(
239                &OperationRequest {
240                    operation: "read",
241                    request: &request,
242                },
243                storage_files_hostcall,
244            )
245        }
246
247        pub fn write(request: WriteRequest) -> Result<WriteResponse, WorkerError> {
248            call_host(
249                &OperationRequest {
250                    operation: "write",
251                    request: &request,
252                },
253                storage_files_hostcall,
254            )
255        }
256
257        pub fn delete(request: DeleteRequest) -> Result<DeleteResponse, WorkerError> {
258            call_host(
259                &OperationRequest {
260                    operation: "delete",
261                    request: &request,
262                },
263                storage_files_hostcall,
264            )
265        }
266
267        pub fn list(request: ListRequest) -> Result<ListResponse, WorkerError> {
268            call_host(
269                &OperationRequest {
270                    operation: "list",
271                    request: &request,
272                },
273                storage_files_hostcall,
274            )
275        }
276    }
277
278    pub mod kv {
279        use super::*;
280
281        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282        #[serde(deny_unknown_fields)]
283        pub struct GetRequest {
284            pub store_id: String,
285            pub key: String,
286            #[serde(default, skip_serializing_if = "Option::is_none")]
287            pub max_bytes: Option<u64>,
288        }
289
290        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291        #[serde(deny_unknown_fields)]
292        pub struct GetResponse {
293            pub ok: bool,
294            pub key: String,
295            pub value_base64: String,
296            pub size_bytes: i64,
297            pub usage: Usage,
298        }
299
300        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301        #[serde(deny_unknown_fields)]
302        pub struct PutRequest {
303            pub store_id: String,
304            pub key: String,
305            pub value_base64: String,
306        }
307
308        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309        #[serde(deny_unknown_fields)]
310        pub struct PutResponse {
311            pub ok: bool,
312            pub key: String,
313            pub size_bytes: i64,
314            pub usage: Usage,
315        }
316
317        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318        #[serde(deny_unknown_fields)]
319        pub struct DeleteRequest {
320            pub store_id: String,
321            pub key: String,
322        }
323
324        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325        #[serde(deny_unknown_fields)]
326        pub struct DeleteResponse {
327            pub ok: bool,
328            pub key: String,
329        }
330
331        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332        #[serde(deny_unknown_fields)]
333        pub struct ListRequest {
334            pub store_id: String,
335            #[serde(default, skip_serializing_if = "String::is_empty")]
336            pub prefix: String,
337            #[serde(default, skip_serializing_if = "Option::is_none")]
338            pub max_entries: Option<u32>,
339        }
340
341        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342        #[serde(deny_unknown_fields)]
343        pub struct Entry {
344            pub key: String,
345            pub size_bytes: i64,
346            pub updated_at: String,
347        }
348
349        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350        #[serde(deny_unknown_fields)]
351        pub struct ListResponse {
352            pub ok: bool,
353            #[serde(default)]
354            pub prefix: String,
355            pub entries: Vec<Entry>,
356            pub usage: Usage,
357        }
358
359        pub fn get(request: GetRequest) -> Result<GetResponse, WorkerError> {
360            call_host(
361                &OperationRequest {
362                    operation: "get",
363                    request: &request,
364                },
365                storage_kv_hostcall,
366            )
367        }
368
369        pub fn put(request: PutRequest) -> Result<PutResponse, WorkerError> {
370            call_host(
371                &OperationRequest {
372                    operation: "put",
373                    request: &request,
374                },
375                storage_kv_hostcall,
376            )
377        }
378
379        pub fn delete(request: DeleteRequest) -> Result<DeleteResponse, WorkerError> {
380            call_host(
381                &OperationRequest {
382                    operation: "delete",
383                    request: &request,
384                },
385                storage_kv_hostcall,
386            )
387        }
388
389        pub fn list(request: ListRequest) -> Result<ListResponse, WorkerError> {
390            call_host(
391                &OperationRequest {
392                    operation: "list",
393                    request: &request,
394                },
395                storage_kv_hostcall,
396            )
397        }
398    }
399
400    pub mod sqlite {
401        use super::*;
402
403        #[derive(Debug, Clone, PartialEq)]
404        pub enum Value {
405            Null,
406            Integer(i64),
407            Float(f64),
408            Text(String),
409            BlobBase64(String),
410        }
411
412        #[derive(Serialize, Deserialize)]
413        #[serde(deny_unknown_fields)]
414        struct ValueWire {
415            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
416            null: bool,
417            #[serde(default, skip_serializing_if = "Option::is_none")]
418            int: Option<i64>,
419            #[serde(default, skip_serializing_if = "Option::is_none")]
420            float: Option<f64>,
421            #[serde(default, skip_serializing_if = "Option::is_none")]
422            text: Option<String>,
423            #[serde(default, skip_serializing_if = "Option::is_none")]
424            blob_base64: Option<String>,
425        }
426
427        impl Serialize for Value {
428            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
429            where
430                S: Serializer,
431            {
432                let wire = match self {
433                    Self::Null => ValueWire {
434                        null: true,
435                        int: None,
436                        float: None,
437                        text: None,
438                        blob_base64: None,
439                    },
440                    Self::Integer(value) => ValueWire {
441                        null: false,
442                        int: Some(*value),
443                        float: None,
444                        text: None,
445                        blob_base64: None,
446                    },
447                    Self::Float(value) => ValueWire {
448                        null: false,
449                        int: None,
450                        float: Some(*value),
451                        text: None,
452                        blob_base64: None,
453                    },
454                    Self::Text(value) => ValueWire {
455                        null: false,
456                        int: None,
457                        float: None,
458                        text: Some(value.clone()),
459                        blob_base64: None,
460                    },
461                    Self::BlobBase64(value) => ValueWire {
462                        null: false,
463                        int: None,
464                        float: None,
465                        text: None,
466                        blob_base64: Some(value.clone()),
467                    },
468                };
469                wire.serialize(serializer)
470            }
471        }
472
473        impl<'de> Deserialize<'de> for Value {
474            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
475            where
476                D: Deserializer<'de>,
477            {
478                let wire = ValueWire::deserialize(deserializer)?;
479                let variants = usize::from(wire.null)
480                    + usize::from(wire.int.is_some())
481                    + usize::from(wire.float.is_some())
482                    + usize::from(wire.text.is_some())
483                    + usize::from(wire.blob_base64.is_some());
484                if variants != 1 {
485                    return Err(serde::de::Error::custom(
486                        "SQLite value must contain exactly one typed field",
487                    ));
488                }
489                if wire.null {
490                    return Ok(Self::Null);
491                }
492                if let Some(value) = wire.int {
493                    return Ok(Self::Integer(value));
494                }
495                if let Some(value) = wire.float {
496                    return Ok(Self::Float(value));
497                }
498                if let Some(value) = wire.text {
499                    return Ok(Self::Text(value));
500                }
501                Ok(Self::BlobBase64(wire.blob_base64.unwrap_or_default()))
502            }
503        }
504
505        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506        #[serde(deny_unknown_fields)]
507        pub struct ExecRequest {
508            pub store_id: String,
509            #[serde(default, skip_serializing_if = "String::is_empty")]
510            pub database: String,
511            pub sql: String,
512            #[serde(default, skip_serializing_if = "Vec::is_empty")]
513            pub args: Vec<Value>,
514            #[serde(default, skip_serializing_if = "Option::is_none")]
515            pub timeout_ms: Option<u64>,
516        }
517
518        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
519        #[serde(deny_unknown_fields)]
520        pub struct ExecResponse {
521            pub ok: bool,
522            pub database: String,
523            pub rows_affected: i64,
524            #[serde(default)]
525            pub last_insert_id: i64,
526            pub usage: Usage,
527        }
528
529        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530        #[serde(deny_unknown_fields)]
531        pub struct QueryRequest {
532            pub store_id: String,
533            #[serde(default, skip_serializing_if = "String::is_empty")]
534            pub database: String,
535            pub sql: String,
536            #[serde(default, skip_serializing_if = "Vec::is_empty")]
537            pub args: Vec<Value>,
538            #[serde(default, skip_serializing_if = "Option::is_none")]
539            pub max_rows: Option<u32>,
540            #[serde(default, skip_serializing_if = "Option::is_none")]
541            pub max_response_bytes: Option<u64>,
542            #[serde(default, skip_serializing_if = "Option::is_none")]
543            pub timeout_ms: Option<u64>,
544        }
545
546        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
547        #[serde(deny_unknown_fields)]
548        pub struct QueryResponse {
549            pub ok: bool,
550            pub database: String,
551            pub columns: Vec<String>,
552            pub rows: Vec<Vec<Value>>,
553            pub usage: Usage,
554        }
555
556        pub fn exec(request: ExecRequest) -> Result<ExecResponse, WorkerError> {
557            call_host(
558                &OperationRequest {
559                    operation: "exec",
560                    request: &request,
561                },
562                storage_sqlite_hostcall,
563            )
564        }
565
566        pub fn query(request: QueryRequest) -> Result<QueryResponse, WorkerError> {
567            call_host(
568                &OperationRequest {
569                    operation: "query",
570                    request: &request,
571                },
572                storage_sqlite_hostcall,
573            )
574        }
575    }
576}
577
578pub mod network {
579    use super::*;
580
581    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
582    #[serde(rename_all = "snake_case")]
583    pub enum Transport {
584        Http,
585        Websocket,
586        Tcp,
587        Udp,
588    }
589
590    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
591    #[serde(rename_all = "snake_case")]
592    pub enum Operation {
593        Http,
594        HttpStream,
595        WebsocketRoundTrip,
596        TcpRoundTrip,
597        UdpRoundTrip,
598    }
599
600    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601    #[serde(deny_unknown_fields)]
602    pub struct ExecuteRequest {
603        pub connector_id: String,
604        pub transport: Transport,
605        pub destination: String,
606        pub operation: Operation,
607        #[serde(default, skip_serializing_if = "String::is_empty")]
608        pub method: String,
609        #[serde(default, skip_serializing_if = "String::is_empty")]
610        pub path: String,
611        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
612        pub query: BTreeMap<String, Vec<String>>,
613        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
614        pub headers: BTreeMap<String, Vec<String>>,
615        #[serde(default, skip_serializing_if = "String::is_empty")]
616        pub message_type: String,
617        #[serde(default, skip_serializing_if = "String::is_empty")]
618        pub body_base64: String,
619        #[serde(default, skip_serializing_if = "String::is_empty")]
620        pub payload_base64: String,
621        #[serde(default, skip_serializing_if = "Option::is_none")]
622        pub ttl_ms: Option<u64>,
623        #[serde(default, skip_serializing_if = "Option::is_none")]
624        pub max_request_bytes: Option<u64>,
625        #[serde(default, skip_serializing_if = "Option::is_none")]
626        pub max_response_bytes: Option<u64>,
627        #[serde(default, skip_serializing_if = "Option::is_none")]
628        pub max_chunk_bytes: Option<u64>,
629        #[serde(default, skip_serializing_if = "Option::is_none")]
630        pub max_buffered_bytes: Option<u64>,
631        #[serde(default, skip_serializing_if = "Option::is_none")]
632        pub timeout_ms: Option<u64>,
633        #[serde(default, skip_serializing_if = "String::is_empty")]
634        pub content_type: String,
635    }
636
637    impl ExecuteRequest {
638        pub fn http_get(
639            connector_id: impl Into<String>,
640            destination: impl Into<String>,
641            path: impl Into<String>,
642        ) -> Self {
643            Self {
644                connector_id: connector_id.into(),
645                transport: Transport::Http,
646                destination: destination.into(),
647                operation: Operation::Http,
648                method: "GET".to_string(),
649                path: path.into(),
650                query: BTreeMap::new(),
651                headers: BTreeMap::new(),
652                message_type: String::new(),
653                body_base64: String::new(),
654                payload_base64: String::new(),
655                ttl_ms: None,
656                max_request_bytes: None,
657                max_response_bytes: None,
658                max_chunk_bytes: None,
659                max_buffered_bytes: None,
660                timeout_ms: None,
661                content_type: String::new(),
662            }
663        }
664    }
665
666    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667    #[serde(deny_unknown_fields)]
668    pub struct Destination {
669        pub transport: Transport,
670        #[serde(default)]
671        pub scheme: String,
672        pub host: String,
673        pub port: u16,
674    }
675
676    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
677    #[serde(deny_unknown_fields)]
678    pub struct ExecuteResponse {
679        pub ok: bool,
680        #[serde(default)]
681        pub transport: Option<Transport>,
682        #[serde(default)]
683        pub destination: Option<Destination>,
684        #[serde(default)]
685        pub status_code: Option<u16>,
686        #[serde(default)]
687        pub headers: BTreeMap<String, Vec<String>>,
688        #[serde(default)]
689        pub message_type: String,
690        #[serde(default)]
691        pub body_base64: String,
692        #[serde(default)]
693        pub payload_base64: String,
694        #[serde(default)]
695        pub stream_id: String,
696        #[serde(default)]
697        pub bytes_read: i64,
698        #[serde(default)]
699        pub chunk_count: u32,
700        #[serde(default)]
701        pub grant_id: String,
702        #[serde(default)]
703        pub connector_id: String,
704        #[serde(default)]
705        pub runtime_generation_id: String,
706    }
707
708    pub fn execute(request: ExecuteRequest) -> Result<ExecuteResponse, WorkerError> {
709        call_host(&request, network_execute_hostcall)
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use serde_json::json;
717
718    #[test]
719    fn sqlite_values_are_exactly_typed() {
720        let values = vec![
721            storage::sqlite::Value::Null,
722            storage::sqlite::Value::Integer(7),
723            storage::sqlite::Value::Float(1.5),
724            storage::sqlite::Value::Text("memo".to_string()),
725            storage::sqlite::Value::BlobBase64("AAE=".to_string()),
726        ];
727        let encoded = serde_json::to_value(&values).unwrap();
728        let decoded: Vec<storage::sqlite::Value> = serde_json::from_value(encoded).unwrap();
729        assert_eq!(decoded, values);
730        assert!(
731            serde_json::from_value::<storage::sqlite::Value>(json!({"int": 1, "text": "x"}))
732                .is_err()
733        );
734        assert!(
735            serde_json::from_value::<storage::sqlite::Value>(
736                json!({"text": "x", "token": "secret"})
737            )
738            .is_err()
739        );
740    }
741
742    #[test]
743    fn network_request_cannot_encode_host_owned_invocation_fields() {
744        let mut request = network::ExecuteRequest::http_get(
745            "forecast",
746            "https://api.example.com",
747            "/v1/forecast",
748        );
749        request
750            .query
751            .insert("latitude".to_string(), vec!["52.52".to_string()]);
752        let encoded = serde_json::to_value(request).unwrap();
753        assert_eq!(encoded["operation"], "http");
754        for forbidden in [
755            "stream_id",
756            "surface_instance_id",
757            "owner_session_hash",
758            "owner_user_hash",
759            "owner_env_hash",
760            "session_channel_id_hash",
761            "bridge_channel_id",
762        ] {
763            assert!(encoded.get(forbidden).is_none(), "unexpected {forbidden}");
764        }
765    }
766
767    #[test]
768    fn typed_success_responses_reject_unknown_fields() {
769        let response = json!({
770            "ok": true,
771            "database": "notes.sqlite",
772            "columns": ["title"],
773            "rows": [[{"text": "Launch"}]],
774            "usage": {
775                "plugin_instance_id": "plugini_1",
776                "store_id": "notes",
777                "usage_bytes": 10,
778                "quota_bytes": 100,
779                "usage_files": 1,
780                "quota_files": 4
781            },
782            "handle_grant_token": "secret"
783        });
784        assert!(serde_json::from_value::<storage::sqlite::QueryResponse>(response).is_err());
785    }
786
787    #[test]
788    fn operation_specific_storage_responses_reject_cross_operation_fields() {
789        let usage = json!({
790            "plugin_instance_id": "plugini_1",
791            "store_id": "workspace",
792            "usage_bytes": 10,
793            "quota_bytes": 100,
794            "usage_files": 1,
795            "quota_files": 4
796        });
797        let cases = [
798            serde_json::to_vec(&json!({
799                "ok": true,
800                "path": "notes/a.txt",
801                "data_base64": "YQ==",
802                "size_bytes": 1,
803                "entries": [],
804                "usage": usage.clone()
805            }))
806            .unwrap(),
807            serde_json::to_vec(&json!({
808                "ok": true,
809                "path": "notes/a.txt",
810                "size_bytes": 1,
811                "data_base64": "YQ==",
812                "usage": usage.clone()
813            }))
814            .unwrap(),
815            serde_json::to_vec(&json!({
816                "ok": true,
817                "path": "notes/a.txt",
818                "usage": usage.clone()
819            }))
820            .unwrap(),
821            serde_json::to_vec(&json!({
822                "ok": true,
823                "path": "notes",
824                "entries": [],
825                "data_base64": "YQ==",
826                "usage": usage.clone()
827            }))
828            .unwrap(),
829        ];
830        assert!(decode_hostcall_response::<storage::files::ReadResponse>(&cases[0]).is_err());
831        assert!(decode_hostcall_response::<storage::files::WriteResponse>(&cases[1]).is_err());
832        assert!(decode_hostcall_response::<storage::files::DeleteResponse>(&cases[2]).is_err());
833        assert!(decode_hostcall_response::<storage::files::ListResponse>(&cases[3]).is_err());
834
835        let kv_get = serde_json::to_vec(&json!({
836            "ok": true,
837            "key": "theme",
838            "value_base64": "ZGFyaw==",
839            "size_bytes": 4,
840            "entries": [],
841            "usage": usage.clone()
842        }))
843        .unwrap();
844        let kv_put = serde_json::to_vec(&json!({
845            "ok": true,
846            "key": "theme",
847            "size_bytes": 4,
848            "value_base64": "ZGFyaw==",
849            "usage": usage.clone()
850        }))
851        .unwrap();
852        let kv_delete = serde_json::to_vec(&json!({
853            "ok": true,
854            "key": "theme",
855            "usage": usage.clone()
856        }))
857        .unwrap();
858        let kv_list = serde_json::to_vec(&json!({
859            "ok": true,
860            "prefix": "settings/",
861            "entries": [],
862            "value_base64": "ZGFyaw==",
863            "usage": usage.clone()
864        }))
865        .unwrap();
866        assert!(decode_hostcall_response::<storage::kv::GetResponse>(&kv_get).is_err());
867        assert!(decode_hostcall_response::<storage::kv::PutResponse>(&kv_put).is_err());
868        assert!(decode_hostcall_response::<storage::kv::DeleteResponse>(&kv_delete).is_err());
869        assert!(decode_hostcall_response::<storage::kv::ListResponse>(&kv_list).is_err());
870
871        let sqlite_exec = serde_json::to_vec(&json!({
872            "ok": true,
873            "database": "notes.sqlite",
874            "rows_affected": 1,
875            "columns": [],
876            "rows": [],
877            "usage": usage.clone()
878        }))
879        .unwrap();
880        let sqlite_query = serde_json::to_vec(&json!({
881            "ok": true,
882            "database": "notes.sqlite",
883            "columns": [],
884            "rows": [],
885            "rows_affected": 1,
886            "usage": usage
887        }))
888        .unwrap();
889        assert!(decode_hostcall_response::<storage::sqlite::ExecResponse>(&sqlite_exec).is_err());
890        assert!(decode_hostcall_response::<storage::sqlite::QueryResponse>(&sqlite_query).is_err());
891    }
892
893    #[test]
894    fn hostcall_failures_require_the_closed_failure_contract() {
895        let failure = br#"{"ok":false,"code":"NETWORK_TARGET_DENIED","message":"blocked","error_origin":"hostcall"}"#;
896        let error = decode_hostcall_response::<network::ExecuteResponse>(failure)
897            .expect_err("closed hostcall failure");
898        assert_eq!(error.code, "NETWORK_TARGET_DENIED");
899        assert_eq!(error.message, "blocked");
900
901        for invalid in [
902            br#"{"ok":false,"message":"blocked","error_origin":"hostcall"}"#.as_slice(),
903            br#"{"ok":false,"code":"NETWORK_TARGET_DENIED","message":"blocked","error_origin":"runtime"}"#.as_slice(),
904            br#"{"ok":false,"code":"NETWORK_TARGET_DENIED","message":"blocked","error_origin":"hostcall","future":true}"#.as_slice(),
905            br#"{"ok":false,"code":"NETWORK_TARGET_DENIED","code":"NETWORK_CONNECTOR_DENIED","message":"blocked","error_origin":"hostcall"}"#.as_slice(),
906            br#"{"ok":false,"code":"NETWORK_TARGET_DENIED","message":"blocked","error_origin":"hostcall"} {}"#.as_slice(),
907        ] {
908            assert!(
909                decode_hostcall_response::<network::ExecuteResponse>(invalid).is_err(),
910                "accepted invalid hostcall failure: {}",
911                String::from_utf8_lossy(invalid)
912            );
913        }
914    }
915}