// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. namespace WixToolset.Tools.WixCop { using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Xml; using System.Xml.Linq; using WixToolset.Data; using WixToolset.Extensibility.Services; using WixToolset.Tools.Core; /// /// WiX source code converter. /// public class Converter { private const char XDocumentNewLine = '\n'; // XDocument normalizes "\r\n" to just "\n". private static readonly XNamespace WixNamespace = "http://wixtoolset.org/schemas/v4/wxs"; private static readonly XName DirectoryElementName = WixNamespace + "Directory"; private static readonly XName FileElementName = WixNamespace + "File"; private static readonly XName ExePackageElementName = WixNamespace + "ExePackage"; private static readonly XName MsiPackageElementName = WixNamespace + "MsiPackage"; private static readonly XName MspPackageElementName = WixNamespace + "MspPackage"; private static readonly XName MsuPackageElementName = WixNamespace + "MsuPackage"; private static readonly XName PayloadElementName = WixNamespace + "Payload"; private static readonly XName CustomActionElementName = WixNamespace + "CustomAction"; private static readonly XName PropertyElementName = WixNamespace + "Property"; private static readonly XName WixElementWithoutNamespaceName = XNamespace.None + "Wix"; private static readonly Dictionary OldToNewNamespaceMapping = new Dictionary() { { "http://schemas.microsoft.com/wix/BalExtension", "http://wixtoolset.org/schemas/v4/wxs/bal" }, { "http://schemas.microsoft.com/wix/ComPlusExtension", "http://wixtoolset.org/schemas/v4/wxs/complus" }, { "http://schemas.microsoft.com/wix/DependencyExtension", "http://wixtoolset.org/schemas/v4/wxs/dependency" }, { "http://schemas.microsoft.com/wix/DifxAppExtension", "http://wixtoolset.org/schemas/v4/wxs/difxapp" }, { "http://schemas.microsoft.com/wix/FirewallExtension", "http://wixtoolset.org/schemas/v4/wxs/firewall" }, { "http://schemas.microsoft.com/wix/GamingExtension", "http://wixtoolset.org/schemas/v4/wxs/gaming" }, { "http://schemas.microsoft.com/wix/IIsExtension", "http://wixtoolset.org/schemas/v4/wxs/iis" }, { "http://schemas.microsoft.com/wix/MsmqExtension", "http://wixtoolset.org/schemas/v4/wxs/msmq" }, { "http://schemas.microsoft.com/wix/NetFxExtension", "http://wixtoolset.org/schemas/v4/wxs/netfx" }, { "http://schemas.microsoft.com/wix/PSExtension", "http://wixtoolset.org/schemas/v4/wxs/powershell" }, { "http://schemas.microsoft.com/wix/SqlExtension", "http://wixtoolset.org/schemas/v4/wxs/sql" }, { "http://schemas.microsoft.com/wix/TagExtension", "http://wixtoolset.org/schemas/v4/wxs/tag" }, { "http://schemas.microsoft.com/wix/UtilExtension", "http://wixtoolset.org/schemas/v4/wxs/util" }, { "http://schemas.microsoft.com/wix/VSExtension", "http://wixtoolset.org/schemas/v4/wxs/vs" }, { "http://wixtoolset.org/schemas/thmutil/2010", "http://wixtoolset.org/schemas/v4/thmutil" }, { "http://schemas.microsoft.com/wix/2009/Lux", "http://wixtoolset.org/schemas/v4/lux" }, { "http://schemas.microsoft.com/wix/2006/wi", "http://wixtoolset.org/schemas/v4/wxs" }, { "http://schemas.microsoft.com/wix/2006/localization", "http://wixtoolset.org/schemas/v4/wxl" }, { "http://schemas.microsoft.com/wix/2006/libraries", "http://wixtoolset.org/schemas/v4/wixlib" }, { "http://schemas.microsoft.com/wix/2006/objects", "http://wixtoolset.org/schemas/v4/wixobj" }, { "http://schemas.microsoft.com/wix/2006/outputs", "http://wixtoolset.org/schemas/v4/wixout" }, { "http://schemas.microsoft.com/wix/2007/pdbs", "http://wixtoolset.org/schemas/v4/wixpdb" }, { "http://schemas.microsoft.com/wix/2003/04/actions", "http://wixtoolset.org/schemas/v4/wi/actions" }, { "http://schemas.microsoft.com/wix/2006/tables", "http://wixtoolset.org/schemas/v4/wi/tables" }, { "http://schemas.microsoft.com/wix/2006/WixUnit", "http://wixtoolset.org/schemas/v4/wixunit" }, }; private readonly Dictionary> ConvertElementMapping; /// /// Instantiate a new Converter class. /// /// Indentation value to use when validating leading whitespace. /// Test errors to display as warnings. /// Test errors to ignore. public Converter(IMessaging messaging, int indentationAmount, IEnumerable errorsAsWarnings = null, IEnumerable ignoreErrors = null) { this.ConvertElementMapping = new Dictionary> { { Converter.DirectoryElementName, this.ConvertDirectoryElement }, { Converter.FileElementName, this.ConvertFileElement }, { Converter.ExePackageElementName, this.ConvertSuppressSignatureValidation }, { Converter.MsiPackageElementName, this.ConvertSuppressSignatureValidation }, { Converter.MspPackageElementName, this.ConvertSuppressSignatureValidation }, { Converter.MsuPackageElementName, this.ConvertSuppressSignatureValidation }, { Converter.PayloadElementName, this.ConvertSuppressSignatureValidation }, { Converter.CustomActionElementName, this.ConvertCustomActionElement }, { Converter.PropertyElementName, this.ConvertPropertyElement }, { Converter.WixElementWithoutNamespaceName, this.ConvertWixElementWithoutNamespace }, }; this.Messaging = messaging; this.IndentationAmount = indentationAmount; this.ErrorsAsWarnings = new HashSet(this.YieldConverterTypes(errorsAsWarnings)); this.IgnoreErrors = new HashSet(this.YieldConverterTypes(ignoreErrors)); } private int Errors { get; set; } private HashSet ErrorsAsWarnings { get; set; } private HashSet IgnoreErrors { get; set; } private IMessaging Messaging { get; } private int IndentationAmount { get; set; } private string SourceFile { get; set; } /// /// Convert a file. /// /// The file to convert. /// Option to save the converted errors that are found. /// The number of errors found. public int ConvertFile(string sourceFile, bool saveConvertedFile) { XDocument document; // Set the instance info. this.Errors = 0; this.SourceFile = sourceFile; try { document = XDocument.Load(this.SourceFile, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); } catch (XmlException e) { this.OnError(ConverterTestType.XmlException, null, "The xml is invalid. Detail: '{0}'", e.Message); return this.Errors; } this.ConvertDocument(document); // Fix errors if requested and necessary. if (saveConvertedFile && 0 < this.Errors) { try { using (var writer = File.CreateText(this.SourceFile)) { document.Save(writer, SaveOptions.DisableFormatting | SaveOptions.OmitDuplicateNamespaces); } } catch (UnauthorizedAccessException) { this.OnError(ConverterTestType.UnauthorizedAccessException, null, "Could not write to file."); } } return this.Errors; } /// /// Convert a document. /// /// The document to convert. /// The number of errors found. public int ConvertDocument(XDocument document) { var declaration = document.Declaration; // Convert the declaration. if (null != declaration) { if (!String.Equals("utf-8", declaration.Encoding, StringComparison.OrdinalIgnoreCase)) { if (this.OnError(ConverterTestType.DeclarationEncodingWrong, document.Root, "The XML declaration encoding is not properly set to 'utf-8'.")) { declaration.Encoding = "utf-8"; } } } else // missing declaration { if (this.OnError(ConverterTestType.DeclarationMissing, null, "This file is missing an XML declaration on the first line.")) { document.Declaration = new XDeclaration("1.0", "utf-8", null); document.Root.AddBeforeSelf(new XText(XDocumentNewLine.ToString())); } } // Start converting the nodes at the top. this.ConvertNodes(document.Nodes(), 0); return this.Errors; } private void ConvertNodes(IEnumerable nodes, int level) { // Note we operate on a copy of the node list since we may // remove some whitespace nodes during this processing. foreach (var node in nodes.ToList()) { if (node is XText text) { if (!String.IsNullOrWhiteSpace(text.Value)) { text.Value = text.Value.Trim(); } else if (node.NextNode is XCData cdata) { this.EnsurePrecedingWhitespaceRemoved(text, node, ConverterTestType.WhitespacePrecedingNodeWrong); } else if (node.NextNode is XElement element) { this.EnsurePrecedingWhitespaceCorrect(text, node, level, ConverterTestType.WhitespacePrecedingNodeWrong); } else if (node.NextNode is null) // this is the space before the close element { if (node.PreviousNode is null || node.PreviousNode is XCData) { this.EnsurePrecedingWhitespaceRemoved(text, node.Parent, ConverterTestType.WhitespacePrecedingEndElementWrong); } else if (level == 0) // root element's close tag { this.EnsurePrecedingWhitespaceCorrect(text, node, 0, ConverterTestType.WhitespacePrecedingEndElementWrong); } else { this.EnsurePrecedingWhitespaceCorrect(text, node, level - 1, ConverterTestType.WhitespacePrecedingEndElementWrong); } } } else if (node is XElement element) { this.ConvertElement(element); this.ConvertNodes(element.Nodes(), level + 1); } } } private void EnsurePrecedingWhitespaceCorrect(XText whitespace, XNode node, int level, ConverterTestType testType) { if (!Converter.LeadingWhitespaceValid(this.IndentationAmount, level, whitespace.Value)) { var message = testType == ConverterTestType.WhitespacePrecedingEndElementWrong ? "The whitespace preceding this end element is incorrect." : "The whitespace preceding this node is incorrect."; if (this.OnError(testType, node, message)) { Converter.FixupWhitespace(this.IndentationAmount, level, whitespace); } } } private void EnsurePrecedingWhitespaceRemoved(XText whitespace, XNode node, ConverterTestType testType) { if (!String.IsNullOrEmpty(whitespace.Value)) { var message = testType == ConverterTestType.WhitespacePrecedingEndElementWrong ? "The whitespace preceding this end element is incorrect." : "The whitespace preceding this node is incorrect."; if (this.OnError(testType, node, message)) { whitespace.Remove(); } } } private void ConvertElement(XElement element) { // Gather any deprecated namespaces, then update this element tree based on those deprecations. var deprecatedToUpdatedNamespaces = new Dictionary(); foreach (var declaration in element.Attributes().Where(a => a.IsNamespaceDeclaration)) { if (Converter.OldToNewNamespaceMapping.TryGetValue(declaration.Value, out var ns)) { if (this.OnError(ConverterTestType.XmlnsValueWrong, declaration, "The namespace '{0}' is out of date. It must be '{1}'.", declaration.Value, ns.NamespaceName)) { deprecatedToUpdatedNamespaces.Add(declaration.Value, ns); } } } if (deprecatedToUpdatedNamespaces.Any()) { Converter.UpdateElementsWithDeprecatedNamespaces(element.DescendantsAndSelf(), deprecatedToUpdatedNamespaces); } // Apply any specialized conversion actions. if (this.ConvertElementMapping.TryGetValue(element.Name, out var convert)) { convert(element); } } private void ConvertDirectoryElement(XElement element) { if (null == element.Attribute("Name")) { var attribute = element.Attribute("ShortName"); if (null != attribute) { var shortName = attribute.Value; if (this.OnError(ConverterTestType.AssignDirectoryNameFromShortName, element, "The directory ShortName attribute is being renamed to Name since Name wasn't specified for value '{0}'", shortName)) { element.Add(new XAttribute("Name", shortName)); attribute.Remove(); } } } } private void ConvertFileElement(XElement element) { if (null == element.Attribute("Id")) { var attribute = element.Attribute("Name"); if (null == attribute) { attribute = element.Attribute("Source"); } if (null != attribute) { var name = Path.GetFileName(attribute.Value); if (this.OnError(ConverterTestType.AssignAnonymousFileId, element, "The file id is being updated to '{0}' to ensure it remains the same as the default", name)) { IEnumerable attributes = element.Attributes().ToList(); element.RemoveAttributes(); element.Add(new XAttribute("Id", ToolsCommon.GetIdentifierFromName(name))); element.Add(attributes); } } } } private void ConvertSuppressSignatureValidation(XElement element) { var suppressSignatureValidation = element.Attribute("SuppressSignatureValidation"); if (null != suppressSignatureValidation) { if (this.OnError(ConverterTestType.SuppressSignatureValidationDeprecated, element, "The chain package element contains deprecated '{0}' attribute. Use the 'EnableSignatureValidation' attribute instead.", suppressSignatureValidation)) { if ("no" == suppressSignatureValidation.Value) { element.Add(new XAttribute("EnableSignatureValidation", "yes")); } } suppressSignatureValidation.Remove(); } } private void ConvertCustomActionElement(XElement xCustomAction) { var xBinaryKey = xCustomAction.Attribute("BinaryKey"); if (xBinaryKey?.Value == "WixCA") { if (this.OnError(ConverterTestType.WixCABinaryIdRenamed, xCustomAction, "The WixCA custom action DLL Binary table id has been renamed. Use the id 'UtilCA' instead.")) { xBinaryKey.Value = "UtilCA"; } } var xDllEntry = xCustomAction.Attribute("DllEntry"); if (xDllEntry?.Value == "CAQuietExec" || xDllEntry?.Value == "CAQuietExec64") { if (this.OnError(ConverterTestType.QuietExecCustomActionsRenamed, xCustomAction, "The CAQuietExec and CAQuietExec64 custom action ids have been renamed. Use the ids 'WixQuietExec' and 'WixQuietExec64' instead.")) { xDllEntry.Value = xDllEntry.Value.Replace("CAQuietExec", "WixQuietExec"); } } var xProperty = xCustomAction.Attribute("Property"); if (xProperty?.Value == "QtExecCmdLine" || xProperty?.Value == "QtExec64CmdLine") { if (this.OnError(ConverterTestType.QuietExecCustomActionsRenamed, xCustomAction, "The QtExecCmdLine and QtExec64CmdLine property ids have been renamed. Use the ids 'WixQuietExecCmdLine' and 'WixQuietExec64CmdLine' instead.")) { xProperty.Value = xProperty.Value.Replace("QtExec", "WixQuietExec"); } } } private void ConvertPropertyElement(XElement xProperty) { var xId = xProperty.Attribute("Id"); if (xId.Value == "QtExecCmdTimeout") { this.OnError(ConverterTestType.QtExecCmdTimeoutAmbiguous, xProperty, "QtExecCmdTimeout was previously used for both CAQuietExec and CAQuietExec64. For WixQuietExec, use WixQuietExecCmdTimeout. For WixQuietExec64, use WixQuietExec64CmdTimeout."); } } /// /// Converts a Wix element. /// /// The Wix element to convert. /// The converted element. private void ConvertWixElementWithoutNamespace(XElement element) { if (this.OnError(ConverterTestType.XmlnsMissing, element, "The xmlns attribute is missing. It must be present with a value of '{0}'.", WixNamespace.NamespaceName)) { element.Name = WixNamespace.GetName(element.Name.LocalName); element.Add(new XAttribute("xmlns", WixNamespace.NamespaceName)); // set the default namespace. foreach (var elementWithoutNamespace in element.Elements().Where(e => XNamespace.None == e.Name.Namespace)) { elementWithoutNamespace.Name = WixNamespace.GetName(elementWithoutNamespace.Name.LocalName); } } } private IEnumerable YieldConverterTypes(IEnumerable types) { if (null != types) { foreach (var type in types) { if (Enum.TryParse(type, true, out var itt)) { yield return itt; } else // not a known ConverterTestType { this.OnError(ConverterTestType.ConverterTestTypeUnknown, null, "Unknown error type: '{0}'.", type); } } } } private static void UpdateElementsWithDeprecatedNamespaces(IEnumerable elements, Dictionary deprecatedToUpdatedNamespaces) { foreach (var element in elements) { if (deprecatedToUpdatedNamespaces.TryGetValue(element.Name.Namespace, out var ns)) { element.Name = ns.GetName(element.Name.LocalName); } // Remove all the attributes and add them back to with their namespace updated (as necessary). IEnumerable attributes = element.Attributes().ToList(); element.RemoveAttributes(); foreach (var attribute in attributes) { var convertedAttribute = attribute; if (attribute.IsNamespaceDeclaration) { if (deprecatedToUpdatedNamespaces.TryGetValue(attribute.Value, out ns)) { convertedAttribute = ("xmlns" == attribute.Name.LocalName) ? new XAttribute(attribute.Name.LocalName, ns.NamespaceName) : new XAttribute(XNamespace.Xmlns + attribute.Name.LocalName, ns.NamespaceName); } } else if (deprecatedToUpdatedNamespaces.TryGetValue(attribute.Name.Namespace, out ns)) { convertedAttribute = new XAttribute(ns.GetName(attribute.Name.LocalName), attribute.Value); } element.Add(convertedAttribute); } } } /// /// Determine if the whitespace preceding a node is appropriate for its depth level. /// /// Indentation value to use when validating leading whitespace. /// The depth level that should match this whitespace. /// The whitespace to validate. /// true if the whitespace is legal; false otherwise. private static bool LeadingWhitespaceValid(int indentationAmount, int level, string whitespace) { // Strip off leading newlines; there can be an arbitrary number of these. whitespace = whitespace.TrimStart(XDocumentNewLine); var indentation = new string(' ', level * indentationAmount); return whitespace == indentation; } /// /// Fix the whitespace in a whitespace node. /// /// Indentation value to use when validating leading whitespace. /// The depth level of the desired whitespace. /// The whitespace node to fix. private static void FixupWhitespace(int indentationAmount, int level, XText whitespace) { var value = new StringBuilder(whitespace.Value.Length); // Keep any previous preceeding new lines. var newlines = whitespace.Value.TakeWhile(c => c == XDocumentNewLine).Count(); // Ensure there is always at least one new line before the indentation. value.Append(XDocumentNewLine, newlines == 0 ? 1 : newlines); whitespace.Value = value.Append(' ', level * indentationAmount).ToString(); } /// /// Output an error message to the console. /// /// The type of converter test. /// The node that caused the error. /// Detailed error message. /// Additional formatted string arguments. /// Returns true indicating that action should be taken on this error, and false if it should be ignored. private bool OnError(ConverterTestType converterTestType, XObject node, string message, params object[] args) { if (this.IgnoreErrors.Contains(converterTestType)) // ignore the error { return false; } // Increase the error count. this.Errors++; var sourceLine = (null == node) ? new SourceLineNumber(this.SourceFile ?? "wixcop.exe") : new SourceLineNumber(this.SourceFile, ((IXmlLineInfo)node).LineNumber); var warning = this.ErrorsAsWarnings.Contains(converterTestType); var display = String.Format(CultureInfo.CurrentCulture, message, args); var msg = new Message(sourceLine, warning ? MessageLevel.Warning : MessageLevel.Error, (int)converterTestType, "{0} ({1})", display, converterTestType.ToString()); this.Messaging.Write(msg); return true; } /// /// Converter test types. These are used to condition error messages down to warnings. /// private enum ConverterTestType { /// /// Internal-only: displayed when a string cannot be converted to an ConverterTestType. /// ConverterTestTypeUnknown, /// /// Displayed when an XML loading exception has occurred. /// XmlException, /// /// Displayed when a file cannot be accessed; typically when trying to save back a fixed file. /// UnauthorizedAccessException, /// /// Displayed when the encoding attribute in the XML declaration is not 'UTF-8'. /// DeclarationEncodingWrong, /// /// Displayed when the XML declaration is missing from the source file. /// DeclarationMissing, /// /// Displayed when the whitespace preceding a CDATA node is wrong. /// WhitespacePrecedingCDATAWrong, /// /// Displayed when the whitespace preceding a node is wrong. /// WhitespacePrecedingNodeWrong, /// /// Displayed when an element is not empty as it should be. /// NotEmptyElement, /// /// Displayed when the whitespace following a CDATA node is wrong. /// WhitespaceFollowingCDATAWrong, /// /// Displayed when the whitespace preceding an end element is wrong. /// WhitespacePrecedingEndElementWrong, /// /// Displayed when the xmlns attribute is missing from the document element. /// XmlnsMissing, /// /// Displayed when the xmlns attribute on the document element is wrong. /// XmlnsValueWrong, /// /// Assign an identifier to a File element when on Id attribute is specified. /// AssignAnonymousFileId, /// /// SuppressSignatureValidation attribute is deprecated and replaced with EnableSignatureValidation. /// SuppressSignatureValidationDeprecated, /// /// WixCA Binary/@Id has been renamed to UtilCA. /// WixCABinaryIdRenamed, /// /// QtExec custom actions have been renamed. /// QuietExecCustomActionsRenamed, /// /// QtExecCmdTimeout was previously used for both CAQuietExec and CAQuietExec64. For WixQuietExec, use WixQuietExecCmdTimeout. For WixQuietExec64, use WixQuietExec64CmdTimeout. /// QtExecCmdTimeoutAmbiguous, /// /// Directory/@ShortName may only be specified with Directory/@Name. /// AssignDirectoryNameFromShortName, } } }