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