openlatch-client 0.6.19

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
//! `openlatch system auth switch --org <ORG>` — move an enrolled machine to
//! another of the user's organizations (PLT-7 C-8).
//!
//! The sequence, each step only after the one before succeeded:
//!
//! 1. read the current organization from the policy pin (`bundle.meta.json`);
//! 2. browser sign-in, organization list, resolve `--org` ([`sign_in_with_code`]);
//! 3. the exchange (`intent = switch`) returns the new key — the old one still works;
//! 4. a running daemon drains with the old key and pauses (`/admin/switch/begin`);
//! 5. the pending-switch marker, then the new key in the store;
//! 6. a running daemon applies the switch (`/admin/switch/commit`); a stopped one
//!    applies it at its next start through the marker rule;
//! 7. the old key is revoked, fail-open.
//!
//! A failure at 1–5 changes nothing: a key minted at 3 is revoked best-effort,
//! and a failure at 5 restores the previous credential state before it reports.
//! From 6 on the switch is committed: a failed commit restarts the daemon so the
//! marker rule applies it, and a failed revoke only warns.

use std::time::Duration;

use secrecy::{ExposeSecret, SecretString};

use crate::cli::commands::auth::{
    attempt_server_revocation, make_file_store, pinned_organization, sign_in_with_code,
    validate_online_full, Exchanged, Intent, Org, SignInCtx,
};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::AuthSwitchArgs;
use crate::core::auth::{CredentialStore, KeyringCredentialStore};
use crate::core::pending_switch::{self, PendingSwitch};
use crate::error::{OlError, ERR_NO_CREDENTIALS, ERR_SWITCH_DAEMON, ERR_SWITCH_ENV_KEY};

/// The drain budget sent to `/admin/switch/begin` (C-8 step 4).
const BEGIN_TIMEOUT_MS: u64 = 10_000;
/// How long the CLI waits on a switch route: the drain budget plus the daemon's
/// own worker acknowledgements.
const ROUTE_TIMEOUT: Duration = Duration::from_secs(45);
/// The suggestion on a step 5 failure whose rollback the read-back confirmed.
const NOTHING_CHANGED: &str = "Nothing changed: this machine keeps its organization and its \
                               key. Fix the storage problem, then run the command again.";

/// Run `openlatch system auth switch`.
pub fn run_switch(args: &AuthSwitchArgs, output: &OutputConfig) -> Result<(), OlError> {
    // A key from the environment is the operator's: it is changed there.
    if std::env::var("OPENLATCH_API_KEY").is_ok_and(|k| !k.is_empty()) {
        return Err(OlError::new(
            ERR_SWITCH_ENV_KEY,
            "The key comes from OPENLATCH_API_KEY, so this command cannot change it",
        )
        .with_suggestion("Set OPENLATCH_API_KEY to a key from the target organization instead."));
    }

    // 1. The current organization: the pin, not the credential (C-8 preconditions).
    let Some(pinned) = pinned_organization()? else {
        return Err(OlError::new(
            ERR_NO_CREDENTIALS,
            "This machine is not enrolled in any organization yet",
        )
        .with_suggestion("Run `openlatch init` to enroll it."));
    };
    let host =
        crate::cli::commands::auth::system_hostname().unwrap_or_else(|| "This machine".to_string());
    if args.org == pinned {
        let current = Org {
            id: pinned.clone(),
            slug: String::new(),
            name: pinned.clone(),
        };
        render_already_in(output, &current);
        return Ok(());
    }

    let rt = tokio::runtime::Runtime::new().map_err(|e| {
        OlError::new(
            ERR_SWITCH_DAEMON,
            format!("Failed to create async runtime: {e}"),
        )
    })?;
    let ctx = SignInCtx::from_config(output, false, args.no_browser);

    // The old key, read from the store directly: never OPENLATCH_API_KEY.
    let keychain = KeyringCredentialStore::new();
    let file_store = make_file_store();
    let old = crate::core::auth::retrieve_stored(&keychain, &file_store).map(|(k, _)| k);
    let old_name = old
        .as_ref()
        .map(|k| {
            rt.block_on(validate_online_full(
                k.expose_secret(),
                &ctx.api_url,
                &ctx.egress,
            ))
        })
        .filter(|v| v.online && v.org_id == pinned && !v.org_name.is_empty())
        .map(|v| v.org_name);
    let from = Org {
        id: pinned.clone(),
        slug: String::new(),
        name: old_name.unwrap_or_else(|| pinned.clone()),
    };

    // 2–3. Sign in, resolve --org, exchange. Nothing has changed if this fails.
    let (new_key, to) = match rt.block_on(sign_in_with_code(
        &ctx,
        Intent::Switch,
        Some(&args.org),
        Some(&pinned),
    ))? {
        Exchanged::AlreadyIn(org) => {
            render_already_in(output, &org);
            return Ok(());
        }
        Exchanged::New { key, organization } => (key, organization),
    };
    let revoke_new = || {
        rt.block_on(attempt_server_revocation(
            new_key.expose_secret(),
            &ctx.api_url,
            &ctx.egress,
        ))
    };

    // 4. A running daemon drains with the old key and pauses under a lease.
    let daemon = match Daemon::locate() {
        Ok(d) => d,
        Err(reason) => {
            revoke_new();
            return Err(daemon_refused(&reason));
        }
    };
    let mut events_sent = 0u64;
    if let Some(d) = daemon.as_ref().filter(|d| d.running()) {
        match d.begin() {
            Ok(sent) => events_sent = sent,
            Err(reason) => {
                d.abort();
                revoke_new();
                return Err(daemon_refused(&reason));
            }
        }
    }

    // 5. The marker first, then the key: a death between the two leaves the old
    //    key in place and a marker that expires after an hour.
    let openlatch_dir = crate::config::openlatch_dir();
    let marker = PendingSwitch::new(&to.id, &new_key, pending_switch::now_unix_secs());
    let stored = pending_switch::write(&openlatch_dir, &marker)
        .map_err(|e| {
            OlError::new(
                crate::error::ERR_FILE_FALLBACK_ERROR,
                format!("Could not write the pending-switch marker: {e}"),
            )
            .with_suggestion(NOTHING_CHANGED)
        })
        .and_then(|()| store_switching(&keychain, &file_store, &new_key));
    if let Err(e) = stored {
        let _ = pending_switch::delete(&openlatch_dir);
        if let Some(d) = daemon.as_ref() {
            d.abort();
        }
        revoke_new();
        return Err(e);
    }

    // 6. Apply now, or at the daemon's next start.
    let mut events_dropped: Option<u64> = None;
    let mut warning: Option<&'static str> = None;
    let mut applied_now = false;
    if let Some(d) = daemon.as_ref().filter(|d| d.running()) {
        match d.commit() {
            Commit::Applied(dropped) => {
                events_dropped = Some(dropped);
                applied_now = true;
            }
            Commit::AlreadyApplied => applied_now = true,
            Commit::Failed if d.restart() => applied_now = true,
            Commit::Failed => warning = Some("the switch completes when the daemon next starts"),
        }
    }

    // 7. Revoke the old key, fail-open.
    let old_key_revoked = match old.as_ref() {
        Some(k) => rt.block_on(attempt_server_revocation(
            k.expose_secret(),
            &ctx.api_url,
            &ctx.egress,
        )),
        None => false,
    };

    render_moved(
        output,
        &Moved {
            host: &host,
            from: &from,
            to: &to,
            events_sent,
            events_dropped,
            applied_now,
            old_key_revoked,
            had_old_key: old.is_some(),
            warning,
        },
    );
    Ok(())
}

/// Store `key` in the keychain, else the file, and delete the other tier
/// (C-8 step 5). On a failure the previous state of BOTH tiers is restored, so
/// the store reads back exactly as it did before.
pub(crate) fn store_switching(
    keychain: &dyn CredentialStore,
    file: &dyn CredentialStore,
    key: &SecretString,
) -> Result<(), OlError> {
    let before_keychain = keychain.retrieve().ok();
    let before_file = file.retrieve().ok();
    let before = crate::core::auth::retrieve_stored(keychain, file).map(|(k, _)| k);
    let result = match keychain.store(key.clone()) {
        Ok(()) => file.delete(),
        Err(keychain_err) => file.store(key.clone()).map_err(|file_err| {
            OlError::new(
                file_err.code,
                format!(
                    "Could not store the new key in the OS keychain ({}) or the encrypted \
                     file ({})",
                    keychain_err.message, file_err.message
                ),
            )
        }),
    };
    // The keychain could not take it: an older entry there would shadow the new
    // key at the next lookup, so it must go, and the lookup must then return the
    // new key. Anything less is a failed store, rolled back like any other.
    let result = result.and_then(|()| {
        if keychain
            .retrieve()
            .is_ok_and(|k| k.expose_secret() != key.expose_secret())
        {
            keychain.delete()?;
            keychain.invalidate();
        }
        match crate::core::auth::retrieve_stored(keychain, file) {
            Some((k, _)) if k.expose_secret() == key.expose_secret() => Ok(()),
            _ => Err(OlError::new(
                crate::error::ERR_FILE_FALLBACK_ERROR,
                "The new key was stored, but the credential lookup does not return it",
            )),
        }
    });
    match result {
        Ok(()) => Ok(()),
        Err(e) => {
            restore(keychain, before_keychain);
            restore(file, before_file);
            // C-8 step 5: check that the lookup finds the old key again.
            let after = crate::core::auth::retrieve_stored(keychain, file).map(|(k, _)| k);
            let restored = match (&before, &after) {
                (Some(b), Some(a)) => b.expose_secret() == a.expose_secret(),
                (None, None) => true,
                _ => false,
            };
            if restored {
                Err(e.with_suggestion(NOTHING_CHANGED))
            } else {
                Err(OlError::new(
                    e.code,
                    format!(
                        "{}; the previous credential could not be restored either",
                        e.message
                    ),
                )
                .with_suggestion(
                    "Run `openlatch system auth status` to see which key is stored, then run \
                     this command again.",
                ))
            }
        }
    }
}

fn restore(store: &dyn CredentialStore, value: Option<SecretString>) {
    let _ = match value {
        Some(v) => store.store(v),
        None => store.delete(),
    };
}

fn daemon_refused(reason: &str) -> OlError {
    OlError::new(
        ERR_SWITCH_DAEMON,
        format!("The daemon did not accept the switch ({reason}); nothing changed"),
    )
    .with_suggestion("Check `openlatch status`, then run the command again.")
}

/// The local daemon, reached through its admin routes.
struct Daemon {
    port: u16,
    token: String,
    client: reqwest::blocking::Client,
}

enum Commit {
    Applied(u64),
    /// `409` and no marker left: the marker rule already applied it.
    AlreadyApplied,
    Failed,
}

impl Daemon {
    /// The daemon on the configured port, with its admin token.
    ///
    /// `Ok(None)` only when no daemon answers on that port. A daemon that is
    /// up but whose admin connection cannot be set up (the token missing or
    /// unreadable) is an error, never "stopped": treating it as stopped would
    /// skip `begin` and `commit` while it keeps uploading (C-8 step 4).
    fn locate() -> Result<Option<Self>, String> {
        let Ok(cfg) = crate::config::Config::load(None, None, false) else {
            return Ok(None);
        };
        if !crate::cli::commands::lifecycle::check_health(cfg.port) {
            return Ok(None);
        }
        let token = std::fs::read_to_string(crate::config::openlatch_dir().join("daemon.token"))
            .map_err(|e| format!("the daemon is running but its admin token cannot be read: {e}"))?
            .trim()
            .to_string();
        if token.is_empty() {
            return Err("the daemon is running but its admin token is empty".to_string());
        }
        let client = crate::egress::blocking_client_builder()
            .timeout(ROUTE_TIMEOUT)
            .build()
            .map_err(|e| format!("cannot build the admin client: {e}"))?;
        Ok(Some(Self {
            port: cfg.port,
            token,
            client,
        }))
    }

    fn running(&self) -> bool {
        crate::cli::commands::lifecycle::check_health(self.port)
    }

    fn post(
        &self,
        route: &str,
        body: serde_json::Value,
    ) -> Result<(u16, serde_json::Value), String> {
        let resp = self
            .client
            .post(format!(
                "http://127.0.0.1:{}/admin/switch/{route}",
                self.port
            ))
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .map_err(|e| format!("daemon unreachable: {e}"))?;
        let status = resp.status().as_u16();
        let body = resp.json().unwrap_or(serde_json::Value::Null);
        Ok((status, body))
    }

    /// `POST /admin/switch/begin` → events sent with the old key.
    fn begin(&self) -> Result<u64, String> {
        let (status, body) = self.post(
            "begin",
            serde_json::json!({ "timeout_ms": BEGIN_TIMEOUT_MS }),
        )?;
        if status == 200 {
            Ok(body["sent"].as_u64().unwrap_or(0))
        } else {
            Err(route_reason(status, &body))
        }
    }

    fn commit(&self) -> Commit {
        match self.post("commit", serde_json::json!({})) {
            Ok((200, body)) => Commit::Applied(body["dropped"].as_u64().unwrap_or(0)),
            Ok((409, _))
                if matches!(
                    pending_switch::read(&crate::config::openlatch_dir()),
                    Ok(None)
                ) =>
            {
                Commit::AlreadyApplied
            }
            Ok((status, body)) => {
                tracing::warn!(reason = %route_reason(status, &body), "switch commit refused; restarting the daemon");
                Commit::Failed
            }
            Err(e) => {
                tracing::warn!(error = %e, "switch commit failed; restarting the daemon");
                Commit::Failed
            }
        }
    }

    /// Best-effort: end the lease and resume uploads.
    fn abort(&self) {
        let _ = self.post("abort", serde_json::json!({}));
    }

    /// Restart the daemon so the marker rule applies the switch (C-8 step 6).
    fn restart(&self) -> bool {
        use crate::cli::commands::lifecycle as lc;
        if !lc::stop_via_supervisor() {
            lc::send_shutdown_request(self.port, &self.token);
            let deadline = std::time::Instant::now() + Duration::from_secs(10);
            while std::time::Instant::now() < deadline && lc::check_health(self.port) {
                std::thread::sleep(Duration::from_millis(100));
            }
        }
        if lc::start_via_supervisor(self.port) {
            return true;
        }
        lc::spawn_daemon_background(self.port, &self.token).is_ok()
            && lc::wait_for_health(self.port, 10)
    }
}

fn route_reason(status: u16, body: &serde_json::Value) -> String {
    let reason = body["error"]["reason"]
        .as_str()
        .unwrap_or("unexpected reply");
    format!("HTTP {status}, {reason}")
}

fn org_json(org: &Org) -> serde_json::Value {
    serde_json::json!({ "id": org.id, "name": org.name })
}

fn render_already_in(output: &OutputConfig, org: &Org) {
    if output.format == OutputFormat::Json {
        output.print_json(&serde_json::json!({
            "switched": false,
            "from": org_json(org),
            "to": org_json(org),
            "events_sent": 0,
            "events_dropped": 0,
            "old_key_revoked": false,
        }));
    } else {
        output.print_step(&format!("Already in {}", org.name));
    }
}

struct Moved<'a> {
    host: &'a str,
    from: &'a Org,
    to: &'a Org,
    events_sent: u64,
    events_dropped: Option<u64>,
    applied_now: bool,
    old_key_revoked: bool,
    had_old_key: bool,
    warning: Option<&'static str>,
}

fn render_moved(output: &OutputConfig, m: &Moved<'_>) {
    if output.format == OutputFormat::Json {
        output.print_json(&serde_json::json!({
            "switched": true,
            "from": org_json(m.from),
            "to": org_json(m.to),
            "events_sent": m.events_sent,
            "events_dropped": m.events_dropped,
            "old_key_revoked": m.old_key_revoked,
        }));
        if let Some(w) = m.warning {
            output.print_note(&format!("Warning: {w}"));
        }
        return;
    }
    output.print_step(&format!(
        "Moved {} from {} to {}",
        m.host, m.from.name, m.to.name
    ));
    match m.events_dropped {
        Some(n) if n > 0 => output.print_substep(&format!("{n} queued events dropped")),
        Some(_) => {}
        None if !m.applied_now => {
            output.print_substep("queued events will be dropped when the daemon next starts")
        }
        None => {}
    }
    if let Some(w) = m.warning {
        output.print_note(&format!("Warning: {w}"));
    }
    if m.had_old_key && !m.old_key_revoked {
        output.print_note("old key not revoked — revoke it in the console");
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::auth::{retrieve_stored, InMemoryCredentialStore};

    /// A store that refuses every write: a broken keychain, a full disk.
    struct Refusing(InMemoryCredentialStore);

    impl CredentialStore for Refusing {
        fn store(&self, _key: SecretString) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "refused"))
        }
        fn retrieve(&self) -> Result<SecretString, OlError> {
            self.0.retrieve()
        }
        fn delete(&self) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "refused"))
        }
    }

    /// A store that takes writes but refuses to delete: a keychain entry that
    /// cannot be removed once written.
    struct Undeletable(InMemoryCredentialStore);

    impl CredentialStore for Undeletable {
        fn store(&self, key: SecretString) -> Result<(), OlError> {
            self.0.store(key)
        }
        fn retrieve(&self) -> Result<SecretString, OlError> {
            self.0.retrieve()
        }
        fn delete(&self) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "refused"))
        }
    }

    fn key(s: &str) -> SecretString {
        SecretString::from(s.to_string())
    }

    fn stored(primary: &dyn CredentialStore, fallback: &dyn CredentialStore) -> Option<String> {
        // The stored tiers, as `retrieve_credential` reads them after the
        // environment (which these tests never set; the integration flow
        // asserts the full lookup through `auth status`).
        retrieve_stored(primary, fallback).map(|(k, _)| k.expose_secret().to_string())
    }

    #[test]
    fn switch_store_goes_to_the_keychain_and_clears_the_file() {
        let keychain = InMemoryCredentialStore::new();
        let file = InMemoryCredentialStore::new();
        file.store(key("old")).unwrap();
        store_switching(&keychain, &file, &key("new")).expect("stored");
        assert_eq!(keychain.retrieve().unwrap().expose_secret(), "new");
        assert!(file.retrieve().is_err(), "the other tier is deleted");
    }

    /// A keychain that refuses writes but can still be cleared.
    struct ReadOnlyKeychain(InMemoryCredentialStore);

    impl CredentialStore for ReadOnlyKeychain {
        fn store(&self, _key: SecretString) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "refused"))
        }
        fn retrieve(&self) -> Result<SecretString, OlError> {
            self.0.retrieve()
        }
        fn delete(&self) -> Result<(), OlError> {
            self.0.delete()
        }
    }

    #[test]
    fn switch_store_falls_back_to_the_file_and_clears_a_stale_keychain_entry() {
        let keychain = ReadOnlyKeychain(InMemoryCredentialStore::new());
        keychain.0.store(key("old")).unwrap();
        let file = InMemoryCredentialStore::new();
        store_switching(&keychain, &file, &key("new")).expect("stored");
        assert!(keychain.retrieve().is_err(), "the stale entry is gone");
        assert_eq!(stored(&keychain, &file).as_deref(), Some("new"));
    }

    /// The keychain holds the old key and refuses both the write and the
    /// delete: the old key would shadow the new one, so the store fails and
    /// is rolled back rather than reported as a switch.
    #[test]
    fn switch_store_fails_when_a_stale_keychain_entry_cannot_be_cleared() {
        let keychain = Refusing(InMemoryCredentialStore::new());
        keychain.0.store(key("old")).unwrap();
        let file = InMemoryCredentialStore::new();
        let err = store_switching(&keychain, &file, &key("new")).expect_err("shadowed");
        assert_eq!(err.suggestion.as_deref(), Some(NOTHING_CHANGED));
        assert!(file.retrieve().is_err(), "the file tier is as it was");
        assert_eq!(stored(&keychain, &file).as_deref(), Some("old"));
    }

    /// AC-23: a storage failure leaves `retrieve_credential` returning the old key.
    #[test]
    fn switch_store_failure_restores_the_old_key_in_its_tier() {
        let keychain = Refusing(InMemoryCredentialStore::new());
        let file = Refusing(InMemoryCredentialStore::new());
        file.0.store(key("old")).unwrap();
        let err =
            store_switching(&keychain, &file, &key("ol_secret_9f3a")).expect_err("both refuse");
        assert!(
            !err.message.contains("ol_secret_9f3a"),
            "the key never reaches a message"
        );
        assert_eq!(err.suggestion.as_deref(), Some(NOTHING_CHANGED));
        assert_eq!(stored(&keychain, &file).as_deref(), Some("old"));
    }

    /// AC-23: the keychain took the new key but deleting the file failed — the
    /// keychain is put back as it was, so the old key is found again.
    #[test]
    fn switch_store_failure_after_the_keychain_write_rolls_the_keychain_back() {
        let keychain = InMemoryCredentialStore::new();
        let file = Refusing(InMemoryCredentialStore::new());
        file.0.store(key("old")).unwrap();
        store_switching(&keychain, &file, &key("new")).expect_err("the file delete fails");
        assert!(keychain.retrieve().is_err(), "the keychain is empty again");
        assert_eq!(stored(&keychain, &file).as_deref(), Some("old"));
    }

    /// C-8 step 5: when the rollback itself cannot put the old key back, the
    /// read-back check says so instead of reporting a clean "nothing changed".
    #[test]
    fn switch_store_failure_reports_a_rollback_the_read_back_does_not_confirm() {
        let keychain = Undeletable(InMemoryCredentialStore::new());
        let file = Refusing(InMemoryCredentialStore::new());
        file.0.store(key("old")).unwrap();
        let err =
            store_switching(&keychain, &file, &key("new")).expect_err("the file delete fails");
        assert!(
            err.message.contains("could not be restored"),
            "the failed read-back is reported: {}",
            err.message
        );
        assert_ne!(err.suggestion.as_deref(), Some(NOTHING_CHANGED));
    }
}