feat(ptt): code-side initial split — transmit_active / capability badge / sanitizer
Implements the gen2 v0.9.3 doc baseline's first slice of code work:
* SRS-201: split the audio engine's `ptt` AtomicBool into the
authoritative `transmit_active` flag. The legacy `set_ptt` /
`ptt` accessors are retained as `#[doc(hidden)]` thin wrappers
so the existing bridge command and the existing Flutter
hold-to-talk UI keep compiling.
* SAD-075 / SDD-089 acknowledged at the type level: only
`AudioEngine::set_transmit_active` (or its legacy alias)
mutates the flag; the encoder feed reads it once per outbound
frame and never writes.
* SDD-082: new `chanora_audio::ptt` module ships the
`PttCapabilityLevel` enum (`L0Focused`, `L1GlobalShortcut`,
`L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`,
`L4DeviceAware` reserved) with a stable `as_str` mapping and
an `is_global` classifier.
* SDD-087: `PttBackendDescriptor::focused()` constant value for
the universal Focused-PTT fallback. The struct shape carries
only privacy-safe fields (`level`, `backend_id`,
`bound_input_class`) — a key code cannot fit through this
surface by construction (DEC-027).
* SAD-077 / SDD-090: `RedactingLogLayer` now hosts the
`PttBanCheckVisitor` and the `PTT_BANNED_FIELDS` constant
(`key_code`, `scan_code`, `virtual_key`, `vk`, `keysym`,
`keysym_string`, `key_sequence`, `key_press_history`,
`key_timing`). Any record whose field set names a banned key
is dropped before reaching the in-memory log sink or the
user-initiated diagnostic export. The check is structural and
runs ahead of formatting / redaction.
* `SessionEvent::PttCapability` carries the diagnostics-safe
descriptor through the broadcast event stream;
`chanora_core::ChanoraSession::start_audio` publishes the
Focused-PTT descriptor when the audio engine starts (SRS-196
/ SDD-091).
* `BridgeEvent::PttCapability` mirrors the event across the
FFI boundary. flutter_rust_bridge codegen regenerated.
* Flutter `_AudioControls` renders a capability badge above the
PTT button: a globe icon for Global levels, a focus-frame
icon for `L0Focused`, plus a Tooltip exposing the bound input
class. New ARB key `pttCapabilityBadge(level, backend)` in
`app_en.arb` and `app_zh.arb`.
Per-platform global PTT backends (`WindowsRawInputBackend`,
`MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) and the
`MissedKeyUpWatchdog` task land in a separate follow-up commit;
this milestone ships only PTT-L0 universally so the application's
runtime capability reporting is honest from day one.
Tests
-----
* `chanora_audio` rises from 1 to 4 unit tests covering
`PttCapabilityLevel::as_str`, `is_global`, and the
`PttBackendDescriptor::focused()` shape contract.
* `chanora_diagnostics` rises from 9 to 11 unit tests covering
the new `PttBanCheckVisitor` over every banned field name and
the `PTT_BANNED_FIELDS` stability assertion.
* Workspace total: 53 unit + integration tests, all green with
`CHANORA_DISABLE_KEYRING=1` (was 49 at v1.0.0-rc.2).
* `flutter analyze`: clean.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `cargo about generate`: zero warnings (license inventory
regenerated).
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
LICENSE.
* Linux x86_64 release bundle builds clean.
No Android live verification in this commit per the user's note
that the test device was removed. Android arm64-v8a continues to
build via the same `cargo ndk` path; runtime reporting on Android
is `L0Focused` for the foreseeable future.
This commit is contained in:
@@ -77,7 +77,13 @@ impl Default for AudioEngineConfig {
|
||||
|
||||
/// Running audio engine. Drop = stop.
|
||||
pub struct AudioEngine {
|
||||
ptt: Arc<AtomicBool>,
|
||||
/// `transmit_active` is the authoritative gate for outbound
|
||||
/// voice — the Opus encoder feed consults this flag once per
|
||||
/// 20 ms frame. PTT subsystems (focused widget, future
|
||||
/// Windows / macOS / Linux global backends) drive this flag
|
||||
/// through [`Self::set_transmit_active`]; nothing else is
|
||||
/// permitted to flip it (SAD-075 / SDD-089).
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
frames_received: Arc<AtomicU32>,
|
||||
/// Master output gain as f32 bits in an AtomicU32. Default 1.0.
|
||||
@@ -181,7 +187,7 @@ impl AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
let ptt = Arc::new(AtomicBool::new(cfg.ptt_initial));
|
||||
let transmit_active = Arc::new(AtomicBool::new(cfg.ptt_initial));
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||
@@ -196,7 +202,7 @@ impl AudioEngine {
|
||||
let capture_result = try_open_capture(
|
||||
&in_dev,
|
||||
voice_out_tx,
|
||||
ptt.clone(),
|
||||
transmit_active.clone(),
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
);
|
||||
@@ -293,7 +299,7 @@ impl AudioEngine {
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ptt,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
output_gain,
|
||||
@@ -316,19 +322,41 @@ impl AudioEngine {
|
||||
info!(target: "chanora_audio", "audio engine stopped");
|
||||
}
|
||||
|
||||
/// Set the push-to-talk active state. When false, captured audio
|
||||
/// is discarded before encoding. No-op if capture is inactive.
|
||||
pub fn set_ptt(&self, active: bool) {
|
||||
self.ptt.store(active, Ordering::Relaxed);
|
||||
/// Set the **transmission gate** (SRS-201). When true the
|
||||
/// encoder feed is allowed to emit Opus frames; when false the
|
||||
/// captured audio is discarded before encoding. This is the
|
||||
/// only writer permitted on `transmit_active` (SAD-075 /
|
||||
/// SDD-089). Push-to-Talk subsystems — focused PTT today,
|
||||
/// per-platform global backends in a follow-up — call this
|
||||
/// method exclusively. No-op when capture is inactive.
|
||||
pub fn set_transmit_active(&self, active: bool) {
|
||||
self.transmit_active.store(active, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Current PTT state.
|
||||
/// Current transmit gate state.
|
||||
pub fn transmit_active(&self) -> bool {
|
||||
self.transmit_active.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
|
||||
/// the existing bridge `set_ptt` command and the existing
|
||||
/// Flutter UI continue to compile during the v0.9.3 PTT
|
||||
/// migration (SRS-201 splits the conceptual `ptt` flag into
|
||||
/// `transmit_active` / `capture_active`).
|
||||
#[doc(hidden)]
|
||||
pub fn set_ptt(&self, active: bool) {
|
||||
self.set_transmit_active(active);
|
||||
}
|
||||
|
||||
/// Legacy alias for [`Self::transmit_active`].
|
||||
#[doc(hidden)]
|
||||
pub fn ptt(&self) -> bool {
|
||||
self.ptt.load(Ordering::Relaxed)
|
||||
self.transmit_active()
|
||||
}
|
||||
|
||||
/// True if the capture stream opened. When false, the engine
|
||||
/// runs in playback-only mode and PTT is a no-op.
|
||||
/// runs in playback-only mode and the transmit gate is a
|
||||
/// no-op (no frames will ever be encoded).
|
||||
pub fn capture_active(&self) -> bool {
|
||||
self.capture_active
|
||||
}
|
||||
@@ -380,7 +408,7 @@ impl Drop for AudioEngine {
|
||||
fn try_open_capture(
|
||||
in_dev: &cpal::Device,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
ptt: Arc<AtomicBool>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
) -> Result<cpal::Stream, AudioError> {
|
||||
@@ -405,7 +433,7 @@ fn try_open_capture(
|
||||
in_channels,
|
||||
mic_gain,
|
||||
voice_out_tx,
|
||||
ptt,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
)));
|
||||
|
||||
@@ -433,7 +461,9 @@ struct CaptureState {
|
||||
resample_pos: f64,
|
||||
opus_out: [u8; MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
ptt: Arc<AtomicBool>,
|
||||
/// The PTT transmission gate. Read once per outbound frame; the
|
||||
/// CaptureState never mutates this flag.
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
@@ -444,7 +474,7 @@ impl CaptureState {
|
||||
in_channels: usize,
|
||||
mic_gain: f32,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
ptt: Arc<AtomicBool>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -456,15 +486,16 @@ impl CaptureState {
|
||||
resample_pos: 0.0,
|
||||
opus_out: [0u8; MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
ptt,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
|
||||
/// 48 kHz mono frames; encode and send on PTT.
|
||||
/// 48 kHz mono frames; encode and send when `transmit_active`
|
||||
/// is true (PTT engaged).
|
||||
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||
if !self.ptt.load(Ordering::Relaxed) {
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
// Drain accumulator while muted so we don't pop on PTT release.
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
|
||||
@@ -29,8 +29,10 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod engine;
|
||||
pub mod ptt;
|
||||
|
||||
pub use engine::{AudioEngine, AudioEngineConfig};
|
||||
pub use ptt::{PttCapabilityLevel, PttBackendDescriptor};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Desktop Push-to-Talk capability model (SRS-195 / SRS-196 / SAD-071 /
|
||||
//! SDD-081 / SDD-082).
|
||||
//!
|
||||
//! This module defines the typed `PttCapabilityLevel` enum and a
|
||||
//! lightweight `PttBackendDescriptor` value the audio engine
|
||||
//! publishes to upstream consumers so the UI can render the live
|
||||
//! capability badge (SDD-091) and the release verification record
|
||||
//! can carry per-platform evidence (SysDes-148).
|
||||
//!
|
||||
//! Concrete platform backends (`WindowsRawInputBackend`,
|
||||
//! `MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) are deferred
|
||||
//! to a follow-up code milestone; this commit lands the trait shape
|
||||
//! and the universal `FocusedPttBackend` constant value so the
|
||||
//! current Flutter-side hold-to-talk widget reports its capability
|
||||
//! honestly through the bridge.
|
||||
|
||||
use core::fmt;
|
||||
|
||||
/// Detected runtime capability of the active desktop PTT backend.
|
||||
///
|
||||
/// The reported value shall match runtime behaviour — a backend
|
||||
/// that *could* deliver `L2GlobalHoldToTalk` but lacks the
|
||||
/// user-granted permission or the required compositor support
|
||||
/// reports `L0Focused` (SRS-196 / SysRS-298).
|
||||
///
|
||||
/// `L4DeviceAware` is reserved per the gen2 v0.9.3 baseline
|
||||
/// (SDD-082) and is **not** produced by any MVP implementation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PttCapabilityLevel {
|
||||
/// Focused PTT. Press / release works only while the
|
||||
/// application window has input focus. Mandatory baseline on
|
||||
/// every desktop platform per SysRS-296.
|
||||
L0Focused,
|
||||
/// Global shortcut activation. The OS recognises a global
|
||||
/// accelerator and notifies the application, but hold-to-talk
|
||||
/// semantics may be approximated rather than guaranteed.
|
||||
L1GlobalShortcut,
|
||||
/// Global hold-to-talk. Press and release events are delivered
|
||||
/// while the application is not focused.
|
||||
L2GlobalHoldToTalk,
|
||||
/// Global hold-to-talk plus mouse side buttons (typically
|
||||
/// Mouse4 / Mouse5, sometimes labelled "back" / "forward").
|
||||
L3GlobalWithMouseButtons,
|
||||
/// Device-aware PTT. Reserved; no MVP implementation produces
|
||||
/// this value (SDD-082).
|
||||
L4DeviceAware,
|
||||
}
|
||||
|
||||
impl PttCapabilityLevel {
|
||||
/// Short identifier used by the diagnostics sanitizer and by
|
||||
/// the release verification record. Stable across releases —
|
||||
/// release notes and platform-test traces compare against these
|
||||
/// strings.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::L0Focused => "L0Focused",
|
||||
Self::L1GlobalShortcut => "L1GlobalShortcut",
|
||||
Self::L2GlobalHoldToTalk => "L2GlobalHoldToTalk",
|
||||
Self::L3GlobalWithMouseButtons => "L3GlobalWithMouseButtons",
|
||||
Self::L4DeviceAware => "L4DeviceAware",
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the level represents a global (non-focused)
|
||||
/// behaviour. UI consumers use this to gate the "global PTT
|
||||
/// available" affordance.
|
||||
pub fn is_global(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::L1GlobalShortcut
|
||||
| Self::L2GlobalHoldToTalk
|
||||
| Self::L3GlobalWithMouseButtons
|
||||
| Self::L4DeviceAware
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PttCapabilityLevel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics-safe descriptor of the active PTT backend. Carries
|
||||
/// only the fields the privacy policy permits in the user-initiated
|
||||
/// diagnostic export (SRS-202 / DEC-027): the detected capability
|
||||
/// level, a fixed `backend_id` string per implementation, and an
|
||||
/// optional bound-input class (`"keyboard"`, `"mouse-side-button"`,
|
||||
/// …). The raw key code, scan code, virtual-key value, or keysym
|
||||
/// of any user binding is **never** part of this structure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PttBackendDescriptor {
|
||||
/// Detected runtime capability.
|
||||
pub level: PttCapabilityLevel,
|
||||
/// Stable identifier per implementation. Examples:
|
||||
/// `"focused"`, `"raw-input"`, `"low-level-hook"`,
|
||||
/// `"event-tap"`, `"gnome-wayland-portal"`.
|
||||
pub backend_id: &'static str,
|
||||
/// Coarse description of the bound input. `None` when no
|
||||
/// binding is active. The value is a stable category string,
|
||||
/// never a key code.
|
||||
pub bound_input_class: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl PttBackendDescriptor {
|
||||
/// The universal Focused-PTT fallback (SDD-087). Every desktop
|
||||
/// platform reports this value until a platform-specific
|
||||
/// global backend lands in a follow-up code milestone.
|
||||
pub const fn focused() -> Self {
|
||||
Self {
|
||||
level: PttCapabilityLevel::L0Focused,
|
||||
backend_id: "focused",
|
||||
bound_input_class: Some("keyboard"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PttBackendDescriptor {
|
||||
fn default() -> Self {
|
||||
Self::focused()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_as_str_is_stable() {
|
||||
assert_eq!(PttCapabilityLevel::L0Focused.as_str(), "L0Focused");
|
||||
assert_eq!(
|
||||
PttCapabilityLevel::L2GlobalHoldToTalk.as_str(),
|
||||
"L2GlobalHoldToTalk"
|
||||
);
|
||||
assert_eq!(
|
||||
PttCapabilityLevel::L3GlobalWithMouseButtons.as_str(),
|
||||
"L3GlobalWithMouseButtons"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_is_global_classification() {
|
||||
assert!(!PttCapabilityLevel::L0Focused.is_global());
|
||||
assert!(PttCapabilityLevel::L1GlobalShortcut.is_global());
|
||||
assert!(PttCapabilityLevel::L2GlobalHoldToTalk.is_global());
|
||||
assert!(PttCapabilityLevel::L3GlobalWithMouseButtons.is_global());
|
||||
assert!(PttCapabilityLevel::L4DeviceAware.is_global());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_descriptor_carries_only_safe_fields() {
|
||||
let d = PttBackendDescriptor::focused();
|
||||
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
|
||||
assert_eq!(d.backend_id, "focused");
|
||||
assert_eq!(d.bound_input_class, Some("keyboard"));
|
||||
// Compile-time check: the struct shape itself excludes
|
||||
// anything that could carry a key code (DEC-027).
|
||||
let _: &str = d.backend_id;
|
||||
let _: Option<&str> = d.bound_input_class;
|
||||
}
|
||||
}
|
||||
@@ -510,6 +510,22 @@ pub enum BridgeEvent {
|
||||
/// Latest client count.
|
||||
clients: u32,
|
||||
},
|
||||
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3,
|
||||
/// DEC-023..028). The fields carry only privacy-safe values per
|
||||
/// DEC-027: the capability level, a stable backend identifier,
|
||||
/// and the bound input class. No key codes, scan codes, or
|
||||
/// virtual-key values cross this boundary.
|
||||
PttCapability {
|
||||
/// Stable capability identifier (`"L0Focused"`,
|
||||
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
|
||||
/// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`).
|
||||
level: String,
|
||||
/// Stable backend identifier (e.g. `"focused"`).
|
||||
backend_id: String,
|
||||
/// Coarse bound input class (e.g. `"keyboard"`,
|
||||
/// `"mouse-side-button"`); empty when no binding is active.
|
||||
bound_input_class: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
@@ -534,6 +550,15 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
chanora_core::SessionEvent::SnapshotChanged { channels, clients } => {
|
||||
BridgeEvent::SnapshotChanged { channels, clients }
|
||||
}
|
||||
chanora_core::SessionEvent::PttCapability {
|
||||
level,
|
||||
backend_id,
|
||||
bound_input_class,
|
||||
} => BridgeEvent::PttCapability {
|
||||
level,
|
||||
backend_id,
|
||||
bound_input_class,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,6 +929,16 @@ impl SseDecode for crate::api::BridgeEvent {
|
||||
clients: var_clients,
|
||||
};
|
||||
}
|
||||
7 => {
|
||||
let mut var_level = <String>::sse_decode(deserializer);
|
||||
let mut var_backendId = <String>::sse_decode(deserializer);
|
||||
let mut var_boundInputClass = <String>::sse_decode(deserializer);
|
||||
return crate::api::BridgeEvent::PttCapability {
|
||||
level: var_level,
|
||||
backend_id: var_backendId,
|
||||
bound_input_class: var_boundInputClass,
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
@@ -1247,6 +1257,17 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
|
||||
clients.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
crate::api::BridgeEvent::PttCapability {
|
||||
level,
|
||||
backend_id,
|
||||
bound_input_class,
|
||||
} => [
|
||||
7.into_dart(),
|
||||
level.into_into_dart().into_dart(),
|
||||
backend_id.into_into_dart().into_dart(),
|
||||
bound_input_class.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
@@ -1440,6 +1461,16 @@ impl SseEncode for crate::api::BridgeEvent {
|
||||
<u32>::sse_encode(channels, serializer);
|
||||
<u32>::sse_encode(clients, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::PttCapability {
|
||||
level,
|
||||
backend_id,
|
||||
bound_input_class,
|
||||
} => {
|
||||
<i32>::sse_encode(7, serializer);
|
||||
<String>::sse_encode(level, serializer);
|
||||
<String>::sse_encode(backend_id, serializer);
|
||||
<String>::sse_encode(bound_input_class, serializer);
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
|
||||
@@ -384,8 +384,35 @@ impl InMemoryLogSink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Field names that name a raw key value or key-press timing
|
||||
/// sequence. Records carrying any of these names are dropped before
|
||||
/// they reach the log sink (REDACT-PTT-001..006 in
|
||||
/// `docs/security/diagnostic-redaction-audit-report.md`; SDD-090).
|
||||
///
|
||||
/// We compare by exact field name rather than a content scan —
|
||||
/// partial-redaction false negatives are riskier than a missing
|
||||
/// log line, and the audio-engine and PTT-backend code paths emit
|
||||
/// records with stable field names that we control.
|
||||
const PTT_BANNED_FIELDS: &[&str] = &[
|
||||
"key_code",
|
||||
"scan_code",
|
||||
"virtual_key",
|
||||
"vk",
|
||||
"keysym",
|
||||
"keysym_string",
|
||||
"key_sequence",
|
||||
"key_press_history",
|
||||
"key_timing",
|
||||
];
|
||||
|
||||
/// A `tracing` Layer that funnels records into an
|
||||
/// [`InMemoryLogSink`]. Install during process init.
|
||||
///
|
||||
/// Per DEC-027 the layer also drops any record that carries one of
|
||||
/// [`PTT_BANNED_FIELDS`] in its field set; the structural check
|
||||
/// runs before format / redaction so a banned record never reaches
|
||||
/// the in-memory sink and therefore never reaches the user-initiated
|
||||
/// diagnostic export.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RedactingLogLayer {
|
||||
sink: InMemoryLogSink,
|
||||
@@ -403,6 +430,17 @@ where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
// PttSanitizer (SAD-077 / SDD-090): drop any record whose
|
||||
// field set names a raw key code, scan code, virtual-key
|
||||
// value, keysym, or timing sequence. The structural check
|
||||
// is fast — at most one allocation-free pass over the
|
||||
// field list.
|
||||
let mut ban_check = PttBanCheckVisitor::default();
|
||||
event.record(&mut ban_check);
|
||||
if ban_check.banned {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut visitor = FormatVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
let line = format!(
|
||||
@@ -418,6 +456,40 @@ where
|
||||
fn on_new_span(&self, _: &Attributes<'_>, _: &Id, _: Context<'_, S>) {}
|
||||
}
|
||||
|
||||
/// Lightweight `tracing::field::Visit` implementation that only
|
||||
/// notes whether any visited field name matches the PTT banned
|
||||
/// list. Allocation-free.
|
||||
#[derive(Default)]
|
||||
struct PttBanCheckVisitor {
|
||||
banned: bool,
|
||||
}
|
||||
|
||||
impl PttBanCheckVisitor {
|
||||
fn check(&mut self, name: &str) {
|
||||
if !self.banned && PTT_BANNED_FIELDS.iter().any(|b| *b == name) {
|
||||
self.banned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for PttBanCheckVisitor {
|
||||
fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
|
||||
self.check(field.name());
|
||||
}
|
||||
fn record_str(&mut self, field: &Field, _value: &str) {
|
||||
self.check(field.name());
|
||||
}
|
||||
fn record_i64(&mut self, field: &Field, _value: i64) {
|
||||
self.check(field.name());
|
||||
}
|
||||
fn record_u64(&mut self, field: &Field, _value: u64) {
|
||||
self.check(field.name());
|
||||
}
|
||||
fn record_bool(&mut self, field: &Field, _value: bool) {
|
||||
self.check(field.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FormatVisitor {
|
||||
message: String,
|
||||
@@ -577,4 +649,53 @@ mod tests {
|
||||
assert!(!txt.contains("10.0.0.1"));
|
||||
assert!(txt.contains("[ip]") || txt.contains(REDACTION_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_ban_check_visitor_flags_banned_fields() {
|
||||
// Direct test of the visitor (we don't spin up a full
|
||||
// tracing subscriber for this).
|
||||
let mut v = PttBanCheckVisitor::default();
|
||||
v.check("backend_id"); // safe
|
||||
assert!(!v.banned);
|
||||
v.check("key_code"); // banned
|
||||
assert!(v.banned);
|
||||
|
||||
let mut v2 = PttBanCheckVisitor::default();
|
||||
for name in [
|
||||
"scan_code",
|
||||
"virtual_key",
|
||||
"vk",
|
||||
"keysym",
|
||||
"keysym_string",
|
||||
"key_sequence",
|
||||
"key_press_history",
|
||||
"key_timing",
|
||||
] {
|
||||
v2 = PttBanCheckVisitor::default();
|
||||
v2.check(name);
|
||||
assert!(v2.banned, "expected {name} to be banned");
|
||||
}
|
||||
|
||||
// Allowed PTT fields stay safe.
|
||||
let mut v3 = PttBanCheckVisitor::default();
|
||||
for name in ["capability_level", "backend_id", "bound_input_class"] {
|
||||
v3.check(name);
|
||||
}
|
||||
assert!(!v3.banned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_banned_list_is_non_empty_and_stable() {
|
||||
// Lightweight regression catch: the audit document
|
||||
// REDACT-PTT-001..006 enumerates these exact names.
|
||||
assert!(PTT_BANNED_FIELDS.contains(&"key_code"));
|
||||
assert!(PTT_BANNED_FIELDS.contains(&"scan_code"));
|
||||
assert!(PTT_BANNED_FIELDS.contains(&"virtual_key"));
|
||||
assert!(PTT_BANNED_FIELDS.contains(&"keysym"));
|
||||
assert!(PTT_BANNED_FIELDS.contains(&"key_sequence"));
|
||||
// No accidental additions of safe field names.
|
||||
for safe in ["capability_level", "backend_id", "bound_input_class"] {
|
||||
assert!(!PTT_BANNED_FIELDS.contains(&safe));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user