File: Utility\MSBuildNuGetProjectSystemUtility.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.PackageManagement\NuGet.PackageManagement.csproj (NuGet.PackageManagement)
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

#nullable disable

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using NuGet.Common;
using NuGet.Frameworks;
using NuGet.PackageManagement;
using NuGet.Packaging;
using NuGet.Packaging.Core;

namespace NuGet.ProjectManagement
{
    public static class MSBuildNuGetProjectSystemUtility
    {
        internal static XDocument GetOrCreateDocument(XName rootName, string path, IMSBuildProjectSystem msBuildNuGetProjectSystem)
        {
            if (File.Exists(Path.Combine(msBuildNuGetProjectSystem.ProjectFullPath, path)))
            {
                try
                {
                    return Shared.XmlUtility.Load(Path.Combine(msBuildNuGetProjectSystem.ProjectFullPath, path), LoadOptions.PreserveWhitespace);
                }
                catch (FileNotFoundException) { }
            }

            var document = new XDocument(new XElement(rootName));
            // Add it to the project system
            AddFile(msBuildNuGetProjectSystem, path, document.Save);

            return document;
        }

        public static FrameworkSpecificGroup GetMostCompatibleGroup(NuGetFramework projectTargetFramework,
            IEnumerable<FrameworkSpecificGroup> itemGroups)
        {
            var reducer = new FrameworkReducer();
            var mostCompatibleFramework
                = reducer.GetNearest(projectTargetFramework, itemGroups.Select(i => i.TargetFramework));
            if (mostCompatibleFramework != null)
            {
                var mostCompatibleGroup
                    = itemGroups.FirstOrDefault(i => i.TargetFramework.Equals(mostCompatibleFramework));

                if (IsValid(mostCompatibleGroup))
                {
                    return mostCompatibleGroup;
                }
            }

            return null;
        }

        /// <summary>
        /// Filter out invalid package items and replace the directory separator with the correct slash for the 
        /// current OS.
        /// </summary>
        /// <remarks>If the group is null or contains only only _._ this method will return the same group.</remarks>
        public static FrameworkSpecificGroup Normalize(FrameworkSpecificGroup group)
        {
            // Default to returning the same group
            var result = group;

            // If the group is null or it does not contain any items besides _._ then this is a no-op.
            // If it does have items create a new normalized group to replace it with.
            if (group?.Items.Any() == true)
            {
                // Filter out invalid files
                var normalizedItems = GetValidPackageItems(group.Items)
                                            .Select(item => PathUtility.ReplaceAltDirSeparatorWithDirSeparator(item));

                // Create a new group
                result = new FrameworkSpecificGroup(
                    targetFramework: group.TargetFramework,
                    items: normalizedItems);
            }

            return result;
        }

        public static bool IsValid(FrameworkSpecificGroup frameworkSpecificGroup)
        {
            if (frameworkSpecificGroup != null)
            {
                return (frameworkSpecificGroup.HasEmptyFolder
                     || frameworkSpecificGroup.Items.Any()
                     || !frameworkSpecificGroup.TargetFramework.Equals(NuGetFramework.AnyFramework));
            }

            return false;
        }

        internal static async Task TryAddFileAsync(
            IMSBuildProjectSystem projectSystem,
            string path,
            Func<Task<Stream>> streamTaskFactory,
            CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (projectSystem.FileExistsInProject(path))
            {
                // file exists in project, ask user if he wants to overwrite or ignore
                var conflictMessage = string.Format(CultureInfo.CurrentCulture,
                    Strings.FileConflictMessage, path, projectSystem.ProjectName);
                var fileConflictAction = projectSystem.NuGetProjectContext.ResolveFileConflict(conflictMessage);
                if (fileConflictAction == FileConflictAction.Overwrite
                    || fileConflictAction == FileConflictAction.OverwriteAll)
                {
                    // overwrite
                    projectSystem.NuGetProjectContext.Log(MessageLevel.Info, Strings.Info_OverwritingExistingFile, path);
                    using (var stream = await streamTaskFactory())
                    {
                        projectSystem.AddFile(path, stream);
                    }
                }
                else
                {
                    // ignore
                    projectSystem.NuGetProjectContext.Log(MessageLevel.Warning, Strings.Warning_FileAlreadyExists, path);
                }
            }
            else
            {
                projectSystem.AddFile(path, await streamTaskFactory());
            }
        }

        internal static async Task AddFilesAsync(
            IMSBuildProjectSystem projectSystem,
            IAsyncPackageCoreReader packageReader,
            FrameworkSpecificGroup frameworkSpecificGroup,
            IDictionary<FileTransformExtensions, IPackageFileTransformer> fileTransformers,
            CancellationToken cancellationToken)
        {
            var packageTargetFramework = frameworkSpecificGroup.TargetFramework;

            var packageItemListAsArchiveEntryNames = frameworkSpecificGroup.Items.ToList();
            packageItemListAsArchiveEntryNames.Sort(PackageItemComparer.Instance);

            try
            {
                var paths =
                    packageItemListAsArchiveEntryNames.Select(
                        file => ResolvePath(fileTransformers, fte => fte.InstallExtension,
                            GetEffectivePathForContentFile(packageTargetFramework, file)));
                paths = paths.Where(p => !string.IsNullOrEmpty(p));

                projectSystem.RegisterProcessedFiles(paths);
            }
            catch (Exception)
            {
                // Ignore all exceptions for now
            }

            foreach (var file in packageItemListAsArchiveEntryNames)
            {
                if (IsEmptyFolder(file))
                {
                    continue;
                }

                var effectivePathForContentFile = GetEffectivePathForContentFile(packageTargetFramework, file);

                // Resolve the target path
                IPackageFileTransformer installTransformer;
                var path = ResolveTargetPath(projectSystem,
                    fileTransformers,
                    fte => fte.InstallExtension, effectivePathForContentFile, out installTransformer);

                if (projectSystem.IsSupportedFile(path))
                {
                    if (installTransformer != null)
                    {
                        await installTransformer.TransformFileAsync(
                            () => packageReader.GetStreamAsync(file, cancellationToken),
                            path,
                            projectSystem,
                            cancellationToken);
                    }
                    else
                    {
                        // Ignore uninstall transform file during installation
                        string truncatedPath;
                        var uninstallTransformer =
                            FindFileTransformer(fileTransformers, fte => fte.UninstallExtension,
                                effectivePathForContentFile, out truncatedPath);
                        if (uninstallTransformer != null)
                        {
                            continue;
                        }

                        await TryAddFileAsync(
                            projectSystem,
                            path,
                            () => packageReader.GetStreamAsync(file, cancellationToken),
                            cancellationToken);
                    }
                }
            }
        }

        [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")]
        internal static async Task DeleteFilesAsync(
            IMSBuildProjectSystem projectSystem,
            ZipArchive zipArchive,
            IEnumerable<string> otherPackagesPath,
            FrameworkSpecificGroup frameworkSpecificGroup,
            IDictionary<FileTransformExtensions, IPackageFileTransformer> fileTransformers,
            CancellationToken cancellationToken)
        {
            var packageTargetFramework = frameworkSpecificGroup.TargetFramework;
            IPackageFileTransformer transformer;

            var directoryLookup = frameworkSpecificGroup.Items.ToLookup(
                p => Path.GetDirectoryName(ResolveTargetPath(projectSystem,
                    fileTransformers,
                    fte => fte.UninstallExtension,
                    GetEffectivePathForContentFile(packageTargetFramework, p),
                    out transformer)));

            // Get all directories that this package may have added
            var directories = from grouping in directoryLookup
                              from directory in FileSystemUtility.GetDirectories(grouping.Key, altDirectorySeparator: false)
                              orderby directory.Length descending
                              select directory;

            var projectFullPath = projectSystem.ProjectFullPath;

            // Remove files from every directory
            foreach (var directory in directories)
            {
                var directoryFiles = directoryLookup.Contains(directory)
                    ? directoryLookup[directory]
                    : Enumerable.Empty<string>();

                if (!Directory.Exists(Path.Combine(projectFullPath, directory)))
                {
                    continue;
                }

                foreach (var file in directoryFiles)
                {
                    if (IsEmptyFolder(file))
                    {
                        continue;
                    }

                    // Resolve the path
                    var path = ResolveTargetPath(projectSystem,
                        fileTransformers,
                        fte => fte.UninstallExtension,
                        GetEffectivePathForContentFile(packageTargetFramework, file),
                        out transformer);

                    if (projectSystem.IsSupportedFile(path))
                    {
                        // Register the file being uninstalled (used by web site project system).
                        projectSystem.RegisterProcessedFiles(new[] { path });

                        if (transformer != null)
                        {
                            // TODO: use the framework from packages.config instead of the current framework
                            // which may have changed during re-targeting
                            var projectFramework = projectSystem.TargetFramework;

                            var matchingFiles = new List<InternalZipFileInfo>();
                            foreach (var otherPackagePath in otherPackagesPath)
                            {
                                using (var otherPackageZipReader = new PackageArchiveReader(otherPackagePath))
                                {
                                    // use the project framework to find the group that would have been installed
                                    var mostCompatibleContentFilesGroup = GetMostCompatibleGroup(
                                        projectFramework,
                                        otherPackageZipReader.GetContentItems());

                                    if (IsValid(mostCompatibleContentFilesGroup))
                                    {
                                        // Should not normalize content files group.
                                        // It should be like a ZipFileEntry with a forward slash.
                                        foreach (var otherPackageItem in mostCompatibleContentFilesGroup.Items)
                                        {
                                            if (GetEffectivePathForContentFile(packageTargetFramework,
                                                otherPackageItem)
                                                .Equals(
                                                    GetEffectivePathForContentFile(packageTargetFramework, file),
                                                    StringComparison.OrdinalIgnoreCase))
                                            {
                                                matchingFiles.Add(new InternalZipFileInfo(otherPackagePath,
                                                    otherPackageItem));
                                            }
                                        }
                                    }
                                }
                            }

                            try
                            {
                                var zipArchiveFileEntry = PathUtility.GetEntry(zipArchive, file);
                                if (zipArchiveFileEntry != null)
                                {
                                    await transformer.RevertFileAsync(
                                        () => Task.FromResult(zipArchiveFileEntry.Open()),
                                        path, matchingFiles,
                                        projectSystem,
                                        cancellationToken);
                                }
                            }
                            catch (Exception e)
                            {
                                projectSystem.NuGetProjectContext.Log(MessageLevel.Warning, e.Message);
                            }
                        }
                        else
                        {
                            try
                            {
                                var zipArchiveFileEntry = PathUtility.GetEntry(zipArchive, file);
                                if (zipArchiveFileEntry != null)
                                {
                                    await DeleteFileSafeAsync(
                                        path,
                                        () => Task.FromResult(zipArchiveFileEntry.Open()),
                                        projectSystem,
                                        cancellationToken);
                                }
                            }
                            catch (Exception e)
                            {
                                projectSystem.NuGetProjectContext.Log(MessageLevel.Warning, e.Message);
                            }

                        }
                    }
                }

                // If the directory is empty then delete it
                if (!GetFilesSafe(projectSystem, directory).Any()
                    && !GetDirectoriesSafe(projectSystem, directory).Any())
                {
                    DeleteDirectorySafe(projectSystem, directory);
                }
            }
        }

        internal static IEnumerable<string> GetFilesSafe(IMSBuildProjectSystem projectSystem, string path)
        {
            return GetFilesSafe(projectSystem, path, "*.*");
        }

        internal static IEnumerable<string> GetFilesSafe(IMSBuildProjectSystem projectSystem, string path, string filter)
        {
            try
            {
                return GetFiles(projectSystem, path, filter, recursive: false);
            }
            catch (Exception e)
            {
                projectSystem.NuGetProjectContext.Log(MessageLevel.Warning, e.Message);
            }

            return Enumerable.Empty<string>();
        }

        internal static IEnumerable<string> GetFiles(
            IMSBuildProjectSystem projectSystem,
            string path,
            string filter,
            bool recursive)
        {
            return projectSystem.GetFiles(path, filter, recursive);
        }

        internal static async Task DeleteFileSafeAsync(
            string path,
            Func<Task<Stream>> streamFactory,
            IMSBuildProjectSystem projectSystem,
            CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();

            // Only delete the file if it exists and the checksum is the same
            if (projectSystem.FileExistsInProject(path))
            {
                var fullPath = Path.Combine(projectSystem.ProjectFullPath, path);
                if (await FileSystemUtility.ContentEqualsAsync(fullPath, streamFactory))
                {
                    PerformSafeAction(() => projectSystem.RemoveFile(path), projectSystem.NuGetProjectContext);
                }
                else
                {
                    // This package installed a file that was modified so warn the user
                    projectSystem.NuGetProjectContext.Log(MessageLevel.Warning, Strings.Warning_FileModified, fullPath);
                }
            }
        }

        internal static IEnumerable<string> GetDirectoriesSafe(IMSBuildProjectSystem projectSystem, string path)
        {
            try
            {
                return GetDirectories(projectSystem, path);
            }
            catch (Exception e)
            {
                projectSystem.NuGetProjectContext.Log(MessageLevel.Warning, e.Message);
            }

            return Enumerable.Empty<string>();
        }

        internal static IEnumerable<string> GetDirectories(IMSBuildProjectSystem projectSystem, string path)
        {
            return projectSystem.GetDirectories(path);
        }

        internal static void DeleteDirectorySafe(IMSBuildProjectSystem projectSystem, string path)
        {
            PerformSafeAction(() => DeleteDirectory(projectSystem, path), projectSystem.NuGetProjectContext);
        }

        // Deletes an empty folder from disk and the project
        private static void DeleteDirectory(IMSBuildProjectSystem projectSystem, string path)
        {
            var fullPath = Path.Combine(projectSystem.ProjectFullPath, path);
            if (!Directory.Exists(fullPath))
            {
                return;
            }

            // Only delete this folder if it is empty and we didn't specify that we want to recurse
            if (GetFiles(projectSystem, path, "*.*", recursive: false).Any() || GetDirectories(projectSystem, path).Any())
            {
                projectSystem.NuGetProjectContext.Log(MessageLevel.Warning, Strings.Warning_DirectoryNotEmpty, path);
                return;
            }
            projectSystem.RegisterProcessedFiles(new[] { path });

            projectSystem.DeleteDirectory(path, recursive: false);

            // Workaround for update-package TFS issue. If we're bound to TFS, do not try and delete directories.
            var sourceControlManager = SourceControlUtility.GetSourceControlManager(projectSystem.NuGetProjectContext);
            if (sourceControlManager != null)
            {
                // Source control bound, do not delete
                return;
            }

            // For potential project systems that do not remove items from disk, we delete the folder directly
            // There is no actual scenario where we know this is broken without the code below, but since the
            // code was always there, we are leaving it behind for now.
            if (!Directory.Exists(fullPath))
            {
                Directory.Delete(fullPath, recursive: false);

                // The directory is not guaranteed to be gone since there could be
                // other open handles. Wait, up to half a second, until the directory is gone.
                for (var i = 0; Directory.Exists(fullPath) && i < 5; ++i)
                {
                    Thread.Sleep(100);
                }

                projectSystem.RegisterProcessedFiles(new[] { path });

                projectSystem.NuGetProjectContext.Log(MessageLevel.Debug, Strings.Debug_RemovedFolder, fullPath);
            }
        }

        private static void PerformSafeAction(Action action, INuGetProjectContext nuGetProjectContext)
        {
            try
            {
                Attempt(action);
            }
            catch (Exception e)
            {
                nuGetProjectContext.Log(MessageLevel.Warning, e.Message);
            }
        }

        private static void Attempt(Action action, int retries = 3, int delayBeforeRetry = 150)
        {
            while (retries > 0)
            {
                try
                {
                    action();
                    break;
                }
                catch
                {
                    retries--;
                    if (retries == 0)
                    {
                        throw;
                    }
                }
                Thread.Sleep(delayBeforeRetry);
            }
        }

        private static bool IsEmptyFolder(string packageFilePath)
        {
            return packageFilePath != null &&
                   PackagingCoreConstants.EmptyFolder.Equals(Path.GetFileName(packageFilePath), StringComparison.OrdinalIgnoreCase);
        }

        private static string ResolvePath(
            IDictionary<FileTransformExtensions, IPackageFileTransformer> fileTransformers,
            Func<FileTransformExtensions, string> extensionSelector,
            string effectivePath)
        {
            string truncatedPath;

            // Remove the transformer extension (e.g. .pp, .transform)
            var transformer = FindFileTransformer(
                fileTransformers, extensionSelector, effectivePath, out truncatedPath);

            if (transformer != null)
            {
                effectivePath = truncatedPath;
            }

            return effectivePath;
        }

        private static string ResolveTargetPath(
            IMSBuildProjectSystem projectSystem,
            IDictionary<FileTransformExtensions, IPackageFileTransformer> fileTransformers,
            Func<FileTransformExtensions, string> extensionSelector,
            string effectivePath,
            out IPackageFileTransformer transformer)
        {
            string truncatedPath;

            // Remove the transformer extension (e.g. .pp, .transform)
            transformer = FindFileTransformer(fileTransformers, extensionSelector, effectivePath, out truncatedPath);
            if (transformer != null)
            {
                effectivePath = truncatedPath;
            }

            return projectSystem.ResolvePath(effectivePath);
        }

        private static IPackageFileTransformer FindFileTransformer(
            IDictionary<FileTransformExtensions, IPackageFileTransformer> fileTransformers,
            Func<FileTransformExtensions, string> extensionSelector,
            string effectivePath,
            out string truncatedPath)
        {
            foreach ((var transformExtensions, var fileTransformer) in fileTransformers)
            {
                var extension = extensionSelector(transformExtensions);
                if (effectivePath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
                {
                    truncatedPath = effectivePath.Substring(0, effectivePath.Length - extension.Length);

                    // Bug 1686: Don't allow transforming packages.config.transform,
                    // but we still want to copy packages.config.transform as-is into the project.
                    var fileName = Path.GetFileName(truncatedPath);
                    if (!Constants.PackageReferenceFile.Equals(fileName, StringComparison.OrdinalIgnoreCase))
                    {
                        return fileTransformer;
                    }
                }
            }

            truncatedPath = effectivePath;
            return null;
        }

        private static string GetEffectivePathForContentFile(NuGetFramework nuGetFramework, string zipArchiveEntryFullName)
        {
            // Always use Path.DirectorySeparatorChar
            var effectivePathForContentFile = PathUtility.ReplaceAltDirSeparatorWithDirSeparator(zipArchiveEntryFullName);

            if (effectivePathForContentFile.StartsWith(PackagingConstants.Folders.Content + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
            {
                effectivePathForContentFile = effectivePathForContentFile.Substring((PackagingConstants.Folders.Content + Path.DirectorySeparatorChar).Length);
                if (!nuGetFramework.Equals(NuGetFramework.AnyFramework))
                {
                    // Parsing out the framework name out of the effective path
                    var frameworkFolderEndIndex = effectivePathForContentFile.IndexOf(Path.DirectorySeparatorChar);
                    if (frameworkFolderEndIndex != -1)
                    {
                        if (effectivePathForContentFile.Length > frameworkFolderEndIndex + 1)
                        {
                            effectivePathForContentFile = effectivePathForContentFile.Substring(frameworkFolderEndIndex + 1);
                        }
                    }

                    return effectivePathForContentFile;
                }
            }

            // Return the effective path with Path.DirectorySeparatorChar
            return effectivePathForContentFile;
        }

        internal static IEnumerable<string> GetValidPackageItems(IEnumerable<string> items)
        {
            if (items == null
                || !items.Any())
            {
                return Enumerable.Empty<string>();
            }

            // Assume nupkg and nuspec as the save mode for identifying valid package files
            return items.Where(i => PackageHelper.IsPackageFile(i, PackageSaveMode.Defaultv3));
        }

        internal static void AddFile(IMSBuildProjectSystem projectSystem, string path, Action<Stream> writeToStream)
        {
            using (var memoryStream = new MemoryStream())
            {
                writeToStream(memoryStream);
                memoryStream.Seek(0, SeekOrigin.Begin);
                projectSystem.AddFile(path, memoryStream);
            }
        }

        private class PackageItemComparer : IComparer<string>
        {
            public static PackageItemComparer Instance { get; } = new();

            public int Compare(string x, string y)
            {
                // BUG 636: We sort files so that they are added in the correct order
                // e.g aspx before aspx.cs

                if (x.Equals(y, StringComparison.OrdinalIgnoreCase))
                {
                    return 0;
                }

                // Add files that are prefixes of other files first
                if (x.StartsWith(y, StringComparison.OrdinalIgnoreCase))
                {
                    return -1;
                }

                if (y.StartsWith(x, StringComparison.OrdinalIgnoreCase))
                {
                    return 1;
                }

                return string.Compare(y, x, StringComparison.OrdinalIgnoreCase);
            }
        }
    }
}