winbrew_core\fs/
move_or_copy.rs

1use super::FsError;
2use super::cleanup::{cleanup_path, inspect_path as inspect_cleanup_path};
3use std::fs;
4use std::io::{self, ErrorKind};
5use std::path::{Path, PathBuf};
6
7type BoxedResult<T> = std::result::Result<T, Box<FsError>>;
8
9/// Replaces `source_dir` with `target_dir`, copying across volumes when rename
10/// is not available and rolling back the backup on failure.
11///
12/// On Windows, cross-volume rename failures fall back to copy + cleanup instead
13/// of failing the install outright.
14pub fn replace_directory(source_dir: &Path, target_dir: &Path) -> BoxedResult<()> {
15    replace_directory_with_rename(source_dir, target_dir, rename_path)
16}
17
18/// Returns the sibling `.old` backup path used during directory replacement.
19pub fn backup_path_for(target_dir: &Path) -> PathBuf {
20    let parent = target_dir.parent().unwrap_or(target_dir);
21    let name = target_dir
22        .file_name()
23        .map(|value| value.to_string_lossy())
24        .unwrap_or_default();
25
26    parent.join(format!("{name}.old"))
27}
28
29fn replace_directory_with_rename<R>(
30    source_dir: &Path,
31    target_dir: &Path,
32    rename: R,
33) -> BoxedResult<()>
34where
35    R: Fn(&Path, &Path) -> std::io::Result<()>,
36{
37    let backup_dir = backup_path_for(target_dir);
38    cleanup_path(&backup_dir)?;
39
40    match rename(source_dir, target_dir) {
41        Ok(()) => Ok(()),
42        Err(err) if is_cross_device_error(&err) => match rename(target_dir, &backup_dir) {
43            Ok(()) => finish_replacement_after_backup(source_dir, target_dir, &backup_dir, rename),
44            Err(rename_err) if rename_err.kind() == ErrorKind::NotFound => {
45                // Cross-device fallback copies the staged directory tree after the
46                // original target has been moved aside.
47                copy_dir_all(source_dir, target_dir).map_err(|copy_err| {
48                    Box::new(FsError::copy_across_volumes(
49                        source_dir, target_dir, copy_err,
50                    ))
51                })?;
52
53                let _ = cleanup_path(source_dir);
54
55                Ok(())
56            }
57            Err(rename_err) => Err(Box::new(FsError::move_aside(
58                target_dir,
59                &backup_dir,
60                rename_err,
61            ))),
62        },
63        Err(err) if is_target_conflict_error(&err) => match rename(target_dir, &backup_dir) {
64            Ok(()) => finish_replacement_after_backup(source_dir, target_dir, &backup_dir, rename),
65            Err(rename_err) if rename_err.kind() == ErrorKind::NotFound => Err(Box::new(
66                FsError::move_into_place(source_dir, target_dir, err),
67            )),
68            Err(rename_err) => Err(Box::new(FsError::move_aside(
69                target_dir,
70                &backup_dir,
71                rename_err,
72            ))),
73        },
74        Err(err) => Err(Box::new(FsError::move_into_place(
75            source_dir, target_dir, err,
76        ))),
77    }
78}
79
80fn finish_replacement_after_backup<R>(
81    source_dir: &Path,
82    target_dir: &Path,
83    backup_dir: &Path,
84    rename: R,
85) -> BoxedResult<()>
86where
87    R: Fn(&Path, &Path) -> std::io::Result<()>,
88{
89    match rename(source_dir, target_dir) {
90        Ok(()) => {
91            let _ = cleanup_path(backup_dir);
92            Ok(())
93        }
94        Err(err) if is_cross_device_error(&err) => {
95            if let Err(copy_err) = copy_dir_all(source_dir, target_dir) {
96                let _ = cleanup_path(target_dir);
97
98                if let Err(rollback_err) = rename(backup_dir, target_dir) {
99                    return Err(Box::new(FsError::rollback_failed(
100                        "failed to copy staged installation across volumes",
101                        source_dir,
102                        target_dir,
103                        copy_err,
104                        rollback_err,
105                    )));
106                }
107
108                let _ = cleanup_path(source_dir);
109
110                return Err(Box::new(FsError::copy_across_volumes(
111                    source_dir, target_dir, copy_err,
112                )));
113            }
114
115            let _ = cleanup_path(source_dir);
116            let _ = cleanup_path(backup_dir);
117
118            Ok(())
119        }
120        Err(err) => {
121            if let Err(rollback_err) = rename(backup_dir, target_dir) {
122                return Err(Box::new(FsError::rollback_failed(
123                    "failed to move staged installation into place",
124                    source_dir,
125                    target_dir,
126                    err,
127                    rollback_err,
128                )));
129            }
130
131            Err(Box::new(FsError::move_into_place(
132                source_dir, target_dir, err,
133            )))
134        }
135    }
136}
137
138fn rename_path(from: &Path, to: &Path) -> std::io::Result<()> {
139    fs::rename(from, to)
140}
141
142fn copy_dir_all(source_dir: &Path, target_dir: &Path) -> BoxedResult<()> {
143    fs::create_dir_all(target_dir)
144        .map_err(|err| Box::new(FsError::create_directory(target_dir, err)))?;
145
146    for entry in fs::read_dir(source_dir)
147        .map_err(|err| Box::new(FsError::read_directory(source_dir, err)))?
148    {
149        let entry =
150            entry.map_err(|err| Box::new(FsError::read_directory_entry(source_dir, err)))?;
151        let source_path = entry.path();
152        let target_path = target_dir.join(entry.file_name());
153
154        // `file_type()` tells us file vs directory, but the path inspection is
155        // still needed to reject Windows reparse points explicitly.
156        let path_info = inspect_cleanup_path(&source_path)
157            .map_err(|err| Box::new(FsError::inspect(&source_path, err)))?;
158        let file_type = entry
159            .file_type()
160            .map_err(|err| Box::new(FsError::inspect(&source_path, err)))?;
161
162        if path_info.is_reparse_point {
163            return Err(Box::new(FsError::copy_symlink(&source_path)));
164        } else if file_type.is_dir() {
165            copy_dir_all(&source_path, &target_path)?;
166        } else if file_type.is_file() {
167            fs::copy(&source_path, &target_path)
168                .map_err(|err| Box::new(FsError::copy_file(&source_path, &target_path, err)))?;
169        } else if file_type.is_symlink() {
170            return Err(Box::new(FsError::copy_symlink(&source_path)));
171        } else {
172            return Err(Box::new(FsError::unsupported_entry(&source_path)));
173        }
174    }
175
176    Ok(())
177}
178
179fn is_cross_device_error(err: &std::io::Error) -> bool {
180    matches!(err.kind(), ErrorKind::CrossesDevices)
181}
182
183#[cfg(windows)]
184fn is_target_conflict_error(err: &io::Error) -> bool {
185    // Windows may report a target that already exists or is otherwise busy as
186    // PermissionDenied, so treat both as a backup-and-retry conflict.
187    matches!(
188        err.kind(),
189        ErrorKind::AlreadyExists | ErrorKind::PermissionDenied
190    )
191}
192
193#[cfg(not(windows))]
194fn is_target_conflict_error(err: &io::Error) -> bool {
195    matches!(err.kind(), ErrorKind::AlreadyExists)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{backup_path_for, replace_directory_with_rename};
201    use std::fs;
202    use std::io::{self, ErrorKind};
203    use tempfile::tempdir;
204
205    fn cross_device_error() -> io::Error {
206        io::Error::new(ErrorKind::CrossesDevices, "simulated cross-device error")
207    }
208
209    #[test]
210    fn backup_path_for_appends_old_suffix_next_to_target() {
211        let path = std::path::Path::new(r"C:\pkg\tool.exe");
212        assert_eq!(
213            backup_path_for(path),
214            std::path::Path::new(r"C:\pkg\tool.exe.old")
215        );
216    }
217
218    #[test]
219    fn replace_directory_copies_across_volumes_when_rename_fails() {
220        let temp_dir = tempdir().expect("temp dir");
221        let source_dir = temp_dir.path().join("source");
222        let target_dir = temp_dir.path().join("target");
223
224        fs::create_dir_all(&source_dir).expect("source dir");
225        fs::write(source_dir.join("payload.txt"), b"copied payload").expect("source file");
226
227        let result = replace_directory_with_rename(&source_dir, &target_dir, |from, to| {
228            if from == source_dir.as_path() && to == target_dir.as_path() {
229                Err(cross_device_error())
230            } else {
231                fs::rename(from, to)
232            }
233        });
234
235        result.expect("cross-volume replacement");
236        assert_eq!(
237            fs::read_to_string(target_dir.join("payload.txt")).expect("copied payload"),
238            "copied payload"
239        );
240        assert!(!source_dir.exists());
241    }
242
243    #[test]
244    fn replace_directory_restores_backup_on_failure() {
245        let temp_dir = tempdir().expect("temp dir");
246        let source_dir = temp_dir.path().join("source");
247        let target_dir = temp_dir.path().join("target");
248        let backup_dir = backup_path_for(&target_dir);
249
250        fs::create_dir_all(&source_dir).expect("source dir");
251        fs::create_dir_all(&target_dir).expect("target dir");
252        fs::write(source_dir.join("new.txt"), b"new").expect("source file");
253        fs::write(target_dir.join("old.txt"), b"old").expect("target file");
254
255        let result = replace_directory_with_rename(&source_dir, &target_dir, |from, to| {
256            if from == source_dir.as_path() && to == target_dir.as_path() {
257                Err(io::Error::new(
258                    ErrorKind::PermissionDenied,
259                    "simulated failure",
260                ))
261            } else {
262                fs::rename(from, to)
263            }
264        });
265
266        assert!(result.is_err());
267        assert_eq!(
268            fs::read_to_string(target_dir.join("old.txt")).expect("restored"),
269            "old"
270        );
271        assert_eq!(
272            fs::read_to_string(source_dir.join("new.txt")).expect("source kept"),
273            "new"
274        );
275        assert!(!backup_dir.exists());
276    }
277
278    #[test]
279    fn replace_directory_reports_rollback_failure() {
280        let temp_dir = tempdir().expect("temp dir");
281        let source_dir = temp_dir.path().join("source");
282        let target_dir = temp_dir.path().join("target");
283        let backup_dir = backup_path_for(&target_dir);
284
285        fs::create_dir_all(&source_dir).expect("source dir");
286        fs::create_dir_all(&target_dir).expect("target dir");
287
288        let result = replace_directory_with_rename(&source_dir, &target_dir, |from, to| {
289            if (from == source_dir.as_path() && to == target_dir.as_path())
290                || (from == backup_dir.as_path() && to == target_dir.as_path())
291            {
292                Err(io::Error::new(
293                    ErrorKind::PermissionDenied,
294                    "simulated failure",
295                ))
296            } else {
297                fs::rename(from, to)
298            }
299        });
300
301        let error = result.expect_err("expected rollback failure");
302        assert!(error.to_string().contains("rollback also failed"));
303        assert!(backup_dir.exists());
304        assert!(!target_dir.exists());
305    }
306}