@@ -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 :
0 commit comments