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

            
19
use 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)]
30
pub 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)]
51
pub 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

            
70
impl KeyringResult {
71
    /// The retrieved secret, if this is a successful `Get`.
72
1158
    #[must_use] pub const fn secret(&self) -> Option<&AzString> {
73
1158
        match self {
74
1041
            Self::Retrieved(s) => Some(s),
75
117
            _ => None,
76
        }
77
1158
    }
78

            
79
    /// `true` for the success outcomes (`Stored` / `Retrieved` / `Deleted`).
80
150
    #[must_use] pub const fn is_ok(&self) -> bool {
81
80
        matches!(
82
150
            self,
83
            Self::Stored | Self::Retrieved(_) | Self::Deleted
84
        )
85
150
    }
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`).
92
impl_option!(
93
    KeyringResult,
94
    OptionKeyringResult,
95
    copy = false,
96
    [Debug, Clone, PartialEq, Eq]
97
);
98

            
99
#[cfg(test)]
100
mod 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
}