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
35/// Outcome of processing a host-api message.
36#[derive(serde::Serialize)]
37#[serde(tag = "type")]
38pub enum HostApiOutcome {
39    /// Send this response directly back to the app.
40    Response { data: Vec<u8> },
41    /// Sign request — needs wallet to produce a signature before responding.
42    NeedsSign {
43        request_id: String,
44        request_tag: u8,
45        public_key: Vec<u8>,
46        payload: Vec<u8>,
47    },
48    /// JSON-RPC query — needs routing through the chain API allowlist + RPC bridge.
49    NeedsChainQuery {
50        request_id: String,
51        method: String,
52        params: serde_json::Value,
53    },
54    /// JSON-RPC subscription — needs routing through the chain API for streaming responses.
55    NeedsChainSubscription {
56        request_id: String,
57        method: String,
58        params: serde_json::Value,
59    },
60    /// Navigation request — the workbench should open this URL (may be a .dot address).
61    NeedsNavigate { request_id: String, url: String },
62    /// Start a chainHead_v1_follow subscription for a specific chain.
63    NeedsChainFollow {
64        request_id: String,
65        genesis_hash: Vec<u8>,
66        with_runtime: bool,
67    },
68    /// A chain interaction request (header, storage, call, etc.) that needs
69    /// routing to smoldot via JSON-RPC. The response_tag and json_rpc_method
70    /// tell the workbench how to route and encode the response.
71    NeedsChainRpc {
72        request_id: String,
73        request_tag: u8,
74        genesis_hash: Vec<u8>,
75        json_rpc_method: String,
76        json_rpc_params: serde_json::Value,
77        /// The follow subscription ID from the product-SDK (opaque string).
78        follow_sub_id: Option<String>,
79    },
80    /// Product-scoped storage read — the host must look up `key` in persistent
81    /// storage for the current product and call `encode_storage_read_response`.
82    NeedsStorageRead { request_id: String, key: String },
83    /// Product-scoped storage write — the host must persist `value` under `key`
84    /// for the current product and call `encode_storage_write_response`.
85    NeedsStorageWrite {
86        request_id: String,
87        key: String,
88        value: Vec<u8>,
89    },
90    /// Product-scoped storage clear — the host must remove `key` from persistent
91    /// storage for the current product and call `encode_storage_clear_response`.
92    NeedsStorageClear { request_id: String, key: String },
93    /// No response needed (fire-and-forget).
94    Silent,
95}
96
97/// Shared host implementation. Handles decoded requests, returns encoded responses.
98pub struct HostApi {
99    accounts: Vec<Account>,
100    supported_chains: std::collections::HashSet<[u8; 32]>,
101}
102
103impl Default for HostApi {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl HostApi {
110    pub fn new() -> Self {
111        Self {
112            accounts: Vec::new(),
113            supported_chains: std::collections::HashSet::new(),
114        }
115    }
116
117    /// Set the accounts that will be returned by host_get_non_product_accounts.
118    ///
119    /// SECURITY: This exposes account identifiers to any product that requests
120    /// them. Will be replaced by scoped per-product identities via host-sso.
121    pub fn set_accounts(&mut self, accounts: Vec<Account>) {
122        self.accounts = accounts;
123    }
124
125    /// Set the chain genesis hashes that this host supports.
126    /// Each hash is 32 bytes (raw, not hex-encoded).
127    pub fn set_supported_chains(&mut self, chains: impl IntoIterator<Item = [u8; 32]>) {
128        self.supported_chains = chains.into_iter().collect();
129    }
130
131    /// Process a raw binary message from the app.
132    ///
133    /// Returns `HostApiOutcome::Response` for immediate replies,
134    /// `HostApiOutcome::NeedsSign` for sign requests that need wallet approval,
135    /// or `HostApiOutcome::Silent` for fire-and-forget messages.
136    ///
137    /// `app_id` identifies the product — the host must use this value to scope
138    /// storage when acting on `NeedsStorageRead`, `NeedsStorageWrite`, and
139    /// `NeedsStorageClear` outcomes. It is not embedded in the outcome itself.
140    pub fn handle_message(&mut self, raw: &[u8], app_id: &str) -> HostApiOutcome {
141        #[cfg(feature = "tracing")]
142        let _span = info_span!(
143            "host_api.handle_message",
144            app_id,
145            request_tag = tracing::field::Empty,
146            request_kind = tracing::field::Empty,
147        )
148        .entered();
149        let _ = app_id; // used by tracing; host reads it from call-site context
150
151        let (request_id, request_tag, req) = match decode_message(raw) {
152            Ok(v) => v,
153            Err(e) => {
154                log::warn!(
155                    "[hostapi] failed to decode message: kind={}",
156                    decode_error_kind(&e)
157                );
158                #[cfg(feature = "tracing")]
159                tracing::Span::current().record("request_kind", "decode_error");
160                return HostApiOutcome::Silent;
161            }
162        };
163
164        #[cfg(feature = "tracing")]
165        {
166            tracing::Span::current().record("request_tag", request_tag);
167            tracing::Span::current().record("request_kind", request_kind(&req));
168        }
169
170        log::info!(
171            "[hostapi] request: kind={} (tag={request_tag})",
172            request_kind(&req)
173        );
174
175        match req {
176            HostRequest::Handshake { version } => {
177                if version == PROTOCOL_VERSION {
178                    HostApiOutcome::Response {
179                        data: encode_response(&request_id, request_tag, &HostResponse::HandshakeOk),
180                    }
181                } else {
182                    log::warn!("[hostapi] unsupported protocol version: {version}");
183                    HostApiOutcome::Response {
184                        data: encode_response(
185                            &request_id,
186                            request_tag,
187                            &HostResponse::Error("unsupported protocol version".into()),
188                        ),
189                    }
190                }
191            }
192
193            HostRequest::GetNonProductAccounts => {
194                // SECURITY: This exposes the soft-derivation root (//wallet//sso
195                // pubkey) to any product that requests it, allowing enumeration
196                // of all product addresses. This will be replaced by the host-sso
197                // pairing flow which provides scoped, per-product identities.
198                // See docs/threat-model-soft-derivation.md scenarios 1 & 2.
199                // TODO: remove once host-sso is fully wired and VoxChat uses it.
200                log::info!(
201                    "[hostapi] returning {} non-product account(s)",
202                    self.accounts.len()
203                );
204                HostApiOutcome::Response {
205                    data: encode_response(
206                        &request_id,
207                        request_tag,
208                        &HostResponse::AccountList(self.accounts.clone()),
209                    ),
210                }
211            }
212
213            HostRequest::FeatureSupported { feature_data } => {
214                let feature_kind = feature_log_kind(&feature_data);
215                // Check string features first (signing, navigate, etc.)
216                let supported = if let Ok(s) = std::str::from_utf8(&feature_data) {
217                    let r = matches!(s, "signing" | "sign" | "navigate");
218                    r
219                } else {
220                    // Binary feature: byte 0 = type, then SCALE-encoded data.
221                    // Type 0 = chain: compact_length + genesis_hash (32 bytes)
222                    let r = if feature_data.first() == Some(&0) {
223                        codec::Reader::new(&feature_data[1..])
224                            .read_var_bytes()
225                            .ok()
226                            .and_then(|h| <[u8; 32]>::try_from(h).ok())
227                            .map(|h| self.supported_chains.contains(&h))
228                            .unwrap_or(false)
229                    } else {
230                        false
231                    };
232                    r
233                };
234                log::info!("[hostapi] feature_supported: feature={feature_kind} -> {supported}");
235                HostApiOutcome::Response {
236                    data: encode_feature_response(&request_id, supported),
237                }
238            }
239
240            HostRequest::AccountConnectionStatusStart => HostApiOutcome::Response {
241                data: encode_account_status(&request_id, true),
242            },
243
244            HostRequest::LocalStorageRead { key } => {
245                if key.len() > MAX_STORAGE_KEY_LENGTH {
246                    return HostApiOutcome::Response {
247                        data: encode_response(
248                            &request_id,
249                            request_tag,
250                            &HostResponse::Error("storage key too long".into()),
251                        ),
252                    };
253                }
254                HostApiOutcome::NeedsStorageRead { request_id, key }
255            }
256
257            HostRequest::LocalStorageWrite { key, value } => {
258                if key.len() > MAX_STORAGE_KEY_LENGTH {
259                    return HostApiOutcome::Response {
260                        data: encode_response(
261                            &request_id,
262                            request_tag,
263                            &HostResponse::Error("storage key too long".into()),
264                        ),
265                    };
266                }
267                if value.len() > MAX_STORAGE_VALUE_SIZE {
268                    return HostApiOutcome::Response {
269                        data: encode_response(
270                            &request_id,
271                            request_tag,
272                            &HostResponse::Error("storage value too large".into()),
273                        ),
274                    };
275                }
276                HostApiOutcome::NeedsStorageWrite {
277                    request_id,
278                    key,
279                    value,
280                }
281            }
282
283            HostRequest::LocalStorageClear { key } => {
284                if key.len() > MAX_STORAGE_KEY_LENGTH {
285                    return HostApiOutcome::Response {
286                        data: encode_response(
287                            &request_id,
288                            request_tag,
289                            &HostResponse::Error("storage key too long".into()),
290                        ),
291                    };
292                }
293                HostApiOutcome::NeedsStorageClear { request_id, key }
294            }
295
296            HostRequest::NavigateTo { url } => {
297                log::info!("[hostapi] navigate_to request");
298                HostApiOutcome::NeedsNavigate { request_id, url }
299            }
300
301            HostRequest::SignPayload {
302                public_key,
303                payload,
304            } => {
305                log::info!(
306                    "[hostapi] sign_payload request (pubkey={} bytes)",
307                    public_key.len()
308                );
309                HostApiOutcome::NeedsSign {
310                    request_id,
311                    request_tag,
312                    public_key,
313                    payload,
314                }
315            }
316
317            HostRequest::SignRaw { public_key, data } => {
318                log::info!(
319                    "[hostapi] sign_raw request (pubkey={} bytes)",
320                    public_key.len()
321                );
322                HostApiOutcome::NeedsSign {
323                    request_id,
324                    request_tag,
325                    public_key,
326                    payload: data,
327                }
328            }
329
330            HostRequest::CreateTransaction { .. } => {
331                log::info!("[hostapi] create_transaction (not yet implemented)");
332                HostApiOutcome::Response {
333                    data: encode_response(
334                        &request_id,
335                        request_tag,
336                        &HostResponse::Error("create_transaction not yet implemented".into()),
337                    ),
338                }
339            }
340
341            HostRequest::JsonRpcSend { data } => {
342                // The data is a SCALE string containing a JSON-RPC request body,
343                // or raw bytes we try to interpret as UTF-8 JSON.
344                let json_str = parse_jsonrpc_data(&data);
345                match json_str {
346                    Some((method, params)) => {
347                        log::info!("[hostapi] jsonrpc_send request");
348                        HostApiOutcome::NeedsChainQuery {
349                            request_id,
350                            method,
351                            params,
352                        }
353                    }
354                    None => {
355                        log::warn!("[hostapi] failed to parse JSON-RPC send data");
356                        HostApiOutcome::Response {
357                            data: encode_response(
358                                &request_id,
359                                request_tag,
360                                &HostResponse::Error("invalid json-rpc request".into()),
361                            ),
362                        }
363                    }
364                }
365            }
366
367            HostRequest::JsonRpcSubscribeStart { data } => {
368                let json_str = parse_jsonrpc_data(&data);
369                match json_str {
370                    Some((method, params)) => {
371                        log::info!("[hostapi] jsonrpc_subscribe_start request");
372                        HostApiOutcome::NeedsChainSubscription {
373                            request_id,
374                            method,
375                            params,
376                        }
377                    }
378                    None => {
379                        log::warn!("[hostapi] failed to parse JSON-RPC subscribe data");
380                        HostApiOutcome::Response {
381                            data: encode_response(
382                                &request_id,
383                                request_tag,
384                                &HostResponse::Error("invalid json-rpc subscribe request".into()),
385                            ),
386                        }
387                    }
388                }
389            }
390
391            HostRequest::ChainHeadFollowStart {
392                genesis_hash,
393                with_runtime,
394            } => {
395                log::info!("[hostapi] chainHead follow start (genesis={} bytes, withRuntime={with_runtime})", genesis_hash.len());
396                HostApiOutcome::NeedsChainFollow {
397                    request_id,
398                    genesis_hash,
399                    with_runtime,
400                }
401            }
402
403            HostRequest::ChainHeadRequest {
404                tag,
405                genesis_hash,
406                follow_sub_id,
407                data,
408            } => {
409                let method = match tag {
410                    TAG_CHAIN_HEAD_HEADER_REQ => "chainHead_v1_header",
411                    TAG_CHAIN_HEAD_BODY_REQ => "chainHead_v1_body",
412                    TAG_CHAIN_HEAD_STORAGE_REQ => "chainHead_v1_storage",
413                    TAG_CHAIN_HEAD_CALL_REQ => "chainHead_v1_call",
414                    TAG_CHAIN_HEAD_UNPIN_REQ => "chainHead_v1_unpin",
415                    TAG_CHAIN_HEAD_CONTINUE_REQ => "chainHead_v1_continue",
416                    TAG_CHAIN_HEAD_STOP_OP_REQ => "chainHead_v1_stopOperation",
417                    _ => {
418                        log::warn!("[hostapi] unknown chain head request tag: {tag}");
419                        return HostApiOutcome::Silent;
420                    }
421                };
422                log::info!("[hostapi] chain request: {method} (tag={tag})");
423                HostApiOutcome::NeedsChainRpc {
424                    request_id,
425                    request_tag: tag,
426                    genesis_hash,
427                    json_rpc_method: method.into(),
428                    json_rpc_params: data,
429                    follow_sub_id: Some(follow_sub_id),
430                }
431            }
432
433            HostRequest::ChainSpecRequest { tag, genesis_hash } => {
434                let method = match tag {
435                    TAG_CHAIN_SPEC_GENESIS_REQ => "chainSpec_v1_genesisHash",
436                    TAG_CHAIN_SPEC_NAME_REQ => "chainSpec_v1_chainName",
437                    TAG_CHAIN_SPEC_PROPS_REQ => "chainSpec_v1_properties",
438                    _ => {
439                        log::warn!("[hostapi] unknown chainSpec request tag: {tag}");
440                        return HostApiOutcome::Silent;
441                    }
442                };
443                log::info!("[hostapi] chainSpec request: {method} (tag={tag})");
444                HostApiOutcome::NeedsChainRpc {
445                    request_id,
446                    request_tag: tag,
447                    genesis_hash,
448                    json_rpc_method: method.into(),
449                    json_rpc_params: serde_json::Value::Array(vec![]),
450                    follow_sub_id: None,
451                }
452            }
453
454            HostRequest::ChainTxBroadcast {
455                genesis_hash,
456                transaction,
457            } => {
458                log::info!("[hostapi] transaction broadcast");
459                let tx_hex = chain::bytes_to_hex(&transaction);
460                HostApiOutcome::NeedsChainRpc {
461                    request_id,
462                    request_tag,
463                    genesis_hash,
464                    json_rpc_method: "transaction_v1_broadcast".into(),
465                    json_rpc_params: serde_json::json!([tx_hex]),
466                    follow_sub_id: None,
467                }
468            }
469
470            HostRequest::ChainTxStop {
471                genesis_hash,
472                operation_id,
473            } => {
474                log::info!("[hostapi] transaction stop");
475                HostApiOutcome::NeedsChainRpc {
476                    request_id,
477                    request_tag,
478                    genesis_hash,
479                    json_rpc_method: "transaction_v1_stop".into(),
480                    json_rpc_params: serde_json::json!([operation_id]),
481                    follow_sub_id: None,
482                }
483            }
484
485            HostRequest::Unimplemented { tag } => {
486                log::info!("[hostapi] unimplemented method (tag={tag})");
487                if is_subscription_control(tag) {
488                    HostApiOutcome::Silent
489                } else {
490                    HostApiOutcome::Response {
491                        data: encode_response(
492                            &request_id,
493                            request_tag,
494                            &HostResponse::Error("not implemented".into()),
495                        ),
496                    }
497                }
498            }
499
500            HostRequest::Unknown { tag } => {
501                log::warn!("[hostapi] unknown tag: {tag}");
502                HostApiOutcome::Silent
503            }
504        }
505    }
506}
507
508/// Parse the `data` field from a `JsonRpcSend` request.
509///
510/// The Product SDK encodes this as a SCALE string containing the full JSON-RPC
511/// request (e.g. `{"jsonrpc":"2.0","id":1,"method":"state_getMetadata","params":[]}`).
512/// We try SCALE string first, then fall back to raw UTF-8.
513fn parse_jsonrpc_data(data: &[u8]) -> Option<(String, serde_json::Value)> {
514    // Try SCALE string (compact length + UTF-8 bytes).
515    let json_str = codec::Reader::new(data)
516        .read_string()
517        .ok()
518        .or_else(|| std::str::from_utf8(data).ok().map(|s| s.to_string()))?;
519
520    let v: serde_json::Value = serde_json::from_str(&json_str).ok()?;
521    let method = v.get("method")?.as_str()?.to_string();
522    let params = v
523        .get("params")
524        .cloned()
525        .unwrap_or(serde_json::Value::Array(vec![]));
526    Some((method, params))
527}
528
529/// Check if a tag is a subscription control message (stop/interrupt).
530fn is_subscription_control(tag: u8) -> bool {
531    matches!(
532        tag,
533        TAG_ACCOUNT_STATUS_STOP
534            | TAG_ACCOUNT_STATUS_INTERRUPT
535            | TAG_CHAT_LIST_STOP
536            | TAG_CHAT_LIST_INTERRUPT
537            | TAG_CHAT_ACTION_STOP
538            | TAG_CHAT_ACTION_INTERRUPT
539            | TAG_CHAT_CUSTOM_MSG_STOP
540            | TAG_CHAT_CUSTOM_MSG_INTERRUPT
541            | TAG_STATEMENT_STORE_STOP
542            | TAG_STATEMENT_STORE_INTERRUPT
543            | TAG_PREIMAGE_LOOKUP_STOP
544            | TAG_PREIMAGE_LOOKUP_INTERRUPT
545            | TAG_JSONRPC_SUB_STOP
546            | TAG_JSONRPC_SUB_INTERRUPT
547            | TAG_CHAIN_HEAD_FOLLOW_STOP
548            | TAG_CHAIN_HEAD_FOLLOW_INTERRUPT
549    )
550}
551
552fn decode_error_kind(err: &codec::DecodeErr) -> &'static str {
553    match err {
554        codec::DecodeErr::Eof => "eof",
555        codec::DecodeErr::CompactTooLarge => "compact_too_large",
556        codec::DecodeErr::InvalidUtf8 => "invalid_utf8",
557        codec::DecodeErr::InvalidOption => "invalid_option",
558        codec::DecodeErr::InvalidTag(_) => "invalid_tag",
559        codec::DecodeErr::BadMessage(_) => "bad_message",
560    }
561}
562
563fn feature_log_kind(feature_data: &[u8]) -> &'static str {
564    match std::str::from_utf8(feature_data) {
565        Ok("signing" | "sign" | "navigate") => "utf8_known",
566        Ok(_) => "utf8_other",
567        Err(_) if feature_data.first() == Some(&0) => "binary_chain",
568        Err(_) => "binary_other",
569    }
570}
571
572fn request_kind(req: &HostRequest) -> &'static str {
573    match req {
574        HostRequest::Handshake { .. } => "handshake",
575        HostRequest::GetNonProductAccounts => "get_non_product_accounts",
576        HostRequest::FeatureSupported { .. } => "feature_supported",
577        HostRequest::LocalStorageRead { .. } => "local_storage_read",
578        HostRequest::LocalStorageWrite { .. } => "local_storage_write",
579        HostRequest::LocalStorageClear { .. } => "local_storage_clear",
580        HostRequest::SignPayload { .. } => "sign_payload",
581        HostRequest::SignRaw { .. } => "sign_raw",
582        HostRequest::CreateTransaction { .. } => "create_transaction",
583        HostRequest::NavigateTo { .. } => "navigate_to",
584        HostRequest::AccountConnectionStatusStart => "account_connection_status_start",
585        HostRequest::JsonRpcSend { .. } => "jsonrpc_send",
586        HostRequest::JsonRpcSubscribeStart { .. } => "jsonrpc_subscribe_start",
587        HostRequest::ChainHeadFollowStart { .. } => "chain_head_follow_start",
588        HostRequest::ChainHeadRequest { .. } => "chain_head_request",
589        HostRequest::ChainSpecRequest { .. } => "chain_spec_request",
590        HostRequest::ChainTxBroadcast { .. } => "chain_tx_broadcast",
591        HostRequest::ChainTxStop { .. } => "chain_tx_stop",
592        HostRequest::Unimplemented { .. } => "unimplemented",
593        HostRequest::Unknown { .. } => "unknown",
594    }
595}
596
597// ---------------------------------------------------------------------------
598// JS bridge script — injected into WKWebView at document_start
599// ---------------------------------------------------------------------------
600
601/// JavaScript injected before the Polkadot app loads. Sets up:
602/// 1. `window.__HOST_WEBVIEW_MARK__ = true` — SDK webview detection
603/// 2. `MessageChannel` with port2 as `window.__HOST_API_PORT__`
604/// 3. Binary message forwarding between port1 and native (base64)
605pub const HOST_API_BRIDGE_SCRIPT: &str = r#"
606(function() {
607    'use strict';
608    if (window.__hostApiBridge) { return; }
609    window.__hostApiBridge = true;
610    window.__HOST_WEBVIEW_MARK__ = true;
611    var ch = new MessageChannel();
612    window.__HOST_API_PORT__ = ch.port2;
613    ch.port2.start();
614    var port1 = ch.port1;
615    port1.start();
616    port1.onmessage = function(ev) {
617        var data = ev.data;
618        if (!data) { console.warn('[host-bridge] data is falsy, dropping'); return; }
619        var bytes;
620        if (data instanceof Uint8Array) { bytes = data; }
621        else if (data instanceof ArrayBuffer) { bytes = new Uint8Array(data); }
622        else if (ArrayBuffer.isView(data)) { bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); }
623        else { console.warn('[host-bridge] unknown data type: ' + typeof data + ' constructor=' + (data.constructor ? data.constructor.name : '?') + ', dropping'); return; }
624        var binary = '';
625        for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); }
626        try {
627            window.webkit.messageHandlers.hostApi.postMessage(btoa(binary));
628        } catch(e) {
629            console.error('[host-bridge] postMessage to hostApi FAILED:', e.message);
630        }
631    };
632    window.__hostApiReply = function(b64) {
633        try {
634            var binary = atob(b64);
635            var bytes = new Uint8Array(binary.length);
636            for (var i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
637            port1.postMessage(bytes);
638        } catch(e) { console.error('[host-bridge] reply failed:', e.message); }
639    };
640})();
641"#;
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use crate::protocol::*;
647    use std::sync::atomic::{AtomicBool, Ordering};
648    use std::sync::{Mutex, Once};
649
650    const TEST_APP: &str = "test-app";
651    static TEST_LOGGER_INIT: Once = Once::new();
652    static TEST_LOGGER_INSTALLED: AtomicBool = AtomicBool::new(false);
653    static TEST_LOG_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
654    static TEST_LOGGER: TestLogger = TestLogger::new();
655
656    struct TestLogger {
657        entries: Mutex<Vec<String>>,
658        capture_thread: Mutex<Option<std::thread::ThreadId>>,
659    }
660
661    impl TestLogger {
662        const fn new() -> Self {
663            Self {
664                entries: Mutex::new(Vec::new()),
665                capture_thread: Mutex::new(None),
666            }
667        }
668    }
669
670    impl log::Log for TestLogger {
671        fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
672            metadata.level() <= log::Level::Info
673        }
674
675        fn log(&self, record: &log::Record<'_>) {
676            if !self.enabled(record.metadata()) {
677                return;
678            }
679
680            let capture_thread = self
681                .capture_thread
682                .lock()
683                .unwrap_or_else(|e| e.into_inner());
684            if capture_thread.as_ref() != Some(&std::thread::current().id()) {
685                return;
686            }
687            drop(capture_thread);
688
689            self.entries
690                .lock()
691                .unwrap_or_else(|e| e.into_inner())
692                .push(record.args().to_string());
693        }
694
695        fn flush(&self) {}
696    }
697
698    fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
699        TEST_LOGGER_INIT.call_once(|| {
700            let installed = log::set_logger(&TEST_LOGGER).is_ok();
701            if installed {
702                log::set_max_level(log::LevelFilter::Info);
703            }
704            TEST_LOGGER_INSTALLED.store(installed, Ordering::Relaxed);
705        });
706        assert!(
707            TEST_LOGGER_INSTALLED.load(Ordering::Relaxed),
708            "test logger could not be installed"
709        );
710
711        let _guard = TEST_LOG_CAPTURE_LOCK
712            .lock()
713            .unwrap_or_else(|e| e.into_inner());
714
715        TEST_LOGGER
716            .entries
717            .lock()
718            .unwrap_or_else(|e| e.into_inner())
719            .clear();
720        *TEST_LOGGER
721            .capture_thread
722            .lock()
723            .unwrap_or_else(|e| e.into_inner()) = Some(std::thread::current().id());
724
725        let result = f();
726
727        *TEST_LOGGER
728            .capture_thread
729            .lock()
730            .unwrap_or_else(|e| e.into_inner()) = None;
731        let logs = TEST_LOGGER
732            .entries
733            .lock()
734            .unwrap_or_else(|e| e.into_inner())
735            .clone();
736        TEST_LOGGER
737            .entries
738            .lock()
739            .unwrap_or_else(|e| e.into_inner())
740            .clear();
741
742        (result, logs)
743    }
744
745    fn assert_logs_contain(logs: &[String], needle: &str) {
746        let joined = logs.join("\n");
747        assert!(
748            joined.contains(needle),
749            "expected logs to contain {needle:?}, got:\n{joined}"
750        );
751    }
752
753    fn assert_logs_do_not_contain(logs: &[String], needle: &str) {
754        let joined = logs.join("\n");
755        assert!(
756            !joined.contains(needle),
757            "expected logs not to contain {needle:?}, got:\n{joined}"
758        );
759    }
760
761    /// Extract a Response from HostApiOutcome, panicking on other variants.
762    fn expect_response(outcome: HostApiOutcome) -> Vec<u8> {
763        match outcome {
764            HostApiOutcome::Response { data: v } => v,
765            other => panic!("expected Response, got {}", outcome_name(&other)),
766        }
767    }
768
769    fn expect_silent(outcome: HostApiOutcome) {
770        match outcome {
771            HostApiOutcome::Silent => {}
772            other => panic!("expected Silent, got {}", outcome_name(&other)),
773        }
774    }
775
776    fn outcome_name(o: &HostApiOutcome) -> &'static str {
777        match o {
778            HostApiOutcome::Response { .. } => "Response",
779            HostApiOutcome::NeedsSign { .. } => "NeedsSign",
780            HostApiOutcome::NeedsChainQuery { .. } => "NeedsChainQuery",
781            HostApiOutcome::NeedsChainSubscription { .. } => "NeedsChainSubscription",
782            HostApiOutcome::NeedsNavigate { .. } => "NeedsNavigate",
783            HostApiOutcome::NeedsChainFollow { .. } => "NeedsChainFollow",
784            HostApiOutcome::NeedsChainRpc { .. } => "NeedsChainRpc",
785            HostApiOutcome::NeedsStorageRead { .. } => "NeedsStorageRead",
786            HostApiOutcome::NeedsStorageWrite { .. } => "NeedsStorageWrite",
787            HostApiOutcome::NeedsStorageClear { .. } => "NeedsStorageClear",
788            HostApiOutcome::Silent => "Silent",
789        }
790    }
791
792    fn make_handshake_request(request_id: &str) -> Vec<u8> {
793        let mut msg = Vec::new();
794        codec::encode_string(&mut msg, request_id);
795        msg.push(TAG_HANDSHAKE_REQ);
796        msg.push(0); // v1
797        msg.push(PROTOCOL_VERSION);
798        msg
799    }
800
801    fn make_get_accounts_request(request_id: &str) -> Vec<u8> {
802        let mut msg = Vec::new();
803        codec::encode_string(&mut msg, request_id);
804        msg.push(TAG_GET_NON_PRODUCT_ACCOUNTS_REQ);
805        msg.push(0); // v1
806        msg
807    }
808
809    fn make_storage_write(request_id: &str, key: &str, value: &[u8]) -> Vec<u8> {
810        let mut msg = Vec::new();
811        codec::encode_string(&mut msg, request_id);
812        msg.push(TAG_LOCAL_STORAGE_WRITE_REQ);
813        msg.push(0); // v1
814        codec::encode_string(&mut msg, key);
815        codec::encode_var_bytes(&mut msg, value);
816        msg
817    }
818
819    fn make_storage_read(request_id: &str, key: &str) -> Vec<u8> {
820        let mut msg = Vec::new();
821        codec::encode_string(&mut msg, request_id);
822        msg.push(TAG_LOCAL_STORAGE_READ_REQ);
823        msg.push(0); // v1
824        codec::encode_string(&mut msg, key);
825        msg
826    }
827
828    fn make_storage_clear(request_id: &str, key: &str) -> Vec<u8> {
829        let mut msg = Vec::new();
830        codec::encode_string(&mut msg, request_id);
831        msg.push(TAG_LOCAL_STORAGE_CLEAR_REQ);
832        msg.push(0); // v1
833        codec::encode_string(&mut msg, key);
834        msg
835    }
836
837    fn make_feature_supported_request(request_id: &str, feature_data: &[u8]) -> Vec<u8> {
838        let mut msg = Vec::new();
839        codec::encode_string(&mut msg, request_id);
840        msg.push(TAG_FEATURE_SUPPORTED_REQ);
841        msg.push(0); // v1
842        msg.extend_from_slice(feature_data);
843        msg
844    }
845
846    fn make_navigate_request(request_id: &str, url: &str) -> Vec<u8> {
847        let mut msg = Vec::new();
848        codec::encode_string(&mut msg, request_id);
849        msg.push(TAG_NAVIGATE_TO_REQ);
850        msg.push(0); // v1
851        codec::encode_string(&mut msg, url);
852        msg
853    }
854
855    fn make_jsonrpc_request(request_id: &str, tag: u8, json: &str) -> Vec<u8> {
856        let mut msg = Vec::new();
857        codec::encode_string(&mut msg, request_id);
858        msg.push(tag);
859        msg.push(0); // v1
860        codec::encode_string(&mut msg, json);
861        msg
862    }
863
864    #[test]
865    fn handshake_flow() {
866        let mut api = HostApi::new();
867        let req = make_handshake_request("hs-1");
868        let resp = expect_response(api.handle_message(&req, TEST_APP));
869
870        let mut r = codec::Reader::new(&resp);
871        assert_eq!(r.read_string().unwrap(), "hs-1");
872        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
873        assert_eq!(r.read_u8().unwrap(), 0); // v1
874        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
875    }
876
877    #[test]
878    fn handshake_wrong_version() {
879        let mut api = HostApi::new();
880        let mut req = Vec::new();
881        codec::encode_string(&mut req, "hs-bad");
882        req.push(TAG_HANDSHAKE_REQ);
883        req.push(0); // v1
884        req.push(255); // wrong version
885        let resp = expect_response(api.handle_message(&req, TEST_APP));
886
887        let mut r = codec::Reader::new(&resp);
888        assert_eq!(r.read_string().unwrap(), "hs-bad");
889        assert_eq!(r.read_u8().unwrap(), TAG_HANDSHAKE_RESP);
890        assert_eq!(r.read_u8().unwrap(), 0); // v1
891        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
892    }
893
894    #[test]
895    fn request_kind_redacts_payload_variants() {
896        assert_eq!(
897            request_kind(&HostRequest::SignPayload {
898                public_key: vec![0xAA; 32],
899                payload: b"secret-payload".to_vec(),
900            }),
901            "sign_payload"
902        );
903        assert_eq!(
904            request_kind(&HostRequest::NavigateTo {
905                url: "https://example.com/private".into(),
906            }),
907            "navigate_to"
908        );
909        assert_eq!(
910            request_kind(&HostRequest::LocalStorageWrite {
911                key: "secret".into(),
912                value: b"top-secret".to_vec(),
913            }),
914            "local_storage_write"
915        );
916    }
917
918    #[test]
919    fn decode_error_kind_redacts_error_details() {
920        assert_eq!(
921            decode_error_kind(&codec::DecodeErr::BadMessage("secret")),
922            "bad_message"
923        );
924        assert_eq!(
925            decode_error_kind(&codec::DecodeErr::InvalidTag(255)),
926            "invalid_tag"
927        );
928    }
929
930    #[test]
931    fn feature_log_kind_redacts_feature_data() {
932        assert_eq!(feature_log_kind(b"signing"), "utf8_known");
933        assert_eq!(feature_log_kind(b"secret-feature"), "utf8_other");
934        assert_eq!(
935            feature_log_kind(&[0, 0xde, 0xad, 0xbe, 0xef]),
936            "binary_chain"
937        );
938        assert_eq!(
939            feature_log_kind(&[1, 0xde, 0xad, 0xbe, 0xef]),
940            "binary_other"
941        );
942    }
943
944    #[test]
945    fn get_accounts_empty() {
946        let mut api = HostApi::new();
947        let req = make_get_accounts_request("acc-1");
948        let resp = expect_response(api.handle_message(&req, TEST_APP));
949
950        let mut r = codec::Reader::new(&resp);
951        assert_eq!(r.read_string().unwrap(), "acc-1");
952        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
953        assert_eq!(r.read_u8().unwrap(), 0); // v1
954        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
955        assert_eq!(r.read_compact_u32().unwrap(), 0); // empty vector
956    }
957
958    #[test]
959    fn get_accounts_returns_configured_accounts() {
960        let mut api = HostApi::new();
961        api.set_accounts(vec![Account {
962            public_key: vec![0xAA; 32],
963            name: Some("Test Account".into()),
964        }]);
965
966        let req = make_get_accounts_request("acc-2");
967        let resp = expect_response(api.handle_message(&req, TEST_APP));
968
969        let mut r = codec::Reader::new(&resp);
970        assert_eq!(r.read_string().unwrap(), "acc-2");
971        assert_eq!(r.read_u8().unwrap(), TAG_GET_NON_PRODUCT_ACCOUNTS_RESP);
972        assert_eq!(r.read_u8().unwrap(), 0); // v1
973        assert_eq!(r.read_u8().unwrap(), 0); // Result::Ok
974        assert_eq!(r.read_compact_u32().unwrap(), 1); // 1 account
975    }
976
977    #[test]
978    fn storage_read_returns_needs_storage_read() {
979        let mut api = HostApi::new();
980        let req = make_storage_read("r-1", "mykey");
981
982        match api.handle_message(&req, TEST_APP) {
983            HostApiOutcome::NeedsStorageRead { request_id, key } => {
984                assert_eq!(request_id, "r-1");
985                assert_eq!(key, "mykey");
986            }
987            other => panic!("expected NeedsStorageRead, got {}", outcome_name(&other)),
988        }
989    }
990
991    #[test]
992    fn storage_write_returns_needs_storage_write() {
993        let mut api = HostApi::new();
994        let req = make_storage_write("w-1", "mykey", b"myvalue");
995
996        match api.handle_message(&req, TEST_APP) {
997            HostApiOutcome::NeedsStorageWrite {
998                request_id,
999                key,
1000                value,
1001            } => {
1002                assert_eq!(request_id, "w-1");
1003                assert_eq!(key, "mykey");
1004                assert_eq!(value, b"myvalue");
1005            }
1006            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1007        }
1008    }
1009
1010    #[test]
1011    fn storage_clear_returns_needs_storage_clear() {
1012        let mut api = HostApi::new();
1013        let req = make_storage_clear("c-1", "clearme");
1014
1015        match api.handle_message(&req, TEST_APP) {
1016            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1017                assert_eq!(request_id, "c-1");
1018                assert_eq!(key, "clearme");
1019            }
1020            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1021        }
1022    }
1023
1024    #[test]
1025    fn storage_clear_of_nonexistent_key_emits_outcome() {
1026        let mut api = HostApi::new();
1027        let req = make_storage_clear("c-2", "never-written");
1028
1029        match api.handle_message(&req, TEST_APP) {
1030            HostApiOutcome::NeedsStorageClear { request_id, key } => {
1031                assert_eq!(request_id, "c-2");
1032                assert_eq!(key, "never-written");
1033            }
1034            other => panic!("expected NeedsStorageClear, got {}", outcome_name(&other)),
1035        }
1036    }
1037
1038    #[test]
1039    fn unimplemented_request_returns_error() {
1040        let mut api = HostApi::new();
1041        let mut msg = Vec::new();
1042        codec::encode_string(&mut msg, "unimp-1");
1043        msg.push(TAG_PUSH_NOTIFICATION_REQ);
1044
1045        let resp = expect_response(api.handle_message(&msg, TEST_APP));
1046
1047        let mut r = codec::Reader::new(&resp);
1048        assert_eq!(r.read_string().unwrap(), "unimp-1");
1049        assert_eq!(r.read_u8().unwrap(), TAG_PUSH_NOTIFICATION_RESP);
1050        assert_eq!(r.read_u8().unwrap(), 0); // v1
1051        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1052    }
1053
1054    #[test]
1055    fn subscription_stop_returns_silent() {
1056        let mut api = HostApi::new();
1057        let mut msg = Vec::new();
1058        codec::encode_string(&mut msg, "stop-1");
1059        msg.push(TAG_ACCOUNT_STATUS_STOP);
1060
1061        expect_silent(api.handle_message(&msg, TEST_APP));
1062    }
1063
1064    #[test]
1065    fn malformed_input_returns_silent() {
1066        let mut api = HostApi::new();
1067
1068        // Empty input
1069        expect_silent(api.handle_message(&[], TEST_APP));
1070
1071        // Truncated after request_id
1072        let mut msg = Vec::new();
1073        codec::encode_string(&mut msg, "trunc");
1074        expect_silent(api.handle_message(&msg, TEST_APP));
1075    }
1076
1077    #[test]
1078    fn unknown_tag_returns_silent() {
1079        let mut api = HostApi::new();
1080        let mut msg = Vec::new();
1081        codec::encode_string(&mut msg, "unk-1");
1082        msg.push(0xFF); // unknown tag
1083        expect_silent(api.handle_message(&msg, TEST_APP));
1084    }
1085
1086    #[test]
1087    fn storage_write_rejects_oversized_value() {
1088        let mut api = HostApi::new();
1089        let big_value = vec![0xAA; super::MAX_STORAGE_VALUE_SIZE + 1];
1090        let req = make_storage_write("w-big", "key", &big_value);
1091        let resp = expect_response(api.handle_message(&req, TEST_APP));
1092
1093        let mut r = codec::Reader::new(&resp);
1094        assert_eq!(r.read_string().unwrap(), "w-big");
1095        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1096        assert_eq!(r.read_u8().unwrap(), 0); // v1
1097        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1098    }
1099
1100    #[test]
1101    fn storage_write_rejects_long_key() {
1102        let mut api = HostApi::new();
1103        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1104        let req = make_storage_write("w-longkey", &long_key, b"v");
1105        let resp = expect_response(api.handle_message(&req, TEST_APP));
1106
1107        let mut r = codec::Reader::new(&resp);
1108        assert_eq!(r.read_string().unwrap(), "w-longkey");
1109        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_WRITE_RESP);
1110        assert_eq!(r.read_u8().unwrap(), 0); // v1
1111        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1112    }
1113
1114    #[test]
1115    fn sign_payload_returns_needs_sign() {
1116        let mut api = HostApi::new();
1117        let mut msg = Vec::new();
1118        codec::encode_string(&mut msg, "sign-1");
1119        msg.push(TAG_SIGN_PAYLOAD_REQ);
1120        msg.push(0); // v1
1121        codec::encode_var_bytes(&mut msg, &[0xAA; 32]); // publicKey
1122        msg.extend_from_slice(b"payload-data");
1123
1124        match api.handle_message(&msg, TEST_APP) {
1125            HostApiOutcome::NeedsSign {
1126                request_id,
1127                request_tag,
1128                public_key,
1129                payload,
1130            } => {
1131                assert_eq!(request_id, "sign-1");
1132                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1133                assert_eq!(public_key, vec![0xAA; 32]);
1134                assert_eq!(payload, b"payload-data");
1135            }
1136            _ => panic!("expected NeedsSign"),
1137        }
1138    }
1139
1140    #[test]
1141    fn sign_raw_returns_needs_sign() {
1142        let mut api = HostApi::new();
1143        let mut msg = Vec::new();
1144        codec::encode_string(&mut msg, "sign-2");
1145        msg.push(TAG_SIGN_RAW_REQ);
1146        msg.push(0); // v1
1147        codec::encode_var_bytes(&mut msg, &[0xBB; 32]); // publicKey
1148        msg.extend_from_slice(b"raw-bytes");
1149
1150        match api.handle_message(&msg, TEST_APP) {
1151            HostApiOutcome::NeedsSign {
1152                request_id,
1153                request_tag,
1154                public_key,
1155                payload,
1156            } => {
1157                assert_eq!(request_id, "sign-2");
1158                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1159                assert_eq!(public_key, vec![0xBB; 32]);
1160                assert_eq!(payload, b"raw-bytes");
1161            }
1162            _ => panic!("expected NeedsSign"),
1163        }
1164    }
1165
1166    #[test]
1167    fn logging_redacts_sign_payload_requests() {
1168        let mut api = HostApi::new();
1169        let request_id = "req-id-secret-123";
1170        let payload_secret = "payload-secret-456";
1171        let pubkey_secret_hex = "abababab";
1172        let mut msg = Vec::new();
1173        codec::encode_string(&mut msg, request_id);
1174        msg.push(TAG_SIGN_PAYLOAD_REQ);
1175        msg.push(0); // v1
1176        codec::encode_var_bytes(&mut msg, &[0xAB; 32]);
1177        msg.extend_from_slice(payload_secret.as_bytes());
1178
1179        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1180
1181        match outcome {
1182            HostApiOutcome::NeedsSign {
1183                request_id: actual_request_id,
1184                request_tag,
1185                public_key,
1186                payload,
1187            } => {
1188                assert_eq!(actual_request_id, request_id);
1189                assert_eq!(request_tag, TAG_SIGN_PAYLOAD_REQ);
1190                assert_eq!(public_key, vec![0xAB; 32]);
1191                assert_eq!(payload, payload_secret.as_bytes());
1192            }
1193            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1194        }
1195
1196        assert_logs_contain(&logs, "request: kind=sign_payload (tag=36)");
1197        assert_logs_contain(&logs, "sign_payload request (pubkey=32 bytes)");
1198        assert_logs_do_not_contain(&logs, request_id);
1199        assert_logs_do_not_contain(&logs, payload_secret);
1200        assert_logs_do_not_contain(&logs, pubkey_secret_hex);
1201    }
1202
1203    #[test]
1204    fn logging_redacts_sign_raw_requests() {
1205        let mut api = HostApi::new();
1206        let request_id = "req-id-secret-raw";
1207        let raw_secret = "raw-secret-789";
1208        let mut msg = Vec::new();
1209        codec::encode_string(&mut msg, request_id);
1210        msg.push(TAG_SIGN_RAW_REQ);
1211        msg.push(0); // v1
1212        codec::encode_var_bytes(&mut msg, &[0xCD; 32]);
1213        msg.extend_from_slice(raw_secret.as_bytes());
1214
1215        let (outcome, logs) = capture_logs(|| api.handle_message(&msg, TEST_APP));
1216
1217        match outcome {
1218            HostApiOutcome::NeedsSign {
1219                request_id: actual_request_id,
1220                request_tag,
1221                public_key,
1222                payload,
1223            } => {
1224                assert_eq!(actual_request_id, request_id);
1225                assert_eq!(request_tag, TAG_SIGN_RAW_REQ);
1226                assert_eq!(public_key, vec![0xCD; 32]);
1227                assert_eq!(payload, raw_secret.as_bytes());
1228            }
1229            other => panic!("expected NeedsSign, got {}", outcome_name(&other)),
1230        }
1231
1232        assert_logs_contain(&logs, "request: kind=sign_raw (tag=34)");
1233        assert_logs_contain(&logs, "sign_raw request (pubkey=32 bytes)");
1234        assert_logs_do_not_contain(&logs, request_id);
1235        assert_logs_do_not_contain(&logs, raw_secret);
1236    }
1237
1238    #[test]
1239    fn logging_redacts_navigation_requests() {
1240        let mut api = HostApi::new();
1241        let request_id = "req-id-secret-nav";
1242        let url = "https://example.com/callback?token=nav-secret-123";
1243        let req = make_navigate_request(request_id, url);
1244
1245        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1246
1247        match outcome {
1248            HostApiOutcome::NeedsNavigate {
1249                request_id: actual_request_id,
1250                url: actual_url,
1251            } => {
1252                assert_eq!(actual_request_id, request_id);
1253                assert_eq!(actual_url, url);
1254            }
1255            other => panic!("expected NeedsNavigate, got {}", outcome_name(&other)),
1256        }
1257
1258        assert_logs_contain(&logs, "request: kind=navigate_to (tag=6)");
1259        assert_logs_contain(&logs, "navigate_to request");
1260        assert_logs_do_not_contain(&logs, request_id);
1261        assert_logs_do_not_contain(&logs, "nav-secret-123");
1262    }
1263
1264    #[test]
1265    fn logging_redacts_local_storage_write_requests() {
1266        let mut api = HostApi::new();
1267        let request_id = "req-id-secret-storage";
1268        let key = "storage-secret-key";
1269        let value = b"storage-secret-value";
1270        let req = make_storage_write(request_id, key, value);
1271
1272        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1273
1274        match outcome {
1275            HostApiOutcome::NeedsStorageWrite {
1276                request_id: actual_request_id,
1277                key: actual_key,
1278                value: actual_value,
1279            } => {
1280                assert_eq!(actual_request_id, request_id);
1281                assert_eq!(actual_key, key);
1282                assert_eq!(actual_value, value);
1283            }
1284            other => panic!("expected NeedsStorageWrite, got {}", outcome_name(&other)),
1285        }
1286
1287        assert_logs_contain(&logs, "request: kind=local_storage_write (tag=14)");
1288        assert_logs_do_not_contain(&logs, request_id);
1289        assert_logs_do_not_contain(&logs, key);
1290        assert_logs_do_not_contain(&logs, "storage-secret-value");
1291    }
1292
1293    #[test]
1294    fn logging_redacts_feature_supported_requests() {
1295        let mut api = HostApi::new();
1296        let utf8_secret = "feature-secret-utf8";
1297        let utf8_req =
1298            make_feature_supported_request("req-id-feature-utf8", utf8_secret.as_bytes());
1299
1300        let (utf8_outcome, utf8_logs) = capture_logs(|| api.handle_message(&utf8_req, TEST_APP));
1301        let utf8_resp = expect_response(utf8_outcome);
1302        let mut utf8_reader = codec::Reader::new(&utf8_resp);
1303        utf8_reader.read_string().unwrap();
1304        assert_eq!(utf8_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1305        utf8_reader.read_u8().unwrap();
1306        utf8_reader.read_u8().unwrap();
1307        assert_eq!(utf8_reader.read_u8().unwrap(), 0);
1308        assert_logs_contain(&utf8_logs, "request: kind=feature_supported (tag=2)");
1309        assert_logs_contain(&utf8_logs, "feature_supported: feature=utf8_other -> false");
1310        assert_logs_do_not_contain(&utf8_logs, utf8_secret);
1311
1312        let binary_req =
1313            make_feature_supported_request("req-id-feature-binary", &[0, 0xde, 0xad, 0xbe, 0xef]);
1314        let (binary_outcome, binary_logs) =
1315            capture_logs(|| api.handle_message(&binary_req, TEST_APP));
1316        let binary_resp = expect_response(binary_outcome);
1317        let mut binary_reader = codec::Reader::new(&binary_resp);
1318        binary_reader.read_string().unwrap();
1319        assert_eq!(binary_reader.read_u8().unwrap(), TAG_FEATURE_SUPPORTED_RESP);
1320        binary_reader.read_u8().unwrap();
1321        binary_reader.read_u8().unwrap();
1322        assert_eq!(binary_reader.read_u8().unwrap(), 0);
1323        assert_logs_contain(
1324            &binary_logs,
1325            "feature_supported: feature=binary_chain -> false",
1326        );
1327        assert_logs_do_not_contain(&binary_logs, "deadbeef");
1328    }
1329
1330    #[test]
1331    fn logging_redacts_jsonrpc_requests() {
1332        let mut api = HostApi::new();
1333        let request_id = "req-id-secret-jsonrpc";
1334        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-method","params":["jsonrpc-secret-param"]}"#;
1335        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SEND_REQ, json);
1336
1337        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1338
1339        match outcome {
1340            HostApiOutcome::NeedsChainQuery {
1341                request_id: actual_request_id,
1342                method,
1343                params,
1344            } => {
1345                assert_eq!(actual_request_id, request_id);
1346                assert_eq!(method, "rpc-secret-method");
1347                assert_eq!(params, serde_json::json!(["jsonrpc-secret-param"]));
1348            }
1349            other => panic!("expected NeedsChainQuery, got {}", outcome_name(&other)),
1350        }
1351
1352        assert_logs_contain(&logs, "request: kind=jsonrpc_send (tag=70)");
1353        assert_logs_contain(&logs, "jsonrpc_send request");
1354        assert_logs_do_not_contain(&logs, request_id);
1355        assert_logs_do_not_contain(&logs, "rpc-secret-method");
1356        assert_logs_do_not_contain(&logs, "jsonrpc-secret-param");
1357    }
1358
1359    #[test]
1360    fn logging_redacts_jsonrpc_subscribe_requests() {
1361        let mut api = HostApi::new();
1362        let request_id = "req-id-secret-jsonrpc-sub";
1363        let json = r#"{"jsonrpc":"2.0","id":1,"method":"rpc-secret-subscribe","params":["jsonrpc-secret-sub-param"]}"#;
1364        let req = make_jsonrpc_request(request_id, TAG_JSONRPC_SUB_START, json);
1365
1366        let (outcome, logs) = capture_logs(|| api.handle_message(&req, TEST_APP));
1367
1368        match outcome {
1369            HostApiOutcome::NeedsChainSubscription {
1370                request_id: actual_request_id,
1371                method,
1372                params,
1373            } => {
1374                assert_eq!(actual_request_id, request_id);
1375                assert_eq!(method, "rpc-secret-subscribe");
1376                assert_eq!(params, serde_json::json!(["jsonrpc-secret-sub-param"]));
1377            }
1378            other => panic!(
1379                "expected NeedsChainSubscription, got {}",
1380                outcome_name(&other)
1381            ),
1382        }
1383
1384        assert_logs_contain(&logs, "request: kind=jsonrpc_subscribe_start (tag=72)");
1385        assert_logs_contain(&logs, "jsonrpc_subscribe_start request");
1386        assert_logs_do_not_contain(&logs, request_id);
1387        assert_logs_do_not_contain(&logs, "rpc-secret-subscribe");
1388        assert_logs_do_not_contain(&logs, "jsonrpc-secret-sub-param");
1389    }
1390
1391    #[test]
1392    fn logging_redacts_decode_failures() {
1393        let mut api = HostApi::new();
1394        let malformed = b"decode-secret-123";
1395
1396        let (outcome, logs) = capture_logs(|| api.handle_message(malformed, TEST_APP));
1397
1398        expect_silent(outcome);
1399        assert_logs_contain(&logs, "failed to decode message: kind=");
1400        assert_logs_do_not_contain(&logs, "decode-secret-123");
1401    }
1402
1403    #[test]
1404    fn storage_read_rejects_long_key() {
1405        let mut api = HostApi::new();
1406        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1407        let req = make_storage_read("r-longkey", &long_key);
1408        let resp = expect_response(api.handle_message(&req, TEST_APP));
1409
1410        let mut r = codec::Reader::new(&resp);
1411        assert_eq!(r.read_string().unwrap(), "r-longkey");
1412        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_READ_RESP);
1413        assert_eq!(r.read_u8().unwrap(), 0); // v1
1414        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1415    }
1416
1417    #[test]
1418    fn storage_clear_rejects_long_key() {
1419        let mut api = HostApi::new();
1420        let long_key = "k".repeat(super::MAX_STORAGE_KEY_LENGTH + 1);
1421        let req = make_storage_clear("c-longkey", &long_key);
1422        let resp = expect_response(api.handle_message(&req, TEST_APP));
1423
1424        let mut r = codec::Reader::new(&resp);
1425        assert_eq!(r.read_string().unwrap(), "c-longkey");
1426        assert_eq!(r.read_u8().unwrap(), TAG_LOCAL_STORAGE_CLEAR_RESP);
1427        assert_eq!(r.read_u8().unwrap(), 0); // v1
1428        assert_eq!(r.read_u8().unwrap(), 1); // Result::Err
1429    }
1430}