File: FileUtilities.cs
Web Access
Project: src\msbuild\src\Framework\Microsoft.Build.Framework.csproj (Microsoft.Build.Framework)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
#if NET
using System.Buffers;
#endif
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using Microsoft.Build.Shared;
using Microsoft.Build.Shared.FileSystem;

#if NETFRAMEWORK
using NewPath = Microsoft.IO.Path;
using Path = System.IO.Path;
#else
using NewPath = System.IO.Path;
using Path = System.IO.Path;
#endif

namespace Microsoft.Build.Framework
{
    /// <summary>
    /// This class contains utility methods for file IO.
    /// Functions from FileUtilities are transferred here as part of the effort to remove Shared files.
    /// </summary>
    internal static partial class FileUtilities
    {
        private const char UnixDirectorySeparator = '/';
        private const char WindowsDirectorySeparator = '\\';

        internal static readonly char[] Slashes = [UnixDirectorySeparator, WindowsDirectorySeparator];

        /// <summary>
        /// AsyncLocal working directory for use during property/item expansion in multithreaded mode.
        /// Set by MultiThreadedTaskEnvironmentDriver when building projects. null in multi-process mode.
        /// Using AsyncLocal ensures the value flows to child threads/tasks spawned during execution of tasks.
        /// </summary>
        private static readonly AsyncLocal<string?> s_currentThreadWorkingDirectory = new();

        internal static string? CurrentThreadWorkingDirectory
        {
            get => s_currentThreadWorkingDirectory.Value;
            set => s_currentThreadWorkingDirectory.Value = value;
        }

        /// <summary>
        /// The directory where MSBuild stores cache information used during the build.
        /// </summary>
        internal static string? cacheDirectory = null;

        /// <summary>
        /// FOR UNIT TESTS ONLY
        /// Clear out the static variable used for the cache directory so that tests that
        /// modify it can validate their modifications.
        /// </summary>
        internal static void ClearCacheDirectoryPath()
        {
            cacheDirectory = null;
        }

        public static readonly StringComparison PathComparison = IsFileSystemCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;

        public static readonly StringComparer PathComparer = IsFileSystemCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;

        private static bool? s_isFileSystemCaseSensitive;

        public static bool IsFileSystemCaseSensitive
            => s_isFileSystemCaseSensitive ??= ComputeIsFileSystemCaseSensitive();

        /// <summary>
        ///  Determines whether the file system is case sensitive.
        ///  Copied from https://github.com/dotnet/runtime/blob/73ba11f3015216b39cb866d9fb7d3d25e93489f2/src/libraries/Common/src/System/IO/PathInternal.CaseSensitivity.cs#L41-L59.
        /// </summary>
        private static bool ComputeIsFileSystemCaseSensitive()
        {
            try
            {
                string pathWithUpperCase = Path.Combine(Path.GetTempPath(), $"CASESENSITIVETEST{Guid.NewGuid():N}");
                using (new FileStream(pathWithUpperCase, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose))
                {
                    string lowerCased = pathWithUpperCase.ToLowerInvariant();
                    return !FileSystems.Default.FileExists(lowerCased);
                }
            }
            catch (Exception ex)
            {
                // In case something goes terribly wrong, we don't want to fail just because
                // of a casing test, so we assume case-insensitive-but-preserving.
                Debug.Fail($"Casing test failed: {ex}");
                return false;
            }
        }

        /// <summary>
        /// Copied from https://github.com/dotnet/corefx/blob/056715ff70e14712419d82d51c8c50c54b9ea795/src/Common/src/System/IO/PathInternal.Windows.cs#L61
        /// MSBuild should support the union of invalid path chars across the supported OSes, so builds can have the same behaviour crossplatform: https://github.com/dotnet/msbuild/issues/781#issuecomment-243942514
        /// </summary>
#if NET
        internal static readonly SearchValues<char> InvalidPathChars = SearchValues.Create(
#else
        internal static readonly char[] InvalidPathChars = (
#endif
        [
            '|', '\0',
            (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10,
            (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20,
            (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30,
            (char)31
        ]);

        /// <summary>
        /// Copied from https://github.com/dotnet/corefx/blob/387cf98c410bdca8fd195b28cbe53af578698f94/src/System.Runtime.Extensions/src/System/IO/Path.Windows.cs#L18
        /// MSBuild should support the union of invalid path chars across the supported OSes, so builds can have the same behaviour crossplatform: https://github.com/dotnet/msbuild/issues/781#issuecomment-243942514
        /// </summary>
        internal static readonly char[] InvalidFileNameCharsArray =
        [
            '\"', '<', '>', '|', '\0',
            (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10,
            (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20,
            (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30,
            (char)31, ':', '*', '?', '\\', '/'
        ];

#if NET
        internal static readonly SearchValues<char> InvalidFileNameChars = SearchValues.Create(InvalidFileNameCharsArray);
#else
        internal static char[] InvalidFileNameChars => InvalidFileNameCharsArray;
#endif

        internal static readonly string DirectorySeparatorString = Path.DirectorySeparatorChar.ToString();

        private static readonly ConcurrentDictionary<string, bool> FileExistenceCache = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);

        private static readonly IFileSystem DefaultFileSystem = FileSystems.Default;

        /// <summary>
        /// Retrieves the MSBuild runtime cache directory
        /// </summary>
        internal static string GetCacheDirectory()
        {
            if (cacheDirectory == null)
            {
                cacheDirectory = Path.Combine(TempFileDirectory, string.Format(CultureInfo.CurrentUICulture, "MSBuild{0}-{1}", EnvironmentUtilities.CurrentProcessId, AppDomain.CurrentDomain.Id));
            }

            return cacheDirectory;
        }

        /// <summary>
        /// Clears the MSBuild runtime cache
        /// </summary>
        internal static void ClearCacheDirectory()
        {
            string cacheDirectory = GetCacheDirectory();

            if (DefaultFileSystem.DirectoryExists(cacheDirectory))
            {
                DeleteDirectoryNoThrow(cacheDirectory, true);
            }
        }

        /// <summary>
        /// Indicates if the given character is a slash in current OS.
        /// </summary>
        /// <param name="c"></param>
        /// <returns>true, if slash</returns>
        internal static bool IsSlash(char c)
        {
            return (c == Path.DirectorySeparatorChar) || (c == Path.AltDirectorySeparatorChar);
        }

        /// <summary>
        /// Indicates if the given file-spec ends with a slash.
        /// </summary>
        /// <param name="fileSpec">The file spec.</param>
        /// <returns>true, if file-spec has trailing slash</returns>
        internal static bool EndsWithSlash(string fileSpec)
        {
            return (fileSpec.Length > 0)
                ? IsSlash(fileSpec[fileSpec.Length - 1])
                : false;
        }

        /// <summary>
        /// Fixes backslashes to forward slashes on Unix. This allows to recognise windows style paths on Unix. 
        /// However, this leads to incorrect path on Linux if backslash was part of the file/directory name.
        /// </summary>  
        internal static string FixFilePath(string path)
        {
            return string.IsNullOrEmpty(path) || Path.DirectorySeparatorChar == WindowsDirectorySeparator ? path : path.Replace(WindowsDirectorySeparator, UnixDirectorySeparator);
        }

        /// <summary>
        /// If the given path doesn't have a trailing slash then add one.
        /// If the path is an empty string, does not modify it.
        /// </summary>
        /// <param name="fileSpec">The path to check.</param>
        /// <returns>A path with a slash.</returns>
        internal static string EnsureTrailingSlash(string fileSpec)
        {
            fileSpec = FixFilePath(fileSpec);
            if (fileSpec.Length > 0 && !IsSlash(fileSpec[fileSpec.Length - 1]))
            {
                fileSpec += Path.DirectorySeparatorChar;
            }

            return fileSpec;
        }

        /// <summary>
        /// Ensures the path does not have a trailing slash.
        /// </summary>
        internal static string EnsureNoTrailingSlash(string path)
        {
            path = FixFilePath(path);
            if (path.Length > 0 && IsSlash(path[path.Length - 1]))
            {
                path = path.Substring(0, path.Length - 1);
            }

            return path;
        }

        public static bool IsPathTooLong(string path)
            => path.Length >= NativeMethods.MaxPath; // >= not > because MAX_PATH assumes a trailing null

        /// <summary>
        /// Checks if the path contains backslashes on Unix.
        /// </summary>
        private static bool HasWindowsDirectorySeparatorOnUnix(string path)
            => NativeMethods.IsUnixLike && path.IndexOf(WindowsDirectorySeparator) >= 0;

        /// <summary>
        /// Checks if the path contains forward slashes on Windows.
        /// </summary>
        private static bool HasUnixDirectorySeparatorOnWindows(string path)
            => NativeMethods.IsWindows && path.IndexOf(UnixDirectorySeparator) >= 0;

        /// <summary>
        /// Quickly checks if the path may contain relative segments like "." or "..".
        /// This is a non-precise detection that may have false positives but no false negatives.
        /// </summary>
        /// <remarks>
        /// Check for relative path segments "." and ".."
        /// In absolute path those segments can not appear in the beginning of the path, only after a path separator.
        /// This is not a precise full detection of relative segments. There are no false negatives as this might affect correctness, but it may have false positives:
        /// like when there is a hidden file or directory starting with a dot, or on linux the backslash and dot can be part of the file name.
        /// </remarks>
        private static bool MayHaveRelativeSegment(string path)
            => path.Contains("/.") || path.Contains("\\.");

        /// <summary>
        /// If the given path doesn't have a trailing slash then add one.
        /// </summary>
        /// <param name="path">The absolute path to check.</param>
        /// <returns>An absolute path with a trailing slash.</returns>
        /// <remarks>
        /// If the path does not require modification, returns the current instance to avoid unnecessary allocations.
        /// Preserves the OriginalValue of the current instance.
        /// </remarks>
        internal static AbsolutePath EnsureTrailingSlash(AbsolutePath path)
        {
            if (string.IsNullOrEmpty(path.Value))
            {
                return path;
            }

            // Check if the path already has a trailing slash and no separator fixing is needed on Unix.
            // EnsureTrailingSlash should also fix the path separators on Unix.
            if (IsSlash(path.Value[path.Value.Length - 1]) && !HasWindowsDirectorySeparatorOnUnix(path.Value))
            {
                return path;
            }

            return new AbsolutePath(EnsureTrailingSlash(path.Value),
                original: path.OriginalValue,
                ignoreRootedCheck: true);
        }

        /// <summary>
        /// Ensures the absolute path does not have a trailing slash.
        /// </summary>
        /// <param name="path">The absolute path to check.</param>
        /// <returns>An absolute path without a trailing slash.</returns>
        /// <remarks>
        /// If the path does not require modification, returns the current instance to avoid unnecessary allocations.
        /// Preserves the OriginalValue of the current instance.
        /// </remarks>
        internal static AbsolutePath EnsureNoTrailingSlash(AbsolutePath path)
        {
            if (string.IsNullOrEmpty(path.Value))
            {
                return path;
            }

            // Check if already has no trailing slash and no separator fixing needed on unix 
            // (EnsureNoTrailingSlash also should fix the paths on unix). 
            if (!IsSlash(path.Value[path.Value.Length - 1]) && !HasWindowsDirectorySeparatorOnUnix(path.Value))
            {
                return path;
            }

            return new AbsolutePath(EnsureNoTrailingSlash(path.Value),
                original: path.OriginalValue,
                ignoreRootedCheck: true);
        }

        /// <summary>
        /// Gets the canonicalized full path of the provided path.
        /// Resolves relative segments like "." and "..". Fixes directory separators.
        /// ASSUMES INPUT IS ALREADY UNESCAPED.
        /// </summary>
        /// <remarks>
        /// If the path does not require modification, returns the current instance to avoid unnecessary allocations.
        /// Preserves the OriginalValue of the current instance.
        /// </remarks>
        internal static AbsolutePath NormalizePath(AbsolutePath path)
        {
            if (string.IsNullOrEmpty(path.Value))
            {
                return path;
            }

            if (!MayHaveRelativeSegment(path.Value) &&
                !HasWindowsDirectorySeparatorOnUnix(path.Value) &&
                !HasUnixDirectorySeparatorOnWindows(path.Value))
            {
                return path;
            }

            return new AbsolutePath(FixFilePath(NewPath.GetFullPath(path.Value)),
                original: path.OriginalValue,
                ignoreRootedCheck: true);
        }

        /// <summary>
        /// Fixes file path separators for the current platform.
        /// </summary>
        internal static AbsolutePath FixFilePath(AbsolutePath path)
        {
            if (string.IsNullOrEmpty(path.Value) || !HasWindowsDirectorySeparatorOnUnix(path.Value))
            {
                return path;
            }

            return new AbsolutePath(FixFilePath(path.Value),
                original: path.OriginalValue,
                ignoreRootedCheck: true);
        }

        /// <summary>
        /// If <see cref="CurrentThreadWorkingDirectory"/> is set and <paramref name="path"/> is relative,
        /// resolves it to an <see cref="AbsolutePath"/> using the thread-local working directory as base.
        /// Returns <c>null</c> when the path does not need resolution (no thread working directory,
        /// empty path, or already fully qualified).
        /// </summary>
        /// <remarks>
        /// <para>
        /// Callers are responsible for calling <see cref="FixFilePath(string)"/>
        /// BEFORE passing the path to this method. On Linux, backslash is a valid filename character
        /// and Path.Combine/GetFullPath won't recognize ..\segments unless backslashes are first
        /// normalized to forward slashes.
        /// </para>
        /// <para>
        /// GetFullPath can throw for inputs with illegal path characters (e.g., wildcards).
        /// In that case we fall back to a simple Path.Combine, which preserves the non-throwing
        /// behavior of APIs like File.Exists and Directory.Exists.
        /// </para>
        /// </remarks>
        internal static AbsolutePath? MakeFullPathFromThreadWorkingDirectory(string path)
        {
            string? workingDir = CurrentThreadWorkingDirectory;
            if (string.IsNullOrEmpty(workingDir) || string.IsNullOrEmpty(path))
            {
                return null;
            }

            if (NewPath.IsPathFullyQualified(path))
            {
                return null;
            }

            // Use the 2-argument GetFullPath overload — it correctly handles drive-relative
            // ("\foo") and current-directory-relative ("C:foo") paths on Windows by resolving
            // them against the specified base directory, not the process CWD.
            try
            {
                return new AbsolutePath(NewPath.GetFullPath(path, workingDir!));
            }
            catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
            {
                // For invalid paths (e.g., wildcards) fall back to a simple combination,
                // preserving the non-throwing behavior of File.Exists/Directory.Exists.
                return new AbsolutePath(path, new AbsolutePath(workingDir!));
            }
        }

        /// <summary>
        /// Get the hex hash string for the string
        /// </summary>
        internal static string GetHexHash(string stringToHash)
        {
            return stringToHash.GetHashCode().ToString("X", CultureInfo.InvariantCulture);
        }

        /// <summary>
        /// Get the hash for the assemblyPaths
        /// </summary>
        internal static int GetPathsHash(IEnumerable<string> assemblyPaths)
        {
            StringBuilder builder = new StringBuilder();

            foreach (string path in assemblyPaths)
            {
                if (path != null)
                {
                    string directoryPath = path.Trim();
                    if (directoryPath.Length > 0)
                    {
                        DateTime lastModifiedTime;
                        if (NativeMethods.GetLastWriteDirectoryUtcTime(directoryPath, out lastModifiedTime))
                        {
                            builder.Append(lastModifiedTime.Ticks);
                            builder.Append('|');
                            builder.Append(directoryPath.ToUpperInvariant());
                            builder.Append('|');
                        }
                    }
                }
            }

            return builder.ToString().GetHashCode();
        }

        /// <summary>
        /// Returns whether MSBuild can write to the given directory. Throws for PathTooLongExceptions
        /// but not other exceptions.
        /// </summary>
        internal static bool CanWriteToDirectory(string directory)
        {
            try
            {
                string testFilePath = Path.Combine(directory, $"MSBuild_{Guid.NewGuid():N}_testFile.txt");
                FileInfo file = new(testFilePath);
                file.Directory!.Create(); // If the directory already exists, this method does nothing.
                File.WriteAllText(testFilePath, $"MSBuild process {EnvironmentUtilities.CurrentProcessId} successfully wrote to file.");
                File.Delete(testFilePath);
                return true;
            }
            catch (PathTooLongException)
            {
                throw new ArgumentException(SR.FormatDebugPathTooLong(directory));
            }
            catch (Exception)
            {
                return false;
            }
        }

        /// <summary>
        /// Ensures the path does not have a leading or trailing slash after removing the first 'start' characters.
        /// </summary>
        internal static string EnsureNoLeadingOrTrailingSlash(string path, int start)
        {
            int stop = path.Length;
            while (start < stop && IsSlash(path[start]))
            {
                start++;
            }
            while (start < stop && IsSlash(path[stop - 1]))
            {
                stop--;
            }

            return FixFilePath(path.Substring(start, stop - start));
        }

        /// <summary>
        /// Ensures the path does not have a leading slash after removing the first 'start' characters but does end in a slash.
        /// </summary>
        internal static string EnsureTrailingNoLeadingSlash(string path, int start)
        {
            int stop = path.Length;
            while (start < stop && IsSlash(path[start]))
            {
                start++;
            }

            return FixFilePath(start < stop && IsSlash(path[stop - 1]) ?
                path.Substring(start) :
#if NET
                string.Concat(path.AsSpan(start), new(in Path.DirectorySeparatorChar)));
#else
                path.Substring(start) + Path.DirectorySeparatorChar);
#endif
        }

        /// <summary>
        /// Ensures the path is enclosed within single quotes.
        /// </summary>
        /// <param name="path">The path to check.</param>
        /// <returns>The path enclosed by quotes.</returns>
        internal static string EnsureSingleQuotes(string path)
        {
            return EnsureQuotes(path);
        }

        /// <summary>
        /// Ensures the path is enclosed within double quotes.
        /// </summary>
        /// <param name="path">The path to check.</param>
        /// <returns>The path enclosed by quotes.</returns>
        internal static string EnsureDoubleQuotes(string path)
        {
            return EnsureQuotes(path, isSingleQuote: false);
        }

        /// <summary>
        /// Ensures the path is enclosed within quotes.
        /// </summary>
        /// <param name="path">The path to check.</param>
        /// <param name="isSingleQuote">Indicates if single or double quotes should be used</param>
        /// <returns>The path enclosed by quotes.</returns>
        internal static string EnsureQuotes(string path, bool isSingleQuote = true)
        {
            path = FixFilePath(path);

            const char singleQuote = '\'';
            const char doubleQuote = '\"';
            var targetQuote = isSingleQuote ? singleQuote : doubleQuote;
            var convertQuote = isSingleQuote ? doubleQuote : singleQuote;

            if (!string.IsNullOrEmpty(path))
            {
                // Special case: convert the quotes.
                if (path.Length > 1 && path[0] == convertQuote && path[path.Length - 1] == convertQuote)
                {
#if NET
                    path = $"{targetQuote}{path.AsSpan(1, path.Length - 2)}{targetQuote}";
#else
                    path = $"{targetQuote}{path.Substring(1, path.Length - 2)}{targetQuote}";
#endif
                }
                // Enclose the path in a set of the 'target' quote unless the string is already quoted with the 'target' quotes.
                else if (path.Length == 1 || path[0] != targetQuote || path[path.Length - 1] != targetQuote)
                {
                    path = $"{targetQuote}{path}{targetQuote}";
                }
            }

            return path;
        }

        /// <summary>
        /// Trims the string and removes any double quotes around it.
        /// </summary>
        [return: NotNullIfNotNull(nameof(path))]
        internal static string? TrimAndStripAnyQuotes(string? path)
        {
            if (path is null)
            {
                return path;
            }

            // Trim returns the same string if trimming isn't needed
            path = path.Trim();
            path = path.Trim(['"']);

            return path;
        }

        /// <summary>
        /// Get the directory name of a rooted full path
        /// </summary>
        /// <param name="fullPath"></param>
        /// <returns></returns>
        internal static string? GetDirectoryNameOfFullPath(string fullPath)
        {
            if (fullPath != null)
            {
                int i = fullPath.Length;
                while (i > 0 && fullPath[--i] != Path.DirectorySeparatorChar && fullPath[i] != Path.AltDirectorySeparatorChar)
                {
                    ;
                }

                return FixFilePath(fullPath.Substring(0, i));
            }
            return null;
        }

        internal static string TruncatePathToTrailingSegments(string path, int trailingSegmentsToKeep)
        {
            Assumed.NotNullOrEmpty(path);
            Assumed.PositiveOrZero(trailingSegmentsToKeep, "trailing segments must be positive");

            var segments = path.Split(Slashes, StringSplitOptions.RemoveEmptyEntries);

            var headingSegmentsToRemove = Math.Max(0, segments.Length - trailingSegmentsToKeep);

            return string.Join(DirectorySeparatorString, segments.Skip(headingSegmentsToRemove));
        }

        internal static bool ContainsRelativePathSegments(string path)
        {
            for (int i = 0; i < path.Length; i++)
            {
                if (i + 1 < path.Length && path[i] == '.' && path[i + 1] == '.')
                {
                    if (RelativePathBoundsAreValid(path, i, i + 1))
                    {
                        return true;
                    }
                    else
                    {
                        i += 2;
                        continue;
                    }
                }

                if (path[i] == '.' && RelativePathBoundsAreValid(path, i, i))
                {
                    return true;
                }
            }

            return false;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private static bool RelativePathBoundsAreValid(string path, int leftIndex, int rightIndex)
        {
            var leftBound = leftIndex - 1 >= 0
                ? path[leftIndex - 1]
                : (char?)null;

            var rightBound = rightIndex + 1 < path.Length
                ? path[rightIndex + 1]
                : (char?)null;

            return IsValidRelativePathBound(leftBound) && IsValidRelativePathBound(rightBound);
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private static bool IsValidRelativePathBound(char? c)
        {
            return c == null || IsAnySlash(c.Value);
        }

        /// <summary>
        /// Returns true if any path segment is exactly "..", without allocating.
        /// </summary>
        internal static bool ContainsParentTraversalSegment(ReadOnlySpan<char> path)
        {
            // Walk each segment; return true only for a segment that is exactly "..".
            while (!path.IsEmpty)
            {
                int sep = path.IndexOfAny(Slashes);
                ReadOnlySpan<char> segment = sep < 0 ? path : path.Slice(0, sep);
                if (segment.SequenceEqual("..".AsSpan()))
                {
                    return true;
                }

                path = sep < 0 ? default : path.Slice(sep + 1);
            }

            return false;
        }

        /// <summary>
        /// Gets the canonicalized full path of the provided path.
        /// Guidance for use: call this on all paths accepted through public entry
        /// points that need normalization. After that point, only verify the path
        /// is rooted, using ErrorUtilities.VerifyThrowPathRooted.
        /// ASSUMES INPUT IS ALREADY UNESCAPED.
        /// </summary>
        internal static string NormalizePath(string path)
        {
            ArgumentException.ThrowIfNullOrEmpty(path);
            string fullPath = NewPath.GetFullPath(path);
            return FixFilePath(fullPath);
        }

        internal static string NormalizePath(string directory, string file)
        {
            return NormalizePath(Path.Combine(directory, file));
        }

        internal static string NormalizePath(params string[] paths)
        {
            return NormalizePath(Path.Combine(paths));
        }

        /// <summary>
        /// Normalizes all path separators (both forward and back slashes) to forward slashes.
        /// This is platform-independent, unlike FrameworkFileUtilities.FixFilePath which only normalizes on non-Windows platforms.
        /// Use this when you need consistent path comparison regardless of which separator style is used.
        /// </summary>
        /// <param name="path">The path to normalize</param>
        /// <returns>The path with all backslashes replaced by forward slashes, or the original path if null/empty</returns>
        internal static string NormalizePathSeparatorsToForwardSlash(string path)
        {
            return string.IsNullOrEmpty(path) ? path : path.Replace('\\', '/');
        }

        /// <summary>
        /// If on Unix, convert backslashes to slashes for strings that resemble paths.
        /// The heuristic is if something resembles paths (contains slashes) check if the
        /// first segment exists and is a directory.
        /// Use a native shared method to massage file path. If the file is adjusted,
        /// that qualifies is as a path.
        ///
        /// @baseDirectory is just passed to LooksLikeUnixFilePath, to help with the check
        /// </summary>
        internal static string MaybeAdjustFilePath(string value, string baseDirectory = "")
        {
            var comparisonType = StringComparison.Ordinal;

            // Don't bother with arrays or properties or network paths, or those that
            // have no slashes.
            if (NativeMethods.IsWindows || string.IsNullOrEmpty(value)
                || value.StartsWith("$(", comparisonType) || value.StartsWith("@(", comparisonType)
                || value.StartsWith("\\\\", comparisonType))
            {
                return value;
            }

            // For Unix-like systems, we may want to convert backslashes to slashes
            Span<char> newValue = ConvertToUnixSlashes(value.ToCharArray());

            // Find the part of the name we want to check, that is remove quotes, if present
            bool shouldAdjust = newValue.IndexOf('/') != -1 && LooksLikeUnixFilePath(RemoveQuotes(newValue), baseDirectory);
            return shouldAdjust ? newValue.ToString() : value;
        }

        /// <summary>
        /// If on Unix, convert backslashes to slashes for strings that resemble paths.
        /// This overload takes and returns ReadOnlyMemory of characters.
        /// </summary>
        internal static ReadOnlyMemory<char> MaybeAdjustFilePath(ReadOnlyMemory<char> value, string baseDirectory = "")
        {
            if (NativeMethods.IsWindows || value.IsEmpty)
            {
                return value;
            }

            // Don't bother with arrays or properties or network paths.
            if (value.Length >= 2)
            {
                var span = value.Span;

                // The condition is equivalent to span.StartsWith("$(") || span.StartsWith("@(") || span.StartsWith("\\\\")
                if ((span[1] == '(' && (span[0] == '$' || span[0] == '@')) ||
                    (span[1] == '\\' && span[0] == '\\'))
                {
                    return value;
                }
            }

            // For Unix-like systems, we may want to convert backslashes to slashes
            Span<char> newValue = ConvertToUnixSlashes(value.ToArray());

            // Find the part of the name we want to check, that is remove quotes, if present
            bool shouldAdjust = newValue.IndexOf('/') != -1 && LooksLikeUnixFilePath(RemoveQuotes(newValue), baseDirectory);
            return shouldAdjust ? newValue.ToString().AsMemory() : value;
        }

        private static Span<char> ConvertToUnixSlashes(Span<char> path)
        {
            return path.IndexOf('\\') == -1 ? path : CollapseSlashes(path);
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private static Span<char> CollapseSlashes(Span<char> str)
        {
            int sliceLength = 0;

            // Performs Regex.Replace(str, @"[\\/]+", "/")
            for (int i = 0; i < str.Length; i++)
            {
                bool isCurSlash = IsAnySlash(str[i]);
                bool isPrevSlash = i > 0 && IsAnySlash(str[i - 1]);

                if (!isCurSlash || !isPrevSlash)
                {
                    str[sliceLength] = str[i] == '\\' ? '/' : str[i];
                    sliceLength++;
                }
            }

            return str.Slice(0, sliceLength);
        }

        private static Span<char> RemoveQuotes(Span<char> path)
        {
            int endId = path.Length - 1;
            char singleQuote = '\'';
            char doubleQuote = '\"';

            bool hasQuotes = path.Length > 2
                && ((path[0] == singleQuote && path[endId] == singleQuote)
                || (path[0] == doubleQuote && path[endId] == doubleQuote));

            return hasQuotes ? path.Slice(1, endId - 1) : path;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        internal static bool IsAnySlash(char c) => c == '/' || c == '\\';

        /// <summary>
        /// If on Unix, check if the string looks like a file path.
        /// The heuristic is if something resembles paths (contains slashes) check if the
        /// first segment exists and is a directory.
        ///
        /// If @baseDirectory is not null, then look for the first segment exists under
        /// that
        /// </summary>
        internal static bool LooksLikeUnixFilePath(string value, string baseDirectory = "")
            => LooksLikeUnixFilePath(value.AsSpan(), baseDirectory);

        internal static bool LooksLikeUnixFilePath(ReadOnlySpan<char> value, string baseDirectory = "")
        {
            if (NativeMethods.IsWindows)
            {
                return false;
            }

            // In MT mode the process CWD should not be used when resolving the first relative path segment. Use the
            // thread-local working directory so the directory existence heuristic runs against the correct project directory.
            if (string.IsNullOrEmpty(baseDirectory))
            {
                baseDirectory = CurrentThreadWorkingDirectory ?? "";
            }

            // The first slash will either be at the beginning of the string or after the first directory name
            int directoryLength = value.Slice(1).IndexOf('/') + 1;
            bool shouldCheckDirectory = directoryLength != 0;

            // Check for actual files or directories under / that get missed by the above logic
            bool shouldCheckFileOrDirectory = !shouldCheckDirectory && value.Length > 0 && value[0] == '/';
            ReadOnlySpan<char> directory = value.Slice(0, directoryLength);

            return (shouldCheckDirectory && DefaultFileSystem.DirectoryExists(Path.Combine(baseDirectory, directory.ToString())))
                || (shouldCheckFileOrDirectory && DefaultFileSystem.FileOrDirectoryExists(value.ToString()));
        }

        /// <summary>
        /// Extracts the directory from the given file-spec.
        /// </summary>
        /// <param name="fileSpec">The filespec.</param>
        /// <returns>directory path</returns>
        internal static string GetDirectory(string fileSpec)
        {
            string? directory = Path.GetDirectoryName(FixFilePath(fileSpec));

            // if file-spec is a root directory e.g. c:, c:\, \, \\server\share
            // NOTE: Path.GetDirectoryName also treats invalid UNC file-specs as root directories e.g. \\, \\server
            if (directory == null)
            {
                // just use the file-spec as-is
                directory = fileSpec;
            }
            else if ((directory.Length > 0) && !EndsWithSlash(directory))
            {
                // restore trailing slash if Path.GetDirectoryName has removed it (this happens with non-root directories)
                directory += Path.DirectorySeparatorChar;
            }

            return directory;
        }

        /// <summary>
        /// Deletes all subdirectories within the specified directory without throwing exceptions.
        /// This method enumerates all subdirectories in the given directory and attempts to delete
        /// each one recursively. If any IO-related exceptions occur during enumeration or deletion,
        /// they are silently ignored.
        /// </summary>
        /// <param name="directory">The directory whose subdirectories should be deleted.</param>
        /// <remarks>
        /// This method is useful for cleanup operations where partial failure is acceptable.
        /// It will not delete the root directory itself, only its subdirectories.
        /// IO exceptions during directory enumeration or deletion are caught and ignored.
        /// </remarks>
        internal static void DeleteSubdirectoriesNoThrow(string directory)
        {
            try
            {
                foreach (string dir in FileSystems.Default.EnumerateDirectories(directory))
                {
                    DeleteDirectoryNoThrow(dir, recursive: true, retryCount: 1);
                }
            }
            catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex))
            {
                // If we can't enumerate the directories, ignore. Other cases should be handled by DeleteDirectoryNoThrow.
            }
        }

        /// <summary>
        /// Determines whether the given assembly file name has one of the listed extensions.
        /// </summary>
        /// <param name="fileName">The name of the file</param>
        /// <param name="allowedExtensions">Array of extensions to consider.</param>
        /// <returns></returns>
        internal static bool HasExtension(string fileName, string[] allowedExtensions)
        {
            Debug.Assert(allowedExtensions?.Length > 0);

            // Easiest way to invoke invalid path chars
            // check, which callers are relying on.
            if (allowedExtensions != null && Path.HasExtension(fileName))
            {
                foreach (string extension in allowedExtensions)
                {
                    Debug.Assert(!String.IsNullOrEmpty(extension) && extension[0] == '.');

                    if (fileName.EndsWith(extension, PathComparison))
                    {
                        return true;
                    }
                }
            }

            return false;
        }

        // ISO 8601 Universal time with sortable format
        internal const string FileTimeFormat = "yyyy'-'MM'-'dd HH':'mm':'ss'.'fffffff";

        /// <summary>
        /// Determines the full path for the given file-spec.
        /// ASSUMES INPUT IS STILL ESCAPED
        /// </summary>
        /// <param name="fileSpec">The file spec to get the full path of.</param>
        /// <param name="currentDirectory"></param>
        /// <param name="escape">Whether to escape the path after getting the full path.</param>
        /// <returns>Full path to the file, escaped if not specified otherwise.</returns>
        internal static string GetFullPath(string fileSpec, string currentDirectory, bool escape = true)
        {
            // Sending data out of the engine into the filesystem, so time to unescape.
            fileSpec = FixFilePath(EscapingUtilities.UnescapeAll(fileSpec));

            string fullPath = NormalizePath(Path.Combine(currentDirectory, fileSpec));
            // In some cases we might want to NOT escape in order to preserve symbols like @, %, $ etc.
            if (escape)
            {
                // Data coming back from the filesystem into the engine, so time to escape it back.
                fullPath = EscapingUtilities.Escape(fullPath);
            }

            if (NativeMethods.IsWindows && !EndsWithSlash(fullPath))
            {
                if (FileUtilitiesRegex.IsDrivePattern(fileSpec) ||
                    FileUtilitiesRegex.IsUncPattern(fullPath))
                {
                    // append trailing slash if Path.GetFullPath failed to (this happens with drive-specs and UNC shares)
                    fullPath += Path.DirectorySeparatorChar;
                }
            }

            return fullPath;
        }

        /// <summary>
        /// A variation of Path.GetFullPath that will return the input value
        /// instead of throwing any IO exception.
        /// Useful to get a better path for an error message, without the risk of throwing
        /// if the error message was itself caused by the path being invalid!
        /// </summary>
        internal static string GetFullPathNoThrow(string path)
        {
            try
            {
                path = NormalizePath(path);
            }
            catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex))
            {
            }

            return path;
        }

        /// <summary>
        /// Compare if two paths, relative to the given currentDirectory are equal.
        /// Does not throw IO exceptions. See <see cref="GetFullPathNoThrow(string)"/>
        /// </summary>
        /// <param name="first"></param>
        /// <param name="second"></param>
        /// <param name="currentDirectory"></param>
        /// <param name="alwaysIgnoreCase"></param>
        /// <returns></returns>
        internal static bool ComparePathsNoThrow(string first, string second, string currentDirectory, bool alwaysIgnoreCase = false)
        {
            StringComparison pathComparison = alwaysIgnoreCase ? StringComparison.OrdinalIgnoreCase : PathComparison;
            // perf: try comparing the bare strings first
            if (string.Equals(first, second, pathComparison))
            {
                return true;
            }

            var firstFullPath = NormalizePathForComparisonNoThrow(first, currentDirectory);
            var secondFullPath = NormalizePathForComparisonNoThrow(second, currentDirectory);

            return string.Equals(firstFullPath, secondFullPath, pathComparison);
        }

        /// <summary>
        /// Normalizes a path for path comparison
        /// Does not throw IO exceptions. See <see cref="GetFullPathNoThrow(string)"/>
        ///
        /// </summary>
        internal static string NormalizePathForComparisonNoThrow(string path, string currentDirectory)
        {
            // file is invalid, return early to avoid triggering an exception
            if (PathIsInvalid(path))
            {
                return path;
            }

            var normalizedPath = NormalizeForPathComparison(path);
            var fullPath = GetFullPathNoThrow(Path.Combine(currentDirectory, normalizedPath));

            return fullPath;
        }

        internal static bool PathIsInvalid(string path)
        {
            // Path.GetFileName does not react well to malformed filenames.
            // For example, Path.GetFileName("a/b/foo:bar") returns bar instead of foo:bar
            // It also throws exceptions on illegal path characters
#if NET
            if (!path.AsSpan().ContainsAny(InvalidPathChars))
            {
                int lastDirectorySeparator = path.LastIndexOfAny(Slashes);
                return path.AsSpan(lastDirectorySeparator >= 0 ? lastDirectorySeparator + 1 : 0).ContainsAny(InvalidFileNameChars);
            }
#else
            if (path.IndexOfAny(InvalidPathChars) < 0)
            {
                int lastDirectorySeparator = path.LastIndexOfAny(Slashes);
                return path.IndexOfAny(InvalidFileNameChars, lastDirectorySeparator >= 0 ? lastDirectorySeparator + 1 : 0) >= 0;
            }
#endif
            return true;
        }

        /// <summary>
        /// A variation on File.Delete that will throw ExceptionHandling.NotExpectedException exceptions
        /// </summary>
        internal static void DeleteNoThrow(string path)
        {
            try
            {
                File.Delete(FixFilePath(path));
            }
            catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex))
            {
            }
        }

        /// <summary>
        /// A variation on Directory.Delete that will throw ExceptionHandling.NotExpectedException exceptions
        /// </summary>
        [SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", MessageId = "System.Int32.TryParse(System.String,System.Int32@)", Justification = "We expect the out value to be 0 if the parse fails and compensate accordingly")]
        internal static void DeleteDirectoryNoThrow(string path, bool recursive, int retryCount = 0, int retryTimeOut = 0)
        {
            // Try parse will set the out parameter to 0 if the string passed in is null, or is outside the range of an int.
            if (!int.TryParse(Environment.GetEnvironmentVariable("MSBUILDDIRECTORYDELETERETRYCOUNT"), out retryCount))
            {
                retryCount = 0;
            }

            if (!int.TryParse(Environment.GetEnvironmentVariable("MSBUILDDIRECTORYDELETRETRYTIMEOUT"), out retryTimeOut))
            {
                retryTimeOut = 0;
            }

            retryCount = retryCount < 1 ? 2 : retryCount;
            retryTimeOut = retryTimeOut < 1 ? 500 : retryTimeOut;

            path = FixFilePath(path);

            for (int i = 0; i < retryCount; i++)
            {
                try
                {
                    if (DefaultFileSystem.DirectoryExists(path))
                    {
                        Directory.Delete(path, recursive);
                        break;
                    }
                }
                catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex))
                {
                }

                if (i + 1 < retryCount) // should not wait for the final iteration since we not gonna check anyway
                {
                    Thread.Sleep(retryTimeOut);
                }
            }
        }

        /// <summary>
        /// Deletes a directory, ensuring that Directory.Delete does not get a path ending in a slash.
        /// </summary>
        /// <remarks>
        /// This is a workaround for https://github.com/dotnet/corefx/issues/3780, which clashed with a common
        /// pattern in our tests.
        /// </remarks>
        internal static void DeleteWithoutTrailingBackslash(string path, bool recursive = false)
        {
            // Some tests (such as FileMatcher and Evaluation tests) were failing with an UnauthorizedAccessException or directory not empty.
            //  This retry logic works around that issue.
            const int NUM_TRIES = 3;
            for (int i = 0; i < NUM_TRIES; i++)
            {
                try
                {
                    Directory.Delete(EnsureNoTrailingSlash(path), recursive);

                    // If we got here, the directory was successfully deleted
                    return;
                }
                catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
                {
                    if (i == NUM_TRIES - 1)
                    {
                        // var files = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories);
                        // string fileString = string.Join(Environment.NewLine, files);
                        // string message = $"Unable to delete directory '{path}'.  Contents:" + Environment.NewLine + fileString;
                        // throw new IOException(message, ex);
                        throw;
                    }
                }

                Thread.Sleep(10);
            }
        }

        /// <summary>
        /// Gets a file info object for the specified file path. If the file path
        /// is invalid, or is a directory, or cannot be accessed, or does not exist,
        /// it returns null rather than throwing or returning a FileInfo around a non-existent file.
        /// This allows it to be called where File.Exists() (which never throws, and returns false
        /// for directories) was called - but with the advantage that a FileInfo object is returned
        /// that can be queried (e.g., for LastWriteTime) without hitting the disk again.
        /// </summary>
        /// <param name="filePath"></param>
        /// <returns>FileInfo around path if it is an existing /file/, else null</returns>
        internal static FileInfo? GetFileInfoNoThrow(string filePath)
        {
            filePath = AttemptToShortenPath(filePath);

            FileInfo fileInfo;

            try
            {
                fileInfo = new FileInfo(filePath);
            }
            catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
            {
                // Invalid or inaccessible path: treat as if nonexistent file, just as File.Exists does
                return null;
            }

            if (fileInfo.Exists)
            {
                // It's an existing file
                return fileInfo;
            }
            else
            {
                // Nonexistent, or existing but a directory, just as File.Exists behaves
                return null;
            }
        }

        /// <summary>
        /// Returns if the directory exists
        /// </summary>
        /// <param name="fullPath">Full path to the directory in the filesystem</param>
        /// <param name="fileSystem">The file system</param>
        /// <returns></returns>
        internal static bool DirectoryExistsNoThrow(string fullPath, IFileSystem? fileSystem = null)
        {
            fullPath = AttemptToShortenPath(fullPath);

            try
            {
                fileSystem ??= DefaultFileSystem;

                return Traits.Instance.CacheFileExistence
                    ? FileExistenceCache.GetOrAdd(fullPath, fullPath => fileSystem.DirectoryExists(fullPath))
                    : fileSystem.DirectoryExists(fullPath);
            }
            catch
            {
                return false;
            }
        }

        /// <summary>
        /// Returns if the directory exists
        /// </summary>
        /// <param name="fullPath">Full path to the file in the filesystem</param>
        /// <param name="fileSystem">The file system</param>
        /// <returns></returns>
        internal static bool FileExistsNoThrow(string fullPath, IFileSystem? fileSystem = null)
        {
            fullPath = AttemptToShortenPath(fullPath);

            try
            {
                fileSystem ??= DefaultFileSystem;

                return Traits.Instance.CacheFileExistence
                    ? FileExistenceCache.GetOrAdd(fullPath, fullPath => fileSystem.FileExists(fullPath))
                    : fileSystem.FileExists(fullPath);
            }
            catch
            {
                return false;
            }
        }

        /// <summary>
        /// If there is a directory or file at the specified path, returns true.
        /// Otherwise, returns false.
        /// Does not throw IO exceptions, to match Directory.Exists and File.Exists.
        /// Unlike calling each of those in turn it only accesses the disk once, which is faster.
        /// </summary>
        internal static bool FileOrDirectoryExistsNoThrow(string fullPath, IFileSystem? fileSystem = null)
        {
            fullPath = AttemptToShortenPath(fullPath);

            try
            {
                fileSystem ??= DefaultFileSystem;

                return Traits.Instance.CacheFileExistence
                    ? FileExistenceCache.GetOrAdd(fullPath, fullPath => fileSystem.FileOrDirectoryExists(fullPath))
                    : fileSystem.FileOrDirectoryExists(fullPath);
            }
            catch
            {
                return false;
            }
        }

        /// <summary>
        /// This method returns true if the specified filename is a solution file (.sln) or
        /// solution filter file (.slnf); otherwise, it returns false.
        /// </summary>
        /// <remarks>
        /// Solution filters are included because they are a thin veneer over solutions, just
        /// with a more limited set of projects to build, and should be treated the same way.
        /// </remarks>
        internal static bool IsSolutionFilename(string filename)
        {
            return HasExtension(filename, ".sln") ||
                   HasExtension(filename, ".slnf") ||
                   HasExtension(filename, ".slnx");
        }

        internal static bool IsSolutionFilterFilename(string filename)
        {
            return HasExtension(filename, ".slnf");
        }

        internal static bool IsSolutionXFilename(string filename)
        {
            return HasExtension(filename, ".slnx");
        }

        /// <summary>
        /// Returns true if the specified filename is a VC++ project file, otherwise returns false
        /// </summary>
        internal static bool IsVCProjFilename(string filename)
        {
            return HasExtension(filename, ".vcproj");
        }

        internal static bool IsDspFilename(string filename)
        {
            return HasExtension(filename, ".dsp");
        }

        /// <summary>
        /// Returns true if the specified filename is a metaproject file (.metaproj), otherwise false.
        /// </summary>
        internal static bool IsMetaprojectFilename(string? filename)
        {
            return HasExtension(filename, ".metaproj");
        }

        internal static bool IsBinaryLogFilename(string filename)
        {
            return HasExtension(filename, ".binlog");
        }

        private static bool HasExtension(string? filename, string extension)
        {
            if (String.IsNullOrEmpty(filename))
            {
                return false;
            }

            return filename!.EndsWith(extension, PathComparison);
        }

        /// <summary>
        /// Given the absolute location of a file, and a disc location, returns relative file path to that disk location.
        /// Throws UriFormatException.
        /// </summary>
        /// <param name="basePath">
        /// The base path we want to be relative to. Must be absolute.
        /// Should <i>not</i> include a filename as the last segment will be interpreted as a directory.
        /// </param>
        /// <param name="path">
        /// The path we need to make relative to basePath.  The path can be either absolute path or a relative path in which case it is relative to the base path.
        /// If the path cannot be made relative to the base path (for example, it is on another drive), it is returned verbatim.
        /// If the basePath is an empty string, returns the path.
        /// </param>
        /// <returns>relative path (can be the full path)</returns>
        internal static string MakeRelative(string basePath, string path)
        {
            ArgumentNullException.ThrowIfNull(basePath);
            ArgumentException.ThrowIfNullOrEmpty(path);

            string fullBase = NewPath.GetFullPath(basePath);
            string fullPath = NewPath.GetFullPath(path);

            string[] splitBase = fullBase.Split(MSBuildConstants.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
            string[] splitPath = fullPath.Split(MSBuildConstants.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);

            Assumed.Positive(splitPath.Length, "Cannot call MakeRelative on a path of only slashes.");

            // On a mac, the path could start with any number of slashes and still be valid. We have to check them all.
            int indexOfFirstNonSlashChar = 0;
            while (path[indexOfFirstNonSlashChar] == Path.DirectorySeparatorChar)
            {
                indexOfFirstNonSlashChar++;
            }
            if (path.IndexOf(splitPath[0], PathComparison) != indexOfFirstNonSlashChar)
            {
                // path was already relative so just return it
                return FixFilePath(path);
            }

            int index = 0;
            while (index < splitBase.Length && index < splitPath.Length && splitBase[index].Equals(splitPath[index], PathComparison))
            {
                index++;
            }

            if (index == splitBase.Length && index == splitPath.Length)
            {
                return ".";
            }

            // If the paths have no component in common, the only valid relative path is the full path.
            if (index == 0)
            {
                return fullPath;
            }

            StringBuilder sb = StringBuilderCache.Acquire();

            for (int i = index; i < splitBase.Length; i++)
            {
                sb.Append("..").Append(Path.DirectorySeparatorChar);
            }
            for (int i = index; i < splitPath.Length; i++)
            {
                sb.Append(splitPath[i]).Append(Path.DirectorySeparatorChar);
            }

            if (fullPath[fullPath.Length - 1] != Path.DirectorySeparatorChar)
            {
                sb.Length--;
            }

            return StringBuilderCache.GetStringAndRelease(sb);
        }

        /// <summary>
        /// Normalizes the path if and only if it is longer than max path,
        /// or would be if rooted by the current directory.
        /// This may make it shorter by removing ".."'s.
        /// </summary>
        internal static string AttemptToShortenPath(string path)
        {
            if (IsPathTooLong(path) || IsPathTooLongIfRooted(path))
            {
                // Attempt to make it shorter -- perhaps there are some \..\ elements
                path = GetFullPathNoThrow(path);
            }
            return FixFilePath(path);
        }
        private static bool IsPathTooLongIfRooted(string path)
        {
            bool hasMaxPath = NativeMethods.HasMaxPath;
            int maxPath = NativeMethods.MaxPath;
            // >= not > because MAX_PATH assumes a trailing null
            return hasMaxPath && !IsRootedNoThrow(path) && Environment.CurrentDirectory.Length + path.Length + 1 /* slash */ >= maxPath;
        }

        /// <summary>
        /// A variation of Path.IsRooted that not throw any IO exception.
        /// </summary>
        private static bool IsRootedNoThrow(string path)
        {
            try
            {
                return Path.IsPathRooted(FixFilePath(path));
            }
            catch (Exception ex) when (ExceptionHandling.IsIoRelatedException(ex))
            {
                return false;
            }
        }

        /// <summary>
        /// Get the folder N levels above the given. Will stop and return current path when rooted.
        /// </summary>
        /// <param name="path">Path to get the folder above.</param>
        /// <param name="count">Number of levels up to walk.</param>
        /// <returns>Full path to the folder N levels above the path.</returns>
        internal static string GetFolderAbove(string path, int count = 1)
        {
            if (count < 1)
            {
                return path;
            }

            var parent = Directory.GetParent(path);

            while (count > 1 && parent?.Parent != null)
            {
                parent = parent.Parent;
                count--;
            }

            return parent?.FullName ?? path;
        }

        /// <summary>
        /// Combine multiple paths. Should only be used when compiling against .NET 2.0.
        /// <remarks>
        /// Only use in .NET 2.0. Otherwise, use System.IO.Path.Combine(...)
        /// </remarks>
        /// </summary>
        /// <param name="root">Root path.</param>
        /// <param name="paths">Paths to concatenate.</param>
        /// <returns>Combined path.</returns>
        internal static string CombinePaths(string root, params string[] paths)
        {
            ArgumentNullException.ThrowIfNull(root);
            ArgumentNullException.ThrowIfNull(paths);

            return paths.Aggregate(root, Path.Combine);
        }

        internal static string TrimTrailingSlashes(this string s)
        {
            return s.TrimEnd(Slashes);
        }

        /// <summary>
        /// Replace all backward slashes to forward slashes
        /// </summary>
        internal static string ToSlash(this string s)
        {
            return s.Replace('\\', '/');
        }

        internal static string ToBackslash(this string s)
        {
            return s.Replace('/', '\\');
        }

        /// <summary>
        /// Ensure all slashes are the current platform's slash
        /// </summary>
        /// <param name="s"></param>
        /// <returns></returns>
        internal static string ToPlatformSlash(this string s)
        {
            var separator = Path.DirectorySeparatorChar;

            return s.Replace(separator == '/' ? '\\' : '/', separator);
        }

        internal static string WithTrailingSlash(this string s)
        {
            return EnsureTrailingSlash(s);
        }

        internal static string NormalizeForPathComparison(this string s)
            => s.ToPlatformSlash().TrimTrailingSlashes();

        // TODO: assumption on file system case sensitivity: https://github.com/dotnet/msbuild/issues/781
        internal static bool PathsEqual(string path1, string path2)
        {
            if (path1 == null && path2 == null)
            {
                return true;
            }
            if (path1 == null || path2 == null)
            {
                return false;
            }

            var endA = path1.Length - 1;
            var endB = path2.Length - 1;

            // Trim trailing slashes
            for (var i = endA; i >= 0; i--)
            {
                var c = path1[i];
                if (c == '/' || c == '\\')
                {
                    endA--;
                }
                else
                {
                    break;
                }
            }

            for (var i = endB; i >= 0; i--)
            {
                var c = path2[i];
                if (c == '/' || c == '\\')
                {
                    endB--;
                }
                else
                {
                    break;
                }
            }

            if (endA != endB)
            {
                // Lengths not the same
                return false;
            }

            for (var i = 0; i <= endA; i++)
            {
                var charA = (uint)path1[i];
                var charB = (uint)path2[i];

                if ((charA | charB) > 0x7F)
                {
                    // Non-ascii chars move to non fast path
                    return PathsEqualNonAscii(path1, path2, i, endA - i + 1);
                }

                // uppercase both chars - notice that we need just one compare per char
                if ((uint)(charA - 'a') <= (uint)('z' - 'a'))
                {
                    charA -= 0x20;
                }

                if ((uint)(charB - 'a') <= (uint)('z' - 'a'))
                {
                    charB -= 0x20;
                }

                // Set path delimiters the same
                if (charA == '\\')
                {
                    charA = '/';
                }
                if (charB == '\\')
                {
                    charB = '/';
                }

                if (charA != charB)
                {
                    return false;
                }
            }

            return true;
        }

        internal static StreamWriter OpenWrite(string path, bool append, Encoding? encoding = null)
        {
            const int DefaultFileStreamBufferSize = 4096;
            FileMode mode = append ? FileMode.Append : FileMode.Create;
            Stream fileStream = new FileStream(path, mode, FileAccess.Write, FileShare.Read, DefaultFileStreamBufferSize, FileOptions.SequentialScan);
            if (encoding == null)
            {
                return new StreamWriter(fileStream);
            }
            else
            {
                return new StreamWriter(fileStream, encoding);
            }
        }

        internal static StreamReader OpenRead(string path, Encoding? encoding = null, bool detectEncodingFromByteOrderMarks = true)
        {
            const int DefaultFileStreamBufferSize = 4096;
            Stream fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, DefaultFileStreamBufferSize, FileOptions.SequentialScan);
            if (encoding == null)
            {
                return new StreamReader(fileStream);
            }
            else
            {
                return new StreamReader(fileStream, encoding, detectEncodingFromByteOrderMarks);
            }
        }

        /// <summary>
        /// Locate a file in either the directory specified or a location in the
        /// directory structure above that directory.
        /// </summary>
        internal static string GetDirectoryNameOfFileAbove(string startingDirectory, string fileName, IFileSystem? fileSystem = null)
        {
            fileSystem ??= DefaultFileSystem;

            // Canonicalize our starting location
            string? lookInDirectory = NewPath.GetFullPath(startingDirectory);

            do
            {
                // Construct the path that we will use to test against
                string possibleFileDirectory = Path.Combine(lookInDirectory, fileName);

                // If we successfully locate the file in the directory that we're
                // looking in, simply return that location. Otherwise we'll
                // keep moving up the tree.
                if (fileSystem.FileExists(possibleFileDirectory))
                {
                    // We've found the file, return the directory we found it in
                    return lookInDirectory;
                }
                else
                {
                    // GetDirectoryName will return null when we reach the root
                    // terminating our search
                    lookInDirectory = Path.GetDirectoryName(lookInDirectory);
                }
            }
            while (lookInDirectory != null);

            // When we didn't find the location, then return an empty string
            return string.Empty;
        }

        /// <summary>
        /// Searches for a file based on the specified starting directory.
        /// </summary>
        /// <param name="file">The file to search for.</param>
        /// <param name="startingDirectory">An optional directory to start the search in.  The default location is the directory
        ///     of the file containing the property function.</param>
        /// <param name="fileSystem">The filesystem</param>
        /// <returns>The full path of the file if it is found, otherwise an empty string.</returns>
        internal static string GetPathOfFileAbove(string file, string startingDirectory, IFileSystem? fileSystem = null)
        {
            // This method does not accept a path, only a file name
            if (file.Any(i => i.Equals(Path.DirectorySeparatorChar) || i.Equals(Path.AltDirectorySeparatorChar)))
            {
                throw new ArgumentException(SR.FormatInvalidGetPathOfFileAboveParameter(file));
            }

            // Search for a directory that contains that file
            string directoryName = GetDirectoryNameOfFileAbove(startingDirectory, file, fileSystem);

            return String.IsNullOrEmpty(directoryName) ? String.Empty : NormalizePath(directoryName, file);
        }

        internal static void EnsureDirectoryExists(string directoryPath)
        {
            if (!string.IsNullOrEmpty(directoryPath) && !DefaultFileSystem.DirectoryExists(directoryPath))
            {
                Directory.CreateDirectory(directoryPath);
            }
        }

        // Method is simple set of function calls and may inline;
        // we don't want it inlining into the tight loop that calls it as an exit case,
        // so mark as non-inlining
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static bool PathsEqualNonAscii(string strA, string strB, int i, int length)
        {
            if (string.Compare(strA, i, strB, i, length, StringComparison.OrdinalIgnoreCase) == 0)
            {
                return true;
            }

            var slash1 = ToSlash(strA);
            var slash2 = ToSlash(strB);

            if (string.Compare(slash1, i, slash2, i, length, StringComparison.OrdinalIgnoreCase) == 0)
            {
                return true;
            }

            return false;
        }

        /// <summary>
        /// Clears the file existence cache.
        /// </summary>
        internal static void ClearFileExistenceCache()
        {
            FileExistenceCache.Clear();
        }

        internal static void ReadFromStream(this Stream stream, byte[] content, int startIndex, int length)
        {
            stream.ReadExactly(content, startIndex, length);
        }
    }
}