diff --git a/.claude/agents/winapp.md b/.claude/agents/winapp.md index f2de24752..05139d332 100644 --- a/.claude/agents/winapp.md +++ b/.claude/agents/winapp.md @@ -42,7 +42,10 @@ Does the project already have an appxmanifest.xml? │ └─ Is the exe separate from your app code? (Electron, sparse package testing) │ └─ winapp create-debug-identity (registers sparse package) ├─ Need to sign an existing MSIX or exe? - │ └─ winapp sign + │ ├─ With a local dev/CA certificate (PFX)? + │ │ └─ winapp sign + │ └─ With Azure Trusted Signing (cloud-managed identity, no local PFX)? + │ └─ winapp az-sign └─ Need to run a Windows SDK tool directly (makeappx, signtool, makepri)? └─ winapp tool @@ -169,6 +172,18 @@ Want to inspect or interact with a running app's UI? - `--password ` — certificate password - `--timestamp ` — timestamp server URL (recommended for production to stay valid after cert expires) +### `winapp az-sign ` +**Purpose:** Code-sign an exe, MSIX, or MSIX bundle using Azure Trusted Signing (a cloud-managed signing identity — no local PFX). +**When to use:** For production signing when the certificate is managed in Azure rather than as a local PFX file. Works in CI/CD and interactively. +**Key options:** +- `--subscription ` (`-s`) — Azure subscription ID (prompts if omitted and multiple exist) +- `--resource-group ` (`-r`) — resource group to narrow down signing accounts +- `--account ` — signing account name (requires `--resource-group`) +- `--profile ` (`-p`) — certificate profile name (requires `--account`) +- `--metadata-file ` (`-m`) — reuse an existing `metadata.json`, skipping resource discovery and identity selection (authentication may still prompt for a tenant or `az login`) +**Auth:** Uses `DefaultAzureCredential`. For CI/CD set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` (or OIDC/managed identity); interactively falls back to `az login`. +**Requires:** An Azure Code Signing account + certificate profile, and the Code Signing Certificate Profile Signer role. Also needs two machine-wide x64 runtimes that winapp does not auto-install (it downloads the raw NuGet package, not the client-tools installer): the x64 .NET 8+ runtime (the signing library is a managed assembly loaded by `signtool.exe`) and the x64 Visual C++ Redistributable. Plus SignTool 10.0.22621.755+. A dlib load failure (e.g. `0xc000007b`) usually means a missing runtime — most often the VC++ Redistributable. + ### `winapp manifest generate [directory]` **Purpose:** Create an `appxmanifest.xml` without full project setup. **When to use:** When you only need a manifest and image assets, without SDK installation or config file creation. diff --git a/.claude/skills/winapp-signing/SKILL.md b/.claude/skills/winapp-signing/SKILL.md index 2e1ccfe7a..24337176f 100644 --- a/.claude/skills/winapp-signing/SKILL.md +++ b/.claude/skills/winapp-signing/SKILL.md @@ -9,6 +9,7 @@ Use this skill when: - **Generating a development certificate** for local MSIX signing and testing - **Installing (trusting) a certificate** on a machine so MSIX packages can be installed - **Signing an MSIX package or executable** for distribution +- **Signing with Azure Trusted Signing** (cloud-managed signing identity) via `winapp az-sign` ## Prerequisites @@ -80,6 +81,27 @@ When packaging multiple architectures into an `.msixbundle`, only the bundle nee Note: The `package` command can sign automatically when you pass `--cert`, so you often don't need `sign` separately. +### Sign with Azure Trusted Signing (cloud signing) + +For production-grade signing without managing a PFX file, use `winapp az-sign` to sign with [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/). The signing identity (certificate) is managed in Azure, so no private key ever lives on the machine. + +```powershell +# Interactive: discover subscription, account, and profile (prompts for any not provided) +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips resource discovery and identity selection; authentication may still be interactive) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +**Authentication:** `az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). In CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or GitHub Actions OIDC / managed identity). An existing Azure CLI session (`az login`, including the `azure/login` GitHub Action) is also honored in any environment. Only when no credentials are found *and* the session is interactive will `az-sign` launch `az login` for you. + +**Prerequisites:** An Azure Code Signing account and certificate profile (created in the Azure portal after identity validation), plus a role assignment granting your identity the **Code Signing Certificate Profile Signer** role. Signing also requires two machine-wide x64 runtimes that winapp does **not** install for you (it downloads the raw NuGet signing package, not the official client-tools installer): the **x64 .NET 8+ runtime** (the signing library is a managed assembly loaded by `signtool.exe`; winapp's self-contained runtime does not satisfy it) and the **x64 Visual C++ Redistributable** (https://aka.ms/vs/17/release/vc_redist.x64.exe). Also requires **SignTool 10.0.22621.755 or later**. If signing fails while *loading* the dlib (e.g. `0xc000007b` or a missing-DLL error) rather than during authentication, install the missing runtime — most often the VC++ Redistributable. + +> **Least-privilege CI:** Auto-discovery (listing subscriptions, resource groups, accounts, and profiles) needs read access at a parent scope. To avoid *every* collection-listing call, pass all four of `--subscription`, `--resource-group`, `--account`, and `--profile`: `az-sign` then validates the account and profile with direct resource reads (a GET on each named resource) instead of enumerating the parent collection, so a principal scoped to just that account and profile is sufficient. Omitting any one of them re-introduces a listing call — for example, leaving out `--subscription` makes `az-sign` list the subscriptions your identity can access — which a narrowly-scoped principal may not be permitted to do. A principal scoped only to a single certificate profile can skip validation entirely by passing a pre-generated `--metadata-file` (which specifies the account endpoint and profile directly). + ## Recommended workflow 1. **Generate cert** — `winapp cert generate` (auto-infers publisher from manifest) @@ -108,6 +130,8 @@ Note: The `package` command can sign automatically when you pass `--cert`, so yo | "Certificate not trusted" | Cert not installed on machine | `winapp cert install ./devcert.pfx` (admin) | | "Certificate file already exists" | `devcert.pfx` already present | Use `--if-exists overwrite` or `--if-exists skip` | | Signature invalid after time passes | No timestamp used during signing | Re-sign with `--timestamp http://timestamp.digicert.com` | +| `az-sign` fails with "No credentials found" | No Azure auth in environment | Run `az login`, or set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` for CI/CD | +| `az-sign` "No Trusted Signing accounts found" | No account in the subscription/resource group | Create a Trusted Signing account and certificate profile in the Azure portal | ## Command Reference @@ -181,3 +205,23 @@ Code-sign an MSIX package or executable. Example: winapp sign ./app.msix ./devce |--------|-------------|---------| | `--password` | Certificate password | `password` | | `--timestamp` | Timestamp server URL | (none) | + +### `winapp az-sign` + +Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes | Path to the file to sign (exe, msix, or msixbundle) | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--account` | Signing account name. Must be used with --resource-group | (none) | +| `--metadata-file` | Path to an existing metadata.json file. Skips resource discovery and account/profile selection prompts and signs using this file directly. A non-interactive Azure credential should already be available; the CLI can otherwise fall back to an interactive tenant prompt or 'az login', but the npm programmatic API is always non-interactive and fails instead of prompting. | (none) | +| `--profile` | Certificate profile name. Must be used with --account | (none) | +| `--resource-group` | Resource group to narrow down signing accounts | (none) | +| `--subscription` | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | (none) | diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 9786f9fe0..b03539752 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -43,7 +43,10 @@ Does the project already have an appxmanifest.xml? │ └─ Is the exe separate from your app code? (Electron, sparse package testing) │ └─ winapp create-debug-identity (registers sparse package) ├─ Need to sign an existing MSIX or exe? - │ └─ winapp sign + │ ├─ With a local dev/CA certificate (PFX)? + │ │ └─ winapp sign + │ └─ With Azure Trusted Signing (cloud-managed identity, no local PFX)? + │ └─ winapp az-sign └─ Need to run a Windows SDK tool directly (makeappx, signtool, makepri)? └─ winapp tool @@ -170,6 +173,18 @@ Want to inspect or interact with a running app's UI? - `--password ` — certificate password - `--timestamp ` — timestamp server URL (recommended for production to stay valid after cert expires) +### `winapp az-sign ` +**Purpose:** Code-sign an exe, MSIX, or MSIX bundle using Azure Trusted Signing (a cloud-managed signing identity — no local PFX). +**When to use:** For production signing when the certificate is managed in Azure rather than as a local PFX file. Works in CI/CD and interactively. +**Key options:** +- `--subscription ` (`-s`) — Azure subscription ID (prompts if omitted and multiple exist) +- `--resource-group ` (`-r`) — resource group to narrow down signing accounts +- `--account ` — signing account name (requires `--resource-group`) +- `--profile ` (`-p`) — certificate profile name (requires `--account`) +- `--metadata-file ` (`-m`) — reuse an existing `metadata.json`, skipping resource discovery and identity selection (authentication may still prompt for a tenant or `az login`) +**Auth:** Uses `DefaultAzureCredential`. For CI/CD set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` (or OIDC/managed identity); interactively falls back to `az login`. +**Requires:** An Azure Code Signing account + certificate profile, and the Code Signing Certificate Profile Signer role. Also needs two machine-wide x64 runtimes that winapp does not auto-install (it downloads the raw NuGet package, not the client-tools installer): the x64 .NET 8+ runtime (the signing library is a managed assembly loaded by `signtool.exe`) and the x64 Visual C++ Redistributable. Plus SignTool 10.0.22621.755+. A dlib load failure (e.g. `0xc000007b`) usually means a missing runtime — most often the VC++ Redistributable. + ### `winapp manifest generate [directory]` **Purpose:** Create an `appxmanifest.xml` without full project setup. **When to use:** When you only need a manifest and image assets, without SDK installation or config file creation. diff --git a/.github/plugin/skills/winapp-cli/signing/SKILL.md b/.github/plugin/skills/winapp-cli/signing/SKILL.md index 2e1ccfe7a..24337176f 100644 --- a/.github/plugin/skills/winapp-cli/signing/SKILL.md +++ b/.github/plugin/skills/winapp-cli/signing/SKILL.md @@ -9,6 +9,7 @@ Use this skill when: - **Generating a development certificate** for local MSIX signing and testing - **Installing (trusting) a certificate** on a machine so MSIX packages can be installed - **Signing an MSIX package or executable** for distribution +- **Signing with Azure Trusted Signing** (cloud-managed signing identity) via `winapp az-sign` ## Prerequisites @@ -80,6 +81,27 @@ When packaging multiple architectures into an `.msixbundle`, only the bundle nee Note: The `package` command can sign automatically when you pass `--cert`, so you often don't need `sign` separately. +### Sign with Azure Trusted Signing (cloud signing) + +For production-grade signing without managing a PFX file, use `winapp az-sign` to sign with [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/). The signing identity (certificate) is managed in Azure, so no private key ever lives on the machine. + +```powershell +# Interactive: discover subscription, account, and profile (prompts for any not provided) +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips resource discovery and identity selection; authentication may still be interactive) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +**Authentication:** `az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). In CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or GitHub Actions OIDC / managed identity). An existing Azure CLI session (`az login`, including the `azure/login` GitHub Action) is also honored in any environment. Only when no credentials are found *and* the session is interactive will `az-sign` launch `az login` for you. + +**Prerequisites:** An Azure Code Signing account and certificate profile (created in the Azure portal after identity validation), plus a role assignment granting your identity the **Code Signing Certificate Profile Signer** role. Signing also requires two machine-wide x64 runtimes that winapp does **not** install for you (it downloads the raw NuGet signing package, not the official client-tools installer): the **x64 .NET 8+ runtime** (the signing library is a managed assembly loaded by `signtool.exe`; winapp's self-contained runtime does not satisfy it) and the **x64 Visual C++ Redistributable** (https://aka.ms/vs/17/release/vc_redist.x64.exe). Also requires **SignTool 10.0.22621.755 or later**. If signing fails while *loading* the dlib (e.g. `0xc000007b` or a missing-DLL error) rather than during authentication, install the missing runtime — most often the VC++ Redistributable. + +> **Least-privilege CI:** Auto-discovery (listing subscriptions, resource groups, accounts, and profiles) needs read access at a parent scope. To avoid *every* collection-listing call, pass all four of `--subscription`, `--resource-group`, `--account`, and `--profile`: `az-sign` then validates the account and profile with direct resource reads (a GET on each named resource) instead of enumerating the parent collection, so a principal scoped to just that account and profile is sufficient. Omitting any one of them re-introduces a listing call — for example, leaving out `--subscription` makes `az-sign` list the subscriptions your identity can access — which a narrowly-scoped principal may not be permitted to do. A principal scoped only to a single certificate profile can skip validation entirely by passing a pre-generated `--metadata-file` (which specifies the account endpoint and profile directly). + ## Recommended workflow 1. **Generate cert** — `winapp cert generate` (auto-infers publisher from manifest) @@ -108,6 +130,8 @@ Note: The `package` command can sign automatically when you pass `--cert`, so yo | "Certificate not trusted" | Cert not installed on machine | `winapp cert install ./devcert.pfx` (admin) | | "Certificate file already exists" | `devcert.pfx` already present | Use `--if-exists overwrite` or `--if-exists skip` | | Signature invalid after time passes | No timestamp used during signing | Re-sign with `--timestamp http://timestamp.digicert.com` | +| `az-sign` fails with "No credentials found" | No Azure auth in environment | Run `az login`, or set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` for CI/CD | +| `az-sign` "No Trusted Signing accounts found" | No account in the subscription/resource group | Create a Trusted Signing account and certificate profile in the Azure portal | ## Command Reference @@ -181,3 +205,23 @@ Code-sign an MSIX package or executable. Example: winapp sign ./app.msix ./devce |--------|-------------|---------| | `--password` | Certificate password | `password` | | `--timestamp` | Timestamp server URL | (none) | + +### `winapp az-sign` + +Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes | Path to the file to sign (exe, msix, or msixbundle) | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--account` | Signing account name. Must be used with --resource-group | (none) | +| `--metadata-file` | Path to an existing metadata.json file. Skips resource discovery and account/profile selection prompts and signs using this file directly. A non-interactive Azure credential should already be available; the CLI can otherwise fall back to an interactive tenant prompt or 'az login', but the npm programmatic API is always non-interactive and fails instead of prompting. | (none) | +| `--profile` | Certificate profile name. Must be used with --account | (none) | +| `--resource-group` | Resource group to narrow down signing accounts | (none) | +| `--subscription` | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | (none) | diff --git a/README.md b/README.md index d501a5c14..c0020f2af 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ See also: [Debugging Guide](./docs/debugging.md) — choosing between `winapp ru - [`cert`](./docs/usage.md#cert) - Generate and install development certificates - [`sign`](./docs/usage.md#sign) - Sign MSIX packages and executables +- [`az-sign`](./docs/usage.md#az-sign) - Sign with Azure Trusted Signing (cloud-managed identity, no local PFX) - [`create-external-catalog`](./docs/usage.md#create-external-catalog) - Generate CodeIntegrityExternal.cat for TrustedLaunch sparse packages **Development Tools:** diff --git a/docs/cli-schema.json b/docs/cli-schema.json index db8884556..d26959304 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -50,6 +50,129 @@ } }, "subcommands": { + "az-sign": { + "description": "Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix", + "hidden": false, + "arguments": { + "file-path": { + "description": "Path to the file to sign (exe, msix, or msixbundle)", + "order": 0, + "hidden": false, + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + } + } + }, + "options": { + "--account": { + "description": "Signing account name. Must be used with --resource-group", + "hidden": false, + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--metadata-file": { + "description": "Path to an existing metadata.json file. Skips resource discovery and account/profile selection prompts and signs using this file directly. A non-interactive Azure credential should already be available; the CLI can otherwise fall back to an interactive tenant prompt or 'az login', but the npm programmatic API is always non-interactive and fails instead of prompting.", + "hidden": false, + "aliases": [ + "-m" + ], + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--profile": { + "description": "Certificate profile name. Must be used with --account", + "hidden": false, + "aliases": [ + "-p" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--resource-group": { + "description": "Resource group to narrow down signing accounts", + "hidden": false, + "aliases": [ + "-r" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--subscription": { + "description": "Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted.", + "hidden": false, + "aliases": [ + "-s" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } + }, "cert": { "description": "Manage development certificates for code signing. Use 'cert generate' to create a self-signed certificate for testing, or 'cert install' (requires elevation) to trust an existing certificate on this machine.", "hidden": false, diff --git a/docs/fragments/skills/winapp-cli/signing.md b/docs/fragments/skills/winapp-cli/signing.md index 0a9677723..5e679d258 100644 --- a/docs/fragments/skills/winapp-cli/signing.md +++ b/docs/fragments/skills/winapp-cli/signing.md @@ -4,6 +4,7 @@ Use this skill when: - **Generating a development certificate** for local MSIX signing and testing - **Installing (trusting) a certificate** on a machine so MSIX packages can be installed - **Signing an MSIX package or executable** for distribution +- **Signing with Azure Trusted Signing** (cloud-managed signing identity) via `winapp az-sign` ## Prerequisites @@ -75,6 +76,27 @@ When packaging multiple architectures into an `.msixbundle`, only the bundle nee Note: The `package` command can sign automatically when you pass `--cert`, so you often don't need `sign` separately. +### Sign with Azure Trusted Signing (cloud signing) + +For production-grade signing without managing a PFX file, use `winapp az-sign` to sign with [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/). The signing identity (certificate) is managed in Azure, so no private key ever lives on the machine. + +```powershell +# Interactive: discover subscription, account, and profile (prompts for any not provided) +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips resource discovery and identity selection; authentication may still be interactive) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +**Authentication:** `az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). In CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or GitHub Actions OIDC / managed identity). An existing Azure CLI session (`az login`, including the `azure/login` GitHub Action) is also honored in any environment. Only when no credentials are found *and* the session is interactive will `az-sign` launch `az login` for you. + +**Prerequisites:** An Azure Code Signing account and certificate profile (created in the Azure portal after identity validation), plus a role assignment granting your identity the **Code Signing Certificate Profile Signer** role. Signing also requires two machine-wide x64 runtimes that winapp does **not** install for you (it downloads the raw NuGet signing package, not the official client-tools installer): the **x64 .NET 8+ runtime** (the signing library is a managed assembly loaded by `signtool.exe`; winapp's self-contained runtime does not satisfy it) and the **x64 Visual C++ Redistributable** (https://aka.ms/vs/17/release/vc_redist.x64.exe). Also requires **SignTool 10.0.22621.755 or later**. If signing fails while *loading* the dlib (e.g. `0xc000007b` or a missing-DLL error) rather than during authentication, install the missing runtime — most often the VC++ Redistributable. + +> **Least-privilege CI:** Auto-discovery (listing subscriptions, resource groups, accounts, and profiles) needs read access at a parent scope. To avoid *every* collection-listing call, pass all four of `--subscription`, `--resource-group`, `--account`, and `--profile`: `az-sign` then validates the account and profile with direct resource reads (a GET on each named resource) instead of enumerating the parent collection, so a principal scoped to just that account and profile is sufficient. Omitting any one of them re-introduces a listing call — for example, leaving out `--subscription` makes `az-sign` list the subscriptions your identity can access — which a narrowly-scoped principal may not be permitted to do. A principal scoped only to a single certificate profile can skip validation entirely by passing a pre-generated `--metadata-file` (which specifies the account endpoint and profile directly). + ## Recommended workflow 1. **Generate cert** — `winapp cert generate` (auto-infers publisher from manifest) @@ -103,3 +125,5 @@ Note: The `package` command can sign automatically when you pass `--cert`, so yo | "Certificate not trusted" | Cert not installed on machine | `winapp cert install ./devcert.pfx` (admin) | | "Certificate file already exists" | `devcert.pfx` already present | Use `--if-exists overwrite` or `--if-exists skip` | | Signature invalid after time passes | No timestamp used during signing | Re-sign with `--timestamp http://timestamp.digicert.com` | +| `az-sign` fails with "No credentials found" | No Azure auth in environment | Run `az login`, or set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` for CI/CD | +| `az-sign` "No Trusted Signing accounts found" | No account in the subscription/resource group | Create a Trusted Signing account and certificate profile in the Azure portal | diff --git a/docs/npm-usage.md b/docs/npm-usage.md index f9819ed5f..06aefa5df 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -59,6 +59,29 @@ Result returned by every command wrapper. These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`). +### `azSign()` + +Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix + +```typescript +function azSign(options: AzSignOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `filePath` | `string` | Yes | Path to the file to sign (exe, msix, or msixbundle) | +| `account` | `string \| undefined` | No | Signing account name. Must be used with --resource-group | +| `metadataFile` | `string \| undefined` | No | Path to an existing metadata.json file. Skips resource discovery and account/profile selection prompts and signs using this file directly. A non-interactive Azure credential should already be available; the CLI can otherwise fall back to an interactive tenant prompt or 'az login', but the npm programmatic API is always non-interactive and fails instead of prompting. | +| `profile` | `string \| undefined` | No | Certificate profile name. Must be used with --account | +| `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts | +| `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* + +--- + ### `certGenerate()` Create a self-signed certificate for local testing only. Publisher must match the manifest (auto-inferred if --manifest provided or Package.appxmanifest is in working directory). Output: devcert.pfx (default password: 'password'). For production, obtain a certificate from a trusted CA. Use 'cert install' to trust on this machine. @@ -1247,6 +1270,20 @@ ManifestTemplates values. type ManifestTemplates = "packaged" | "sparse" ``` +### `AzSignOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `filePath` | `string` | Yes | Path to the file to sign (exe, msix, or msixbundle) | +| `account` | `string \| undefined` | No | Signing account name. Must be used with --resource-group | +| `metadataFile` | `string \| undefined` | No | Path to an existing metadata.json file. Skips resource discovery and account/profile selection prompts and signs using this file directly. A non-interactive Azure credential should already be available; the CLI can otherwise fall back to an interactive tenant prompt or 'az login', but the npm programmatic API is always non-interactive and fails instead of prompting. | +| `profile` | `string \| undefined` | No | Certificate profile name. Must be used with --account | +| `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts | +| `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | + ### `CertGenerateOptions` | Property | Type | Required | Description | diff --git a/docs/usage.md b/docs/usage.md index e8f9a7a55..2e17d572d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -736,6 +736,53 @@ winapp sign ./bin/MyApp.exe --cert ./mycert.pfx --cert-password mypassword --- +### az-sign + +Code-sign a file (exe, MSIX, or MSIX bundle) using [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/) — a cloud-managed signing identity, so no private key (PFX) ever lives on the local machine. + +```bash +winapp az-sign [options] +``` + +**Arguments:** + +- `file-path` - Path to the file to sign (exe, msix, or msixbundle) + +**Options:** + +- `--subscription`, `-s` - Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted +- `--resource-group`, `-r` - Resource group to narrow down signing accounts +- `--account` - Signing account name. Must be used with `--resource-group` +- `--profile`, `-p` - Certificate profile name. Must be used with `--account` +- `--metadata-file`, `-m` - Path to an existing `metadata.json`. Skips resource discovery and account/profile selection prompts and signs directly. A non-interactive Azure credential should already be available; the CLI can otherwise fall back to an interactive tenant prompt or `az login`, but the npm programmatic API is always non-interactive and fails instead of prompting + +**Authentication:** + +`az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). For CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or use GitHub Actions OIDC / managed identity). An existing Azure CLI session (`az login`, including the `azure/login` GitHub Action) is also honored in any environment. Only when no credentials are found *and* the session is interactive will `az-sign` launch `az login` for you. + +**Prerequisites:** + +- An Azure Code Signing account and a certificate profile (created in the Azure portal after identity validation), plus the **Code Signing Certificate Profile Signer** role assigned to your identity. For more guidance, visit [Azure Artifact Signing quickstart docs.](https://learn.microsoft.com/azure/artifact-signing/quickstart) +- A machine-wide **x64 .NET 8 (or later) runtime** installed. The Azure signing client library is a managed assembly that `signtool.exe` loads in a separate process; winapp's own self-contained runtime does not satisfy it. Install it from https://dotnet.microsoft.com/download if signing fails with a runtime-load error. +- The **Microsoft Visual C++ Redistributable (x64)**. The Azure signing client library depends on the VC++ runtime, and because winapp downloads the raw NuGet package rather than the official client-tools installer, this dependency is **not** installed automatically. A clean machine can load-fail even with .NET and SignTool present. Install the latest x64 redistributable from https://aka.ms/vs/17/release/vc_redist.x64.exe if signing fails with a `0xc000007b`, "The application was unable to start correctly", or missing-DLL error from the dlib. + +> **Least-privilege CI:** Auto-discovery (listing subscriptions, resource groups, accounts, and profiles) needs read access at a parent scope. To avoid *every* collection-listing call, pass all four of `--subscription`, `--resource-group`, `--account`, and `--profile`: `az-sign` then validates the account and profile with direct resource reads (a GET on each named resource) instead of enumerating the parent collection, so a principal scoped to just that account and profile is sufficient. Omitting any one of them re-introduces a listing call — for example, leaving out `--subscription` makes `az-sign` list the subscriptions your identity can access — which a narrowly-scoped principal may not be permitted to do. A principal scoped only to a single certificate profile can skip validation entirely by passing a pre-generated `--metadata-file` (which specifies the account endpoint and profile directly). + +**Examples:** + +```bash +# Interactive — discover/select subscription, account, and profile +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips resource discovery and selection; authentication may still prompt) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +--- + ### create-external-catalog Generate a `CodeIntegrityExternal.cat` catalog file containing hashes of executable files from specified directories. This catalog is used with the [TrustedLaunch](https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-trustedlaunch-trustedlaunch) flag in MSIX sparse package manifests ([AllowExternalContent](https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-uap10-allowexternalcontent)) to allow execution of external files not included in the package itself. diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index e0779b380..e96530721 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -100,7 +100,7 @@ $SkillCommandMap = @{ "setup" = @("init", "restore", "update", "run") "package" = @("package", "create-external-catalog") "identity" = @("create-debug-identity") - "signing" = @("cert generate", "cert install", "cert info", "sign") + "signing" = @("cert generate", "cert install", "cert info", "sign", "az-sign") "manifest" = @("manifest generate", "manifest update-assets", "manifest add-alias") "troubleshoot" = @("get-winapp-path", "tool", "store") "frameworks" = @() # No auto-generated command sections — links to guides diff --git a/src/winapp-CLI/Directory.Packages.props b/src/winapp-CLI/Directory.Packages.props index b52bc83be..4a5db9a0f 100644 --- a/src/winapp-CLI/Directory.Packages.props +++ b/src/winapp-CLI/Directory.Packages.props @@ -1,5 +1,6 @@ + @@ -19,4 +20,4 @@ - + \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzSignCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzSignCommandTests.cs new file mode 100644 index 000000000..f66844261 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzSignCommandTests.cs @@ -0,0 +1,768 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using WinApp.Cli.Commands; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AzSignCommandTests : BaseCommandTests +{ + private FakeAzureAuthService _fakeAuthService = null!; + private FakeAzureSigningService _fakeSigningService = null!; + private FakeAzureSignToolService _fakeSignToolService = null!; + private AzSignCommand _command = null!; + + // A complete, valid dlib metadata document (trusted endpoint + account + profile) for tests that + // exercise the happy path of supplying a metadata file directly. + private const string ValidMetadataJson = + "{\"Endpoint\":\"https://eus.codesigning.azure.net\",\"CodeSigningAccountName\":\"myaccount\",\"CertificateProfileName\":\"myprofile\"}"; + + [TestInitialize] + public void Setup() + { + _fakeAuthService = (FakeAzureAuthService)GetRequiredService(); + _fakeSigningService = (FakeAzureSigningService)GetRequiredService(); + _fakeSignToolService = (FakeAzureSignToolService)GetRequiredService(); + _command = GetRequiredService(); + } + + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + return services + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } + + [TestMethod] + public async Task AzSign_AccountWithoutResourceGroup_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--account", "myaccount", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "--account must be used with --resource-group"); + } + + [TestMethod] + public async Task AzSign_ProfileWithoutAccount_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--profile", "myprofile", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "--profile must be used with --account"); + } + + [TestMethod] + public async Task AzSign_AuthenticationFails_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeAuthService.ShouldFail = true; + _fakeAuthService.FailureMessage = "Azure authentication failed. No credentials found."; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "Azure authentication failed"); + } + + [TestMethod] + public async Task AzSign_NoSubscriptionsFound_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "No Azure subscriptions found"); + } + + [TestMethod] + public async Task AzSign_NoSigningAccountsFound_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "No signing accounts found"); + } + + [TestMethod] + public async Task AzSign_NoProfilesFound_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "No certificate profiles found"); + } + + [TestMethod] + public async Task AzSign_MetadataFileNotFound_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var nonExistentMetadata = Path.Join(_tempDirectory.FullName, "nonexistent.json"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", nonExistentMetadata, filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "Metadata file not found"); + } + + [TestMethod] + public async Task AzSign_SpecifiedAccountNotFound_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "nonexistent", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "not found"); + } + + [TestMethod] + public async Task AzSign_SingleSubscription_AutoSelects() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = + [ + new CertificateProfile("myprofile", "PublicTrust", "Active") + ]; + + // Accept the confirmation prompt (single sub/account/profile are auto-selected first). + TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + // Reaches the signing stage and succeeds via the faked sign-tool service. + Assert.AreEqual(0, result); + Assert.AreEqual(1, _fakeSignToolService.CallCount, "Should reach the signing stage"); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + Assert.IsFalse(allOutput.Contains("No Azure subscriptions found"), "Should not fail at subscription selection"); + Assert.IsFalse(allOutput.Contains("No signing accounts found"), "Should not fail at account selection"); + Assert.IsFalse(allOutput.Contains("No certificate profiles found"), "Should not fail at profile selection"); + } + + [TestMethod] + public async Task AzSign_MultipleSubscriptions_WithFlag_SkipsPrompt() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Sub 1"), + new AzureSubscription("sub-456", "Sub 2") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = + [ + new CertificateProfile("myprofile", "PublicTrust", "Active") + ]; + + // Account/profile are auto-selected (single), so the sign confirmation still prompts. + TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); + + // Using --subscription flag should skip the subscription prompt + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--subscription", "sub-123", filePath]); + + Assert.AreEqual(0, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + Assert.IsFalse(allOutput.Contains("Select an Azure subscription"), "Should not prompt for subscription when flag is provided"); + } + + [TestMethod] + public async Task AzSign_AllFlagsProvided_SkipsAllPrompting() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = + [ + new CertificateProfile("myprofile", "PublicTrust", "Active") + ]; + + // No prompt input pushed: with account+profile supplied, no confirmation should be required. + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(0, result); + Assert.AreEqual(1, _fakeSignToolService.CallCount, "Should reach the signing stage without prompting"); + Assert.AreEqual("fake-tenant-id", _fakeSignToolService.LastTenantId, "Tenant ID should be forwarded to signtool"); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + Assert.IsFalse(allOutput.Contains("Select"), "Should not show any selection prompts when all flags are provided"); + Assert.IsFalse(allOutput.Contains("Sign with profile"), "Should not show a confirmation prompt when account and profile are explicit"); + } + + [TestMethod] + public async Task AzSign_AllFlagsProvided_GeneratesMetadataAndCleansUp() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("myprofile", "PublicTrust", "Active")]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(0, result); + + // The generated metadata file should contain the resolved signing identity... + Assert.IsNotNull(_fakeSignToolService.LastMetadataContent); + StringAssert.Contains(_fakeSignToolService.LastMetadataContent, "myaccount"); + StringAssert.Contains(_fakeSignToolService.LastMetadataContent, "myprofile"); + StringAssert.Contains(_fakeSignToolService.LastMetadataContent, "https://eus.codesigning.azure.net"); + + // ...and the temp file must be cleaned up afterward. + Assert.IsNotNull(_fakeSignToolService.LastMetadataFilePath); + Assert.IsFalse(File.Exists(_fakeSignToolService.LastMetadataFilePath!.FullName), "Generated metadata file should be deleted after signing"); + } + + [TestMethod] + public async Task AzSign_WithMetadataFile_UsesFileDirectly_DoesNotDelete() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + await File.WriteAllTextAsync(metadataPath, ValidMetadataJson); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + Assert.AreEqual(0, result); + Assert.AreEqual(1, _fakeSignToolService.CallCount); + Assert.AreEqual(metadataPath, _fakeSignToolService.LastMetadataFilePath!.FullName); + // A user-supplied metadata file must not be deleted. + Assert.IsTrue(File.Exists(metadataPath), "User-provided metadata file should not be deleted"); + // The metadata-file path must still run credential/login preparation (the only path that + // performs the advertised `az login` fallback), even though it skips ARM resource discovery. + Assert.AreEqual(1, _fakeAuthService.GetAccessTokenCallCount, + "The metadata-file path should prepare Azure credentials so the dlib can authenticate"); + } + + [TestMethod] + public async Task AzSign_WithMetadataFile_WhenAuthFails_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + await File.WriteAllTextAsync(metadataPath, ValidMetadataJson); + + _fakeAuthService.ShouldFail = true; + _fakeAuthService.FailureMessage = "Azure authentication failed. No credentials found."; + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + // Authentication failure on the metadata-file path must surface as an error rather than being + // deferred to an opaque failure deep inside the dlib. + Assert.AreEqual(1, result); + Assert.AreEqual(0, _fakeSignToolService.CallCount, "Signing should not run when credential preparation fails"); + } + + [TestMethod] + public async Task AzSign_SignToolFails_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("myprofile", "PublicTrust", "Active")]; + _fakeSignToolService.ShouldFail = true; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "signtool.exe execution failed"); + + // The generated metadata temp file must be cleaned up even when signing fails. + Assert.IsNotNull(_fakeSignToolService.LastMetadataFilePath); + Assert.IsFalse(File.Exists(_fakeSignToolService.LastMetadataFilePath!.FullName), + "Generated metadata file should be deleted after a signing failure"); + } + + [TestMethod] + public async Task AzSign_NonInteractive_MultipleSubscriptions_ReturnsActionableError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeAuthService.IsInteractive = false; + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Sub 1"), + new AzureSubscription("sub-456", "Sub 2") + ]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "--subscription"); + } + + [TestMethod] + public async Task AzSign_NonInteractive_MultipleAccounts_ReturnsActionableError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeAuthService.IsInteractive = false; + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("account-a", "rg-a", "eastus", "https://eus.codesigning.azure.net"), + new SigningAccount("account-b", "rg-b", "westus", "https://wus.codesigning.azure.net") + ]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--subscription", "sub-123", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "--account"); + } + + [TestMethod] + public async Task AzSign_NonInteractive_MultipleProfiles_ReturnsActionableError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeAuthService.IsInteractive = false; + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = + [ + new CertificateProfile("profile-a", "PublicTrust", "Active"), + new CertificateProfile("profile-b", "PrivateTrust", "Active") + ]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--subscription", "sub-123", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "--profile"); + } + + [TestMethod] + public async Task AzSign_WhitespaceOnlyProfile_IsTreatedAsNotProvided() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("myprofile", "PublicTrust", "Active")]; + + // A whitespace-only --profile must normalize to "not provided" rather than tripping the + // "--profile must be used with --account" dependency check; the single profile auto-selects. + TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--profile", " ", filePath]); + + Assert.AreEqual(0, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + Assert.IsFalse(allOutput.Contains("--profile must be used with --account"), + "Whitespace-only --profile should be treated as not provided"); + Assert.AreEqual(1, _fakeSignToolService.CallCount, "Should reach the signing stage"); + } + + [TestMethod] + public async Task AzSign_MetadataFileCombinedWithResourceFlags_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + await File.WriteAllTextAsync(metadataPath, "{\"Endpoint\":\"https://eus.codesigning.azure.net\"}"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--metadata-file", metadataPath, "--account", "myaccount", "--resource-group", "myrg", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "--metadata-file cannot be combined"); + Assert.AreEqual(0, _fakeSignToolService.CallCount, "Signing must not run when inputs are ambiguous"); + } + + [TestMethod] + public async Task AzSign_MetadataFileWithUntrustedEndpoint_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + await File.WriteAllTextAsync(metadataPath, "{\"Endpoint\":\"https://evil.example.com\"}"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "untrusted Endpoint"); + Assert.AreEqual(0, _fakeSignToolService.CallCount, "Must not sign with an untrusted endpoint"); + } + + [TestMethod] + public async Task AzSign_DiscoveredAccountUriUntrusted_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + // The auto-discovery path must apply the same trusted-host pin as --metadata-file. Here ARM + // returns an accountUri on an attacker-controlled host; signing must be refused before the + // access token could ever be handed to the dlib for that endpoint. + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://evil.example.com")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("myprofile", "PublicTrust", "Active")]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "untrusted Endpoint"); + Assert.AreEqual(0, _fakeSignToolService.CallCount, "Must not sign with an untrusted discovered endpoint"); + } + + [TestMethod] + [DoNotParallelize] + public async Task GenerateMetadataFileAsync_WhenCancelledDuringWrite_LeavesNoTempFile() + { + // The cleanup catch in GenerateMetadataFileAsync must delete the partially-created temp file + // when generation is cancelled (or faults) mid-write, so no orphaned winapp-az-sign-*.json is + // left in %TEMP%. + var before = Directory.GetFiles(Path.GetTempPath(), "winapp-az-sign-*.json").Length; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var metadata = new AzSignCommand.Handler.SigningMetadata("https://eus.codesigning.azure.net", "myaccount", "myprofile"); + + var threw = false; + try + { + await AzSignCommand.Handler.GenerateMetadataFileAsync(metadata, cts.Token); + } + catch (OperationCanceledException) + { + threw = true; + } + + Assert.IsTrue(threw, "Cancellation during metadata generation should surface as an OperationCanceledException"); + + var after = Directory.GetFiles(Path.GetTempPath(), "winapp-az-sign-*.json").Length; + Assert.AreEqual(before, after, + "A cancelled metadata generation must not leave an orphaned temp file behind"); + } + + [TestMethod] + public async Task AzSign_MetadataFileMissingEndpoint_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + await File.WriteAllTextAsync(metadataPath, "{\"CertificateProfileName\":\"myprofile\"}"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "Endpoint"); + Assert.AreEqual(0, _fakeSignToolService.CallCount); + } + + [TestMethod] + public async Task AzSign_MetadataFileMissingAccountName_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + // Trusted endpoint and profile present, but no CodeSigningAccountName. + await File.WriteAllTextAsync(metadataPath, + "{\"Endpoint\":\"https://eus.codesigning.azure.net\",\"CertificateProfileName\":\"myprofile\"}"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "CodeSigningAccountName"); + // Validation must fail before any Azure work or signing. + Assert.AreEqual(0, _fakeAuthService.GetAccessTokenCallCount, "Must not authenticate with incomplete metadata"); + Assert.AreEqual(0, _fakeSignToolService.CallCount, "Must not sign with incomplete metadata"); + } + + [TestMethod] + public async Task AzSign_MetadataFileMissingProfileName_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + // Trusted endpoint and account present, but no CertificateProfileName. + await File.WriteAllTextAsync(metadataPath, + "{\"Endpoint\":\"https://eus.codesigning.azure.net\",\"CodeSigningAccountName\":\"myaccount\"}"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "CertificateProfileName"); + Assert.AreEqual(0, _fakeAuthService.GetAccessTokenCallCount, "Must not authenticate with incomplete metadata"); + Assert.AreEqual(0, _fakeSignToolService.CallCount, "Must not sign with incomplete metadata"); + } + + [TestMethod] + public async Task AzSign_MetadataFileWithNonObjectRoot_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Join(_tempDirectory.FullName, "metadata.json"); + await File.WriteAllTextAsync(metadataPath, "[\"not\",\"an\",\"object\"]"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "must contain a JSON object"); + Assert.AreEqual(0, _fakeSignToolService.CallCount); + } + + [TestMethod] + public async Task AzSign_ExplicitProfileNotFound_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("otherprofile", "PublicTrust", "Active")]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "missingprofile", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "not found"); + Assert.AreEqual(0, _fakeSignToolService.CallCount); + } + + [TestMethod] + public async Task AzSign_ExplicitProfileNotActive_ReturnsError() + { + var filePath = Path.Join(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("myprofile", "PublicTrust", "Suspended")]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(1, result); + var allOutput = $"{ConsoleStdOut}{ConsoleStdErr}"; + StringAssert.Contains(allOutput, "Suspended"); + Assert.AreEqual(0, _fakeSignToolService.CallCount); + } + + [TestMethod] + [DataRow("https://eus.codesigning.azure.net", true)] + [DataRow("https://weu.codesigning.azure.net/", true)] + [DataRow("https://codesigning.azure.net", true)] + [DataRow("https://EUS.CodeSigning.Azure.Net", true)] + [DataRow("https://evil-codesigning.azure.net", false)] + [DataRow("https://codesigning.azure.net.evil.com", false)] + [DataRow("http://eus.codesigning.azure.net", false)] + [DataRow("ftp://eus.codesigning.azure.net", false)] + [DataRow("eus.codesigning.azure.net", false)] + [DataRow("not a url", false)] + public void IsTrustedSigningEndpoint_ValidatesHostAndScheme(string endpoint, bool expected) + { + Assert.AreEqual(expected, AzSignCommand.Handler.IsTrustedSigningEndpoint(endpoint)); + } +} + +internal class FakeAzureAuthService : IAzureAuthService +{ + public bool ShouldFail { get; set; } + public string FailureMessage { get; set; } = "Authentication failed"; + public bool IsInteractive { get; set; } = true; + public string? TenantId { get; set; } = "fake-tenant-id"; + public int GetAccessTokenCallCount { get; private set; } + + public Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken = default) + { + GetAccessTokenCallCount++; + if (ShouldFail) + { + throw new InvalidOperationException(FailureMessage); + } + return Task.FromResult("fake-access-token"); + } +} + +internal class FakeAzureSignToolService : IAzureSignToolService +{ + public bool ShouldFail { get; set; } + public int CallCount { get; private set; } + public FileInfo? LastFilePath { get; private set; } + public FileInfo? LastMetadataFilePath { get; private set; } + public string? LastMetadataContent { get; private set; } + public string? LastTenantId { get; private set; } + + public async Task SignAsync( + FileInfo filePath, + FileInfo metadataFilePath, + string? tenantId, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + CallCount++; + LastFilePath = filePath; + LastMetadataFilePath = metadataFilePath; + LastTenantId = tenantId; + + // Capture the metadata contents now, since the handler deletes generated files after we return. + metadataFilePath.Refresh(); + if (metadataFilePath.Exists) + { + LastMetadataContent = await File.ReadAllTextAsync(metadataFilePath.FullName, cancellationToken); + } + + if (ShouldFail) + { + throw new InvalidOperationException("signtool.exe execution failed with exit code 1."); + } + } +} + +internal class FakeAzureSigningService : IAzureSigningService +{ + public IReadOnlyList Subscriptions { get; set; } = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + + public IReadOnlyList SigningAccounts { get; set; } = + [ + new SigningAccount("test-account", "test-rg", "eastus", "https://eus.codesigning.azure.net") + ]; + + public IReadOnlyList CertificateProfiles { get; set; } = + [ + new CertificateProfile("test-profile", "PublicTrust", "Active") + ]; + + public Task> ListSubscriptionsAsync(string accessToken, CancellationToken cancellationToken = default) + { + return Task.FromResult(Subscriptions); + } + + public Task> ListSigningAccountsAsync(string accessToken, string subscriptionId, string? resourceGroup = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(SigningAccounts); + } + + public Task GetSigningAccountAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default) + { + var match = SigningAccounts.FirstOrDefault(a => + string.Equals(a.Name, accountName, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(match); + } + + public Task> ListCertificateProfilesAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default) + { + return Task.FromResult(CertificateProfiles); + } + + public Task GetCertificateProfileAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, string profileName, CancellationToken cancellationToken = default) + { + var match = CertificateProfiles.FirstOrDefault(p => + string.Equals(p.Name, profileName, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(match); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzureAuthServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzureAuthServiceTests.cs new file mode 100644 index 000000000..13e1a6f1a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzureAuthServiceTests.cs @@ -0,0 +1,485 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console.Testing; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AzureAuthServiceTests +{ + [TestMethod] + [DataRow("72f988bf-86f1-41af-91ab-2d7cd011db47")] // GUID tenant + [DataRow("contoso.onmicrosoft.com")] // domain tenant + [DataRow("my-tenant.example.co.uk")] + public void IsValidTenantId_AcceptsGuidsAndDomains(string tenantId) + { + Assert.IsTrue(AzureAuthService.IsValidTenantId(tenantId)); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow("not a tenant")] // whitespace + [DataRow("tenant && rm -rf /")] // shell metacharacters + [DataRow("--query \"[].id\"")] // extra CLI argument injection + [DataRow("tenant\"; calc.exe; \"")] + [DataRow("tenant`whoami`")] + [DataRow("contoso.onmicrosoft.com --debug")] + public void IsValidTenantId_RejectsInvalidOrUnsafeInput(string tenantId) + { + Assert.IsFalse(AzureAuthService.IsValidTenantId(tenantId)); + } + + [TestMethod] + public void IsTrustedAzureCliPath_RejectsFileOutsideInstallRoots() + { + var installRoot = Directory.CreateTempSubdirectory("winapp-aztrust-root").FullName; + var elsewhere = Directory.CreateTempSubdirectory("winapp-aztrust-cwd").FullName; + try + { + var azPath = Path.Join(elsewhere, "az.cmd"); + File.WriteAllText(azPath, "@echo off"); + + Assert.IsFalse(AzureAuthService.IsTrustedAzureCliPath(azPath, [installRoot])); + } + finally + { + Directory.Delete(installRoot, recursive: true); + Directory.Delete(elsewhere, recursive: true); + } + } + + [TestMethod] + public void IsTrustedAzureCliPath_RejectsRepoControlledSiblingOutsideWorkingSubtree() + { + // A repo-controlled az.cmd (e.g. node_modules\.bin) is outside the working subtree but is + // still attacker-controlled, so it must not be trusted just for being "outside cwd". + var installRoot = Directory.CreateTempSubdirectory("winapp-aztrust-root2").FullName; + var repo = Directory.CreateTempSubdirectory("winapp-aztrust-repo").FullName; + try + { + var subDir = Path.Join(repo, "node_modules", ".bin"); + Directory.CreateDirectory(subDir); + var azPath = Path.Join(subDir, "az.cmd"); + File.WriteAllText(azPath, "@echo off"); + + Assert.IsFalse(AzureAuthService.IsTrustedAzureCliPath(azPath, [installRoot])); + } + finally + { + Directory.Delete(installRoot, recursive: true); + Directory.Delete(repo, recursive: true); + } + } + + [TestMethod] + public void IsTrustedAzureCliPath_AcceptsFileUnderKnownInstallRoot() + { + var installRoot = Directory.CreateTempSubdirectory("winapp-aztrust-install").FullName; + try + { + var wbin = Path.Join(installRoot, "wbin"); + Directory.CreateDirectory(wbin); + var azPath = Path.Join(wbin, "az.cmd"); + File.WriteAllText(azPath, "@echo off"); + + Assert.IsTrue(AzureAuthService.IsTrustedAzureCliPath(azPath, [installRoot])); + } + finally + { + Directory.Delete(installRoot, recursive: true); + } + } + + [TestMethod] + public void IsTrustedAzureCliPath_RejectsNonExistentOrRelativePath() + { + var installRoot = Directory.CreateTempSubdirectory("winapp-aztrust-none").FullName; + try + { + Assert.IsFalse(AzureAuthService.IsTrustedAzureCliPath(Path.Join(installRoot, "missing.cmd"), [installRoot])); + Assert.IsFalse(AzureAuthService.IsTrustedAzureCliPath("az.cmd", [installRoot])); + } + finally + { + Directory.Delete(installRoot, recursive: true); + } + } + + [TestMethod] + public void SelectFirstTrustedAzureCliPath_SkipsUntrustedHitAndReturnsLaterTrustedPath() + { + var elsewhere = Directory.CreateTempSubdirectory("winapp-azwhere-cwd").FullName; + var install = Directory.CreateTempSubdirectory("winapp-azwhere-install").FullName; + try + { + // where.exe lists a hijacked copy outside any install root first, then a legitimate one. + var hijack = Path.Join(elsewhere, "az.cmd"); + File.WriteAllText(hijack, "@echo off"); + var trusted = Path.Join(install, "az.cmd"); + File.WriteAllText(trusted, "@echo off"); + + var whereOutput = $"{hijack}\r\n{trusted}\r\n"; + + var result = AzureAuthService.SelectFirstTrustedAzureCliPath(whereOutput, [install]); + + Assert.AreEqual(trusted, result, "The untrusted hit must be skipped in favor of the trusted install path"); + } + finally + { + Directory.Delete(elsewhere, recursive: true); + Directory.Delete(install, recursive: true); + } + } + + [TestMethod] + public void SelectFirstTrustedAzureCliPath_WhenAllCandidatesUntrusted_ReturnsNull() + { + var installRoot = Directory.CreateTempSubdirectory("winapp-azwhere-root").FullName; + var elsewhere = Directory.CreateTempSubdirectory("winapp-azwhere-none").FullName; + try + { + var hijack = Path.Join(elsewhere, "az.cmd"); + File.WriteAllText(hijack, "@echo off"); + var subHijack = Path.Join(elsewhere, "sub"); + Directory.CreateDirectory(subHijack); + var subHijackCmd = Path.Join(subHijack, "az.cmd"); + File.WriteAllText(subHijackCmd, "@echo off"); + + var whereOutput = $"{hijack}\n{subHijackCmd}\n"; + + Assert.IsNull(AzureAuthService.SelectFirstTrustedAzureCliPath(whereOutput, [installRoot])); + Assert.IsNull(AzureAuthService.SelectFirstTrustedAzureCliPath(string.Empty, [installRoot])); + } + finally + { + Directory.Delete(installRoot, recursive: true); + Directory.Delete(elsewhere, recursive: true); + } + } + + private const string ArmScope = "https://management.azure.com/.default"; + + [TestMethod] + public async Task GetAccessTokenAsync_WhenPrimaryCredentialSucceeds_ReturnsToken() + { + using var service = new TestableAzureAuthService + { + PrimaryCredential = new StubCredential("primary-token"), + }; + + var token = await service.GetAccessTokenAsync(ArmScope); + + Assert.AreEqual("primary-token", token); + Assert.AreEqual(0, service.RunAzLoginCallCount, "Should not fall back to az login when the primary credential works"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_NonInteractiveAndCredentialFails_ThrowsCiGuidance() + { + using var service = new TestableAzureAuthService + { + InteractiveOverride = false, + PrimaryCredential = new ThrowingCredential(), + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "AZURE_CLIENT_SECRET"); + Assert.AreEqual(0, service.RunAzLoginCallCount, "Non-interactive sessions must never launch az login"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InteractiveButNoAzureCli_ThrowsInstallGuidance() + { + using var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = null, + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "Install the Azure CLI"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_ExistingCliSession_ReturnsTokenWithoutLogin() + { + using var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + HasExistingSession = true, + SeedTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", + }; + + var token = await service.GetAccessTokenAsync(ArmScope); + + Assert.AreEqual("cli-token", token); + Assert.AreEqual(0, service.RunAzLoginCallCount, "A valid cached session must not trigger an interactive login"); + + // The token must be minted through the validated absolute az path, never an ambient lookup. + var tokenRequest = service.ProcessRunner.Requests.Single(); + Assert.AreEqual(@"C:\fake\az.cmd", tokenRequest.FileName); + string[] expectedTokenArgs = ["account", "get-access-token", "--scope", ArmScope, "--output", "json", "--tenant", "72f988bf-86f1-41af-91ab-2d7cd011db47"]; + CollectionAssert.AreEqual(expectedTokenArgs, tokenRequest.Arguments.ToArray()); + } + + [TestMethod] + public async Task GetAccessTokenAsync_NonInteractiveExistingCliSession_ReturnsTokenWithoutLogin() + { + // Regression: DefaultAzureCredential excludes AzureCliCredential, so a CI job authenticated via + // 'az login' (e.g. the azure/login GitHub Action) is only reachable through the explicit CLI + // token path. That path must run even when non-interactive, rather than throwing CI guidance. + using var service = new TestableAzureAuthService + { + InteractiveOverride = false, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + HasExistingSession = true, + SeedTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", + }; + + var token = await service.GetAccessTokenAsync(ArmScope); + + Assert.AreEqual("cli-token", token); + Assert.AreEqual(0, service.RunAzLoginCallCount, "Non-interactive sessions must never launch az login"); + + // The token must still be minted through the validated absolute az path, never an ambient lookup. + var tokenRequest = service.ProcessRunner.Requests.Single(); + Assert.AreEqual(@"C:\fake\az.cmd", tokenRequest.FileName); + } + + [TestMethod] + public async Task GetAccessTokenAsync_NonInteractiveCliInstalledButNoSession_ThrowsCiGuidanceWithoutLogin() + { + // The CLI is present but has no cached session; a non-interactive environment cannot launch an + // interactive login, so it must surface CI guidance rather than prompting. + using var service = new TestableAzureAuthService + { + InteractiveOverride = false, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + HasExistingSession = false, + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "AZURE_CLIENT_SECRET"); + Assert.AreEqual(0, service.RunAzLoginCallCount, "Non-interactive sessions must never launch az login"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InteractiveLoginSucceeds_RetriesViaValidatedAzPath() + { + using var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + LoginResult = true, + SeedTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", + }; + + var token = await service.GetAccessTokenAsync(ArmScope); + + Assert.AreEqual("cli-token", token); + Assert.AreEqual(1, service.RunAzLoginCallCount); + Assert.AreEqual("72f988bf-86f1-41af-91ab-2d7cd011db47", service.LastLoginTenantId); + + // The login itself must run against the validated absolute path with a list-form argument + // for the (validated) tenant — never a concatenated command string. + var loginRequest = service.ProcessRunner.Requests.Single(r => r.Arguments.Count > 0 && r.Arguments[0] == "login"); + Assert.AreEqual(@"C:\fake\az.cmd", loginRequest.FileName); + string[] expectedLoginArgs = ["login", "--tenant", "72f988bf-86f1-41af-91ab-2d7cd011db47"]; + CollectionAssert.AreEqual(expectedLoginArgs, loginRequest.Arguments.ToArray()); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InteractiveLoginFails_Throws() + { + using var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + LoginResult = false, + SeedTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "Azure CLI login failed"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InteractiveLoginSucceedsButRetryTokenFails_Throws() + { + using var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + LoginResult = true, + TokenAlwaysFails = true, + SeedTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "retrieving an access token failed"); + Assert.AreEqual(1, service.RunAzLoginCallCount); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InvalidPresetTenantId_Throws() + { + using var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + LoginResult = true, + SeedTenantId = "not a valid tenant", + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "Invalid AZURE_TENANT_ID"); + Assert.AreEqual(0, service.RunAzLoginCallCount, "Login must not run with an invalid tenant id"); + Assert.AreEqual(0, service.ProcessRunner.Requests.Count, "An invalid tenant must never reach the Azure CLI"); + } + + private sealed class TestableAzureAuthService : AzureAuthService, IDisposable + { + private readonly TestConsole _console; + + public TestableAzureAuthService() : this(new TestConsole(), new FakeProcessRunner()) { } + + private TestableAzureAuthService(TestConsole console, FakeProcessRunner processRunner) + : base(NullLogger.Instance, console, processRunner) + { + _console = console; + ProcessRunner = processRunner; + } + + public FakeProcessRunner ProcessRunner { get; } + + public void Dispose() + { + _console.Dispose(); + } + + public bool? InteractiveOverride { get; init; } + public TokenCredential PrimaryCredential { get; init; } = new ThrowingCredential(); + public string? AzCliPath { get; init; } + + // Convenience wrappers so tests read/configure through the injected fake runner. + public bool HasExistingSession { init => ProcessRunner.HasSession = value; } + public bool LoginResult { init => ProcessRunner.LoginSucceeds = value; } + public bool TokenAlwaysFails { init => ProcessRunner.TokenAlwaysFails = value; } + public int RunAzLoginCallCount => ProcessRunner.LoginCallCount; + public string? LastLoginTenantId => ProcessRunner.LastLoginTenantId; + + // Pre-seed the tenant so the interactive paths don't block on a prompt. + public string? SeedTenantId { init => TenantId = value; } + + public override bool IsInteractive => InteractiveOverride ?? base.IsInteractive; + + protected override TokenCredential CreateCredential() => PrimaryCredential; + + protected override string? FindAzureCli() => AzCliPath; + } + + /// + /// A fake that emulates the Azure CLI: it services the login and + /// account get-access-token invocations the real service constructs, so the argument + /// building, tenant forwarding, and token parsing run for real without launching a process. + /// + private sealed class FakeProcessRunner : IProcessRunner + { + public bool HasSession { get; set; } + public bool LoginSucceeds { get; set; } = true; + public bool TokenAlwaysFails { get; set; } + public string TokenValue { get; set; } = "cli-token"; + public int LoginCallCount { get; private set; } + public string? LastLoginTenantId { get; private set; } + public List Requests { get; } = []; + + public Task RunAsync( + ProcessRunRequest request, + Action? onOutputLine = null, + Action? onErrorLine = null, + CancellationToken cancellationToken = default) + { + Requests.Add(request); + var args = request.Arguments; + + if (args.Count > 0 && args[0] == "login") + { + LoginCallCount++; + LastLoginTenantId = ArgValue(args, "--tenant"); + if (LoginSucceeds) + { + HasSession = true; + } + + return Task.FromResult(new ProcessRunResult(LoginSucceeds ? 0 : 1, string.Empty, LoginSucceeds ? string.Empty : "login failed")); + } + + // account get-access-token + if (TokenAlwaysFails || !HasSession) + { + return Task.FromResult(new ProcessRunResult(1, string.Empty, "Please run 'az login'.")); + } + + var json = $$"""{"accessToken":"{{TokenValue}}","expiresOn":"2999-01-01 00:00:00.000000","tokenType":"Bearer"}"""; + return Task.FromResult(new ProcessRunResult(0, json, string.Empty)); + } + + private static string? ArgValue(IReadOnlyList args, string name) + { + for (var i = 0; i < args.Count - 1; i++) + { + if (args[i] == name) + { + return args[i + 1]; + } + } + + return null; + } + } + + private sealed class StubCredential(string token) : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(token, DateTimeOffset.UtcNow.AddHours(1)); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetToken(requestContext, cancellationToken)); + } + + private sealed class ThrowingCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => throw new AuthenticationFailedException("no credentials"); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => throw new AuthenticationFailedException("no credentials"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzureSignToolServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzureSignToolServiceTests.cs new file mode 100644 index 000000000..f851f74ca --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzureSignToolServiceTests.cs @@ -0,0 +1,415 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Services; +using WinApp.Cli.Tools; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AzureSignToolServiceTests : BaseCommandTests +{ + public AzureSignToolServiceTests() : base(configPaths: false) + { + } + + private static readonly string PackageDirName = AzureSignToolService.ArtifactSigningClientPackage.ToLowerInvariant(); + private const string DlibDllName = "Azure.CodeSigning.Dlib.dll"; + + private DirectoryInfo _nugetCacheDir = null!; + private DirectoryInfo _globalWinappDir = null!; + private FakeSignToolNugetService _nuget = null!; + private FakeSignToolPackageInstallationService _installer = null!; + private AzureSignToolService _service = null!; + + [TestInitialize] + public void SetupService() + { + _nugetCacheDir = _tempDirectory.CreateSubdirectory("nuget-cache"); + _globalWinappDir = _tempDirectory.CreateSubdirectory("global-winapp"); + _nuget = new FakeSignToolNugetService(_nugetCacheDir); + _installer = new FakeSignToolPackageInstallationService(); + + _service = new AzureSignToolService( + new FakeSignToolBuildToolsService(), + _nuget, + _installer, + new FakeSignToolWinappDirectoryService(_globalWinappDir)) + { + // Tests use placeholder DLL content; bypass the compiled-in SHA-256 pin here. Dedicated + // tests below exercise the real verification behaviour (accept / reject / reinstall). + DlibIntegrityVerifier = _ => true, + }; + } + + private FileInfo CreateDlibInCache() + { + var binDir = Path.Join( + _nugetCacheDir.FullName, + PackageDirName, + AzureSignToolService.ArtifactSigningClientVersion, + "bin", + "x64"); + Directory.CreateDirectory(binDir); + var dlibPath = Path.Join(binDir, DlibDllName); + File.WriteAllText(dlibPath, "fake dlib"); + return new FileInfo(dlibPath); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_WhenDlibAlreadyCached_ReturnsPathWithoutInstalling() + { + var expected = CreateDlibInCache(); + + var result = await _service.EnsureTrustedSigningDlibAsync(TestTaskContext, TestContext.CancellationToken); + + Assert.AreEqual(expected.FullName, result.FullName); + Assert.AreEqual(0, _installer.EnsureCallCount, "Should not install when the dlib is already cached"); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_WhenMissingThenInstalled_InstallsAndReturnsPath() + { + // Simulate the package install materializing the dlib on disk. + _installer.EnsureResult = true; + _installer.OnEnsure = () => CreateDlibInCache(); + + var result = await _service.EnsureTrustedSigningDlibAsync(TestTaskContext, TestContext.CancellationToken); + + Assert.AreEqual(1, _installer.EnsureCallCount, "Should attempt to install the signing client package"); + Assert.AreEqual(DlibDllName, result.Name); + Assert.IsTrue(result.Exists); + StringAssert.Contains(result.FullName, AzureSignToolService.ArtifactSigningClientVersion); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_WhenInstallSucceedsButDlibMissing_Throws() + { + // Install reports success but nothing is written to the cache. + _installer.EnsureResult = true; + + var ex = await Assert.ThrowsExactlyAsync( + () => _service.EnsureTrustedSigningDlibAsync(TestTaskContext, TestContext.CancellationToken)); + + StringAssert.Contains(ex.Message, "Could not find the Trusted Signing client library"); + Assert.AreEqual(1, _installer.EnsureCallCount); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_WhenCachedDlibFailsIntegrity_RemovesAndReinstalls() + { + // A cached dlib whose content does not match a pinned hash must not be trusted: the version + // directory is removed and the package reinstalled, and the reinstalled (now-verified) copy + // is returned. + CreateDlibInCache(); + _installer.EnsureResult = true; + _installer.OnEnsure = () => CreateDlibInCache(); + + var callCount = 0; + _service.DlibIntegrityVerifier = _ => ++callCount > 1; // reject the cached copy, accept after reinstall + + var result = await _service.EnsureTrustedSigningDlibAsync(TestTaskContext, TestContext.CancellationToken); + + Assert.AreEqual(1, _installer.EnsureCallCount, "A cached dlib that fails verification should trigger a reinstall"); + Assert.AreEqual(DlibDllName, result.Name); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_WhenDownloadedDlibFailsIntegrity_Throws() + { + // If even the freshly installed dlib fails verification, refuse to use it (fail closed). + _installer.EnsureResult = true; + _installer.OnEnsure = () => CreateDlibInCache(); + _service.DlibIntegrityVerifier = _ => false; + + var ex = await Assert.ThrowsExactlyAsync( + () => _service.EnsureTrustedSigningDlibAsync(TestTaskContext, TestContext.CancellationToken)); + + StringAssert.Contains(ex.Message, "failed integrity verification"); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_DefaultVerifier_RejectsUnpinnedContent() + { + // Exercise the real compiled-in SHA-256 pin (no verifier override): placeholder DLL content + // must be rejected, proving the production path does not blindly trust cached/downloaded bits. + var service = new AzureSignToolService( + new FakeSignToolBuildToolsService(), _nuget, _installer, + new FakeSignToolWinappDirectoryService(_globalWinappDir)); + CreateDlibInCache(); // "fake dlib" content — not a pinned hash + _installer.EnsureResult = true; + _installer.OnEnsure = () => CreateDlibInCache(); + + var ex = await Assert.ThrowsExactlyAsync( + () => service.EnsureTrustedSigningDlibAsync(TestTaskContext, TestContext.CancellationToken)); + + StringAssert.Contains(ex.Message, "failed integrity verification"); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_WhenInstallFails_Throws() + { + _installer.EnsureResult = false; + + var ex = await Assert.ThrowsExactlyAsync( + () => _service.EnsureTrustedSigningDlibAsync(TestTaskContext, TestContext.CancellationToken)); + + StringAssert.Contains(ex.Message, "Could not find the Trusted Signing client library"); + } + + [TestMethod] + public async Task EnsureTrustedSigningDlibAsync_WhenCancelledDuringInstall_PropagatesCancellation() + { + using var cts = new CancellationTokenSource(); + + // EnsurePackageAsync swallows OperationCanceledException and returns false, so simulate a + // download cancelled mid-flight: the token is cancelled while "installing" and the install + // reports failure. The service must surface cancellation rather than the generic install + // failure and must not continue into the post-install dlib lookup. + _installer.EnsureResult = false; + _installer.OnEnsure = () => cts.Cancel(); + + await Assert.ThrowsExactlyAsync( + () => _service.EnsureTrustedSigningDlibAsync(TestTaskContext, cts.Token)); + } + + [TestMethod] + public async Task SignAsync_ForwardsTenantAndSelectsArchMatchedSigntool() + { + var dlib = CreateDlibInCache(); // bin/x64/Azure.CodeSigning.Dlib.dll + var signtool = CreateFakeSigntool("x64"); + var recording = new RecordingBuildToolsService { SignToolToReturn = signtool }; + var service = new AzureSignToolService( + recording, _nuget, _installer, new FakeSignToolWinappDirectoryService(_globalWinappDir)) + { + DlibIntegrityVerifier = _ => true, + TrustedAzureCliBinDirsProvider = () => Array.Empty(), + }; + + var fileToSign = new FileInfo(Path.Join(_tempDirectory.FullName, "app.msix")); + await File.WriteAllTextAsync(fileToSign.FullName, "MZ", TestContext.CancellationToken); + var metadata = new FileInfo(Path.Join(_tempDirectory.FullName, "metadata.json")); + await File.WriteAllTextAsync(metadata.FullName, "{}", TestContext.CancellationToken); + + await service.SignAsync(fileToSign, metadata, "my-tenant-id", TestTaskContext, TestContext.CancellationToken); + + // The x64 dlib forces the architecture-matched x64 signtool to be used as the override. + Assert.AreEqual(signtool.FullName, recording.CapturedToolPathOverride!.FullName); + + // signtool arguments reference the dlib, the metadata file, and the target file. + StringAssert.Contains(recording.CapturedArguments!, dlib.FullName); + StringAssert.Contains(recording.CapturedArguments!, metadata.FullName); + StringAssert.Contains(recording.CapturedArguments!, fileToSign.FullName); + + // The tenant id is forwarded to the dlib via AZURE_TENANT_ID. + Assert.IsNotNull(recording.CapturedEnvironment); + Assert.AreEqual("my-tenant-id", recording.CapturedEnvironment!["AZURE_TENANT_ID"]); + + // signtool runs from a trusted system directory rather than inheriting the caller's working + // directory, so the dlib's own 'az' lookup can't pick up a repo-local az.cmd next to the target. + Assert.AreEqual(Environment.SystemDirectory, recording.CapturedWorkingDirectory, + "SignAsync must anchor signtool's working directory to System32"); + } + + [TestMethod] + public async Task SignAsync_WithoutTenant_DoesNotInjectEnvironment() + { + CreateDlibInCache(); + var signtool = CreateFakeSigntool("x64"); + var recording = new RecordingBuildToolsService { SignToolToReturn = signtool }; + var service = new AzureSignToolService( + recording, _nuget, _installer, new FakeSignToolWinappDirectoryService(_globalWinappDir)) + { + DlibIntegrityVerifier = _ => true, + TrustedAzureCliBinDirsProvider = () => Array.Empty(), + }; + + var fileToSign = new FileInfo(Path.Join(_tempDirectory.FullName, "app.msix")); + await File.WriteAllTextAsync(fileToSign.FullName, "MZ", TestContext.CancellationToken); + var metadata = new FileInfo(Path.Join(_tempDirectory.FullName, "metadata.json")); + await File.WriteAllTextAsync(metadata.FullName, "{}", TestContext.CancellationToken); + + await service.SignAsync(fileToSign, metadata, tenantId: null, TestTaskContext, TestContext.CancellationToken); + + Assert.IsNull(recording.CapturedEnvironment, "No AZURE_TENANT_ID should be injected when no tenant is known"); + } + + [TestMethod] + public async Task SignAsync_PrependsTrustedAzureCliDirsToPath() + { + CreateDlibInCache(); + var signtool = CreateFakeSigntool("x64"); + var recording = new RecordingBuildToolsService { SignToolToReturn = signtool }; + var trustedDir = Path.Join(_tempDirectory.FullName, "trusted-az", "wbin"); + var service = new AzureSignToolService( + recording, _nuget, _installer, new FakeSignToolWinappDirectoryService(_globalWinappDir)) + { + DlibIntegrityVerifier = _ => true, + TrustedAzureCliBinDirsProvider = () => new[] { trustedDir }, + }; + + var fileToSign = new FileInfo(Path.Join(_tempDirectory.FullName, "app.msix")); + await File.WriteAllTextAsync(fileToSign.FullName, "MZ", TestContext.CancellationToken); + var metadata = new FileInfo(Path.Join(_tempDirectory.FullName, "metadata.json")); + await File.WriteAllTextAsync(metadata.FullName, "{}", TestContext.CancellationToken); + + await service.SignAsync(fileToSign, metadata, "tenant", TestTaskContext, TestContext.CancellationToken); + + Assert.IsNotNull(recording.CapturedEnvironment); + var path = recording.CapturedEnvironment!["PATH"]; + + // The trusted Azure CLI directory is prepended so the dlib's AzureCliCredential resolves the + // legitimate az.cmd before any 'az.cmd' injected elsewhere on PATH. + StringAssert.StartsWith(path, trustedDir + Path.PathSeparator, + "The trusted Azure CLI directory must be prepended to PATH"); + + // Prepending is additive: the caller's existing PATH entries are preserved after the prefix. + var currentPath = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(currentPath)) + { + StringAssert.Contains(path, currentPath); + } + } + + [TestMethod] + public async Task SignAsync_WhenResolvedSigntoolArchMismatchesDlib_SwapsToMatchingArchSibling() + { + // The dlib only ships x64/x86, so on an ARM64 host the resolved arm64 signtool must be + // swapped for the sibling x64 signtool that matches the x64 dlib. + var dlib = CreateDlibInCache(); // bin/x64/Azure.CodeSigning.Dlib.dll + var arm64Signtool = CreateFakeSigntool("arm64"); // what tool resolution returns + var x64Signtool = CreateFakeSigntool("x64"); // sibling under the same bin parent + var recording = new RecordingBuildToolsService { SignToolToReturn = arm64Signtool }; + var service = new AzureSignToolService( + recording, _nuget, _installer, new FakeSignToolWinappDirectoryService(_globalWinappDir)) + { + DlibIntegrityVerifier = _ => true, + }; + + var fileToSign = new FileInfo(Path.Join(_tempDirectory.FullName, "app.msix")); + await File.WriteAllTextAsync(fileToSign.FullName, "MZ", TestContext.CancellationToken); + var metadata = new FileInfo(Path.Join(_tempDirectory.FullName, "metadata.json")); + await File.WriteAllTextAsync(metadata.FullName, "{}", TestContext.CancellationToken); + + await service.SignAsync(fileToSign, metadata, "tenant", TestTaskContext, TestContext.CancellationToken); + + Assert.IsNotNull(dlib); + Assert.AreEqual(x64Signtool.FullName, recording.CapturedToolPathOverride!.FullName, + "The x64 dlib should force a swap from the resolved arm64 signtool to the sibling x64 signtool"); + } + + private FileInfo CreateFakeSigntool(string architecture) + { + var dir = Path.Join(_tempDirectory.FullName, "sdk", "bin", architecture); + Directory.CreateDirectory(dir); + var signtool = new FileInfo(Path.Join(dir, "signtool.exe")); + File.WriteAllText(signtool.FullName, "fake signtool"); + return signtool; + } +} + +internal sealed class FakeSignToolNugetService(DirectoryInfo cacheDir) : INugetService +{ + public DirectoryInfo GetNuGetGlobalPackagesDir() => cacheDir; + + public Task GetLatestVersionAsync(string packageName, SdkInstallMode sdkInstallMode, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task> InstallPackageAsync(string package, string version, TaskContext taskContext, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task> GetPackageDependenciesAsync(string packageName, string version, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public DirectoryInfo GetNuGetPackageDir(string packageName, string version) + => throw new NotImplementedException(); +} + +internal sealed class FakeSignToolWinappDirectoryService(DirectoryInfo globalDir) : IWinappDirectoryService +{ + public DirectoryInfo GetGlobalWinappDirectory() => globalDir; + + public DirectoryInfo GetLocalWinappDirectory(DirectoryInfo? baseDirectory = null) + => throw new NotImplementedException(); + + public void SetCacheDirectoryForTesting(DirectoryInfo? cacheDirectory) + { + // no-op + } +} + +internal sealed class FakeSignToolPackageInstallationService : IPackageInstallationService +{ + public bool EnsureResult { get; set; } = true; + public int EnsureCallCount { get; private set; } + public Action? OnEnsure { get; set; } + + public void InitializeWorkspace(DirectoryInfo rootDirectory) + => throw new NotImplementedException(); + + public Task> InstallPackagesAsync( + DirectoryInfo rootDirectory, + IEnumerable packages, + TaskContext taskContext, + SdkInstallMode sdkInstallMode = SdkInstallMode.Stable, + bool ignoreConfig = false, + CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task EnsurePackageAsync( + DirectoryInfo rootDirectory, + string packageName, + TaskContext taskContext, + string? version = null, + SdkInstallMode sdkInstallMode = SdkInstallMode.Stable, + CancellationToken cancellationToken = default) + { + EnsureCallCount++; + OnEnsure?.Invoke(); + return Task.FromResult(EnsureResult); + } +} + +internal sealed class FakeSignToolBuildToolsService : IBuildToolsService +{ + public FileInfo? GetBuildToolPath(string toolName) => throw new NotImplementedException(); + + public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext taskContext, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task EnsureBuildToolsAsync(TaskContext taskContext, bool forceLatest = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); +} + +internal sealed class RecordingBuildToolsService : IBuildToolsService +{ + public FileInfo SignToolToReturn { get; set; } = null!; + + public Tool? CapturedTool { get; private set; } + public string? CapturedArguments { get; private set; } + public FileInfo? CapturedToolPathOverride { get; private set; } + public IReadOnlyDictionary? CapturedEnvironment { get; private set; } + public string? CapturedWorkingDirectory { get; private set; } + + public FileInfo? GetBuildToolPath(string toolName) => throw new NotImplementedException(); + + public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext taskContext, CancellationToken cancellationToken = default) + => Task.FromResult(SignToolToReturn); + + public Task EnsureBuildToolsAsync(TaskContext taskContext, bool forceLatest = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default) + { + CapturedTool = tool; + CapturedArguments = arguments; + CapturedToolPathOverride = toolPathOverride; + CapturedEnvironment = environment; + CapturedWorkingDirectory = workingDirectory; + return Task.FromResult((string.Empty, string.Empty)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzureSigningServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzureSigningServiceTests.cs new file mode 100644 index 000000000..9d7ea4a10 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzureSigningServiceTests.cs @@ -0,0 +1,430 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AzureSigningServiceTests +{ + private static AzureSigningService CreateService(StubHttpMessageHandler handler) + { + var http = new HttpClient(handler); + return new AzureSigningService(NullLogger.Instance, http); + } + + [TestMethod] + public async Task ListSubscriptionsAsync_ParsesAndFiltersByEnabledState() + { + const string json = """ + { + "value": [ + { "subscriptionId": "sub-1", "displayName": "Enabled Sub", "state": "Enabled" }, + { "subscriptionId": "sub-2", "displayName": "Disabled Sub", "state": "Disabled" } + ] + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var subscriptions = await service.ListSubscriptionsAsync("token"); + + // Only the enabled subscription is returned. + Assert.AreEqual(1, subscriptions.Count); + Assert.AreEqual("sub-1", subscriptions[0].SubscriptionId); + Assert.AreEqual("Enabled Sub", subscriptions[0].DisplayName); + + // The bearer token is attached and the subscriptions endpoint is targeted. + Assert.AreEqual("Bearer", handler.LastRequest!.Headers.Authorization!.Scheme); + Assert.AreEqual("token", handler.LastRequest.Headers.Authorization.Parameter); + StringAssert.Contains(handler.LastRequestUri!, "https://management.azure.com/subscriptions?api-version="); + } + + [TestMethod] + public async Task ListSigningAccountsAsync_WithResourceGroup_BuildsScopedUrlAndParsesAccount() + { + const string json = """ + { + "value": [ + { + "name": "myaccount", + "location": "eastus", + "id": "/subscriptions/sub-1/resourceGroups/my-rg/providers/Microsoft.CodeSigning/codeSigningAccounts/myaccount", + "properties": { "accountUri": "https://eus.codesigning.azure.net" } + } + ] + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var accounts = await service.ListSigningAccountsAsync("token", "sub-1", "my-rg"); + + Assert.AreEqual(1, accounts.Count); + Assert.AreEqual("myaccount", accounts[0].Name); + Assert.AreEqual("my-rg", accounts[0].ResourceGroup); + Assert.AreEqual("eastus", accounts[0].Location); + Assert.AreEqual("https://eus.codesigning.azure.net", accounts[0].AccountUri); + + // Resource-group-scoped URL is used when a group is supplied. + StringAssert.Contains(handler.LastRequestUri!, "/resourceGroups/my-rg/providers/Microsoft.CodeSigning/codeSigningAccounts"); + } + + [TestMethod] + public async Task ListSigningAccountsAsync_WithoutResourceGroup_BuildsSubscriptionWideUrl() + { + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, """{ "value": [] }"""); + + var service = CreateService(handler); + var accounts = await service.ListSigningAccountsAsync("token", "sub-1"); + + Assert.AreEqual(0, accounts.Count); + StringAssert.Contains(handler.LastRequestUri!, "/subscriptions/sub-1/providers/Microsoft.CodeSigning/codeSigningAccounts"); + Assert.IsFalse(handler.LastRequestUri!.Contains("/resourceGroups/"), "Should not include a resource group segment"); + } + + [TestMethod] + public async Task ListCertificateProfilesAsync_ParsesProfiles() + { + const string json = """ + { + "value": [ + { "name": "profile-a", "properties": { "profileType": "PublicTrust", "status": "Active" } } + ] + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var profiles = await service.ListCertificateProfilesAsync("token", "sub-1", "my-rg", "myaccount"); + + Assert.AreEqual(1, profiles.Count); + Assert.AreEqual("profile-a", profiles[0].Name); + Assert.AreEqual("PublicTrust", profiles[0].ProfileType); + Assert.AreEqual("Active", profiles[0].Status); + StringAssert.Contains(handler.LastRequestUri!, "/codeSigningAccounts/myaccount/certificateProfiles"); + } + + [TestMethod] + public async Task GetArmResponse_OnErrorStatus_SurfacesParsedAzureError() + { + const string errorJson = """ + { "error": { "code": "AuthorizationFailed", "message": "The client does not have authorization." } } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.Forbidden, errorJson); + + var service = CreateService(handler); + var ex = await Assert.ThrowsExactlyAsync( + () => service.ListSubscriptionsAsync("token")); + + StringAssert.Contains(ex.Message, "AuthorizationFailed"); + StringAssert.Contains(ex.Message, "The client does not have authorization."); + } + + [TestMethod] + public async Task GetArmResponse_OnErrorStatusWithNonJsonBody_FallsBackToStatusCode() + { + var handler = new StubHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"); + + var service = CreateService(handler); + var ex = await Assert.ThrowsExactlyAsync( + () => service.ListSubscriptionsAsync("token")); + + StringAssert.Contains(ex.Message, "500"); + } + + [TestMethod] + public async Task ListSubscriptionsAsync_FollowsNextLinkAcrossPages() + { + const string page1 = """ + { + "value": [ { "subscriptionId": "sub-1", "displayName": "Page 1 Sub", "state": "Enabled" } ], + "nextLink": "https://management.azure.com/subscriptions?api-version=2022-12-01&$skiptoken=page2" + } + """; + const string page2 = """ + { + "value": [ { "subscriptionId": "sub-2", "displayName": "Page 2 Sub", "state": "Enabled" } ] + } + """; + var handler = new QueueHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(page1) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(page2) }); + + var service = new AzureSigningService(NullLogger.Instance, new HttpClient(handler)); + var subscriptions = await service.ListSubscriptionsAsync("token"); + + Assert.AreEqual(2, subscriptions.Count, "Both pages of results should be combined"); + Assert.AreEqual("sub-1", subscriptions[0].SubscriptionId); + Assert.AreEqual("sub-2", subscriptions[1].SubscriptionId); + Assert.AreEqual(2, handler.RequestUris.Count, "Both the initial URL and the nextLink should be requested"); + StringAssert.Contains(handler.RequestUris[1], "$skiptoken=page2"); + } + + [TestMethod] + public async Task ListSubscriptionsAsync_RepeatedNextLink_ThrowsInsteadOfLoopingForever() + { + const string cyclic = """ + { + "value": [ { "subscriptionId": "sub-1", "displayName": "Loop Sub", "state": "Enabled" } ], + "nextLink": "https://management.azure.com/subscriptions?api-version=2022-12-01&$skiptoken=loop" + } + """; + var handler = new QueueHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(cyclic) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(cyclic) }); + + var service = new AzureSigningService(NullLogger.Instance, new HttpClient(handler)); + var ex = await Assert.ThrowsExactlyAsync( + () => service.ListSubscriptionsAsync("token")); + + StringAssert.Contains(ex.Message, "repeated nextLink"); + } + + [TestMethod] + public async Task ListSubscriptionsAsync_NextLinkToUntrustedHost_ThrowsWithoutFollowingIt() + { + const string page1 = """ + { + "value": [ { "subscriptionId": "sub-1", "displayName": "Page 1 Sub", "state": "Enabled" } ], + "nextLink": "https://evil.example.com/subscriptions?api-version=2022-12-01&$skiptoken=page2" + } + """; + var handler = new QueueHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(page1) }, + // This second response must never be requested — following it would leak the token. + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{ "value": [] }""") }); + + var service = new AzureSigningService(NullLogger.Instance, new HttpClient(handler)); + var ex = await Assert.ThrowsExactlyAsync( + () => service.ListSubscriptionsAsync("token")); + + StringAssert.Contains(ex.Message, "untrusted"); + Assert.AreEqual(1, handler.RequestUris.Count, "The untrusted nextLink must not be fetched"); + } + + [TestMethod] + public async Task ListSubscriptionsAsync_WhenCancelledMidPagination_ThrowsAndStopsRequesting() + { + const string page1 = """ + { + "value": [ { "subscriptionId": "sub-1", "displayName": "Page 1 Sub", "state": "Enabled" } ], + "nextLink": "https://management.azure.com/subscriptions?api-version=2022-12-01&$skiptoken=page2" + } + """; + using var cts = new CancellationTokenSource(); + var handler = new BlockingSecondPageHandler(page1, cts); + + var service = new AzureSigningService(NullLogger.Instance, new HttpClient(handler)); + + var threw = false; + try + { + await service.ListSubscriptionsAsync("token", cts.Token); + } + catch (OperationCanceledException) + { + threw = true; + } + + Assert.IsTrue(threw, "A cancelled paginated request must surface an OperationCanceledException"); + // The initial page and the (blocked, then cancelled) nextLink request are attempted; no page + // beyond the cancellation point may be requested — a regression dropping the token would + // otherwise keep paging. + Assert.AreEqual(2, handler.RequestUris.Count, + "Only the first page and the cancelled nextLink request should be attempted"); + } + + [TestMethod] + public async Task GetSigningAccountAsync_Success_ParsesAccountAndTargetsResourceScopedUrl() + { + const string json = """ + { + "name": "myaccount", + "location": "eastus", + "id": "/subscriptions/sub-1/resourceGroups/my-rg/providers/Microsoft.CodeSigning/codeSigningAccounts/myaccount", + "properties": { "accountUri": "https://eus.codesigning.azure.net" } + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var account = await service.GetSigningAccountAsync("token", "sub-1", "my-rg", "myaccount"); + + Assert.IsNotNull(account); + Assert.AreEqual("myaccount", account.Name); + Assert.AreEqual("my-rg", account.ResourceGroup); + Assert.AreEqual("eastus", account.Location); + Assert.AreEqual("https://eus.codesigning.azure.net", account.AccountUri); + + // Direct GET on the named account — no list segment, so only reader on the resource is needed. + StringAssert.Contains(handler.LastRequestUri!, "/resourceGroups/my-rg/providers/Microsoft.CodeSigning/codeSigningAccounts/myaccount?api-version="); + Assert.AreEqual("token", handler.LastRequest!.Headers.Authorization!.Parameter); + } + + [TestMethod] + public async Task GetSigningAccountAsync_NotFound_ReturnsNull() + { + var handler = new StubHttpMessageHandler(HttpStatusCode.NotFound, """{ "error": { "code": "NotFound", "message": "gone" } }"""); + + var service = CreateService(handler); + var account = await service.GetSigningAccountAsync("token", "sub-1", "my-rg", "missing"); + + Assert.IsNull(account); + } + + [TestMethod] + public async Task GetSigningAccountAsync_OnErrorStatus_SurfacesParsedAzureError() + { + const string errorJson = """ + { "error": { "code": "AuthorizationFailed", "message": "The client does not have authorization." } } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.Forbidden, errorJson); + + var service = CreateService(handler); + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetSigningAccountAsync("token", "sub-1", "my-rg", "myaccount")); + + StringAssert.Contains(ex.Message, "AuthorizationFailed"); + } + + [TestMethod] + public async Task GetCertificateProfileAsync_Success_ParsesProfileAndTargetsProfileScopedUrl() + { + const string json = """ + { + "name": "profile-a", + "properties": { "profileType": "PublicTrust", "status": "Active" } + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var profile = await service.GetCertificateProfileAsync("token", "sub-1", "my-rg", "myaccount", "profile-a"); + + Assert.IsNotNull(profile); + Assert.AreEqual("profile-a", profile.Name); + Assert.AreEqual("PublicTrust", profile.ProfileType); + Assert.AreEqual("Active", profile.Status); + + // Direct GET on the named profile — no certificateProfiles list, so a profile-scoped + // signer principal that cannot enumerate the collection still validates successfully. + StringAssert.Contains(handler.LastRequestUri!, "/codeSigningAccounts/myaccount/certificateProfiles/profile-a?api-version="); + } + + [TestMethod] + public async Task GetCertificateProfileAsync_NotFound_ReturnsNull() + { + var handler = new StubHttpMessageHandler(HttpStatusCode.NotFound, """{ "error": { "code": "NotFound", "message": "gone" } }"""); + + var service = CreateService(handler); + var profile = await service.GetCertificateProfileAsync("token", "sub-1", "my-rg", "myaccount", "missing"); + + Assert.IsNull(profile); + } + + [TestMethod] + public async Task GetCertificateProfileAsync_OnErrorStatus_SurfacesParsedAzureError() + { + const string errorJson = """ + { "error": { "code": "AuthorizationFailed", "message": "The client does not have authorization." } } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.Forbidden, errorJson); + + var service = CreateService(handler); + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetCertificateProfileAsync("token", "sub-1", "my-rg", "myaccount", "profile-a")); + + StringAssert.Contains(ex.Message, "AuthorizationFailed"); + } + + private sealed class StubHttpMessageHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler + { + private HttpResponseMessage? _response; + + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestUri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastRequestUri = request.RequestUri?.ToString(); + _response = new HttpResponseMessage(statusCode) + { + Content = new StringContent(body) + }; + return Task.FromResult(_response); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _response?.Dispose(); + } + base.Dispose(disposing); + } + } + + private sealed class QueueHttpMessageHandler(params HttpResponseMessage[] responses) : HttpMessageHandler + { + private readonly Queue _responses = new(responses); + + public List RequestUris { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUris.Add(request.RequestUri!.ToString()); + if (_responses.Count == 0) + { + throw new InvalidOperationException("QueueHttpMessageHandler received more requests than queued responses."); + } + + return Task.FromResult(_responses.Dequeue()); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + while (_responses.Count > 0) + { + _responses.Dequeue().Dispose(); + } + } + base.Dispose(disposing); + } + } + + /// + /// Returns a first page (with a nextLink) on the initial request, then — on the nextLink + /// request — cancels the shared token and blocks on it so the paginated call is cancelled + /// mid-flight, exercising cancellation propagation through GetArmResponseAsync. + /// + private sealed class BlockingSecondPageHandler(string firstPageBody, CancellationTokenSource cts) : HttpMessageHandler + { + private int _requestCount; + + public List RequestUris { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUris.Add(request.RequestUri!.ToString()); + _requestCount++; + + if (_requestCount == 1) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(firstPageBody) }; + } + + // The nextLink request: cancel and then block on the token so the service observes + // cancellation while awaiting the second page. + cts.Cancel(); + await Task.Delay(Timeout.Infinite, cancellationToken); + throw new InvalidOperationException("Unreachable: the request should have been cancelled."); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceCoverageTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceCoverageTests.cs index 7a2babbc0..e695e4682 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceCoverageTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceCoverageTests.cs @@ -271,7 +271,7 @@ public async Task RunBuildToolAsync_WhenToolExitsNonZero_ThrowsWithProcessDetail File.WriteAllText(failingTool, "@echo captured-stdout-marker\r\n@exit /b 1"); var ex = await Assert.ThrowsExactlyAsync(async () => - await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, true, TestContext.CancellationToken)); + await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, true, cancellationToken: TestContext.CancellationToken)); Assert.IsTrue(ex.ProcessId > 0, "Expected a captured process id."); Assert.Contains("captured-stdout-marker", ex.Stdout); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs index 52333f9fe..f9866b474 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs @@ -27,12 +27,12 @@ public void BuildToolsService_WithTestCacheDirectory_UsesOverriddenDirectory() Assert.IsNull(result); // Additional verification: Create a fake bin directory structure and verify it's found - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); - var fakeToolPath = Path.Combine(binDir, "mt.exe"); + var fakeToolPath = Path.Join(binDir, "mt.exe"); File.WriteAllText(fakeToolPath, "fake tool"); // Now it should find the tool in our test directory @@ -40,17 +40,109 @@ public void BuildToolsService_WithTestCacheDirectory_UsesOverriddenDirectory() Assert.AreEqual(fakeToolPath, result2?.FullName); } + [TestMethod] + public async Task RunBuildToolAsync_WithToolPathOverrideAndEnvironment_UsesOverrideAndInjectsEnv() + { + // cmd.exe stands in for an architecture-matched signtool: passing it as toolPathOverride + // must bypass tool resolution/installation entirely, and the supplied environment must + // reach the child process (az-sign relies on both for AZURE_TENANT_ID propagation). + var cmd = new FileInfo(Path.Join(Environment.SystemDirectory, "cmd.exe")); + Assert.IsTrue(cmd.Exists, "cmd.exe is expected to exist on the test host"); + + var (stdout, _) = await _buildToolsService.RunBuildToolAsync( + new GenericTool("signtool.exe"), + "/c echo %WINAPP_OVERRIDE_TEST%", + TestTaskContext, + toolPathOverride: cmd, + environment: new Dictionary { ["WINAPP_OVERRIDE_TEST"] = "override-and-env-work" }, + cancellationToken: TestContext.CancellationToken); + + StringAssert.Contains(stdout, "override-and-env-work"); + } + + [TestMethod] + public async Task RunBuildToolAsync_WithLargeOutputOnBothStreams_DrainsConcurrentlyWithoutDeadlock() + { + // A tool that fills both stdout and stderr well past the OS pipe buffer (~64KB) would + // deadlock if the runner read one stream to completion before touching the other. + // signtool /v /debug is exactly such a tool; cmd.exe stands in here. + var cmd = new FileInfo(Path.Join(Environment.SystemDirectory, "cmd.exe")); + Assert.IsTrue(cmd.Exists, "cmd.exe is expected to exist on the test host"); + + // ~8000 iterations * ~30 bytes = ~240KB per stream, comfortably past the pipe buffer. + const string args = "/c for /L %i in (1,1,8000) do @(echo OUT-%i-xxxxxxxxxxxxxxxxxxxxxxxx& echo ERR-%i-xxxxxxxxxxxxxxxxxxxxxxxx 1>&2)"; + + // Bound the run with a linked token rather than racing a bare Task.Delay: if draining were + // sequential the operation would hang, and CancelAfter drives the production cancellation path, + // which kills the child. A detached Task.WhenAny timeout would instead leave the deadlocked + // cmd.exe and its pipe reads alive for the rest of the test process. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(60)); + + string stdout; + string stderr; + try + { + (stdout, stderr) = await _buildToolsService.RunBuildToolAsync( + new GenericTool("signtool.exe"), + args, + TestTaskContext, + toolPathOverride: cmd, + cancellationToken: cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested && !TestContext.CancellationToken.IsCancellationRequested) + { + Assert.Fail("RunBuildToolAsync deadlocked draining both pipes; the timeout cancelled and killed the child."); + throw; // unreachable — Assert.Fail always throws — but satisfies definite assignment. + } + + StringAssert.Contains(stdout, "OUT-8000-"); + StringAssert.Contains(stderr, "ERR-8000-"); + } + + [TestMethod] + public async Task RunBuildToolAsync_WhenCancelled_KillsChildAndThrowsWithoutWaitingForExit() + { + var cmd = new FileInfo(Path.Join(Environment.SystemDirectory, "cmd.exe")); + Assert.IsTrue(cmd.Exists, "cmd.exe is expected to exist on the test host"); + + // ping sleeps ~30s; the cancellation must cut the wait short and kill the child. + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(500)); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + OperationCanceledException? caught = null; + try + { + await _buildToolsService.RunBuildToolAsync( + new GenericTool("signtool.exe"), + "/c ping -n 30 127.0.0.1 >nul", + TestTaskContext, + toolPathOverride: cmd, + cancellationToken: cts.Token); + } + catch (OperationCanceledException ex) + { + caught = ex; + } + stopwatch.Stop(); + + Assert.IsNotNull(caught, "Cancellation should surface as an OperationCanceledException"); + Assert.IsTrue(stopwatch.Elapsed < TimeSpan.FromSeconds(15), + $"Cancellation should not wait for the child to exit on its own (took {stopwatch.Elapsed.TotalSeconds:F1}s)"); + } + [TestMethod] public void GetBuildToolPath_WithNonExistentTool_ReturnsNull() { // Arrange - Create package structure but without the requested tool - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); // Create a different tool, but not the one we're looking for - File.WriteAllText(Path.Combine(binDir, "signtool.exe"), "fake signtool"); + File.WriteAllText(Path.Join(binDir, "signtool.exe"), "fake signtool"); // Act var result = _buildToolsService.GetBuildToolPath("mt.exe"); @@ -63,20 +155,20 @@ public void GetBuildToolPath_WithNonExistentTool_ReturnsNull() public void GetBuildToolPath_WithMultipleVersions_ReturnsLatestVersion() { // Arrange - Create multiple package versions - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); // Create older version - var olderPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.22000.1"); - var olderBinDir = Path.Combine(olderPackageDir, "bin", "10.0.22000.0", "x64"); + var olderPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.22000.1"); + var olderBinDir = Path.Join(olderPackageDir, "bin", "10.0.22000.0", "x64"); Directory.CreateDirectory(olderBinDir); - var olderToolPath = Path.Combine(olderBinDir, "mt.exe"); + var olderToolPath = Path.Join(olderBinDir, "mt.exe"); File.WriteAllText(olderToolPath, "old mt.exe"); // Create newer version - var newerPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var newerBinDir = Path.Combine(newerPackageDir, "bin", "10.0.26100.0", "x64"); + var newerPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var newerBinDir = Path.Join(newerPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(newerBinDir); - var newerToolPath = Path.Combine(newerBinDir, "mt.exe"); + var newerToolPath = Path.Join(newerBinDir, "mt.exe"); File.WriteAllText(newerToolPath, "new mt.exe"); // Act @@ -90,20 +182,20 @@ public void GetBuildToolPath_WithMultipleVersions_ReturnsLatestVersion() public void GetBuildToolPath_WithPinnedVersion_ReturnsPinnedVersion() { // Arrange - Create multiple package versions - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); // Create older version - var olderPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.22000.1742"); - var olderBinDir = Path.Combine(olderPackageDir, "bin", "10.0.22000.0", "x64"); + var olderPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.22000.1742"); + var olderBinDir = Path.Join(olderPackageDir, "bin", "10.0.22000.0", "x64"); Directory.CreateDirectory(olderBinDir); - var olderToolPath = Path.Combine(olderBinDir, "mt.exe"); + var olderToolPath = Path.Join(olderBinDir, "mt.exe"); File.WriteAllText(olderToolPath, "old mt.exe"); // Create newer version - var newerPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1742"); - var newerBinDir = Path.Combine(newerPackageDir, "bin", "10.0.26100.0", "x64"); + var newerPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1742"); + var newerBinDir = Path.Join(newerPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(newerBinDir); - var newerToolPath = Path.Combine(newerBinDir, "mt.exe"); + var newerToolPath = Path.Join(newerBinDir, "mt.exe"); File.WriteAllText(newerToolPath, "new mt.exe"); // Create config file that pins to older version @@ -126,18 +218,18 @@ public void GetBuildToolPath_WithPinnedVersion_ReturnsPinnedVersion() public void GetBuildToolPath_WithMultipleArchitectures_ReturnsCorrectArchitecture() { // Arrange - Create package with multiple architectures - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binVersionDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binVersionDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0"); // Create x64 and x86 directories - var x64BinDir = Path.Combine(binVersionDir, "x64"); - var x86BinDir = Path.Combine(binVersionDir, "x86"); + var x64BinDir = Path.Join(binVersionDir, "x64"); + var x86BinDir = Path.Join(binVersionDir, "x86"); Directory.CreateDirectory(x64BinDir); Directory.CreateDirectory(x86BinDir); - var x64ToolPath = Path.Combine(x64BinDir, "mt.exe"); - var x86ToolPath = Path.Combine(x86BinDir, "mt.exe"); + var x64ToolPath = Path.Join(x64BinDir, "mt.exe"); + var x86ToolPath = Path.Join(x86BinDir, "mt.exe"); File.WriteAllText(x64ToolPath, "x64 mt.exe"); File.WriteAllText(x86ToolPath, "x86 mt.exe"); @@ -153,17 +245,17 @@ public void GetBuildToolPath_WithMultipleArchitectures_ReturnsCorrectArchitectur public async Task RunBuildToolAsync_WithValidTool_ReturnsOutput() { // Arrange - Create a fake tool that outputs to stdout - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); // Create a batch file that simulates a tool (since we can't create a real executable easily) - var fakeToolPath = Path.Combine(binDir, "echo.cmd"); + var fakeToolPath = Path.Join(binDir, "echo.cmd"); File.WriteAllText(fakeToolPath, "@echo Hello from fake tool"); // Act - var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "", TestTaskContext, true, TestContext.CancellationToken); + var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); // Assert Assert.Contains("Hello from fake tool", stdout); @@ -179,7 +271,7 @@ public async Task RunBuildToolAsync_WithNonExistentTool_ThrowsFileNotFoundExcept // Act & Assert await Assert.ThrowsExactlyAsync(async () => { - await _buildToolsService.RunBuildToolAsync(new GenericTool("nonexistent.exe"), "", TestTaskContext, true, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("nonexistent.exe"), "", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); }); } @@ -202,9 +294,9 @@ public async Task EnsureBuildToolsAsync_WithNoExistingPackage_ShouldAttemptInsta public async Task EnsureBuildToolsAsync_WithExistingPackage_ReturnsExistingPath() { // Arrange - Create existing package structure - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); // Act @@ -218,9 +310,9 @@ public async Task EnsureBuildToolsAsync_WithExistingPackage_ReturnsExistingPath( public async Task EnsureBuildToolsAsync_WithForceLatest_ShouldAttemptReinstallation() { // Arrange - Create existing package structure - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); // Act - Force latest should attempt reinstallation even with existing package @@ -235,12 +327,12 @@ public async Task EnsureBuildToolsAsync_WithForceLatest_ShouldAttemptReinstallat public async Task EnsureBuildToolAvailableAsync_WithExistingTool_ReturnsToolPath() { // Arrange - Create package structure with a tool - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); - var toolPath = Path.Combine(binDir, "mt.exe"); + var toolPath = Path.Join(binDir, "mt.exe"); File.WriteAllText(toolPath, "fake mt.exe"); // Act @@ -254,12 +346,12 @@ public async Task EnsureBuildToolAvailableAsync_WithExistingTool_ReturnsToolPath public async Task EnsureBuildToolAvailableAsync_WithToolNameWithoutExtension_AddsExtensionAndReturnsPath() { // Arrange - Create package structure with a tool - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); - var toolPath = Path.Combine(binDir, "mt.exe"); + var toolPath = Path.Join(binDir, "mt.exe"); File.WriteAllText(toolPath, "fake mt.exe"); // Act - Request tool without .exe extension @@ -301,13 +393,13 @@ public async Task EnsureBuildToolAvailableAsync_WithNoExistingPackageAndInstallS public async Task EnsureBuildToolAvailableAsync_WithNonExistentTool_ThrowsFileNotFoundException() { // Arrange - Create package structure but without the requested tool - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); // Create a different tool, but not the one we're looking for - File.WriteAllText(Path.Combine(binDir, "signtool.exe"), "fake signtool"); + File.WriteAllText(Path.Join(binDir, "signtool.exe"), "fake signtool"); // Act & Assert await Assert.ThrowsExactlyAsync(async () => @@ -326,7 +418,7 @@ public async Task RunBuildToolAsync_WithNoExistingPackage_AutoInstallsAndRuns() { // Create a simple batch command that outputs something // This will either succeed (if BuildTools installs successfully) or throw an exception - await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "test", TestTaskContext, true, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "test", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); // If we reach here, the auto-installation worked - test passes } @@ -344,16 +436,16 @@ public async Task RunBuildToolAsync_WithNoExistingPackage_AutoInstallsAndRuns() public async Task RunBuildToolAsync_WithExistingTool_RunsDirectly() { // Arrange - Create package structure with a working batch file - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); - var batchFile = Path.Combine(binDir, "test.cmd"); + var batchFile = Path.Join(binDir, "test.cmd"); File.WriteAllText(batchFile, "@echo Hello from test tool"); // Act - var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("test.cmd"), "", TestTaskContext, true, TestContext.CancellationToken); + var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("test.cmd"), "", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); // Assert Assert.Contains("Hello from test tool", stdout); @@ -372,18 +464,18 @@ public class BuildToolsServicePrintErrorsTests() : BaseCommandTests(configPaths: public async Task RunBuildToolAsync_WithPrintErrorsTrue_WritesErrorOutput() { // Arrange - Create a batch file that fails with exit code 1 and writes to stderr - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); - var failingTool = Path.Combine(binDir, "failing.cmd"); + var failingTool = Path.Join(binDir, "failing.cmd"); File.WriteAllText(failingTool, "@echo Error message to stderr 1>&2\r\n@exit /b 1"); // Act: Run with printErrors=true - error SHOULD be printed via PrintErrorText var exception = await Assert.ThrowsExactlyAsync(async () => { - await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: true, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: true, cancellationToken: TestContext.CancellationToken); }); // Verify the exception captures the error info @@ -399,18 +491,18 @@ public async Task RunBuildToolAsync_WithPrintErrorsTrue_WritesErrorOutput() public async Task RunBuildToolAsync_WithPrintErrorsFalse_DoesNotWriteErrorOutput() { // Arrange - Create a batch file that fails with exit code 1 and writes to stderr - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); - var buildToolsPackageDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); - var binDir = Path.Combine(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); + var buildToolsPackageDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", "10.0.26100.1"); + var binDir = Path.Join(buildToolsPackageDir, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(binDir); - var failingTool = Path.Combine(binDir, "failing.cmd"); + var failingTool = Path.Join(binDir, "failing.cmd"); File.WriteAllText(failingTool, "@echo Error message to stderr 1>&2\r\n@exit /b 1"); // Act: Run with printErrors=false - error should NOT be printed via PrintErrorText var exception = await Assert.ThrowsExactlyAsync(async () => { - await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: false, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: false, cancellationToken: TestContext.CancellationToken); }); // Verify the exception still captures the error info @@ -444,21 +536,21 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi public void GetBuildToolPath_WithNoConfig_UsesCsprojPinnedVersion() { // Arrange - Create multiple package versions in the NuGet cache - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); var olderVersion = "10.0.22000.1742"; var newerVersion = "10.0.26100.1742"; - var olderBinDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", olderVersion, "bin", "10.0.22000.0", "x64"); + var olderBinDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", olderVersion, "bin", "10.0.22000.0", "x64"); Directory.CreateDirectory(olderBinDir); - File.WriteAllText(Path.Combine(olderBinDir, "mt.exe"), "old mt.exe"); + File.WriteAllText(Path.Join(olderBinDir, "mt.exe"), "old mt.exe"); - var newerBinDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", newerVersion, "bin", "10.0.26100.0", "x64"); + var newerBinDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", newerVersion, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(newerBinDir); - File.WriteAllText(Path.Combine(newerBinDir, "mt.exe"), "new mt.exe"); + File.WriteAllText(Path.Join(newerBinDir, "mt.exe"), "new mt.exe"); // Create a .csproj in the temp directory so FindCsproj finds it - var csprojPath = Path.Combine(_tempDirectory.FullName, "TestApp.csproj"); + var csprojPath = Path.Join(_tempDirectory.FullName, "TestApp.csproj"); File.WriteAllText(csprojPath, "net10.0"); // Configure FakeDotNetService to return the older version from package list @@ -488,18 +580,18 @@ public void GetBuildToolPath_WithNoConfig_UsesCsprojPinnedVersion() public void GetBuildToolPath_WithNoConfigAndNoCsproj_ReturnsLatestVersion() { // Arrange - Create multiple package versions in the NuGet cache - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); var olderVersion = "10.0.22000.1742"; var newerVersion = "10.0.26100.1742"; - var olderBinDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", olderVersion, "bin", "10.0.22000.0", "x64"); + var olderBinDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", olderVersion, "bin", "10.0.22000.0", "x64"); Directory.CreateDirectory(olderBinDir); - File.WriteAllText(Path.Combine(olderBinDir, "mt.exe"), "old mt.exe"); + File.WriteAllText(Path.Join(olderBinDir, "mt.exe"), "old mt.exe"); - var newerBinDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", newerVersion, "bin", "10.0.26100.0", "x64"); + var newerBinDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", newerVersion, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(newerBinDir); - File.WriteAllText(Path.Combine(newerBinDir, "mt.exe"), "new mt.exe"); + File.WriteAllText(Path.Join(newerBinDir, "mt.exe"), "new mt.exe"); // No .csproj, no winapp.yaml — should fall back to latest @@ -516,21 +608,21 @@ public void GetBuildToolPath_WithNoConfigAndNoCsproj_ReturnsLatestVersion() public void GetBuildToolPath_WithNoConfigAndTransitiveCsprojPackage_UsesCsprojVersion() { // Arrange - Create multiple package versions in the NuGet cache - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); var pinnedVersion = "10.0.22000.1742"; var newerVersion = "10.0.26100.1742"; - var pinnedBinDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", pinnedVersion, "bin", "10.0.22000.0", "x64"); + var pinnedBinDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", pinnedVersion, "bin", "10.0.22000.0", "x64"); Directory.CreateDirectory(pinnedBinDir); - File.WriteAllText(Path.Combine(pinnedBinDir, "mt.exe"), "pinned mt.exe"); + File.WriteAllText(Path.Join(pinnedBinDir, "mt.exe"), "pinned mt.exe"); - var newerBinDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", newerVersion, "bin", "10.0.26100.0", "x64"); + var newerBinDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", newerVersion, "bin", "10.0.26100.0", "x64"); Directory.CreateDirectory(newerBinDir); - File.WriteAllText(Path.Combine(newerBinDir, "mt.exe"), "new mt.exe"); + File.WriteAllText(Path.Join(newerBinDir, "mt.exe"), "new mt.exe"); // Create a .csproj - var csprojPath = Path.Combine(_tempDirectory.FullName, "TestApp.csproj"); + var csprojPath = Path.Join(_tempDirectory.FullName, "TestApp.csproj"); File.WriteAllText(csprojPath, "net10.0"); // Configure as a transitive package (not top-level) @@ -560,7 +652,7 @@ public void GetBuildToolPath_WithNoConfigAndTransitiveCsprojPackage_UsesCsprojVe public void GetBuildToolPath_ConfigTakesPrecedenceOverCsproj() { // Arrange - Create multiple package versions - var packagesDir = Path.Combine(_testCacheDirectory.FullName, "packages"); + var packagesDir = Path.Join(_testCacheDirectory.FullName, "packages"); var configVersion = "10.0.22000.1742"; var csprojVersion = "10.0.24000.1742"; @@ -569,9 +661,9 @@ public void GetBuildToolPath_ConfigTakesPrecedenceOverCsproj() foreach (var version in new[] { configVersion, csprojVersion, newerVersion }) { var sdkVersion = version.Replace(".1742", ".0"); - var binDir = Path.Combine(packagesDir, "microsoft.windows.sdk.buildtools", version, "bin", sdkVersion, "x64"); + var binDir = Path.Join(packagesDir, "microsoft.windows.sdk.buildtools", version, "bin", sdkVersion, "x64"); Directory.CreateDirectory(binDir); - File.WriteAllText(Path.Combine(binDir, "mt.exe"), $"mt.exe {version}"); + File.WriteAllText(Path.Join(binDir, "mt.exe"), $"mt.exe {version}"); } // Create winapp.yaml pinning to the config version @@ -582,7 +674,7 @@ public void GetBuildToolPath_ConfigTakesPrecedenceOverCsproj() File.WriteAllText(_configService.ConfigPath.FullName, configContent); // Create a .csproj that references a different version - var csprojPath = Path.Combine(_tempDirectory.FullName, "TestApp.csproj"); + var csprojPath = Path.Join(_tempDirectory.FullName, "TestApp.csproj"); File.WriteAllText(csprojPath, "net10.0"); _fakeDotNetService.PackageListResult = new DotNetPackageListJson( diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ConfigurableBuildToolsService.cs b/src/winapp-CLI/WinApp.Cli.Tests/ConfigurableBuildToolsService.cs index cc39cf837..b8a5d445e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ConfigurableBuildToolsService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ConfigurableBuildToolsService.cs @@ -38,7 +38,7 @@ public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext public Task EnsureBuildToolsAsync(TaskContext taskContext, bool forceLatest = false, CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default) { Invocations.Add((tool.ExecutableName, arguments)); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeBuildToolsService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeBuildToolsService.cs index 6a88fa74f..245c3558c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeBuildToolsService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeBuildToolsService.cs @@ -55,7 +55,7 @@ public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext return Task.FromResult(BuildToolsResult); } - public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default) { Invocations.Add((tool.ExecutableName, arguments)); OnRun?.Invoke(arguments); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs index a7ca66c06..e14ea94ee 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs @@ -928,7 +928,7 @@ public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext public Task EnsureBuildToolsAsync(TaskContext taskContext, bool forceLatest = false, CancellationToken cancellationToken = default) => _inner.EnsureBuildToolsAsync(taskContext, forceLatest, cancellationToken); - public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default) { if (ThrowWhen?.Invoke(tool.ExecutableName, arguments) == true) { @@ -951,6 +951,6 @@ public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext return Task.FromResult<(string, string)>((string.Empty, string.Empty)); } - return _inner.RunBuildToolAsync(tool, arguments, taskContext, printErrors, cancellationToken); + return _inner.RunBuildToolAsync(tool, arguments, taskContext, printErrors, toolPathOverride, environment, workingDirectory, cancellationToken); } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ProcessRunnerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ProcessRunnerTests.cs new file mode 100644 index 000000000..b5a6b7eb8 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/ProcessRunnerTests.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +/// +/// Exercises the real against harmless system commands so the +/// security-sensitive process construction, concurrent stream draining, exit-code handling, and +/// cancellation/kill behavior are validated without performing a real Azure login. +/// +[TestClass] +public class ProcessRunnerTests +{ + private static readonly string CmdExe = Path.Join(Environment.SystemDirectory, "cmd.exe"); + + private DirectoryInfo _tempDirectory = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void Initialize() + { + _tempDirectory = Directory.CreateDirectory( + Path.Join(Path.GetTempPath(), "winapp-processrunner-tests", Guid.NewGuid().ToString("N"))); + } + + [TestCleanup] + public void Cleanup() + { + try + { + _tempDirectory.Delete(recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup of the per-test temp directory; a leftover temp folder is harmless. + } + } + + [TestMethod] + public async Task RunAsync_CapturesStandardOutputAndExitCode() + { + var runner = new ProcessRunner(); + + var result = await runner.RunAsync(new ProcessRunRequest(CmdExe, ["/c", "echo", "hello-world"])); + + Assert.AreEqual(0, result.ExitCode); + StringAssert.Contains(result.StandardOutput, "hello-world"); + } + + [TestMethod] + public async Task RunAsync_ForwardsEachOutputLineToCallback() + { + var runner = new ProcessRunner(); + var lines = new List(); + + var result = await runner.RunAsync( + new ProcessRunRequest(CmdExe, ["/c", "echo", "line-from-callback"]), + onOutputLine: lines.Add); + + Assert.AreEqual(0, result.ExitCode); + CollectionAssert.Contains(lines, "line-from-callback"); + } + + [TestMethod] + public async Task RunAsync_NonZeroExitCode_IsReported() + { + var runner = new ProcessRunner(); + + var result = await runner.RunAsync(new ProcessRunRequest(CmdExe, ["/c", "exit", "3"])); + + Assert.AreEqual(3, result.ExitCode); + } + + [TestMethod] + public async Task RunAsync_PassesEnvironmentVariablesToChild() + { + var runner = new ProcessRunner(); + + var result = await runner.RunAsync(new ProcessRunRequest( + CmdExe, + ["/c", "echo", "%WINAPP_TEST_VAR%"], + Environment: new Dictionary { ["WINAPP_TEST_VAR"] = "injected-value" })); + + Assert.AreEqual(0, result.ExitCode); + StringAssert.Contains(result.StandardOutput, "injected-value"); + } + + [TestMethod] + public async Task RunAsync_WhenCancelled_KillsProcessAndThrows() + { + var runner = new ProcessRunner(); + using var cts = new CancellationTokenSource(); + + // A batch file that waits a few seconds and only then writes a marker file. If cancellation + // truly kills the process tree, the child 'ping' is terminated before the marker is written, + // so the marker never appears — proving termination, not merely that an exception surfaced. + var markerFile = Path.Join(_tempDirectory.FullName, "child-completed.marker"); + var batchFile = Path.Join(_tempDirectory.FullName, "slow-child.cmd"); + await File.WriteAllTextAsync( + batchFile, + $"@echo off\r\nping -n 6 127.0.0.1 >nul\r\necho done> \"{markerFile}\"\r\n", + TestContext.CancellationToken); + + var runTask = runner.RunAsync( + new ProcessRunRequest(batchFile, []), + cancellationToken: cts.Token); + + cts.CancelAfter(TimeSpan.FromMilliseconds(250)); + + var threw = false; + try + { + await runTask; + } + catch (OperationCanceledException) + { + threw = true; + } + + Assert.IsTrue(threw, "Cancellation should surface as an OperationCanceledException"); + + // Wait past the child's own delay; if the tree was killed, the marker is never written. + await Task.Delay(TimeSpan.FromSeconds(7), TestContext.CancellationToken); + Assert.IsFalse(File.Exists(markerFile), + "The child process (and its 'ping' descendant) must be killed on cancellation, so its delayed marker file is never written"); + } + + [TestMethod] + public async Task RunAsync_ExecutesBatchFileDirectly_AndForwardsArguments() + { + // Regression test for the concern that ProcessRunner (UseShellExecute=false) cannot execute a + // .cmd/.bat target directly. FindAzureCli resolves 'az.cmd', which is handed to ProcessRunner + // as the executable. Since .NET 8 the runtime transparently launches a .cmd/.bat target via + // cmd.exe with safe argument escaping, so a batch file runs correctly and its arguments are + // forwarded. This locks in that behavior so the cached-session token and 'az login' paths keep + // working, mirroring how 'az.cmd' is invoked. + var runner = new ProcessRunner(); + var batchFile = Path.Join(_tempDirectory.FullName, "harmless.cmd"); + + // '@echo off' keeps the prompt out of stdout; the script echoes a marker plus its first arg. + await File.WriteAllTextAsync( + batchFile, + "@echo off\r\necho batch-ran arg=%1\r\n", + TestContext.CancellationToken); + + var result = await runner.RunAsync( + new ProcessRunRequest(batchFile, ["forwarded-arg"]), + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(0, result.ExitCode, "A .cmd target must execute successfully via ProcessRunner"); + StringAssert.Contains(result.StandardOutput, "batch-ran"); + StringAssert.Contains(result.StandardOutput, "forwarded-arg", + "Arguments must be forwarded to the batch file"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ToolCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ToolCommandTests.cs index 5daf958fa..ddf56cebd 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ToolCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ToolCommandTests.cs @@ -257,7 +257,7 @@ public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext public Task EnsureBuildToolsAsync(TaskContext taskContext, bool forceLatest = false, CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default) => Task.FromResult((string.Empty, string.Empty)); } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs index 5831644d4..e8c823052 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs @@ -18,6 +18,8 @@ namespace WinApp.Cli.Tests; [DoNotParallelize] // Modifies static Console streams and environment variables public class UpdateNotificationGatingTests { + private const string UpdateNoticeMarker = " is available. To update, "; + private string _tempCacheDir = null!; private string? _savedCacheDir; private string? _savedCaller; @@ -72,9 +74,9 @@ public async Task JsonMode_SuppressesUpdateNotice_StdoutHasNoNotice() { var (stdout, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global", "--json"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--json stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--json stderr must not contain update notice. Got stderr: {stderr}"); } @@ -83,9 +85,9 @@ public async Task QuietMode_SuppressesUpdateNotice() { var (stdout, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global", "--quiet"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--quiet stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--quiet stderr must not contain update notice. Got stderr: {stderr}"); } @@ -94,9 +96,9 @@ public async Task CliSchemaMode_SuppressesUpdateNotice() { var (stdout, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["--cli-schema"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--cli-schema stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--cli-schema stderr must not contain update notice. Got stderr: {stderr}"); } @@ -107,9 +109,9 @@ public async Task NormalMode_ShowsUpdateNotice_OnStderr() // never stdout. We capture stderr via Console.SetError. var (stdout, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"Update notice must not appear on stdout. Got stdout: {stdout}"); - Assert.IsTrue(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsTrue(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"Update notice should appear on stderr in normal mode. Got stderr: {stderr}"); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinApp.Cli.Tests.csproj b/src/winapp-CLI/WinApp.Cli.Tests/WinApp.Cli.Tests.csproj index c5998e2cd..8a1399905 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinApp.Cli.Tests.csproj +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinApp.Cli.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/src/winapp-CLI/WinApp.Cli/Commands/AzSignCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/AzSignCommand.cs new file mode 100644 index 000000000..366413da3 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/AzSignCommand.cs @@ -0,0 +1,636 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Commands; + +internal class AzSignCommand : Command, IShortDescription +{ + public string ShortDescription => "Code-sign a file using Azure Trusted Signing"; + + public static Argument FilePathArgument { get; } + public static Option SubscriptionOption { get; } + public static Option ResourceGroupOption { get; } + public static Option AccountOption { get; } + public static Option ProfileOption { get; } + public static Option MetadataFileOption { get; } + + static AzSignCommand() + { + FilePathArgument = new Argument("file-path") + { + Description = "Path to the file to sign (exe, msix, or msixbundle)" + }; + FilePathArgument.AcceptExistingOnly(); + + SubscriptionOption = new Option("--subscription", "-s") + { + Description = "Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted." + }; + + ResourceGroupOption = new Option("--resource-group", "-r") + { + Description = "Resource group to narrow down signing accounts" + }; + + AccountOption = new Option("--account") + { + Description = "Signing account name. Must be used with --resource-group" + }; + + ProfileOption = new Option("--profile", "-p") + { + Description = "Certificate profile name. Must be used with --account" + }; + + MetadataFileOption = new Option("--metadata-file", "-m") + { + Description = "Path to an existing metadata.json file. Skips resource discovery and account/profile selection prompts and signs using this file directly. A non-interactive Azure credential should already be available; the CLI can otherwise fall back to an interactive tenant prompt or 'az login', but the npm programmatic API is always non-interactive and fails instead of prompting." + }; + } + + public AzSignCommand() : base("az-sign", "Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix") + { + Arguments.Add(FilePathArgument); + Options.Add(SubscriptionOption); + Options.Add(ResourceGroupOption); + Options.Add(AccountOption); + Options.Add(ProfileOption); + Options.Add(MetadataFileOption); + } + + public class Handler( + IAzureAuthService azureAuthService, + IAzureSigningService azureSigningService, + IAzureSignToolService azureSignToolService, + IStatusService statusService, + IAnsiConsole ansiConsole, + ILogger logger) : AsynchronousCommandLineAction + { + private const string ArmScope = "https://management.azure.com/.default"; + + // Azure Code Signing profiles must be "Active" to sign; other states (disabled, + // suspended, still-provisioning) would fail later inside signtool's dlib. + private const string ActiveProfileStatus = "Active"; + + // The signing dlib sends an Azure access token to the metadata "Endpoint" host, so a + // user-supplied Endpoint is only trusted on the Azure Code Signing data-plane domain. + private const string SigningEndpointHostSuffix = ".codesigning.azure.net"; + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var filePath = parseResult.GetRequiredValue(FilePathArgument); + // Normalize whitespace-only values to null so a value like "--account ' '" is treated + // as "not provided" consistently by both the dependency checks and resource discovery. + var subscription = Normalize(parseResult.GetValue(SubscriptionOption)); + var resourceGroup = Normalize(parseResult.GetValue(ResourceGroupOption)); + var account = Normalize(parseResult.GetValue(AccountOption)); + var profile = Normalize(parseResult.GetValue(ProfileOption)); + var metadataFile = parseResult.GetValue(MetadataFileOption); + + // Validate flag dependencies + if (account != null && resourceGroup == null) + { + logger.LogError("--account must be used with --resource-group"); + return 1; + } + + if (profile != null && account == null) + { + logger.LogError("--profile must be used with --account"); + return 1; + } + + // Validate the metadata file exists before doing any Azure work, so an obvious + // input error fails fast rather than after an interactive login. + if (metadataFile != null && !metadataFile.Exists) + { + logger.LogError("Metadata file not found: {Path}", metadataFile.FullName); + return 1; + } + + // A metadata file is authoritative — it fully specifies the signing identity, so any + // resource-selection flags would be silently ignored. Reject the combination rather + // than sign with something other than what the user typed. + if (metadataFile != null && + (subscription != null || resourceGroup != null || account != null || profile != null)) + { + logger.LogError( + "--metadata-file cannot be combined with --subscription, --resource-group, --account, or --profile."); + return 1; + } + + try + { + // Determine what to sign with + FileInfo metadataFilePath; + bool generatedMetadata; + + if (metadataFile != null) + { + // Validate the user-supplied metadata before any Azure work: signtool's dlib sends + // an Azure access token to the Endpoint, so an attacker-controlled Endpoint (in a + // checked-in or CI-produced metadata.json) could exfiltrate the token. Also require + // the account and profile fields so an incomplete file fails fast here rather than + // after authentication with an opaque error from inside signtool. + ValidateMetadataFile(metadataFile); + + // Even though we skip ARM resource discovery here, still run the credential/login + // preparation. This is the only path that detects a missing interactive credential + // and performs the advertised `az login` fallback; without it, `az-sign + // --metadata-file ...` would fail deep inside the dlib when the user has no cached + // login instead of prompting to sign in. The token itself is unused (the dlib + // authenticates independently for the signing data plane) — acquiring it just + // ensures a usable credential exists first. + var loginToken = await azureAuthService.GetAccessTokenAsync(ArmScope, cancellationToken); + if (string.IsNullOrEmpty(loginToken)) + { + logger.LogError("Failed to authenticate with Azure."); + return 1; + } + + metadataFilePath = metadataFile; + generatedMetadata = false; + } + else + { + // Authenticate for ARM resource discovery (not needed when metadata file is provided, + // since the dlib authenticates independently for the signing data plane) + var accessToken = await azureAuthService.GetAccessTokenAsync(ArmScope, cancellationToken); + + if (string.IsNullOrEmpty(accessToken)) + { + logger.LogError("Failed to authenticate with Azure."); + return 1; + } + + // Discover resources (may involve REST calls shown in status) + var metadata = await DiscoverAndSelectResourcesAsync( + accessToken, subscription, resourceGroup, account, profile, cancellationToken); + + if (metadata == null) + { + return 1; + } + + // Confirm selection (outside status context) unless the signing identity + // was fully specified on the command line, or we're running non-interactively + // (where a prompt would only hang/fail automated callers). + var fullySpecified = account != null && profile != null; + if (!fullySpecified && azureAuthService.IsInteractive) + { + var confirm = await ansiConsole.PromptAsync( + new ConfirmationPrompt( + $"Sign with profile [green]{metadata.Value.ProfileName}[/] in account [green]{metadata.Value.AccountName}[/]?") + { + DefaultValue = true + }, + cancellationToken); + + if (!confirm) + { + logger.LogInformation("Signing cancelled."); + return 1; + } + } + + // Generate metadata.json + metadataFilePath = await GenerateMetadataFileAsync(metadata.Value, cancellationToken); + generatedMetadata = true; + } + + // Step 5: Sign with signtool (inside status context) + try + { + return await statusService.ExecuteWithStatusAsync($"Signing: {filePath.Name}", async (taskContext, ct) => + { + await azureSignToolService.SignAsync(filePath, metadataFilePath, azureAuthService.TenantId, taskContext, ct); + return (0, $"Successfully signed: {filePath.Name}"); + }, cancellationToken); + } + finally + { + if (generatedMetadata) + { + CleanupMetadataFile(metadataFilePath); + } + } + } + catch (InvalidOperationException ex) + { + logger.LogError("{Message}", ex.Message); + return 1; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + logger.LogError("Operation cancelled."); + return 1; + } + catch (OperationCanceledException) + { + // HttpClient.Timeout throws OperationCanceledException even when the user's + // cancellation token was not signalled. Report this as a timeout, not a cancellation. + logger.LogError("An Azure API request timed out. Check your network connection and try again."); + return 1; + } + } + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// + /// Throws with an actionable message when a selection prompt would be required but the + /// environment is non-interactive (CI, redirected input), so scripted callers get a clear + /// error telling them which flag to pass instead of hanging on an unanswerable prompt. + /// + private void RequireInteractiveSelection(string message) + { + if (!azureAuthService.IsInteractive) + { + throw new InvalidOperationException(message); + } + } + + /// + /// Validates a user-supplied metadata file before it is handed to signtool's dlib. The dlib + /// acquires an Azure access token and sends it to the Endpoint, so an attacker-controlled + /// Endpoint (e.g. in a checked-in or CI-produced metadata.json) could exfiltrate the token — + /// only an https URL on the Azure Code Signing data-plane domain is accepted. The + /// CodeSigningAccountName and CertificateProfileName fields required by the dlib + /// contract are also validated so an incomplete file fails fast here instead of after + /// authentication with an opaque error from inside signtool. + /// + private static void ValidateMetadataFile(FileInfo metadataFile) + { + JsonDocument doc; + try + { + using var stream = metadataFile.OpenRead(); + doc = JsonDocument.Parse(stream); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Metadata file '{metadataFile.FullName}' is not valid JSON: {ex.Message}"); + } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Metadata file '{metadataFile.FullName}' must contain a JSON object."); + } + + var endpoint = GetRequiredMetadataString(root, "Endpoint", metadataFile); + + if (!IsTrustedSigningEndpoint(endpoint)) + { + throw new InvalidOperationException( + $"Refusing to sign with untrusted Endpoint '{endpoint}' from the metadata file. " + + $"The Endpoint must be an https URL on a '*{SigningEndpointHostSuffix}' host — this " + + "protects your Azure access token from being sent to an attacker-controlled endpoint."); + } + + // The dlib metadata contract also requires the signing account and certificate profile. + // Validate them up front so an incomplete file fails fast here instead of after + // authentication/download work with an opaque error from inside signtool. + GetRequiredMetadataString(root, "CodeSigningAccountName", metadataFile); + GetRequiredMetadataString(root, "CertificateProfileName", metadataFile); + } + } + + /// + /// Reads a required non-empty string property from the metadata root, throwing a clear error + /// when it is missing, not a string, or empty. + /// + private static string GetRequiredMetadataString(JsonElement root, string propertyName, FileInfo metadataFile) + { + var value = root.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String + ? prop.GetString() + : null; + + if (string.IsNullOrEmpty(value)) + { + throw new InvalidOperationException( + $"Metadata file '{metadataFile.FullName}' is missing a non-empty string '{propertyName}' property."); + } + + return value; + } + + /// + /// Returns true only for an absolute https URL whose host is the Azure Code Signing + /// data-plane domain (e.g. https://eus.codesigning.azure.net). The leading dot in + /// the suffix prevents lookalikes such as evil-codesigning.azure.net. + /// + internal static bool IsTrustedSigningEndpoint(string endpoint) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return uri.Host.EndsWith(SigningEndpointHostSuffix, StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Host, SigningEndpointHostSuffix.TrimStart('.'), StringComparison.OrdinalIgnoreCase); + } + + private async Task DiscoverAndSelectResourcesAsync( + string accessToken, string? subscriptionId, string? resourceGroup, + string? accountName, string? profileName, + CancellationToken cancellationToken) + { + // Resolve subscription + if (string.IsNullOrEmpty(subscriptionId)) + { + subscriptionId = await SelectSubscriptionAsync(accessToken, cancellationToken); + if (subscriptionId == null) + { + return null; + } + } + + // Resolve signing account + string resolvedResourceGroup; + string resolvedAccountName; + string? accountUri; + + if (!string.IsNullOrEmpty(accountName)) + { + resolvedResourceGroup = resourceGroup!; + resolvedAccountName = accountName; + // Use a direct GET rather than listing all accounts in the resource group. + // A CI principal scoped to the account/profile may not have the broader list + // permission on the resource group. + var matchedAccount = await azureSigningService.GetSigningAccountAsync( + accessToken, subscriptionId, resolvedResourceGroup, accountName, cancellationToken); + + if (matchedAccount == null) + { + throw new InvalidOperationException( + $"Signing account '{accountName}' not found in resource group '{resolvedResourceGroup}'."); + } + + accountUri = matchedAccount.AccountUri; + } + else + { + var selectedAccount = await SelectSigningAccountAsync( + accessToken, subscriptionId, resourceGroup, cancellationToken); + if (selectedAccount == null) + { + return null; + } + resolvedResourceGroup = selectedAccount.ResourceGroup; + resolvedAccountName = selectedAccount.Name; + accountUri = selectedAccount.AccountUri; + } + + // Resolve certificate profile + string resolvedProfileName; + if (!string.IsNullOrEmpty(profileName)) + { + // Validate the explicitly named profile the same way interactive selection does, + // so a typo or a disabled/suspended profile fails here with an actionable message + // rather than deep inside signtool's dlib. This stays non-interactive. + // + // Use a direct GET on the named profile rather than listing the parent collection: + // a CI principal whose signer role is scoped to the individual profile has no list + // permission, so listing would 403 the documented --account --profile flow. + var matchedProfile = await azureSigningService.GetCertificateProfileAsync( + accessToken, subscriptionId, resolvedResourceGroup, resolvedAccountName, profileName, cancellationToken); + + if (matchedProfile == null) + { + throw new InvalidOperationException( + $"Certificate profile '{profileName}' not found in signing account '{resolvedAccountName}'."); + } + + if (!string.Equals(matchedProfile.Status, ActiveProfileStatus, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Certificate profile '{profileName}' has status '{matchedProfile.Status}' and cannot sign. " + + "Specify a profile with 'Active' status."); + } + + resolvedProfileName = matchedProfile.Name; + } + else + { + var selectedProfile = await SelectCertificateProfileAsync( + accessToken, subscriptionId, resolvedResourceGroup, resolvedAccountName, + cancellationToken); + if (selectedProfile == null) + { + return null; + } + resolvedProfileName = selectedProfile; + } + + // The endpoint must come from the account's accountUri property (regional, e.g. https://eus.codesigning.azure.net) + if (string.IsNullOrEmpty(accountUri)) + { + throw new InvalidOperationException( + $"Signing account '{resolvedAccountName}' does not have an endpoint URI. " + + "The account may still be provisioning. Please try again in a few minutes, " + + "or specify a metadata file with --metadata-file."); + } + + // Apply the same trusted-host pin the --metadata-file path enforces. The accountUri + // arrives from ARM (HTTPS, pinned to management.azure.com), but a compromised or MITM'd + // ARM response could otherwise steer the dlib's Azure access token to an attacker host. + // Pinning both paths keeps the token destination consistent (defense in depth). + if (!IsTrustedSigningEndpoint(accountUri)) + { + throw new InvalidOperationException( + $"Refusing to sign with untrusted Endpoint '{accountUri}' discovered for signing account " + + $"'{resolvedAccountName}'. The Endpoint must be an https URL on a '*{SigningEndpointHostSuffix}' " + + "host — this protects your Azure access token from being sent to an attacker-controlled endpoint."); + } + + return new SigningMetadata(accountUri, resolvedAccountName, resolvedProfileName); + } + + private async Task SelectSubscriptionAsync( + string accessToken, CancellationToken cancellationToken) + { + var subscriptions = await azureSigningService.ListSubscriptionsAsync(accessToken, cancellationToken); + + if (subscriptions.Count == 0) + { + throw new InvalidOperationException( + "No Azure subscriptions found. Ensure your account has access to at least one subscription."); + } + + if (subscriptions.Count == 1) + { + return subscriptions[0].SubscriptionId; + } + + RequireInteractiveSelection( + "Multiple Azure subscriptions are available but none was specified. " + + "Re-run with --subscription (this environment is non-interactive and cannot prompt)."); + // Prompt over the model so the returned value is the real subscription; the converter + // escapes display names so Spectre markup characters (e.g. brackets) don't misrender or throw. + var prompt = new SelectionPrompt() + .Title("Select an Azure subscription:") + .UseConverter(s => $"{Markup.Escape(s.DisplayName)} ({s.SubscriptionId})") + .AddChoices(subscriptions); + + var selected = await ansiConsole.PromptAsync(prompt, cancellationToken); + return selected.SubscriptionId; + } + + private async Task SelectSigningAccountAsync( + string accessToken, string subscriptionId, string? resourceGroup, + CancellationToken cancellationToken) + { + var accounts = await azureSigningService.ListSigningAccountsAsync( + accessToken, subscriptionId, resourceGroup, cancellationToken); + + if (accounts.Count == 0) + { + var context = string.IsNullOrEmpty(resourceGroup) + ? "subscription" + : $"resource group '{resourceGroup}'"; + throw new InvalidOperationException( + $"No signing accounts found in the {context}.\n" + + "Create one in the Azure portal (search for 'Trusted Signing', formerly Azure Code Signing)."); + } + + if (accounts.Count == 1) + { + return accounts[0]; + } + + // Format choices based on whether resource group was provided + string DisplayAccount(SigningAccount a) => string.IsNullOrEmpty(resourceGroup) + ? $"{a.Name}, Resource Group: {a.ResourceGroup}" + : a.Name; + + RequireInteractiveSelection( + "Multiple Trusted Signing accounts are available but none was specified. " + + "Re-run with --resource-group --account (this environment is non-interactive and cannot prompt)."); + // Prompt over the model so the returned value is the real account; the converter escapes + // display names so markup characters in account/resource-group names don't misrender or throw. + var prompt = new SelectionPrompt() + .Title("Select a signing account:") + .UseConverter(a => Markup.Escape(DisplayAccount(a))) + .AddChoices(accounts); + + return await ansiConsole.PromptAsync(prompt, cancellationToken); + } + + private async Task SelectCertificateProfileAsync( + string accessToken, string subscriptionId, string resourceGroup, string accountName, + CancellationToken cancellationToken) + { + var allProfiles = await azureSigningService.ListCertificateProfilesAsync( + accessToken, subscriptionId, resourceGroup, accountName, cancellationToken); + + // Only offer profiles that can actually sign; disabled/suspended profiles would + // fail later in signtool with a confusing error. + var profiles = allProfiles + .Where(p => string.Equals(p.Status, ActiveProfileStatus, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (profiles.Count == 0) + { + var totalCount = allProfiles.Count; + var message = totalCount > 0 + ? $"No active certificate profiles found for signing account '{accountName}'. " + + $"{totalCount} profile(s) exist but none have 'Active' status." + : $"No certificate profiles found for signing account '{accountName}'.\n" + + "Create a certificate profile in the Azure portal after completing identity validation."; + throw new InvalidOperationException(message); + } + + if (profiles.Count == 1) + { + return profiles[0].Name; + } + + RequireInteractiveSelection( + "Multiple certificate profiles are available but none was specified. " + + "Re-run with --profile (this environment is non-interactive and cannot prompt)."); + // Prompt over the model so the returned value is the real profile name; the converter + // escapes display strings so markup characters in profile names don't misrender or throw. + var prompt = new SelectionPrompt() + .Title("Select a certificate profile:") + .UseConverter(p => Markup.Escape($"{p.Name} ({p.ProfileType})")) + .AddChoices(profiles); + + var selected = await ansiConsole.PromptAsync(prompt, cancellationToken); + return selected.Name; + } + + internal static async Task GenerateMetadataFileAsync(SigningMetadata metadata, CancellationToken cancellationToken) + { + var tempPath = Path.Join(Path.GetTempPath(), $"winapp-az-sign-{Guid.NewGuid():N}.json"); + + try + { + using (var stream = File.Create(tempPath)) + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteString("Endpoint", metadata.Endpoint); + writer.WriteString("CodeSigningAccountName", metadata.AccountName); + writer.WriteString("CertificateProfileName", metadata.ProfileName); + // Exclude SharedTokenCacheCredential — it picks up stale consumer tokens from the MSAL + // shared cache and fails because the Azure.CodeSigning app is AAD-only + writer.WriteStartArray("ExcludeCredentials"); + writer.WriteStringValue("SharedTokenCacheCredential"); + writer.WriteEndArray(); + writer.WriteEndObject(); + await writer.FlushAsync(cancellationToken); + } + } + catch + { + // Don't leave a partial/orphaned metadata file behind on cancellation or write failure. + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort cleanup; ignore delete failures (locked/already gone/access denied). + } + + throw; + } + + return new FileInfo(tempPath); + } + + private static void CleanupMetadataFile(FileInfo metadataFile) + { + try + { + metadataFile.Refresh(); + if (metadataFile.Exists) + { + metadataFile.Delete(); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort cleanup; ignore delete failures (locked/already gone/access denied). + } + } + + internal readonly record struct SigningMetadata(string Endpoint, string AccountName, string ProfileName); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index 766e777ea..98082d20f 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -66,6 +66,7 @@ public WinAppRootCommand( GetWinappPathCommand getWinappPathCommand, CertCommand certCommand, SignCommand signCommand, + AzSignCommand azSignCommand, ToolCommand toolCommand, MSStoreCommand msStoreCommand, IAnsiConsole ansiConsole, @@ -84,6 +85,7 @@ public WinAppRootCommand( Subcommands.Add(getWinappPathCommand); Subcommands.Add(certCommand); Subcommands.Add(signCommand); + Subcommands.Add(azSignCommand); Subcommands.Add(toolCommand); Subcommands.Add(msStoreCommand); Subcommands.Add(createExternalCatalogCommand); @@ -100,7 +102,7 @@ public WinAppRootCommand( var helpOption = Options.OfType().First(); helpOption.Action = new CustomHelpAction(this, ansiConsole, ("Setup", [typeof(InitCommand), typeof(RestoreCommand), typeof(UpdateCommand)]), - ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]), + ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(AzSignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]), ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand), typeof(RunCommand), typeof(UnregisterCommand)]), ("UI Automation", [typeof(UiCommand)]) ); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index c7188bd64..62d7b5d1b 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -51,6 +51,11 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + // Azure Trusted Signing services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() // UI Automation services .AddSingleton() .AddSingleton() @@ -85,6 +90,7 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler() .UseCommandHandler(false) .UseCommandHandler() diff --git a/src/winapp-CLI/WinApp.Cli/Services/AzureAuthService.cs b/src/winapp-CLI/WinApp.Cli/Services/AzureAuthService.cs new file mode 100644 index 000000000..9ae1e9f93 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/AzureAuthService.cs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Diagnostics; +using System.Text.RegularExpressions; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Telemetry; + +namespace WinApp.Cli.Services; + +/// +/// Provides Azure authentication using Azure.Identity's credential chain. +/// In interactive environments, falls back to running 'az login' when +/// DefaultAzureCredential fails — the Trusted Signing dlib requires +/// AzureCliCredential for local interactive signing. +/// +internal partial class AzureAuthService(ILogger logger, IAnsiConsole ansiConsole, IProcessRunner processRunner) : IAzureAuthService +{ + public virtual bool IsInteractive => + Environment.UserInteractive + && !Console.IsInputRedirected + && !CIEnvironmentDetectorForTelemetry.IsCIEnvironment(); + + public string? TenantId { get; protected set; } = Environment.GetEnvironmentVariable("AZURE_TENANT_ID"); + + public async Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken = default) + { + var presetTenantId = TenantId; + if (!string.IsNullOrEmpty(presetTenantId) && !IsValidTenantId(presetTenantId)) + { + throw new InvalidOperationException( + $"Invalid AZURE_TENANT_ID value '{presetTenantId}'. " + + "It must be a tenant GUID or a domain such as contoso.onmicrosoft.com."); + } + + var credential = CreateCredential(); + + try + { + var context = new TokenRequestContext([scope]); + var token = await credential.GetTokenAsync(context, cancellationToken); + LogAuthMethod(token); + return token.Token; + } + catch (AuthenticationFailedException) + { + // DefaultAzureCredential excludes AzureCliCredential (see CreateCredential), so a session + // established by 'az login' — including the azure/login GitHub Actions flow in CI — is only + // reachable through this explicit, validated-path lookup. Attempt it in EVERY environment + // before giving up: resolve the CLI to a validated absolute path, then mint a token from any + // existing session. Only launching a NEW interactive 'az login' (which prompts) is gated by + // IsInteractive further below. + var azPath = FindAzureCli(); + if (azPath != null) + { + var tenantId = TenantId; + + // A preset tenant must be well-formed before it is ever passed to the CLI (it rejects + // both bad input and argument-injection metacharacters). + if (!string.IsNullOrEmpty(tenantId) && !IsValidTenantId(tenantId)) + { + throw new InvalidOperationException( + $"Invalid AZURE_TENANT_ID value '{tenantId}'. " + + "It must be a tenant GUID or a domain such as contoso.onmicrosoft.com."); + } + + // First try to mint a token from an existing Azure CLI session using the validated + // absolute path. This preserves the zero-prompt experience for anyone who already ran + // 'az login' — the case DefaultAzureCredential's (now excluded) CLI credential covered — + // and, crucially, works in non-interactive CI where 'az login' ran earlier. + var existingToken = await GetTokenViaAzureCliAsync(azPath, scope, tenantId, cancellationToken); + if (existingToken != null) + { + logger.LogInformation("Authenticated via Azure CLI"); + return existingToken; + } + + // No usable cached session. Launching a new interactive 'az login' only makes sense when + // we can actually prompt; in non-interactive/CI environments fall through to guidance. + if (IsInteractive) + { + if (string.IsNullOrEmpty(tenantId)) + { + tenantId = await ansiConsole.PromptAsync( + new TextPrompt("Enter your [green]Azure Tenant ID[/] (found in Azure Portal > Azure AD > Overview):") + .ValidationErrorMessage("[red]Enter a valid tenant ID — a GUID or a domain like contoso.onmicrosoft.com[/]") + .Validate(IsValidTenantId), + cancellationToken); + TenantId = tenantId; + } + + logger.LogInformation("Signing in via Azure CLI..."); + var success = await RunAzLoginAsync(azPath, tenantId, cancellationToken); + + if (!success) + { + throw new InvalidOperationException("Azure CLI login failed. Please try running 'az login' manually."); + } + + // Retrieve the token from the now-valid session via the same validated absolute path. + var retryToken = await GetTokenViaAzureCliAsync(azPath, scope, tenantId, cancellationToken); + if (retryToken == null) + { + throw new InvalidOperationException( + "Azure CLI login appeared to succeed but retrieving an access token failed. " + + "Try running 'az login' manually, then re-run the command."); + } + + logger.LogInformation("Authenticated via Azure CLI"); + return retryToken; + } + } + + // Reached when no credentials worked: either the Azure CLI is not installed, or it is + // installed but has no usable cached session and we cannot launch an interactive login. + // Emit environment-appropriate guidance. + if (!IsInteractive) + { + throw new InvalidOperationException( + "Azure authentication failed. No credentials found in the environment.\n\n" + + "For CI/CD, set these environment variables:\n" + + " AZURE_TENANT_ID - Your Azure AD tenant ID\n" + + " AZURE_CLIENT_ID - Service principal application ID\n" + + " AZURE_CLIENT_SECRET - Service principal secret\n\n" + + "For GitHub Actions OIDC, set:\n" + + " AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_FEDERATED_TOKEN_FILE\n\n" + + "For managed identity (Azure-hosted runners), no configuration is needed.\n\n" + + "Alternatively, ensure the Azure CLI is installed and run 'az login' before this command."); + } + + throw new InvalidOperationException( + "Azure authentication failed. To sign interactively, you have two options:\n\n" + + "Option 1: Install the Azure CLI and run this command again (login will be handled automatically)\n" + + " Install from: https://aka.ms/installazurecli\n\n" + + "Option 2: Set environment variables for a service principal:\n" + + " AZURE_TENANT_ID - Your Azure AD tenant ID\n" + + " AZURE_CLIENT_ID - Service principal application ID\n" + + " AZURE_CLIENT_SECRET - Service principal secret"); + } + } + + /// Creates the primary credential chain. Virtual to allow tests to substitute a fake. + protected virtual TokenCredential CreateCredential() + { + var options = new DefaultAzureCredentialOptions + { + ExcludeInteractiveBrowserCredential = true, + + // Exclude the Azure CLI credential from the implicit chain: it invokes 'az' via an + // ambient process lookup rather than the absolute path validated by FindAzureCli, so a + // repo-local or PATH-hijacked az.cmd could run. CLI-based auth is instead handled + // explicitly below through GetTokenViaAzureCliAsync using the validated path. + ExcludeAzureCliCredential = true, + }; + + var tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID"); + if (!string.IsNullOrEmpty(tenantId)) + { + options.TenantId = tenantId; + } + + return new DefaultAzureCredential(options); + } + + /// + /// Mints an access token from the current Azure CLI session by invoking az account + /// get-access-token at the validated absolute . Returns null when + /// no usable session exists or the token cannot be parsed, so callers can fall back to an + /// interactive login. Virtual for tests. + /// + protected virtual async Task GetTokenViaAzureCliAsync(string azPath, string scope, string? tenantId, CancellationToken cancellationToken) + { + var arguments = new List + { + "account", + "get-access-token", + "--scope", + scope, + "--output", + "json", + }; + + // Only forward a tenant that has already been validated, so it can never carry extra args. + if (!string.IsNullOrEmpty(tenantId) && IsValidTenantId(tenantId)) + { + arguments.Add("--tenant"); + arguments.Add(tenantId); + } + + ProcessRunResult result; + try + { + result = await processRunner.RunAsync( + new ProcessRunRequest(azPath, arguments, CreateNoWindow: true), + cancellationToken: cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) + { + // Win32Exception: the az process could not be spawned (not found / not executable). + // InvalidOperationException: ProcessRunner reports Process.Start returned null. + // IOException: failure draining the process output streams. + logger.LogDebug(ex, "Azure CLI token retrieval failed to start."); + return null; + } + + if (result.ExitCode != 0 || string.IsNullOrWhiteSpace(result.StandardOutput)) + { + return null; + } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(result.StandardOutput); + if (doc.RootElement.TryGetProperty("accessToken", out var tokenProp) + && tokenProp.ValueKind == System.Text.Json.JsonValueKind.String) + { + var token = tokenProp.GetString(); + return string.IsNullOrEmpty(token) ? null : token; + } + } + catch (System.Text.Json.JsonException) + { + // Fall through — treat unparseable output as "no token". + } + + return null; + } + + protected virtual string? FindAzureCli() + { + // Check common install locations on Windows. Derive them from the same trusted-root helper + // used to validate a PATH-resolved az.cmd so the two lists cannot drift (the roots include + // ProgramFilesX86, the default Azure CLI MSI location on 64-bit Windows). + var installed = GetAzureCliInstallRoots() + .Select(root => Path.Join(root, "wbin", "az.cmd")) + .FirstOrDefault(File.Exists); + if (installed != null) + { + return installed; + } + + // Fall back to PATH lookup. Launch where.exe by its absolute System32 path so a + // malicious 'where.exe' in the current directory cannot be run instead (its own + // resolution would otherwise search the working directory first). + try + { + var whereExe = Path.Join(Environment.SystemDirectory, "where.exe"); + if (!File.Exists(whereExe)) + { + return null; + } + + var psi = new ProcessStartInfo + { + FileName = whereExe, + Arguments = "az.cmd", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + if (p != null) + { + var output = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + var trusted = SelectFirstTrustedAzureCliPath(output, GetAzureCliInstallRoots()); + if (trusted != null) + { + return trusted; + } + } + } + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) + { + // where.exe not available or failed to run: Win32Exception (cannot spawn), + // InvalidOperationException (no process handle), or IOException (reading its output). + } + + return null; + } + + /// + /// The known, machine-scoped Azure CLI installation roots on Windows. An az.cmd + /// resolved from PATH is only trusted when it lives under one of these directories, so a + /// repo- or user-writable copy elsewhere on PATH cannot be launched automatically. + /// + internal static IReadOnlyList GetAzureCliInstallRoots() + { + var bases = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs"), + }; + + return bases + .Where(b => !string.IsNullOrEmpty(b)) + .Select(b => Path.Join(b, "Microsoft SDKs", "Azure", "CLI2")) + .ToList(); + } + + /// + /// Picks the first trusted Azure CLI path from raw where.exe output. where.exe searches + /// the current directory *before* PATH, so a malicious 'az.cmd' dropped into the working + /// directory (or anywhere else on PATH) could appear first. Walk every candidate and return the + /// first one that lives under a known Azure CLI installation root, rather than trusting whatever + /// happens to resolve first. + /// + internal static string? SelectFirstTrustedAzureCliPath(string whereOutput, IReadOnlyList allowedRoots) + { + if (string.IsNullOrEmpty(whereOutput)) + { + return null; + } + + var whereCandidates = whereOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return whereCandidates.FirstOrDefault(candidate => IsTrustedAzureCliPath(candidate, allowedRoots)); + } + + /// + /// Trusts an Azure CLI path resolved from where.exe only when it is a rooted, existing + /// file located under one of the supplied known installation roots. "Outside the current + /// directory" is deliberately not sufficient: a repo-controlled az.cmd in a sibling + /// folder (e.g. C:\repo\node_modules\.bin) is outside the working subtree yet still + /// attacker-controlled, so we require a positive match against a trusted install location. + /// + internal static bool IsTrustedAzureCliPath(string resolvedPath, IReadOnlyList allowedRoots) + { + if (string.IsNullOrWhiteSpace(resolvedPath) + || !Path.IsPathRooted(resolvedPath) + || !File.Exists(resolvedPath)) + { + return false; + } + + var fullPath = Path.GetFullPath(resolvedPath); + + foreach (var root in allowedRoots) + { + if (string.IsNullOrEmpty(root)) + { + continue; + } + + var fullRoot = Path.GetFullPath(root); + + // Path.GetRelativePath yields "." when equal, a rooted path or a "..\" prefix when the + // resolved path is outside the root, and a plain relative segment when it is inside. + var relative = Path.GetRelativePath(fullRoot, fullPath); + var isUnderRoot = relative == "." + || (!Path.IsPathRooted(relative) + && relative != ".." + && !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)); + + if (isUnderRoot) + { + return true; + } + } + + return false; + } + + protected virtual async Task RunAzLoginAsync(string azPath, string tenantId, CancellationToken cancellationToken) + { + // Use an argument list rather than string interpolation so the (already validated) tenant + // value can never be smuggled in as extra arguments to the az.cmd target. CreateNoWindow is + // false so the CLI can pop a browser for interactive login. Both pipes are drained + // concurrently and forwarded so the user sees device-login instructions; routing through + // the logger lets --quiet suppress them. + var result = await processRunner.RunAsync( + new ProcessRunRequest(azPath, ["login", "--tenant", tenantId], CreateNoWindow: false), + onOutputLine: line => logger.LogInformation("{AzCliOutput}", line), + onErrorLine: line => logger.LogInformation("{AzCliOutput}", line), + cancellationToken: cancellationToken); + + return result.ExitCode == 0; + } + + /// + /// Validates that a tenant identifier is a GUID or a DNS-style domain name. This both + /// prevents bad input from reaching the Azure CLI and rejects shell/argument metacharacters. + /// + internal static bool IsValidTenantId(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return false; + } + + return Guid.TryParse(tenantId, out _) || TenantDomainRegex().IsMatch(tenantId); + } + + [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")] + private static partial Regex TenantDomainRegex(); + + private void LogAuthMethod(AccessToken token) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET"))) + { + logger.LogInformation("Authenticated via environment credentials (service principal)"); + } + else if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AZURE_FEDERATED_TOKEN_FILE")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL"))) + { + logger.LogInformation("Authenticated via workload identity (OIDC federation)"); + } + else if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT"))) + { + logger.LogInformation("Authenticated via managed identity"); + } + else + { + logger.LogInformation("Authenticated via cached credential"); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/AzureSignToolService.cs b/src/winapp-CLI/WinApp.Cli/Services/AzureSignToolService.cs new file mode 100644 index 000000000..04dc85d87 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/AzureSignToolService.cs @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Tools; + +namespace WinApp.Cli.Services; + +/// +/// Acquires the Azure Trusted Signing client library and drives signtool to sign a file. +/// +internal class AzureSignToolService( + IBuildToolsService buildToolsService, + INugetService nugetService, + IPackageInstallationService packageInstallationService, + IWinappDirectoryService winappDirectoryService) : IAzureSignToolService +{ + internal const string ArtifactSigningClientPackage = "Microsoft.ArtifactSigning.Client"; + + // Pin to a known-good version so the DLL loaded into signtool is reproducible and + // not silently upgraded to whatever happens to be latest in the NuGet feed. + internal const string ArtifactSigningClientVersion = "1.0.128"; + + // SHA-256 of the exact Azure.CodeSigning.Dlib.dll payloads shipped in the pinned package + // version above (bin/x64 and bin/x86). signtool loads this DLL and it acquires an Azure + // access token inside the signing process, so the flat-container download (HTTPS + extract, + // no NuGet signature/hash check) is not by itself sufficient to authenticate it: a mirrored + // or compromised feed/CDN could serve an altered DLL. We therefore refuse to hand signtool a + // dlib whose content does not match one of these compiled-in hashes (fail closed). When the + // pinned version is bumped, refresh these values from the official nuget.org package. + private static readonly HashSet TrustedDlibSha256 = new(StringComparer.OrdinalIgnoreCase) + { + // bin/x64/Azure.CodeSigning.Dlib.dll + "2D4C1BBC87467B3AC25BBC49DF58CC8B36A0F92B3E21AA98BBBAD08A4D7C98BA", + // bin/x86/Azure.CodeSigning.Dlib.dll + "584110D279E2C112206E4CC28949354654E8A72B143EDB52352CFA103C65AB7D", + }; + + // Microsoft's official RFC 3161 timestamp endpoint for Azure Trusted Signing. It is HTTP by + // design, matching Microsoft's documented signtool integration and the industry norm for + // timestamp authorities (DigiCert, Sectigo, etc. all publish http:// timestamp URLs). Security + // does not depend on the transport: the timestamp token returned by the TSA is itself + // cryptographically signed, and signtool verifies that signature, so a man-in-the-middle cannot + // forge or alter the timestamp without invalidating it — a tampered response is rejected rather + // than trusted. The TSA also does not receive the file being signed (only a hash), so HTTP does + // not expose content. https is not offered at this endpoint, so using it would simply fail. + private const string TimestampUrl = "http://timestamp.acs.microsoft.com"; + + /// + /// Resolves the trusted Azure CLI wbin directories (those containing a real az.cmd) + /// that are prepended to signtool's PATH. Exposed as a settable seam only so tests can supply a + /// deterministic set independent of what is installed on the test host; production uses the known + /// machine/user install roots. + /// + internal Func> TrustedAzureCliBinDirsProvider { get; set; } = DefaultTrustedAzureCliBinDirs; + + private static IReadOnlyList DefaultTrustedAzureCliBinDirs() => + AzureAuthService.GetAzureCliInstallRoots() + .Select(root => Path.Join(root, "wbin")) + .Where(wbin => File.Exists(Path.Join(wbin, "az.cmd"))) + .ToList(); + + public async Task SignAsync( + FileInfo filePath, + FileInfo metadataFilePath, + string? tenantId, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + // Ensure the Trusted Signing dlib is available + var dlibPath = await EnsureTrustedSigningDlibAsync(taskContext, cancellationToken); + + // The dlib only ships as x64/x86. We must use the matching signtool architecture. + // Determine dlib architecture from its path (bin/x64/ or bin/x86/) + var dlibArch = dlibPath.Directory?.Name; // "x64" or "x86" + + // Ensure signtool is installed, then find the matching architecture version + var signtoolPath = await buildToolsService.EnsureBuildToolAvailableAsync("signtool.exe", taskContext, cancellationToken: cancellationToken); + + // If we're on ARM64 and the signtool resolved is arm64, swap to x64 to match the dlib + if (dlibArch != null && !signtoolPath.FullName.Contains($"\\{dlibArch}\\", StringComparison.OrdinalIgnoreCase)) + { + // Try to find the x64 signtool alongside the resolved one + var signtoolDir = signtoolPath.Directory!; + var parentDir = signtoolDir.Parent; + if (parentDir != null) + { + var matchingArchSigntool = new FileInfo(Path.Join(parentDir.FullName, dlibArch, "signtool.exe")); + if (matchingArchSigntool.Exists) + { + signtoolPath = matchingArchSigntool; + } + } + } + + // Build signtool arguments for Azure Trusted Signing + var arguments = $@"sign /v /debug /fd SHA256 /tr ""{TimestampUrl}"" /td SHA256 /dlib ""{dlibPath.FullName}"" /dmdf ""{metadataFilePath.FullName}"" ""{filePath.FullName}"""; + + taskContext.AddDebugMessage($"Using signtool: {signtoolPath.FullName}"); + taskContext.AddDebugMessage($"Using dlib: {dlibPath.FullName}"); + + // Build the child environment: forward the tenant so the dlib authenticates against the + // correct tenant, and harden PATH so its Azure CLI lookup can't be hijacked (see below). + var environment = BuildSigntoolEnvironment(tenantId); + + // Reuse the shared build-tool runner so process spawning, concurrent stream draining, + // cancellation/kill, and exit-code handling live in one place. We pass the resolved + // (architecture-matched) signtool path as an override rather than re-resolving by name. + // + // The dlib performs its own Azure.Identity authentication in-process and, unlike + // AzureAuthService, its credential chain still includes AzureCliCredential, which resolves + // 'az' by ambient lookup. Two things harden that lookup: (1) signtool runs from a trusted + // system directory (System32) instead of inheriting the caller's working directory, so a + // repo-local 'az.cmd' next to the target can't be found via the current-directory-first + // search; and (2) the known Azure CLI install directories are prepended to PATH (see + // BuildSigntoolEnvironment), so the legitimate az.cmd wins over any 'az.cmd' injected + // elsewhere on PATH. All signtool file arguments (dlib, metadata, target) are absolute, so + // the working directory does not affect signing. + await buildToolsService.RunBuildToolAsync( + new GenericTool("signtool.exe"), + arguments, + taskContext, + toolPathOverride: signtoolPath, + environment: environment, + workingDirectory: Environment.SystemDirectory, + cancellationToken: cancellationToken); + + taskContext.AddDebugMessage("File signed successfully"); + } + + /// + /// Builds the environment overrides for the signtool child process: the tenant id (when known) + /// and a hardened PATH that prepends the trusted Azure CLI install directories. Prepending is + /// additive — existing PATH entries are preserved so non-standard installs keep working — while + /// ensuring the dlib's AzureCliCredential resolves the legitimate az.cmd ahead of + /// any attacker-injected one later on PATH. Returns null when there is nothing to override. + /// + private Dictionary? BuildSigntoolEnvironment(string? tenantId) + { + var environment = new Dictionary(); + + if (!string.IsNullOrEmpty(tenantId)) + { + environment["AZURE_TENANT_ID"] = tenantId; + } + + var trustedAzBinDirs = TrustedAzureCliBinDirsProvider(); + if (trustedAzBinDirs.Count > 0) + { + var prefix = string.Join(Path.PathSeparator, trustedAzBinDirs); + var currentPath = Environment.GetEnvironmentVariable("PATH"); + environment["PATH"] = string.IsNullOrEmpty(currentPath) + ? prefix + : prefix + Path.PathSeparator + currentPath; + } + + return environment.Count > 0 ? environment : null; + } + + /// + /// Verifies that a located dlib matches one of the compiled-in pinned hashes before it is used. + /// Exposed as a settable seam (default: ) only so unit tests can + /// exercise the install/cache flow with placeholder DLL content; production never overrides it, so + /// the real SHA-256 check always runs. + /// + internal Func DlibIntegrityVerifier { get; set; } = DefaultIsTrustedDlib; + + internal async Task EnsureTrustedSigningDlibAsync(TaskContext taskContext, CancellationToken cancellationToken) + { + // Check if already available in NuGet cache. Verify the cached DLL's content before trusting + // it: the shared NuGet global cache can be populated by anything, so a cache hit alone does + // not prove the payload is the pinned, authenticated dlib. + var dlibPath = FindTrustedSigningDlib(ArtifactSigningClientVersion); + if (dlibPath != null) + { + if (DlibIntegrityVerifier(dlibPath)) + { + return dlibPath; + } + + // The cached DLL does not match a pinned hash — treat it like a poisoned cache entry and + // delete the version directory so the install below re-extracts a clean copy. + taskContext.AddDebugMessage( + $"Cached {ArtifactSigningClientPackage} dlib failed integrity verification; removing and reinstalling."); + RemoveVersionDir(ArtifactSigningClientVersion, taskContext); + } + + // Download the pinned version of the package + await taskContext.AddSubTaskAsync($"Installing {ArtifactSigningClientPackage} {ArtifactSigningClientVersion}...", async (subContext, ct) => + { + // A previous install that failed or was cancelled mid-extraction can leave the version + // directory present but without the dlib. EnsurePackageAsync treats any existing version + // directory as installed and would skip re-downloading, permanently poisoning the cache. + // Remove such a partial directory first so the install actually re-extracts. + RemovePoisonedVersionDir(ArtifactSigningClientVersion, subContext); + + var globalWinappDir = winappDirectoryService.GetGlobalWinappDirectory(); + var success = await packageInstallationService.EnsurePackageAsync( + globalWinappDir, + ArtifactSigningClientPackage, + subContext, + version: ArtifactSigningClientVersion, + cancellationToken: ct); + + if (!success) + { + return (1, $"Failed to install {ArtifactSigningClientPackage}."); + } + + return (0, $"{ArtifactSigningClientPackage} installed successfully."); + }, cancellationToken); + + // EnsurePackageAsync swallows all exceptions (including OperationCanceledException) and + // returns false, and the subtask wrapper likewise converts a thrown cancellation into a + // failed-result tuple rather than rethrowing. Either way a cancelled download would otherwise + // fall through to the dlib lookup below. Re-check the caller's token here so Ctrl+C during the + // install propagates as cancellation instead of a generic "could not find the dlib" failure. + cancellationToken.ThrowIfCancellationRequested(); + + dlibPath = FindTrustedSigningDlib(ArtifactSigningClientVersion); + if (dlibPath == null) + { + throw new InvalidOperationException( + $"Could not find the Trusted Signing client library after installing {ArtifactSigningClientPackage} {ArtifactSigningClientVersion}.\n" + + "Ensure the package contains the expected DLL structure."); + } + + // Fail closed: never hand signtool a freshly downloaded dlib whose content does not match a + // pinned hash. A mismatch here means the feed/CDN served something other than the pinned, + // known-good payload. + if (!DlibIntegrityVerifier(dlibPath)) + { + throw new InvalidOperationException( + $"The downloaded {ArtifactSigningClientPackage} {ArtifactSigningClientVersion} dlib failed integrity verification " + + $"(unexpected SHA-256 for {dlibPath.Name}). Refusing to use it; the NuGet feed or CDN may be compromised or mirrored."); + } + + return dlibPath; + } + + /// + /// Returns true only when the DLL's SHA-256 matches one of the compiled-in hashes for the + /// pinned package version. This authenticates the exact executable payload signtool loads, + /// closing the gap that the flat-container downloader (HTTPS + unzip, no NuGet signature check) + /// leaves open. Reads the file fully and hashes it; failures to read are treated as untrusted. + /// + private static bool DefaultIsTrustedDlib(FileInfo dlib) + { + try + { + using var stream = dlib.OpenRead(); + var hash = Convert.ToHexString(SHA256.HashData(stream)); + return TrustedDlibSha256.Contains(hash); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Cannot read the file to hash it (locked, missing, or access denied) — treat as untrusted. + return false; + } + } + + /// + /// Deletes a cached package version directory when it exists but does not contain the expected + /// dlib — the signature of a failed or cancelled prior extraction. Leaving it in place would let + /// EnsurePackageAsync treat the package as already installed and skip re-downloading, + /// so every later run would fail without recovering. Best-effort: failures are logged, not thrown. + /// + private void RemovePoisonedVersionDir(string version, TaskContext taskContext) + { + try + { + var nugetCache = nugetService.GetNuGetGlobalPackagesDir(); + var versionDir = new DirectoryInfo(Path.Join(nugetCache.FullName, ArtifactSigningClientPackage.ToLowerInvariant(), version)); + if (!versionDir.Exists) + { + return; + } + + // If the dlib is already present the cache is healthy — nothing to clean up. + if (FindTrustedSigningDlib(version) != null) + { + return; + } + + taskContext.AddDebugMessage($"Removing incomplete cached package at {versionDir.FullName} before reinstalling."); + versionDir.Delete(recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort: if cleanup fails, the subsequent install/find will surface a clear error. + taskContext.AddDebugMessage($"Could not clean up incomplete package cache: {ex.Message}"); + } + } + + /// + /// Unconditionally deletes the cached package version directory (best-effort). Used when a cached + /// dlib fails integrity verification and must be removed before a clean reinstall. + /// + private void RemoveVersionDir(string version, TaskContext taskContext) + { + try + { + var nugetCache = nugetService.GetNuGetGlobalPackagesDir(); + var versionDir = new DirectoryInfo(Path.Join(nugetCache.FullName, ArtifactSigningClientPackage.ToLowerInvariant(), version)); + if (versionDir.Exists) + { + versionDir.Delete(recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + taskContext.AddDebugMessage($"Could not remove cached package directory: {ex.Message}"); + } + } + + private FileInfo? FindTrustedSigningDlib(string? version = null) + { + var nugetCache = nugetService.GetNuGetGlobalPackagesDir(); + var packageDir = new DirectoryInfo(Path.Join(nugetCache.FullName, ArtifactSigningClientPackage.ToLowerInvariant())); + + if (!packageDir.Exists) + { + return null; + } + + IEnumerable versionDirs; + if (!string.IsNullOrEmpty(version)) + { + // Prefer the exact pinned version so the loaded DLL is reproducible. + var exactDir = new DirectoryInfo(Path.Join(packageDir.FullName, version)); + if (!exactDir.Exists) + { + return null; + } + versionDirs = [exactDir]; + } + else + { + // Fall back to the highest installed version using semantic (not lexicographic) ordering. + versionDirs = packageDir.GetDirectories() + .OrderByDescending(d => d.Name, Comparer.Create(NugetService.CompareVersions)); + } + + foreach (var versionDir in versionDirs) + { + // The dlib is at: bin/x64/Azure.CodeSigning.Dlib.dll (x64 works on ARM64 via emulation) + var dlibFile = new FileInfo(Path.Join(versionDir.FullName, "bin", "x64", "Azure.CodeSigning.Dlib.dll")); + if (dlibFile.Exists) + { + return dlibFile; + } + + // Fallback: search recursively for the DLL, but never an x86 build. winapp ships and + // documents x64/arm64 runtime prerequisites only (x64 runs on ARM64 via emulation), so + // handing signtool an x86 dlib would load an architecture with no matching .NET/VC++ + // runtime on the machine. Exclude any candidate under an "x86" directory. + var found = versionDir.GetFiles("Azure.CodeSigning.Dlib.dll", SearchOption.AllDirectories) + .FirstOrDefault(f => !IsX86Dlib(f)); + if (found != null) + { + return found; + } + } + + return null; + } + + /// + /// Returns true when the resolved dlib lives under an x86 directory (e.g. bin/x86). + /// winapp only ships/documents x64 and arm64 runtime prerequisites, so an x86 dlib must never be + /// handed to signtool even though the recursive fallback might otherwise find one. + /// + private static bool IsX86Dlib(FileInfo dlibFile) + { + for (var dir = dlibFile.Directory; dir != null; dir = dir.Parent) + { + if (string.Equals(dir.Name, "x86", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/AzureSigningService.cs b/src/winapp-CLI/WinApp.Cli/Services/AzureSigningService.cs new file mode 100644 index 000000000..2ff9bbd3a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/AzureSigningService.cs @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace WinApp.Cli.Services; + +/// +/// Calls Azure REST APIs to discover Trusted Signing resources. +/// NativeAOT-compatible — uses raw HTTP with System.Text.Json. +/// +internal class AzureSigningService : IAzureSigningService +{ + private static readonly HttpClient SharedHttp = new() { Timeout = TimeSpan.FromSeconds(30) }; + private const string ArmBaseUrl = "https://management.azure.com"; + private static readonly string ArmHost = new Uri(ArmBaseUrl).Host; + private const string SubscriptionsApiVersion = "2022-12-01"; + private const string TrustedSigningApiVersion = "2024-02-05-preview"; + + private readonly ILogger logger; + private readonly HttpClient http; + + public AzureSigningService(ILogger logger) : this(logger, SharedHttp) + { + } + + // Test seam: allows injecting an HttpClient backed by a stub message handler. + internal AzureSigningService(ILogger logger, HttpClient httpClient) + { + this.logger = logger; + this.http = httpClient; + } + + public async Task> ListSubscriptionsAsync(string accessToken, CancellationToken cancellationToken = default) + { + var url = $"{ArmBaseUrl}/subscriptions?api-version={SubscriptionsApiVersion}"; + var subscriptions = await GetArmPagedAsync(url, accessToken, item => + { + var state = item.TryGetProperty("state", out var stateProp) ? stateProp.GetString() : null; + + // Only include enabled subscriptions + if (!string.Equals(state, "Enabled", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var subId = item.GetProperty("subscriptionId").GetString() ?? ""; + var displayName = item.GetProperty("displayName").GetString() ?? ""; + return new AzureSubscription(subId, displayName); + }, cancellationToken); + + logger.LogInformation("Found {Count} enabled subscription(s)", subscriptions.Count); + return subscriptions; + } + + public async Task> ListSigningAccountsAsync(string accessToken, string subscriptionId, string? resourceGroup = null, CancellationToken cancellationToken = default) + { + string url = !string.IsNullOrEmpty(resourceGroup) + ? $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CodeSigning/codeSigningAccounts?api-version={TrustedSigningApiVersion}" + : $"{ArmBaseUrl}/subscriptions/{subscriptionId}/providers/Microsoft.CodeSigning/codeSigningAccounts?api-version={TrustedSigningApiVersion}"; + + var accounts = await GetArmPagedAsync(url, accessToken, item => + { + var name = item.GetProperty("name").GetString() ?? ""; + var location = item.TryGetProperty("location", out var locProp) ? locProp.GetString() ?? "" : ""; + var id = item.GetProperty("id").GetString() ?? ""; + + // Parse resource group from the resource ID + var rg = ParseResourceGroupFromId(id); + + // Get account URI from properties + string? accountUri = null; + if (item.TryGetProperty("properties", out var props) && + props.TryGetProperty("accountUri", out var uriProp)) + { + accountUri = uriProp.GetString(); + } + + return new SigningAccount(name, rg, location, accountUri); + }, cancellationToken); + + logger.LogInformation("Found {Count} signing account(s)", accounts.Count); + return accounts; + } + + public async Task> ListCertificateProfilesAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default) + { + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CodeSigning/codeSigningAccounts/{accountName}/certificateProfiles?api-version={TrustedSigningApiVersion}"; + + var profiles = await GetArmPagedAsync(url, accessToken, item => + { + var name = item.GetProperty("name").GetString() ?? ""; + var profileType = ""; + var status = ""; + + if (item.TryGetProperty("properties", out var props)) + { + profileType = props.TryGetProperty("profileType", out var typeProp) ? typeProp.GetString() ?? "" : ""; + status = props.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; + } + + return new CertificateProfile(name, profileType, status); + }, cancellationToken); + + logger.LogInformation("Found {Count} certificate profile(s) for account '{Account}'", profiles.Count, accountName); + return profiles; + } + + public async Task GetSigningAccountAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default) + { + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CodeSigning/codeSigningAccounts/{accountName}?api-version={TrustedSigningApiVersion}"; + + var item = await GetArmResourceAsync(url, accessToken, cancellationToken); + if (item == null) + { + return null; + } + + var element = item.Value; + var name = element.GetProperty("name").GetString() ?? ""; + var location = element.TryGetProperty("location", out var locProp) ? locProp.GetString() ?? "" : ""; + var id = element.GetProperty("id").GetString() ?? ""; + var rg = ParseResourceGroupFromId(id); + + string? accountUri = null; + if (element.TryGetProperty("properties", out var props) && + props.TryGetProperty("accountUri", out var uriProp)) + { + accountUri = uriProp.GetString(); + } + + return new SigningAccount(name, rg, location, accountUri); + } + + public async Task GetCertificateProfileAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, string profileName, CancellationToken cancellationToken = default) + { + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CodeSigning/codeSigningAccounts/{accountName}/certificateProfiles/{profileName}?api-version={TrustedSigningApiVersion}"; + + var item = await GetArmResourceAsync(url, accessToken, cancellationToken); + if (item == null) + { + return null; + } + + var element = item.Value; + var name = element.GetProperty("name").GetString() ?? ""; + var profileType = ""; + var status = ""; + if (element.TryGetProperty("properties", out var props)) + { + profileType = props.TryGetProperty("profileType", out var typeProp) ? typeProp.GetString() ?? "" : ""; + status = props.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; + } + + return new CertificateProfile(name, profileType, status); + } + + /// + /// Issues a single ARM GET and returns the parsed root element, or null on 404. Shared by the + /// direct-GET resource lookups (, + /// ) so they need only the reader role on the specific + /// resource rather than list permission on the parent collection. + /// + private async Task GetArmResourceAsync(string url, string accessToken, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await http.SendAsync(request, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorMessage = TryParseAzureError(content) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; + throw new InvalidOperationException($"Azure API request failed: {errorMessage}"); + } + + using var doc = JsonDocument.Parse(content); + return doc.RootElement.Clone(); + } + + /// + /// Fetches an ARM list endpoint and follows the nextLink continuation until it is + /// absent, so results that span multiple pages (many subscriptions/accounts/profiles) are + /// returned in full. may return null to skip an item. + /// + private async Task> GetArmPagedAsync( + string url, + string accessToken, + Func selector, + CancellationToken cancellationToken) + where T : class + { + var results = new List(); + + // Track visited links with ordinal (case-sensitive) comparison. ARM's nextLink carries an + // opaque continuation token in its query string that may be case-sensitive, so two genuinely + // distinct pages can differ only by case; a case-insensitive set would treat them as a repeat + // and abort pagination early, silently dropping results. Host trust is validated separately by + // EnsureTrustedArmUrl, so this comparison only needs to detect exact-URL cycles. + var visited = new HashSet(StringComparer.Ordinal); + string? nextUrl = url; + + while (!string.IsNullOrEmpty(nextUrl)) + { + // Guard against a cyclic or repeated nextLink so a misbehaving service can't spin + // this loop forever. + if (!visited.Add(nextUrl)) + { + throw new InvalidOperationException( + "Azure API pagination returned a repeated nextLink; aborting to avoid an infinite loop."); + } + + // Never attach the bearer token to a URL the server can choose: only follow HTTPS + // links that stay on the ARM host. A crafted nextLink pointing elsewhere would + // otherwise leak the access token to an attacker-controlled endpoint. + EnsureTrustedArmUrl(nextUrl); + + var json = await GetArmResponseAsync(nextUrl, accessToken, cancellationToken); + using var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("value", out var valueArray) + && valueArray.ValueKind == JsonValueKind.Array) + { + results.AddRange(valueArray.EnumerateArray() + .Select(selector) + .Where(selected => selected != null) + .Select(selected => selected!)); + } + + nextUrl = doc.RootElement.TryGetProperty("nextLink", out var nextLink) + ? nextLink.GetString() + : null; + } + + return results; + } + + /// + /// Rejects a pagination URL that is not an absolute HTTPS URL on the Azure Resource Manager + /// host, so the bearer token is never sent to a server-chosen or attacker-controlled endpoint. + /// + private static void EnsureTrustedArmUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.Equals(uri.Host, ArmHost, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Refusing to follow an untrusted Azure pagination link '{url}'. " + + $"Expected an https URL on host '{ArmHost}'."); + } + } + + private async Task GetArmResponseAsync(string url, string accessToken, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await http.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorMessage = TryParseAzureError(content) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; + throw new InvalidOperationException($"Azure API request failed: {errorMessage}"); + } + + return content; + } + + private static string? TryParseAzureError(string responseBody) + { + try + { + using var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.TryGetProperty("error", out var errorObj)) + { + var code = errorObj.TryGetProperty("code", out var codeProp) ? codeProp.GetString() : null; + var message = errorObj.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null; + if (message != null) + { + return code != null ? $"{code}: {message}" : message; + } + } + } + catch (JsonException) + { + // Not valid JSON or unexpected structure + } + return null; + } + + private static string ParseResourceGroupFromId(string resourceId) + { + // Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/... + var parts = resourceId.Split('/', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length - 1; i++) + { + if (string.Equals(parts[i], "resourceGroups", StringComparison.OrdinalIgnoreCase)) + { + return parts[i + 1]; + } + } + return ""; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs b/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs index 3feb24231..ad4904dce 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs @@ -296,14 +296,18 @@ await taskContext.AddSubTaskAsync($"{actionMessage} {BUILD_TOOLS_PACKAGE}{versio /// Arguments to pass to the tool /// Whether to print errors using the tool's PrintErrorText method /// Task context for logging + /// Explicit executable path to run instead of resolving the tool by name + /// Additional environment variables to set on the child process /// Cancellation token /// Tuple containing (stdout, stderr) - public async Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public async Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - // Ensure the build tool is available, installing BuildTools if necessary - var toolPath = await EnsureBuildToolAvailableAsync(tool.ExecutableName, taskContext, cancellationToken: cancellationToken); + // Use the caller-supplied executable when provided (e.g. an architecture-matched + // signtool), otherwise ensure the build tool is available, installing BuildTools if necessary. + var toolPath = toolPathOverride + ?? await EnsureBuildToolAvailableAsync(tool.ExecutableName, taskContext, cancellationToken: cancellationToken); var psi = new ProcessStartInfo { @@ -315,12 +319,51 @@ await taskContext.AddSubTaskAsync($"{actionMessage} {BUILD_TOOLS_PACKAGE}{versio CreateNoWindow = true }; + // A caller-supplied working directory is used verbatim; otherwise the child inherits the + // caller's current directory. Signing supplies a trusted directory so that when the Trusted + // Signing dlib shells out to resolve 'az', a repo-local 'az.cmd' in the caller's working + // directory cannot be picked up ahead of a legitimate one on PATH. + if (!string.IsNullOrEmpty(workingDirectory)) + { + psi.WorkingDirectory = workingDirectory; + } + + if (environment != null) + { + foreach (var (key, value) in environment) + { + psi.Environment[key] = value; + } + } + cancellationToken.ThrowIfCancellationRequested(); using var p = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {tool.ExecutableName} process"); - var stdout = await p.StandardOutput.ReadToEndAsync(cancellationToken); - var stderr = await p.StandardError.ReadToEndAsync(cancellationToken); - await p.WaitForExitAsync(cancellationToken); + + // Drain both pipes concurrently before awaiting exit. Reading stdout to completion + // before touching stderr can deadlock if the tool fills the stderr buffer (signtool + // /v /debug can) while we're still blocked on stdout. + var stdoutTask = p.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = p.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await p.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + TryKillProcessTree(p); + + // We killed the process while its pipes were still being read. Observe both read tasks + // before rethrowing so their reads don't keep running and can't surface later as + // unobserved task exceptions; the failures they raise here are the expected result of + // cancelling/killing mid-read. + await DrainReadsQuietlyAsync(stdoutTask, stderrTask); + throw; + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; if (!string.IsNullOrWhiteSpace(stdout)) { @@ -347,6 +390,46 @@ await taskContext.AddSubTaskAsync($"{actionMessage} {BUILD_TOOLS_PACKAGE}{versio return (stdout, stderr); } + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + // Best-effort cleanup on cancellation + } + catch (System.ComponentModel.Win32Exception) + { + // Best-effort cleanup on cancellation + } + catch (NotSupportedException) + { + // Best-effort cleanup on cancellation + } + } + + /// + /// Awaits the stdout/stderr read tasks after the process was killed on cancellation, swallowing + /// the expected failures (cancellation or a broken pipe from the kill) so the tasks are observed + /// and never surface as unobserved exceptions. Any unexpected exception is left to propagate. + /// + private static async Task DrainReadsQuietlyAsync(Task stdoutTask, Task stderrTask) + { + try + { + await Task.WhenAll(stdoutTask, stderrTask); + } + catch (Exception ex) when (ex is OperationCanceledException or IOException) + { + // Expected: the reads were cancelled or their pipe closed when we killed the process. + } + } + internal class InvalidBuildToolException : InvalidOperationException { public InvalidBuildToolException(int processId, string stdout, string stderr, string message) : base(message) diff --git a/src/winapp-CLI/WinApp.Cli/Services/IAzureAuthService.cs b/src/winapp-CLI/WinApp.Cli/Services/IAzureAuthService.cs new file mode 100644 index 000000000..cecbbbbf5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IAzureAuthService.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// +/// Provides Azure authentication tokens for ARM and Trusted Signing data plane operations. +/// +internal interface IAzureAuthService +{ + /// + /// Acquires an access token for the specified resource scope. + /// Tries environment credentials, workload identity, managed identity, and then Azure CLI. + /// + /// The OAuth2 scope (e.g., "https://management.azure.com/.default") + /// Cancellation token + /// A valid bearer token + /// When authentication fails in a non-interactive environment + Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken = default); + + /// + /// Whether the current environment supports interactive authentication. + /// + bool IsInteractive { get; } + + /// + /// The Azure tenant ID used for authentication, if known. + /// Set from AZURE_TENANT_ID environment variable or user input during interactive login. + /// + string? TenantId { get; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IAzureSignToolService.cs b/src/winapp-CLI/WinApp.Cli/Services/IAzureSignToolService.cs new file mode 100644 index 000000000..a8f9f1cfd --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IAzureSignToolService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; + +namespace WinApp.Cli.Services; + +/// +/// Runs signtool with the Azure Trusted Signing dlib to code-sign a file. +/// Owns acquisition of the Trusted Signing client package and signtool invocation, +/// keeping that logic out of the command handler so it can be substituted in tests. +/// +internal interface IAzureSignToolService +{ + /// + /// Signs using the supplied Trusted Signing metadata file. + /// + /// The file to sign (exe, msix, or msixbundle). + /// The Trusted Signing metadata.json file. + /// Azure tenant ID to forward to signtool's dlib, if known. + /// Status/progress context. + /// Cancellation token. + /// When signing fails. + Task SignAsync( + FileInfo filePath, + FileInfo metadataFilePath, + string? tenantId, + TaskContext taskContext, + CancellationToken cancellationToken = default); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IAzureSigningService.cs b/src/winapp-CLI/WinApp.Cli/Services/IAzureSigningService.cs new file mode 100644 index 000000000..0ae894782 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IAzureSigningService.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// +/// Provides access to Azure Trusted Signing REST APIs for discovering +/// subscriptions, signing accounts, and certificate profiles. +/// +internal interface IAzureSigningService +{ + /// + /// Lists all Azure subscriptions accessible to the authenticated user. + /// + Task> ListSubscriptionsAsync(string accessToken, CancellationToken cancellationToken = default); + + /// + /// Lists Trusted Signing accounts. If resourceGroup is provided, lists only within that group. + /// Otherwise lists all signing accounts in the subscription. + /// + Task> ListSigningAccountsAsync(string accessToken, string subscriptionId, string? resourceGroup = null, CancellationToken cancellationToken = default); + + /// + /// Gets a single signing account by name via a direct ARM GET, avoiding the broader list + /// permission that requires. Returns null if the + /// account is not found (404). + /// + Task GetSigningAccountAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default); + + /// + /// Lists certificate profiles under a signing account. + /// + Task> ListCertificateProfilesAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default); + + /// + /// Gets a single certificate profile by name via a direct ARM GET, avoiding the broader + /// list permission that requires. This keeps the + /// documented --account --profile flow working for a CI principal whose signer role is + /// scoped to the individual profile and therefore cannot enumerate the parent collection. + /// Returns null if the profile is not found (404). + /// + Task GetCertificateProfileAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, string profileName, CancellationToken cancellationToken = default); +} + +internal record AzureSubscription(string SubscriptionId, string DisplayName); + +internal record SigningAccount(string Name, string ResourceGroup, string Location, string? AccountUri); + +internal record CertificateProfile(string Name, string ProfileType, string Status); diff --git a/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs b/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs index 4448baaba..95e85f279 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs @@ -38,7 +38,10 @@ internal interface IBuildToolsService /// Arguments to pass to the tool /// The task context for logging /// Whether to print errors using the tool's PrintErrorText method + /// Explicit executable path to run instead of resolving the tool by name (e.g. an architecture-matched signtool) + /// Additional environment variables to set on the child process + /// Working directory for the child process. When null, the process inherits the caller's current directory. Signing passes a trusted directory so a tool that shells out (e.g. the Trusted Signing dlib resolving az) cannot pick up an executable dropped into the caller's working directory. /// Cancellation token /// Tuple containing (stdout, stderr) - Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default); + Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, string? workingDirectory = null, CancellationToken cancellationToken = default); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IProcessRunner.cs b/src/winapp-CLI/WinApp.Cli/Services/IProcessRunner.cs new file mode 100644 index 000000000..8a9cf24cb --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IProcessRunner.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// +/// Describes a child process to launch. Arguments are passed as a list (never a single +/// concatenated string) so already-validated values cannot be smuggled in as extra arguments. +/// +internal sealed record ProcessRunRequest( + string FileName, + IReadOnlyList Arguments, + bool CreateNoWindow = true, + IReadOnlyDictionary? Environment = null); + +/// Captured result of a completed child process. +internal sealed record ProcessRunResult(int ExitCode, string StandardOutput, string StandardError); + +/// +/// Launches child processes with redirected, concurrently drained output. Injecting this behind an +/// interface lets security-sensitive process construction (the Azure CLI login/token fallback) be +/// exercised in tests without spawning a real process. +/// +internal interface IProcessRunner +{ + /// + /// Starts the requested process, forwarding each stdout/stderr line to the optional callbacks + /// as it arrives while also accumulating the full output. Draining both pipes concurrently + /// avoids the deadlock that occurs when a child fills a redirected pipe. On cancellation the + /// process tree is killed and propagates. + /// + Task RunAsync( + ProcessRunRequest request, + Action? onOutputLine = null, + Action? onErrorLine = null, + CancellationToken cancellationToken = default); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/ProcessRunner.cs b/src/winapp-CLI/WinApp.Cli/Services/ProcessRunner.cs new file mode 100644 index 000000000..a2058a544 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/ProcessRunner.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace WinApp.Cli.Services; + +/// +/// Default backed by . +/// +internal sealed class ProcessRunner : IProcessRunner +{ + public async Task RunAsync( + ProcessRunRequest request, + Action? onOutputLine = null, + Action? onErrorLine = null, + CancellationToken cancellationToken = default) + { + var psi = new ProcessStartInfo + { + FileName = request.FileName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = request.CreateNoWindow, + }; + + // Use ArgumentList rather than a concatenated string so already-validated values can never + // be reinterpreted as additional command-line arguments to the target. + foreach (var arg in request.Arguments) + { + psi.ArgumentList.Add(arg); + } + + if (request.Environment != null) + { + foreach (var kvp in request.Environment) + { + psi.Environment[kvp.Key] = kvp.Value; + } + } + + using var process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start process '{request.FileName}'."); + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + var stdoutTask = DrainAsync(process.StandardOutput, stdout, onOutputLine, cancellationToken); + var stderrTask = DrainAsync(process.StandardError, stderr, onErrorLine, cancellationToken); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + TryKillProcessTree(process); + + // We killed the process while its pipes were still being drained. Observe both read + // tasks before rethrowing so their reads don't keep running and can't surface later as + // unobserved task exceptions; the failures they raise here are the expected result of + // cancelling/killing mid-read. + await DrainReadsQuietlyAsync(stdoutTask, stderrTask); + throw; + } + + await Task.WhenAll(stdoutTask, stderrTask); + + return new ProcessRunResult(process.ExitCode, stdout.ToString(), stderr.ToString()); + } + + /// + /// Awaits the stdout/stderr drain tasks after the process was killed on cancellation, swallowing + /// the expected failures (cancellation or a broken pipe from the kill) so the tasks are observed + /// and never surface as unobserved exceptions. Any unexpected exception is left to propagate. + /// + private static async Task DrainReadsQuietlyAsync(Task stdoutTask, Task stderrTask) + { + try + { + await Task.WhenAll(stdoutTask, stderrTask); + } + catch (Exception ex) when (ex is OperationCanceledException or IOException) + { + // Expected: the reads were cancelled or their pipe closed when we killed the process. + } + } + + private static async Task DrainAsync(StreamReader reader, StringBuilder sink, Action? onLine, CancellationToken cancellationToken) + { + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) + { + sink.AppendLine(line); + onLine?.Invoke(line); + } + } + + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException) + { + // Best-effort cleanup on cancellation: the process may have already exited + // (InvalidOperationException) or be unkillable; nothing more we can do. + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj b/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj index 82f1e04b8..c7aeb5592 100644 --- a/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj +++ b/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj @@ -53,6 +53,7 @@ + diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 2192a5847..8100b8926 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -53,6 +53,7 @@ npx winapp --help - [`cert`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#cert) - Generate and install development certificates - [`sign`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#sign) - Sign MSIX packages and executables +- [`az-sign`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#az-sign) - Sign packages and executables with Azure Trusted Signing **Development Tools:** @@ -81,6 +82,8 @@ await packageApp({ inputFolder: './dist', cert: './devcert.pfx' }); Full programmatic API reference: [NPM API Documentation](https://github.com/microsoft/WinAppCli/blob/main/docs/npm-usage.md) +> **Note — the programmatic API runs the CLI non-interactively.** The wrapper functions capture output and give the native process piped stdin, so commands that would normally prompt cannot do so. For `azSign` in particular this means you must pass either a `metadataFile` or a fully specified identity (`subscription`, `resourceGroup`, `account`, and `profile`), and a non-interactive Azure credential must already be available (for example `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET`, OIDC, a managed identity, or an existing `az login` session). Calls that would otherwise require a selection prompt or an interactive `az login` fail instead of prompting. + ## 🔧 Feedback - [File an issue, feature request or bug](https://github.com/microsoft/WinAppCli/issues): please ensure that you are not filing a duplicate issue diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 365a56dd0..d70bdba2b 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -2,7 +2,7 @@ * AUTO-GENERATED — DO NOT EDIT * * Regenerate with: npm run generate-commands - * Source schema version: 0.4.1 + * Source schema version: 1.0.0 * * Programmatic wrappers for all winapp CLI commands. * Each function builds the CLI arguments, invokes the native CLI, @@ -66,6 +66,39 @@ async function execCommand(args: string[], opts: CommonOptions): Promise { + const args: string[] = ['az-sign']; + args.push(options.filePath); + if (options.account) args.push('--account', options.account); + if (options.metadataFile) args.push('--metadata-file', options.metadataFile); + if (options.profile) args.push('--profile', options.profile); + if (options.resourceGroup) args.push('--resource-group', options.resourceGroup); + if (options.subscription) args.push('--subscription', options.subscription); + return execCommand(args, options); +} + // --------------------------------------------------------------------------- // cert generate // ---------------------------------------------------------------------------