Skip to main content

qframe/storage/
migrate.rs

1//! Moving an application's settings from the folder it used on its own into its family's
2//! layout, once, so that no file is ever lost on the way.
3//!
4//! Each file is copied before anything is removed: the new file is claimed under its name, filled
5//! with [`atomic_write`], given the old file's permissions and read back, and only a copy that
6//! reads back the same lets the old file go. Whatever cannot be moved that way stays where it is
7//! and is reported.
8
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use super::FILE_NAME;
14use super::atomic::atomic_write;
15use crate::diagnostics::Diagnostic;
16
17/// What [`Family::adopt`](super::Family::adopt) did: the files it moved and, for everything it
18/// left behind, a diagnostic that names the path and says why.
19#[derive(Debug, Clone, Default, PartialEq)]
20pub struct Migration {
21    moved: Vec<(PathBuf, PathBuf)>,
22    diagnostics: Vec<Diagnostic>,
23}
24
25impl Migration {
26    /// The files that were moved, each as `(from, to)`, in the order they were moved.
27    #[must_use]
28    pub fn moved(&self) -> &[(PathBuf, PathBuf)] {
29        &self.moved
30    }
31
32    /// Everything that was left behind, and why: a file whose new place was taken, a symbolic
33    /// link, a file that could not be read or copied, a folder that could not be listed.
34    /// Warnings for what was left on purpose, errors for what failed.
35    #[must_use]
36    pub fn diagnostics(&self) -> &[Diagnostic] {
37        &self.diagnostics
38    }
39
40    /// Whether nothing was left behind: every file found was moved, or there was nothing to move.
41    #[must_use]
42    pub fn is_clean(&self) -> bool {
43        self.diagnostics.is_empty()
44    }
45
46    /// The report when the platform gives the family no folder to move into.
47    pub(super) fn without_folder() -> Self {
48        let mut report = Self::default();
49        report.diagnostics.push(Diagnostic::warning(None, "no config directory found; nothing is adopted"));
50        report
51    }
52
53    fn warn(&mut self, path: &Path, message: impl std::fmt::Display) {
54        self.diagnostics.push(Diagnostic::warning(None, format!("{}: {message}", path.display())));
55    }
56
57    fn fail(&mut self, path: &Path, message: impl std::fmt::Display) {
58        self.diagnostics.push(Diagnostic::error(None, format!("{}: {message}", path.display())));
59    }
60}
61
62/// Moves `legacy/settings.toml` to `app_file` and every other file under `legacy` to the same
63/// place under `app_dir`, then removes the old folders left empty.
64pub(super) fn adopt(legacy: &Path, app_file: &Path, app_dir: &Path) -> Migration {
65    let mut report = Migration::default();
66    match fs::symlink_metadata(legacy) {
67        Err(error) if error.kind() == io::ErrorKind::NotFound => return report,
68        Err(error) => {
69            report.fail(legacy, format_args!("could not be read ({error}); nothing is adopted"));
70            return report;
71        }
72        Ok(meta) if meta.file_type().is_symlink() => {
73            report.warn(legacy, "a symbolic link is not followed; nothing is adopted from it");
74            return report;
75        }
76        Ok(meta) if !meta.is_dir() => {
77            report.warn(legacy, "not a folder; nothing is adopted from it");
78            return report;
79        }
80        Ok(_) => {}
81    }
82    let (old, new) = (real(legacy), real(app_dir));
83    if old == new {
84        // The application's folder already: its other files are in place, only the settings move.
85        let settings = legacy.join(FILE_NAME);
86        match fs::symlink_metadata(&settings) {
87            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
88            Err(error) => report.fail(&settings, format_args!("could not be read ({error}); it stays where it is")),
89            Ok(meta) => adopt_entry(&settings, meta.file_type(), app_file, &mut report),
90        }
91        return report;
92    }
93    if new.starts_with(&old) || old.starts_with(&new) {
94        // Moving a folder into itself, or out of itself, would walk files it has just moved.
95        report.warn(legacy, format_args!("overlaps {}; nothing is adopted", app_dir.display()));
96        return report;
97    }
98    adopt_folder(legacy, Path::new(""), app_file, app_dir, &mut report);
99    remove_if_empty(legacy, &mut report);
100    report
101}
102
103/// `path` with every link on the way resolved when it exists, so two names of one folder compare
104/// equal; `path` itself otherwise.
105fn real(path: &Path) -> PathBuf {
106    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
107}
108
109/// Moves every file under `folder`, which sits at `relative` under the legacy folder.
110fn adopt_folder(folder: &Path, relative: &Path, app_file: &Path, app_dir: &Path, report: &mut Migration) {
111    let entries = match fs::read_dir(folder) {
112        Ok(entries) => entries,
113        Err(error) => {
114            report.fail(folder, format_args!("could not be listed ({error}); what is in it stays"));
115            return;
116        }
117    };
118    let mut entries: Vec<_> = entries
119        .filter_map(|entry| match entry {
120            Ok(entry) => Some(entry),
121            Err(error) => {
122                report.fail(folder, format_args!("could not be listed completely ({error}); the rest stays"));
123                None
124            }
125        })
126        .collect();
127    // The same order on every system, so the report reads the same.
128    entries.sort_by_key(fs::DirEntry::file_name);
129    for entry in entries {
130        let path = entry.path();
131        let name = entry.file_name();
132        let kind = match entry.file_type() {
133            Ok(kind) => kind,
134            Err(error) => {
135                report.fail(&path, format_args!("could not be read ({error}); it stays where it is"));
136                continue;
137            }
138        };
139        if kind.is_dir() {
140            adopt_folder(&path, &relative.join(&name), app_file, app_dir, report);
141            remove_if_empty(&path, report);
142            continue;
143        }
144        let target = if relative.as_os_str().is_empty() && name == FILE_NAME {
145            app_file.to_path_buf()
146        } else {
147            app_dir.join(relative).join(&name)
148        };
149        adopt_entry(&path, kind, &target, report);
150    }
151}
152
153/// Moves the entry at `from`, of type `kind`, to `to` when it is a plain file; reports it otherwise.
154fn adopt_entry(from: &Path, kind: fs::FileType, to: &Path, report: &mut Migration) {
155    if kind.is_symlink() {
156        report.warn(from, "a symbolic link is not moved; it stays where it is");
157    } else if !kind.is_file() {
158        report.warn(from, "not a regular file; it stays where it is");
159    } else if move_file(from, to, report) {
160        report.moved.push((from.to_path_buf(), to.to_path_buf()));
161    }
162}
163
164/// Moves one plain file, copying before removing. Returns whether it moved; when it did not, the
165/// reason is in `report` and the old file is where it was.
166fn move_file(from: &Path, to: &Path, report: &mut Migration) -> bool {
167    if fs::symlink_metadata(to).is_ok() {
168        report.warn(from, format_args!("{} already exists; this file stays and nothing is merged", to.display()));
169        return false;
170    }
171    let (contents, permissions) = match fs::read(from).and_then(|contents| Ok((contents, fs::metadata(from)?))) {
172        Ok((contents, meta)) => (contents, meta.permissions()),
173        Err(error) => {
174            report.fail(from, format_args!("could not be read ({error}); it stays where it is"));
175            return false;
176        }
177    };
178    match claim(to, &permissions) {
179        Ok(()) => {}
180        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
181            report.warn(from, format_args!("{} already exists; this file stays and nothing is merged", to.display()));
182            return false;
183        }
184        Err(error) => {
185            report.fail(from, format_args!("could not be copied to {} ({error}); it stays where it is", to.display()));
186            return false;
187        }
188    }
189    let copied = (|| {
190        atomic_write(to, &contents)?;
191        fs::set_permissions(to, permissions)?;
192        if fs::read(to)? != contents {
193            return Err(io::Error::other("the copy reads back different"));
194        }
195        Ok(())
196    })();
197    if let Err(error) = copied {
198        // The new name was claimed by this call, so what is there is this call's own copy.
199        let _ = fs::remove_file(to);
200        report.fail(from, format_args!("could not be copied to {} ({error}); it stays where it is", to.display()));
201        return false;
202    }
203    if let Err(error) = fs::remove_file(from) {
204        report.fail(from, format_args!("copied to {} but could not be removed ({error}); both stay", to.display()));
205        return false;
206    }
207    true
208}
209
210/// Creates the empty file `to`, failing when anything is already there, so no other file can
211/// take the name between the check and the write and nothing is ever overwritten. On Unix it is
212/// created with the permissions of the file it is a copy of, so a private file is never readable
213/// by others, not even for the moment before they are set.
214fn claim(to: &Path, permissions: &fs::Permissions) -> io::Result<()> {
215    if let Some(parent) = to.parent() {
216        fs::create_dir_all(parent)?;
217    }
218    let mut options = fs::OpenOptions::new();
219    options.write(true).create_new(true);
220    #[cfg(unix)]
221    {
222        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
223        options.mode(permissions.mode());
224    }
225    // No error is being dropped here: where a file has no mode there is nothing to copy from
226    // the old permissions, and the parameter would otherwise be an unused one.
227    #[cfg(not(unix))]
228    let _ = permissions;
229    options.open(to).map(drop)
230}
231
232/// Removes `folder` when nothing is left in it.
233fn remove_if_empty(folder: &Path, report: &mut Migration) {
234    let empty = fs::read_dir(folder).is_ok_and(|mut entries| entries.next().is_none());
235    if empty && let Err(error) = fs::remove_dir(folder) {
236        report.warn(folder, format_args!("is empty but could not be removed ({error})"));
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    /// A fresh folder of this test's own under the temporary folder.
245    fn temp_dir(name: &str) -> PathBuf {
246        let dir = std::env::temp_dir().join(format!("quvyta-migrate-{name}-{}", std::process::id()));
247        let _ = fs::remove_dir_all(&dir);
248        fs::create_dir_all(&dir).expect("a temporary folder");
249        dir
250    }
251
252    fn write(path: &Path, text: &str) {
253        fs::create_dir_all(path.parent().expect("a folder")).expect("folders");
254        fs::write(path, text).expect("written");
255    }
256
257    fn read(path: &Path) -> String {
258        fs::read_to_string(path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
259    }
260
261    /// The legacy folder and the family's places for the application `packages`.
262    struct Layout {
263        root: PathBuf,
264        legacy: PathBuf,
265        file: PathBuf,
266        dir: PathBuf,
267    }
268
269    impl Layout {
270        fn new(name: &str) -> Self {
271            let root = temp_dir(name);
272            let legacy = root.join("quvyta-packages");
273            let family = root.join("quvyta");
274            Self { file: family.join("packages.conf"), dir: family.join("packages"), legacy, root }
275        }
276
277        fn adopt(&self) -> Migration {
278            adopt(&self.legacy, &self.file, &self.dir)
279        }
280    }
281
282    impl Drop for Layout {
283        fn drop(&mut self) {
284            let _ = fs::remove_dir_all(&self.root);
285        }
286    }
287
288    #[test]
289    fn settings_become_the_app_file_and_the_rest_moves_under_the_app_folder() {
290        let layout = Layout::new("plain");
291        write(&layout.legacy.join("settings.toml"), "theme = \"iris\"\n");
292        write(&layout.legacy.join("history.toml"), "one\n");
293        write(&layout.legacy.join("profiles/work/settings.toml"), "nested\n");
294
295        let report = layout.adopt();
296        assert!(report.is_clean(), "{:?}", report.diagnostics());
297        assert_eq!(read(&layout.file), "theme = \"iris\"\n");
298        assert_eq!(read(&layout.dir.join("history.toml")), "one\n");
299        assert_eq!(read(&layout.dir.join("profiles/work/settings.toml")), "nested\n", "only the top file is special");
300        assert_eq!(report.moved().len(), 3);
301        assert!(report.moved().contains(&(layout.legacy.join("settings.toml"), layout.file.clone())));
302        assert!(!layout.legacy.exists(), "the emptied old folders are gone");
303    }
304
305    #[test]
306    fn a_second_run_finds_nothing_and_changes_nothing() {
307        let layout = Layout::new("twice");
308        write(&layout.legacy.join("settings.toml"), "a = 1\n");
309        assert_eq!(layout.adopt().moved().len(), 1);
310        let again = layout.adopt();
311        assert_eq!(again, Migration::default());
312        assert_eq!(read(&layout.file), "a = 1\n");
313
314        let missing = adopt(&layout.root.join("never-there"), &layout.file, &layout.dir);
315        assert_eq!(missing, Migration::default(), "a missing old folder is no problem");
316    }
317
318    #[test]
319    fn when_the_old_folder_is_the_app_folder_only_the_settings_move() {
320        let root = temp_dir("in-place");
321        let dir = root.join("quvyta").join("focus");
322        let file = root.join("quvyta").join("focus.conf");
323        write(&dir.join("settings.toml"), "slide = true\n");
324        write(&dir.join("blocks.toml"), "blocks\n");
325
326        let report = adopt(&dir, &file, &dir);
327        assert!(report.is_clean(), "{:?}", report.diagnostics());
328        assert_eq!(report.moved(), &[(dir.join("settings.toml"), file.clone())]);
329        assert_eq!(read(&file), "slide = true\n");
330        assert_eq!(read(&dir.join("blocks.toml")), "blocks\n", "the other files are already in place");
331        assert!(dir.is_dir(), "the application's folder stays");
332
333        // Once the settings are gone the folder is the application's own, and nothing moves.
334        assert_eq!(adopt(&dir, &file, &dir), Migration::default());
335        // An empty application folder is not removed either.
336        fs::remove_file(dir.join("blocks.toml")).expect("remove");
337        assert_eq!(adopt(&dir, &file, &dir), Migration::default());
338        assert!(dir.is_dir());
339        fs::remove_dir_all(&root).expect("clean");
340    }
341
342    #[test]
343    fn a_taken_place_keeps_both_files() {
344        let layout = Layout::new("conflict");
345        write(&layout.legacy.join("settings.toml"), "old\n");
346        write(&layout.legacy.join("notes.txt"), "moves\n");
347        write(&layout.file, "new\n");
348
349        let report = layout.adopt();
350        assert_eq!(read(&layout.file), "new\n", "never overwritten");
351        assert_eq!(read(&layout.legacy.join("settings.toml")), "old\n", "never lost");
352        assert_eq!(read(&layout.dir.join("notes.txt")), "moves\n", "the rest still moves");
353        assert_eq!(report.diagnostics().len(), 1, "{:?}", report.diagnostics());
354        let message = &report.diagnostics()[0].message;
355        assert!(message.contains("settings.toml") && message.contains("already exists"), "{message}");
356        assert!(layout.legacy.is_dir(), "a folder with a file left in it stays");
357
358        // Nothing changes on the next run either: the same conflict, reported again.
359        let again = layout.adopt();
360        assert!(again.moved().is_empty());
361        assert_eq!(again.diagnostics(), report.diagnostics());
362    }
363
364    #[cfg(unix)]
365    #[test]
366    fn a_symbolic_link_is_left_alone() {
367        let layout = Layout::new("link");
368        write(&layout.root.join("dotfiles/settings.toml"), "linked\n");
369        fs::create_dir_all(&layout.legacy).expect("folder");
370        std::os::unix::fs::symlink(layout.root.join("dotfiles/settings.toml"), layout.legacy.join("settings.toml"))
371            .expect("link");
372
373        let report = layout.adopt();
374        assert!(report.moved().is_empty());
375        assert!(report.diagnostics()[0].message.contains("symbolic link"), "{:?}", report.diagnostics());
376        assert!(fs::symlink_metadata(layout.legacy.join("settings.toml")).expect("still there").is_symlink());
377        assert!(!layout.file.exists());
378        assert_eq!(read(&layout.root.join("dotfiles/settings.toml")), "linked\n");
379
380        // A linked old folder is not followed either.
381        let linked = layout.root.join("linked-folder");
382        std::os::unix::fs::symlink(&layout.legacy, &linked).expect("link");
383        let report = adopt(&linked, &layout.file, &layout.dir);
384        assert!(report.moved().is_empty() && !report.is_clean());
385    }
386
387    #[cfg(unix)]
388    #[test]
389    fn permissions_come_along() {
390        use std::os::unix::fs::PermissionsExt as _;
391        let layout = Layout::new("mode");
392        let secret = layout.legacy.join("tokens/api.toml");
393        write(&secret, "token = \"s\"\n");
394        fs::set_permissions(&secret, fs::Permissions::from_mode(0o600)).expect("private");
395        write(&layout.legacy.join("settings.toml"), "a = 1\n");
396        fs::set_permissions(layout.legacy.join("settings.toml"), fs::Permissions::from_mode(0o640)).expect("mode");
397
398        assert!(layout.adopt().is_clean());
399        let mode = |path: &Path| fs::metadata(path).expect("moved").permissions().mode() & 0o777;
400        assert_eq!(mode(&layout.dir.join("tokens/api.toml")), 0o600);
401        assert_eq!(mode(&layout.file), 0o640);
402    }
403
404    #[cfg(unix)]
405    #[test]
406    fn an_unreadable_file_stays_and_is_reported() {
407        use std::os::unix::fs::PermissionsExt as _;
408        let layout = Layout::new("unreadable");
409        let locked = layout.legacy.join("locked.toml");
410        write(&locked, "x\n");
411        write(&layout.legacy.join("settings.toml"), "a = 1\n");
412        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("lock");
413        if fs::read(&locked).is_ok() {
414            // Running as root: every file is readable, so there is nothing to see here.
415            return;
416        }
417
418        let report = layout.adopt();
419        assert_eq!(report.moved().len(), 1, "the readable file still moves");
420        assert_eq!(report.diagnostics().len(), 1, "{:?}", report.diagnostics());
421        assert!(report.diagnostics()[0].message.contains("could not be read"), "{:?}", report.diagnostics());
422        assert!(locked.exists() && !layout.dir.join("locked.toml").exists(), "the file stays, no copy is left");
423        assert!(layout.legacy.is_dir());
424        fs::set_permissions(&locked, fs::Permissions::from_mode(0o600)).expect("unlock for cleanup");
425    }
426
427    #[test]
428    fn folders_that_hold_each_other_are_refused() {
429        let root = temp_dir("overlap");
430        let family = root.join("quvyta");
431        write(&family.join("settings.toml"), "a = 1\n");
432        let report = adopt(&family, &family.join("code.conf"), &family.join("code"));
433        assert!(report.moved().is_empty());
434        assert!(report.diagnostics()[0].message.contains("overlaps"), "{:?}", report.diagnostics());
435        assert_eq!(read(&family.join("settings.toml")), "a = 1\n");
436        fs::remove_dir_all(&root).expect("clean");
437    }
438
439    #[test]
440    fn an_old_file_in_place_of_a_folder_is_reported() {
441        let root = temp_dir("not-a-folder");
442        write(&root.join("old"), "a file\n");
443        let report = adopt(&root.join("old"), &root.join("new.conf"), &root.join("new"));
444        assert!(report.diagnostics()[0].message.contains("not a folder"), "{:?}", report.diagnostics());
445        assert_eq!(read(&root.join("old")), "a file\n");
446        fs::remove_dir_all(&root).expect("clean");
447    }
448}