Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Directory.Move and DirectoryInfo.MoveTo #63675

Merged
merged 7 commits into from
Feb 8, 2022
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Runtime.InteropServices;
using System.Text;

internal static partial class Interop
{
Expand All @@ -18,5 +19,20 @@ internal static partial class Sys
/// </returns>
[GeneratedDllImport(Libraries.SystemNative, EntryPoint = "SystemNative_Rename", CharSet = CharSet.Ansi, SetLastError = true)]
internal static partial int Rename(string oldPath, string newPath);

[GeneratedDllImport(Libraries.SystemNative, EntryPoint = "SystemNative_Rename", SetLastError = true)]
internal static partial int Rename(ref byte oldPath, ref byte newPath);

internal static int Rename(ReadOnlySpan<char> oldPath, ReadOnlySpan<char> newPath)
{
ValueUtf8Converter converterNewPath = new(stackalloc byte[DefaultPathBufferSize]);
ValueUtf8Converter converterOldPath = new(stackalloc byte[DefaultPathBufferSize]);
int result = Rename(
ref MemoryMarshal.GetReference(converterOldPath.ConvertAndTerminateString(oldPath)),
ref MemoryMarshal.GetReference(converterNewPath.ConvertAndTerminateString(newPath)));
converterNewPath.Dispose();
converterOldPath.Dispose();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you use explicit Dispose calls (instead of using-Statements)?

return result;
}
}
}
30 changes: 15 additions & 15 deletions src/libraries/System.IO.FileSystem/tests/Directory/Move.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ public void TrailingDirectorySeparators()
string testDirDest = Path.Combine(TestDirectory, GetTestFileName());

Directory.CreateDirectory(testDirSource);
Directory.Move(testDirSource + Path.DirectorySeparatorChar, testDirDest + Path.DirectorySeparatorChar);
Move(testDirSource + Path.DirectorySeparatorChar, testDirDest + Path.DirectorySeparatorChar);
Assert.True(Directory.Exists(testDirDest));
}

Expand Down Expand Up @@ -216,15 +216,15 @@ public void Path_Longer_Than_MaxLongPath_Throws_Exception()
public void ThrowIOExceptionWhenMovingDirectoryToItself()
{
Directory.CreateDirectory(Path.Combine(TestDirectory, "foo"));
Assert.Throws<IOException>(() => Directory.Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "foo")));
Assert.Throws<IOException>(() => Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "foo")));
}

[Fact]
public void ThrowIOExceptionWhenMovingToExistingDirectoryWithSameCase()
{
Directory.CreateDirectory(Path.Combine(TestDirectory, "foo"));
Directory.CreateDirectory(Path.Combine(TestDirectory, "bar", "foo"));
Assert.Throws<IOException>(() => Directory.Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "bar", "foo")));
Assert.Throws<IOException>(() => Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "bar", "foo")));
}

[Fact]
Expand All @@ -233,7 +233,7 @@ public void ToNewDirectoryButWithDifferentCasing()
Directory.CreateDirectory(Path.Combine(TestDirectory, "foo"));
var otherDirectory = Path.Combine(TestDirectory, "bar");
Directory.CreateDirectory(Path.Combine(otherDirectory));
Directory.Move(Path.Combine(TestDirectory, "foo"), Path.Combine(otherDirectory, "FOO"));
Move(Path.Combine(TestDirectory, "foo"), Path.Combine(otherDirectory, "FOO"));
Assert.True(Directory.Exists(Path.Combine(otherDirectory, "FOO")));
Assert.False(Directory.Exists(Path.Combine(TestDirectory, "foo")));
}
Expand All @@ -245,7 +245,7 @@ public void SameDirectoryWithDifferentCasing_WithFileContent()
var fooDirectoryUppercase = Path.Combine(TestDirectory, "FOO");
Directory.CreateDirectory(fooDirectory);
File.WriteAllText(Path.Combine(fooDirectory, "bar.txt"), string.Empty);
Directory.Move(fooDirectory, fooDirectoryUppercase);
Move(fooDirectory, fooDirectoryUppercase);
var firstFile = Directory.GetFiles(fooDirectoryUppercase);
Assert.Equal("bar.txt", Path.GetFileName(firstFile[0]));
}
Expand All @@ -255,7 +255,7 @@ public void WithDifferentRootCase()
{
Directory.CreateDirectory($"{TestDirectory}/bar");
var root = Path.GetPathRoot(TestDirectory);
Directory.Move($"{TestDirectory}/bar".Replace(root, root.ToLower()), $"{TestDirectory}/foo");
Move($"{TestDirectory}/bar".Replace(root, root.ToLower()), $"{TestDirectory}/foo");
Assert.True(Directory.Exists($"{TestDirectory}/foo"));
Assert.False(Directory.Exists($"{TestDirectory}/bar"));
}
Expand All @@ -267,7 +267,7 @@ public void SameDirectoryWithDifferentCasing_WithDirectoryContent()
var fooDirectoryPathUpperCase = Path.Combine(TestDirectory, "FOO");
Directory.CreateDirectory(fooDirectoryPath);
Directory.CreateDirectory(Path.Combine(fooDirectoryPath, "bar"));
Directory.Move(fooDirectoryPath, fooDirectoryPathUpperCase);
Move(fooDirectoryPath, fooDirectoryPathUpperCase);
var firstFile = Directory.GetDirectories(fooDirectoryPathUpperCase);
Assert.Equal("bar", Path.GetFileName(firstFile[0]));
}
Expand All @@ -282,15 +282,15 @@ public void DirectoryWithDifferentCasingThanFileSystem_ToAnotherDirectory()
{
Directory.CreateDirectory(Path.Combine(TestDirectory, "FOO"));
Directory.CreateDirectory(Path.Combine(TestDirectory, "bar"));
Directory.Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "bar", "FOO"));
Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "bar", "FOO"));
}

[Fact]
[PlatformSpecific(TestPlatforms.Windows | TestPlatforms.OSX)]
public void DirectoryWithDifferentCasingThanFileSystem_ToItself()
{
Directory.CreateDirectory(Path.Combine(TestDirectory, "FOO"));
Directory.Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "FOO"));
Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "FOO"));
Assert.True(Directory.Exists(Path.Combine(TestDirectory, "FOO")));
}

Expand All @@ -300,15 +300,15 @@ public void DirectoryWithDifferentCasingThanFileSystem_ToAnotherDirectory_CaseSe
{
Directory.CreateDirectory(Path.Combine(TestDirectory, "FOO"));
Directory.CreateDirectory(Path.Combine(TestDirectory, "bar"));
Assert.Throws<DirectoryNotFoundException>(() => Directory.Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "bar", "FOO")));
Assert.Throws<DirectoryNotFoundException>(() => Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "bar", "FOO")));
}

[Fact]
[PlatformSpecific(TestPlatforms.Linux)]
public void DirectoryWithDifferentCasingThanFileSystem_ToItself_CaseSensitiveOS()
{
Directory.CreateDirectory(Path.Combine(TestDirectory, "FOO"));
Assert.Throws<DirectoryNotFoundException>(() => Directory.Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "FOO")));
Assert.Throws<DirectoryNotFoundException>(() => Move(Path.Combine(TestDirectory, "foo"), Path.Combine(TestDirectory, "FOO")));
}

[Fact]
Expand All @@ -320,7 +320,7 @@ public void CaseVariantDirectoryNameWithCaseVariantPaths_CaseInsensitiveFileSyst
Directory.CreateDirectory(Path.Combine(TestDirectory, "FOO", "bar"));
Directory.CreateDirectory(Path.Combine(TestDirectory, "foo"));

Assert.Throws<IOException>(() => Directory.Move(directoryToBeMoved, Path.Combine(newPath, "bar")));
Assert.Throws<IOException>(() => Move(directoryToBeMoved, Path.Combine(newPath, "bar")));
}

[Fact]
Expand All @@ -329,7 +329,7 @@ public void MoveDirectory_FailToMoveDirectoryWithUpperCaseToOtherDirectoryWithLo
{
Directory.CreateDirectory($"{TestDirectory}/FOO");
Directory.CreateDirectory($"{TestDirectory}/bar/foo");
Assert.Throws<IOException>(() => Directory.Move($"{TestDirectory}/FOO", $"{TestDirectory}/bar/foo"));
Assert.Throws<IOException>(() => Move($"{TestDirectory}/FOO", $"{TestDirectory}/bar/foo"));
}

[Fact]
Expand All @@ -338,7 +338,7 @@ public void MoveDirectory_NoOpWhenMovingDirectoryWithUpperCaseToOtherDirectoryWi
{
Directory.CreateDirectory($"{TestDirectory}/FOO");
Directory.CreateDirectory($"{TestDirectory}/bar/foo");
Directory.Move($"{TestDirectory}/FOO", $"{TestDirectory}/bar/foo");
Move($"{TestDirectory}/FOO", $"{TestDirectory}/bar/foo");
Assert.True(Directory.Exists(Path.Combine(TestDirectory, "bar", "foo")));
}

Expand All @@ -348,7 +348,7 @@ public void MoveDirectory_FailToMoveLowerCaseDirectoryWhenUpperCaseDirectoryExis
{
Directory.CreateDirectory($"{TestDirectory}/bar/FOO");
Directory.CreateDirectory($"{TestDirectory}/foo");
Assert.Throws<IOException>(() => Directory.Move($"{TestDirectory}/foo", $"{TestDirectory}/bar/foo"));
Assert.Throws<IOException>(() => Move($"{TestDirectory}/foo", $"{TestDirectory}/bar/foo"));
}

[ConditionalFact(nameof(AreAllLongPathsAvailable))]
Expand Down
43 changes: 1 addition & 42 deletions src/libraries/System.Private.CoreLib/src/System/IO/Directory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,48 +248,7 @@ public static void Move(string sourceDirName, string destDirName)
if (destDirName.Length == 0)
throw new ArgumentException(SR.Argument_EmptyFileName, nameof(destDirName));

string fullsourceDirName = Path.GetFullPath(sourceDirName);
string sourcePath = PathInternal.EnsureTrailingSeparator(fullsourceDirName);

string fulldestDirName = Path.GetFullPath(destDirName);
string destPath = PathInternal.EnsureTrailingSeparator(fulldestDirName);

ReadOnlySpan<char> sourceDirNameFromFullPath = Path.GetFileName(fullsourceDirName.AsSpan());
ReadOnlySpan<char> destDirNameFromFullPath = Path.GetFileName(fulldestDirName.AsSpan());

StringComparison fileSystemSensitivity = PathInternal.StringComparison;
bool directoriesAreCaseVariants =
!sourceDirNameFromFullPath.SequenceEqual(destDirNameFromFullPath) &&
sourceDirNameFromFullPath.Equals(destDirNameFromFullPath, StringComparison.OrdinalIgnoreCase);
bool sameDirectoryDifferentCase =
directoriesAreCaseVariants &&
destDirNameFromFullPath.Equals(sourceDirNameFromFullPath, fileSystemSensitivity);

// If the destination directories are the exact same name
if (!sameDirectoryDifferentCase && string.Equals(sourcePath, destPath, fileSystemSensitivity))
throw new IOException(SR.IO_SourceDestMustBeDifferent);

ReadOnlySpan<char> sourceRoot = Path.GetPathRoot(sourcePath.AsSpan());
ReadOnlySpan<char> destinationRoot = Path.GetPathRoot(destPath.AsSpan());

// Compare paths for the same, skip this step if we already know the paths are identical.
if (!sourceRoot.Equals(destinationRoot, StringComparison.OrdinalIgnoreCase))
throw new IOException(SR.IO_SourceDestMustHaveSameRoot);

// Windows will throw if the source file/directory doesn't exist, we preemptively check
// to make sure our cross platform behavior matches .NET Framework behavior.
if (!FileSystem.DirectoryExists(fullsourceDirName) && !FileSystem.FileExists(fullsourceDirName))
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path, fullsourceDirName));

if (!sameDirectoryDifferentCase // This check is to allow renaming of directories
&& FileSystem.DirectoryExists(fulldestDirName))
throw new IOException(SR.Format(SR.IO_AlreadyExists_Name, fulldestDirName));

// If the directories aren't the same and the OS says the directory exists already, fail.
if (!sameDirectoryDifferentCase && Directory.Exists(fulldestDirName))
throw new IOException(SR.Format(SR.IO_AlreadyExists_Name, fulldestDirName));

FileSystem.MoveDirectory(fullsourceDirName, fulldestDirName);
FileSystem.MoveDirectory(Path.GetFullPath(sourceDirName), Path.GetFullPath(destDirName));
}

public static void Delete(string path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,30 +195,10 @@ public void MoveTo(string destDirName)

string destination = Path.GetFullPath(destDirName);

string destinationWithSeparator = PathInternal.EnsureTrailingSeparator(destination);
string sourceWithSeparator = PathInternal.EnsureTrailingSeparator(FullPath);

if (string.Equals(sourceWithSeparator, destinationWithSeparator, PathInternal.StringComparison))
throw new IOException(SR.IO_SourceDestMustBeDifferent);

string? sourceRoot = Path.GetPathRoot(sourceWithSeparator);
string? destinationRoot = Path.GetPathRoot(destinationWithSeparator);

if (!string.Equals(sourceRoot, destinationRoot, PathInternal.StringComparison))
throw new IOException(SR.IO_SourceDestMustHaveSameRoot);

// Windows will throw if the source file/directory doesn't exist, we preemptively check
// to make sure our cross platform behavior matches .NET Framework behavior.
if (!Exists && !FileSystem.FileExists(FullPath))
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path, FullPath));

if (FileSystem.DirectoryExists(destination))
throw new IOException(SR.Format(SR.IO_AlreadyExists_Name, destinationWithSeparator));

FileSystem.MoveDirectory(FullPath, destination);

Init(originalPath: destDirName,
fullPath: destinationWithSeparator,
fullPath: PathInternal.EnsureTrailingSeparator(destination),
fileName: null,
isNormalized: true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,40 +387,58 @@ private static void CreateParentsAndDirectory(string fullPath)
}
}

public static void MoveDirectory(string sourceFullPath, string destFullPath)
private static void MoveDirectory(string sourceFullPath, string destFullPath, bool sameDirectoryDifferentCase)
{
// Windows doesn't care if you try and copy a file via "MoveDirectory"...
if (FileExists(sourceFullPath))
{
// ... but it doesn't like the source to have a trailing slash ...
ReadOnlySpan<char> srcNoDirectorySeparator = Path.TrimEndingDirectorySeparator(sourceFullPath.AsSpan());
ReadOnlySpan<char> destNoDirectorySeparator = Path.TrimEndingDirectorySeparator(destFullPath.AsSpan());

if (OperatingSystem.IsBrowser() && Path.EndsInDirectorySeparator(sourceFullPath) && FileExists(sourceFullPath))
{
// On Windows we end up with ERROR_INVALID_NAME, which is
// "The filename, directory name, or volume label syntax is incorrect."
//
// This surfaces as an IOException, if we let it go beyond here it would
// give DirectoryNotFound.

if (Path.EndsInDirectorySeparator(sourceFullPath))
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));

// ... but it doesn't care if the destination has a trailing separator.
destFullPath = Path.TrimEndingDirectorySeparator(destFullPath);
// On Unix, rename fails with ENOTDIR, but on WASM it does not.
// So if the path ends with directory separator, but it's a file, we just throw.
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
}

if (FileExists(destFullPath))
if (!sameDirectoryDifferentCase) // This check is to allow renaming of directories
Copy link
Member

@tmds tmds Feb 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because file systems are case sensitive I don't understand why we need a specific check for sameDirectoryDifferentCase?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is something that already existed and to be honest I have not questioned that before you have asked the question. It seems that @wfurt agrees with you (#65059 (comment)):

The MoveDirectory_FailToMoveLowerCaseDirectoryWhenUpperCaseDirectoryExists is technically wrong on Unix IMHO.
The case sensitivity is determined by given filesystem not by the OS itself.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My guess is that this may be there to surface better exceptions. We could defer to actual IO but that may give different behaviors on different platforms. And Some OSes and file systems do care about case and some don't.

{
// Some Unix distros will overwrite the destination file if it already exists.
// Throwing IOException to match Windows behavior.
throw new IOException(SR.Format(SR.IO_AlreadyExists_Name, destFullPath));
if (Interop.Sys.Stat(destNoDirectorySeparator, out _) >= 0)
{
// destination exists, but before we throw we need to check whether source exists or not

// Windows will throw if the source file/directory doesn't exist, we preemptively check
// to make sure our cross platform behavior matches .NET Framework behavior.
if (Interop.Sys.Stat(srcNoDirectorySeparator, out Interop.Sys.FileStatus sourceFileStatus) < 0)
{
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
}
else if ((sourceFileStatus.Mode & Interop.Sys.FileTypes.S_IFMT) != Interop.Sys.FileTypes.S_IFDIR
&& Path.EndsInDirectorySeparator(sourceFullPath))
{
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
}

// Some Unix distros will overwrite the destination file if it already exists.
// Throwing IOException to match Windows behavior.
throw new IOException(SR.Format(SR.IO_AlreadyExists_Name, destFullPath));
}
}

if (Interop.Sys.Rename(sourceFullPath, destFullPath) < 0)
if (Interop.Sys.Rename(sourceFullPath, destNoDirectorySeparator) < 0)
{
Interop.ErrorInfo errorInfo = Interop.Sys.GetLastErrorInfo();
switch (errorInfo.Error)
{
case Interop.Error.EACCES: // match Win32 exception
throw new IOException(SR.Format(SR.UnauthorizedAccess_IODenied_Path, sourceFullPath), errorInfo.RawErrno);
case Interop.Error.ENOENT:
throw new DirectoryNotFoundException(SR.Format(SR.IO_PathNotFound_Path,
Interop.Sys.Stat(srcNoDirectorySeparator, out _) >= 0
? destFullPath // the source directory exists, so destination does not. Example: Move("/tmp/existing/", "/tmp/nonExisting1/nonExisting2/")
: sourceFullPath));
case Interop.Error.ENOTDIR: // sourceFullPath exists and it's not a directory
throw new IOException(SR.Format(SR.IO_PathNotFound_Path, sourceFullPath));
default:
throw Interop.GetExceptionForIoErrno(errorInfo, isDirectory: true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public static DateTimeOffset GetLastWriteTime(string fullPath)
return data.ftLastWriteTime.ToDateTimeOffset();
}

public static void MoveDirectory(string sourceFullPath, string destFullPath)
private static void MoveDirectory(string sourceFullPath, string destFullPath, bool sameDirectoryDifferentCase)
{
if (!Interop.Kernel32.MoveFile(sourceFullPath, destFullPath, overwrite: false))
{
Expand Down
Loading