1use crate::provider;
10use crate::provider::ProviderId;
11mod adopt;
12#[cfg(test)]
13mod crash;
14mod enroll;
15#[cfg(test)]
16mod foreign;
17mod forget;
18#[cfg(test)]
19pub(crate) mod harness;
20mod journal;
21#[cfg(test)]
22mod refusals;
23mod rename;
24pub(crate) mod renew;
25#[cfg(test)]
26mod two_tools;
27mod uninstall;
28
29pub use crate::pending::Reclaimed;
30pub use adopt::{Adopted, adopt};
31pub use enroll::{Enrolled, Said, SignIn, WatchedSignIn, enroll, sign_in, sign_in_watched};
32pub use forget::forget;
33pub(crate) use journal::interrupted_tool;
34pub use journal::{Abandoned, Recovered, pending as interrupted};
35pub use rename::rename;
36pub use renew::{Due, Renewal, renew_due, renew_parked};
37pub use uninstall::{Removed, uninstall};
38
39use crate::context::Context;
40use crate::error::{Error, Result};
41use crate::service::Warning;
42use crate::state::{Account, Key, Park, State};
43use crate::{api, fault, holder, home, lock, park, pending, state, store};
44use journal::{Journal, clear_journal, reconcile, write_journal};
45use serde_json::Value;
46use std::os::unix::fs::OpenOptionsExt;
47use std::path::PathBuf;
48
49pub const ADOPTION_CEILING_SECONDS: u32 = 33;
54
55#[derive(Debug)]
56pub enum Outcome {
57 Switched {
58 provider: ProviderId,
60 from: String,
61 to: String,
62 parked: Park,
63 adoption: provider::Adoption,
67 },
68 AlreadyActive { label: String },
70}
71
72pub struct Settled {
75 _exclusive: std::fs::File,
76 state: State,
77 ctx: Context,
78}
79
80pub fn abandon(ctx: &Context) -> Result<Option<Abandoned>> {
83 refuse_custom_oauth(ctx, None)?;
84 let _exclusive = exclusive(ctx)?;
85 let mut state = state::load(ctx)?;
86 journal::abandon(ctx, &mut state)
87}
88
89fn refuse_custom_oauth(ctx: &Context, tool: Option<ProviderId>) -> Result<()> {
96 if !crate::settings::custom_oauth(ctx) {
97 return Ok(());
98 }
99 let claude = Some(ProviderId::Claude);
100 if tool.is_none() || tool == claude || journal::interrupted_tool(ctx) == claude {
101 return Err(Error::CustomOauthEndpoint);
102 }
103 Ok(())
104}
105
106pub fn settle(ctx: &Context, tool: Option<ProviderId>) -> Result<(Settled, Option<Recovered>)> {
111 refuse_custom_oauth(ctx, tool)?;
112 let exclusive = exclusive(ctx)?;
113 let mut state = state::load(ctx)?;
114 let recovered = reconcile(ctx, &mut state)?;
115 pending::sweep(ctx, &mut state)?;
117 drop_live_twins(ctx, &mut state)?;
118 purge(ctx, &mut state);
119 Ok((
120 Settled {
121 _exclusive: exclusive,
122 state,
123 ctx: ctx.clone(),
124 },
125 recovered,
126 ))
127}
128
129pub fn repair(settled: Settled) -> Result<pending::Reclaimed> {
134 let Settled {
135 _exclusive,
136 mut state,
137 ctx,
138 } = settled;
139 let reclaimed = pending::reclaim(&ctx, &mut state)?;
140 purge(&ctx, &mut state);
141 Ok(reclaimed)
142}
143
144fn drop_live_twins(ctx: &Context, state: &mut State) -> Result<()> {
149 let twins = park::live_twins(ctx, state);
150 if twins.is_empty() {
151 return Ok(());
152 }
153 for service in &twins {
154 state.discard(service);
155 }
156 state::save(ctx, state)
157}
158
159fn purge(ctx: &Context, state: &mut State) -> usize {
162 let listed = state.discarded.len();
163 let remaining = park::purge(ctx, state);
164 if remaining != listed {
165 let _ = state::save(ctx, state);
166 }
167 remaining
168}
169
170fn exclusive(ctx: &Context) -> Result<std::fs::File> {
174 let (file, path) = lock_file(ctx)?;
175 file.lock()
176 .map_err(|source| Error::HomeUnwritable { path, source })?;
177 Ok(file)
178}
179
180fn try_exclusive(ctx: &Context) -> Option<std::fs::File> {
182 let (file, _) = lock_file(ctx).ok()?;
183 file.try_lock().ok()?;
184 Some(file)
185}
186
187fn lock_file(ctx: &Context) -> Result<(std::fs::File, PathBuf)> {
188 let path = home::dir(ctx).join("state.lock");
189 let fail = |source| Error::HomeUnwritable {
190 path: path.clone(),
191 source,
192 };
193 home::ensure(ctx).map_err(fail)?;
194 let file = std::fs::OpenOptions::new()
195 .create(true)
196 .truncate(false)
197 .write(true)
198 .mode(0o600)
199 .open(&path)
200 .map_err(fail)?;
201 Ok((file, path))
202}
203
204pub(super) fn identify_document(
211 ctx: &Context,
212 which: ProviderId,
213 document: &Value,
214) -> Result<api::Owner> {
215 let credential = provider::Credential::new(which, document.clone());
216 provider::of(which)
217 .identify(ctx, &credential)
218 .map(|found| api::Owner {
219 account_uuid: found.account_id,
220 email: found.email,
221 organization_uuid: found.group.unwrap_or_default(),
222 })
223 .map_err(|e| match e {
224 provider::ProviderError::Unauthorized => Error::SessionExpired { tool: which },
225 other @ (provider::ProviderError::ShapeUnexpected { .. }
226 | provider::ProviderError::Unsupported { .. }) => shape(which, other),
227 other => Error::IdentityUnverifiable {
228 tool: which,
229 cause: crate::error::Cause::of_provider(&other),
230 detail: other.to_string(),
231 },
232 })
233}
234
235pub(super) fn nothing_signed_in(ctx: &Context, which: ProviderId) -> Error {
241 match provider::of(which).recorded_identity(ctx) {
242 Some(found) => Error::LiveCredentialElsewhere { email: found.email },
243 None => Error::LiveCredentialAbsent { tool: which },
244 }
245}
246
247pub(super) fn live_store(ctx: &Context, which: ProviderId) -> Result<provider::LiveStore> {
249 provider::of(which).live(ctx).map_err(|e| shape(which, e))
250}
251
252fn read_live(
257 ctx: &Context,
258 which: ProviderId,
259 live: &provider::LiveStore,
260) -> Result<(String, Value)> {
261 let raw = store::read_raw(&live.chain, &live.service)?
262 .ok_or_else(|| nothing_signed_in(ctx, which))?;
263 let document = serde_json::from_str(&raw)
264 .map_err(|e| Error::Store(store::Error::Malformed(e.to_string())))?;
265 match provider::of(which).slice(&document) {
266 Err(provider::ProviderError::NoLogin { .. }) => Err(nothing_signed_in(ctx, which)),
267 Err(other) => Err(shape(which, other)),
268 Ok(_) => Ok((raw, document)),
269 }
270}
271
272pub fn switch(settled: Settled, key: &Key) -> Result<(Outcome, Vec<Warning>)> {
273 let Settled {
274 _exclusive,
275 mut state,
276 ctx,
277 } = settled;
278 let ctx = &ctx;
279 let label = &key.label;
280 let tool = provider::of(key.provider);
281 let target = state
282 .get(key)
283 .cloned()
284 .ok_or_else(|| Error::AccountUnknown {
285 label: key.typed(),
286 enrolled: state.labels(key.provider),
287 })?;
288 let live = live_store(ctx, key.provider)?;
289
290 let (_, first) = read_live(ctx, key.provider, &live)?;
293 let outgoing = identify_document(ctx, key.provider, &first)?;
294
295 if outgoing.account_uuid == target.account_uuid {
296 if state.active_for(key.provider) != Some(label.as_str()) {
297 state.set_active(key.provider, Some(label.to_string()));
298 state.used(key, ctx.now());
299 state::save(ctx, &state)?;
300 }
301 return Ok((
302 Outcome::AlreadyActive {
303 label: state.typed(key),
304 },
305 Vec::new(),
306 ));
307 }
308 let outgoing_key = state
309 .by_uuid(key.provider, &outgoing.account_uuid)
310 .map(Account::key)
311 .ok_or_else(|| Error::LiveAccountNotEnrolled {
312 tool: key.provider,
313 email: outgoing.email.clone(),
314 })?;
315 let (from, to) = (state.typed(&outgoing_key), state.typed(key));
316 let held = target.parked.clone().ok_or_else(|| Error::NothingParked {
317 tool: key.provider,
318 label: to.clone(),
319 })?;
320 if !held.restorable_at(ctx.now()) {
321 return Err(Error::ParkedLoginExpired { label: to.clone() });
322 }
323 let incoming = park::load(ctx, key, &held)?;
324 let (held, incoming) = prove_incoming(ctx, &mut state, key, &target, held, incoming)?;
327
328 let Readied {
329 guard,
330 before_raw,
331 before,
332 next,
333 on_the_command_line,
334 } = ready(
335 ctx,
336 key.provider,
337 &live,
338 &first,
339 &outgoing.account_uuid,
340 &incoming,
341 &to,
342 )?;
343
344 let parking = park::price(
349 ctx,
350 key.provider,
351 &from,
352 &park::service_name(&outgoing.account_uuid, ctx.now_millis()),
353 &tool.slice(&before).map_err(|e| shape(key.provider, e))?,
354 )?;
355 let park_service = park::reserve(ctx, &outgoing.account_uuid)?;
356 write_journal(
357 ctx,
358 &Journal {
359 provider: key.provider,
360 started_at: ctx.now(),
361 from_label: outgoing_key.label.clone(),
362 from_uuid: outgoing.account_uuid.clone(),
363 to_label: label.to_string(),
364 to_uuid: target.account_uuid.clone(),
365 park_service: park_service.clone(),
366 incoming_service: held.service.clone(),
367 from_fingerprint: tool.fingerprint(&before),
369 to_fingerprint: held.refresh_fingerprint.clone(),
370 slot: Some(tool.slot(ctx)),
371 },
372 )?;
373 fault::point("switch.journal_written");
374
375 let slice = tool.slice(&before).map_err(|e| shape(key.provider, e))?;
379 let parked = match park::store_at(ctx, key.provider, &park_service, &slice) {
380 Ok(parked) => parked,
381 Err(e) => {
382 clear_journal(ctx);
383 return Err(e);
384 }
385 };
386 fault::point("switch.park_stored");
387 state.park(&outgoing_key, parked.clone());
388 if let Err(e) = state::save(ctx, &state) {
389 let _ = store::vault_delete(ctx, &parked.service);
390 clear_journal(ctx);
391 return Err(e);
392 }
393 fault::point("switch.park_recorded");
394
395 if tool.park_semantics() == provider::ParkSemantics::MoveOnly
400 && store::vault_read(ctx, &parked.service)?.is_none()
401 {
402 state.release(&parked.service);
403 state::save(ctx, &state)?;
404 clear_journal(ctx);
405 return Err(Error::ParkedCredentialMissing { label: from });
406 }
407
408 let now = store::read_raw(&live.chain, &live.service);
417 if !matches!(&now, Ok(Some(now)) if *now == before_raw) {
418 let still_outgoing = now
419 .ok()
420 .flatten()
421 .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
422 .and_then(|document| identify_document(ctx, key.provider, &document).ok())
423 .is_some_and(|found| found.account_uuid == outgoing.account_uuid);
424 if still_outgoing {
425 state.discard(&parked.service);
426 }
427 state::save(ctx, &state)?;
428 clear_journal(ctx);
429 purge(ctx, &mut state);
430 return Err(Error::SignedInAccountChanged);
431 }
432
433 if let Err(e) = install_with(
434 key.provider,
435 |body| store::write_raw(&live.chain, &live.service, body),
436 || store::read_raw(&live.chain, &live.service),
437 &next,
438 &before_raw,
439 &from,
440 &to,
441 ) {
442 if matches!(e, Error::SwitchUnverified { .. }) {
447 return Err(e);
448 }
449 if !only_copy_left(&e) {
450 state.discard(&parked.service);
451 }
452 state::save(ctx, &state)?;
453 clear_journal(ctx);
454 purge(ctx, &mut state);
455 return Err(e);
456 }
457 fault::point("switch.installed");
458
459 let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
468 match holds(key.provider, &live) {
469 Ok(true) => {}
470 Ok(false) => {
471 clear_journal(ctx);
472 return Err(Error::SwitchDidNotHold {
473 tool: key.provider,
474 from,
475 to,
476 });
477 }
478 Err(unreadable) => {
479 return Err(Error::SwitchUnverified {
480 tool: key.provider,
481 from,
482 to,
483 detail: unreadable.to_string(),
484 });
485 }
486 }
487
488 state.discard(&held.service);
489 state.set_active(key.provider, Some(label.to_string()));
490 state.used(key, ctx.now());
491 state::save(ctx, &state)?;
492 fault::point("switch.recorded");
493 drop(guard);
494
495 let outgoing_identity = provider::Identity {
498 account_id: outgoing.account_uuid.clone(),
499 email: outgoing.email.clone(),
500 group: Some(outgoing.organization_uuid.clone()).filter(|g| !g.is_empty()),
501 };
502 let cache_warning = tool
503 .after_switch(ctx, &target, &outgoing_identity)
504 .err()
505 .map(Warning::ConfigNotUpdated);
506 fault::point("switch.config_updated");
507 let parks_pending = purge(ctx, &mut state);
508 clear_journal(ctx);
509
510 let still_running =
515 still_holding(ctx, key.provider).map(|holding| Warning::SessionsStillRunning {
516 from: from.clone(),
517 holding,
518 });
519 let warnings = on_the_command_line
520 .into_iter()
521 .chain(parking)
522 .chain(still_running)
523 .chain(lock_lost.then_some(Warning::LockCompromised { tool: key.provider }))
524 .chain(cache_warning)
525 .chain((parks_pending > 0).then_some(Warning::ParksPendingRemoval(parks_pending)))
526 .collect();
527 Ok((
528 Outcome::Switched {
529 provider: key.provider,
530 adoption: tool.adoption(),
531 from,
532 to,
533 parked,
534 },
535 warnings,
536 ))
537}
538
539struct Readied {
541 guard: Option<lock::Guard>,
543 before_raw: String,
545 before: Value,
546 next: String,
548 on_the_command_line: Option<Warning>,
549}
550
551fn ready(
557 ctx: &Context,
558 which: ProviderId,
559 live: &provider::LiveStore,
560 first: &Value,
561 signed_in: &str,
562 incoming: &Value,
563 label: &str,
564) -> Result<Readied> {
565 let tool = provider::of(which);
566 let guard = tool
567 .write_lock(ctx)
568 .map(|dir| lock::acquire(&dir))
569 .transpose()?;
570 let (before_raw, before) = read_live(ctx, which, live)?;
571 if tool.slice(&before).ok() != tool.slice(first).ok()
574 && identify_document(ctx, which, &before)?.account_uuid != signed_in
575 {
576 return Err(Error::SignedInAccountChanged);
577 }
578
579 let next = to_body(
582 tool.splice(&before, incoming)
583 .map_err(|e| shape(which, e))?,
584 );
585 let price = store::cost(&live.chain, &live.service, &next);
588 if price.is_some_and(store::Cost::refused) {
589 let price = price.expect("refused implies a ceiling");
590 return Err(Error::CredentialTooLarge {
591 tool: which,
592 label: label.to_string(),
593 bytes: price.needs,
594 limit: price.limit,
595 });
596 }
597 let on_the_command_line =
600 price
601 .filter(|p| p.on_the_second_route())
602 .map(|p| Warning::WrittenOnTheCommandLine {
603 tool: which,
604 bytes: p.needs,
605 limit: p.limit,
606 });
607 Ok(Readied {
608 guard,
609 before_raw,
610 before,
611 next,
612 on_the_command_line,
613 })
614}
615
616fn holds(which: ProviderId, live: &provider::LiveStore) -> std::result::Result<bool, store::Error> {
621 let tool = provider::of(which);
622 store::read_raw(&live.chain, &live.service).map(|now| {
623 now.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
624 .is_some_and(|document| tool.slice(&document).is_ok())
625 })
626}
627
628pub(crate) fn still_holding(ctx: &Context, which: ProviderId) -> Option<Vec<holder::Holding>> {
631 match provider::of(which).adoption() {
632 provider::Adoption::RestartRequired { program, holders } => {
633 holder::find(ctx, program, holders).filter(|holding| !holding.is_empty())
634 }
635 provider::Adoption::PollingWithin(_) => None,
636 }
637}
638
639fn prove_incoming(
651 ctx: &Context,
652 state: &mut State,
653 key: &Key,
654 target: &Account,
655 held: Park,
656 incoming: Value,
657) -> Result<(Park, Value)> {
658 let label = state.typed(key);
659 if held.askable_at(ctx.now()) {
660 let credential = provider::Credential::new(key.provider, incoming.clone());
661 match provider::of(key.provider).verify(ctx, &credential) {
662 Ok(found) if found.account_id == target.account_uuid => return Ok((held, incoming)),
663 Ok(other) => {
664 return Err(Error::ParkedLoginBelongsElsewhere {
665 label,
666 email: other.email,
667 });
668 }
669 Err(provider::ProviderError::Unauthorized) => {}
672 Err(provider::ProviderError::ShapeUnexpected { detail, .. }) => {
673 return Err(Error::ParkedCredentialCorrupt { label, detail });
674 }
675 Err(e) => {
676 return Err(Error::IdentityUnverifiable {
677 tool: key.provider,
678 cause: crate::error::Cause::of_provider(&e),
679 detail: e.to_string(),
680 });
681 }
682 }
683 }
684
685 let Some(fresh) = renew::renew_one(ctx, state, key, &held)? else {
687 return Err(Error::IdentityUnverifiable {
688 tool: key.provider,
689 cause: crate::error::Cause::Unreachable,
690 detail: format!(
691 "`{label}`'s parked login needs renewing and {} did not answer",
692 key.provider.service()
693 ),
694 });
695 };
696 let document = park::load(ctx, key, &fresh)?;
697 Ok((fresh, document))
698}
699
700fn only_copy_left(failure: &Error) -> bool {
704 matches!(failure, Error::SwitchCorrupted { .. })
705}
706
707pub(super) fn shape(tool: ProviderId, error: provider::ProviderError) -> Error {
714 match error {
715 provider::ProviderError::ShapeUnexpected { detail, .. } => {
716 Error::LiveCredentialShapeUnexpected { tool, detail }
717 }
718 provider::ProviderError::Unsupported { reason, .. } => {
719 Error::LiveStoreUnsupported { tool, reason }
720 }
721 other => Error::LiveCredentialShapeUnexpected {
722 tool,
723 detail: other.to_string(),
724 },
725 }
726}
727
728fn to_body(document: Value) -> String {
730 serde_json::to_string(&document).expect("a credential document stays serialisable")
731}
732
733fn install_with(
743 tool: ProviderId,
744 write: impl Fn(&str) -> std::result::Result<(), store::Error>,
745 read: impl Fn() -> std::result::Result<Option<String>, store::Error>,
746 next: &str,
747 before_raw: &str,
748 from: &str,
749 to: &str,
750) -> Result<()> {
751 let Err(failure) = write(next) else {
752 return Ok(());
753 };
754 let rolled_back = |detail: String| Error::SwitchRolledBack {
755 from: from.to_string(),
756 to: to.to_string(),
757 detail,
758 };
759 match read() {
760 Ok(Some(now)) if now == before_raw => return Err(rolled_back(failure.to_string())),
762 Err(unreadable) => {
765 return Err(Error::SwitchUnverified {
766 tool,
767 from: from.to_string(),
768 to: to.to_string(),
769 detail: format!("{failure}; {unreadable}"),
770 });
771 }
772 _ => {}
774 }
775 match write(before_raw) {
776 Ok(()) => Err(rolled_back(failure.to_string())),
777 Err(rollback) => Err(Error::SwitchCorrupted {
778 tool,
779 from: from.to_string(),
780 to: to.to_string(),
781 detail: format!("{failure}; {rollback}"),
782 }),
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 use std::cell::RefCell;
791
792 fn failing(message: &str) -> store::Error {
793 store::Error::Write(message.into())
794 }
795
796 #[test]
797 fn a_successful_write_needs_no_rollback() {
798 let written = RefCell::new(Vec::new());
799 let result = install_with(
800 ProviderId::Claude,
801 |b| {
802 written.borrow_mut().push(b.to_string());
803 Ok(())
804 },
805 || unreachable!(),
806 "new",
807 "old",
808 "a",
809 "b",
810 );
811 assert!(result.is_ok());
812 assert_eq!(*written.borrow(), vec!["new"]);
813 }
814
815 #[test]
816 fn a_failed_write_that_changed_nothing_is_not_reported_as_a_lost_login() {
817 let result = install_with(
818 ProviderId::Claude,
819 |_| Err(failing("keychain locked")),
820 || Ok(Some("old".into())),
821 "new",
822 "old",
823 "a",
824 "b",
825 );
826 assert!(
827 matches!(result, Err(Error::SwitchRolledBack { .. })),
828 "the old login never left, so the user must not be told to sign in again"
829 );
830 }
831
832 #[test]
833 fn a_half_write_is_rolled_back() {
834 let slot = RefCell::new("old".to_string());
835 let result = install_with(
836 ProviderId::Claude,
837 |b| {
838 if b == "new" {
839 *slot.borrow_mut() = "garbled".into();
840 Err(failing("interrupted"))
841 } else {
842 *slot.borrow_mut() = b.to_string();
843 Ok(())
844 }
845 },
846 || Ok(Some(slot.borrow().clone())),
847 "new",
848 "old",
849 "a",
850 "b",
851 );
852 assert!(matches!(result, Err(Error::SwitchRolledBack { .. })));
853 assert_eq!(
854 *slot.borrow(),
855 "old",
856 "the previous login must be back in place"
857 );
858 }
859
860 #[test]
866 fn a_store_that_cannot_be_read_back_is_not_a_lost_login() {
867 let writes = RefCell::new(0);
868 let result = install_with(
869 ProviderId::Claude,
870 |_| {
871 *writes.borrow_mut() += 1;
872 Err(failing("the keychain is locked"))
873 },
874 || Err(store::Error::Unreadable("the keychain is locked".into())),
875 "new",
876 "old",
877 "a",
878 "b",
879 );
880 assert!(
881 matches!(result, Err(Error::SwitchUnverified { .. })),
882 "not knowing is its own answer, and must not read as a lost login"
883 );
884 assert_eq!(
885 *writes.borrow(),
886 1,
887 "and nothing further is written into a store that cannot be read"
888 );
889 }
890
891 #[test]
892 fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
893 let result = install_with(
894 ProviderId::Claude,
895 |_| Err(failing("disk full")),
896 || Ok(Some("garbled".into())),
897 "new",
898 "old",
899 "a",
900 "b",
901 );
902 assert!(matches!(result, Err(Error::SwitchCorrupted { .. })));
903 }
904
905 #[test]
906 fn a_copy_is_kept_after_a_failed_install_only_when_it_is_all_that_is_left() {
907 let (from, to, detail) = ("a".to_string(), "b".to_string(), String::new());
908 assert!(!only_copy_left(&Error::SwitchRolledBack {
909 from: from.clone(),
910 to: to.clone(),
911 detail: detail.clone(),
912 }));
913 assert!(only_copy_left(&Error::SwitchCorrupted {
914 tool: ProviderId::Claude,
915 from,
916 to,
917 detail
918 }));
919 }
920}