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