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
+40
View File
@@ -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<void> _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<void>(
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,