1pub mod app;
23pub mod capture;
24pub mod merge;
25
26use std::collections::BTreeSet;
27use std::path::{Path, PathBuf};
28
29use serde::Deserialize;
30
31use crate::config::AnthropicConfig;
32use crate::display::sanitize_untrusted_path;
33use crate::error::{AppError, Result};
34
35use app::AppControl;
36use merge::{ScheduledMerge, SessionMerge};
37
38const CONFIG_JSON: &str = "config.json";
40const SESSIONS_DIR: &str = "claude-code-sessions";
42const COOKIE_FILES: [&str; 2] = ["Cookies", "Cookies-journal"];
45const LEVELDB_DIRS: [&str; 3] = ["Local Storage", "Session Storage", "IndexedDB"];
47const BRIDGE_FILE: &str = "bridge-state.json";
52const DEVICE_REGISTRY: &str = "ant-device-registry.json";
55
56const TOKEN_CACHE: &str = "config-tokenCache";
58const TOKEN_CACHE_V2: &str = "config-tokenCacheV2";
59const DESKTOP_STATE: &str = "desktop-state";
60const META_JSON: &str = "meta.json";
61pub const ACCOUNT_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
65
66#[derive(Debug, Clone)]
70pub struct Paths {
71 pub data_dir: PathBuf,
72 pub profiles_dir: PathBuf,
73 pub backups_dir: PathBuf,
74}
75
76impl Paths {
77 pub fn at(data_dir: PathBuf, profiles_dir: PathBuf, backups_dir: PathBuf) -> Self {
79 Self {
80 data_dir,
81 profiles_dir,
82 backups_dir,
83 }
84 }
85
86 pub fn resolve(anthropic: &AnthropicConfig) -> Result<Self> {
89 let home = crate::cache::home_dir()?;
90 let profiles_dir = anthropic
91 .desktop_profiles_dir
92 .clone()
93 .unwrap_or_else(|| home.join(".claude-acc").join("profiles"));
94 let backups_dir = profiles_dir
95 .parent()
96 .map_or_else(|| home.join(".claude-acc"), Path::to_path_buf)
97 .join("backups");
98 Ok(Self {
99 data_dir: home.join("Library/Application Support/Claude"),
100 profiles_dir,
101 backups_dir,
102 })
103 }
104
105 pub fn available(&self) -> bool {
108 self.data_dir.is_dir()
109 }
110
111 pub fn config_json(&self) -> PathBuf {
112 self.data_dir.join(CONFIG_JSON)
113 }
114
115 pub fn sessions_root(&self) -> PathBuf {
116 self.data_dir.join(SESSIONS_DIR)
117 }
118
119 pub fn profile_dir(&self, label: &str) -> PathBuf {
120 self.profiles_dir.join(label)
121 }
122
123 pub fn account_switch_lock(&self) -> PathBuf {
126 self.backups_dir.join(".account-switch.lock")
127 }
128
129 pub fn prelogin_dir(&self) -> PathBuf {
132 self.backups_dir.with_file_name("prelogin-backup")
133 }
134
135 pub fn synced_path(&self) -> PathBuf {
140 self.backups_dir.with_file_name("synced.json")
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ProfileMeta {
147 pub label: String,
150 pub email: Option<String>,
151 pub account_uuid: String,
152 pub org_uuid: Option<String>,
156 pub has_credentials: bool,
157 pub has_desktop_state: bool,
158}
159
160#[derive(Debug, Deserialize)]
161struct RawMeta {
162 email: Option<String>,
163 #[serde(rename = "accountUuid")]
164 account_uuid: Option<String>,
165 #[serde(rename = "orgUuid")]
166 org_uuid: Option<String>,
167}
168
169pub fn load_profiles(profiles_dir: &Path) -> Vec<ProfileMeta> {
173 let Ok(entries) = std::fs::read_dir(profiles_dir) else {
174 return Vec::new();
175 };
176 let mut profiles: Vec<ProfileMeta> = entries
177 .flatten()
178 .filter_map(|entry| {
179 let dir = entry.path();
180 let label = dir.file_name()?.to_str()?.to_string();
181 let raw: RawMeta =
182 serde_json::from_slice(&std::fs::read(dir.join(META_JSON)).ok()?).ok()?;
183 let account_uuid = raw.account_uuid.filter(|uuid| !uuid.is_empty())?;
184 Some(ProfileMeta {
185 label,
186 email: raw.email.filter(|email| !email.is_empty()),
187 account_uuid,
188 org_uuid: raw.org_uuid.filter(|uuid| !uuid.is_empty()),
189 has_credentials: dir.join(TOKEN_CACHE).is_file()
190 && dir.join(TOKEN_CACHE_V2).is_file(),
191 has_desktop_state: dir.join(DESKTOP_STATE).is_dir(),
192 })
193 })
194 .collect();
195 profiles.sort_by(|a, b| a.label.cmp(&b.label));
196 profiles
197}
198
199pub fn load_synced(path: &Path) -> merge::Synced {
203 std::fs::read(path)
204 .map(|bytes| merge::parse_synced(&bytes))
205 .unwrap_or_default()
206}
207
208pub fn save_synced(path: &Path, synced: &merge::Synced) -> Result<()> {
211 crate::cache::atomic_write(path, &serde_json::to_vec(synced)?)
212}
213
214pub fn active_account_uuid(config_json: &Path) -> Option<String> {
217 let bytes = std::fs::read(config_json).ok()?;
218 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
219 value
220 .get("lastKnownAccountUuid")?
221 .as_str()
222 .filter(|uuid| !uuid.is_empty())
223 .map(str::to_string)
224}
225
226pub fn label_for_uuid<'a>(profiles: &'a [ProfileMeta], account_uuid: &str) -> Option<&'a str> {
227 profiles
228 .iter()
229 .find(|profile| profile.account_uuid == account_uuid)
230 .map(|profile| profile.label.as_str())
231}
232
233pub fn session_count(sessions_root: &Path, profile: &ProfileMeta) -> usize {
235 let Some(org) = &profile.org_uuid else {
236 return 0;
237 };
238 let Ok(entries) = std::fs::read_dir(sessions_root.join(&profile.account_uuid).join(org)) else {
239 return 0;
240 };
241 entries
242 .flatten()
243 .filter(|entry| {
244 entry
245 .path()
246 .extension()
247 .is_some_and(|extension| extension == "json")
248 })
249 .count()
250}
251
252#[derive(Debug, Clone)]
254pub struct SwitchOpts {
255 pub keep_bridge: bool,
259 pub backup_sessions: bool,
264 pub keep_backups: usize,
266}
267
268impl Default for SwitchOpts {
269 fn default() -> Self {
270 Self {
271 keep_bridge: false,
272 backup_sessions: false,
273 keep_backups: 10,
274 }
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct SavedTokens {
282 pub token_cache: String,
283 pub token_cache_v2: String,
284}
285
286#[derive(Debug)]
288pub struct SwitchPlan {
289 pub target: ProfileMeta,
290 pub outgoing: Option<String>,
292 pub sessions: SessionMerge,
293 pub scheduled: Option<ScheduledMerge>,
296 pub tokens: SavedTokens,
300 pub archive: PathBuf,
301 pub archive_members: Vec<String>,
302 pub restores_desktop_state: bool,
303 pub opts: SwitchOpts,
304 pub deletions: Vec<merge::DeletionCandidate>,
308 pub confirmed_deletions: BTreeSet<merge::DeletionKey>,
311 pub prior_synced: merge::Synced,
314}
315
316pub fn plan_switch(paths: &Paths, label: &str, opts: SwitchOpts) -> Result<SwitchPlan> {
321 let profiles = load_profiles(&paths.profiles_dir);
322 let target = profiles
323 .iter()
324 .find(|profile| profile.label == label)
325 .cloned()
326 .ok_or_else(|| {
327 let known: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
328 AppError::Credentials(format!(
329 "no saved Claude Desktop account {label:?} in {}; known: {known:?}. \
330 Capture one with `claude-acc add {label}` \
331 (https://github.com/ohmaseclaro/claude-acc)",
332 paths.profiles_dir.display()
333 ))
334 })?;
335
336 let sessions_root = paths.sessions_root();
337 let synced = load_synced(&paths.synced_path());
338 let (sessions, scheduled) = match &target.org_uuid {
339 Some(org) => (
340 merge::plan_session_merge(&sessions_root, &target.account_uuid, org),
341 Some(merge::plan_scheduled_merge(
342 &sessions_root,
343 &target.account_uuid,
344 org,
345 &synced,
346 )?),
347 ),
348 None => (SessionMerge::default(), None),
349 };
350 let deletions = merge::deletion_candidates(&sessions_root, &synced);
351
352 let outgoing = active_account_uuid(&paths.config_json())
353 .and_then(|uuid| label_for_uuid(&profiles, &uuid).map(str::to_string));
354
355 let profile_dir = paths.profile_dir(label);
356 let token_cache = std::fs::read_to_string(profile_dir.join(TOKEN_CACHE)).map_err(|_| {
357 AppError::Credentials(format!(
358 "no complete saved Desktop credential for {label:?}; capture or sign into that \
359 account before switching"
360 ))
361 })?;
362 let token_cache_v2 =
363 std::fs::read_to_string(profile_dir.join(TOKEN_CACHE_V2)).map_err(|_| {
364 AppError::Credentials(format!(
365 "no complete saved Desktop credential for {label:?}; capture or sign into that \
366 account before switching"
367 ))
368 })?;
369 if token_cache.is_empty() || token_cache_v2.is_empty() {
370 return Err(AppError::Credentials(format!(
371 "the saved Desktop credential for {label:?} is empty; capture it again before switching"
372 )));
373 }
374 let tokens = SavedTokens {
375 token_cache,
376 token_cache_v2,
377 };
378 if !profile_dir.join(DESKTOP_STATE).is_dir() {
379 return Err(AppError::Credentials(format!(
380 "no saved Desktop browser state for {label:?}; capture that account again before switching"
381 )));
382 }
383
384 let stamp = crate::claude_desktop::timestamp();
385 Ok(SwitchPlan {
386 archive: paths
387 .backups_dir
388 .join(format!("switch-{stamp}-{label}.tar.gz")),
389 archive_members: archive_members(paths, &opts),
390 restores_desktop_state: true,
391 target,
392 outgoing,
393 sessions,
394 scheduled,
395 tokens,
396 opts,
397 deletions,
398 confirmed_deletions: BTreeSet::new(),
399 prior_synced: synced,
400 })
401}
402
403pub fn apply_switch(paths: &Paths, plan: &SwitchPlan, app: &dyn AppControl) -> Result<Vec<String>> {
409 let mut notes = Vec::new();
410 let members: Vec<&str> = plan.archive_members.iter().map(String::as_str).collect();
411
412 if let Err(error) = app.quit() {
414 return match app.relaunch() {
419 Ok(()) => Err(error),
420 Err(relaunch) => Err(AppError::Other(format!(
421 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
422 ))),
423 };
424 }
425 let mut archived = false;
426 let mut result = (|| {
427 if !members.is_empty() {
428 app.archive(&plan.archive, &paths.data_dir, &members)?;
429 archived = true;
430 prune_archives(
433 &paths.backups_dir,
434 plan.opts.keep_backups.max(1),
435 &mut notes,
436 );
437 }
438 apply_switch_while_stopped(paths, plan, &mut notes)
439 })();
440
441 if result.is_err()
442 && archived
443 && let Err(rollback) = app.restore(&plan.archive, &paths.data_dir, &identity_members())
444 {
445 let original = result.unwrap_err();
446 result = Err(AppError::Other(format!(
447 "{original}; automatic Desktop rollback was incomplete: {rollback}"
448 )));
449 }
450
451 let relaunch = app.relaunch();
452 match (result, relaunch) {
453 (Ok(()), Ok(())) => Ok(notes),
454 (Err(error), Ok(())) => Err(error),
455 (Ok(()), Err(error)) => Err(error),
456 (Err(error), Err(relaunch)) => Err(AppError::Other(format!(
457 "{error}; Claude Desktop also could not be relaunched: {relaunch}"
458 ))),
459 }
460}
461
462fn apply_switch_while_stopped(
463 paths: &Paths,
464 plan: &SwitchPlan,
465 notes: &mut Vec<String>,
466) -> Result<()> {
467 let display_names = plan
470 .scheduled
471 .as_ref()
472 .map(ScheduledMerge::display_names)
473 .transpose()?;
474
475 for (source, destination) in plan.sessions.copied.iter().chain(&plan.sessions.updated) {
479 copy_file(source, destination)?;
480 }
481 if let Some(scheduled) = &plan.scheduled {
482 crate::cache::atomic_write(&scheduled.target, &scheduled.bytes)?;
483 }
484 match merge::plan_deletion_sweep(&paths.sessions_root(), &plan.confirmed_deletions) {
489 Ok(sweep) => {
490 for (path, bytes) in sweep.rewrites {
491 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
492 notes.push(format!(
493 "could not apply deletion to {}: {error}",
494 sanitize_untrusted_path(&path)
495 ));
496 }
497 }
498 for path in sweep.removals {
503 if let Err(error) = std::fs::remove_file(&path) {
504 notes.push(format!(
505 "could not remove {}: {error}",
506 sanitize_untrusted_path(&path)
507 ));
508 }
509 }
510 }
511 Err(error) => notes.push(format!("deletion sweep skipped: {error}")),
512 }
513 if let Some(display_names) = &display_names {
519 match merge::plan_name_convergence(&paths.sessions_root(), display_names) {
520 Ok(convergence) => {
521 let mut fully_applied = true;
522 for (path, bytes) in convergence.rewrites {
523 if let Err(error) = crate::cache::atomic_write(&path, &bytes) {
524 fully_applied = false;
525 notes.push(format!(
526 "could not converge routine names in {}: {error}",
527 sanitize_untrusted_path(&path)
528 ));
529 }
530 }
531 if fully_applied && convergence.converged > 0 {
532 notes.push(format!(
533 "converged {} routine name(s) across accounts",
534 convergence.converged
535 ));
536 }
537 }
538 Err(error) => notes.push(format!("name convergence skipped: {error}")),
539 }
540 }
541 let mut synced = merge::current_state(&paths.sessions_root());
544 let mut canonical = plan
545 .scheduled
546 .as_ref()
547 .map(|scheduled| scheduled.canonical_routines.clone())
548 .unwrap_or_else(|| merge::canonical_routines(&plan.prior_synced));
549 for key in &plan.confirmed_deletions {
550 if key.kind == merge::ConflictKind::Routine {
551 canonical.remove(&key.id);
552 }
553 }
554 let present: BTreeSet<String> = synced
555 .values()
556 .flat_map(|account| account.routines.iter().cloned())
557 .collect();
558 canonical.retain(|id, _| present.contains(id));
559 merge::set_canonical_routines(&mut synced, &canonical);
560 if let Err(error) = save_synced(&paths.synced_path(), &synced) {
561 notes.push(format!("could not record the schedule sync: {error}"));
562 }
563
564 let live_config = paths.config_json();
569 let outgoing = active_account_uuid(&live_config).and_then(|uuid| {
570 label_for_uuid(&load_profiles(&paths.profiles_dir), &uuid).map(str::to_string)
571 });
572 if let Some(label) = &outgoing {
573 snapshot_profile(paths, label, notes)?;
574 }
575
576 swap_credentials(&live_config, &plan.tokens, &plan.target.account_uuid)?;
577
578 restore_desktop_state(paths, &plan.target.label)?;
579
580 if !plan.opts.keep_bridge {
581 let bridge = paths.data_dir.join(BRIDGE_FILE);
582 if bridge.is_file()
583 && let Err(error) = std::fs::remove_file(&bridge)
584 {
585 notes.push(format!("could not clear {BRIDGE_FILE}: {error}"));
586 }
587 }
588 restore_device_registry(paths, &plan.target.label, notes);
589
590 Ok(())
591}
592
593fn merge_history_into(
600 paths: &Paths,
601 account_uuid: &str,
602 org_uuid: &str,
603 notes: &mut Vec<String>,
604) -> (usize, usize) {
605 let sessions_root = paths.sessions_root();
606 let sessions = merge::plan_session_merge(&sessions_root, account_uuid, org_uuid);
607 let mut copied = 0;
608 for (source, destination) in sessions.copied.iter().chain(&sessions.updated) {
609 match copy_file(source, destination) {
610 Ok(()) => copied += 1,
611 Err(error) => notes.push(format!(
612 "could not seed {}: {error}",
613 sanitize_untrusted_path(destination)
614 )),
615 }
616 }
617 let routines = match merge::plan_scheduled_merge(
618 &sessions_root,
619 account_uuid,
620 org_uuid,
621 &load_synced(&paths.synced_path()),
622 ) {
623 Ok(scheduled) => match crate::cache::atomic_write(&scheduled.target, &scheduled.bytes) {
624 Ok(()) => scheduled.added + scheduled.updated,
625 Err(error) => {
626 notes.push(format!("schedule seed skipped: {error}"));
627 0
628 }
629 },
630 Err(error) => {
631 notes.push(format!("schedule seed skipped: {error}"));
632 0
633 }
634 };
635 (copied, routines)
636}
637
638fn snapshot_profile(paths: &Paths, label: &str, notes: &mut Vec<String>) -> Result<()> {
646 let profile_dir = paths.profile_dir(label);
647 std::fs::create_dir_all(&profile_dir).map_err(|e| AppError::io_at(&profile_dir, e))?;
648 restrict(&profile_dir, 0o700, notes);
649
650 let bytes =
651 std::fs::read(paths.config_json()).map_err(|e| AppError::io_at(paths.config_json(), e))?;
652 let value: serde_json::Value = serde_json::from_slice(&bytes)?;
653 let live_tokens = SavedTokens {
654 token_cache: required_token(&value, "oauth:tokenCache")?.to_string(),
655 token_cache_v2: required_token(&value, "oauth:tokenCacheV2")?.to_string(),
656 };
657 write_saved_tokens(&profile_dir, &live_tokens, notes)?;
658
659 let registry = paths.data_dir.join(DEVICE_REGISTRY);
662 if registry.is_file() {
663 let bytes = std::fs::read(®istry).map_err(|e| AppError::io_at(®istry, e))?;
664 crate::cache::atomic_write(&profile_dir.join(DEVICE_REGISTRY), &bytes)?;
665 }
666
667 snapshot_desktop_state(paths, &profile_dir, notes)?;
668 Ok(())
670}
671
672fn required_token<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
673 value
674 .get(key)
675 .and_then(serde_json::Value::as_str)
676 .filter(|blob| !blob.is_empty())
677 .ok_or_else(|| {
678 AppError::Credentials(format!(
679 "the live Desktop login is missing {key}; refusing to overwrite its saved profile"
680 ))
681 })
682}
683
684fn write_saved_tokens(
685 profile_dir: &Path,
686 tokens: &SavedTokens,
687 notes: &mut Vec<String>,
688) -> Result<()> {
689 let token_path = profile_dir.join(TOKEN_CACHE);
690 let token_v2_path = profile_dir.join(TOKEN_CACHE_V2);
691 let original = read_optional_file(&token_path)?;
692 let original_v2 = read_optional_file(&token_v2_path)?;
693 let write_result = (|| {
694 crate::cache::atomic_write(&token_path, tokens.token_cache.as_bytes())?;
695 crate::cache::atomic_write(&token_v2_path, tokens.token_cache_v2.as_bytes())?;
696 Ok(())
697 })();
698 if let Err(error) = write_result {
699 let mut rollback = Vec::new();
700 if let Err(failure) = restore_optional_file(&token_path, original.as_deref()) {
701 rollback.push(failure.to_string());
702 }
703 if let Err(failure) = restore_optional_file(&token_v2_path, original_v2.as_deref()) {
704 rollback.push(failure.to_string());
705 }
706 return if rollback.is_empty() {
707 Err(error)
708 } else {
709 Err(AppError::Other(format!(
710 "{error}; saved-token rollback was incomplete: {}",
711 rollback.join("; ")
712 )))
713 };
714 }
715 restrict(&token_path, 0o600, notes);
716 restrict(&token_v2_path, 0o600, notes);
717 Ok(())
718}
719
720fn snapshot_desktop_state(
721 paths: &Paths,
722 profile_dir: &Path,
723 notes: &mut Vec<String>,
724) -> Result<()> {
725 let state_dir = profile_dir.join(DESKTOP_STATE);
726 let previous = profile_dir.join(".desktop-state.previous");
727 if previous.exists() && !state_dir.exists() {
728 std::fs::rename(&previous, &state_dir).map_err(|e| AppError::io_at(&previous, e))?;
729 } else {
730 remove_if_present(&previous)?;
731 }
732
733 let staged = tempfile::Builder::new()
734 .prefix(".desktop-state.pending-")
735 .tempdir_in(profile_dir)
736 .map_err(|e| AppError::io_at(profile_dir, e))?;
737 restrict(staged.path(), 0o700, notes);
738 for name in COOKIE_FILES {
739 let source = paths.data_dir.join(name);
740 let destination = staged.path().join(name);
741 if source.is_file() {
742 copy_file(&source, &destination)?;
743 restrict(&destination, 0o600, notes);
744 }
745 }
746 for name in LEVELDB_DIRS {
747 let source = paths.data_dir.join(name);
748 let destination = staged.path().join(name);
749 if source.is_dir() {
750 copy_dir(&source, &destination)?;
751 }
752 }
753
754 let staged = staged.keep();
755 if state_dir.exists() {
756 std::fs::rename(&state_dir, &previous).map_err(|e| AppError::io_at(&state_dir, e))?;
757 }
758 if let Err(error) = std::fs::rename(&staged, &state_dir) {
759 let restore = if previous.exists() {
760 std::fs::rename(&previous, &state_dir)
761 } else {
762 Ok(())
763 };
764 let _ = remove_if_present(&staged);
765 return match restore {
766 Ok(()) => Err(AppError::io_at(&staged, error)),
767 Err(rollback) => Err(AppError::Other(format!(
768 "could not install the Desktop-state snapshot: {error}; could not restore the previous snapshot: {rollback}"
769 ))),
770 };
771 }
772 if let Err(error) = remove_if_present(&previous) {
773 notes.push(format!(
774 "could not remove the previous Desktop-state snapshot: {error}"
775 ));
776 }
777 Ok(())
778}
779
780fn swap_credentials(config_json: &Path, tokens: &SavedTokens, account_uuid: &str) -> Result<()> {
781 let existing = std::fs::read(config_json).map_err(|e| AppError::io_at(config_json, e))?;
782 let bytes = merge::swap_config_tokens(
783 &existing,
784 &tokens.token_cache,
785 &tokens.token_cache_v2,
786 account_uuid,
787 )?;
788 crate::cache::atomic_write(config_json, &bytes)
791}
792
793fn restore_desktop_state(paths: &Paths, label: &str) -> Result<()> {
794 let state_dir = paths.profile_dir(label).join(DESKTOP_STATE);
795 for name in COOKIE_FILES {
796 let source = state_dir.join(name);
797 let destination = paths.data_dir.join(name);
798 if source.is_file() {
799 copy_file(&source, &destination)?;
800 } else {
801 remove_if_present(&destination)?;
802 }
803 }
804 for name in LEVELDB_DIRS {
805 let source = state_dir.join(name);
806 let destination = paths.data_dir.join(name);
807 if source.is_dir() {
808 replace_dir(&source, &destination)?;
809 } else {
810 remove_if_present(&destination)?;
811 }
812 }
813 Ok(())
814}
815
816fn restore_device_registry(paths: &Paths, label: &str, notes: &mut Vec<String>) {
819 let snapshot = paths.profile_dir(label).join(DEVICE_REGISTRY);
820 let live = paths.data_dir.join(DEVICE_REGISTRY);
821 let (Ok(saved), Ok(current)) = (std::fs::read(&snapshot), std::fs::read(&live)) else {
822 return;
823 };
824 match merge::merge_device_registry(¤t, &saved) {
825 Ok(bytes) if bytes != current => {
826 if let Err(error) = crate::cache::atomic_write(&live, &bytes) {
827 notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}"));
828 }
829 }
830 Ok(_) => {}
831 Err(error) => notes.push(format!("could not merge {DEVICE_REGISTRY}: {error}")),
832 }
833}
834
835fn archive_members(paths: &Paths, opts: &SwitchOpts) -> Vec<String> {
841 let mut members = Vec::new();
842 for name in [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE] {
843 if paths.data_dir.join(name).exists() {
844 members.push(name.to_string());
845 }
846 }
847 for name in COOKIE_FILES.into_iter().chain(LEVELDB_DIRS) {
848 if paths.data_dir.join(name).exists() {
849 members.push(name.to_string());
850 }
851 }
852 if opts.backup_sessions {
853 if paths.sessions_root().is_dir() {
854 members.push(SESSIONS_DIR.to_string());
855 }
856 return members;
857 }
858 let sessions_root = paths.sessions_root();
859 let Ok(accounts) = std::fs::read_dir(&sessions_root) else {
860 return members;
861 };
862 let mut registries = Vec::new();
863 for account in accounts.flatten() {
864 let Ok(orgs) = std::fs::read_dir(account.path()) else {
865 continue;
866 };
867 for org in orgs.flatten() {
868 let path = org.path().join("scheduled-tasks.json");
869 if path.is_file()
870 && let Ok(relative) = path.strip_prefix(&paths.data_dir)
871 {
872 registries.push(relative.display().to_string());
873 }
874 }
875 }
876 registries.sort();
877 members.extend(registries);
878 members
879}
880
881fn identity_members() -> Vec<&'static str> {
882 [CONFIG_JSON, DEVICE_REGISTRY, BRIDGE_FILE]
883 .into_iter()
884 .chain(COOKIE_FILES)
885 .chain(LEVELDB_DIRS)
886 .collect()
887}
888
889fn prune_archives(backups_dir: &Path, keep: usize, notes: &mut Vec<String>) {
890 let Ok(entries) = std::fs::read_dir(backups_dir) else {
891 return;
892 };
893 let mut archives: Vec<PathBuf> = entries
896 .flatten()
897 .map(|entry| entry.path())
898 .filter(|path| {
899 path.file_name()
900 .and_then(|name| name.to_str())
901 .is_some_and(|name| name.starts_with("switch-") && name.ends_with(".tar.gz"))
902 })
903 .collect();
904 if archives.len() <= keep {
905 return;
906 }
907 archives.sort();
908 let doomed = archives.len() - keep;
909 for path in archives.into_iter().take(doomed) {
910 if let Err(error) = std::fs::remove_file(&path) {
911 notes.push(format!(
912 "could not prune {}: {error}",
913 sanitize_untrusted_path(&path)
914 ));
915 }
916 }
917}
918
919fn copy_file(source: &Path, destination: &Path) -> Result<()> {
920 if let Some(parent) = destination.parent() {
921 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
922 }
923 std::fs::copy(source, destination).map_err(|e| AppError::io_at(source, e))?;
924 Ok(())
925}
926
927fn remove_if_present(path: &Path) -> Result<()> {
928 match std::fs::symlink_metadata(path) {
929 Ok(metadata) if metadata.is_dir() => {
930 std::fs::remove_dir_all(path).map_err(|e| AppError::io_at(path, e))
931 }
932 Ok(_) => std::fs::remove_file(path).map_err(|e| AppError::io_at(path, e)),
933 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
934 Err(error) => Err(AppError::io_at(path, error)),
935 }
936}
937
938fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
939 match std::fs::read(path) {
940 Ok(bytes) => Ok(Some(bytes)),
941 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
942 Err(error) => Err(AppError::io_at(path, error)),
943 }
944}
945
946fn restore_optional_file(path: &Path, original: Option<&[u8]>) -> Result<()> {
947 match original {
948 Some(bytes) => crate::cache::atomic_write(path, bytes),
949 None => remove_if_present(path),
950 }
951}
952
953fn replace_dir(source: &Path, destination: &Path) -> Result<()> {
957 if destination.exists() {
958 std::fs::remove_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
959 }
960 copy_dir(source, destination)
961}
962
963fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
964 std::fs::create_dir_all(destination).map_err(|e| AppError::io_at(destination, e))?;
965 let entries = std::fs::read_dir(source).map_err(|e| AppError::io_at(source, e))?;
966 for entry in entries.flatten() {
967 let child = entry.path();
968 let target = destination.join(entry.file_name());
969 if child.is_dir() {
970 copy_dir(&child, &target)?;
971 } else {
972 std::fs::copy(&child, &target).map_err(|e| AppError::io_at(&child, e))?;
973 }
974 }
975 Ok(())
976}
977
978#[cfg(unix)]
979fn restrict(path: &Path, mode: u32, notes: &mut Vec<String>) {
980 use std::os::unix::fs::PermissionsExt;
981
982 if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
983 notes.push(format!(
984 "could not restrict {}: {error}",
985 sanitize_untrusted_path(path)
986 ));
987 }
988}
989
990#[cfg(not(unix))]
991fn restrict(_path: &Path, _mode: u32, _notes: &mut Vec<String>) {}
992
993fn timestamp() -> String {
994 chrono::Local::now().format("%Y%m%d-%H%M%S%.3f").to_string()
995}
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000 use app::Recorder;
1001 use std::cell::RefCell;
1002
1003 struct Fixture {
1004 _root: tempfile::TempDir,
1005 paths: Paths,
1006 }
1007
1008 #[derive(Default)]
1009 struct QuitFailure {
1010 steps: RefCell<Vec<&'static str>>,
1011 }
1012
1013 impl AppControl for QuitFailure {
1014 fn quit(&self) -> Result<()> {
1015 self.steps.borrow_mut().push("quit");
1016 Err(AppError::Other("liveness probe failed".into()))
1017 }
1018
1019 fn relaunch(&self) -> Result<()> {
1020 self.steps.borrow_mut().push("relaunch");
1021 Ok(())
1022 }
1023
1024 fn archive(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1025 panic!("archive must not run after an unconfirmed quit")
1026 }
1027
1028 fn restore(&self, _archive: &Path, _root: &Path, _members: &[&str]) -> Result<()> {
1029 panic!("restore must not run before a switch starts")
1030 }
1031 }
1032
1033 fn write(path: &Path, contents: &str) {
1034 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1035 std::fs::write(path, contents).unwrap();
1036 }
1037
1038 fn fixture() -> Fixture {
1040 let root = tempfile::TempDir::new().unwrap();
1041 let data = root.path().join("data");
1042 let profiles = root.path().join("profiles");
1043 let backups = root.path().join("backups");
1044
1045 write(
1046 &data.join(CONFIG_JSON),
1047 r#"{"lastKnownAccountUuid":"uuid-here","oauth:tokenCache":"live-a",
1048 "oauth:tokenCacheV2":"live-b","dxt:allowlistEnabled:org-1":true}"#,
1049 );
1050 write(
1051 &data.join(DEVICE_REGISTRY),
1052 r#"{"uuid-here":{"deviceId":"d1"}}"#,
1053 );
1054 write(
1055 &data.join(BRIDGE_FILE),
1056 r#"{"remoteSessionId":"cse_stale"}"#,
1057 );
1058 write(&data.join("Cookies"), "live-cookies");
1059 write(&data.join("Local Storage/leveldb/CURRENT"), "live-ldb");
1060 write(
1061 &data.join(SESSIONS_DIR).join("uuid-here/org-1/local_x.json"),
1062 r#"{"lastActivityAt":500}"#,
1063 );
1064 write(
1065 &data
1066 .join(SESSIONS_DIR)
1067 .join("uuid-here/org-1/scheduled-tasks.json"),
1068 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1069 );
1070
1071 write(
1072 &profiles.join("here/meta.json"),
1073 r#"{"label":"here","email":"here@example.com","accountUuid":"uuid-here","orgUuid":"org-1"}"#,
1074 );
1075 write(
1076 &profiles.join("there/meta.json"),
1077 r#"{"label":"there","email":"there@example.com","accountUuid":"uuid-there","orgUuid":"org-2"}"#,
1078 );
1079 write(&profiles.join("there").join(TOKEN_CACHE), "saved-a");
1080 write(&profiles.join("there").join(TOKEN_CACHE_V2), "saved-b");
1081 write(
1082 &profiles.join("there").join(DESKTOP_STATE).join("Cookies"),
1083 "there-cookies",
1084 );
1085 write(
1086 &profiles
1087 .join("there")
1088 .join(DESKTOP_STATE)
1089 .join("Local Storage/leveldb/CURRENT"),
1090 "there-ldb",
1091 );
1092
1093 Fixture {
1094 paths: Paths::at(data, profiles, backups),
1095 _root: root,
1096 }
1097 }
1098
1099 fn manifest(root: &Path) -> Vec<(String, u64)> {
1102 fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
1103 let Ok(entries) = std::fs::read_dir(dir) else {
1104 return;
1105 };
1106 for entry in entries.flatten() {
1107 let path = entry.path();
1108 if path.is_dir() {
1109 walk(&path, root, out);
1110 } else if let Ok(meta) = entry.metadata() {
1111 let relative = path.strip_prefix(root).unwrap().display().to_string();
1112 out.push((relative, meta.len()));
1113 }
1114 }
1115 }
1116 let mut out = Vec::new();
1117 walk(root, root, &mut out);
1118 out.sort();
1119 out
1120 }
1121
1122 #[test]
1123 fn profiles_load_sorted_with_their_capture_state() {
1124 let fixture = fixture();
1125 let profiles = load_profiles(&fixture.paths.profiles_dir);
1126
1127 assert_eq!(profiles.len(), 2);
1128 assert_eq!(profiles[0].label, "here");
1129 assert_eq!(profiles[0].email.as_deref(), Some("here@example.com"));
1130 assert!(!profiles[0].has_credentials);
1131 assert_eq!(profiles[1].label, "there");
1132 assert!(profiles[1].has_credentials);
1133 assert!(profiles[1].has_desktop_state);
1134 }
1135
1136 #[test]
1137 fn a_malformed_profile_is_skipped_not_fatal() {
1138 let fixture = fixture();
1139 write(
1140 &fixture.paths.profiles_dir.join("broken/meta.json"),
1141 "{ not json",
1142 );
1143 write(
1144 &fixture.paths.profiles_dir.join("no-uuid/meta.json"),
1145 r#"{"label":"x"}"#,
1146 );
1147
1148 let profiles = load_profiles(&fixture.paths.profiles_dir);
1149 let labels: Vec<&str> = profiles.iter().map(|p| p.label.as_str()).collect();
1150 assert_eq!(labels, ["here", "there"]);
1151 }
1152
1153 #[test]
1154 fn the_active_account_resolves_to_its_label() {
1155 let fixture = fixture();
1156 let profiles = load_profiles(&fixture.paths.profiles_dir);
1157 let uuid = active_account_uuid(&fixture.paths.config_json()).unwrap();
1158
1159 assert_eq!(label_for_uuid(&profiles, &uuid), Some("here"));
1160 assert_eq!(label_for_uuid(&profiles, "uuid-nobody"), None);
1161 }
1162
1163 #[test]
1164 fn session_counts_come_from_the_accounts_own_folder() {
1165 let fixture = fixture();
1166 let profiles = load_profiles(&fixture.paths.profiles_dir);
1167 let sessions_root = fixture.paths.sessions_root();
1168
1169 assert_eq!(session_count(&sessions_root, &profiles[0]), 2);
1170 assert_eq!(session_count(&sessions_root, &profiles[1]), 0);
1171 }
1172
1173 #[test]
1174 fn planning_a_switch_changes_nothing_on_disk() {
1175 let fixture = fixture();
1176 let before = manifest(&fixture.paths.data_dir);
1177
1178 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1179
1180 assert_eq!(plan.outgoing.as_deref(), Some("here"));
1181 assert_eq!(plan.tokens.token_cache, "saved-a");
1182 assert!(plan.restores_desktop_state);
1183 assert_eq!(plan.sessions.copied.len(), 1, "{:?}", plan.sessions);
1184 assert_eq!(manifest(&fixture.paths.data_dir), before);
1185 assert!(!fixture.paths.backups_dir.exists());
1186 }
1187
1188 #[test]
1189 fn planning_an_unknown_label_lists_the_known_ones() {
1190 let fixture = fixture();
1191 let error = plan_switch(&fixture.paths, "nope", SwitchOpts::default()).unwrap_err();
1192 let message = error.to_string();
1193 assert!(message.contains("here"), "{message}");
1194 assert!(message.contains("claude-acc"), "{message}");
1195 }
1196
1197 #[test]
1198 fn the_archive_skips_the_session_tree_by_default() {
1199 let fixture = fixture();
1200
1201 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1202 assert!(plan.archive_members.contains(&CONFIG_JSON.to_string()));
1203 assert!(plan.archive_members.contains(&DEVICE_REGISTRY.to_string()));
1204 assert!(plan.archive_members.contains(&BRIDGE_FILE.to_string()));
1205 assert!(plan.archive_members.contains(&"Cookies".to_string()));
1206 assert!(plan.archive_members.contains(&"Local Storage".to_string()));
1207 assert!(!plan.archive_members.contains(&SESSIONS_DIR.to_string()));
1208 assert!(
1209 plan.archive_members
1210 .iter()
1211 .any(|member| member.ends_with("scheduled-tasks.json"))
1212 );
1213
1214 let full = plan_switch(
1215 &fixture.paths,
1216 "there",
1217 SwitchOpts {
1218 backup_sessions: true,
1219 ..SwitchOpts::default()
1220 },
1221 )
1222 .unwrap();
1223 assert!(full.archive_members.contains(&SESSIONS_DIR.to_string()));
1224 }
1225
1226 #[test]
1227 fn applying_a_switch_quits_then_archives_then_relaunches() {
1228 let fixture = fixture();
1229 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1230 let recorder = Recorder::default();
1231
1232 apply_switch(&fixture.paths, &plan, &recorder).unwrap();
1233
1234 let steps = recorder.steps();
1235 assert_eq!(steps[0], "quit");
1236 assert!(steps[1].starts_with("archive "), "{steps:?}");
1237 assert_eq!(steps[2], "relaunch");
1238 }
1239
1240 #[test]
1241 fn a_failed_liveness_probe_relaunches_without_touching_account_data() {
1242 let fixture = fixture();
1243 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1244 let before = std::fs::read(fixture.paths.config_json()).unwrap();
1245 let app = QuitFailure::default();
1246
1247 let error = apply_switch(&fixture.paths, &plan, &app).unwrap_err();
1248
1249 assert!(error.to_string().contains("liveness probe failed"));
1250 assert_eq!(*app.steps.borrow(), ["quit", "relaunch"]);
1251 assert_eq!(std::fs::read(fixture.paths.config_json()).unwrap(), before);
1252 }
1253
1254 #[test]
1259 fn a_confirmed_deletion_reaches_every_account_and_updates_the_record() {
1260 let fixture = fixture();
1261 let sessions = fixture.paths.sessions_root();
1262 write(
1264 &sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1265 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1266 );
1267 write(
1268 &sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1269 r#"{"scheduledTasks":[{"id":"t1","createdAt":1},{"id":"t2","createdAt":2}]}"#,
1270 );
1271
1272 let mut plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1273 plan.confirmed_deletions = [merge::DeletionKey {
1274 kind: merge::ConflictKind::Routine,
1275 id: "t1".to_string(),
1276 deleted_by: "uuid-here".to_string(),
1277 still_in: vec!["uuid-there".to_string()],
1278 }]
1279 .into_iter()
1280 .collect();
1281 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1282
1283 for registry in [
1284 sessions.join("uuid-here/org-1/scheduled-tasks.json"),
1285 sessions.join("uuid-there/org-2/scheduled-tasks.json"),
1286 ] {
1287 let value: serde_json::Value =
1288 serde_json::from_slice(&std::fs::read(®istry).unwrap()).unwrap();
1289 let ids: Vec<&str> = value["scheduledTasks"]
1290 .as_array()
1291 .unwrap()
1292 .iter()
1293 .filter_map(|task| task["id"].as_str())
1294 .collect();
1295 assert!(
1296 !ids.contains(&"t1"),
1297 "{registry:?} still holds the deleted task"
1298 );
1299 }
1300 let there: serde_json::Value = serde_json::from_slice(
1303 &std::fs::read(sessions.join("uuid-there/org-2/scheduled-tasks.json")).unwrap(),
1304 )
1305 .unwrap();
1306 assert_eq!(there["scheduledTasks"][0]["id"], "t2");
1307
1308 let recorded = load_synced(&fixture.paths.synced_path());
1311 assert!(!recorded.is_empty(), "the sync record was never written");
1312 assert!(
1313 recorded
1314 .values()
1315 .all(|state| !state.routines.contains("t1")),
1316 "{recorded:?} still lists the deleted task"
1317 );
1318 }
1319
1320 #[test]
1325 fn keeping_every_conflict_leaves_other_accounts_untouched() {
1326 let fixture = fixture();
1327 let sessions = fixture.paths.sessions_root();
1328 let bystander = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1329 write(
1330 &bystander,
1331 r#"{"scheduledTasks":[{"id":"t1","createdAt":1}]}"#,
1332 );
1333 let before = std::fs::read(&bystander).unwrap();
1334
1335 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1336 assert!(plan.confirmed_deletions.is_empty());
1337 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1338
1339 assert_eq!(std::fs::read(&bystander).unwrap(), before);
1340 }
1341
1342 #[test]
1346 fn a_switch_converges_routine_names_across_accounts() {
1347 let fixture = fixture();
1348 let sessions = fixture.paths.sessions_root();
1349 let here = sessions.join("uuid-here/org-1/scheduled-tasks.json");
1350 let there = sessions.join("uuid-there/org-2/scheduled-tasks.json");
1351 write(
1353 &here,
1354 r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"Home name"}]}"#,
1355 );
1356 write(
1357 &there,
1358 r#"{"scheduledTasks":[{"id":"t1","createdAt":1,"displayName":"There name"}]}"#,
1359 );
1360
1361 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1362 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1363
1364 let name = |path: &std::path::Path| -> String {
1365 let v: serde_json::Value =
1366 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1367 v["scheduledTasks"][0]["displayName"]
1368 .as_str()
1369 .unwrap_or_default()
1370 .to_string()
1371 };
1372 assert_eq!(name(&here), name(&there), "names did not converge");
1374 assert!(!name(&here).is_empty());
1375 }
1376
1377 #[test]
1378 fn applying_a_switch_swaps_the_credential_and_carries_history() {
1379 let fixture = fixture();
1380 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1381
1382 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1383
1384 let config: serde_json::Value =
1385 serde_json::from_slice(&std::fs::read(fixture.paths.config_json()).unwrap()).unwrap();
1386 assert_eq!(config["oauth:tokenCache"], "saved-a");
1387 assert_eq!(config["oauth:tokenCacheV2"], "saved-b");
1388 assert_eq!(config["lastKnownAccountUuid"], "uuid-there");
1389 assert_eq!(config["dxt:allowlistEnabled:org-1"], true);
1390
1391 assert!(
1393 fixture
1394 .paths
1395 .sessions_root()
1396 .join("uuid-there/org-2/local_x.json")
1397 .is_file()
1398 );
1399 assert_eq!(
1400 std::fs::read_to_string(fixture.paths.data_dir.join("Cookies")).unwrap(),
1401 "there-cookies"
1402 );
1403 assert_eq!(
1404 std::fs::read_to_string(fixture.paths.data_dir.join("Local Storage/leveldb/CURRENT"))
1405 .unwrap(),
1406 "there-ldb"
1407 );
1408 assert!(!fixture.paths.data_dir.join(BRIDGE_FILE).exists());
1410 }
1411
1412 #[test]
1413 fn the_outgoing_account_is_snapshotted_before_the_swap() {
1414 let fixture = fixture();
1415 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1416
1417 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1418
1419 let here = fixture.paths.profile_dir("here");
1423 assert_eq!(
1424 std::fs::read_to_string(here.join(TOKEN_CACHE)).unwrap(),
1425 "live-a"
1426 );
1427 assert_eq!(
1428 std::fs::read_to_string(here.join(TOKEN_CACHE_V2)).unwrap(),
1429 "live-b"
1430 );
1431 assert_eq!(
1432 std::fs::read_to_string(here.join(DESKTOP_STATE).join("Cookies")).unwrap(),
1433 "live-cookies"
1434 );
1435 let meta = std::fs::read_to_string(here.join(META_JSON)).unwrap();
1437 assert!(meta.contains("here@example.com"), "{meta}");
1438 }
1439
1440 #[test]
1441 fn planning_refuses_an_incomplete_saved_identity_without_touching_the_app() {
1442 let fixture = fixture();
1443 std::fs::remove_file(fixture.paths.profile_dir("there").join(TOKEN_CACHE)).unwrap();
1444 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1445 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1446 assert!(error.to_string().contains("credential"), "{error}");
1447 }
1448
1449 #[test]
1450 fn planning_refuses_credentials_without_saved_browser_state() {
1451 let fixture = fixture();
1452 std::fs::remove_dir_all(fixture.paths.profile_dir("there").join(DESKTOP_STATE)).unwrap();
1453
1454 let error = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap_err();
1455
1456 assert!(error.to_string().contains("browser state"), "{error}");
1457 }
1458
1459 #[test]
1460 fn a_failed_switch_requests_rollback_and_still_relaunches() {
1461 let fixture = fixture();
1462 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1463 write(&fixture.paths.config_json(), "{ not json");
1464 let recorder = Recorder::default();
1465
1466 let error = apply_switch(&fixture.paths, &plan, &recorder).unwrap_err();
1467
1468 assert!(error.to_string().contains("json"), "{error}");
1469 let steps = recorder.steps();
1470 assert_eq!(steps.first().map(String::as_str), Some("quit"));
1471 assert!(
1472 steps.iter().any(|step| step.starts_with("restore ")),
1473 "{steps:?}"
1474 );
1475 assert_eq!(steps.last().map(String::as_str), Some("relaunch"));
1476 }
1477
1478 #[test]
1479 fn restoring_browser_state_removes_files_the_target_does_not_have() {
1480 let fixture = fixture();
1481 write(&fixture.paths.data_dir.join("Cookies-journal"), "outgoing");
1482 write(
1483 &fixture.paths.data_dir.join("Session Storage/CURRENT"),
1484 "outgoing",
1485 );
1486 let plan = plan_switch(&fixture.paths, "there", SwitchOpts::default()).unwrap();
1487
1488 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1489
1490 assert!(!fixture.paths.data_dir.join("Cookies-journal").exists());
1491 assert!(!fixture.paths.data_dir.join("Session Storage").exists());
1492 }
1493
1494 #[test]
1495 fn keeping_the_bridge_leaves_it_in_place() {
1496 let fixture = fixture();
1497 let plan = plan_switch(
1498 &fixture.paths,
1499 "there",
1500 SwitchOpts {
1501 keep_bridge: true,
1502 ..SwitchOpts::default()
1503 },
1504 )
1505 .unwrap();
1506
1507 apply_switch(&fixture.paths, &plan, &Recorder::default()).unwrap();
1508 assert!(fixture.paths.data_dir.join(BRIDGE_FILE).is_file());
1509 }
1510
1511 #[test]
1512 fn archives_are_pruned_oldest_first() {
1513 let dir = tempfile::TempDir::new().unwrap();
1514 for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
1515 std::fs::write(dir.path().join(format!("switch-{stamp}-x.tar.gz")), "z").unwrap();
1516 }
1517 std::fs::write(dir.path().join("unrelated.txt"), "keep me").unwrap();
1518 let mut notes = Vec::new();
1519
1520 prune_archives(dir.path(), 2, &mut notes);
1521
1522 assert!(notes.is_empty(), "{notes:?}");
1523 assert!(!dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1524 assert!(dir.path().join("switch-20260102-000000-x.tar.gz").exists());
1525 assert!(dir.path().join("switch-20260103-000000-x.tar.gz").exists());
1526 assert!(dir.path().join("unrelated.txt").exists());
1527 }
1528
1529 #[test]
1538 fn no_note_interpolates_an_unsanitized_path() {
1539 let mut sites = Vec::new();
1540 for file in crate::guard::rs_files_in("src") {
1541 let source = std::fs::read_to_string(&file).expect("readable module");
1542 let body = crate::guard::production_code(&source);
1543 let mut rest = body.as_str();
1544 while let Some(at) = rest.find("notes.push(") {
1545 let call = &rest[at..];
1546 let mut depth = 0usize;
1547 let mut end = call.len();
1548 for (i, ch) in call.char_indices() {
1549 match ch {
1550 '(' => depth += 1,
1551 ')' => {
1552 depth -= 1;
1553 if depth == 0 {
1554 end = i;
1555 break;
1556 }
1557 }
1558 _ => {}
1559 }
1560 }
1561 if call[..end].contains(".display()") {
1562 sites.push(format!("{}: {}", file.display(), &call[..end]));
1563 }
1564 rest = &call[end.max(1)..];
1565 }
1566 }
1567 assert!(
1568 sites.is_empty(),
1569 "a note reaches the terminal verbatim; render its path with \
1570 `sanitize_untrusted_path`. Found: {sites:#?}"
1571 );
1572 }
1573
1574 #[cfg(unix)]
1581 #[test]
1582 fn a_note_does_not_carry_a_terminal_escape_out_of_a_path() {
1583 let dir = tempfile::TempDir::new().unwrap();
1584 std::fs::create_dir(dir.path().join("switch-20260101-000000-\x1b[2Kx.tar.gz")).unwrap();
1585 std::fs::write(dir.path().join("switch-20260102-000000-y.tar.gz"), "z").unwrap();
1586 let mut notes = Vec::new();
1587
1588 prune_archives(dir.path(), 1, &mut notes);
1589
1590 assert_eq!(notes.len(), 1, "{notes:?}");
1591 assert!(!notes[0].contains('\u{1b}'), "{:?}", notes[0]);
1592 assert!(!notes[0].contains('\n'), "{:?}", notes[0]);
1593 assert!(notes[0].contains("could not prune"), "{}", notes[0]);
1594 }
1595
1596 #[test]
1597 fn pruning_never_discards_the_only_rollback_archive() {
1598 let dir = tempfile::TempDir::new().unwrap();
1599 std::fs::write(dir.path().join("switch-20260101-000000-x.tar.gz"), "z").unwrap();
1600 let mut notes = Vec::new();
1601
1602 prune_archives(dir.path(), 1, &mut notes);
1603
1604 assert!(dir.path().join("switch-20260101-000000-x.tar.gz").exists());
1605 }
1606}