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, 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 =
514 running_sessions(ctx, key.provider).map(|(program, count)| Warning::SessionsStillRunning {
515 program,
516 count,
517 from: from.clone(),
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
628fn running_sessions(ctx: &Context, which: ProviderId) -> Option<(&'static str, usize)> {
631 match provider::of(which).adoption() {
632 provider::Adoption::RestartRequired { program } => ctx
633 .host()
634 .running(program)
635 .filter(|count| *count > 0)
636 .map(|count| (program, count)),
637 provider::Adoption::PollingWithin(_) => None,
638 }
639}
640
641fn prove_incoming(
653 ctx: &Context,
654 state: &mut State,
655 key: &Key,
656 target: &Account,
657 held: Park,
658 incoming: Value,
659) -> Result<(Park, Value)> {
660 let label = state.typed(key);
661 if held.askable_at(ctx.now()) {
662 let credential = provider::Credential::new(key.provider, incoming.clone());
663 match provider::of(key.provider).verify(ctx, &credential) {
664 Ok(found) if found.account_id == target.account_uuid => return Ok((held, incoming)),
665 Ok(other) => {
666 return Err(Error::ParkedLoginBelongsElsewhere {
667 label,
668 email: other.email,
669 });
670 }
671 Err(provider::ProviderError::Unauthorized) => {}
674 Err(provider::ProviderError::ShapeUnexpected { detail, .. }) => {
675 return Err(Error::ParkedCredentialCorrupt { label, detail });
676 }
677 Err(e) => {
678 return Err(Error::IdentityUnverifiable {
679 tool: key.provider,
680 cause: crate::error::Cause::of_provider(&e),
681 detail: e.to_string(),
682 });
683 }
684 }
685 }
686
687 let Some(fresh) = renew::renew_one(ctx, state, key, &held)? else {
689 return Err(Error::IdentityUnverifiable {
690 tool: key.provider,
691 cause: crate::error::Cause::Unreachable,
692 detail: format!(
693 "`{label}`'s parked login needs renewing and {} did not answer",
694 key.provider.service()
695 ),
696 });
697 };
698 let document = park::load(ctx, key, &fresh)?;
699 Ok((fresh, document))
700}
701
702fn only_copy_left(failure: &Error) -> bool {
706 matches!(failure, Error::SwitchCorrupted { .. })
707}
708
709pub(super) fn shape(tool: ProviderId, error: provider::ProviderError) -> Error {
716 match error {
717 provider::ProviderError::ShapeUnexpected { detail, .. } => {
718 Error::LiveCredentialShapeUnexpected { tool, detail }
719 }
720 provider::ProviderError::Unsupported { reason, .. } => {
721 Error::LiveStoreUnsupported { tool, reason }
722 }
723 other => Error::LiveCredentialShapeUnexpected {
724 tool,
725 detail: other.to_string(),
726 },
727 }
728}
729
730fn to_body(document: Value) -> String {
732 serde_json::to_string(&document).expect("a credential document stays serialisable")
733}
734
735fn install_with(
745 tool: ProviderId,
746 write: impl Fn(&str) -> std::result::Result<(), store::Error>,
747 read: impl Fn() -> std::result::Result<Option<String>, store::Error>,
748 next: &str,
749 before_raw: &str,
750 from: &str,
751 to: &str,
752) -> Result<()> {
753 let Err(failure) = write(next) else {
754 return Ok(());
755 };
756 let rolled_back = |detail: String| Error::SwitchRolledBack {
757 from: from.to_string(),
758 to: to.to_string(),
759 detail,
760 };
761 match read() {
762 Ok(Some(now)) if now == before_raw => return Err(rolled_back(failure.to_string())),
764 Err(unreadable) => {
767 return Err(Error::SwitchUnverified {
768 tool,
769 from: from.to_string(),
770 to: to.to_string(),
771 detail: format!("{failure}; {unreadable}"),
772 });
773 }
774 _ => {}
776 }
777 match write(before_raw) {
778 Ok(()) => Err(rolled_back(failure.to_string())),
779 Err(rollback) => Err(Error::SwitchCorrupted {
780 tool,
781 from: from.to_string(),
782 to: to.to_string(),
783 detail: format!("{failure}; {rollback}"),
784 }),
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 use std::cell::RefCell;
793
794 fn failing(message: &str) -> store::Error {
795 store::Error::Write(message.into())
796 }
797
798 #[test]
799 fn a_successful_write_needs_no_rollback() {
800 let written = RefCell::new(Vec::new());
801 let result = install_with(
802 ProviderId::Claude,
803 |b| {
804 written.borrow_mut().push(b.to_string());
805 Ok(())
806 },
807 || unreachable!(),
808 "new",
809 "old",
810 "a",
811 "b",
812 );
813 assert!(result.is_ok());
814 assert_eq!(*written.borrow(), vec!["new"]);
815 }
816
817 #[test]
818 fn a_failed_write_that_changed_nothing_is_not_reported_as_a_lost_login() {
819 let result = install_with(
820 ProviderId::Claude,
821 |_| Err(failing("keychain locked")),
822 || Ok(Some("old".into())),
823 "new",
824 "old",
825 "a",
826 "b",
827 );
828 assert!(
829 matches!(result, Err(Error::SwitchRolledBack { .. })),
830 "the old login never left, so the user must not be told to sign in again"
831 );
832 }
833
834 #[test]
835 fn a_half_write_is_rolled_back() {
836 let slot = RefCell::new("old".to_string());
837 let result = install_with(
838 ProviderId::Claude,
839 |b| {
840 if b == "new" {
841 *slot.borrow_mut() = "garbled".into();
842 Err(failing("interrupted"))
843 } else {
844 *slot.borrow_mut() = b.to_string();
845 Ok(())
846 }
847 },
848 || Ok(Some(slot.borrow().clone())),
849 "new",
850 "old",
851 "a",
852 "b",
853 );
854 assert!(matches!(result, Err(Error::SwitchRolledBack { .. })));
855 assert_eq!(
856 *slot.borrow(),
857 "old",
858 "the previous login must be back in place"
859 );
860 }
861
862 #[test]
868 fn a_store_that_cannot_be_read_back_is_not_a_lost_login() {
869 let writes = RefCell::new(0);
870 let result = install_with(
871 ProviderId::Claude,
872 |_| {
873 *writes.borrow_mut() += 1;
874 Err(failing("the keychain is locked"))
875 },
876 || Err(store::Error::Unreadable("the keychain is locked".into())),
877 "new",
878 "old",
879 "a",
880 "b",
881 );
882 assert!(
883 matches!(result, Err(Error::SwitchUnverified { .. })),
884 "not knowing is its own answer, and must not read as a lost login"
885 );
886 assert_eq!(
887 *writes.borrow(),
888 1,
889 "and nothing further is written into a store that cannot be read"
890 );
891 }
892
893 #[test]
894 fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
895 let result = install_with(
896 ProviderId::Claude,
897 |_| Err(failing("disk full")),
898 || Ok(Some("garbled".into())),
899 "new",
900 "old",
901 "a",
902 "b",
903 );
904 assert!(matches!(result, Err(Error::SwitchCorrupted { .. })));
905 }
906
907 #[test]
908 fn a_copy_is_kept_after_a_failed_install_only_when_it_is_all_that_is_left() {
909 let (from, to, detail) = ("a".to_string(), "b".to_string(), String::new());
910 assert!(!only_copy_left(&Error::SwitchRolledBack {
911 from: from.clone(),
912 to: to.clone(),
913 detail: detail.clone(),
914 }));
915 assert!(only_copy_left(&Error::SwitchCorrupted {
916 tool: ProviderId::Claude,
917 from,
918 to,
919 detail
920 }));
921 }
922}