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