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