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

            
16
use 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)]
29
pub 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

            
43
impl 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
77
    #[must_use] pub const fn is_available(&self) -> bool {
47
77
        !matches!(self, Self::NotAvailable)
48
77
    }
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)]
59
pub 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

            
82
impl 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
212
    #[must_use] pub const fn is_success(&self) -> bool {
87
71
        matches!(
88
212
            self,
89
            Self::Authenticated | Self::FellBackToPasscode
90
        )
91
212
    }
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.
99
impl_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)]
115
pub 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

            
132
impl Default for BiometricPrompt {
133
1224
    fn default() -> Self {
134
1224
        Self {
135
1224
            reason: AzString::from_const_str(""),
136
1224
            cancel_label: AzString::from_const_str(""),
137
1224
            allow_device_credential: false,
138
1224
        }
139
1224
    }
140
}
141

            
142
impl BiometricPrompt {
143
    /// Convenience constructor: a biometric-only prompt showing `reason`,
144
    /// with the platform-default cancel label and no passcode fallback.
145
1199
    #[must_use] pub fn new(reason: AzString) -> Self {
146
1199
        Self {
147
1199
            reason,
148
1199
            ..Self::default()
149
1199
        }
150
1199
    }
151
}
152

            
153
#[cfg(test)]
154
mod 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
        const _: () = assert!(!NONE_AVAIL && FACE_AVAIL);
206
    }
207

            
208
    // ------------------------------------------------------------------
209
    // BiometricResult::is_success  (predicate)
210
    // ------------------------------------------------------------------
211

            
212
    /// basic_true_false + full variant sweep: only `Authenticated` and
213
    /// `FellBackToPasscode` count as success. In particular `Failed`
214
    /// (biometric mismatch) and `Cancelled` must NOT unlock the vault.
215
    #[test]
216
    fn is_success_per_variant() {
217
        assert!(BiometricResult::Authenticated.is_success());
218
        assert!(BiometricResult::FellBackToPasscode.is_success());
219

            
220
        assert!(!BiometricResult::Failed.is_success());
221
        assert!(!BiometricResult::Cancelled.is_success());
222
        assert!(!BiometricResult::Unavailable.is_success());
223
        assert!(!BiometricResult::Error.is_success());
224
    }
225

            
226
    /// invariant: exactly two of the six variants are successes — a naive
227
    /// impl that treated `FellBackToPasscode` as failure, or `Failed` as
228
    /// success, would break this count.
229
    #[test]
230
    fn is_success_exactly_two_successes() {
231
        let all = [
232
            BiometricResult::Authenticated,
233
            BiometricResult::Failed,
234
            BiometricResult::Cancelled,
235
            BiometricResult::FellBackToPasscode,
236
            BiometricResult::Unavailable,
237
            BiometricResult::Error,
238
        ];
239
        let successes = all.iter().filter(|r| r.is_success()).count();
240
        assert_eq!(successes, 2);
241
        // Every non-success variant is deterministically false.
242
        for r in all {
243
            let expect = matches!(
244
                r,
245
                BiometricResult::Authenticated | BiometricResult::FellBackToPasscode
246
            );
247
            assert_eq!(r.is_success(), expect);
248
        }
249
    }
250

            
251
    /// The `const fn` is const-evaluable — regression guard.
252
    #[test]
253
    fn is_success_const_evaluable() {
254
        const OK: BiometricResult = BiometricResult::Authenticated;
255
        const BAD: BiometricResult = BiometricResult::Failed;
256
        const OK_S: bool = OK.is_success();
257
        const BAD_S: bool = BAD.is_success();
258
        const _: () = assert!(OK_S && !BAD_S);
259
    }
260

            
261
    // ------------------------------------------------------------------
262
    // BiometricPrompt::new  (constructor)
263
    // ------------------------------------------------------------------
264

            
265
    /// invariants_hold: `new` copies `reason` verbatim and applies the
266
    /// documented defaults (empty cancel label, no device-credential
267
    /// fallback). Uses AzString content-equality.
268
    #[test]
269
    fn new_sets_reason_and_defaults() {
270
        let reason = AzString::from("Unlock your vault");
271
        let prompt = BiometricPrompt::new(reason.clone());
272
        assert_eq!(prompt.reason, reason);
273
        assert_eq!(prompt.reason.as_str(), "Unlock your vault");
274
        assert!(prompt.cancel_label.is_empty());
275
        assert!(!prompt.allow_device_credential);
276
    }
277

            
278
    /// `new("")` must be indistinguishable from `Default::default()`.
279
    #[test]
280
    fn new_empty_equals_default() {
281
        let from_new = BiometricPrompt::new(AzString::from(""));
282
        assert_eq!(from_new, BiometricPrompt::default());
283
        assert!(from_new.reason.is_empty());
284
    }
285

            
286
    /// no_panic: a multi-megabyte ASCII reason is stored without
287
    /// truncation or panic.
288
    #[test]
289
    fn new_huge_ascii_reason_no_panic() {
290
        let big = "x".repeat(1_000_000);
291
        let prompt = BiometricPrompt::new(AzString::from(big.as_str()));
292
        assert_eq!(prompt.reason.as_str().len(), 1_000_000);
293
        assert_eq!(prompt.reason.as_str(), big.as_str());
294
        // Defaults still hold for the extreme input.
295
        assert!(prompt.cancel_label.is_empty());
296
        assert!(!prompt.allow_device_credential);
297
    }
298

            
299
    /// no_panic + unicode: emoji, combining marks, CJK and RTL text
300
    /// survive byte-for-byte through the constructor.
301
    #[test]
302
    fn new_unicode_reason_roundtrips() {
303
        let weird = "Fingerprint 🔒 verificação — 指紋 مرحبا e\u{0301}";
304
        let prompt = BiometricPrompt::new(AzString::from(weird));
305
        assert_eq!(prompt.reason.as_str(), weird);
306
        assert_eq!(prompt.reason.as_bytes(), weird.as_bytes());
307
        assert_eq!(prompt.reason.as_str().chars().count(), weird.chars().count());
308
    }
309

            
310
    /// A multibyte reason repeated to ~1 MB: char count and byte count
311
    /// stay consistent (no UTF-8 boundary corruption).
312
    #[test]
313
    fn new_huge_multibyte_reason() {
314
        let big = "λ".repeat(500_000); // 2 bytes each
315
        let prompt = BiometricPrompt::new(AzString::from(big.as_str()));
316
        assert_eq!(prompt.reason.as_str().chars().count(), 500_000);
317
        assert_eq!(prompt.reason.as_bytes().len(), 1_000_000);
318
    }
319

            
320
    /// A reason containing embedded NUL / control bytes must NOT be
321
    /// truncated at the NUL (guards against C-string-style handling).
322
    #[test]
323
    fn new_embedded_nul_and_control_preserved() {
324
        let s = String::from("before\0mid\tline\nafter\r\u{7}end");
325
        let prompt = BiometricPrompt::new(AzString::from(s.clone()));
326
        assert_eq!(prompt.reason.as_str(), s.as_str());
327
        assert_eq!(prompt.reason.as_bytes().len(), s.len());
328
        // The NUL is retained, not used as a terminator.
329
        assert!(prompt.reason.as_bytes().contains(&0));
330
    }
331

            
332
    /// Boundary reason lengths around common allocation/size thresholds
333
    /// all round-trip their length exactly.
334
    #[test]
335
    fn new_boundary_lengths() {
336
        for len in [0usize, 1, 2, 15, 16, 17, 255, 256, 4096] {
337
            let s = "a".repeat(len);
338
            let prompt = BiometricPrompt::new(AzString::from(s.as_str()));
339
            assert_eq!(prompt.reason.as_str().len(), len, "len {len} mismatch");
340
            assert!(!prompt.allow_device_credential);
341
        }
342
    }
343

            
344
    /// `new` equals a hand-built struct with the same reason and the
345
    /// documented default fields — pins the field wiring.
346
    #[test]
347
    fn new_matches_manual_construction() {
348
        let reason = AzString::from("Confirm identity");
349
        let via_new = BiometricPrompt::new(reason.clone());
350
        let manual = BiometricPrompt {
351
            reason,
352
            cancel_label: AzString::from(""),
353
            allow_device_credential: false,
354
        };
355
        assert_eq!(via_new, manual);
356
    }
357

            
358
    /// `new` never enables the device-credential fallback, whatever the
359
    /// reason — it is documented as biometric-only.
360
    #[test]
361
    fn new_is_always_biometric_only() {
362
        let reasons = [
363
            String::new(),
364
            "ok".to_string(),
365
            "🔒".to_string(),
366
            "z".repeat(10_000),
367
        ];
368
        for reason in &reasons {
369
            let prompt = BiometricPrompt::new(AzString::from(reason.as_str()));
370
            assert!(!prompt.allow_device_credential);
371
            assert!(prompt.cancel_label.is_empty());
372
        }
373
    }
374

            
375
    /// Clone of a constructed prompt compares equal (Clone/PartialEq
376
    /// consistency with the deep AzString buffer).
377
    #[test]
378
    fn new_clone_is_equal() {
379
        let prompt = BiometricPrompt::new(AzString::from("clone me"));
380
        assert_eq!(prompt.clone(), prompt);
381
    }
382

            
383
    // ------------------------------------------------------------------
384
    // OptionBiometricResult round-trip (encode == decode)
385
    // ------------------------------------------------------------------
386

            
387
    /// `Option<BiometricResult>` <-> `OptionBiometricResult` round-trips
388
    /// losslessly for every variant and for `None`.
389
    #[test]
390
    fn option_result_roundtrips() {
391
        let cases = [
392
            None,
393
            Some(BiometricResult::Authenticated),
394
            Some(BiometricResult::Failed),
395
            Some(BiometricResult::Cancelled),
396
            Some(BiometricResult::FellBackToPasscode),
397
            Some(BiometricResult::Unavailable),
398
            Some(BiometricResult::Error),
399
        ];
400
        for original in cases {
401
            let wrapped: OptionBiometricResult = original.into();
402
            assert_eq!(wrapped.is_some(), original.is_some());
403
            assert_eq!(wrapped.is_none(), original.is_none());
404
            assert_eq!(wrapped.as_option(), original.as_ref());
405
            let back: Option<BiometricResult> = wrapped.into();
406
            assert_eq!(back, original);
407
        }
408
    }
409

            
410
    // ------------------------------------------------------------------
411
    // FFI discriminant stability (`#[repr(C)]` ABI contract)
412
    //
413
    // Both enums cross the FFI boundary as C enums; the platform backends
414
    // (iOS/Android/Windows/Linux) map their native codes onto these
415
    // discriminants. Re-ordering or inserting a variant silently
416
    // re-numbers them, which would turn a `Cancelled` into a `Failed` —
417
    // or worse, an `Error` into an `Authenticated` — for any already
418
    // compiled shell. These pin the numbering.
419
    // ------------------------------------------------------------------
420

            
421
    /// `BiometricKind` discriminants are 0..=3 in declaration order, with
422
    /// `NotAvailable == 0` (so a zeroed C struct means "no sensor").
423
    #[test]
424
    fn kind_discriminants_are_stable() {
425
        assert_eq!(BiometricKind::NotAvailable as u32, 0);
426
        assert_eq!(BiometricKind::Fingerprint as u32, 1);
427
        assert_eq!(BiometricKind::Face as u32, 2);
428
        assert_eq!(BiometricKind::Iris as u32, 3);
429
        // The zero value is the safe/default one.
430
        assert_eq!(BiometricKind::default() as u32, 0);
431
    }
432

            
433
    /// `BiometricResult` discriminants are 0..=5 in declaration order.
434
    /// Note `Authenticated == 0`: a zeroed result is a *success*, so the
435
    /// backends must never hand out a default-initialised result.
436
    #[test]
437
    fn result_discriminants_are_stable() {
438
        assert_eq!(BiometricResult::Authenticated as u32, 0);
439
        assert_eq!(BiometricResult::Failed as u32, 1);
440
        assert_eq!(BiometricResult::Cancelled as u32, 2);
441
        assert_eq!(BiometricResult::FellBackToPasscode as u32, 3);
442
        assert_eq!(BiometricResult::Unavailable as u32, 4);
443
        assert_eq!(BiometricResult::Error as u32, 5);
444
    }
445

            
446
    /// encode == decode: variant -> discriminant -> variant is the
447
    /// identity for every variant, and out-of-range codes (the boundary /
448
    /// overflow cases a native backend could hand us) decode to `None`
449
    /// rather than being reinterpreted as a valid variant.
450
    #[test]
451
    fn discriminant_roundtrip_and_out_of_range() {
452
        fn kind_from_u32(d: u32) -> Option<BiometricKind> {
453
            match d {
454
                0 => Some(BiometricKind::NotAvailable),
455
                1 => Some(BiometricKind::Fingerprint),
456
                2 => Some(BiometricKind::Face),
457
                3 => Some(BiometricKind::Iris),
458
                _ => None,
459
            }
460
        }
461
        fn result_from_u32(d: u32) -> Option<BiometricResult> {
462
            match d {
463
                0 => Some(BiometricResult::Authenticated),
464
                1 => Some(BiometricResult::Failed),
465
                2 => Some(BiometricResult::Cancelled),
466
                3 => Some(BiometricResult::FellBackToPasscode),
467
                4 => Some(BiometricResult::Unavailable),
468
                5 => Some(BiometricResult::Error),
469
                _ => None,
470
            }
471
        }
472

            
473
        for k in [
474
            BiometricKind::NotAvailable,
475
            BiometricKind::Fingerprint,
476
            BiometricKind::Face,
477
            BiometricKind::Iris,
478
        ] {
479
            assert_eq!(kind_from_u32(k as u32), Some(k));
480
        }
481
        for r in [
482
            BiometricResult::Authenticated,
483
            BiometricResult::Failed,
484
            BiometricResult::Cancelled,
485
            BiometricResult::FellBackToPasscode,
486
            BiometricResult::Unavailable,
487
            BiometricResult::Error,
488
        ] {
489
            assert_eq!(result_from_u32(r as u32), Some(r));
490
        }
491

            
492
        // Boundary / bogus native codes.
493
        assert_eq!(kind_from_u32(4), None);
494
        assert_eq!(kind_from_u32(u32::MAX), None);
495
        assert_eq!(result_from_u32(6), None);
496
        assert_eq!(result_from_u32(u32::MAX), None);
497
    }
498

            
499
    /// Both C enums share one underlying repr, and the FFI Option wrapper
500
    /// is at least as large as its payload (no niche-packing surprise that
501
    /// would break the `#[repr(C, u8)]` layout the shells rely on).
502
    #[test]
503
    fn repr_layout_invariants() {
504
        use core::mem::size_of;
505
        assert_eq!(size_of::<BiometricKind>(), size_of::<BiometricResult>());
506
        assert!(size_of::<OptionBiometricResult>() >= size_of::<BiometricResult>());
507
        assert!(size_of::<BiometricKind>() > 0);
508
    }
509

            
510
    // ------------------------------------------------------------------
511
    // Ord / Hash / Copy derive invariants
512
    // ------------------------------------------------------------------
513

            
514
    /// The derived total order follows declaration order (it is the same
515
    /// order the discriminants encode), and sorting is stable against it.
516
    #[test]
517
    fn ord_matches_declaration_order() {
518
        assert!(BiometricKind::NotAvailable < BiometricKind::Fingerprint);
519
        assert!(BiometricKind::Fingerprint < BiometricKind::Face);
520
        assert!(BiometricKind::Face < BiometricKind::Iris);
521

            
522
        let mut kinds = [
523
            BiometricKind::Iris,
524
            BiometricKind::NotAvailable,
525
            BiometricKind::Face,
526
            BiometricKind::Fingerprint,
527
        ];
528
        kinds.sort_unstable();
529
        assert_eq!(
530
            kinds,
531
            [
532
                BiometricKind::NotAvailable,
533
                BiometricKind::Fingerprint,
534
                BiometricKind::Face,
535
                BiometricKind::Iris,
536
            ]
537
        );
538
        // The one unavailable kind sorts first — `kinds[1..]` are all real
539
        // sensors.
540
        assert!(!kinds[0].is_available());
541
        assert!(kinds[1..].iter().all(BiometricKind::is_available));
542

            
543
        let mut results = [
544
            BiometricResult::Error,
545
            BiometricResult::Authenticated,
546
            BiometricResult::Cancelled,
547
        ];
548
        results.sort_unstable();
549
        assert_eq!(
550
            results,
551
            [
552
                BiometricResult::Authenticated,
553
                BiometricResult::Cancelled,
554
                BiometricResult::Error,
555
            ]
556
        );
557
    }
558

            
559
    /// Hash is variant-distinguishing (no two variants collide in a set)
560
    /// and equal values hash equal — the manager keys cached results by
561
    /// these types.
562
    #[test]
563
    fn hash_is_consistent_and_distinct() {
564
        use std::{
565
            collections::{hash_map::DefaultHasher, HashSet},
566
            hash::{Hash, Hasher},
567
        };
568

            
569
        fn hash_of<T: Hash>(t: &T) -> u64 {
570
            let mut h = DefaultHasher::new();
571
            t.hash(&mut h);
572
            h.finish()
573
        }
574

            
575
        let kinds = [
576
            BiometricKind::NotAvailable,
577
            BiometricKind::Fingerprint,
578
            BiometricKind::Face,
579
            BiometricKind::Iris,
580
        ];
581
        let results = [
582
            BiometricResult::Authenticated,
583
            BiometricResult::Failed,
584
            BiometricResult::Cancelled,
585
            BiometricResult::FellBackToPasscode,
586
            BiometricResult::Unavailable,
587
            BiometricResult::Error,
588
        ];
589

            
590
        assert_eq!(kinds.iter().collect::<HashSet<_>>().len(), 4);
591
        assert_eq!(results.iter().collect::<HashSet<_>>().len(), 6);
592

            
593
        // Eq => equal hashes (the Hash/Eq contract).
594
        for k in kinds {
595
            let copy = k;
596
            assert_eq!(k, copy);
597
            assert_eq!(hash_of(&k), hash_of(&copy));
598
        }
599
        for r in results {
600
            let copy = r;
601
            assert_eq!(r, copy);
602
            assert_eq!(hash_of(&r), hash_of(&copy));
603
        }
604
    }
605

            
606
    /// Both enums are `Copy`: reading a result does not consume it, so a
607
    /// manager can hand the same value to several callbacks.
608
    #[test]
609
    fn enums_are_copy() {
610
        let r = BiometricResult::FellBackToPasscode;
611
        let moved = r;
612
        // `r` is still usable — this only compiles/behaves if `Copy`.
613
        assert!(r.is_success());
614
        assert!(moved.is_success());
615
        assert_eq!(r, moved);
616

            
617
        let k = BiometricKind::Face;
618
        let moved_k = k;
619
        assert!(k.is_available());
620
        assert_eq!(k, moved_k);
621
    }
622

            
623
    // ------------------------------------------------------------------
624
    // Documented cross-type pairing
625
    // ------------------------------------------------------------------
626

            
627
    /// `BiometricResult::Unavailable` is documented to pair with
628
    /// `BiometricKind::NotAvailable`: neither may unlock anything.
629
    #[test]
630
    fn unavailable_pairs_with_not_available() {
631
        assert!(!BiometricResult::Unavailable.is_success());
632
        assert!(!BiometricKind::NotAvailable.is_available());
633
        // Every non-success result must not accidentally be readable as an
634
        // available sensor kind through the shared discriminant space.
635
        assert_ne!(
636
            BiometricResult::Authenticated as u32,
637
            BiometricResult::Unavailable as u32
638
        );
639
    }
640

            
641
    // ------------------------------------------------------------------
642
    // OptionBiometricResult — the FFI accessor's "no request yet" state
643
    // ------------------------------------------------------------------
644

            
645
    /// The wrapper defaults to `None` ("no request has completed yet") —
646
    /// crucially NOT to `Some(Authenticated)`, which discriminant 0 of the
647
    /// payload would be.
648
    #[test]
649
    fn option_result_default_is_none() {
650
        let d = OptionBiometricResult::default();
651
        assert!(d.is_none());
652
        assert!(!d.is_some());
653
        assert_eq!(d.as_option(), None);
654
        assert_eq!(Option::<BiometricResult>::from(d), None);
655
        // A defaulted accessor must never read as a successful unlock.
656
        assert!(!d.as_option().is_some_and(BiometricResult::is_success));
657
    }
658

            
659
    /// `replace` has `mem::replace` semantics: it returns the *previous*
660
    /// value and installs the new one. A vault gate that mis-read the
661
    /// return value would act on the stale result.
662
    #[test]
663
    fn option_result_replace_returns_previous() {
664
        let mut slot = OptionBiometricResult::None;
665
        let prev = slot.replace(BiometricResult::Failed);
666
        assert!(prev.is_none());
667
        assert_eq!(slot, OptionBiometricResult::Some(BiometricResult::Failed));
668

            
669
        let prev = slot.replace(BiometricResult::Authenticated);
670
        assert_eq!(prev, OptionBiometricResult::Some(BiometricResult::Failed));
671
        assert_eq!(
672
            slot,
673
            OptionBiometricResult::Some(BiometricResult::Authenticated)
674
        );
675
        assert!(slot.as_option().is_some_and(BiometricResult::is_success));
676
        // The previous (failed) attempt is not a success.
677
        assert!(!prev.as_option().is_some_and(BiometricResult::is_success));
678
    }
679

            
680
    /// `map` / `and_then` / `as_ref` / `as_mut` / `into_option` behave
681
    /// exactly like the std `Option` equivalents for every variant.
682
    #[test]
683
    fn option_result_combinators_match_std_option() {
684
        for original in [
685
            None,
686
            Some(BiometricResult::Authenticated),
687
            Some(BiometricResult::Failed),
688
            Some(BiometricResult::Cancelled),
689
            Some(BiometricResult::FellBackToPasscode),
690
            Some(BiometricResult::Unavailable),
691
            Some(BiometricResult::Error),
692
        ] {
693
            let wrapped: OptionBiometricResult = original.into();
694

            
695
            assert_eq!(wrapped.into_option(), original);
696
            assert_eq!(wrapped.as_ref(), original.as_ref());
697
            assert_eq!(
698
                wrapped.map(|r| r.is_success()),
699
                original.map(|r| r.is_success())
700
            );
701
            assert_eq!(
702
                wrapped.and_then(|r| r.is_success().then_some(r)),
703
                original.and_then(|r| r.is_success().then_some(r))
704
            );
705

            
706
            // as_mut writes through to the wrapper (and is `None` exactly
707
            // when the source Option was `None` — it must not conjure a
708
            // slot out of the `None` variant).
709
            let mut target = wrapped;
710
            let wrote = target.as_mut().map(|slot| *slot = BiometricResult::Error);
711
            assert_eq!(wrote.is_some(), original.is_some());
712
            if original.is_some() {
713
                assert_eq!(target, OptionBiometricResult::Some(BiometricResult::Error));
714
            } else {
715
                assert_eq!(target, OptionBiometricResult::None);
716
            }
717
        }
718
    }
719

            
720
    /// The wrapper's derived order puts `None` before every `Some(_)`, and
721
    /// `Some(_)` follows the payload order — pins the `#[repr(C, u8)]`
722
    /// tag ordering that codegen mirrors.
723
    #[test]
724
    fn option_result_ord_none_first() {
725
        assert!(
726
            OptionBiometricResult::None
727
                < OptionBiometricResult::Some(BiometricResult::Authenticated)
728
        );
729
        assert!(
730
            OptionBiometricResult::Some(BiometricResult::Authenticated)
731
                < OptionBiometricResult::Some(BiometricResult::Error)
732
        );
733
    }
734

            
735
    // ------------------------------------------------------------------
736
    // BiometricPrompt — equality / field wiring / string invariants
737
    // ------------------------------------------------------------------
738

            
739
    /// `PartialEq` is sensitive to *every* field: two prompts that differ
740
    /// only in the cancel label, only in the reason, or only in the
741
    /// device-credential flag must not compare equal. A derive that
742
    /// dropped a field would let a biometric-only prompt compare equal to
743
    /// a passcode-fallback one.
744
    #[test]
745
    fn prompt_eq_is_field_sensitive() {
746
        let base = BiometricPrompt {
747
            reason: AzString::from("Unlock"),
748
            cancel_label: AzString::from("Nope"),
749
            allow_device_credential: true,
750
        };
751

            
752
        let mut other_reason = base.clone();
753
        other_reason.reason = AzString::from("Unlock!");
754
        assert_ne!(base, other_reason);
755

            
756
        let mut other_cancel = base.clone();
757
        other_cancel.cancel_label = AzString::from("nope");
758
        assert_ne!(base, other_cancel);
759

            
760
        let mut other_flag = base.clone();
761
        other_flag.allow_device_credential = false;
762
        assert_ne!(base, other_flag);
763

            
764
        // …and an identical rebuild does compare equal (deep AzString eq).
765
        let same = BiometricPrompt {
766
            reason: AzString::from("Unlock"),
767
            cancel_label: AzString::from("Nope"),
768
            allow_device_credential: true,
769
        };
770
        assert_eq!(base, same);
771
    }
772

            
773
    /// Post-construction length invariants: `len()`, `as_str().len()` and
774
    /// `as_bytes().len()` agree for ASCII, multibyte, embedded-NUL and
775
    /// empty reasons — no off-by-one or capacity/len confusion.
776
    #[test]
777
    fn prompt_string_len_invariants() {
778
        let cases = [
779
            String::new(),
780
            "a".to_string(),
781
            "λ🔒指".to_string(),
782
            String::from("nul\0inside"),
783
            "z".repeat(70_000),
784
        ];
785
        for s in &cases {
786
            let prompt = BiometricPrompt::new(AzString::from(s.as_str()));
787
            assert_eq!(prompt.reason.len(), s.len());
788
            assert_eq!(prompt.reason.as_str().len(), s.len());
789
            assert_eq!(prompt.reason.as_bytes().len(), s.len());
790
            assert_eq!(prompt.reason.is_empty(), s.is_empty());
791
            // The untouched field keeps its documented default.
792
            assert_eq!(prompt.cancel_label.len(), 0);
793
            assert!(prompt.cancel_label.is_empty());
794
        }
795
    }
796

            
797
    /// No Unicode normalization happens: an NFC "é" and an NFD "e" + U+0301
798
    /// stay distinct byte sequences (a normalizing string type would make
799
    /// these two prompts compare equal and silently change what the OS
800
    /// modal displays).
801
    #[test]
802
    fn prompt_does_not_normalize_unicode() {
803
        let nfc = BiometricPrompt::new(AzString::from("caf\u{e9}"));
804
        let nfd = BiometricPrompt::new(AzString::from("cafe\u{301}"));
805
        assert_ne!(nfc, nfd);
806
        assert_eq!(nfc.reason.as_bytes().len(), 5);
807
        assert_eq!(nfd.reason.as_bytes().len(), 6);
808
        assert_eq!(nfc.reason.as_str().chars().count(), 4);
809
        assert_eq!(nfd.reason.as_str().chars().count(), 5);
810
    }
811

            
812
    /// The constructor does not trim, collapse or otherwise rewrite the
813
    /// reason — leading/trailing whitespace survives verbatim.
814
    #[test]
815
    fn prompt_preserves_whitespace_verbatim() {
816
        let raw = "  \t Unlock the vault \n ";
817
        let prompt = BiometricPrompt::new(AzString::from(raw));
818
        assert_eq!(prompt.reason.as_str(), raw);
819
        assert_ne!(prompt.reason.as_str(), raw.trim());
820
    }
821

            
822
    /// `AzString` round-trips out of a constructed prompt unchanged
823
    /// (`into_library_owned_string` gives back exactly what went in),
824
    /// including for a multibyte reason.
825
    #[test]
826
    fn prompt_reason_string_roundtrip() {
827
        for original in [String::new(), "Unlock".to_string(), "🔒λ指紋".to_string()] {
828
            let prompt = BiometricPrompt::new(AzString::from(original.clone()));
829
            let back = prompt.reason.clone().into_library_owned_string();
830
            assert_eq!(back, original);
831
        }
832
    }
833

            
834
    /// A prompt whose *both* strings are ~1 MB clones deeply and compares
835
    /// equal — exercises the deep-copy path for the non-defaulted field
836
    /// too (the constructor never sets it, so it is built by hand).
837
    #[test]
838
    fn prompt_huge_both_strings_clone_deeply() {
839
        let big_reason = "r".repeat(1_000_000);
840
        let big_cancel = "c".repeat(1_000_000);
841
        let prompt = BiometricPrompt {
842
            reason: AzString::from(big_reason.as_str()),
843
            cancel_label: AzString::from(big_cancel.as_str()),
844
            allow_device_credential: true,
845
        };
846
        let cloned = prompt.clone();
847
        assert_eq!(cloned, prompt);
848
        assert_eq!(cloned.reason.as_str(), big_reason.as_str());
849
        assert_eq!(cloned.cancel_label.as_str(), big_cancel.as_str());
850
        assert!(cloned.allow_device_credential);
851
        // The two buffers did not get aliased/swapped by the clone.
852
        assert_ne!(cloned.reason, cloned.cancel_label);
853
    }
854

            
855
    /// `Default::default()` is idempotent and fully empty — two defaults
856
    /// compare equal and neither enables the passcode fallback.
857
    #[test]
858
    fn prompt_default_is_empty_and_stable() {
859
        let a = BiometricPrompt::default();
860
        let b = BiometricPrompt::default();
861
        assert_eq!(a, b);
862
        assert!(a.reason.is_empty());
863
        assert!(a.cancel_label.is_empty());
864
        assert!(!a.allow_device_credential);
865
        // An empty reason is accepted (documented as "discouraged", not
866
        // rejected) — the constructor must not panic on it.
867
        assert_eq!(BiometricPrompt::new(AzString::from_const_str("")), a);
868
    }
869

            
870
    /// `Debug` renders the reason (used in backend logs); it must not lose
871
    /// the field or print a placeholder.
872
    #[test]
873
    fn prompt_debug_contains_fields() {
874
        let prompt = BiometricPrompt {
875
            reason: AzString::from("Unlock vault"),
876
            cancel_label: AzString::from("Abort"),
877
            allow_device_credential: true,
878
        };
879
        let dbg = format!("{prompt:?}");
880
        assert!(dbg.contains("BiometricPrompt"), "{dbg}");
881
        assert!(dbg.contains("Unlock vault"), "{dbg}");
882
        assert!(dbg.contains("Abort"), "{dbg}");
883
        assert!(dbg.contains("true"), "{dbg}");
884
    }
885
}