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:
EdisonJwa
2026-05-15 01:26:49 +08:00
parent 71ecb83781
commit d2d9ba0a5b
14 changed files with 737 additions and 32 deletions
+45 -6
View File
@@ -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
+37 -7
View File
@@ -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!(),
}
}