Skip to main content

azul_core/
biometric.rs

1//! POD types for the biometric-authentication surface
2//! (SUPER_PLAN_2 §1 feature 4 + research/02).
3//!
4//! Defined here in `azul-core` so the request config and result types
5//! can cross the FFI without `azul-layout` having to be a dependency.
6//! The stateful side (latest result, sync availability, async result
7//! channel) lives in `azul_layout::managers::biometric::BiometricManager`
8//! and re-exports these types for the existing import paths.
9//!
10//! Unlike geolocation (a continuous probe-driven subscription), biometric
11//! auth is **request-driven**: a callback asks `App::request_biometric_auth`
12//! with a [`BiometricPrompt`]; the OS draws its own modal; the platform
13//! backend parks the [`BiometricResult`] in the manager's async channel
14//! when the user responds.
15
16use azul_css::AzString;
17
18/// What biometric hardware the device can authenticate with right now.
19///
20/// This is the *sync availability probe* (iOS `LAContext.biometryType` /
21/// `canEvaluatePolicy`; Android `BiometricManager.canAuthenticate`), not
22/// the outcome of an auth attempt — that is [`BiometricResult`].
23/// `NotAvailable` covers "no sensor", "not enrolled", and "disabled by
24/// policy" alike; callers that need to distinguish those use the richer
25/// per-attempt [`BiometricResult`] variants.
26#[repr(C)]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28#[derive(Default)]
29pub enum BiometricKind {
30    /// No usable biometric sensor (absent, unenrolled, or disabled).
31    #[default]
32    NotAvailable,
33    /// Fingerprint reader (Touch ID, Android fingerprint, Windows Hello
34    /// fingerprint).
35    Fingerprint,
36    /// Face recognition (Face ID, Android face unlock, Windows Hello face).
37    Face,
38    /// Iris scanner (Samsung legacy, some Android OEMs).
39    Iris,
40}
41
42
43impl BiometricKind {
44    /// `true` for any real sensor — i.e. anything except `NotAvailable`.
45    /// Lets the demo gate decide whether to even offer a biometric unlock.
46    #[must_use] pub const fn is_available(&self) -> bool {
47        !matches!(self, Self::NotAvailable)
48    }
49}
50
51/// The outcome of one `request_biometric_auth` attempt, delivered to the
52/// caller's completion callback once the OS prompt resolves.
53///
54/// Maps onto every platform's result enum: iOS `LAError`, Android
55/// `BiometricPrompt.AuthenticationCallback`, Windows
56/// `UserConsentVerificationResult`, Linux polkit / PAM (research/02 §6).
57#[repr(C)]
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub enum BiometricResult {
60    /// The user matched their face / finger / iris. Unlock granted.
61    Authenticated,
62    /// The user presented a biometric but it did not match (wrong
63    /// finger / face). Distinct from `Cancelled` — the prompt is still
64    /// up or retries were exhausted without a deliberate cancel.
65    Failed,
66    /// The user dismissed the prompt (tapped Cancel / pressed back).
67    Cancelled,
68    /// Biometrics failed but the user authenticated via the OS passcode
69    /// / PIN / device-credential fallback. Still a successful unlock —
70    /// only delivered when [`BiometricPrompt::allow_device_credential`]
71    /// was set.
72    FellBackToPasscode,
73    /// No usable biometric is enrolled / available on this device, so
74    /// the prompt could not be shown (Linux degraded path, or hardware
75    /// absent). Pairs with [`BiometricKind::NotAvailable`].
76    Unavailable,
77    /// A platform error occurred (sensor busy, lockout, key invalidated,
78    /// or an unmapped native error code).
79    Error,
80}
81
82impl BiometricResult {
83    /// `true` when the user successfully unlocked — either by biometric
84    /// match (`Authenticated`) or by the OS passcode fallback
85    /// (`FellBackToPasscode`). The vault gate keys off this.
86    #[must_use] pub const fn is_success(&self) -> bool {
87        matches!(
88            self,
89            Self::Authenticated | Self::FellBackToPasscode
90        )
91    }
92}
93
94// FFI Option wrapper. `CallbackInfo::get_biometric_result() ->
95// Option<BiometricResult>` returns `None` until the first request
96// completes; this is the no-codegen prerequisite for that accessor
97// (mirrors `OptionLocationFix`). The `availability` accessor returns a
98// bare `BiometricKind` (NotAvailable encodes "none"), so no Option there.
99impl_option!(
100    BiometricResult,
101    OptionBiometricResult,
102    [Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash]
103);
104
105/// Configuration for one biometric-auth request — what the OS prompt
106/// shows and which fallbacks are allowed. Passed to
107/// `App::request_biometric_auth`.
108///
109/// Strings are plain [`AzString`]; an empty string means "use the
110/// platform default label" (so callers only override what they care
111/// about). This keeps the public surface engine-agnostic and codegen
112/// stays a single struct with no nested `Option<String>` wrappers.
113#[repr(C)]
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct BiometricPrompt {
116    /// Reason shown in the OS prompt — required on iOS
117    /// (`localizedReason`; the `NSFaceIDUsageDescription` plist key is
118    /// declared separately), shown as the Android subtitle and the
119    /// Windows / Linux message line. Empty is accepted but discouraged.
120    pub reason: AzString,
121    /// Label for the cancel / negative button (Android requires one;
122    /// iOS `localizedCancelTitle`). Empty → platform default ("Cancel").
123    pub cancel_label: AzString,
124    /// Allow the OS passcode / PIN / device-credential fallback when
125    /// biometrics fail or aren't enrolled. When the user takes that
126    /// path the result is [`BiometricResult::FellBackToPasscode`].
127    /// `false` = biometric-only (iOS `…WithBiometrics`, Android
128    /// `BIOMETRIC_STRONG` without `DEVICE_CREDENTIAL`).
129    pub allow_device_credential: bool,
130}
131
132impl Default for BiometricPrompt {
133    fn default() -> Self {
134        Self {
135            reason: AzString::from_const_str(""),
136            cancel_label: AzString::from_const_str(""),
137            allow_device_credential: false,
138        }
139    }
140}
141
142impl BiometricPrompt {
143    /// Convenience constructor: a biometric-only prompt showing `reason`,
144    /// with the platform-default cancel label and no passcode fallback.
145    #[must_use] pub fn new(reason: AzString) -> Self {
146        Self {
147            reason,
148            ..Self::default()
149        }
150    }
151}
152
153#[cfg(test)]
154mod autotest_generated {
155    use super::*;
156
157    // ------------------------------------------------------------------
158    // BiometricKind::is_available  (predicate)
159    // ------------------------------------------------------------------
160
161    /// basic_true_false: one known-false (`NotAvailable`) and the three
162    /// known-true sensor variants return the documented bool.
163    #[test]
164    fn is_available_known_true_false() {
165        assert!(!BiometricKind::NotAvailable.is_available());
166        assert!(BiometricKind::Fingerprint.is_available());
167        assert!(BiometricKind::Face.is_available());
168        assert!(BiometricKind::Iris.is_available());
169    }
170
171    /// edge_inputs: `Default::default()` is `NotAvailable`, so the default
172    /// gate must report "no biometric offered" — never panics.
173    #[test]
174    fn is_available_default_is_unavailable() {
175        assert_eq!(BiometricKind::default(), BiometricKind::NotAvailable);
176        assert!(!BiometricKind::default().is_available());
177    }
178
179    /// invariant: `is_available()` is exactly "not the NotAvailable
180    /// variant" for every variant — catches a mis-mapped `matches!`.
181    #[test]
182    fn is_available_iff_not_notavailable() {
183        let all = [
184            BiometricKind::NotAvailable,
185            BiometricKind::Fingerprint,
186            BiometricKind::Face,
187            BiometricKind::Iris,
188        ];
189        for k in all {
190            assert_eq!(k.is_available(), k != BiometricKind::NotAvailable);
191        }
192        // Exactly one variant is unavailable.
193        let unavailable = all.iter().filter(|k| !k.is_available()).count();
194        assert_eq!(unavailable, 1);
195    }
196
197    /// The `const fn` really is usable in a const context (compile-time
198    /// evaluation) — a regression guard against dropping `const`.
199    #[test]
200    fn is_available_const_evaluable() {
201        const NONE: BiometricKind = BiometricKind::NotAvailable;
202        const FACE: BiometricKind = BiometricKind::Face;
203        const NONE_AVAIL: bool = NONE.is_available();
204        const FACE_AVAIL: bool = FACE.is_available();
205        assert!(!NONE_AVAIL);
206        assert!(FACE_AVAIL);
207    }
208
209    // ------------------------------------------------------------------
210    // BiometricResult::is_success  (predicate)
211    // ------------------------------------------------------------------
212
213    /// basic_true_false + full variant sweep: only `Authenticated` and
214    /// `FellBackToPasscode` count as success. In particular `Failed`
215    /// (biometric mismatch) and `Cancelled` must NOT unlock the vault.
216    #[test]
217    fn is_success_per_variant() {
218        assert!(BiometricResult::Authenticated.is_success());
219        assert!(BiometricResult::FellBackToPasscode.is_success());
220
221        assert!(!BiometricResult::Failed.is_success());
222        assert!(!BiometricResult::Cancelled.is_success());
223        assert!(!BiometricResult::Unavailable.is_success());
224        assert!(!BiometricResult::Error.is_success());
225    }
226
227    /// invariant: exactly two of the six variants are successes — a naive
228    /// impl that treated `FellBackToPasscode` as failure, or `Failed` as
229    /// success, would break this count.
230    #[test]
231    fn is_success_exactly_two_successes() {
232        let all = [
233            BiometricResult::Authenticated,
234            BiometricResult::Failed,
235            BiometricResult::Cancelled,
236            BiometricResult::FellBackToPasscode,
237            BiometricResult::Unavailable,
238            BiometricResult::Error,
239        ];
240        let successes = all.iter().filter(|r| r.is_success()).count();
241        assert_eq!(successes, 2);
242        // Every non-success variant is deterministically false.
243        for r in all {
244            let expect = matches!(
245                r,
246                BiometricResult::Authenticated | BiometricResult::FellBackToPasscode
247            );
248            assert_eq!(r.is_success(), expect);
249        }
250    }
251
252    /// The `const fn` is const-evaluable — regression guard.
253    #[test]
254    fn is_success_const_evaluable() {
255        const OK: BiometricResult = BiometricResult::Authenticated;
256        const BAD: BiometricResult = BiometricResult::Failed;
257        const OK_S: bool = OK.is_success();
258        const BAD_S: bool = BAD.is_success();
259        assert!(OK_S);
260        assert!(!BAD_S);
261    }
262
263    // ------------------------------------------------------------------
264    // BiometricPrompt::new  (constructor)
265    // ------------------------------------------------------------------
266
267    /// invariants_hold: `new` copies `reason` verbatim and applies the
268    /// documented defaults (empty cancel label, no device-credential
269    /// fallback). Uses AzString content-equality.
270    #[test]
271    fn new_sets_reason_and_defaults() {
272        let reason = AzString::from("Unlock your vault");
273        let prompt = BiometricPrompt::new(reason.clone());
274        assert_eq!(prompt.reason, reason);
275        assert_eq!(prompt.reason.as_str(), "Unlock your vault");
276        assert!(prompt.cancel_label.is_empty());
277        assert!(!prompt.allow_device_credential);
278    }
279
280    /// `new("")` must be indistinguishable from `Default::default()`.
281    #[test]
282    fn new_empty_equals_default() {
283        let from_new = BiometricPrompt::new(AzString::from(""));
284        assert_eq!(from_new, BiometricPrompt::default());
285        assert!(from_new.reason.is_empty());
286    }
287
288    /// no_panic: a multi-megabyte ASCII reason is stored without
289    /// truncation or panic.
290    #[test]
291    fn new_huge_ascii_reason_no_panic() {
292        let big = "x".repeat(1_000_000);
293        let prompt = BiometricPrompt::new(AzString::from(big.as_str()));
294        assert_eq!(prompt.reason.as_str().len(), 1_000_000);
295        assert_eq!(prompt.reason.as_str(), big.as_str());
296        // Defaults still hold for the extreme input.
297        assert!(prompt.cancel_label.is_empty());
298        assert!(!prompt.allow_device_credential);
299    }
300
301    /// no_panic + unicode: emoji, combining marks, CJK and RTL text
302    /// survive byte-for-byte through the constructor.
303    #[test]
304    fn new_unicode_reason_roundtrips() {
305        let weird = "Fingerprint 🔒 verificação — 指紋 مرحبا e\u{0301}";
306        let prompt = BiometricPrompt::new(AzString::from(weird));
307        assert_eq!(prompt.reason.as_str(), weird);
308        assert_eq!(prompt.reason.as_bytes(), weird.as_bytes());
309        assert_eq!(prompt.reason.as_str().chars().count(), weird.chars().count());
310    }
311
312    /// A multibyte reason repeated to ~1 MB: char count and byte count
313    /// stay consistent (no UTF-8 boundary corruption).
314    #[test]
315    fn new_huge_multibyte_reason() {
316        let big = "λ".repeat(500_000); // 2 bytes each
317        let prompt = BiometricPrompt::new(AzString::from(big.as_str()));
318        assert_eq!(prompt.reason.as_str().chars().count(), 500_000);
319        assert_eq!(prompt.reason.as_bytes().len(), 1_000_000);
320    }
321
322    /// A reason containing embedded NUL / control bytes must NOT be
323    /// truncated at the NUL (guards against C-string-style handling).
324    #[test]
325    fn new_embedded_nul_and_control_preserved() {
326        let s = String::from("before\0mid\tline\nafter\r\u{7}end");
327        let prompt = BiometricPrompt::new(AzString::from(s.clone()));
328        assert_eq!(prompt.reason.as_str(), s.as_str());
329        assert_eq!(prompt.reason.as_bytes().len(), s.len());
330        // The NUL is retained, not used as a terminator.
331        assert!(prompt.reason.as_bytes().contains(&0));
332    }
333
334    /// Boundary reason lengths around common allocation/size thresholds
335    /// all round-trip their length exactly.
336    #[test]
337    fn new_boundary_lengths() {
338        for len in [0usize, 1, 2, 15, 16, 17, 255, 256, 4096] {
339            let s = "a".repeat(len);
340            let prompt = BiometricPrompt::new(AzString::from(s.as_str()));
341            assert_eq!(prompt.reason.as_str().len(), len, "len {len} mismatch");
342            assert!(!prompt.allow_device_credential);
343        }
344    }
345
346    /// `new` equals a hand-built struct with the same reason and the
347    /// documented default fields — pins the field wiring.
348    #[test]
349    fn new_matches_manual_construction() {
350        let reason = AzString::from("Confirm identity");
351        let via_new = BiometricPrompt::new(reason.clone());
352        let manual = BiometricPrompt {
353            reason,
354            cancel_label: AzString::from(""),
355            allow_device_credential: false,
356        };
357        assert_eq!(via_new, manual);
358    }
359
360    /// `new` never enables the device-credential fallback, whatever the
361    /// reason — it is documented as biometric-only.
362    #[test]
363    fn new_is_always_biometric_only() {
364        let reasons = [
365            String::new(),
366            "ok".to_string(),
367            "🔒".to_string(),
368            "z".repeat(10_000),
369        ];
370        for reason in &reasons {
371            let prompt = BiometricPrompt::new(AzString::from(reason.as_str()));
372            assert!(!prompt.allow_device_credential);
373            assert!(prompt.cancel_label.is_empty());
374        }
375    }
376
377    /// Clone of a constructed prompt compares equal (Clone/PartialEq
378    /// consistency with the deep AzString buffer).
379    #[test]
380    fn new_clone_is_equal() {
381        let prompt = BiometricPrompt::new(AzString::from("clone me"));
382        assert_eq!(prompt.clone(), prompt);
383    }
384
385    // ------------------------------------------------------------------
386    // OptionBiometricResult round-trip (encode == decode)
387    // ------------------------------------------------------------------
388
389    /// `Option<BiometricResult>` <-> `OptionBiometricResult` round-trips
390    /// losslessly for every variant and for `None`.
391    #[test]
392    fn option_result_roundtrips() {
393        let cases = [
394            None,
395            Some(BiometricResult::Authenticated),
396            Some(BiometricResult::Failed),
397            Some(BiometricResult::Cancelled),
398            Some(BiometricResult::FellBackToPasscode),
399            Some(BiometricResult::Unavailable),
400            Some(BiometricResult::Error),
401        ];
402        for original in cases {
403            let wrapped: OptionBiometricResult = original.into();
404            assert_eq!(wrapped.is_some(), original.is_some());
405            assert_eq!(wrapped.is_none(), original.is_none());
406            assert_eq!(wrapped.as_option(), original.as_ref());
407            let back: Option<BiometricResult> = wrapped.into();
408            assert_eq!(back, original);
409        }
410    }
411
412    // ------------------------------------------------------------------
413    // FFI discriminant stability (`#[repr(C)]` ABI contract)
414    //
415    // Both enums cross the FFI boundary as C enums; the platform backends
416    // (iOS/Android/Windows/Linux) map their native codes onto these
417    // discriminants. Re-ordering or inserting a variant silently
418    // re-numbers them, which would turn a `Cancelled` into a `Failed` —
419    // or worse, an `Error` into an `Authenticated` — for any already
420    // compiled shell. These pin the numbering.
421    // ------------------------------------------------------------------
422
423    /// `BiometricKind` discriminants are 0..=3 in declaration order, with
424    /// `NotAvailable == 0` (so a zeroed C struct means "no sensor").
425    #[test]
426    fn kind_discriminants_are_stable() {
427        assert_eq!(BiometricKind::NotAvailable as u32, 0);
428        assert_eq!(BiometricKind::Fingerprint as u32, 1);
429        assert_eq!(BiometricKind::Face as u32, 2);
430        assert_eq!(BiometricKind::Iris as u32, 3);
431        // The zero value is the safe/default one.
432        assert_eq!(BiometricKind::default() as u32, 0);
433    }
434
435    /// `BiometricResult` discriminants are 0..=5 in declaration order.
436    /// Note `Authenticated == 0`: a zeroed result is a *success*, so the
437    /// backends must never hand out a default-initialised result.
438    #[test]
439    fn result_discriminants_are_stable() {
440        assert_eq!(BiometricResult::Authenticated as u32, 0);
441        assert_eq!(BiometricResult::Failed as u32, 1);
442        assert_eq!(BiometricResult::Cancelled as u32, 2);
443        assert_eq!(BiometricResult::FellBackToPasscode as u32, 3);
444        assert_eq!(BiometricResult::Unavailable as u32, 4);
445        assert_eq!(BiometricResult::Error as u32, 5);
446    }
447
448    /// encode == decode: variant -> discriminant -> variant is the
449    /// identity for every variant, and out-of-range codes (the boundary /
450    /// overflow cases a native backend could hand us) decode to `None`
451    /// rather than being reinterpreted as a valid variant.
452    #[test]
453    fn discriminant_roundtrip_and_out_of_range() {
454        fn kind_from_u32(d: u32) -> Option<BiometricKind> {
455            match d {
456                0 => Some(BiometricKind::NotAvailable),
457                1 => Some(BiometricKind::Fingerprint),
458                2 => Some(BiometricKind::Face),
459                3 => Some(BiometricKind::Iris),
460                _ => None,
461            }
462        }
463        fn result_from_u32(d: u32) -> Option<BiometricResult> {
464            match d {
465                0 => Some(BiometricResult::Authenticated),
466                1 => Some(BiometricResult::Failed),
467                2 => Some(BiometricResult::Cancelled),
468                3 => Some(BiometricResult::FellBackToPasscode),
469                4 => Some(BiometricResult::Unavailable),
470                5 => Some(BiometricResult::Error),
471                _ => None,
472            }
473        }
474
475        for k in [
476            BiometricKind::NotAvailable,
477            BiometricKind::Fingerprint,
478            BiometricKind::Face,
479            BiometricKind::Iris,
480        ] {
481            assert_eq!(kind_from_u32(k as u32), Some(k));
482        }
483        for r in [
484            BiometricResult::Authenticated,
485            BiometricResult::Failed,
486            BiometricResult::Cancelled,
487            BiometricResult::FellBackToPasscode,
488            BiometricResult::Unavailable,
489            BiometricResult::Error,
490        ] {
491            assert_eq!(result_from_u32(r as u32), Some(r));
492        }
493
494        // Boundary / bogus native codes.
495        assert_eq!(kind_from_u32(4), None);
496        assert_eq!(kind_from_u32(u32::MAX), None);
497        assert_eq!(result_from_u32(6), None);
498        assert_eq!(result_from_u32(u32::MAX), None);
499    }
500
501    /// Both C enums share one underlying repr, and the FFI Option wrapper
502    /// is at least as large as its payload (no niche-packing surprise that
503    /// would break the `#[repr(C, u8)]` layout the shells rely on).
504    #[test]
505    fn repr_layout_invariants() {
506        use core::mem::size_of;
507        assert_eq!(size_of::<BiometricKind>(), size_of::<BiometricResult>());
508        assert!(size_of::<OptionBiometricResult>() >= size_of::<BiometricResult>());
509        assert!(size_of::<BiometricKind>() > 0);
510    }
511
512    // ------------------------------------------------------------------
513    // Ord / Hash / Copy derive invariants
514    // ------------------------------------------------------------------
515
516    /// The derived total order follows declaration order (it is the same
517    /// order the discriminants encode), and sorting is stable against it.
518    #[test]
519    fn ord_matches_declaration_order() {
520        assert!(BiometricKind::NotAvailable < BiometricKind::Fingerprint);
521        assert!(BiometricKind::Fingerprint < BiometricKind::Face);
522        assert!(BiometricKind::Face < BiometricKind::Iris);
523
524        let mut kinds = [
525            BiometricKind::Iris,
526            BiometricKind::NotAvailable,
527            BiometricKind::Face,
528            BiometricKind::Fingerprint,
529        ];
530        kinds.sort_unstable();
531        assert_eq!(
532            kinds,
533            [
534                BiometricKind::NotAvailable,
535                BiometricKind::Fingerprint,
536                BiometricKind::Face,
537                BiometricKind::Iris,
538            ]
539        );
540        // The one unavailable kind sorts first — `kinds[1..]` are all real
541        // sensors.
542        assert!(!kinds[0].is_available());
543        assert!(kinds[1..].iter().all(BiometricKind::is_available));
544
545        let mut results = [
546            BiometricResult::Error,
547            BiometricResult::Authenticated,
548            BiometricResult::Cancelled,
549        ];
550        results.sort_unstable();
551        assert_eq!(
552            results,
553            [
554                BiometricResult::Authenticated,
555                BiometricResult::Cancelled,
556                BiometricResult::Error,
557            ]
558        );
559    }
560
561    /// Hash is variant-distinguishing (no two variants collide in a set)
562    /// and equal values hash equal — the manager keys cached results by
563    /// these types.
564    #[test]
565    fn hash_is_consistent_and_distinct() {
566        use std::{
567            collections::{hash_map::DefaultHasher, HashSet},
568            hash::{Hash, Hasher},
569        };
570
571        fn hash_of<T: Hash>(t: &T) -> u64 {
572            let mut h = DefaultHasher::new();
573            t.hash(&mut h);
574            h.finish()
575        }
576
577        let kinds = [
578            BiometricKind::NotAvailable,
579            BiometricKind::Fingerprint,
580            BiometricKind::Face,
581            BiometricKind::Iris,
582        ];
583        let results = [
584            BiometricResult::Authenticated,
585            BiometricResult::Failed,
586            BiometricResult::Cancelled,
587            BiometricResult::FellBackToPasscode,
588            BiometricResult::Unavailable,
589            BiometricResult::Error,
590        ];
591
592        assert_eq!(kinds.iter().collect::<HashSet<_>>().len(), 4);
593        assert_eq!(results.iter().collect::<HashSet<_>>().len(), 6);
594
595        // Eq => equal hashes (the Hash/Eq contract).
596        for k in kinds {
597            let copy = k;
598            assert_eq!(k, copy);
599            assert_eq!(hash_of(&k), hash_of(&copy));
600        }
601        for r in results {
602            let copy = r;
603            assert_eq!(r, copy);
604            assert_eq!(hash_of(&r), hash_of(&copy));
605        }
606    }
607
608    /// Both enums are `Copy`: reading a result does not consume it, so a
609    /// manager can hand the same value to several callbacks.
610    #[test]
611    fn enums_are_copy() {
612        let r = BiometricResult::FellBackToPasscode;
613        let moved = r;
614        // `r` is still usable — this only compiles/behaves if `Copy`.
615        assert!(r.is_success());
616        assert!(moved.is_success());
617        assert_eq!(r, moved);
618
619        let k = BiometricKind::Face;
620        let moved_k = k;
621        assert!(k.is_available());
622        assert_eq!(k, moved_k);
623    }
624
625    // ------------------------------------------------------------------
626    // Documented cross-type pairing
627    // ------------------------------------------------------------------
628
629    /// `BiometricResult::Unavailable` is documented to pair with
630    /// `BiometricKind::NotAvailable`: neither may unlock anything.
631    #[test]
632    fn unavailable_pairs_with_not_available() {
633        assert!(!BiometricResult::Unavailable.is_success());
634        assert!(!BiometricKind::NotAvailable.is_available());
635        // Every non-success result must not accidentally be readable as an
636        // available sensor kind through the shared discriminant space.
637        assert_ne!(
638            BiometricResult::Authenticated as u32,
639            BiometricResult::Unavailable as u32
640        );
641    }
642
643    // ------------------------------------------------------------------
644    // OptionBiometricResult — the FFI accessor's "no request yet" state
645    // ------------------------------------------------------------------
646
647    /// The wrapper defaults to `None` ("no request has completed yet") —
648    /// crucially NOT to `Some(Authenticated)`, which discriminant 0 of the
649    /// payload would be.
650    #[test]
651    fn option_result_default_is_none() {
652        let d = OptionBiometricResult::default();
653        assert!(d.is_none());
654        assert!(!d.is_some());
655        assert_eq!(d.as_option(), None);
656        assert_eq!(Option::<BiometricResult>::from(d), None);
657        // A defaulted accessor must never read as a successful unlock.
658        assert!(!d.as_option().is_some_and(BiometricResult::is_success));
659    }
660
661    /// `replace` has `mem::replace` semantics: it returns the *previous*
662    /// value and installs the new one. A vault gate that mis-read the
663    /// return value would act on the stale result.
664    #[test]
665    fn option_result_replace_returns_previous() {
666        let mut slot = OptionBiometricResult::None;
667        let prev = slot.replace(BiometricResult::Failed);
668        assert!(prev.is_none());
669        assert_eq!(slot, OptionBiometricResult::Some(BiometricResult::Failed));
670
671        let prev = slot.replace(BiometricResult::Authenticated);
672        assert_eq!(prev, OptionBiometricResult::Some(BiometricResult::Failed));
673        assert_eq!(
674            slot,
675            OptionBiometricResult::Some(BiometricResult::Authenticated)
676        );
677        assert!(slot.as_option().is_some_and(BiometricResult::is_success));
678        // The previous (failed) attempt is not a success.
679        assert!(!prev.as_option().is_some_and(BiometricResult::is_success));
680    }
681
682    /// `map` / `and_then` / `as_ref` / `as_mut` / `into_option` behave
683    /// exactly like the std `Option` equivalents for every variant.
684    #[test]
685    fn option_result_combinators_match_std_option() {
686        for original in [
687            None,
688            Some(BiometricResult::Authenticated),
689            Some(BiometricResult::Failed),
690            Some(BiometricResult::Cancelled),
691            Some(BiometricResult::FellBackToPasscode),
692            Some(BiometricResult::Unavailable),
693            Some(BiometricResult::Error),
694        ] {
695            let wrapped: OptionBiometricResult = original.into();
696
697            assert_eq!(wrapped.into_option(), original);
698            assert_eq!(wrapped.as_ref(), original.as_ref());
699            assert_eq!(
700                wrapped.map(|r| r.is_success()),
701                original.map(|r| r.is_success())
702            );
703            assert_eq!(
704                wrapped.and_then(|r| r.is_success().then_some(r)),
705                original.and_then(|r| r.is_success().then_some(r))
706            );
707
708            // as_mut writes through to the wrapper (and is `None` exactly
709            // when the source Option was `None` — it must not conjure a
710            // slot out of the `None` variant).
711            let mut target = wrapped;
712            let wrote = target.as_mut().map(|slot| *slot = BiometricResult::Error);
713            assert_eq!(wrote.is_some(), original.is_some());
714            if original.is_some() {
715                assert_eq!(target, OptionBiometricResult::Some(BiometricResult::Error));
716            } else {
717                assert_eq!(target, OptionBiometricResult::None);
718            }
719        }
720    }
721
722    /// The wrapper's derived order puts `None` before every `Some(_)`, and
723    /// `Some(_)` follows the payload order — pins the `#[repr(C, u8)]`
724    /// tag ordering that codegen mirrors.
725    #[test]
726    fn option_result_ord_none_first() {
727        assert!(
728            OptionBiometricResult::None
729                < OptionBiometricResult::Some(BiometricResult::Authenticated)
730        );
731        assert!(
732            OptionBiometricResult::Some(BiometricResult::Authenticated)
733                < OptionBiometricResult::Some(BiometricResult::Error)
734        );
735    }
736
737    // ------------------------------------------------------------------
738    // BiometricPrompt — equality / field wiring / string invariants
739    // ------------------------------------------------------------------
740
741    /// `PartialEq` is sensitive to *every* field: two prompts that differ
742    /// only in the cancel label, only in the reason, or only in the
743    /// device-credential flag must not compare equal. A derive that
744    /// dropped a field would let a biometric-only prompt compare equal to
745    /// a passcode-fallback one.
746    #[test]
747    fn prompt_eq_is_field_sensitive() {
748        let base = BiometricPrompt {
749            reason: AzString::from("Unlock"),
750            cancel_label: AzString::from("Nope"),
751            allow_device_credential: true,
752        };
753
754        let mut other_reason = base.clone();
755        other_reason.reason = AzString::from("Unlock!");
756        assert_ne!(base, other_reason);
757
758        let mut other_cancel = base.clone();
759        other_cancel.cancel_label = AzString::from("nope");
760        assert_ne!(base, other_cancel);
761
762        let mut other_flag = base.clone();
763        other_flag.allow_device_credential = false;
764        assert_ne!(base, other_flag);
765
766        // …and an identical rebuild does compare equal (deep AzString eq).
767        let same = BiometricPrompt {
768            reason: AzString::from("Unlock"),
769            cancel_label: AzString::from("Nope"),
770            allow_device_credential: true,
771        };
772        assert_eq!(base, same);
773    }
774
775    /// Post-construction length invariants: `len()`, `as_str().len()` and
776    /// `as_bytes().len()` agree for ASCII, multibyte, embedded-NUL and
777    /// empty reasons — no off-by-one or capacity/len confusion.
778    #[test]
779    fn prompt_string_len_invariants() {
780        let cases = [
781            String::new(),
782            "a".to_string(),
783            "λ🔒指".to_string(),
784            String::from("nul\0inside"),
785            "z".repeat(70_000),
786        ];
787        for s in &cases {
788            let prompt = BiometricPrompt::new(AzString::from(s.as_str()));
789            assert_eq!(prompt.reason.len(), s.len());
790            assert_eq!(prompt.reason.as_str().len(), s.len());
791            assert_eq!(prompt.reason.as_bytes().len(), s.len());
792            assert_eq!(prompt.reason.is_empty(), s.is_empty());
793            // The untouched field keeps its documented default.
794            assert_eq!(prompt.cancel_label.len(), 0);
795            assert!(prompt.cancel_label.is_empty());
796        }
797    }
798
799    /// No Unicode normalization happens: an NFC "é" and an NFD "e" + U+0301
800    /// stay distinct byte sequences (a normalizing string type would make
801    /// these two prompts compare equal and silently change what the OS
802    /// modal displays).
803    #[test]
804    fn prompt_does_not_normalize_unicode() {
805        let nfc = BiometricPrompt::new(AzString::from("caf\u{e9}"));
806        let nfd = BiometricPrompt::new(AzString::from("cafe\u{301}"));
807        assert_ne!(nfc, nfd);
808        assert_eq!(nfc.reason.as_bytes().len(), 5);
809        assert_eq!(nfd.reason.as_bytes().len(), 6);
810        assert_eq!(nfc.reason.as_str().chars().count(), 4);
811        assert_eq!(nfd.reason.as_str().chars().count(), 5);
812    }
813
814    /// The constructor does not trim, collapse or otherwise rewrite the
815    /// reason — leading/trailing whitespace survives verbatim.
816    #[test]
817    fn prompt_preserves_whitespace_verbatim() {
818        let raw = "  \t Unlock the vault \n ";
819        let prompt = BiometricPrompt::new(AzString::from(raw));
820        assert_eq!(prompt.reason.as_str(), raw);
821        assert_ne!(prompt.reason.as_str(), raw.trim());
822    }
823
824    /// `AzString` round-trips out of a constructed prompt unchanged
825    /// (`into_library_owned_string` gives back exactly what went in),
826    /// including for a multibyte reason.
827    #[test]
828    fn prompt_reason_string_roundtrip() {
829        for original in [String::new(), "Unlock".to_string(), "🔒λ指紋".to_string()] {
830            let prompt = BiometricPrompt::new(AzString::from(original.clone()));
831            let back = prompt.reason.clone().into_library_owned_string();
832            assert_eq!(back, original);
833        }
834    }
835
836    /// A prompt whose *both* strings are ~1 MB clones deeply and compares
837    /// equal — exercises the deep-copy path for the non-defaulted field
838    /// too (the constructor never sets it, so it is built by hand).
839    #[test]
840    fn prompt_huge_both_strings_clone_deeply() {
841        let big_reason = "r".repeat(1_000_000);
842        let big_cancel = "c".repeat(1_000_000);
843        let prompt = BiometricPrompt {
844            reason: AzString::from(big_reason.as_str()),
845            cancel_label: AzString::from(big_cancel.as_str()),
846            allow_device_credential: true,
847        };
848        let cloned = prompt.clone();
849        assert_eq!(cloned, prompt);
850        assert_eq!(cloned.reason.as_str(), big_reason.as_str());
851        assert_eq!(cloned.cancel_label.as_str(), big_cancel.as_str());
852        assert!(cloned.allow_device_credential);
853        // The two buffers did not get aliased/swapped by the clone.
854        assert_ne!(cloned.reason, cloned.cancel_label);
855    }
856
857    /// `Default::default()` is idempotent and fully empty — two defaults
858    /// compare equal and neither enables the passcode fallback.
859    #[test]
860    fn prompt_default_is_empty_and_stable() {
861        let a = BiometricPrompt::default();
862        let b = BiometricPrompt::default();
863        assert_eq!(a, b);
864        assert!(a.reason.is_empty());
865        assert!(a.cancel_label.is_empty());
866        assert!(!a.allow_device_credential);
867        // An empty reason is accepted (documented as "discouraged", not
868        // rejected) — the constructor must not panic on it.
869        assert_eq!(BiometricPrompt::new(AzString::from_const_str("")), a);
870    }
871
872    /// `Debug` renders the reason (used in backend logs); it must not lose
873    /// the field or print a placeholder.
874    #[test]
875    fn prompt_debug_contains_fields() {
876        let prompt = BiometricPrompt {
877            reason: AzString::from("Unlock vault"),
878            cancel_label: AzString::from("Abort"),
879            allow_device_credential: true,
880        };
881        let dbg = format!("{prompt:?}");
882        assert!(dbg.contains("BiometricPrompt"), "{dbg}");
883        assert!(dbg.contains("Unlock vault"), "{dbg}");
884        assert!(dbg.contains("Abort"), "{dbg}");
885        assert!(dbg.contains("true"), "{dbg}");
886    }
887}