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)]
420 fn is_root() -> bool {
421 unsafe { libc::geteuid() == 0 }
423 }
424
425 #[cfg(unix)]
426 #[test]
427 fn a_failure_mid_category_rolls_back_and_the_next_run_retries() {
428 use std::os::unix::fs::PermissionsExt;
429 if is_root() {
430 return;
431 }
432 let dir = tempfile::tempdir().unwrap();
433 let home = dir.path().join("home");
434 let legacy = seed_legacy(&home);
435 let paths = split_paths(&home, dir.path());
436 let recents = legacy.join("recents.json");
438 fs::set_permissions(&recents, fs::Permissions::from_mode(0o000)).unwrap();
439
440 let first = migrate_legacy_layout(&paths);
441
442 let state_failed = first
443 .failed
444 .iter()
445 .any(|(src, err)| src == &recents && err.contains("state category left unseeded"));
446 assert!(state_failed, "{first:?}");
447 assert!(
448 !paths.history().exists(),
449 "the entry copied before the failure is rolled back"
450 );
451 assert!(
452 !first
453 .copied
454 .iter()
455 .any(|(_, dst)| dst.starts_with(paths.state_dir()))
456 );
457 assert!(paths.preferences().exists());
459 assert!(paths.cert_for("web").exists());
460
461 fs::set_permissions(&recents, fs::Permissions::from_mode(0o600)).unwrap();
462 let second = migrate_legacy_layout(&paths);
463
464 assert!(second.failed.is_empty(), "{second:?}");
465 assert!(paths.history().exists());
466 assert!(paths.recents().exists());
467 assert!(paths.log_file().exists());
468 assert!(
470 second
471 .copied
472 .iter()
473 .all(|(_, dst)| dst.starts_with(paths.state_dir())),
474 "{second:?}"
475 );
476 }
477
478 #[cfg(unix)]
479 #[test]
480 fn a_directory_entry_that_fails_leaves_no_partial_copy() {
481 use std::os::unix::fs::PermissionsExt;
482 if is_root() {
483 return;
484 }
485 let dir = tempfile::tempdir().unwrap();
486 let home = dir.path().join("home");
487 let legacy = seed_legacy(&home);
488 let paths = split_paths(&home, dir.path());
489 fs::write(legacy.join("certs/locked-cert.pub"), "x").unwrap();
491 fs::set_permissions(
492 legacy.join("certs/locked-cert.pub"),
493 fs::Permissions::from_mode(0o000),
494 )
495 .unwrap();
496
497 let report = migrate_legacy_layout(&paths);
498
499 assert!(
500 report
501 .failed
502 .iter()
503 .any(|(src, _)| src == &legacy.join("certs")),
504 "{report:?}"
505 );
506 assert!(
507 !paths.certs_dir().exists(),
508 "no half-copied certs directory"
509 );
510 assert!(
511 !paths.config_original().exists(),
512 "data category stays unseeded"
513 );
514 let leftovers: Vec<_> = fs::read_dir(paths.data_dir())
515 .unwrap()
516 .filter_map(Result::ok)
517 .map(|e| e.file_name())
518 .collect();
519 assert!(
520 leftovers.is_empty(),
521 "temp directory cleaned up: {leftovers:?}"
522 );
523 }
524}