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:
EdisonJwa
2026-05-15 15:02:03 +08:00
parent 02ffadfa52
commit 7b21916049
16 changed files with 642 additions and 31 deletions
+121
View File
@@ -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));
}
}
}