1use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use tokio::sync::mpsc;
8use tokio_util::sync::CancellationToken;
9
10use crate::fs::{CANCELLED, rename_noreplace, same_object};
11use crate::progress::{OperationComplete, OperationProgress, OperationType};
12use crate::{OPERATION_CHANNEL_SIZE, OperationError};
13
14#[derive(Debug)]
16pub enum RenameResult {
17 Progress(OperationProgress),
18 Complete(OperationComplete),
19}
20
21pub fn start_rename(source: PathBuf, new_name: String) -> mpsc::Receiver<RenameResult> {
23 let (tx, rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
24 tokio::spawn(async move {
25 let mut progress = OperationProgress::new(OperationType::Rename, 1, 0);
26 progress.set_current_file(Some(source.clone()));
27 let _ = tx.try_send(RenameResult::Progress(progress));
28 let token = CancellationToken::new();
29 let work_source = source.clone();
30 let result = tokio::task::spawn_blocking(move || -> Result<bool, String> {
31 validate_filename(&new_name)?;
32 let target = crate::fs::parent_or_current(&work_source).join(&new_name);
33 if target == work_source {
34 fs::symlink_metadata(&work_source).map_err(|error| error.to_string())?;
35 return Ok(false);
36 }
37 rename_paths(&[(work_source, target)], &token)?;
38 Ok(true)
39 })
40 .await
41 .unwrap_or_else(|error| Err(format!("worker failed: {error}")));
42 let (succeeded, failed, errors) = match result {
43 Ok(changed) => (usize::from(changed), 0, Vec::new()),
44 Err(error) => (0, 1, vec![OperationError::new(source, error)]),
45 };
46 let _ = tx
47 .send(RenameResult::Complete(OperationComplete {
48 operation_type: OperationType::Rename,
49 cancelled: false,
50 succeeded,
51 failed,
52 skipped: 0,
53 bytes_processed: 0,
54 errors,
55 }))
56 .await;
57 });
58 rx
59}
60
61pub fn rename_paths(
70 renames: &[(PathBuf, PathBuf)],
71 token: &CancellationToken,
72) -> Result<(), String> {
73 let renames = validate_batch(renames)?;
74 if renames.is_empty() {
75 return Ok(());
76 }
77 let nonce = std::process::id()
78 ^ (std::time::SystemTime::now()
79 .duration_since(std::time::UNIX_EPOCH)
80 .map(|elapsed| elapsed.subsec_nanos())
81 .unwrap_or(0));
82
83 let mut staged: Vec<(PathBuf, PathBuf, PathBuf)> = Vec::with_capacity(renames.len());
85 for (index, (source, target)) in renames.into_iter().enumerate() {
86 let temporary = crate::fs::parent_or_current(&source)
87 .join(format!(".gravityfile-rename-{nonce:08x}-{index}"));
88 let result = if token.is_cancelled() {
89 Err(CANCELLED.to_string())
90 } else {
91 rename_noreplace(&source, &temporary)
92 .map_err(|error| format!("cannot rename '{}': {error}", source.display()))
93 };
94 if let Err(error) = result {
95 return Err(with_rollback(error, rollback(&staged, 0)));
96 }
97 staged.push((source, temporary, target));
98 }
99
100 for index in 0..staged.len() {
103 let (_, temporary, target) = &staged[index];
104 let result = if token.is_cancelled() {
105 Err(CANCELLED.to_string())
106 } else {
107 rename_noreplace(temporary, target)
108 .map_err(|error| format!("cannot rename to '{}': {error}", target.display()))
109 };
110 if let Err(error) = result {
111 return Err(with_rollback(error, rollback(&staged, index)));
112 }
113 }
114 Ok(())
115}
116
117fn rollback(staged: &[(PathBuf, PathBuf, PathBuf)], published: usize) -> Vec<String> {
119 let mut errors = Vec::new();
120 let mut restorable = vec![true; staged.len()];
121 for (index, (_, temporary, target)) in staged[..published].iter().enumerate().rev() {
122 if let Err(error) = rename_noreplace(target, temporary) {
123 restorable[index] = false;
124 errors.push(format!("'{}' left in place: {error}", target.display()));
125 }
126 }
127 for (index, (source, temporary, _)) in staged.iter().enumerate().rev() {
128 if restorable[index]
129 && let Err(error) = rename_noreplace(temporary, source)
130 {
131 errors.push(format!(
132 "'{}' left at '{}': {error}",
133 source.display(),
134 temporary.display()
135 ));
136 }
137 }
138 errors
139}
140
141fn with_rollback(error: String, rollback_errors: Vec<String>) -> String {
142 if rollback_errors.is_empty() {
143 error
144 } else {
145 format!(
146 "{error}; rollback incomplete: {}",
147 rollback_errors.join("; ")
148 )
149 }
150}
151
152fn validate_batch(renames: &[(PathBuf, PathBuf)]) -> Result<Vec<(PathBuf, PathBuf)>, String> {
155 let resolve = |path: &Path| -> Result<PathBuf, String> {
156 let name = path
157 .file_name()
158 .ok_or_else(|| format!("'{}' has no file name", path.display()))?;
159 let parent = dunce::canonicalize(crate::fs::parent_or_current(path)).map_err(|error| {
160 format!(
161 "cannot resolve the directory of '{}': {error}",
162 path.display()
163 )
164 })?;
165 Ok(parent.join(name))
166 };
167 let mut resolved = Vec::with_capacity(renames.len());
168 for (source, target) in renames {
169 let (source, target) = (resolve(source)?, resolve(target)?);
170 fs::symlink_metadata(&source)
171 .map_err(|error| format!("cannot access '{}': {error}", source.display()))?;
172 if source != target {
173 resolved.push((source, target));
174 }
175 }
176 let sources: HashSet<&Path> = resolved
177 .iter()
178 .map(|(source, _)| source.as_path())
179 .collect();
180 let targets: HashSet<&Path> = resolved
181 .iter()
182 .map(|(_, target)| target.as_path())
183 .collect();
184 if sources.len() != resolved.len() {
185 return Err("the same item is renamed more than once".into());
186 }
187 if targets.len() != resolved.len() {
188 return Err("two items would receive the same name".into());
189 }
190 for (source, target) in &resolved {
191 for path in [source, target] {
192 if let Some(ancestor) = path.ancestors().skip(1).find(|a| sources.contains(a)) {
193 return Err(format!(
194 "'{}' is inside '{}', which is also being renamed",
195 path.display(),
196 ancestor.display()
197 ));
198 }
199 }
200 if fs::symlink_metadata(target).is_ok()
203 && !sources.contains(target.as_path())
204 && !same_object(source, target)
205 {
206 return Err(format!("'{}' already exists", target.display()));
207 }
208 }
209 Ok(resolved)
210}
211
212pub fn validate_filename(name: &str) -> Result<(), String> {
214 if name.is_empty() {
215 return Err("Name cannot be empty".into());
216 }
217 if name.len() > 255 {
218 return Err("Name is too long (max 255 bytes)".into());
219 }
220 for c in ['/', '\0'] {
221 if name.contains(c) {
222 return Err(format!("Name cannot contain '{c}'"));
223 }
224 }
225 if name.chars().any(char::is_control) {
226 return Err("Name cannot contain control characters".into());
227 }
228 #[cfg(windows)]
229 validate_windows_name(name)?;
230 if name.starts_with(' ') || name.ends_with(' ') {
231 return Err("Name cannot start or end with spaces".into());
232 }
233 if name.ends_with('.') {
234 return Err("Name cannot end with a dot".into());
235 }
236 if name == "." || name == ".." {
237 return Err("'.' and '..' are reserved names".into());
238 }
239 Ok(())
240}
241
242#[cfg(any(windows, test))]
244fn validate_windows_name(name: &str) -> Result<(), String> {
245 for c in ['\\', ':', '*', '?', '"', '<', '>', '|'] {
246 if name.contains(c) {
247 return Err(format!("Name cannot contain '{c}'"));
248 }
249 }
250 const RESERVED: &[&str] = &[
251 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
252 "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7",
253 "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
254 ];
255 let upper = name.to_uppercase();
256 if RESERVED.contains(&upper.split('.').next().unwrap_or("")) {
257 return Err("Reserved filename".into());
258 }
259 Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn read(path: &Path) -> String {
267 fs::read_to_string(path).unwrap()
268 }
269
270 #[test]
271 fn swaps_and_cycles() {
272 let dir = tempfile::tempdir().unwrap();
273 let [a, b, c] = ["a", "b", "c"].map(|name| dir.path().join(name));
274 for path in [&a, &b, &c] {
275 fs::write(path, path.file_name().unwrap().to_str().unwrap()).unwrap();
276 }
277 rename_paths(
278 &[
279 (a.clone(), b.clone()),
280 (b.clone(), c.clone()),
281 (c.clone(), a.clone()),
282 ],
283 &CancellationToken::new(),
284 )
285 .unwrap();
286 assert_eq!(
287 (read(&a), read(&b), read(&c)),
288 ("c".into(), "a".into(), "b".into())
289 );
290 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 3);
291 }
292
293 #[test]
294 fn external_conflicts_change_nothing() {
295 let dir = tempfile::tempdir().unwrap();
296 let [a, b, taken] = ["a", "b", "taken"].map(|name| dir.path().join(name));
297 for path in [&a, &b, &taken] {
298 fs::write(path, "x").unwrap();
299 }
300 let error = rename_paths(
301 &[
302 (a.clone(), dir.path().join("a2")),
303 (b.clone(), taken.clone()),
304 ],
305 &CancellationToken::new(),
306 )
307 .unwrap_err();
308 assert!(error.contains("already exists"), "{error}");
309 assert!(a.exists() && b.exists() && !dir.path().join("a2").exists());
310 }
311
312 #[test]
313 fn duplicate_and_nested_entries_are_rejected() {
314 let dir = tempfile::tempdir().unwrap();
315 let parent = dir.path().join("p");
316 fs::create_dir(&parent).unwrap();
317 fs::write(parent.join("child"), "x").unwrap();
318 let token = CancellationToken::new();
319 assert!(
320 rename_paths(
321 &[
322 (parent.clone(), dir.path().join("q")),
323 (parent.join("child"), parent.join("c2"))
324 ],
325 &token
326 )
327 .unwrap_err()
328 .contains("inside")
329 );
330 let file = parent.join("child");
331 assert!(
332 rename_paths(
333 &[(file.clone(), parent.join("x")), (file, parent.join("y"))],
334 &token
335 )
336 .unwrap_err()
337 .contains("more than once")
338 );
339 }
340
341 #[test]
342 fn cancellation_restores_every_source() {
343 let dir = tempfile::tempdir().unwrap();
344 let a = dir.path().join("a");
345 fs::write(&a, "a").unwrap();
346 let token = CancellationToken::new();
347 token.cancel();
348 assert!(rename_paths(&[(a.clone(), dir.path().join("b"))], &token).is_err());
349 assert_eq!(read(&a), "a");
350 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
351 }
352
353 #[test]
354 fn case_only_rename() {
355 let dir = tempfile::tempdir().unwrap();
356 let upper = dir.path().join("Readme.md");
357 fs::write(&upper, "x").unwrap();
358 rename_paths(
359 &[(upper, dir.path().join("README.md"))],
360 &CancellationToken::new(),
361 )
362 .unwrap();
363 let names: Vec<_> = fs::read_dir(dir.path())
364 .unwrap()
365 .map(|entry| entry.unwrap().file_name())
366 .collect();
367 assert_eq!(names, vec![std::ffi::OsString::from("README.md")]);
368 }
369
370 #[cfg(unix)]
371 #[test]
372 fn renames_symlinks_not_their_targets() {
373 let dir = tempfile::tempdir().unwrap();
374 fs::write(dir.path().join("target"), "t").unwrap();
375 let link = dir.path().join("link");
376 std::os::unix::fs::symlink("target", &link).unwrap();
377 rename_paths(
378 &[(link, dir.path().join("renamed"))],
379 &CancellationToken::new(),
380 )
381 .unwrap();
382 assert!(
383 fs::symlink_metadata(dir.path().join("renamed"))
384 .unwrap()
385 .is_symlink()
386 );
387 assert!(dir.path().join("target").is_file());
388 }
389
390 #[tokio::test]
391 async fn start_rename_reports_conflicts_and_no_ops() {
392 let dir = tempfile::tempdir().unwrap();
393 let a = dir.path().join("a");
394 fs::write(&a, "a").unwrap();
395 fs::write(dir.path().join("b"), "b").unwrap();
396 let complete = |mut rx: mpsc::Receiver<RenameResult>| async move {
397 loop {
398 if let Some(RenameResult::Complete(complete)) = rx.recv().await {
399 return complete;
400 }
401 }
402 };
403 let result = complete(start_rename(a.clone(), "b".into())).await;
404 assert_eq!(result.failed, 1);
405 let result = complete(start_rename(a.clone(), "a".into())).await;
406 assert_eq!((result.succeeded, result.failed), (0, 0));
407 let result = complete(start_rename(a, "c".into())).await;
408 assert_eq!(result.succeeded, 1);
409 assert!(dir.path().join("c").exists());
410 }
411
412 #[test]
413 fn filename_validation() {
414 for valid in ["file.txt", "my file", ".hidden", "日本語"] {
415 assert!(validate_filename(valid).is_ok(), "{valid}");
416 }
417 for invalid in [
418 "", ".", "..", "a/b", "a\0b", " lead", "trail ", "dot.", "tab\t",
419 ] {
420 assert!(validate_filename(invalid).is_err(), "{invalid:?}");
421 }
422 assert!(validate_filename(&"x".repeat(256)).is_err());
423 for reserved in ["CON", "con.txt", "COM¹", "LPT9.log", "a:b"] {
424 assert!(validate_windows_name(reserved).is_err(), "{reserved}");
425 }
426 }
427}