Skip to main content

purple_ssh/runtime/
layout.rs

1// Seeds the split category directories from the legacy `~/.purple` tree the
2// first time a category resolves somewhere else. Runs once at process start,
3// before logging opens the log file in the state directory.
4
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8
9use super::env::{Category, Paths};
10
11/// Legacy entries per category, relative to `~/.purple`. Askpass retry
12/// markers expire within a minute and are not carried over.
13fn 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/// What one startup migration did. Logged by the launcher once the log file
31/// is open, since the state directory holding that file may itself be a
32/// migration target.
33#[derive(Debug, Default, PartialEq, Eq)]
34pub struct MigrationReport {
35    /// `(legacy source, new location)` pairs that were copied.
36    pub copied: Vec<(PathBuf, PathBuf)>,
37    /// `(legacy source, error text)` pairs for copies that failed.
38    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
47/// Copy legacy files into every category directory that resolved outside
48/// `~/.purple` and holds none of that category's files yet. Directories are
49/// created with mode 0700; copied files keep their mode. The legacy copies
50/// stay in place, so a user can roll back by unsetting the variables.
51///
52/// A category is seeded all or nothing: when one entry fails to copy, the
53/// entries already copied for that category are removed again, so the next
54/// start finds the directory untouched and tries again.
55pub 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
108/// Create `dir` (and its parents) when absent, mode 0700 on the leaf. An
109/// existing directory is left exactly as it is.
110pub 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
123/// Copy one legacy entry so that `dst` either appears complete or not at
124/// all. A file goes through an atomic write. A directory tree is copied
125/// into a temporary sibling first and renamed into place at the end; a
126/// failure removes the partial copy.
127fn 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
143/// Copy a directory tree. New directories get mode 0700, files keep theirs.
144fn 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
159/// Copy one file through an atomic write, keeping its mode. Everything that
160/// can fail is read before the write, so a returned error means `dst` was
161/// never created. Carrying the mode over is best effort: the atomic write
162/// leaves 0600 behind, which is never wider than the source.
163fn 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
184/// Remove a copied entry again, file or directory tree.
185fn 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        // Ephemeral markers and unknown files stay behind.
273        assert!(!paths.askpass_marker("a").exists());
274        assert!(!paths.state_dir().join(".DS_Store").exists());
275        // The legacy tree is untouched.
276        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        // Config is skipped as a whole: the dotfile-managed preferences stay
320        // and the sibling snippets file is not seeded next to it.
321        assert_eq!(
322            fs::read_to_string(paths.preferences()).unwrap(),
323            "theme=Nord\n"
324        );
325        assert!(!paths.snippets_file().exists());
326        // Other categories still migrate.
327        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        // Config and state resolve to the same directory. Seeding config must
353        // not make the state seed think its files are already there.
354        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        // A file where the cache directory should go makes that category fail.
407        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    /// Tests that inject a copy failure through mode 0o000 skip under
418    /// root, because root reads files regardless of permission bits.
419    #[cfg(unix)]
420    fn is_root() -> bool {
421        // SAFETY: geteuid takes no arguments and cannot fail.
422        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        // history.tsv copies first, then recents.json cannot be read.
437        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        // The other categories still landed.
458        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        // Only the state category was seeded on the retry.
469        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        // One unreadable file inside certs/ fails the whole certs entry.
490        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}