feat(diagnostics): A.3 — redacted in-memory log sink + user-initiated export
Replaces the diagnostics scaffold with the production redaction policy + a user-initiated export path that satisfies DEC-016 (no automatic uploads). * `chanora_diagnostics::Redactor` applies the six policy rules to every captured log line: `$HOME` paths → `[home]`; IPv4 + IPv6 literals → `[ip]`; email-shaped strings → `[email]`; long base64-ish tokens → `[token]`; substrings registered with `KnownSecretRegistry` → `[REDACTED]`. The registry implements SS-AUD-003 defence-in-depth: storage adapters can register secrets as they cross out of the keyring so an accidental `Debug` print is still scrubbed at write time. * `InMemoryLogSink` is a bounded ring buffer (cap 500 lines in the bridge) that always passes lines through the redactor before storing them. `RedactingLogLayer` plugs it into `tracing- subscriber` alongside the existing logcat / fmt layers. * `DiagnosticExport::from_sink` builds a plaintext blob — already redacted — combining free-form metadata (crate version, target os/arch) with the retained log tail. `bridge::api:: export_diagnostics()` is the Flutter-facing entrypoint (`#[frb(sync)]`). * `bridge_init` now installs the redaction layer on both Android and desktop hosts, switching from the global `fmt::init()` shortcut to a layered `Registry` so the in-memory sink can sit side-by-side with the platform sink. * Flutter adds a bug-report icon to the AppBar; tapping it opens a scrollable monospace dialog with Copy and Close actions. New `diagnosticsAction` / `copyAction` / `closeAction` strings land in `app_en.arb` + `app_zh.arb`. Tests cover the redaction matrix (IPv4, IPv6, email, long tokens, known secret), the ring buffer capacity, and the full `DiagnosticExport::to_text()` round-trip — 9/9 green. Live-verified on Moto G: the dialog rendered a multi-line transcript with `[ip]`, `[token]`, `[home]` substitutions, the metadata block showed `target_os=android` `target_arch=aarch64`, and Copy placed the same text on the clipboard.
This commit is contained in:
@@ -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<chanora_core::InMemoryLogSink> = 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
|
||||
|
||||
@@ -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::SseCodec, _>(
|
||||
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!(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user