1use crate::context::Context;
6use crate::doctor::{self, Diagnosis};
7use crate::error::{Error, Result};
8use crate::holder::{self, capitalised};
9use crate::host::Elevation;
10use crate::provider::ProviderId;
11use crate::state::{self, Account, Key};
12use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
13use crate::{audit, readings, schedule, status, statusline};
14use std::fmt;
15
16#[derive(Debug, Clone, Copy)]
26pub struct Permit {
27 _only_the_gate_makes_one: (),
28}
29
30#[cfg(test)]
31impl Permit {
32 pub(crate) fn for_a_test() -> Permit {
35 Permit {
36 _only_the_gate_makes_one: (),
37 }
38 }
39}
40
41pub(crate) fn gate(ctx: &Context) -> Result<Permit> {
54 crate::release::check()?;
55 match ctx.host().elevation(ctx) {
56 Elevation::Normal => {}
57 Elevation::Elevated { why } => return Err(Error::Elevated { why: Some(why) }),
58 Elevation::Unknown => return Err(Error::Elevated { why: None }),
59 }
60 crate::home::check_absolute(ctx)?;
61 Ok(Permit {
62 _only_the_gate_makes_one: (),
63 })
64}
65
66#[derive(Debug)]
68#[non_exhaustive]
69pub enum Warning {
70 Recovered(Recovered),
72 ConfigNotUpdated(Error),
75 ParksPendingRemoval(usize),
77 ParkedLoginRefused {
79 tool: ProviderId,
80 label: String,
81 },
82 RenewalFailed(Error),
83 LockCompromised {
85 tool: ProviderId,
86 },
87 AuthOverridden {
90 tool: ProviderId,
91 names: Vec<String>,
92 },
93 FallbackLogin {
96 tool: ProviderId,
97 path: std::path::PathBuf,
98 },
99 WrittenOnTheCommandLine {
101 tool: ProviderId,
102 bytes: usize,
103 limit: usize,
104 },
105 SessionsStillRunning {
109 from: String,
110 holding: Vec<crate::holder::Holding>,
111 },
112 SessionsKeepTheOldLogin {
115 label: String,
116 holding: Vec<crate::holder::Holding>,
117 },
118 SessionsUnknown {
122 tool: ProviderId,
123 from: String,
124 },
125 SessionsUnknownAfterSignIn {
129 tool: ProviderId,
130 label: String,
131 },
132 SignInParkedNotInUse {
135 tool: ProviderId,
136 label: String,
137 why: String,
138 },
139 SwitchStuck(Error),
143 ReadOnly {
148 why: Option<&'static str>,
149 },
150}
151
152impl Warning {
153 pub fn code(&self) -> &'static str {
155 match self {
156 Warning::Recovered(r) => r.code(),
157 Warning::LockCompromised { .. } => "lock_compromised",
158 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
159 e.code()
160 }
161 Warning::ParksPendingRemoval(_) => "parks_pending_removal",
162 Warning::ParkedLoginRefused { .. } => "parked_login_refused",
163 Warning::AuthOverridden { .. } => "auth_overridden",
164 Warning::FallbackLogin { .. } => "fallback_login",
165 Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
166 Warning::SessionsStillRunning { .. } => "sessions_still_running",
167 Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
168 Warning::SessionsUnknown { .. } | Warning::SessionsUnknownAfterSignIn { .. } => {
169 "sessions_unknown"
170 }
171 Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
172 Warning::ReadOnly { .. } => "read_only",
173 }
174 }
175}
176
177impl fmt::Display for Warning {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 match self {
180 Warning::Recovered(r) => write!(f, "{r}"),
181 Warning::LockCompromised { tool } => write!(
182 f,
183 "{} reclaimed the credential write lock while this change was under way, so \
184 it may have written the login at the same time. Pitboard read the slot back \
185 and the change stood, but check with `pitboard` that the right account is \
186 signed in.",
187 tool.name()
188 ),
189 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
190 write!(f, "{e}")
191 }
192 Warning::ParksPendingRemoval(count) => write!(
193 f,
194 "{count} parked login(s) no longer in use could not be removed yet; Pitboard \
195 tries again on its next change"
196 ),
197 Warning::ParkedLoginRefused { tool, label } => write!(
198 f,
199 "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
200 {label} --sign-in` to sign in to it again.",
201 tool.service()
202 ),
203 Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
204 write!(
205 f,
206 "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
207 so it was written on the argument line, where a process running as you \
208 could have read it while the call lasted."
209 )?;
210 if *tool == ProviderId::Claude {
211 write!(
212 f,
213 " Claude Code writes this same login the same way whenever it \
214 refreshes the token."
215 )?;
216 }
217 Ok(())
218 }
219 Warning::AuthOverridden { tool, names } => write!(
220 f,
221 "{} is set, so {} signs in with it and not with the login Pitboard moved. \
222 Unset it for the switch to take effect.",
223 names.join(" and "),
224 tool.name()
225 ),
226 Warning::FallbackLogin { tool, path } => write!(
227 f,
228 "{} holds another {} login, which a session that cannot read the keychain, \
229 such as one started over SSH, signs in with. No switch reaches it; `pitboard \
230 doctor` says what to do.",
231 path.display(),
232 tool.name()
233 ),
234 Warning::SessionsStillRunning { from, holding } => write!(
235 f,
236 "{} started before this switch {} still running and still using `{from}`. {} \
237 Do not sign out in {}: signing out there revokes `{from}`'s login, which \
238 Pitboard has just parked.",
239 capitalised(&holder::described(holding)),
240 if holder::plural(holding) { "are" } else { "is" },
241 holder::remedies(holding, "to use the new account"),
242 if holder::plural(holding) {
243 "any of them"
244 } else {
245 "it"
246 },
247 ),
248 Warning::SessionsKeepTheOldLogin { label, holding } => write!(
249 f,
250 "{} started before this sign-in {} still running and still using `{label}`'s \
251 old login. {} Otherwise one of them can put the old login back in place of \
252 the new one when it refreshes its token.",
253 capitalised(&holder::described(holding)),
254 if holder::plural(holding) { "are" } else { "is" },
255 holder::remedies(holding, "to use the new one"),
256 ),
257 Warning::SessionsUnknown { tool, from } => write!(
258 f,
259 "Pitboard could not tell whether {} sessions started before this switch are \
260 still running, because it could not read the list of processes. Do not sign \
261 out in one that is: signing out there revokes `{from}`'s login, which \
262 Pitboard has just parked.",
263 tool.name()
264 ),
265 Warning::SessionsUnknownAfterSignIn { tool, label } => write!(
266 f,
267 "Pitboard could not tell whether {} sessions started before this sign-in are \
268 still running, because it could not read the list of processes. Quit any \
269 that are still using `{label}`'s old login and start them again. Otherwise \
270 one of them can put the old login back in place of the new one when it \
271 refreshes its token.",
272 tool.name()
273 ),
274 Warning::SignInParkedNotInUse { tool, label, why } => write!(
275 f,
276 "{} goes on with the login it has: Pitboard could not tell whose it is \
277 ({why}), so it parked the new login for `{label}` rather than write over \
278 that one. If that login no longer works, run `{}` and sign in to `{label}` \
279 there.",
280 tool.name(),
281 tool.login_command()
282 ),
283 Warning::ReadOnly { why } => write!(
284 f,
285 "{}, so it changes nothing: these are the numbers it last measured, and it \
286 renews no parked login and asks nobody. Run it as yourself to ask again.",
287 match why {
288 Some(why) => format!("Pitboard runs {why}"),
289 None => "Pitboard cannot tell whether it runs as root or with sudo".into(),
290 }
291 ),
292 }
293 }
294}
295
296#[derive(Debug)]
297pub struct Done<T> {
298 pub value: T,
299 pub warnings: Vec<Warning>,
300}
301
302#[derive(Debug)]
305pub struct Failed {
306 pub error: Error,
307 pub warnings: Vec<Warning>,
308}
309
310pub type Changing<T> = std::result::Result<Done<T>, Failed>;
311
312pub struct Pitboard {
313 ctx: Context,
314}
315
316impl Pitboard {
317 pub fn new(ctx: Context) -> Pitboard {
318 Pitboard { ctx }
319 }
320
321 pub fn permit(&self) -> Result<Permit> {
329 gate(&self.ctx)
330 }
331
332 pub fn check_homes(&self) -> Result<()> {
338 crate::home::check_absolute(&self.ctx)
339 }
340
341 fn permitted(&self) -> std::result::Result<Permit, Failed> {
343 self.permit().map_err(|error| Failed {
344 error,
345 warnings: Vec::new(),
346 })
347 }
348
349 pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
371 let permit = match self.permit() {
372 Ok(permit) => permit,
373 Err(Error::Elevated { why }) => {
374 let mut read = self.status_offline()?;
375 read.warnings.push(Warning::ReadOnly { why });
376 return Ok(read);
377 }
378 Err(other) => return Err(other),
379 };
380 let renewed = switch::renew_parked(&self.ctx, permit);
381 let state = state::load(&self.ctx)?;
384 let (stuck, report) = std::thread::scope(|scope| {
388 let stuck = scope.spawn(|| switch::stuck(&self.ctx, &state, switch::Asking::Service));
389 let report = status::gather(&self.ctx, permit, &state, fresh);
390 (stuck.join().ok().flatten(), report)
391 });
392 let mut warnings: Vec<Warning> = stuck.map(Warning::SwitchStuck).into_iter().collect();
393 for (key, outcome) in renewed {
394 audit::record(&self.ctx, permit, "renew", &key.typed(), outcome.code());
395 match outcome {
396 Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
397 tool: key.provider,
398 label: state.typed(&key),
399 }),
400 Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
401 Renewal::Renewed | Renewal::Deferred => {}
402 }
403 }
404 Ok(Done {
405 value: report,
406 warnings,
407 })
408 }
409
410 pub fn doctor(&self) -> Diagnosis {
411 doctor::run(&self.ctx)
412 }
413
414 pub fn status_offline(&self) -> Result<Done<status::Report>> {
427 let state = state::load(&self.ctx)?;
428 let warnings = switch::stuck(&self.ctx, &state, switch::Asking::Nobody)
429 .map(Warning::SwitchStuck)
430 .into_iter()
431 .collect();
432 Ok(Done {
433 value: status::gather_offline(&self.ctx, &state),
434 warnings,
435 })
436 }
437
438 pub fn statusline(&self, session: &str) -> statusline::StatusLine {
444 statusline::read(&self.ctx, self.permit().ok(), session)
445 }
446
447 pub fn account(&self, typed: &str) -> Option<Account> {
449 let state = state::load(&self.ctx).ok()?;
450 crate::label::resolve(&state, typed).ok().cloned()
451 }
452
453 pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
454 let permit = self.permitted()?;
455 let key = self.named(permit, "use", typed)?;
456 self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
457 switch::switch(settled, &key)
458 })
459 }
460
461 pub fn auto_switch(
469 &self,
470 threshold: crate::autoswitch::Threshold,
471 ) -> Changing<crate::autoswitch::Auto> {
472 let permit = self.permitted()?;
473 let state = state::load(&self.ctx).map_err(|error| Failed {
474 error,
475 warnings: Vec::new(),
476 })?;
477 let plan = match crate::autoswitch::look(&self.ctx, &state, threshold) {
478 crate::autoswitch::Next::Say(auto) => {
479 return Ok(Done {
480 value: auto,
481 warnings: Vec::new(),
482 });
483 }
484 crate::autoswitch::Next::Switch(plan) => plan,
485 };
486 let subject = state.typed(&plan.to);
487 self.changing(
488 permit,
489 "auto-switch",
490 &subject,
491 Some(ProviderId::Claude),
492 |settled| switch::automatically(settled, &plan, threshold),
493 )
494 }
495
496 fn named(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
502 let state = state::load(&self.ctx).map_err(|error| Failed {
503 error,
504 warnings: Vec::new(),
505 })?;
506 crate::label::resolve(&state, typed)
507 .map(Account::key)
508 .map_err(|error| self.refused(permit, verb, typed, error.code(), error))
509 }
510
511 pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
513 let permit = self.permitted()?;
514 let key = self.chosen(permit, "enroll", typed)?;
515 self.changing(
516 permit,
517 "enroll",
518 &key.typed(),
519 Some(key.provider),
520 |settled| switch::enroll(settled, &key, None),
521 )
522 }
523
524 fn chosen(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
530 self.enrolling(typed)
531 .map_err(|error| self.refused(permit, verb, typed, "label_unusable", error))
532 }
533
534 fn refused(
544 &self,
545 permit: Permit,
546 verb: &str,
547 subject: &str,
548 code: &str,
549 error: Error,
550 ) -> Failed {
551 let recovered = if switch::interrupted(&self.ctx) {
552 switch::settle(&self.ctx, permit, switch::interrupted_tool(&self.ctx))
553 .ok()
554 .and_then(|(_, recovered)| recovered)
555 } else {
556 None
557 };
558 let mut warnings = Vec::new();
559 if let Some(r) = recovered {
560 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
561 warnings.push(Warning::Recovered(r));
562 }
563 audit::record(&self.ctx, permit, verb, subject, code);
564 Failed { error, warnings }
565 }
566
567 fn enrolling(&self, typed: &str) -> Result<Key> {
574 if typed.contains(crate::label::SEPARATOR)
575 && let Ok(state) = state::load(&self.ctx)
576 && let Some(existing) = state.accounts.iter().find(|a| a.label == typed)
577 {
578 return Ok(existing.key());
579 }
580 crate::label::choose(typed)
581 .map(|chosen| Key::new(chosen.provider, chosen.label))
582 .map_err(Error::Usage)
583 }
584
585 pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
588 let key = self.enrolling(typed).ok()?;
589 state::load(&self.ctx).ok()?.get(&key).cloned()
590 }
591
592 pub fn name_to_type(&self, typed: &str) -> String {
595 let Ok(key) = self.enrolling(typed) else {
596 return typed.to_string();
597 };
598 state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
599 }
600
601 pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
605 let permit = self.permitted()?;
606 let key = self.signing_in(permit, typed)?;
607 switch::sign_in(&self.ctx, permit, key.provider)
608 .map_err(|error| self.not_started(permit, typed, error))
609 }
610
611 pub fn sign_in_watched(
614 &self,
615 typed: &str,
616 ) -> std::result::Result<switch::WatchedSignIn, Failed> {
617 let permit = self.permitted()?;
618 let key = self.signing_in(permit, typed)?;
619 switch::sign_in_watched_as(&self.ctx, permit, &key)
620 .map_err(|error| self.not_started(permit, typed, error))
621 }
622
623 fn signing_in(&self, permit: Permit, typed: &str) -> std::result::Result<Key, Failed> {
629 let key = self.chosen(permit, "enroll", typed)?;
630 self.ready_to_sign_in(key.provider)
631 .map_err(|error| self.not_started(permit, typed, error))?;
632 Ok(key)
633 }
634
635 fn not_started(&self, permit: Permit, typed: &str, error: Error) -> Failed {
637 audit::record(&self.ctx, permit, "enroll", typed, error.code());
638 Failed {
639 error,
640 warnings: Vec::new(),
641 }
642 }
643
644 fn ready_to_sign_in(&self, tool: crate::provider::ProviderId) -> Result<()> {
648 if tool == crate::provider::ProviderId::Claude && self.ctx.custom_oauth() {
649 return Err(Error::CustomOauthEndpoint);
650 }
651 state::load(&self.ctx)?;
652 let driver = crate::provider::of(tool);
653 switch::live_store(&self.ctx, tool)?;
656 if let crate::provider::Isolation::NotIsolated { reason } =
661 driver.private_signin_isolation(&self.ctx)
662 {
663 return Err(Error::SignInNotIsolated { reason });
664 }
665 if driver.program(&self.ctx).is_none() {
666 return Err(Error::ProgramMissing {
667 tool,
668 program: self.ctx.program_for(tool).display().to_string(),
669 });
670 }
671 Ok(())
672 }
673
674 pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
675 let permit = self.permitted()?;
676 let key = self.chosen(permit, "enroll", typed)?;
677 self.changing(
678 permit,
679 "enroll",
680 &key.typed(),
681 Some(key.provider),
682 |settled| switch::enroll(settled, &key, Some(login)),
683 )
684 }
685
686 pub fn forget(&self, typed: &str) -> Changing<String> {
688 let permit = self.permitted()?;
689 let key = self.named(permit, "forget", typed)?;
690 self.changing(
691 permit,
692 "forget",
693 &key.typed(),
694 Some(key.provider),
695 |settled| switch::forget(settled, &key),
696 )
697 }
698
699 pub fn abandon_recovery(&self) -> Result<Option<switch::Abandoned>> {
702 let permit = self.permit()?;
703 let outcome = switch::abandon(&self.ctx, permit);
704 audit::record(
705 &self.ctx,
706 permit,
707 "abandon",
708 "",
709 match &outcome {
710 Ok(_) => "ok",
711 Err(e) => e.code(),
712 },
713 );
714 outcome
715 }
716
717 pub fn renew(&self) -> Result<Vec<(Key, Renewal)>> {
727 let ctx = schedule::for_its_run(&self.ctx);
728 let permit = gate(&ctx)?;
729 let outcomes = switch::renew_due(&ctx, permit, switch::Due::ToStayAlive);
730 for (key, outcome) in &outcomes {
731 audit::record(&ctx, permit, "renew", &key.typed(), outcome.code());
732 }
733 Ok(outcomes)
734 }
735
736 pub fn schedule(&self) -> schedule::Installed {
739 schedule::status(&self.ctx)
740 }
741
742 pub fn schedule_renews_this_home(&self) -> bool {
746 schedule::serves(&self.ctx)
747 }
748
749 pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
753 schedule::install(&self.ctx, self.permit()?)
754 }
755
756 pub fn schedule_uninstall(&self) -> Result<bool> {
758 schedule::uninstall(&self.ctx, self.permit()?)
759 }
760
761 pub fn schedule_repair(&self) -> Result<bool> {
764 schedule::repair(&self.ctx, self.permit()?)
765 }
766
767 pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
773 switch::adopt(&self.ctx, self.permit()?)
774 }
775
776 pub fn repair(&self) -> Changing<switch::Reclaimed> {
781 let permit = self.permitted()?;
782 self.changing(permit, "repair", "", None, |settled| {
783 switch::repair(settled).map(|r| (r, Vec::new()))
784 })
785 }
786
787 pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
792 match switch::still_holding(&self.ctx, which) {
793 switch::StillHolding::These(holding) => holding,
794 switch::StillHolding::Nothing | switch::StillHolding::Unknown => Vec::new(),
795 }
796 }
797
798 pub fn changed_at(&self) -> i64 {
801 state::changed_at(&self.ctx)
802 }
803
804 pub fn readings_changed_at(&self) -> i64 {
807 readings::changed_at(&self.ctx)
808 }
809
810 pub fn app_file(&self, file: crate::app::AppFile) -> std::io::Result<Option<String>> {
813 crate::app::read_app_file(&self.ctx, file)
814 }
815
816 pub fn keep_app_file(&self, file: crate::app::AppFile, body: &str) -> Result<()> {
819 crate::app::write_app_file(&self.ctx, self.permit()?, file, body)
820 }
821
822 pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
824 audit::read(&self.ctx, limit)
825 }
826
827 pub fn uninstall(&self) -> Changing<switch::Removed> {
831 let permit = self.permitted()?;
832 self.changing(permit, "uninstall", "", None, |settled| {
833 switch::uninstall(settled).map(|r| (r, Vec::new()))
834 })
835 }
836
837 pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
841 let permit = self.permitted()?;
842 let from = self.named(permit, "rename", from)?;
843 let chosen = self.chosen(permit, "rename", to)?;
844 if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
847 let error = Error::Usage(format!(
848 "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
849 tool and enrol the account there instead.",
850 from.provider, chosen.provider
851 ));
852 return Err(self.refused(permit, "rename", &from.typed(), error.code(), error));
853 }
854 let to = chosen.label;
855 self.changing(
856 permit,
857 "rename",
858 &format!("{from} -> {to}"),
859 Some(from.provider),
860 |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
861 )
862 }
863
864 fn changing<T: Audited>(
870 &self,
871 permit: Permit,
872 verb: &str,
873 subject: &str,
874 tool: Option<ProviderId>,
875 run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
876 ) -> Changing<T> {
877 let (settled, recovered) = switch::settle(&self.ctx, permit, tool).map_err(|error| {
878 audit::record(&self.ctx, permit, verb, subject, error.code());
879 Failed {
880 error,
881 warnings: Vec::new(),
882 }
883 })?;
884 let mut warnings = Vec::new();
885 if let Some(tool) = tool {
888 let names = crate::provider::of(tool).overridden_by(&self.ctx);
889 if !names.is_empty() {
890 warnings.push(Warning::AuthOverridden { tool, names });
891 }
892 if let Some(path) = crate::provider::of(tool).fallback_login(&self.ctx) {
893 warnings.push(Warning::FallbackLogin { tool, path });
894 }
895 }
896 if let Some(r) = recovered {
897 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
898 warnings.push(Warning::Recovered(r));
899 }
900 match run(settled) {
901 Ok((value, more)) => {
902 if value.recorded() {
903 audit::record(&self.ctx, permit, verb, subject, value.audit_code());
904 }
905 warnings.extend(more);
906 Ok(Done { value, warnings })
907 }
908 Err(mut error) => {
909 audit::record(&self.ctx, permit, verb, subject, error.code());
910 warnings.extend(error.take_warnings());
911 Err(Failed { error, warnings })
912 }
913 }
914 }
915}
916
917trait Audited {
919 fn audit_code(&self) -> &'static str {
920 "ok"
921 }
922
923 fn recorded(&self) -> bool {
926 true
927 }
928}
929
930impl Audited for crate::autoswitch::Auto {
931 fn recorded(&self) -> bool {
932 matches!(self, crate::autoswitch::Auto::Switched { .. })
933 }
934}
935
936impl Audited for Outcome {
937 fn audit_code(&self) -> &'static str {
938 match self {
939 Outcome::Switched { .. } => "ok",
940 Outcome::AlreadyActive { .. } => "already_active",
941 }
942 }
943}
944
945impl Audited for switch::Reclaimed {}
946impl Audited for Enrolled {}
947impl Audited for String {}
948
949impl Audited for switch::Removed {
950 fn audit_code(&self) -> &'static str {
951 if self.pending > 0 {
952 "parks_pending_removal"
953 } else {
954 "ok"
955 }
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::switch::harness::{Machine, codex_machine, hold, machine};
963 use std::collections::BTreeMap;
964 use std::sync::Arc;
965
966 type Make = fn(&str) -> Machine;
967
968 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
970
971 type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
972
973 const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
979 (
980 "use",
981 "use",
982 "account_unknown",
983 "account_unknown",
984 |p, _| p.switch_to("nobody").err(),
985 ),
986 ("enroll", "enroll", "usage", "label_unusable", |p, _| {
987 p.enroll_current("codx/work").err()
988 }),
989 ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
990 p.sign_in("codx/work").err()
991 }),
992 ("rename", "rename", "usage", "usage", |p, tool| {
993 let other = if tool == ProviderId::Claude {
994 ProviderId::Codex
995 } else {
996 ProviderId::Claude
997 };
998 p.rename(
999 &Key::new(tool, "here").qualified(),
1000 &Key::new(other, "moved").qualified(),
1001 )
1002 .err()
1003 }),
1004 ];
1005
1006 fn interrupted(make: Make, name: &str) -> Machine {
1009 let m = make(name);
1010 let settled = switch::settle(&m.ctx, Permit::for_a_test(), None)
1011 .expect("nothing to recover yet")
1012 .0;
1013 let died = crate::fault::killing("switch.park_recorded", || {
1014 switch::switch(settled, &m.key("there"))
1015 });
1016 assert_eq!(died.unwrap_err(), "switch.park_recorded");
1017 assert!(switch::interrupted(&m.ctx));
1018 m
1019 }
1020
1021 fn stuck(make: Make, name: &str) -> Machine {
1025 let m = interrupted(make, name);
1026 m.fault_live(crate::store::memory::Fault::Vanish);
1027 m
1028 }
1029
1030 fn stuck_unless_asked(name: &str) -> Machine {
1036 let m = interrupted(machine, name);
1037 m.sign_in(&crate::switch::harness::document("renewed-since"));
1038 m
1039 }
1040
1041 fn said(read: &Done<status::Report>) -> Vec<(&'static str, String)> {
1043 read.warnings
1044 .iter()
1045 .map(|w| (w.code(), w.to_string()))
1046 .collect()
1047 }
1048
1049 type Untouched = (
1053 BTreeMap<String, Vec<u8>>,
1054 Vec<(String, Option<String>)>,
1055 Option<serde_json::Value>,
1056 );
1057
1058 fn untouched(m: &Machine) -> Untouched {
1059 let kept_by_a_read = ["usage.json", "usage.lock", "asking.json"];
1060 let mut files = files(m);
1061 files.retain(|path, _| !kept_by_a_read.iter().any(|kept| path.ends_with(kept)));
1062 let vault = m.mem.vault();
1063 let parked = vault
1064 .services()
1065 .into_iter()
1066 .map(|service| {
1067 let held = vault.peek(&service);
1068 (service, held)
1069 })
1070 .collect();
1071 (files, parked, m.live())
1072 }
1073
1074 #[test]
1078 fn a_switch_says_a_login_is_left_where_it_does_not_reach() {
1079 let m = machine("switch-fallback-login");
1080 let left = crate::provider::claude::live::credential_file(&m.ctx);
1081 m.mem.file_at(left.clone()).plant(
1082 &crate::provider::claude::paths::live_service(&m.ctx),
1083 &crate::switch::harness::document("left-by-a-sign-in").to_string(),
1084 );
1085 let switched = Pitboard::new(m.ctx.clone())
1086 .switch_to("there")
1087 .expect("a switch");
1088 let said: Vec<String> = switched
1089 .warnings
1090 .iter()
1091 .filter(|w| w.code() == "fallback_login")
1092 .map(ToString::to_string)
1093 .collect();
1094 assert_eq!(said.len(), 1, "{:?}", switched.warnings);
1095 assert!(
1096 said[0].starts_with(&left.display().to_string()),
1097 "{}",
1098 said[0]
1099 );
1100
1101 for (tool, make) in MACHINES {
1102 let m = make(&format!("switch-nothing-left-{tool}"));
1103 let switched = Pitboard::new(m.ctx.clone())
1104 .switch_to(&m.key("there").typed())
1105 .expect("a switch");
1106 assert!(
1107 switched
1108 .warnings
1109 .iter()
1110 .all(|w| w.code() != "fallback_login"),
1111 "{tool}: {:?}",
1112 switched.warnings
1113 );
1114 }
1115 }
1116
1117 #[test]
1122 fn a_read_says_an_interrupted_switch_it_cannot_finish_is_waiting() {
1123 for (tool, make) in MACHINES {
1124 let m = stuck(make, &format!("read-stuck-{tool}"));
1125 let pitboard = Pitboard::new(m.ctx.clone());
1126 let before = untouched(&m);
1127
1128 let read = pitboard
1129 .status(false)
1130 .expect("a read is not refused over it");
1131
1132 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1133 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1134 let refused = pitboard
1135 .switch_to(&m.key("there").typed())
1136 .expect_err("every change stops at it");
1137 assert_eq!(refused.error.code(), "recovery_undetermined", "{tool}");
1138 assert_eq!(
1139 said(&read),
1140 [("recovery_undetermined", refused.error.to_string())],
1141 "{tool}: said in the refusal's own words"
1142 );
1143 }
1144 }
1145
1146 #[test]
1149 fn a_read_asks_whose_the_login_is_where_only_the_service_can_say() {
1150 let m = stuck_unless_asked("read-stuck-asked");
1151 let pitboard = Pitboard::new(m.ctx.clone());
1152 let before = untouched(&m);
1153
1154 let read = pitboard.status(false).expect("a read");
1155
1156 assert_eq!(untouched(&m), before, "a read changes nothing");
1157 let refused = pitboard.switch_to("there").expect_err("it stops at it");
1158 assert_eq!(
1159 said(&read),
1160 [("recovery_undetermined", refused.error.to_string())]
1161 );
1162 assert!(
1163 refused.error.to_string().contains("has expired"),
1164 "{}",
1165 refused.error
1166 );
1167 }
1168
1169 #[test]
1173 fn a_read_says_nothing_of_a_switch_the_next_change_finishes() {
1174 for (tool, make) in MACHINES {
1175 let m = interrupted(make, &format!("read-settles-{tool}"));
1176 let before = untouched(&m);
1177
1178 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
1179
1180 assert_eq!(said(&read), [], "{tool}");
1181 assert_eq!(untouched(&m), before, "{tool}: a read finishes nothing");
1182 assert!(switch::interrupted(&m.ctx), "{tool}");
1183 }
1184 }
1185
1186 #[test]
1193 fn a_read_says_a_switch_is_waiting_only_where_a_change_would_stop_at_it() {
1194 for (tool, make) in MACHINES {
1195 let m = stuck(make, &format!("read-stuck-custom-{tool}"));
1196 let mut ctx = m.ctx.clone();
1197 ctx.custom_oauth = true;
1198 let pitboard = Pitboard::new(ctx);
1199
1200 let read = pitboard.status(false).expect("a read");
1201 let known = pitboard.status_offline().expect("a read");
1202 let refused = pitboard
1203 .switch_to(&m.key("there").typed())
1204 .expect_err("refused");
1205
1206 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1207 let expected: &[&str] = match m.which {
1208 ProviderId::Claude => &[],
1209 ProviderId::Codex => &["recovery_undetermined"],
1210 };
1211 assert_eq!(codes, expected, "{tool}: {}", refused.error);
1212 let codes: Vec<&str> = known.warnings.iter().map(Warning::code).collect();
1213 assert_eq!(codes, expected, "{tool}: the read that asks nobody too");
1214 assert_eq!(
1215 refused.error.code() == "recovery_undetermined",
1216 !expected.is_empty(),
1217 "{tool}"
1218 );
1219
1220 let gave_up = pitboard.abandon_recovery();
1221 match m.which {
1222 ProviderId::Claude => assert_eq!(
1223 gave_up.expect_err("refused").code(),
1224 "custom_oauth_endpoint",
1225 "{tool}"
1226 ),
1227 ProviderId::Codex => {
1228 assert!(gave_up.expect("given up").is_some(), "{tool}");
1229 assert!(!switch::interrupted(&m.ctx), "{tool}: nothing waits");
1230 }
1231 }
1232 }
1233 }
1234
1235 #[test]
1241 fn a_read_that_asks_nobody_says_a_switch_is_waiting_wherever_it_can_tell() {
1242 for (tool, make) in MACHINES {
1243 let m = stuck(make, &format!("known-stuck-{tool}"));
1244 let pitboard = Pitboard::new(m.ctx.clone());
1245 let (before, asked) = (untouched(&m), m.api.calls());
1246
1247 let read = pitboard.status_offline().expect("a read");
1248
1249 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1250 assert_eq!(m.api.calls(), asked, "{tool}: and asks nobody");
1251 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1252 let refused = pitboard
1253 .switch_to(&m.key("there").typed())
1254 .expect_err("every change stops at it");
1255 assert_eq!(
1256 said(&read),
1257 [("recovery_undetermined", refused.error.to_string())],
1258 "{tool}"
1259 );
1260 }
1261
1262 let m = stuck_unless_asked("known-stuck-unasked");
1263 let asked = m.api.calls();
1264 let read = Pitboard::new(m.ctx.clone())
1265 .status_offline()
1266 .expect("a read");
1267 assert_eq!(m.api.calls(), asked, "asks nobody");
1268 assert_eq!(said(&read), [], "only Anthropic could tell");
1269 }
1270
1271 #[test]
1278 fn whose_a_codex_login_is_is_told_without_asking_anyone() {
1279 let m = interrupted(codex_machine, "codex-renewed-since");
1280 m.sign_in(&crate::switch::harness::codex_login(
1281 "here",
1282 "renewed-since",
1283 ));
1284 m.mem.vault().fault(
1285 &reserved(&m),
1286 crate::store::memory::Fault::Unreadable("locked".into()),
1287 );
1288 let pitboard = Pitboard::new(m.ctx.clone());
1289 let asked = m.api.calls();
1290
1291 let read = pitboard.status_offline().expect("a read");
1292 let checks = pitboard.doctor().checks;
1293
1294 assert_eq!(m.api.calls(), asked, "asks nobody");
1295 let refused = pitboard
1296 .switch_to("codex/there")
1297 .expect_err("it stops at it");
1298 assert_eq!(refused.error.code(), "recovery_undetermined");
1299 assert_eq!(
1300 said(&read),
1301 [("recovery_undetermined", refused.error.to_string())]
1302 );
1303 let check = checks
1304 .iter()
1305 .find(|c| c.code == "interrupted_switch")
1306 .expect("said");
1307 assert_eq!(check.detail, refused.error.to_string());
1308 }
1309
1310 #[test]
1316 fn a_codex_store_a_requirement_pins_is_refused_before_a_sign_in() {
1317 let m = codex_machine("codex-pinned");
1318 let ctx = m.ctx.clone().with_codex_program("/nowhere/codex".into());
1321 m.mem.administers(
1322 "/etc/codex/requirements.toml",
1323 "cli_auth_credentials_store = \"keyring\"\n",
1324 );
1325 let pitboard = Pitboard::new(ctx);
1326 for refused in [
1327 pitboard.sign_in("codex/new").err().expect("refused"),
1328 pitboard
1329 .sign_in_watched("codex/new")
1330 .err()
1331 .expect("refused"),
1332 ] {
1333 assert_eq!(refused.error.code(), "live_store_unsupported");
1334 let said = refused.error.to_string();
1335 assert!(
1336 said.contains("pinned to `keyring` by /etc/codex/requirements.toml"),
1337 "{said}"
1338 );
1339 assert!(
1340 said.contains("No line in your own config.toml can change it"),
1341 "{said}"
1342 );
1343 }
1344 let switched = pitboard.switch_to("codex/there").expect_err("refused too");
1345 assert_eq!(switched.error.code(), "live_store_unsupported");
1346 let file = m
1347 .mem
1348 .file_at(crate::provider::codex::paths::auth_file(&m.ctx));
1349 let kept = crate::store::RawStore::read(&file, "auth.json")
1350 .expect("readable")
1351 .expect("there");
1352 let kept: serde_json::Value = serde_json::from_str(&kept).expect("JSON");
1353 assert_eq!(
1354 kept["tokens"]["refresh_token"], "here-refresh",
1355 "the auth.json Codex does not use is left as it was"
1356 );
1357 }
1358
1359 fn reserved(m: &Machine) -> String {
1361 let raw = std::fs::read_to_string(crate::home::dir(&m.ctx).join("journal.json"))
1362 .expect("a record");
1363 serde_json::from_str::<serde_json::Value>(&raw).expect("a record is JSON")["park_service"]
1364 .as_str()
1365 .expect("a park")
1366 .to_owned()
1367 }
1368
1369 #[test]
1373 fn doctor_says_a_switch_is_stuck_where_it_can_tell_without_asking() {
1374 for (tool, make) in MACHINES {
1375 let m = stuck(make, &format!("doctor-stuck-{tool}"));
1376 let pitboard = Pitboard::new(m.ctx.clone());
1377 let before = untouched(&m);
1378
1379 let checks = pitboard.doctor().checks;
1380
1381 assert_eq!(untouched(&m), before, "{tool}: doctor changes nothing");
1382 let refused = pitboard
1383 .switch_to(&m.key("there").typed())
1384 .expect_err("refused");
1385 let check = checks
1386 .iter()
1387 .find(|c| c.code == "interrupted_switch")
1388 .expect("said");
1389 assert_eq!(check.level, doctor::Level::Warn, "{tool}");
1390 assert_eq!(check.detail, refused.error.to_string(), "{tool}");
1391 assert!(check.advice.contains("pitboard abandon"), "{tool}");
1392 }
1393
1394 let m = stuck_unless_asked("doctor-stuck-unasked");
1395 let asked = m.api.calls();
1396 let checks = Pitboard::new(m.ctx.clone()).doctor().checks;
1397 assert_eq!(m.api.calls(), asked, "doctor asks nobody");
1398 let check = checks
1399 .iter()
1400 .find(|c| c.code == "interrupted_switch")
1401 .expect("said");
1402 assert_eq!(check.detail, "a switch did not finish");
1403 }
1404
1405 fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
1407 std::fs::read_dir(crate::home::dir(&m.ctx))
1408 .expect("a Pitboard home")
1409 .map(|entry| entry.expect("an entry").path())
1410 .filter(|path| path.file_name() != Some("audit.log".as_ref()))
1411 .map(|path| {
1412 let body = std::fs::read(&path).unwrap_or_default();
1413 (path.display().to_string(), body)
1414 })
1415 .collect()
1416 }
1417
1418 fn last_audited(m: &Machine) -> Vec<(String, String)> {
1420 audit::read(&m.ctx, 2)
1421 .into_iter()
1422 .map(|entry| (entry.verb, entry.outcome))
1423 .collect()
1424 }
1425
1426 #[test]
1431 fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
1432 for (tool, make) in MACHINES {
1433 for (change, verb, code, audited, refuse) in REFUSALS {
1434 let at = format!("{tool}, {change}");
1435 let m = interrupted(make, &format!("refused-{tool}-{change}"));
1436
1437 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
1438 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
1439
1440 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
1441 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1442 assert_eq!(said, ["interrupted_switch_undone"], "{at}");
1443 assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
1444 hold(&m, &at);
1445 assert_eq!(
1446 last_audited(&m),
1447 [
1448 (
1449 "recover".to_string(),
1450 "interrupted_switch_undone".to_string()
1451 ),
1452 (verb.to_string(), audited.to_string()),
1453 ],
1454 "{at}"
1455 );
1456 }
1457 }
1458 }
1459
1460 #[test]
1463 fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
1464 for (tool, make) in MACHINES {
1465 for (change, verb, code, audited, refuse) in REFUSALS {
1466 let at = format!("{tool}, {change}");
1467 let m = make(&format!("refused-quietly-{tool}-{change}"));
1468 let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
1469
1470 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
1471 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
1472
1473 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
1474 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
1475 assert_eq!(files(&m), before, "{at}: not even the lock file is made");
1476 assert_eq!(m.mem.vault().services(), parked, "{at}");
1477 assert_eq!(m.live(), live, "{at}");
1478 assert_eq!(
1479 last_audited(&m).last(),
1480 Some(&(verb.to_string(), audited.to_string())),
1481 "{at}"
1482 );
1483 }
1484 }
1485 }
1486
1487 #[test]
1491 fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
1492 for (tool, make) in MACHINES {
1493 let m = make(&format!("not-installed-said-{tool}"));
1494 m.mem.vault().takes_on_stdin(64);
1495 let login = crate::switch::harness::signed_in(&m, "here", "here-refresh-2");
1496 m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
1497
1498 let failed = Pitboard::new(m.ctx.clone())
1499 .enroll_signed_in(&m.key("here").typed(), login)
1500 .expect_err("it did not hold");
1501
1502 assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
1503 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1504 assert_eq!(said, ["written_on_the_command_line"], "{tool}");
1505 }
1506 }
1507
1508 #[test]
1513 fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
1514 let codex = interrupted(codex_machine, "refused-custom-codex");
1515 let mut ctx = codex.ctx.clone();
1516 ctx.custom_oauth = true;
1517 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
1518 assert_eq!(failed.error.code(), "account_unknown");
1519 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1520 assert_eq!(said, ["interrupted_switch_undone"]);
1521 assert!(!switch::interrupted(&codex.ctx));
1522
1523 let claude = interrupted(machine, "refused-custom-claude");
1524 let mut ctx = claude.ctx.clone();
1525 ctx.custom_oauth = true;
1526 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
1527 assert_eq!(
1528 failed.error.code(),
1529 "account_unknown",
1530 "the refusal, not the recovery that could not run"
1531 );
1532 assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
1533 assert!(
1534 switch::interrupted(&claude.ctx),
1535 "the record is kept for a run that can finish it"
1536 );
1537 }
1538
1539 const REFUSED_AS: [Elevation; 3] = [
1542 Elevation::Elevated { why: "as root" },
1543 Elevation::Elevated { why: "with sudo" },
1544 Elevation::Unknown,
1545 ];
1546
1547 fn failed<T>(outcome: Result<T>) -> Option<Failed> {
1550 outcome.err().map(|error| Failed {
1551 error,
1552 warnings: Vec::new(),
1553 })
1554 }
1555
1556 type Change = fn(&Pitboard, &Machine) -> Option<Failed>;
1557
1558 const CHANGES: [(&str, Change); 15] = [
1561 ("switch_to", |p, m| {
1562 p.switch_to(&m.key("there").typed()).err()
1563 }),
1564 ("enroll_current", |p, m| {
1565 p.enroll_current(&m.key("here").typed()).err()
1566 }),
1567 ("sign_in", |p, m| p.sign_in(&m.key("new").typed()).err()),
1568 ("sign_in_watched", |p, m| {
1569 p.sign_in_watched(&m.key("new").typed()).err()
1570 }),
1571 ("forget", |p, m| p.forget(&m.key("there").typed()).err()),
1572 ("rename", |p, m| {
1573 p.rename(&m.key("there").typed(), "elsewhere").err()
1574 }),
1575 ("abandon_recovery", |p, _| failed(p.abandon_recovery())),
1576 ("renew", |p, _| failed(p.renew())),
1577 ("schedule_install", |p, _| failed(p.schedule_install())),
1578 ("schedule_uninstall", |p, _| failed(p.schedule_uninstall())),
1579 ("schedule_repair", |p, _| failed(p.schedule_repair())),
1580 ("adopt", |p, _| failed(p.adopt())),
1581 ("repair", |p, _| p.repair().err()),
1582 ("keep_app_file", |p, _| {
1583 failed(p.keep_app_file(crate::app::AppFile::Preferences, "{}"))
1584 }),
1585 ("uninstall", |p, _| p.uninstall().err()),
1586 ];
1587
1588 type Everything = (
1593 BTreeMap<String, Option<Vec<u8>>>,
1594 Vec<(String, Option<String>)>,
1595 Vec<(String, Option<String>)>,
1596 Option<serde_json::Value>,
1597 );
1598
1599 fn everything(m: &Machine) -> Everything {
1600 fn walk(dir: &std::path::Path, into: &mut BTreeMap<String, Option<Vec<u8>>>) {
1601 for entry in std::fs::read_dir(dir).expect("a directory to read") {
1602 let path = entry.expect("an entry").path();
1603 if path.ends_with(".pitboard/signin") {
1604 continue;
1605 }
1606 if path.is_dir() {
1607 into.insert(path.display().to_string(), None);
1608 walk(&path, into);
1609 } else {
1610 let body = std::fs::read(&path).expect("a file to read");
1611 into.insert(path.display().to_string(), Some(body));
1612 }
1613 }
1614 }
1615 let mut files = BTreeMap::new();
1616 walk(&m.ctx_home(), &mut files);
1617 let items = |store: &crate::store::memory::MemoryStore| {
1618 store
1619 .services()
1620 .into_iter()
1621 .map(|service| {
1622 let held = store.peek(&service);
1623 (service, held)
1624 })
1625 .collect()
1626 };
1627 (files, items(m.mem.live()), items(m.mem.vault()), m.live())
1628 }
1629
1630 #[test]
1637 fn every_change_is_refused_where_pitboard_runs_as_root_or_with_sudo_and_changes_nothing() {
1638 for (tool, make) in MACHINES {
1639 for elevation in REFUSED_AS {
1640 for (change, run) in CHANGES {
1641 let at = format!("{tool}, {change}, {elevation:?}");
1642 let m = interrupted(make, &format!("gate-{tool}-{change}"));
1643 m.mem.runs_with(elevation);
1644 let (before, asked) = (everything(&m), m.api.calls());
1645
1646 let failed = run(&Pitboard::new(m.ctx.clone()), &m)
1647 .unwrap_or_else(|| panic!("{at}: refused"));
1648
1649 assert_eq!(failed.error.code(), "elevated", "{at}: {}", failed.error);
1650 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
1651 assert!(everything(&m) == before, "{at}: nothing changes");
1652 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1653 }
1654
1655 let at = format!("{tool}, enroll_signed_in, {elevation:?}");
1656 let m = interrupted(make, &format!("gate-{tool}-enroll-signed-in"));
1657 let login = crate::switch::harness::signed_in(&m, "new", "new-refresh");
1658 m.mem.runs_with(elevation);
1659 let before = everything(&m);
1660 let failed = Pitboard::new(m.ctx.clone())
1661 .enroll_signed_in(&m.key("new").typed(), login)
1662 .expect_err("refused");
1663 assert_eq!(failed.error.code(), "elevated", "{at}");
1664 assert!(everything(&m) == before, "{at}: nothing changes");
1665 }
1666 }
1667 }
1668
1669 #[test]
1672 fn the_gate_says_why_it_refuses_and_lets_a_person_through() {
1673 let m = machine("gate-words");
1674 let pitboard = Pitboard::new(m.ctx.clone());
1675 assert!(pitboard.permit().is_ok(), "as the person");
1676 for elevation in REFUSED_AS {
1677 m.mem.runs_with(elevation);
1678 let refused = pitboard.permit().expect_err("refused");
1679 assert_eq!(refused.code(), "elevated");
1680 assert_eq!(refused.exit_code(), 1);
1681 assert_eq!(
1682 refused.to_string(),
1683 match elevation {
1684 Elevation::Unknown => {
1685 "Pitboard changes nothing when it cannot tell whether it runs as root \
1686 or with sudo. Run it as yourself."
1687 }
1688 _ => {
1689 "Pitboard changes nothing when it runs as root or with sudo. Run it \
1690 as yourself."
1691 }
1692 },
1693 );
1694 }
1695 }
1696
1697 #[test]
1703 fn every_change_and_read_is_refused_where_the_home_is_not_a_full_path() {
1704 for home in ["", "relative"] {
1705 for (tool, make) in MACHINES {
1706 let at = format!("{tool}, HOME={home:?}");
1707 let m = make(&format!("unplaced-{tool}-{}", home.len()));
1708 let mut ctx = m.ctx.clone();
1709 ctx.home = std::path::PathBuf::from(home);
1710 let pitboard = Pitboard::new(ctx);
1711 let refused = |error: Error| {
1712 assert_eq!(error.code(), "home_not_absolute", "{at}: {error}");
1713 assert!(error.to_string().starts_with("HOME is "), "{at}: {error}");
1714 };
1715
1716 refused(pitboard.permit().expect_err("the gate refuses"));
1717 refused(pitboard.status_offline().err().expect("refused"));
1718 refused(pitboard.status(false).err().expect("refused"));
1719 let before = (everything(&m), m.api.calls());
1720 for (change, run) in CHANGES {
1721 let failed = run(&pitboard, &m).unwrap_or_else(|| panic!("{at}: {change}"));
1722 refused(failed.error);
1723 assert!(failed.warnings.is_empty(), "{at}, {change}");
1724 }
1725 assert!(
1726 (everything(&m), m.api.calls()) == before,
1727 "{at}: nothing changes and nobody is asked"
1728 );
1729 }
1730 }
1731 }
1732
1733 type Read = Vec<(
1736 ProviderId,
1737 Option<String>,
1738 bool,
1739 Option<crate::usage::Snapshot>,
1740 Option<status::Stale>,
1741 )>;
1742
1743 fn rows(report: &status::Report) -> Read {
1744 report
1745 .rows
1746 .iter()
1747 .map(|row| {
1748 (
1749 row.provider,
1750 row.label.clone(),
1751 row.signed_in,
1752 row.usage.clone(),
1753 row.stale,
1754 )
1755 })
1756 .collect()
1757 }
1758
1759 #[test]
1765 fn a_read_where_pitboard_may_change_nothing_asks_nobody_and_says_why() {
1766 for (tool, make) in MACHINES {
1767 for elevation in REFUSED_AS {
1768 let at = format!("{tool}, {elevation:?}");
1769 let m = make(&format!("gate-read-{tool}"));
1770 let later = m
1771 .ctx
1772 .clone()
1773 .with_clock(Arc::new(crate::time::FixedClock::at(
1774 crate::switch::harness::NOW + 11 * 86_400,
1775 )));
1776 let pitboard = Pitboard::new(later);
1777 m.mem.runs_with(elevation);
1778 let (before, asked) = (everything(&m), m.api.calls());
1779
1780 let read = pitboard.status(false).expect("a read");
1781 let known = pitboard.status_offline().expect("a read");
1782
1783 assert!(everything(&m) == before, "{at}: nothing is written");
1784 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1785 assert_eq!(rows(&read.value), rows(&known.value), "{at}");
1786 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1787 assert_eq!(codes.last(), Some(&"read_only"), "{at}");
1788 assert_eq!(read.warnings.len(), known.warnings.len() + 1, "{at}");
1789 let said = read.warnings.last().expect("said").to_string();
1790 match elevation {
1791 Elevation::Elevated { why } => assert!(
1792 said.starts_with(&format!("Pitboard runs {why}, so it changes nothing")),
1793 "{at}: {said}"
1794 ),
1795 _ => assert!(said.starts_with("Pitboard cannot tell"), "{at}: {said}"),
1796 }
1797
1798 m.mem.runs_with(Elevation::Normal);
1799 let live = pitboard.status(false).expect("a read");
1800 assert!(m.api.calls() > asked, "{at}: as the person, it asks");
1801 assert!(
1802 !live.warnings.iter().any(|w| w.code() == "read_only"),
1803 "{at}"
1804 );
1805 }
1806 }
1807 }
1808
1809 #[test]
1813 fn the_status_line_where_pitboard_may_change_nothing_draws_the_same_line_and_writes_nothing() {
1814 let session = serde_json::json!({
1815 "session_id": "a-session",
1816 "rate_limits": {
1817 "five_hour": {"used_percentage": 40.0, "resets_at": crate::switch::harness::NOW + 3_600},
1818 "seven_day": {"used_percentage": 20.0, "resets_at": crate::switch::harness::NOW + 86_400},
1819 },
1820 })
1821 .to_string();
1822 for elevation in REFUSED_AS {
1823 let m = machine("gate-statusline");
1824 let pitboard = Pitboard::new(m.ctx.clone());
1825 m.mem.runs_with(elevation);
1826 let before = everything(&m);
1827
1828 let refused = pitboard.statusline(&session);
1829
1830 assert!(
1831 everything(&m) == before,
1832 "{elevation:?}: nothing is written"
1833 );
1834 m.mem.runs_with(Elevation::Normal);
1835 assert_eq!(pitboard.statusline(&session), refused, "{elevation:?}");
1836 assert!(
1837 crate::home::dir(&m.ctx).join("sessions.json").exists(),
1838 "{elevation:?}: as the person, the session is kept"
1839 );
1840 }
1841 }
1842
1843 #[test]
1847 fn a_renewal_where_pitboard_may_change_nothing_is_refused_and_asks_nobody() {
1848 for (tool, make) in MACHINES {
1849 for elevation in REFUSED_AS {
1850 let at = format!("{tool}, {elevation:?}");
1851 let m = make(&format!("gate-renew-{tool}"));
1852 crate::switch::harness::renews(&m, "there-refresh", "there-refresh-2");
1853 let later = m
1854 .ctx
1855 .clone()
1856 .with_clock(Arc::new(crate::time::FixedClock::at(
1857 crate::switch::harness::NOW + 11 * 86_400,
1858 )));
1859 let pitboard = Pitboard::new(later);
1860 m.mem.runs_with(elevation);
1861 let (before, asked) = (everything(&m), m.api.calls());
1862
1863 let refused = pitboard.renew().expect_err("refused");
1864
1865 assert_eq!(refused.code(), "elevated", "{at}");
1866 assert!(everything(&m) == before, "{at}: nothing changes");
1867 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1868
1869 m.mem.runs_with(Elevation::Normal);
1870 let renewed = pitboard.renew().expect("as the person, it renews");
1871 let codes: Vec<&str> = renewed.iter().map(|(_, r)| r.code()).collect();
1872 assert_eq!(codes, ["renewed"], "{at}");
1873 }
1874 }
1875 }
1876
1877 #[test]
1880 fn doctor_says_why_pitboard_changes_nothing() {
1881 let m = machine("gate-doctor");
1882 let pitboard = Pitboard::new(m.ctx.clone());
1883 assert!(
1884 !pitboard
1885 .doctor()
1886 .checks
1887 .iter()
1888 .any(|c| c.code == "elevated"),
1889 "as the person"
1890 );
1891 for elevation in REFUSED_AS {
1892 m.mem.runs_with(elevation);
1893 let checks = pitboard.doctor().checks;
1894 let check = checks.first().expect("checks");
1895 assert_eq!(check.code, "elevated", "{elevation:?}");
1896 assert_eq!(check.level, doctor::Level::Fail, "{elevation:?}");
1897 assert_eq!(
1898 check.detail,
1899 match elevation {
1900 Elevation::Elevated { why } => format!("Pitboard runs {why}"),
1901 _ => "Pitboard cannot tell whether it runs as root or with sudo".into(),
1902 }
1903 );
1904 assert!(
1905 check.advice.ends_with("Run it as yourself."),
1906 "{}",
1907 check.advice
1908 );
1909 }
1910 }
1911}