File: Evaluation\ParserIgnoreConfiguration.cs
Web Access
Project: src\msbuild\src\Build\Microsoft.Build.csproj (Microsoft.Build)
// 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.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Microsoft.Build.BackEnd;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.Shared.FileSystem;

namespace Microsoft.Build.Evaluation
{
    /// <summary>
    /// Manages a set of allowed unknown attributes and elements that should be silently skipped
    /// during project parsing instead of throwing an InvalidProjectFileException.
    /// Configuration is loaded from Directory.Parse.config files discovered from:
    /// 1. MSBUILD_PARSE_CONFIG environment variable paths
    /// 2. Walking up from the project file's directory (passed via ProjectCollection constructor)
    /// </summary>
    internal sealed class ParserIgnoreConfiguration : ITranslatable
    {
        internal const string ConfigFileName = "Directory.Parse.config";
        internal const string EnvironmentVariableName = "MSBUILD_PARSE_CONFIG";

        // Generic element names for dynamically-named elements in the config.
        // These allow rules like Attribute:Property:X to apply to all property elements.
        internal const string GenericPropertyElement = "Property";
        internal const string GenericItemElement = "Item";
        internal const string GenericItemDefinitionElement = "ItemDefinition";
        internal const string GenericMetadataElement = "Metadata";
        internal const string GenericUsingTaskBodyElement = "UsingTaskBody";

        /// <summary>
        /// Static collection of config file paths to embed in the binlog.
        /// Populated during config loading, consumed by BinaryLogger.Shutdown.
        /// </summary>
        private static ConcurrentBag<string> s_binlogEmbedPaths = new();

        /// <summary>Paths collected for binlog embedding.</summary>
        internal static IEnumerable<string> BinlogEmbedPaths => s_binlogEmbedPaths;

        /// <summary>Clears the embed paths after they've been written to the binlog.</summary>
        internal static void ClearBinlogEmbedPaths()
        {
            s_binlogEmbedPaths = new ConcurrentBag<string>();
        }

        private Dictionary<string, HashSet<string>> _allowedAttributes;
        private Dictionary<string, HashSet<string>> _allowedChildren;
        private HashSet<string> _loadedConfigFiles;
        private readonly ConcurrentDictionary<string, int> _skippedItems = new(StringComparer.OrdinalIgnoreCase);

        private ParserIgnoreConfiguration()
        {
            _allowedAttributes = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
            _allowedChildren = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
            _loadedConfigFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        }

        private ParserIgnoreConfiguration(ITranslator translator)
            : this()
        {
            ((ITranslatable)this).Translate(translator);
        }

        internal IReadOnlyCollection<string> LoadedConfigFiles => _loadedConfigFiles;

        internal static ParserIgnoreConfiguration Empty { get; } = new ParserIgnoreConfiguration();

        public static bool Equals(ParserIgnoreConfiguration? left, ParserIgnoreConfiguration? right)
        {
            if (ReferenceEquals(left, right))
            {
                return true;
            }
            
            if (left is null || right is null)
            {
                return false;
            }

            return left._loadedConfigFiles.SetEquals(right._loadedConfigFiles)
                && CollectionHelpers.DictionaryEquals(left._allowedAttributes, right._allowedAttributes, HashSet<string>.CreateSetComparer())
                && CollectionHelpers.DictionaryEquals(left._allowedChildren, right._allowedChildren, HashSet<string>.CreateSetComparer());
        }

        /// <summary>
        /// Checks whether the given file path has already been loaded into this configuration.
        /// </summary>
        internal bool ContainsLoadedFile(string filePath)
        {
            if (string.IsNullOrEmpty(filePath))
            {
                return false;
            }

            try
            {
                return _loadedConfigFiles.Contains(FileUtilities.NormalizePath(filePath));
            }
            catch
            {
                return false;
            }
        }

        internal bool CheckSkipAttribute(string elementName, string attributeName)
        {
            if (_allowedAttributes.Count == 0)
            {
                return false;
            }

            if (!_allowedAttributes.TryGetValue(elementName, out HashSet<string>? allowedAttributes) || !allowedAttributes.Contains(attributeName))
            {
                return false;
            }

            RecordSkippedItem($"Attribute:{elementName}:{attributeName}");
            return true;
        }

        internal bool CheckSkipElement(string parentElementName, string childElementName)
        {
            if (_allowedChildren.Count == 0)
            {
                return false;
            }

            if (!_allowedChildren.TryGetValue(parentElementName, out HashSet<string>? allowedChildren) || !allowedChildren.Contains(childElementName))
            {
                return false;
            }

            RecordSkippedItem($"Element:{parentElementName}:{childElementName}");
            return true;
        }

        internal string? GetSkippedSummaryMessage()
        {
            if (_skippedItems.IsEmpty)
            {
                return null;
            }

            var sb = new StringBuilder("Skipped unrecognized items allowed by Directory.Parse.config:");
            foreach (var kvp in _skippedItems)
            {
                sb.Append($" {kvp.Key} ({kvp.Value} occurrence{(kvp.Value > 1 ? "s" : string.Empty)});");
            }

            _skippedItems.Clear();

            sb.Length--;
            return sb.ToString();
        }

        internal string? GetLoadedConfigsMessage()
        {
            if (_loadedConfigFiles.Count == 0)
            {
                return null;
            }

            return $"Loaded Directory.Parse.config from: {string.Join(", ", _loadedConfigFiles)}";
        }

        internal static ParserIgnoreConfiguration Merge(ParserIgnoreConfiguration left, ParserIgnoreConfiguration right)
        {
            ArgumentNullException.ThrowIfNull(left);
            ArgumentNullException.ThrowIfNull(right);

            var merged = new ParserIgnoreConfiguration();

            UnionEntries(merged._allowedAttributes, left._allowedAttributes);
            UnionEntries(merged._allowedAttributes, right._allowedAttributes);
            UnionEntries(merged._allowedChildren, left._allowedChildren);
            UnionEntries(merged._allowedChildren, right._allowedChildren);
            merged._loadedConfigFiles.UnionWith(left._loadedConfigFiles);
            merged._loadedConfigFiles.UnionWith(right._loadedConfigFiles);
            MergeSkippedItems(merged._skippedItems, left._skippedItems);
            MergeSkippedItems(merged._skippedItems, right._skippedItems);

            return merged;
        }

        internal static ParserIgnoreConfiguration LoadFromFile(string filePath)
        {
            var config = new ParserIgnoreConfiguration();
            config.LoadFile(filePath);
            return config;
        }

        internal static ParserIgnoreConfiguration LoadGlobalConfig()
        {
            var config = new ParserIgnoreConfiguration();

            string? envValue = Environment.GetEnvironmentVariable(EnvironmentVariableName);
            if (!string.IsNullOrEmpty(envValue))
            {
                string[] paths = envValue.Split(new[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries);
                foreach (string path in paths)
                {
                    string trimmedPath = path.Trim();
                    if (trimmedPath.Length > 0)
                    {
                        config.LoadFile(trimmedPath);
                    }
                }
            }

            return config;
        }

        void ITranslatable.Translate(ITranslator translator)
        {
            translator.TranslateDictionary(ref _allowedAttributes, StringComparer.OrdinalIgnoreCase, TranslateHashSetValue, HashSetValueFactory);
            translator.TranslateDictionary(ref _allowedChildren, StringComparer.OrdinalIgnoreCase, TranslateHashSetValue, HashSetValueFactory);
            translator.Translate(ref _loadedConfigFiles);
        }

        private static void TranslateHashSetValue(ITranslator translator, NodePacketValueFactory<HashSet<string>> factory, ref HashSet<string> value)
        {
            translator.Translate(ref value);
        }

        private static HashSet<string> HashSetValueFactory(ITranslator translator)
        {
            HashSet<string>? set = null;
            translator.Translate(ref set);
            return set ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        }

        internal static ParserIgnoreConfiguration FactoryForDeserialization(ITranslator translator)
        {
            return new ParserIgnoreConfiguration(translator);
        }

        private static void UnionEntries(Dictionary<string, HashSet<string>> destination, Dictionary<string, HashSet<string>> source)
        {
            foreach (var kvp in source)
            {
                if (!destination.TryGetValue(kvp.Key, out HashSet<string>? names))
                {
                    names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                    destination[kvp.Key] = names;
                }

                names.UnionWith(kvp.Value);
            }
        }

        private static void MergeSkippedItems(ConcurrentDictionary<string, int> destination, ConcurrentDictionary<string, int> source)
        {
            foreach (var kvp in source)
            {
                destination.AddOrUpdate(kvp.Key, kvp.Value, (_, count) => count + kvp.Value);
            }
        }

        private void RecordSkippedItem(string key)
        {
            _skippedItems.AddOrUpdate(key, 1, static (_, count) => count + 1);
        }

        private void LoadFile(string filePath)
        {
            if (string.IsNullOrEmpty(filePath))
            {
                return;
            }

            string fullPath;
            try
            {
                fullPath = FileUtilities.NormalizePath(filePath);
            }
            catch
            {
                return;
            }

            if (!FileSystems.Default.FileExists(fullPath) || !_loadedConfigFiles.Add(fullPath))
            {
                return;
            }

            s_binlogEmbedPaths.Add(fullPath);

            try
            {
                var settings = new System.Xml.XmlReaderSettings { DtdProcessing = System.Xml.DtdProcessing.Prohibit };
                var doc = new System.Xml.XmlDocument();
                using (var stream = File.OpenRead(fullPath))
                using (var reader = System.Xml.XmlReader.Create(stream, settings))
                {
                    doc.Load(reader);
                }

                System.Xml.XmlElement? root = doc.DocumentElement;
                if (root is null || !root.Name.Equals("ParseConfig", StringComparison.OrdinalIgnoreCase))
                {
                    return;
                }

                foreach (System.Xml.XmlNode child in root.ChildNodes)
                {
                    if (child.NodeType != System.Xml.XmlNodeType.Element)
                    {
                        continue;
                    }

                    Dictionary<string, HashSet<string>>? target = null;
                    if (child.Name.Equals("IgnoreAttributes", StringComparison.OrdinalIgnoreCase))
                    {
                        target = _allowedAttributes;
                    }
                    else if (child.Name.Equals("IgnoreChildren", StringComparison.OrdinalIgnoreCase))
                    {
                        target = _allowedChildren;
                    }

                    if (target is null)
                    {
                        continue;
                    }

                    foreach (System.Xml.XmlNode ignoreNode in child.ChildNodes)
                    {
                        if (ignoreNode.NodeType != System.Xml.XmlNodeType.Element
                            || !ignoreNode.Name.Equals("Ignore", StringComparison.OrdinalIgnoreCase))
                        {
                            continue;
                        }

                        System.Xml.XmlElement ignoreElement = (System.Xml.XmlElement)ignoreNode;
                        string elementName = ignoreElement.GetAttribute("Element");
                        string name = ignoreElement.GetAttribute("Name");

                        if (!string.IsNullOrEmpty(elementName) && !string.IsNullOrEmpty(name))
                        {
                            AddAllowedName(target, elementName, name);
                        }
                    }
                }
            }
            catch
            {
                // If the file can't be parsed as XML, silently skip it
            }
        }

        private static void AddAllowedName(Dictionary<string, HashSet<string>> destination, string elementName, string unknownName)
        {
            if (!destination.TryGetValue(elementName, out HashSet<string>? names))
            {
                names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                destination[elementName] = names;
            }

            names.Add(unknownName);
        }
    }
}