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 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(©));
598 }
599 for r in results {
600 let copy = r;
601 assert_eq!(r, copy);
602 assert_eq!(hash_of(&r), hash_of(©));
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}