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; }