Skip to main content

azul_layout/managers/
biometric.rs

1//! Biometric manager — cross-platform state for the biometric-auth
2//! surface (`SUPER_PLAN_2` §1 feature 4 + research/02).
3//!
4//! **Request-driven**, unlike the continuous `GeolocationManager`. The
5//! three callers are:
6//!
7//! - A **callback** invokes `App::request_biometric_auth(prompt)` (e.g.
8//!   the `AzulVault` unlock button). The OS draws its own modal sheet; the
9//!   app cannot skin it.
10//!
11//! - The **platform backend** (`dll/src/desktop/extra/biometric/<plat>.rs`)
12//!   shows the prompt (iOS / macOS `LAContext.evaluatePolicy`, Android
13//!   `BiometricPrompt.authenticate`, Windows `UserConsentVerifier`, Linux
14//!   polkit / PAM) and, when the user responds, parks the outcome in the
15//!   async result channel [`push_biometric_result`]. It also writes the
16//!   sync availability probe via [`BiometricManager::set_availability`].
17//!
18//! - The dll **layout pass** drains the channel once per frame via
19//!   [`drain_biometric_results`] and applies the latest through
20//!   [`BiometricManager::set_last_result`]; callbacks then read it with
21//!   `CallbackInfo::get_biometric_result()` and the device capability via
22//!   the sync availability accessor.
23//!
24//! No platform deps (`SUPER_PLAN_2` §0.5); the async-result channel is
25//! copied verbatim from `geolocation.rs`.
26
27use alloc::vec::Vec;
28
29use azul_core::dom::DomNodeId;
30use azul_core::events::{
31    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
32};
33use azul_core::task::Instant;
34
35// `BiometricKind` / `BiometricResult` / `BiometricPrompt` live in
36// `azul-core` so the request config can cross the FFI without a cyclic
37// dep on `azul-layout`. Re-exported here for the existing
38// `azul_layout::managers::biometric::*` import paths.
39pub use azul_core::biometric::{BiometricKind, BiometricPrompt, BiometricResult};
40
41/// Cross-platform biometric state. One per `App` — the OS exposes a
42/// single per-process authentication surface, not per-window.
43#[derive(Copy, Debug, Clone, PartialEq, Eq)]
44pub struct BiometricManager {
45    /// Outcome of the most recent `request_biometric_auth`, or `None`
46    /// until the first request completes. Read by callbacks via
47    /// `CallbackInfo::get_biometric_result()`.
48    pub last_result: Option<BiometricResult>,
49    /// Cached sync availability probe — what the device *can* do
50    /// (`Face` / `Fingerprint` / `Iris` / `NotAvailable`). The backend
51    /// refreshes it on startup and after enrollment changes; callbacks
52    /// read it to decide whether to even offer biometric unlock.
53    pub availability: BiometricKind,
54    /// Prompts dispatched to the native backend whose outcome has not been
55    /// folded back yet (MWA-A1b). While non-zero the capability pump keeps
56    /// its timer armed so the reply reaches callbacks in an idle app.
57    pub in_flight: u32,
58    /// `true` when a prompt outcome was folded since the last event pass
59    /// (set on EVERY completion, even a repeated identical outcome — the
60    /// user re-authenticated and the callback must hear about it). Read by
61    /// the `EventProvider` impl (`EventType::BiometricResult`), cleared by
62    /// [`clear_pending_event`](Self::clear_pending_event).
63    pub pending_event: bool,
64}
65
66impl Default for BiometricManager {
67    fn default() -> Self {
68        Self {
69            last_result: None,
70            availability: BiometricKind::NotAvailable,
71            in_flight: 0,
72            pending_event: false,
73        }
74    }
75}
76
77impl BiometricManager {
78    #[must_use] pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Most recent auth outcome, or `None` until the first request
83    /// resolves.
84    #[must_use] pub const fn last_result(&self) -> Option<BiometricResult> {
85        self.last_result
86    }
87
88    /// Device capability probe (sync). `NotAvailable` until the backend
89    /// reports otherwise.
90    #[must_use] pub const fn availability(&self) -> BiometricKind {
91        self.availability
92    }
93
94    /// `true` if the device has a usable biometric sensor.
95    #[must_use] pub const fn is_available(&self) -> bool {
96        self.availability.is_available()
97    }
98
99    /// Platform backend records the device's biometric capability.
100    /// Returns `true` if it changed, so the caller can relayout to
101    /// reflect a newly-enrolled (or newly-removed) sensor.
102    pub fn set_availability(&mut self, kind: BiometricKind) -> bool {
103        let changed = self.availability != kind;
104        self.availability = kind;
105        changed
106    }
107
108    /// Apply the outcome the backend delivered for the user's request.
109    /// Returns `true` if it differs from the previous outcome (so the
110    /// window can be marked dirty to re-render the unlocked / denied
111    /// state).
112    pub fn set_last_result(&mut self, result: BiometricResult) -> bool {
113        let changed = self.last_result != Some(result);
114        self.last_result = Some(result);
115        // MWA-A1b: every completion fires an event (even an identical
116        // repeat outcome — it answers a fresh request) and retires one
117        // in-flight prompt.
118        self.pending_event = true;
119        self.in_flight = self.in_flight.saturating_sub(1);
120        changed
121    }
122
123    /// The pump dispatched `n` prompts to the native backend; keep the
124    /// timer armed until their outcomes fold back (MWA-A1b).
125    pub const fn mark_requests_dispatched(&mut self, n: u32) {
126        self.in_flight = self.in_flight.saturating_add(n);
127    }
128
129    /// Clear the pending-event flag. The dll calls this after the event
130    /// pass has collected the `BiometricResult` event.
131    pub const fn clear_pending_event(&mut self) {
132        self.pending_event = false;
133    }
134
135    /// `true` while a dispatched prompt's outcome is still outstanding
136    /// (MWA-A1b arming signal).
137    #[must_use] pub const fn has_pending_async(&self) -> bool {
138        self.in_flight > 0
139    }
140
141    /// `true` if the last attempt unlocked successfully (biometric match
142    /// or OS passcode fallback). Convenience for the vault gate.
143    #[must_use] pub const fn last_was_success(&self) -> bool {
144        matches!(self.last_result, Some(r) if r.is_success())
145    }
146}
147
148impl EventProvider for BiometricManager {
149    /// Yield a window-level `BiometricResult` event when a prompt outcome
150    /// was folded since the last pass (target = root; read the outcome via
151    /// `CallbackInfo::get_biometric_result` inside the callback).
152    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
153        if self.pending_event {
154            alloc::vec![SyntheticEvent::new(
155                EventType::BiometricResult,
156                CoreEventSource::User,
157                DomNodeId::ROOT,
158                timestamp,
159                EventData::None,
160            )]
161        } else {
162            Vec::new()
163        }
164    }
165}
166
167// ────────── Async result channel (platform backend → manager) ─────────
168//
169// The OS prompt's reply block / `AuthenticationCallback` fires on an
170// arbitrary thread with no handle to the live `BiometricManager` (it
171// lives inside the window's `LayoutWindow`). The backend parks each
172// result here; the layout pass drains it once per frame via
173// [`drain_biometric_results`] and applies the latest through
174// [`BiometricManager::set_last_result`]. Pure Rust — no platform
175// dependency (SUPER_PLAN_2 §0.5). Mirrors the geolocation manager's
176// async-fix channel.
177
178static PENDING_RESULTS: std::sync::Mutex<Vec<BiometricResult>> =
179    std::sync::Mutex::new(Vec::new());
180
181/// Park a biometric result delivered by a platform backend (in the dll).
182/// Thread-safe; recovers from a poisoned lock so one panicking applier
183/// can't wedge delivery forever.
184pub fn push_biometric_result(result: BiometricResult) {
185    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
186    q.push(result);
187}
188
189/// Drain every result parked by [`push_biometric_result`], in arrival
190/// order. Called once per layout pass; the caller applies them through
191/// [`BiometricManager::set_last_result`] (the last one wins).
192pub fn drain_biometric_results() -> Vec<BiometricResult> {
193    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
194    core::mem::take(&mut *q)
195}
196
197// ────────── Request channel (callback → platform backend) ─────────────
198//
199// The reverse direction: a callback (e.g. an unlock button's `on_click`)
200// calls `CallbackInfo::request_biometric_auth(prompt)`, which parks the
201// prompt here. The dll layout pass drains it via
202// [`drain_biometric_requests`] and dispatches each to the native backend
203// (`dll::desktop::extra::biometric::request`), which shows the OS prompt
204// and later parks the outcome back through [`push_biometric_result`].
205// Decoupling via a channel keeps the request callable from any callback
206// without threading the window's backend handle through `CallbackInfo`,
207// and keeps `azul-layout` platform-free (SUPER_PLAN_2 §0.5).
208
209static PENDING_REQUESTS: std::sync::Mutex<Vec<BiometricPrompt>> =
210    std::sync::Mutex::new(Vec::new());
211
212/// Queue a biometric-auth request from a callback. Picked up by the dll
213/// layout pass and dispatched to the native prompt. Thread-safe;
214/// poison-recovering.
215pub fn push_biometric_request(prompt: BiometricPrompt) {
216    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
217    q.push(prompt);
218}
219
220/// Drain every request queued by [`push_biometric_request`], in arrival
221/// order. Called once per layout pass; the dll dispatches each to the
222/// platform backend.
223pub fn drain_biometric_requests() -> Vec<BiometricPrompt> {
224    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
225    core::mem::take(&mut *q)
226}
227
228/// MWA-C-biometric: true while requests are parked in the channel but not
229/// yet dispatched.
230///
231/// The capability pump's arming check must count these —
232/// `has_pending_async` only sees `in_flight` (post-dispatch), so a prompt
233/// queued MID-pass (after the top-of-pass pump already ran) would otherwise
234/// wait for an unrelated event before ever being shown.
235pub fn has_queued_requests() -> bool {
236    PENDING_REQUESTS
237        .lock()
238        .map_or_else(|e| !e.into_inner().is_empty(), |q| !q.is_empty())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn manager_defaults_to_unavailable_and_no_result() {
247        let mgr = BiometricManager::new();
248        assert_eq!(mgr.availability(), BiometricKind::NotAvailable);
249        assert!(!mgr.is_available());
250        assert_eq!(mgr.last_result(), None);
251        assert!(!mgr.last_was_success());
252    }
253
254    #[test]
255    fn set_availability_returns_change_flag() {
256        let mut mgr = BiometricManager::new();
257        assert!(mgr.set_availability(BiometricKind::Face));
258        assert!(mgr.is_available());
259        assert_eq!(mgr.availability(), BiometricKind::Face);
260        // Same value again — no change.
261        assert!(!mgr.set_availability(BiometricKind::Face));
262        // Different value — change.
263        assert!(mgr.set_availability(BiometricKind::Fingerprint));
264    }
265
266    #[test]
267    fn set_last_result_returns_change_flag() {
268        let mut mgr = BiometricManager::new();
269        assert!(mgr.set_last_result(BiometricResult::Failed));
270        assert_eq!(mgr.last_result(), Some(BiometricResult::Failed));
271        assert!(!mgr.last_was_success());
272        // Re-applying the same outcome is not a change.
273        assert!(!mgr.set_last_result(BiometricResult::Failed));
274        // A new outcome is a change, and Authenticated is a success.
275        assert!(mgr.set_last_result(BiometricResult::Authenticated));
276        assert!(mgr.last_was_success());
277    }
278
279    #[test]
280    fn passcode_fallback_counts_as_success() {
281        let mut mgr = BiometricManager::new();
282        mgr.set_last_result(BiometricResult::FellBackToPasscode);
283        assert!(mgr.last_was_success());
284        assert!(BiometricResult::FellBackToPasscode.is_success());
285        // Cancelled / Failed / Unavailable / Error are not successes.
286        for r in [
287            BiometricResult::Cancelled,
288            BiometricResult::Failed,
289            BiometricResult::Unavailable,
290            BiometricResult::Error,
291        ] {
292            assert!(!r.is_success(), "{r:?} must not be a success");
293        }
294    }
295
296    #[test]
297    fn async_results_round_trip_through_manager() {
298        // The channel is process-global; serialize against every other
299        // channel test, then clear any residue.
300        let _guard = super::autotest_generated::lock_channels();
301        drop(drain_biometric_results());
302
303        push_biometric_result(BiometricResult::Failed);
304        push_biometric_result(BiometricResult::Authenticated); // last wins
305        let drained = drain_biometric_results();
306        assert_eq!(drained.len(), 2, "both parked results drain in order");
307        assert_eq!(drained[0], BiometricResult::Failed);
308        assert_eq!(drained[1], BiometricResult::Authenticated);
309
310        // Applying them reflects in last_result() — what the layout pass does.
311        let mut mgr = BiometricManager::new();
312        for r in &drained {
313            mgr.set_last_result(*r);
314        }
315        assert_eq!(
316            mgr.last_result(),
317            Some(BiometricResult::Authenticated),
318            "the last applied result wins"
319        );
320        assert!(mgr.last_was_success());
321
322        // A second drain is empty — the queue was taken, not copied.
323        assert!(drain_biometric_results().is_empty());
324    }
325
326    #[test]
327    fn requests_round_trip_through_channel() {
328        // Process-global; serialize against every other channel test, then
329        // clear residue.
330        let _guard = super::autotest_generated::lock_channels();
331        drop(drain_biometric_requests());
332
333        push_biometric_request(BiometricPrompt::new("Unlock A".into()));
334        push_biometric_request(BiometricPrompt::new("Unlock B".into()));
335        let drained = drain_biometric_requests();
336        assert_eq!(drained.len(), 2, "both queued requests drain in order");
337        assert_eq!(drained[0].reason.as_str(), "Unlock A");
338        assert_eq!(drained[1].reason.as_str(), "Unlock B");
339
340        // A second drain is empty — the queue was taken, not copied.
341        assert!(drain_biometric_requests().is_empty());
342    }
343
344    #[test]
345    fn biometric_prompt_defaults_and_constructor() {
346        let d = BiometricPrompt::default();
347        assert!(!d.allow_device_credential);
348        assert_eq!(d.reason.as_str(), "");
349
350        let p = BiometricPrompt::new("Unlock your vault".into());
351        assert_eq!(p.reason.as_str(), "Unlock your vault");
352        assert_eq!(p.cancel_label.as_str(), "");
353        assert!(!p.allow_device_credential);
354    }
355}
356
357#[cfg(test)]
358mod pump_provider_tests {
359    use super::*;
360    use azul_core::task::{Instant, SystemTick};
361
362    fn ts() -> Instant {
363        Instant::Tick(SystemTick::new(0))
364    }
365
366    #[test]
367    fn in_flight_tracks_dispatch_and_completion() {
368        let mut mgr = BiometricManager::new();
369        assert!(!mgr.has_pending_async());
370        mgr.mark_requests_dispatched(2);
371        assert!(mgr.has_pending_async());
372        mgr.set_last_result(BiometricResult::Cancelled);
373        assert!(mgr.has_pending_async(), "one of two still outstanding");
374        mgr.set_last_result(BiometricResult::Authenticated);
375        assert!(!mgr.has_pending_async());
376        // saturates — an unsolicited result never underflows
377        mgr.set_last_result(BiometricResult::Failed);
378        assert!(!mgr.has_pending_async());
379    }
380
381    #[test]
382    fn every_completion_fires_an_event_even_identical_repeats() {
383        let mut mgr = BiometricManager::new();
384        assert!(mgr.get_pending_events(ts()).is_empty());
385        mgr.set_last_result(BiometricResult::Cancelled);
386        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
387        mgr.clear_pending_event();
388        assert!(mgr.get_pending_events(ts()).is_empty());
389        // identical outcome answering a FRESH prompt → fresh event
390        mgr.set_last_result(BiometricResult::Cancelled);
391        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
392        assert_eq!(
393            mgr.get_pending_events(ts())[0].event_type,
394            EventType::BiometricResult
395        );
396    }
397}
398
399#[cfg(test)]
400mod autotest_generated {
401    use alloc::string::{String, ToString};
402    use std::sync::{Mutex, PoisonError};
403
404    use azul_core::task::SystemTick;
405
406    use super::*;
407
408    /// The result/request channels are process-global statics and `cargo
409    /// test` runs tests in parallel threads, so EVERY test that pushes to
410    /// or drains a channel must hold this lock — otherwise one test's
411    /// pushes land inside another's drain window. Shared with the
412    /// hand-written `tests` module above.
413    static CHANNEL_LOCK: Mutex<()> = Mutex::new(());
414
415    pub(super) fn lock_channels() -> std::sync::MutexGuard<'static, ()> {
416        CHANNEL_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
417    }
418
419    fn ts() -> Instant {
420        Instant::Tick(SystemTick::new(0))
421    }
422
423    const ALL_KINDS: [BiometricKind; 4] = [
424        BiometricKind::NotAvailable,
425        BiometricKind::Fingerprint,
426        BiometricKind::Face,
427        BiometricKind::Iris,
428    ];
429
430    const ALL_RESULTS: [BiometricResult; 6] = [
431        BiometricResult::Authenticated,
432        BiometricResult::Failed,
433        BiometricResult::Cancelled,
434        BiometricResult::FellBackToPasscode,
435        BiometricResult::Unavailable,
436        BiometricResult::Error,
437    ];
438
439    // ── constructor ────────────────────────────────────────────────────
440
441    #[test]
442    fn new_equals_default_and_holds_the_zero_state_invariants() {
443        let a = BiometricManager::new();
444        let b = BiometricManager::default();
445        assert_eq!(a, b, "new() must be exactly default()");
446        // Every field is at its documented zero state.
447        assert_eq!(a.last_result, None);
448        assert_eq!(a.availability, BiometricKind::NotAvailable);
449        assert_eq!(a.in_flight, 0);
450        assert!(!a.pending_event);
451        // ...and the accessors agree with the fields.
452        assert_eq!(a.last_result(), None);
453        assert_eq!(a.availability(), BiometricKind::NotAvailable);
454        assert!(!a.is_available());
455        assert!(!a.has_pending_async());
456        assert!(!a.last_was_success());
457        assert!(
458            a.get_pending_events(ts()).is_empty(),
459            "a fresh manager owes the event pass nothing"
460        );
461        // Repeated construction is deterministic.
462        assert_eq!(BiometricManager::new(), BiometricManager::new());
463    }
464
465    // ── getters / predicates ───────────────────────────────────────────
466
467    #[test]
468    fn getters_mirror_the_public_fields_for_every_kind_result_pair() {
469        for kind in ALL_KINDS {
470            for result in ALL_RESULTS {
471                let mut mgr = BiometricManager::new();
472                mgr.set_availability(kind);
473                mgr.set_last_result(result);
474
475                assert_eq!(mgr.availability(), kind, "{kind:?}");
476                assert_eq!(mgr.availability(), mgr.availability, "{kind:?}");
477                assert_eq!(
478                    mgr.is_available(),
479                    kind.is_available(),
480                    "is_available must delegate to the kind ({kind:?})"
481                );
482                assert_eq!(mgr.last_result(), Some(result), "{result:?}");
483                assert_eq!(mgr.last_result(), mgr.last_result, "{result:?}");
484                assert_eq!(
485                    mgr.last_was_success(),
486                    result.is_success(),
487                    "last_was_success must delegate to the result ({result:?})"
488                );
489                // The two axes are independent: an unavailable sensor can
490                // still hold a successful (passcode-fallback) outcome.
491                assert_eq!(mgr.availability(), kind);
492            }
493        }
494    }
495
496    #[test]
497    fn is_available_is_true_for_every_real_sensor_and_false_only_for_none() {
498        let mut mgr = BiometricManager::new();
499        assert!(!mgr.is_available(), "known-false: the default");
500        mgr.set_availability(BiometricKind::Fingerprint);
501        assert!(mgr.is_available(), "known-true: a real sensor");
502
503        for kind in ALL_KINDS {
504            let mut mgr = BiometricManager::new();
505            mgr.set_availability(kind);
506            assert_eq!(
507                mgr.is_available(),
508                kind != BiometricKind::NotAvailable,
509                "only NotAvailable is unavailable ({kind:?})"
510            );
511        }
512    }
513
514    #[test]
515    fn last_was_success_is_false_while_no_request_has_resolved() {
516        // `matches!(None, Some(r) if ..)` must not be read as "unknown".
517        let mgr = BiometricManager::new();
518        assert_eq!(mgr.last_result(), None);
519        assert!(
520            !mgr.last_was_success(),
521            "an unauthenticated vault must stay locked before the first attempt"
522        );
523        // An available sensor alone never implies a success.
524        let mut mgr = BiometricManager::new();
525        mgr.set_availability(BiometricKind::Face);
526        assert!(mgr.is_available());
527        assert!(!mgr.last_was_success());
528    }
529
530    #[test]
531    fn only_authenticated_and_passcode_fallback_unlock_the_gate() {
532        for result in ALL_RESULTS {
533            let mut mgr = BiometricManager::new();
534            mgr.set_last_result(result);
535            let expected = matches!(
536                result,
537                BiometricResult::Authenticated | BiometricResult::FellBackToPasscode
538            );
539            assert_eq!(
540                mgr.last_was_success(),
541                expected,
542                "{result:?} must {} unlock the gate",
543                if expected { "" } else { "not" }
544            );
545        }
546    }
547
548    // ── set_availability / set_last_result change flags ─────────────────
549
550    #[test]
551    fn set_availability_change_flag_is_exactly_inequality_for_all_pairs() {
552        for from in ALL_KINDS {
553            for to in ALL_KINDS {
554                let mut mgr = BiometricManager::new();
555                mgr.availability = from;
556                let changed = mgr.set_availability(to);
557                assert_eq!(
558                    changed,
559                    from != to,
560                    "set_availability({to:?}) over {from:?} reported the wrong change flag"
561                );
562                assert_eq!(mgr.availability(), to, "the write must land regardless");
563                // Re-applying the same value is never a change.
564                assert!(!mgr.set_availability(to), "idempotent re-apply of {to:?}");
565                assert_eq!(mgr.availability(), to);
566            }
567        }
568    }
569
570    #[test]
571    fn set_availability_does_not_disturb_the_async_or_result_state() {
572        let mut mgr = BiometricManager::new();
573        mgr.mark_requests_dispatched(3);
574        mgr.set_last_result(BiometricResult::Authenticated);
575        mgr.clear_pending_event();
576        let before = mgr;
577
578        mgr.set_availability(BiometricKind::Iris);
579
580        assert_eq!(mgr.last_result, before.last_result, "result untouched");
581        assert_eq!(mgr.in_flight, before.in_flight, "in_flight untouched");
582        assert_eq!(
583            mgr.pending_event, before.pending_event,
584            "an availability probe is not a prompt outcome — it must not fire an event"
585        );
586    }
587
588    #[test]
589    fn set_last_result_change_flag_is_exactly_inequality_for_all_pairs() {
590        for first in ALL_RESULTS {
591            for second in ALL_RESULTS {
592                let mut mgr = BiometricManager::new();
593                assert!(
594                    mgr.set_last_result(first),
595                    "None -> Some({first:?}) is always a change"
596                );
597                let changed = mgr.set_last_result(second);
598                assert_eq!(
599                    changed,
600                    first != second,
601                    "set_last_result({second:?}) over {first:?} reported the wrong change flag"
602                );
603                assert_eq!(mgr.last_result(), Some(second), "the write must land");
604            }
605        }
606    }
607
608    #[test]
609    fn every_completion_arms_a_pending_event_even_when_unchanged() {
610        // The change flag ("re-render?") and the event flag ("tell the
611        // callback?") are different questions — a repeat outcome answers a
612        // FRESH prompt and must still be delivered.
613        for result in ALL_RESULTS {
614            let mut mgr = BiometricManager::new();
615            mgr.set_last_result(result);
616            mgr.clear_pending_event();
617
618            let changed = mgr.set_last_result(result);
619            assert!(!changed, "identical outcome is not a state change ({result:?})");
620            assert!(
621                mgr.pending_event,
622                "a repeated {result:?} must still fire an event"
623            );
624            assert_eq!(mgr.get_pending_events(ts()).len(), 1);
625        }
626    }
627
628    // ── mark_requests_dispatched (numeric) ─────────────────────────────
629
630    #[test]
631    fn mark_requests_dispatched_zero_is_a_no_op() {
632        let mut mgr = BiometricManager::new();
633        mgr.mark_requests_dispatched(0);
634        assert_eq!(mgr.in_flight, 0, "0 prompts dispatched adds nothing");
635        assert!(
636            !mgr.has_pending_async(),
637            "dispatching nothing must not arm the pump's timer"
638        );
639
640        mgr.mark_requests_dispatched(1);
641        mgr.mark_requests_dispatched(0);
642        assert_eq!(mgr.in_flight, 1, "0 must not clear an outstanding prompt");
643        assert!(mgr.has_pending_async());
644    }
645
646    #[test]
647    fn mark_requests_dispatched_saturates_at_u32_max_instead_of_overflowing() {
648        let mut mgr = BiometricManager::new();
649        mgr.mark_requests_dispatched(u32::MAX);
650        assert_eq!(mgr.in_flight, u32::MAX);
651        assert!(mgr.has_pending_async());
652
653        // A debug-build `+` here would panic; saturating_add must clamp.
654        mgr.mark_requests_dispatched(1);
655        assert_eq!(mgr.in_flight, u32::MAX, "saturates, does not wrap to 0");
656        mgr.mark_requests_dispatched(u32::MAX);
657        assert_eq!(mgr.in_flight, u32::MAX, "still clamped");
658        assert!(
659            mgr.has_pending_async(),
660            "wrapping to 0 here would disarm the pump and strand the reply"
661        );
662    }
663
664    #[test]
665    fn mark_requests_dispatched_accumulates_across_calls() {
666        let mut mgr = BiometricManager::new();
667        for _ in 0..10u32 {
668            mgr.mark_requests_dispatched(7);
669        }
670        assert_eq!(mgr.in_flight, 70);
671        // Boundary: u32::MAX - 1 then +1 lands exactly on MAX, not past it.
672        let mut mgr = BiometricManager::new();
673        mgr.mark_requests_dispatched(u32::MAX - 1);
674        mgr.mark_requests_dispatched(1);
675        assert_eq!(mgr.in_flight, u32::MAX);
676    }
677
678    #[test]
679    fn unsolicited_results_saturate_in_flight_at_zero_instead_of_underflowing() {
680        // A backend that parks a result for a prompt we never dispatched
681        // (or a duplicate delivery) must not wrap in_flight to u32::MAX —
682        // that would arm the capability timer forever.
683        let mut mgr = BiometricManager::new();
684        assert_eq!(mgr.in_flight, 0);
685        for result in ALL_RESULTS {
686            mgr.set_last_result(result);
687            assert_eq!(mgr.in_flight, 0, "underflow on unsolicited {result:?}");
688            assert!(!mgr.has_pending_async());
689        }
690    }
691
692    #[test]
693    fn each_completion_retires_exactly_one_in_flight_prompt() {
694        let mut mgr = BiometricManager::new();
695        mgr.mark_requests_dispatched(3);
696        assert_eq!(mgr.in_flight, 3);
697
698        mgr.set_last_result(BiometricResult::Cancelled);
699        assert_eq!(mgr.in_flight, 2);
700        assert!(mgr.has_pending_async());
701        mgr.set_last_result(BiometricResult::Failed);
702        assert_eq!(mgr.in_flight, 1);
703        assert!(mgr.has_pending_async());
704        mgr.set_last_result(BiometricResult::Authenticated);
705        assert_eq!(mgr.in_flight, 0);
706        assert!(!mgr.has_pending_async(), "all three outcomes folded back");
707
708        // Even from a saturated count, one completion retires exactly one.
709        let mut mgr = BiometricManager::new();
710        mgr.mark_requests_dispatched(u32::MAX);
711        mgr.set_last_result(BiometricResult::Error);
712        assert_eq!(mgr.in_flight, u32::MAX - 1);
713    }
714
715    #[test]
716    fn has_pending_async_is_true_exactly_while_in_flight_is_non_zero() {
717        for n in [0u32, 1, 2, u32::MAX - 1, u32::MAX] {
718            let mut mgr = BiometricManager::new();
719            mgr.mark_requests_dispatched(n);
720            assert_eq!(
721                mgr.has_pending_async(),
722                n > 0,
723                "has_pending_async disagreed with in_flight = {n}"
724            );
725        }
726    }
727
728    // ── clear_pending_event ────────────────────────────────────────────
729
730    #[test]
731    fn clear_pending_event_is_idempotent_and_touches_nothing_else() {
732        let mut mgr = BiometricManager::new();
733        mgr.set_availability(BiometricKind::Face);
734        mgr.mark_requests_dispatched(2);
735        mgr.set_last_result(BiometricResult::Authenticated);
736        assert!(mgr.pending_event);
737
738        mgr.clear_pending_event();
739        assert!(!mgr.pending_event);
740        let after_first = mgr;
741
742        // Clearing twice (a second event pass with no new outcome) is safe.
743        mgr.clear_pending_event();
744        assert_eq!(mgr, after_first, "the second clear is a no-op");
745        assert!(mgr.get_pending_events(ts()).is_empty());
746
747        // The outcome, the sensor probe and the in-flight count survive it.
748        assert_eq!(mgr.last_result(), Some(BiometricResult::Authenticated));
749        assert!(mgr.last_was_success(), "clearing the event must not re-lock the vault");
750        assert_eq!(mgr.availability(), BiometricKind::Face);
751        assert_eq!(mgr.in_flight, 1);
752        assert!(mgr.has_pending_async(), "the second prompt is still outstanding");
753    }
754
755    #[test]
756    fn clear_pending_event_on_a_fresh_manager_is_a_no_op() {
757        let mut mgr = BiometricManager::new();
758        mgr.clear_pending_event();
759        assert_eq!(mgr, BiometricManager::new());
760    }
761
762    // ── EventProvider ──────────────────────────────────────────────────
763
764    #[test]
765    fn pending_event_yields_exactly_one_root_event_and_is_not_consuming() {
766        let mut mgr = BiometricManager::new();
767        mgr.set_last_result(BiometricResult::Authenticated);
768
769        let events = mgr.get_pending_events(ts());
770        assert_eq!(events.len(), 1);
771        assert_eq!(events[0].event_type, EventType::BiometricResult);
772        assert_eq!(events[0].source, CoreEventSource::User);
773        assert_eq!(
774            events[0].target,
775            DomNodeId::ROOT,
776            "the outcome is window-level, not node-level"
777        );
778        assert_eq!(events[0].data, EventData::None);
779        assert_eq!(events[0].timestamp, ts(), "the caller's timestamp is preserved");
780
781        // Reading is not draining — only clear_pending_event() retires it.
782        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
783        mgr.clear_pending_event();
784        assert!(mgr.get_pending_events(ts()).is_empty());
785    }
786
787    // ── Copy semantics ─────────────────────────────────────────────────
788
789    #[test]
790    fn manager_is_copy_so_a_snapshot_never_sees_later_mutations() {
791        // `BiometricManager: Copy` — a caller holding `let mut m = *mgr;`
792        // mutates a detached snapshot. Pin the semantics so a future field
793        // (e.g. a Vec of pending prompts) can't silently change them.
794        let mut original = BiometricManager::new();
795        original.mark_requests_dispatched(1);
796
797        let mut snapshot = original;
798        snapshot.set_last_result(BiometricResult::Authenticated);
799        snapshot.set_availability(BiometricKind::Iris);
800
801        assert_eq!(original.last_result(), None, "the original is untouched");
802        assert_eq!(original.availability(), BiometricKind::NotAvailable);
803        assert_eq!(original.in_flight, 1);
804        assert!(!original.pending_event);
805
806        assert_eq!(snapshot.last_result(), Some(BiometricResult::Authenticated));
807        assert_eq!(snapshot.in_flight, 0);
808    }
809
810    // ── async result channel ───────────────────────────────────────────
811
812    #[test]
813    fn results_channel_round_trips_every_variant_in_arrival_order() {
814        let _guard = lock_channels();
815        drop(drain_biometric_results());
816
817        for result in ALL_RESULTS {
818            push_biometric_result(result);
819        }
820        let drained = drain_biometric_results();
821        assert_eq!(drained.len(), ALL_RESULTS.len(), "no result is dropped");
822        assert_eq!(
823            drained.as_slice(),
824            ALL_RESULTS.as_slice(),
825            "the channel is FIFO — the layout pass relies on last-one-wins"
826        );
827        assert!(
828            drain_biometric_results().is_empty(),
829            "draining takes the queue, it does not copy it"
830        );
831    }
832
833    #[test]
834    fn draining_an_empty_results_channel_repeatedly_is_safe() {
835        let _guard = lock_channels();
836        drop(drain_biometric_results());
837        for _ in 0..3 {
838            assert!(drain_biometric_results().is_empty());
839        }
840    }
841
842    #[test]
843    fn duplicate_results_are_all_preserved_in_the_channel() {
844        // Dedup is the manager's job (via the change flag), not the
845        // channel's — every parked outcome answers its own prompt.
846        let _guard = lock_channels();
847        drop(drain_biometric_results());
848
849        for _ in 0..64 {
850            push_biometric_result(BiometricResult::Failed);
851        }
852        let drained = drain_biometric_results();
853        assert_eq!(drained.len(), 64);
854        assert!(drained.iter().all(|r| *r == BiometricResult::Failed));
855
856        // Folding them all back never underflows in_flight, and the last
857        // one still wins.
858        let mut mgr = BiometricManager::new();
859        mgr.mark_requests_dispatched(2);
860        for r in &drained {
861            mgr.set_last_result(*r);
862        }
863        assert_eq!(mgr.in_flight, 0, "saturating_sub floors at zero");
864        assert_eq!(mgr.last_result(), Some(BiometricResult::Failed));
865    }
866
867    #[test]
868    fn results_pushed_from_many_threads_are_all_delivered() {
869        let _guard = lock_channels();
870        drop(drain_biometric_results());
871
872        let threads: Vec<_> = (0..4)
873            .map(|_| {
874                std::thread::spawn(|| {
875                    for _ in 0..25 {
876                        push_biometric_result(BiometricResult::Authenticated);
877                    }
878                })
879            })
880            .collect();
881        for t in threads {
882            t.join().expect("a backend thread panicked while parking a result");
883        }
884
885        let drained = drain_biometric_results();
886        assert_eq!(drained.len(), 100, "no result lost across 4 backend threads");
887        assert!(drained.iter().all(|r| r.is_success()));
888        assert!(drain_biometric_results().is_empty());
889    }
890
891    // ── request channel ────────────────────────────────────────────────
892
893    #[test]
894    fn has_queued_requests_tracks_the_queue_exactly() {
895        let _guard = lock_channels();
896        drop(drain_biometric_requests());
897        assert!(
898            !has_queued_requests(),
899            "known-false: nothing parked after a drain"
900        );
901
902        push_biometric_request(BiometricPrompt::new("Unlock".into()));
903        assert!(has_queued_requests(), "known-true: one prompt parked");
904        // Reading the flag must not consume the queue.
905        assert!(has_queued_requests());
906
907        push_biometric_request(BiometricPrompt::default());
908        assert!(has_queued_requests());
909
910        let drained = drain_biometric_requests();
911        assert_eq!(drained.len(), 2);
912        assert!(
913            !has_queued_requests(),
914            "the pump's arming check must go quiet once the prompts are dispatched"
915        );
916    }
917
918    #[test]
919    fn request_channel_round_trips_hostile_prompt_strings_byte_for_byte() {
920        let _guard = lock_channels();
921        drop(drain_biometric_requests());
922
923        let hostile: Vec<String> = alloc::vec![
924            String::new(),                           // empty → platform default
925            "Entsperre den Tresor 🔐".to_string(),   // emoji (4-byte scalar)
926            "افتح الخزنة".to_string(),               // RTL
927            "e\u{0301}\u{0301}\u{0301}".to_string(), // stacked combining marks
928            "line\nbreak\ttab\r\n".to_string(),      // control chars
929            "nul\0inside".to_string(),               // interior NUL
930            "%s %n {} \\0 ${x}".to_string(),         // format-string bait
931            "u".repeat(64 * 1024),                   // 64 KiB reason
932        ];
933
934        for reason in &hostile {
935            push_biometric_request(BiometricPrompt {
936                reason: reason.clone().into(),
937                cancel_label: reason.clone().into(),
938                allow_device_credential: true,
939            });
940        }
941
942        let drained = drain_biometric_requests();
943        assert_eq!(drained.len(), hostile.len(), "no prompt is dropped");
944        for (prompt, expected) in drained.iter().zip(hostile.iter()) {
945            assert_eq!(
946                prompt.reason.as_str(),
947                expected.as_str(),
948                "the reason must survive the channel unmangled"
949            );
950            assert_eq!(prompt.cancel_label.as_str(), expected.as_str());
951            assert!(prompt.allow_device_credential);
952        }
953        assert_eq!(
954            drained.last().unwrap().reason.as_str().len(),
955            64 * 1024,
956            "a 64 KiB reason is not truncated"
957        );
958        assert!(!has_queued_requests());
959    }
960
961    #[test]
962    fn requests_pushed_from_many_threads_are_all_delivered() {
963        let _guard = lock_channels();
964        drop(drain_biometric_requests());
965
966        let threads: Vec<_> = (0..4)
967            .map(|i| {
968                std::thread::spawn(move || {
969                    for j in 0..25 {
970                        push_biometric_request(BiometricPrompt::new(
971                            alloc::format!("t{i}-{j}").into(),
972                        ));
973                    }
974                })
975            })
976            .collect();
977        for t in threads {
978            t.join().expect("a callback thread panicked while queueing a prompt");
979        }
980
981        let drained = drain_biometric_requests();
982        assert_eq!(drained.len(), 100, "no prompt lost across 4 callback threads");
983        // Each thread's own prompts keep their relative order.
984        for i in 0..4 {
985            let mine: Vec<_> = drained
986                .iter()
987                .filter(|p| p.reason.as_str().starts_with(&alloc::format!("t{i}-")))
988                .collect();
989            assert_eq!(mine.len(), 25, "thread {i} lost a prompt");
990            for (j, p) in mine.iter().enumerate() {
991                assert_eq!(p.reason.as_str(), alloc::format!("t{i}-{j}"));
992            }
993        }
994        assert!(!has_queued_requests());
995    }
996
997    // ── full request → dispatch → result loop ──────────────────────────
998
999    #[test]
1000    fn full_request_dispatch_result_loop_leaves_the_pump_disarmed() {
1001        let _guard = lock_channels();
1002        drop(drain_biometric_requests());
1003        drop(drain_biometric_results());
1004
1005        let mut mgr = BiometricManager::new();
1006        mgr.set_availability(BiometricKind::Fingerprint);
1007
1008        // 1. Two callbacks each queue a prompt.
1009        push_biometric_request(BiometricPrompt::new("Unlock A".into()));
1010        push_biometric_request(BiometricPrompt::new("Unlock B".into()));
1011        assert!(
1012            has_queued_requests(),
1013            "queued-but-undispatched prompts must arm the pump on their own — \
1014             has_pending_async() cannot see them yet"
1015        );
1016        assert!(!mgr.has_pending_async());
1017
1018        // 2. The layout pass drains and dispatches them.
1019        let requests = drain_biometric_requests();
1020        assert_eq!(requests.len(), 2);
1021        mgr.mark_requests_dispatched(u32::try_from(requests.len()).unwrap());
1022        assert!(!has_queued_requests());
1023        assert!(mgr.has_pending_async(), "now the in-flight count arms the pump");
1024
1025        // 3. The backend parks both outcomes; the next pass folds them back.
1026        push_biometric_result(BiometricResult::Failed);
1027        push_biometric_result(BiometricResult::FellBackToPasscode);
1028        for r in drain_biometric_results() {
1029            mgr.set_last_result(r);
1030        }
1031
1032        assert_eq!(mgr.in_flight, 0);
1033        assert!(!mgr.has_pending_async(), "pump disarms once every outcome folded");
1034        assert!(mgr.pending_event);
1035        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
1036        assert_eq!(mgr.last_result(), Some(BiometricResult::FellBackToPasscode));
1037        assert!(mgr.last_was_success(), "the passcode fallback unlocks the vault");
1038
1039        mgr.clear_pending_event();
1040        assert!(mgr.get_pending_events(ts()).is_empty());
1041    }
1042}