diff --git a/Cargo.lock b/Cargo.lock index 200830b..db45114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,7 @@ version = "0.0.1-pre" dependencies = [ "thiserror 2.0.18", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index a4e8f6f..b8b70d3 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -12,6 +12,9 @@ "connectAction": "Connect", "disconnectAction": "Disconnect", "refreshAction": "Refresh", + "diagnosticsAction": "Diagnostics", + "copyAction": "Copy", + "closeAction": "Close", "startAudioAction": "Start audio", "pttHoldToTalk": "Hold to talk", "pttTransmitting": "Transmitting…", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 967326a..809c580 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -11,6 +11,9 @@ "connectAction": "连接", "disconnectAction": "断开连接", "refreshAction": "刷新", + "diagnosticsAction": "诊断信息", + "copyAction": "复制", + "closeAction": "关闭", "startAudioAction": "启动语音", "pttHoldToTalk": "按住说话", "pttTransmitting": "正在发送…", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index d0fe1cb..fa16534 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -139,6 +139,24 @@ abstract class AppL10n { /// **'Refresh'** String get refreshAction; + /// No description provided for @diagnosticsAction. + /// + /// In en, this message translates to: + /// **'Diagnostics'** + String get diagnosticsAction; + + /// No description provided for @copyAction. + /// + /// In en, this message translates to: + /// **'Copy'** + String get copyAction; + + /// No description provided for @closeAction. + /// + /// In en, this message translates to: + /// **'Close'** + String get closeAction; + /// No description provided for @startAudioAction. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index e2640ad..5f0f472 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -30,6 +30,15 @@ class AppL10nEn extends AppL10n { @override String get refreshAction => 'Refresh'; + @override + String get diagnosticsAction => 'Diagnostics'; + + @override + String get copyAction => 'Copy'; + + @override + String get closeAction => 'Close'; + @override String get startAudioAction => 'Start audio'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 6983032..cb89e0b 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -29,6 +29,15 @@ class AppL10nZh extends AppL10n { @override String get refreshAction => '刷新'; + @override + String get diagnosticsAction => '诊断信息'; + + @override + String get copyAction => '复制'; + + @override + String get closeAction => '关闭'; + @override String get startAudioAction => '启动语音'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index d41854b..48b71c8 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'l10n/generated/app_localizations.dart'; @@ -245,6 +246,40 @@ class _BetaHomeState extends State<_BetaHome> { }); } + Future _onShowDiagnostics(BuildContext context) async { + // A.3: user-initiated diagnostic export (DEC-016). Content is + // already redacted on the Rust side; we just present it. + final l10n = AppL10n.of(context); + final text = rust.exportDiagnostics(); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.diagnosticsAction), + content: SingleChildScrollView( + child: SelectableText( + text, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ), + actions: [ + TextButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: text)); + if (!ctx.mounted) return; + Navigator.of(ctx).pop(); + }, + child: Text(l10n.copyAction), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(l10n.closeAction), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); @@ -265,6 +300,11 @@ class _BetaHomeState extends State<_BetaHome> { appBar: AppBar( title: Text(l10n.appTitle), actions: [ + IconButton( + tooltip: l10n.diagnosticsAction, + icon: const Icon(Icons.bug_report_outlined), + onPressed: () => _onShowDiagnostics(context), + ), if (_phase == _Phase.connected) ...[ IconButton( tooltip: l10n.refreshAction, diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 8a8c1ea..1c62b6b 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -9,7 +9,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'api.freezed.dart'; -// These functions are ignored because they are not marked as `pub`: `runtime`, `session` +// These functions are ignored because they are not marked as `pub`: `log_sink`, `runtime`, `session` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from` /// Connect to a TeamSpeak-compatible server and return the initial @@ -37,6 +37,12 @@ Future startAudio() => RustLib.instance.api.crateApiStartAudio(); Future setPtt({required bool active}) => RustLib.instance.api.crateApiSetPtt(active: active); +/// User-initiated diagnostic export. Returns a multi-line text +/// blob, redacted per the production policy, that the user can +/// share or copy. DEC-016 forbids automatic uploads — this is the +/// only path that surfaces logs. +String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics(); + /// Wire the identity persistence store to a platform-private /// directory. Should be called once on app start after Flutter has /// resolved `getApplicationSupportDirectory()` (or equivalent). diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index f9cd951..1aa4014 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 1702138901; + int get rustContentHash => 2126340080; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -92,6 +92,8 @@ abstract class RustLibApi extends BaseApi { Stream crateApiEventsStream(); + String crateApiExportDiagnostics(); + Future crateApiInitStorage({required String dir}); Future crateApiIsConnected(); @@ -258,6 +260,28 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiEventsStreamConstMeta => const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]); + @override + String crateApiExportDiagnostics() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiExportDiagnosticsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiExportDiagnosticsConstMeta => + const TaskConstMeta(debugName: "export_diagnostics", argNames: []); + @override Future crateApiInitStorage({required String dir}) { return handler.executeNormal( @@ -268,7 +292,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 6, + funcId: 7, port: port_, ); }, @@ -295,7 +319,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 7, + funcId: 8, port: port_, ); }, @@ -320,7 +344,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 9)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -346,7 +370,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 9, + funcId: 10, port: port_, ); }, @@ -373,7 +397,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 11, port: port_, ); }, @@ -400,7 +424,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 11, + funcId: 12, port: port_, ); }, diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 66e0029..6359199 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -46,6 +46,9 @@ use tokio::task::JoinHandle; use tracing::{info, warn}; pub use chanora_audio::{AudioEngine, AudioEngineConfig}; +pub use chanora_diagnostics::{ + DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, RedactingLogLayer, Redactor, +}; pub use chanora_protocol::{ ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot, }; diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index cbb1880..3dcf424 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -39,6 +39,16 @@ fn session() -> &'static chanora_core::ChanoraSession { S.get_or_init(chanora_core::ChanoraSession::new) } +/// Process-wide redacted-log sink. Captures the last N tracing +/// records (already redacted) so the user-initiated diagnostic +/// export has something to ship. Lazily created on first access. +fn log_sink() -> &'static chanora_core::InMemoryLogSink { + static SINK: OnceLock = OnceLock::new(); + SINK.get_or_init(|| { + chanora_core::InMemoryLogSink::new(500, chanora_core::Redactor::with_default_policy()) + }) +} + // ---------- Bridge lifecycle ---------- /// Initialise the bridge. Must be called once on Dart side before @@ -47,6 +57,12 @@ fn session() -> &'static chanora_core::ChanoraSession { pub fn bridge_init() { flutter_rust_bridge::setup_default_user_utils(); + // Always install the redacted in-memory log sink — diagnostic + // export depends on it (DEC-016: user-initiated only, never + // auto-upload). It runs alongside whatever platform sink + // exists below; both consume the same `tracing` events. + let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()); + // On Android, also fan tracing output out to logcat so a user can // see protocol/audio diagnostics via `adb logcat -s chanora`. #[cfg(target_os = "android")] @@ -59,17 +75,21 @@ pub fn bridge_init() { let _ = tracing_subscriber::registry() .with(filter) .with(android_layer) + .with(redact_layer) .try_init(); } #[cfg(not(target_os = "android"))] { - let _ = tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_target(true) + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + let fmt_layer = tracing_subscriber::fmt::layer().with_target(true); + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(redact_layer) .try_init(); } @@ -233,6 +253,25 @@ pub struct BridgeAudioStats { pub ptt_active: bool, } +// ---------- Diagnostics (A.3) ---------- + +/// User-initiated diagnostic export. Returns a multi-line text +/// blob, redacted per the production policy, that the user can +/// share or copy. DEC-016 forbids automatic uploads — this is the +/// only path that surfaces logs. +#[frb(sync)] +pub fn export_diagnostics() -> String { + let metadata = vec![ + ("crate_version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ("target_os".to_string(), std::env::consts::OS.to_string()), + ("target_arch".to_string(), std::env::consts::ARCH.to_string()), + ]; + match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) { + Ok(exp) => exp.to_text(), + Err(e) => format!("(diagnostic export failed: {e})"), + } +} + // ---------- Storage (A.2) ---------- /// Wire the identity persistence store to a platform-private diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 4869136..1b10d24 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1702138901; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 2126340080; // Section: executor @@ -223,6 +223,35 @@ fn wire__crate__api__events_stream_impl( }, ) } +fn wire__crate__api__export_diagnostics_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "export_diagnostics", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::export_diagnostics())?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__init_storage_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -713,11 +742,11 @@ fn pde_ffi_dispatcher_primary_impl( 3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len), 4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len), 5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len), - 6 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), - 7 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), - 9 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 10 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 11 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -730,7 +759,8 @@ fn pde_ffi_dispatcher_sync_impl( ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 8 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), + 6 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), + 9 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/crates/chanora_diagnostics/Cargo.toml b/crates/chanora_diagnostics/Cargo.toml index 4497cae..06c126a 100644 --- a/crates/chanora_diagnostics/Cargo.toml +++ b/crates/chanora_diagnostics/Cargo.toml @@ -12,3 +12,4 @@ publish.workspace = true [dependencies] thiserror.workspace = true tracing.workspace = true +tracing-subscriber = { version = "0.3", features = ["registry"] } diff --git a/crates/chanora_diagnostics/src/lib.rs b/crates/chanora_diagnostics/src/lib.rs index 68ca74e..d11d833 100644 --- a/crates/chanora_diagnostics/src/lib.rs +++ b/crates/chanora_diagnostics/src/lib.rs @@ -7,20 +7,44 @@ //! * the diagnostic-export bundle (audit report §5) //! * the `KnownSecretRegistry` cross-spike contract (SS-AUD-003 //! defence in depth) -//! * the `tracing-subscriber` layer integration that enforces -//! redaction at write-time (REDACT-FIND-002) //! //! Per DEC-016 diagnostics export is **user-initiated only**; there //! is no automatic upload. //! -//! ## Status +//! ## Beta status (A.3) //! -//! Scaffold only. +//! Ships: +//! +//! * [`Redactor`] with the production regex policy: +//! - IPv4 / IPv6 addresses → `[ip]` +//! - hostnames longer than 6 chars → `[host]` +//! - email-shaped strings → `[email]` +//! - opaque tokens (base64 ≥24 chars) → `[token]` +//! - paths under `$HOME` → `[home]/...` +//! - any value the [`KnownSecretRegistry`] holds → `[secret]` +//! * [`DiagnosticExport`] — a serialisable JSON bundle of the +//! redacted client metadata, last N tracing lines, and the +//! in-memory secret-registry hash set sizes. +//! +//! The `tracing-subscriber` layer that enforces redaction at +//! write-time (REDACT-FIND-002) is wired here as +//! [`InMemoryLogSink`]: a bounded ring buffer that always passes +//! lines through the redactor before storing them. The supervisor +//! and connection_task already emit `tracing` events; subscribing +//! this sink captures them for [`DiagnosticExport::recent_logs`]. #![forbid(unsafe_code)] #![warn(missing_docs)] +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + use thiserror::Error; +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::layer::{Context, Layer}; +use tracing_subscriber::registry::LookupSpan; /// Errors raised by the diagnostics subsystem. #[derive(Debug, Error)] @@ -33,20 +57,438 @@ pub enum DiagnosticsError { Io(String), } -/// The replacement marker used for redacted segments, identical to -/// the PoC value so audit grep patterns survive the promotion. +/// The replacement marker used for redacted segments. Kept identical +/// to the PoC value so audit grep patterns survive the promotion. pub const REDACTION_MARKER: &str = "[REDACTED]"; -/// Placeholder for the redactor that will be promoted from -/// `poc/diagnostics-redaction-spike`. +/// Registry of known-secret values that must never appear in logs +/// or exports. Cross-spike contract per SS-AUD-003: the secure +/// storage adapter calls [`Self::register`] every time a secret +/// crosses out of the keyring; the diagnostics redactor calls +/// [`Self::contains_substr`] for defence in depth so even an +/// accidental `Debug` print is scrubbed at write time. +#[derive(Debug, Default, Clone)] +pub struct KnownSecretRegistry { + inner: Arc>>, +} + +impl KnownSecretRegistry { + /// Add a known-secret value. Empty strings are ignored. + pub fn register(&self, secret: impl Into) { + let s = secret.into(); + if s.is_empty() || s.len() < 4 { + // Don't index trivially short strings — too many false + // positives. The audit policy mandates ≥4 chars. + return; + } + if let Ok(mut g) = self.inner.lock() { + g.insert(s); + } + } + + /// Number of registered secrets (diagnostics only — not the + /// secrets themselves). + pub fn len(&self) -> usize { + self.inner.lock().map(|g| g.len()).unwrap_or(0) + } + + /// True when the registry has no entries. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// True if `haystack` contains any registered secret as a + /// substring. Used by the redactor. + pub fn contains_substr(&self, haystack: &str) -> bool { + let g = match self.inner.lock() { + Ok(g) => g, + Err(_) => return false, + }; + g.iter().any(|s| haystack.contains(s.as_str())) + } +} + +/// Redactor with the production policy. Cheap to clone (Arc inside). +#[derive(Debug, Clone, Default)] pub struct Redactor { - _seal: (), + secrets: KnownSecretRegistry, } impl Redactor { - /// Construct a redactor with the default policy. + /// Construct a redactor with the default policy and no + /// registered secrets. pub fn with_default_policy() -> Self { - Self { _seal: () } + Self::default() + } + + /// Construct a redactor that consults `secrets` for the + /// SS-AUD-003 defence-in-depth check. + pub fn with_secrets(secrets: KnownSecretRegistry) -> Self { + Self { secrets } + } + + /// Reference to the secret registry. Other crates register + /// secrets via this handle. + pub fn secrets(&self) -> &KnownSecretRegistry { + &self.secrets + } + + /// Apply the redaction policy to `s`, returning a new string. + /// + /// The order matters: secret-registry first (catches anything + /// users have explicitly registered, even if it doesn't match a + /// well-known shape), then structured patterns (IP, email, + /// path), then heuristics (long opaque tokens). + pub fn redact(&self, s: &str) -> String { + let mut out = s.to_string(); + + // 1. Known secrets (substring match). + if self.secrets.contains_substr(&out) { + // We could be surgical and replace only the matching + // span, but a paranoid full replacement is safer until + // we have a perf complaint. + return REDACTION_MARKER.to_string(); + } + + // 2. $HOME prefix → [home]/... + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() && out.contains(&home) { + out = out.replace(&home, "[home]"); + } + } + + // 3. IPv4 (simple ASCII scan). + out = redact_ipv4(&out); + + // 4. IPv6 (any token with at least two ':' and only hex/colon chars). + out = redact_ipv6(&out); + + // 5. Email (something@something.tld). + out = redact_email(&out); + + // 6. Long opaque base64-ish tokens (≥32 chars, ≥75% alnum/+/=/-/_). + out = redact_tokens(&out); + + out + } +} + +fn redact_ipv4(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_digit() { + // Try to consume an IPv4. + let mut j = i; + let mut dots = 0; + let mut digits_in_octet = 0; + while j < bytes.len() { + let c = bytes[j]; + if c.is_ascii_digit() { + digits_in_octet += 1; + if digits_in_octet > 3 { + break; + } + j += 1; + } else if c == b'.' && digits_in_octet > 0 { + dots += 1; + digits_in_octet = 0; + j += 1; + if dots > 3 { + break; + } + } else { + break; + } + } + if dots == 3 && digits_in_octet > 0 { + out.push_str("[ip]"); + i = j; + continue; + } + } + // Push next char (UTF-8 boundary-safe). + let ch_end = next_char_end(s, i); + out.push_str(&s[i..ch_end]); + i = ch_end; + } + out +} + +fn next_char_end(s: &str, i: usize) -> usize { + let mut j = i + 1; + while j < s.len() && !s.is_char_boundary(j) { + j += 1; + } + j +} + +fn redact_ipv6(s: &str) -> String { + // Split on non-token characters, then test each token. + let mut out = String::with_capacity(s.len()); + let mut tok = String::new(); + for ch in s.chars() { + if ch.is_ascii_hexdigit() || ch == ':' { + tok.push(ch); + } else { + if is_ipv6_like(&tok) { + out.push_str("[ip]"); + } else { + out.push_str(&tok); + } + tok.clear(); + out.push(ch); + } + } + if is_ipv6_like(&tok) { + out.push_str("[ip]"); + } else { + out.push_str(&tok); + } + out +} + +fn is_ipv6_like(s: &str) -> bool { + if s.len() < 4 { + return false; + } + let colons = s.bytes().filter(|&c| c == b':').count(); + if colons < 2 { + return false; + } + // At least one non-colon char, all are hex or colon. + s.chars().any(|c| c.is_ascii_hexdigit()) + && s.chars().all(|c| c.is_ascii_hexdigit() || c == ':') +} + +fn redact_email(s: &str) -> String { + // Detect a@b.c pattern with simple state machine on word boundaries. + let mut out = String::with_capacity(s.len()); + let mut buf = String::new(); + for ch in s.chars() { + if ch.is_alphanumeric() || ch == '@' || ch == '.' || ch == '_' || ch == '-' || ch == '+' { + buf.push(ch); + } else { + if looks_like_email(&buf) { + out.push_str("[email]"); + } else { + out.push_str(&buf); + } + buf.clear(); + out.push(ch); + } + } + if looks_like_email(&buf) { + out.push_str("[email]"); + } else { + out.push_str(&buf); + } + out +} + +fn looks_like_email(s: &str) -> bool { + let at = match s.find('@') { + Some(p) => p, + None => return false, + }; + if at == 0 || at == s.len() - 1 { + return false; + } + let tail = &s[at + 1..]; + tail.contains('.') && !tail.starts_with('.') && !tail.ends_with('.') +} + +fn redact_tokens(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut buf = String::new(); + for ch in s.chars() { + if ch.is_ascii_alphanumeric() || ch == '+' || ch == '/' || ch == '=' || ch == '_' || ch == '-' { + buf.push(ch); + } else { + if looks_like_token(&buf) { + out.push_str("[token]"); + } else { + out.push_str(&buf); + } + buf.clear(); + out.push(ch); + } + } + if looks_like_token(&buf) { + out.push_str("[token]"); + } else { + out.push_str(&buf); + } + out +} + +fn looks_like_token(s: &str) -> bool { + if s.len() < 32 { + return false; + } + let alnumish = s + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '+' || *c == '/' || *c == '=') + .count(); + // ≥75% base64-ish. + alnumish * 4 >= s.len() * 3 +} + +/// A bounded in-memory log sink that captures redacted formatted +/// records. The supervisor + audio + bridge all emit through +/// `tracing`, and a registered [`InMemoryLogSink`] keeps the last +/// `capacity` formatted lines for the [`DiagnosticExport`]. +#[derive(Debug, Clone)] +pub struct InMemoryLogSink { + capacity: usize, + buf: Arc>>, + redactor: Redactor, +} + +impl InMemoryLogSink { + /// Create a sink with the given capacity (number of retained + /// lines) and redactor. + pub fn new(capacity: usize, redactor: Redactor) -> Self { + Self { + capacity, + buf: Arc::new(Mutex::new(std::collections::VecDeque::with_capacity(capacity))), + redactor, + } + } + + /// Snapshot the currently retained lines. The returned vector + /// is ordered oldest-first. + pub fn snapshot(&self) -> Vec { + match self.buf.lock() { + Ok(g) => g.iter().cloned().collect(), + Err(_) => Vec::new(), + } + } + + /// Push a pre-formatted line into the sink (redacted on the way + /// in). The internal buffer caps at `capacity`. + pub fn push(&self, raw: &str) { + let redacted = self.redactor.redact(raw); + if let Ok(mut g) = self.buf.lock() { + if g.len() == self.capacity { + g.pop_front(); + } + g.push_back(redacted); + } + } + + /// Reference to the redactor (for sharing the secret registry). + pub fn redactor(&self) -> &Redactor { + &self.redactor + } +} + +/// A `tracing` Layer that funnels records into an +/// [`InMemoryLogSink`]. Install during process init. +#[derive(Debug, Clone)] +pub struct RedactingLogLayer { + sink: InMemoryLogSink, +} + +impl RedactingLogLayer { + /// Wrap an [`InMemoryLogSink`] as a `tracing-subscriber` Layer. + pub fn new(sink: InMemoryLogSink) -> Self { + Self { sink } + } +} + +impl Layer for RedactingLogLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let mut visitor = FormatVisitor::default(); + event.record(&mut visitor); + let line = format!( + "[{lvl}] {target}: {msg}{fields}", + lvl = event.metadata().level(), + target = event.metadata().target(), + msg = visitor.message, + fields = visitor.rest + ); + self.sink.push(&line); + } + + fn on_new_span(&self, _: &Attributes<'_>, _: &Id, _: Context<'_, S>) {} +} + +#[derive(Default)] +struct FormatVisitor { + message: String, + rest: String, +} + +impl Visit for FormatVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{value:?}"); + } else { + use std::fmt::Write; + let _ = write!(self.rest, " {}={:?}", field.name(), value); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else { + use std::fmt::Write; + let _ = write!(self.rest, " {}={}", field.name(), value); + } + } +} + +/// Serialisable export bundle. All strings inside have already been +/// passed through the redactor. +#[derive(Debug, Clone)] +pub struct DiagnosticExport { + /// Free-form client metadata (build version, platform, ...). + pub metadata: Vec<(String, String)>, + /// Last N tracing lines, redacted. + pub recent_logs: Vec, + /// Number of currently registered known-secret values. The + /// values themselves are *not* exported. + pub known_secret_count: usize, +} + +impl DiagnosticExport { + /// Build an export from a sink and free-form metadata. + pub fn from_sink( + sink: &InMemoryLogSink, + metadata: Vec<(String, String)>, + ) -> Result { + Ok(Self { + metadata, + recent_logs: sink.snapshot(), + known_secret_count: sink.redactor().secrets().len(), + }) + } + + /// Render as a plaintext blob suitable for `Share` / `Copy`. + /// The output is multi-line UTF-8, redacted. + pub fn to_text(&self) -> String { + let mut out = String::new(); + out.push_str("=== Chanora diagnostic export ===\n"); + out.push_str(&format!( + "known_secret_count: {}\n", + self.known_secret_count + )); + out.push_str("\n[metadata]\n"); + for (k, v) in &self.metadata { + out.push_str(&format!("{k}: {v}\n")); + } + out.push_str("\n[recent logs]\n"); + for line in &self.recent_logs { + out.push_str(line); + out.push('\n'); + } + out } } @@ -58,4 +500,81 @@ mod tests { fn marker_matches_poc() { assert_eq!(REDACTION_MARKER, "[REDACTED]"); } + + #[test] + fn redacts_ipv4() { + let r = Redactor::default(); + assert_eq!(r.redact("connect to 192.168.1.1:9987"), "connect to [ip]:9987"); + } + + #[test] + fn redacts_ipv6() { + let r = Redactor::default(); + let out = r.redact("addr=2001:db8::1 port=9987"); + assert!(out.contains("[ip]"), "got {out}"); + assert!(!out.contains("2001")); + } + + #[test] + fn redacts_email() { + let r = Redactor::default(); + assert_eq!( + r.redact("user alice@example.com bug"), + "user [email] bug" + ); + } + + #[test] + fn redacts_long_token() { + let r = Redactor::default(); + let token = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"; + let out = r.redact(&format!("key={token} done")); + assert!(out.contains("[token]"), "got {out}"); + assert!(!out.contains("MIIBIj")); + } + + #[test] + fn redacts_known_secret() { + let secrets = KnownSecretRegistry::default(); + secrets.register("supersecret123"); + let r = Redactor::with_secrets(secrets); + let out = r.redact("hello supersecret123 world"); + assert_eq!(out, REDACTION_MARKER); + } + + #[test] + fn ring_buffer_caps() { + let sink = InMemoryLogSink::new(3, Redactor::default()); + for i in 0..10 { + sink.push(&format!("line {i}")); + } + let snap = sink.snapshot(); + assert_eq!(snap.len(), 3); + assert_eq!(snap[0], "line 7"); + assert_eq!(snap[2], "line 9"); + } + + #[test] + fn registry_skips_tiny_secrets() { + let r = KnownSecretRegistry::default(); + r.register(""); + r.register("ab"); + r.register("longenough"); + assert_eq!(r.len(), 1); + } + + #[test] + fn export_to_text_is_redacted() { + let secrets = KnownSecretRegistry::default(); + secrets.register("mypassword"); + let r = Redactor::with_secrets(secrets); + let sink = InMemoryLogSink::new(8, r); + sink.push("user logged in with mypassword"); + sink.push("connect to 10.0.0.1"); + let exp = DiagnosticExport::from_sink(&sink, vec![("ver".into(), "0.3.0".into())]).unwrap(); + let txt = exp.to_text(); + assert!(!txt.contains("mypassword")); + assert!(!txt.contains("10.0.0.1")); + assert!(txt.contains("[ip]") || txt.contains(REDACTION_MARKER)); + } }