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