Skip to main content

host_api/
lib.rs

1//! Host API — transport-agnostic engine for the Polkadot app host-api protocol.
2//!
3//! Processes binary SCALE-encoded messages from Polkadot apps and returns binary
4//! responses. Works over any transport: WKWebView MessagePort, PolkaVM host calls,
5//! WebSocket, etc.
6//!
7//! See [`HostApi::handle_message`] for the main entry point.
8
9pub mod chain;
10pub mod codec;
11pub mod protocol;
12
13use protocol::{
14    decode_message, encode_account_status, encode_feature_response, encode_response, Account,
15    HostRequest, HostResponse, PROTOCOL_VERSION, TAG_ACCOUNT_STATUS_INTERRUPT,
16    TAG_ACCOUNT_STATUS_STOP, TAG_CHAIN_HEAD_BODY_REQ, TAG_CHAIN_HEAD_CALL_REQ,
17    TAG_CHAIN_HEAD_CONTINUE_REQ, TAG_CHAIN_HEAD_FOLLOW_INTERRUPT, TAG_CHAIN_HEAD_FOLLOW_STOP,
18    TAG_CHAIN_HEAD_HEADER_REQ, TAG_CHAIN_HEAD_STOP_OP_REQ, TAG_CHAIN_HEAD_STORAGE_REQ,
19    TAG_CHAIN_HEAD_UNPIN_REQ, TAG_CHAIN_SPEC_GENESIS_REQ, TAG_CHAIN_SPEC_NAME_REQ,
20    TAG_CHAIN_SPEC_PROPS_REQ, TAG_CHAT_ACTION_INTERRUPT, TAG_CHAT_ACTION_STOP,
21    TAG_CHAT_CUSTOM_MSG_INTERRUPT, TAG_CHAT_CUSTOM_MSG_STOP, TAG_CHAT_LIST_INTERRUPT,
22    TAG_CHAT_LIST_STOP, TAG_JSONRPC_SUB_INTERRUPT, TAG_JSONRPC_SUB_STOP,
23    TAG_PREIMAGE_LOOKUP_INTERRUPT, TAG_PREIMAGE_LOOKUP_STOP, TAG_STATEMENT_STORE_INTERRUPT,
24    TAG_STATEMENT_STORE_STOP,
25};
26
27#[cfg(feature = "tracing")]
28use tracing::info_span;
29
30/// Maximum size of a single storage value (64 KB).
31const MAX_STORAGE_VALUE_SIZE: usize = 64 * 1024;
32/// Maximum storage key length (512 bytes).
33const MAX_STORAGE_KEY_LENGTH: usize = 512;
34/// Maximum push notification text length in UTF-8 bytes (1024 bytes).
35/// Multi-byte characters count proportionally to their encoded size.
36const MAX_PUSH_NOTIFICATION_TEXT_LEN: usize = 1024;
37/// Maximum deeplink URL length in UTF-8 bytes (2048 bytes).
38const MAX_DEEPLINK_URL_LEN: usize = 2048;
39/// Allowed URI scheme prefixes for push notification deeplinks.
40/// The SDK rejects any deeplink that does not start with one of these
41/// schemes. Only `https://` is permitted.
42const ALLOWED_DEEPLINK_SCHEMES: &[&str] = &["https://"];
43
44/// Outcome of processing a host-api message.
45#[derive(serde::Serialize)]
46#[serde(tag = "type")]
47pub enum HostApiOutcome {
48    /// Send this response directly back to the app.
49    Response { data: Vec<u8> },
50    /// Sign request — needs wallet to produce a signature before responding.
51    NeedsSign {
52        request_id: String,
53        request_tag: u8,
54        public_key: Vec<u8>,
55        payload: Vec<u8>,
56    },
57    /// JSON-RPC query — needs routing through the chain API allowlist + RPC bridge.
58    NeedsChainQuery {
59        request_id: String,
60        method: String,
61        params: serde_json::Value,
62    },
63    /// JSON-RPC subscription — needs routing through the chain API for streaming responses.
64    NeedsChainSubscription {
65        request_id: String,
66        method: String,
67        params: serde_json::Value,
68    },
69    /// Navigation request — the workbench should open this URL (may be a .dot address).
70    NeedsNavigate { request_id: String, url: String },
71    /// Push notification — host should display a toast/banner with the given text.
72    ///
73    /// **`deeplink` is unvalidated by the SDK.** The protocol layer passes it
74    /// through as-is. Hosts must reject or sanitize values that fail URL
75    /// parsing before opening them (e.g. null bytes, control characters).
76    NeedsPushNotification {
77        request_id: String,
78        text: String,
79        deeplink: Option<String>,
80    },
81    /// Start a chainHead_v1_follow subscription for a specific chain.
82    NeedsChainFollow {
83        request_id: String,
84        genesis_hash: Vec<u8>,
85        with_runtime: bool,
86    },
87    /// A chain interaction request (header, storage, call, etc.) that needs
88    /// routing to smoldot via JSON-RPC. The response_tag and json_rpc_method
89    /// tell the workbench how to route and encode the response.
90    NeedsChainRpc {
91        request_id: String,
92        request_tag: u8,
93        genesis_hash: Vec<u8>,
94        json_rpc_method: String,
95        json_rpc_params: serde_json::Value,
96        /// The follow subscription ID from the product-SDK (opaque string).
97        follow_sub_id: Option<String>,
98    },
99    /// Product-scoped storage read — the host must look up `key` in persistent
100    /// storage for the current product and call `encode_storage_read_response`.
101    NeedsStorageRead { request_id: String, key: String },
102    /// Product-scoped storage write — the host must persist `value` under `key`
103    /// for the current product and call `encode_storage_write_response`.
104    NeedsStorageWrite {
105        request_id: String,
106        key: String,
107        value: Vec<u8>,
108    },
109    /// Product-scoped storage clear — the host must remove `key` from persistent
110    /// storage for the current product and call `encode_storage_clear_response`.
111    NeedsStorageClear { request_id: String, key: String },
112    /// No response needed (fire-and-forget).
113    Silent,
114}
115
116/// Shared host implementation. Handles decoded requests, returns encoded responses.
117pub struct HostApi {
118    accounts: Vec<Account>,
119    supported_chains: std::collections::HashSet<[u8; 32]>,
120}
121
122impl Default for HostApi {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl HostApi {
129    pub fn new() -> Self {
130        Self {
131            accounts: Vec::new(),
132            supported_chains: std::collections::HashSet::new(),
133        }
134    }
135
136    /// Set the accounts that will be returned by host_get_non_product_accounts.
137    ///
138    /// SECURITY: This exposes account identifiers to any product that requests
139    /// them. Will be replaced by scoped per-product identities via host-sso.
140    pub fn set_accounts(&mut self, accounts: Vec<Account>) {
141        self.accounts = accounts;
142    }
143
144    /// Set the chain genesis hashes that this host supports.
145    /// Each hash is 32 bytes (raw, not hex-encoded).
146    pub fn set_supported_chains(&mut self, chains: impl IntoIterator<Item = [u8; 32]>) {
147        self.supported_chains = chains.into_iter().collect();
148    }
149
150    /// Process a raw binary message from the app.
151    ///
152    /// Returns `HostApiOutcome::Response` for immediate replies,
153    /// `HostApiOutcome::NeedsSign` for sign requests that need wallet approval,
154    /// or `HostApiOutcome::Silent` for fire-and-forget messages.
155    ///
156    /// `app_id` identifies the product — the host must use this value to scope
157    /// storage when acting on `NeedsStorageRead`, `NeedsStorageWrite`, and
158    /// `NeedsStorageClear` outcomes. It is not embedded in the outcome itself.
159    pub fn handle_message(&mut self, raw: &[u8], app_id: &str) -> HostApiOutcome {
160        #[cfg(feature = "tracing")]
161        let _span = info_span!(
162            "host_api.handle_message",
163            app_id,
164            request_tag = tracing::field::Empty,
165            request_kind = tracing::field::Empty,
166        )
167        .entered();
168        let _ = app_id; // used by tracing; host reads it from call-site context
169
170        let (request_id, request_tag, req) = match decode_message(raw) {
171            Ok(v) => v,
172            Err(e) => {
173                log::warn!(
174                    "[hostapi] failed to decode message: kind={}",
175                    decode_error_kind(&e)
176                );
177                #[cfg(feature = "tracing")]
178                tracing::Span::current().record("request_kind", "decode_error");
179                return HostApiOutcome::Silent;
180            }
181        };
182
183        #[cfg(feature = "tracing")]
184        {
185            tracing::Span::current().record("request_tag", request_tag);
186            tracing::Span::current().record("request_kind", request_kind(&req));
187        }
188
189        log::info!(
190            "[hostapi] request: kind={} (tag={request_tag})",
191            request_kind(&req)
192        );
193
194        match req {
195            HostRequest::Handshake { version } => {
196                if version == PROTOCOL_VERSION {
197                    HostApiOutcome::Response {
198                        data: encode_response(&request_id, request_tag, &HostResponse::HandshakeOk),
199                    }
200                } else {
201                    log::warn!("[hostapi] unsupported protocol version: {version}");
202                    HostApiOutcome::Response {
203                        data: encode_response(
204                            &request_id,
205                            request_tag,
206                            &HostResponse::Error("unsupported protocol version".into()),
207                        ),
208                    }
209                }
210            }
211
212            HostRequest::GetNonProductAccounts => {
213                // SECURITY: This exposes the soft-derivation root (//wallet//sso
214                // pubkey) to any product that requests it, allowing enumeration
215                // of all product addresses. This will be replaced by the host-sso
216                // pairing flow which provides scoped, per-product identities.
217                // See docs/threat-model-soft-derivation.md scenarios 1 & 2.
218                // TODO: remove once host-sso is fully wired and VoxChat uses it.
219                log::info!(
220                    "[hostapi] returning {} non-product account(s)",
221                    self.accounts.len()
222                );
223                HostApiOutcome::Response {
224                    data: encode_response(
225                        &request_id,
226                        request_tag,
227                        &HostResponse::AccountList(self.accounts.clone()),
228                    ),
229                }
230            }
231
232            HostRequest::FeatureSupported { feature_data } => {
233                let feature_kind = feature_log_kind(&feature_data);
234                // Check string features first (signing, navigate, etc.)
235                let supported = if let Ok(s) = std::str::from_utf8(&feature_data) {
236                    let r = matches!(s, "signing" | "sign" | "navigate" | "push_notification");
237                    r
238                } else {
239                    // Binary feature: byte 0 = type, then SCALE-encoded data.
240                    // Type 0 = chain: compact_length + genesis_hash (32 bytes)
241                    let r = if feature_data.first() == Some(&0) {
242                        codec::Reader::new(&feature_data[1..])
243                            .read_var_bytes()
244                            .ok()
245                            .and_then(|h| <[u8; 32]>::try_from(h).ok())
246                            .map(|h| self.supported_chains.contains(&h))
247                            .unwrap_or(false)
248                    } else {
249                        false
250                    };
251                    r
252                };
253                log::info!("[hostapi] feature_supported: feature={feature_kind} -> {supported}");
254                HostApiOutcome::Response {
255                    data: encode_feature_response(&request_id, supported),
256                }
257            }
258
259            HostRequest::AccountConnectionStatusStart => HostApiOutcome::Response {
260                data: encode_account_status(&request_id, true),
261            },
262
263            HostRequest::LocalStorageRead { key } => {
264                if key.len() > MAX_STORAGE_KEY_LENGTH {
265                    return HostApiOutcome::Response {
266                        data: encode_response(
267                            &request_id,
268                            request_tag,
269                            &HostResponse::Error("storage key too long".into()),
270                        ),
271                    };
272                }
273                HostApiOutcome::NeedsStorageRead { request_id, key }
274            }
275
276            HostRequest::LocalStorageWrite { key, value } => {
277                if key.len() > MAX_STORAGE_KEY_LENGTH {
278                    return HostApiOutcome::Response {
279                        data: encode_response(
280                            &request_id,
281                            request_tag,
282                            &HostResponse::Error("storage key too long".into()),
283                        ),
284                    };
285                }
286                if value.len() > MAX_STORAGE_VALUE_SIZE {
287                    return HostApiOutcome::Response {
288                        data: encode_response(
289                            &request_id,
290                            request_tag,
291                            &HostResponse::Error("storage value too large".into()),
292                        ),
293                    };
294                }
295                HostApiOutcome::NeedsStorageWrite {
296                    request_id,
297                    key,
298                    value,
299                }
300            }
301
302            HostRequest::LocalStorageClear { key } => {
303                if key.len() > MAX_STORAGE_KEY_LENGTH {
304                    return HostApiOutcome::Response {
305                        data: encode_response(
306                            &request_id,
307                            request_tag,
308                            &HostResponse::Error("storage key too long".into()),
309                        ),
310                    };
311                }
312                HostApiOutcome::NeedsStorageClear { request_id, key }
313            }
314
315            HostRequest::NavigateTo { url } => {
316                log::info!("[hostapi] navigate_to request");
317                HostApiOutcome::NeedsNavigate { request_id, url }
318            }
319
320            HostRequest::PushNotification { text, deeplink } => {
321                log::info!("[hostapi] push_notification request");
322                if text.len() > MAX_PUSH_NOTIFICATION_TEXT_LEN {
323                    return HostApiOutcome::Response {
324                        data: encode_response(
325                            &request_id,
326                            request_tag,
327                            &HostResponse::Error("push notification text too long".into()),
328                        ),
329                    };
330                }
331                if let Some(ref dl) = deeplink {
332                    if dl.len() > MAX_DEEPLINK_URL_LEN {
333                        return HostApiOutcome::Response {
334                            data: encode_response(
335                                &request_id,
336                                request_tag,
337                                &HostResponse::Error("deeplink URL too long".into()),
338                            ),
339                        };
340                    }
341                    if !is_allowed_deeplink_scheme(dl) {
342                        return HostApiOutcome::Response {
343                            data: encode_response(
344                                &request_id,
345                                request_tag,
346                                &HostResponse::Error(
347                                    "deeplink scheme not allowed; only https:// is accepted".into(),
348                                ),
349                            ),
350                        };
351                    }
352                }
353                HostApiOutcome::NeedsPushNotification {
354                    request_id,
355                    text,
356                    deeplink,
357                }
358            }
359
360            HostRequest::SignPayload {
361                public_key,
362                payload,
363            } => {
364                log::info!(
365                    "[hostapi] sign_payload request (pubkey={} bytes)",
366                    public_key.len()
367                );
368                HostApiOutcome::NeedsSign {
369                    request_id,
370                    request_tag,
371                    public_key,
372                    payload,
373                }
374            }
375
376            HostRequest::SignRaw { public_key, data } => {
377                log::info!(
378                    "[hostapi] sign_raw request (pubkey={} bytes)",
379                    public_key.len()
380                );
381                HostApiOutcome::NeedsSign {
382                    request_id,
383                    request_tag,
384                    public_key,
385                    payload: data,
386                }
387            }
388
389            HostRequest::CreateTransaction { .. } => {
390                log::info!("[hostapi] create_transaction (not yet implemented)");
391                HostApiOutcome::Response {
392                    data: encode_response(
393                        &request_id,
394                        request_tag,
395                        &HostResponse::Error("create_transaction not yet implemented".into()),
396                    ),
397                }
398            }
399
400            HostRequest::JsonRpcSend { data } => {
401                // The data is a SCALE string containing a JSON-RPC request body,
402                // or raw bytes we try to interpret as UTF-8 JSON.
403                let json_str = parse_jsonrpc_data(&data);
404                match json_str {
405                    Some((method, params)) => {
406                        log::info!("[hostapi] jsonrpc_send request");
407                        HostApiOutcome::NeedsChainQuery {
408                            request_id,
409                            method,
410                            params,
411                        }
412                    }
413                    None => {
414                        log::warn!("[hostapi] failed to parse JSON-RPC send data");
415                        HostApiOutcome::Response {
416                            data: encode_response(
417                                &request_id,
418                                request_tag,
419                                &HostResponse::Error("invalid json-rpc request".into()),
420                            ),
421                        }
422                    }
423                }
424            }
425
426            HostRequest::JsonRpcSubscribeStart { data } => {
427                let json_str = parse_jsonrpc_data(&data);
428                match json_str {
429                    Some((method, params)) => {
430                        log::info!("[hostapi] jsonrpc_subscribe_start request");
431                        HostApiOutcome::NeedsChainSubscription {
432                            request_id,
433                            method,
434                            params,
435                        }
436                    }
437                    None => {
438                        log::warn!("[hostapi] failed to parse JSON-RPC subscribe data");
439                        HostApiOutcome::Response {
440                            data: encode_response(
441                                &request_id,
442                                request_tag,
443                                &HostResponse::Error("invalid json-rpc subscribe request".into()),
444                            ),
445                        }
446                    }
447                }
448            }
449
450            HostRequest::ChainHeadFollowStart {
451                genesis_hash,
452                with_runtime,
453            } => {
454                log::info!("[hostapi] chainHead follow start (genesis={} bytes, withRuntime={with_runtime})", genesis_hash.len());
455                HostApiOutcome::NeedsChainFollow {
456                    request_id,
457                    genesis_hash,
458                    with_runtime,
459                }
460            }
461
462            HostRequest::ChainHeadRequest {
463                tag,
464                genesis_hash,
465                follow_sub_id,
466                data,
467            } => {
468                let method = match tag {
469                    TAG_CHAIN_HEAD_HEADER_REQ => "chainHead_v1_header",
470                    TAG_CHAIN_HEAD_BODY_REQ => "chainHead_v1_body",
471                    TAG_CHAIN_HEAD_STORAGE_REQ => "chainHead_v1_storage",
472                    TAG_CHAIN_HEAD_CALL_REQ => "chainHead_v1_call",
473                    TAG_CHAIN_HEAD_UNPIN_REQ => "chainHead_v1_unpin",
474                    TAG_CHAIN_HEAD_CONTINUE_REQ => "chainHead_v1_continue",
475                    TAG_CHAIN_HEAD_STOP_OP_REQ => "chainHead_v1_stopOperation",
476                    _ => {
477                        log::warn!("[hostapi] unknown chain head request tag: {tag}");
478                        return HostApiOutcome::Silent;
479                    }
480                };
481                log::info!("[hostapi] chain request: {method} (tag={tag})");
482                HostApiOutcome::NeedsChainRpc {
483                    request_id,
484                    request_tag: tag,
485                    genesis_hash,
486                    json_rpc_method: method.into(),
487                    json_rpc_params: data,
488                    follow_sub_id: Some(follow_sub_id),
489                }
490            }
491
492            HostRequest::ChainSpecRequest { tag, genesis_hash } => {
493                let method = match tag {
494                    TAG_CHAIN_SPEC_GENESIS_REQ => "chainSpec_v1_genesisHash",
495                    TAG_CHAIN_SPEC_NAME_REQ => "chainSpec_v1_chainName",
496                    TAG_CHAIN_SPEC_PROPS_REQ => "chainSpec_v1_properties",
497                    _ => {
498                        log::warn!("[hostapi] unknown chainSpec request tag: {tag}");
499                        return HostApiOutcome::Silent;
500                    }
501                };
502                log::info!("[hostapi] chainSpec request: {method} (tag={tag})");
503                HostApiOutcome::NeedsChainRpc {
504                    request_id,
505                    request_tag: tag,
506                    genesis_hash,
507                    json_rpc_method: method.into(),
508                    json_rpc_params: serde_json::Value::Array(vec![]),
509                    follow_sub_id: None,
510                }
511            }
512
513            HostRequest::ChainTxBroadcast {
514                genesis_hash,
515                transaction,
516            } => {
517                log::info!("[hostapi] transaction broadcast");
518                let tx_hex = chain::bytes_to_hex(&transaction);
519                HostApiOutcome::NeedsChainRpc {
520                    request_id,
521                    request_tag,
522                    genesis_hash,
523                    json_rpc_method: "transaction_v1_broadcast".into(),
524                    json_rpc_params: serde_json::json!([tx_hex]),
525                    follow_sub_id: None,
526                }
527            }
528
529            HostRequest::ChainTxStop {
530                genesis_hash,
531                operation_id,
532            } => {
533                log::info!("[hostapi] transaction stop");
534                HostApiOutcome::NeedsChainRpc {
535                    request_id,
536                    request_tag,
537                    genesis_hash,
538                    json_rpc_method: "transaction_v1_stop".into(),
539                    json_rpc_params: serde_json::json!([operation_id]),
540                    follow_sub_id: None,
541                }
542            }
543
544            HostRequest::Unimplemented { tag } => {
545                log::info!("[hostapi] unimplemented method (tag={tag})");
546                if is_subscription_control(tag) {
547                    HostApiOutcome::Silent
548                } else {
549                    HostApiOutcome::Response {
550                        data: encode_response(
551                            &request_id,
552                            request_tag,
553                            &HostResponse::Error("not implemented".into()),
554                        ),
555                    }
556                }
557            }
558
559            HostRequest::Unknown { tag } => {
560                log::warn!("[hostapi] unknown tag: {tag}");
561                HostApiOutcome::Silent
562            }
563        }
564    }
565}
566
567/// Parse the `data` field from a `JsonRpcSend` request.
568///
569/// The Product SDK encodes this as a SCALE string containing the full JSON-RPC
570/// request (e.g. `{"jsonrpc":"2.0","id":1,"method":"state_getMetadata","params":[]}`).
571/// We try SCALE string first, then fall back to raw UTF-8.
572fn parse_jsonrpc_data(data: &[u8]) -> Option<(String, serde_json::Value)> {
573    // Try SCALE string (compact length + UTF-8 bytes).
574    let json_str = codec::Reader::new(data)
575        .read_string()
576        .ok()
577        .or_else(|| std::str::from_utf8(data).ok().map(|s| s.to_string()))?;
578
579    let v: serde_json::Value = serde_json::from_str(&json_str).ok()?;
580    let method = v.get("method")?.as_str()?.to_string();
581    let params = v
582        .get("params")
583        .cloned()
584        .unwrap_or(serde_json::Value::Array(vec![]));
585    Some((method, params))
586}
587
588/// Check if a tag is a subscription control message (stop/interrupt).
589fn is_subscription_control(tag: u8) -> bool {
590    matches!(
591        tag,
592        TAG_ACCOUNT_STATUS_STOP
593            | TAG_ACCOUNT_STATUS_INTERRUPT
594            | TAG_CHAT_LIST_STOP
595            | TAG_CHAT_LIST_INTERRUPT
596            | TAG_CHAT_ACTION_STOP
597            | TAG_CHAT_ACTION_INTERRUPT
598            | TAG_CHAT_CUSTOM_MSG_STOP
599            | TAG_CHAT_CUSTOM_MSG_INTERRUPT
600            | TAG_STATEMENT_STORE_STOP
601            | TAG_STATEMENT_STORE_INTERRUPT
602            | TAG_PREIMAGE_LOOKUP_STOP
603            | TAG_PREIMAGE_LOOKUP_INTERRUPT
604            | TAG_JSONRPC_SUB_STOP
605            | TAG_JSONRPC_SUB_INTERRUPT
606            | TAG_CHAIN_HEAD_FOLLOW_STOP
607            | TAG_CHAIN_HEAD_FOLLOW_INTERRUPT
608    )
609}
610
611fn decode_error_kind(err: &codec::DecodeErr) -> &'static str {
612    match err {
613        codec::DecodeErr::Eof => "eof",
614        codec::DecodeErr::CompactTooLarge => "compact_too_large",
615        codec::DecodeErr::InvalidUtf8 => "invalid_utf8",
616        codec::DecodeErr::InvalidOption => "invalid_option",
617        codec::DecodeErr::InvalidTag(_) => "invalid_tag",
618        codec::DecodeErr::BadMessage(_) => "bad_message",
619        codec::DecodeErr::UnknownProtocol => "unknown_protocol",
620    }
621}
622
623fn feature_log_kind(feature_data: &[u8]) -> &'static str {
624    match std::str::from_utf8(feature_data) {
625        Ok("signing" | "sign" | "navigate" | "push_notification") => "utf8_known",
626        Ok(_) => "utf8_other",
627        Err(_) if feature_data.first() == Some(&0) => "binary_chain",
628        Err(_) => "binary_other",
629    }
630}
631
632fn request_kind(req: &HostRequest) -> &'static str {
633    match req {
634        HostRequest::Handshake { .. } => "handshake",
635        HostRequest::GetNonProductAccounts => "get_non_product_accounts",
636        HostRequest::FeatureSupported { .. } => "feature_supported",
637        HostRequest::LocalStorageRead { .. } => "local_storage_read",
638        HostRequest::LocalStorageWrite { .. } => "local_storage_write",
639        HostRequest::LocalStorageClear { .. } => "local_storage_clear",
640        HostRequest::SignPayload { .. } => "sign_payload",
641        HostRequest::SignRaw { .. } => "sign_raw",
642        HostRequest::CreateTransaction { .. } => "create_transaction",
643        HostRequest::NavigateTo { .. } => "navigate_to",
644        HostRequest::PushNotification { .. } => "push_notification",
645        HostRequest::AccountConnectionStatusStart => "account_connection_status_start",
646        HostRequest::JsonRpcSend { .. } => "jsonrpc_send",
647        HostRequest::JsonRpcSubscribeStart { .. } => "jsonrpc_subscribe_start",
648        HostRequest::ChainHeadFollowStart { .. } => "chain_head_follow_start",
649        HostRequest::ChainHeadRequest { .. } => "chain_head_request",
650        HostRequest::ChainSpecRequest { .. } => "chain_spec_request",
651        HostRequest::ChainTxBroadcast { .. } => "chain_tx_broadcast",
652        HostRequest::ChainTxStop { .. } => "chain_tx_stop",
653        HostRequest::Unimplemented { .. } => "unimplemented",
654        HostRequest::Unknown { .. } => "unknown",
655    }
656}
657
658/// Check whether a deeplink URI has an allowed scheme.
659/// Only `https://` is accepted by default (case-insensitive on the scheme).
660fn is_allowed_deeplink_scheme(url: &str) -> bool {
661    let lower = url.to_ascii_lowercase();
662    ALLOWED_DEEPLINK_SCHEMES
663        .iter()
664        .any(|scheme| lower.starts_with(scheme))
665}
666
667// ---------------------------------------------------------------------------
668// JS bridge script — injected into WKWebView at document_start
669// ---------------------------------------------------------------------------
670
671/// JavaScript injected before the Polkadot app loads. Sets up:
672/// 1. `window.__HOST_WEBVIEW_MARK__ = true` — SDK webview detection
673/// 2. `MessageChannel` with port2 as `window.__HOST_API_PORT__`
674/// 3. Binary message forwarding between port1 and native (base64)
675pub const HOST_API_BRIDGE_SCRIPT: &str = concat!(
676    r#"
677(function() {
678    'use strict';
679    if (window.__hostApiBridge) { return; }
680    window.__hostApiBridge = true;
681    window.__HOST_WEBVIEW_MARK__ = true;
682    var ch = new MessageChannel();
683    window.__HOST_API_PORT__ = ch.port2;
684    ch.port2.start();
685    var port1 = ch.port1;
686    port1.start();
687    if (!window.host) { window.host = {}; }
688    if (!window.host.storage) { window.host.storage = {}; }
689    port1.onmessage = function(ev) {
690        var data = ev.data;
691        if (!data) { console.warn('[host-bridge] data is falsy, dropping'); return; }
692        var bytes;
693        if (data instanceof Uint8Array) { bytes = data; }
694        else if (data instanceof ArrayBuffer) { bytes = new Uint8Array(data); }
695        else if (ArrayBuffer.isView(data)) { bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); }
696        else { console.warn('[host-bridge] unknown data type: ' + typeof data + ' constructor=' + (data.constructor ? data.constructor.name : '?') + ', dropping'); return; }
697        var binary = '';
698        for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); }
699        try {
700            window.webkit.messageHandlers.hostApi.postMessage(btoa(binary));
701        } catch(e) {
702            console.error('[host-bridge] postMessage to hostApi FAILED:', e.message);
703        }
704    };
705    window.__hostApiReply = function(b64) {
706        try {
707            var binary = atob(b64);
708            var bytes = new Uint8Array(binary.length);
709            for (var i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
710            port1.postMessage(bytes);
711        } catch(e) { console.error('[host-bridge] reply failed:', e.message); }
712    };
713"#,
714    include_str!("js/storage_bridge.js"),
715    r#"
716})();
717"#
718);
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use crate::protocol::*;
724    use std::sync::atomic::{AtomicBool, Ordering};
725    use std::sync::{Mutex, Once};
726
727    const TEST_APP: &str = "test-app";
728    static TEST_LOGGER_INIT: Once = Once::new();
729    static TEST_LOGGER_INSTALLED: AtomicBool = AtomicBool::new(false);
730    static TEST_LOG_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
731    static TEST_LOGGER: TestLogger = TestLogger::new();
732
733    struct TestLogger {
734        entries: Mutex<Vec<String>>,
735        capture_thread: Mutex<Option<std::thread::ThreadId>>,
736    }
737
738    impl TestLogger {
739        const fn new() -> Self {
740            Self {
741                entries: Mutex::new(Vec::new()),
742                capture_thread: Mutex::new(None),
743            }
744        }
745    }
746
747    impl log::Log for TestLogger {
748        fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
749            metadata.level() <= log::Level::Info
750        }
751
752        fn log(&self, record: &log::Record<'_>) {
753            if !self.enabled(record.metadata()) {
754                return;
755            }
756
757            let capture_thread = self
758                .capture_thread
759                .lock()
760                .unwrap_or_else(|e| e.into_inner());
761            if capture_thread.as_ref() != Some(&std::thread::current().id()) {
762                return;
763            }
764            drop(capture_thread);
765
766            self.entries
767                .lock()
768                .unwrap_or_else(|e| e.into_inner())
769                .push(record.args().to_string());
770        }
771
772        fn flush(&self) {}
773    }
774
775    fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
776        TEST_LOGGER_INIT.call_once(|| {
777            let installed = log::set_logger(&TEST_LOGGER).is_ok();
778            if installed {
779                log::set_max_level(log::LevelFilter::Info);
780            }
781            TEST_LOGGER_INSTALLED.store(installed, Ordering::Relaxed);
782        });
783        assert!(
784            TEST_LOGGER_INSTALLED.load(Ordering::Relaxed),
785            "test logger could not be installed"
786        );
787
788        let _guard = TEST_LOG_CAPTURE_LOCK
789            .lock()
790            .unwrap_or_else(|e| e.into_inner());
791
792        TEST_LOGGER
793            .entries
794            .lock()
795            .unwrap_or_else(|e| e.into_inner())
796            .clear();
797        *TEST_LOGGER
798            .capture_thread
799            .lock()
800            .unwrap_or_else(|e| e.into_inner()) = Some(std::thread::current().id());
801
802        let result = f();
803
804        *TEST_LOGGER
805            .capture_thread
806            .lock()
807            .unwrap_or_else(|e| e.into_inner()) = None;
808        let logs = TEST_LOGGER
809            .entries
810            .lock()
811            .unwrap_or_else(|e| e.into_inner())
812            .clone();
813        TEST_LOGGER
814            .entries
815            .lock()
816            .unwrap_or_else(|e| e.into_inner())
817            .clear();
818
819        (result, logs)
820    }
821
822    fn assert_logs_contain(logs: &[String], needle: &str) {
823        let joined = logs.join("\n");
824        assert!(
825            joined.contains(needle),
826            "expected logs to contain {needle:?}, got:\n{joined}"
827        );
828    }
829
830    fn assert_logs_do_not_contain(logs: &[String], needle: &str) {
831        let joined = logs.join("\n");
832        assert!(
833            !joined.contains(needle),
834            "expected logs not to contain {needle:?}, got:\n{joined}"
835        );
836    }
837
838    #[test]
839    fn host_api_bridge_script_exposes_storage_surface() {
840        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.get"));
841        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.set"));
842        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.remove"));
843    }
844
845    /// Extract a Response from HostApiOutcome, panicking on other variants.
846    fn expect_response(outcome: HostApiOutcome) -> Vec<u8> {
847        match outcome {
848            HostApiOutcome::Response { data: v } => v,
849            other => panic!("expected Response, got {}", outcome_name(&other)),
850        }
851    }
852
853    fn expect_silent(outcome: HostApiOutcome) {
854        match outcome {
855            HostApiOutcome::Silent => {}
856            other => panic!("expected Silent, got {}", outcome_name(&other)),
857        }
858    }
859
860    fn outcome_name(o: &HostApiOutcome) -> &'static str {
861        match o {
862            HostApiOutcome::Response { .. } => "Response",
863            HostApiOutcome::NeedsSign { .. } => "NeedsSign",
864            HostApiOutcome::NeedsChainQuery { .. } => "NeedsChainQuery",
865            HostApiOutcome::NeedsChainSubscription { .. } => "NeedsChainSubscription",
866            HostApiOutcome::NeedsNavigate { .. } => "NeedsNavigate",
867            HostApiOutcome::NeedsPushNotification { .. } => "NeedsPushNotification",
868            HostApiOutcome::NeedsChainFollow { .. } => "NeedsChainFollow",
869            HostApiOutcome::NeedsChainRpc { .. } => "NeedsChainRpc",
870            HostApiOutcome::NeedsStorageRead { .. } => "NeedsStorageRead",
871            HostApiOutcome::NeedsStorageWrite { .. } => "NeedsStorageWrite",
872            HostApiOutcome::NeedsStorageClear { .. } => "NeedsStorageClear",
873            HostApiOutcome::Silent => "Silent",
874        }
875    }
876
877    fn make_handshake_request(request_id: &str) -> Vec<u8> {
878        let mut msg = Vec::new();
879        msg.push(PROTOCOL_DISCRIMINATOR);
880        codec::encode_string(&mut msg, request_id);
881        msg.push(TAG_HANDSHAKE_REQ);
882        msg.push(0); // v1
883        msg.push(PROTOCOL_VERSION);
884        msg
885    }
886
887    fn make_get_accounts_request(request_id: &str) -> Vec<u8> {
888        let mut msg = Vec::new();
889        msg.push(PROTOCOL_DISCRIMINATOR);
890        codec::encode_string(&mut msg, request_id);
891        msg.push(TAG_GET_NON_PRODUCT_ACCOUNTS_REQ);
892        msg.push(0); // v1
893        msg
894    }
895
896    fn make_storage_write(request_id: &str, key: &str, value: &[u8]) -> Vec<u8> {
897        let mut msg = Vec::new();
898        msg.push(PROTOCOL_DISCRIMINATOR);
899        codec::encode_string(&mut msg, request_id);
900        msg.push(TAG_LOCAL_STORAGE_WRITE_REQ);
901        msg.push(0); // v1
902        codec::encode_string(&mut msg, key);
903        codec::encode_var_bytes(&mut msg, value);
904        msg
905    }
906
907    fn make_storage_read(request_id: &str, key: &str) -> Vec<u8> {
908        let mut msg = Vec::new();
909        msg.push(PROTOCOL_DISCRIMINATOR);
910        codec::encode_string(&mut msg, request_id);
911        msg.push(TAG_LOCAL_STORAGE_READ_REQ);
912        msg.push(0); // v1
913        codec::encode_string(&mut msg, key);
914        msg
915    }
916
917    fn make_storage_clear(request_id: &str, key: &str) -> Vec<u8> {
918        let mut msg = Vec::new();
919        msg.push(PROTOCOL_DISCRIMINATOR);
920        codec::encode_string(&mut msg, request_id);
921        msg.push(TAG_LOCAL_STORAGE_CLEAR_REQ);
922        msg.push(0); // v1
923        codec::encode_string(&mut msg, key);
924        msg
925    }
926
927    fn make_feature_supported_request(request_id: &str, feature_data: &[u8]) -> Vec<u8> {
928        let mut msg = Vec::new();
929        msg.push(PROTOCOL_DISCRIMINATOR);
930        codec::encode_string(&mut msg, request_id);
931        msg.push(TAG_FEATURE_SUPPORTED_REQ);
932        msg.push(0); // v1
933        msg.extend_from_slice(feature_data);
934        msg
935    }
936
937    fn make_navigate_request(request_id: &str, url: &str) -> Vec<u8> {
938        let mut msg = Vec::new();
939        msg.push(PROTOCOL_DISCRIMINATOR);
940        codec::encode_string(&mut msg, request_id);
941        msg.push(TAG_NAVIGATE_TO_REQ);
942        msg.push(0); // v1
943        codec::encode_string(&mut msg, url);
944        msg
945    }
946
947    fn make_jsonrpc_request(request_id: &str, tag: u8, json: &str) -> Vec<u8> {
948        let mut msg = Vec::new();
949        msg.push(PROTOCOL_DISCRIMINATOR);
950        codec::encode_string(&mut msg, request_id);
951        msg.push(tag);
952        msg.push(0); // v1
953        codec::encode_string(&mut msg, json);
954        msg
955    }
956
957    #[test]
958    fn handshake_flow() {
959        let mut api = HostApi::new();
960        let req = make_handshake_request("hs-1");
961        let resp = expect_response(api.handle_message(&req, TEST_APP));
962
963        let mut r = codec::Reader::new(&resp);
964        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
965        assert_eq!(r.read_string().unwrap(), "hs-1");
966        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
967        assert_eq!(r.read_u8().unwrap(), 0); // v1
968        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
969    }
970
971    #[test]
972    fn handshake_wrong_version() {
973        let mut api = HostApi::new();
974        let mut req = Vec::new();
975        req.push(PROTOCOL_DISCRIMINATOR);
976        codec::encode_string(&mut req, "hs-bad");
977        req.push(TAG_HANDSHAKE_REQ);
978        req.push(0); // v1
979        req.push(255); // wrong version
980        let resp = expect_response(api.handle_message(&req, TEST_APP));
981
982        let mut r = codec::Reader::new(&resp);
983        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
984        assert_eq!(r.read_string().unwrap(), "hs-bad");
985        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
986        assert_eq!(r.read_u8().unwrap(), 0); // v1
987        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
988    }
989
990    #[test]
991    fn request_kind_redacts_payload_variants() {
992        assert_eq!(
993            request_kind(&HostRequest::SignPayload {
994                public_key: vec![0xAA; 32],
995                payload: b"secret-payload".to_vec(),
996            }),
997            "sign_payload"
998        );
999        assert_eq!(
1000            request_kind(&HostRequest::NavigateTo {
1001                url: "https://example.com/private".into(),
1002            }),
1003            "navigate_to"
1004        );
1005        assert_eq!(
1006            request_kind(&HostRequest::LocalStorageWrite {
1007                key: "secret".into(),
1008                value: b"top-secret".to_vec(),
1009            }),
1010            "local_storage_write"
1011        );
1012    }
1013
1014    #[test]
1015    fn decode_error_kind_redacts_error_details() {
1016        assert_eq!(
1017            decode_error_kind(&codec::DecodeErr::BadMessage("secret")),
1018            "bad_message"
1019        );
1020        assert_eq!(
1021            decode_error_kind(&codec::DecodeErr::InvalidTag(255)),
1022            "invalid_tag"
1023        );
1024        assert_eq!(
1025            decode_error_kind(&codec::DecodeErr::UnknownProtocol),
1026            "unknown_protocol"
1027        );
1028    }
1029
1030    #[test]
1031    fn feature_log_kind_redacts_feature_data() {
1032        assert_eq!(feature_log_kind(b"signing"), "utf8_known");
1033        assert_eq!(feature_log_kind(b"secret-feature"), "utf8_other");
1034        assert_eq!(
1035            feature_log_kind(&[0, 0xde, 0xad, 0xbe, 0xef]),
1036            "binary_chain"
1037        );
1038        assert_eq!(
1039            feature_log_kind(&[1, 0xde, 0xad, 0xbe, 0xef]),
1040            "binary_other"
1041        );
1042    }
1043
1044    #[test]
1045    fn get_accounts_empty() {
1046        let mut api = HostApi::new();
1047        let req = make_get_accounts_request("acc-1");
1048        let resp = expect_response(api.handle_message(&req, TEST_APP));
1049
1050        let mut r = codec::Reader::new(&resp);
1051        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1052        assert_eq!(r.read_string().unwrap(), "acc-1");
1053        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
1054        assert_eq!(r.read_u8().unwrap(), 0); // v1
1055        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1056        assert_eq!(r.read_compact_u32().unwrap(), 0); // empty vector
1057    }
1058
1059    #[test]
1060    fn get_accounts_returns_configured_accounts() {
1061        let mut api = HostApi::new();
1062        api.set_accounts(vec![Account {
1063            public_key: vec![0xAA; 32],
1064            name: Some("Test Account".into()),
1065        }]);
1066
1067        let req = make_get_accounts_request("acc-2");
1068        let resp = expect_response(api.handle_message(&req, TEST_APP));
1069
1070        let mut r = codec::Reader::new(&resp);
1071        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1072        assert_eq!(r.read_string().unwrap(), "acc-2");
1073        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
1074        assert_eq!(r.read_u8().unwrap(), 0); // v1
1075        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1076        assert_eq!(r.read_compact_u32().unwrap(), 1); // 1 account
1077    }
1078
1079    #[test]
1080    fn storage_read_returns_needs_storage_read() {
1081        let mut api = HostApi::new();
1082        let req = make_storage_read("r-1", "mykey");
1083
1084        match api.handle_message(&req, TEST_APP) {
1085            HostApiOutcome::NeedsStorageRead { request_id, key } => {
1086                assert_eq!(request_id, "r-1");
1087                assert_eq!(key, "mykey");
1088            }
1089            other => panic!("expected NeedsStorageRead, got {}", outcome_name(&other)),
1090        }
1091    }
1092
1093    #[test]
1094    fn storage_write_returns_needs_storage_write() {
1095        let mut api = HostApi::new();
1096        let req = make_storage_write("w-1", "mykey", b"myvalue");
1097
1098        match api.handle_message(&req, TEST_APP) {
1099            HostApiOutcome::NeedsStorageWrite {
1100                request_id,
1101                key,
1102                value,
1103            } => {
1104                assert_eq!(request_id, "w-1");
1105                assert_eq!(key, "mykey");
1106                assert_eq!(value, b"myvalue");
1107            }
1108            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1109        }
1110    }
1111
1112    #[test]
1113    fn storage_clear_returns_needs_storage_clear() {
1114        let mut api = HostApi::new();
1115        let req = make_storage_clear("c-1", "clearme");
1116
1117        match api.handle_message(&req, TEST_APP) {
1118            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1119                assert_eq!(request_id, "c-1");
1120                assert_eq!(key, "clearme");
1121            }
1122            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1123        }
1124    }
1125
1126    #[test]
1127    fn storage_clear_of_nonexistent_key_emits_outcome() {
1128        let mut api = HostApi::new();
1129        let req = make_storage_clear("c-2", "never-written");
1130
1131        match api.handle_message(&req, TEST_APP) {
1132            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1133                assert_eq!(request_id, "c-2");
1134                assert_eq!(key, "never-written");
1135            }
1136            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1137        }
1138    }
1139
1140    #[test]
1141    fn device_permission_returns_unimplemented_error() {
1142        let mut api = HostApi::new();
1143        let mut msg = Vec::new();
1144        msg.push(PROTOCOL_DISCRIMINATOR);
1145        codec::encode_string(&mut msg, "unimp-1");
1146        msg.push(TAG_DEVICE_PERMISSION_REQ);
1147
1148        let resp = expect_response(api.handle_message(&msg, TEST_APP));
1149
1150        let mut r = codec::Reader::new(&resp);
1151        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1152        assert_eq!(r.read_string().unwrap(), "unimp-1");
1153        assert_eq!(r.read_u8().unwrap(), TAG_DEVICE_PERMISSION_RESP);
1154        assert_eq!(r.read_u8().unwrap(), 0); // v1
1155        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1156    }
1157
1158    #[test]
1159    fn protocol_layer_passes_through_null_byte_deeplink_host_must_validate() {
1160        let mut api = HostApi::new();
1161        let mut msg = Vec::new();
1162        msg.push(PROTOCOL_DISCRIMINATOR);
1163        codec::encode_string(&mut msg, "pn-null");
1164        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1165        msg.push(0); // v1
1166        codec::encode_string(&mut msg, "Alert");
1167        codec::encode_option_some(&mut msg);
1168        codec::encode_string(&mut msg, "https://evil.com\x00@good.com");
1169
1170        match api.handle_message(&msg, TEST_APP) {
1171            HostApiOutcome::NeedsPushNotification {
1172                request_id,
1173                text,
1174                deeplink,
1175            } => {
1176                assert_eq!(request_id, "pn-null");
1177                assert_eq!(text, "Alert");
1178                // The protocol passes the deeplink through as-is; host-side
1179                // URL validation must sanitize if needed.
1180                assert_eq!(deeplink.as_deref(), Some("https://evil.com\x00@good.com"));
1181            }
1182            other => panic!(
1183                "expected NeedsPushNotification, got {}",
1184                outcome_name(&other)
1185            ),
1186        }
1187    }
1188
1189    #[test]
1190    fn protocol_layer_passes_through_control_char_deeplink_host_must_validate() {
1191        let mut api = HostApi::new();
1192        let mut msg = Vec::new();
1193        msg.push(PROTOCOL_DISCRIMINATOR);
1194        codec::encode_string(&mut msg, "pn-ctrl");
1195        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1196        msg.push(0); // v1
1197        codec::encode_string(&mut msg, "Alert");
1198        codec::encode_option_some(&mut msg);
1199        codec::encode_string(&mut msg, "https://evil.com/\x07\x08\x1b");
1200
1201        match api.handle_message(&msg, TEST_APP) {
1202            HostApiOutcome::NeedsPushNotification {
1203                request_id,
1204                text,
1205                deeplink,
1206            } => {
1207                assert_eq!(request_id, "pn-ctrl");
1208                assert_eq!(text, "Alert");
1209                assert_eq!(deeplink.as_deref(), Some("https://evil.com/\x07\x08\x1b"));
1210            }
1211            other => panic!(
1212                "expected NeedsPushNotification, got {}",
1213                outcome_name(&other)
1214            ),
1215        }
1216    }
1217
1218    #[test]
1219    fn subscription_stop_returns_silent() {
1220        let mut api = HostApi::new();
1221        let mut msg = Vec::new();
1222        msg.push(PROTOCOL_DISCRIMINATOR);
1223        codec::encode_string(&mut msg, "stop-1");
1224        msg.push(TAG_ACCOUNT_STATUS_STOP);
1225
1226        expect_silent(api.handle_message(&msg, TEST_APP));
1227    }
1228
1229    #[test]
1230    fn malformed_input_returns_silent() {
1231        let mut api = HostApi::new();
1232
1233        // Empty input
1234        expect_silent(api.handle_message(&[], TEST_APP));
1235
1236        // Truncated after discriminator + request_id (no tag)
1237        let mut msg = Vec::new();
1238        msg.push(PROTOCOL_DISCRIMINATOR);
1239        codec::encode_string(&mut msg, "trunc");
1240        expect_silent(api.handle_message(&msg, TEST_APP));
1241    }
1242
1243    #[test]
1244    fn unknown_tag_returns_silent() {
1245        let mut api = HostApi::new();
1246        let mut msg = Vec::new();
1247        msg.push(PROTOCOL_DISCRIMINATOR);
1248        codec::encode_string(&mut msg, "unk-1");
1249        msg.push(0xFF); // unknown tag
1250        expect_silent(api.handle_message(&msg, TEST_APP));
1251    }
1252
1253    #[test]
1254    fn storage_write_rejects_oversized_value() {
1255        let mut api = HostApi::new();
1256        let big_value = vec![0xAA; super::MAX_STORAGE_VALUE_SIZE + 1];
1257        let req = make_storage_write("w-big", "key", &big_value);
1258        let resp = expect_response(api.handle_message(&req, TEST_APP));
1259
1260        let mut r = codec::Reader::new(&resp);
1261        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1262        assert_eq!(r.read_string().unwrap(), "w-big");
1263        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1264        assert_eq!(r.read_u8().unwrap(), 0); // v1
1265        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1266    }
1267
1268    #[test]
1269    fn storage_write_rejects_long_key() {
1270        let mut api = HostApi::new();
1271        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1272        let req = make_storage_write("w-longkey", &long_key, b"v");
1273        let resp = expect_response(api.handle_message(&req, TEST_APP));
1274
1275        let mut r = codec::Reader::new(&resp);
1276        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1277        assert_eq!(r.read_string().unwrap(), "w-longkey");
1278        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1279        assert_eq!(r.read_u8().unwrap(), 0); // v1
1280        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1281    }
1282
1283    #[test]
1284    fn sign_payload_returns_needs_sign() {
1285        let mut api = HostApi::new();
1286        let mut msg = Vec::new();
1287        msg.push(PROTOCOL_DISCRIMINATOR);
1288        codec::encode_string(&mut msg, "sign-1");
1289        msg.push(TAG_SIGN_PAYLOAD_REQ);
1290        msg.push(0); // v1
1291        codec::encode_var_bytes(&mut msg, &[0xAA; 32]); // publicKey
1292        msg.extend_from_slice(b"payload-data");
1293
1294        match api.handle_message(&msg, TEST_APP) {
1295            HostApiOutcome::NeedsSign {
1296                request_id,
1297                request_tag,
1298                public_key,
1299                payload,
1300            } => {
1301                assert_eq!(request_id, "sign-1");
1302                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1303                assert_eq!(public_key, vec![0xAA; 32]);
1304                assert_eq!(payload, b"payload-data");
1305            }
1306            _ => panic!("expected NeedsSign"),
1307        }
1308    }
1309
1310    #[test]
1311    fn sign_raw_returns_needs_sign() {
1312        let mut api = HostApi::new();
1313        let mut msg = Vec::new();
1314        msg.push(PROTOCOL_DISCRIMINATOR);
1315        codec::encode_string(&mut msg, "sign-2");
1316        msg.push(TAG_SIGN_RAW_REQ);
1317        msg.push(0); // v1
1318        codec::encode_var_bytes(&mut msg, &[0xBB; 32]); // publicKey
1319        msg.extend_from_slice(b"raw-bytes");
1320
1321        match api.handle_message(&msg, TEST_APP) {
1322            HostApiOutcome::NeedsSign {
1323                request_id,
1324                request_tag,
1325                public_key,
1326                payload,
1327            } => {
1328                assert_eq!(request_id, "sign-2");
1329                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1330                assert_eq!(public_key, vec![0xBB; 32]);
1331                assert_eq!(payload, b"raw-bytes");
1332            }
1333            _ => panic!("expected NeedsSign"),
1334        }
1335    }
1336
1337    #[test]
1338    fn logging_redacts_sign_payload_requests() {
1339        let mut api = HostApi::new();
1340        let request_id = "req-id-secret-123";
1341        let payload_secret = "payload-secret-456";
1342        let pubkey_secret_hex = "abababab";
1343        let mut msg = Vec::new();
1344        msg.push(PROTOCOL_DISCRIMINATOR);
1345        codec::encode_string(&mut msg, request_id);
1346        msg.push(TAG_SIGN_PAYLOAD_REQ);
1347        msg.push(0); // v1
1348        codec::encode_var_bytes(&mut msg, &[0xAB; 32]);
1349        msg.extend_from_slice(payload_secret.as_bytes());
1350
1351        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1352
1353        match outcome {
1354            HostApiOutcome::NeedsSign {
1355                request_id: actual_request_id,
1356                request_tag,
1357                public_key,
1358                payload,
1359            } => {
1360                assert_eq!(actual_request_id, request_id);
1361                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1362                assert_eq!(public_key, vec![0xAB; 32]);
1363                assert_eq!(payload, payload_secret.as_bytes());
1364            }
1365            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1366        }
1367
1368        assert_logs_contain(&logs, "request: kind=sign_payload (tag=36)");
1369        assert_logs_contain(&logs, "sign_payload request (pubkey=32 bytes)");
1370        assert_logs_do_not_contain(&logs, request_id);
1371        assert_logs_do_not_contain(&logs, payload_secret);
1372        assert_logs_do_not_contain(&logs, pubkey_secret_hex);
1373    }
1374
1375    #[test]
1376    fn logging_redacts_sign_raw_requests() {
1377        let mut api = HostApi::new();
1378        let request_id = "req-id-secret-raw";
1379        let raw_secret = "raw-secret-789";
1380        let mut msg = Vec::new();
1381        msg.push(PROTOCOL_DISCRIMINATOR);
1382        codec::encode_string(&mut msg, request_id);
1383        msg.push(TAG_SIGN_RAW_REQ);
1384        msg.push(0); // v1
1385        codec::encode_var_bytes(&mut msg, &[0xCD; 32]);
1386        msg.extend_from_slice(raw_secret.as_bytes());
1387
1388        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1389
1390        match outcome {
1391            HostApiOutcome::NeedsSign {
1392                request_id: actual_request_id,
1393                request_tag,
1394                public_key,
1395                payload,
1396            } => {
1397                assert_eq!(actual_request_id, request_id);
1398                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1399                assert_eq!(public_key, vec![0xCD; 32]);
1400                assert_eq!(payload, raw_secret.as_bytes());
1401            }
1402            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1403        }
1404
1405        assert_logs_contain(&logs, "request: kind=sign_raw (tag=34)");
1406        assert_logs_contain(&logs, "sign_raw request (pubkey=32 bytes)");
1407        assert_logs_do_not_contain(&logs, request_id);
1408        assert_logs_do_not_contain(&logs, raw_secret);
1409    }
1410
1411    #[test]
1412    fn logging_redacts_navigation_requests() {
1413        let mut api = HostApi::new();
1414        let request_id = "req-id-secret-nav";
1415        let url = "https://example.com/callback?token=nav-secret-123";
1416        let req = make_navigate_request(request_id, url);
1417
1418        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1419
1420        match outcome {
1421            HostApiOutcome::NeedsNavigate {
1422                request_id: actual_request_id,
1423                url: actual_url,
1424            } => {
1425                assert_eq!(actual_request_id, request_id);
1426                assert_eq!(actual_url, url);
1427            }
1428            other => panic!("expected NeedsNavigate, got {}", outcome_name(&other)),
1429        }
1430
1431        assert_logs_contain(&logs, "request: kind=navigate_to (tag=6)");
1432        assert_logs_contain(&logs, "navigate_to request");
1433        assert_logs_do_not_contain(&logs, request_id);
1434        assert_logs_do_not_contain(&logs, "nav-secret-123");
1435    }
1436
1437    #[test]
1438    fn logging_redacts_local_storage_write_requests() {
1439        let mut api = HostApi::new();
1440        let request_id = "req-id-secret-storage";
1441        let key = "storage-secret-key";
1442        let value = b"storage-secret-value";
1443        let req = make_storage_write(request_id, key, value);
1444
1445        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1446        match outcome {
1447            HostApiOutcome::NeedsStorageWrite {
1448                request_id: actual_request_id,
1449                key: actual_key,
1450                value: actual_value,
1451            } => {
1452                assert_eq!(actual_request_id, request_id);
1453                assert_eq!(actual_key, key);
1454                assert_eq!(actual_value, value);
1455            }
1456            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1457        }
1458
1459        assert_logs_contain(&logs, "request: kind=local_storage_write (tag=14)");
1460        assert_logs_do_not_contain(&logs, request_id);
1461        assert_logs_do_not_contain(&logs, key);
1462        assert_logs_do_not_contain(&logs, "storage-secret-value");
1463    }
1464
1465    #[test]
1466    fn logging_redacts_feature_supported_requests() {
1467        let mut api = HostApi::new();
1468        let utf8_secret = "feature-secret-utf8";
1469        let utf8_req =
1470            make_feature_supported_request("req-id-feature-utf8", utf8_secret.as_bytes());
1471
1472        let (utf8_outcome, utf8_logs) = capture_logs(|| api.handle_message(&utf8_req, TEST_APP));
1473        let utf8_resp = expect_response(utf8_outcome);
1474        let mut utf8_reader = codec::Reader::new(&utf8_resp);
1475        utf8_reader.read_u8().unwrap(); // discriminator
1476        utf8_reader.read_string().unwrap();
1477        assert_eq!(utf8_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1478        utf8_reader.read_u8().unwrap();
1479        utf8_reader.read_u8().unwrap();
1480        assert_eq!(utf8_reader.read_u8().unwrap(), 0);
1481        assert_logs_contain(&utf8_logs, "request: kind=feature_supported (tag=2)");
1482        assert_logs_contain(&utf8_logs, "feature_supported: feature=utf8_other -> false");
1483        assert_logs_do_not_contain(&utf8_logs, utf8_secret);
1484
1485        let binary_req =
1486            make_feature_supported_request("req-id-feature-binary", &[0, 0xde, 0xad, 0xbe, 0xef]);
1487        let (binary_outcome, binary_logs) =
1488            capture_logs(|| api.handle_message(&binary_req, TEST_APP));
1489        let binary_resp = expect_response(binary_outcome);
1490        let mut binary_reader = codec::Reader::new(&binary_resp);
1491        binary_reader.read_u8().unwrap(); // discriminator
1492        binary_reader.read_string().unwrap();
1493        assert_eq!(binary_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1494        binary_reader.read_u8().unwrap();
1495        binary_reader.read_u8().unwrap();
1496        assert_eq!(binary_reader.read_u8().unwrap(), 0);
1497        assert_logs_contain(
1498            &binary_logs,
1499            "feature_supported: feature=binary_chain -> false",
1500        );
1501        assert_logs_do_not_contain(&binary_logs, "deadbeef");
1502    }
1503
1504    #[test]
1505    fn logging_redacts_jsonrpc_requests() {
1506        let mut api = HostApi::new();
1507        let request_id = "req-id-secret-jsonrpc";
1508        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-method","params":["jsonrpc-secret-param"]}"#;
1509        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SEND_REQ, json);
1510
1511        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1512
1513        match outcome {
1514            HostApiOutcome::NeedsChainQuery {
1515                request_id: actual_request_id,
1516                method,
1517                params,
1518            } => {
1519                assert_eq!(actual_request_id, request_id);
1520                assert_eq!(method, "rpc-secret-method");
1521                assert_eq!(params, serde_json::json!(["jsonrpc-secret-param"]));
1522            }
1523            other => panic!("expected NeedsChainQuery, got {}", outcome_name(&other)),
1524        }
1525
1526        assert_logs_contain(&logs, "request: kind=jsonrpc_send (tag=70)");
1527        assert_logs_contain(&logs, "jsonrpc_send request");
1528        assert_logs_do_not_contain(&logs, request_id);
1529        assert_logs_do_not_contain(&logs, "rpc-secret-method");
1530        assert_logs_do_not_contain(&logs, "jsonrpc-secret-param");
1531    }
1532
1533    #[test]
1534    fn logging_redacts_jsonrpc_subscribe_requests() {
1535        let mut api = HostApi::new();
1536        let request_id = "req-id-secret-jsonrpc-sub";
1537        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-subscribe","params":["jsonrpc-secret-sub-param"]}"#;
1538        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SUB_START, json);
1539
1540        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1541
1542        match outcome {
1543            HostApiOutcome::NeedsChainSubscription {
1544                request_id: actual_request_id,
1545                method,
1546                params,
1547            } => {
1548                assert_eq!(actual_request_id, request_id);
1549                assert_eq!(method, "rpc-secret-subscribe");
1550                assert_eq!(params, serde_json::json!(["jsonrpc-secret-sub-param"]));
1551            }
1552            other => panic!(
1553                "expected NeedsChainSubscription, got {}",
1554                outcome_name(&other)
1555            ),
1556        }
1557
1558        assert_logs_contain(&logs, "request: kind=jsonrpc_subscribe_start (tag=72)");
1559        assert_logs_contain(&logs, "jsonrpc_subscribe_start request");
1560        assert_logs_do_not_contain(&logs, request_id);
1561        assert_logs_do_not_contain(&logs, "rpc-secret-subscribe");
1562        assert_logs_do_not_contain(&logs, "jsonrpc-secret-sub-param");
1563    }
1564
1565    #[test]
1566    fn logging_redacts_decode_failures() {
1567        let mut api = HostApi::new();
1568        let malformed = b"decode-secret-123";
1569
1570        let (outcome, logs) = capture_logs(|| api.handle_message(malformed, TEST_APP));
1571
1572        expect_silent(outcome);
1573        assert_logs_contain(&logs, "failed to decode message: kind=");
1574        assert_logs_do_not_contain(&logs, "decode-secret-123");
1575    }
1576
1577    // -- Push notification tests --
1578
1579    fn make_push_notification_request(
1580        request_id: &str,
1581        text: &str,
1582        deeplink: Option<&str>,
1583    ) -> Vec<u8> {
1584        let mut msg = Vec::new();
1585        msg.push(PROTOCOL_DISCRIMINATOR);
1586        codec::encode_string(&mut msg, request_id);
1587        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1588        msg.push(0); // v1
1589        codec::encode_string(&mut msg, text);
1590        match deeplink {
1591            Some(dl) => {
1592                codec::encode_option_some(&mut msg);
1593                codec::encode_string(&mut msg, dl);
1594            }
1595            None => codec::encode_option_none(&mut msg),
1596        }
1597        msg
1598    }
1599
1600    #[test]
1601    fn push_notification_returns_needs_push_notification() {
1602        let mut api = HostApi::new();
1603        let req = make_push_notification_request(
1604            "pn-1",
1605            "Transfer complete",
1606            Some("https://app.example/tx/123"),
1607        );
1608
1609        match api.handle_message(&req, TEST_APP) {
1610            HostApiOutcome::NeedsPushNotification {
1611                request_id,
1612                text,
1613                deeplink,
1614            } => {
1615                assert_eq!(request_id, "pn-1");
1616                assert_eq!(text, "Transfer complete");
1617                assert_eq!(deeplink.as_deref(), Some("https://app.example/tx/123"));
1618            }
1619            other => panic!(
1620                "expected NeedsPushNotification, got {}",
1621                outcome_name(&other)
1622            ),
1623        }
1624    }
1625
1626    #[test]
1627    fn push_notification_without_deeplink() {
1628        let mut api = HostApi::new();
1629        let req = make_push_notification_request("pn-2", "Hello world", None);
1630
1631        match api.handle_message(&req, TEST_APP) {
1632            HostApiOutcome::NeedsPushNotification {
1633                request_id,
1634                text,
1635                deeplink,
1636            } => {
1637                assert_eq!(request_id, "pn-2");
1638                assert_eq!(text, "Hello world");
1639                assert!(deeplink.is_none());
1640            }
1641            other => panic!(
1642                "expected NeedsPushNotification, got {}",
1643                outcome_name(&other)
1644            ),
1645        }
1646    }
1647
1648    #[test]
1649    fn push_notification_rejects_oversized_text() {
1650        let mut api = HostApi::new();
1651        let big_text = "x".repeat(super::MAX_PUSH_NOTIFICATION_TEXT_LEN + 1);
1652        let req = make_push_notification_request("pn-big", &big_text, None);
1653        let resp = expect_response(api.handle_message(&req, TEST_APP));
1654
1655        let mut r = codec::Reader::new(&resp);
1656        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1657        assert_eq!(r.read_string().unwrap(), "pn-big");
1658        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1659        assert_eq!(r.read_u8().unwrap(), 0); // v1
1660        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1661        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1662        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1663    }
1664
1665    #[test]
1666    fn push_notification_accepts_max_length_text() {
1667        let mut api = HostApi::new();
1668        let text_at_limit = "x".repeat(super::MAX_PUSH_NOTIFICATION_TEXT_LEN);
1669        let req = make_push_notification_request("pn-limit", &text_at_limit, None);
1670
1671        match api.handle_message(&req, TEST_APP) {
1672            HostApiOutcome::NeedsPushNotification {
1673                request_id, text, ..
1674            } => {
1675                assert_eq!(request_id, "pn-limit");
1676                assert_eq!(text.len(), super::MAX_PUSH_NOTIFICATION_TEXT_LEN);
1677            }
1678            other => panic!(
1679                "expected NeedsPushNotification for text at limit, got {}",
1680                outcome_name(&other)
1681            ),
1682        }
1683    }
1684
1685    #[test]
1686    fn push_notification_rejects_javascript_deeplink() {
1687        let mut api = HostApi::new();
1688        let req = make_push_notification_request("pn-js", "hi", Some("javascript:alert(1)"));
1689        let resp = expect_response(api.handle_message(&req, TEST_APP));
1690
1691        let mut r = codec::Reader::new(&resp);
1692        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1693        assert_eq!(r.read_string().unwrap(), "pn-js");
1694        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1695        assert_eq!(r.read_u8().unwrap(), 0); // v1
1696        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1697        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1698        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1699    }
1700
1701    #[test]
1702    fn push_notification_rejects_file_deeplink() {
1703        let mut api = HostApi::new();
1704        let req = make_push_notification_request("pn-file", "hi", Some("file:///etc/passwd"));
1705        let resp = expect_response(api.handle_message(&req, TEST_APP));
1706
1707        let mut r = codec::Reader::new(&resp);
1708        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1709        assert_eq!(r.read_string().unwrap(), "pn-file");
1710        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1711        assert_eq!(r.read_u8().unwrap(), 0); // v1
1712        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1713        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1714        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1715    }
1716
1717    #[test]
1718    fn push_notification_rejects_data_deeplink() {
1719        let mut api = HostApi::new();
1720        let req = make_push_notification_request(
1721            "pn-data",
1722            "hi",
1723            Some("data:text/html,<script>alert(1)</script>"),
1724        );
1725        let resp = expect_response(api.handle_message(&req, TEST_APP));
1726
1727        let mut r = codec::Reader::new(&resp);
1728        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1729        assert_eq!(r.read_string().unwrap(), "pn-data");
1730        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1731        assert_eq!(r.read_u8().unwrap(), 0); // v1
1732        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1733        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1734        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1735    }
1736
1737    #[test]
1738    fn push_notification_rejects_plain_http_deeplink() {
1739        let mut api = HostApi::new();
1740        let req =
1741            make_push_notification_request("pn-http", "hi", Some("http://app.example/tx/123"));
1742        let resp = expect_response(api.handle_message(&req, TEST_APP));
1743
1744        let mut r = codec::Reader::new(&resp);
1745        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1746        assert_eq!(r.read_string().unwrap(), "pn-http");
1747        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1748        assert_eq!(r.read_u8().unwrap(), 0); // v1
1749        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1750        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1751        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1752    }
1753
1754    #[test]
1755    fn push_notification_allows_https_deeplink() {
1756        let mut api = HostApi::new();
1757        let req =
1758            make_push_notification_request("pn-https", "hi", Some("https://app.example/tx/123"));
1759
1760        match api.handle_message(&req, TEST_APP) {
1761            HostApiOutcome::NeedsPushNotification {
1762                request_id,
1763                deeplink,
1764                ..
1765            } => {
1766                assert_eq!(request_id, "pn-https");
1767                assert_eq!(deeplink.as_deref(), Some("https://app.example/tx/123"));
1768            }
1769            other => panic!(
1770                "expected NeedsPushNotification, got {}",
1771                outcome_name(&other)
1772            ),
1773        }
1774    }
1775
1776    #[test]
1777    fn push_notification_scheme_check_is_case_insensitive() {
1778        let mut api = HostApi::new();
1779        let req = make_push_notification_request("pn-case", "hi", Some("HTTPS://app.example"));
1780
1781        match api.handle_message(&req, TEST_APP) {
1782            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1783                assert_eq!(request_id, "pn-case");
1784            }
1785            other => panic!(
1786                "expected NeedsPushNotification, got {}",
1787                outcome_name(&other)
1788            ),
1789        }
1790    }
1791
1792    #[test]
1793    fn push_notification_rejects_multibyte_text_exceeding_byte_limit() {
1794        let mut api = HostApi::new();
1795        // Each '🦊' is 4 UTF-8 bytes; 257 * 4 = 1028 bytes > 1024.
1796        let text = "🦊".repeat(257);
1797        assert_eq!(text.len(), 1028);
1798        let req = make_push_notification_request("pn-mb-big", &text, None);
1799        let resp = expect_response(api.handle_message(&req, TEST_APP));
1800
1801        let mut r = codec::Reader::new(&resp);
1802        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1803        assert_eq!(r.read_string().unwrap(), "pn-mb-big");
1804        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1805        assert_eq!(r.read_u8().unwrap(), 0); // v1
1806        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1807    }
1808
1809    #[test]
1810    fn push_notification_accepts_multibyte_text_at_byte_limit() {
1811        let mut api = HostApi::new();
1812        // Each '🦊' is 4 UTF-8 bytes; 256 * 4 = 1024 bytes == limit.
1813        let text = "🦊".repeat(256);
1814        assert_eq!(text.len(), 1024);
1815        let req = make_push_notification_request("pn-mb-limit", &text, None);
1816
1817        match api.handle_message(&req, TEST_APP) {
1818            HostApiOutcome::NeedsPushNotification {
1819                request_id,
1820                text: actual_text,
1821                ..
1822            } => {
1823                assert_eq!(request_id, "pn-mb-limit");
1824                assert_eq!(actual_text.len(), 1024);
1825            }
1826            other => panic!(
1827                "expected NeedsPushNotification for multibyte text at limit, got {}",
1828                outcome_name(&other)
1829            ),
1830        }
1831    }
1832
1833    #[test]
1834    fn push_notification_rejects_empty_deeplink() {
1835        let mut api = HostApi::new();
1836        let req = make_push_notification_request("pn-empty-dl", "hi", Some(""));
1837        let resp = expect_response(api.handle_message(&req, TEST_APP));
1838
1839        let mut r = codec::Reader::new(&resp);
1840        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1841        assert_eq!(r.read_string().unwrap(), "pn-empty-dl");
1842        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1843        assert_eq!(r.read_u8().unwrap(), 0); // v1
1844        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1845    }
1846
1847    #[test]
1848    fn push_notification_accepts_bare_https_scheme_deeplink() {
1849        // "https://" with no host passes the scheme check.
1850        // Host is responsible for further URL validation.
1851        let mut api = HostApi::new();
1852        let req = make_push_notification_request("pn-bare", "hi", Some("https://"));
1853        match api.handle_message(&req, TEST_APP) {
1854            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1855                assert_eq!(request_id, "pn-bare");
1856            }
1857            other => panic!(
1858                "expected NeedsPushNotification for bare https://, got {}",
1859                outcome_name(&other)
1860            ),
1861        }
1862    }
1863
1864    #[test]
1865    fn push_notification_accepts_deeplink_at_byte_limit() {
1866        let mut api = HostApi::new();
1867        let base = "https://app.example/";
1868        let pad = super::MAX_DEEPLINK_URL_LEN - base.len();
1869        let url = format!("{}{}", base, "a".repeat(pad));
1870        assert_eq!(url.len(), super::MAX_DEEPLINK_URL_LEN);
1871        let req = make_push_notification_request("pn-dl-limit", "hi", Some(&url));
1872        match api.handle_message(&req, TEST_APP) {
1873            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1874                assert_eq!(request_id, "pn-dl-limit");
1875            }
1876            other => panic!(
1877                "expected NeedsPushNotification at deeplink limit, got {}",
1878                outcome_name(&other)
1879            ),
1880        }
1881    }
1882
1883    #[test]
1884    fn push_notification_rejects_oversized_deeplink() {
1885        let mut api = HostApi::new();
1886        let long_url = format!(
1887            "https://app.example/{}",
1888            "a".repeat(super::MAX_DEEPLINK_URL_LEN)
1889        );
1890        let req = make_push_notification_request("pn-dl-long", "hi", Some(&long_url));
1891        let resp = expect_response(api.handle_message(&req, TEST_APP));
1892
1893        let mut r = codec::Reader::new(&resp);
1894        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1895        assert_eq!(r.read_string().unwrap(), "pn-dl-long");
1896        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1897        assert_eq!(r.read_u8().unwrap(), 0); // v1
1898        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1899    }
1900
1901    #[test]
1902    fn storage_read_rejects_long_key() {
1903        let mut api = HostApi::new();
1904        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1905        let req = make_storage_read("r-longkey", &long_key);
1906        let resp = expect_response(api.handle_message(&req, TEST_APP));
1907
1908        let mut r = codec::Reader::new(&resp);
1909        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1910        assert_eq!(r.read_string().unwrap(), "r-longkey");
1911        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_READ_RESP);
1912        assert_eq!(r.read_u8().unwrap(), 0); // v1
1913        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1914    }
1915
1916    #[test]
1917    fn push_notification_serializes_to_json() {
1918        let outcome = HostApiOutcome::NeedsPushNotification {
1919            request_id: "pn-s".into(),
1920            text: "Hello".into(),
1921            deeplink: Some("https://app.example".into()),
1922        };
1923        let json = serde_json::to_value(&outcome).unwrap();
1924        assert_eq!(json["type"], "NeedsPushNotification");
1925        assert_eq!(json["request_id"], "pn-s");
1926        assert_eq!(json["text"], "Hello");
1927        assert_eq!(json["deeplink"], "https://app.example");
1928    }
1929
1930    #[test]
1931    fn push_notification_serializes_null_deeplink() {
1932        let outcome = HostApiOutcome::NeedsPushNotification {
1933            request_id: "pn-n".into(),
1934            text: "Hi".into(),
1935            deeplink: None,
1936        };
1937        let json = serde_json::to_value(&outcome).unwrap();
1938        assert_eq!(json["type"], "NeedsPushNotification");
1939        assert_eq!(json["deeplink"], serde_json::Value::Null);
1940    }
1941
1942    #[test]
1943    fn feature_supported_push_notification() {
1944        let mut api = HostApi::new();
1945        let req = make_feature_supported_request("fs-pn", b"push_notification");
1946        let resp = expect_response(api.handle_message(&req, TEST_APP));
1947
1948        let mut r = codec::Reader::new(&resp);
1949        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1950        assert_eq!(r.read_string().unwrap(), "fs-pn");
1951        assert_eq!(r.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1952        assert_eq!(r.read_u8().unwrap(), 0); // v1
1953        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1954        assert_eq!(r.read_u8().unwrap(), 1); // true
1955    }
1956
1957    #[test]
1958    fn logging_does_not_leak_push_notification_content() {
1959        let mut api = HostApi::new();
1960        let request_id = "req-id-secret-pn";
1961        let text = "notification-secret-text-123";
1962        let deeplink = "https://secret-deeplink.example/foo";
1963        let req = make_push_notification_request(request_id, text, Some(deeplink));
1964
1965        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1966
1967        match outcome {
1968            HostApiOutcome::NeedsPushNotification {
1969                request_id: actual_request_id,
1970                text: actual_text,
1971                deeplink: actual_deeplink,
1972            } => {
1973                assert_eq!(actual_request_id, request_id);
1974                assert_eq!(actual_text, text);
1975                assert_eq!(actual_deeplink.as_deref(), Some(deeplink));
1976            }
1977            other => panic!(
1978                "expected NeedsPushNotification, got {}",
1979                outcome_name(&other)
1980            ),
1981        }
1982
1983        assert_logs_contain(&logs, "request: kind=push_notification (tag=4)");
1984        assert_logs_contain(&logs, "push_notification request");
1985        assert_logs_do_not_contain(&logs, request_id);
1986        assert_logs_do_not_contain(&logs, "notification-secret-text-123");
1987        assert_logs_do_not_contain(&logs, "secret-deeplink");
1988    }
1989
1990    #[test]
1991    fn storage_clear_rejects_long_key() {
1992        let mut api = HostApi::new();
1993        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1994        let req = make_storage_clear("c-longkey", &long_key);
1995        let resp = expect_response(api.handle_message(&req, TEST_APP));
1996
1997        let mut r = codec::Reader::new(&resp);
1998        assert_eq!(r.read_u8().unwrap(), PROTOCOL_DISCRIMINATOR);
1999        assert_eq!(r.read_string().unwrap(), "c-longkey");
2000        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_CLEAR_RESP);
2001        assert_eq!(r.read_u8().unwrap(), 0); // v1
2002        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
2003    }
2004
2005    #[test]
2006    fn message_without_discriminator_returns_silent() {
2007        let mut api = HostApi::new();
2008        // Build a valid handshake but WITHOUT the discriminator prefix
2009        let mut msg = Vec::new();
2010        codec::encode_string(&mut msg, "hs-no-disc");
2011        msg.push(TAG_HANDSHAKE_REQ);
2012        msg.push(0); // v1
2013        msg.push(PROTOCOL_VERSION);
2014        expect_silent(api.handle_message(&msg, TEST_APP));
2015    }
2016
2017    #[test]
2018    fn message_with_wrong_discriminator_returns_silent() {
2019        let mut api = HostApi::new();
2020        let mut msg = Vec::new();
2021        msg.push(0x02); // wrong discriminator
2022        codec::encode_string(&mut msg, "hs-bad-disc");
2023        msg.push(TAG_HANDSHAKE_REQ);
2024        msg.push(0);
2025        msg.push(PROTOCOL_VERSION);
2026        expect_silent(api.handle_message(&msg, TEST_APP));
2027    }
2028
2029    #[test]
2030    fn response_first_byte_is_discriminator() {
2031        let mut api = HostApi::new();
2032        let req = make_handshake_request("hs-disc");
2033        let resp = expect_response(api.handle_message(&req, TEST_APP));
2034        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2035    }
2036
2037    #[test]
2038    fn all_response_types_carry_discriminator() {
2039        let mut api = HostApi::new();
2040        // Handshake
2041        let resp = expect_response(api.handle_message(&make_handshake_request("hs-d"), TEST_APP));
2042        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2043        // Get accounts
2044        let resp =
2045            expect_response(api.handle_message(&make_get_accounts_request("acc-d"), TEST_APP));
2046        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2047        // Storage responses encoded by the host
2048        let resp = encode_storage_write_response("sw-d", false);
2049        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2050        let resp = encode_storage_read_response("sr-d", Some(b"v"));
2051        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2052        // Feature supported
2053        let resp = expect_response(api.handle_message(
2054            &make_feature_supported_request("fs-d", b"signing"),
2055            TEST_APP,
2056        ));
2057        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2058        let resp = encode_storage_write_response("sc-d", true);
2059        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2060        // Push notification response
2061        let resp = encode_push_notification_response("pn-d");
2062        assert_eq!(resp[0], PROTOCOL_DISCRIMINATOR);
2063    }
2064}