diff --git a/Directory.Build.targets b/Directory.Build.targets
index 902bacc3..6e7e89ec 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -41,7 +41,7 @@
-
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e67c5f58..66eb81b6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,12 +12,13 @@
-
+
-
-
+
+
+
diff --git a/MSBuildSdks.sln b/MSBuildSdks.sln
index 0171088c..6c20869a 100644
--- a/MSBuildSdks.sln
+++ b/MSBuildSdks.sln
@@ -82,6 +82,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SampleNoTargets", "SampleNo
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Build.CopyOnWrite", "src\CopyOnWrite\Microsoft.Build.CopyOnWrite.csproj", "{153D1183-2953-4D4D-A5AD-AA2CF99B0DE3}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Build.CopyOnWrite.UnitTests", "src\CopyOnWrite.UnitTests\Microsoft.Build.CopyOnWrite.UnitTests.csproj", "{AF9F2AFE-04D4-40B3-B17F-54ABD3DE7E4E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -140,6 +142,10 @@ Global
{153D1183-2953-4D4D-A5AD-AA2CF99B0DE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{153D1183-2953-4D4D-A5AD-AA2CF99B0DE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{153D1183-2953-4D4D-A5AD-AA2CF99B0DE3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AF9F2AFE-04D4-40B3-B17F-54ABD3DE7E4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AF9F2AFE-04D4-40B3-B17F-54ABD3DE7E4E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AF9F2AFE-04D4-40B3-B17F-54ABD3DE7E4E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AF9F2AFE-04D4-40B3-B17F-54ABD3DE7E4E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/Artifacts.UnitTests/RobocopyTests.cs b/src/Artifacts.UnitTests/RobocopyTests.cs
index 20672732..e88babaa 100644
--- a/src/Artifacts.UnitTests/RobocopyTests.cs
+++ b/src/Artifacts.UnitTests/RobocopyTests.cs
@@ -21,8 +21,6 @@ namespace Microsoft.Build.Artifacts.UnitTests
{
public class RobocopyTests : MSBuildSdkTestBase
{
- private static readonly bool IsWindows = Environment.OSVersion.Platform == PlatformID.Win32NT;
-
[Fact]
public void DedupKeyOsDifferences()
{
diff --git a/src/Artifacts/Microsoft.Build.Artifacts.csproj b/src/Artifacts/Microsoft.Build.Artifacts.csproj
index 01aa024c..39173854 100644
--- a/src/Artifacts/Microsoft.Build.Artifacts.csproj
+++ b/src/Artifacts/Microsoft.Build.Artifacts.csproj
@@ -11,6 +11,9 @@
snupkg
true
+
+
+
diff --git a/src/Artifacts/README.md b/src/Artifacts/README.md
index 53a3acd6..d8d6d8c1 100644
--- a/src/Artifacts/README.md
+++ b/src/Artifacts/README.md
@@ -172,7 +172,7 @@ The `` items specify collections of artifacts to stage. These items
| `IsRecursive` | Enables a recursive path search for artifacts to stage | `true` |
| `VerifyExists` | Enables a check that the file exists before copying | `true` |
| `AlwaysCopy` | Enables copies even if the destination already exists | `false` |
-| `OnlyNewer` | Enables copies only if the destnation exist and the source is newer | `false` |
+| `OnlyNewer` | Enables copies only if the destination exists and the source is newer | `false` |
| `FileMatch` | A list of one or more file filters seperated by a space or semicolon to include. Wildcards include `*` and `?` | `*`|
| `FileExclude` | A list of one or more file filters seperated by a space or semicolon to exclude. Wildcards include `*` and `?` | |
| `DirExclude` | A list of one or more directory filters seperated by a space or semicolon to exclude. Wildcards include `*` and `?` | |
diff --git a/src/Artifacts/Tasks/Robocopy.cs b/src/Artifacts/Tasks/Robocopy.cs
index f90e157f..4881b9de 100644
--- a/src/Artifacts/Tasks/Robocopy.cs
+++ b/src/Artifacts/Tasks/Robocopy.cs
@@ -3,6 +3,7 @@
// Licensed under the MIT license.
using Microsoft.Build.Framework;
+using Microsoft.Build.Shared;
using Microsoft.Build.Utilities;
using System;
using System.Collections.Concurrent;
@@ -30,7 +31,6 @@ public class Robocopy : Task
private static readonly ExecutionDataflowBlockOptions ActionBlockOptions = new () { MaxDegreeOfParallelism = MsBuildCopyParallelism, EnsureOrdered = MsBuildCopyParallelism == 1 };
private readonly ConcurrentDictionary _dirsCreated = new (Artifacts.FileSystem.PathComparer);
- private readonly Dictionary _realDirPaths = new (Artifacts.FileSystem.PathComparer); // Cache results of symlink resolution to avoid I/O.
private readonly HashSet _destinationPathsStarted = new (Artifacts.FileSystem.PathComparer); // Destination paths that were dispatched to copy. Extra copies to the same destination are copied single-threaded in a second wave.
private readonly List _duplicateDestinationDelayedJobs = new (); // Jobs that were delayed because they were to a destination path that was already dispatched to copy.
private readonly ActionBlock _copyFileBlock;
@@ -66,7 +66,7 @@ public Robocopy()
public bool ShowDiagnosticTrace { get; set; }
///
- /// Gets or sets a value indicating whether to log errors on retries
+ /// Gets or sets a value indicating whether to log errors on retries.
///
public bool ShowErrorOnRetry { get; set; }
@@ -156,7 +156,7 @@ private void CopyFile(FileInfo sourceFile, FileInfo destFile, RobocopyMetadata m
}
else if (!_filesCopied.Contains(new CopyFileDedupKey(sourceFile.FullName, destFile.FullName)))
{
- Log.LogMessage("Delaying {0} to {1} as duplicate destination", sourceFile.FullName, destFile.FullName);
+ Log.LogMessage("Delaying copying {0} to {1} as duplicate destination", sourceFile.FullName, destFile.FullName);
_duplicateDestinationDelayedJobs.Add(new CopyJob(sourceFile, destFile, metadata));
}
else
@@ -216,7 +216,7 @@ private void CopyFileImpl(FileInfo sourceFile, FileInfo destFile, RobocopyMetada
}
else
{
- Log.LogMessage(MessageImportance.Low, "Skipped copying {0} to {1}", sourceFile.FullName, destFile.FullName);
+ Log.LogMessage(MessageImportance.Low, "Skipped copying {0} to {1}", sourcePath, destPath);
Interlocked.Increment(ref _numFilesSkipped);
}
@@ -224,7 +224,11 @@ private void CopyFileImpl(FileInfo sourceFile, FileInfo destFile, RobocopyMetada
}
catch (IOException e)
{
- LogCopyFailureAndSleep(retry, "Failed to copy {0} to {1}. {2}", sourcePath, destPath, e.Message);
+ // Avoid issuing an error if the paths are actually to the same file.
+ if (!CopyExceptionHandling.FullPathsAreIdentical(sourcePath, destPath))
+ {
+ LogCopyFailureAndSleep(retry, "Failed to copy {0} to {1}. {2}", sourcePath, destPath, e.Message);
+ }
}
}
}
@@ -257,19 +261,15 @@ private void CopyItems(IList items, DirectoryInfo source)
{
foreach (string file in item.FileMatches)
{
- // Break down symlinks/junctions to their real paths to avoid duplicate copies.
string sourcePath = Path.Combine(sourceDir, file);
FileInfo sourceFile = new FileInfo(sourcePath);
- if (Verify(sourceFile, true, item.VerifyExists))
+ if (Verify(sourceFile, item.VerifyExists))
{
foreach (string destDir in item.DestinationFolders)
{
string destPath = Path.Combine(destDir, file);
FileInfo destFile = new FileInfo(destPath);
- if (Verify(destFile, shouldExist: false, false))
- {
- CopyFile(sourceFile, destFile, item);
- }
+ CopyFile(sourceFile, destFile, item);
}
}
}
@@ -278,8 +278,6 @@ private void CopyItems(IList items, DirectoryInfo source)
private void CopySearch(IList bucket, bool isRecursive, string match, DirectoryInfo source, string? subDirectory)
{
- string sourceDir = source.FullName;
-
bool hasSubDirectory = !string.IsNullOrEmpty(subDirectory);
foreach (FileInfo sourceFile in FileSystem.EnumerateFiles(source, match))
@@ -294,10 +292,7 @@ private void CopySearch(IList bucket, bool isRecursive, string
string destDir = hasSubDirectory ? Path.Combine(destinationDir, subDirectory!) : destinationDir;
string destPath = Path.Combine(destDir, fileName);
FileInfo destFile = new FileInfo(destPath);
- if (Verify(destFile, shouldExist: false, false))
- {
- CopyFile(sourceFile, destFile, item);
- }
+ CopyFile(sourceFile, destFile, item);
}
}
}
@@ -444,9 +439,9 @@ private void LogCopyFailureAndSleep(int attempt, string message, params object[]
}
}
- private bool Verify(FileInfo file, bool shouldExist, bool verifyExists)
+ private bool Verify(FileInfo file, bool verifyExists)
{
- if (!shouldExist || FileSystem.FileExists(file))
+ if (FileSystem.FileExists(file))
{
return true;
}
diff --git a/src/CopyOnWrite.UnitTests/CopyExceptionHandlingTests.cs b/src/CopyOnWrite.UnitTests/CopyExceptionHandlingTests.cs
new file mode 100644
index 00000000..7f90f1ac
--- /dev/null
+++ b/src/CopyOnWrite.UnitTests/CopyExceptionHandlingTests.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+// Licensed under the MIT license.
+
+using Microsoft.Build.Framework;
+using Microsoft.Build.Shared;
+using Microsoft.Build.UnitTests.Common;
+using System.IO;
+using Xunit;
+
+namespace Microsoft.Build.CopyOnWrite.UnitTests;
+
+public class CopyExceptionHandlingTests : MSBuildSdkTestBase
+{
+ [Fact]
+ public void PathsAreIdentical_NoSymlinks()
+ {
+ string osRoot = IsWindows ? @"C:\" : "/";
+ string osCapitalizationDependentName = IsWindows ? "NAME" : "name";
+ Assert.True(CopyExceptionHandling.PathsAreIdentical(osRoot, osRoot));
+ Assert.True(CopyExceptionHandling.PathsAreIdentical(Path.Combine(osRoot, "name"), Path.Combine(osCapitalizationDependentName)));
+ Assert.True(CopyExceptionHandling.PathsAreIdentical(osRoot, Path.Combine(osRoot, "subDir", "..")));
+ }
+
+ [Fact]
+ public void PathsAreIdentical_Symlinks()
+ {
+ if (!UserCanCreateSymlinks())
+ {
+ return;
+ }
+
+ using var tempDir = new DisposableTempDirectory();
+ string regularFilePath = Path.Combine(tempDir.Path, "regular.txt");
+ File.WriteAllText(regularFilePath, "regular");
+ Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, regularFilePath));
+
+ string regularFilePathWithNonCanonicalSegments =
+ Path.Combine(tempDir.Path, "..", Path.GetFileName(tempDir.Path), ".", "regular.txt");
+ Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, regularFilePathWithNonCanonicalSegments));
+
+ string symlinkPath = Path.Combine(tempDir.Path, "symlink_to_regular.txt");
+ string? errorMessage = null;
+ bool linkCreated = NativeMethods.MakeSymbolicLink(symlinkPath, regularFilePath, ref errorMessage);
+ Assert.True(linkCreated);
+ Assert.Null(errorMessage);
+ Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, symlinkPath));
+
+ File.Delete(symlinkPath);
+ errorMessage = null;
+ linkCreated = NativeMethods.MakeSymbolicLink(symlinkPath, regularFilePathWithNonCanonicalSegments, ref errorMessage);
+ Assert.True(linkCreated);
+ Assert.Null(errorMessage);
+ Assert.True(CopyExceptionHandling.PathsAreIdentical(regularFilePath, symlinkPath));
+ }
+
+ private bool UserCanCreateSymlinks()
+ {
+ return !IsWindows || IsAdministratorOnWindows();
+ }
+}
diff --git a/src/CopyOnWrite.UnitTests/Microsoft.Build.CopyOnWrite.UnitTests.csproj b/src/CopyOnWrite.UnitTests/Microsoft.Build.CopyOnWrite.UnitTests.csproj
new file mode 100644
index 00000000..5aa466bf
--- /dev/null
+++ b/src/CopyOnWrite.UnitTests/Microsoft.Build.CopyOnWrite.UnitTests.csproj
@@ -0,0 +1,24 @@
+
+
+ net472;netcoreapp3.1;net6.0
+ false
+ Enable
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CopyOnWrite/Copy.cs b/src/CopyOnWrite/Copy.cs
index b73cf65d..513b5709 100644
--- a/src/CopyOnWrite/Copy.cs
+++ b/src/CopyOnWrite/Copy.cs
@@ -864,7 +864,7 @@ private bool DoCopyWithRetries(FileState sourceFileState, FileState destinationF
// if this was just because the source and destination files are the
// same file, that's not a failure.
// Note -- we check this exceptional case here, not before the copy, for perf.
- if (PathsAreIdentical(sourceFileState.Name, destinationFileState.Name))
+ if (CopyExceptionHandling.PathsAreIdentical(sourceFileState.Name, destinationFileState.Name))
{
return true;
}
@@ -968,18 +968,6 @@ public override bool Execute()
#endregion
- ///
- /// Compares two paths to see if they refer to the same file. We can't solve the general
- /// canonicalization problem, so we just compare strings on the full paths.
- ///
- private static bool PathsAreIdentical(string source, string destination)
- {
- string fullSourcePath = Path.GetFullPath(source);
- string fullDestinationPath = Path.GetFullPath(destination);
- StringComparison filenameComparison = NativeMethods.IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
- return String.Equals(fullSourcePath, fullDestinationPath, filenameComparison);
- }
-
private static int GetParallelismFromEnvironment()
{
int parallelism = CopyTaskParallelism;
diff --git a/src/CopyOnWrite/MSBuild/NativeMethods.cs b/src/CopyOnWrite/MSBuild/NativeMethods.cs
index d9fcb811..3f26f4a6 100644
--- a/src/CopyOnWrite/MSBuild/NativeMethods.cs
+++ b/src/CopyOnWrite/MSBuild/NativeMethods.cs
@@ -2,11 +2,14 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
+using System.Text;
using Microsoft.Win32;
+using Microsoft.Win32.SafeHandles;
-#nullable disable
+#nullable enable
namespace Microsoft.Build.Framework;
@@ -104,7 +107,7 @@ private static bool IsLongPathsEnabledRegistry()
{
using (RegistryKey fileSystemKey = Registry.LocalMachine.OpenSubKey(WINDOWS_FILE_SYSTEM_REGISTRY_KEY))
{
- object longPathsEnabledValue = fileSystemKey?.GetValue(WINDOWS_LONG_PATHS_ENABLED_VALUE_NAME, 0);
+ object? longPathsEnabledValue = fileSystemKey?.GetValue(WINDOWS_LONG_PATHS_ENABLED_VALUE_NAME, 0);
return fileSystemKey != null && Convert.ToInt32(longPathsEnabledValue) == 1;
}
}
@@ -132,7 +135,7 @@ internal static bool IsWindows
[DllImport("libc", SetLastError = true)]
internal static extern int link(string oldpath, string newpath);
- internal static bool MakeHardLink(string newFileName, string exitingFileName, ref string errorMessage)
+ internal static bool MakeHardLink(string newFileName, string exitingFileName, ref string? errorMessage)
{
bool hardLinkCreated;
if (IsWindows)
@@ -152,29 +155,29 @@ internal static bool MakeHardLink(string newFileName, string exitingFileName, re
//------------------------------------------------------------------------------
// CreateSymbolicLink
//------------------------------------------------------------------------------
- internal enum SymbolicLink
+ private enum SymbolicLink
{
File = 0,
Directory = 1
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.I1)]
- internal static extern bool CreateSymbolicLink(string symLinkFileName, string targetFileName, SymbolicLink dwFlags);
+ private static extern bool CreateSymbolicLink(string symLinkFileName, string targetFileName, SymbolicLink dwFlags);
[DllImport("libc", SetLastError = true)]
- internal static extern int symlink(string oldpath, string newpath);
+ private static extern int symlink(string oldpath, string newpath);
- internal static bool MakeSymbolicLink(string newFileName, string exitingFileName, ref string errorMessage)
+ public static bool MakeSymbolicLink(string newFileName, string existingFileName, ref string? errorMessage)
{
bool symbolicLinkCreated;
if (IsWindows)
{
- symbolicLinkCreated = CreateSymbolicLink(newFileName, exitingFileName, SymbolicLink.File);
+ symbolicLinkCreated = CreateSymbolicLink(newFileName, existingFileName, SymbolicLink.File);
errorMessage = symbolicLinkCreated ? null : Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()).Message;
}
else
{
- symbolicLinkCreated = symlink(exitingFileName, newFileName) == 0;
+ symbolicLinkCreated = symlink(existingFileName, newFileName) == 0;
errorMessage = symbolicLinkCreated ? null : "The link() library call failed with the following error code: " + Marshal.GetLastWin32Error();
}
diff --git a/src/CopyOnWrite/Microsoft.Build.CopyOnWrite.csproj b/src/CopyOnWrite/Microsoft.Build.CopyOnWrite.csproj
index 0a661bdf..8af77688 100644
--- a/src/CopyOnWrite/Microsoft.Build.CopyOnWrite.csproj
+++ b/src/CopyOnWrite/Microsoft.Build.CopyOnWrite.csproj
@@ -13,6 +13,10 @@
true
+
+
+
+
build\
@@ -25,7 +29,7 @@
-
+
compile; build; native; contentfiles; analyzers; buildtransitive
@@ -37,4 +41,8 @@
+
+
+
+
diff --git a/src/Shared/CopyExceptionHandling.cs b/src/Shared/CopyExceptionHandling.cs
new file mode 100644
index 00000000..ea2beb9e
--- /dev/null
+++ b/src/Shared/CopyExceptionHandling.cs
@@ -0,0 +1,235 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+// Licensed under the MIT license.
+
+using Microsoft.Win32.SafeHandles;
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+
+#nullable enable
+
+#pragma warning disable SA1201 // Enum should not follow method
+
+namespace Microsoft.Build.Shared;
+
+///
+/// Utility methods for classifying and handling exceptions during copy operations.
+///
+internal static class CopyExceptionHandling
+{
+ private static readonly StringComparison PathComparison = IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ private static bool? _isWindows;
+
+ ///
+ /// Gets a value indicating whether we are running under some version of Windows.
+ ///
+ private static bool IsWindows
+ {
+ get
+ {
+ _isWindows ??= RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ return _isWindows.Value;
+ }
+ }
+
+ ///
+ /// Compares two paths to see if they refer to the same file, regardless of different path canonicalization
+ /// (e.g. inclusion of '.' or '..' path segments in one or the other path) or symlinks.
+ /// Because of slow performance, this method is intended for use in exception paths for IOException.
+ ///
+ /// The source file path.
+ /// The destination file path.
+ /// True if the paths refer to the same file and a copy operation failure can be ignored.
+ internal static bool PathsAreIdentical(string source, string destination)
+ {
+ // Collapse path parts like '.' and '..' into a more canonical format.
+ string fullSourcePath = Path.GetFullPath(source);
+ string fullDestinationPath = Path.GetFullPath(destination);
+ if (string.Equals(fullSourcePath, fullDestinationPath, PathComparison))
+ {
+ return true;
+ }
+
+ return FullPathsAreIdentical(fullSourcePath, fullDestinationPath);
+ }
+
+ ///
+ /// Compares two paths to see if they refer to the same file, regardless of symlinks.
+ /// Assumes the provided paths have already been canonicalized via Path.GetFullPath().
+ /// Because of slow performance, this method is intended for use in exception paths for IOException.
+ ///
+ /// The source file path.
+ /// The destination file path.
+ /// True if the paths refer to the same file and a copy operation failure can be ignored.
+ internal static bool FullPathsAreIdentical(string source, string destination)
+ {
+ // Might be copying a file onto itself via symlinks. Compare the resolved paths.
+ string? symlinkResolvedSource = GetRealPathOrNull(source);
+ string? symlinkResolvedDestination = GetRealPathOrNull(destination);
+ return string.Equals(symlinkResolvedSource, symlinkResolvedDestination, PathComparison);
+ }
+
+ ///
+ /// Expands all symlinks and returns a fully resolved and normalized path.
+ ///
+ /// The unresolved path.
+ ///
+ /// Null if the real path does not exist after expanding all symlinks in
+ /// or if there was an error retrieving the final path.
+ ///
+ internal static string? GetRealPathOrNull(string path)
+ {
+ if (IsWindows)
+ {
+ SafeFileHandle handle = CreateFileW(
+ path,
+ FileDesiredAccess.None,
+ FileShare.Read | FileShare.Delete,
+ lpSecurityAttributes: IntPtr.Zero,
+ dwCreationDisposition: FileMode.Open,
+ dwFlagsAndAttributes: FileFlagsAndAttributes.FileFlagBackupSemantics,
+ hTemplateFile: IntPtr.Zero);
+ if (handle.IsInvalid)
+ {
+ return null;
+ }
+
+ using (handle)
+ {
+ try
+ {
+ return GetFinalPathNameByHandle(handle);
+ }
+ catch (Win32Exception)
+ {
+ return null;
+ }
+ }
+ }
+
+ // Linux or Mac - use the realpath syscall.
+ const int maxPath = 4096;
+ var sb = new StringBuilder(maxPath);
+ IntPtr ptr = RealPath(path, sb);
+ return ptr != IntPtr.Zero ? sb.ToString() : null;
+ }
+
+ // Adapted from https://github.com/microsoft/BuildXL/blob/c6e4c1a4f1b2f4ebac3ed2fe3e4b81a7908d1843/Public/Src/Utilities/Native/IO/Windows/FileSystem.Win.cs#L2768
+ private static string GetFinalPathNameByHandle(SafeFileHandle handle, bool volumeGuidPath = false)
+ {
+ const int VolumeNameGuid = 0x1;
+ const int maxPath = 260;
+ var pathBuffer = new StringBuilder(maxPath + 1);
+ int neededSize = maxPath;
+
+ do
+ {
+ // Capacity must include the null terminator character
+ pathBuffer.EnsureCapacity(neededSize + 1);
+ neededSize = GetFinalPathNameByHandleW(handle, pathBuffer, pathBuffer.Capacity, flags: volumeGuidPath ? VolumeNameGuid : 0);
+ if (neededSize == 0)
+ {
+ int winErr = Marshal.GetLastWin32Error();
+
+ // ERROR_PATH_NOT_FOUND
+ if (winErr == 0x3 && !volumeGuidPath)
+ {
+ return GetFinalPathNameByHandle(handle, volumeGuidPath: true);
+ }
+
+ throw new Win32Exception(winErr, $"Error calling {nameof(GetFinalPathNameByHandleW)}");
+ }
+ }
+ while (neededSize >= pathBuffer.Capacity);
+
+ bool expectedPrefixIsPresent = true;
+
+ // The returned path can either have a \\?\ or a \??\ prefix
+ // Observe LongPathPrefix and NtPathPrefix have the same length
+ const string LongPathPrefix = @"\\?\";
+ const string NtPathPrefix = @"\??\";
+ if (pathBuffer.Length >= LongPathPrefix.Length)
+ {
+ for (int i = 0; i < LongPathPrefix.Length; i++)
+ {
+ int currentChar = pathBuffer[i];
+ if (!(currentChar == LongPathPrefix[i] || currentChar == NtPathPrefix[i]))
+ {
+ expectedPrefixIsPresent = false;
+ break;
+ }
+ }
+ }
+ else
+ {
+ expectedPrefixIsPresent = false;
+ }
+
+ // Some paths do not come back with any prefixes. This is the case for example of unix-like paths
+ // that some tools, even on Windows, decide to probe
+ if (volumeGuidPath || !expectedPrefixIsPresent)
+ {
+ return pathBuffer.ToString();
+ }
+
+ return pathBuffer.ToString(startIndex: LongPathPrefix.Length, length: pathBuffer.Length - LongPathPrefix.Length);
+ }
+
+ // https://man7.org/linux/man-pages/man3/realpath.3.html
+ [DllImport("libc", EntryPoint = "realpath", SetLastError = true, ExactSpelling = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)]
+ private static extern IntPtr RealPath(string path, StringBuilder resolvedPath);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ private static extern SafeFileHandle CreateFileW(
+ string lpFileName,
+ FileDesiredAccess dwDesiredAccess,
+ FileShare dwShareMode,
+ IntPtr lpSecurityAttributes,
+ FileMode dwCreationDisposition,
+ FileFlagsAndAttributes dwFlagsAndAttributes,
+ IntPtr hTemplateFile);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ private static extern int GetFinalPathNameByHandleW(SafeFileHandle hFile, [Out] StringBuilder filePathBuffer, int filePathBufferSize, int flags);
+
+ [Flags]
+ private enum FileDesiredAccess : uint
+ {
+ ///
+ /// No access requested.
+ ///
+ None = 0,
+
+ ///
+ /// See http://msdn.microsoft.com/en-us/library/windows/desktop/aa364399(v=vs.85).aspx
+ ///
+ GenericWrite = 0x40000000,
+ }
+
+ [Flags]
+ private enum FileFlagsAndAttributes : uint
+ {
+ ///
+ /// Normal reparse point processing will not occur; CreateFile will attempt to open the reparse point. When a file is
+ /// opened, a file handle is returned, whether or not the filter that controls the reparse point is operational.
+ /// This flag cannot be used with the CREATE_ALWAYS flag.
+ /// If the file is not a reparse point, then this flag is ignored.
+ ///
+ FileFlagOpenReparsePoint = 0x00200000,
+
+ ///
+ /// The file is being opened or created for a backup or restore operation. The system ensures that the calling process
+ /// overrides file security checks when the process has SE_BACKUP_NAME and SE_RESTORE_NAME privileges. For more
+ /// information, see Changing Privileges in a Token.
+ /// You must set this flag to obtain a handle to a directory. A directory handle can be passed to some functions instead of
+ /// a file handle.
+ ///
+ FileFlagBackupSemantics = 0x02000000,
+ }
+}
diff --git a/src/TestShared/DisposableTempDirectory.cs b/src/TestShared/DisposableTempDirectory.cs
new file mode 100644
index 00000000..372b1e1c
--- /dev/null
+++ b/src/TestShared/DisposableTempDirectory.cs
@@ -0,0 +1,131 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+//
+// Licensed under the MIT license.
+
+using System;
+using System.IO;
+using System.Threading;
+
+#nullable enable
+
+namespace Microsoft.Build.UnitTests.Common;
+
+///
+/// Helper class that deletes the temp directory on dispose.
+///
+internal sealed class DisposableTempDirectory : IDisposable
+{
+ ///
+ /// Stores the previous current directory if one is specified.
+ ///
+ private readonly string? _previousCurrentDirectory;
+
+ ///
+ /// The caller can decide whether to throw an exception if deleting the temporary directory at time of disposing fails.
+ ///
+ private readonly bool _throwIfDeleteFails;
+
+ ///
+ /// Signifies if the current object has been disposed.
+ /// 1 = true
+ /// 0 = false.
+ ///
+ ///
+ /// Using int because does not have boolean methods.
+ ///
+ private int _isDisposed;
+
+ ///
+ /// Creates a temporary directory under %TEMP%.
+ ///
+ /// true
to set the temp directory as the current directory, otherwise.false
. The current directory is restored when the object is disposed.
+ public DisposableTempDirectory(bool setCurrentDirectory = false)
+ : this(System.IO.Path.GetTempPath(), setCurrentDirectory)
+ {
+ }
+
+ ///
+ /// Creates a directory under basePath.
+ ///
+ /// The root path to create a temporary directory under.
+ /// true
to set the temp directory as the current directory, otherwise false. The current directory is restored when the object is disposed.
+ /// When true, throws on delete failure.
+ public DisposableTempDirectory(string basePath, bool setCurrentDirectory = false, bool throwIfDeleteFails = true)
+ {
+ _throwIfDeleteFails = throwIfDeleteFails;
+
+ Path = CreateRandomDirectory(basePath);
+
+ if (setCurrentDirectory)
+ {
+ // Save the current directory and then switch to the temp directory
+ _previousCurrentDirectory = Environment.CurrentDirectory;
+ Environment.CurrentDirectory = Path;
+ }
+ }
+
+ ///
+ /// Path to the temp directory.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Deletes temp folder wrapped by the class.
+ ///
+ public void Dispose()
+ {
+ // Thread-safe non-reentrant check
+ if (Interlocked.Exchange(ref _isDisposed, 1) == 1)
+ {
+ return;
+ }
+
+ // Restore the previous current directory if necessary
+ if (_previousCurrentDirectory != null)
+ {
+ Environment.CurrentDirectory = _previousCurrentDirectory;
+ }
+
+ try
+ {
+ RecursiveDeleteDirectory(Path);
+ }
+ catch
+ {
+ if (_throwIfDeleteFails)
+ {
+ throw;
+ }
+ }
+ }
+
+ ///
+ /// Creates a random directory with a unique name under basePath, returning its path.
+ ///
+ private static string CreateRandomDirectory(string basePath)
+ {
+ string path = System.IO.Path.Combine(basePath, System.IO.Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static void RecursiveDeleteDirectory(string dir)
+ {
+ // Recursive delete can silently fail to delete sometimes.
+ const int retries = 5;
+ for (int i = 0; i < retries && Directory.Exists(dir); i++)
+ {
+ try
+ {
+ Directory.Delete(dir, true);
+ }
+ catch
+ {
+ if (i == retries - 1)
+ {
+ throw;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Shared/MSBuildSdkTestBase.cs b/src/TestShared/MSBuildSdkTestBase.cs
similarity index 73%
rename from src/Shared/MSBuildSdkTestBase.cs
rename to src/TestShared/MSBuildSdkTestBase.cs
index 382f84fe..6eda7dad 100644
--- a/src/Shared/MSBuildSdkTestBase.cs
+++ b/src/TestShared/MSBuildSdkTestBase.cs
@@ -4,11 +4,14 @@
using Microsoft.Build.Utilities.ProjectCreation;
using System;
-using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
+using System.Security.Principal;
using System.Threading;
+#nullable enable
+
namespace Microsoft.Build.UnitTests.Common
{
public abstract class MSBuildSdkTestBase : MSBuildTestBase, IDisposable
@@ -16,7 +19,7 @@ public abstract class MSBuildSdkTestBase : MSBuildTestBase, IDisposable
private readonly string _testRootPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
private readonly string _previousCurrentDirectory = Environment.CurrentDirectory;
- public MSBuildSdkTestBase()
+ protected MSBuildSdkTestBase()
{
File.WriteAllText(
Path.Combine(TestRootPath, "NuGet.config"),
@@ -31,7 +34,9 @@ public MSBuildSdkTestBase()
Environment.CurrentDirectory = TestRootPath;
}
- public string TestRootPath
+ protected bool IsWindows { get; } = Environment.OSVersion.Platform == PlatformID.Win32NT;
+
+ protected string TestRootPath
{
get
{
@@ -52,7 +57,7 @@ protected DirectoryInfo CreateFiles(string directoryName, params string[] files)
foreach (FileInfo file in files.Select(i => new FileInfo(Path.Combine(directory.FullName, i))))
{
- file.Directory.Create();
+ file.Directory?.Create();
File.WriteAllBytes(file.FullName, new byte[0]);
}
@@ -88,6 +93,21 @@ protected virtual void Dispose(bool disposing)
}
}
+#pragma warning disable SA1204 // OS-specific - internal logic guards from non-Windows usage
+ [SuppressMessage("Interoperability", "CA1416: Validate platform compatibility", Justification = "Internal logic guards from non-Windows usage")]
+ protected bool IsAdministratorOnWindows()
+ {
+ if (!IsWindows)
+ {
+ throw new InvalidOperationException();
+ }
+
+ using WindowsIdentity identity = WindowsIdentity.GetCurrent();
+ WindowsPrincipal principal = new WindowsPrincipal(identity);
+ return principal.IsInRole(WindowsBuiltInRole.Administrator);
+ }
+#pragma warning restore SA1204
+
protected string GetTempFile(string name)
{
if (name == null)
@@ -98,7 +118,7 @@ protected string GetTempFile(string name)
return Path.Combine(TestRootPath, name);
}
- protected string GetTempFileWithExtension(string extension = null)
+ protected string GetTempFileWithExtension(string? extension = null)
{
return Path.Combine(TestRootPath, $"{Path.GetRandomFileName()}{extension ?? string.Empty}");
}
diff --git a/src/Shared/MockTaskItem.cs b/src/TestShared/MockTaskItem.cs
similarity index 93%
rename from src/Shared/MockTaskItem.cs
rename to src/TestShared/MockTaskItem.cs
index 4b425b04..955147fb 100644
--- a/src/Shared/MockTaskItem.cs
+++ b/src/TestShared/MockTaskItem.cs
@@ -7,6 +7,8 @@
using System.Collections;
using System.Collections.Generic;
+#nullable enable
+
namespace Microsoft.Build.UnitTests.Common
{
internal class MockTaskItem : Dictionary, ITaskItem2
@@ -17,7 +19,7 @@ public MockTaskItem(string itemSpec)
ItemSpec = itemSpec;
}
- public string EvaluatedIncludeEscaped { get; set; }
+ public string? EvaluatedIncludeEscaped { get; set; }
public string ItemSpec { get; set; }
@@ -45,7 +47,7 @@ public void CopyMetadataTo(ITaskItem destinationItem)
public string GetMetadata(string metadataName)
{
- if (TryGetValue(metadataName, out string value))
+ if (TryGetValue(metadataName, out string? value))
{
return value;
}