pas_external/audit/sink.rs
1//! `AuditSink` port + `AuditEvent` value type + ship-with adapters
2//! (`NoopAuditSink`, `MemoryAuditSink`).
3//!
4//! See module-level docs in `audit/mod.rs` for the deep-module rationale
5//! (β1 + composition + non-blocking failure-mode contract).
6
7use std::collections::BTreeMap;
8#[cfg(any(test, feature = "test-support"))]
9use std::sync::{Arc, Mutex};
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14
15use super::RateLimitKey;
16
17/// Audit emission port for verify-failure events (M48).
18///
19/// **One method, no `Result`**: M48 is observability, NOT auth-flow
20/// critical. Adapters that fail to persist MUST log internally via
21/// `tracing::error!` and continue — the verify hot path NEVER bubbles
22/// audit failures into the auth contract. The trait surface enforces
23/// this by giving the caller no error to propagate in the first place.
24///
25/// **`&self` not `&mut self`**: a single verifier emits concurrently
26/// across many request handlers; interior mutability lives inside each
27/// adapter (e.g. [`MemoryAuditSink`] uses `Mutex`).
28///
29/// **`Send + Sync` bounds**: required for `Arc<dyn AuditSink>` use in
30/// Axum handlers and tokio tasks.
31#[async_trait]
32pub trait AuditSink: std::fmt::Debug + Send + Sync {
33 async fn record_failure(&self, event: AuditEvent);
34}
35
36/// Single typed event emitted on every `BearerVerifier::verify` rejection.
37///
38/// `kind` drives audit-pivot grouping; `source_id` drives rate-limiting
39/// and per-source dashboards; `metadata` carries free-form context
40/// (engine M-row identifier for `Other`, claim names, etc.).
41///
42/// **Best-effort hint decoding**: `client_id_hint` and `kid_hint` come
43/// from the rejected token's payload/header via defensive base64+JSON
44/// parse. Either may be `None` if the token was malformed; callers MUST
45/// NOT treat absence as a security signal — by definition the token
46/// was rejected, so its claims are untrusted. The hints exist for
47/// grouping, not authentication.
48///
49/// **`source_id` derivation**: per Phase 9 design call (e), source_id
50/// is the compound `client_id_hint ‖ kid_hint` key. Anonymous /
51/// kid-less rejections collapse into a canonical `"anon::nokid"`
52/// bucket so attacker-controlled token mangling can't explode the
53/// bucket count. See [`compose_source_id`] and [`AuditEvent::from_hints`].
54///
55/// All fields are `pub` so adapters can serialize them or pivot on
56/// arbitrary subsets. The canonical construction path is
57/// [`AuditEvent::from_hints`], which guarantees `source_id` matches
58/// the hints. Hand-constructing with mismatched values is technically
59/// possible (and useful for fault-injection in tests) but a code
60/// review concern in production.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct AuditEvent {
63 /// Failure classification — drives audit-pivot grouping.
64 pub kind: VerifyErrorKind,
65 /// Wall-clock at engine reject (UTC, RFC 3339 wire format).
66 #[serde(with = "time::serde::rfc3339")]
67 pub occurred_at: OffsetDateTime,
68 /// Compound `client_id_hint ‖ kid_hint` key for rate-limiting +
69 /// per-source pivot.
70 pub source_id: String,
71 /// Best-effort `client_id` claim from the rejected token's payload.
72 pub client_id_hint: Option<String>,
73 /// Best-effort `kid` from the rejected token's header.
74 pub kid_hint: Option<String>,
75 /// Free-form structured context — engine M-row identifier for
76 /// `Other`, claim names for telemetry, etc. `BTreeMap` (not
77 /// `HashMap`) for deterministic ordering in snapshot tests.
78 pub metadata: BTreeMap<String, serde_json::Value>,
79}
80
81impl AuditEvent {
82 /// Canonical constructor — composes `source_id` from the hints so
83 /// the two never disagree. Production callers (Phase 9.D
84 /// `PasJwtVerifier::verify`) use this.
85 #[must_use]
86 pub fn from_hints(
87 kind: VerifyErrorKind,
88 occurred_at: OffsetDateTime,
89 client_id_hint: Option<String>,
90 kid_hint: Option<String>,
91 metadata: BTreeMap<String, serde_json::Value>,
92 ) -> Self {
93 let source_id = compose_source_id(client_id_hint.as_deref(), kid_hint.as_deref());
94 Self {
95 kind,
96 occurred_at,
97 source_id,
98 client_id_hint,
99 kid_hint,
100 metadata,
101 }
102 }
103
104 /// id_token-specific canonical constructor (Phase 10.11.D, δ2).
105 ///
106 /// Composes the 3-tuple `azp ‖ aud ‖ kid` source key — strongest
107 /// per-source discrimination for log-flood DoS prevention on the
108 /// RP side. azp (when present) is the canonical "authorized party";
109 /// aud may be array (the engine surfaces only the first element to
110 /// the hint pipeline); kid identifies the signing key.
111 ///
112 /// **Field repurpose**: stores `azp_hint` in
113 /// [`Self::client_id_hint`] (the SDK-shaped "authorized party"
114 /// shares semantic with access-token's `client_id`); pushes
115 /// `aud_hint` into `metadata` under the key `"aud_hint"`. Dashboard
116 /// pivots on the access-token side use `client_id_hint` directly;
117 /// id_token pivots use the same field plus the `aud_hint` metadata
118 /// entry.
119 ///
120 /// Production caller: [`crate::oidc::PasIdTokenVerifier::emit_failure`].
121 #[must_use]
122 pub fn from_id_token_hints(
123 kind: VerifyErrorKind,
124 occurred_at: OffsetDateTime,
125 azp_hint: Option<String>,
126 aud_hint: Option<String>,
127 kid_hint: Option<String>,
128 mut metadata: BTreeMap<String, serde_json::Value>,
129 ) -> Self {
130 let source_id = compose_id_token_source_id(
131 azp_hint.as_deref(),
132 aud_hint.as_deref(),
133 kid_hint.as_deref(),
134 );
135 if let Some(aud) = &aud_hint {
136 metadata.insert(
137 "aud_hint".to_owned(),
138 serde_json::Value::String(aud.clone()),
139 );
140 }
141 Self {
142 kind,
143 occurred_at,
144 source_id,
145 client_id_hint: azp_hint,
146 kid_hint,
147 metadata,
148 }
149 }
150
151 /// Per-bucket rate-limit key. By default 1:1 with `source_id`.
152 /// Composing once at construction keeps this O(1).
153 #[must_use]
154 pub fn rate_limit_key(&self) -> RateLimitKey {
155 RateLimitKey::new(self.source_id.clone())
156 }
157}
158
159/// Compose a Phase 9 (e) compound source key from optional hints.
160///
161/// Free function (not a method) because it is referentially transparent:
162/// same inputs → same output, no Self needed. `PasJwtVerifier::verify`
163/// (Phase 9.D) calls this to derive the `source_id` field from a
164/// best-effort token decode; tests use it to construct expected keys
165/// without building an `AuditEvent` first.
166///
167/// Sentinels `"anon"` (missing client_id) and `"nokid"` (missing kid)
168/// collapse anonymous rejections into a single bucket so attacker-
169/// controlled token mangling can't explode bucket count. The `"::"`
170/// separator is a deliberate choice for grep-friendliness ("all
171/// anon::*" surfaces every anonymous bucket as a glob).
172#[must_use]
173pub fn compose_source_id(client_id_hint: Option<&str>, kid_hint: Option<&str>) -> String {
174 let cid = client_id_hint.unwrap_or("anon");
175 let kid = kid_hint.unwrap_or("nokid");
176 format!("{cid}::{kid}")
177}
178
179/// Phase 10.11.D δ2 — id_token compound source key from `azp ‖ aud ‖ kid`.
180///
181/// Sibling of [`compose_source_id`] for the RP-side id_token verify
182/// pipeline. Three components give strongest discrimination for
183/// log-flood DoS prevention: the canonical authorized party (`azp`),
184/// the audience (`aud`, first element when array), and the signing key
185/// (`kid`). Sentinels `"anon"` / `"noaud"` / `"nokid"` collapse
186/// anonymous rejections into canonical buckets.
187///
188/// Used by [`AuditEvent::from_id_token_hints`] to derive `source_id`;
189/// boundary tests reference it to construct expected keys without
190/// building a full event first.
191#[must_use]
192pub fn compose_id_token_source_id(
193 azp_hint: Option<&str>,
194 aud_hint: Option<&str>,
195 kid_hint: Option<&str>,
196) -> String {
197 let azp = azp_hint.unwrap_or("anon");
198 let aud = aud_hint.unwrap_or("noaud");
199 let kid = kid_hint.unwrap_or("nokid");
200 format!("{azp}::{aud}::{kid}")
201}
202
203/// Failure classification — mirrors the
204/// [`VerifyError`](crate::token::VerifyError) and
205/// [`IdVerifyError`](crate::oidc::IdVerifyError) surfaces but lives at
206/// the audit layer.
207///
208/// Stable, low-cardinality enum suitable for audit-dashboard pivots.
209/// `MissingClaim` carries the claim name as `String` (~6 well-known
210/// claims in production: `"aud"`, `"iat"`, `"jti"`, `"sub"`, `"exp"`,
211/// `"client_id"`) — still low-cardinality at the dashboard layer.
212/// The String allocation (vs `&'static str` in
213/// [`VerifyError::MissingClaim`](crate::token::VerifyError::MissingClaim))
214/// is the cost of full serde round-trip support; per-event overhead
215/// is negligible on the rare-failure emission path.
216///
217/// `Other` is a flat catch-all — engine M-row identifier goes into
218/// [`AuditEvent::metadata`] under the key `"engine_msg"` to keep this
219/// enum bounded.
220///
221/// ── id_token nesting (Phase 10.11.B) ───────────────────────────────────
222///
223/// OIDC-specific failure rows (M66-M73 + M29-mirror) live inside
224/// [`IdTokenFailureKind`] under a single nested variant `IdToken(_)`.
225/// The audit-pivot pattern callers use:
226///
227/// ```ignore
228/// match event.kind {
229/// VerifyErrorKind::IdToken(_) => bucket_for("id_token failures"),
230/// VerifyErrorKind::IdTokenAsBearer => bucket_for("M73 misuse"),
231/// VerifyErrorKind::Expired => bucket_for("token expired"),
232/// // ...
233/// }
234/// ```
235///
236/// A single `IdToken(_)` arm filters the entire OIDC failure family —
237/// dashboards do not need to enumerate 14 names. Profile-agnostic JOSE
238/// failures (`Expired`, `IssuerInvalid`, `AudienceInvalid`,
239/// `MissingClaim`, `SignatureInvalid`, `KeysetUnavailable`,
240/// `InvalidFormat`) reuse the existing flat variants — they are shared
241/// between access_token and id_token verify surfaces, and forcing
242/// consumers to write a 2nd match arm for "id_token expired"
243/// distinct from "access_token expired" would noise the call site
244/// without changing the operator action ("token expired, refresh").
245/// The audit log distinguishes the two via [`AuditEvent::source_id`]
246/// composition (compound `azp ‖ aud ‖ kid` for id_token vs
247/// `client_id ‖ kid` for access).
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(tag = "tag", content = "claim")]
250pub enum VerifyErrorKind {
251 InvalidFormat,
252 SignatureInvalid,
253 Expired,
254 IssuerInvalid,
255 AudienceInvalid,
256 MissingClaim(String),
257 KeysetUnavailable,
258 IdTokenAsBearer,
259 /// Phase 10.11.B — nested OIDC-specific failure family. See
260 /// [`IdTokenFailureKind`].
261 IdToken(IdTokenFailureKind),
262 Other,
263}
264
265/// id_token-specific failure classification (Phase 10.11.B).
266///
267/// Mirrors the OIDC-specific variants of
268/// [`IdVerifyError`](crate::oidc::IdVerifyError). Nests inside
269/// [`VerifyErrorKind::IdToken`] so dashboard pivots can filter "all
270/// id_token failures" via a single match arm.
271///
272/// Variants in this enum cover M66-M73 + M29-mirror — JOSE-layer
273/// rejections (Expired, SignatureInvalid, etc.) are *shared* with
274/// access_token and remain on the outer [`VerifyErrorKind`] flat
275/// variants. The boundary mapping (`From<&IdVerifyError> for
276/// VerifyErrorKind`, lands in Phase 10.11.D) routes JOSE rejections to
277/// the flat variants and id_token-specific rejections to
278/// `IdToken(IdTokenFailureKind::*)`.
279///
280/// Two payload-carrying variants:
281///
282/// - `UnknownClaim(String)` — engine M72 carries the offending claim
283/// name (e.g. `"backdoor"`, `"email"` at Openid scope). Audit logs
284/// distinguish forgery (random key) from issuer drift (legitimate
285/// OIDC claim outside scope) by reading the inner string.
286/// - `CatMismatch(String)` — engine M29-mirror carries the offending
287/// `cat` value (e.g. `"access"` for an attacker presenting a forged
288/// id_token; `""` for a stripped-claims forgery; arbitrary string
289/// for bespoke forgery).
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub enum IdTokenFailureKind {
292 /// M66 — `nonce` claim absent.
293 NonceMissing,
294 /// M66 — `nonce` claim present but does not match expected.
295 NonceMismatch,
296 /// M67 — `at_hash` claim absent while access_token binding is configured.
297 AtHashMissing,
298 /// M67 — `at_hash` claim present but does not match.
299 AtHashMismatch,
300 /// M68 — `c_hash` claim absent while authorization_code binding is configured.
301 CHashMissing,
302 /// M68 — `c_hash` claim present but does not match.
303 CHashMismatch,
304 /// M69 — `azp` claim absent on multi-aud id_token.
305 AzpMissing,
306 /// M69 — `azp` claim present but does not equal expected client_id.
307 AzpMismatch,
308 /// M70 — `auth_time` claim absent while max_age is configured.
309 AuthTimeMissing,
310 /// M70 — `now - auth_time > max_age`.
311 AuthTimeStale,
312 /// M71 — `acr` claim absent while acr_values is configured.
313 AcrMissing,
314 /// M71 — `acr` claim present but not in allowlist.
315 AcrNotAllowed,
316 /// M72 — claim name outside per-scope allowlist. Carries name.
317 UnknownClaim(String),
318 /// M29-mirror — `cat` claim value is not `"id"`. Carries the
319 /// offending value.
320 CatMismatch(String),
321}
322
323/// Default sink — explicitly does nothing.
324///
325/// Used when a consumer constructs `PasJwtVerifier::from_jwks_url`
326/// without calling `with_audit`. Named "Noop" (not "Empty" / "Null")
327/// so call sites read as an explicit choice. Cheap to clone; carries
328/// no state.
329#[derive(Debug, Default, Clone)]
330pub struct NoopAuditSink;
331
332#[async_trait]
333impl AuditSink for NoopAuditSink {
334 async fn record_failure(&self, _event: AuditEvent) {}
335}
336
337/// Test-support sink — accumulates events in insertion order behind a
338/// `Mutex`.
339///
340/// Test code calls [`MemoryAuditSink::events`] to assert ordering and
341/// contents. The `Arc<Mutex<Vec<...>>>` shape is deliberate:
342/// [`AuditSink::record_failure`] takes `&self`, so interior mutability
343/// is required; `Mutex` (rather than `RwLock`) is right because every
344/// access is a mutation.
345///
346/// Uses `std::sync::Mutex` rather than `tokio::sync::Mutex` so the
347/// adapter compiles unconditionally — no tokio runtime required when
348/// downstream consumers enable just `test-support` without `oauth` /
349/// `well-known-fetch`. This is safe because no `.await` happens while
350/// the lock is held; the future stays `Send`.
351#[cfg(any(test, feature = "test-support"))]
352#[derive(Debug, Default, Clone)]
353pub struct MemoryAuditSink {
354 events: Arc<Mutex<Vec<AuditEvent>>>,
355}
356
357#[cfg(any(test, feature = "test-support"))]
358impl MemoryAuditSink {
359 #[must_use]
360 pub fn new() -> Self {
361 Self::default()
362 }
363
364 /// Snapshot the recorded events. Returns a clone so the caller
365 /// doesn't hold the lock during assertion logic. `AuditEvent` is
366 /// shallow-cloneable; copying the whole vec is cheap for test
367 /// volumes.
368 pub fn events(&self) -> Vec<AuditEvent> {
369 self.events
370 .lock()
371 .unwrap_or_else(|poisoned| poisoned.into_inner())
372 .clone()
373 }
374
375 /// Convenience: count without cloning the vec.
376 pub fn len(&self) -> usize {
377 self.events
378 .lock()
379 .unwrap_or_else(|poisoned| poisoned.into_inner())
380 .len()
381 }
382
383 pub fn is_empty(&self) -> bool {
384 self.len() == 0
385 }
386}
387
388#[cfg(any(test, feature = "test-support"))]
389#[async_trait]
390impl AuditSink for MemoryAuditSink {
391 async fn record_failure(&self, event: AuditEvent) {
392 let mut events = self
393 .events
394 .lock()
395 .unwrap_or_else(|poisoned| poisoned.into_inner());
396 events.push(event);
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn fixture(kind: VerifyErrorKind, source_id: &str) -> AuditEvent {
405 AuditEvent {
406 kind,
407 occurred_at: OffsetDateTime::UNIX_EPOCH,
408 source_id: source_id.to_owned(),
409 client_id_hint: None,
410 kid_hint: None,
411 metadata: BTreeMap::new(),
412 }
413 }
414
415 #[test]
416 fn compose_source_id_uses_compound_separator() {
417 assert_eq!(
418 compose_source_id(Some("rcw-client"), Some("k1")),
419 "rcw-client::k1"
420 );
421 }
422
423 #[test]
424 fn compose_source_id_collapses_anonymous_into_canonical_bucket() {
425 // Phase 9 (e): missing client_id and missing kid both collapse
426 // to canonical sentinels so attacker-controlled token mangling
427 // can't explode the bucket count.
428 assert_eq!(compose_source_id(None, None), "anon::nokid");
429 }
430
431 #[test]
432 fn compose_id_token_source_id_uses_three_tuple_separator() {
433 // δ2 — `azp ‖ aud ‖ kid`. Three-component key gives strongest
434 // discrimination for RP-side id_token DoS prevention.
435 assert_eq!(
436 compose_id_token_source_id(Some("rp-id"), Some("rp-aud"), Some("k1")),
437 "rp-id::rp-aud::k1"
438 );
439 }
440
441 #[test]
442 fn compose_id_token_source_id_collapses_anonymous_into_canonical() {
443 assert_eq!(
444 compose_id_token_source_id(None, None, None),
445 "anon::noaud::nokid"
446 );
447 }
448
449 #[test]
450 fn compose_id_token_source_id_partial_anonymity() {
451 // azp absent but aud present — common when the IdP omits azp
452 // on a single-aud id_token (OIDC §2 SHOULD, not MUST).
453 assert_eq!(
454 compose_id_token_source_id(None, Some("rp-aud"), Some("k1")),
455 "anon::rp-aud::k1"
456 );
457 // azp present, aud absent (multi-aud token where the engine
458 // failed to surface either element) — degenerate but possible.
459 assert_eq!(
460 compose_id_token_source_id(Some("rp-id"), None, Some("k1")),
461 "rp-id::noaud::k1"
462 );
463 }
464
465 #[test]
466 fn from_id_token_hints_derives_three_tuple_source_id_and_pushes_aud_into_metadata() {
467 let event = AuditEvent::from_id_token_hints(
468 VerifyErrorKind::IdToken(IdTokenFailureKind::NonceMismatch),
469 OffsetDateTime::UNIX_EPOCH,
470 Some("rp-id".to_owned()),
471 Some("rp-aud".to_owned()),
472 Some("k1".to_owned()),
473 BTreeMap::new(),
474 );
475 assert_eq!(event.source_id, "rp-id::rp-aud::k1");
476 assert_eq!(event.client_id_hint.as_deref(), Some("rp-id"));
477 assert_eq!(event.kid_hint.as_deref(), Some("k1"));
478 assert_eq!(
479 event
480 .metadata
481 .get("aud_hint")
482 .and_then(|v| v.as_str()),
483 Some("rp-aud")
484 );
485 }
486
487 #[test]
488 fn compose_source_id_partial_anonymity() {
489 assert_eq!(compose_source_id(None, Some("k1")), "anon::k1");
490 assert_eq!(compose_source_id(Some("rcw"), None), "rcw::nokid");
491 }
492
493 #[test]
494 fn from_hints_derives_source_id_from_hints() {
495 let event = AuditEvent::from_hints(
496 VerifyErrorKind::SignatureInvalid,
497 OffsetDateTime::UNIX_EPOCH,
498 Some("rcw".to_owned()),
499 Some("k1".to_owned()),
500 BTreeMap::new(),
501 );
502 assert_eq!(event.source_id, "rcw::k1");
503 assert_eq!(event.client_id_hint.as_deref(), Some("rcw"));
504 assert_eq!(event.kid_hint.as_deref(), Some("k1"));
505 }
506
507 #[test]
508 fn rate_limit_key_round_trip() {
509 let event = fixture(VerifyErrorKind::SignatureInvalid, "rcw::k1");
510 assert_eq!(event.rate_limit_key().as_str(), "rcw::k1");
511 }
512
513 #[test]
514 #[allow(clippy::expect_used)]
515 fn verify_error_kind_round_trips_through_serde() {
516 let kind = VerifyErrorKind::MissingClaim("aud".to_owned());
517 let json = serde_json::to_string(&kind).expect("serialize");
518 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize");
519 assert_eq!(kind, back);
520 }
521
522 #[test]
523 #[allow(clippy::expect_used)]
524 fn verify_error_kind_id_token_variants_round_trip_through_serde() {
525 // Unit nested variant — wire shape:
526 // {"tag": "IdToken", "claim": "NonceMissing"}.
527 let unit = VerifyErrorKind::IdToken(IdTokenFailureKind::NonceMissing);
528 let json = serde_json::to_string(&unit).expect("serialize unit");
529 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize unit");
530 assert_eq!(unit, back);
531
532 // Payloaded nested variant — wire shape:
533 // {"tag": "IdToken", "claim": {"UnknownClaim": "backdoor"}}.
534 let payload = VerifyErrorKind::IdToken(IdTokenFailureKind::UnknownClaim(
535 "backdoor".to_owned(),
536 ));
537 let json = serde_json::to_string(&payload).expect("serialize payload");
538 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize payload");
539 assert_eq!(payload, back);
540
541 // CatMismatch with empty value — engine signals stripped-claims
542 // forgery this way; round-trip must preserve the empty string.
543 let cat = VerifyErrorKind::IdToken(IdTokenFailureKind::CatMismatch(String::new()));
544 let json = serde_json::to_string(&cat).expect("serialize cat");
545 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize cat");
546 assert_eq!(cat, back);
547 }
548
549 #[tokio::test]
550 async fn noop_sink_is_a_no_op() {
551 let sink = NoopAuditSink;
552 let event = fixture(VerifyErrorKind::Expired, "x");
553 // Contract: returns without panic. Nothing observable to assert.
554 sink.record_failure(event).await;
555 }
556
557 #[tokio::test]
558 async fn memory_sink_records_events_in_insertion_order() {
559 let sink = MemoryAuditSink::new();
560 sink.record_failure(fixture(VerifyErrorKind::Expired, "a"))
561 .await;
562 sink.record_failure(fixture(VerifyErrorKind::SignatureInvalid, "b"))
563 .await;
564 sink.record_failure(fixture(VerifyErrorKind::IdTokenAsBearer, "c"))
565 .await;
566
567 let events = sink.events();
568 assert_eq!(events.len(), 3);
569 assert_eq!(events[0].kind, VerifyErrorKind::Expired);
570 assert_eq!(events[1].kind, VerifyErrorKind::SignatureInvalid);
571 assert_eq!(events[2].kind, VerifyErrorKind::IdTokenAsBearer);
572 assert_eq!(events[0].source_id, "a");
573 }
574
575 #[tokio::test]
576 async fn memory_sink_is_empty_initially() {
577 let sink = MemoryAuditSink::new();
578 assert!(sink.is_empty());
579 sink.record_failure(fixture(VerifyErrorKind::Other, "x"))
580 .await;
581 assert_eq!(sink.len(), 1);
582 }
583
584 /// Compile-time guard: MemoryAuditSink + NoopAuditSink must be
585 /// usable behind `Arc<dyn AuditSink>`. If a future change adds a
586 /// generic method to `AuditSink`, object-safety regresses and this
587 /// fails to compile.
588 #[allow(dead_code)]
589 fn dyn_object_safety() {
590 let _: Arc<dyn AuditSink> = Arc::new(NoopAuditSink);
591 let _: Arc<dyn AuditSink> = Arc::new(MemoryAuditSink::new());
592 }
593}