diff --git a/src/Tasks.UnitTests/Copy_Tests.cs b/src/Tasks.UnitTests/Copy_Tests.cs index 708f5d41148..c73787e160a 100644 --- a/src/Tasks.UnitTests/Copy_Tests.cs +++ b/src/Tasks.UnitTests/Copy_Tests.cs @@ -3080,5 +3080,85 @@ internal CopyFunctor(int countOfSuccess, bool throwOnFailure) return null; } } + + /// + /// Test that Copy task correctly handles case-sensitive paths on Unix systems. + /// On Unix, "CS" and "cs" are different paths and should not conflict. + /// This reproduces the issue mentioned in GitHub issue #12146. + /// + [Fact] + public void CopyToFileWithSameCaseInsensitiveNameAsExistingDirectoryOnUnix() + { + // Skip this test on case-insensitive file systems (Windows, macOS with default APFS/HFS+) + if (!FileUtilities.GetIsFileSystemCaseSensitive()) + { + return; + } + + string tempPath = Path.GetTempPath(); + string tempDir = Path.Combine(tempPath, "CopyTestDir" + Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(tempDir); + + // Create a subdirectory structure to match the real scenario + string outputDir = Path.Combine(tempDir, "bin", "Debug", "net10.0"); + Directory.CreateDirectory(outputDir); + + // Create a directory named "cs" (lowercase) in the output directory + string lowercaseDir = Path.Combine(outputDir, "cs"); + Directory.CreateDirectory(lowercaseDir); + + // Create a few source files to copy (representing multiple files being copied to same dest dir) + string sourceDir = Path.Combine(tempDir, "CS", "obj", "Debug", "net10.0"); + Directory.CreateDirectory(sourceDir); + + string sourceFile1 = Path.Combine(sourceDir, "apphost"); + string sourceFile2 = Path.Combine(sourceDir, "app.dll"); + File.WriteAllText(sourceFile1, "test apphost content"); + File.WriteAllText(sourceFile2, "test dll content"); + + // Try to copy files to the output directory - one should be "CS", the other some other file + string destFile1 = Path.Combine(outputDir, "CS"); + string destFile2 = Path.Combine(outputDir, "app.dll"); + + Copy t = new Copy(); + MockEngine engine = new MockEngine(); + t.BuildEngine = engine; + t.SourceFiles = new ITaskItem[] { + new TaskItem(sourceFile1), + new TaskItem(sourceFile2) + }; + t.DestinationFiles = new ITaskItem[] { + new TaskItem(destFile1), + new TaskItem(destFile2) + }; + + // This should succeed on Unix because "cs" (directory) and "CS" (file) are different + bool result = t.Execute(); + + if (!result) + { + // Log the error to see what went wrong + string log = engine.Log; + Console.WriteLine("Copy failed with log: " + log); + } + + Assert.True(result, "Copy should succeed on Unix when destination file name differs in case from existing directory"); + Assert.True(File.Exists(destFile1), "Destination file CS should be created"); + Assert.True(File.Exists(destFile2), "Destination file app.dll should be created"); + + // Ensure the directory still exists and wasn't corrupted + Assert.True(Directory.Exists(lowercaseDir), "The cs directory should still exist"); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } } } diff --git a/src/Tasks/Copy.cs b/src/Tasks/Copy.cs index 641ed43193f..9f7184c558b 100644 --- a/src/Tasks/Copy.cs +++ b/src/Tasks/Copy.cs @@ -109,7 +109,7 @@ public Copy() private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); // Bool is just a placeholder, we're mainly interested in a threadsafe key set. - private readonly ConcurrentDictionary _directoriesKnownToExist = new ConcurrentDictionary(DefaultCopyParallelism, DefaultCopyParallelism, StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _directoriesKnownToExist = new ConcurrentDictionary(DefaultCopyParallelism, DefaultCopyParallelism, FileUtilities.PathComparer); /// /// Force the copy to retry even when it hits ERROR_ACCESS_DENIED -- normally we wouldn't retry in this case since @@ -493,7 +493,7 @@ private bool CopySingleThreaded( // { dest -> source } var filesActuallyCopied = new Dictionary( DestinationFiles.Length, // Set length to common case of 1:1 source->dest. - StringComparer.OrdinalIgnoreCase); + FileUtilities.PathComparer); // Now that we have a list of destinationFolder files, copy from source to destinationFolder. for (int i = 0; i < SourceFiles.Length && !_cancellationTokenSource.IsCancellationRequested; ++i) @@ -503,7 +503,7 @@ private bool CopySingleThreaded( MSBuildEventSource.Log.CopyUpToDateStart(destPath); if (filesActuallyCopied.TryGetValue(destPath, out string originalSource)) { - if (String.Equals(originalSource, SourceFiles[i].ItemSpec, StringComparison.OrdinalIgnoreCase)) + if (String.Equals(originalSource, SourceFiles[i].ItemSpec, FileUtilities.PathComparison)) { // Already copied from this location, don't copy again. copyComplete = true; @@ -587,7 +587,7 @@ private bool CopyParallel( // Map: Destination path -> indexes in SourceFiles/DestinationItems array indices (ordered low->high). var partitionsByDestination = new Dictionary>( DestinationFiles.Length, // Set length to common case of 1:1 source->dest. - StringComparer.OrdinalIgnoreCase); + FileUtilities.PathComparer); for (int i = 0; i < SourceFiles.Length && !_cancellationTokenSource.IsCancellationRequested; ++i) { @@ -653,7 +653,7 @@ void ProcessPartition() String.Equals( sourcePath, SourceFiles[partition[partitionIndex - 1]].ItemSpec, - StringComparison.OrdinalIgnoreCase); + FileUtilities.PathComparison); if (!copyComplete) {