feat(ptt): close P0 unit-boundary gaps (SDD-088 / SDD-090 / SDD-091)

The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:

* SDD-088 PttController — backend ownership + binding mutex +
  capability watch lived split between AudioEngine and
  ChanoraSession. Extracted into chanora_core::ptt::PttController.
  AudioEngine now owns only the cpal streams and the missed-key-up
  watchdog (SDD-092); the platform input backend, the active
  PttBinding, and the capability watch::Sender live in the
  controller. ChanoraSession::start_audio constructs the controller
  against the engine's gate; disconnect/reconnect/restart paths
  tear it down through stop().await before the engine.

* SDD-090 PttSanitizer — banned-field check was inlined as
  PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
  into a generic PttSanitizer<L> tracing_subscriber::Layer that
  decorates an inner Layer (canonical pairing:
  RedactingLogLayer::with_sanitizer). The inner layer keeps its
  own structural ban check as defence-in-depth for bare-install
  callers.

* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
  Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
  into a public PttCapabilityBadge widget and added the
  SDD-091-specified per-platform explanation sheet that opens on
  the info-icon tap when the resolved capability is L0Focused.
  New l10n strings (en + zh) cover the sheet copy.

Tests:
  * 2 new unit tests for PttController (arm + descriptor watch)
  * 1 new unit test for PttSanitizer (end-to-end through a real
    tracing subscriber proving banned drop + safe forward)
  cargo test --workspace: 55 passed / 0 failed / 3 ignored
  cargo deny check: advisories ok, bans ok, licenses ok, sources ok
  flutter analyze: no issues
  tools/validate_docs.py: zero undefined refs, zero direct-layer
    violations (pre-existing 35 old-package-name warning unchanged)

No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
This commit is contained in:
EdisonJwa
2026-05-15 17:45:03 +08:00
parent 63f2901a6a
commit 9831624079
10 changed files with 747 additions and 170 deletions
+104 -10
View File
@@ -408,11 +408,19 @@ const PTT_BANNED_FIELDS: &[&str] = &[
/// 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.
/// Per DEC-027 the records that carry raw key data must never reach
/// the in-memory sink. Per SDD-090 that responsibility lives in a
/// separate decorator Layer — [`PttSanitizer`] — so the
/// privacy boundary is its own named software unit. Constructors
/// that want the decorated stack should call
/// [`PttSanitizer::wrap`] (or use `RedactingLogLayer::with_sanitizer`
/// for the canonical pairing).
///
/// `RedactingLogLayer` retains the same structural ban check for
/// callers that install it bare, so existing wiring continues to
/// honour DEC-027 even without `PttSanitizer`. The two checks are
/// idempotent — a sanitiser-wrapped layer never sees a banned
/// record so the inner check is a no-op on that path.
#[derive(Debug, Clone)]
pub struct RedactingLogLayer {
sink: InMemoryLogSink,
@@ -423,6 +431,12 @@ impl RedactingLogLayer {
pub fn new(sink: InMemoryLogSink) -> Self {
Self { sink }
}
/// Convenience: wrap `self` in a [`PttSanitizer`] decorator
/// (SDD-090). Equivalent to `PttSanitizer::wrap(self)`.
pub fn with_sanitizer(self) -> PttSanitizer<Self> {
PttSanitizer::wrap(self)
}
}
impl<S> Layer<S> for RedactingLogLayer
@@ -430,11 +444,10 @@ 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.
// Defence-in-depth: a `RedactingLogLayer` installed without
// a surrounding `PttSanitizer` still drops banned records.
// When wrapped by `PttSanitizer` this check is unreachable
// (the sanitiser short-circuits first).
let mut ban_check = PttBanCheckVisitor::default();
event.record(&mut ban_check);
if ban_check.banned {
@@ -456,6 +469,56 @@ where
fn on_new_span(&self, _: &Attributes<'_>, _: &Id, _: Context<'_, S>) {}
}
/// PTT sanitiser Layer (SAD-077 / SDD-090).
///
/// Decorates an inner `tracing-subscriber::Layer` (typically
/// [`RedactingLogLayer`]). On each `on_event` the sanitiser performs
/// a single allocation-free pass over the event's field set and
/// drops the record if any field name matches the
/// [`PTT_BANNED_FIELDS`] list. Records without banned fields are
/// forwarded verbatim to the inner Layer's `on_event`.
///
/// The implementation is allocation-free on the success path (the
/// typical "no banned field" case): the visitor holds a single
/// `bool` on the stack and exits early once a banned name is seen.
#[derive(Debug, Clone)]
pub struct PttSanitizer<L> {
inner: L,
}
impl<L> PttSanitizer<L> {
/// Wrap an inner Layer with the PTT sanitiser. Use
/// [`RedactingLogLayer::with_sanitizer`] for the canonical
/// pairing.
pub fn wrap(inner: L) -> Self {
Self { inner }
}
/// Borrow the wrapped inner Layer (read-only).
pub fn inner(&self) -> &L {
&self.inner
}
}
impl<S, L> Layer<S> for PttSanitizer<L>
where
S: Subscriber + for<'a> LookupSpan<'a>,
L: Layer<S>,
{
fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
let mut ban_check = PttBanCheckVisitor::default();
event.record(&mut ban_check);
if ban_check.banned {
return;
}
self.inner.on_event(event, ctx);
}
fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
self.inner.on_new_span(attrs, id, ctx);
}
}
/// Lightweight `tracing::field::Visit` implementation that only
/// notes whether any visited field name matches the PTT banned
/// list. Allocation-free.
@@ -698,4 +761,35 @@ mod tests {
assert!(!PTT_BANNED_FIELDS.contains(&safe));
}
}
#[test]
fn ptt_sanitizer_drops_banned_records_before_inner_layer() {
// End-to-end test through a real tracing subscriber: a
// sanitiser-wrapped RedactingLogLayer must drop records
// that name banned fields and must forward records that
// do not. Uses `with_default` so the subscriber is scoped
// to the closure (no global state mutation across tests).
use tracing_subscriber::layer::SubscriberExt;
let secrets = KnownSecretRegistry::default();
let redactor = Redactor::with_secrets(secrets);
let sink = InMemoryLogSink::new(16, redactor);
let inner = RedactingLogLayer::new(sink.clone());
let sanitised = PttSanitizer::wrap(inner);
let subscriber = tracing_subscriber::registry().with(sanitised);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(key_code = 42, "banned record must drop");
tracing::info!(backend_id = "linux-portal", "safe record must pass");
});
let exported =
DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
let text = exported.to_text();
assert!(
!text.contains("banned record must drop"),
"sanitiser must have dropped the banned record"
);
assert!(
text.contains("safe record must pass"),
"sanitiser must forward the safe record to the inner layer"
);
}
}