1use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8
9use super::env::{Category, Paths};
10
11fn legacy_entries(category: Category) -> &'static [&'static str] {
14 match category {
15 Category::Config => &["preferences", "snippets", "providers", "themes"],
16 Category::Data => &["certs", "config.original"],
17 Category::State => &[
18 "history.tsv",
19 "recents.json",
20 "key_activity.json",
21 "snippet_runs.json",
22 "sync_history.tsv",
23 "purple.log",
24 "mcp-audit.log",
25 ],
26 Category::Cache => &["container_cache.jsonl", "last_version_check"],
27 }
28}
29
30#[derive(Debug, Default, PartialEq, Eq)]
34pub struct MigrationReport {
35 pub copied: Vec<(PathBuf, PathBuf)>,
37 pub failed: Vec<(PathBuf, String)>,
39}
40
41impl MigrationReport {
42 pub fn is_empty(&self) -> bool {
43 self.copied.is_empty() && self.failed.is_empty()
44 }
45}
46
47pub fn migrate_legacy_layout(paths: &Paths) -> MigrationReport {
56 let mut report = MigrationReport::default();
57 let legacy = paths.legacy_dir();
58 for category in Category::ALL {
59 let target = paths.dir(category);
60 if target == legacy {
61 continue;
62 }
63 if let Err(e) = ensure_private_dir(target) {
64 report
65 .failed
66 .push((target.to_path_buf(), format!("create directory: {e}")));
67 continue;
68 }
69 let entries = legacy_entries(category);
70 if entries.iter().any(|name| target.join(name).exists()) {
71 continue;
72 }
73 let mut done: Vec<(PathBuf, PathBuf)> = Vec::new();
74 let mut failure: Option<(PathBuf, String)> = None;
75 for name in entries {
76 let src = legacy.join(name);
77 if !src.exists() {
78 continue;
79 }
80 let dst = target.join(name);
81 match copy_entry(&src, &dst) {
82 Ok(()) => done.push((src, dst)),
83 Err(e) => {
84 failure = Some((src, e.to_string()));
85 break;
86 }
87 }
88 }
89 match failure {
90 Some((src, err)) => {
91 for (_, dst) in &done {
92 let _ = remove_path(dst);
93 }
94 report.failed.push((
95 src,
96 format!(
97 "{err} ({} category left unseeded, retried on the next start)",
98 category.name()
99 ),
100 ));
101 }
102 None => report.copied.extend(done),
103 }
104 }
105 report
106}
107
108pub fn ensure_private_dir(dir: &Path) -> io::Result<()> {
111 if dir.is_dir() {
112 return Ok(());
113 }
114 fs::create_dir_all(dir)?;
115 #[cfg(unix)]
116 {
117 use std::os::unix::fs::PermissionsExt;
118 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
119 }
120 Ok(())
121}
122
123fn copy_entry(src: &Path, dst: &Path) -> io::Result<()> {
128 if !src.is_dir() {
129 return copy_file(src, dst);
130 }
131 let mut tmp_name = std::ffi::OsString::from(".");
132 tmp_name.push(dst.file_name().unwrap_or_default());
133 tmp_name.push(format!(".purple_tmp.{}", std::process::id()));
134 let tmp = dst.with_file_name(tmp_name);
135 let _ = fs::remove_dir_all(&tmp);
136 let copied = copy_tree(src, &tmp).and_then(|()| fs::rename(&tmp, dst));
137 if copied.is_err() {
138 let _ = fs::remove_dir_all(&tmp);
139 }
140 copied
141}
142
143fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
145 ensure_private_dir(dst)?;
146 for entry in fs::read_dir(src)? {
147 let entry = entry?;
148 let from = entry.path();
149 let to = dst.join(entry.file_name());
150 if from.is_dir() {
151 copy_tree(&from, &to)?;
152 } else {
153 copy_file(&from, &to)?;
154 }
155 }
156 Ok(())
157}
158
159fn copy_file(src: &Path, dst: &Path) -> io::Result<()> {
164 #[cfg(unix)]
165 let mode = {
166 use std::os::unix::fs::PermissionsExt;
167 fs::metadata(src)?.permissions().mode() & 0o777
168 };
169 let content = fs::read(src)?;
170 crate::fs_util::atomic_write(dst, &content)?;
171 #[cfg(unix)]
172 {
173 use std::os::unix::fs::PermissionsExt;
174 if let Err(e) = fs::set_permissions(dst, fs::Permissions::from_mode(mode)) {
175 log::debug!(
176 "[purple] could not carry mode {mode:o} onto {}: {e}",
177 dst.display()
178 );
179 }
180 }
181 Ok(())
182}
183
184fn remove_path(path: &Path) -> io::Result<()> {
186 if path.is_dir() {
187 fs::remove_dir_all(path)
188 } else {
189 fs::remove_file(path)
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn split_paths(home: &Path, base: &Path) -> Paths {
198 let cfg = base.join("cfg").to_string_lossy().to_string();
199 let data = base.join("data").to_string_lossy().to_string();
200 let state = base.join("state").to_string_lossy().to_string();
201 let cache = base.join("cache").to_string_lossy().to_string();
202 Paths::resolve(home, move |k| match k {
203 "XDG_CONFIG_HOME" => Some(cfg.clone()),
204 "XDG_DATA_HOME" => Some(data.clone()),
205 "XDG_STATE_HOME" => Some(state.clone()),
206 "XDG_CACHE_HOME" => Some(cache.clone()),
207 _ => None,
208 })
209 }
210
211 fn seed_legacy(home: &Path) -> PathBuf {
212 let legacy = home.join(".purple");
213 fs::create_dir_all(legacy.join("certs")).unwrap();
214 fs::create_dir_all(legacy.join("themes")).unwrap();
215 fs::write(legacy.join("preferences"), "theme=Purple\n").unwrap();
216 fs::write(legacy.join("providers"), "[aws]\n").unwrap();
217 fs::write(legacy.join("snippets"), "").unwrap();
218 fs::write(legacy.join("themes/mine.toml"), "name = \"mine\"\n").unwrap();
219 fs::write(legacy.join("certs/web-cert.pub"), "cert").unwrap();
220 fs::write(legacy.join("config.original"), "Host a\n").unwrap();
221 fs::write(legacy.join("history.tsv"), "a\t1\t1\t1\n").unwrap();
222 fs::write(legacy.join("recents.json"), "[]").unwrap();
223 fs::write(legacy.join("purple.log"), "log\n").unwrap();
224 fs::write(legacy.join("container_cache.jsonl"), "{}\n").unwrap();
225 fs::write(legacy.join("last_version_check"), "1\n2\n\n").unwrap();
226 fs::write(legacy.join(".askpass_a"), "").unwrap();
227 fs::write(legacy.join(".DS_Store"), "junk").unwrap();
228 legacy
229 }
230
231 #[test]
232 fn legacy_layout_is_a_no_op() {
233 let dir = tempfile::tempdir().unwrap();
234 seed_legacy(dir.path());
235 let report = migrate_legacy_layout(&Paths::new(dir.path()));
236 assert!(report.is_empty(), "{report:?}");
237 }
238
239 #[test]
240 fn copies_each_category_into_its_own_directory() {
241 let dir = tempfile::tempdir().unwrap();
242 let home = dir.path().join("home");
243 let legacy = seed_legacy(&home);
244 let paths = split_paths(&home, dir.path());
245
246 let report = migrate_legacy_layout(&paths);
247
248 assert!(report.failed.is_empty(), "{report:?}");
249 assert_eq!(
250 fs::read_to_string(paths.preferences()).unwrap(),
251 "theme=Purple\n"
252 );
253 assert_eq!(
254 fs::read_to_string(paths.providers_config()).unwrap(),
255 "[aws]\n"
256 );
257 assert!(paths.snippets_file().exists());
258 assert_eq!(
259 fs::read_to_string(paths.themes_dir().join("mine.toml")).unwrap(),
260 "name = \"mine\"\n"
261 );
262 assert_eq!(fs::read_to_string(paths.cert_for("web")).unwrap(), "cert");
263 assert_eq!(
264 fs::read_to_string(paths.config_original()).unwrap(),
265 "Host a\n"
266 );
267 assert!(paths.history().exists());
268 assert!(paths.recents().exists());
269 assert!(paths.log_file().exists());
270 assert!(paths.container_cache().exists());
271 assert!(paths.last_version_check().exists());
272 assert!(!paths.askpass_marker("a").exists());
274 assert!(!paths.state_dir().join(".DS_Store").exists());
275 assert!(legacy.join("preferences").exists());
277 assert!(legacy.join("certs/web-cert.pub").exists());
278 assert_eq!(report.copied.len(), 11, "{report:?}");
279 }
280
281 #[cfg(unix)]
282 #[test]
283 fn created_directories_are_private_and_file_modes_survive() {
284 use std::os::unix::fs::PermissionsExt;
285 let dir = tempfile::tempdir().unwrap();
286 let home = dir.path().join("home");
287 let legacy = seed_legacy(&home);
288 fs::set_permissions(legacy.join("providers"), fs::Permissions::from_mode(0o600)).unwrap();
289 fs::set_permissions(
290 legacy.join("preferences"),
291 fs::Permissions::from_mode(0o644),
292 )
293 .unwrap();
294 let paths = split_paths(&home, dir.path());
295
296 migrate_legacy_layout(&paths);
297
298 let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
299 for category in Category::ALL {
300 assert_eq!(mode(paths.dir(category)), 0o700, "{category:?}");
301 }
302 assert_eq!(mode(&paths.certs_dir()), 0o700);
303 assert_eq!(mode(&paths.themes_dir()), 0o700);
304 assert_eq!(mode(&paths.providers_config()), 0o600);
305 assert_eq!(mode(&paths.preferences()), 0o644);
306 }
307
308 #[test]
309 fn a_target_that_already_holds_category_files_is_left_alone() {
310 let dir = tempfile::tempdir().unwrap();
311 let home = dir.path().join("home");
312 seed_legacy(&home);
313 let paths = split_paths(&home, dir.path());
314 fs::create_dir_all(paths.config_dir()).unwrap();
315 fs::write(paths.preferences(), "theme=Nord\n").unwrap();
316
317 let report = migrate_legacy_layout(&paths);
318
319 assert_eq!(
322 fs::read_to_string(paths.preferences()).unwrap(),
323 "theme=Nord\n"
324 );
325 assert!(!paths.snippets_file().exists());
326 assert!(paths.history().exists());
328 assert!(paths.cert_for("web").exists());
329 assert!(
330 !report
331 .copied
332 .iter()
333 .any(|(_, dst)| dst.starts_with(paths.config_dir()))
334 );
335 }
336
337 #[test]
338 fn a_second_run_copies_nothing() {
339 let dir = tempfile::tempdir().unwrap();
340 let home = dir.path().join("home");
341 seed_legacy(&home);
342 let paths = split_paths(&home, dir.path());
343
344 let first = migrate_legacy_layout(&paths);
345 assert!(!first.copied.is_empty());
346 let second = migrate_legacy_layout(&paths);
347 assert!(second.is_empty(), "{second:?}");
348 }
349
350 #[test]
351 fn overlapping_categories_each_seed_their_own_files() {
352 let dir = tempfile::tempdir().unwrap();
355 let home = dir.path().join("home");
356 seed_legacy(&home);
357 let shared = dir.path().join("shared").to_string_lossy().to_string();
358 let paths = Paths::resolve(&home, move |k| match k {
359 "PURPLE_CONFIG_DIR" | "PURPLE_STATE_DIR" => Some(shared.clone()),
360 _ => None,
361 });
362
363 let report = migrate_legacy_layout(&paths);
364
365 assert!(report.failed.is_empty(), "{report:?}");
366 assert!(paths.preferences().exists());
367 assert!(paths.history().exists());
368 assert_eq!(paths.config_dir(), paths.state_dir());
369 }
370
371 #[test]
372 fn missing_legacy_tree_creates_empty_directories() {
373 let dir = tempfile::tempdir().unwrap();
374 let home = dir.path().join("home");
375 let paths = split_paths(&home, dir.path());
376
377 let report = migrate_legacy_layout(&paths);
378
379 assert!(report.is_empty(), "{report:?}");
380 for category in Category::ALL {
381 assert!(paths.dir(category).is_dir(), "{category:?}");
382 }
383 }
384
385 #[cfg(unix)]
386 #[test]
387 fn ensure_private_dir_leaves_an_existing_directory_untouched() {
388 use std::os::unix::fs::PermissionsExt;
389 let dir = tempfile::tempdir().unwrap();
390 let existing = dir.path().join("cfg");
391 fs::create_dir_all(&existing).unwrap();
392 fs::set_permissions(&existing, fs::Permissions::from_mode(0o755)).unwrap();
393 ensure_private_dir(&existing).unwrap();
394 assert_eq!(
395 fs::metadata(&existing).unwrap().permissions().mode() & 0o777,
396 0o755
397 );
398 }
399
400 #[test]
401 fn a_failed_copy_is_reported_and_does_not_stop_the_rest() {
402 let dir = tempfile::tempdir().unwrap();
403 let home = dir.path().join("home");
404 seed_legacy(&home);
405 let paths = split_paths(&home, dir.path());
406 fs::create_dir_all(dir.path().join("cache")).unwrap();
408 fs::write(dir.path().join("cache/purple"), "not a directory").unwrap();
409
410 let report = migrate_legacy_layout(&paths);
411
412 assert_eq!(report.failed.len(), 1, "{report:?}");
413 assert!(paths.preferences().exists());
414 assert!(paths.history().exists());
415 }
416
417 #[cfg(unix)]
418 #[test]
419 fn a_failure_mid_category_rolls_back_and_the_next_run_retries() {
420 use std::os::unix::fs::PermissionsExt;
421 let dir = tempfile::tempdir().unwrap();
422 let home = dir.path().join("home");
423 let legacy = seed_legacy(&home);
424 let paths = split_paths(&home, dir.path());
425 let recents = legacy.join("recents.json");
427 fs::set_permissions(&recents, fs::Permissions::from_mode(0o000)).unwrap();
428
429 let first = migrate_legacy_layout(&paths);
430
431 let state_failed = first
432 .failed
433 .iter()
434 .any(|(src, err)| src == &recents && err.contains("state category left unseeded"));
435 assert!(state_failed, "{first:?}");
436 assert!(
437 !paths.history().exists(),
438 "the entry copied before the failure is rolled back"
439 );
440 assert!(
441 !first
442 .copied
443 .iter()
444 .any(|(_, dst)| dst.starts_with(paths.state_dir()))
445 );
446 assert!(paths.preferences().exists());
448 assert!(paths.cert_for("web").exists());
449
450 fs::set_permissions(&recents, fs::Permissions::from_mode(0o600)).unwrap();
451 let second = migrate_legacy_layout(&paths);
452
453 assert!(second.failed.is_empty(), "{second:?}");
454 assert!(paths.history().exists());
455 assert!(paths.recents().exists());
456 assert!(paths.log_file().exists());
457 assert!(
459 second
460 .copied
461 .iter()
462 .all(|(_, dst)| dst.starts_with(paths.state_dir())),
463 "{second:?}"
464 );
465 }
466
467 #[cfg(unix)]
468 #[test]
469 fn a_directory_entry_that_fails_leaves_no_partial_copy() {
470 use std::os::unix::fs::PermissionsExt;
471 let dir = tempfile::tempdir().unwrap();
472 let home = dir.path().join("home");
473 let legacy = seed_legacy(&home);
474 let paths = split_paths(&home, dir.path());
475 fs::write(legacy.join("certs/locked-cert.pub"), "x").unwrap();
477 fs::set_permissions(
478 legacy.join("certs/locked-cert.pub"),
479 fs::Permissions::from_mode(0o000),
480 )
481 .unwrap();
482
483 let report = migrate_legacy_layout(&paths);
484
485 assert!(
486 report
487 .failed
488 .iter()
489 .any(|(src, _)| src == &legacy.join("certs")),
490 "{report:?}"
491 );
492 assert!(
493 !paths.certs_dir().exists(),
494 "no half-copied certs directory"
495 );
496 assert!(
497 !paths.config_original().exists(),
498 "data category stays unseeded"
499 );
500 let leftovers: Vec<_> = fs::read_dir(paths.data_dir())
501 .unwrap()
502 .filter_map(Result::ok)
503 .map(|e| e.file_name())
504 .collect();
505 assert!(
506 leftovers.is_empty(),
507 "temp directory cleaned up: {leftovers:?}"
508 );
509 }
510}