diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CatalogFileGenerator.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CatalogFileGenerator.cs new file mode 100644 index 00000000000..b0c42086fdf --- /dev/null +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CatalogFileGenerator.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.DotNet.Build.Tasks.Workloads +{ + /// + /// Generates a catalog (.cat) file for .js files in a directory. + /// JS files are customer-modifiable runtime/toolchain files that cannot be + /// directly Authenticode-signed. A .cat catalog provides integrity verification + /// without preventing modification. The .cat file is then signed by the Arcade + /// signing infrastructure via FileExtensionSignInfo. + /// + internal static class CatalogFileGenerator + { + /// + /// Generates a .cat catalog file covering all .js files in the specified directory. + /// The .cat file is placed in the root of the directory so WiX Heat will include it + /// in the MSI alongside the .js files. + /// + /// The directory to search for .js files. + /// The base name for the catalog file (without extension). + /// Optional logger for diagnostic messages. + /// The path to the generated .cat file, or null if no .js files were found or makecat.exe is unavailable. + public static string? GenerateCatalog(string sourceDirectory, string catalogName, TaskLoggingHelper? log = null) + { + if (string.IsNullOrWhiteSpace(sourceDirectory) || !Directory.Exists(sourceDirectory)) + { + return null; + } + + string[] jsFiles = Directory.GetFiles(sourceDirectory, "*.js", SearchOption.AllDirectories); + if (jsFiles.Length == 0) + { + return null; + } + + string? makecatPath = FindMakecat(); + if (makecatPath == null) + { + log?.LogMessage(MessageImportance.Normal, + "makecat.exe not found. Skipping catalog generation for .js files. " + + "Catalog signing requires the Windows SDK."); + return null; + } + + string catOutputPath = Path.Combine(sourceDirectory, $"{catalogName}.cat"); + string cdfPath = Path.ChangeExtension(catOutputPath, ".cdf"); + + // Generate the CDF (Catalog Definition File) + using (StreamWriter writer = new(cdfPath)) + { + writer.WriteLine("[CatalogHeader]"); + writer.WriteLine($"Name={catOutputPath}"); + writer.WriteLine("CatalogVersion=2"); + writer.WriteLine("HashAlgorithms=SHA256"); + writer.WriteLine(); + writer.WriteLine("[CatalogFiles]"); + + int index = 0; + foreach (string jsFile in jsFiles) + { + string fileName = Path.GetFileName(jsFile); + // Use a sanitized label: remove non-alphanumeric chars except dots and hyphens + string label = $"js_{index}_{SanitizeLabel(fileName)}"; + writer.WriteLine($"{label}={jsFile}"); + index++; + } + } + + log?.LogMessage(MessageImportance.Low, + $"Generated CDF with {jsFiles.Length} .js files at {cdfPath}"); + + // Run makecat.exe to produce the .cat file + ProcessStartInfo psi = new() + { + FileName = makecatPath, + Arguments = $"\"{cdfPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using Process process = Process.Start(psi)!; + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + log?.LogWarning($"makecat.exe failed with exit code {process.ExitCode}. " + + $"stdout: {stdout} stderr: {stderr}"); + return null; + } + + if (!File.Exists(catOutputPath)) + { + log?.LogWarning($"makecat.exe completed but catalog file was not created: {catOutputPath}"); + return null; + } + + log?.LogMessage(MessageImportance.Normal, + $"Generated catalog file covering {jsFiles.Length} .js files: {catOutputPath}"); + + // Clean up the CDF file - it's not needed in the MSI + try { File.Delete(cdfPath); } catch { /* best effort */ } + + return catOutputPath; + } + + /// + /// Finds makecat.exe from the Windows SDK. + /// + private static string? FindMakecat() + { + // Try PATH first + string? pathResult = FindInPath("makecat.exe"); + if (pathResult != null) return pathResult; + + // Search Windows SDK locations + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + string sdkRoot = Path.Combine(programFilesX86, "Windows Kits", "10", "bin"); + + if (Directory.Exists(sdkRoot)) + { + // Find the latest version's x64 directory + return Directory.GetFiles(sdkRoot, "makecat.exe", SearchOption.AllDirectories) + .Where(f => f.Contains("x64", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(f => f) + .FirstOrDefault(); + } + + return null; + } + + private static string? FindInPath(string fileName) + { + string? pathVar = Environment.GetEnvironmentVariable("PATH"); + if (pathVar == null) return null; + + foreach (string dir in pathVar.Split(Path.PathSeparator)) + { + string fullPath = Path.Combine(dir, fileName); + if (File.Exists(fullPath)) return fullPath; + } + + return null; + } + + private static string SanitizeLabel(string name) => + new(name.Select(c => char.IsLetterOrDigit(c) || c == '.' || c == '-' ? c : '_').ToArray()); + } +} diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs index 5865ac44ac7..e6fd3c34267 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs @@ -338,6 +338,12 @@ protected override bool ExecuteCore() Log.LogMessage(MessageImportance.Low, string.Format(Strings.BuildExtractingPackage, data.Package.PackagePath)); data.Package.Extract(); + // Generate a catalog (.cat) file for any .js files in the extracted package. + // JS files are customer-modifiable and cannot be directly Authenticode-signed. + // The .cat provides integrity verification and is signed via FileExtensionSignInfo. + CatalogFileGenerator.GenerateCatalog(data.Package.DestinationDirectory, + data.Package.ShortName, Log); + // Enumerate over the platforms and build each MSI once. _ = Parallel.ForEach(data.FeatureBands.Keys, platform => { @@ -384,6 +390,10 @@ protected override bool ExecuteCore() foreach (var pack in packGroup.Packs) { pack.Extract(); + + // Generate catalog for .js files in each pack of the group. + CatalogFileGenerator.GenerateCatalog(pack.DestinationDirectory, + pack.ShortName, Log); } foreach (var platform in packGroup.ManifestsPerPlatform.Keys)