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
9pub fn replace_directory(source_dir: &Path, target_dir: &Path) -> BoxedResult<()> {
15 replace_directory_with_rename(source_dir, target_dir, rename_path)
16}
17
18pub 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 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 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 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}