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)
{