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

            
27
use alloc::vec::Vec;
28

            
29
use azul_core::dom::DomNodeId;
30
use azul_core::events::{
31
    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
32
};
33
use 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.
39
pub 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)]
44
pub 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

            
66
impl Default for BiometricManager {
67
5704
    fn default() -> Self {
68
5704
        Self {
69
5704
            last_result: None,
70
5704
            availability: BiometricKind::NotAvailable,
71
5704
            in_flight: 0,
72
5704
            pending_event: false,
73
5704
        }
74
5704
    }
75
}
76

            
77
impl BiometricManager {
78
5703
    #[must_use] pub fn new() -> Self {
79
5703
        Self::default()
80
5703
    }
81

            
82
    /// Most recent auth outcome, or `None` until the first request
83
    /// resolves.
84
94
    #[must_use] pub const fn last_result(&self) -> Option<BiometricResult> {
85
94
        self.last_result
86
94
    }
87

            
88
    /// Device capability probe (sync). `NotAvailable` until the backend
89
    /// reports otherwise.
90
109
    #[must_use] pub const fn availability(&self) -> BiometricKind {
91
109
        self.availability
92
109
    }
93

            
94
    /// `true` if the device has a usable biometric sensor.
95
34
    #[must_use] pub const fn is_available(&self) -> bool {
96
34
        self.availability.is_available()
97
34
    }
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
69
    pub fn set_availability(&mut self, kind: BiometricKind) -> bool {
103
69
        let changed = self.availability != kind;
104
69
        self.availability = kind;
105
69
        changed
106
69
    }
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
205
    pub fn set_last_result(&mut self, result: BiometricResult) -> bool {
113
205
        let changed = self.last_result != Some(result);
114
205
        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
205
        self.pending_event = true;
119
205
        self.in_flight = self.in_flight.saturating_sub(1);
120
205
        changed
121
205
    }
122

            
123
    /// The pump dispatched `n` prompts to the native backend; keep the
124
    /// timer armed until their outcomes fold back (MWA-A1b).
125
31
    pub const fn mark_requests_dispatched(&mut self, n: u32) {
126
31
        self.in_flight = self.in_flight.saturating_add(n);
127
31
    }
128

            
129
    /// Clear the pending-event flag. The dll calls this after the event
130
    /// pass has collected the `BiometricResult` event.
131
87
    pub const fn clear_pending_event(&mut self) {
132
87
        self.pending_event = false;
133
87
    }
134

            
135
    /// `true` while a dispatched prompt's outcome is still outstanding
136
    /// (MWA-A1b arming signal).
137
28
    #[must_use] pub const fn has_pending_async(&self) -> bool {
138
28
        self.in_flight > 0
139
28
    }
140

            
141
    /// `true` if the last attempt unlocked successfully (biometric match
142
    /// or OS passcode fallback). Convenience for the vault gate.
143
40
    #[must_use] pub const fn last_was_success(&self) -> bool {
144
36
        matches!(self.last_result, Some(r) if r.is_success())
145
40
    }
146
}
147

            
148
impl 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
92
    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
153
92
        if self.pending_event {
154
12
            alloc::vec![SyntheticEvent::new(
155
12
                EventType::BiometricResult,
156
12
                CoreEventSource::User,
157
                DomNodeId::ROOT,
158
12
                timestamp,
159
12
                EventData::None,
160
            )]
161
        } else {
162
80
            Vec::new()
163
        }
164
92
    }
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

            
178
static 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.
184
174
pub fn push_biometric_result(result: BiometricResult) {
185
174
    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
186
174
    q.push(result);
187
174
}
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).
192
17
pub fn drain_biometric_results() -> Vec<BiometricResult> {
193
17
    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
194
17
    core::mem::take(&mut *q)
195
17
}
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

            
209
static 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.
215
114
pub fn push_biometric_request(prompt: BiometricPrompt) {
216
114
    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
217
114
    q.push(prompt);
218
114
}
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.
223
11
pub fn drain_biometric_requests() -> Vec<BiometricPrompt> {
224
11
    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
225
11
    core::mem::take(&mut *q)
226
11
}
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.
235
9
pub fn has_queued_requests() -> bool {
236
9
    PENDING_REQUESTS
237
9
        .lock()
238
9
        .map_or_else(|e| !e.into_inner().is_empty(), |q| !q.is_empty())
239
9
}
240

            
241
#[cfg(test)]
242
mod tests {
243
    use super::*;
244

            
245
    #[test]
246
1
    fn manager_defaults_to_unavailable_and_no_result() {
247
1
        let mgr = BiometricManager::new();
248
1
        assert_eq!(mgr.availability(), BiometricKind::NotAvailable);
249
1
        assert!(!mgr.is_available());
250
1
        assert_eq!(mgr.last_result(), None);
251
1
        assert!(!mgr.last_was_success());
252
1
    }
253

            
254
    #[test]
255
1
    fn set_availability_returns_change_flag() {
256
1
        let mut mgr = BiometricManager::new();
257
1
        assert!(mgr.set_availability(BiometricKind::Face));
258
1
        assert!(mgr.is_available());
259
1
        assert_eq!(mgr.availability(), BiometricKind::Face);
260
        // Same value again — no change.
261
1
        assert!(!mgr.set_availability(BiometricKind::Face));
262
        // Different value — change.
263
1
        assert!(mgr.set_availability(BiometricKind::Fingerprint));
264
1
    }
265

            
266
    #[test]
267
1
    fn set_last_result_returns_change_flag() {
268
1
        let mut mgr = BiometricManager::new();
269
1
        assert!(mgr.set_last_result(BiometricResult::Failed));
270
1
        assert_eq!(mgr.last_result(), Some(BiometricResult::Failed));
271
1
        assert!(!mgr.last_was_success());
272
        // Re-applying the same outcome is not a change.
273
1
        assert!(!mgr.set_last_result(BiometricResult::Failed));
274
        // A new outcome is a change, and Authenticated is a success.
275
1
        assert!(mgr.set_last_result(BiometricResult::Authenticated));
276
1
        assert!(mgr.last_was_success());
277
1
    }
278

            
279
    #[test]
280
1
    fn passcode_fallback_counts_as_success() {
281
1
        let mut mgr = BiometricManager::new();
282
1
        mgr.set_last_result(BiometricResult::FellBackToPasscode);
283
1
        assert!(mgr.last_was_success());
284
1
        assert!(BiometricResult::FellBackToPasscode.is_success());
285
        // Cancelled / Failed / Unavailable / Error are not successes.
286
4
        for r in [
287
1
            BiometricResult::Cancelled,
288
1
            BiometricResult::Failed,
289
1
            BiometricResult::Unavailable,
290
1
            BiometricResult::Error,
291
        ] {
292
4
            assert!(!r.is_success(), "{r:?} must not be a success");
293
        }
294
1
    }
295

            
296
    #[test]
297
1
    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
1
        let _guard = autotest_generated::lock_channels();
301
1
        drop(drain_biometric_results());
302

            
303
1
        push_biometric_result(BiometricResult::Failed);
304
1
        push_biometric_result(BiometricResult::Authenticated); // last wins
305
1
        let drained = drain_biometric_results();
306
1
        assert_eq!(drained.len(), 2, "both parked results drain in order");
307
1
        assert_eq!(drained[0], BiometricResult::Failed);
308
1
        assert_eq!(drained[1], BiometricResult::Authenticated);
309

            
310
        // Applying them reflects in last_result() — what the layout pass does.
311
1
        let mut mgr = BiometricManager::new();
312
3
        for r in &drained {
313
2
            mgr.set_last_result(*r);
314
2
        }
315
1
        assert_eq!(
316
1
            mgr.last_result(),
317
            Some(BiometricResult::Authenticated),
318
            "the last applied result wins"
319
        );
320
1
        assert!(mgr.last_was_success());
321

            
322
        // A second drain is empty — the queue was taken, not copied.
323
1
        assert!(drain_biometric_results().is_empty());
324
1
    }
325

            
326
    #[test]
327
1
    fn requests_round_trip_through_channel() {
328
        // Process-global; serialize against every other channel test, then
329
        // clear residue.
330
1
        let _guard = autotest_generated::lock_channels();
331
1
        drop(drain_biometric_requests());
332

            
333
1
        push_biometric_request(BiometricPrompt::new("Unlock A".into()));
334
1
        push_biometric_request(BiometricPrompt::new("Unlock B".into()));
335
1
        let drained = drain_biometric_requests();
336
1
        assert_eq!(drained.len(), 2, "both queued requests drain in order");
337
1
        assert_eq!(drained[0].reason.as_str(), "Unlock A");
338
1
        assert_eq!(drained[1].reason.as_str(), "Unlock B");
339

            
340
        // A second drain is empty — the queue was taken, not copied.
341
1
        assert!(drain_biometric_requests().is_empty());
342
1
    }
343

            
344
    #[test]
345
1
    fn biometric_prompt_defaults_and_constructor() {
346
1
        let d = BiometricPrompt::default();
347
1
        assert!(!d.allow_device_credential);
348
1
        assert_eq!(d.reason.as_str(), "");
349

            
350
1
        let p = BiometricPrompt::new("Unlock your vault".into());
351
1
        assert_eq!(p.reason.as_str(), "Unlock your vault");
352
1
        assert_eq!(p.cancel_label.as_str(), "");
353
1
        assert!(!p.allow_device_credential);
354
1
    }
355
}
356

            
357
#[cfg(test)]
358
mod pump_provider_tests {
359
    use super::*;
360
    use azul_core::task::{Instant, SystemTick};
361

            
362
5
    fn ts() -> Instant {
363
5
        Instant::Tick(SystemTick::new(0))
364
5
    }
365

            
366
    #[test]
367
1
    fn in_flight_tracks_dispatch_and_completion() {
368
1
        let mut mgr = BiometricManager::new();
369
1
        assert!(!mgr.has_pending_async());
370
1
        mgr.mark_requests_dispatched(2);
371
1
        assert!(mgr.has_pending_async());
372
1
        mgr.set_last_result(BiometricResult::Cancelled);
373
1
        assert!(mgr.has_pending_async(), "one of two still outstanding");
374
1
        mgr.set_last_result(BiometricResult::Authenticated);
375
1
        assert!(!mgr.has_pending_async());
376
        // saturates — an unsolicited result never underflows
377
1
        mgr.set_last_result(BiometricResult::Failed);
378
1
        assert!(!mgr.has_pending_async());
379
1
    }
380

            
381
    #[test]
382
1
    fn every_completion_fires_an_event_even_identical_repeats() {
383
1
        let mut mgr = BiometricManager::new();
384
1
        assert!(mgr.get_pending_events(ts()).is_empty());
385
1
        mgr.set_last_result(BiometricResult::Cancelled);
386
1
        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
387
1
        mgr.clear_pending_event();
388
1
        assert!(mgr.get_pending_events(ts()).is_empty());
389
        // identical outcome answering a FRESH prompt → fresh event
390
1
        mgr.set_last_result(BiometricResult::Cancelled);
391
1
        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
392
1
        assert_eq!(
393
1
            mgr.get_pending_events(ts())[0].event_type,
394
            EventType::BiometricResult
395
        );
396
1
    }
397
}
398

            
399
#[cfg(test)]
400
mod 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]
    fn full_request_dispatch_result_loop_leaves_the_pump_disarmed() {
        let _guard = lock_channels();
        drop(drain_biometric_requests());
        drop(drain_biometric_results());
        let mut mgr = BiometricManager::new();
        mgr.set_availability(BiometricKind::Fingerprint);
        // 1. Two callbacks each queue a prompt.
        push_biometric_request(BiometricPrompt::new("Unlock A".into()));
        push_biometric_request(BiometricPrompt::new("Unlock B".into()));
        assert!(
            has_queued_requests(),
            "queued-but-undispatched prompts must arm the pump on their own — \
             has_pending_async() cannot see them yet"
        );
        assert!(!mgr.has_pending_async());
        // 2. The layout pass drains and dispatches them.
        let requests = drain_biometric_requests();
        assert_eq!(requests.len(), 2);
        mgr.mark_requests_dispatched(u32::try_from(requests.len()).unwrap());
        assert!(!has_queued_requests());
        assert!(mgr.has_pending_async(), "now the in-flight count arms the pump");
        // 3. The backend parks both outcomes; the next pass folds them back.
        push_biometric_result(BiometricResult::Failed);
        push_biometric_result(BiometricResult::FellBackToPasscode);
        for r in drain_biometric_results() {
            mgr.set_last_result(r);
        }
        assert_eq!(mgr.in_flight, 0);
        assert!(!mgr.has_pending_async(), "pump disarms once every outcome folded");
        assert!(mgr.pending_event);
        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
        assert_eq!(mgr.last_result(), Some(BiometricResult::FellBackToPasscode));
        assert!(mgr.last_was_success(), "the passcode fallback unlocks the vault");
        mgr.clear_pending_event();
        assert!(mgr.get_pending_events(ts()).is_empty());
    }
}