Skip to main content

azul_core/
keyring.rs

1//! POD types for the system-keyring surface
2//! (SUPER_PLAN_2 §4 P4.2 + research/02 §0 "hardware-bound" storage).
3//!
4//! A biometry-bindable secret key/value store backed by the OS keyring:
5//! iOS/macOS Keychain (`SecItem*`, optionally `kSecAttrAccessControl =
6//! biometryCurrentSet`), Android `KeyStore` (`setUserAuthenticationRequired`),
7//! Linux libsecret, Windows `CredentialLocker`. Defined here in `azul-core`
8//! so the request/result types cross the FFI without `azul-layout` being a
9//! dependency; the stateful side lives in
10//! `azul_layout::managers::keyring::KeyringManager`.
11//!
12//! Request-driven and channel-delivered, mirroring biometric ([`crate::
13//! biometric`]): a `Get` of a biometry-bound item shows the OS prompt and
14//! resolves asynchronously, so *every* op resolves through the result
15//! channel for a uniform, engine-agnostic surface. One op is in flight at
16//! a time (the demo reveals one entry at a time); request↔result
17//! correlation by id is a future refinement.
18
19use azul_css::AzString;
20
21/// A keyring operation queued by a callback
22/// (`CallbackInfo::keyring_store` / `keyring_get` / `keyring_delete`) and
23/// dispatched to the platform backend by the layout pass.
24///
25/// `secret` is an [`AzString`] — the common case is a password / token;
26/// binary blobs are base64-encoded by the caller. `key` is the lookup
27/// name, scoped to the app's keyring service.
28#[repr(C, u8)]
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum KeyringRequest {
31    /// Write `secret` under `key`, overwriting any existing value. When
32    /// `require_biometry` is set the item is stored access-controlled so a
33    /// later `Get` triggers the OS biometric prompt (Keychain
34    /// `biometryCurrentSet` / `KeyStore` `setUserAuthenticationRequired`).
35    Store {
36        key: AzString,
37        secret: AzString,
38        require_biometry: bool,
39    },
40    /// Read the secret stored under `key`. For a biometry-bound item the
41    /// OS shows its auth prompt first; the result arrives asynchronously.
42    Get { key: AzString },
43    /// Remove the item stored under `key` (no-op if absent).
44    Delete { key: AzString },
45}
46#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
47/// The outcome of a [`KeyringRequest`], delivered to the result channel
48/// and read by callbacks via `CallbackInfo::get_keyring_result()`.
49#[repr(C, u8)]
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum KeyringResult {
52    /// A `Store` succeeded.
53    Stored,
54    /// A `Get` returned the secret.
55    Retrieved(AzString),
56    /// A `Delete` succeeded (the key is now absent).
57    Deleted,
58    /// The requested key was not present in the keyring.
59    NotFound,
60    /// A biometry-bound read was refused — the user failed or cancelled
61    /// the OS auth prompt.
62    Denied,
63    /// No keyring backend is available on this platform / it isn't
64    /// configured (e.g. Linux without a running secret service).
65    Unavailable,
66    /// A platform error occurred (locked keychain, I/O, unmapped code).
67    Error,
68}
69
70impl KeyringResult {
71    /// The retrieved secret, if this is a successful `Get`.
72    #[must_use] pub const fn secret(&self) -> Option<&AzString> {
73        match self {
74            Self::Retrieved(s) => Some(s),
75            _ => None,
76        }
77    }
78
79    /// `true` for the success outcomes (`Stored` / `Retrieved` / `Deleted`).
80    #[must_use] pub const fn is_ok(&self) -> bool {
81        matches!(
82            self,
83            Self::Stored | Self::Retrieved(_) | Self::Deleted
84        )
85    }
86}
87
88// FFI Option wrapper for `CallbackInfo::get_keyring_result() ->
89// Option<KeyringResult>` — `None` until the first op completes. Not Copy
90// (carries an `AzString` in `Retrieved`), so `copy = false` (mirrors
91// `OptionNodeType`).
92impl_option!(
93    KeyringResult,
94    OptionKeyringResult,
95    copy = false,
96    [Debug, Clone, PartialEq, Eq]
97);
98
99#[cfg(test)]
100mod autotest_generated {
101    use super::*;
102    use alloc::string::String;
103
104    // Payloads chosen to break naive C-string / byte-length assumptions in the
105    // FFI layer: interior NUL, C0/C1 controls, CRLF.
106    const NASTY_BYTES: &str = "pw\0with\u{1}nul\u{7f}\r\n\t";
107    // Combining marks, RTL overrides, replacement char and the maximum scalar.
108    const NASTY_UNICODE: &str = "🔑🗝 ключ 鍵 مفتاح e\u{301}\u{202e}terces\u{202c}\u{fffd}\u{10ffff}";
109
110    /// Every `KeyringResult` variant, in declaration order.
111    fn all_variants() -> [KeyringResult; 7] {
112        [
113            KeyringResult::Stored,
114            KeyringResult::Retrieved(AzString::from("s3cr3t")),
115            KeyringResult::Deleted,
116            KeyringResult::NotFound,
117            KeyringResult::Denied,
118            KeyringResult::Unavailable,
119            KeyringResult::Error,
120        ]
121    }
122
123    /// The documented contract, restated independently of the impl:
124    /// `(is_ok, has_secret)`. The exhaustive match is deliberate — adding a
125    /// variant to `KeyringResult` breaks this and forces the truth tables
126    /// below to be revisited rather than silently under-testing the new one.
127    fn expected(r: &KeyringResult) -> (bool, bool) {
128        match r {
129            KeyringResult::Stored => (true, false),
130            KeyringResult::Retrieved(_) => (true, true),
131            KeyringResult::Deleted => (true, false),
132            KeyringResult::NotFound => (false, false),
133            KeyringResult::Denied => (false, false),
134            KeyringResult::Unavailable => (false, false),
135            KeyringResult::Error => (false, false),
136        }
137    }
138
139    // ---- getter: KeyringResult::secret ------------------------------------
140
141    #[test]
142    fn secret_returns_the_exact_payload_of_retrieved() {
143        let r = KeyringResult::Retrieved(AzString::from("hunter2"));
144        assert_eq!(r.secret().expect("Retrieved must yield a secret").as_str(), "hunter2");
145    }
146
147    #[test]
148    fn secret_is_none_for_every_non_retrieved_variant() {
149        for r in &all_variants() {
150            let has_secret = expected(r).1;
151            assert_eq!(
152                r.secret().is_some(),
153                has_secret,
154                "secret() disagrees with the documented contract for {r:?}"
155            );
156        }
157    }
158
159    #[test]
160    fn secret_of_an_empty_payload_is_some_empty_not_none() {
161        // An empty secret is a *present* secret. Collapsing it to `None` would
162        // make a stored-empty-string indistinguishable from `NotFound`.
163        let r = KeyringResult::Retrieved(AzString::from(""));
164        assert_eq!(r.secret().expect("empty payload is still a payload").as_str(), "");
165        assert!(r.is_ok());
166
167        let d = KeyringResult::Retrieved(AzString::default());
168        assert_eq!(d.secret().expect("default payload is still a payload").as_str(), "");
169        assert!(d.is_ok());
170    }
171
172    #[test]
173    fn secret_preserves_interior_nul_and_control_bytes() {
174        let r = KeyringResult::Retrieved(AzString::from(NASTY_BYTES));
175        let s = r.secret().expect("Retrieved must yield a secret");
176        assert_eq!(s.as_str(), NASTY_BYTES);
177        // Byte-exact: nothing truncated at the NUL, nothing re-encoded.
178        assert_eq!(s.as_str().as_bytes(), NASTY_BYTES.as_bytes());
179        assert_eq!(s.as_str().len(), NASTY_BYTES.len());
180    }
181
182    #[test]
183    fn secret_preserves_multibyte_and_max_scalar_boundaries() {
184        let r = KeyringResult::Retrieved(AzString::from(NASTY_UNICODE));
185        let s = r.secret().expect("Retrieved must yield a secret");
186        // `AzString::as_str` is `from_utf8_unchecked`, so a byte-level mangling
187        // here would be UB rather than a clean error — assert byte equality.
188        assert_eq!(s.as_str(), NASTY_UNICODE);
189        assert_eq!(s.as_str().as_bytes(), NASTY_UNICODE.as_bytes());
190        assert_eq!(s.as_str().chars().count(), NASTY_UNICODE.chars().count());
191        assert!(s.as_str().ends_with('\u{10ffff}'));
192    }
193
194    #[test]
195    fn secret_handles_a_megabyte_payload() {
196        let big = "k".repeat(1 << 20);
197        let r = KeyringResult::Retrieved(AzString::from(big.clone()));
198        let s = r.secret().expect("Retrieved must yield a secret");
199        assert_eq!(s.as_str().len(), 1 << 20);
200        assert_eq!(s.as_str(), big.as_str());
201        assert!(r.is_ok());
202    }
203
204    #[test]
205    fn secret_borrows_the_payload_in_place_and_is_repeatable() {
206        let r = KeyringResult::Retrieved(AzString::from("borrow-me"));
207        let inner: &AzString = match &r {
208            KeyringResult::Retrieved(s) => s,
209            other => unreachable!("constructed Retrieved, got {other:?}"),
210        };
211
212        let first = r.secret().expect("Retrieved must yield a secret");
213        assert!(
214            core::ptr::eq(first, inner),
215            "secret() must borrow the payload, not hand back a copy"
216        );
217
218        // Repeated calls are stable: same AzString, same backing buffer.
219        let second = r.secret().expect("secret() must be repeatable");
220        assert!(core::ptr::eq(first, second));
221        assert_eq!(first.as_str().as_ptr(), second.as_str().as_ptr());
222    }
223
224    // ---- predicate: KeyringResult::is_ok ----------------------------------
225
226    #[test]
227    fn is_ok_truth_table_is_exhaustive() {
228        for r in &all_variants() {
229            let want = expected(r).0;
230            assert_eq!(r.is_ok(), want, "is_ok() wrong for {r:?}");
231        }
232
233        // Spelled out as well, so a mis-edited `expected()` can't hide a bug.
234        assert!(KeyringResult::Stored.is_ok());
235        assert!(KeyringResult::Retrieved(AzString::from("x")).is_ok());
236        assert!(KeyringResult::Deleted.is_ok());
237        assert!(!KeyringResult::NotFound.is_ok());
238        assert!(!KeyringResult::Denied.is_ok());
239        assert!(!KeyringResult::Unavailable.is_ok());
240        assert!(!KeyringResult::Error.is_ok());
241    }
242
243    #[test]
244    fn is_ok_is_independent_of_the_retrieved_payload() {
245        let payloads = [
246            String::new(),
247            String::from(NASTY_BYTES),
248            String::from(NASTY_UNICODE),
249            "a".repeat(100_000),
250        ];
251        for p in payloads {
252            assert!(
253                KeyringResult::Retrieved(AzString::from(p)).is_ok(),
254                "Retrieved is a success outcome whatever it carries"
255            );
256        }
257    }
258
259    #[test]
260    fn is_ok_and_secret_never_contradict_each_other() {
261        for r in &all_variants() {
262            // A secret can only come from a success; a failure never carries one.
263            if r.secret().is_some() {
264                assert!(r.is_ok(), "{r:?} yielded a secret but reports failure");
265            }
266            if !r.is_ok() {
267                assert!(r.secret().is_none(), "failure {r:?} yielded a secret");
268            }
269        }
270    }
271
272    #[test]
273    fn accessors_are_pure() {
274        for r in &all_variants() {
275            let before = r.clone();
276            for _ in 0..8 {
277                assert_eq!(r.is_ok(), before.is_ok());
278                assert_eq!(r.secret().is_some(), before.secret().is_some());
279            }
280            assert_eq!(*r, before, "accessors must not mutate the receiver");
281        }
282    }
283
284    // ---- clone/drop: AzString carries an FFI destructor ------------------
285
286    #[test]
287    fn dropping_a_clone_leaves_the_original_payload_readable() {
288        let payload = "🔐 secret-that-must-survive";
289        let original = KeyringResult::Retrieved(AzString::from(payload));
290
291        for _ in 0..1000 {
292            let c = original.clone();
293            assert_eq!(c.secret().expect("clone keeps the payload").as_str(), payload);
294            // A shallow clone would free the original's buffer right here.
295            drop(c);
296        }
297
298        assert_eq!(
299            original.secret().expect("original survives 1000 clone/drop cycles").as_str(),
300            payload
301        );
302        assert!(original.is_ok());
303    }
304
305    #[test]
306    fn dropping_the_original_leaves_the_clone_payload_readable() {
307        let payload = NASTY_UNICODE;
308        let original = KeyringResult::Retrieved(AzString::from(payload));
309        let clone = original.clone();
310        drop(original);
311        assert_eq!(clone.secret().expect("clone owns its payload").as_str(), payload);
312    }
313
314    #[test]
315    fn eq_is_sensitive_to_both_variant_and_payload() {
316        assert_eq!(
317            KeyringResult::Retrieved(AzString::from("a")),
318            KeyringResult::Retrieved(AzString::from("a"))
319        );
320        assert_ne!(
321            KeyringResult::Retrieved(AzString::from("a")),
322            KeyringResult::Retrieved(AzString::from("b"))
323        );
324        // Empty payload must not compare equal to a payload-less success.
325        assert_ne!(KeyringResult::Retrieved(AzString::from("")), KeyringResult::Stored);
326        assert_ne!(KeyringResult::NotFound, KeyringResult::Denied);
327        assert_ne!(KeyringResult::Unavailable, KeyringResult::Error);
328        assert_eq!(KeyringResult::Deleted, KeyringResult::Deleted);
329    }
330
331    // ---- round-trip: OptionKeyringResult FFI wrapper ----------------------
332
333    #[test]
334    fn option_wrapper_round_trips_none_and_every_variant() {
335        let none: OptionKeyringResult = Option::<KeyringResult>::None.into();
336        assert!(none.is_none());
337        assert_eq!(Option::<KeyringResult>::from(none), None);
338        assert!(OptionKeyringResult::default().is_none());
339
340        for r in all_variants() {
341            let wrapped: OptionKeyringResult = Some(r.clone()).into();
342            let back: Option<KeyringResult> = wrapped.into();
343            assert_eq!(back, Some(r), "encode/decode through OptionKeyringResult lost data");
344        }
345    }
346
347    #[test]
348    fn option_wrapper_accessors_agree_with_each_other() {
349        let payload = "round-trip-me";
350        let some = OptionKeyringResult::Some(KeyringResult::Retrieved(AzString::from(payload)));
351        assert!(some.is_some());
352        assert!(!some.is_none());
353        assert_eq!(
354            some.as_option().and_then(KeyringResult::secret).map(AzString::as_str),
355            Some(payload)
356        );
357        assert_eq!(some.as_ref(), some.as_option());
358
359        let none = OptionKeyringResult::None;
360        assert!(none.is_none());
361        assert!(!none.is_some());
362        assert!(none.as_option().is_none());
363    }
364
365    #[test]
366    fn option_wrapper_into_option_clones_and_leaves_the_wrapper_intact() {
367        // `into_option(&self)` takes a reference and clones — calling it twice
368        // must not double-free or hollow out the wrapper.
369        let payload = NASTY_BYTES;
370        let wrapped = OptionKeyringResult::Some(KeyringResult::Retrieved(AzString::from(payload)));
371
372        let first = wrapped.into_option().expect("Some stays Some");
373        let second = wrapped.into_option().expect("into_option must not consume the wrapper");
374        assert_eq!(first, second);
375        assert_eq!(first.secret().expect("payload survives").as_str(), payload);
376        drop(first);
377        assert_eq!(second.secret().expect("payload survives its sibling").as_str(), payload);
378        drop(second);
379        assert_eq!(
380            wrapped.as_option().and_then(KeyringResult::secret).map(AzString::as_str),
381            Some(payload),
382            "wrapper must still own its payload after both clones died"
383        );
384    }
385
386    #[test]
387    fn option_wrapper_replace_returns_the_previous_value() {
388        let mut o = OptionKeyringResult::None;
389        assert_eq!(o.replace(KeyringResult::Stored), OptionKeyringResult::None);
390
391        let prev = o.replace(KeyringResult::Retrieved(AzString::from("new")));
392        assert_eq!(prev, OptionKeyringResult::Some(KeyringResult::Stored));
393        assert_eq!(
394            o.as_option().and_then(KeyringResult::secret).map(AzString::as_str),
395            Some("new")
396        );
397    }
398
399    #[test]
400    fn option_wrapper_as_mut_can_overwrite_a_retrieved_payload() {
401        let mut o = OptionKeyringResult::Some(KeyringResult::Retrieved(AzString::from("old")));
402        if let Some(slot) = o.as_mut() {
403            *slot = KeyringResult::Deleted; // drops the old AzString in place
404        }
405        assert_eq!(o.as_option(), Some(&KeyringResult::Deleted));
406        assert!(o.as_option().expect("still Some").is_ok());
407        assert!(o.as_option().expect("still Some").secret().is_none());
408    }
409
410    // ---- KeyringRequest: payload-carrying FFI enum ------------------------
411
412    #[test]
413    fn request_equality_is_sensitive_to_key_secret_and_biometry_flag() {
414        let store = KeyringRequest::Store {
415            key: AzString::from("k"),
416            secret: AzString::from("s"),
417            require_biometry: false,
418        };
419        assert_eq!(store, store.clone());
420
421        assert_ne!(
422            store,
423            KeyringRequest::Store {
424                key: AzString::from("k"),
425                secret: AzString::from("s"),
426                require_biometry: true,
427            },
428            "require_biometry changes the stored access control — it must not be ignored"
429        );
430        assert_ne!(
431            store,
432            KeyringRequest::Store {
433                key: AzString::from("k"),
434                secret: AzString::from("other"),
435                require_biometry: false,
436            }
437        );
438        assert_ne!(
439            KeyringRequest::Get { key: AzString::from("k") },
440            KeyringRequest::Delete { key: AzString::from("k") },
441            "a read and a delete of the same key are different operations"
442        );
443        assert_ne!(
444            KeyringRequest::Get { key: AzString::from("a") },
445            KeyringRequest::Get { key: AzString::from("b") }
446        );
447    }
448
449    #[test]
450    fn request_payloads_survive_clone_and_drop() {
451        let key = "🔑".repeat(10_000);
452        let req = KeyringRequest::Store {
453            key: AzString::from(key.clone()),
454            secret: AzString::from(NASTY_BYTES),
455            require_biometry: true,
456        };
457
458        let clone = req.clone();
459        drop(req);
460
461        match &clone {
462            KeyringRequest::Store { key: k, secret: s, require_biometry } => {
463                assert_eq!(k.as_str(), key.as_str());
464                assert_eq!(s.as_str(), NASTY_BYTES);
465                assert!(*require_biometry);
466            }
467            other => unreachable!("constructed Store, got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn request_accepts_empty_keys_without_panicking() {
473        // An empty key is nonsense to a backend, but the POD type must still
474        // construct, compare and drop cleanly rather than blow up in the callback.
475        let empty = KeyringRequest::Get { key: AzString::from("") };
476        assert_eq!(empty, KeyringRequest::Get { key: AzString::default() });
477        assert_ne!(empty, KeyringRequest::Get { key: AzString::from("k") });
478    }
479}