// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Xml; namespace ReleaseBuilder; /// /// Implemenation of the "heat" command line tool from Wix /// public static class WixHeatBuilder { /// /// Converts a string to a valid Wix identifier. /// /// The input string. /// The WiX identifier. public static string ConvertToIdentifier(string input) { if (string.IsNullOrEmpty(input)) throw new ArgumentException("Input string cannot be null or empty."); // Remove invalid characters (only allow A-Z, a-z, 0-9, _, .) var cleanedInput = Regex.Replace(input, @"[^A-Za-z0-9_.]", "_"); if (cleanedInput.Length > 72) { var hash = BitConverter.ToString(SHA256.HashData(Encoding.UTF8.GetBytes(cleanedInput))) .Replace("-", string.Empty)[..12]; cleanedInput = cleanedInput[..(72 - hash.Length)] + hash; } // Ensure the identifier starts with a letter or underscore if (!char.IsLetter(cleanedInput[0]) && cleanedInput[0] != '_') cleanedInput = "_" + cleanedInput; return cleanedInput; } /// /// Creates a Wix filelist from a directory /// /// The source folder to create the filelist from /// The name of the directory reference /// The name of the component group /// A function to generate file IDs. /// The wix file xml contents public static string CreateWixFilelist(string sourceFolder, string version, string folderPrefix = "$(var.HarvestPath)", string directoryRefName = "INSTALLLOCATION", string componentGroupId = "DUPLICATIBIN", string wixNs = "http://schemas.microsoft.com/wix/2006/wi", Func? fileIdGenerator = null) { var itemIds = new Dictionary(); fileIdGenerator ??= (x) => ConvertToIdentifier(Path.GetRelativePath(sourceFolder, x)); Func pathTransformer = (x) => $"{folderPrefix}{Path.GetRelativePath(sourceFolder, x)}"; var doc = new XmlDocument(); doc.LoadXml($""); var root = doc.DocumentElement!; var fragment = doc.CreateElement("Fragment"); root.AppendChild(fragment); var directoryRef = doc.CreateElement("DirectoryRef"); directoryRef.SetAttribute("Id", directoryRefName); fragment.AppendChild(directoryRef); foreach (var f in Directory.EnumerateFileSystemEntries(sourceFolder)) if (File.Exists(f)) AddFile(doc, directoryRef, f, version, itemIds, fileIdGenerator, pathTransformer); else if (Directory.Exists(f)) AddDirectory(doc, directoryRef, f, version, itemIds, fileIdGenerator, pathTransformer); var fragment2 = doc.CreateElement("Fragment"); root.AppendChild(fragment2); var componentGroup = doc.CreateElement("ComponentGroup"); componentGroup.SetAttribute("Id", componentGroupId); fragment2.AppendChild(componentGroup); foreach (var file in itemIds.Keys) { var componentRef = doc.CreateElement("ComponentRef"); componentRef.SetAttribute("Id", itemIds[file]); componentGroup.AppendChild(componentRef); } return doc.OuterXml; } /// /// Adds a file to the XML document. /// /// The XML document. /// The XML element representing the directory reference. /// The file to be added. /// The version of the file. /// The dictionary containing the item IDs. /// The function to generate file IDs. private static void AddFile(XmlDocument doc, XmlElement directoryRef, string file, string version, Dictionary itemIds, Func fileIdGenerator, Func pathTransformer) { var id = fileIdGenerator.Invoke(file); itemIds.Add(file, id); var component = doc.CreateElement("Component"); component.SetAttribute("Id", id); component.SetAttribute("Guid", "*"); directoryRef.AppendChild(component); var fileElement = doc.CreateElement("File"); fileElement.SetAttribute("Id", id); fileElement.SetAttribute("KeyPath", "yes"); fileElement.SetAttribute("DefaultVersion", version); fileElement.SetAttribute("Source", pathTransformer(file)); component.AppendChild(fileElement); } /// /// Recursively adds a directory and its contents to an XML document. /// /// The XML document to add the directory to. /// The parent directory reference element. /// The directory path to add. /// The version of the directory. /// A dictionary to store the mapping between directory paths and their generated IDs. /// A function to generate file IDs. private static void AddDirectory(XmlDocument doc, XmlElement directoryRef, string dir, string version, Dictionary itemIds, Func fileIdGenerator, Func pathTransformer) { var id = fileIdGenerator.Invoke(dir); var dirName = Path.GetFileName(dir); var directory = doc.CreateElement("Directory"); directory.SetAttribute("Id", id); directory.SetAttribute("Name", dirName); directoryRef.AppendChild(directory); foreach (var file in Directory.GetFiles(dir)) AddFile(doc, directory, file, version, itemIds, fileIdGenerator, pathTransformer); foreach (var subDir in Directory.GetDirectories(dir)) AddDirectory(doc, directory, subDir, version, itemIds, fileIdGenerator, pathTransformer); } }