From 07bbcf64a2f39e9f87e7db1da4c3de27d2d59aa9 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Tue, 30 Jun 2026 18:28:10 -0400 Subject: [PATCH 1/6] enhance --debug-output crash stacks --- docs/debugging.md | 10 + src/winapp-CLI/Directory.Packages.props | 6 + .../WinApp.Cli.Tests/CrashDumpServiceTests.cs | 19 +- .../WinApp.Cli.Tests/FakeCrashDumpService.cs | 4 +- .../WinApp.Cli.Tests/FakeXamlTriageService.cs | 23 ++ .../XamlTriageBinariesTests.cs | 97 ++++++ .../XamlTriageServiceTests.cs | 64 ++++ .../ZipRangeExtractorTests.cs | 146 ++++++++ .../Helpers/HostBuilderExtensions.cs | 1 + .../WinApp.Cli/Helpers/ZipRangeExtractor.cs | 242 +++++++++++++ src/winapp-CLI/WinApp.Cli/Program.cs | 8 + .../WinApp.Cli/Services/CrashDumpService.cs | 136 ++++++-- .../WinApp.Cli/Services/DebugOutputService.cs | 30 +- .../WinApp.Cli/Services/ICrashDumpService.cs | 16 +- .../WinApp.Cli/Services/IXamlTriageService.cs | 36 ++ .../Services/WinDbgJsProviderAcquirer.cs | 216 ++++++++++++ .../WinApp.Cli/Services/XamlTriageBinaries.cs | 321 ++++++++++++++++++ .../WinApp.Cli/Services/XamlTriageRunner.cs | 122 +++++++ .../WinApp.Cli/Services/XamlTriageService.cs | 301 ++++++++++++++++ src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj | 7 + 20 files changed, 1781 insertions(+), 24 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs diff --git a/docs/debugging.md b/docs/debugging.md index 533961d1..e5e6a46b 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -123,6 +123,16 @@ winapp run .\build\Debug --debug-output --symbols > **Important:** This attaches winapp as the debugger. Windows only allows one debugger per process, so you **cannot** also attach Visual Studio, VS Code, or WinDbg. +#### WinUI stowed-exception triage + +Most WinUI crashes start inside a XAML event handler and surface as a **stowed exception** (`0xC000027B`) that is re-raised later from the dispatcher, so the normal stack no longer points at the real cause. When the crashed app loaded `Microsoft.UI.Xaml.dll`, winapp automatically runs an extra triage pass that decodes the stowed exception and the native XAML dispatch chain (`Microsoft.UI.Xaml` → `CXcpDispatcher` → `CoreMessagingXP` → CLR host). The result is appended to the debug log. No flag is needed — it is enabled automatically for WinUI dumps. Add `--symbols` for fully resolved function names in the dispatch chain. + +To make this work, winapp captures the crash dump with the terminating stowed exception's record (and its parameters, which point at the stowed-exception array) while keeping the first-chance thread context, so the standard managed analysis still recovers your original user frame *and* the triage pass can locate the stowed exception. + +This pass hosts DbgEng with the WinUI team's WinDbg JavaScript extension. The native debugging engine (`dbgeng.dll` and friends) comes from NuGet, and `JsProvider.dll` — the JavaScript scripting host, which is **not** on NuGet — is fetched on first use directly from the official WinDbg download (only the few hundred kilobytes needed are read, not the full package). Everything is cached under the winapp global directory, so subsequent runs are offline. If your environment blocks those downloads, install **Debugging Tools for Windows** (via the Windows SDK) or set the `WINAPP_DBGTOOLS_DIR` environment variable to a debugger directory that already contains `dbgeng.dll` and `JsProvider.dll`. When the binaries can't be obtained, the triage pass is skipped (the standard managed/native analysis still runs) and the log explains why. + +The triage pass runs in a short-lived child process. This is required: winapp's main process loads the system `dbghelp.dll` while capturing and analyzing the dump, and the modern engine `dbgeng.dll` cannot bind to that older, already-resident copy — a fresh process gives the engine a clean loader state. Decoding the stowed-exception structures also needs operating-system symbols (`combase.dll`), which `--symbols` downloads from the Microsoft public symbol server; on builds whose symbols aren't published there, the triage pass still identifies the stowed exception but cannot fully expand it. + ## IDE setup ### VS Code diff --git a/src/winapp-CLI/Directory.Packages.props b/src/winapp-CLI/Directory.Packages.props index 52640091..b52bc83b 100644 --- a/src/winapp-CLI/Directory.Packages.props +++ b/src/winapp-CLI/Directory.Packages.props @@ -12,5 +12,11 @@ + + + diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs index 4b75d7a6..23343f48 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs @@ -13,6 +13,7 @@ public class CrashDumpServiceTests { private TestConsole _console = null!; private ILogger _logger = null!; + private FakeXamlTriageService _xamlTriage = null!; private CrashDumpService _service = null!; private string _tempDir = null!; @@ -21,7 +22,8 @@ public void Setup() { _console = new TestConsole(); _logger = LoggerFactory.Create(b => b.SetMinimumLevel(LogLevel.Debug)).CreateLogger(); - _service = new CrashDumpService(_console, _logger); + _xamlTriage = new FakeXamlTriageService(); + _service = new CrashDumpService(_console, _logger, _xamlTriage); _tempDir = Path.Combine(Path.GetTempPath(), $"CrashDumpTest_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); } @@ -100,4 +102,19 @@ public async Task AnalyzeDumpAsync_InvalidDump_ShowsDumpPath() var output = _console.Output; Assert.IsTrue(output.Contains("invalid.dmp"), $"Expected dump filename in output: {output}"); } + + [TestMethod] + public async Task AnalyzeDumpAsync_InvalidDump_DoesNotRunWinUiTriage() + { + // Arrange — an unreadable dump can't be inspected for WinUI modules, so triage must be skipped. + var dumpPath = Path.Combine(_tempDir, "invalid.dmp"); + await File.WriteAllTextAsync(dumpPath, "not a dump"); + var logPath = Path.Combine(_tempDir, "test.log"); + + // Act + await _service.AnalyzeDumpAsync(dumpPath, logPath); + + // Assert + Assert.AreEqual(0, _xamlTriage.AnalyzeCalls.Count, "WinUI triage must not run for an unreadable/non-WinUI dump."); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs index 8d83d9a5..4b60b736 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs @@ -16,7 +16,9 @@ internal class FakeCrashDumpService : ICrashDumpService public string? WriteMiniDump(uint processId, byte[]? savedContext, uint savedThreadId, - int savedExceptionCode, nuint savedExceptionAddress) + int savedExceptionCode, nuint savedExceptionAddress, + int crashExceptionCode = 0, nuint crashExceptionAddress = 0, + nuint[]? crashExceptionParameters = null) { WriteCalls.Add((processId, savedThreadId)); return FakeDumpPath; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs new file mode 100644 index 00000000..2d324124 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +/// +/// Fake WinUI triage service that records calls and returns a configurable result +/// without hosting DbgEng or downloading any debugging binaries. +/// +internal class FakeXamlTriageService : IXamlTriageService +{ + public List<(string DumpPath, bool UseSymbols)> AnalyzeCalls { get; } = []; + + public string? FakeResult { get; set; } + + public Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default) + { + AnalyzeCalls.Add((dumpPath, useSymbols)); + return Task.FromResult(FakeResult); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs new file mode 100644 index 00000000..786797a6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +[DoNotParallelize] +public class XamlTriageBinariesTests +{ + private string _tempDir = null!; + private string? _originalOverride; + + [TestInitialize] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"XamlTriageBin_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _originalOverride = Environment.GetEnvironmentVariable(XamlTriageBinaries.EnvOverride); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, _originalOverride); + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, true); } catch { } + } + } + + [TestMethod] + public void ResolveExisting_OverrideToEmptyDir_ReturnsNull() + { + var emptyDir = Path.Combine(_tempDir, "empty"); + Directory.CreateDirectory(emptyDir); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, emptyDir); + + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + + Assert.IsNull(resolved, "An override pointing at a directory without dbgeng.dll must not resolve."); + } + + [TestMethod] + public void ResolveExisting_FullLayout_ResolvesWithSymSrv() + { + var dir = Path.Combine(_tempDir, "full"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "dbgeng.dll"), ""); + File.WriteAllText(Path.Combine(dir, "JsProvider.dll"), ""); + File.WriteAllText(Path.Combine(dir, "symsrv.dll"), ""); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + + Assert.IsNotNull(resolved); + Assert.AreEqual(dir, resolved.BinDir); + Assert.IsTrue(resolved.HasSymSrv, "symsrv.dll is present, so HasSymSrv must be true."); + } + + [TestMethod] + public void ResolveExisting_JsProviderInWinext_ResolvesWithoutSymSrv() + { + var dir = Path.Combine(_tempDir, "winext-layout"); + Directory.CreateDirectory(Path.Combine(dir, "winext")); + File.WriteAllText(Path.Combine(dir, "dbgeng.dll"), ""); + File.WriteAllText(Path.Combine(dir, "winext", "JsProvider.dll"), ""); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + + Assert.IsNotNull(resolved); + Assert.IsFalse(resolved.HasSymSrv, "No symsrv.dll present, so HasSymSrv must be false."); + } + + [TestMethod] + public void ResolveExisting_MissingJsProvider_ReturnsNull() + { + var dir = Path.Combine(_tempDir, "no-jsprovider"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "dbgeng.dll"), ""); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + + Assert.IsNull(resolved, "Without JsProvider.dll the JS extension cannot load, so resolution must fail."); + } + + [TestMethod] + public void ArchTokens_AreNonEmpty() + { + Assert.IsFalse(string.IsNullOrWhiteSpace(XamlTriageBinaries.KitsArch)); + Assert.IsFalse(string.IsNullOrWhiteSpace(XamlTriageBinaries.NuGetArch)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs new file mode 100644 index 00000000..854c7c33 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class XamlTriageServiceTests +{ + // Mirrors the real failure shape when OS symbols for combase.dll are unavailable: + // the extension loads and detects the stowed exception but cannot expand its structs. + private const string SymbolGapOutput = + "************* Symbol Loading Error Summary **************\n" + + "Module name Error\n" + + "combase The system cannot find the file specified\n" + + "JavaScript script successfully loaded from 'winui-dbgext.js'\n" + + "*** WARNING: Symbols for combase.dll not loaded/unavailable.\n" + + "Error: Error: Invalid argument to method 'createPointerObject' [__Initialize @winui-dbgext (line 3116 col 39)]"; + + [TestMethod] + public void DescribeSymbolGap_WithSymbols_ExplainsServer404() + { + var note = XamlTriageService.DescribeSymbolGap(SymbolGapOutput, useSymbols: true); + + Assert.IsNotNull(note); + StringAssert.Contains(note, "0xC000027B"); + StringAssert.Contains(note, "combase.dll"); + StringAssert.Contains(note, "404"); + } + + [TestMethod] + public void DescribeSymbolGap_WithoutSymbols_SuggestsSymbolsFlag() + { + var note = XamlTriageService.DescribeSymbolGap(SymbolGapOutput, useSymbols: false); + + Assert.IsNotNull(note); + StringAssert.Contains(note, "--symbols"); + } + + [TestMethod] + public void DescribeSymbolGap_SuccessfulOutput_ReturnsNull() + { + const string goodOutput = + "-------------------------\n" + + "Callstack for hr=0x80131509\n" + + " winui_app!App.OnLaunched\n" + + "========================="; + + Assert.IsNull(XamlTriageService.DescribeSymbolGap(goodOutput, useSymbols: true)); + } + + [TestMethod] + public void DescribeSymbolGap_SymbolsMissingButDecodeSucceeded_ReturnsNull() + { + // A symbol warning alone (without the createPointerObject decode failure) is not the gap. + const string partial = + "*** WARNING: Symbols for combase.dll not loaded/unavailable.\n" + + "-------------------------\n" + + "Callstack for hr=0x80131509"; + + Assert.IsNull(XamlTriageService.DescribeSymbolGap(partial, useSymbols: true)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs new file mode 100644 index 00000000..d527254c --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Text; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class ZipRangeExtractorTests +{ + private const string InnerMsixName = "windbg_win-arm64.msix"; + private const string JsProviderPath = "arm64/winext/JsProvider.dll"; + + // A fake PE payload (starts with the MZ signature) that compresses well via deflate. + private static byte[] FakeJsProvider() + { + var payload = new byte[4096]; + payload[0] = (byte)'M'; + payload[1] = (byte)'Z'; + for (var i = 2; i < payload.Length; i++) + { + payload[i] = (byte)(i % 7); + } + + return payload; + } + + /// Builds a ZIP containing the supplied entries (forward-slash names). + private static byte[] BuildZip(IEnumerable<(string Name, byte[] Data, CompressionLevel Level)> entries) + { + using var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, data, level) in entries) + { + var entry = archive.CreateEntry(name, level); + using var stream = entry.Open(); + stream.Write(data, 0, data.Length); + } + } + + return ms.ToArray(); + } + + /// Builds an outer bundle ZIP that STORES an inner msix ZIP containing JsProvider.dll. + private static (byte[] Bundle, byte[] ExpectedJsProvider) BuildNestedBundle(string jsProviderPath = JsProviderPath) + { + var js = FakeJsProvider(); + var innerMsix = BuildZip( + [ + ("AppxManifest.xml", Encoding.UTF8.GetBytes(""), CompressionLevel.Optimal), + (jsProviderPath, js, CompressionLevel.Optimal), + ("arm64/winext/chakra/JsProvider.dll", Encoding.UTF8.GetBytes("MZchakra"), CompressionLevel.Optimal), + ]); + + var bundle = BuildZip( + [ + ("AppxMetadata/AppxBundleManifest.xml", Encoding.UTF8.GetBytes(""), CompressionLevel.Optimal), + // Inner msix packages are STORED in the real bundle, so the inner archive is contiguous. + (InnerMsixName, innerMsix, CompressionLevel.NoCompression), + ("windbg_win-x64.msix", Encoding.UTF8.GetBytes("not a real zip"), CompressionLevel.NoCompression), + ]); + + return (bundle, js); + } + + [TestMethod] + public async Task ExtractJsProvider_NestedBundle_ReturnsPeBytes() + { + var (bundle, expected) = BuildNestedBundle(); + var reader = new MemoryRangeReader(bundle); + + var bytes = await WinDbgJsProviderAcquirer.ExtractJsProviderAsync(reader, InnerMsixName, "arm64", CancellationToken.None); + + Assert.IsNotNull(bytes, "JsProvider.dll should be extracted from the nested bundle."); + CollectionAssert.AreEqual(expected, bytes, "Extracted bytes must match the original (deflate round-trip)."); + } + + [TestMethod] + public async Task ExtractJsProvider_MissingInnerMsix_ReturnsNull() + { + var (bundle, _) = BuildNestedBundle(); + var reader = new MemoryRangeReader(bundle); + + var bytes = await WinDbgJsProviderAcquirer.ExtractJsProviderAsync(reader, "windbg_win-does-not-exist.msix", "arm64", CancellationToken.None); + + Assert.IsNull(bytes, "A missing inner msix must yield null, not throw."); + } + + [TestMethod] + public async Task ExtractJsProvider_MissingFileInInner_ReturnsNull() + { + var (bundle, _) = BuildNestedBundle(); + var reader = new MemoryRangeReader(bundle); + + // The inner msix exists but contains no amd64/winext/JsProvider.dll. + var bytes = await WinDbgJsProviderAcquirer.ExtractJsProviderAsync(reader, InnerMsixName, "amd64", CancellationToken.None); + + Assert.IsNull(bytes, "A missing JsProvider path must yield null, not throw."); + } + + [TestMethod] + public async Task ExtractEntry_StoredAndDeflate_RoundTrip() + { + var stored = Encoding.UTF8.GetBytes("stored-payload-exactly"); + var deflated = FakeJsProvider(); + var zip = BuildZip( + [ + ("stored.bin", stored, CompressionLevel.NoCompression), + ("deflated.bin", deflated, CompressionLevel.Optimal), + ]); + var reader = new MemoryRangeReader(zip); + + var (cdOffset, cdSize) = await ZipRangeExtractor.FindCentralDirectoryAsync(reader, 0, zip.Length, CancellationToken.None); + var entries = ZipRangeExtractor.ParseCentralDirectory( + await reader.ReadAsync(cdOffset, (int)cdSize, CancellationToken.None), 0); + + var storedEntry = entries.Single(e => e.Name == "stored.bin"); + var deflatedEntry = entries.Single(e => e.Name == "deflated.bin"); + Assert.AreEqual(0, storedEntry.Method, "NoCompression must produce a STORED entry."); + Assert.AreEqual(8, deflatedEntry.Method, "Optimal must produce a DEFLATE entry."); + + CollectionAssert.AreEqual(stored, await ZipRangeExtractor.ExtractEntryAsync(reader, storedEntry, CancellationToken.None)); + CollectionAssert.AreEqual(deflated, await ZipRangeExtractor.ExtractEntryAsync(reader, deflatedEntry, CancellationToken.None)); + } + + [TestMethod] + public void HostTokens_KnownArchitectures_Map() + { + Assert.AreEqual(("windbg_win-x64.msix", "amd64"), WinDbgJsProviderAcquirer.HostTokens(Architecture.X64)); + Assert.AreEqual(("windbg_win-arm64.msix", "arm64"), WinDbgJsProviderAcquirer.HostTokens(Architecture.Arm64)); + Assert.AreEqual(("windbg_win-x86.msix", "x86"), WinDbgJsProviderAcquirer.HostTokens(Architecture.X86)); + } + + [TestMethod] + public void HostTokens_UnsupportedArchitecture_ReturnsNulls() + { + var (msix, prefix) = WinDbgJsProviderAcquirer.HostTokens(Architecture.Wasm); + Assert.IsNull(msix); + Assert.IsNull(prefix); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 08062388..a9e8a82a 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -45,6 +45,7 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(AnsiConsole.Console) .AddSingleton() diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs b/src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs new file mode 100644 index 00000000..9dcc3a0c --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Buffers.Binary; +using System.IO.Compression; + +namespace WinApp.Cli.Helpers; + +/// +/// Random-access byte source addressed by absolute offset. Implementations may be backed by an +/// in-memory buffer (tests) or HTTP range requests against a remote archive (production). +/// +internal interface IRangeReader +{ + /// Total length of the underlying resource in bytes. + Task GetLengthAsync(CancellationToken cancellationToken); + + /// Reads exactly bytes starting at . + Task ReadAsync(long offset, int length, CancellationToken cancellationToken); +} + +/// A single central-directory entry resolved from a ZIP (with ZIP64 fields applied). +/// Entry path using forward slashes. +/// Compression method (0 = stored, 8 = deflate). +/// Size of the entry's data in the archive. +/// Size after decompression. +/// Absolute offset (in the reader) of the entry's local file header. +internal sealed record ZipEntry(string Name, ushort Method, long CompressedSize, long UncompressedSize, long LocalHeaderOffset); + +/// +/// Parses a ZIP (including ZIP64) central directory and extracts individual entries using only +/// ranged reads — never downloading the whole archive. Supports nested archives (e.g. a STORED +/// inner .msix inside an .msixbundle) by treating each as a sub-range with its own +/// base offset. +/// +internal static class ZipRangeExtractor +{ + private const uint EocdSignature = 0x06054b50; // PK\x05\x06 + private const uint Zip64LocatorSignature = 0x07064b50; // PK\x06\x07 + private const uint Zip64EocdSignature = 0x06064b50; // PK\x06\x06 + private const uint CentralHeaderSignature = 0x02014b50; // PK\x01\x02 + private const int MaxEocdSearch = 65557; // 22-byte EOCD + 64KiB max comment + private const uint Zip64Marker = 0xFFFFFFFF; + private const ushort Zip64ExtraId = 0x0001; + + /// + /// Locates the central directory for an archive whose bytes occupy + /// [archiveBase, archiveBase + archiveSize) within . + /// + /// The absolute offset and size of the central directory. + public static async Task<(long Offset, long Size)> FindCentralDirectoryAsync( + IRangeReader reader, long archiveBase, long archiveSize, CancellationToken cancellationToken) + { + var tailLen = (int)Math.Min(MaxEocdSearch, archiveSize); + var tailStart = archiveBase + archiveSize - tailLen; + var tail = await reader.ReadAsync(tailStart, tailLen, cancellationToken); + + var eocd = LastIndexOfSignature(tail, EocdSignature); + if (eocd < 0) + { + throw new InvalidDataException("End-of-central-directory record not found."); + } + + var count = BinaryPrimitives.ReadUInt16LittleEndian(tail.AsSpan(eocd + 10)); + long cdSize = BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(eocd + 12)); + long cdOffset = BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(eocd + 16)); + + if (cdOffset == Zip64Marker || cdSize == Zip64Marker || count == 0xFFFF) + { + (cdOffset, cdSize) = await ReadZip64DirectoryAsync( + reader, archiveBase, tail, tailStart, eocd, cancellationToken); + } + + return (archiveBase + cdOffset, cdSize); + } + + private static async Task<(long Offset, long Size)> ReadZip64DirectoryAsync( + IRangeReader reader, long archiveBase, byte[] tail, long tailStart, int eocd, CancellationToken cancellationToken) + { + var locator = LastIndexOfSignature(tail.AsSpan(0, eocd), Zip64LocatorSignature); + if (locator < 0) + { + throw new InvalidDataException("ZIP64 end-of-central-directory locator not found."); + } + + // Offset of the ZIP64 EOCD record, relative to the archive's base. + var recordRelative = (long)BinaryPrimitives.ReadUInt64LittleEndian(tail.AsSpan(locator + 8)); + var recordAbsolute = archiveBase + recordRelative; + + byte[] record; + int recordPos; + if (recordAbsolute >= tailStart && recordAbsolute + 56 <= tailStart + tail.Length) + { + record = tail; + recordPos = (int)(recordAbsolute - tailStart); + } + else + { + record = await reader.ReadAsync(recordAbsolute, 56, cancellationToken); + recordPos = 0; + } + + if (BinaryPrimitives.ReadUInt32LittleEndian(record.AsSpan(recordPos)) != Zip64EocdSignature) + { + throw new InvalidDataException("ZIP64 end-of-central-directory record signature mismatch."); + } + + var cdSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(record.AsSpan(recordPos + 40)); + var cdOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(record.AsSpan(recordPos + 48)); + return (cdOffset, cdSize); + } + + /// + /// Parses the raw central-directory bytes into entries. is added + /// to each entry's (archive-relative) local-header offset to produce an absolute reader offset. + /// + public static IReadOnlyList ParseCentralDirectory(byte[] centralDirectory, long archiveBase) + { + var entries = new List(); + var p = 0; + while (p + 46 <= centralDirectory.Length && + BinaryPrimitives.ReadUInt32LittleEndian(centralDirectory.AsSpan(p)) == CentralHeaderSignature) + { + var method = BinaryPrimitives.ReadUInt16LittleEndian(centralDirectory.AsSpan(p + 10)); + long compressed = BinaryPrimitives.ReadUInt32LittleEndian(centralDirectory.AsSpan(p + 20)); + long uncompressed = BinaryPrimitives.ReadUInt32LittleEndian(centralDirectory.AsSpan(p + 24)); + var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(centralDirectory.AsSpan(p + 28)); + var extraLen = BinaryPrimitives.ReadUInt16LittleEndian(centralDirectory.AsSpan(p + 30)); + var commentLen = BinaryPrimitives.ReadUInt16LittleEndian(centralDirectory.AsSpan(p + 32)); + long localOffset = BinaryPrimitives.ReadUInt32LittleEndian(centralDirectory.AsSpan(p + 42)); + + var name = System.Text.Encoding.UTF8.GetString(centralDirectory, p + 46, nameLen); + var extra = centralDirectory.AsSpan(p + 46 + nameLen, extraLen); + + if (uncompressed == Zip64Marker || compressed == Zip64Marker || localOffset == Zip64Marker) + { + ApplyZip64Extra(extra, ref uncompressed, ref compressed, ref localOffset); + } + + entries.Add(new ZipEntry(name.Replace('\\', '/'), method, compressed, uncompressed, archiveBase + localOffset)); + p += 46 + nameLen + extraLen + commentLen; + } + + return entries; + } + + private static void ApplyZip64Extra(ReadOnlySpan extra, ref long uncompressed, ref long compressed, ref long localOffset) + { + var p = 0; + while (p + 4 <= extra.Length) + { + var id = BinaryPrimitives.ReadUInt16LittleEndian(extra[p..]); + var dataLen = BinaryPrimitives.ReadUInt16LittleEndian(extra[(p + 2)..]); + var q = p + 4; + if (id == Zip64ExtraId) + { + // Fields appear in this fixed order, but only those whose 32-bit value was 0xFFFFFFFF. + if (uncompressed == Zip64Marker && q + 8 <= extra.Length) { uncompressed = (long)BinaryPrimitives.ReadUInt64LittleEndian(extra[q..]); q += 8; } + if (compressed == Zip64Marker && q + 8 <= extra.Length) { compressed = (long)BinaryPrimitives.ReadUInt64LittleEndian(extra[q..]); q += 8; } + if (localOffset == Zip64Marker && q + 8 <= extra.Length) { localOffset = (long)BinaryPrimitives.ReadUInt64LittleEndian(extra[q..]); } + return; + } + + p += 4 + dataLen; + } + } + + /// Computes the absolute offset where an entry's (compressed) data begins. + public static async Task GetDataStartAsync(IRangeReader reader, ZipEntry entry, CancellationToken cancellationToken) + { + // Local file headers carry their own name/extra lengths, which may differ from the central + // directory's, so the data start must be derived from the local header itself. + var header = await reader.ReadAsync(entry.LocalHeaderOffset, 30, cancellationToken); + var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(26)); + var extraLen = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(28)); + return entry.LocalHeaderOffset + 30 + nameLen + extraLen; + } + + /// Reads and decompresses a single entry's bytes (supports stored and deflate). + public static async Task ExtractEntryAsync(IRangeReader reader, ZipEntry entry, CancellationToken cancellationToken) + { + if (entry.CompressedSize > int.MaxValue) + { + throw new InvalidDataException($"Entry '{entry.Name}' is too large to extract ({entry.CompressedSize} bytes)."); + } + + var dataStart = await GetDataStartAsync(reader, entry, cancellationToken); + var compressed = await reader.ReadAsync(dataStart, (int)entry.CompressedSize, cancellationToken); + + return entry.Method switch + { + 0 => compressed, + 8 => Inflate(compressed, entry.UncompressedSize), + _ => throw new NotSupportedException($"Unsupported ZIP compression method {entry.Method} for '{entry.Name}'."), + }; + } + + private static byte[] Inflate(byte[] compressed, long expectedSize) + { + using var input = new MemoryStream(compressed, writable: false); + using var deflate = new DeflateStream(input, CompressionMode.Decompress); + using var output = expectedSize is > 0 and <= int.MaxValue + ? new MemoryStream((int)expectedSize) + : new MemoryStream(); + deflate.CopyTo(output); + return output.ToArray(); + } + + private static int LastIndexOfSignature(ReadOnlySpan buffer, uint signature) + { + Span needle = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(needle, signature); + for (var i = buffer.Length - 4; i >= 0; i--) + { + if (buffer[i] == needle[0] && buffer[i + 1] == needle[1] && + buffer[i + 2] == needle[2] && buffer[i + 3] == needle[3]) + { + return i; + } + } + + return -1; + } +} + +/// An backed by an in-memory buffer (used by tests). +internal sealed class MemoryRangeReader(byte[] data) : IRangeReader +{ + public Task GetLengthAsync(CancellationToken cancellationToken) => Task.FromResult((long)data.Length); + + public Task ReadAsync(long offset, int length, CancellationToken cancellationToken) + { + if (offset < 0 || length < 0 || offset + length > data.Length) + { + throw new ArgumentOutOfRangeException(nameof(offset), $"Range {offset}..{offset + length} is outside the buffer ({data.Length})."); + } + + var slice = new byte[length]; + Array.Copy(data, offset, slice, 0, length); + return Task.FromResult(slice); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index 17e76195..2092b0f8 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -17,6 +17,14 @@ internal static class Program { internal static async Task Main(string[] args) { + // Hidden internal verb: the WinUI DbgEng triage pass runs in this isolated child process so + // its modern dbgeng.dll is not poisoned by the system32 dbghelp.dll the parent already loaded. + // Intercept before any host/service setup to keep the loader state clean and output noise-free. + if (args.Length > 0 && args[0] == Services.XamlTriageRunner.InternalVerb) + { + return Services.XamlTriageRunner.Run(args); + } + // Ensure UTF-8 I/O for emoji-capable terminals; fall back silently if not supported try { diff --git a/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs b/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs index ad2fe59b..4ca789ec 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs @@ -25,14 +25,16 @@ namespace WinApp.Cli.Services; /// Writes minidumps for crashed processes and analyzes them using ClrMD /// to produce human-readable crash reports with managed exception details and stack traces. /// -internal sealed class CrashDumpService(IAnsiConsole console, ILogger logger) : ICrashDumpService +internal sealed class CrashDumpService(IAnsiConsole console, ILogger logger, IXamlTriageService xamlTriageService) : ICrashDumpService { private static readonly string DumpDirectory = Path.Combine(Path.GetTempPath(), "winapp-dumps"); /// public unsafe string? WriteMiniDump(uint processId, byte[]? savedContext, uint savedThreadId, - int savedExceptionCode, nuint savedExceptionAddress) + int savedExceptionCode, nuint savedExceptionAddress, + int crashExceptionCode = 0, nuint crashExceptionAddress = 0, + nuint[]? crashExceptionParameters = null) { try { @@ -62,15 +64,24 @@ internal sealed class CrashDumpService(IAnsiConsole console, ILogger 0 }; + + var recordCode = useStowedRecord ? crashExceptionCode : savedExceptionCode; + var recordAddress = useStowedRecord ? crashExceptionAddress : savedExceptionAddress; + + logger.LogDebug("Writing dump (thread {ThreadId}); exception record code 0x{Code:X8}{Stowed}.", + savedThreadId, recordCode, useStowedRecord ? " (stowed, with parameters)" : string.Empty); + + // CONTEXT must be 16-byte aligned on x64/ARM64. The saved byte[] from a managed + // array doesn't guarantee this, so copy into an aligned native buffer. var pContext = (CONTEXT*)NativeMemory.AlignedAlloc((nuint)savedContext.Length, 16); try { @@ -81,11 +92,24 @@ internal sealed class CrashDumpService(IAnsiConsole console, ILogger AnalyzeWithClrMD(dumpPath, symbolSearchPaths)); + var (summary, details, isWinUi) = await Task.Run(() => AnalyzeWithClrMD(dumpPath, symbolSearchPaths)); + + // WinUI triage pass — auto-enabled when Microsoft.UI.Xaml.dll is in the dump's + // module list. Recovers the stowed exception (0xC000027B) and the XAML dispatch + // chain that the ClrMD/DbgEng passes alone cannot surface. + string? xamlTriage = null; + if (isWinUi) + { + console.MarkupLine(useSymbols + ? "[dim]Running WinUI stowed-exception triage (downloading symbols may take a few minutes)...[/]" + : "[dim]Running WinUI stowed-exception triage...[/]"); + xamlTriage = await xamlTriageService.TryAnalyzeAsync(dumpPath, useSymbols); + } // ClrMD found managed exception — no need for native fallback if (!string.IsNullOrWhiteSpace(summary)) { + var managedLog = new StringBuilder(); if (!string.IsNullOrWhiteSpace(details)) + { + managedLog.AppendLine(details); + } + + if (!string.IsNullOrWhiteSpace(xamlTriage)) + { + managedLog.AppendLine(); + managedLog.AppendLine(xamlTriage); + } + + if (managedLog.Length > 0) { await File.AppendAllTextAsync(logPath, - $"\n\n=== Crash Analysis ({DateTime.Now:yyyy-MM-dd HH:mm:ss}) ===\n{details}\n"); + $"\n\n=== Crash Analysis ({DateTime.Now:yyyy-MM-dd HH:mm:ss}) ===\n{managedLog}\n"); } console.WriteLine(); @@ -166,6 +214,11 @@ await File.AppendAllTextAsync(logPath, console.MarkupLine("[red][[CRASH ANALYSIS]][/]"); console.WriteLine(summary); console.MarkupLine("[red]=====================================[/]"); + if (!string.IsNullOrWhiteSpace(xamlTriage)) + { + console.MarkupLine("[dim]WinUI stowed-exception triage written to the debug log.[/]"); + } + console.MarkupLine($"[dim]Crash dump:[/] {dumpPath.EscapeMarkup()}"); console.MarkupLine($"[dim]Full debug log:[/] {logPath.EscapeMarkup()}"); return; @@ -190,6 +243,12 @@ await File.AppendAllTextAsync(logPath, allDetails.AppendLine(nativeDetails); } + if (!string.IsNullOrWhiteSpace(xamlTriage)) + { + allDetails.AppendLine(); + allDetails.AppendLine(xamlTriage); + } + if (allDetails.Length > 0) { await File.AppendAllTextAsync(logPath, @@ -207,6 +266,11 @@ await File.AppendAllTextAsync(logPath, console.MarkupLine("[red]=====================================[/]"); } + if (!string.IsNullOrWhiteSpace(xamlTriage)) + { + console.MarkupLine("[dim]WinUI stowed-exception triage written to the debug log.[/]"); + } + console.MarkupLine($"[dim]Crash dump:[/] {dumpPath.EscapeMarkup()}"); console.MarkupLine($"[dim]Full debug log:[/] {logPath.EscapeMarkup()}"); if (!useSymbols && !string.IsNullOrWhiteSpace(nativeSummary)) @@ -223,10 +287,15 @@ await File.AppendAllTextAsync(logPath, } } - private (string Summary, string Details) AnalyzeWithClrMD(string dumpPath, IReadOnlyList? symbolSearchPaths) + private (string Summary, string Details, bool IsWinUi) AnalyzeWithClrMD(string dumpPath, IReadOnlyList? symbolSearchPaths) { using var dt = DataTarget.LoadDump(dumpPath); + // Detect WinUI so the triage pass can be auto-enabled. Only meaningful when the dump + // matches the host architecture (the triage engine/extension are host-arch native). + var archMatches = dt.DataReader.Architecture == RuntimeInformation.ProcessArchitecture; + var isWinUi = archMatches && DumpHasWinUiModule(dt); + // Cross-architecture analysis is not supported (e.g., ARM64 winapp analyzing x64 dump). // ClrMD requires a matching-architecture DAC DLL that cannot be loaded cross-arch. if (dt.DataReader.Architecture != RuntimeInformation.ProcessArchitecture) @@ -235,12 +304,13 @@ await File.AppendAllTextAsync(logPath, $"Cross-architecture crash dump (target: {dt.DataReader.Architecture}, host: {RuntimeInformation.ProcessArchitecture}).\n" + "Automatic analysis is not supported for cross-architecture dumps.\n" + "Open the dump in WinDbg for full analysis.", - $"Skipped analysis: dump architecture ({dt.DataReader.Architecture}) does not match host ({RuntimeInformation.ProcessArchitecture})."); + $"Skipped analysis: dump architecture ({dt.DataReader.Architecture}) does not match host ({RuntimeInformation.ProcessArchitecture}).", + isWinUi); } if (dt.ClrVersions.Length == 0) { - return (string.Empty, "No CLR runtime found in dump (native-only crash)."); + return (string.Empty, "No CLR runtime found in dump (native-only crash).", isWinUi); } ClrRuntime runtime; @@ -257,7 +327,8 @@ await File.AppendAllTextAsync(logPath, $"the host architecture ({RuntimeInformation.ProcessArchitecture}).\n" + "This typically happens when debugging an x64 app under ARM64 emulation.\n" + "Open the dump in WinDbg for full analysis.", - $"ClrMD DAC load failed: {ex.Message}"); + $"ClrMD DAC load failed: {ex.Message}", + isWinUi); } using var _ = runtime; @@ -411,7 +482,32 @@ await File.AppendAllTextAsync(logPath, } } - return (summary.ToString().Trim(), details.ToString().Trim()); + return (summary.ToString().Trim(), details.ToString().Trim(), isWinUi); + } + + /// + /// Returns true when the dump's loaded module list contains Microsoft.UI.Xaml.dll, + /// indicating a WinUI (Windows App SDK) app whose crashes benefit from the triage pass. + /// + private static bool DumpHasWinUiModule(DataTarget dt) + { + try + { + foreach (var module in dt.EnumerateModules()) + { + var fileName = Path.GetFileName(module.FileName); + if (string.Equals(fileName, "Microsoft.UI.Xaml.dll", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + catch (Exception) + { + // Module enumeration is best-effort; absence simply disables the triage pass. + } + + return false; } private static void FormatException(ClrException ex, StringBuilder summary, StringBuilder details, PdbSourceResolver pdbResolver) diff --git a/src/winapp-CLI/WinApp.Cli/Services/DebugOutputService.cs b/src/winapp-CLI/WinApp.Cli/Services/DebugOutputService.cs index 3a464006..b7cd8d68 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/DebugOutputService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/DebugOutputService.cs @@ -278,10 +278,16 @@ private unsafe void HandleException( if (_crashDumpPath == null) { + // Forward the terminating exception's parameters. For a stowed exception + // (0xC000027B) these point at the stowed-exception array that WinUI triage reads; + // the dump otherwise keeps the first-chance context for ClrMD's managed frames. + var crashParameters = ReadExceptionParameters(exInfo.ExceptionRecord); + _crashDumpPath = crashDumpService.WriteMiniDump( debugEvent.dwProcessId, _savedFirstChanceContext, _savedFirstChanceThreadId, - _savedFirstChanceExceptionCode, _savedFirstChanceExceptionAddress); + _savedFirstChanceExceptionCode, _savedFirstChanceExceptionAddress, + unchecked((int)code), address, crashParameters); } } @@ -299,6 +305,28 @@ private static unsafe void CloseHandleSafe(HANDLE handle) } } + /// + /// Reads the parameters (ExceptionInformation) from a debug-event exception record. For a + /// stowed exception (0xC000027B) element 0 is the stowed-exception array pointer and element + /// 1 is the count; these are copied verbatim into the dump so WinUI triage can locate them. + /// + private static unsafe nuint[]? ReadExceptionParameters(in EXCEPTION_RECORD record) + { + var count = (int)Math.Min(record.NumberParameters, (uint)record.ExceptionInformation.Length); + if (count <= 0) + { + return null; + } + + var parameters = new nuint[count]; + var source = record.ExceptionInformation.AsReadOnlySpan(); + for (var i = 0; i < count; i++) + { + parameters[i] = source[i]; + } + return parameters; + } + /// /// Captures the faulting thread's context at first-chance time, when it still /// points to the user code that caused the exception. diff --git a/src/winapp-CLI/WinApp.Cli/Services/ICrashDumpService.cs b/src/winapp-CLI/WinApp.Cli/Services/ICrashDumpService.cs index 5863effa..1d3712fa 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ICrashDumpService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ICrashDumpService.cs @@ -19,10 +19,24 @@ internal interface ICrashDumpService /// Thread ID from the first-chance exception. /// Exception code from the first-chance exception. /// Exception address from the first-chance exception. + /// + /// Exception code of the terminating (second-chance) exception, or 0. When this is a stowed + /// exception (0xC000027B) and are supplied, + /// the dump's exception record carries those parameters so WinUI stowed-exception triage + /// (!xamlstowed) can locate the stowed-exception array, while the first-chance context is + /// still used for the thread so ClrMD recovers the original managed user frames. + /// + /// Address of the terminating (second-chance) exception, or 0. + /// + /// The terminating exception's parameters (EXCEPTION_RECORD.ExceptionInformation); for a + /// stowed exception, element 0 is the stowed-exception array pointer and element 1 is the count. + /// /// The full path to the dump file, or null if the dump failed. string? WriteMiniDump(uint processId, byte[]? savedContext, uint savedThreadId, - int savedExceptionCode, nuint savedExceptionAddress); + int savedExceptionCode, nuint savedExceptionAddress, + int crashExceptionCode = 0, nuint crashExceptionAddress = 0, + nuint[]? crashExceptionParameters = null); /// /// Analyzes a minidump and prints a crash summary to the console. diff --git a/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs new file mode 100644 index 00000000..5e4634c9 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// +/// Produces WinUI-specific crash triage for a minidump by hosting DbgEng and running +/// the WinUI team's WinDbg JavaScript extension (!xamlstowed / !xamltriage). +/// +/// Most WinUI crashes originate inside a XAML event handler and surface as a stowed +/// exception (0xC000027B) wrapping a managed exception. The standard ClrMD/DbgEng +/// passes only reliably recover the faulting user frame; this service recovers the +/// originating HRESULT, its ErrorContext chain, and the native dispatch stack +/// (Microsoft.UI.Xaml → CXcpDispatcher → CoreMessagingXP → CLR host). +/// +/// +internal interface IXamlTriageService +{ + /// + /// Runs the WinUI triage pass against a dump and returns formatted output suitable + /// for appending to the debug log, or null when triage is not applicable. + /// + /// + /// This method never throws for missing tooling or unsupported scenarios — it + /// degrades gracefully, logging diagnostics and returning either an explanatory + /// note (so the absence is recorded in the log) or null. + /// + /// Path to the minidump file (must contain Microsoft.UI.Xaml). + /// + /// When true, symbol resolution against the Microsoft Symbol Server is enabled so the + /// full native dispatch chain resolves to function names. First run downloads symbols. + /// + /// Cancellation token. + /// Formatted triage text for the log, or null when nothing was produced. + Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs new file mode 100644 index 00000000..b30a7c4e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Xml.Linq; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Services; + +/// +/// Acquires JsProvider.dll (the WinDbg JavaScript scripting host required by +/// .scriptload) for the host architecture. The DLL is not distributed on NuGet — it ships +/// only inside the WinDbg .msixbundle — so it is extracted directly from the official +/// download using HTTP range requests, reading only the few hundred kilobytes needed rather than +/// the ~772 MB bundle. +/// +/// +/// The bundle is a ZIP64 archive whose per-architecture inner .msix packages are STORED +/// (uncompressed), so the inner archive can be addressed as a contiguous sub-range and parsed in +/// place. JsProvider.dll inside the inner msix is deflate-compressed and is inflated after +/// extraction. See for the underlying range-based ZIP reader. +/// +internal static class WinDbgJsProviderAcquirer +{ + // Stable entry point published by the WinDbg team; resolves to the current MainBundle URI. + private const string AppInstallerUrl = "https://windbg.download.prss.microsoft.com/dbazure/prod/1-0-0/windbg.appinstaller"; + + // Pinned fallback used when the appinstaller cannot be parsed (kept reasonably current). + private const string FallbackBundleUrl = "https://windbg.download.prss.microsoft.com/dbazure/prod/1-2603-20001-0/windbg.msixbundle"; + + private const string TargetFileName = "JsProvider.dll"; + private const int MaxReadAttempts = 5; + + /// + /// Attempts to download JsProvider.dll into (next to the + /// debugging engine). Returns true on success; failures are logged and swallowed so the + /// triage pass can degrade gracefully. + /// + public static async Task TryAcquireAsync(DirectoryInfo destDir, ILogger logger, CancellationToken cancellationToken) + { + var (msixName, pathPrefix) = HostTokens(RuntimeInformation.ProcessArchitecture); + if (msixName == null || pathPrefix == null) + { + logger.LogDebug("JsProvider acquisition skipped: unsupported host architecture {Arch}.", RuntimeInformation.ProcessArchitecture); + return false; + } + + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + var bundleUrl = await ResolveBundleUrlAsync(http, logger, cancellationToken); + + var reader = new HttpRangeReader(http, bundleUrl); + var bytes = await ExtractJsProviderAsync(reader, msixName, pathPrefix, cancellationToken); + if (bytes == null) + { + logger.LogDebug("JsProvider.dll not found in WinDbg bundle for {Msix}/{Prefix}.", msixName, pathPrefix); + return false; + } + + Directory.CreateDirectory(destDir.FullName); + var targetPath = Path.Combine(destDir.FullName, TargetFileName); + await File.WriteAllBytesAsync(targetPath, bytes, cancellationToken); + logger.LogDebug("Acquired {File} ({Size} bytes) from WinDbg bundle into {Dir}.", TargetFileName, bytes.Length, destDir.FullName); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to acquire {File} from the WinDbg bundle.", TargetFileName); + return false; + } + } + + /// + /// Extracts the host-architecture JsProvider.dll bytes from a WinDbg bundle exposed via + /// , or null when the expected entries are absent. Validates that + /// the result is a PE image. This is the unit-testable core (no network dependency). + /// + public static async Task ExtractJsProviderAsync( + IRangeReader reader, string msixName, string pathPrefix, CancellationToken cancellationToken) + { + var total = await reader.GetLengthAsync(cancellationToken); + + var (cdOffset, cdSize) = await ZipRangeExtractor.FindCentralDirectoryAsync(reader, 0, total, cancellationToken); + var bundleEntries = ZipRangeExtractor.ParseCentralDirectory( + await reader.ReadAsync(cdOffset, checked((int)cdSize), cancellationToken), 0); + + var inner = bundleEntries.FirstOrDefault(e => e.Name.Equals(msixName, StringComparison.OrdinalIgnoreCase)); + if (inner == null) + { + return null; + } + + var innerStart = await ZipRangeExtractor.GetDataStartAsync(reader, inner, cancellationToken); + var (innerCdOffset, innerCdSize) = await ZipRangeExtractor.FindCentralDirectoryAsync( + reader, innerStart, inner.CompressedSize, cancellationToken); + var innerEntries = ZipRangeExtractor.ParseCentralDirectory( + await reader.ReadAsync(innerCdOffset, checked((int)innerCdSize), cancellationToken), innerStart); + + // Prefer the self-contained provider (/winext/JsProvider.dll) over the chakra variant. + var target = $"{pathPrefix}/winext/{TargetFileName}"; + var js = innerEntries.FirstOrDefault(e => e.Name.Equals(target, StringComparison.OrdinalIgnoreCase)); + if (js == null) + { + return null; + } + + var bytes = await ZipRangeExtractor.ExtractEntryAsync(reader, js, cancellationToken); + if (bytes.Length < 2 || bytes[0] != (byte)'M' || bytes[1] != (byte)'Z') + { + throw new InvalidDataException($"Extracted '{target}' is not a valid PE image."); + } + + return bytes; + } + + /// + /// Maps a process architecture to the WinDbg inner-msix file name and the architecture folder + /// prefix used inside that msix. Returns (null, null) for unsupported architectures. + /// + public static (string? MsixName, string? PathPrefix) HostTokens(Architecture architecture) => architecture switch + { + Architecture.X64 => ("windbg_win-x64.msix", "amd64"), + Architecture.Arm64 => ("windbg_win-arm64.msix", "arm64"), + Architecture.X86 => ("windbg_win-x86.msix", "x86"), + _ => (null, null), + }; + + private static async Task ResolveBundleUrlAsync(HttpClient http, ILogger logger, CancellationToken cancellationToken) + { + try + { + var xml = await http.GetStringAsync(AppInstallerUrl, cancellationToken); + var doc = XDocument.Parse(xml); + var uri = doc.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "MainBundle")? + .Attribute("Uri")?.Value; + + if (!string.IsNullOrWhiteSpace(uri)) + { + logger.LogDebug("Resolved WinDbg bundle URI from appinstaller: {Uri}", uri); + return uri; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Failed to resolve WinDbg bundle URI from appinstaller; using pinned fallback."); + } + + return FallbackBundleUrl; + } + + /// An backed by HTTP range requests with retry-on-transient. + private sealed class HttpRangeReader(HttpClient http, string url) : IRangeReader + { + private long? _length; + + public async Task GetLengthAsync(CancellationToken cancellationToken) + { + if (_length is { } cached) + { + return cached; + } + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Range = new RangeHeaderValue(0, 0); + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + var total = response.Content.Headers.ContentRange?.Length + ?? throw new InvalidOperationException("Server did not report a total length for ranged requests."); + _length = total; + return total; + } + + public async Task ReadAsync(long offset, int length, CancellationToken cancellationToken) + { + Exception? last = null; + for (var attempt = 0; attempt < MaxReadAttempts; attempt++) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Range = new RangeHeaderValue(offset, offset + length - 1); + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode != HttpStatusCode.PartialContent) + { + throw new HttpRequestException($"Expected 206 PartialContent, got {(int)response.StatusCode} for range {offset}-{offset + length - 1}."); + } + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (bytes.Length != length) + { + throw new IOException($"Short range read: requested {length} bytes, got {bytes.Length}."); + } + + return bytes; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + last = ex; + await Task.Delay(TimeSpan.FromMilliseconds(250 * (attempt + 1)), cancellationToken); + } + } + + throw new IOException($"Failed to read range {offset}-{offset + length - 1} after {MaxReadAttempts} attempts.", last); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs new file mode 100644 index 00000000..9d80c227 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace WinApp.Cli.Services; + +/// +/// Native debugging binaries required to host DbgEng and run the WinUI JavaScript +/// extension (!xamlstowed / !xamltriage). +/// +/// Directory containing dbgeng.dll (and co-located providers). +/// symsrv.dll is co-located, enabling srv* symbol paths. +/// Human-readable description of where the binaries were resolved from. +internal sealed record ResolvedTriageBinaries(string BinDir, bool HasSymSrv, string Source); + +/// +/// Locates (and, when missing, downloads on first use) the host-architecture native +/// debugging binaries needed by . +/// +/// Resolution precedence: +/// +/// WINAPP_DBGTOOLS_DIR environment override. +/// An installed copy of Debugging Tools for Windows (Windows Kits). +/// A download-on-first-use cache populated from NuGet (mirrors the tool command). +/// +/// +/// +/// JsProvider.dll (the JS scripting host for .scriptload) is not +/// distributed on NuGet — it ships only inside the WinDbg bundle. The engine bits come from NuGet +/// (global cache or download), while JsProvider.dll is acquired separately via +/// ; when neither can be obtained the caller degrades +/// gracefully. +/// +/// +internal static class XamlTriageBinaries +{ + /// + /// Authoritative override directory containing a full debugger layout (dbgeng + JsProvider). + /// When set, only this directory is considered (installed tools and cache are skipped). + /// + public const string EnvOverride = "WINAPP_DBGTOOLS_DIR"; + + private const string FlatContainer = "https://api.nuget.org/v3-flatcontainer"; + + // Native engine bits available from NuGet. DbgEng ships the full engine layout (including + // dbgmodel.dll and msdia140.dll), so no separate DbgX package is required. JsProvider.dll is + // intentionally absent here — it is not on NuGet and is acquired from the WinDbg bundle instead. + private static readonly (string Package, string[] Files)[] NuGetComponents = + [ + ("Microsoft.Debugging.Platform.DbgEng", ["dbgeng.dll", "dbghelp.dll", "dbgcore.dll", "dbgmodel.dll", "msdia140.dll"]), + ("Microsoft.Debugging.Platform.SymSrv", ["symsrv.dll"]), + ]; + + /// Folder token used by the Windows Kits Debuggers layout for the host arch. + public static string KitsArch => RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => "x64", + }; + + /// Folder token used by the NuGet debugging packages for the host arch. + public static string NuGetArch => RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "amd64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => "amd64", + }; + + /// + /// Resolves an existing directory that contains both dbgeng.dll and + /// JsProvider.dll for the host architecture, or null when none is found. + /// + public static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger) + { + foreach (var (dir, source) in CandidateDirectories(cacheBinDir)) + { + var resolved = TryDirectory(dir, source); + if (resolved != null) + { + logger.LogDebug("Resolved WinUI triage debugging binaries from {Source}: {Dir}", source, dir); + return resolved; + } + } + + return null; + } + + private static IEnumerable<(string Dir, string Source)> CandidateDirectories(DirectoryInfo cacheBinDir) + { + // An explicit override is authoritative: when set, only that directory is considered. + var overrideDir = Environment.GetEnvironmentVariable(EnvOverride); + if (!string.IsNullOrWhiteSpace(overrideDir)) + { + yield return (overrideDir, $"{EnvOverride} override"); + yield break; + } + + foreach (var root in InstalledDebuggerRoots()) + { + yield return (Path.Combine(root, "Windows Kits", "10", "Debuggers", KitsArch), "installed Debugging Tools for Windows"); + } + + yield return (cacheBinDir.FullName, "download-on-first-use cache"); + } + + private static IEnumerable InstalledDebuggerRoots() + { + foreach (var variable in new[] { "ProgramFiles(x86)", "ProgramW6432", "ProgramFiles" }) + { + var value = Environment.GetEnvironmentVariable(variable); + if (!string.IsNullOrWhiteSpace(value)) + { + yield return value; + } + } + } + + /// + /// Returns a resolved descriptor when contains a usable engine + /// (dbgeng.dll) and a co-located JsProvider.dll (in the directory or its winext child). + /// + private static ResolvedTriageBinaries? TryDirectory(string dir, string source) + { + if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir)) + { + return null; + } + + var dbgeng = Path.Combine(dir, "dbgeng.dll"); + if (!File.Exists(dbgeng)) + { + return null; + } + + // dbgeng searches its own directory and the winext subfolder for extension providers. + var hasJsProvider = + File.Exists(Path.Combine(dir, "JsProvider.dll")) || + File.Exists(Path.Combine(dir, "winext", "JsProvider.dll")); + if (!hasJsProvider) + { + return null; + } + + var hasSymSrv = File.Exists(Path.Combine(dir, "symsrv.dll")); + return new ResolvedTriageBinaries(dir, hasSymSrv, source); + } + + /// + /// Returns true when the cache directory already contains the native engine + /// (dbgeng.dll), independent of whether JsProvider.dll is present yet. + /// + public static bool HasEngine(DirectoryInfo cacheBinDir) => + File.Exists(Path.Combine(cacheBinDir.FullName, "dbgeng.dll")); + + /// + /// Best-effort population of the cache directory with the NuGet-available native debugging bits. + /// Prefers copying from the NuGet global packages cache (populated by dotnet restore) and + /// falls back to downloading the flat-container .nupkg on first use. Does not acquire + /// JsProvider.dll. Returns the number of component packages successfully materialized. + /// + public static async Task TryAcquireFromNuGetAsync( + DirectoryInfo cacheBinDir, DirectoryInfo? nugetCacheDir, ILogger logger, CancellationToken cancellationToken) + { + Directory.CreateDirectory(cacheBinDir.FullName); + using var http = new HttpClient(); + var acquired = 0; + + foreach (var (package, files) in NuGetComponents) + { + try + { + if (files.All(f => File.Exists(Path.Combine(cacheBinDir.FullName, f)))) + { + acquired++; + continue; + } + + if (nugetCacheDir != null && TryCopyFromGlobalCache(package, files, nugetCacheDir, cacheBinDir, logger)) + { + acquired++; + continue; + } + + if (await TryMaterializePackageAsync(http, package, files, cacheBinDir, logger, cancellationToken)) + { + acquired++; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Failed to acquire debugging component {Package} from NuGet.", package); + } + } + + return acquired; + } + + /// + /// Copies the required files for a component from the NuGet global packages cache + /// (<cache>/<id>/<version>/content/<arch>/) when a restored copy exists. + /// + private static bool TryCopyFromGlobalCache( + string package, string[] files, DirectoryInfo nugetCacheDir, DirectoryInfo cacheBinDir, ILogger logger) + { + var packageDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, package.ToLowerInvariant())); + if (!packageDir.Exists) + { + return false; + } + + foreach (var versionDir in packageDir.EnumerateDirectories().OrderByDescending(d => d.Name, StringComparer.OrdinalIgnoreCase)) + { + var archDir = Path.Combine(versionDir.FullName, "content", NuGetArch); + if (!Directory.Exists(archDir) || !files.All(f => File.Exists(Path.Combine(archDir, f)))) + { + continue; + } + + foreach (var file in files) + { + File.Copy(Path.Combine(archDir, file), Path.Combine(cacheBinDir.FullName, file), overwrite: true); + } + + logger.LogDebug("Copied {Count} file(s) for {Package} from NuGet global cache {Version}.", files.Length, package, versionDir.Name); + return true; + } + + return false; + } + + private static async Task TryMaterializePackageAsync( + HttpClient http, string package, string[] files, DirectoryInfo cacheBinDir, ILogger logger, CancellationToken cancellationToken) + { + var id = package.ToLowerInvariant(); + + // Resolve the latest stable version via the flat-container index. + using var indexResponse = await http.GetAsync($"{FlatContainer}/{id}/index.json", cancellationToken); + if (!indexResponse.IsSuccessStatusCode) + { + return false; + } + + await using var indexStream = await indexResponse.Content.ReadAsStreamAsync(cancellationToken); + using var indexDoc = await JsonDocument.ParseAsync(indexStream, cancellationToken: cancellationToken); + var version = indexDoc.RootElement.GetProperty("versions").EnumerateArray() + .Select(v => v.GetString()) + .Where(v => v != null && !v.Contains('-', StringComparison.Ordinal)) + .LastOrDefault(); + if (string.IsNullOrEmpty(version)) + { + return false; + } + + // Download and extract the .nupkg (a zip archive) to a temp directory. + var nupkgUrl = $"{FlatContainer}/{id}/{version}/{id}.{version}.nupkg"; + using var nupkgResponse = await http.GetAsync(nupkgUrl, cancellationToken); + if (!nupkgResponse.IsSuccessStatusCode) + { + return false; + } + + var tempPkgDir = Path.Combine(Path.GetTempPath(), $"winapp-dbgtools-{id}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPkgDir); + try + { + await using (var nupkgStream = await nupkgResponse.Content.ReadAsStreamAsync(cancellationToken)) + using (var archive = new ZipArchive(nupkgStream, ZipArchiveMode.Read)) + { + archive.ExtractToDirectory(tempPkgDir, overwriteFiles: true); + } + + var copied = 0; + foreach (var file in files) + { + var source = FindBestArchMatch(tempPkgDir, file); + if (source != null) + { + File.Copy(source, Path.Combine(cacheBinDir.FullName, file), overwrite: true); + copied++; + } + } + + if (copied > 0) + { + logger.LogDebug("Materialized {Count}/{Total} file(s) from {Package} {Version}.", copied, files.Length, package, version); + } + + return copied == files.Length; + } + finally + { + try { Directory.Delete(tempPkgDir, recursive: true); } catch { /* best effort */ } + } + } + + /// + /// Finds the copy of whose path best matches the host + /// architecture, falling back to any match. Prefers paths containing the host arch token. + /// + private static string? FindBestArchMatch(string root, string fileName) + { + var matches = Directory.EnumerateFiles(root, fileName, SearchOption.AllDirectories).ToList(); + if (matches.Count == 0) + { + return null; + } + + var archTokens = new[] { NuGetArch, KitsArch }; + var preferred = matches.FirstOrDefault(m => + archTokens.Any(token => m.Contains(Path.DirectorySeparatorChar + token + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))); + + return preferred ?? matches[0]; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs new file mode 100644 index 00000000..fcf0973e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +#pragma warning disable CA1416 + +using Microsoft.Diagnostics.Runtime.Utilities.DbgEng; +using System.Text; + +namespace WinApp.Cli.Services; + +/// +/// Runs the DbgEng-hosted WinUI extension (!xamlstowed / !xamltriage) in a dedicated +/// process. Isolation is required: the parent winapp process loads the system32 +/// dbghelp.dll while capturing/analyzing the dump, and the modern (DbgX-era) dbgeng.dll +/// from NuGet then fails to load because its dbghelp.dll import binds to that older, +/// already-resident module (ERROR_PROC_NOT_FOUND). A fresh process has a clean loader state, so the +/// engine's own co-located dbghelp.dll is the one that gets bound. +/// +internal static class XamlTriageRunner +{ + /// Hidden first-argument verb that routes Program.Main to this runner. + public const string InternalVerb = "__xaml-triage"; + + private static readonly string SymbolCachePath = Path.Combine(Path.GetTempPath(), "symbols"); + + /// + /// Entry point for the isolated child process. Parses --dump, --bin, --ext + /// and optional --symbols, runs the extension, and writes the captured output to stdout. + /// + public static int Run(string[] args) + { + string? dump = null, bin = null, ext = null; + var useSymbols = false; + for (var i = 1; i < args.Length; i++) + { + switch (args[i]) + { + case "--dump" when i + 1 < args.Length: dump = args[++i]; break; + case "--bin" when i + 1 < args.Length: bin = args[++i]; break; + case "--ext" when i + 1 < args.Length: ext = args[++i]; break; + case "--symbols": useSymbols = true; break; + } + } + + if (dump == null || bin == null || ext == null) + { + Console.Error.WriteLine("xaml-triage: --dump, --bin and --ext are required."); + return 2; + } + + try + { + Console.Out.Write(RunDbgEngExtension(dump, bin, ext, useSymbols)); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"xaml-triage failed: {ex.Message}"); + return 1; + } + } + + /// + /// Executes .scriptload + !xamlstowed + !xamltriage against the dump and + /// returns the captured DbgEng output. + /// + public static string RunDbgEngExtension(string dumpPath, string binDir, string extPath, bool useSymbols) + { + using IDisposable dbgeng = IDebugClient.Create(binDir); + IDebugClient client = (IDebugClient)dbgeng; + IDebugControl control = (IDebugControl)dbgeng; + + var hr = client.OpenDumpFile(dumpPath); + if (hr < 0) + { + return $"DbgEng failed to open dump for WinUI triage: HRESULT 0x{(uint)hr:X8}"; + } + + hr = control.WaitForEvent(TimeSpan.FromSeconds(60)); + if (hr < 0) + { + return $"DbgEng WaitForEvent failed during WinUI triage: HRESULT 0x{(uint)hr:X8}"; + } + + var output = new StringBuilder(); + using (var holder = new DbgEngOutputHolder(client, DEBUG_OUTPUT.ALL)) + { + holder.OutputReceived += (text, _) => output.Append(text); + + if (useSymbols) + { + // Configure the public symbol server (symsrv.dll is co-located with the engine) and + // force-download the modules the extension dereferences: combase.dll provides the + // _STOWED_EXCEPTION_INFORMATION_* types !xamlstowed needs, and the WinUI module + // provides the XAML error-context types. Forcing avoids lazy-load gaps mid-script. + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, + $".sympath srv*{SymbolCachePath}*https://msdl.microsoft.com/download/symbols", + DEBUG_EXECUTE.DEFAULT); + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, ".reload /f combase.dll", DEBUG_EXECUTE.DEFAULT); + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, ".reload /f Microsoft.UI.Xaml.dll", DEBUG_EXECUTE.DEFAULT); + } + + // Switch to the recorded exception context so the extension analyzes the faulting thread + // (the stowed-exception raise site) rather than whichever thread the dump opened on. + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, ".ecxr", DEBUG_EXECUTE.DEFAULT); + + // Register the JavaScript script provider (ships as JsProvider.dll alongside the engine). + // Without this, '.scriptload .js' fails with "No script provider ... for '.js'". + var jsProvider = Path.Combine(binDir, "JsProvider.dll").Replace('\\', '/'); + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, $".load \"{jsProvider}\"", DEBUG_EXECUTE.DEFAULT); + + // Load the JS extension, then run the stowed-exception + triage commands. + // Forward slashes avoid escaping issues in the DbgEng command parser. + var scriptPath = extPath.Replace('\\', '/'); + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, $".scriptload \"{scriptPath}\"", DEBUG_EXECUTE.DEFAULT); + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, "!xamlstowed", DEBUG_EXECUTE.DEFAULT); + control.Execute(DEBUG_OUTCTL.THIS_CLIENT, "!xamltriage", DEBUG_EXECUTE.DEFAULT); + } + + return output.ToString(); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs new file mode 100644 index 00000000..fb672539 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; + +namespace WinApp.Cli.Services; + +/// +/// Runs the WinUI team's WinDbg JavaScript extension against a minidump by hosting DbgEng +/// directly, producing the stowed-exception breakdown and XAML dispatch triage that the +/// standard ClrMD/DbgEng passes cannot recover. +/// +internal sealed class XamlTriageService( + ILogger logger, + IWinappDirectoryService winappDirectoryService, + INugetService nugetService) : IXamlTriageService +{ + // Pinned WinUI debugger extension (microsoft/microsoft-ui-xaml). See plan / docs for rationale. + private const string ExtCommit = "29d537445eaa34d47e66ab8859583ae953c62dd1"; + private const string ExtRepoPath = "dbgext/publicXamlThread/winui-dbgext.js"; + private const string ExtBlobSha1 = "820f8f7d45dc3df82623ac5163dcea8d8212e2d2"; + private const string ExtFileName = "winui-dbgext.js"; + + // Hard ceiling for the isolated triage process (symbol downloads can be slow on first run). + private static readonly TimeSpan TriageTimeout = TimeSpan.FromMinutes(5); + + /// + public async Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default) + { + try + { + var dbgToolsRoot = new DirectoryInfo(Path.Combine( + winappDirectoryService.GetGlobalWinappDirectory().FullName, "dbgtools")); + var cacheBinDir = new DirectoryInfo(Path.Combine(dbgToolsRoot.FullName, XamlTriageBinaries.KitsArch)); + + // Resolve an existing debugger layout; if none, populate the download-on-first-use cache: + // engine bits from NuGet (global cache or download) and JsProvider.dll from the WinDbg bundle. + var binaries = XamlTriageBinaries.ResolveExisting(cacheBinDir, logger); + if (binaries == null) + { + var nugetCacheDir = TryGetNuGetCacheDir(); + await XamlTriageBinaries.TryAcquireFromNuGetAsync(cacheBinDir, nugetCacheDir, logger, cancellationToken); + + // JsProvider.dll only ships in the WinDbg bundle; acquire it once the engine is present. + if (XamlTriageBinaries.HasEngine(cacheBinDir)) + { + await WinDbgJsProviderAcquirer.TryAcquireAsync(cacheBinDir, logger, cancellationToken); + } + + binaries = XamlTriageBinaries.ResolveExisting(cacheBinDir, logger); + } + + if (binaries == null) + { + logger.LogDebug("WinUI triage skipped: debugging binaries (incl. JsProvider.dll) unavailable."); + return UnavailableNote(); + } + + var extPath = await EnsureExtensionAsync(dbgToolsRoot, cancellationToken); + if (extPath == null) + { + logger.LogDebug("WinUI triage skipped: could not obtain {Ext}.", ExtFileName); + return null; + } + + // Run the DbgEng pass in a dedicated child process. The parent has already loaded the + // system32 dbghelp.dll (dump capture + ClrMD analysis), which prevents the modern NuGet + // dbgeng.dll from binding to its co-located dbghelp.dll. A clean process avoids that. + var output = await RunTriageProcessAsync(dumpPath, binaries, extPath, useSymbols, cancellationToken); + + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + var header = $"WinUI Triage (DbgEng + winui-dbgext.js, source: {binaries.Source}):"; + var note = DescribeSymbolGap(output, useSymbols); + return note == null + ? $"{header}\n{output.Trim()}" + : $"{header}\n{note}\n\n{output.Trim()}"; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "WinUI triage pass failed."); + return null; + } + } + + /// + /// Detects the "operating-system symbols unavailable" failure shape — the extension identifies the + /// stowed exception but can't expand its combase.dll structures without OS symbols — and + /// returns a clear explanation to prepend, so the log doesn't end on a cryptic internal JS error. + /// Returns null when the output doesn't show that signature. + /// + internal static string? DescribeSymbolGap(string output, bool useSymbols) + { + var symbolsMissing = output.Contains("combase.dll not loaded/unavailable", StringComparison.OrdinalIgnoreCase) + || (output.Contains("Symbol Loading Error Summary", StringComparison.OrdinalIgnoreCase) + && output.Contains("combase", StringComparison.OrdinalIgnoreCase)); + var decodeFailed = output.Contains("createPointerObject", StringComparison.OrdinalIgnoreCase); + + if (!symbolsMissing || !decodeFailed) + { + return null; + } + + var remedy = useSymbols + ? "The public Microsoft symbol server did not have combase.dll symbols for this Windows build " + + "(it returned 404). Run the same analysis on a build whose OS symbols are published, or point " + + "the engine at a local symbol store that contains them, to get the full breakdown." + : "Re-run with --symbols so the operating-system symbols for combase.dll can be downloaded, then " + + "the stowed exception can be fully expanded."; + + return "Note: a stowed exception (0xC000027B) was detected but could not be fully expanded because " + + "operating-system symbols for combase.dll were unavailable. " + remedy + + "\n(The raw extension output below is kept for reference.)"; + } + + /// + /// Spawns the hidden __xaml-triage verb in a fresh process and captures its stdout. The + /// isolation is essential: see for the dbghelp.dll loader-collision + /// rationale. Works both as a published single-file executable and under dotnet winapp.dll. + /// + private async Task RunTriageProcessAsync( + string dumpPath, ResolvedTriageBinaries binaries, string extPath, bool useSymbols, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // Re-invoke the current binary. When running under the dotnet host (dev/test), ProcessPath is + // dotnet.exe and we must pass the managed entry assembly as the first argument. + var processPath = Environment.ProcessPath!; + if (Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + startInfo.FileName = processPath; + // Only reached under the dotnet host (dev/test), where the entry assembly has a real path. + // A single-file published winapp.exe takes the else-branch, so Location is never empty here. +#pragma warning disable IL3000 + startInfo.ArgumentList.Add(Assembly.GetEntryAssembly()!.Location); +#pragma warning restore IL3000 + } + else + { + startInfo.FileName = processPath; + } + + startInfo.ArgumentList.Add(XamlTriageRunner.InternalVerb); + startInfo.ArgumentList.Add("--dump"); + startInfo.ArgumentList.Add(dumpPath); + startInfo.ArgumentList.Add("--bin"); + startInfo.ArgumentList.Add(binaries.BinDir); + startInfo.ArgumentList.Add("--ext"); + startInfo.ArgumentList.Add(extPath); + if (useSymbols && binaries.HasSymSrv) + { + startInfo.ArgumentList.Add("--symbols"); + } + + using var process = new Process { StartInfo = startInfo }; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) { stdout.AppendLine(e.Data); } }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) { stderr.AppendLine(e.Data); } }; + + if (!process.Start()) + { + logger.LogDebug("WinUI triage child process failed to start."); + return null; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TriageTimeout); + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + TryKill(process); + logger.LogDebug("WinUI triage child process timed out after {Timeout}.", TriageTimeout); + return null; + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + + if (process.ExitCode != 0) + { + logger.LogDebug("WinUI triage child process exited with code {Code}: {Error}", + process.ExitCode, stderr.ToString().Trim()); + return null; + } + + return stdout.ToString(); + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // best effort + } + } + + /// + /// Ensures the pinned winui-dbgext.js is present in the cache and matches its pinned + /// git blob hash, downloading it on first use. Returns the local path or null on failure. + /// + private async Task EnsureExtensionAsync(DirectoryInfo dbgToolsRoot, CancellationToken cancellationToken) + { + var extDir = Path.Combine(dbgToolsRoot.FullName, "ext"); + Directory.CreateDirectory(extDir); + var extPath = Path.Combine(extDir, ExtFileName); + + if (File.Exists(extPath) && GitBlobSha1(await File.ReadAllBytesAsync(extPath, cancellationToken)) == ExtBlobSha1) + { + return extPath; + } + + try + { + var url = $"https://raw.githubusercontent.com/microsoft/microsoft-ui-xaml/{ExtCommit}/{ExtRepoPath}"; + using var http = new HttpClient(); + var bytes = await http.GetByteArrayAsync(url, cancellationToken); + + var actual = GitBlobSha1(bytes); + if (actual != ExtBlobSha1) + { + logger.LogWarning("Downloaded {Ext} hash mismatch (expected {Expected}, got {Actual}); refusing to use it.", + ExtFileName, ExtBlobSha1, actual); + return null; + } + + await File.WriteAllBytesAsync(extPath, bytes, cancellationToken); + return extPath; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Failed to download {Ext}.", ExtFileName); + return null; + } + } + + /// Resolves the NuGet global packages cache directory, tolerating provider failures. + private DirectoryInfo? TryGetNuGetCacheDir() + { + try + { + return nugetService.GetNuGetGlobalPackagesDir(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not resolve the NuGet global packages directory for triage binaries."); + return null; + } + } + + /// Computes the git blob SHA-1 (sha1("blob <len>\0" + content)) of a buffer. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", + Justification = "SHA-1 is used only to reproduce git's content-addressed blob identity for integrity pinning, not for security.")] + private static string GitBlobSha1(byte[] content) + { + var header = Encoding.ASCII.GetBytes($"blob {content.Length}\0"); + var buffer = new byte[header.Length + content.Length]; + Buffer.BlockCopy(header, 0, buffer, 0, header.Length); + Buffer.BlockCopy(content, 0, buffer, header.Length, content.Length); + return Convert.ToHexStringLower(SHA1.HashData(buffer)); + } + + private static string UnavailableNote() => + "WinUI Triage: skipped — the debugger components required for stowed-exception analysis " + + "(dbgeng.dll + JsProvider.dll) could not be obtained.\n" + + "The engine bits come from NuGet and JsProvider.dll is extracted from the WinDbg download; " + + "if your environment blocks those, install Debugging Tools for Windows (Windows SDK) or set " + + $"the {XamlTriageBinaries.EnvOverride} environment variable to a debugger directory that contains them."; +} diff --git a/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj b/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj index b56da957..82f1e04b 100644 --- a/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj +++ b/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj @@ -66,6 +66,13 @@ + + + From 6137dfd681deb2d817a81ddeb387ab13d6ebb8a9 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Tue, 30 Jun 2026 20:34:03 -0400 Subject: [PATCH 2/6] local pr review --- .claude/skills/winapp-setup/SKILL.md | 2 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 2 +- docs/fragments/skills/winapp-cli/setup.md | 2 +- docs/usage.md | 4 +- .../WinApp.Cli.Tests/CrashDumpServiceTests.cs | 39 +++++ .../WinApp.Cli.Tests/FakeCrashDumpService.cs | 2 + .../XamlTriageBinariesTests.cs | 100 ++++++++++++ .../XamlTriageServiceTests.cs | 65 ++++++++ .../ZipRangeExtractorTests.cs | 87 +++++++++++ .../Helpers/AuthenticodeVerifier.cs | 145 ++++++++++++++++++ .../WinApp.Cli/Services/CrashDumpService.cs | 31 +++- .../Services/WinDbgJsProviderAcquirer.cs | 11 ++ .../WinApp.Cli/Services/XamlTriageBinaries.cs | 115 ++++++++++---- .../WinApp.Cli/Services/XamlTriageRunner.cs | 20 ++- .../WinApp.Cli/Services/XamlTriageService.cs | 110 ++++++++----- src/winapp-npm/src/winapp-commands.ts | 2 +- 16 files changed, 651 insertions(+), 86 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs diff --git a/.claude/skills/winapp-setup/SKILL.md b/.claude/skills/winapp-setup/SKILL.md index 00d87db9..322c06b8 100644 --- a/.claude/skills/winapp-setup/SKILL.md +++ b/.claude/skills/winapp-setup/SKILL.md @@ -128,7 +128,7 @@ Use `winapp run` during iterative development — it creates a loose layout pack For console apps, add `--with-alias` to preserve stdin/stdout in the current terminal. -> **`--debug-output` caveat:** Captures `OutputDebugString` and crash diagnostics (minidump + automatic analysis for both managed and native crashes) but attaches winapp as the debugger — you cannot also attach VS Code or WinDbg. Use `--no-launch` if you need your own debugger. Add `--symbols` to download PDB symbols for richer native crash analysis. +> **`--debug-output` caveat:** Captures `OutputDebugString` and crash diagnostics (minidump + automatic analysis for both managed and native crashes) but attaches winapp as the debugger — you cannot also attach VS Code or WinDbg. Use `--no-launch` if you need your own debugger. Add `--symbols` to download PDB symbols for richer native crash analysis. For WinUI 3 apps, a stowed-exception triage pass runs automatically (surfacing the originating HRESULT and native XAML dispatch stack); the debugger components it needs are downloaded on first use, or set `WINAPP_DBGTOOLS_DIR` to a directory containing `dbgeng.dll` and `JsProvider.dll` for offline/locked-down environments. For full debugging scenarios and IDE setup, see the [Debugging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/debugging.md). diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 00d87db9..322c06b8 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -128,7 +128,7 @@ Use `winapp run` during iterative development — it creates a loose layout pack For console apps, add `--with-alias` to preserve stdin/stdout in the current terminal. -> **`--debug-output` caveat:** Captures `OutputDebugString` and crash diagnostics (minidump + automatic analysis for both managed and native crashes) but attaches winapp as the debugger — you cannot also attach VS Code or WinDbg. Use `--no-launch` if you need your own debugger. Add `--symbols` to download PDB symbols for richer native crash analysis. +> **`--debug-output` caveat:** Captures `OutputDebugString` and crash diagnostics (minidump + automatic analysis for both managed and native crashes) but attaches winapp as the debugger — you cannot also attach VS Code or WinDbg. Use `--no-launch` if you need your own debugger. Add `--symbols` to download PDB symbols for richer native crash analysis. For WinUI 3 apps, a stowed-exception triage pass runs automatically (surfacing the originating HRESULT and native XAML dispatch stack); the debugger components it needs are downloaded on first use, or set `WINAPP_DBGTOOLS_DIR` to a directory containing `dbgeng.dll` and `JsProvider.dll` for offline/locked-down environments. For full debugging scenarios and IDE setup, see the [Debugging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/debugging.md). diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index 89e963d4..9a3416a9 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -123,7 +123,7 @@ Use `winapp run` during iterative development — it creates a loose layout pack For console apps, add `--with-alias` to preserve stdin/stdout in the current terminal. -> **`--debug-output` caveat:** Captures `OutputDebugString` and crash diagnostics (minidump + automatic analysis for both managed and native crashes) but attaches winapp as the debugger — you cannot also attach VS Code or WinDbg. Use `--no-launch` if you need your own debugger. Add `--symbols` to download PDB symbols for richer native crash analysis. +> **`--debug-output` caveat:** Captures `OutputDebugString` and crash diagnostics (minidump + automatic analysis for both managed and native crashes) but attaches winapp as the debugger — you cannot also attach VS Code or WinDbg. Use `--no-launch` if you need your own debugger. Add `--symbols` to download PDB symbols for richer native crash analysis. For WinUI 3 apps, a stowed-exception triage pass runs automatically (surfacing the originating HRESULT and native XAML dispatch stack); the debugger components it needs are downloaded on first use, or set `WINAPP_DBGTOOLS_DIR` to a directory containing `dbgeng.dll` and `JsProvider.dll` for offline/locked-down environments. For full debugging scenarios and IDE setup, see the [Debugging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/debugging.md). diff --git a/docs/usage.md b/docs/usage.md index dfbd3e67..45930e8f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -504,8 +504,8 @@ winapp run [options] - `--args ` - Command-line arguments to pass to the application. Alternatively, use `--` followed by arguments to avoid escaping (e.g., `winapp run . -- --flag value`). - `--no-launch` - Only create the debug identity and register the package without launching the application - `--with-alias` - Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a `uap5:ExecutionAlias` in the manifest (use `winapp manifest add-alias` to add one). Cannot be combined with `--no-launch`. Cannot be combined with `--json`. -- `--debug-output` - Capture `OutputDebugString` messages and first-chance exceptions from the launched application. Framework noise (WinUI, COM, DirectX) is filtered from console output; the full log file captures everything. If the app crashes, automatically captures a minidump and analyzes it to show the exception type, message, and stack trace with source file:line numbers (resolved from PDBs in the build output folder). Managed (.NET) crashes are analyzed instantly with no external tools. Native (C++/WinRT) crashes show module names and offsets. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use `--no-launch` instead if you need to attach a different debugger. Cannot be combined with `--no-launch`. Cannot be combined with `--json`. -- `--symbols` - Download PDB symbols from Microsoft Symbol Server for richer native crash analysis with resolved function names. Only used with `--debug-output`. If omitted and a native crash occurs, the output will suggest adding this flag. First run downloads symbols and caches them locally; subsequent runs use the cache. +- `--debug-output` - Capture `OutputDebugString` messages and first-chance exceptions from the launched application. Framework noise (WinUI, COM, DirectX) is filtered from console output; the full log file captures everything. If the app crashes, automatically captures a minidump and analyzes it to show the exception type, message, and stack trace with source file:line numbers (resolved from PDBs in the build output folder). Managed (.NET) crashes are analyzed instantly with no external tools. Native (C++/WinRT) crashes show module names and offsets. When the crashed app is a WinUI 3 app (`Microsoft.UI.Xaml.dll` is loaded), an extra stowed-exception triage pass runs automatically to surface the originating HRESULT, its ErrorContext chain, and the full native XAML dispatch stack; the required debugger components are downloaded on first use (see [Debugging](debugging.md#winui-stowed-exception-triage), overridable via the `WINAPP_DBGTOOLS_DIR` environment variable). Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use `--no-launch` instead if you need to attach a different debugger. Cannot be combined with `--no-launch`. Cannot be combined with `--json`. +- `--symbols` - Download PDB symbols from Microsoft Symbol Server for richer native crash analysis with resolved function names. Only used with `--debug-output`. If omitted and a native crash occurs, the output will suggest adding this flag. This flag also improves the WinUI stowed-exception triage stack for WinUI 3 apps. First run downloads symbols and caches them locally; subsequent runs use the cache. - `--unregister-on-exit` - Unregister the development package after the application exits. Only removes packages registered in development mode. Cannot be combined with `--no-launch`. - `--detach` - Launch the application and return immediately without waiting for it to exit. Useful for CI/automation where you need to interact with the app after launch. Prints the PID to stdout (or in JSON with `--json`). Cannot be combined with `--no-launch`, `--debug-output`, `--with-alias`, or `--unregister-on-exit`. - `--clean` - Remove the existing package's application data (LocalState, settings, etc.) before re-deploying. By default, application data is preserved across re-deployments. diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs index 23343f48..088860eb 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs @@ -117,4 +117,43 @@ public async Task AnalyzeDumpAsync_InvalidDump_DoesNotRunWinUiTriage() // Assert Assert.AreEqual(0, _xamlTriage.AnalyzeCalls.Count, "WinUI triage must not run for an unreadable/non-WinUI dump."); } + + [TestMethod] + public void SelectExceptionRecord_StowedWithParameters_UsesStowedRecord() + { + var (code, address, useStowed) = CrashDumpService.SelectExceptionRecord( + savedExceptionCode: unchecked((int)0xC0000005), savedExceptionAddress: 0x1000, + crashExceptionCode: unchecked((int)0xC000027B), crashExceptionAddress: 0x2000, + crashExceptionParameters: [0xDEAD, 1]); + + Assert.IsTrue(useStowed, "A stowed exception with parameters must drive the dump's exception record."); + Assert.AreEqual(unchecked((int)0xC000027B), code); + Assert.AreEqual((nuint)0x2000, address); + } + + [TestMethod] + public void SelectExceptionRecord_StowedWithoutParameters_FallsBackToFirstChance() + { + var (code, address, useStowed) = CrashDumpService.SelectExceptionRecord( + savedExceptionCode: unchecked((int)0xC0000005), savedExceptionAddress: 0x1000, + crashExceptionCode: unchecked((int)0xC000027B), crashExceptionAddress: 0x2000, + crashExceptionParameters: null); + + Assert.IsFalse(useStowed, "Without stowed parameters there is nothing for !xamlstowed to read, so keep the first-chance record."); + Assert.AreEqual(unchecked((int)0xC0000005), code); + Assert.AreEqual((nuint)0x1000, address); + } + + [TestMethod] + public void SelectExceptionRecord_NonStowedCrash_UsesFirstChance() + { + var (code, address, useStowed) = CrashDumpService.SelectExceptionRecord( + savedExceptionCode: unchecked((int)0xE0434352), savedExceptionAddress: 0x1000, + crashExceptionCode: unchecked((int)0xC0000005), crashExceptionAddress: 0x2000, + crashExceptionParameters: [0xDEAD, 1]); + + Assert.IsFalse(useStowed, "A non-stowed terminating exception must not replace the record, even with parameters."); + Assert.AreEqual(unchecked((int)0xE0434352), code); + Assert.AreEqual((nuint)0x1000, address); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs index 4b60b736..077b7b7b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs @@ -11,6 +11,7 @@ namespace WinApp.Cli.Tests; internal class FakeCrashDumpService : ICrashDumpService { public List<(uint ProcessId, uint ThreadId)> WriteCalls { get; } = []; + public List<(int Code, nuint Address, nuint[]? Parameters)> CrashRecords { get; } = []; public string? FakeDumpPath { get; set; } public List AnalyzeCalls { get; } = []; @@ -21,6 +22,7 @@ internal class FakeCrashDumpService : ICrashDumpService nuint[]? crashExceptionParameters = null) { WriteCalls.Add((processId, savedThreadId)); + CrashRecords.Add((crashExceptionCode, crashExceptionAddress, crashExceptionParameters)); return FakeDumpPath; } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs index 786797a6..f3caac86 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -73,6 +73,23 @@ public void ResolveExisting_JsProviderInWinext_ResolvesWithoutSymSrv() Assert.IsNotNull(resolved); Assert.IsFalse(resolved.HasSymSrv, "No symsrv.dll present, so HasSymSrv must be false."); + Assert.AreEqual(Path.Combine(dir, "winext", "JsProvider.dll"), resolved.JsProviderPath, + "The resolved JsProvider path must point at the winext copy so the child runner can .load it."); + } + + [TestMethod] + public void ResolveExisting_JsProviderInRoot_PrefersRootPath() + { + var dir = Path.Combine(_tempDir, "root-layout"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "dbgeng.dll"), ""); + File.WriteAllText(Path.Combine(dir, "JsProvider.dll"), ""); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + + Assert.IsNotNull(resolved); + Assert.AreEqual(Path.Combine(dir, "JsProvider.dll"), resolved.JsProviderPath); } [TestMethod] @@ -94,4 +111,87 @@ public void ArchTokens_AreNonEmpty() Assert.IsFalse(string.IsNullOrWhiteSpace(XamlTriageBinaries.KitsArch)); Assert.IsFalse(string.IsNullOrWhiteSpace(XamlTriageBinaries.NuGetArch)); } + + [TestMethod] + public void TryCopyFromGlobalCache_PinnedVersionPresent_CopiesFromPinned() + { + const string package = "Test.Package.Bits"; + const string pinned = "2.0.0"; + var cache = new DirectoryInfo(Path.Combine(_tempDir, "cache")); + // Pinned version + a numerically newer version; the newer one must be ignored. + WriteCachePackage(cache, package, pinned, "engine.dll", "pinned"); + WriteCachePackage(cache, package, "9.9.9", "engine.dll", "newer"); + var binDir = new DirectoryInfo(Path.Combine(_tempDir, "bin")); + binDir.Create(); + + var ok = XamlTriageBinaries.TryCopyFromGlobalCache( + package, pinned, ["engine.dll"], cache, binDir, NullLogger.Instance); + + Assert.IsTrue(ok); + Assert.AreEqual("pinned", File.ReadAllText(Path.Combine(binDir.FullName, "engine.dll")), + "The pinned version must win even when a higher version number exists in the cache."); + } + + [TestMethod] + public void TryCopyFromGlobalCache_PinnedAbsent_FallsBackToNewest() + { + const string package = "Test.Package.Bits"; + var cache = new DirectoryInfo(Path.Combine(_tempDir, "cache")); + WriteCachePackage(cache, package, "1.0.0", "engine.dll", "older"); + WriteCachePackage(cache, package, "1.5.0", "engine.dll", "newer"); + var binDir = new DirectoryInfo(Path.Combine(_tempDir, "bin")); + binDir.Create(); + + var ok = XamlTriageBinaries.TryCopyFromGlobalCache( + package, "2.0.0", ["engine.dll"], cache, binDir, NullLogger.Instance); + + Assert.IsTrue(ok, "When the pinned version is missing, the newest cached version is an acceptable fallback."); + Assert.AreEqual("newer", File.ReadAllText(Path.Combine(binDir.FullName, "engine.dll"))); + } + + [TestMethod] + public void DbgPackageVersion_MatchesDirectoryPackagesProps() + { + var propsPath = FindUpwards("Directory.Packages.props", + p => File.ReadAllText(p).Contains("Microsoft.Debugging.Platform.DbgEng", StringComparison.Ordinal)); + Assert.IsNotNull(propsPath, "Could not locate the Directory.Packages.props that pins the DbgEng package."); + + var text = File.ReadAllText(propsPath); + var match = System.Text.RegularExpressions.Regex.Match( + text, "Microsoft\\.Debugging\\.Platform\\.DbgEng\"\\s+Version=\"([^\"]+)\""); + Assert.IsTrue(match.Success, "Could not find the DbgEng PackageVersion entry in Directory.Packages.props."); + Assert.AreEqual(XamlTriageBinaries.DbgPackageVersion, match.Groups[1].Value, + "XamlTriageBinaries.DbgPackageVersion drifted from the version pinned in Directory.Packages.props."); + } + + private static void WriteCachePackage(DirectoryInfo cache, string package, string version, string file, string content) + { + var archDir = Path.Combine(cache.FullName, package.ToLowerInvariant(), version, "content", XamlTriageBinaries.NuGetArch); + Directory.CreateDirectory(archDir); + File.WriteAllText(Path.Combine(archDir, file), content); + } + + private static string? FindUpwards(string fileName, Func predicate) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, fileName); + if (File.Exists(candidate)) + { + try + { + if (predicate(candidate)) + { + return candidate; + } + } + catch (IOException) { } + } + + dir = dir.Parent; + } + + return null; + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs index 854c7c33..ff61e97c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs @@ -61,4 +61,69 @@ public void DescribeSymbolGap_SymbolsMissingButDecodeSucceeded_ReturnsNull() Assert.IsNull(XamlTriageService.DescribeSymbolGap(partial, useSymbols: true)); } + + [TestMethod] + public void GitBlobSha1_EmptyContent_MatchesGitVector() + { + // The well-known git blob hash of empty content: `git hash-object` of an empty file. + Assert.AreEqual("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", XamlTriageService.GitBlobSha1([])); + } + + [TestMethod] + public void GitBlobSha1_KnownContent_MatchesGitVector() + { + // echo -n 'hello' | git hash-object --stdin => b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0 + var bytes = System.Text.Encoding.ASCII.GetBytes("hello"); + Assert.AreEqual("b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0", XamlTriageService.GitBlobSha1(bytes)); + } + + [TestMethod] + public void MatchesPinnedExtensionHash_TamperedContent_ReturnsFalse() + { + // Arbitrary content cannot match the pinned winui-dbgext.js hash, so the integrity gate + // must reject it (this is the rejection path that protects the debugger from a wrong/tampered script). + Assert.IsFalse(XamlTriageService.MatchesPinnedExtensionHash(System.Text.Encoding.UTF8.GetBytes("// not the real extension"))); + } + + [TestMethod] + public void BuildTriageArgs_WithSymbolsAndSymSrv_IncludesSymbolsAndResolvedPaths() + { + var binaries = new ResolvedTriageBinaries(@"C:\dbg", @"C:\dbg\winext\JsProvider.dll", HasSymSrv: true, "test"); + + var args = XamlTriageService.BuildTriageArgs(@"C:\crash.dmp", binaries, @"C:\ext.js", useSymbols: true); + + Assert.AreEqual(XamlTriageRunner.InternalVerb, args[0]); + CollectionAssert.Contains(args, "--symbols"); + AssertPairValue(args, "--dump", @"C:\crash.dmp"); + AssertPairValue(args, "--bin", @"C:\dbg"); + AssertPairValue(args, "--jsprovider", @"C:\dbg\winext\JsProvider.dll"); + AssertPairValue(args, "--ext", @"C:\ext.js"); + } + + [TestMethod] + public void BuildTriageArgs_SymbolsRequestedButNoSymSrv_OmitsSymbols() + { + var binaries = new ResolvedTriageBinaries(@"C:\dbg", @"C:\dbg\JsProvider.dll", HasSymSrv: false, "test"); + + var args = XamlTriageService.BuildTriageArgs(@"C:\crash.dmp", binaries, @"C:\ext.js", useSymbols: true); + + CollectionAssert.DoesNotContain(args, "--symbols"); + } + + [TestMethod] + public void BuildTriageArgs_NoSymbols_OmitsSymbols() + { + var binaries = new ResolvedTriageBinaries(@"C:\dbg", @"C:\dbg\JsProvider.dll", HasSymSrv: true, "test"); + + var args = XamlTriageService.BuildTriageArgs(@"C:\crash.dmp", binaries, @"C:\ext.js", useSymbols: false); + + CollectionAssert.DoesNotContain(args, "--symbols"); + } + + private static void AssertPairValue(List args, string flag, string expected) + { + var idx = args.IndexOf(flag); + Assert.IsTrue(idx >= 0 && idx + 1 < args.Count, $"Expected flag {flag} with a value."); + Assert.AreEqual(expected, args[idx + 1]); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs index d527254c..0306e38d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs @@ -128,6 +128,93 @@ public async Task ExtractEntry_StoredAndDeflate_RoundTrip() CollectionAssert.AreEqual(deflated, await ZipRangeExtractor.ExtractEntryAsync(reader, deflatedEntry, CancellationToken.None)); } + [TestMethod] + public async Task FindCentralDirectory_Zip64Eocd_ResolvesOffsetAndSize() + { + // Hand-build a ZIP64 tail: [cd placeholder][zip64 eocd][zip64 locator][eocd], because + // System.IO.Compression only emits ZIP64 for archives too large to construct in a unit test. + const long cdOffset = 0; + const long cdSize = 10; + var buf = new List(); + + buf.AddRange(new byte[cdSize]); // central-directory placeholder + var recordRelative = buf.Count; // offset of the ZIP64 EOCD record + + var record = new byte[56]; + WriteU32(record, 0, 0x06064b50); // ZIP64 EOCD signature + WriteU64(record, 40, unchecked((ulong)cdSize)); // size of the central directory + WriteU64(record, 48, unchecked((ulong)cdOffset)); // offset of the central directory + buf.AddRange(record); + + var locator = new byte[20]; + WriteU32(locator, 0, 0x07064b50); // ZIP64 locator signature + WriteU64(locator, 8, (ulong)recordRelative); // relative offset of the ZIP64 EOCD record + buf.AddRange(locator); + + var eocd = new byte[22]; + WriteU32(eocd, 0, 0x06054b50); // EOCD signature + WriteU16(eocd, 10, 0xFFFF); // entry count → ZIP64 marker + WriteU32(eocd, 12, 0xFFFFFFFF); // cd size → ZIP64 marker + WriteU32(eocd, 16, 0xFFFFFFFF); // cd offset → ZIP64 marker + buf.AddRange(eocd); + + var data = buf.ToArray(); + var reader = new MemoryRangeReader(data); + + var (offset, size) = await ZipRangeExtractor.FindCentralDirectoryAsync(reader, 0, data.Length, CancellationToken.None); + + Assert.AreEqual(cdOffset, offset, "ZIP64 EOCD central-directory offset must be honored."); + Assert.AreEqual(cdSize, size, "ZIP64 EOCD central-directory size must be honored."); + } + + [TestMethod] + public void ParseCentralDirectory_Zip64ExtraField_Applies64BitValues() + { + const long compressed = 0x1_0000_0001; // > 4 GiB, forcing the 0xFFFFFFFF marker + const long uncompressed = 0x2_0000_0002; + const long localOffset = 0x3_0000_0003; + var name = Encoding.UTF8.GetBytes("big.bin"); + + // ZIP64 extra: id 0x0001, dataLen 24, then uncompressed, compressed, localOffset (the fixed + // order the parser walks, only for fields whose 32-bit slot was 0xFFFFFFFF). + var extra = new byte[4 + 24]; + WriteU16(extra, 0, 0x0001); + WriteU16(extra, 2, 24); + WriteU64(extra, 4, unchecked((ulong)uncompressed)); + WriteU64(extra, 12, unchecked((ulong)compressed)); + WriteU64(extra, 20, unchecked((ulong)localOffset)); + + var header = new byte[46 + name.Length + extra.Length]; + WriteU32(header, 0, 0x02014b50); // central header signature + WriteU16(header, 10, 0); // method: stored + WriteU32(header, 20, 0xFFFFFFFF); // compressed → ZIP64 marker + WriteU32(header, 24, 0xFFFFFFFF); // uncompressed → ZIP64 marker + WriteU16(header, 28, (ushort)name.Length); + WriteU16(header, 30, (ushort)extra.Length); + WriteU16(header, 32, 0); // comment length + WriteU32(header, 42, 0xFFFFFFFF); // local-header offset → ZIP64 marker + name.CopyTo(header, 46); + extra.CopyTo(header, 46 + name.Length); + + var entries = ZipRangeExtractor.ParseCentralDirectory(header, archiveBase: 1000); + + Assert.AreEqual(1, entries.Count); + var entry = entries[0]; + Assert.AreEqual("big.bin", entry.Name); + Assert.AreEqual(compressed, entry.CompressedSize, "64-bit compressed size must come from the ZIP64 extra field."); + Assert.AreEqual(uncompressed, entry.UncompressedSize, "64-bit uncompressed size must come from the ZIP64 extra field."); + Assert.AreEqual(1000 + localOffset, entry.LocalHeaderOffset, "64-bit local-header offset must be applied then rebased."); + } + + private static void WriteU16(byte[] buffer, int offset, ushort value) => + System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset), value); + + private static void WriteU32(byte[] buffer, int offset, uint value) => + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), value); + + private static void WriteU64(byte[] buffer, int offset, ulong value) => + System.Buffers.Binary.BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(offset), value); + [TestMethod] public void HostTokens_KnownArchitectures_Map() { diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs b/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs new file mode 100644 index 00000000..4983ed7e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; + +namespace WinApp.Cli.Helpers; + +/// +/// Verifies that a file on disk carries a valid Authenticode signature that chains to a trusted root +/// and is signed by Microsoft. Used as a defense-in-depth integrity check on native binaries that are +/// downloaded and then loaded into a debugger process (e.g. JsProvider.dll from the WinDbg +/// bundle), so a tampered or substituted file is rejected even though it is fetched over HTTPS from an +/// official Microsoft host. +/// +internal static unsafe partial class AuthenticodeVerifier +{ + // WINTRUST_ACTION_GENERIC_VERIFY_V2 — standard Authenticode policy provider. + private static readonly Guid GenericVerifyV2 = new("00AAC56B-CD44-11D0-8CC2-00C04FC295EE"); + + private const uint WTD_UI_NONE = 2; + private const uint WTD_REVOKE_NONE = 0; + private const uint WTD_CHOICE_FILE = 1; + private const uint WTD_STATEACTION_VERIFY = 1; + private const uint WTD_STATEACTION_CLOSE = 2; + private const uint WTD_REVOCATION_CHECK_NONE = 0x00000010; + + /// + /// Returns true only when has a valid Authenticode signature + /// that chains to a trusted root and whose signer is Microsoft. Any failure (unsigned, + /// untrusted, non-Microsoft, or verification error) returns false — this is a fail-closed + /// security gate. + /// + public static bool IsTrustedMicrosoftSigned(string filePath, ILogger logger) + { + try + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + if (!VerifyTrust(filePath)) + { + logger.LogDebug("Authenticode trust verification failed for {File}.", filePath); + return false; + } + + if (!IsMicrosoftSigner(filePath)) + { + logger.LogDebug("Authenticode signer for {File} is not Microsoft.", filePath); + return false; + } + + return true; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Authenticode verification of {File} threw; treating as untrusted.", filePath); + return false; + } + } + + private static bool VerifyTrust(string filePath) + { + var pPath = Marshal.StringToHGlobalUni(filePath); + try + { + var fileInfo = new WINTRUST_FILE_INFO + { + cbStruct = (uint)sizeof(WINTRUST_FILE_INFO), + pcwszFilePath = pPath, + hFile = IntPtr.Zero, + pgKnownSubject = IntPtr.Zero, + }; + + var data = new WINTRUST_DATA + { + cbStruct = (uint)sizeof(WINTRUST_DATA), + dwUIChoice = WTD_UI_NONE, + fdwRevocationChecks = WTD_REVOKE_NONE, + dwUnionChoice = WTD_CHOICE_FILE, + pInfo = (IntPtr)(&fileInfo), + dwStateAction = WTD_STATEACTION_VERIFY, + dwProvFlags = WTD_REVOCATION_CHECK_NONE, + }; + + var action = GenericVerifyV2; + var result = WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + // Always release the state data, regardless of the verify outcome. + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + return result == 0; + } + finally + { + Marshal.FreeHGlobal(pPath); + } + } + + private static bool IsMicrosoftSigner(string filePath) + { + // CreateFromSignedFile returns the Authenticode signer certificate. There is no + // non-obsolete replacement for extracting a signer from a signed file (X509CertificateLoader + // only loads raw certificate blobs), so the SYSLIB0057 obsoletion is suppressed here. +#pragma warning disable SYSLIB0057 + var subject = X509Certificate.CreateFromSignedFile(filePath).Subject; +#pragma warning restore SYSLIB0057 + return subject.Contains("O=Microsoft Corporation", StringComparison.OrdinalIgnoreCase) + || subject.Contains("CN=Microsoft", StringComparison.OrdinalIgnoreCase); + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_FILE_INFO + { + public uint cbStruct; + public IntPtr pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_DATA + { + public uint cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public uint dwUIChoice; + public uint fdwRevocationChecks; + public uint dwUnionChoice; + public IntPtr pInfo; + public uint dwStateAction; + public IntPtr hWVTStateData; + public IntPtr pwszURLReference; + public uint dwProvFlags; + public uint dwUIContext; + public IntPtr pSignatureSettings; + } + + [LibraryImport("wintrust.dll")] + private static partial int WinVerifyTrust(IntPtr hwnd, ref Guid pgActionID, ref WINTRUST_DATA pWVTData); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs b/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs index 4ca789ec..eb1e6725 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs @@ -70,12 +70,9 @@ internal sealed class CrashDumpService(IAnsiConsole console, ILogger 0 }; - - var recordCode = useStowedRecord ? crashExceptionCode : savedExceptionCode; - var recordAddress = useStowedRecord ? crashExceptionAddress : savedExceptionAddress; + var (recordCode, recordAddress, useStowedRecord) = SelectExceptionRecord( + savedExceptionCode, savedExceptionAddress, + crashExceptionCode, crashExceptionAddress, crashExceptionParameters); logger.LogDebug("Writing dump (thread {ThreadId}); exception record code 0x{Code:X8}{Stowed}.", savedThreadId, recordCode, useStowedRecord ? " (stowed, with parameters)" : string.Empty); @@ -166,6 +163,24 @@ internal sealed class CrashDumpService(IAnsiConsole console, ILogger + /// Chooses which exception the dump's exception record should describe. A terminating stowed + /// exception (0xC000027B) carrying parameters is preferred so WinUI triage can locate the + /// stowed-exception array; otherwise the first-chance exception is used. The thread CONTEXT is + /// always the first-chance one (handled by the caller) so ClrMD still recovers user frames. + /// Exposed internally for testing the selection logic without writing a real dump. + /// + internal static (int Code, nuint Address, bool UseStowed) SelectExceptionRecord( + int savedExceptionCode, nuint savedExceptionAddress, + int crashExceptionCode, nuint crashExceptionAddress, nuint[]? crashExceptionParameters) + { + const int statusStowedException = unchecked((int)0xC000027B); + var useStowed = crashExceptionCode == statusStowedException && crashExceptionParameters is { Length: > 0 }; + return useStowed + ? (crashExceptionCode, crashExceptionAddress, true) + : (savedExceptionCode, savedExceptionAddress, false); + } + /// public async Task AnalyzeDumpAsync(string dumpPath, string logPath, bool useSymbols = false, IReadOnlyList? symbolSearchPaths = null) { @@ -182,8 +197,8 @@ public async Task AnalyzeDumpAsync(string dumpPath, string logPath, bool useSymb if (isWinUi) { console.MarkupLine(useSymbols - ? "[dim]Running WinUI stowed-exception triage (downloading symbols may take a few minutes)...[/]" - : "[dim]Running WinUI stowed-exception triage...[/]"); + ? "[dim]Running WinUI stowed-exception triage (first run may download debugger components and symbols; this can take a few minutes)...[/]" + : "[dim]Running WinUI stowed-exception triage (first run may download debugger components)...[/]"); xamlTriage = await xamlTriageService.TryAnalyzeAsync(dumpPath, useSymbols); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs index b30a7c4e..6528ba13 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs @@ -64,6 +64,17 @@ public static async Task TryAcquireAsync(DirectoryInfo destDir, ILogger lo Directory.CreateDirectory(destDir.FullName); var targetPath = Path.Combine(destDir.FullName, TargetFileName); await File.WriteAllBytesAsync(targetPath, bytes, cancellationToken); + + // Defense-in-depth: this DLL is loaded into the debugger process, so verify it carries a + // valid Authenticode signature from Microsoft before trusting it (the download is HTTPS + // from an official host, but this guards against tampering / a compromised mirror). + if (!AuthenticodeVerifier.IsTrustedMicrosoftSigned(targetPath, logger)) + { + logger.LogDebug("Discarding {File}: it is not validly signed by Microsoft.", TargetFileName); + try { File.Delete(targetPath); } catch { /* best effort */ } + return false; + } + logger.LogDebug("Acquired {File} ({Size} bytes) from WinDbg bundle into {Dir}.", TargetFileName, bytes.Length, destDir.FullName); return true; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs index 9d80c227..9dca2877 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -13,9 +13,11 @@ namespace WinApp.Cli.Services; /// extension (!xamlstowed / !xamltriage). /// /// Directory containing dbgeng.dll (and co-located providers). +/// Full path to the resolved JsProvider.dll (may live in a +/// winext subfolder rather than directly in ). /// symsrv.dll is co-located, enabling srv* symbol paths. /// Human-readable description of where the binaries were resolved from. -internal sealed record ResolvedTriageBinaries(string BinDir, bool HasSymSrv, string Source); +internal sealed record ResolvedTriageBinaries(string BinDir, string JsProviderPath, bool HasSymSrv, string Source); /// /// Locates (and, when missing, downloads on first use) the host-architecture native @@ -46,13 +48,20 @@ internal static class XamlTriageBinaries private const string FlatContainer = "https://api.nuget.org/v3-flatcontainer"; + /// + /// Pinned native-debugger package version. Must stay in sync with the + /// Microsoft.Debugging.Platform.* entries in Directory.Packages.props; a unit test + /// asserts they match so runtime acquisition uses the same version that dotnet restore pins. + /// + public const string DbgPackageVersion = "20260319.1511.0"; + // Native engine bits available from NuGet. DbgEng ships the full engine layout (including // dbgmodel.dll and msdia140.dll), so no separate DbgX package is required. JsProvider.dll is // intentionally absent here — it is not on NuGet and is acquired from the WinDbg bundle instead. - private static readonly (string Package, string[] Files)[] NuGetComponents = + private static readonly (string Package, string Version, string[] Files)[] NuGetComponents = [ - ("Microsoft.Debugging.Platform.DbgEng", ["dbgeng.dll", "dbghelp.dll", "dbgcore.dll", "dbgmodel.dll", "msdia140.dll"]), - ("Microsoft.Debugging.Platform.SymSrv", ["symsrv.dll"]), + ("Microsoft.Debugging.Platform.DbgEng", DbgPackageVersion, ["dbgeng.dll", "dbghelp.dll", "dbgcore.dll", "dbgmodel.dll", "msdia140.dll"]), + ("Microsoft.Debugging.Platform.SymSrv", DbgPackageVersion, ["symsrv.dll"]), ]; /// Folder token used by the Windows Kits Debuggers layout for the host arch. @@ -139,17 +148,20 @@ private static IEnumerable InstalledDebuggerRoots() return null; } - // dbgeng searches its own directory and the winext subfolder for extension providers. - var hasJsProvider = - File.Exists(Path.Combine(dir, "JsProvider.dll")) || - File.Exists(Path.Combine(dir, "winext", "JsProvider.dll")); - if (!hasJsProvider) + // dbgeng searches its own directory and the winext subfolder for extension providers, but the + // child runner must .load JsProvider.dll by explicit path, so capture where it actually lives. + var jsProviderPath = new[] + { + Path.Combine(dir, "JsProvider.dll"), + Path.Combine(dir, "winext", "JsProvider.dll"), + }.FirstOrDefault(File.Exists); + if (jsProviderPath == null) { return null; } var hasSymSrv = File.Exists(Path.Combine(dir, "symsrv.dll")); - return new ResolvedTriageBinaries(dir, hasSymSrv, source); + return new ResolvedTriageBinaries(dir, jsProviderPath, hasSymSrv, source); } /// @@ -172,7 +184,7 @@ public static async Task TryAcquireFromNuGetAsync( using var http = new HttpClient(); var acquired = 0; - foreach (var (package, files) in NuGetComponents) + foreach (var (package, version, files) in NuGetComponents) { try { @@ -182,13 +194,13 @@ public static async Task TryAcquireFromNuGetAsync( continue; } - if (nugetCacheDir != null && TryCopyFromGlobalCache(package, files, nugetCacheDir, cacheBinDir, logger)) + if (nugetCacheDir != null && TryCopyFromGlobalCache(package, version, files, nugetCacheDir, cacheBinDir, logger)) { acquired++; continue; } - if (await TryMaterializePackageAsync(http, package, files, cacheBinDir, logger, cancellationToken)) + if (await TryMaterializePackageAsync(http, package, version, files, cacheBinDir, logger, cancellationToken)) { acquired++; } @@ -204,10 +216,13 @@ public static async Task TryAcquireFromNuGetAsync( /// /// Copies the required files for a component from the NuGet global packages cache - /// (<cache>/<id>/<version>/content/<arch>/) when a restored copy exists. + /// (<cache>/<id>/<version>/content/<arch>/). Prefers the + /// (the version pinned in Directory.Packages.props and + /// guaranteed present after dotnet restore) and only falls back to the newest cached + /// version when the pinned one is absent, so dev/CI builds are deterministic. /// - private static bool TryCopyFromGlobalCache( - string package, string[] files, DirectoryInfo nugetCacheDir, DirectoryInfo cacheBinDir, ILogger logger) + internal static bool TryCopyFromGlobalCache( + string package, string pinnedVersion, string[] files, DirectoryInfo nugetCacheDir, DirectoryInfo cacheBinDir, ILogger logger) { var packageDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, package.ToLowerInvariant())); if (!packageDir.Exists) @@ -215,7 +230,15 @@ private static bool TryCopyFromGlobalCache( return false; } - foreach (var versionDir in packageDir.EnumerateDirectories().OrderByDescending(d => d.Name, StringComparer.OrdinalIgnoreCase)) + // Pinned version first (deterministic); then newest available as a graceful fallback. + var pinnedDir = new DirectoryInfo(Path.Combine(packageDir.FullName, pinnedVersion)); + var candidates = new[] { pinnedDir } + .Where(d => d.Exists) + .Concat(packageDir.EnumerateDirectories() + .Where(d => !d.Name.Equals(pinnedVersion, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(d => d.Name, StringComparer.OrdinalIgnoreCase)); + + foreach (var versionDir in candidates) { var archDir = Path.Combine(versionDir.FullName, "content", NuGetArch); if (!Directory.Exists(archDir) || !files.All(f => File.Exists(Path.Combine(archDir, f)))) @@ -228,6 +251,12 @@ private static bool TryCopyFromGlobalCache( File.Copy(Path.Combine(archDir, file), Path.Combine(cacheBinDir.FullName, file), overwrite: true); } + if (!versionDir.Name.Equals(pinnedVersion, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Pinned version {Pinned} of {Package} not in NuGet cache; used {Used} instead.", + pinnedVersion, package, versionDir.Name); + } + logger.LogDebug("Copied {Count} file(s) for {Package} from NuGet global cache {Version}.", files.Length, package, versionDir.Name); return true; } @@ -236,23 +265,10 @@ private static bool TryCopyFromGlobalCache( } private static async Task TryMaterializePackageAsync( - HttpClient http, string package, string[] files, DirectoryInfo cacheBinDir, ILogger logger, CancellationToken cancellationToken) + HttpClient http, string package, string pinnedVersion, string[] files, DirectoryInfo cacheBinDir, ILogger logger, CancellationToken cancellationToken) { var id = package.ToLowerInvariant(); - - // Resolve the latest stable version via the flat-container index. - using var indexResponse = await http.GetAsync($"{FlatContainer}/{id}/index.json", cancellationToken); - if (!indexResponse.IsSuccessStatusCode) - { - return false; - } - - await using var indexStream = await indexResponse.Content.ReadAsStreamAsync(cancellationToken); - using var indexDoc = await JsonDocument.ParseAsync(indexStream, cancellationToken: cancellationToken); - var version = indexDoc.RootElement.GetProperty("versions").EnumerateArray() - .Select(v => v.GetString()) - .Where(v => v != null && !v.Contains('-', StringComparison.Ordinal)) - .LastOrDefault(); + var version = await ResolveDownloadVersionAsync(http, id, pinnedVersion, logger, cancellationToken); if (string.IsNullOrEmpty(version)) { return false; @@ -300,6 +316,41 @@ private static async Task TryMaterializePackageAsync( } } + /// + /// Resolves the version to download: the pinned version when the flat-container index lists it + /// (the deterministic, expected path), otherwise the latest stable version as a graceful fallback. + /// + private static async Task ResolveDownloadVersionAsync( + HttpClient http, string id, string pinnedVersion, ILogger logger, CancellationToken cancellationToken) + { + using var indexResponse = await http.GetAsync($"{FlatContainer}/{id}/index.json", cancellationToken); + if (!indexResponse.IsSuccessStatusCode) + { + return null; + } + + await using var indexStream = await indexResponse.Content.ReadAsStreamAsync(cancellationToken); + using var indexDoc = await JsonDocument.ParseAsync(indexStream, cancellationToken: cancellationToken); + var versions = indexDoc.RootElement.GetProperty("versions").EnumerateArray() + .Select(v => v.GetString()) + .Where(v => !string.IsNullOrEmpty(v)) + .ToList(); + + if (versions.Any(v => string.Equals(v, pinnedVersion, StringComparison.OrdinalIgnoreCase))) + { + return pinnedVersion; + } + + var latest = versions.LastOrDefault(v => !v!.Contains('-', StringComparison.Ordinal)); + if (latest != null) + { + logger.LogDebug("Pinned version {Pinned} of {Id} not available on the feed; downloading {Latest} instead.", + pinnedVersion, id, latest); + } + + return latest; + } + /// /// Finds the copy of whose path best matches the host /// architecture, falling back to any match. Prefers paths containing the host arch token. diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs index fcf0973e..65408978 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs @@ -29,7 +29,7 @@ internal static class XamlTriageRunner /// public static int Run(string[] args) { - string? dump = null, bin = null, ext = null; + string? dump = null, bin = null, ext = null, jsProvider = null; var useSymbols = false; for (var i = 1; i < args.Length; i++) { @@ -38,6 +38,7 @@ public static int Run(string[] args) case "--dump" when i + 1 < args.Length: dump = args[++i]; break; case "--bin" when i + 1 < args.Length: bin = args[++i]; break; case "--ext" when i + 1 < args.Length: ext = args[++i]; break; + case "--jsprovider" when i + 1 < args.Length: jsProvider = args[++i]; break; case "--symbols": useSymbols = true; break; } } @@ -48,9 +49,13 @@ public static int Run(string[] args) return 2; } + // The provider may live in a winext subfolder; the parent passes its resolved path. Fall back + // to the engine directory for backward compatibility when it is not supplied. + jsProvider ??= Path.Combine(bin, "JsProvider.dll"); + try { - Console.Out.Write(RunDbgEngExtension(dump, bin, ext, useSymbols)); + Console.Out.Write(RunDbgEngExtension(dump, bin, jsProvider, ext, useSymbols)); return 0; } catch (Exception ex) @@ -64,7 +69,7 @@ public static int Run(string[] args) /// Executes .scriptload + !xamlstowed + !xamltriage against the dump and /// returns the captured DbgEng output. /// - public static string RunDbgEngExtension(string dumpPath, string binDir, string extPath, bool useSymbols) + public static string RunDbgEngExtension(string dumpPath, string binDir, string jsProviderPath, string extPath, bool useSymbols) { using IDisposable dbgeng = IDebugClient.Create(binDir); IDebugClient client = (IDebugClient)dbgeng; @@ -106,8 +111,13 @@ public static string RunDbgEngExtension(string dumpPath, string binDir, string e // Register the JavaScript script provider (ships as JsProvider.dll alongside the engine). // Without this, '.scriptload .js' fails with "No script provider ... for '.js'". - var jsProvider = Path.Combine(binDir, "JsProvider.dll").Replace('\\', '/'); - control.Execute(DEBUG_OUTCTL.THIS_CLIENT, $".load \"{jsProvider}\"", DEBUG_EXECUTE.DEFAULT); + var jsProvider = jsProviderPath.Replace('\\', '/'); + var loadHr = control.Execute(DEBUG_OUTCTL.THIS_CLIENT, $".load \"{jsProvider}\"", DEBUG_EXECUTE.DEFAULT); + if (loadHr < 0) + { + return output + $"\nWinUI triage could not load the JavaScript provider " + + $"({jsProviderPath}): HRESULT 0x{(uint)loadHr:X8}"; + } // Load the JS extension, then run the stowed-exception + triage commands. // Forward slashes avoid escaping issues in the DbgEng command parser. diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs index fb672539..d4dbe018 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -64,24 +64,39 @@ internal sealed class XamlTriageService( if (extPath == null) { logger.LogDebug("WinUI triage skipped: could not obtain {Ext}.", ExtFileName); - return null; + return $"WinUI Triage: skipped — the WinUI debugger extension ({ExtFileName}) could not be " + + "obtained (download blocked or its pinned hash did not match)."; } // Run the DbgEng pass in a dedicated child process. The parent has already loaded the // system32 dbghelp.dll (dump capture + ClrMD analysis), which prevents the modern NuGet // dbgeng.dll from binding to its co-located dbghelp.dll. A clean process avoids that. - var output = await RunTriageProcessAsync(dumpPath, binaries, extPath, useSymbols, cancellationToken); + var (output, skipNote) = await RunTriageProcessAsync(dumpPath, binaries, extPath, useSymbols, cancellationToken); + if (skipNote != null) + { + return $"WinUI Triage: skipped — {skipNote}"; + } if (string.IsNullOrWhiteSpace(output)) { - return null; + return "WinUI Triage: skipped — the triage pass produced no output."; } var header = $"WinUI Triage (DbgEng + winui-dbgext.js, source: {binaries.Source}):"; - var note = DescribeSymbolGap(output, useSymbols); - return note == null + + // The user asked for symbols but the resolved engine layout has no symsrv.dll, so the + // child ran without symbol downloads; explain why the breakdown may be incomplete. + var symbolNote = useSymbols && !binaries.HasSymSrv + ? "Note: --symbols was requested but symsrv.dll was not found alongside the debugging " + + $"engine ({binaries.Source}), so symbols could not be downloaded. Install Debugging " + + $"Tools for Windows or point {XamlTriageBinaries.EnvOverride} at a layout that includes symsrv.dll." + : null; + + var gapNote = DescribeSymbolGap(output, useSymbols); + var notes = string.Join("\n", new[] { symbolNote, gapNote }.Where(n => n != null)); + return notes.Length == 0 ? $"{header}\n{output.Trim()}" - : $"{header}\n{note}\n\n{output.Trim()}"; + : $"{header}\n{notes}\n\n{output.Trim()}"; } catch (OperationCanceledException) { @@ -124,12 +139,38 @@ internal sealed class XamlTriageService( "\n(The raw extension output below is kept for reference.)"; } + /// + /// Builds the argument list for the hidden __xaml-triage child verb. Extracted for + /// testability: --symbols is only forwarded when the user asked for symbols and + /// the resolved layout actually has symsrv.dll, and the resolved JsProvider.dll + /// path (which may be in a winext subfolder) is passed explicitly. + /// + internal static List BuildTriageArgs(string dumpPath, ResolvedTriageBinaries binaries, string extPath, bool useSymbols) + { + var args = new List + { + XamlTriageRunner.InternalVerb, + "--dump", dumpPath, + "--bin", binaries.BinDir, + "--jsprovider", binaries.JsProviderPath, + "--ext", extPath, + }; + if (useSymbols && binaries.HasSymSrv) + { + args.Add("--symbols"); + } + + return args; + } + /// /// Spawns the hidden __xaml-triage verb in a fresh process and captures its stdout. The /// isolation is essential: see for the dbghelp.dll loader-collision /// rationale. Works both as a published single-file executable and under dotnet winapp.dll. + /// Returns the captured output, or a short human-readable skip note describing why no output is + /// available (failed to start, timed out, or non-zero exit). /// - private async Task RunTriageProcessAsync( + private async Task<(string? Output, string? SkipNote)> RunTriageProcessAsync( string dumpPath, ResolvedTriageBinaries binaries, string extPath, bool useSymbols, CancellationToken cancellationToken) { var startInfo = new ProcessStartInfo @@ -143,30 +184,19 @@ internal sealed class XamlTriageService( // Re-invoke the current binary. When running under the dotnet host (dev/test), ProcessPath is // dotnet.exe and we must pass the managed entry assembly as the first argument. var processPath = Environment.ProcessPath!; + startInfo.FileName = processPath; if (Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) { - startInfo.FileName = processPath; - // Only reached under the dotnet host (dev/test), where the entry assembly has a real path. - // A single-file published winapp.exe takes the else-branch, so Location is never empty here. -#pragma warning disable IL3000 - startInfo.ArgumentList.Add(Assembly.GetEntryAssembly()!.Location); -#pragma warning restore IL3000 - } - else - { - startInfo.FileName = processPath; + // Only reached under the dotnet host (dev/test). Derive the managed entry DLL path from + // the app base directory + assembly simple name rather than Assembly.Location, which is + // empty for single-file apps and trips the IL3000 single-file/AOT analyzer. + var entryName = Assembly.GetEntryAssembly()!.GetName().Name; + startInfo.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, entryName + ".dll")); } - startInfo.ArgumentList.Add(XamlTriageRunner.InternalVerb); - startInfo.ArgumentList.Add("--dump"); - startInfo.ArgumentList.Add(dumpPath); - startInfo.ArgumentList.Add("--bin"); - startInfo.ArgumentList.Add(binaries.BinDir); - startInfo.ArgumentList.Add("--ext"); - startInfo.ArgumentList.Add(extPath); - if (useSymbols && binaries.HasSymSrv) + foreach (var arg in BuildTriageArgs(dumpPath, binaries, extPath, useSymbols)) { - startInfo.ArgumentList.Add("--symbols"); + startInfo.ArgumentList.Add(arg); } using var process = new Process { StartInfo = startInfo }; @@ -178,7 +208,7 @@ internal sealed class XamlTriageService( if (!process.Start()) { logger.LogDebug("WinUI triage child process failed to start."); - return null; + return (null, "the triage child process could not be started."); } process.BeginOutputReadLine(); @@ -189,12 +219,16 @@ internal sealed class XamlTriageService( try { await process.WaitForExitAsync(timeoutCts.Token); + // WaitForExitAsync returns once the process exits, but the async stdout/stderr readers may + // still have buffered data in flight. The parameterless overload blocks until those readers + // have flushed, so the StringBuilders are complete before we read them. + process.WaitForExit(); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { TryKill(process); logger.LogDebug("WinUI triage child process timed out after {Timeout}.", TriageTimeout); - return null; + return (null, $"the triage child process timed out after {TriageTimeout.TotalMinutes:0} minutes."); } catch (OperationCanceledException) { @@ -206,10 +240,10 @@ internal sealed class XamlTriageService( { logger.LogDebug("WinUI triage child process exited with code {Code}: {Error}", process.ExitCode, stderr.ToString().Trim()); - return null; + return (null, $"the triage child process exited with code {process.ExitCode}."); } - return stdout.ToString(); + return (stdout.ToString(), null); } private static void TryKill(Process process) @@ -237,7 +271,7 @@ private static void TryKill(Process process) Directory.CreateDirectory(extDir); var extPath = Path.Combine(extDir, ExtFileName); - if (File.Exists(extPath) && GitBlobSha1(await File.ReadAllBytesAsync(extPath, cancellationToken)) == ExtBlobSha1) + if (File.Exists(extPath) && MatchesPinnedExtensionHash(await File.ReadAllBytesAsync(extPath, cancellationToken))) { return extPath; } @@ -248,11 +282,10 @@ private static void TryKill(Process process) using var http = new HttpClient(); var bytes = await http.GetByteArrayAsync(url, cancellationToken); - var actual = GitBlobSha1(bytes); - if (actual != ExtBlobSha1) + if (!MatchesPinnedExtensionHash(bytes)) { logger.LogWarning("Downloaded {Ext} hash mismatch (expected {Expected}, got {Actual}); refusing to use it.", - ExtFileName, ExtBlobSha1, actual); + ExtFileName, ExtBlobSha1, GitBlobSha1(bytes)); return null; } @@ -280,10 +313,17 @@ private static void TryKill(Process process) } } + /// + /// Returns true when matches the pinned winui-dbgext.js + /// git blob hash. This is the integrity gate that prevents a tampered or wrong extension from + /// being loaded into the debugger; exposed internally for testing. + /// + internal static bool MatchesPinnedExtensionHash(byte[] content) => GitBlobSha1(content) == ExtBlobSha1; + /// Computes the git blob SHA-1 (sha1("blob <len>\0" + content)) of a buffer. [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", Justification = "SHA-1 is used only to reproduce git's content-addressed blob identity for integrity pinning, not for security.")] - private static string GitBlobSha1(byte[] content) + internal static string GitBlobSha1(byte[] content) { var header = Encoding.ASCII.GetBytes($"blob {content.Length}\0"); var buffer = new byte[header.Length + content.Length]; diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index cbd19763..c43b6c53 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -2,7 +2,7 @@ * AUTO-GENERATED — DO NOT EDIT * * Regenerate with: npm run generate-commands - * Source schema version: 0.3.3 + * Source schema version: 0.4.1 * * Programmatic wrappers for all winapp CLI commands. * Each function builds the CLI arguments, invokes the native CLI, From 4c1b7f972d13fa349ae87cc44756c82520d9ccf6 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Sun, 5 Jul 2026 17:44:14 -0400 Subject: [PATCH 3/6] address copilot feedback --- .../WinApp.Cli.Tests/XamlTriageBinariesTests.cs | 13 +++++++++++++ .../WinApp.Cli/Services/XamlTriageBinaries.cs | 12 ++++++++++-- .../WinApp.Cli/Services/XamlTriageService.cs | 5 ++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs index f3caac86..d4c4d5d2 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -112,6 +112,19 @@ public void ArchTokens_AreNonEmpty() Assert.IsFalse(string.IsNullOrWhiteSpace(XamlTriageBinaries.NuGetArch)); } + [TestMethod] + public void IsEnvOverrideSet_ReflectsEnvironmentVariable() + { + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, null); + Assert.IsFalse(XamlTriageBinaries.IsEnvOverrideSet, "No override set: IsEnvOverrideSet must be false."); + + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, " "); + Assert.IsFalse(XamlTriageBinaries.IsEnvOverrideSet, "Whitespace-only override must be treated as unset."); + + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, _tempDir); + Assert.IsTrue(XamlTriageBinaries.IsEnvOverrideSet, "A non-empty override must report as set."); + } + [TestMethod] public void TryCopyFromGlobalCache_PinnedVersionPresent_CopiesFromPinned() { diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs index 9dca2877..efaa39b9 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -46,6 +46,14 @@ internal static class XamlTriageBinaries /// public const string EnvOverride = "WINAPP_DBGTOOLS_DIR"; + /// + /// true when is configured. The override is authoritative: + /// installed tools, the download-on-first-use cache, and cache acquisition are all skipped so the + /// override remains the single source of truth (see ). + /// + public static bool IsEnvOverrideSet => + !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(EnvOverride)); + private const string FlatContainer = "https://api.nuget.org/v3-flatcontainer"; /// @@ -104,9 +112,9 @@ private static readonly (string Package, string Version, string[] Files)[] NuGet private static IEnumerable<(string Dir, string Source)> CandidateDirectories(DirectoryInfo cacheBinDir) { // An explicit override is authoritative: when set, only that directory is considered. - var overrideDir = Environment.GetEnvironmentVariable(EnvOverride); - if (!string.IsNullOrWhiteSpace(overrideDir)) + if (IsEnvOverrideSet) { + var overrideDir = Environment.GetEnvironmentVariable(EnvOverride)!; yield return (overrideDir, $"{EnvOverride} override"); yield break; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs index d4dbe018..724663df 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -40,8 +40,11 @@ internal sealed class XamlTriageService( // Resolve an existing debugger layout; if none, populate the download-on-first-use cache: // engine bits from NuGet (global cache or download) and JsProvider.dll from the WinDbg bundle. var binaries = XamlTriageBinaries.ResolveExisting(cacheBinDir, logger); - if (binaries == null) + if (binaries == null && !XamlTriageBinaries.IsEnvOverrideSet) { + // Only populate the download-on-first-use cache when no authoritative override is set; + // with an override configured, ResolveExisting never consults the cache, so acquiring + // into it would waste the download and still report triage as unavailable. var nugetCacheDir = TryGetNuGetCacheDir(); await XamlTriageBinaries.TryAcquireFromNuGetAsync(cacheBinDir, nugetCacheDir, logger, cancellationToken); From 11cafc733264c5224ca86ab8827700d468633f4d Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Tue, 7 Jul 2026 21:50:49 -0400 Subject: [PATCH 4/6] address nikola pr review --- .claude/agents/winapp.md | 3 +- .claude/skills/winapp-setup/SKILL.md | 4 +- .github/plugin/agents/winapp.agent.md | 3 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 4 +- docs/cli-schema.json | 4 +- docs/debugging.md | 2 +- .../WinApp.Cli.Tests/AtomicFileTests.cs | 86 ++++++++++ .../AuthenticodeVerifierTests.cs | 81 +++++++++ .../WinApp.Cli.Tests/CrashDumpServiceTests.cs | 26 +++ .../WinApp.Cli.Tests/FakeXamlTriageService.cs | 12 +- .../XamlTriageBinariesTests.cs | 139 +++++++++++++++- .../XamlTriageServiceTests.cs | 53 ++++++ .../WinApp.Cli/Commands/RunCommand.cs | 4 +- .../WinApp.Cli/Helpers/AtomicFile.cs | 81 +++++++++ .../Helpers/AuthenticodeVerifier.cs | 57 ++++++- .../WinApp.Cli/Services/CrashDumpService.cs | 67 +++++++- .../WinApp.Cli/Services/IXamlTriageService.cs | 13 +- .../Services/WinDbgJsProviderAcquirer.cs | 12 +- .../WinApp.Cli/Services/XamlTriageBinaries.cs | 154 +++++++++++++++--- .../WinApp.Cli/Services/XamlTriageResult.cs | 49 ++++++ .../WinApp.Cli/Services/XamlTriageService.cs | 118 ++++++++++++-- 21 files changed, 893 insertions(+), 79 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/AuthenticodeVerifierTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/XamlTriageResult.cs diff --git a/.claude/agents/winapp.md b/.claude/agents/winapp.md index afa1ff77..4791d478 100644 --- a/.claude/agents/winapp.md +++ b/.claude/agents/winapp.md @@ -135,7 +135,8 @@ Want to inspect or interact with a running app's UI? - `--args ` — command-line arguments to pass to the app - `--no-launch` — register the package without launching - `--with-alias` — launch via execution alias (console apps run in current terminal) -- `--debug-output` — capture `OutputDebugString` messages and first-chance exceptions (prevents other debuggers like VS/VS Code from attaching) +- `--debug-output` — capture `OutputDebugString` messages and first-chance exceptions (prevents other debuggers like VS/VS Code from attaching). For WinUI apps it also auto-runs a stowed-exception (`0xC000027B`) triage pass (`!xamlstowed`/`!xamltriage`) that recovers the originating HRESULT and native XAML dispatch stack. The first triage run downloads debugger components (engine bits from NuGet + `JsProvider.dll` from the WinDbg CDN) and caches them under `~\.winapp\dbgtools\`; if downloads are blocked, install Debugging Tools for Windows or point `WINAPP_DBGTOOLS_DIR` at a debugger directory containing `dbgeng.dll` and `JsProvider.dll`. +- `--symbols` — with `--debug-output`, download Microsoft public symbols for richer native crash stacks (first run downloads and caches them) - `--output-appx-directory ` — custom output directory for loose layout **Requires:** Built app output directory + `appxmanifest.xml` diff --git a/.claude/skills/winapp-setup/SKILL.md b/.claude/skills/winapp-setup/SKILL.md index 322c06b8..ed8b021c 100644 --- a/.claude/skills/winapp-setup/SKILL.md +++ b/.claude/skills/winapp-setup/SKILL.md @@ -228,13 +228,13 @@ Creates packaged layout, registers the Application, and launches the packaged ap |--------|-------------|---------| | `--args` | Command-line arguments to pass to the application. Alternatively, use -- followed by arguments to avoid escaping (e.g., winapp run . -- --flag value). | (none) | | `--clean` | Remove the existing package's application data (LocalState, settings, etc.) before re-deploying. By default, application data is preserved across re-deployments. | (none) | -| `--debug-output` | Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. Cannot be combined with --no-launch or --json. | (none) | +| `--debug-output` | Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. For WinUI apps, a crash also triggers a stowed-exception triage pass; the first run downloads debugger components (cached under the winapp global directory) and can be pointed at an existing debugger install via the WINAPP_DBGTOOLS_DIR environment variable. Cannot be combined with --no-launch or --json. | (none) | | `--detach` | Launch the application and return immediately without waiting for it to exit. Useful for CI/automation where you need to interact with the app after launch. Prints the PID to stdout (or in JSON with --json). | (none) | | `--executable` | Path to the executable relative to the input folder. Use to disambiguate when the manifest contains a $targetnametoken$ placeholder and multiple .exe files are present in the input folder. | (none) | | `--json` | Format output as JSON | (none) | | `--manifest` | Path to the Package.appxmanifest (default: auto-detect from input folder or current directory) | (none) | | `--no-launch` | Only create the debug identity and register the package without launching the application | (none) | | `--output-appx-directory` | Output directory for the loose layout package. If not specified, a directory named AppX inside the input-folder directory will be used. | (none) | -| `--symbols` | Download symbols from Microsoft Symbol Server for richer native crash analysis. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache. | (none) | +| `--symbols` | Download symbols from Microsoft Symbol Server for richer native crash analysis, including the WinUI stowed-exception dispatch stack. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache. | (none) | | `--unregister-on-exit` | Unregister the development package after the application exits. Only removes packages registered in development mode. | (none) | | `--with-alias` | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | (none) | diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index b31c658e..74530d3b 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -136,7 +136,8 @@ Want to inspect or interact with a running app's UI? - `--args ` — command-line arguments to pass to the app - `--no-launch` — register the package without launching - `--with-alias` — launch via execution alias (console apps run in current terminal) -- `--debug-output` — capture `OutputDebugString` messages and first-chance exceptions (prevents other debuggers like VS/VS Code from attaching) +- `--debug-output` — capture `OutputDebugString` messages and first-chance exceptions (prevents other debuggers like VS/VS Code from attaching). For WinUI apps it also auto-runs a stowed-exception (`0xC000027B`) triage pass (`!xamlstowed`/`!xamltriage`) that recovers the originating HRESULT and native XAML dispatch stack. The first triage run downloads debugger components (engine bits from NuGet + `JsProvider.dll` from the WinDbg CDN) and caches them under `~\.winapp\dbgtools\`; if downloads are blocked, install Debugging Tools for Windows or point `WINAPP_DBGTOOLS_DIR` at a debugger directory containing `dbgeng.dll` and `JsProvider.dll`. +- `--symbols` — with `--debug-output`, download Microsoft public symbols for richer native crash stacks (first run downloads and caches them) - `--output-appx-directory ` — custom output directory for loose layout **Requires:** Built app output directory + `appxmanifest.xml` diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 322c06b8..ed8b021c 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -228,13 +228,13 @@ Creates packaged layout, registers the Application, and launches the packaged ap |--------|-------------|---------| | `--args` | Command-line arguments to pass to the application. Alternatively, use -- followed by arguments to avoid escaping (e.g., winapp run . -- --flag value). | (none) | | `--clean` | Remove the existing package's application data (LocalState, settings, etc.) before re-deploying. By default, application data is preserved across re-deployments. | (none) | -| `--debug-output` | Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. Cannot be combined with --no-launch or --json. | (none) | +| `--debug-output` | Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. For WinUI apps, a crash also triggers a stowed-exception triage pass; the first run downloads debugger components (cached under the winapp global directory) and can be pointed at an existing debugger install via the WINAPP_DBGTOOLS_DIR environment variable. Cannot be combined with --no-launch or --json. | (none) | | `--detach` | Launch the application and return immediately without waiting for it to exit. Useful for CI/automation where you need to interact with the app after launch. Prints the PID to stdout (or in JSON with --json). | (none) | | `--executable` | Path to the executable relative to the input folder. Use to disambiguate when the manifest contains a $targetnametoken$ placeholder and multiple .exe files are present in the input folder. | (none) | | `--json` | Format output as JSON | (none) | | `--manifest` | Path to the Package.appxmanifest (default: auto-detect from input folder or current directory) | (none) | | `--no-launch` | Only create the debug identity and register the package without launching the application | (none) | | `--output-appx-directory` | Output directory for the loose layout package. If not specified, a directory named AppX inside the input-folder directory will be used. | (none) | -| `--symbols` | Download symbols from Microsoft Symbol Server for richer native crash analysis. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache. | (none) | +| `--symbols` | Download symbols from Microsoft Symbol Server for richer native crash analysis, including the WinUI stowed-exception dispatch stack. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache. | (none) | | `--unregister-on-exit` | Unregister the development package after the application exits. Only removes packages registered in development mode. | (none) | | `--with-alias` | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | (none) | diff --git a/docs/cli-schema.json b/docs/cli-schema.json index f18bc2c1..244ea41d 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -1379,7 +1379,7 @@ "recursive": false }, "--debug-output": { - "description": "Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. Cannot be combined with --no-launch or --json.", + "description": "Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. For WinUI apps, a crash also triggers a stowed-exception triage pass; the first run downloads debugger components (cached under the winapp global directory) and can be pointed at an existing debugger install via the WINAPP_DBGTOOLS_DIR environment variable. Cannot be combined with --no-launch or --json.", "hidden": false, "valueType": "System.Boolean", "hasDefaultValue": true, @@ -1486,7 +1486,7 @@ "recursive": false }, "--symbols": { - "description": "Download symbols from Microsoft Symbol Server for richer native crash analysis. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache.", + "description": "Download symbols from Microsoft Symbol Server for richer native crash analysis, including the WinUI stowed-exception dispatch stack. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache.", "hidden": false, "valueType": "System.Boolean", "hasDefaultValue": true, diff --git a/docs/debugging.md b/docs/debugging.md index e5e6a46b..adc7e761 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -129,7 +129,7 @@ Most WinUI crashes start inside a XAML event handler and surface as a **stowed e To make this work, winapp captures the crash dump with the terminating stowed exception's record (and its parameters, which point at the stowed-exception array) while keeping the first-chance thread context, so the standard managed analysis still recovers your original user frame *and* the triage pass can locate the stowed exception. -This pass hosts DbgEng with the WinUI team's WinDbg JavaScript extension. The native debugging engine (`dbgeng.dll` and friends) comes from NuGet, and `JsProvider.dll` — the JavaScript scripting host, which is **not** on NuGet — is fetched on first use directly from the official WinDbg download (only the few hundred kilobytes needed are read, not the full package). Everything is cached under the winapp global directory, so subsequent runs are offline. If your environment blocks those downloads, install **Debugging Tools for Windows** (via the Windows SDK) or set the `WINAPP_DBGTOOLS_DIR` environment variable to a debugger directory that already contains `dbgeng.dll` and `JsProvider.dll`. When the binaries can't be obtained, the triage pass is skipped (the standard managed/native analysis still runs) and the log explains why. +This pass hosts DbgEng with the WinUI team's WinDbg JavaScript extension. The native debugging engine (`dbgeng.dll` and friends) comes from NuGet, and `JsProvider.dll` — the JavaScript scripting host, which is **not** on NuGet — is fetched on first use directly from the official WinDbg download (only the few hundred kilobytes needed are read, not the full package). The debugger packages are version-pinned and, before any of their native DLLs are extracted and loaded, verified against a compiled-in SHA-512 content hash (and the extension against a pinned hash); `JsProvider.dll` is additionally required to carry a valid Microsoft Authenticode signature, checked with full certificate-chain revocation (falling back to a signature-only check when revocation data can't be reached offline, but always rejecting a revoked certificate). Downloads are staged to a temporary file, verified, then atomically published into the cache, so a concurrent run never observes a partially-written or unverified DLL. Together this means a mirrored or compromised feed cannot substitute altered binaries — any failure skips triage rather than loading unverified code. Everything is cached under the winapp global directory, so subsequent runs are offline. If your environment blocks those downloads, install **Debugging Tools for Windows** (via the Windows SDK) or set the `WINAPP_DBGTOOLS_DIR` environment variable to a debugger directory that already contains `dbgeng.dll` and `JsProvider.dll`. When `WINAPP_DBGTOOLS_DIR` is set it is authoritative — only that directory is consulted — so if it's incomplete the log names the specific missing component (`dbgeng.dll` and/or `JsProvider.dll`) rather than suggesting you set a variable that's already set. When triage succeeds, the console surfaces a one-line verdict (the stowed exception's error code/message); when the binaries can't be obtained, the triage pass is skipped (the standard managed/native analysis still runs), the console says so, and the log explains why. The triage pass runs in a short-lived child process. This is required: winapp's main process loads the system `dbghelp.dll` while capturing and analyzing the dump, and the modern engine `dbgeng.dll` cannot bind to that older, already-resident copy — a fresh process gives the engine a clean loader state. Decoding the stowed-exception structures also needs operating-system symbols (`combase.dll`), which `--symbols` downloads from the Microsoft public symbol server; on builds whose symbols aren't published there, the triage pass still identifies the stowed exception but cannot fully expand it. diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs new file mode 100644 index 00000000..5e8ea98b --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AtomicFileTests +{ + private string _tempDir = null!; + + [TestInitialize] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"AtomicFile_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, true); } catch { } + } + } + + [TestMethod] + public async Task WriteAllBytesAsync_WritesContentAndLeavesNoTempFiles() + { + var dest = Path.Combine(_tempDir, "out.bin"); + var bytes = Encoding.UTF8.GetBytes("hello atomic"); + + await AtomicFile.WriteAllBytesAsync(dest, bytes, CancellationToken.None); + + CollectionAssert.AreEqual(bytes, await File.ReadAllBytesAsync(dest)); + Assert.AreEqual(0, Directory.GetFiles(_tempDir, "*.tmp").Length, "No leftover temp files must remain."); + } + + [TestMethod] + public void Copy_OverwritesExistingDestinationAtomically() + { + var source = Path.Combine(_tempDir, "src.bin"); + var dest = Path.Combine(_tempDir, "dst.bin"); + File.WriteAllText(source, "new"); + File.WriteAllText(dest, "old"); + + AtomicFile.Copy(source, dest); + + Assert.AreEqual("new", File.ReadAllText(dest)); + Assert.AreEqual(0, Directory.GetFiles(_tempDir, "*.tmp").Length); + } + + [TestMethod] + public async Task WriteStagedAsync_DoesNotPublishUntilPublishCalled() + { + var dest = Path.Combine(_tempDir, "staged.bin"); + var bytes = Encoding.UTF8.GetBytes("staged content"); + + var staged = await AtomicFile.WriteStagedAsync(dest, bytes, CancellationToken.None); + + Assert.IsTrue(File.Exists(staged), "The staged temp file must exist."); + Assert.IsFalse(File.Exists(dest), "The destination must not exist before Publish is called."); + Assert.AreNotEqual(dest, staged, "The staged path must differ from the final destination."); + + AtomicFile.Publish(staged, dest); + + Assert.IsTrue(File.Exists(dest), "After Publish, the destination must exist."); + Assert.IsFalse(File.Exists(staged), "After Publish, the staged temp file must be gone (moved)."); + CollectionAssert.AreEqual(bytes, await File.ReadAllBytesAsync(dest)); + } + + [TestMethod] + public async Task DiscardStaged_RemovesStagedFileAndLeavesDestinationAbsent() + { + var dest = Path.Combine(_tempDir, "discard.bin"); + var staged = await AtomicFile.WriteStagedAsync(dest, [1, 2, 3], CancellationToken.None); + + AtomicFile.DiscardStaged(staged); + + Assert.IsFalse(File.Exists(staged), "The staged temp file must be deleted."); + Assert.IsFalse(File.Exists(dest), "The destination must never have been created."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AuthenticodeVerifierTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AuthenticodeVerifierTests.cs new file mode 100644 index 00000000..e2a853bf --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AuthenticodeVerifierTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AuthenticodeVerifierTests +{ + private string _tempDir = null!; + + [TestInitialize] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"Authenticode_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, true); } catch { } + } + } + + [TestMethod] + public void IsMicrosoftSubject_MicrosoftOrganization_ReturnsTrue() + { + Assert.IsTrue(AuthenticodeVerifier.IsMicrosoftSubject( + "CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US")); + } + + [TestMethod] + public void IsMicrosoftSubject_MicrosoftCommonName_ReturnsTrue() + { + Assert.IsTrue(AuthenticodeVerifier.IsMicrosoftSubject("CN=Microsoft Corporation")); + } + + [TestMethod] + public void IsMicrosoftSubject_CaseInsensitive_ReturnsTrue() + { + Assert.IsTrue(AuthenticodeVerifier.IsMicrosoftSubject("cn=microsoft corporation, o=microsoft corporation")); + } + + [TestMethod] + public void IsMicrosoftSubject_ThirdParty_ReturnsFalse() + { + Assert.IsFalse(AuthenticodeVerifier.IsMicrosoftSubject( + "CN=Contoso Ltd, O=Contoso Corporation, C=US")); + } + + [TestMethod] + public void IsMicrosoftSubject_LookalikeWithoutMicrosoftMarkers_ReturnsFalse() + { + // "Microsoftish" text that is not an O=Microsoft Corporation or CN=Microsoft* subject. + Assert.IsFalse(AuthenticodeVerifier.IsMicrosoftSubject("O=Not Microsoft-Affiliated Vendor, CN=Acme")); + } + + [TestMethod] + public void IsTrustedMicrosoftSigned_NonexistentFile_ReturnsFalse() + { + var missing = Path.Combine(_tempDir, "does-not-exist.dll"); + + Assert.IsFalse(AuthenticodeVerifier.IsTrustedMicrosoftSigned(missing, NullLogger.Instance), + "A missing file must fail the fail-closed trust gate."); + } + + [TestMethod] + public void IsTrustedMicrosoftSigned_UnsignedFile_ReturnsFalse() + { + var unsigned = Path.Combine(_tempDir, "unsigned.dll"); + File.WriteAllBytes(unsigned, [0x4D, 0x5A, 0x90, 0x00, 0x01, 0x02, 0x03, 0x04]); + + Assert.IsFalse(AuthenticodeVerifier.IsTrustedMicrosoftSigned(unsigned, NullLogger.Instance), + "An unsigned file must not pass the Authenticode trust gate."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs index 088860eb..269e7e8f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/CrashDumpServiceTests.cs @@ -118,6 +118,32 @@ public async Task AnalyzeDumpAsync_InvalidDump_DoesNotRunWinUiTriage() Assert.AreEqual(0, _xamlTriage.AnalyzeCalls.Count, "WinUI triage must not run for an unreadable/non-WinUI dump."); } + [TestMethod] + public async Task RunXamlTriageGuardedAsync_TriageThrows_ReturnsNoneAndDoesNotPropagate() + { + // Fail-open contract: even if the triage service throws (e.g. an internal HttpClient timeout + // surfacing as OperationCanceledException on a slow first-run download), the crash-analysis flow + // must not be derailed — otherwise the already-computed managed crash stack is discarded as + // "Analysis failed." This guards the H1 regression. + _xamlTriage.ThrowOnAnalyze = new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout."); + + var result = await _service.RunXamlTriageGuardedAsync(Path.Combine(_tempDir, "any.dmp"), useSymbols: false); + + Assert.AreEqual(XamlTriageOutcome.None, result.Outcome, "A thrown triage failure must degrade to None, not propagate."); + Assert.AreEqual(1, _xamlTriage.AnalyzeCalls.Count); + } + + [TestMethod] + public async Task RunXamlTriageGuardedAsync_TriageSucceeds_PassesResultThrough() + { + _xamlTriage.FakeResult = XamlTriageResult.Succeeded("full breakdown", "0xc000027b — boom"); + + var result = await _service.RunXamlTriageGuardedAsync(Path.Combine(_tempDir, "any.dmp"), useSymbols: true); + + Assert.AreEqual(XamlTriageOutcome.Succeeded, result.Outcome); + Assert.AreEqual("0xc000027b — boom", result.Verdict); + } + [TestMethod] public void SelectExceptionRecord_StowedWithParameters_UsesStowedRecord() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs index 2d324124..76f13d21 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs @@ -13,11 +13,19 @@ internal class FakeXamlTriageService : IXamlTriageService { public List<(string DumpPath, bool UseSymbols)> AnalyzeCalls { get; } = []; - public string? FakeResult { get; set; } + public XamlTriageResult FakeResult { get; set; } = XamlTriageResult.None; - public Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default) + /// When set, throws this instead of returning a result. + public Exception? ThrowOnAnalyze { get; set; } + + public Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default) { AnalyzeCalls.Add((dumpPath, useSymbols)); + if (ThrowOnAnalyze != null) + { + throw ThrowOnAnalyze; + } + return Task.FromResult(FakeResult); } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs index d4c4d5d2..aafae852 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -13,6 +13,10 @@ public class XamlTriageBinariesTests private string _tempDir = null!; private string? _originalOverride; + // Pass-through verifier for tests that use dummy (unsigned) binary files: resolution logic is under + // test here, not the real Authenticode gate (covered by AuthenticodeVerifierTests + the L4 test). + private static readonly Func AcceptAny = _ => true; + [TestInitialize] public void Setup() { @@ -53,7 +57,7 @@ public void ResolveExisting_FullLayout_ResolvesWithSymSrv() File.WriteAllText(Path.Combine(dir, "symsrv.dll"), ""); Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); - var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance, AcceptAny); Assert.IsNotNull(resolved); Assert.AreEqual(dir, resolved.BinDir); @@ -69,7 +73,7 @@ public void ResolveExisting_JsProviderInWinext_ResolvesWithoutSymSrv() File.WriteAllText(Path.Combine(dir, "winext", "JsProvider.dll"), ""); Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); - var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance, AcceptAny); Assert.IsNotNull(resolved); Assert.IsFalse(resolved.HasSymSrv, "No symsrv.dll present, so HasSymSrv must be false."); @@ -77,6 +81,85 @@ public void ResolveExisting_JsProviderInWinext_ResolvesWithoutSymSrv() "The resolved JsProvider path must point at the winext copy so the child runner can .load it."); } + [TestMethod] + public void DescribeOverrideGap_NoOverride_ReturnsNull() + { + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, null); + + Assert.IsNull(XamlTriageBinaries.DescribeOverrideGap()); + } + + [TestMethod] + public void DescribeOverrideGap_MissingDirectory_ReportsNonexistent() + { + var missing = Path.Combine(_tempDir, "does-not-exist"); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, missing); + + var gap = XamlTriageBinaries.DescribeOverrideGap(); + + Assert.IsNotNull(gap); + StringAssert.Contains(gap, "does not exist"); + StringAssert.Contains(gap, missing); + } + + [TestMethod] + public void DescribeOverrideGap_EmptyDir_ListsBothMissingComponents() + { + var dir = Path.Combine(_tempDir, "override-empty"); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + var gap = XamlTriageBinaries.DescribeOverrideGap(); + + Assert.IsNotNull(gap); + StringAssert.Contains(gap, "dbgeng.dll"); + StringAssert.Contains(gap, "JsProvider.dll"); + } + + [TestMethod] + public void DescribeOverrideGap_EngineOnly_ListsOnlyJsProvider() + { + var dir = Path.Combine(_tempDir, "override-engine-only"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "dbgeng.dll"), ""); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + var gap = XamlTriageBinaries.DescribeOverrideGap(); + + Assert.IsNotNull(gap); + StringAssert.Contains(gap, "JsProvider.dll"); + Assert.IsFalse(gap.Contains("dbgeng.dll"), "dbgeng.dll is present, so it must not be listed as missing."); + } + + [TestMethod] + public void DescribeOverrideGap_FullLayout_ReturnsNull() + { + var dir = Path.Combine(_tempDir, "override-full"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "dbgeng.dll"), ""); + File.WriteAllText(Path.Combine(dir, "JsProvider.dll"), ""); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + Assert.IsNull(XamlTriageBinaries.DescribeOverrideGap(), + "A complete override layout has no gap to describe."); + } + + [TestMethod] + public void ResolveExisting_JsProviderFailsVerification_ReturnsNull() + { + // L4: a full layout on disk whose JsProvider.dll fails Authenticode verification (e.g. it was + // replaced in the cache after download) must be rejected rather than loaded into the debugger. + var dir = Path.Combine(_tempDir, "tampered"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "dbgeng.dll"), ""); + File.WriteAllText(Path.Combine(dir, "JsProvider.dll"), ""); + Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); + + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance, _ => false); + + Assert.IsNull(resolved, "A JsProvider.dll that fails signature verification must not resolve."); + } + [TestMethod] public void ResolveExisting_JsProviderInRoot_PrefersRootPath() { @@ -86,7 +169,7 @@ public void ResolveExisting_JsProviderInRoot_PrefersRootPath() File.WriteAllText(Path.Combine(dir, "JsProvider.dll"), ""); Environment.SetEnvironmentVariable(XamlTriageBinaries.EnvOverride, dir); - var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance); + var resolved = XamlTriageBinaries.ResolveExisting(new DirectoryInfo(_tempDir), NullLogger.Instance, AcceptAny); Assert.IsNotNull(resolved); Assert.AreEqual(Path.Combine(dir, "JsProvider.dll"), resolved.JsProviderPath); @@ -177,6 +260,56 @@ public void DbgPackageVersion_MatchesDirectoryPackagesProps() "XamlTriageBinaries.DbgPackageVersion drifted from the version pinned in Directory.Packages.props."); } + [TestMethod] + public void VerifyPackageHash_MatchingSha512_ReturnsTrue() + { + var bytes = System.Text.Encoding.UTF8.GetBytes("winapp-dbgtools-package-content"); + var expected = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(bytes)); + + Assert.IsTrue(XamlTriageBinaries.VerifyPackageHash(bytes, expected), + "The exact pinned content hash must verify."); + Assert.IsTrue(XamlTriageBinaries.VerifyPackageHash(bytes, expected.ToLowerInvariant()), + "Hash comparison must be case-insensitive so lower-case hex pins also verify."); + } + + [TestMethod] + public void VerifyPackageHash_TamperedContent_ReturnsFalse() + { + var original = System.Text.Encoding.UTF8.GetBytes("winapp-dbgtools-package-content"); + var expected = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(original)); + var tampered = System.Text.Encoding.UTF8.GetBytes("winapp-dbgtools-package-contenX"); + + Assert.IsFalse(XamlTriageBinaries.VerifyPackageHash(tampered, expected), + "A single altered byte must fail the integrity check so mirrored/compromised feeds are rejected."); + } + + [TestMethod] + public void PinnedPackages_Sha512_MatchesRestoredNupkg() + { + // Guards against a mistyped or stale pinned hash: the packages are restore-only PackageReferences, + // so their .nupkg is present in the NuGet global cache. If this can't be located (e.g. a clean + // machine that restored elsewhere), the assertion is inconclusive rather than a false failure. + var cache = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (string.IsNullOrEmpty(cache)) + { + cache = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + } + + foreach (var (package, version, expectedSha) in XamlTriageBinaries.PinnedPackages) + { + var id = package.ToLowerInvariant(); + var nupkg = Path.Combine(cache, id, version, $"{id}.{version}.nupkg"); + if (!File.Exists(nupkg)) + { + Assert.Inconclusive($"Pinned package not found in NuGet cache: {nupkg}"); + } + + var actual = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(File.ReadAllBytes(nupkg))); + Assert.IsTrue(StringComparer.OrdinalIgnoreCase.Equals(expectedSha, actual), + $"Pinned SHA-512 for {package} {version} drifted from the restored .nupkg. Expected {expectedSha}, got {actual.ToLowerInvariant()}. Update the compiled-in hash."); + } + } + private static void WriteCachePackage(DirectoryInfo cache, string package, string version, string file, string content) { var archDir = Path.Combine(cache.FullName, package.ToLowerInvariant(), version, "content", XamlTriageBinaries.NuGetArch); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs index ff61e97c..da9b0e49 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs @@ -126,4 +126,57 @@ private static void AssertPairValue(List args, string flag, string expec Assert.IsTrue(idx >= 0 && idx + 1 < args.Count, $"Expected flag {flag} with a value."); Assert.AreEqual(expected, args[idx + 1]); } + + [TestMethod] + public void ShouldPropagateCancellation_InternalHttpTimeout_DoesNotPropagate() + { + // HttpClient.Timeout surfaces as a TaskCanceledException whose token is unrelated to the + // caller's. Swallowing it lets the already-computed managed crash stack survive (regression: + // an internal first-run download timeout used to discard the ClrMD stack). + using var caller = new CancellationTokenSource(); + var timeout = new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout."); + + Assert.IsFalse( + XamlTriageService.ShouldPropagateCancellation(timeout, caller.Token), + "An internal timeout with a non-cancelled caller token must not propagate out of the fail-open triage pass."); + } + + [TestMethod] + public void ShouldPropagateCancellation_CallerCancelled_Propagates() + { + using var caller = new CancellationTokenSource(); + caller.Cancel(); + var oce = new OperationCanceledException(caller.Token); + + Assert.IsTrue( + XamlTriageService.ShouldPropagateCancellation(oce, caller.Token), + "Genuine caller cancellation must propagate so the run can abort promptly."); + } + + [TestMethod] + public void TryExtractVerdict_CodeAndMessage_ReturnsCompactVerdict() + { + var output = + "Stowed Exception found\n" + + "Error Code: 0x80004005\n" + + "Error Message: The parameter is incorrect.\n" + + "Stack:\n"; + + var verdict = XamlTriageService.TryExtractVerdict(output); + + Assert.AreEqual("0x80004005 — The parameter is incorrect.", verdict); + } + + [TestMethod] + public void TryExtractVerdict_CodeOnly_ReturnsCode() + { + Assert.AreEqual("0xC000027B", XamlTriageService.TryExtractVerdict("HRESULT = 0xC000027B\nsome stack")); + } + + [TestMethod] + public void TryExtractVerdict_NoRecognizableFields_ReturnsNull() + { + Assert.IsNull(XamlTriageService.TryExtractVerdict("just a native stack with no error code")); + Assert.IsNull(XamlTriageService.TryExtractVerdict("")); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs index f87c03e7..df457456 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs @@ -86,7 +86,7 @@ static RunCommand() DebugOutputOption = new Option("--debug-output") { - Description = "Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. Cannot be combined with --no-launch or --json." + Description = "Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. For WinUI apps, a crash also triggers a stowed-exception triage pass; the first run downloads debugger components (cached under the winapp global directory) and can be pointed at an existing debugger install via the WINAPP_DBGTOOLS_DIR environment variable. Cannot be combined with --no-launch or --json." }; UnregisterOnExitOption = new Option("--unregister-on-exit") @@ -106,7 +106,7 @@ static RunCommand() SymbolsOption = new Option("--symbols") { - Description = "Download symbols from Microsoft Symbol Server for richer native crash analysis. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache." + Description = "Download symbols from Microsoft Symbol Server for richer native crash analysis, including the WinUI stowed-exception dispatch stack. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache." }; ExecutableOption = new Option("--executable") diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs b/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs new file mode 100644 index 00000000..9ca8ad6c --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers; + +/// +/// File writes that publish their result atomically: content is first written to a uniquely-named +/// temporary file in the destination directory, then moved into place with a single rename. A +/// concurrent reader (e.g. a second winapp run --debug-output, or the same run re-resolving +/// the debugger cache) therefore only ever observes the final path as either absent or fully written +/// — never partially written or not-yet-verified. +/// +internal static class AtomicFile +{ + /// Writes to atomically. + public static async Task WriteAllBytesAsync(string destinationPath, byte[] bytes, CancellationToken cancellationToken) + { + var tempPath = MakeTempPath(destinationPath); + try + { + await File.WriteAllBytesAsync(tempPath, bytes, cancellationToken); + File.Move(tempPath, destinationPath, overwrite: true); + } + finally + { + TryDeleteLeftoverTemp(tempPath); + } + } + + /// Copies to atomically. + public static void Copy(string sourcePath, string destinationPath) + { + var tempPath = MakeTempPath(destinationPath); + try + { + File.Copy(sourcePath, tempPath, overwrite: true); + File.Move(tempPath, destinationPath, overwrite: true); + } + finally + { + TryDeleteLeftoverTemp(tempPath); + } + } + + /// + /// Writes to a temporary file in the destination directory and returns its + /// path without publishing it, so the caller can validate the content (e.g. verify an Authenticode + /// signature) before calling to move it into place. + /// + public static async Task WriteStagedAsync(string destinationPath, byte[] bytes, CancellationToken cancellationToken) + { + var tempPath = MakeTempPath(destinationPath); + await File.WriteAllBytesAsync(tempPath, bytes, cancellationToken); + return tempPath; + } + + /// Atomically moves a staged temp file (from ) into place. + public static void Publish(string stagedPath, string destinationPath) => + File.Move(stagedPath, destinationPath, overwrite: true); + + /// Deletes a staged temp file that will not be published. Best effort. + public static void DiscardStaged(string stagedPath) => TryDeleteLeftoverTemp(stagedPath); + + private static string MakeTempPath(string destinationPath) => + destinationPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + private static void TryDeleteLeftoverTemp(string tempPath) + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Best effort — a leftover .tmp is harmless and will be overwritten or ignored. + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs b/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs index 4983ed7e..dd02aab8 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs @@ -21,10 +21,19 @@ internal static unsafe partial class AuthenticodeVerifier private const uint WTD_UI_NONE = 2; private const uint WTD_REVOKE_NONE = 0; + private const uint WTD_REVOKE_WHOLECHAIN = 1; private const uint WTD_CHOICE_FILE = 1; private const uint WTD_STATEACTION_VERIFY = 1; private const uint WTD_STATEACTION_CLOSE = 2; private const uint WTD_REVOCATION_CHECK_NONE = 0x00000010; + private const uint WTD_CACHE_ONLY_URL_RETRIEVAL = 0x00001000; + + // HRESULTs distinguishing "certificate is revoked" (hard fail) from "revocation data unavailable" + // (soft fail — acceptable offline, where only the signature itself can be checked). + private const int CERT_E_REVOKED = unchecked((int)0x800B010C); + private const int CERT_E_REVOCATION_FAILURE = unchecked((int)0x800B010E); + private const int CRYPT_E_REVOCATION_OFFLINE = unchecked((int)0x80092013); + private const int CRYPT_E_NO_REVOCATION_CHECK = unchecked((int)0x80092012); /// /// Returns true only when has a valid Authenticode signature @@ -41,7 +50,7 @@ public static bool IsTrustedMicrosoftSigned(string filePath, ILogger logger) return false; } - if (!VerifyTrust(filePath)) + if (!VerifyTrust(filePath, logger)) { logger.LogDebug("Authenticode trust verification failed for {File}.", filePath); return false; @@ -62,7 +71,34 @@ public static bool IsTrustedMicrosoftSigned(string filePath, ILogger logger) } } - private static bool VerifyTrust(string filePath) + private static bool VerifyTrust(string filePath, ILogger logger) + { + // Prefer full-chain revocation using locally cached CRLs only (no network fetch, so a + // locked-down/offline environment does not hang). A definitively revoked certificate is a hard + // failure; when revocation data simply cannot be obtained offline, fall back to a signature-only + // check so the gate still works — the signature and Microsoft-signer checks remain in force. + var hr = VerifyTrustCore(filePath, WTD_REVOKE_WHOLECHAIN, WTD_CACHE_ONLY_URL_RETRIEVAL); + if (hr == 0) + { + return true; + } + + if (hr is CERT_E_REVOKED) + { + logger.LogDebug("Authenticode certificate for {File} is revoked.", filePath); + return false; + } + + if (hr is CERT_E_REVOCATION_FAILURE or CRYPT_E_REVOCATION_OFFLINE or CRYPT_E_NO_REVOCATION_CHECK) + { + logger.LogDebug("Revocation data unavailable for {File} (0x{Hr:X8}); falling back to signature-only verification.", filePath, hr); + return VerifyTrustCore(filePath, WTD_REVOKE_NONE, WTD_REVOCATION_CHECK_NONE) == 0; + } + + return false; + } + + private static int VerifyTrustCore(string filePath, uint revocationChecks, uint provFlags) { var pPath = Marshal.StringToHGlobalUni(filePath); try @@ -79,11 +115,11 @@ private static bool VerifyTrust(string filePath) { cbStruct = (uint)sizeof(WINTRUST_DATA), dwUIChoice = WTD_UI_NONE, - fdwRevocationChecks = WTD_REVOKE_NONE, + fdwRevocationChecks = revocationChecks, dwUnionChoice = WTD_CHOICE_FILE, pInfo = (IntPtr)(&fileInfo), dwStateAction = WTD_STATEACTION_VERIFY, - dwProvFlags = WTD_REVOCATION_CHECK_NONE, + dwProvFlags = provFlags, }; var action = GenericVerifyV2; @@ -93,7 +129,7 @@ private static bool VerifyTrust(string filePath) data.dwStateAction = WTD_STATEACTION_CLOSE; WinVerifyTrust(IntPtr.Zero, ref action, ref data); - return result == 0; + return result; } finally { @@ -109,10 +145,17 @@ private static bool IsMicrosoftSigner(string filePath) #pragma warning disable SYSLIB0057 var subject = X509Certificate.CreateFromSignedFile(filePath).Subject; #pragma warning restore SYSLIB0057 - return subject.Contains("O=Microsoft Corporation", StringComparison.OrdinalIgnoreCase) - || subject.Contains("CN=Microsoft", StringComparison.OrdinalIgnoreCase); + return IsMicrosoftSubject(subject); } + /// + /// Returns true when an X.509 subject distinguished name identifies Microsoft as the signer. + /// Extracted for unit testing the signer-identity gate independently of the native trust check. + /// + internal static bool IsMicrosoftSubject(string subject) => + subject.Contains("O=Microsoft Corporation", StringComparison.OrdinalIgnoreCase) + || subject.Contains("CN=Microsoft", StringComparison.OrdinalIgnoreCase); + [StructLayout(LayoutKind.Sequential)] private struct WINTRUST_FILE_INFO { diff --git a/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs b/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs index eb1e6725..65d4a42e 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/CrashDumpService.cs @@ -194,12 +194,14 @@ public async Task AnalyzeDumpAsync(string dumpPath, string logPath, bool useSymb // module list. Recovers the stowed exception (0xC000027B) and the XAML dispatch // chain that the ClrMD/DbgEng passes alone cannot surface. string? xamlTriage = null; + XamlTriageResult? xamlTriageResult = null; if (isWinUi) { console.MarkupLine(useSymbols ? "[dim]Running WinUI stowed-exception triage (first run may download debugger components and symbols; this can take a few minutes)...[/]" : "[dim]Running WinUI stowed-exception triage (first run may download debugger components)...[/]"); - xamlTriage = await xamlTriageService.TryAnalyzeAsync(dumpPath, useSymbols); + xamlTriageResult = await RunXamlTriageGuardedAsync(dumpPath, useSymbols); + xamlTriage = xamlTriageResult.LogText; } // ClrMD found managed exception — no need for native fallback @@ -229,10 +231,7 @@ await File.AppendAllTextAsync(logPath, console.MarkupLine("[red][[CRASH ANALYSIS]][/]"); console.WriteLine(summary); console.MarkupLine("[red]=====================================[/]"); - if (!string.IsNullOrWhiteSpace(xamlTriage)) - { - console.MarkupLine("[dim]WinUI stowed-exception triage written to the debug log.[/]"); - } + WriteXamlTriageConsole(xamlTriageResult); console.MarkupLine($"[dim]Crash dump:[/] {dumpPath.EscapeMarkup()}"); console.MarkupLine($"[dim]Full debug log:[/] {logPath.EscapeMarkup()}"); @@ -281,10 +280,7 @@ await File.AppendAllTextAsync(logPath, console.MarkupLine("[red]=====================================[/]"); } - if (!string.IsNullOrWhiteSpace(xamlTriage)) - { - console.MarkupLine("[dim]WinUI stowed-exception triage written to the debug log.[/]"); - } + WriteXamlTriageConsole(xamlTriageResult); console.MarkupLine($"[dim]Crash dump:[/] {dumpPath.EscapeMarkup()}"); console.MarkupLine($"[dim]Full debug log:[/] {logPath.EscapeMarkup()}"); @@ -302,6 +298,59 @@ await File.AppendAllTextAsync(logPath, } } + /// + /// Invokes the WinUI triage service, guaranteeing the crash-analysis flow is never derailed by a + /// triage failure. is contracted to fail open, but + /// this local guard is defense-in-depth: any escaping exception (e.g. an internal HttpClient + /// timeout surfacing as on a slow first-run download) is + /// logged and swallowed so the already-computed managed/native crash stack is still emitted rather + /// than discarded by the outer handler as "Analysis failed." + /// + internal async Task RunXamlTriageGuardedAsync(string dumpPath, bool useSymbols) + { + try + { + return await xamlTriageService.TryAnalyzeAsync(dumpPath, useSymbols); + } + catch (Exception ex) + { + logger.LogWarning(ex, "WinUI triage pass failed; continuing with the standard crash analysis."); + return XamlTriageResult.None; + } + } + + /// + /// Surfaces an accurate WinUI-triage line in the console based on the outcome: the headline verdict + /// (or a "written to the debug log" pointer) on success, a distinct "skipped" note when tooling was + /// unavailable, and nothing when triage was not applicable. Avoids the previous behavior of always + /// claiming triage was written to the log even when it was skipped. + /// + private void WriteXamlTriageConsole(XamlTriageResult? result) + { + if (result == null) + { + return; + } + + switch (result.Outcome) + { + case XamlTriageOutcome.Succeeded: + if (!string.IsNullOrWhiteSpace(result.Verdict)) + { + console.MarkupLine($"[yellow]WinUI stowed exception:[/] {result.Verdict.EscapeMarkup()}"); + } + + console.MarkupLine("[dim]WinUI stowed-exception triage written to the debug log.[/]"); + break; + case XamlTriageOutcome.Skipped: + console.MarkupLine("[dim]WinUI stowed-exception triage was skipped (see the debug log for details).[/]"); + break; + case XamlTriageOutcome.None: + default: + break; + } + } + private (string Summary, string Details, bool IsWinUi) AnalyzeWithClrMD(string dumpPath, IReadOnlyList? symbolSearchPaths) { using var dt = DataTarget.LoadDump(dumpPath); diff --git a/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs index 5e4634c9..180f91be 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs @@ -17,13 +17,14 @@ namespace WinApp.Cli.Services; internal interface IXamlTriageService { /// - /// Runs the WinUI triage pass against a dump and returns formatted output suitable - /// for appending to the debug log, or null when triage is not applicable. + /// Runs the WinUI triage pass against a dump and returns a structured result: a real + /// stowed-exception breakdown, a graceful skip (with an explanatory note for the log), or + /// nothing when triage was not applicable. /// /// /// This method never throws for missing tooling or unsupported scenarios — it - /// degrades gracefully, logging diagnostics and returning either an explanatory - /// note (so the absence is recorded in the log) or null. + /// degrades gracefully, logging diagnostics and returning a + /// note or . /// /// Path to the minidump file (must contain Microsoft.UI.Xaml). /// @@ -31,6 +32,6 @@ internal interface IXamlTriageService /// full native dispatch chain resolves to function names. First run downloads symbols. /// /// Cancellation token. - /// Formatted triage text for the log, or null when nothing was produced. - Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default); + /// A describing the outcome. + Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs index 6528ba13..8344971c 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs @@ -63,18 +63,24 @@ public static async Task TryAcquireAsync(DirectoryInfo destDir, ILogger lo Directory.CreateDirectory(destDir.FullName); var targetPath = Path.Combine(destDir.FullName, TargetFileName); - await File.WriteAllBytesAsync(targetPath, bytes, cancellationToken); + + // Stage to a temp file, verify it, then atomically publish. Writing the 23 MB DLL directly + // to its final path would let a concurrent run (or this run's later ResolveExisting) observe + // and .load a partially-written or not-yet-verified DLL. + var stagedPath = await AtomicFile.WriteStagedAsync(targetPath, bytes, cancellationToken); // Defense-in-depth: this DLL is loaded into the debugger process, so verify it carries a // valid Authenticode signature from Microsoft before trusting it (the download is HTTPS // from an official host, but this guards against tampering / a compromised mirror). - if (!AuthenticodeVerifier.IsTrustedMicrosoftSigned(targetPath, logger)) + if (!AuthenticodeVerifier.IsTrustedMicrosoftSigned(stagedPath, logger)) { logger.LogDebug("Discarding {File}: it is not validly signed by Microsoft.", TargetFileName); - try { File.Delete(targetPath); } catch { /* best effort */ } + AtomicFile.DiscardStaged(stagedPath); return false; } + AtomicFile.Publish(stagedPath, targetPath); + logger.LogDebug("Acquired {File} ({Size} bytes) from WinDbg bundle into {Dir}.", TargetFileName, bytes.Length, destDir.FullName); return true; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs index efaa39b9..eff96454 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging; using System.IO.Compression; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text.Json; +using WinApp.Cli.Helpers; namespace WinApp.Cli.Services; @@ -63,15 +65,31 @@ internal static class XamlTriageBinaries /// public const string DbgPackageVersion = "20260319.1511.0"; + // Expected SHA-512 (hex) of each pinned .nupkg, matching NuGet's own `.nupkg.sha512` for the + // pinned version. Compiled in so the integrity check does not trust the same feed the package is + // fetched from: a mirrored/compromised flat-container feed cannot serve altered native DLLs that + // would then be loaded into the debugger process. Regenerate if DbgPackageVersion changes. + private const string DbgEngPackageSha512 = + "54cf706d6d49151f1b28d5c2eb9bfe2d989ddf461965b03c380409f0ad4e3b8628aedaabdf351b53d80c0cefe4a3dbc45e9d3efa233a86866923d41e062e8d70"; + private const string SymSrvPackageSha512 = + "61dea5162daacf8c9bb601c67258add2f806c34781b4feceb90c1cfe214870f0454761e6e9102c5f294ca44c31c9512604b9e20a64242ecc0807b1383d128ab0"; + // Native engine bits available from NuGet. DbgEng ships the full engine layout (including // dbgmodel.dll and msdia140.dll), so no separate DbgX package is required. JsProvider.dll is // intentionally absent here — it is not on NuGet and is acquired from the WinDbg bundle instead. - private static readonly (string Package, string Version, string[] Files)[] NuGetComponents = + private static readonly (string Package, string Version, string Sha512, string[] Files)[] NuGetComponents = [ - ("Microsoft.Debugging.Platform.DbgEng", DbgPackageVersion, ["dbgeng.dll", "dbghelp.dll", "dbgcore.dll", "dbgmodel.dll", "msdia140.dll"]), - ("Microsoft.Debugging.Platform.SymSrv", DbgPackageVersion, ["symsrv.dll"]), + ("Microsoft.Debugging.Platform.DbgEng", DbgPackageVersion, DbgEngPackageSha512, ["dbgeng.dll", "dbghelp.dll", "dbgcore.dll", "dbgmodel.dll", "msdia140.dll"]), + ("Microsoft.Debugging.Platform.SymSrv", DbgPackageVersion, SymSrvPackageSha512, ["symsrv.dll"]), ]; + /// + /// The pinned NuGet debugger packages and their expected .nupkg SHA-512 (hex). Exposed for a + /// drift test that verifies the compiled-in hashes still match the restored packages. + /// + internal static IReadOnlyList<(string Package, string Version, string Sha512)> PinnedPackages => + NuGetComponents.Select(c => (c.Package, c.Version, c.Sha512)).ToList(); + /// Folder token used by the Windows Kits Debuggers layout for the host arch. public static string KitsArch => RuntimeInformation.ProcessArchitecture switch { @@ -93,17 +111,38 @@ private static readonly (string Package, string Version, string[] Files)[] NuGet /// /// Resolves an existing directory that contains both dbgeng.dll and /// JsProvider.dll for the host architecture, or null when none is found. + /// + /// The resolved JsProvider.dll is re-verified as validly Microsoft-signed on every resolve, + /// not only at download time: it is loaded into the debugger process, and a cache-hit would + /// otherwise trust a copy that could have been replaced on disk since it was acquired. + /// + /// + public static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger) => + ResolveExisting(cacheBinDir, logger, path => AuthenticodeVerifier.IsTrustedMicrosoftSigned(path, logger)); + + /// + /// Testable core of with an injectable + /// so unit tests can exercise resolution without requiring a + /// real Authenticode-signed JsProvider.dll. /// - public static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger) + internal static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger, Func jsProviderVerifier) { foreach (var (dir, source) in CandidateDirectories(cacheBinDir)) { var resolved = TryDirectory(dir, source); - if (resolved != null) + if (resolved == null) { - logger.LogDebug("Resolved WinUI triage debugging binaries from {Source}: {Dir}", source, dir); - return resolved; + continue; + } + + if (!jsProviderVerifier(resolved.JsProviderPath)) + { + logger.LogDebug("Rejecting WinUI triage binaries from {Source}: {Path} is not validly Microsoft-signed.", source, resolved.JsProviderPath); + continue; } + + logger.LogDebug("Resolved WinUI triage debugging binaries from {Source}: {Dir}", source, dir); + return resolved; } return null; @@ -179,11 +218,57 @@ private static IEnumerable InstalledDebuggerRoots() public static bool HasEngine(DirectoryInfo cacheBinDir) => File.Exists(Path.Combine(cacheBinDir.FullName, "dbgeng.dll")); + /// + /// When is set but the override directory is not a usable debugger + /// layout, returns a human-readable description of the override directory and which required + /// component(s) are missing. Returns null when no override is configured. + /// + public static string? DescribeOverrideGap() + { + if (!IsEnvOverrideSet) + { + return null; + } + + var overrideDir = Environment.GetEnvironmentVariable(EnvOverride)!; + if (!Directory.Exists(overrideDir)) + { + return $"the {EnvOverride} override directory '{overrideDir}' does not exist"; + } + + var missing = new List(); + if (!File.Exists(Path.Combine(overrideDir, "dbgeng.dll"))) + { + missing.Add("dbgeng.dll"); + } + + var hasJsProvider = File.Exists(Path.Combine(overrideDir, "JsProvider.dll")) + || File.Exists(Path.Combine(overrideDir, "winext", "JsProvider.dll")); + if (!hasJsProvider) + { + missing.Add("JsProvider.dll"); + } + + if (missing.Count == 0) + { + return null; + } + + return $"the {EnvOverride} override directory '{overrideDir}' is missing {string.Join(" and ", missing)}"; + } + /// /// Best-effort population of the cache directory with the NuGet-available native debugging bits. /// Prefers copying from the NuGet global packages cache (populated by dotnet restore) and /// falls back to downloading the flat-container .nupkg on first use. Does not acquire /// JsProvider.dll. Returns the number of component packages successfully materialized. + /// + /// This intentionally does not delegate to INugetService: that path performs no + /// package-integrity verification, whereas the DLLs materialized here are loaded into the debugger + /// process and are therefore version-pinned and checked against a compiled-in SHA-512 content hash + /// (see ) before extraction. Reusing the general downloader would + /// silently drop that guarantee, so the bespoke download is deliberate. + /// /// public static async Task TryAcquireFromNuGetAsync( DirectoryInfo cacheBinDir, DirectoryInfo? nugetCacheDir, ILogger logger, CancellationToken cancellationToken) @@ -192,7 +277,7 @@ public static async Task TryAcquireFromNuGetAsync( using var http = new HttpClient(); var acquired = 0; - foreach (var (package, version, files) in NuGetComponents) + foreach (var (package, version, sha512, files) in NuGetComponents) { try { @@ -208,7 +293,7 @@ public static async Task TryAcquireFromNuGetAsync( continue; } - if (await TryMaterializePackageAsync(http, package, version, files, cacheBinDir, logger, cancellationToken)) + if (await TryMaterializePackageAsync(http, package, version, sha512, files, cacheBinDir, logger, cancellationToken)) { acquired++; } @@ -256,7 +341,7 @@ internal static bool TryCopyFromGlobalCache( foreach (var file in files) { - File.Copy(Path.Combine(archDir, file), Path.Combine(cacheBinDir.FullName, file), overwrite: true); + AtomicFile.Copy(Path.Combine(archDir, file), Path.Combine(cacheBinDir.FullName, file)); } if (!versionDir.Name.Equals(pinnedVersion, StringComparison.OrdinalIgnoreCase)) @@ -273,7 +358,7 @@ internal static bool TryCopyFromGlobalCache( } private static async Task TryMaterializePackageAsync( - HttpClient http, string package, string pinnedVersion, string[] files, DirectoryInfo cacheBinDir, ILogger logger, CancellationToken cancellationToken) + HttpClient http, string package, string pinnedVersion, string expectedSha512, string[] files, DirectoryInfo cacheBinDir, ILogger logger, CancellationToken cancellationToken) { var id = package.ToLowerInvariant(); var version = await ResolveDownloadVersionAsync(http, id, pinnedVersion, logger, cancellationToken); @@ -282,7 +367,16 @@ private static async Task TryMaterializePackageAsync( return false; } - // Download and extract the .nupkg (a zip archive) to a temp directory. + // Integrity is anchored to the pinned version's compiled-in content hash. We only have a hash + // for the pinned version, so refuse to download (and later load native code from) any other + // version rather than extracting unverified bits into the debugger process. + if (!string.Equals(version, pinnedVersion, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Skipping {Id} {Version}: only pinned version {Pinned} has a verified content hash.", id, version, pinnedVersion); + return false; + } + + // Download the whole .nupkg into memory so its hash can be verified before anything is extracted. var nupkgUrl = $"{FlatContainer}/{id}/{version}/{id}.{version}.nupkg"; using var nupkgResponse = await http.GetAsync(nupkgUrl, cancellationToken); if (!nupkgResponse.IsSuccessStatusCode) @@ -290,11 +384,18 @@ private static async Task TryMaterializePackageAsync( return false; } + var nupkgBytes = await nupkgResponse.Content.ReadAsByteArrayAsync(cancellationToken); + if (!VerifyPackageHash(nupkgBytes, expectedSha512)) + { + logger.LogWarning("Refusing {Id} {Version}: downloaded package hash did not match the pinned value; the feed may be compromised or mirrored.", id, version); + return false; + } + var tempPkgDir = Path.Combine(Path.GetTempPath(), $"winapp-dbgtools-{id}-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPkgDir); try { - await using (var nupkgStream = await nupkgResponse.Content.ReadAsStreamAsync(cancellationToken)) + using (var nupkgStream = new MemoryStream(nupkgBytes, writable: false)) using (var archive = new ZipArchive(nupkgStream, ZipArchiveMode.Read)) { archive.ExtractToDirectory(tempPkgDir, overwriteFiles: true); @@ -306,7 +407,7 @@ private static async Task TryMaterializePackageAsync( var source = FindBestArchMatch(tempPkgDir, file); if (source != null) { - File.Copy(source, Path.Combine(cacheBinDir.FullName, file), overwrite: true); + AtomicFile.Copy(source, Path.Combine(cacheBinDir.FullName, file)); copied++; } } @@ -325,8 +426,9 @@ private static async Task TryMaterializePackageAsync( } /// - /// Resolves the version to download: the pinned version when the flat-container index lists it - /// (the deterministic, expected path), otherwise the latest stable version as a graceful fallback. + /// Confirms the flat-container index lists the pinned version. Only the pinned version can be + /// integrity-verified (its content hash is compiled in), so any other version is rejected rather + /// than downloaded — there is deliberately no "latest" fallback for native code the debugger loads. /// private static async Task ResolveDownloadVersionAsync( HttpClient http, string id, string pinnedVersion, ILogger logger, CancellationToken cancellationToken) @@ -349,14 +451,20 @@ private static async Task TryMaterializePackageAsync( return pinnedVersion; } - var latest = versions.LastOrDefault(v => !v!.Contains('-', StringComparison.Ordinal)); - if (latest != null) - { - logger.LogDebug("Pinned version {Pinned} of {Id} not available on the feed; downloading {Latest} instead.", - pinnedVersion, id, latest); - } + logger.LogDebug("Pinned version {Pinned} of {Id} is not available on the feed; skipping download.", pinnedVersion, id); + return null; + } - return latest; + /// + /// Verifies a downloaded .nupkg's SHA-512 against the compiled-in pinned hash before any of + /// its native DLLs are extracted and loaded into the debugger process. The comparison is + /// case-insensitive hex and does not consult the feed, so a mirrored or compromised flat-container + /// cannot substitute altered content. Returns false on any mismatch (fail closed). + /// + internal static bool VerifyPackageHash(byte[] nupkgBytes, string expectedSha512Hex) + { + var actual = Convert.ToHexString(SHA512.HashData(nupkgBytes)); + return string.Equals(actual, expectedSha512Hex, StringComparison.OrdinalIgnoreCase); } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageResult.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageResult.cs new file mode 100644 index 00000000..bd6e500f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageResult.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// Outcome of a WinUI triage pass. +internal enum XamlTriageOutcome +{ + /// Triage was not run or produced nothing to record (e.g. the pass threw and failed open). + None, + + /// + /// Triage tooling was unavailable or the pass could not produce a breakdown; an explanatory note + /// is recorded in the log, but no actual triage output was produced. + /// + Skipped, + + /// Triage produced a stowed-exception / dispatch-chain breakdown that was written to the log. + Succeeded, +} + +/// +/// Structured result of . Distinguishes a real triage +/// breakdown from a graceful skip so the console can surface an accurate verdict instead of always +/// claiming triage was "written to the debug log". +/// +/// Whether triage succeeded, was skipped, or produced nothing. +/// +/// Text to append to the debug log (the full breakdown for , +/// or the skip explanation for ). null for +/// . +/// +/// +/// Optional one-line headline (e.g. error code + message) surfaced in the console on success. null +/// when no concise verdict could be extracted. +/// +internal sealed record XamlTriageResult(XamlTriageOutcome Outcome, string? LogText, string? Verdict) +{ + /// A successful triage breakdown with optional one-line verdict for the console. + public static XamlTriageResult Succeeded(string logText, string? verdict) => + new(XamlTriageOutcome.Succeeded, logText, verdict); + + /// A graceful skip whose explanatory note is still recorded in the log. + public static XamlTriageResult Skipped(string logText) => + new(XamlTriageOutcome.Skipped, logText, null); + + /// Nothing to record (triage not applicable or failed open). + public static XamlTriageResult None { get; } = new(XamlTriageOutcome.None, null, null); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs index 724663df..c5cd9b14 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -14,7 +14,7 @@ namespace WinApp.Cli.Services; /// directly, producing the stowed-exception breakdown and XAML dispatch triage that the /// standard ClrMD/DbgEng passes cannot recover. /// -internal sealed class XamlTriageService( +internal sealed partial class XamlTriageService( ILogger logger, IWinappDirectoryService winappDirectoryService, INugetService nugetService) : IXamlTriageService @@ -29,7 +29,7 @@ internal sealed class XamlTriageService( private static readonly TimeSpan TriageTimeout = TimeSpan.FromMinutes(5); /// - public async Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default) + public async Task TryAnalyzeAsync(string dumpPath, bool useSymbols, CancellationToken cancellationToken = default) { try { @@ -60,15 +60,16 @@ internal sealed class XamlTriageService( if (binaries == null) { logger.LogDebug("WinUI triage skipped: debugging binaries (incl. JsProvider.dll) unavailable."); - return UnavailableNote(); + return XamlTriageResult.Skipped(UnavailableNote()); } var extPath = await EnsureExtensionAsync(dbgToolsRoot, cancellationToken); if (extPath == null) { logger.LogDebug("WinUI triage skipped: could not obtain {Ext}.", ExtFileName); - return $"WinUI Triage: skipped — the WinUI debugger extension ({ExtFileName}) could not be " + - "obtained (download blocked or its pinned hash did not match)."; + return XamlTriageResult.Skipped( + $"WinUI Triage: skipped — the WinUI debugger extension ({ExtFileName}) could not be " + + "obtained (download blocked or its pinned hash did not match)."); } // Run the DbgEng pass in a dedicated child process. The parent has already loaded the @@ -77,12 +78,12 @@ internal sealed class XamlTriageService( var (output, skipNote) = await RunTriageProcessAsync(dumpPath, binaries, extPath, useSymbols, cancellationToken); if (skipNote != null) { - return $"WinUI Triage: skipped — {skipNote}"; + return XamlTriageResult.Skipped($"WinUI Triage: skipped — {skipNote}"); } if (string.IsNullOrWhiteSpace(output)) { - return "WinUI Triage: skipped — the triage pass produced no output."; + return XamlTriageResult.Skipped("WinUI Triage: skipped — the triage pass produced no output."); } var header = $"WinUI Triage (DbgEng + winui-dbgext.js, source: {binaries.Source}):"; @@ -97,21 +98,94 @@ internal sealed class XamlTriageService( var gapNote = DescribeSymbolGap(output, useSymbols); var notes = string.Join("\n", new[] { symbolNote, gapNote }.Where(n => n != null)); - return notes.Length == 0 + var logText = notes.Length == 0 ? $"{header}\n{output.Trim()}" : $"{header}\n{notes}\n\n{output.Trim()}"; + return XamlTriageResult.Succeeded(logText, TryExtractVerdict(output)); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) when (ShouldPropagateCancellation(ex, cancellationToken)) { + // Genuine caller cancellation propagates; internal HttpClient.Timeout cancellations fall + // through to the fail-open handler below so the already-computed managed stack survives. throw; } catch (Exception ex) { logger.LogWarning(ex, "WinUI triage pass failed."); + return XamlTriageResult.None; + } + } + + /// + /// Decides whether an from the triage pipeline represents + /// genuine caller cancellation (rethrow) versus an internal timeout + /// (swallow, so the already-computed managed crash stack is preserved by the caller). Only the + /// caller's own token being cancelled counts as genuine cancellation — internal download timeouts + /// surface as with an unrelated token and must not propagate. + /// + internal static bool ShouldPropagateCancellation(OperationCanceledException ex, CancellationToken callerToken) + { + _ = ex; + return callerToken.IsCancellationRequested; + } + + /// + /// Best-effort extraction of a concise one-line verdict (error code and/or message) from the raw + /// extension output, so the console can show the headline finding instead of only pointing at the + /// log. Returns null when no recognizable error code/message line is present. + /// + internal static string? TryExtractVerdict(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + string? code = null; + string? message = null; + foreach (var raw in output.Split('\n')) + { + var line = raw.Trim(); + if (code == null) + { + var m = ErrorCodeRegex().Match(line); + if (m.Success) + { + code = m.Groups[1].Value.Trim(); + } + } + + if (message == null) + { + var m = ErrorMessageRegex().Match(line); + if (m.Success && m.Groups[1].Value.Trim().Length > 0) + { + message = m.Groups[1].Value.Trim(); + } + } + + if (code != null && message != null) + { + break; + } + } + + if (code == null && message == null) + { return null; } + + return string.Join(" — ", new[] { code, message }.Where(p => !string.IsNullOrEmpty(p))); } + [System.Text.RegularExpressions.GeneratedRegex(@"(?:error\s*code|hresult)\s*[:=]\s*(0x[0-9a-fA-F]+)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex ErrorCodeRegex(); + + [System.Text.RegularExpressions.GeneratedRegex(@"error\s*(?:message|text)\s*[:=]\s*(.+)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex ErrorMessageRegex(); + /// /// Detects the "operating-system symbols unavailable" failure shape — the extension identifies the /// stowed exception but can't expand its combase.dll structures without OS symbols — and @@ -335,10 +409,24 @@ internal static string GitBlobSha1(byte[] content) return Convert.ToHexStringLower(SHA1.HashData(buffer)); } - private static string UnavailableNote() => - "WinUI Triage: skipped — the debugger components required for stowed-exception analysis " + - "(dbgeng.dll + JsProvider.dll) could not be obtained.\n" + - "The engine bits come from NuGet and JsProvider.dll is extracted from the WinDbg download; " + - "if your environment blocks those, install Debugging Tools for Windows (Windows SDK) or set " + - $"the {XamlTriageBinaries.EnvOverride} environment variable to a debugger directory that contains them."; + private static string UnavailableNote() + { + // When an authoritative override is configured, the cache/installed-tools paths are skipped, + // so telling the user to "set WINAPP_DBGTOOLS_DIR" (which is already set) is misleading. + // Point them at the specific gap in their override directory instead. + var overrideGap = XamlTriageBinaries.DescribeOverrideGap(); + if (overrideGap != null) + { + return "WinUI Triage: skipped — " + overrideGap + ".\n" + + $"The {XamlTriageBinaries.EnvOverride} override is authoritative, so only that directory is " + + "consulted. Populate it with a full debugger layout (dbgeng.dll + JsProvider.dll), or unset " + + $"{XamlTriageBinaries.EnvOverride} to use installed Debugging Tools for Windows or the download-on-first-use cache."; + } + + return "WinUI Triage: skipped — the debugger components required for stowed-exception analysis " + + "(dbgeng.dll + JsProvider.dll) could not be obtained.\n" + + "The engine bits come from NuGet and JsProvider.dll is extracted from the WinDbg download; " + + "if your environment blocks those, install Debugging Tools for Windows (Windows SDK) or set " + + $"the {XamlTriageBinaries.EnvOverride} environment variable to a debugger directory that contains them."; + } } From 3711ec2f80b035a23e060e78fbcdda8fbb7a5849 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Thu, 9 Jul 2026 18:28:17 -0400 Subject: [PATCH 5/6] address pr feedback --- docs/debugging.md | 2 +- .../XamlTriageBinariesTests.cs | 32 +++++- .../Services/WinDbgJsProviderAcquirer.cs | 68 ++++++----- .../WinApp.Cli/Services/XamlTriageBinaries.cs | 106 ++++++++++++++++-- .../WinApp.Cli/Services/XamlTriageService.cs | 16 +++ 5 files changed, 174 insertions(+), 50 deletions(-) diff --git a/docs/debugging.md b/docs/debugging.md index adc7e761..aff15ad5 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -129,7 +129,7 @@ Most WinUI crashes start inside a XAML event handler and surface as a **stowed e To make this work, winapp captures the crash dump with the terminating stowed exception's record (and its parameters, which point at the stowed-exception array) while keeping the first-chance thread context, so the standard managed analysis still recovers your original user frame *and* the triage pass can locate the stowed exception. -This pass hosts DbgEng with the WinUI team's WinDbg JavaScript extension. The native debugging engine (`dbgeng.dll` and friends) comes from NuGet, and `JsProvider.dll` — the JavaScript scripting host, which is **not** on NuGet — is fetched on first use directly from the official WinDbg download (only the few hundred kilobytes needed are read, not the full package). The debugger packages are version-pinned and, before any of their native DLLs are extracted and loaded, verified against a compiled-in SHA-512 content hash (and the extension against a pinned hash); `JsProvider.dll` is additionally required to carry a valid Microsoft Authenticode signature, checked with full certificate-chain revocation (falling back to a signature-only check when revocation data can't be reached offline, but always rejecting a revoked certificate). Downloads are staged to a temporary file, verified, then atomically published into the cache, so a concurrent run never observes a partially-written or unverified DLL. Together this means a mirrored or compromised feed cannot substitute altered binaries — any failure skips triage rather than loading unverified code. Everything is cached under the winapp global directory, so subsequent runs are offline. If your environment blocks those downloads, install **Debugging Tools for Windows** (via the Windows SDK) or set the `WINAPP_DBGTOOLS_DIR` environment variable to a debugger directory that already contains `dbgeng.dll` and `JsProvider.dll`. When `WINAPP_DBGTOOLS_DIR` is set it is authoritative — only that directory is consulted — so if it's incomplete the log names the specific missing component (`dbgeng.dll` and/or `JsProvider.dll`) rather than suggesting you set a variable that's already set. When triage succeeds, the console surfaces a one-line verdict (the stowed exception's error code/message); when the binaries can't be obtained, the triage pass is skipped (the standard managed/native analysis still runs), the console says so, and the log explains why. +This pass hosts DbgEng with the WinUI team's WinDbg JavaScript extension. The native debugging engine (`dbgeng.dll` and friends) comes from NuGet, and `JsProvider.dll` — the JavaScript scripting host, which is **not** on NuGet — is fetched on first use directly from the official WinDbg download (only the few hundred kilobytes needed are read, not the full package). Because `JsProvider.dll` must be the same build as the engine — loading a mismatched provider crashes the debugger on startup — it is pinned to the specific WinDbg bundle whose build matches the NuGet engine (not the rolling "current" release), and after acquisition the two builds are compared: a mismatch is rejected and triage is skipped with a clear reason rather than crashing silently. The debugger packages are version-pinned and, before any of their native DLLs are extracted and loaded, verified against a compiled-in SHA-512 content hash (and the extension against a pinned hash); `JsProvider.dll` is additionally required to carry a valid Microsoft Authenticode signature, checked with full certificate-chain revocation (falling back to a signature-only check when revocation data can't be reached offline, but always rejecting a revoked certificate). Downloads are staged to a temporary file, verified, then atomically published into the cache, so a concurrent run never observes a partially-written or unverified DLL. On every run the cached binaries are re-checked (the `JsProvider.dll` signature and its engine-build match, and the engine DLLs for a valid PE image), so a truncated or drifted cache self-heals by re-acquiring instead of failing every run. Together this means a mirrored or compromised feed cannot substitute altered binaries — any failure skips triage rather than loading unverified code. Everything is cached under the winapp global directory, so subsequent runs are offline. If your environment blocks those downloads, install **Debugging Tools for Windows** (via the Windows SDK) or set the `WINAPP_DBGTOOLS_DIR` environment variable to a debugger directory that already contains `dbgeng.dll` and `JsProvider.dll`. When `WINAPP_DBGTOOLS_DIR` is set it is authoritative — only that directory is consulted — so if it's incomplete the log names the specific missing component (`dbgeng.dll` and/or `JsProvider.dll`) rather than suggesting you set a variable that's already set. When triage succeeds, the console surfaces a one-line verdict (the stowed exception's error code/message); when the binaries can't be obtained, the triage pass is skipped (the standard managed/native analysis still runs), the console says so, and the log explains why. The triage pass runs in a short-lived child process. This is required: winapp's main process loads the system `dbghelp.dll` while capturing and analyzing the dump, and the modern engine `dbgeng.dll` cannot bind to that older, already-resident copy — a fresh process gives the engine a clean loader state. Decoding the stowed-exception structures also needs operating-system symbols (`combase.dll`), which `--symbols` downloads from the Microsoft public symbol server; on builds whose symbols aren't published there, the triage pass still identifies the stowed exception but cannot fully expand it. diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs index aafae852..b9ae8ac4 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -13,9 +13,10 @@ public class XamlTriageBinariesTests private string _tempDir = null!; private string? _originalOverride; - // Pass-through verifier for tests that use dummy (unsigned) binary files: resolution logic is under - // test here, not the real Authenticode gate (covered by AuthenticodeVerifierTests + the L4 test). - private static readonly Func AcceptAny = _ => true; + // Pass-through validator for tests that use dummy (unsigned) binary files: resolution logic is under + // test here, not the real Authenticode/version gate (covered by AuthenticodeVerifierTests, the L4 + // test, and the VersionsMatch tests below). + private static readonly Func AcceptAny = _ => true; [TestInitialize] public void Setup() @@ -195,6 +196,31 @@ public void ArchTokens_AreNonEmpty() Assert.IsFalse(string.IsNullOrWhiteSpace(XamlTriageBinaries.NuGetArch)); } + [TestMethod] + [DataRow("10.0.29547.1002", "10.0.29547.1002", true, DisplayName = "Identical")] + [DataRow("10.0.29547.1002 (WinBuild.160101.0800)", "10.0.29547.1002", true, DisplayName = "Trailing FileVersion decoration ignored")] + [DataRow("10.0.29547.1002", "10.0.29617.1000", false, DisplayName = "Different build")] + [DataRow(null, "10.0.29547.1002", false, DisplayName = "Null engine version")] + [DataRow("10.0.29547.1002", null, false, DisplayName = "Null provider version")] + [DataRow("not-a-version", "10.0.29547.1002", false, DisplayName = "Unparseable")] + public void VersionsMatch_ComparesNumericComponent(string? a, string? b, bool expected) + { + Assert.AreEqual(expected, XamlTriageBinaries.VersionsMatch(a, b)); + } + + [TestMethod] + public void PinnedJsProviderProductVersion_MatchesEngineDrift_Guard() + { + // Drift guard mirroring the .nupkg SHA-512 pins: the JsProvider bundle build MUST equal the + // engine build shipped by the pinned Microsoft.Debugging.Platform.DbgEng NuGet package. If the + // engine package version is bumped, PinnedBundleUrl + PinnedJsProviderProductVersion must be + // bumped in lockstep, or the runtime compat gate will fail-close and triage will be skipped. + // Expected engine build for DbgPackageVersion 20260319.1511.0. + const string expectedEngineBuild = "10.0.29547.1002"; + Assert.AreEqual(Version.Parse(expectedEngineBuild), Version.Parse(WinDbgJsProviderAcquirer.PinnedJsProviderProductVersion), + "JsProvider bundle build drifted from the pinned engine build; update PinnedBundleUrl to a WinDbg bundle whose JsProvider matches the engine, and this constant."); + } + [TestMethod] public void IsEnvOverrideSet_ReflectsEnvironmentVariable() { diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs index 8344971c..2e5b11ef 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs @@ -5,7 +5,6 @@ using System.Net; using System.Net.Http.Headers; using System.Runtime.InteropServices; -using System.Xml.Linq; using WinApp.Cli.Helpers; namespace WinApp.Cli.Services; @@ -25,11 +24,23 @@ namespace WinApp.Cli.Services; /// internal static class WinDbgJsProviderAcquirer { - // Stable entry point published by the WinDbg team; resolves to the current MainBundle URI. - private const string AppInstallerUrl = "https://windbg.download.prss.microsoft.com/dbazure/prod/1-0-0/windbg.appinstaller"; + // JsProvider.dll ships only in the WinDbg .msixbundle, and it MUST match the debugger engine + // build we pin via NuGet (Microsoft.Debugging.Platform.DbgEng). A JsProvider from a different + // engine build makes the triage child process crash immediately with STATUS_BREAKPOINT + // (0x80000003) and triage is silently skipped. The WinDbg "current" appinstaller rolls forward + // independently of the NuGet engine pin, so we deliberately do NOT resolve it; instead we pin the + // exact bundle whose JsProvider build equals the pinned engine build. When the engine NuGet + // version is bumped, bump this bundle URL (and PinnedJsProviderProductVersion) in lockstep — a + // runtime engine/provider version check (see XamlTriageBinaries.IsProviderCompatibleWithEngine) + // fails closed if they ever diverge. + private const string PinnedBundleUrl = "https://windbg.download.prss.microsoft.com/dbazure/prod/1-2603-20001-0/windbg.msixbundle"; - // Pinned fallback used when the appinstaller cannot be parsed (kept reasonably current). - private const string FallbackBundleUrl = "https://windbg.download.prss.microsoft.com/dbazure/prod/1-2603-20001-0/windbg.msixbundle"; + /// + /// Product version of JsProvider.dll shipped by . Must equal + /// the dbgeng.dll product version shipped by the pinned Microsoft.Debugging.Platform.DbgEng + /// NuGet package; a drift test asserts this stays in sync. + /// + internal const string PinnedJsProviderProductVersion = "10.0.29547.1002"; private const string TargetFileName = "JsProvider.dll"; private const int MaxReadAttempts = 5; @@ -50,10 +61,8 @@ public static async Task TryAcquireAsync(DirectoryInfo destDir, ILogger lo try { - using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; - var bundleUrl = await ResolveBundleUrlAsync(http, logger, cancellationToken); - - var reader = new HttpRangeReader(http, bundleUrl); + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(2) }; + var reader = new HttpRangeReader(http, PinnedBundleUrl); var bytes = await ExtractJsProviderAsync(reader, msixName, pathPrefix, cancellationToken); if (bytes == null) { @@ -64,9 +73,9 @@ public static async Task TryAcquireAsync(DirectoryInfo destDir, ILogger lo Directory.CreateDirectory(destDir.FullName); var targetPath = Path.Combine(destDir.FullName, TargetFileName); - // Stage to a temp file, verify it, then atomically publish. Writing the 23 MB DLL directly - // to its final path would let a concurrent run (or this run's later ResolveExisting) observe - // and .load a partially-written or not-yet-verified DLL. + // Stage to a temp file, verify it, then atomically publish. Writing the DLL directly to its + // final path would let a concurrent run (or this run's later ResolveExisting) observe and + // .load a partially-written or not-yet-verified DLL. var stagedPath = await AtomicFile.WriteStagedAsync(targetPath, bytes, cancellationToken); // Defense-in-depth: this DLL is loaded into the debugger process, so verify it carries a @@ -79,6 +88,17 @@ public static async Task TryAcquireAsync(DirectoryInfo destDir, ILogger lo return false; } + // The staged JsProvider must match the already-present engine build; loading a mismatched + // provider crashes the triage child with STATUS_BREAKPOINT. Reject a mismatch here (fail + // closed) rather than publishing a provider that would silently break triage — this guards + // against a future engine bump that outpaces the pinned bundle. + if (!XamlTriageBinaries.IsProviderCompatibleWithEngine(destDir.FullName, stagedPath, logger)) + { + logger.LogDebug("Discarding {File}: its build does not match the debugging engine.", TargetFileName); + AtomicFile.DiscardStaged(stagedPath); + return false; + } + AtomicFile.Publish(stagedPath, targetPath); logger.LogDebug("Acquired {File} ({Size} bytes) from WinDbg bundle into {Dir}.", TargetFileName, bytes.Length, destDir.FullName); @@ -150,30 +170,6 @@ public static async Task TryAcquireAsync(DirectoryInfo destDir, ILogger lo _ => (null, null), }; - private static async Task ResolveBundleUrlAsync(HttpClient http, ILogger logger, CancellationToken cancellationToken) - { - try - { - var xml = await http.GetStringAsync(AppInstallerUrl, cancellationToken); - var doc = XDocument.Parse(xml); - var uri = doc.Descendants() - .FirstOrDefault(e => e.Name.LocalName == "MainBundle")? - .Attribute("Uri")?.Value; - - if (!string.IsNullOrWhiteSpace(uri)) - { - logger.LogDebug("Resolved WinDbg bundle URI from appinstaller: {Uri}", uri); - return uri; - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogDebug(ex, "Failed to resolve WinDbg bundle URI from appinstaller; using pinned fallback."); - } - - return FallbackBundleUrl; - } - /// An backed by HTTP range requests with retry-on-transient. private sealed class HttpRangeReader(HttpClient http, string url) : IRangeReader { diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs index eff96454..4547cf79 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.IO.Compression; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -112,20 +113,24 @@ private static readonly (string Package, string Version, string Sha512, string[] /// Resolves an existing directory that contains both dbgeng.dll and /// JsProvider.dll for the host architecture, or null when none is found. /// - /// The resolved JsProvider.dll is re-verified as validly Microsoft-signed on every resolve, - /// not only at download time: it is loaded into the debugger process, and a cache-hit would - /// otherwise trust a copy that could have been replaced on disk since it was acquired. + /// On every resolve (including cache hits) the JsProvider.dll is re-verified as validly + /// Microsoft-signed and checked to be the same build as the co-located dbgeng.dll: + /// it is loaded into the debugger process, and a copy that was replaced on disk, or that drifted + /// from the engine build (which crashes the triage child with STATUS_BREAKPOINT), must be rejected + /// so the cache self-heals instead of silently breaking triage. /// /// public static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger) => - ResolveExisting(cacheBinDir, logger, path => AuthenticodeVerifier.IsTrustedMicrosoftSigned(path, logger)); + ResolveExisting(cacheBinDir, logger, b => + AuthenticodeVerifier.IsTrustedMicrosoftSigned(b.JsProviderPath, logger) + && IsProviderCompatibleWithEngine(b.BinDir, b.JsProviderPath, logger)); /// /// Testable core of with an injectable - /// so unit tests can exercise resolution without requiring a - /// real Authenticode-signed JsProvider.dll. + /// so unit tests can exercise resolution without requiring a real + /// Authenticode-signed, version-matched JsProvider.dll. /// - internal static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger, Func jsProviderVerifier) + internal static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger, Func validator) { foreach (var (dir, source) in CandidateDirectories(cacheBinDir)) { @@ -135,9 +140,9 @@ private static readonly (string Package, string Version, string Sha512, string[] continue; } - if (!jsProviderVerifier(resolved.JsProviderPath)) + if (!validator(resolved)) { - logger.LogDebug("Rejecting WinUI triage binaries from {Source}: {Path} is not validly Microsoft-signed.", source, resolved.JsProviderPath); + logger.LogDebug("Rejecting WinUI triage binaries from {Source}: {Path} failed signature/version validation.", source, resolved.JsProviderPath); continue; } @@ -218,6 +223,87 @@ private static IEnumerable InstalledDebuggerRoots() public static bool HasEngine(DirectoryInfo cacheBinDir) => File.Exists(Path.Combine(cacheBinDir.FullName, "dbgeng.dll")); + /// + /// Returns true when the JsProvider.dll at is the + /// same product build as the dbgeng.dll in . Loading a JsProvider + /// from a different engine build crashes the triage child with STATUS_BREAKPOINT, so a mismatch (or + /// an unreadable/corrupt engine whose version can't be read) is treated as incompatible. + /// + internal static bool IsProviderCompatibleWithEngine(string binDir, string jsProviderPath, ILogger logger) + { + var engineVersion = TryGetProductVersion(Path.Combine(binDir, "dbgeng.dll")); + var providerVersion = TryGetProductVersion(jsProviderPath); + if (!VersionsMatch(engineVersion, providerVersion)) + { + logger.LogDebug( + "Engine/JsProvider build mismatch: dbgeng.dll={Engine}, JsProvider.dll={Provider}. A mismatched provider crashes the triage child.", + engineVersion ?? "", providerVersion ?? ""); + return false; + } + + return true; + } + + /// + /// Compares two file version strings for equality on their numeric a.b.c.d component, + /// tolerating trailing decorations (e.g. "10.0.29547.1002 (WinBuild.160101.0800)"). Returns + /// false when either value is missing or unparseable. Extracted for unit testing. + /// + internal static bool VersionsMatch(string? a, string? b) + { + var na = NormalizeVersion(a); + var nb = NormalizeVersion(b); + return na != null && na == nb; + } + + private static string? NormalizeVersion(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var token = value.Trim().Split(' ')[0]; + return Version.TryParse(token, out var parsed) ? parsed.ToString() : null; + } + + private static string? TryGetProductVersion(string path) + { + try + { + return File.Exists(path) ? FileVersionInfo.GetVersionInfo(path).ProductVersion : null; + } + catch + { + return null; + } + } + + /// + /// Returns true when exists and looks like an intact PE image (starts + /// with the MZ signature and is not implausibly small). Used to detect a truncated/corrupt + /// cached engine DLL so it is re-acquired instead of poisoning the cache across runs — unlike + /// JsProvider.dll, the engine DLLs are not otherwise re-verified on a cache hit. + /// + private static bool IsUsablePeFile(string path) + { + try + { + var info = new FileInfo(path); + if (!info.Exists || info.Length < 4096) + { + return false; + } + + using var stream = info.OpenRead(); + return stream.ReadByte() == 'M' && stream.ReadByte() == 'Z'; + } + catch + { + return false; + } + } + /// /// When is set but the override directory is not a usable debugger /// layout, returns a human-readable description of the override directory and which required @@ -281,7 +367,7 @@ public static async Task TryAcquireFromNuGetAsync( { try { - if (files.All(f => File.Exists(Path.Combine(cacheBinDir.FullName, f)))) + if (files.All(f => IsUsablePeFile(Path.Combine(cacheBinDir.FullName, f)))) { acquired++; continue; diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs index c5cd9b14..84d3d1ea 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -109,6 +109,13 @@ public async Task TryAnalyzeAsync(string dumpPath, bool useSym // through to the fail-open handler below so the already-computed managed stack survives. throw; } + catch (OperationCanceledException) + { + // Internal download timeout (not caller-requested). Fail open without dumping the raw + // TaskCanceledException stack to the console — the caller keeps the managed crash stack. + logger.LogDebug("WinUI triage pass timed out acquiring debugging tools; skipping triage."); + return XamlTriageResult.None; + } catch (Exception ex) { logger.LogWarning(ex, "WinUI triage pass failed."); @@ -317,6 +324,15 @@ internal static List BuildTriageArgs(string dumpPath, ResolvedTriageBina { logger.LogDebug("WinUI triage child process exited with code {Code}: {Error}", process.ExitCode, stderr.ToString().Trim()); + + // STATUS_BREAKPOINT is the signature of loading a JsProvider.dll built against a different + // engine version than the pinned dbgeng.dll — the version-compat gate should prevent this, + // but surface a clearer verdict than a raw negative exit code if it ever slips through. + if (process.ExitCode == unchecked((int)0x80000003)) + { + return (null, "the triage child process crashed on startup (STATUS_BREAKPOINT), which usually means the JsProvider.dll build does not match the debugging engine."); + } + return (null, $"the triage child process exited with code {process.ExitCode}."); } From 9e3955a7a91810026c0f7fcda3d96a81fe382f87 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:27:24 -0700 Subject: [PATCH 6/6] test: validate JsProvider pin against the restored engine build The drift guard compared two hand-maintained constants, so a DbgPackageVersion bump shipping a new dbgeng build would not be caught. Read the actual dbgeng.dll product version from the restored (restore-only) NuGet package and assert PinnedJsProviderProductVersion matches it, using the same VersionsMatch normalization the runtime gate uses and the same Inconclusive-when-absent tolerance as the .nupkg SHA-512 drift test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../XamlTriageBinariesTests.cs | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs index b9ae8ac4..a50e8580 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -209,16 +209,37 @@ public void VersionsMatch_ComparesNumericComponent(string? a, string? b, bool ex } [TestMethod] - public void PinnedJsProviderProductVersion_MatchesEngineDrift_Guard() + public void PinnedJsProviderProductVersion_MatchesRestoredEngineBuild() { // Drift guard mirroring the .nupkg SHA-512 pins: the JsProvider bundle build MUST equal the - // engine build shipped by the pinned Microsoft.Debugging.Platform.DbgEng NuGet package. If the - // engine package version is bumped, PinnedBundleUrl + PinnedJsProviderProductVersion must be - // bumped in lockstep, or the runtime compat gate will fail-close and triage will be skipped. - // Expected engine build for DbgPackageVersion 20260319.1511.0. - const string expectedEngineBuild = "10.0.29547.1002"; - Assert.AreEqual(Version.Parse(expectedEngineBuild), Version.Parse(WinDbgJsProviderAcquirer.PinnedJsProviderProductVersion), - "JsProvider bundle build drifted from the pinned engine build; update PinnedBundleUrl to a WinDbg bundle whose JsProvider matches the engine, and this constant."); + // engine build shipped by the pinned Microsoft.Debugging.Platform.DbgEng NuGet package — + // loading a mismatched provider crashes the triage child with STATUS_BREAKPOINT, and the + // runtime compat gate then fail-closes triage. Rather than compare two hand-maintained + // constants (which wouldn't notice a DbgPackageVersion bump that ships a new engine build), + // read the *actual* dbgeng.dll product version from the restored package so a bump that forgets + // to re-pin PinnedBundleUrl + PinnedJsProviderProductVersion is caught here. The package is a + // restore-only PackageReference, so its content is in the NuGet global cache on a build/CI + // machine; if it can't be located (restored elsewhere), the assertion is inconclusive. + var cache = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (string.IsNullOrEmpty(cache)) + { + cache = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + } + + var dbgeng = Path.Combine( + cache, "microsoft.debugging.platform.dbgeng", XamlTriageBinaries.DbgPackageVersion, + "content", XamlTriageBinaries.NuGetArch, "dbgeng.dll"); + if (!File.Exists(dbgeng)) + { + Assert.Inconclusive($"Restored DbgEng package not found in NuGet cache: {dbgeng}"); + } + + var engineBuild = System.Diagnostics.FileVersionInfo.GetVersionInfo(dbgeng).ProductVersion; + Assert.IsTrue( + XamlTriageBinaries.VersionsMatch(engineBuild, WinDbgJsProviderAcquirer.PinnedJsProviderProductVersion), + $"JsProvider bundle build drifted from the engine: dbgeng.dll (pinned DbgEng {XamlTriageBinaries.DbgPackageVersion}) " + + $"reports {engineBuild ?? ""}, but PinnedJsProviderProductVersion is {WinDbgJsProviderAcquirer.PinnedJsProviderProductVersion}. " + + "Update PinnedBundleUrl to a WinDbg bundle whose JsProvider matches the engine, and update PinnedJsProviderProductVersion."); } [TestMethod]