File: ItemSpecModifiers.cs
Web Access
Project: ..\..\..\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;
using System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Microsoft.Build.Shared;
using Microsoft.Build.Shared.FileSystem;
 
#nullable disable
 
namespace Microsoft.Build.Framework;
 
/// <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 static readonly FrozenSet<string> s_tableOfItemSpecModifiers = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, All);
    private static readonly FrozenSet<string> s_tableOfDefiningProjectModifiers = FrozenSet.Create(StringComparer.OrdinalIgnoreCase,
    [
        DefiningProjectFullPath,
        DefiningProjectDirectory,
        DefiningProjectName,
        DefiningProjectExtension,
    ]);
 
    /// <summary>
    /// Indicates if the given name is reserved for an item-spec modifier.
    /// </summary>
    internal static bool IsItemSpecModifier(string name)
    {
        if (name == null)
        {
            return false;
        }
 
        // Could still be a case-insensitive match.
        bool result = s_tableOfItemSpecModifiers.Contains(name);
 
        return result;
    }
 
    /// <summary>
    /// Indicates if the given name is reserved for one of the specific subset of itemspec
    /// modifiers to do with the defining project of the item.
    /// </summary>
    internal static bool IsDefiningProjectModifier(string name) => s_tableOfDefiningProjectModifiers.Contains(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)
        {
            if (name.Length == 12)
            {
                if (name[0] == 'R' || name[0] == '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.
    /// Does not cache the result.
    /// </summary>
    internal static string GetItemSpecModifier(string currentDirectory, string itemSpec, string definingProjectEscaped, string modifier)
    {
        string dummy = null;
        return GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, modifier, ref dummy);
    }
 
    /// <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)
    {
        FrameworkErrorUtilities.VerifyThrow(itemSpec != null, "Need item-spec to modify.");
        FrameworkErrorUtilities.VerifyThrow(modifier != null, "Need modifier to apply to item-spec.");
 
        string modifiedItemSpec = null;
 
        try
        {
            if (string.Equals(modifier, FullPath, StringComparison.OrdinalIgnoreCase))
            {
                if (fullPath != null)
                {
                    return fullPath;
                }
 
                if (currentDirectory == null)
                {
                    currentDirectory = FileUtilities.CurrentThreadWorkingDirectory ?? string.Empty;
                }
 
                modifiedItemSpec = FileUtilities.GetFullPath(itemSpec, currentDirectory);
                fullPath = modifiedItemSpec;
 
                ThrowForUrl(modifiedItemSpec, itemSpec, currentDirectory);
            }
            else if (string.Equals(modifier, RootDir, StringComparison.OrdinalIgnoreCase))
            {
                GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, FullPath, ref fullPath);
 
                modifiedItemSpec = Path.GetPathRoot(fullPath);
 
                if (!FileUtilities.EndsWithSlash(modifiedItemSpec))
                {
                    FrameworkErrorUtilities.VerifyThrow(
                        FileUtilitiesRegex.StartsWithUncPattern(modifiedItemSpec),
                        "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)
                    modifiedItemSpec += Path.DirectorySeparatorChar;
                }
            }
            else if (string.Equals(modifier, Filename, StringComparison.OrdinalIgnoreCase))
            {
                // if the item-spec is a root directory, it can have no filename
                if (IsRootDirectory(itemSpec))
                {
                    // 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
                    modifiedItemSpec = string.Empty;
                }
                else
                {
                    // Fix path to avoid problem with Path.GetFileNameWithoutExtension when backslashes in itemSpec on Unix
                    modifiedItemSpec = Path.GetFileNameWithoutExtension(FileUtilities.FixFilePath(itemSpec));
                }
            }
            else if (string.Equals(modifier, Extension, StringComparison.OrdinalIgnoreCase))
            {
                // if the item-spec is a root directory, it can have no extension
                if (IsRootDirectory(itemSpec))
                {
                    // NOTE: this is to prevent Path.GetExtension() from treating server and share elements in a UNC
                    // file-spec as filenames e.g. \\server.ext, \\server\share.ext
                    modifiedItemSpec = string.Empty;
                }
                else
                {
                    modifiedItemSpec = Path.GetExtension(itemSpec);
                }
            }
            else if (string.Equals(modifier, RelativeDir, StringComparison.OrdinalIgnoreCase))
            {
                modifiedItemSpec = FileUtilities.GetDirectory(itemSpec);
            }
            else if (string.Equals(modifier, Directory, StringComparison.OrdinalIgnoreCase))
            {
                GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, FullPath, ref fullPath);
 
                modifiedItemSpec = FileUtilities.GetDirectory(fullPath);
 
                if (NativeMethods.IsWindows)
                {
                    int length = -1;
                    if (FileUtilitiesRegex.StartsWithDrivePattern(modifiedItemSpec))
                    {
                        length = 2;
                    }
                    else
                    {
                        length = FileUtilitiesRegex.StartsWithUncPatternMatchLength(modifiedItemSpec);
                    }
 
                    if (length != -1)
                    {
                        FrameworkErrorUtilities.VerifyThrow((modifiedItemSpec.Length > length) && FileUtilities.IsSlash(modifiedItemSpec[length]),
                                                   "Root directory must have a trailing slash.");
 
                        modifiedItemSpec = modifiedItemSpec.Substring(length + 1);
                    }
                }
                else
                {
                    FrameworkErrorUtilities.VerifyThrow(!string.IsNullOrEmpty(modifiedItemSpec) && FileUtilities.IsSlash(modifiedItemSpec[0]),
                                               "Expected a full non-windows path rooted at '/'.");
 
                    // A full unix path is always rooted at
                    // `/`, and a root-relative path is the
                    // rest of the string.
                    modifiedItemSpec = modifiedItemSpec.Substring(1);
                }
            }
            else if (string.Equals(modifier, RecursiveDir, StringComparison.OrdinalIgnoreCase))
            {
                // only the BuildItem class can compute this modifier -- so leave empty
                modifiedItemSpec = String.Empty;
            }
            else if (string.Equals(modifier, Identity, StringComparison.OrdinalIgnoreCase))
            {
                modifiedItemSpec = itemSpec;
            }
            else if (string.Equals(modifier, ModifiedTime, StringComparison.OrdinalIgnoreCase))
            {
                // About to go out to the filesystem.  This means data is leaving the engine, so need
                // to unescape first.
                string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
 
                FileInfo info = FileUtilities.GetFileInfoNoThrow(unescapedItemSpec);
 
                if (info != null)
                {
                    modifiedItemSpec = info.LastWriteTime.ToString(FileUtilities.FileTimeFormat, null);
                }
                else
                {
                    // File does not exist, or path is a directory
                    modifiedItemSpec = String.Empty;
                }
            }
            else if (string.Equals(modifier, CreatedTime, StringComparison.OrdinalIgnoreCase))
            {
                // About to go out to the filesystem.  This means data is leaving the engine, so need
                // to unescape first.
                string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
 
                if (FileSystems.Default.FileExists(unescapedItemSpec))
                {
                    modifiedItemSpec = File.GetCreationTime(unescapedItemSpec).ToString(FileUtilities.FileTimeFormat, null);
                }
                else
                {
                    // File does not exist, or path is a directory
                    modifiedItemSpec = String.Empty;
                }
            }
            else if (string.Equals(modifier, AccessedTime, StringComparison.OrdinalIgnoreCase))
            {
                // About to go out to the filesystem.  This means data is leaving the engine, so need
                // to unescape first.
                string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec);
 
                if (FileSystems.Default.FileExists(unescapedItemSpec))
                {
                    modifiedItemSpec = File.GetLastAccessTime(unescapedItemSpec).ToString(FileUtilities.FileTimeFormat, null);
                }
                else
                {
                    // File does not exist, or path is a directory
                    modifiedItemSpec = String.Empty;
                }
            }
            else if (IsDefiningProjectModifier(modifier))
            {
                if (String.IsNullOrEmpty(definingProjectEscaped))
                {
                    // We have nothing to work with, but that's sometimes OK -- so just return String.Empty
                    modifiedItemSpec = String.Empty;
                }
                else
                {
                    if (string.Equals(modifier, DefiningProjectDirectory, StringComparison.OrdinalIgnoreCase))
                    {
                        // ItemSpecModifiers.Directory does not contain the root directory
                        modifiedItemSpec = Path.Combine(
                                GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, RootDir),
                                GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, Directory));
                    }
                    else
                    {
                        string additionalModifier = null;
 
                        if (string.Equals(modifier, DefiningProjectFullPath, StringComparison.OrdinalIgnoreCase))
                        {
                            additionalModifier = FullPath;
                        }
                        else if (string.Equals(modifier, DefiningProjectName, StringComparison.OrdinalIgnoreCase))
                        {
                            additionalModifier = Filename;
                        }
                        else if (string.Equals(modifier, DefiningProjectExtension, StringComparison.OrdinalIgnoreCase))
                        {
                            additionalModifier = Extension;
                        }
                        else
                        {
                            throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier.");
                        }
 
                        modifiedItemSpec = GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, additionalModifier);
                    }
                }
            }
            else
            {
                throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier.");
            }
        }
        catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
        {
            throw new InvalidOperationException(SR.FormatInvalidFilespecForTransform(modifier, itemSpec, e.Message));
        }
 
        return modifiedItemSpec;
    }
 
    /// <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 = FileUtilitiesRegex.StartsWithUncPatternMatchLength(path);
 
        // Determine if the given path is a standard drive/unc pattern root
        if (FileUtilitiesRegex.IsDrivePattern(path) ||
            FileUtilitiesRegex.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 (FileUtilitiesRegex.StartsWithDrivePattern(path) &&
            ((path.Length >= 3 && path[2] != '\\' && path[2] != '/') ||
            (path.Length >= 4 && path[3] != '\\' && path[3] != '/')))
        {
            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
            fullPath = Path.GetFullPath(Path.Combine(currentDirectory, itemSpec));
        }
    }
}