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
+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!(),
}
}