1#[cfg(not(target_arch = "wasm32"))]
2use std::sync::{mpsc, Mutex};
3use std::{
4 fmt,
5 sync::{Arc, OnceLock},
6};
7
8#[cfg(not(target_arch = "wasm32"))]
9use std::thread;
10
11use tracing::{Event, Level, Subscriber};
12use tracing_subscriber::{
13 layer::{Context, SubscriberExt},
14 registry::LookupSpan,
15 EnvFilter, Layer, Registry,
16};
17
18#[uniffi::export(with_foreign)]
65pub trait Logger: Sync + Send {
66 fn log(&self, level: LogLevel, message: String);
68}
69
70#[derive(Debug, Clone, Copy, uniffi::Enum)]
72pub enum LogLevel {
73 Trace,
75 Debug,
77 Info,
79 Warn,
81 Error,
83}
84
85const fn log_level(level: Level) -> LogLevel {
86 match level {
87 Level::TRACE => LogLevel::Trace,
88 Level::DEBUG => LogLevel::Debug,
89 Level::INFO => LogLevel::Info,
90 Level::WARN => LogLevel::Warn,
91 Level::ERROR => LogLevel::Error,
92 }
93}
94
95#[derive(Default)]
96struct EventFieldVisitor {
97 message: Option<String>,
98 fields: Vec<(String, String)>,
99}
100
101impl tracing::field::Visit for EventFieldVisitor {
102 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
103 let value = format!("{value:?}");
104 if field.name() == "message" {
105 self.message = Some(value);
106 } else {
107 self.fields.push((field.name().to_string(), value));
108 }
109 }
110
111 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
112 if field.name() == "message" {
113 self.message = Some(value.to_string());
114 } else {
115 self.fields
116 .push((field.name().to_string(), value.to_string()));
117 }
118 }
119}
120
121struct ForeignLoggerLayer;
123
124#[cfg(not(target_arch = "wasm32"))]
128struct LogEvent {
129 level: LogLevel,
130 message: String,
131}
132
133#[cfg(not(target_arch = "wasm32"))]
144static LOG_CHANNEL: OnceLock<Mutex<mpsc::Sender<LogEvent>>> = OnceLock::new();
145#[cfg(target_arch = "wasm32")]
146static LOGGER_INSTANCE: OnceLock<Arc<dyn Logger>> = OnceLock::new();
147static LOGGING_INITIALIZED: OnceLock<()> = OnceLock::new();
148
149impl<S> Layer<S> for ForeignLoggerLayer
150where
151 S: Subscriber + for<'span> LookupSpan<'span>,
152{
153 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
154 #[cfg(not(target_arch = "wasm32"))]
155 let Some(sender) = LOG_CHANNEL.get() else {
156 return;
157 };
158
159 #[cfg(target_arch = "wasm32")]
160 if LOGGER_INSTANCE.get().is_none() {
161 return;
162 }
163
164 let mut visitor = EventFieldVisitor::default();
165 event.record(&mut visitor);
166 let metadata = event.metadata();
167
168 let mut message = visitor.message.unwrap_or_default();
169 if !visitor.fields.is_empty() {
170 let extras = visitor
171 .fields
172 .iter()
173 .map(|(name, value)| format!("{name}={value}"))
174 .collect::<Vec<_>>()
175 .join(" ");
176 if message.is_empty() {
177 message = extras;
178 } else {
179 message = format!("{message} {extras}");
180 }
181 }
182 if message.is_empty() {
183 message = metadata.name().to_string();
184 }
185
186 let formatted =
187 sanitize_hex_secrets(format!("{} {message}", metadata.target()));
188
189 #[cfg(target_arch = "wasm32")]
190 if let Some(logger) = LOGGER_INSTANCE.get() {
191 logger.log(log_level(*metadata.level()), formatted.clone());
192 }
193
194 #[cfg(not(target_arch = "wasm32"))]
195 if let Ok(sender) = sender.lock() {
196 let _ = sender.send(LogEvent {
197 level: log_level(*metadata.level()),
198 message: formatted,
199 });
200 }
201 }
202}
203
204const fn log_level_filter(level: LogLevel) -> &'static str {
205 match level {
206 LogLevel::Trace => "trace",
207 LogLevel::Debug => "debug",
208 LogLevel::Info => "info",
209 LogLevel::Warn => "warn",
210 LogLevel::Error => "error",
211 }
212}
213
214const APP_CRATES: &[&str] = &[
218 "walletkit",
219 "walletkit_core",
220 "world_id_core",
221 "world_id_proof",
222 "world_id_authenticator",
223 "world_id_primitives",
224 "taceo_oprf",
225 "taceo_oprf_client",
226 "taceo_oprf_core",
227 "taceo_oprf_types",
228 "semaphore_rs",
229];
230
231fn build_env_filter(level: Option<LogLevel>) -> EnvFilter {
232 if let Ok(filter) = EnvFilter::try_from_default_env() {
233 return filter;
234 }
235
236 let level_str = level.map_or("info", log_level_filter);
237
238 let needs_per_crate = matches!(level, Some(LogLevel::Trace | LogLevel::Debug));
239 if !needs_per_crate {
240 return EnvFilter::new(level_str);
241 }
242
243 let mut directives = String::new();
245 for crate_name in APP_CRATES {
246 directives.push_str(crate_name);
247 directives.push('=');
248 directives.push_str(level_str);
249 directives.push(',');
250 }
251 directives.push_str("info");
252 EnvFilter::new(directives)
253}
254
255#[uniffi::export]
259pub fn emit_log(level: LogLevel, message: String) {
260 let message = message.into_boxed_str();
261 let message = message.as_ref();
262
263 match level {
264 LogLevel::Trace => tracing::trace!(target: "walletkit", "{message}"),
265 LogLevel::Debug => tracing::debug!(target: "walletkit", "{message}"),
266 LogLevel::Info => tracing::info!(target: "walletkit", "{message}"),
267 LogLevel::Warn => tracing::warn!(target: "walletkit", "{message}"),
268 LogLevel::Error => tracing::error!(target: "walletkit", "{message}"),
269 }
270}
271
272#[cfg(not(target_arch = "wasm32"))]
285fn init_logging_native(logger: Arc<dyn Logger>) {
286 let (tx, rx) = mpsc::channel::<LogEvent>();
287 let _ = LOG_CHANNEL.set(Mutex::new(tx));
288
289 thread::Builder::new()
290 .name("walletkit-logger".into())
291 .spawn(move || {
292 for event in rx {
293 logger.log(event.level, event.message);
294 }
295 })
296 .expect("failed to spawn walletkit logger thread");
297}
298
299#[cfg(target_arch = "wasm32")]
306fn init_logging_wasm(logger: Arc<dyn Logger>) {
307 let _ = LOGGER_INSTANCE.set(logger);
308}
309
310#[uniffi::export]
323pub fn init_logging(logger: Arc<dyn Logger>, level: Option<LogLevel>) {
324 if LOGGING_INITIALIZED.get().is_some() {
325 return;
326 }
327
328 #[cfg(not(target_arch = "wasm32"))]
329 init_logging_native(logger);
330
331 #[cfg(target_arch = "wasm32")]
332 init_logging_wasm(logger);
333
334 let _ = tracing_log::LogTracer::init();
335
336 let filter = build_env_filter(level);
337 let subscriber = Registry::default().with(filter).with(ForeignLoggerLayer);
338
339 if tracing::subscriber::set_global_default(subscriber).is_ok() {
340 let _ = LOGGING_INITIALIZED.set(());
341 }
342}
343
344const HEX_SECRET_MIN_LEN: usize = 21;
346
347fn sanitize_hex_secrets(input: String) -> String {
353 if !has_long_hex_run(input.as_bytes()) {
354 return input;
355 }
356
357 let bytes = input.as_bytes();
358 let len = bytes.len();
359 let mut out = String::with_capacity(len);
360 let mut i = 0;
361
362 while i < len {
363 let has_prefix = i + 1 < len
364 && bytes[i] == b'0'
365 && (bytes[i + 1] == b'x' || bytes[i + 1] == b'X');
366 let digit_start = if has_prefix { i + 2 } else { i };
367
368 let mut j = digit_start;
369 while j < len && bytes[j].is_ascii_hexdigit() {
370 j += 1;
371 }
372
373 let hex_len = j - digit_start;
374 if hex_len >= HEX_SECRET_MIN_LEN {
375 if has_prefix {
376 out.push_str("0x");
377 }
378 out.push(char::from(bytes[digit_start]));
379 out.push(char::from(bytes[digit_start + 1]));
380 out.push_str("..");
381 out.push(char::from(bytes[j - 2]));
382 out.push(char::from(bytes[j - 1]));
383 i = j;
384 } else if j > i {
385 out.push_str(&input[i..j]);
386 i = j;
387 } else {
388 let next = input.ceil_char_boundary(i + 1);
391 out.push_str(&input[i..next]);
392 i = next;
393 }
394 }
395
396 out
397}
398
399fn has_long_hex_run(bytes: &[u8]) -> bool {
400 let mut run: usize = 0;
401 for &b in bytes {
402 if b.is_ascii_hexdigit() {
403 run += 1;
404 if run >= HEX_SECRET_MIN_LEN {
405 return true;
406 }
407 } else {
408 run = 0;
409 }
410 }
411 false
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn short_hex_passes_through() {
420 let input = "tx hash is abcdef1";
421 assert_eq!(sanitize_hex_secrets(input.to_string()), input);
422 }
423
424 #[test]
425 fn long_hex_is_redacted() {
426 let input = "key=deadbeefcafebabe1234567890abcdef1234567890abcdef end";
427 assert_eq!(sanitize_hex_secrets(input.to_string()), "key=de..ef end");
428 }
429
430 #[test]
431 fn hex_with_0x_prefix() {
432 let input = "addr 0xdeadbeefcafebabe1234567890abcdef1234567890abcdef end";
433 assert_eq!(sanitize_hex_secrets(input.to_string()), "addr 0xde..ef end");
434 }
435
436 #[test]
437 fn multiple_secrets_redacted() {
438 let a = "a".repeat(32);
439 let b = "b".repeat(32);
440 let input = format!("x={a} y={b}");
441 assert_eq!(sanitize_hex_secrets(input), "x=aa..aa y=bb..bb");
442 }
443
444 #[test]
445 fn exactly_threshold_is_redacted() {
446 let input = "a".repeat(HEX_SECRET_MIN_LEN);
447 assert_eq!(sanitize_hex_secrets(input), "aa..aa");
448 }
449
450 #[test]
451 fn below_threshold_passes() {
452 let input = "a".repeat(HEX_SECRET_MIN_LEN - 1);
453 assert_eq!(sanitize_hex_secrets(input.clone()), input);
454 }
455
456 #[test]
457 fn no_hex_passes_through() {
458 let input = "hello world, no hex here!";
459 assert_eq!(sanitize_hex_secrets(input.to_string()), input);
460 }
461
462 #[test]
463 fn empty_string() {
464 assert_eq!(sanitize_hex_secrets(String::new()), "");
465 }
466
467 #[test]
468 fn uppercase_hex_redacted() {
469 let input = "DEADBEEFCAFEBABE1234567890ABCDEF1234567890ABCDEF";
470 assert_eq!(sanitize_hex_secrets(input.to_string()), "DE..EF");
471 }
472
473 #[test]
474 fn mixed_text_and_hex() {
475 let secret = "f".repeat(64);
476 let input = format!("user=alice secret={secret} action=login");
477 assert_eq!(
478 sanitize_hex_secrets(input),
479 "user=alice secret=ff..ff action=login"
480 );
481 }
482
483 #[test]
484 fn utf8_preserved_alongside_hex_redaction() {
485 let secret = "a".repeat(32);
486 let input = format!("clé={secret} résumé");
487 assert_eq!(sanitize_hex_secrets(input), "clé=aa..aa résumé");
488 }
489
490 #[test]
491 fn multibyte_utf8_no_hex() {
492 let input = "café naïve 日本語".to_string();
493 assert_eq!(sanitize_hex_secrets(input.clone()), input);
494 }
495
496 #[test]
497 fn no_alloc_when_clean() {
498 let input = String::from("no secrets here");
499 let ptr = input.as_ptr();
500 let output = sanitize_hex_secrets(input);
501 assert_eq!(output.as_ptr(), ptr, "should return same allocation");
502 }
503}