Skip to main content

azul_layout/managers/
keyring.rs

1//! Keyring manager — cross-platform state for the system-keyring surface
2//! (`SUPER_PLAN_2` §4 P4.2).
3//!
4//! Request-driven, mirroring [`crate::managers::biometric`]:
5//!
6//! - A **callback** calls `CallbackInfo::keyring_store/get/delete(...)`,
7//!   which parks a [`KeyringRequest`] in the request channel.
8//! - The dll **layout pass** drains it and dispatches to the platform
9//!   backend (`dll::desktop::extra::keyring`) — Keychain / `KeyStore` /
10//!   libsecret / `CredentialLocker`. A biometry-bound `Get` shows the OS
11//!   prompt; the outcome is parked in the result channel.
12//! - The layout pass folds the latest result into the manager via
13//!   [`KeyringManager::set_last_result`]; callbacks read it with
14//!   `CallbackInfo::get_keyring_result()`.
15//!
16//! No platform deps (`SUPER_PLAN_2` §0.5); the channels are the same
17//! poison-recovering `Mutex<Vec<_>>` pattern as the geolocation /
18//! biometric managers.
19
20use alloc::vec::Vec;
21
22// `KeyringRequest` / `KeyringResult` live in `azul-core` so they cross the
23// FFI without a cyclic dep on `azul-layout`. Re-exported for the existing
24// `azul_layout::managers::keyring::*` import paths.
25pub use azul_core::keyring::{KeyringRequest, KeyringResult};
26
27use azul_core::dom::DomNodeId;
28use azul_core::events::{
29    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
30};
31use azul_core::task::Instant;
32
33/// Cross-platform keyring state. One per `App` — the OS keyring is a
34/// per-process (per-app-identity) store, not per-window.
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
36pub struct KeyringManager {
37    /// Outcome of the most recent keyring op, or `None` until the first
38    /// completes. Read by callbacks via `CallbackInfo::get_keyring_result()`.
39    pub last_result: Option<KeyringResult>,
40    /// Ops dispatched to the native backend whose outcome has not been
41    /// folded back yet (MWA-A1b arming signal for the capability pump).
42    pub in_flight: u32,
43    /// `true` when an op outcome was folded since the last event pass (set
44    /// on EVERY completion — a repeated identical outcome still answers a
45    /// fresh op). Read by the `EventProvider` impl
46    /// (`EventType::KeyringResult`), cleared by
47    /// [`clear_pending_event`](Self::clear_pending_event).
48    pub pending_event: bool,
49}
50
51impl KeyringManager {
52    #[must_use] pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Most recent keyring outcome, or `None` until the first op resolves.
57    #[must_use] pub const fn last_result(&self) -> Option<&KeyringResult> {
58        self.last_result.as_ref()
59    }
60
61    /// Apply the outcome the backend delivered. Returns `true` if it
62    /// differs from the previous one (so the window can be marked dirty to
63    /// re-render the revealed / stored state).
64    pub fn set_last_result(&mut self, result: KeyringResult) -> bool {
65        let changed = self.last_result.as_ref() != Some(&result);
66        self.last_result = Some(result);
67        // MWA-A1b: every completion fires an event and retires one
68        // in-flight op.
69        self.pending_event = true;
70        self.in_flight = self.in_flight.saturating_sub(1);
71        changed
72    }
73
74    /// The pump dispatched `n` ops to the native backend; keep the timer
75    /// armed until their outcomes fold back (MWA-A1b).
76    pub const fn mark_requests_dispatched(&mut self, n: u32) {
77        self.in_flight = self.in_flight.saturating_add(n);
78    }
79
80    /// Clear the pending-event flag. The dll calls this after the event
81    /// pass has collected the `KeyringResult` event.
82    pub const fn clear_pending_event(&mut self) {
83        self.pending_event = false;
84    }
85
86    /// `true` while a dispatched op's outcome is still outstanding
87    /// (MWA-A1b arming signal).
88    #[must_use] pub const fn has_pending_async(&self) -> bool {
89        self.in_flight > 0
90    }
91}
92
93impl EventProvider for KeyringManager {
94    /// Yield a window-level `KeyringResult` event when an op outcome was
95    /// folded since the last pass (target = root; read the outcome via
96    /// `CallbackInfo::get_keyring_result` inside the callback).
97    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
98        if self.pending_event {
99            alloc::vec![SyntheticEvent::new(
100                EventType::KeyringResult,
101                CoreEventSource::User,
102                DomNodeId::ROOT,
103                timestamp,
104                EventData::None,
105            )]
106        } else {
107            Vec::new()
108        }
109    }
110}
111
112// ────────── Request channel (callback → platform backend) ─────────────
113
114static PENDING_REQUESTS: std::sync::Mutex<Vec<KeyringRequest>> =
115    std::sync::Mutex::new(Vec::new());
116
117/// Queue a keyring op from a callback. Drained by the dll layout pass and
118/// dispatched to the native keyring. Thread-safe; poison-recovering.
119pub fn push_keyring_request(request: KeyringRequest) {
120    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
121    q.push(request);
122}
123
124/// Drain every queued keyring op, in arrival order. Called once per
125/// layout pass; the dll dispatches each to the platform backend.
126pub fn drain_keyring_requests() -> Vec<KeyringRequest> {
127    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
128    core::mem::take(&mut *q)
129}
130
131/// MWA-C-biometric/keyring: see `biometric::has_queued_requests` — pump
132/// arming must count parked-but-undispatched requests.
133pub fn has_queued_requests() -> bool {
134    PENDING_REQUESTS
135        .lock()
136        .map_or_else(|e| !e.into_inner().is_empty(), |q| !q.is_empty())
137}
138
139// ────────── Result channel (platform backend → manager) ───────────────
140
141static PENDING_RESULTS: std::sync::Mutex<Vec<KeyringResult>> =
142    std::sync::Mutex::new(Vec::new());
143
144/// Park a keyring result delivered by a platform backend (in the dll).
145/// Thread-safe; poison-recovering (a biometry-bound `Get` resolves from
146/// the OS prompt's reply on an arbitrary thread).
147pub fn push_keyring_result(result: KeyringResult) {
148    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
149    q.push(result);
150}
151
152/// Drain every parked keyring result, in arrival order. Called once per
153/// layout pass; the caller applies them via [`KeyringManager::set_last_result`].
154pub fn drain_keyring_results() -> Vec<KeyringResult> {
155    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
156    core::mem::take(&mut *q)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use azul_css::AzString;
163
164    #[test]
165    fn manager_defaults_to_no_result() {
166        let mgr = KeyringManager::new();
167        assert_eq!(mgr.last_result(), None);
168    }
169
170    #[test]
171    fn set_last_result_returns_change_flag() {
172        let mut mgr = KeyringManager::new();
173        assert!(mgr.set_last_result(KeyringResult::Stored));
174        assert_eq!(mgr.last_result(), Some(&KeyringResult::Stored));
175        // Re-applying the same outcome is not a change.
176        assert!(!mgr.set_last_result(KeyringResult::Stored));
177        // A new outcome is a change.
178        assert!(mgr.set_last_result(KeyringResult::Deleted));
179    }
180
181    #[test]
182    fn result_helpers() {
183        let secret = KeyringResult::Retrieved(AzString::from_const_str("hunter2"));
184        assert_eq!(secret.secret().map(AzString::as_str), Some("hunter2"));
185        assert!(secret.is_ok());
186        assert!(KeyringResult::Stored.is_ok());
187        assert!(KeyringResult::Deleted.is_ok());
188        for r in [
189            KeyringResult::NotFound,
190            KeyringResult::Denied,
191            KeyringResult::Unavailable,
192            KeyringResult::Error,
193        ] {
194            assert!(!r.is_ok(), "{r:?} must not be ok");
195            assert_eq!(r.secret(), None);
196        }
197    }
198
199    #[test]
200    fn requests_round_trip_through_channel() {
201        // Process-global; serialize against every other channel test, then
202        // clear residue.
203        let _guard = super::autotest_generated::lock_channels();
204        drop(drain_keyring_requests());
205
206        push_keyring_request(KeyringRequest::Store {
207            key: AzString::from_const_str("token"),
208            secret: AzString::from_const_str("abc"),
209            require_biometry: true,
210        });
211        push_keyring_request(KeyringRequest::Get {
212            key: AzString::from_const_str("token"),
213        });
214        let drained = drain_keyring_requests();
215        assert_eq!(drained.len(), 2, "both queued requests drain in order");
216        assert!(matches!(drained[0], KeyringRequest::Store { .. }));
217        assert!(matches!(drained[1], KeyringRequest::Get { .. }));
218        assert!(drain_keyring_requests().is_empty());
219    }
220
221    #[test]
222    fn results_round_trip_through_manager() {
223        // Process-global; serialize against every other channel test, then
224        // clear residue.
225        let _guard = super::autotest_generated::lock_channels();
226        drop(drain_keyring_results());
227
228        push_keyring_result(KeyringResult::NotFound);
229        push_keyring_result(KeyringResult::Retrieved(AzString::from_const_str("s"))); // last wins
230        let drained = drain_keyring_results();
231        assert_eq!(drained.len(), 2);
232
233        let mut mgr = KeyringManager::new();
234        for r in drained {
235            mgr.set_last_result(r);
236        }
237        assert_eq!(
238            mgr.last_result().and_then(|r| r.secret()).map(AzString::as_str),
239            Some("s"),
240            "the last applied result wins"
241        );
242        assert!(drain_keyring_results().is_empty());
243    }
244}
245
246#[cfg(test)]
247mod pump_provider_tests {
248    use super::*;
249    use azul_core::task::{Instant, SystemTick};
250
251    fn ts() -> Instant {
252        Instant::Tick(SystemTick::new(0))
253    }
254
255    #[test]
256    fn in_flight_and_events_track_op_lifecycle() {
257        let mut mgr = KeyringManager::new();
258        assert!(!mgr.has_pending_async());
259        mgr.mark_requests_dispatched(1);
260        assert!(mgr.has_pending_async());
261        assert!(mgr.get_pending_events(ts()).is_empty(), "no outcome yet");
262
263        mgr.set_last_result(KeyringResult::Stored);
264        assert!(!mgr.has_pending_async());
265        let events = mgr.get_pending_events(ts());
266        assert_eq!(events.len(), 1);
267        assert_eq!(events[0].event_type, EventType::KeyringResult);
268        mgr.clear_pending_event();
269
270        // repeated identical outcome still fires — it answers a fresh op
271        mgr.mark_requests_dispatched(1);
272        mgr.set_last_result(KeyringResult::Stored);
273        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
274    }
275}
276
277#[cfg(test)]
278mod autotest_generated {
279    use std::sync::{Mutex, PoisonError};
280
281    use azul_core::task::SystemTick;
282    use azul_css::AzString;
283
284    use super::*;
285
286    /// The request/result channels are process-global statics and `cargo
287    /// test` runs tests in parallel threads, so EVERY test that pushes to or
288    /// drains a channel must hold this lock — otherwise one test's pushes
289    /// land inside another's drain window. Shared with the hand-written
290    /// `tests` module above.
291    static CHANNEL_LOCK: Mutex<()> = Mutex::new(());
292
293    pub(super) fn lock_channels() -> std::sync::MutexGuard<'static, ()> {
294        CHANNEL_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
295    }
296
297    fn ts() -> Instant {
298        Instant::Tick(SystemTick::new(0))
299    }
300
301    /// Interior NUL, C0/C1 controls and CRLF — breaks naive C-string /
302    /// byte-length handling in the FFI layer the channels feed.
303    const NASTY_BYTES: &str = "pw\0with\u{1}nul\u{7f}\r\n\t";
304    /// Combining marks, an RTL override, the replacement char and the
305    /// maximum scalar value.
306    const NASTY_UNICODE: &str = "🔑 ключ 鍵 مفتاح e\u{301}\u{202e}terces\u{202c}\u{fffd}\u{10ffff}";
307
308    /// Every `KeyringResult` variant, in declaration order.
309    fn all_results() -> [KeyringResult; 7] {
310        [
311            KeyringResult::Stored,
312            KeyringResult::Retrieved(AzString::from("s3cr3t")),
313            KeyringResult::Deleted,
314            KeyringResult::NotFound,
315            KeyringResult::Denied,
316            KeyringResult::Unavailable,
317            KeyringResult::Error,
318        ]
319    }
320
321    // ── constructor: KeyringManager::new ───────────────────────────────
322
323    #[test]
324    fn new_equals_default_and_holds_the_zero_state_invariants() {
325        let a = KeyringManager::new();
326        let b = KeyringManager::default();
327        assert_eq!(a, b, "new() must be exactly default()");
328
329        // Every documented field is at its zero value, and the derived
330        // accessors agree with the raw fields.
331        assert_eq!(a.last_result, None);
332        assert_eq!(a.last_result(), None);
333        assert_eq!(a.in_flight, 0);
334        assert!(!a.has_pending_async());
335        assert!(!a.pending_event);
336        assert!(
337            a.get_pending_events(ts()).is_empty(),
338            "a fresh manager has nothing to report"
339        );
340    }
341
342    #[test]
343    fn new_carries_no_hidden_global_state_between_instances() {
344        // The manager is per-App, but the channels are process-global — a
345        // fresh manager must not pick up anything parked in them.
346        let _guard = lock_channels();
347        drop(drain_keyring_results());
348        push_keyring_result(KeyringResult::Retrieved(AzString::from("leaked")));
349
350        let mgr = KeyringManager::new();
351        assert_eq!(
352            mgr.last_result(),
353            None,
354            "construction must not drain the result channel"
355        );
356
357        // The parked result is still there — new() did not consume it.
358        assert_eq!(drain_keyring_results().len(), 1);
359    }
360
361    // ── getter: KeyringManager::last_result ────────────────────────────
362
363    #[test]
364    fn last_result_returns_the_exact_outcome_that_was_folded() {
365        let mut mgr = KeyringManager::new();
366        assert_eq!(mgr.last_result(), None, "None until the first op resolves");
367
368        for r in all_results() {
369            mgr.set_last_result(r.clone());
370            assert_eq!(
371                mgr.last_result(),
372                Some(&r),
373                "last_result() must hand back exactly what was folded"
374            );
375        }
376    }
377
378    #[test]
379    fn last_result_preserves_nul_control_and_max_scalar_payloads() {
380        // `AzString::as_str` is `from_utf8_unchecked`, so a byte-level
381        // mangling on the way through the manager would be UB rather than a
382        // clean error — assert byte equality, not just string equality.
383        for payload in [NASTY_BYTES, NASTY_UNICODE, ""] {
384            let mut mgr = KeyringManager::new();
385            mgr.set_last_result(KeyringResult::Retrieved(AzString::from(payload)));
386
387            let s = mgr
388                .last_result()
389                .and_then(KeyringResult::secret)
390                .expect("Retrieved must expose its secret");
391            assert_eq!(s.as_str(), payload);
392            assert_eq!(s.as_str().as_bytes(), payload.as_bytes());
393            assert_eq!(s.as_str().len(), payload.len(), "nothing truncated at the NUL");
394        }
395    }
396
397    #[test]
398    fn last_result_handles_a_megabyte_secret() {
399        let big = "k".repeat(1 << 20);
400        let mut mgr = KeyringManager::new();
401        assert!(mgr.set_last_result(KeyringResult::Retrieved(AzString::from(big.clone()))));
402
403        let s = mgr
404            .last_result()
405            .and_then(KeyringResult::secret)
406            .expect("Retrieved must expose its secret");
407        assert_eq!(s.as_str().len(), 1 << 20);
408        assert_eq!(s.as_str(), big.as_str());
409
410        // Re-folding the identical megabyte payload is not a change (the
411        // comparison must be by value, not by pointer).
412        assert!(!mgr.set_last_result(KeyringResult::Retrieved(AzString::from(big))));
413    }
414
415    #[test]
416    fn last_result_survives_the_flag_and_counter_mutators() {
417        let mut mgr = KeyringManager::new();
418        mgr.set_last_result(KeyringResult::Retrieved(AzString::from("keep-me")));
419
420        mgr.clear_pending_event();
421        mgr.mark_requests_dispatched(7);
422        mgr.mark_requests_dispatched(0);
423
424        assert_eq!(
425            mgr.last_result().and_then(KeyringResult::secret).map(AzString::as_str),
426            Some("keep-me"),
427            "neither the event flag nor the in-flight counter may clobber the outcome"
428        );
429    }
430
431    // ── other: KeyringManager::set_last_result ─────────────────────────
432
433    #[test]
434    fn set_last_result_change_flag_is_true_only_on_a_distinct_outcome() {
435        let mut mgr = KeyringManager::new();
436        for r in all_results() {
437            assert!(
438                mgr.set_last_result(r.clone()),
439                "{r:?} differs from the previous outcome → changed"
440            );
441            assert!(
442                !mgr.set_last_result(r.clone()),
443                "re-folding the identical {r:?} is not a change"
444            );
445        }
446    }
447
448    #[test]
449    fn set_last_result_compares_retrieved_payloads_by_value() {
450        let mut mgr = KeyringManager::new();
451        assert!(mgr.set_last_result(KeyringResult::Retrieved(AzString::from("a"))));
452        assert!(
453            !mgr.set_last_result(KeyringResult::Retrieved(AzString::from("a"))),
454            "same payload, freshly allocated → not a change"
455        );
456        assert!(
457            mgr.set_last_result(KeyringResult::Retrieved(AzString::from("b"))),
458            "a different secret under the same variant IS a change"
459        );
460        // An empty secret is a *present* secret, distinct from NotFound.
461        assert!(mgr.set_last_result(KeyringResult::Retrieved(AzString::from(""))));
462        assert!(!mgr.set_last_result(KeyringResult::Retrieved(AzString::from(""))));
463        assert!(
464            mgr.set_last_result(KeyringResult::NotFound),
465            "Retrieved(\"\") and NotFound must not collapse into one another"
466        );
467    }
468
469    #[test]
470    fn set_last_result_fires_the_event_even_when_the_outcome_is_unchanged() {
471        // MWA-A1b: a repeated identical outcome still answers a *fresh* op,
472        // so the event must fire regardless of the change flag.
473        let mut mgr = KeyringManager::new();
474        mgr.set_last_result(KeyringResult::Denied);
475        mgr.clear_pending_event();
476
477        assert!(!mgr.set_last_result(KeyringResult::Denied), "not a change…");
478        assert!(mgr.pending_event, "…but still a completion → event pending");
479        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
480    }
481
482    #[test]
483    fn set_last_result_without_a_dispatch_does_not_underflow_in_flight() {
484        // The adversarial case: a backend delivers an outcome nobody armed.
485        // A wrapping `-1` here would leave in_flight = u32::MAX and pin the
486        // capability pump on forever.
487        let mut mgr = KeyringManager::new();
488        assert_eq!(mgr.in_flight, 0);
489        for _ in 0..1_000 {
490            mgr.set_last_result(KeyringResult::Error);
491        }
492        assert_eq!(mgr.in_flight, 0, "saturating_sub must clamp at 0, not wrap");
493        assert!(!mgr.has_pending_async());
494    }
495
496    #[test]
497    fn set_last_result_retires_exactly_one_in_flight_op_per_fold() {
498        let mut mgr = KeyringManager::new();
499        mgr.mark_requests_dispatched(3);
500        for expected in [2_u32, 1, 0] {
501            mgr.set_last_result(KeyringResult::Stored);
502            assert_eq!(mgr.in_flight, expected, "one fold retires exactly one op");
503        }
504        assert!(!mgr.has_pending_async(), "all dispatched ops resolved");
505
506        // Extra outcomes past the dispatched count clamp at zero.
507        mgr.set_last_result(KeyringResult::Deleted);
508        assert_eq!(mgr.in_flight, 0);
509    }
510
511    #[test]
512    fn set_last_result_retires_one_op_even_at_the_u32_max_ceiling() {
513        let mut mgr = KeyringManager::new();
514        mgr.in_flight = u32::MAX;
515        mgr.set_last_result(KeyringResult::Stored);
516        assert_eq!(
517            mgr.in_flight,
518            u32::MAX - 1,
519            "the counter is only saturated at the bottom, not stuck at the top"
520        );
521        assert!(mgr.has_pending_async());
522    }
523
524    // ── numeric: KeyringManager::mark_requests_dispatched ──────────────
525
526    #[test]
527    fn mark_requests_dispatched_zero_is_a_no_op() {
528        let mut mgr = KeyringManager::new();
529        mgr.mark_requests_dispatched(0);
530        assert_eq!(mgr.in_flight, 0);
531        assert!(
532            !mgr.has_pending_async(),
533            "dispatching nothing must not arm the pump"
534        );
535
536        mgr.mark_requests_dispatched(5);
537        mgr.mark_requests_dispatched(0);
538        assert_eq!(mgr.in_flight, 5, "a zero dispatch must not disturb the count");
539    }
540
541    #[test]
542    fn mark_requests_dispatched_accumulates_across_calls() {
543        let mut mgr = KeyringManager::new();
544        mgr.mark_requests_dispatched(1);
545        mgr.mark_requests_dispatched(2);
546        mgr.mark_requests_dispatched(3);
547        assert_eq!(mgr.in_flight, 6);
548
549        let mut many = KeyringManager::new();
550        for _ in 0..10_000 {
551            many.mark_requests_dispatched(1);
552        }
553        assert_eq!(many.in_flight, 10_000);
554        assert!(many.has_pending_async());
555    }
556
557    #[test]
558    fn mark_requests_dispatched_saturates_at_u32_max_instead_of_overflowing() {
559        // A debug build would panic on a plain `+` here; the contract is
560        // saturation.
561        let mut mgr = KeyringManager::new();
562        mgr.mark_requests_dispatched(u32::MAX);
563        assert_eq!(mgr.in_flight, u32::MAX);
564
565        mgr.mark_requests_dispatched(1);
566        assert_eq!(mgr.in_flight, u32::MAX, "saturating_add clamps at the ceiling");
567        mgr.mark_requests_dispatched(u32::MAX);
568        assert_eq!(mgr.in_flight, u32::MAX);
569        assert!(mgr.has_pending_async());
570
571        // And from a non-zero base: MAX-1 plus 2 still clamps.
572        let mut near = KeyringManager::new();
573        near.in_flight = u32::MAX - 1;
574        near.mark_requests_dispatched(2);
575        assert_eq!(near.in_flight, u32::MAX);
576    }
577
578    #[test]
579    fn mark_requests_dispatched_touches_neither_the_event_flag_nor_the_outcome() {
580        let mut mgr = KeyringManager::new();
581        mgr.mark_requests_dispatched(u32::MAX);
582        assert!(
583            !mgr.pending_event,
584            "dispatching is not a completion — no event may fire"
585        );
586        assert_eq!(mgr.last_result(), None);
587        assert!(mgr.get_pending_events(ts()).is_empty());
588    }
589
590    // ── other: KeyringManager::clear_pending_event ─────────────────────
591
592    #[test]
593    fn clear_pending_event_is_idempotent_and_only_touches_the_flag() {
594        let mut mgr = KeyringManager::new();
595        // Clearing an already-clear flag is a no-op, not an underflow.
596        mgr.clear_pending_event();
597        assert!(!mgr.pending_event);
598
599        mgr.mark_requests_dispatched(2);
600        mgr.set_last_result(KeyringResult::Stored);
601        assert!(mgr.pending_event);
602
603        mgr.clear_pending_event();
604        mgr.clear_pending_event();
605        assert!(!mgr.pending_event);
606        assert!(
607            mgr.get_pending_events(ts()).is_empty(),
608            "a cleared flag yields no events"
609        );
610
611        // The outcome and the in-flight counter are untouched by the clear.
612        assert_eq!(mgr.last_result(), Some(&KeyringResult::Stored));
613        assert_eq!(mgr.in_flight, 1);
614        assert!(
615            mgr.has_pending_async(),
616            "clearing the event flag must not disarm the still-outstanding op"
617        );
618    }
619
620    // ── predicate: KeyringManager::has_pending_async ───────────────────
621
622    #[test]
623    fn has_pending_async_is_exactly_in_flight_greater_than_zero() {
624        let mut mgr = KeyringManager::new();
625        for (in_flight, expected) in [(0_u32, false), (1, true), (2, true), (u32::MAX, true)] {
626            mgr.in_flight = in_flight;
627            assert_eq!(
628                mgr.has_pending_async(),
629                expected,
630                "in_flight = {in_flight} → has_pending_async() = {expected}"
631            );
632        }
633    }
634
635    #[test]
636    fn has_pending_async_goes_false_only_when_the_last_op_folds_back() {
637        let mut mgr = KeyringManager::new();
638        assert!(!mgr.has_pending_async());
639        mgr.mark_requests_dispatched(2);
640        assert!(mgr.has_pending_async());
641        mgr.set_last_result(KeyringResult::Stored);
642        assert!(mgr.has_pending_async(), "one op is still outstanding");
643        mgr.set_last_result(KeyringResult::Stored);
644        assert!(!mgr.has_pending_async(), "both ops resolved → pump disarms");
645    }
646
647    // ── EventProvider ──────────────────────────────────────────────────
648
649    #[test]
650    fn pending_event_is_a_root_targeted_user_sourced_keyring_result() {
651        let mut mgr = KeyringManager::new();
652        mgr.set_last_result(KeyringResult::Retrieved(AzString::from("s")));
653
654        let stamp = Instant::Tick(SystemTick::new(u64::MAX));
655        let events = mgr.get_pending_events(stamp.clone());
656        assert_eq!(events.len(), 1, "exactly one event per pass");
657
658        let e = &events[0];
659        assert_eq!(e.event_type, EventType::KeyringResult);
660        assert_eq!(e.source, CoreEventSource::User);
661        assert_eq!(e.target, DomNodeId::ROOT, "keyring events are window-level");
662        assert_eq!(e.current_target, DomNodeId::ROOT);
663        assert_eq!(e.timestamp, stamp, "the caller's timestamp is echoed verbatim");
664        assert!(
665            matches!(e.data, EventData::None),
666            "the outcome is read via CallbackInfo, not carried in the event"
667        );
668    }
669
670    #[test]
671    fn get_pending_events_is_non_destructive_until_cleared() {
672        // The dll clears the flag explicitly; polling must not consume it,
673        // or an event pass that reads twice would drop the event.
674        let mut mgr = KeyringManager::new();
675        mgr.set_last_result(KeyringResult::Deleted);
676
677        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
678        assert_eq!(mgr.get_pending_events(ts()).len(), 1, "still pending");
679        assert!(mgr.pending_event);
680
681        mgr.clear_pending_event();
682        assert!(mgr.get_pending_events(ts()).is_empty());
683    }
684
685    // ── channels: push / drain / has_queued_requests ───────────────────
686
687    #[test]
688    fn request_channel_is_fifo_across_every_variant() {
689        let _guard = lock_channels();
690        drop(drain_keyring_requests());
691
692        push_keyring_request(KeyringRequest::Store {
693            key: AzString::from("k1"),
694            secret: AzString::from("s1"),
695            require_biometry: true,
696        });
697        push_keyring_request(KeyringRequest::Get {
698            key: AzString::from("k2"),
699        });
700        push_keyring_request(KeyringRequest::Delete {
701            key: AzString::from("k3"),
702        });
703
704        let drained = drain_keyring_requests();
705        assert_eq!(drained.len(), 3);
706        assert_eq!(
707            drained[0],
708            KeyringRequest::Store {
709                key: AzString::from("k1"),
710                secret: AzString::from("s1"),
711                require_biometry: true,
712            },
713            "arrival order and payload preserved exactly"
714        );
715        assert_eq!(
716            drained[1],
717            KeyringRequest::Get {
718                key: AzString::from("k2")
719            }
720        );
721        assert_eq!(
722            drained[2],
723            KeyringRequest::Delete {
724                key: AzString::from("k3")
725            }
726        );
727
728        // The queue was taken, not copied.
729        assert!(drain_keyring_requests().is_empty());
730    }
731
732    #[test]
733    fn draining_an_empty_channel_is_an_empty_vec_not_a_panic() {
734        let _guard = lock_channels();
735        drop(drain_keyring_requests());
736        drop(drain_keyring_results());
737
738        for _ in 0..3 {
739            assert!(drain_keyring_requests().is_empty());
740            assert!(drain_keyring_results().is_empty());
741        }
742        assert!(!has_queued_requests());
743    }
744
745    #[test]
746    fn has_queued_requests_tracks_only_the_request_channel() {
747        let _guard = lock_channels();
748        drop(drain_keyring_requests());
749        drop(drain_keyring_results());
750        assert!(!has_queued_requests(), "empty queue → nothing to pump");
751
752        // A parked *result* is not a queued *request* — the two statics must
753        // not be conflated, or the pump would arm on its own output.
754        push_keyring_result(KeyringResult::Stored);
755        assert!(!has_queued_requests());
756
757        push_keyring_request(KeyringRequest::Get {
758            key: AzString::from("k"),
759        });
760        assert!(has_queued_requests(), "a parked-but-undispatched request arms the pump");
761        // Reading the predicate does not consume the request.
762        assert!(has_queued_requests());
763
764        assert_eq!(drain_keyring_requests().len(), 1);
765        assert!(!has_queued_requests(), "draining disarms it");
766
767        drop(drain_keyring_results());
768    }
769
770    #[test]
771    fn request_channel_preserves_nul_unicode_and_megabyte_payloads() {
772        let _guard = lock_channels();
773        drop(drain_keyring_requests());
774
775        let big = "s".repeat(1 << 20);
776        push_keyring_request(KeyringRequest::Store {
777            key: AzString::from(NASTY_BYTES),
778            secret: AzString::from(NASTY_UNICODE),
779            require_biometry: false,
780        });
781        push_keyring_request(KeyringRequest::Get {
782            key: AzString::from(big.clone()),
783        });
784
785        let drained = drain_keyring_requests();
786        assert_eq!(drained.len(), 2);
787
788        let KeyringRequest::Store {
789            key,
790            secret,
791            require_biometry,
792        } = &drained[0]
793        else {
794            panic!("first request must still be the Store: {:?}", drained[0]);
795        };
796        // Byte-exact both ways: no truncation at the interior NUL, no
797        // re-encoding of the astral / combining-mark scalars.
798        assert_eq!(key.as_str().as_bytes(), NASTY_BYTES.as_bytes());
799        assert_eq!(secret.as_str().as_bytes(), NASTY_UNICODE.as_bytes());
800        assert!(secret.as_str().ends_with('\u{10ffff}'));
801        assert!(!*require_biometry);
802
803        let KeyringRequest::Get { key } = &drained[1] else {
804            panic!("second request must still be the Get: {:?}", drained[1]);
805        };
806        assert_eq!(key.as_str().len(), 1 << 20);
807        assert_eq!(key.as_str(), big.as_str());
808    }
809
810    #[test]
811    fn request_channel_keeps_arrival_order_under_many_pushes() {
812        let _guard = lock_channels();
813        drop(drain_keyring_requests());
814
815        const N: usize = 1_000;
816        for i in 0..N {
817            push_keyring_request(KeyringRequest::Get {
818                key: AzString::from(format!("key-{i}")),
819            });
820        }
821
822        let drained = drain_keyring_requests();
823        assert_eq!(drained.len(), N);
824        for (i, req) in drained.iter().enumerate() {
825            let KeyringRequest::Get { key } = req else {
826                panic!("expected a Get at {i}, got {req:?}");
827            };
828            assert_eq!(key.as_str(), format!("key-{i}"), "FIFO order at {i}");
829        }
830        assert!(drain_keyring_requests().is_empty());
831    }
832
833    #[test]
834    fn concurrent_pushes_from_many_threads_lose_no_requests() {
835        // The channel is documented thread-safe: a biometry-bound Get can
836        // resolve on an arbitrary thread. Lost or duplicated entries here
837        // would mean a dropped keyring op.
838        let _guard = lock_channels();
839        drop(drain_keyring_requests());
840
841        const THREADS: usize = 8;
842        const PER_THREAD: usize = 50;
843
844        let handles: Vec<_> = (0..THREADS)
845            .map(|t| {
846                std::thread::spawn(move || {
847                    for i in 0..PER_THREAD {
848                        push_keyring_request(KeyringRequest::Delete {
849                            key: AzString::from(format!("t{t}-{i}")),
850                        });
851                    }
852                })
853            })
854            .collect();
855        for h in handles {
856            h.join().expect("pushing thread must not panic");
857        }
858
859        let drained = drain_keyring_requests();
860        assert_eq!(drained.len(), THREADS * PER_THREAD, "no request lost or duplicated");
861
862        // Cross-thread interleaving is unspecified, but the multiset of keys
863        // must be exactly what was pushed.
864        let mut keys: Vec<String> = drained
865            .iter()
866            .map(|r| match r {
867                KeyringRequest::Delete { key } => key.as_str().to_string(),
868                other => panic!("unexpected request in the channel: {other:?}"),
869            })
870            .collect();
871        keys.sort();
872        let mut expected: Vec<String> = (0..THREADS)
873            .flat_map(|t| (0..PER_THREAD).map(move |i| format!("t{t}-{i}")))
874            .collect();
875        expected.sort();
876        assert_eq!(keys, expected);
877    }
878
879    #[test]
880    fn result_channel_folds_into_the_manager_with_last_write_winning() {
881        let _guard = lock_channels();
882        drop(drain_keyring_results());
883
884        let mut mgr = KeyringManager::new();
885        mgr.mark_requests_dispatched(3);
886
887        push_keyring_result(KeyringResult::Denied);
888        push_keyring_result(KeyringResult::NotFound);
889        push_keyring_result(KeyringResult::Retrieved(AzString::from(NASTY_UNICODE)));
890
891        let drained = drain_keyring_results();
892        assert_eq!(drained.len(), 3, "results drain in arrival order");
893        assert_eq!(drained[0], KeyringResult::Denied);
894        assert_eq!(drained[1], KeyringResult::NotFound);
895
896        for r in drained {
897            mgr.set_last_result(r);
898        }
899        assert_eq!(
900            mgr.last_result().and_then(KeyringResult::secret).map(AzString::as_str),
901            Some(NASTY_UNICODE),
902            "the last applied result wins"
903        );
904        assert_eq!(mgr.in_flight, 0, "all three dispatched ops retired");
905        assert!(!mgr.has_pending_async());
906        assert!(mgr.pending_event);
907        assert!(drain_keyring_results().is_empty());
908    }
909}