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:
Generated
+1
@@ -365,6 +365,7 @@ version = "0.0.1-pre"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 => '启动语音';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,4 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user