Skip to main content

gravityfile_ops/
rename.rs

1//! Single and bulk rename.
2
3use 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/// Message sent while a rename runs.
15#[derive(Debug)]
16pub enum RenameResult {
17    Progress(OperationProgress),
18    Complete(OperationComplete),
19}
20
21/// Rename `source` to `new_name` within its directory.
22pub 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
61/// Rename a set of paths as one unit, including swaps (`a→b`, `b→a`), cycles
62/// and case-only changes on case-insensitive filesystems.
63///
64/// Every source is first moved to a private temporary name in its own
65/// directory, then each temporary is published at its target, both without
66/// replacing anything. A failure or cancellation before the last publication
67/// moves everything back. Targets must be in existing directories; a target
68/// that exists and is not itself being renamed is rejected up front.
69pub 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    // Phase 1: sources to temporary names.
84    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    // Phase 2: temporaries to targets. Cancellation is honored until the
101    // final publication, which commits the batch.
102    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
117/// Undo `published` phase-2 renames, then every phase-1 rename.
118fn 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
152/// Resolve parents (not leaves: renaming a symlink renames the link), drop
153/// no-op entries and reject duplicates, nesting and external conflicts.
154fn 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        // A case-only rename finds its own source at the target on
201        // case-insensitive filesystems; that is not a conflict.
202        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
212/// Validate a new file name against portable rules.
213pub 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/// Win32 naming rules, host-testable on every platform.
243#[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}