Skip to main content

walletkit_core/
logger.rs

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/// Trait representing the minimal foreign logging bridge used by `WalletKit`.
19///
20/// `WalletKit` emits tracing events and forwards formatted messages with an
21/// explicit severity `level`.
22///
23/// # Rust example
24///
25/// ```rust
26/// use std::sync::Arc;
27/// use walletkit_core::logger::{init_logging, LogLevel, Logger};
28///
29/// struct AppLogger;
30///
31/// impl Logger for AppLogger {
32///     fn log(&self, level: LogLevel, message: String) {
33///         println!("[{level:?}] {message}");
34///     }
35/// }
36///
37/// init_logging(Arc::new(AppLogger), Some(LogLevel::Debug));
38/// ```
39///
40/// # Swift example
41///
42/// ```swift
43/// final class WalletKitLoggerBridge: WalletKit.Logger {
44///     static let shared = WalletKitLoggerBridge()
45///
46///     func log(level: WalletKit.LogLevel, message: String) {
47///         switch level {
48///         case .trace, .debug:
49///             print("[DEBUG] \(message)")
50///         case .info:
51///             print("[INFO] \(message)")
52///         case .warn:
53///             print("[WARN] \(message)")
54///         case .error:
55///             fputs("[ERROR] \(message)\n", stderr)
56///         @unknown default:
57///             fputs("[UNKNOWN] \(message)\n", stderr)
58///         }
59///     }
60/// }
61///
62/// WalletKit.initLogging(logger: WalletKitLoggerBridge.shared, level: .debug)
63/// ```
64#[uniffi::export(with_foreign)]
65pub trait Logger: Sync + Send {
66    /// Receives a log `message` with its corresponding `level`.
67    fn log(&self, level: LogLevel, message: String);
68}
69
70/// Enumeration of possible log levels for foreign logger callbacks.
71#[derive(Debug, Clone, Copy, uniffi::Enum)]
72pub enum LogLevel {
73    /// Very detailed diagnostic messages.
74    Trace,
75    /// Debug-level messages.
76    Debug,
77    /// Informational messages.
78    Info,
79    /// Warning messages.
80    Warn,
81    /// Error messages.
82    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
121/// Forwards walletkit tracing events to the foreign logger.
122struct ForeignLoggerLayer;
123
124// On native targets, log events flow through a channel to avoid making FFI
125// calls from within a UniFFI future-poll context.  On WASM the Logger is
126// called directly because there is no background thread support.
127#[cfg(not(target_arch = "wasm32"))]
128struct LogEvent {
129    level: LogLevel,
130    message: String,
131}
132
133// Log events are pushed into this channel by `ForeignLoggerLayer::on_event`
134// and delivered to the foreign callback on a dedicated thread.
135//
136// This architecture is required because UniFFI foreign callbacks crash with
137// EXC_BAD_ACCESS when invoked synchronously from within a UniFFI future-poll
138// context (`rust_call_with_out_status`). The nested FFI boundary crossing
139// corrupts state. By decoupling collection from delivery through a channel,
140// the tracing layer never makes an FFI call — it only pushes to an in-process
141// queue — and the dedicated delivery thread calls `Logger::log` from a clean
142// stack with no active FFI frames.
143#[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
214// Only these crates are promoted to the caller-requested level.
215// Everything else stays at the baseline (`info`). This avoids
216// flooding the logger with internal noise from infrastructure crates.
217const 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    // e.g. "walletkit=debug,walletkit_core=debug,...,info"
244    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/// Emits a message at the given level through `WalletKit`'s tracing pipeline.
256///
257/// Useful for verifying that the logging bridge is wired up correctly.
258#[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/// Native platform initializer: wires the foreign logger to a dedicated
273/// delivery thread via an mpsc channel.
274///
275/// The channel decouples `ForeignLoggerLayer::on_event` (called from inside
276/// `UniFFI`'s future-poll machinery) from the actual FFI call to `Logger::log`.
277/// Invoking a `UniFFI` foreign callback synchronously while a `UniFFI` frame is
278/// already on the stack causes an `EXC_BAD_ACCESS`; the background thread avoids
279/// that by delivering events from a clean, FFI-free call stack.
280///
281/// # Panics
282///
283/// Panics if the background thread cannot be spawned.
284#[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/// WASM platform initializer: stores the logger directly so that
300/// `ForeignLoggerLayer::on_event` can call it synchronously.
301///
302/// On WASM there is no background-thread risk: the runtime is
303/// single-threaded and cooperative, so no UniFFI future-poll frame can be
304/// on the stack when a tracing event fires.
305#[cfg(target_arch = "wasm32")]
306fn init_logging_wasm(logger: Arc<dyn Logger>) {
307    let _ = LOGGER_INSTANCE.set(logger);
308}
309
310/// Initializes `WalletKit` tracing and registers a foreign logger sink.
311///
312/// `level` controls the minimum severity for `WalletKit` and its direct
313/// dependencies (taceo, world-id, semaphore). All other crates remain at
314/// `Info` regardless of this setting. Pass `None` to default to `Info`.
315/// The `RUST_LOG` environment variable, when set, always takes precedence.
316///
317/// This function is idempotent. The first call wins; subsequent calls are no-ops.
318///
319/// # Panics
320///
321/// Panics if the dedicated logger delivery thread cannot be spawned (native only).
322#[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
344/// Minimum contiguous hex digits to treat as a potential secret.
345const HEX_SECRET_MIN_LEN: usize = 21;
346
347/// Replaces hex sequences of [`HEX_SECRET_MIN_LEN`] or more digits with a
348/// redacted form showing only the first and last two hex characters.
349/// An optional `0x` prefix is preserved in the output.
350///
351/// Returns `input` unmodified (zero-allocation) when no redaction is needed.
352fn 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            // Copy one full UTF-8 character. Non-ASCII leading bytes
389            // are never hex digits, so `i` is always at a char boundary.
390            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}