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.
352#[must_use]
353#[uniffi::export]
354pub fn sanitize_hex_secrets(input: String) -> String {
355    if !has_long_hex_run(input.as_bytes()) {
356        return input;
357    }
358
359    let bytes = input.as_bytes();
360    let len = bytes.len();
361    let mut out = String::with_capacity(len);
362    let mut i = 0;
363
364    while i < len {
365        let has_prefix = i + 1 < len
366            && bytes[i] == b'0'
367            && (bytes[i + 1] == b'x' || bytes[i + 1] == b'X');
368        let digit_start = if has_prefix { i + 2 } else { i };
369
370        let mut j = digit_start;
371        while j < len && bytes[j].is_ascii_hexdigit() {
372            j += 1;
373        }
374
375        let hex_len = j - digit_start;
376        if hex_len >= HEX_SECRET_MIN_LEN {
377            if has_prefix {
378                out.push_str("0x");
379            }
380            out.push(char::from(bytes[digit_start]));
381            out.push(char::from(bytes[digit_start + 1]));
382            out.push_str("..");
383            out.push(char::from(bytes[j - 2]));
384            out.push(char::from(bytes[j - 1]));
385            i = j;
386        } else if j > i {
387            out.push_str(&input[i..j]);
388            i = j;
389        } else {
390            // Copy one full UTF-8 character. Non-ASCII leading bytes
391            // are never hex digits, so `i` is always at a char boundary.
392            let next = input.ceil_char_boundary(i + 1);
393            out.push_str(&input[i..next]);
394            i = next;
395        }
396    }
397
398    out
399}
400
401fn has_long_hex_run(bytes: &[u8]) -> bool {
402    let mut run: usize = 0;
403    for &b in bytes {
404        if b.is_ascii_hexdigit() {
405            run += 1;
406            if run >= HEX_SECRET_MIN_LEN {
407                return true;
408            }
409        } else {
410            run = 0;
411        }
412    }
413    false
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn short_hex_passes_through() {
422        let input = "tx hash is abcdef1";
423        assert_eq!(sanitize_hex_secrets(input.to_string()), input);
424    }
425
426    #[test]
427    fn long_hex_is_redacted() {
428        let input = "key=deadbeefcafebabe1234567890abcdef1234567890abcdef end";
429        assert_eq!(sanitize_hex_secrets(input.to_string()), "key=de..ef end");
430    }
431
432    #[test]
433    fn hex_with_0x_prefix() {
434        let input = "addr 0xdeadbeefcafebabe1234567890abcdef1234567890abcdef end";
435        assert_eq!(sanitize_hex_secrets(input.to_string()), "addr 0xde..ef end");
436    }
437
438    #[test]
439    fn multiple_secrets_redacted() {
440        let a = "a".repeat(32);
441        let b = "b".repeat(32);
442        let input = format!("x={a} y={b}");
443        assert_eq!(sanitize_hex_secrets(input), "x=aa..aa y=bb..bb");
444    }
445
446    #[test]
447    fn exactly_threshold_is_redacted() {
448        let input = "a".repeat(HEX_SECRET_MIN_LEN);
449        assert_eq!(sanitize_hex_secrets(input), "aa..aa");
450    }
451
452    #[test]
453    fn below_threshold_passes() {
454        let input = "a".repeat(HEX_SECRET_MIN_LEN - 1);
455        assert_eq!(sanitize_hex_secrets(input.clone()), input);
456    }
457
458    #[test]
459    fn no_hex_passes_through() {
460        let input = "hello world, no hex here!";
461        assert_eq!(sanitize_hex_secrets(input.to_string()), input);
462    }
463
464    #[test]
465    fn empty_string() {
466        assert_eq!(sanitize_hex_secrets(String::new()), "");
467    }
468
469    #[test]
470    fn uppercase_hex_redacted() {
471        let input = "DEADBEEFCAFEBABE1234567890ABCDEF1234567890ABCDEF";
472        assert_eq!(sanitize_hex_secrets(input.to_string()), "DE..EF");
473    }
474
475    #[test]
476    fn mixed_text_and_hex() {
477        let secret = "f".repeat(64);
478        let input = format!("user=alice secret={secret} action=login");
479        assert_eq!(
480            sanitize_hex_secrets(input),
481            "user=alice secret=ff..ff action=login"
482        );
483    }
484
485    #[test]
486    fn utf8_preserved_alongside_hex_redaction() {
487        let secret = "a".repeat(32);
488        let input = format!("clé={secret} résumé");
489        assert_eq!(sanitize_hex_secrets(input), "clé=aa..aa résumé");
490    }
491
492    #[test]
493    fn multibyte_utf8_no_hex() {
494        let input = "café naïve 日本語".to_string();
495        assert_eq!(sanitize_hex_secrets(input.clone()), input);
496    }
497
498    #[test]
499    fn no_alloc_when_clean() {
500        let input = String::from("no secrets here");
501        let ptr = input.as_ptr();
502        let output = sanitize_hex_secrets(input);
503        assert_eq!(output.as_ptr(), ptr, "should return same allocation");
504    }
505}