1use crate::provider;
10use crate::provider::ProviderId;
11mod adopt;
12mod auto;
13#[cfg(test)]
14mod crash;
15mod enroll;
16#[cfg(test)]
17mod foreign;
18mod forget;
19#[cfg(test)]
20pub(crate) mod harness;
21mod journal;
22#[cfg(test)]
23mod refusals;
24mod rename;
25pub(crate) mod renew;
26#[cfg(test)]
27mod two_tools;
28mod uninstall;
29
30pub use crate::pending::Reclaimed;
31pub use adopt::{Adopted, adopt};
32pub(crate) use auto::automatically;
33#[cfg(feature = "test-support")]
34pub(crate) use enroll::planted;
35pub(crate) use enroll::sign_in_watched_as;
36pub use enroll::{Enrolled, Said, SignIn, WatchedSignIn, enroll, sign_in, sign_in_watched};
37#[cfg(any(test, feature = "test-support"))]
38pub use enroll::{ScriptedSignIn, SignInScript};
39pub use forget::forget;
40pub use journal::{Abandoned, Recovered, pending as interrupted};
41pub(crate) use journal::{Asking, interrupted_tool};
42pub use rename::rename;
43pub use renew::{Due, Renewal, renew_due, renew_parked};
44pub use uninstall::{Removed, uninstall};
45
46use crate::context::Context;
47use crate::error::{Error, Result};
48use crate::service::{Permit, Warning};
49use crate::state::{Account, Key, Park, State};
50use crate::{api, fault, holder, home, lock, park, pending, state, store};
51use journal::{Journal, clear_journal, reconcile, write_journal};
52use serde_json::Value;
53use std::path::PathBuf;
54
55pub const ADOPTION_CEILING_SECONDS: u32 = 33;
60
61#[derive(Debug)]
62pub enum Outcome {
63 Switched {
64 provider: ProviderId,
66 from: String,
67 to: String,
68 parked: Park,
69 adoption: provider::Adoption,
73 },
74 AlreadyActive { label: String },
76}
77
78pub struct Settled {
82 _exclusive: std::fs::File,
83 state: State,
84 ctx: Context,
85 permit: Permit,
86}
87
88pub fn abandon(ctx: &Context, permit: Permit) -> Result<Option<Abandoned>> {
96 refuse_custom_oauth(ctx, journal::interrupted_tool(ctx))?;
97 let _exclusive = exclusive(ctx, permit)?;
98 let mut state = state::load(ctx)?;
99 journal::abandon(ctx, permit, &mut state)
100}
101
102fn refuse_custom_oauth(ctx: &Context, tool: Option<ProviderId>) -> Result<()> {
109 if !crate::settings::custom_oauth(ctx) {
110 return Ok(());
111 }
112 let claude = Some(ProviderId::Claude);
113 if tool.is_none() || tool == claude || journal::interrupted_tool(ctx) == claude {
114 return Err(Error::CustomOauthEndpoint);
115 }
116 Ok(())
117}
118
119pub fn settle(
124 ctx: &Context,
125 permit: Permit,
126 tool: Option<ProviderId>,
127) -> Result<(Settled, Option<Recovered>)> {
128 refuse_custom_oauth(ctx, tool)?;
129 let exclusive = exclusive(ctx, permit)?;
130 let mut state = state::load(ctx)?;
131 let recovered = reconcile(ctx, permit, &mut state)?;
132 pending::sweep(ctx, permit, &mut state)?;
134 drop_live_twins(ctx, permit, &mut state)?;
135 purge(ctx, permit, &mut state);
136 Ok((
137 Settled {
138 _exclusive: exclusive,
139 state,
140 ctx: ctx.clone(),
141 permit,
142 },
143 recovered,
144 ))
145}
146
147pub(crate) fn stuck(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
158 if !journal::pending(ctx) {
159 return None;
160 }
161 refuse_custom_oauth(ctx, journal::interrupted_tool(ctx)).ok()?;
165 journal::refusal(ctx, state, asking)
166}
167
168pub fn repair(settled: Settled) -> Result<pending::Reclaimed> {
173 let Settled {
174 _exclusive,
175 mut state,
176 ctx,
177 permit,
178 } = settled;
179 let reclaimed = pending::reclaim(&ctx, permit, &mut state)?;
180 purge(&ctx, permit, &mut state);
181 Ok(reclaimed)
182}
183
184fn drop_live_twins(ctx: &Context, permit: Permit, state: &mut State) -> Result<()> {
189 let twins = park::live_twins(ctx, state);
190 if twins.is_empty() {
191 return Ok(());
192 }
193 for service in &twins {
194 state.discard(service);
195 }
196 state::save(ctx, permit, state)
197}
198
199fn purge(ctx: &Context, permit: Permit, state: &mut State) -> usize {
202 let listed = state.discarded.len();
203 let remaining = park::purge(ctx, permit, state);
204 if remaining != listed {
205 let _ = state::save(ctx, permit, state);
206 }
207 remaining
208}
209
210fn exclusive(ctx: &Context, permit: Permit) -> Result<std::fs::File> {
214 let (file, path) = lock_file(ctx, permit)?;
215 file.lock()
216 .map_err(|source| Error::HomeUnwritable { path, source })?;
217 Ok(file)
218}
219
220fn try_exclusive(ctx: &Context, permit: Permit) -> Option<std::fs::File> {
222 let (file, _) = lock_file(ctx, permit).ok()?;
223 file.try_lock().ok()?;
224 Some(file)
225}
226
227fn lock_file(ctx: &Context, permit: Permit) -> Result<(std::fs::File, PathBuf)> {
228 let path = home::dir(ctx).join("state.lock");
229 let fail = |source| Error::HomeUnwritable {
230 path: path.clone(),
231 source,
232 };
233 home::ensure(ctx, permit).map_err(fail)?;
234 let file = crate::host::fs::open_private_lock(permit, &path).map_err(fail)?;
235 Ok((file, path))
236}
237
238pub(super) fn identify_document(
245 ctx: &Context,
246 which: ProviderId,
247 document: &Value,
248) -> Result<api::Owner> {
249 let credential = provider::Credential::new(which, document.clone());
250 provider::of(which)
251 .identify(ctx, &credential)
252 .map(api::Owner::from)
253 .map_err(|e| match e {
254 provider::ProviderError::Unauthorized => Error::SessionExpired { tool: which },
255 other @ (provider::ProviderError::ShapeUnexpected { .. }
256 | provider::ProviderError::Unsupported { .. }) => shape(which, other),
257 other => Error::IdentityUnverifiable {
258 tool: which,
259 cause: crate::error::Cause::of_provider(&other),
260 detail: other.to_string(),
261 },
262 })
263}
264
265pub(super) fn nothing_signed_in(ctx: &Context, which: ProviderId) -> Error {
271 match provider::of(which).recorded_identity(ctx) {
272 Some(found) => Error::LiveCredentialElsewhere { email: found.email },
273 None => Error::LiveCredentialAbsent { tool: which },
274 }
275}
276
277pub(super) fn live_store(ctx: &Context, which: ProviderId) -> Result<provider::LiveStore> {
279 provider::of(which).live(ctx).map_err(|e| shape(which, e))
280}
281
282fn read_live(
287 ctx: &Context,
288 which: ProviderId,
289 live: &provider::LiveStore,
290) -> Result<(String, Value)> {
291 let raw = store::read_raw(&live.chain, &live.service)?
292 .ok_or_else(|| nothing_signed_in(ctx, which))?;
293 let document = serde_json::from_str(&raw)
294 .map_err(|e| Error::Store(store::Error::Malformed(e.to_string())))?;
295 match provider::of(which).slice(&document) {
296 Err(provider::ProviderError::NoLogin { .. }) => Err(nothing_signed_in(ctx, which)),
297 Err(other) => Err(shape(which, other)),
298 Ok(_) => Ok((raw, document)),
299 }
300}
301
302pub fn switch(settled: Settled, key: &Key) -> Result<(Outcome, Vec<Warning>)> {
303 let Settled {
304 _exclusive,
305 state,
306 ctx,
307 permit,
308 } = settled;
309 switch_held(state, &ctx, permit, key, None)
310}
311
312fn switch_held(
317 mut state: State,
318 ctx: &Context,
319 permit: Permit,
320 key: &Key,
321 expected: Option<&str>,
322) -> Result<(Outcome, Vec<Warning>)> {
323 let label = &key.label;
324 let tool = provider::of(key.provider);
325 let target = state
326 .get(key)
327 .cloned()
328 .ok_or_else(|| Error::AccountUnknown {
329 label: key.typed(),
330 enrolled: state.labels(key.provider),
331 })?;
332 let live = live_store(ctx, key.provider)?;
333
334 let (_, first) = read_live(ctx, key.provider, &live)?;
337 let outgoing = identify_document(ctx, key.provider, &first)?;
338
339 if target.owned_by(&outgoing) {
340 if state.active_for(key.provider) != Some(label.as_str()) {
341 state.set_active(key.provider, Some(label.to_string()));
342 state.used(key, ctx.now());
343 state::save(ctx, permit, &state)?;
344 }
345 return Ok((
346 Outcome::AlreadyActive {
347 label: state.typed(key),
348 },
349 Vec::new(),
350 ));
351 }
352 if expected.is_some_and(|expected| expected != state.id_of(key.provider, &outgoing)) {
353 return Err(Error::SwitchOvertaken);
354 }
355 let (outgoing_key, outgoing_id) = state
356 .account_of(key.provider, &outgoing)
357 .map(|account| (account.key(), account.id.clone()))
358 .ok_or_else(|| Error::LiveAccountNotEnrolled {
359 tool: key.provider,
360 who: crate::words::login(
361 key.provider,
362 &outgoing.email,
363 state
364 .accounts
365 .iter()
366 .any(|a| a.provider() == key.provider && a.email == outgoing.email),
367 ),
368 })?;
369 let (from, to) = (state.typed(&outgoing_key), state.typed(key));
370 let held = target.parked.clone().ok_or_else(|| Error::NothingParked {
371 tool: key.provider,
372 label: to.clone(),
373 })?;
374 if !held.restorable_at(ctx.now()) {
375 return Err(Error::ParkedLoginExpired { label: to.clone() });
376 }
377 let incoming = park::load(ctx, key, &held)?;
378 let (held, incoming) = prove_incoming(ctx, permit, &mut state, key, &target, held, incoming)?;
381
382 let guard = write_lock(ctx, permit, key.provider)?;
383 let Readied {
384 before_raw,
385 before,
386 next,
387 on_the_command_line,
388 } = ready(ctx, key.provider, &live, &first, &outgoing, &incoming, &to)?;
389
390 let parking = park::price(
395 ctx,
396 key.provider,
397 &from,
398 &park::service_name(&outgoing_id, ctx.now_millis()),
399 &tool.slice(&before).map_err(|e| shape(key.provider, e))?,
400 )?;
401 let park_service = park::reserve(ctx, permit, &outgoing_id)?;
402 write_journal(
403 ctx,
404 permit,
405 &Journal {
406 provider: key.provider,
407 started_at: ctx.now(),
408 from_label: outgoing_key.label.clone(),
409 from_id: outgoing_id,
410 to_label: label.to_string(),
411 to_id: target.id.clone(),
412 park_service: park_service.clone(),
413 incoming_service: held.service.clone(),
414 from_fingerprint: tool.fingerprint(&before),
416 to_fingerprint: held.refresh_fingerprint.clone(),
417 slot: Some(tool.slot(ctx)),
418 },
419 )?;
420 fault::point("switch.journal_written");
421
422 let slice = tool.slice(&before).map_err(|e| shape(key.provider, e))?;
426 let parked = match park::store_at(ctx, permit, key.provider, &park_service, &slice) {
427 Ok(parked) => parked,
428 Err(e) => {
429 clear_journal(ctx, permit);
430 return Err(e);
431 }
432 };
433 fault::point("switch.park_stored");
434 state.park(&outgoing_key, parked.clone());
435 if let Err(e) = state::save(ctx, permit, &state) {
436 let _ = store::vault_delete(ctx, permit, &parked.service);
437 clear_journal(ctx, permit);
438 return Err(e);
439 }
440 fault::point("switch.park_recorded");
441
442 if tool.park_semantics() == provider::ParkSemantics::MoveOnly
447 && store::vault_read(ctx, &parked.service)?.is_none()
448 {
449 state.release(&parked.service);
450 state::save(ctx, permit, &state)?;
451 clear_journal(ctx, permit);
452 return Err(Error::ParkedCredentialMissing { label: from });
453 }
454
455 let now = store::read_raw(&live.chain, &live.service);
464 if !matches!(&now, Ok(Some(now)) if *now == before_raw) {
465 let still_outgoing = now
466 .ok()
467 .flatten()
468 .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
469 .and_then(|document| identify_document(ctx, key.provider, &document).ok())
470 .is_some_and(|found| found.same_login(&outgoing));
471 if still_outgoing {
472 state.discard(&parked.service);
473 }
474 state::save(ctx, permit, &state)?;
475 clear_journal(ctx, permit);
476 purge(ctx, permit, &mut state);
477 return Err(Error::SignedInAccountChanged);
478 }
479
480 if let Err(e) = install_with(
481 key.provider,
482 |body| store::write_raw(&live.chain, permit, &live.service, body),
483 || store::read_raw(&live.chain, &live.service),
484 &next,
485 &before_raw,
486 &from,
487 &to,
488 ) {
489 if matches!(e, Error::SwitchUnverified { .. }) {
494 return Err(e);
495 }
496 if !only_copy_left(&e) {
497 state.discard(&parked.service);
498 }
499 state::save(ctx, permit, &state)?;
500 clear_journal(ctx, permit);
501 purge(ctx, permit, &mut state);
502 return Err(e);
503 }
504 fault::point("switch.installed");
505
506 let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
515 match holds(key.provider, &live) {
516 Ok(true) => {}
517 Ok(false) => {
518 clear_journal(ctx, permit);
519 return Err(Error::SwitchDidNotHold {
520 tool: key.provider,
521 from,
522 to,
523 });
524 }
525 Err(unreadable) => {
526 return Err(Error::SwitchUnverified {
527 tool: key.provider,
528 from,
529 to,
530 detail: unreadable.to_string(),
531 });
532 }
533 }
534
535 state.discard(&held.service);
536 state.set_active(key.provider, Some(label.to_string()));
537 state.used(key, ctx.now());
538 state::save(ctx, permit, &state)?;
539 fault::point("switch.recorded");
540 drop(guard);
541
542 let outgoing_identity = provider::Identity {
545 account_id: outgoing.account_uuid.clone(),
546 email: outgoing.email.clone(),
547 group: Some(outgoing.organization_uuid.clone()).filter(|g| !g.is_empty()),
548 };
549 let cache_warning = tool
550 .after_switch(ctx, permit, &target, &outgoing_identity)
551 .err()
552 .map(Warning::ConfigNotUpdated);
553 fault::point("switch.config_updated");
554 let parks_pending = purge(ctx, permit, &mut state);
555 clear_journal(ctx, permit);
556
557 let still_running = match still_holding(ctx, key.provider) {
563 StillHolding::Nothing => None,
564 StillHolding::These(holding) => Some(Warning::SessionsStillRunning {
565 from: from.clone(),
566 holding,
567 }),
568 StillHolding::Unknown => Some(Warning::SessionsUnknown {
569 tool: key.provider,
570 from: from.clone(),
571 }),
572 };
573 let warnings = on_the_command_line
574 .into_iter()
575 .chain(parking)
576 .chain(still_running)
577 .chain(lock_lost.then_some(Warning::LockCompromised { tool: key.provider }))
578 .chain(cache_warning)
579 .chain((parks_pending > 0).then_some(Warning::ParksPendingRemoval(parks_pending)))
580 .collect();
581 Ok((
582 Outcome::Switched {
583 provider: key.provider,
584 adoption: tool.adoption(),
585 from,
586 to,
587 parked,
588 },
589 warnings,
590 ))
591}
592
593struct Readied {
595 before_raw: String,
597 before: Value,
598 next: String,
600 on_the_command_line: Option<Warning>,
601}
602
603fn write_lock(ctx: &Context, permit: Permit, which: ProviderId) -> Result<Option<lock::Guard>> {
607 Ok(provider::of(which)
608 .write_lock(ctx)
609 .map(|dir| lock::acquire(permit, &dir))
610 .transpose()?)
611}
612
613fn ready(
620 ctx: &Context,
621 which: ProviderId,
622 live: &provider::LiveStore,
623 first: &Value,
624 signed_in: &api::Owner,
625 incoming: &Value,
626 label: &str,
627) -> Result<Readied> {
628 let tool = provider::of(which);
629 let (before_raw, before) = read_live(ctx, which, live)?;
630 if tool.slice(&before).ok() != tool.slice(first).ok()
633 && !identify_document(ctx, which, &before)?.same_login(signed_in)
634 {
635 return Err(Error::SignedInAccountChanged);
636 }
637
638 let next = to_body(
641 tool.splice(&before, incoming)
642 .map_err(|e| shape(which, e))?,
643 );
644 let price = store::cost(&live.chain, &live.service, &next);
647 if price.is_some_and(store::Cost::refused) {
648 let price = price.expect("refused implies a ceiling");
649 return Err(Error::CredentialTooLarge {
650 tool: which,
651 label: label.to_string(),
652 bytes: price.needs,
653 limit: price.limit,
654 });
655 }
656 let on_the_command_line =
659 price
660 .filter(|p| p.on_the_second_route())
661 .map(|p| Warning::WrittenOnTheCommandLine {
662 tool: which,
663 bytes: p.needs,
664 limit: p.limit,
665 });
666 Ok(Readied {
667 before_raw,
668 before,
669 next,
670 on_the_command_line,
671 })
672}
673
674fn holds(which: ProviderId, live: &provider::LiveStore) -> std::result::Result<bool, store::Error> {
679 let tool = provider::of(which);
680 store::read_raw(&live.chain, &live.service).map(|now| {
681 now.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
682 .is_some_and(|document| tool.slice(&document).is_ok())
683 })
684}
685
686#[derive(Debug, Clone, PartialEq, Eq)]
689pub(crate) enum StillHolding {
690 Nothing,
692 These(Vec<holder::Holding>),
694 Unknown,
698}
699
700pub(crate) fn still_holding(ctx: &Context, which: ProviderId) -> StillHolding {
703 match provider::of(which).adoption() {
704 provider::Adoption::RestartRequired { program, holders } => {
705 match holder::find(ctx, program, holders) {
706 None => StillHolding::Unknown,
707 Some(holding) if holding.is_empty() => StillHolding::Nothing,
708 Some(holding) => StillHolding::These(holding),
709 }
710 }
711 provider::Adoption::PollingWithin(_) => StillHolding::Nothing,
712 }
713}
714
715fn prove_incoming(
727 ctx: &Context,
728 permit: Permit,
729 state: &mut State,
730 key: &Key,
731 target: &Account,
732 held: Park,
733 incoming: Value,
734) -> Result<(Park, Value)> {
735 let label = state.typed(key);
736 if held.askable_at(ctx.now()) {
737 let credential = provider::Credential::new(key.provider, incoming.clone());
738 match provider::of(key.provider)
739 .verify(ctx, &credential)
740 .map(api::Owner::from)
741 {
742 Ok(found) if target.owned_by(&found) => return Ok((held, incoming)),
743 Ok(other) => {
744 return Err(Error::ParkedLoginBelongsElsewhere {
745 label,
746 who: crate::words::login(
747 key.provider,
748 &other.email,
749 other.email == target.email,
750 ),
751 });
752 }
753 Err(provider::ProviderError::Unauthorized) => {}
756 Err(provider::ProviderError::ShapeUnexpected { detail, .. }) => {
757 return Err(Error::ParkedCredentialCorrupt { label, detail });
758 }
759 Err(e) => {
760 return Err(Error::IdentityUnverifiable {
761 tool: key.provider,
762 cause: crate::error::Cause::of_provider(&e),
763 detail: e.to_string(),
764 });
765 }
766 }
767 }
768
769 let Some(fresh) = renew::renew_one(ctx, permit, state, key, &held)? else {
771 return Err(Error::IdentityUnverifiable {
772 tool: key.provider,
773 cause: crate::error::Cause::Unreachable,
774 detail: format!(
775 "`{label}`'s parked login needs renewing and {} did not answer",
776 key.provider.service()
777 ),
778 });
779 };
780 let document = park::load(ctx, key, &fresh)?;
781 Ok((fresh, document))
782}
783
784fn only_copy_left(failure: &Error) -> bool {
788 matches!(failure, Error::SwitchCorrupted { .. })
789}
790
791pub(super) fn shape(tool: ProviderId, error: provider::ProviderError) -> Error {
798 match error {
799 provider::ProviderError::ShapeUnexpected { detail, .. } => {
800 Error::LiveCredentialShapeUnexpected { tool, detail }
801 }
802 provider::ProviderError::Unsupported { reason, .. } => {
803 Error::LiveStoreUnsupported { tool, reason }
804 }
805 other => Error::LiveCredentialShapeUnexpected {
806 tool,
807 detail: other.to_string(),
808 },
809 }
810}
811
812fn to_body(document: Value) -> String {
814 serde_json::to_string(&document).expect("a credential document stays serialisable")
815}
816
817fn install_with(
827 tool: ProviderId,
828 write: impl Fn(&str) -> std::result::Result<(), store::Error>,
829 read: impl Fn() -> std::result::Result<Option<String>, store::Error>,
830 next: &str,
831 before_raw: &str,
832 from: &str,
833 to: &str,
834) -> Result<()> {
835 let Err(failure) = write(next) else {
836 return Ok(());
837 };
838 let rolled_back = |detail: String| Error::SwitchRolledBack {
839 from: from.to_string(),
840 to: to.to_string(),
841 detail,
842 };
843 match read() {
844 Ok(Some(now)) if now == before_raw => return Err(rolled_back(failure.to_string())),
846 Err(unreadable) => {
849 return Err(Error::SwitchUnverified {
850 tool,
851 from: from.to_string(),
852 to: to.to_string(),
853 detail: format!("{failure}; {unreadable}"),
854 });
855 }
856 _ => {}
858 }
859 match write(before_raw) {
860 Ok(()) => Err(rolled_back(failure.to_string())),
861 Err(rollback) => Err(Error::SwitchCorrupted {
862 tool,
863 from: from.to_string(),
864 to: to.to_string(),
865 detail: format!("{failure}; {rollback}"),
866 }),
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873
874 use std::cell::RefCell;
875
876 fn failing(message: &str) -> store::Error {
877 store::Error::Write(message.into())
878 }
879
880 #[test]
881 fn a_successful_write_needs_no_rollback() {
882 let written = RefCell::new(Vec::new());
883 let result = install_with(
884 ProviderId::Claude,
885 |b| {
886 written.borrow_mut().push(b.to_string());
887 Ok(())
888 },
889 || unreachable!(),
890 "new",
891 "old",
892 "a",
893 "b",
894 );
895 assert!(result.is_ok());
896 assert_eq!(*written.borrow(), vec!["new"]);
897 }
898
899 #[test]
900 fn a_failed_write_that_changed_nothing_is_not_reported_as_a_lost_login() {
901 let result = install_with(
902 ProviderId::Claude,
903 |_| Err(failing("keychain locked")),
904 || Ok(Some("old".into())),
905 "new",
906 "old",
907 "a",
908 "b",
909 );
910 assert!(
911 matches!(result, Err(Error::SwitchRolledBack { .. })),
912 "the old login never left, so the user must not be told to sign in again"
913 );
914 }
915
916 #[test]
917 fn a_half_write_is_rolled_back() {
918 let slot = RefCell::new("old".to_string());
919 let result = install_with(
920 ProviderId::Claude,
921 |b| {
922 if b == "new" {
923 *slot.borrow_mut() = "garbled".into();
924 Err(failing("interrupted"))
925 } else {
926 *slot.borrow_mut() = b.to_string();
927 Ok(())
928 }
929 },
930 || Ok(Some(slot.borrow().clone())),
931 "new",
932 "old",
933 "a",
934 "b",
935 );
936 assert!(matches!(result, Err(Error::SwitchRolledBack { .. })));
937 assert_eq!(
938 *slot.borrow(),
939 "old",
940 "the previous login must be back in place"
941 );
942 }
943
944 #[test]
950 fn a_store_that_cannot_be_read_back_is_not_a_lost_login() {
951 let writes = RefCell::new(0);
952 let result = install_with(
953 ProviderId::Claude,
954 |_| {
955 *writes.borrow_mut() += 1;
956 Err(failing("the keychain is locked"))
957 },
958 || Err(store::Error::Unreadable("the keychain is locked".into())),
959 "new",
960 "old",
961 "a",
962 "b",
963 );
964 assert!(
965 matches!(result, Err(Error::SwitchUnverified { .. })),
966 "not knowing is its own answer, and must not read as a lost login"
967 );
968 assert_eq!(
969 *writes.borrow(),
970 1,
971 "and nothing further is written into a store that cannot be read"
972 );
973 }
974
975 #[test]
976 fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
977 let result = install_with(
978 ProviderId::Claude,
979 |_| Err(failing("disk full")),
980 || Ok(Some("garbled".into())),
981 "new",
982 "old",
983 "a",
984 "b",
985 );
986 assert!(matches!(result, Err(Error::SwitchCorrupted { .. })));
987 }
988
989 #[test]
990 fn a_copy_is_kept_after_a_failed_install_only_when_it_is_all_that_is_left() {
991 let (from, to, detail) = ("a".to_string(), "b".to_string(), String::new());
992 assert!(!only_copy_left(&Error::SwitchRolledBack {
993 from: from.clone(),
994 to: to.clone(),
995 detail: detail.clone(),
996 }));
997 assert!(only_copy_left(&Error::SwitchCorrupted {
998 tool: ProviderId::Claude,
999 from,
1000 to,
1001 detail
1002 }));
1003 }
1004}