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 00d87db9..ed8b021c 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). @@ -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 00d87db9..ed8b021c 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). @@ -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 e6df3c86..9093210f 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). 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. + ## IDE setup ### VS Code 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 5ce162ba..c67d6073 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/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/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 4b75d7a6..269e7e8f 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,84 @@ 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."); + } + + [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() + { + 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 8d83d9a5..077b7b7b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeCrashDumpService.cs @@ -11,14 +11,18 @@ 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; } = []; 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)); + CrashRecords.Add((crashExceptionCode, crashExceptionAddress, crashExceptionParameters)); 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..76f13d21 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeXamlTriageService.cs @@ -0,0 +1,31 @@ +// 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 XamlTriageResult FakeResult { get; set; } = XamlTriageResult.None; + + /// 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 new file mode 100644 index 00000000..a50e8580 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -0,0 +1,390 @@ +// 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; + + // 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() + { + _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, AcceptAny); + + 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, AcceptAny); + + 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 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() + { + 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, AcceptAny); + + Assert.IsNotNull(resolved); + Assert.AreEqual(Path.Combine(dir, "JsProvider.dll"), resolved.JsProviderPath); + } + + [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)); + } + + [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_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 — + // 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] + 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() + { + 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."); + } + + [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); + 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 new file mode 100644 index 00000000..da9b0e49 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageServiceTests.cs @@ -0,0 +1,182 @@ +// 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)); + } + + [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]); + } + + [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.Tests/ZipRangeExtractorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs new file mode 100644 index 00000000..0306e38d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs @@ -0,0 +1,233 @@ +// 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 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() + { + 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/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 new file mode 100644 index 00000000..dd02aab8 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/AuthenticodeVerifier.cs @@ -0,0 +1,188 @@ +// 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_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 + /// 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)) + { + 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, 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 + { + 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 = revocationChecks, + dwUnionChoice = WTD_CHOICE_FILE, + pInfo = (IntPtr)(&fileInfo), + dwStateAction = WTD_STATEACTION_VERIFY, + dwProvFlags = provFlags, + }; + + 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; + } + 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 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 + { + 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/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index a55b1f2e..bb8d6e32 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..65d4a42e 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,21 @@ 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) { @@ -149,15 +188,41 @@ public async Task AnalyzeDumpAsync(string dumpPath, string logPath, bool useSymb try { - var (summary, details) = await Task.Run(() => 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; + 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)...[/]"); + xamlTriageResult = await RunXamlTriageGuardedAsync(dumpPath, useSymbols); + xamlTriage = xamlTriageResult.LogText; + } // 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 +231,8 @@ await File.AppendAllTextAsync(logPath, console.MarkupLine("[red][[CRASH ANALYSIS]][/]"); console.WriteLine(summary); console.MarkupLine("[red]=====================================[/]"); + WriteXamlTriageConsole(xamlTriageResult); + console.MarkupLine($"[dim]Crash dump:[/] {dumpPath.EscapeMarkup()}"); console.MarkupLine($"[dim]Full debug log:[/] {logPath.EscapeMarkup()}"); return; @@ -190,6 +257,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 +280,8 @@ await File.AppendAllTextAsync(logPath, console.MarkupLine("[red]=====================================[/]"); } + WriteXamlTriageConsole(xamlTriageResult); + console.MarkupLine($"[dim]Crash dump:[/] {dumpPath.EscapeMarkup()}"); console.MarkupLine($"[dim]Full debug log:[/] {logPath.EscapeMarkup()}"); if (!useSymbols && !string.IsNullOrWhiteSpace(nativeSummary)) @@ -223,10 +298,68 @@ await File.AppendAllTextAsync(logPath, } } - private (string Summary, string Details) AnalyzeWithClrMD(string dumpPath, IReadOnlyList? symbolSearchPaths) + /// + /// 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); + // 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 +368,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 +391,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 +546,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..180f91be --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IXamlTriageService.cs @@ -0,0 +1,37 @@ +// 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 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 a + /// note or . + /// + /// 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. + /// 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 new file mode 100644 index 00000000..2e5b11ef --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs @@ -0,0 +1,229 @@ +// 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 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 +{ + // 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"; + + /// + /// 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; + + /// + /// 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(2) }; + var reader = new HttpRangeReader(http, PinnedBundleUrl); + 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); + + // 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 + // 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(stagedPath, logger)) + { + logger.LogDebug("Discarding {File}: it is not validly signed by Microsoft.", TargetFileName); + AtomicFile.DiscardStaged(stagedPath); + 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); + 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), + }; + + /// 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..4547cf79 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -0,0 +1,574 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using WinApp.Cli.Helpers; + +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). +/// 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, string JsProviderPath, 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"; + + /// + /// 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"; + + /// + /// 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"; + + // 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 Sha512, string[] Files)[] NuGetComponents = + [ + ("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 + { + 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. + /// + /// 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, 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, version-matched JsProvider.dll. + /// + internal static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger, Func validator) + { + foreach (var (dir, source) in CandidateDirectories(cacheBinDir)) + { + var resolved = TryDirectory(dir, source); + if (resolved == null) + { + continue; + } + + if (!validator(resolved)) + { + logger.LogDebug("Rejecting WinUI triage binaries from {Source}: {Path} failed signature/version validation.", source, resolved.JsProviderPath); + continue; + } + + 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. + if (IsEnvOverrideSet) + { + var overrideDir = Environment.GetEnvironmentVariable(EnvOverride)!; + 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, 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, jsProviderPath, 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")); + + /// + /// 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 + /// 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) + { + Directory.CreateDirectory(cacheBinDir.FullName); + using var http = new HttpClient(); + var acquired = 0; + + foreach (var (package, version, sha512, files) in NuGetComponents) + { + try + { + if (files.All(f => IsUsablePeFile(Path.Combine(cacheBinDir.FullName, f)))) + { + acquired++; + continue; + } + + if (nugetCacheDir != null && TryCopyFromGlobalCache(package, version, files, nugetCacheDir, cacheBinDir, logger)) + { + acquired++; + continue; + } + + if (await TryMaterializePackageAsync(http, package, version, sha512, 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>/). 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. + /// + 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) + { + return false; + } + + // 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)))) + { + continue; + } + + foreach (var file in files) + { + AtomicFile.Copy(Path.Combine(archDir, file), Path.Combine(cacheBinDir.FullName, file)); + } + + 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; + } + + return false; + } + + private static async Task TryMaterializePackageAsync( + 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); + if (string.IsNullOrEmpty(version)) + { + return false; + } + + // 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) + { + 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 + { + using (var nupkgStream = new MemoryStream(nupkgBytes, writable: false)) + 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) + { + AtomicFile.Copy(source, Path.Combine(cacheBinDir.FullName, file)); + 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 */ } + } + } + + /// + /// 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) + { + 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; + } + + logger.LogDebug("Pinned version {Pinned} of {Id} is not available on the feed; skipping download.", pinnedVersion, id); + return null; + } + + /// + /// 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); + } + + /// + /// 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/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/XamlTriageRunner.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs new file mode 100644 index 00000000..65408978 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageRunner.cs @@ -0,0 +1,132 @@ +// 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, jsProvider = 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 "--jsprovider" when i + 1 < args.Length: jsProvider = 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; + } + + // 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, jsProvider, 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 jsProviderPath, 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 = 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. + 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..84d3d1ea --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -0,0 +1,448 @@ +// 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 partial 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 && !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); + + // 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 XamlTriageResult.Skipped(UnavailableNote()); + } + + var extPath = await EnsureExtensionAsync(dbgToolsRoot, cancellationToken); + if (extPath == null) + { + logger.LogDebug("WinUI triage skipped: could not obtain {Ext}.", ExtFileName); + 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 + // 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, skipNote) = await RunTriageProcessAsync(dumpPath, binaries, extPath, useSymbols, cancellationToken); + if (skipNote != null) + { + return XamlTriageResult.Skipped($"WinUI Triage: skipped — {skipNote}"); + } + + if (string.IsNullOrWhiteSpace(output)) + { + return XamlTriageResult.Skipped("WinUI Triage: skipped — the triage pass produced no output."); + } + + var header = $"WinUI Triage (DbgEng + winui-dbgext.js, source: {binaries.Source}):"; + + // 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)); + var logText = notes.Length == 0 + ? $"{header}\n{output.Trim()}" + : $"{header}\n{notes}\n\n{output.Trim()}"; + return XamlTriageResult.Succeeded(logText, TryExtractVerdict(output)); + } + 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 (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."); + 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 + /// 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.)"; + } + + /// + /// 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<(string? Output, string? SkipNote)> 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!; + startInfo.FileName = processPath; + if (Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + // 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")); + } + + foreach (var arg in BuildTriageArgs(dumpPath, binaries, extPath, useSymbols)) + { + startInfo.ArgumentList.Add(arg); + } + + 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, "the triage child process could not be started."); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TriageTimeout); + 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, $"the triage child process timed out after {TriageTimeout.TotalMinutes:0} minutes."); + } + 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()); + + // 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}."); + } + + return (stdout.ToString(), null); + } + + 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) && MatchesPinnedExtensionHash(await File.ReadAllBytesAsync(extPath, cancellationToken))) + { + 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); + + if (!MatchesPinnedExtensionHash(bytes)) + { + logger.LogWarning("Downloaded {Ext} hash mismatch (expected {Expected}, got {Actual}); refusing to use it.", + ExtFileName, ExtBlobSha1, GitBlobSha1(bytes)); + 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; + } + } + + /// + /// 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.")] + internal 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() + { + // 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."; + } +} 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 @@ + + +