File: Utilities\FileUtilities.ItemSpecModifiers.cs
Web Access
Project: ..\..\..\src\MSBuildTaskHost\MSBuildTaskHost.csproj (MSBuildTaskHost)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Microsoft.Build.TaskHost.Resources;
 
namespace Microsoft.Build.TaskHost.Utilities;
 
internal static partial class FileUtilities
{
    /// <summary>
    /// Encapsulates the definitions of the item-spec modifiers a.k.a. reserved item metadata.
    /// </summary>
    internal static class ItemSpecModifiers
    {
        internal const string FullPath = "FullPath";
        internal const string RootDir = "RootDir";
        internal const string Filename = "Filename";
        internal const string Extension = "Extension";
        internal const string RelativeDir = "RelativeDir";
        internal const string Directory = "Directory";
        internal const string RecursiveDir = "RecursiveDir";
        internal const string Identity = "Identity";
        internal const string ModifiedTime = "ModifiedTime";
        internal const string CreatedTime = "CreatedTime";
        internal const string AccessedTime = "AccessedTime";
        internal const string DefiningProjectFullPath = "DefiningProjectFullPath";
        internal const string DefiningProjectDirectory = "DefiningProjectDirectory";
        internal const string DefiningProjectName = "DefiningProjectName";
        internal const string DefiningProjectExtension = "DefiningProjectExtension";
 
        // These are all the well-known attributes.
        internal static readonly string[] All =
        [
            FullPath,
            RootDir,
            Filename,
            Extension,
            RelativeDir,
            Directory,
            RecursiveDir, // <-- Not derivable.
            Identity,
            ModifiedTime,
            CreatedTime,
            AccessedTime,
            DefiningProjectFullPath,
            DefiningProjectDirectory,
            DefiningProjectName,
            DefiningProjectExtension
        ];
 
        private enum ItemSpecModifierKind
        {
            FullPath,
            RootDir,
            Filename,
            Extension,
            RelativeDir,
            Directory,
            RecursiveDir, // <-- Not derivable.
            Identity,
            ModifiedTime,
            CreatedTime,
            AccessedTime,
            DefiningProjectFullPath,
            DefiningProjectDirectory,
            DefiningProjectName,
            DefiningProjectExtension
        }
 
        private static readonly Dictionary<string, ItemSpecModifierKind> s_itemSpecModifierMap = new(StringComparer.OrdinalIgnoreCase)
        {
            { FullPath, ItemSpecModifierKind.FullPath },
            { RootDir, ItemSpecModifierKind.RootDir },
            { Filename, ItemSpecModifierKind.Filename },
            { Extension, ItemSpecModifierKind.Extension },
            { RelativeDir, ItemSpecModifierKind.RelativeDir },
            { Directory, ItemSpecModifierKind.Directory },
            { RecursiveDir, ItemSpecModifierKind.RecursiveDir },
            { Identity, ItemSpecModifierKind.Identity },
            { ModifiedTime, ItemSpecModifierKind.ModifiedTime },
            { CreatedTime, ItemSpecModifierKind.CreatedTime },
            { AccessedTime, ItemSpecModifierKind.AccessedTime },
            { DefiningProjectFullPath, ItemSpecModifierKind.DefiningProjectFullPath },
            { DefiningProjectDirectory, ItemSpecModifierKind.DefiningProjectDirectory },
            { DefiningProjectName, ItemSpecModifierKind.DefiningProjectName },
            { DefiningProjectExtension, ItemSpecModifierKind.DefiningProjectExtension }
        };
 
        /// <summary>
        /// Indicates if the given name is reserved for an item-spec modifier.
        /// </summary>
        internal static bool IsItemSpecModifier([NotNullWhen(true)] string name)
            => name != null && s_itemSpecModifierMap.ContainsKey(name);
 
        /// <summary>
        /// Indicates if the given name is reserved for a derivable item-spec modifier.
        /// Derivable means it can be computed given a file name.
        /// </summary>
        /// <param name="name">Name to check.</param>
        /// <returns>true, if name of a derivable modifier</returns>
        internal static bool IsDerivableItemSpecModifier(string name)
        {
            bool isItemSpecModifier = IsItemSpecModifier(name);
 
            if (isItemSpecModifier && name.Length == 12 && name[0] is 'R' or 'r')
            {
                // The only 12 letter ItemSpecModifier that starts with 'R' is 'RecursiveDir'
                return false;
            }
 
            return isItemSpecModifier;
        }
 
        /// <summary>
        /// Performs path manipulations on the given item-spec as directed.
        ///
        /// Supported modifiers:
        ///     %(FullPath)         = full path of item
        ///     %(RootDir)          = root directory of item
        ///     %(Filename)         = item filename without extension
        ///     %(Extension)        = item filename extension
        ///     %(RelativeDir)      = item directory as given in item-spec
        ///     %(Directory)        = full path of item directory relative to root
        ///     %(RecursiveDir)     = portion of item path that matched a recursive wildcard
        ///     %(Identity)         = item-spec as given
        ///     %(ModifiedTime)     = last write time of item
        ///     %(CreatedTime)      = creation time of item
        ///     %(AccessedTime)     = last access time of item
        ///
        /// NOTES:
        /// 1) This method always returns an empty string for the %(RecursiveDir) modifier because it does not have enough
        ///    information to compute it -- only the BuildItem class can compute this modifier.
        /// 2) All but the file time modifiers could be cached, but it's not worth the space. Only full path is cached, as the others are just string manipulations.
        /// </summary>
        /// <remarks>
        /// Methods of the Path class "normalize" slashes and periods. For example:
        /// 1) successive slashes are combined into 1 slash
        /// 2) trailing periods are discarded
        /// 3) forward slashes are changed to back-slashes
        ///
        /// As a result, we cannot rely on any file-spec that has passed through a Path method to remain the same. We will
        /// therefore not bother preserving slashes and periods when file-specs are transformed.
        ///
        /// Never returns null.
        /// </remarks>
        /// <param name="currentDirectory">The root directory for relative item-specs. When called on the Engine thread, this is the project directory. When called as part of building a task, it is null, indicating that the current directory should be used.</param>
        /// <param name="itemSpec">The item-spec to modify.</param>
        /// <param name="definingProjectEscaped">The path to the project that defined this item (may be null).</param>
        /// <param name="modifier">The modifier to apply to the item-spec.</param>
        /// <param name="fullPath">Full path if any was previously computed, to cache.</param>
        /// <returns>The modified item-spec (can be empty string, but will never be null).</returns>
        /// <exception cref="InvalidOperationException">Thrown when the item-spec is not a path.</exception>
        [SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity", Justification = "Pre-existing")]
        internal static string GetItemSpecModifier(
            string? currentDirectory,
            string itemSpec,
            string? definingProjectEscaped,
            string modifier,
            ref string? fullPath)
        {
            ErrorUtilities.VerifyThrow(itemSpec != null, "Need item-spec to modify.");
            ErrorUtilities.VerifyThrow(modifier != null, "Need modifier to apply to item-spec.");
 
            try
            {
                if (s_itemSpecModifierMap.TryGetValue(modifier, out ItemSpecModifierKind kind))
                {
                    switch (kind)
                    {
                        case ItemSpecModifierKind.FullPath:
                            ComputeFullPath(itemSpec, currentDirectory, ref fullPath);
                            return fullPath;
 
                        case ItemSpecModifierKind.RootDir:
                            ComputeFullPath(itemSpec, currentDirectory, ref fullPath);
                            return ComputeRootDir(fullPath);
 
                        case ItemSpecModifierKind.Filename:
                            return ComputeFileName(itemSpec);
 
                        case ItemSpecModifierKind.Extension:
                            return ComputeExtension(itemSpec);
 
                        case ItemSpecModifierKind.RelativeDir:
                            return ComputeRelativeDir(itemSpec);
 
                        case ItemSpecModifierKind.Directory:
                            ComputeFullPath(itemSpec, currentDirectory, ref fullPath);
                            return ComputeDirectory(fullPath);
 
                        case ItemSpecModifierKind.RecursiveDir:
                            // only the BuildItem class can compute this modifier -- so leave empty
                            return string.Empty;
 
                        case ItemSpecModifierKind.Identity:
                            return itemSpec;
 
                        case ItemSpecModifierKind.ModifiedTime:
                            return ComputeModifiedTime(itemSpec);
 
                        case ItemSpecModifierKind.CreatedTime:
                            return ComputeCreatedTime(itemSpec);
 
                        case ItemSpecModifierKind.AccessedTime:
                            return ComputeAccessedTime(itemSpec);
 
                        case ItemSpecModifierKind.DefiningProjectDirectory:
                        case ItemSpecModifierKind.DefiningProjectFullPath:
                        case ItemSpecModifierKind.DefiningProjectName:
                        case ItemSpecModifierKind.DefiningProjectExtension:
                            if (string.IsNullOrEmpty(definingProjectEscaped))
                            {
                                // We have nothing to work with, but that's sometimes OK -- so just return String.Empty
                                return string.Empty;
                            }
 
                            ErrorUtilities.VerifyThrow(definingProjectEscaped != null, $"{nameof(definingProjectEscaped)} is null.");
 
                            switch (kind)
                            {
                                case ItemSpecModifierKind.DefiningProjectDirectory:
                                    return ComputeDefiningProjectDirectory(definingProjectEscaped, currentDirectory);
 
                                case ItemSpecModifierKind.DefiningProjectFullPath:
                                    return ComputeDefiningProjectFullPath(definingProjectEscaped, currentDirectory);
 
                                case ItemSpecModifierKind.DefiningProjectName:
                                    return ComputeDefiningProjectFileName(definingProjectEscaped);
 
                                case ItemSpecModifierKind.DefiningProjectExtension:
                                    return ComputeDefiningProjectExtension(definingProjectEscaped);
                            }
 
                            break;
                    }
                }
            }
            catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
            {
                ErrorUtilities.ThrowInvalidOperation(SR.Shared_InvalidFilespecForTransform, modifier, itemSpec, e.Message);
            }
 
            ErrorUtilities.ThrowInternalError($"\"{modifier}\" is not a valid item-spec modifier.");
            return null;
        }
 
        private static string ComputeFullPath(string itemSpec, string? currentDirectory)
        {
            currentDirectory ??= string.Empty;
            string fullPath = GetFullPath(itemSpec, currentDirectory);
 
            ThrowForUrl(fullPath, itemSpec, currentDirectory);
 
            return fullPath;
        }
 
        private static void ComputeFullPath(string itemSpec, string? currentDirectory, [NotNull] ref string? existingFullPath)
        {
            if (existingFullPath != null)
            {
                return;
            }
 
            existingFullPath = ComputeFullPath(itemSpec, currentDirectory);
        }
 
        private static string ComputeRootDir(string fullPath)
        {
            string rootDir = Path.GetPathRoot(fullPath);
 
            if (!EndsWithSlash(rootDir))
            {
                ErrorUtilities.VerifyThrow(
                    StartsWithUncPattern(rootDir),
                    "Only UNC shares should be missing trailing slashes.");
 
                // restore/append trailing slash if Path.GetPathRoot() has either removed it, or failed to add it
                // (this happens with UNC shares)
                rootDir += Path.DirectorySeparatorChar;
            }
 
            return rootDir;
        }
 
        private static string ComputeFileName(string itemSpec)
             // if the item-spec is a root directory, it can have no filename
             // NOTE: this is to prevent Path.GetFileNameWithoutExtension() from treating server and share elements
             // in a UNC file-spec as filenames e.g. \\server, \\server\share
             => IsRootDirectory(itemSpec)
                ? string.Empty
                : Path.GetFileNameWithoutExtension(itemSpec);
 
        private static string ComputeExtension(string itemSpec)
             // if the item-spec is a root directory, it can have no extension
             // NOTE: this is to prevent Path.GetFileNameWithoutExtension() from treating server and share elements
             // in a UNC file-spec as filenames e.g. \\server, \\server\share
             => IsRootDirectory(itemSpec)
                ? string.Empty
                : Path.GetExtension(itemSpec);
 
        private static string ComputeRelativeDir(string itemSpec)
            => GetDirectory(itemSpec);
 
        private static string ComputeDirectory(string fullPath)
        {
            string directory = GetDirectory(fullPath);
 
            int length = StartsWithDrivePattern(directory)
                ? 2
                : StartsWithUncPatternMatchLength(directory);
 
            if (length != -1)
            {
                ErrorUtilities.VerifyThrow(
                    directory.Length > length && IsAnySlash(directory[length]),
                    "Root directory must have a trailing slash.");
 
                directory = directory.Substring(length + 1);
            }
 
            return directory;
        }
 
        private static string ComputeModifiedTime(string itemSpec)
        {
            // About to go out to the filesystem.  This means data is leaving the engine, so need to unescape first.
            string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
 
            return NativeMethods.FileExists(unescapedItemSpec)
                ? File.GetLastWriteTime(unescapedItemSpec).ToString(FileTimeFormat, provider: null)
                : string.Empty; // File does not exist, or path is a directory
        }
 
        private static string ComputeCreatedTime(string itemSpec)
        {
            // About to go out to the filesystem.  This means data is leaving the engine, so need to unescape first.
            string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
 
            return NativeMethods.FileExists(unescapedItemSpec)
                ? File.GetCreationTime(unescapedItemSpec).ToString(FileTimeFormat, provider: null)
                : string.Empty; // File does not exist, or path is a directory
        }
 
        private static string ComputeAccessedTime(string itemSpec)
        {
            // About to go out to the filesystem.  This means data is leaving the engine, so need to unescape first.
            string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
 
            return NativeMethods.FileExists(unescapedItemSpec)
                ? File.GetLastAccessTime(unescapedItemSpec).ToString(FileTimeFormat, provider: null)
                : string.Empty; // File does not exist, or path is a directory
        }
 
        private static string ComputeDefiningProjectDirectory(string itemSpec, string? currentDirectory)
        {
            string fullPath = ComputeFullPath(itemSpec, currentDirectory);
            string rootDir = ComputeRootDir(fullPath);
            string directory = ComputeDirectory(fullPath);
 
            return Path.Combine(rootDir, directory);
        }
 
        private static string ComputeDefiningProjectFullPath(string itemSpec, string? currentDirectory)
            => ComputeFullPath(itemSpec, currentDirectory);
 
        private static string ComputeDefiningProjectFileName(string itemSpec)
            => ComputeFileName(itemSpec);
 
        private static string ComputeDefiningProjectExtension(string itemSpec)
            => ComputeExtension(itemSpec);
 
        /// <summary>
        /// Indicates whether the given path is a UNC or drive pattern root directory.
        /// <para>Note: This function mimics the behavior of checking if Path.GetDirectoryName(path) == null.</para>
        /// </summary>
        /// <param name="path"></param>
        /// <returns></returns>
        private static bool IsRootDirectory(string path)
        {
            // Eliminate all non-rooted paths
            if (!Path.IsPathRooted(path))
            {
                return false;
            }
 
            int uncMatchLength = StartsWithUncPatternMatchLength(path);
 
            // Determine if the given path is a standard drive/unc pattern root
            if (IsDrivePattern(path) ||
                IsDrivePatternWithSlash(path) ||
                uncMatchLength == path.Length)
            {
                return true;
            }
 
            // Eliminate all non-root unc paths.
            if (uncMatchLength != -1)
            {
                return false;
            }
 
            // Eliminate any drive patterns that don't have a slash after the colon or where the 4th character is a non-slash
            // A non-slash at [3] is specifically checked here because Path.GetDirectoryName
            // considers "C:///" a valid root.
            if (StartsWithDrivePattern(path) &&
                ((path.Length >= 3 && path[2] is not (BackSlash or ForwardSlash)) ||
                 (path.Length >= 4 && path[3] is not (BackSlash or ForwardSlash))))
            {
                return false;
            }
 
            // There are some edge cases that can get to this point.
            // After eliminating valid / invalid roots, fall back on original behavior.
            return Path.GetDirectoryName(path) == null;
        }
 
        /// <summary>
        /// Temporary check for something like http://foo which will end up like c:\foo\bar\http://foo
        /// We should either have no colon, or exactly one colon.
        /// UNDONE: This is a minimal safe change for Dev10. The correct fix should be to make GetFullPath/NormalizePath throw for this.
        /// </summary>
        private static void ThrowForUrl(string fullPath, string itemSpec, string currentDirectory)
        {
            if (fullPath.IndexOf(':') != fullPath.LastIndexOf(':'))
            {
                // Cause a better error to appear
                _ = Path.GetFullPath(Path.Combine(currentDirectory, itemSpec));
            }
        }
    }
}