Skip to content

Commit 3c00cb0

Browse files
authored
Fix for self-copy deleting the source file in CoW copying (#510)
In CoW Copy and Artifacts, do not copy when the source and destination paths are the same (canonicalized) to avoid deleting the source file when a copy-on-self is happening. This does not handle the case of the same file via differing symlinked paths.
1 parent b54b4a1 commit 3c00cb0

7 files changed

Lines changed: 101 additions & 97 deletions

File tree

src/Artifacts.UnitTests/RobocopyTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,45 @@ public void SelfCopiesShouldNoOp()
350350
fs.NumCopyFileCalls.ShouldBe(0);
351351
}
352352

353+
[Fact]
354+
public void ExplicitSelfCopiesShouldNoOp()
355+
{
356+
// Use a relative path to allow aliasing a local file and its full path.
357+
string localSource = Path.Combine(Environment.CurrentDirectory, "foo.txt");
358+
File.WriteAllText(localSource, string.Empty);
359+
360+
BuildEngine buildEngine = BuildEngine.Create();
361+
MockFileSystem fs = new MockFileSystem();
362+
363+
Robocopy copyArtifacts = new Robocopy
364+
{
365+
BuildEngine = buildEngine,
366+
Sources = new ITaskItem[]
367+
{
368+
new MockTaskItem(localSource)
369+
{
370+
["DestinationFolder"] = ".",
371+
["AlwaysCopy"] = "true",
372+
},
373+
new MockTaskItem(Path.Combine(".", "foo.txt"))
374+
{
375+
["DestinationFolder"] = Environment.CurrentDirectory,
376+
["AlwaysCopy"] = "true",
377+
},
378+
},
379+
Sleep = _ => { },
380+
FileSystem = fs,
381+
};
382+
383+
copyArtifacts.Execute().ShouldBeTrue(buildEngine.GetConsoleLog());
384+
copyArtifacts.NumFilesCopied.ShouldBe(0);
385+
copyArtifacts.NumErrors.ShouldBe(0);
386+
copyArtifacts.NumFilesSkipped.ShouldBe(1);
387+
copyArtifacts.NumDuplicateDestinationDelayedJobs.ShouldBe(0);
388+
fs.NumCloneFileCalls.ShouldBe(0);
389+
fs.NumCopyFileCalls.ShouldBe(0);
390+
}
391+
353392
[Fact]
354393
public void DifferentSourcesSameDestinationShouldRunDuplicatesSeparately()
355394
{

src/Artifacts/Tasks/RobocopyMetadata.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,13 @@ public bool IsMatch(string item, string subDirectory, bool isFile)
264264

265265
public bool ShouldCopy(IFileSystem fileSystem, FileInfo source, FileInfo dest)
266266
{
267+
if (string.Equals(source.FullName, dest.FullName, FileSystem.PathComparison))
268+
{
269+
// Self-copy makes no sense.
270+
// TODO: This does not handle the case where the file is the same via different directory symlinks/junctions.
271+
return false;
272+
}
273+
267274
if (AlwaysCopy || !fileSystem.FileExists(dest))
268275
{
269276
return true;

src/CopyOnWrite.UnitTests/CopyExceptionHandlingTests.cs

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,6 @@ namespace Microsoft.Build.CopyOnWrite.UnitTests;
1212

1313
public class CopyExceptionHandlingTests : MSBuildSdkTestBase
1414
{
15-
[Fact]
16-
public void PathsAreIdentical_NoSymlinks()
17-
{
18-
string osRoot = IsWindows ? @"C:\" : "/";
19-
string osCapitalizationDependentName = IsWindows ? "NAME" : "name";
20-
Assert.True(CopyExceptionHandling.PathsAreIdentical(osRoot, osRoot));
21-
Assert.True(CopyExceptionHandling.PathsAreIdentical(Path.Combine(osRoot, "name"), Path.Combine(osCapitalizationDependentName)));
22-
Assert.True(CopyExceptionHandling.PathsAreIdentical(osRoot, Path.Combine(osRoot, "subDir", "..")));
23-
}
24-
2515
[Fact]
2616
public void PathsAreIdentical_Symlinks()
2717
{
@@ -33,25 +23,25 @@ public void PathsAreIdentical_Symlinks()
3323
using var tempDir = new DisposableTempDirectory();
3424
string regularFilePath = Path.Combine(tempDir.Path, "regular.txt");
3525
File.WriteAllText(regularFilePath, "regular");
36-
Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, regularFilePath));
26+
Assert.True(CopyExceptionHandling.FullPathsAreIdentical(regularFilePath, regularFilePath));
3727

3828
string regularFilePathWithNonCanonicalSegments =
3929
Path.Combine(tempDir.Path, "..", Path.GetFileName(tempDir.Path), ".", "regular.txt");
40-
Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, regularFilePathWithNonCanonicalSegments));
30+
Assert.True(CopyExceptionHandling.FullPathsAreIdentical(regularFilePath, regularFilePathWithNonCanonicalSegments));
4131

4232
string symlinkPath = Path.Combine(tempDir.Path, "symlink_to_regular.txt");
4333
string? errorMessage = null;
4434
bool linkCreated = NativeMethods.MakeSymbolicLink(symlinkPath, regularFilePath, ref errorMessage);
4535
Assert.True(linkCreated);
4636
Assert.Null(errorMessage);
47-
Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, symlinkPath));
37+
Assert.True(CopyExceptionHandling.FullPathsAreIdentical(regularFilePath, symlinkPath));
4838

4939
File.Delete(symlinkPath);
5040
errorMessage = null;
5141
linkCreated = NativeMethods.MakeSymbolicLink(symlinkPath, regularFilePathWithNonCanonicalSegments, ref errorMessage);
5242
Assert.True(linkCreated);
5343
Assert.Null(errorMessage);
54-
Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, symlinkPath));
44+
Assert.True(CopyExceptionHandling.FullPathsAreIdentical(regularFilePath, symlinkPath));
5545
}
5646

5747
private bool UserCanCreateSymlinks()

src/CopyOnWrite/Copy.cs

Lines changed: 30 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
using System.Threading;
1414
using System.Threading.Tasks.Dataflow;
1515

16-
#nullable disable
16+
#nullable enable annotations
1717

1818
namespace Microsoft.Build.Tasks
1919
{
@@ -50,28 +50,16 @@ public class Copy : Task, ICancelableTask
5050
public Copy()
5151
{
5252
RetryDelayMilliseconds = RetryDelayMillisecondsDefault;
53-
54-
if (DidNotCopyBecauseOfFileMatch == null)
55-
{
56-
CreatesDirectory = "Creating directory \"{0}\"";
57-
DidNotCopyBecauseOfFileMatch = "Did not copy from file \"{0}\" to file \"{1}\" because the \"{2}\" parameter was set to \"{3}\" in the project and the files' sizes and timestamps match.";
58-
FileComment = "Copying file from \"{0}\" to \"{1}\".";
59-
HardLinkComment = "Creating hard link to copy \"{0}\" to \"{1}\"";
60-
RetryingAsFileCopy = "Could not use a link to copy \"{0}\" to \"{1}\". Copying the file instead. {2}";
61-
RetryingAsSymbolicLink = "Could not use a hard link to copy \"{0}\" to \"{1}\". Copying the file with symbolic link instead. {2}";
62-
RemovingReadOnlyAttribute = "Removing read-only attribute from \"{0}\".";
63-
SymbolicLinkComment = "Creating symbolic link to copy \"{0}\" to \"{1}\"";
64-
}
6553
}
6654

67-
private static string CreatesDirectory;
68-
private static string DidNotCopyBecauseOfFileMatch;
69-
private static string FileComment;
70-
private static string HardLinkComment;
71-
private static string RetryingAsFileCopy;
72-
private static string RetryingAsSymbolicLink;
73-
private static string RemovingReadOnlyAttribute;
74-
private static string SymbolicLinkComment;
55+
private static readonly string CreatesDirectory = "Creating directory \"{0}\"";
56+
private static readonly string DidNotCopyBecauseOfFileMatch = "Did not copy from file \"{0}\" to file \"{1}\" because the \"{2}\" parameter was set to \"{3}\" in the project and the files' sizes and timestamps match.";
57+
private static readonly string FileComment = "Copying file from \"{0}\" to \"{1}\".";
58+
private static readonly string HardLinkComment = "Creating hard link to copy \"{0}\" to \"{1}\"";
59+
private static readonly string RetryingAsFileCopy = "Could not use a link to copy \"{0}\" to \"{1}\". Copying the file instead. {2}";
60+
private static readonly string RetryingAsSymbolicLink = "Could not use a hard link to copy \"{0}\" to \"{1}\". Copying the file with symbolic link instead. {2}";
61+
private static readonly string RemovingReadOnlyAttribute = "Removing read-only attribute from \"{0}\".";
62+
private static readonly string SymbolicLinkComment = "Creating symbolic link to copy \"{0}\" to \"{1}\"";
7563

7664
#region Properties
7765

@@ -100,9 +88,9 @@ public Copy()
10088
private const int RetryDelayMillisecondsDefault = 1000;
10189

10290
[Required]
103-
public ITaskItem[] SourceFiles { get; set; }
91+
public ITaskItem[] SourceFiles { get; set; }
10492

105-
public ITaskItem DestinationFolder { get; set; }
93+
public ITaskItem? DestinationFolder { get; set; }
10694

10795
/// <summary>
10896
/// Gets or sets the number of times to attempt to copy, if all previous attempts failed.
@@ -136,7 +124,7 @@ public Copy()
136124
public bool SkipUnchangedFiles { get; set; }
137125

138126
[Output]
139-
public ITaskItem[] DestinationFiles { get; set; }
127+
public ITaskItem[] DestinationFiles { get; set; }
140128

141129
/// <summary>
142130
/// The subset of files that were successfully copied.
@@ -333,8 +321,8 @@ private void LogDiagnostic(string message, params object[] messageArgs)
333321
if (!hardLinkCreated && !symbolicLinkCreated)
334322
{
335323
// Do not log a fake command line as well, as it's superfluous, and also potentially expensive
336-
string sourceFilePath = FileUtilities.GetFullPathNoThrow(sourceFileState.Name);
337-
string destinationFilePath = FileUtilities.GetFullPathNoThrow(destinationFileState.Name);
324+
string sourceFilePath = sourceFileState.FullPath;
325+
string destinationFilePath = destinationFileState.FullPath;
338326
Log.LogMessage(MessageImportance.Normal, FileComment, sourceFilePath, destinationFilePath);
339327

340328
bool cloned = false;
@@ -438,7 +426,7 @@ internal bool Execute(
438426
bool success;
439427
try
440428
{
441-
success = parallelism == 1 || DestinationFiles.Length == 1
429+
success = parallelism == 1 || DestinationFiles!.Length == 1
442430
? CopySingleThreaded(copyFile, out destinationFilesSuccessfullyCopied)
443431
: CopyParallel(copyFile, parallelism, out destinationFilesSuccessfullyCopied);
444432
}
@@ -462,7 +450,7 @@ private bool CopySingleThreaded(
462450
out List<ITaskItem> destinationFilesSuccessfullyCopied)
463451
{
464452
bool success = true;
465-
destinationFilesSuccessfullyCopied = new List<ITaskItem>(DestinationFiles.Length);
453+
destinationFilesSuccessfullyCopied = new List<ITaskItem>(DestinationFiles!.Length);
466454

467455
// Set of files we actually copied and the location from which they were originally copied. The purpose
468456
// of this collection is to let us skip copying duplicate files. We will only copy the file if it
@@ -474,7 +462,7 @@ private bool CopySingleThreaded(
474462
StringComparer.OrdinalIgnoreCase);
475463

476464
// Now that we have a list of destinationFolder files, copy from source to destinationFolder.
477-
for (int i = 0; i < SourceFiles.Length && !_cancellationTokenSource.IsCancellationRequested; ++i)
465+
for (int i = 0; i < SourceFiles!.Length && !_cancellationTokenSource.IsCancellationRequested; ++i)
478466
{
479467
bool copyComplete = false;
480468
string destPath = DestinationFiles[i].ItemSpec;
@@ -544,10 +532,10 @@ private bool CopyParallel(
544532

545533
// Map: Destination path -> indexes in SourceFiles/DestinationItems array indices (ordered low->high).
546534
var partitionsByDestination = new Dictionary<string, List<int>>(
547-
DestinationFiles.Length, // Set length to common case of 1:1 source->dest.
535+
DestinationFiles!.Length, // Set length to common case of 1:1 source->dest.
548536
StringComparer.OrdinalIgnoreCase);
549537

550-
for (int i = 0; i < SourceFiles.Length && !_cancellationTokenSource.IsCancellationRequested; ++i)
538+
for (int i = 0; i < SourceFiles!.Length && !_cancellationTokenSource.IsCancellationRequested; ++i)
551539
{
552540
ITaskItem destItem = DestinationFiles[i];
553541
string destPath = destItem.ItemSpec;
@@ -680,7 +668,7 @@ private bool ValidateInputs()
680668
}
681669

682670
// If the caller passed in DestinationFiles, then its length must match SourceFiles.
683-
if (DestinationFiles != null && DestinationFiles.Length != SourceFiles.Length)
671+
if (DestinationFiles != null && DestinationFiles.Length != SourceFiles!.Length)
684672
{
685673
LogError(
686674
"MSB3094: \"{2}\" refers to {0} item(s), and \"{3}\" refers to {1} item(s). They must have the same number of items.",
@@ -706,19 +694,19 @@ private bool InitializeDestinationFiles()
706694
if (DestinationFiles == null)
707695
{
708696
// If the caller passed in DestinationFolder, convert it to DestinationFiles
709-
DestinationFiles = new ITaskItem[SourceFiles.Length];
697+
DestinationFiles = new ITaskItem[SourceFiles!.Length];
710698

711699
for (int i = 0; i < SourceFiles.Length; ++i)
712700
{
713701
// Build the correct path.
714702
string destinationFile;
715703
try
716704
{
717-
destinationFile = Path.Combine(DestinationFolder.ItemSpec, Path.GetFileName(SourceFiles[i].ItemSpec));
705+
destinationFile = Path.Combine(DestinationFolder!.ItemSpec, Path.GetFileName(SourceFiles[i].ItemSpec));
718706
}
719707
catch (ArgumentException e)
720708
{
721-
LogError("MSB3021: Unable to copy file \"{0}\" to \"{1}\". {2}", SourceFiles[i].ItemSpec, DestinationFolder.ItemSpec, e.Message);
709+
LogError("MSB3021: Unable to copy file \"{0}\" to \"{1}\". {2}", SourceFiles![i].ItemSpec!, DestinationFolder!.ItemSpec, e.Message);
722710
// Clear the outputs.
723711
DestinationFiles = Array.Empty<ITaskItem>();
724712
return false;
@@ -761,13 +749,9 @@ private bool DoCopyIfNecessary(FileState sourceFileState, FileState destinationF
761749
"true"
762750
);
763751
}
764-
// We only do the cheap check for identicalness here, we try the more expensive check
765-
// of comparing the fullpaths of source and destination to see if they are identical,
766-
// in the exception handler lower down.
767-
else if (!String.Equals(
768-
sourceFileState.Name,
769-
destinationFileState.Name,
770-
StringComparison.OrdinalIgnoreCase))
752+
// We only do a cheap check for path equality here, we try the more expensive check
753+
// of comparing the underlying file path (if symlinks/junctions are in use) in the exception handler lower down.
754+
else if (!string.Equals(sourceFileState.FullPath, destinationFileState.FullPath, StringComparison.OrdinalIgnoreCase))
771755
{
772756
success = DoCopyWithRetries(sourceFileState, destinationFileState, copyFile);
773757
}
@@ -864,7 +848,8 @@ private bool DoCopyWithRetries(FileState sourceFileState, FileState destinationF
864848
// if this was just because the source and destination files are the
865849
// same file, that's not a failure.
866850
// Note -- we check this exceptional case here, not before the copy, for perf.
867-
if (CopyExceptionHandling.PathsAreIdentical(sourceFileState.Name, destinationFileState.Name))
851+
// TODO: This does not handle the case of having deleted the destination file prior to trying to copy - we can delete the destination and it happens to be the source as well!
852+
if (CopyExceptionHandling.FullPathsAreIdentical(sourceFileState.FullPath, destinationFileState.FullPath))
868853
{
869854
return true;
870855
}
@@ -1023,11 +1008,11 @@ private bool TryCopyOnWrite(string sourceFullPath, string destFullPath)
10231008
try
10241009
{
10251010
CoW.CloneFile(sourceFullPath, destFullPath, CloneFlags.PathIsFullyResolved);
1026-
Log.LogMessage(MessageImportance.Low, $"CloneFile '{sourceFullPath}' to '{destFullPath}'.");
1011+
Log.LogMessage(MessageImportance.Low, $"Created copy-on-write link '{sourceFullPath}' to '{destFullPath}'.");
10271012
}
10281013
catch (Exception e)
10291014
{
1030-
Log.LogMessage($"Could not CloneFile '{sourceFullPath}' to '{destFullPath}'. Reason: {e.Message}");
1015+
Log.LogMessage($"Could not create copy-on-write link '{sourceFullPath}' to '{destFullPath}'. Reason: {e.Message}");
10311016
return false;
10321017
}
10331018

src/CopyOnWrite/MSBuild/FileState.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ private class FileDirInfo
4242
/// </summary>
4343
private readonly string _filename;
4444

45+
/// <summary>
46+
/// The fully resolved path (via Path.GetFullPath).
47+
/// </summary>
48+
public readonly string FullPath;
49+
4550
/// <summary>
4651
/// Set to true if file or directory exists
4752
/// </summary>
@@ -85,6 +90,7 @@ public FileDirInfo(string filename)
8590
LastWriteTimeUtc = new DateTime(1601, 1, 1);
8691

8792
_filename = FileUtilities.AttemptToShortenPath(filename); // This is no-op unless the path actually is too long
93+
FullPath = FileUtilities.NormalizePath(_filename);
8894

8995
int oldMode = 0;
9096

@@ -304,10 +310,19 @@ internal long Length
304310

305311
/// <summary>
306312
/// Name of the file as it was passed in.
307-
/// Not normalized.
313+
/// Not normalized unless the original path was too long.
308314
/// </summary>
309315
internal string Name => _filename;
310316

317+
internal string FullPath
318+
{
319+
get
320+
{
321+
_data.Value.ThrowException();
322+
return _data.Value.FullPath;
323+
}
324+
}
325+
311326
/// <summary>
312327
/// Use in case the state is known to have changed exogenously.
313328
/// </summary>

src/CopyOnWrite/MSBuild/FileUtilities.cs

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
using Microsoft.Build.Framework;
55
using System;
6-
using System.Collections.Concurrent;
76
using System.IO;
87

8+
#nullable enable
9+
910
namespace Microsoft.Build.Shared
1011
{
1112
/// <summary>
@@ -15,8 +16,6 @@ namespace Microsoft.Build.Shared
1516
/// </summary>
1617
internal static class FileUtilities
1718
{
18-
private static readonly ConcurrentDictionary<string, bool> FileExistenceCache = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
19-
2019
/// <summary>
2120
/// Gets the canonicalized full path of the provided path.
2221
/// Guidance for use: call this on all paths accepted through internal entry
@@ -26,20 +25,10 @@ internal static class FileUtilities
2625
/// </summary>
2726
internal static string NormalizePath(string path)
2827
{
29-
if (path == null)
30-
{
31-
throw new ArgumentNullException(nameof(path));
32-
}
33-
34-
string fullPath = GetFullPath(path);
28+
string fullPath = Path.GetFullPath(path);
3529
return FixFilePath(fullPath);
3630
}
3731

38-
private static string GetFullPath(string path)
39-
{
40-
return Path.GetFullPath(path);
41-
}
42-
4332
internal static string FixFilePath(string path)
4433
{
4534
return string.IsNullOrEmpty(path) || Path.DirectorySeparatorChar == '\\' ? path : path.Replace('\\', '/'); // .Replace("//", "/");

0 commit comments

Comments
 (0)