openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
//! Proxy credentials: where the password lives, and how a URL renders without it.
//!
//! A proxy password is a second credential identity in a process that had exactly one. It
//! is filed under the same service as the API key but a different username —
//! `openlatch` / `proxy:<authority>` — which is what makes the two unoverwritable by
//! construction rather than by convention, and what makes a *changed* proxy authority a
//! different entry that inherits nothing from the old one (D-19).
//!
//! | Tier | Source | Persisted? |
//! | ---- | ------ | ---------- |
//! | 1 | `OPENLATCH_PROXY` userinfo | never — in memory for the life of the process |
//! | 2 | OS keychain, `openlatch` / `proxy:<authority>` | yes, by the OS |
//! | 3 | `proxy-credentials.enc` beside `credentials.enc` | yes, AES-256-GCM |
//!
//! `config.toml` is deliberately absent from that table: a password in a plaintext file on
//! disk is the thing the credential store exists to avoid, and `EgressConfig::resolve`
//! refuses a `[proxy] url` that carries userinfo for the same reason.

use std::collections::BTreeMap;
use std::path::PathBuf;

use secrecy::{ExposeSecret, SecretString};

use crate::core::auth::keyring::{PROXY_USERNAME_PREFIX, SERVICE_NAME};
use crate::core::auth::{CredentialStore, FileCredentialStore, KeyringCredentialStore};
use crate::core::error::{OlError, ERR_FILE_FALLBACK_ERROR};

/// The file the encrypted `{authority: password}` map lives in, beside `credentials.enc`.
pub const PROXY_CREDENTIALS_FILE: &str = "proxy-credentials.enc";

/// What replaces a password anywhere a URL is rendered.
const MASK: &str = "*****";

/// The default port for a scheme, so two spellings of one proxy are one key.
///
/// `http://proxy.corp` and `http://proxy.corp:80` are the same proxy and must resolve to the
/// same credential; without the default filled in they would be two entries, and the second
/// spelling would silently find no password.
fn default_port(scheme: &str) -> Option<u16> {
    match scheme {
        "http" => Some(80),
        "https" => Some(443),
        // Both SOCKS spellings share 1080; they differ only in who resolves DNS.
        "socks5" | "socks5h" => Some(1080),
        _ => None,
    }
}

/// Split `scheme://[userinfo@]host[:port][/path]` into its parts.
///
/// Hand-rolled rather than routed through a URL crate because the one caller that matters
/// runs on a string this crate already validated at parse time, and because a parser that
/// *normalises* would quietly change the authority a credential is filed under.
fn parts(url: &str) -> Option<(&str, Option<&str>, &str, &str)> {
    let (scheme, rest) = url.split_once("://")?;
    let (authority, path) = match rest.find('/') {
        Some(i) => (&rest[..i], &rest[i..]),
        None => (rest, ""),
    };
    match authority.rsplit_once('@') {
        Some((userinfo, host)) => Some((scheme, Some(userinfo), host, path)),
        None => Some((scheme, None, authority, path)),
    }
}

/// Split a host[:port] authority, tolerating an IPv6 literal in brackets.
fn split_host_port(authority: &str) -> (&str, Option<&str>) {
    if let Some(rest) = authority.strip_prefix('[') {
        if let Some((host, tail)) = rest.split_once(']') {
            return (host, tail.strip_prefix(':'));
        }
    }
    match authority.rsplit_once(':') {
        Some((h, p)) => (h, Some(p)),
        None => (authority, None),
    }
}

/// The credential key for a proxy URL: lowercased host, explicit port.
///
/// `Some("proxy.corp.example:8080")`. Returns `None` for a URL with no scheme or no host — a
/// shape the config parser already refuses, so a `None` here means the caller is holding
/// something that never came through the config path.
pub fn authority_key(url: &str) -> Option<String> {
    let (scheme, _userinfo, authority, _path) = parts(url)?;
    if authority.is_empty() {
        return None;
    }
    let (host, port) = split_host_port(authority);
    if host.is_empty() {
        return None;
    }
    let host = host.to_ascii_lowercase();
    let port = port
        .and_then(|p| p.parse::<u16>().ok())
        .or_else(|| default_port(&scheme.to_ascii_lowercase()))?;
    Some(format!("{host}:{port}"))
}

/// The keychain username for a proxy authority.
pub fn keyring_username(authority: &str) -> String {
    format!("{PROXY_USERNAME_PREFIX}{authority}")
}

/// Render a URL with the password replaced by `*****`.
///
/// `http://alice:hunter2@proxy.corp:8080` becomes `http://alice:*****@proxy.corp:8080`. The
/// username survives, because "which account is this failing as" is the first question an
/// operator asks and the answer is not a secret. Every path that renders a proxy URL — a
/// log line, a diagnostic, an error chain — goes through here.
pub fn mask_userinfo(url: &str) -> String {
    // A value with no scheme still gets masked. This is the case that matters most in
    // practice: `OPENLATCH_PROXY=alice:hunter2@proxy.corp:8080` is a *malformed* URL, and
    // the code path it reaches is the one that renders it into an error message. Returning
    // it verbatim because it failed to parse would print the password into the log at
    // exactly the moment someone mistyped the variable.
    let Some((scheme, userinfo, host, path)) = parts(url) else {
        return match url.rsplit_once('@') {
            Some((userinfo, rest)) => mask_authority(userinfo, rest, ""),
            None => url.to_string(),
        };
    };
    let Some(userinfo) = userinfo else {
        return url.to_string();
    };
    format!("{scheme}://{}", mask_authority(userinfo, host, path))
}

/// `user:*****@host` / `user@host` — the authority half, with the secret removed.
fn mask_authority(userinfo: &str, host: &str, path: &str) -> String {
    match userinfo.split_once(':') {
        // A username with a password: keep the name, mask the secret.
        Some((user, _password)) => format!("{user}:{MASK}@{host}{path}"),
        // Userinfo with no colon is a bare username, and nothing there is secret. It is
        // still rendered through this function so no caller has to decide.
        None => format!("{userinfo}@{host}{path}"),
    }
}

/// The encrypted `{authority: password}` map that backs the file tier.
///
/// Wraps [`FileCredentialStore`] at its own path rather than reimplementing the crypto: the
/// AES-256-GCM/HKDF scheme, the owner-only permissions and the agent_id-derived key are the
/// same ones `credentials.enc` uses, and one implementation is what keeps them that way.
/// The map is the only thing this layer adds — the API key is one secret, proxy passwords
/// are one per authority.
pub struct ProxyCredentialFile {
    inner: FileCredentialStore,
}

impl ProxyCredentialFile {
    /// A store at `path`, encrypted under `agent_id`.
    pub fn new(path: PathBuf, agent_id: String) -> Self {
        Self {
            inner: FileCredentialStore::new(path, agent_id),
        }
    }

    /// The password for `authority`, if the file holds one.
    ///
    /// A missing, unreadable or undecryptable file is `None`, not an error: the file tier is
    /// a fallback, and "there is no password here" is the same answer either way. A corrupt
    /// file surfaces where it can be acted on — the credential *write* path — rather than as
    /// a startup failure on every read.
    pub fn get(&self, authority: &str) -> Option<SecretString> {
        self.read_map()
            .ok()?
            .get(authority)
            .map(|p| SecretString::from(p.clone()))
    }

    /// Write `password` for `authority`, leaving every other entry intact.
    pub fn set(&self, authority: &str, password: &SecretString) -> Result<(), OlError> {
        let mut map = self.read_map().unwrap_or_default();
        map.insert(authority.to_string(), password.expose_secret().to_string());
        self.write_map(&map)
    }

    /// Remove `authority`'s entry. A no-op when it was not there.
    pub fn remove(&self, authority: &str) -> Result<(), OlError> {
        let mut map = self.read_map().unwrap_or_default();
        if map.remove(authority).is_none() {
            return Ok(());
        }
        self.write_map(&map)
    }

    fn read_map(&self) -> Result<BTreeMap<String, String>, OlError> {
        let raw = self.inner.retrieve()?;
        // A blob that does not parse reads as absent through `get`'s `.ok()?`. It is an
        // error here so `set` reports it rather than silently overwriting.
        serde_json::from_str(raw.expose_secret()).map_err(|e| {
            OlError::new(
                ERR_FILE_FALLBACK_ERROR,
                format!("{PROXY_CREDENTIALS_FILE} is not a valid credential map: {e}"),
            )
            .with_suggestion(
                "Delete the file and re-enter the proxy password with 'openlatch proxy set'.",
            )
        })
    }

    fn write_map(&self, map: &BTreeMap<String, String>) -> Result<(), OlError> {
        let json = serde_json::to_string(map).map_err(|e| {
            OlError::new(
                ERR_FILE_FALLBACK_ERROR,
                format!("could not serialize the proxy credential map: {e}"),
            )
        })?;
        self.inner.store(SecretString::from(json))
    }
}

/// Where a resolved proxy password came from. Provenance for diagnostics, never the value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordSource {
    /// `OPENLATCH_PROXY` userinfo, held in memory only.
    Env,
    /// The OS keychain.
    Keychain,
    /// `proxy-credentials.enc`.
    File,
}

impl PasswordSource {
    /// The wire string for diagnostics.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Env => "env",
            Self::Keychain => "keychain",
            Self::File => "file",
        }
    }
}

/// Resolve the password for `authority`: env, then keychain, then file.
///
/// `env` is the value already parked in memory at config-resolution time. The keychain read
/// runs on a blocking thread (the `keyring.rs` convention — a Secret Service call from a
/// runtime thread can deadlock on Linux), and every failure below it is a fall-through
/// rather than an error: an absent credential is a network state, and this initiative's
/// prime invariant is that a network state never fails startup.
pub async fn resolve_password(
    authority: &str,
    env: Option<&str>,
    file: Option<&ProxyCredentialFile>,
) -> Option<(SecretString, PasswordSource)> {
    if let Some(value) = env.filter(|v| !v.is_empty()) {
        return Some((SecretString::from(value.to_string()), PasswordSource::Env));
    }

    let username = keyring_username(authority);
    let store = KeyringCredentialStore::for_identity(SERVICE_NAME, &username);
    if let Ok(secret) = store.retrieve_async().await {
        return Some((secret, PasswordSource::Keychain));
    }

    file.and_then(|f| f.get(authority))
        .map(|secret| (secret, PasswordSource::File))
}

/// The credential key for a proxy URL, or an empty string when the URL has no usable
/// authority.
///
/// A thin infallible wrapper over [`authority_key`] for the CLI, which reaches this point
/// holding a URL the config parser already accepted and has no branch to take if it were
/// somehow unusable.
pub fn credential_authority(url: &str) -> String {
    authority_key(url).unwrap_or_default()
}

/// The **write** side of the credential ladder that [`resolve_password`] reads.
///
/// This type exists to keep one invariant that is easy to break and silent when broken:
/// *what `openlatch proxy set` writes is exactly what the daemon later looks for.* Both
/// sides key on the proxy authority — `keyring_username(authority)` in the keychain,
/// `authority` in the encrypted map — and neither includes the username. A key that
/// disagreed by one field would store the password successfully, report success to the
/// operator, and then produce a 407 on every request with nothing anywhere to explain it.
///
/// Keyed per authority rather than one slot for "the" proxy because a laptop legitimately
/// has more than one route: office, VPN, home. Storing the office password should not
/// silently evict the VPN's.
pub struct ProxyCredentialStore {
    keyring_service: String,
    file: ProxyCredentialFile,
}

impl ProxyCredentialStore {
    /// A store writing under `ol_dir`, with the file tier encrypted under `agent_id`.
    pub fn new(ol_dir: &std::path::Path, agent_id: String) -> Self {
        Self {
            keyring_service: SERVICE_NAME.to_string(),
            file: ProxyCredentialFile::new(ol_dir.join(PROXY_CREDENTIALS_FILE), agent_id),
        }
    }

    fn keyring_for(&self, authority: &str) -> KeyringCredentialStore {
        KeyringCredentialStore::for_identity(&self.keyring_service, &keyring_username(authority))
    }

    /// Store `password` for `authority`, overwriting whatever was there.
    ///
    /// **Both tiers are attempted, and either one succeeding is success.** A headless Linux
    /// host has no Secret Service and would otherwise store nothing while reporting that it
    /// had; a host that has one gains a file copy that the same [`clear`](Self::clear)
    /// deletes. It fails only when neither tier could hold the credential, because a silent
    /// success here reads to the operator as "saved" and produces a 407 on every subsequent
    /// request with nothing to explain it.
    ///
    /// # Errors
    ///
    /// Returns the file tier's error when neither tier accepted the credential.
    pub fn store(&self, authority: &str, password: &SecretString) -> Result<(), OlError> {
        let keyring_ok = self
            .keyring_for(authority)
            .store(SecretString::from(password.expose_secret().to_string()))
            .is_ok();
        match self.file.set(authority, password) {
            Ok(()) => Ok(()),
            Err(e) if keyring_ok => {
                tracing::debug!(
                    "proxy credential stored in the OS keychain; the file fallback declined: {e}"
                );
                Ok(())
            }
            Err(e) => Err(e),
        }
    }

    /// The password stored for `authority`, or `None`.
    ///
    /// The synchronous twin of [`resolve_password`]'s keychain and file tiers, for callers
    /// that are not on a runtime — `openlatch proxy status` above all. The daemon uses
    /// `resolve_password`, which additionally consults the environment and reports which
    /// tier answered.
    pub fn retrieve(&self, authority: &str) -> Option<SecretString> {
        if let Ok(secret) = self.keyring_for(authority).retrieve() {
            return Some(secret);
        }
        self.file.get(authority)
    }

    /// Delete `authority`'s credential from both tiers.
    ///
    /// Best-effort by design: a keychain that refuses and a file that is already gone are
    /// both "the credential is not there", which is the state the caller asked for.
    pub fn clear(&self, authority: &str) {
        let _ = self.keyring_for(authority).delete();
        let _ = self.file.remove(authority);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn the_authority_key_fills_in_the_scheme_default_port() {
        // Two spellings of one proxy must be one credential entry, or the bare-host
        // spelling silently finds nothing.
        assert_eq!(
            authority_key("http://proxy.corp").as_deref(),
            Some("proxy.corp:80")
        );
        assert_eq!(
            authority_key("http://proxy.corp:80").as_deref(),
            Some("proxy.corp:80")
        );
        assert_eq!(
            authority_key("https://proxy.corp").as_deref(),
            Some("proxy.corp:443")
        );
        assert_eq!(
            authority_key("socks5://proxy.corp").as_deref(),
            Some("proxy.corp:1080")
        );
        assert_eq!(
            authority_key("socks5h://proxy.corp").as_deref(),
            Some("proxy.corp:1080")
        );
    }

    #[test]
    fn the_authority_key_is_case_folded_on_the_host_only() {
        assert_eq!(
            authority_key("http://PROXY.Corp.Example:8080").as_deref(),
            Some("proxy.corp.example:8080")
        );
    }

    #[test]
    fn the_authority_key_ignores_userinfo_and_path() {
        assert_eq!(
            authority_key("http://alice:hunter2@proxy.corp:8080/pac").as_deref(),
            Some("proxy.corp:8080")
        );
    }

    #[test]
    fn an_ipv6_literal_keeps_its_port() {
        assert_eq!(
            authority_key("http://[::1]:3128").as_deref(),
            Some("::1:3128")
        );
    }

    #[test]
    fn a_url_with_no_scheme_or_host_has_no_key() {
        assert_eq!(authority_key("proxy.corp:8080"), None);
        assert_eq!(authority_key("http://"), None);
    }

    /// D-19, restated as a test: the credential key is derived from the authority, so a
    /// changed authority is a different key. Nothing inherits.
    #[test]
    fn a_changed_authority_is_a_different_key() {
        let old = authority_key("http://old-proxy.corp:8080").expect("key");
        let new = authority_key("http://new-proxy.corp:8080").expect("key");
        assert_ne!(old, new);
        assert_ne!(keyring_username(&old), keyring_username(&new));
        // And neither can ever collide with the API-key singleton's username.
        assert_ne!(keyring_username(&old), "api-key");
        assert!(keyring_username(&old).starts_with("proxy:"));
    }

    #[test]
    fn masking_keeps_the_username_and_drops_the_password() {
        assert_eq!(
            mask_userinfo("http://alice:hunter2@proxy.corp:8080"),
            "http://alice:*****@proxy.corp:8080"
        );
        assert_eq!(
            mask_userinfo("https://dom%5Calice:p%40ss@proxy.corp:8080/path"),
            "https://dom%5Calice:*****@proxy.corp:8080/path"
        );
    }

    #[test]
    fn masking_leaves_a_url_without_a_password_alone() {
        assert_eq!(
            mask_userinfo("http://proxy.corp:8080"),
            "http://proxy.corp:8080"
        );
        assert_eq!(
            mask_userinfo("http://alice@proxy.corp:8080"),
            "http://alice@proxy.corp:8080"
        );
        assert_eq!(mask_userinfo("not a url"), "not a url");
    }

    /// The case that actually leaked: a schemeless value is a *malformed* URL, and the code
    /// path it reaches is the one that renders it into an error message.
    #[test]
    fn masking_covers_a_value_with_no_scheme_at_all() {
        assert_eq!(
            mask_userinfo("alice:hunter2@proxy.corp:8080"),
            "alice:*****@proxy.corp:8080"
        );
        assert_eq!(mask_userinfo("proxy.corp:8080"), "proxy.corp:8080");
    }

    /// A password containing an at-sign is legal, and is the reason the split runs from the
    /// right: a left-to-right `split_once` would leak the tail of it.
    #[test]
    fn masking_handles_an_at_sign_inside_the_password() {
        assert_eq!(
            mask_userinfo("http://alice:p@ssw0rd@proxy.corp:8080"),
            "http://alice:*****@proxy.corp:8080"
        );
    }

    #[test]
    fn the_file_store_round_trips_one_authority() {
        let dir = tempdir().expect("tempdir");
        let store = ProxyCredentialFile::new(
            dir.path().join(PROXY_CREDENTIALS_FILE),
            "agt_proxy_test".to_string(),
        );
        assert!(store.get("proxy.corp:8080").is_none());

        store
            .set(
                "proxy.corp:8080",
                &SecretString::from("hunter2".to_string()),
            )
            .expect("set");
        let got = store.get("proxy.corp:8080").expect("stored password");
        assert_eq!(got.expose_secret(), "hunter2");
    }

    #[test]
    fn the_file_store_keeps_one_entry_per_authority() {
        let dir = tempdir().expect("tempdir");
        let store = ProxyCredentialFile::new(
            dir.path().join(PROXY_CREDENTIALS_FILE),
            "agt_proxy_test".to_string(),
        );
        store
            .set("a.corp:8080", &SecretString::from("pass-a".to_string()))
            .expect("set a");
        store
            .set("b.corp:8080", &SecretString::from("pass-b".to_string()))
            .expect("set b");

        assert_eq!(
            store.get("a.corp:8080").expect("a").expose_secret(),
            "pass-a"
        );
        assert_eq!(
            store.get("b.corp:8080").expect("b").expose_secret(),
            "pass-b"
        );
        // The failure this rules out: a third authority inheriting a password because the
        // store keyed on something coarser than the authority.
        assert!(store.get("c.corp:8080").is_none());
    }

    #[test]
    fn the_file_store_removes_one_entry_without_disturbing_the_rest() {
        let dir = tempdir().expect("tempdir");
        let store = ProxyCredentialFile::new(
            dir.path().join(PROXY_CREDENTIALS_FILE),
            "agt_proxy_test".to_string(),
        );
        store
            .set("a.corp:8080", &SecretString::from("pass-a".to_string()))
            .expect("set a");
        store
            .set("b.corp:8080", &SecretString::from("pass-b".to_string()))
            .expect("set b");
        store.remove("a.corp:8080").expect("remove a");
        store
            .remove("missing.corp:8080")
            .expect("remove is a no-op");

        assert!(store.get("a.corp:8080").is_none());
        assert_eq!(
            store.get("b.corp:8080").expect("b").expose_secret(),
            "pass-b"
        );
    }

    /// The file is encrypted, so the password must not be findable in its bytes.
    #[test]
    fn the_file_on_disk_does_not_contain_the_password() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join(PROXY_CREDENTIALS_FILE);
        let store = ProxyCredentialFile::new(path.clone(), "agt_proxy_test".to_string());
        store
            .set(
                "proxy.corp:8080",
                &SecretString::from("hunter2-plaintext".to_string()),
            )
            .expect("set");

        let bytes = std::fs::read(&path).expect("read the credential file");
        assert!(
            !bytes
                .windows(b"hunter2-plaintext".len())
                .any(|w| w == b"hunter2-plaintext"),
            "the proxy password must not appear in the file's bytes"
        );
    }

    #[test]
    fn a_file_written_under_a_different_agent_id_reads_as_absent() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join(PROXY_CREDENTIALS_FILE);
        ProxyCredentialFile::new(path.clone(), "agt_one".to_string())
            .set(
                "proxy.corp:8080",
                &SecretString::from("hunter2".to_string()),
            )
            .expect("set");

        let other = ProxyCredentialFile::new(path, "agt_two".to_string());
        assert!(
            other.get("proxy.corp:8080").is_none(),
            "an undecryptable file is 'no password here', never a panic"
        );
    }

    #[tokio::test]
    async fn env_beats_every_store() {
        let dir = tempdir().expect("tempdir");
        let file = ProxyCredentialFile::new(
            dir.path().join(PROXY_CREDENTIALS_FILE),
            "agt_proxy_test".to_string(),
        );
        file.set(
            "proxy.corp:8080",
            &SecretString::from("file-password".to_string()),
        )
        .expect("set");

        let (secret, source) =
            resolve_password("proxy.corp:8080", Some("env-password"), Some(&file))
                .await
                .expect("resolved");
        assert_eq!(secret.expose_secret(), "env-password");
        assert_eq!(source, PasswordSource::Env);
    }

    /// The suites run with `OPENLATCH_SKIP_KEYRING=1`, so the keychain tier reports
    /// unavailable and the file tier answers — which is exactly the fall-through this
    /// asserts. An absent keychain must never be an error that stops the ladder.
    #[tokio::test]
    async fn the_file_tier_answers_when_the_keychain_does_not() {
        let dir = tempdir().expect("tempdir");
        let file = ProxyCredentialFile::new(
            dir.path().join(PROXY_CREDENTIALS_FILE),
            "agt_proxy_test".to_string(),
        );
        file.set(
            "proxy.corp:8080",
            &SecretString::from("file-password".to_string()),
        )
        .expect("set");

        let (secret, source) = resolve_password("proxy.corp:8080", None, Some(&file))
            .await
            .expect("resolved");
        assert_eq!(secret.expose_secret(), "file-password");
        assert_eq!(source, PasswordSource::File);
    }

    #[tokio::test]
    async fn no_password_anywhere_is_none_not_an_error() {
        assert!(resolve_password("proxy.corp:8080", None, None)
            .await
            .is_none());
    }

    /// The other half of D-19: a second authority must not pick up the first's password out
    /// of the file tier.
    #[tokio::test]
    async fn a_changed_authority_inherits_nothing() {
        let dir = tempdir().expect("tempdir");
        let file = ProxyCredentialFile::new(
            dir.path().join(PROXY_CREDENTIALS_FILE),
            "agt_proxy_test".to_string(),
        );
        file.set("old.corp:8080", &SecretString::from("old-pass".to_string()))
            .expect("set");

        assert!(resolve_password("new.corp:8080", None, Some(&file))
            .await
            .is_none());
    }
}