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
Generated
+1
View File
@@ -365,6 +365,7 @@ version = "0.0.1-pre"
dependencies = [
"thiserror 2.0.18",
"tracing",
"tracing-subscriber",
]
[[package]]
+3
View File
@@ -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…",
+3
View File
@@ -11,6 +11,9 @@
"connectAction": "连接",
"disconnectAction": "断开连接",
"refreshAction": "刷新",
"diagnosticsAction": "诊断信息",
"copyAction": "复制",
"closeAction": "关闭",
"startAudioAction": "启动语音",
"pttHoldToTalk": "按住说话",
"pttTransmitting": "正在发送…",
@@ -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:
@@ -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';
@@ -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 => '启动语音';
+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,
+7 -1
View File
@@ -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<void> startAudio() => RustLib.instance.api.crateApiStartAudio();
Future<void> 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).
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
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<BridgeEvent> crateApiEventsStream();
String crateApiExportDiagnostics();
Future<void> crateApiInitStorage({required String dir});
Future<bool> 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<void> 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_,
);
},
+3
View File
@@ -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,
};
+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!(),
}
}
+1
View File
@@ -12,3 +12,4 @@ publish.workspace = true
[dependencies]
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["registry"] }
+530 -11
View File
@@ -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<Mutex<HashSet<String>>>,
}
impl KnownSecretRegistry {
/// Add a known-secret value. Empty strings are ignored.
pub fn register(&self, secret: impl Into<String>) {
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<Mutex<std::collections::VecDeque<String>>>,
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<String> {
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<S> Layer<S> 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<String>,
/// 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<Self, DiagnosticsError> {
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));
}
}