Repository: uutils/coreutils (2025-08-30T03:22:29Z)

Issue:
mv: Behavior different from gnu when moving directory symbolic links (nixos-rebuild switch fails)
Hey, (as we were talking on #help on Discord) I think we might actually have a bug here.  (edit: well, we do - it was reproduced)


When I try rebuilding switch my NixOS system, even using the gnu ln (due to #6350) I still stumble on that mv error making the switch fail. See: https://github.com/LuNeder/nixos-config/blob/62092e522557eebe13946f5fa5a4b16b53faa6e0/new-new-log-file

This ```mv: cannot move '/run/wrappers/bin-tmp' to '/run/wrappers/bin': Directory not empty``` error does not happens when using the gnu mv. Hardcoding the script to use gnu mv makes the nixos-rebuild switch successful! See: https://github.com/LuNeder/nixos-config/blob/324f1bc4d3180e8b6485a765c1b8c21360972c99/log-file-but-fixed-maybe


I once again made those scripts print the stat (and ls) of the stuff it’s messing with, before and after the mv command (uutils only has the before, because mv fails).
  - Uutils: https://github.com/LuNeder/nixos-config/blob/62092e522557eebe13946f5fa5a4b16b53faa6e0/log-stat-mv
  - Gnu: https://github.com/LuNeder/nixos-config/blob/324f1bc4d3180e8b6485a765c1b8c21360972c99/log-stat-mv-gnu


If it helps with the troubleshooting, here’s the complete script in which the problematic mv is located: https://github.com/LuNeder/nixos-config/blob/62092e522557eebe13946f5fa5a4b16b53faa6e0/suid-strt-troubleshoot#L168

Additionally, just like on the 6350 case, both /run/… paths are symbolic links to directories. And just like on that case, this problem does not happens at boot (when /run/wrappers/bin probably doesn’t exist yet) - it only happens when switching on a booted system (when that path does exist).




Discussion:
This affects any `mv -T [anything] symlink-to-dir` operation.
This affects any `mv -T symlink-to-dir symlink-to-another-dir` operation. 
(`mv -T [dir] symlink-to-another-dir` fails, but that's expected behaviour!)  
`mv -T some-file symlink-to-another-dir` also works on gnu, but fails on UUtils. 

Reproducer as requested by Sylvestre at Discord:

### Case 1: replace link to dir with link to dir. (Bug: fails on uutils, works on gnu)

This one is what breaks `nixos-rebuild switch` and the issue I mentioned when I first opened this issue.

`reproduce.sh`:

```sh
#!/bin/sh

# Prepare
mkdir a
mkdir new
touch ./a/old
touch ./new/new

ln -s ./a ./link
ln -s ./new ./newlink

echo Before mv, ls should print 'old'
ls ./link

# Run bug. We want to substitute link with newlink.
mv --version
echo Trying mv
mv -T "./newlink" "./link"
# Uutils shows an error on the above mv, gnu coreutils replaces the link.

echo ls link again, after mv. should print 'new'
ls ./link
```

![Image showing that the script being ran with uutils first, and then with gnu coreutils. Uutils with the mentioned error, while gnu coreutils successfully overrites the symbolic link.](https://github.com/user-attachments/assets/cc9329a9-3f17-4b2e-8508-4285f65dbe59)


### Case 2: replace link to dir with dir. (Shows an error on both uutils and gnu, **as expected**, but uutils may be failing for the wrong reason!)

When fixing this bug, be careful not to make a new bug tho. Replacing a link with another link should work, as shown above, but replacing a link with a directory should still error!

`reproduce2.sh`:

```sh
#!/bin/sh

# Prepare
mkdir a
mkdir new
touch ./a/old
touch ./new/new

ln -s ./a ./link

echo Before mv, ls should print 'old'
ls ./link

# Run mv. We want to substitute link with the new directory (making it no longer a link). This should fail no matter the coreutils!
mv --version
echo "Trying mv, should error (cannot overwrite non-directory './link' with directory './new') "
mv -T "./new" "./link"
# Uutils shows an error on the above mv, gnu coreutils also errors but for a different (opposite!) reason.

echo 'ls link again, after mv. should print 'old' on both {u, core}utils'
ls ./link
```

![Image showing that both uutils and gnu coreutils show errors when trying to replace a link with a directory](https://github.com/user-attachments/assets/af1666bc-5ecd-41ef-95f6-1a0619b85852)

### Case 3: Replacing link to a dir with a regular file. (Bug: fails on uutils, works on gnu)

Replacing the link with a regular file (instead of with another link) should also work tho, according to gnu. It also fails on uutils.

`reproduce3.sh`:

```sh
#!/bin/sh

# Prepare
mkdir a
touch new
touch ./a/old

ln -s ./a ./link

echo Before mv, ls should print 'old'
ls ./link

# Run mv. We want to substitute link with the new file (making it no longer a link). This should work, at least according to gnu (if this is a good or bad thing is a whole other convo tho lmao).
mv --version
echo "Trying mv"
mv -T "./new" "./link"
# Uutils shows an error on the above mv, gnu coreutils replaces the link with the file a (renamed to 'link' but not a link).

echo "ls link again, after mv. should print './link' (which is a file)"
ls ./link
```

![Image showing the aforementioned behaviour running the above script](https://github.com/user-attachments/assets/d43d5d55-995d-456c-acbc-2d0a831d1814)

Honestly I really prefer UUtil's treatment for Case 3 (while preferring gnu's treatment for Case 1) here. But you do aim for 100% parity with gnu coreutils, so this probably needs to be 'fixed' too.

### Conclusion-ish

GNU seems to deal with symlinks to directory as regular files, while UUtils seems to deal with symlinks to directories as regular directories (this is a theory on comparing Case 1 and 3 with Case 2, as well as the opposite error messages shown by gnu and uutils on Case 2 (gnu explicitly states that it cannot substitute a non-dir with a dir, while uutils doesnt substitute it because it treats the link as a non-empty dir)).

If this theory is correct, I really think uutil's way of dealing with this is WAY better than gnu's (except for case 1 maybe). Still, you do aim for 100% compatibility and as I mentioned in the original issue description this does cause major compatibility issues, so I guess this should probably be "fixed". As far as I’m aware, gnu’s way is also more correct technically speaking (unfortunately).
For accessibility and simplicity, can you edit your issue to use code-blocks instead of screenshots for the reproducer output please ?



Patch:
diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs
index b8929014974..b737b88a04c 100644
--- a/src/uu/mv/src/mv.rs
+++ b/src/uu/mv/src/mv.rs
@@ -375,8 +375,8 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
         });
     }
 
-    let target_is_dir = target.is_dir();
-    let source_is_dir = source.is_dir();
+    let target_is_dir = target.is_dir() && !target.is_symlink();
+    let source_is_dir = source.is_dir() && !source.is_symlink();
 
     if path_ends_with_terminator(target)
         && (!target_is_dir && !source_is_dir)
@@ -415,7 +415,7 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
         } else {
             move_files_into_dir(&[source.to_path_buf()], target, opts)
         }
-    } else if target.exists() && source.is_dir() {
+    } else if target.exists() && source_is_dir {
         match opts.overwrite {
             OverwriteMode::NoClobber => return Ok(()),
             OverwriteMode::Interactive => {
@@ -747,7 +747,7 @@ fn rename(
     }
 
     // "to" may no longer exist if it was backed up
-    if to.exists() && to.is_dir() {
+    if to.exists() && to.is_dir() && !to.is_symlink() {
         // normalize behavior between *nix and windows
         if from.is_dir() {
             if is_empty_dir(to) {


Test patch:
diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs
index 23d7315fba5..7741fa8ca97 100644
--- a/tests/by-util/test_mv.rs
+++ b/tests/by-util/test_mv.rs
@@ -398,6 +398,78 @@ fn test_mv_replace_file() {
     assert!(at.file_exists(file_b));
 }
 
+#[test]
+#[cfg(all(unix, not(target_os = "android")))]
+fn test_mv_replace_symlink_with_symlink() {
+    let (at, mut ucmd) = at_and_ucmd!();
+
+    at.mkdir("a");
+    at.mkdir("b");
+    at.touch("a/empty_file_a");
+    at.touch("b/empty_file_b");
+
+    at.symlink_dir("a", "symlink_a");
+    at.symlink_dir("b", "symlink_b");
+
+    assert_eq!(at.read("symlink_a/empty_file_a"), "");
+
+    ucmd.arg("-T")
+        .arg("symlink_b")
+        .arg("symlink_a")
+        .succeeds()
+        .no_stderr();
+
+    assert!(at.file_exists("symlink_a/empty_file_b"));
+    assert!(!at.file_exists("symlink_a/empty_file_a"));
+    assert!(!at.symlink_exists("symlink_b"));
+}
+
+#[test]
+#[cfg(all(unix, not(target_os = "android")))]
+fn test_mv_replace_symlink_with_directory() {
+    let (at, mut ucmd) = at_and_ucmd!();
+
+    at.mkdir("a");
+    at.mkdir("b");
+    at.touch("a/empty_file_a");
+    at.touch("b/empty_file_b");
+
+    at.symlink_dir("a", "symlink");
+
+    ucmd.arg("-T")
+        .arg("b")
+        .arg("symlink")
+        .fails()
+        .stderr_contains("cannot overwrite non-directory")
+        .stderr_contains("with directory");
+}
+
+#[test]
+#[cfg(all(unix, not(target_os = "android")))]
+fn test_mv_replace_symlink_with_file() {
+    let (at, mut ucmd) = at_and_ucmd!();
+
+    at.mkdir("a");
+    at.touch("a/empty_file_a");
+    at.touch("empty_file_b");
+
+    at.symlink_dir("a", "symlink");
+
+    assert!(at.file_exists("symlink/empty_file_a"));
+
+    ucmd.arg("-T")
+        .arg("empty_file_b")
+        .arg("symlink")
+        .succeeds()
+        .no_stderr();
+
+    assert!(at.file_exists("symlink"));
+    assert!(!at.is_symlink("symlink"));
+    assert!(!at.file_exists("empty_file_b"));
+    assert!(at.dir_exists("a"));
+    assert!(at.file_exists("a/empty_file_a"));
+}
+
 #[test]
 fn test_mv_force_replace_file() {
     let (at, mut ucmd) = at_and_ucmd!();