1pub 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
30const MAX_STORAGE_VALUE_SIZE: usize = 64 * 1024;
32const MAX_STORAGE_KEY_LENGTH: usize = 512;
34const MAX_PUSH_NOTIFICATION_TEXT_LEN: usize = 1024;
37const MAX_DEEPLINK_URL_LEN: usize = 2048;
39const ALLOWED_DEEPLINK_SCHEMES: &[&str] = &["https://"];
43
44#[derive(serde::Serialize)]
46#[serde(tag = "type")]
47pub enum HostApiOutcome {
48 Response { data: Vec<u8> },
50 NeedsSign {
52 request_id: String,
53 request_tag: u8,
54 public_key: Vec<u8>,
55 payload: Vec<u8>,
56 },
57 NeedsChainQuery {
59 request_id: String,
60 method: String,
61 params: serde_json::Value,
62 },
63 NeedsChainSubscription {
65 request_id: String,
66 method: String,
67 params: serde_json::Value,
68 },
69 NeedsNavigate { request_id: String, url: String },
71 NeedsPushNotification {
77 request_id: String,
78 text: String,
79 deeplink: Option<String>,
80 },
81 NeedsChainFollow {
83 request_id: String,
84 genesis_hash: Vec<u8>,
85 with_runtime: bool,
86 },
87 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 follow_sub_id: Option<String>,
98 },
99 NeedsStorageRead { request_id: String, key: String },
102 NeedsStorageWrite {
105 request_id: String,
106 key: String,
107 value: Vec<u8>,
108 },
109 NeedsStorageClear { request_id: String, key: String },
112 Silent,
114}
115
116pub 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 pub fn set_accounts(&mut self, accounts: Vec<Account>) {
141 self.accounts = accounts;
142 }
143
144 pub fn set_supported_chains(&mut self, chains: impl IntoIterator<Item = [u8; 32]>) {
147 self.supported_chains = chains.into_iter().collect();
148 }
149
150 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; 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 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 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 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 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
567fn parse_jsonrpc_data(data: &[u8]) -> Option<(String, serde_json::Value)> {
573 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
588fn 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
657fn 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
666pub 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 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); 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); 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); 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); 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); 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); 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); 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); 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); assert_eq!(r.read_u8().unwrap(), 0); }
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); req.push(255); 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); assert_eq!(r.read_u8().unwrap(), 1); }
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); assert_eq!(r.read_u8().unwrap(), 0); assert_eq!(r.read_compact_u32().unwrap(), 0); }
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); assert_eq!(r.read_u8().unwrap(), 0); assert_eq!(r.read_compact_u32().unwrap(), 1); }
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); assert_eq!(r.read_u8().unwrap(), 1); }
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); 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 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); 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 expect_silent(api.handle_message(&[], TEST_APP));
1212
1213 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); 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); assert_eq!(r.read_u8().unwrap(), 1); }
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); assert_eq!(r.read_u8().unwrap(), 1); }
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); codec::encode_var_bytes(&mut msg, &[0xAA; 32]); 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); codec::encode_var_bytes(&mut msg, &[0xBB; 32]); 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); 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); 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 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); 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); assert_eq!(r.read_u8().unwrap(), 1); assert_eq!(r.read_u8().unwrap(), 0); 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); assert_eq!(r.read_u8().unwrap(), 1); assert_eq!(r.read_u8().unwrap(), 0); 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); assert_eq!(r.read_u8().unwrap(), 1); assert_eq!(r.read_u8().unwrap(), 0); 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); assert_eq!(r.read_u8().unwrap(), 1); assert_eq!(r.read_u8().unwrap(), 0); 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); assert_eq!(r.read_u8().unwrap(), 1); assert_eq!(r.read_u8().unwrap(), 0); 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 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); assert_eq!(r.read_u8().unwrap(), 1); }
1769
1770 #[test]
1771 fn push_notification_accepts_multibyte_text_at_byte_limit() {
1772 let mut api = HostApi::new();
1773 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); assert_eq!(r.read_u8().unwrap(), 1); }
1806
1807 #[test]
1808 fn push_notification_accepts_bare_https_scheme_deeplink() {
1809 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); assert_eq!(r.read_u8().unwrap(), 1); }
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); assert_eq!(r.read_u8().unwrap(), 1); }
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); assert_eq!(r.read_u8().unwrap(), 0); assert_eq!(r.read_u8().unwrap(), 1); }
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); assert_eq!(r.read_u8().unwrap(), 1); }
1960}