Skip to content

Commit 8e8a621

Browse files
authored
FileSystem: MoveDirectory: some improvements. (#65132)
* FileSystem: MoveDirectory: some improvements. - Perform same file check after MoveFile/rename failed. - Unix: use LStat to instead of Stat so links are not followed. * Fix case-sensitive rename.
1 parent e6c8d42 commit 8e8a621

File tree

4 files changed

+59
-60
lines changed

4 files changed

+59
-60
lines changed

src/libraries/System.IO.FileSystem/tests/Directory/Move.cs

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ public void CaseVariantDirectoryNameWithCaseVariantPaths_CaseInsensitiveFileSyst
324324
}
325325

326326
[Fact]
327-
[PlatformSpecific(TestPlatforms.Windows | TestPlatforms.FreeBSD | TestPlatforms.NetBSD)]
327+
[PlatformSpecific(TestPlatforms.Windows | TestPlatforms.OSX | TestPlatforms.FreeBSD | TestPlatforms.NetBSD)]
328328
public void MoveDirectory_FailToMoveDirectoryWithUpperCaseToOtherDirectoryWithLowerCase()
329329
{
330330
Directory.CreateDirectory($"{TestDirectory}/FOO");
@@ -333,17 +333,7 @@ public void MoveDirectory_FailToMoveDirectoryWithUpperCaseToOtherDirectoryWithLo
333333
}
334334

335335
[Fact]
336-
[PlatformSpecific(TestPlatforms.OSX)]
337-
public void MoveDirectory_NoOpWhenMovingDirectoryWithUpperCaseToOtherDirectoryWithLowerCase()
338-
{
339-
Directory.CreateDirectory($"{TestDirectory}/FOO");
340-
Directory.CreateDirectory($"{TestDirectory}/bar/foo");
341-
Move($"{TestDirectory}/FOO", $"{TestDirectory}/bar/foo");
342-
Assert.True(Directory.Exists(Path.Combine(TestDirectory, "bar", "foo")));
343-
}
344-
345-
[Fact]
346-
[PlatformSpecific(TestPlatforms.Windows | TestPlatforms.OSX)]
336+
[PlatformSpecific(TestPlatforms.Windows)]
347337
public void MoveDirectory_FailToMoveLowerCaseDirectoryWhenUpperCaseDirectoryExists()
348338
{
349339
Directory.CreateDirectory($"{TestDirectory}/bar/FOO");

src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Unix.cs

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -390,40 +390,54 @@ private static void CreateParentsAndDirectory(string fullPath)
390390
}
391391
}
392392

393-
private static void MoveDirectory(string sourceFullPath, string destFullPath, bool sameDirectoryDifferentCase)
393+
private static void MoveDirectory(string sourceFullPath, string destFullPath, bool isCaseSensitiveRename)
394394
{
395+
// isCaseSensitiveRename is only set for case-insensitive systems (like macOS).
396+
Debug.Assert(!isCaseSensitiveRename || !PathInternal.IsCaseSensitive);
397+
395398
ReadOnlySpan<char> srcNoDirectorySeparator = Path.TrimEndingDirectorySeparator(sourceFullPath.AsSpan());
396399
ReadOnlySpan<char> destNoDirectorySeparator = Path.TrimEndingDirectorySeparator(destFullPath.AsSpan());
397400

401+
// When the path ends with a directory separator, it must not be a file.
402+
// On Unix 'rename' fails with ENOTDIR, on wasm we need to manually check.
398403
if (OperatingSystem.IsBrowser() && Path.EndsInDirectorySeparator(sourceFullPath) && FileExists(sourceFullPath))
399404
{
400-
// On Windows we end up with ERROR_INVALID_NAME, which is
401-
// "The filename, directory name, or volume label syntax is incorrect."
402-
// On Unix, rename fails with ENOTDIR, but on WASM it does not.
403-
// So if the path ends with directory separator, but it's a file, we just throw.
404405
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
405406
}
406407

407-
if (!sameDirectoryDifferentCase) // This check is to allow renaming of directories
408+
// The destination must not exist (unless it is a case-sensitive rename).
409+
// On Unix 'rename' will overwrite the destination file if it already exists, we need to manually check.
410+
if (!isCaseSensitiveRename && Interop.Sys.LStat(destNoDirectorySeparator, out Interop.Sys.FileStatus destFileStatus) >= 0)
408411
{
409-
if (Interop.Sys.Stat(destNoDirectorySeparator, out _) >= 0)
410-
{
411-
// destination exists, but before we throw we need to check whether source exists or not
412+
// Maintain order of exceptions as on Windows.
412413

413-
// Windows will throw if the source file/directory doesn't exist, we preemptively check
414-
// to make sure our cross platform behavior matches .NET Framework behavior.
415-
if (Interop.Sys.Stat(srcNoDirectorySeparator, out Interop.Sys.FileStatus sourceFileStatus) < 0)
416-
{
417-
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
418-
}
419-
else if ((sourceFileStatus.Mode & Interop.Sys.FileTypes.S_IFMT) != Interop.Sys.FileTypes.S_IFDIR
420-
&& Path.EndsInDirectorySeparator(sourceFullPath))
414+
// Throw if the source doesn't exist.
415+
if (Interop.Sys.LStat(srcNoDirectorySeparator, out Interop.Sys.FileStatus sourceFileStatus) < 0)
416+
{
417+
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
418+
}
419+
// Source and destination must not be the same file unless it is a case-sensitive rename.
420+
else if (sourceFileStatus.Dev == destFileStatus.Dev &&
421+
sourceFileStatus.Ino == destFileStatus.Ino)
422+
{
423+
// isCaseSensitiveRename is only true when the system is case-insensitive (like macOS).
424+
// On a case-sensitive system (like Linux), there can stil be case-insensitive filesystems mounted.
425+
// When both paths refer to the same file and they differ only in casing, we fall through to Rename.
426+
if (!PathInternal.IsCaseSensitive && // handled by isCaseSensitiveRename.
427+
!srcNoDirectorySeparator.Equals(destNoDirectorySeparator, StringComparison.OrdinalIgnoreCase) || // different paths.
428+
Path.GetFileName(srcNoDirectorySeparator).SequenceEqual(Path.GetFileName(destNoDirectorySeparator))) // same names.
421429
{
422-
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
430+
throw new IOException(SR.IO_SourceDestMustBeDifferent);
423431
}
424-
425-
// Some Unix distros will overwrite the destination file if it already exists.
426-
// Throwing IOException to match Windows behavior.
432+
}
433+
// When the path ends with a directory separator, it must be a directory.
434+
else if ((sourceFileStatus.Mode & Interop.Sys.FileTypes.S_IFMT) != Interop.Sys.FileTypes.S_IFDIR
435+
&& Path.EndsInDirectorySeparator(sourceFullPath))
436+
{
437+
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
438+
}
439+
else
440+
{
427441
throw new IOException(SR.Format(SR.IO_AlreadyExists_Name, destFullPath));
428442
}
429443
}
@@ -436,10 +450,7 @@ private static void MoveDirectory(string sourceFullPath, string destFullPath, bo
436450
case Interop.Error.EACCES: // match Win32 exception
437451
throw new IOException(SR.Format(SR.UnauthorizedAccess_IODenied_Path, sourceFullPath), errorInfo.RawErrno);
438452
case Interop.Error.ENOENT:
439-
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path,
440-
Interop.Sys.Stat(srcNoDirectorySeparator, out _) >= 0
441-
? destFullPath // the source directory exists, so destination does not. Example: Move("/tmp/existing/", "/tmp/nonExisting1/nonExisting2/")
442-
: sourceFullPath));
453+
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
443454
case Interop.Error.ENOTDIR: // sourceFullPath exists and it's not a directory
444455
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
445456
default:

src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Windows.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,16 @@ public static DateTimeOffset GetLastWriteTime(string fullPath)
154154
return data.ftLastWriteTime.ToDateTimeOffset();
155155
}
156156

157-
private static void MoveDirectory(string sourceFullPath, string destFullPath, bool sameDirectoryDifferentCase)
157+
private static void MoveDirectory(string sourceFullPath, string destFullPath, bool isCaseSensitiveRename)
158158
{
159+
// Source and destination must have the same root.
160+
ReadOnlySpan<char> sourceRoot = Path.GetPathRoot(sourceFullPath);
161+
ReadOnlySpan<char> destinationRoot = Path.GetPathRoot(destFullPath);
162+
if (!sourceRoot.Equals(destinationRoot, StringComparison.OrdinalIgnoreCase))
163+
{
164+
throw new IOException(SR.IO_SourceDestMustHaveSameRoot);
165+
}
166+
159167
if (!Interop.Kernel32.MoveFile(sourceFullPath, destFullPath, overwrite: false))
160168
{
161169
int errorCode = Marshal.GetLastWin32Error();

src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.cs

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,19 @@ internal static void MoveDirectory(string sourceFullPath, string destFullPath)
1919
ReadOnlySpan<char> srcNoDirectorySeparator = Path.TrimEndingDirectorySeparator(sourceFullPath.AsSpan());
2020
ReadOnlySpan<char> destNoDirectorySeparator = Path.TrimEndingDirectorySeparator(destFullPath.AsSpan());
2121

22-
ReadOnlySpan<char> sourceDirNameFromFullPath = Path.GetFileName(sourceFullPath.AsSpan());
23-
ReadOnlySpan<char> destDirNameFromFullPath = Path.GetFileName(destFullPath.AsSpan());
24-
25-
StringComparison fileSystemSensitivity = PathInternal.StringComparison;
26-
bool directoriesAreCaseVariants =
27-
!sourceDirNameFromFullPath.SequenceEqual(destDirNameFromFullPath) &&
28-
sourceDirNameFromFullPath.Equals(destDirNameFromFullPath, StringComparison.OrdinalIgnoreCase);
29-
bool sameDirectoryDifferentCase =
30-
directoriesAreCaseVariants &&
31-
destDirNameFromFullPath.Equals(sourceDirNameFromFullPath, fileSystemSensitivity);
32-
33-
// If the destination directories are the exact same name
34-
if (!sameDirectoryDifferentCase && srcNoDirectorySeparator.Equals(destNoDirectorySeparator, fileSystemSensitivity))
35-
throw new IOException(SR.IO_SourceDestMustBeDifferent);
36-
37-
ReadOnlySpan<char> sourceRoot = Path.GetPathRoot(srcNoDirectorySeparator);
38-
ReadOnlySpan<char> destinationRoot = Path.GetPathRoot(destNoDirectorySeparator);
39-
40-
// Compare paths for the same, skip this step if we already know the paths are identical.
41-
if (!sourceRoot.Equals(destinationRoot, StringComparison.OrdinalIgnoreCase))
42-
throw new IOException(SR.IO_SourceDestMustHaveSameRoot);
22+
// Don't allow the same path, except for changing the casing of the filename.
23+
bool isCaseSensitiveRename = false;
24+
if (srcNoDirectorySeparator.Equals(destNoDirectorySeparator, PathInternal.StringComparison))
25+
{
26+
if (PathInternal.IsCaseSensitive || // FileNames will be equal because paths are equal.
27+
Path.GetFileName(srcNoDirectorySeparator).SequenceEqual(Path.GetFileName(destNoDirectorySeparator)))
28+
{
29+
throw new IOException(SR.IO_SourceDestMustBeDifferent);
30+
}
31+
isCaseSensitiveRename = true;
32+
}
4333

44-
MoveDirectory(sourceFullPath, destFullPath, sameDirectoryDifferentCase);
34+
MoveDirectory(sourceFullPath, destFullPath, isCaseSensitiveRename);
4535
}
4636
}
4737
}

0 commit comments

Comments
 (0)