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    }
620}
621
622fn feature_log_kind(feature_data: &[u8]) -> &'static str {
623    match std::str::from_utf8(feature_data) {
624        Ok("signing" | "sign" | "navigate" | "push_notification") => "utf8_known",
625        Ok(_) => "utf8_other",
626        Err(_) if feature_data.first() == Some(&0) => "binary_chain",
627        Err(_) => "binary_other",
628    }
629}
630
631fn request_kind(req: &HostRequest) -> &'static str {
632    match req {
633        HostRequest::Handshake { .. } => "handshake",
634        HostRequest::GetNonProductAccounts => "get_non_product_accounts",
635        HostRequest::FeatureSupported { .. } => "feature_supported",
636        HostRequest::LocalStorageRead { .. } => "local_storage_read",
637        HostRequest::LocalStorageWrite { .. } => "local_storage_write",
638        HostRequest::LocalStorageClear { .. } => "local_storage_clear",
639        HostRequest::SignPayload { .. } => "sign_payload",
640        HostRequest::SignRaw { .. } => "sign_raw",
641        HostRequest::CreateTransaction { .. } => "create_transaction",
642        HostRequest::NavigateTo { .. } => "navigate_to",
643        HostRequest::PushNotification { .. } => "push_notification",
644        HostRequest::AccountConnectionStatusStart => "account_connection_status_start",
645        HostRequest::JsonRpcSend { .. } => "jsonrpc_send",
646        HostRequest::JsonRpcSubscribeStart { .. } => "jsonrpc_subscribe_start",
647        HostRequest::ChainHeadFollowStart { .. } => "chain_head_follow_start",
648        HostRequest::ChainHeadRequest { .. } => "chain_head_request",
649        HostRequest::ChainSpecRequest { .. } => "chain_spec_request",
650        HostRequest::ChainTxBroadcast { .. } => "chain_tx_broadcast",
651        HostRequest::ChainTxStop { .. } => "chain_tx_stop",
652        HostRequest::Unimplemented { .. } => "unimplemented",
653        HostRequest::Unknown { .. } => "unknown",
654    }
655}
656
657/// Check whether a deeplink URI has an allowed scheme.
658/// Only `https://` is accepted by default (case-insensitive on the scheme).
659fn is_allowed_deeplink_scheme(url: &str) -> bool {
660    let lower = url.to_ascii_lowercase();
661    ALLOWED_DEEPLINK_SCHEMES
662        .iter()
663        .any(|scheme| lower.starts_with(scheme))
664}
665
666// ---------------------------------------------------------------------------
667// JS bridge script — injected into WKWebView at document_start
668// ---------------------------------------------------------------------------
669
670/// JavaScript injected before the Polkadot app loads. Sets up:
671/// 1. `window.__HOST_WEBVIEW_MARK__ = true` — SDK webview detection
672/// 2. `MessageChannel` with port2 as `window.__HOST_API_PORT__`
673/// 3. Binary message forwarding between port1 and native (base64)
674pub const HOST_API_BRIDGE_SCRIPT: &str = concat!(
675    r#"
676(function() {
677    'use strict';
678    if (window.__hostApiBridge) { return; }
679    window.__hostApiBridge = true;
680    window.__HOST_WEBVIEW_MARK__ = true;
681    var ch = new MessageChannel();
682    window.__HOST_API_PORT__ = ch.port2;
683    ch.port2.start();
684    var port1 = ch.port1;
685    port1.start();
686    if (!window.host) { window.host = {}; }
687    if (!window.host.storage) { window.host.storage = {}; }
688    port1.onmessage = function(ev) {
689        var data = ev.data;
690        if (!data) { console.warn('[host-bridge] data is falsy, dropping'); return; }
691        var bytes;
692        if (data instanceof Uint8Array) { bytes = data; }
693        else if (data instanceof ArrayBuffer) { bytes = new Uint8Array(data); }
694        else if (ArrayBuffer.isView(data)) { bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); }
695        else { console.warn('[host-bridge] unknown data type: ' + typeof data + ' constructor=' + (data.constructor ? data.constructor.name : '?') + ', dropping'); return; }
696        var binary = '';
697        for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); }
698        try {
699            window.webkit.messageHandlers.hostApi.postMessage(btoa(binary));
700        } catch(e) {
701            console.error('[host-bridge] postMessage to hostApi FAILED:', e.message);
702        }
703    };
704    window.__hostApiReply = function(b64) {
705        try {
706            var binary = atob(b64);
707            var bytes = new Uint8Array(binary.length);
708            for (var i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
709            port1.postMessage(bytes);
710        } catch(e) { console.error('[host-bridge] reply failed:', e.message); }
711    };
712"#,
713    include_str!("js/storage_bridge.js"),
714    r#"
715})();
716"#
717);
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::protocol::*;
723    use std::sync::atomic::{AtomicBool, Ordering};
724    use std::sync::{Mutex, Once};
725
726    const TEST_APP: &str = "test-app";
727    static TEST_LOGGER_INIT: Once = Once::new();
728    static TEST_LOGGER_INSTALLED: AtomicBool = AtomicBool::new(false);
729    static TEST_LOG_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
730    static TEST_LOGGER: TestLogger = TestLogger::new();
731
732    struct TestLogger {
733        entries: Mutex<Vec<String>>,
734        capture_thread: Mutex<Option<std::thread::ThreadId>>,
735    }
736
737    impl TestLogger {
738        const fn new() -> Self {
739            Self {
740                entries: Mutex::new(Vec::new()),
741                capture_thread: Mutex::new(None),
742            }
743        }
744    }
745
746    impl log::Log for TestLogger {
747        fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
748            metadata.level() <= log::Level::Info
749        }
750
751        fn log(&self, record: &log::Record<'_>) {
752            if !self.enabled(record.metadata()) {
753                return;
754            }
755
756            let capture_thread = self
757                .capture_thread
758                .lock()
759                .unwrap_or_else(|e| e.into_inner());
760            if capture_thread.as_ref() != Some(&std::thread::current().id()) {
761                return;
762            }
763            drop(capture_thread);
764
765            self.entries
766                .lock()
767                .unwrap_or_else(|e| e.into_inner())
768                .push(record.args().to_string());
769        }
770
771        fn flush(&self) {}
772    }
773
774    fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
775        TEST_LOGGER_INIT.call_once(|| {
776            let installed = log::set_logger(&TEST_LOGGER).is_ok();
777            if installed {
778                log::set_max_level(log::LevelFilter::Info);
779            }
780            TEST_LOGGER_INSTALLED.store(installed, Ordering::Relaxed);
781        });
782        assert!(
783            TEST_LOGGER_INSTALLED.load(Ordering::Relaxed),
784            "test logger could not be installed"
785        );
786
787        let _guard = TEST_LOG_CAPTURE_LOCK
788            .lock()
789            .unwrap_or_else(|e| e.into_inner());
790
791        TEST_LOGGER
792            .entries
793            .lock()
794            .unwrap_or_else(|e| e.into_inner())
795            .clear();
796        *TEST_LOGGER
797            .capture_thread
798            .lock()
799            .unwrap_or_else(|e| e.into_inner()) = Some(std::thread::current().id());
800
801        let result = f();
802
803        *TEST_LOGGER
804            .capture_thread
805            .lock()
806            .unwrap_or_else(|e| e.into_inner()) = None;
807        let logs = TEST_LOGGER
808            .entries
809            .lock()
810            .unwrap_or_else(|e| e.into_inner())
811            .clone();
812        TEST_LOGGER
813            .entries
814            .lock()
815            .unwrap_or_else(|e| e.into_inner())
816            .clear();
817
818        (result, logs)
819    }
820
821    fn assert_logs_contain(logs: &[String], needle: &str) {
822        let joined = logs.join("\n");
823        assert!(
824            joined.contains(needle),
825            "expected logs to contain {needle:?}, got:\n{joined}"
826        );
827    }
828
829    fn assert_logs_do_not_contain(logs: &[String], needle: &str) {
830        let joined = logs.join("\n");
831        assert!(
832            !joined.contains(needle),
833            "expected logs not to contain {needle:?}, got:\n{joined}"
834        );
835    }
836
837    #[test]
838    fn host_api_bridge_script_exposes_storage_surface() {
839        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.get"));
840        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.set"));
841        assert!(HOST_API_BRIDGE_SCRIPT.contains("window.host.storage.remove"));
842    }
843
844    /// Extract a Response from HostApiOutcome, panicking on other variants.
845    fn expect_response(outcome: HostApiOutcome) -> Vec<u8> {
846        match outcome {
847            HostApiOutcome::Response { data: v } => v,
848            other => panic!("expected Response, got {}", outcome_name(&other)),
849        }
850    }
851
852    fn expect_silent(outcome: HostApiOutcome) {
853        match outcome {
854            HostApiOutcome::Silent => {}
855            other => panic!("expected Silent, got {}", outcome_name(&other)),
856        }
857    }
858
859    fn outcome_name(o: &HostApiOutcome) -> &'static str {
860        match o {
861            HostApiOutcome::Response { .. } => "Response",
862            HostApiOutcome::NeedsSign { .. } => "NeedsSign",
863            HostApiOutcome::NeedsChainQuery { .. } => "NeedsChainQuery",
864            HostApiOutcome::NeedsChainSubscription { .. } => "NeedsChainSubscription",
865            HostApiOutcome::NeedsNavigate { .. } => "NeedsNavigate",
866            HostApiOutcome::NeedsPushNotification { .. } => "NeedsPushNotification",
867            HostApiOutcome::NeedsChainFollow { .. } => "NeedsChainFollow",
868            HostApiOutcome::NeedsChainRpc { .. } => "NeedsChainRpc",
869            HostApiOutcome::NeedsStorageRead { .. } => "NeedsStorageRead",
870            HostApiOutcome::NeedsStorageWrite { .. } => "NeedsStorageWrite",
871            HostApiOutcome::NeedsStorageClear { .. } => "NeedsStorageClear",
872            HostApiOutcome::Silent => "Silent",
873        }
874    }
875
876    fn make_handshake_request(request_id: &str) -> Vec<u8> {
877        let mut msg = Vec::new();
878        codec::encode_string(&mut msg, request_id);
879        msg.push(TAG_HANDSHAKE_REQ);
880        msg.push(0); // v1
881        msg.push(PROTOCOL_VERSION);
882        msg
883    }
884
885    fn make_get_accounts_request(request_id: &str) -> Vec<u8> {
886        let mut msg = Vec::new();
887        codec::encode_string(&mut msg, request_id);
888        msg.push(TAG_GET_NON_PRODUCT_ACCOUNTS_REQ);
889        msg.push(0); // v1
890        msg
891    }
892
893    fn make_storage_write(request_id: &str, key: &str, value: &[u8]) -> Vec<u8> {
894        let mut msg = Vec::new();
895        codec::encode_string(&mut msg, request_id);
896        msg.push(TAG_LOCAL_STORAGE_WRITE_REQ);
897        msg.push(0); // v1
898        codec::encode_string(&mut msg, key);
899        codec::encode_var_bytes(&mut msg, value);
900        msg
901    }
902
903    fn make_storage_read(request_id: &str, key: &str) -> Vec<u8> {
904        let mut msg = Vec::new();
905        codec::encode_string(&mut msg, request_id);
906        msg.push(TAG_LOCAL_STORAGE_READ_REQ);
907        msg.push(0); // v1
908        codec::encode_string(&mut msg, key);
909        msg
910    }
911
912    fn make_storage_clear(request_id: &str, key: &str) -> Vec<u8> {
913        let mut msg = Vec::new();
914        codec::encode_string(&mut msg, request_id);
915        msg.push(TAG_LOCAL_STORAGE_CLEAR_REQ);
916        msg.push(0); // v1
917        codec::encode_string(&mut msg, key);
918        msg
919    }
920
921    fn make_feature_supported_request(request_id: &str, feature_data: &[u8]) -> Vec<u8> {
922        let mut msg = Vec::new();
923        codec::encode_string(&mut msg, request_id);
924        msg.push(TAG_FEATURE_SUPPORTED_REQ);
925        msg.push(0); // v1
926        msg.extend_from_slice(feature_data);
927        msg
928    }
929
930    fn make_navigate_request(request_id: &str, url: &str) -> Vec<u8> {
931        let mut msg = Vec::new();
932        codec::encode_string(&mut msg, request_id);
933        msg.push(TAG_NAVIGATE_TO_REQ);
934        msg.push(0); // v1
935        codec::encode_string(&mut msg, url);
936        msg
937    }
938
939    fn make_jsonrpc_request(request_id: &str, tag: u8, json: &str) -> Vec<u8> {
940        let mut msg = Vec::new();
941        codec::encode_string(&mut msg, request_id);
942        msg.push(tag);
943        msg.push(0); // v1
944        codec::encode_string(&mut msg, json);
945        msg
946    }
947
948    #[test]
949    fn handshake_flow() {
950        let mut api = HostApi::new();
951        let req = make_handshake_request("hs-1");
952        let resp = expect_response(api.handle_message(&req, TEST_APP));
953
954        let mut r = codec::Reader::new(&resp);
955        assert_eq!(r.read_string().unwrap(), "hs-1");
956        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
957        assert_eq!(r.read_u8().unwrap(), 0); // v1
958        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
959    }
960
961    #[test]
962    fn handshake_wrong_version() {
963        let mut api = HostApi::new();
964        let mut req = Vec::new();
965        codec::encode_string(&mut req, "hs-bad");
966        req.push(TAG_HANDSHAKE_REQ);
967        req.push(0); // v1
968        req.push(255); // wrong version
969        let resp = expect_response(api.handle_message(&req, TEST_APP));
970
971        let mut r = codec::Reader::new(&resp);
972        assert_eq!(r.read_string().unwrap(), "hs-bad");
973        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
974        assert_eq!(r.read_u8().unwrap(), 0); // v1
975        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
976    }
977
978    #[test]
979    fn request_kind_redacts_payload_variants() {
980        assert_eq!(
981            request_kind(&HostRequest::SignPayload {
982                public_key: vec![0xAA; 32],
983                payload: b"secret-payload".to_vec(),
984            }),
985            "sign_payload"
986        );
987        assert_eq!(
988            request_kind(&HostRequest::NavigateTo {
989                url: "https://example.com/private".into(),
990            }),
991            "navigate_to"
992        );
993        assert_eq!(
994            request_kind(&HostRequest::LocalStorageWrite {
995                key: "secret".into(),
996                value: b"top-secret".to_vec(),
997            }),
998            "local_storage_write"
999        );
1000    }
1001
1002    #[test]
1003    fn decode_error_kind_redacts_error_details() {
1004        assert_eq!(
1005            decode_error_kind(&codec::DecodeErr::BadMessage("secret")),
1006            "bad_message"
1007        );
1008        assert_eq!(
1009            decode_error_kind(&codec::DecodeErr::InvalidTag(255)),
1010            "invalid_tag"
1011        );
1012    }
1013
1014    #[test]
1015    fn feature_log_kind_redacts_feature_data() {
1016        assert_eq!(feature_log_kind(b"signing"), "utf8_known");
1017        assert_eq!(feature_log_kind(b"secret-feature"), "utf8_other");
1018        assert_eq!(
1019            feature_log_kind(&[0, 0xde, 0xad, 0xbe, 0xef]),
1020            "binary_chain"
1021        );
1022        assert_eq!(
1023            feature_log_kind(&[1, 0xde, 0xad, 0xbe, 0xef]),
1024            "binary_other"
1025        );
1026    }
1027
1028    #[test]
1029    fn get_accounts_empty() {
1030        let mut api = HostApi::new();
1031        let req = make_get_accounts_request("acc-1");
1032        let resp = expect_response(api.handle_message(&req, TEST_APP));
1033
1034        let mut r = codec::Reader::new(&resp);
1035        assert_eq!(r.read_string().unwrap(), "acc-1");
1036        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
1037        assert_eq!(r.read_u8().unwrap(), 0); // v1
1038        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1039        assert_eq!(r.read_compact_u32().unwrap(), 0); // empty vector
1040    }
1041
1042    #[test]
1043    fn get_accounts_returns_configured_accounts() {
1044        let mut api = HostApi::new();
1045        api.set_accounts(vec![Account {
1046            public_key: vec![0xAA; 32],
1047            name: Some("Test Account".into()),
1048        }]);
1049
1050        let req = make_get_accounts_request("acc-2");
1051        let resp = expect_response(api.handle_message(&req, TEST_APP));
1052
1053        let mut r = codec::Reader::new(&resp);
1054        assert_eq!(r.read_string().unwrap(), "acc-2");
1055        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
1056        assert_eq!(r.read_u8().unwrap(), 0); // v1
1057        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1058        assert_eq!(r.read_compact_u32().unwrap(), 1); // 1 account
1059    }
1060
1061    #[test]
1062    fn storage_read_returns_needs_storage_read() {
1063        let mut api = HostApi::new();
1064        let req = make_storage_read("r-1", "mykey");
1065
1066        match api.handle_message(&req, TEST_APP) {
1067            HostApiOutcome::NeedsStorageRead { request_id, key } => {
1068                assert_eq!(request_id, "r-1");
1069                assert_eq!(key, "mykey");
1070            }
1071            other => panic!("expected NeedsStorageRead, got {}", outcome_name(&other)),
1072        }
1073    }
1074
1075    #[test]
1076    fn storage_write_returns_needs_storage_write() {
1077        let mut api = HostApi::new();
1078        let req = make_storage_write("w-1", "mykey", b"myvalue");
1079
1080        match api.handle_message(&req, TEST_APP) {
1081            HostApiOutcome::NeedsStorageWrite {
1082                request_id,
1083                key,
1084                value,
1085            } => {
1086                assert_eq!(request_id, "w-1");
1087                assert_eq!(key, "mykey");
1088                assert_eq!(value, b"myvalue");
1089            }
1090            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1091        }
1092    }
1093
1094    #[test]
1095    fn storage_clear_returns_needs_storage_clear() {
1096        let mut api = HostApi::new();
1097        let req = make_storage_clear("c-1", "clearme");
1098
1099        match api.handle_message(&req, TEST_APP) {
1100            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1101                assert_eq!(request_id, "c-1");
1102                assert_eq!(key, "clearme");
1103            }
1104            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1105        }
1106    }
1107
1108    #[test]
1109    fn storage_clear_of_nonexistent_key_emits_outcome() {
1110        let mut api = HostApi::new();
1111        let req = make_storage_clear("c-2", "never-written");
1112
1113        match api.handle_message(&req, TEST_APP) {
1114            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1115                assert_eq!(request_id, "c-2");
1116                assert_eq!(key, "never-written");
1117            }
1118            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1119        }
1120    }
1121
1122    #[test]
1123    fn device_permission_returns_unimplemented_error() {
1124        let mut api = HostApi::new();
1125        let mut msg = Vec::new();
1126        codec::encode_string(&mut msg, "unimp-1");
1127        msg.push(TAG_DEVICE_PERMISSION_REQ);
1128
1129        let resp = expect_response(api.handle_message(&msg, TEST_APP));
1130
1131        let mut r = codec::Reader::new(&resp);
1132        assert_eq!(r.read_string().unwrap(), "unimp-1");
1133        assert_eq!(r.read_u8().unwrap(), TAG_DEVICE_PERMISSION_RESP);
1134        assert_eq!(r.read_u8().unwrap(), 0); // v1
1135        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1136    }
1137
1138    #[test]
1139    fn protocol_layer_passes_through_null_byte_deeplink_host_must_validate() {
1140        let mut api = HostApi::new();
1141        let mut msg = Vec::new();
1142        codec::encode_string(&mut msg, "pn-null");
1143        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1144        msg.push(0); // v1
1145        codec::encode_string(&mut msg, "Alert");
1146        codec::encode_option_some(&mut msg);
1147        codec::encode_string(&mut msg, "https://evil.com\x00@good.com");
1148
1149        match api.handle_message(&msg, TEST_APP) {
1150            HostApiOutcome::NeedsPushNotification {
1151                request_id,
1152                text,
1153                deeplink,
1154            } => {
1155                assert_eq!(request_id, "pn-null");
1156                assert_eq!(text, "Alert");
1157                // The protocol passes the deeplink through as-is; host-side
1158                // URL validation must sanitize if needed.
1159                assert_eq!(deeplink.as_deref(), Some("https://evil.com\x00@good.com"));
1160            }
1161            other => panic!(
1162                "expected NeedsPushNotification, got {}",
1163                outcome_name(&other)
1164            ),
1165        }
1166    }
1167
1168    #[test]
1169    fn protocol_layer_passes_through_control_char_deeplink_host_must_validate() {
1170        let mut api = HostApi::new();
1171        let mut msg = Vec::new();
1172        codec::encode_string(&mut msg, "pn-ctrl");
1173        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1174        msg.push(0); // v1
1175        codec::encode_string(&mut msg, "Alert");
1176        codec::encode_option_some(&mut msg);
1177        codec::encode_string(&mut msg, "https://evil.com/\x07\x08\x1b");
1178
1179        match api.handle_message(&msg, TEST_APP) {
1180            HostApiOutcome::NeedsPushNotification {
1181                request_id,
1182                text,
1183                deeplink,
1184            } => {
1185                assert_eq!(request_id, "pn-ctrl");
1186                assert_eq!(text, "Alert");
1187                assert_eq!(deeplink.as_deref(), Some("https://evil.com/\x07\x08\x1b"));
1188            }
1189            other => panic!(
1190                "expected NeedsPushNotification, got {}",
1191                outcome_name(&other)
1192            ),
1193        }
1194    }
1195
1196    #[test]
1197    fn subscription_stop_returns_silent() {
1198        let mut api = HostApi::new();
1199        let mut msg = Vec::new();
1200        codec::encode_string(&mut msg, "stop-1");
1201        msg.push(TAG_ACCOUNT_STATUS_STOP);
1202
1203        expect_silent(api.handle_message(&msg, TEST_APP));
1204    }
1205
1206    #[test]
1207    fn malformed_input_returns_silent() {
1208        let mut api = HostApi::new();
1209
1210        // Empty input
1211        expect_silent(api.handle_message(&[], TEST_APP));
1212
1213        // Truncated after request_id
1214        let mut msg = Vec::new();
1215        codec::encode_string(&mut msg, "trunc");
1216        expect_silent(api.handle_message(&msg, TEST_APP));
1217    }
1218
1219    #[test]
1220    fn unknown_tag_returns_silent() {
1221        let mut api = HostApi::new();
1222        let mut msg = Vec::new();
1223        codec::encode_string(&mut msg, "unk-1");
1224        msg.push(0xFF); // unknown tag
1225        expect_silent(api.handle_message(&msg, TEST_APP));
1226    }
1227
1228    #[test]
1229    fn storage_write_rejects_oversized_value() {
1230        let mut api = HostApi::new();
1231        let big_value = vec![0xAA; super::MAX_STORAGE_VALUE_SIZE + 1];
1232        let req = make_storage_write("w-big", "key", &big_value);
1233        let resp = expect_response(api.handle_message(&req, TEST_APP));
1234
1235        let mut r = codec::Reader::new(&resp);
1236        assert_eq!(r.read_string().unwrap(), "w-big");
1237        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1238        assert_eq!(r.read_u8().unwrap(), 0); // v1
1239        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1240    }
1241
1242    #[test]
1243    fn storage_write_rejects_long_key() {
1244        let mut api = HostApi::new();
1245        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1246        let req = make_storage_write("w-longkey", &long_key, b"v");
1247        let resp = expect_response(api.handle_message(&req, TEST_APP));
1248
1249        let mut r = codec::Reader::new(&resp);
1250        assert_eq!(r.read_string().unwrap(), "w-longkey");
1251        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1252        assert_eq!(r.read_u8().unwrap(), 0); // v1
1253        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1254    }
1255
1256    #[test]
1257    fn sign_payload_returns_needs_sign() {
1258        let mut api = HostApi::new();
1259        let mut msg = Vec::new();
1260        codec::encode_string(&mut msg, "sign-1");
1261        msg.push(TAG_SIGN_PAYLOAD_REQ);
1262        msg.push(0); // v1
1263        codec::encode_var_bytes(&mut msg, &[0xAA; 32]); // publicKey
1264        msg.extend_from_slice(b"payload-data");
1265
1266        match api.handle_message(&msg, TEST_APP) {
1267            HostApiOutcome::NeedsSign {
1268                request_id,
1269                request_tag,
1270                public_key,
1271                payload,
1272            } => {
1273                assert_eq!(request_id, "sign-1");
1274                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1275                assert_eq!(public_key, vec![0xAA; 32]);
1276                assert_eq!(payload, b"payload-data");
1277            }
1278            _ => panic!("expected NeedsSign"),
1279        }
1280    }
1281
1282    #[test]
1283    fn sign_raw_returns_needs_sign() {
1284        let mut api = HostApi::new();
1285        let mut msg = Vec::new();
1286        codec::encode_string(&mut msg, "sign-2");
1287        msg.push(TAG_SIGN_RAW_REQ);
1288        msg.push(0); // v1
1289        codec::encode_var_bytes(&mut msg, &[0xBB; 32]); // publicKey
1290        msg.extend_from_slice(b"raw-bytes");
1291
1292        match api.handle_message(&msg, TEST_APP) {
1293            HostApiOutcome::NeedsSign {
1294                request_id,
1295                request_tag,
1296                public_key,
1297                payload,
1298            } => {
1299                assert_eq!(request_id, "sign-2");
1300                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1301                assert_eq!(public_key, vec![0xBB; 32]);
1302                assert_eq!(payload, b"raw-bytes");
1303            }
1304            _ => panic!("expected NeedsSign"),
1305        }
1306    }
1307
1308    #[test]
1309    fn logging_redacts_sign_payload_requests() {
1310        let mut api = HostApi::new();
1311        let request_id = "req-id-secret-123";
1312        let payload_secret = "payload-secret-456";
1313        let pubkey_secret_hex = "abababab";
1314        let mut msg = Vec::new();
1315        codec::encode_string(&mut msg, request_id);
1316        msg.push(TAG_SIGN_PAYLOAD_REQ);
1317        msg.push(0); // v1
1318        codec::encode_var_bytes(&mut msg, &[0xAB; 32]);
1319        msg.extend_from_slice(payload_secret.as_bytes());
1320
1321        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1322
1323        match outcome {
1324            HostApiOutcome::NeedsSign {
1325                request_id: actual_request_id,
1326                request_tag,
1327                public_key,
1328                payload,
1329            } => {
1330                assert_eq!(actual_request_id, request_id);
1331                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1332                assert_eq!(public_key, vec![0xAB; 32]);
1333                assert_eq!(payload, payload_secret.as_bytes());
1334            }
1335            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1336        }
1337
1338        assert_logs_contain(&logs, "request: kind=sign_payload (tag=36)");
1339        assert_logs_contain(&logs, "sign_payload request (pubkey=32 bytes)");
1340        assert_logs_do_not_contain(&logs, request_id);
1341        assert_logs_do_not_contain(&logs, payload_secret);
1342        assert_logs_do_not_contain(&logs, pubkey_secret_hex);
1343    }
1344
1345    #[test]
1346    fn logging_redacts_sign_raw_requests() {
1347        let mut api = HostApi::new();
1348        let request_id = "req-id-secret-raw";
1349        let raw_secret = "raw-secret-789";
1350        let mut msg = Vec::new();
1351        codec::encode_string(&mut msg, request_id);
1352        msg.push(TAG_SIGN_RAW_REQ);
1353        msg.push(0); // v1
1354        codec::encode_var_bytes(&mut msg, &[0xCD; 32]);
1355        msg.extend_from_slice(raw_secret.as_bytes());
1356
1357        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1358
1359        match outcome {
1360            HostApiOutcome::NeedsSign {
1361                request_id: actual_request_id,
1362                request_tag,
1363                public_key,
1364                payload,
1365            } => {
1366                assert_eq!(actual_request_id, request_id);
1367                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1368                assert_eq!(public_key, vec![0xCD; 32]);
1369                assert_eq!(payload, raw_secret.as_bytes());
1370            }
1371            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1372        }
1373
1374        assert_logs_contain(&logs, "request: kind=sign_raw (tag=34)");
1375        assert_logs_contain(&logs, "sign_raw request (pubkey=32 bytes)");
1376        assert_logs_do_not_contain(&logs, request_id);
1377        assert_logs_do_not_contain(&logs, raw_secret);
1378    }
1379
1380    #[test]
1381    fn logging_redacts_navigation_requests() {
1382        let mut api = HostApi::new();
1383        let request_id = "req-id-secret-nav";
1384        let url = "https://example.com/callback?token=nav-secret-123";
1385        let req = make_navigate_request(request_id, url);
1386
1387        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1388
1389        match outcome {
1390            HostApiOutcome::NeedsNavigate {
1391                request_id: actual_request_id,
1392                url: actual_url,
1393            } => {
1394                assert_eq!(actual_request_id, request_id);
1395                assert_eq!(actual_url, url);
1396            }
1397            other => panic!("expected NeedsNavigate, got {}", outcome_name(&other)),
1398        }
1399
1400        assert_logs_contain(&logs, "request: kind=navigate_to (tag=6)");
1401        assert_logs_contain(&logs, "navigate_to request");
1402        assert_logs_do_not_contain(&logs, request_id);
1403        assert_logs_do_not_contain(&logs, "nav-secret-123");
1404    }
1405
1406    #[test]
1407    fn logging_redacts_local_storage_write_requests() {
1408        let mut api = HostApi::new();
1409        let request_id = "req-id-secret-storage";
1410        let key = "storage-secret-key";
1411        let value = b"storage-secret-value";
1412        let req = make_storage_write(request_id, key, value);
1413
1414        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1415
1416        match outcome {
1417            HostApiOutcome::NeedsStorageWrite {
1418                request_id: actual_request_id,
1419                key: actual_key,
1420                value: actual_value,
1421            } => {
1422                assert_eq!(actual_request_id, request_id);
1423                assert_eq!(actual_key, key);
1424                assert_eq!(actual_value, value);
1425            }
1426            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1427        }
1428
1429        assert_logs_contain(&logs, "request: kind=local_storage_write (tag=14)");
1430        assert_logs_do_not_contain(&logs, request_id);
1431        assert_logs_do_not_contain(&logs, key);
1432        assert_logs_do_not_contain(&logs, "storage-secret-value");
1433    }
1434
1435    #[test]
1436    fn logging_redacts_feature_supported_requests() {
1437        let mut api = HostApi::new();
1438        let utf8_secret = "feature-secret-utf8";
1439        let utf8_req =
1440            make_feature_supported_request("req-id-feature-utf8", utf8_secret.as_bytes());
1441
1442        let (utf8_outcome, utf8_logs) = capture_logs(|| api.handle_message(&utf8_req, TEST_APP));
1443        let utf8_resp = expect_response(utf8_outcome);
1444        let mut utf8_reader = codec::Reader::new(&utf8_resp);
1445        utf8_reader.read_string().unwrap();
1446        assert_eq!(utf8_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1447        utf8_reader.read_u8().unwrap();
1448        utf8_reader.read_u8().unwrap();
1449        assert_eq!(utf8_reader.read_u8().unwrap(), 0);
1450        assert_logs_contain(&utf8_logs, "request: kind=feature_supported (tag=2)");
1451        assert_logs_contain(&utf8_logs, "feature_supported: feature=utf8_other -> false");
1452        assert_logs_do_not_contain(&utf8_logs, utf8_secret);
1453
1454        let binary_req =
1455            make_feature_supported_request("req-id-feature-binary", &[0, 0xde, 0xad, 0xbe, 0xef]);
1456        let (binary_outcome, binary_logs) =
1457            capture_logs(|| api.handle_message(&binary_req, TEST_APP));
1458        let binary_resp = expect_response(binary_outcome);
1459        let mut binary_reader = codec::Reader::new(&binary_resp);
1460        binary_reader.read_string().unwrap();
1461        assert_eq!(binary_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1462        binary_reader.read_u8().unwrap();
1463        binary_reader.read_u8().unwrap();
1464        assert_eq!(binary_reader.read_u8().unwrap(), 0);
1465        assert_logs_contain(
1466            &binary_logs,
1467            "feature_supported: feature=binary_chain -> false",
1468        );
1469        assert_logs_do_not_contain(&binary_logs, "deadbeef");
1470    }
1471
1472    #[test]
1473    fn logging_redacts_jsonrpc_requests() {
1474        let mut api = HostApi::new();
1475        let request_id = "req-id-secret-jsonrpc";
1476        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-method","params":["jsonrpc-secret-param"]}"#;
1477        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SEND_REQ, json);
1478
1479        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1480
1481        match outcome {
1482            HostApiOutcome::NeedsChainQuery {
1483                request_id: actual_request_id,
1484                method,
1485                params,
1486            } => {
1487                assert_eq!(actual_request_id, request_id);
1488                assert_eq!(method, "rpc-secret-method");
1489                assert_eq!(params, serde_json::json!(["jsonrpc-secret-param"]));
1490            }
1491            other => panic!("expected NeedsChainQuery, got {}", outcome_name(&other)),
1492        }
1493
1494        assert_logs_contain(&logs, "request: kind=jsonrpc_send (tag=70)");
1495        assert_logs_contain(&logs, "jsonrpc_send request");
1496        assert_logs_do_not_contain(&logs, request_id);
1497        assert_logs_do_not_contain(&logs, "rpc-secret-method");
1498        assert_logs_do_not_contain(&logs, "jsonrpc-secret-param");
1499    }
1500
1501    #[test]
1502    fn logging_redacts_jsonrpc_subscribe_requests() {
1503        let mut api = HostApi::new();
1504        let request_id = "req-id-secret-jsonrpc-sub";
1505        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-subscribe","params":["jsonrpc-secret-sub-param"]}"#;
1506        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SUB_START, json);
1507
1508        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1509
1510        match outcome {
1511            HostApiOutcome::NeedsChainSubscription {
1512                request_id: actual_request_id,
1513                method,
1514                params,
1515            } => {
1516                assert_eq!(actual_request_id, request_id);
1517                assert_eq!(method, "rpc-secret-subscribe");
1518                assert_eq!(params, serde_json::json!(["jsonrpc-secret-sub-param"]));
1519            }
1520            other => panic!(
1521                "expected NeedsChainSubscription, got {}",
1522                outcome_name(&other)
1523            ),
1524        }
1525
1526        assert_logs_contain(&logs, "request: kind=jsonrpc_subscribe_start (tag=72)");
1527        assert_logs_contain(&logs, "jsonrpc_subscribe_start request");
1528        assert_logs_do_not_contain(&logs, request_id);
1529        assert_logs_do_not_contain(&logs, "rpc-secret-subscribe");
1530        assert_logs_do_not_contain(&logs, "jsonrpc-secret-sub-param");
1531    }
1532
1533    #[test]
1534    fn logging_redacts_decode_failures() {
1535        let mut api = HostApi::new();
1536        let malformed = b"decode-secret-123";
1537
1538        let (outcome, logs) = capture_logs(|| api.handle_message(malformed, TEST_APP));
1539
1540        expect_silent(outcome);
1541        assert_logs_contain(&logs, "failed to decode message: kind=");
1542        assert_logs_do_not_contain(&logs, "decode-secret-123");
1543    }
1544
1545    // -- Push notification tests --
1546
1547    fn make_push_notification_request(
1548        request_id: &str,
1549        text: &str,
1550        deeplink: Option<&str>,
1551    ) -> Vec<u8> {
1552        let mut msg = Vec::new();
1553        codec::encode_string(&mut msg, request_id);
1554        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1555        msg.push(0); // v1
1556        codec::encode_string(&mut msg, text);
1557        match deeplink {
1558            Some(dl) => {
1559                codec::encode_option_some(&mut msg);
1560                codec::encode_string(&mut msg, dl);
1561            }
1562            None => codec::encode_option_none(&mut msg),
1563        }
1564        msg
1565    }
1566
1567    #[test]
1568    fn push_notification_returns_needs_push_notification() {
1569        let mut api = HostApi::new();
1570        let req = make_push_notification_request(
1571            "pn-1",
1572            "Transfer complete",
1573            Some("https://app.example/tx/123"),
1574        );
1575
1576        match api.handle_message(&req, TEST_APP) {
1577            HostApiOutcome::NeedsPushNotification {
1578                request_id,
1579                text,
1580                deeplink,
1581            } => {
1582                assert_eq!(request_id, "pn-1");
1583                assert_eq!(text, "Transfer complete");
1584                assert_eq!(deeplink.as_deref(), Some("https://app.example/tx/123"));
1585            }
1586            other => panic!(
1587                "expected NeedsPushNotification, got {}",
1588                outcome_name(&other)
1589            ),
1590        }
1591    }
1592
1593    #[test]
1594    fn push_notification_without_deeplink() {
1595        let mut api = HostApi::new();
1596        let req = make_push_notification_request("pn-2", "Hello world", None);
1597
1598        match api.handle_message(&req, TEST_APP) {
1599            HostApiOutcome::NeedsPushNotification {
1600                request_id,
1601                text,
1602                deeplink,
1603            } => {
1604                assert_eq!(request_id, "pn-2");
1605                assert_eq!(text, "Hello world");
1606                assert!(deeplink.is_none());
1607            }
1608            other => panic!(
1609                "expected NeedsPushNotification, got {}",
1610                outcome_name(&other)
1611            ),
1612        }
1613    }
1614
1615    #[test]
1616    fn push_notification_rejects_oversized_text() {
1617        let mut api = HostApi::new();
1618        let big_text = "x".repeat(super::MAX_PUSH_NOTIFICATION_TEXT_LEN + 1);
1619        let req = make_push_notification_request("pn-big", &big_text, None);
1620        let resp = expect_response(api.handle_message(&req, TEST_APP));
1621
1622        let mut r = codec::Reader::new(&resp);
1623        assert_eq!(r.read_string().unwrap(), "pn-big");
1624        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1625        assert_eq!(r.read_u8().unwrap(), 0); // v1
1626        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1627        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1628        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1629    }
1630
1631    #[test]
1632    fn push_notification_accepts_max_length_text() {
1633        let mut api = HostApi::new();
1634        let text_at_limit = "x".repeat(super::MAX_PUSH_NOTIFICATION_TEXT_LEN);
1635        let req = make_push_notification_request("pn-limit", &text_at_limit, None);
1636
1637        match api.handle_message(&req, TEST_APP) {
1638            HostApiOutcome::NeedsPushNotification {
1639                request_id, text, ..
1640            } => {
1641                assert_eq!(request_id, "pn-limit");
1642                assert_eq!(text.len(), super::MAX_PUSH_NOTIFICATION_TEXT_LEN);
1643            }
1644            other => panic!(
1645                "expected NeedsPushNotification for text at limit, got {}",
1646                outcome_name(&other)
1647            ),
1648        }
1649    }
1650
1651    #[test]
1652    fn push_notification_rejects_javascript_deeplink() {
1653        let mut api = HostApi::new();
1654        let req = make_push_notification_request("pn-js", "hi", Some("javascript:alert(1)"));
1655        let resp = expect_response(api.handle_message(&req, TEST_APP));
1656
1657        let mut r = codec::Reader::new(&resp);
1658        assert_eq!(r.read_string().unwrap(), "pn-js");
1659        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1660        assert_eq!(r.read_u8().unwrap(), 0); // v1
1661        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1662        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1663        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1664    }
1665
1666    #[test]
1667    fn push_notification_rejects_file_deeplink() {
1668        let mut api = HostApi::new();
1669        let req = make_push_notification_request("pn-file", "hi", Some("file:///etc/passwd"));
1670        let resp = expect_response(api.handle_message(&req, TEST_APP));
1671
1672        let mut r = codec::Reader::new(&resp);
1673        assert_eq!(r.read_string().unwrap(), "pn-file");
1674        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1675        assert_eq!(r.read_u8().unwrap(), 0); // v1
1676        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1677        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1678        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1679    }
1680
1681    #[test]
1682    fn push_notification_rejects_data_deeplink() {
1683        let mut api = HostApi::new();
1684        let req = make_push_notification_request(
1685            "pn-data",
1686            "hi",
1687            Some("data:text/html,<script>alert(1)</script>"),
1688        );
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_string().unwrap(), "pn-data");
1693        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1694        assert_eq!(r.read_u8().unwrap(), 0); // v1
1695        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1696        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1697        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1698    }
1699
1700    #[test]
1701    fn push_notification_rejects_plain_http_deeplink() {
1702        let mut api = HostApi::new();
1703        let req =
1704            make_push_notification_request("pn-http", "hi", Some("http://app.example/tx/123"));
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_string().unwrap(), "pn-http");
1709        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1710        assert_eq!(r.read_u8().unwrap(), 0); // v1
1711        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1712        assert_eq!(r.read_u8().unwrap(), 0); // error variant 0
1713        assert_eq!(r.pos, resp.len(), "no unconsumed bytes expected");
1714    }
1715
1716    #[test]
1717    fn push_notification_allows_https_deeplink() {
1718        let mut api = HostApi::new();
1719        let req =
1720            make_push_notification_request("pn-https", "hi", Some("https://app.example/tx/123"));
1721
1722        match api.handle_message(&req, TEST_APP) {
1723            HostApiOutcome::NeedsPushNotification {
1724                request_id,
1725                deeplink,
1726                ..
1727            } => {
1728                assert_eq!(request_id, "pn-https");
1729                assert_eq!(deeplink.as_deref(), Some("https://app.example/tx/123"));
1730            }
1731            other => panic!(
1732                "expected NeedsPushNotification, got {}",
1733                outcome_name(&other)
1734            ),
1735        }
1736    }
1737
1738    #[test]
1739    fn push_notification_scheme_check_is_case_insensitive() {
1740        let mut api = HostApi::new();
1741        let req = make_push_notification_request("pn-case", "hi", Some("HTTPS://app.example"));
1742
1743        match api.handle_message(&req, TEST_APP) {
1744            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1745                assert_eq!(request_id, "pn-case");
1746            }
1747            other => panic!(
1748                "expected NeedsPushNotification, got {}",
1749                outcome_name(&other)
1750            ),
1751        }
1752    }
1753
1754    #[test]
1755    fn push_notification_rejects_multibyte_text_exceeding_byte_limit() {
1756        let mut api = HostApi::new();
1757        // Each '🦊' is 4 UTF-8 bytes; 257 * 4 = 1028 bytes > 1024.
1758        let text = "🦊".repeat(257);
1759        assert_eq!(text.len(), 1028);
1760        let req = make_push_notification_request("pn-mb-big", &text, None);
1761        let resp = expect_response(api.handle_message(&req, TEST_APP));
1762
1763        let mut r = codec::Reader::new(&resp);
1764        assert_eq!(r.read_string().unwrap(), "pn-mb-big");
1765        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1766        assert_eq!(r.read_u8().unwrap(), 0); // v1
1767        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1768    }
1769
1770    #[test]
1771    fn push_notification_accepts_multibyte_text_at_byte_limit() {
1772        let mut api = HostApi::new();
1773        // Each '🦊' is 4 UTF-8 bytes; 256 * 4 = 1024 bytes == limit.
1774        let text = "🦊".repeat(256);
1775        assert_eq!(text.len(), 1024);
1776        let req = make_push_notification_request("pn-mb-limit", &text, None);
1777
1778        match api.handle_message(&req, TEST_APP) {
1779            HostApiOutcome::NeedsPushNotification {
1780                request_id,
1781                text: actual_text,
1782                ..
1783            } => {
1784                assert_eq!(request_id, "pn-mb-limit");
1785                assert_eq!(actual_text.len(), 1024);
1786            }
1787            other => panic!(
1788                "expected NeedsPushNotification for multibyte text at limit, got {}",
1789                outcome_name(&other)
1790            ),
1791        }
1792    }
1793
1794    #[test]
1795    fn push_notification_rejects_empty_deeplink() {
1796        let mut api = HostApi::new();
1797        let req = make_push_notification_request("pn-empty-dl", "hi", Some(""));
1798        let resp = expect_response(api.handle_message(&req, TEST_APP));
1799
1800        let mut r = codec::Reader::new(&resp);
1801        assert_eq!(r.read_string().unwrap(), "pn-empty-dl");
1802        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1803        assert_eq!(r.read_u8().unwrap(), 0); // v1
1804        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1805    }
1806
1807    #[test]
1808    fn push_notification_accepts_bare_https_scheme_deeplink() {
1809        // "https://" with no host passes the scheme check.
1810        // Host is responsible for further URL validation.
1811        let mut api = HostApi::new();
1812        let req = make_push_notification_request("pn-bare", "hi", Some("https://"));
1813        match api.handle_message(&req, TEST_APP) {
1814            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1815                assert_eq!(request_id, "pn-bare");
1816            }
1817            other => panic!(
1818                "expected NeedsPushNotification for bare https://, got {}",
1819                outcome_name(&other)
1820            ),
1821        }
1822    }
1823
1824    #[test]
1825    fn push_notification_accepts_deeplink_at_byte_limit() {
1826        let mut api = HostApi::new();
1827        let base = "https://app.example/";
1828        let pad = super::MAX_DEEPLINK_URL_LEN - base.len();
1829        let url = format!("{}{}", base, "a".repeat(pad));
1830        assert_eq!(url.len(), super::MAX_DEEPLINK_URL_LEN);
1831        let req = make_push_notification_request("pn-dl-limit", "hi", Some(&url));
1832        match api.handle_message(&req, TEST_APP) {
1833            HostApiOutcome::NeedsPushNotification { request_id, .. } => {
1834                assert_eq!(request_id, "pn-dl-limit");
1835            }
1836            other => panic!(
1837                "expected NeedsPushNotification at deeplink limit, got {}",
1838                outcome_name(&other)
1839            ),
1840        }
1841    }
1842
1843    #[test]
1844    fn push_notification_rejects_oversized_deeplink() {
1845        let mut api = HostApi::new();
1846        let long_url = format!(
1847            "https://app.example/{}",
1848            "a".repeat(super::MAX_DEEPLINK_URL_LEN)
1849        );
1850        let req = make_push_notification_request("pn-dl-long", "hi", Some(&long_url));
1851        let resp = expect_response(api.handle_message(&req, TEST_APP));
1852
1853        let mut r = codec::Reader::new(&resp);
1854        assert_eq!(r.read_string().unwrap(), "pn-dl-long");
1855        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1856        assert_eq!(r.read_u8().unwrap(), 0); // v1
1857        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1858    }
1859
1860    #[test]
1861    fn storage_read_rejects_long_key() {
1862        let mut api = HostApi::new();
1863        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1864        let req = make_storage_read("r-longkey", &long_key);
1865        let resp = expect_response(api.handle_message(&req, TEST_APP));
1866
1867        let mut r = codec::Reader::new(&resp);
1868        assert_eq!(r.read_string().unwrap(), "r-longkey");
1869        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_READ_RESP);
1870        assert_eq!(r.read_u8().unwrap(), 0); // v1
1871        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1872    }
1873
1874    #[test]
1875    fn push_notification_serializes_to_json() {
1876        let outcome = HostApiOutcome::NeedsPushNotification {
1877            request_id: "pn-s".into(),
1878            text: "Hello".into(),
1879            deeplink: Some("https://app.example".into()),
1880        };
1881        let json = serde_json::to_value(&outcome).unwrap();
1882        assert_eq!(json["type"], "NeedsPushNotification");
1883        assert_eq!(json["request_id"], "pn-s");
1884        assert_eq!(json["text"], "Hello");
1885        assert_eq!(json["deeplink"], "https://app.example");
1886    }
1887
1888    #[test]
1889    fn push_notification_serializes_null_deeplink() {
1890        let outcome = HostApiOutcome::NeedsPushNotification {
1891            request_id: "pn-n".into(),
1892            text: "Hi".into(),
1893            deeplink: None,
1894        };
1895        let json = serde_json::to_value(&outcome).unwrap();
1896        assert_eq!(json["type"], "NeedsPushNotification");
1897        assert_eq!(json["deeplink"], serde_json::Value::Null);
1898    }
1899
1900    #[test]
1901    fn feature_supported_push_notification() {
1902        let mut api = HostApi::new();
1903        let req = make_feature_supported_request("fs-pn", b"push_notification");
1904        let resp = expect_response(api.handle_message(&req, TEST_APP));
1905
1906        let mut r = codec::Reader::new(&resp);
1907        assert_eq!(r.read_string().unwrap(), "fs-pn");
1908        assert_eq!(r.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1909        assert_eq!(r.read_u8().unwrap(), 0); // v1
1910        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
1911        assert_eq!(r.read_u8().unwrap(), 1); // true
1912    }
1913
1914    #[test]
1915    fn logging_does_not_leak_push_notification_content() {
1916        let mut api = HostApi::new();
1917        let request_id = "req-id-secret-pn";
1918        let text = "notification-secret-text-123";
1919        let deeplink = "https://secret-deeplink.example/foo";
1920        let req = make_push_notification_request(request_id, text, Some(deeplink));
1921
1922        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1923
1924        match outcome {
1925            HostApiOutcome::NeedsPushNotification {
1926                request_id: actual_request_id,
1927                text: actual_text,
1928                deeplink: actual_deeplink,
1929            } => {
1930                assert_eq!(actual_request_id, request_id);
1931                assert_eq!(actual_text, text);
1932                assert_eq!(actual_deeplink.as_deref(), Some(deeplink));
1933            }
1934            other => panic!(
1935                "expected NeedsPushNotification, got {}",
1936                outcome_name(&other)
1937            ),
1938        }
1939
1940        assert_logs_contain(&logs, "request: kind=push_notification (tag=4)");
1941        assert_logs_contain(&logs, "push_notification request");
1942        assert_logs_do_not_contain(&logs, request_id);
1943        assert_logs_do_not_contain(&logs, "notification-secret-text-123");
1944        assert_logs_do_not_contain(&logs, "secret-deeplink");
1945    }
1946
1947    #[test]
1948    fn storage_clear_rejects_long_key() {
1949        let mut api = HostApi::new();
1950        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1951        let req = make_storage_clear("c-longkey", &long_key);
1952        let resp = expect_response(api.handle_message(&req, TEST_APP));
1953
1954        let mut r = codec::Reader::new(&resp);
1955        assert_eq!(r.read_string().unwrap(), "c-longkey");
1956        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_CLEAR_RESP);
1957        assert_eq!(r.read_u8().unwrap(), 0); // v1
1958        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1959    }
1960}