Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/Tasks.UnitTests/Copy_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3080,5 +3080,85 @@ internal CopyFunctor(int countOfSuccess, bool throwOnFailure)
return null;
}
}

/// <summary>
/// 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.
/// </summary>
[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);
}
}
}
}
}
10 changes: 5 additions & 5 deletions src/Tasks/Copy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, bool> _directoriesKnownToExist = new ConcurrentDictionary<string, bool>(DefaultCopyParallelism, DefaultCopyParallelism, StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, bool> _directoriesKnownToExist = new ConcurrentDictionary<string, bool>(DefaultCopyParallelism, DefaultCopyParallelism, FileUtilities.PathComparer);

/// <summary>
/// Force the copy to retry even when it hits ERROR_ACCESS_DENIED -- normally we wouldn't retry in this case since
Expand Down Expand Up @@ -493,7 +493,7 @@ private bool CopySingleThreaded(
// { dest -> source }
var filesActuallyCopied = new Dictionary<string, string>(
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)
Expand All @@ -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;
Expand Down Expand Up @@ -587,7 +587,7 @@ private bool CopyParallel(
// Map: Destination path -> indexes in SourceFiles/DestinationItems array indices (ordered low->high).
var partitionsByDestination = new Dictionary<string, List<int>>(
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)
{
Expand Down Expand Up @@ -653,7 +653,7 @@ void ProcessPartition()
String.Equals(
sourcePath,
SourceFiles[partition[partitionIndex - 1]].ItemSpec,
StringComparison.OrdinalIgnoreCase);
FileUtilities.PathComparison);

if (!copyComplete)
{
Expand Down