From 3f0df3c3d692dd0c509e9bb07a2e5502df0453fd Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Tue, 18 Aug 2026 12:01:17 -0700 Subject: [PATCH 01/47] Finalize telemetry documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- README.md | 25 ++- docs/schema.md | 17 +- .../mxc-state-aware-sandbox-api.md | 4 +- .../telemetry-administrative-policy.md | 161 +++++++++++++ docs/telemetry/telemetry-consent-design.md | 203 +++++++++++++++++ docs/telemetry/telemetry.md | 211 ++++-------------- sdk/dotnet/README.md | 28 ++- sdk/node/README.md | 29 ++- .../scripts/run_telemetry_etw_smoke_test.ps1 | 111 +++++++-- 9 files changed, 565 insertions(+), 224 deletions(-) create mode 100644 docs/telemetry/telemetry-administrative-policy.md create mode 100644 docs/telemetry/telemetry-consent-design.md diff --git a/README.md b/README.md index aed988796..823ecad18 100644 --- a/README.md +++ b/README.md @@ -229,13 +229,19 @@ Successful non-dry-run audits require capture metadata, canonical denials JSON, > **Warning:** `--audit` injects `permissiveLearningMode` — AppContainer restrictions are **not** enforced for the duration of the run. Use only for policy authoring. It cannot be combined with `processContainer.captureDenials`; use `captureDenials.mode: "allow"` for permissive application-driven capture. `learningModeLogging` and `permissiveLearningMode` are reserved internal capability names and are rejected in `processContainer.capabilities`. See [docs/learning-mode/capabilities.md](docs/learning-mode/capabilities.md) for the three learning-mode flows. -## Telemetry (Experimental) +## Telemetry MXC supports optional TraceLogging ETW telemetry for execution observability. When enabled, structured events (`MXC.Execution` and `MXC.Error`) are emitted to the local ETW subsystem via the Rust [`tracelogging`](https://crates.io/crates/tracelogging) crate. Every event includes common fields (Version, Channel, IsDebugging, `UTCReplace_AppSessionGuid`) as Part C custom event data. -Telemetry is **experimental** and requires: -1. The `--experimental` CLI flag -2. `"experimental": { "telemetry": { "enabled": true } }` in the JSON config +Telemetry requires: +1. Top-level `"telemetry": { "enabled": true }` in the JSON config +2. Explicit per-user telemetry consent on Windows +3. An administrative policy that permits collection, when a policy is configured + +The configuration flag is an additional per-run opt-in; it cannot grant consent +or bypass an administrative block. Telemetry remains off unless every applicable +gate is open. MXC does not use the Windows Diagnostics & feedback setting as a +substitute for application consent. On non-Windows platforms, all telemetry functions are no-ops. @@ -245,12 +251,11 @@ The software may collect information about you and your use of the software and #### How to turn telemetry off -Telemetry is **off by default**. MXC emits telemetry only when **both** of the following are set, so no action is required to keep it disabled: - -1. The `--experimental` CLI flag is passed, **and** -2. `"experimental": { "telemetry": { "enabled": true } }` is present in the JSON config. +Telemetry is **off by default**. To keep it off, do not set +`"telemetry": { "enabled": true }` for the run. -Omitting either (the default) turns telemetry off entirely. On non-Windows platforms all telemetry functions are no-ops. +If telemetry is enabled in config, collection still does not occur unless +Windows user consent is granted and administrative policy allows collection. #### What official builds send @@ -277,6 +282,8 @@ Privacy information can be found at https://privacy.microsoft.com and in the Mic | [docs/windows-sandbox/windows-sandbox.md](docs/windows-sandbox/windows-sandbox.md) | Windows Sandbox backend | | [docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md](docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md) | State-aware sandbox lifecycle API | | [docs/telemetry/telemetry.md](docs/telemetry/telemetry.md) | TraceLogging telemetry architecture | +| [docs/telemetry/telemetry-consent-design.md](docs/telemetry/telemetry-consent-design.md) | Telemetry consent contract | +| [docs/telemetry/telemetry-administrative-policy.md](docs/telemetry/telemetry-administrative-policy.md) | Administrative telemetry controls | ## Contributing diff --git a/docs/schema.md b/docs/schema.md index cdbcd3128..68ff4135c 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -124,7 +124,7 @@ production configs and the dev schema when working on experimental features: "nestedPty": true, // Allow inner process to allocate its own pty (posix_openpt) "keychainAccess": false // Allow Keychain via securityd / trustd / cfprefsd / lsd.* }, - "telemetry": { // Telemetry (experimental, Windows only) + "telemetry": { // Telemetry (Windows only) "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate } } @@ -134,17 +134,12 @@ production configs and the dev schema when working on experimental features: > **State-aware fields.** The `phase` top-level field is the **state-aware > discriminator**: a request that includes it is parsed as a state-aware > lifecycle request (see below), *not* the one-shot config above. The `sandboxId` -> and `correlationVector` top-level fields are state-aware-only — a one-shot -> request carrying either is rejected with a parse error. `correlationVector` is -> the Microsoft Correlation Vector (MS-CV) seeded at `provision` and relayed by -> the client onto later phases (emitted under the TraceLogging `__TlgCV__` field -> when experimental telemetry is enabled). The client relays the value verbatim; -> the executor validates it on each non-`provision` phase and *spins* a fresh -> child element off a mutable base, passes an already-frozen vector through -> unchanged, and reseeds a new base if the relayed value is missing or malformed. -> See +> top-level field is state-aware-only — a one-shot request carrying `sandboxId` +> is rejected with a parse error. Callers cannot supply `correlationVector`; +> it is rejected as an unknown field because lifecycle correlation is internal +> to MXC and is not part of the request or response contract. See > [`docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md`](state-aware-lifecycle/mxc-state-aware-sandbox-api.md) -> and [`docs/telemetry/telemetry.md`](telemetry/telemetry.md#correlating-a-lifecycle). +> and [`docs/telemetry/telemetry.md`](telemetry/telemetry.md). ### Working Directory diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index 541ace5ab..b10190646 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -395,7 +395,6 @@ type DeprovisionMetadataFor = interface ProvisionResult { sandboxId: SandboxId; metadata?: ProvisionMetadataFor; - correlationVector?: string; // MS-CV to relay onto later phases (telemetry) } interface StartResult { @@ -664,7 +663,6 @@ State-aware-only fields: | Field | Type | Required | Description | |---|---|---|---| | `phase` | `Phase` member | Yes | Discriminator. Absence means a one-shot request. | -| `correlationVector` | string | No. Relayed by the client onto non-`provision` phases; absent on `provision` (seeded by the executor). Rejected as a parse error on one-shot requests. | Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in its result; the client relays it verbatim into later phases so the lifecycle shares a telemetry base prefix (emitted under `__TlgCV__`). Each non-`provision` phase validates the relayed value and *spins* a fresh child element off a mutable base (keeping repeat invocations distinct), passes an already-frozen vector through unchanged, and reseeds a new base if it is absent or malformed. Ignored unless experimental telemetry is enabled. See [telemetry docs](../telemetry/telemetry.md#correlating-a-lifecycle). | | `process` | `ProcessConfig` | Required for `exec`; absent otherwise. | Cross-backend execution fields. | Cross-cutting fields available to state-aware (state-aware-only at top level — backends @@ -773,7 +771,7 @@ type NonExecResponseEnvelope = { result: TResult } | { error: ErrorEnve | Phase | `TResult` shape | |---|---| -| `provision` | `{ sandboxId: SandboxId; metadata?: object; correlationVector?: string }` | +| `provision` | `{ sandboxId: SandboxId; metadata?: object }` | | `start` | `{ metadata?: object }` | | `stop` | `{ metadata?: object }` | | `deprovision` | `{ metadata?: object }` | diff --git a/docs/telemetry/telemetry-administrative-policy.md b/docs/telemetry/telemetry-administrative-policy.md new file mode 100644 index 000000000..00400aaaf --- /dev/null +++ b/docs/telemetry/telemetry-administrative-policy.md @@ -0,0 +1,161 @@ +# MXC administrative telemetry policy + +Audience: developers embedding MXC in a product that ships to Windows devices. + +MXC supports a single administrative policy that lets an organization prevent +MXC from collecting diagnostic data on a device, regardless of what the user +chooses. It is a **ceiling, never a grant** — see +[Why policy cannot substitute for consent](#why-policy-cannot-substitute-for-consent). + +The policy is **Windows-only**, because [MXC only ever collects telemetry on +Windows](telemetry.md). On Linux and macOS there is nothing to restrict. + +## The setting + +| | | +|---|---| +| Key | `HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Mxc` | +| Value name | `AllowTelemetry` | +| Type | `REG_DWORD` | +| Scope | Machine (all users on the device) | + +`HKLM\SOFTWARE\Policies\...` is an administrator-controlled machine-level registry location. MXC deliberately does not honor a per-user (`HKCU`) equivalent. + +### Values + +The values mirror the Windows diagnostic-data scale so administrators do not +have to learn a second one: + +| Value | Windows name | Effect on MXC | +|-------|--------------|---------------| +| *(value absent)* | — | **Unrestricted.** The user's own choice decides. | +| `0` | Security / Off | **Blocked.** MXC collects nothing. | +| `1` | Required (Basic) | **Blocked.** MXC collects nothing. | +| `3` | Optional (Full) | **Allowed.** MXC may collect *if the user has also consented.* | +| anything else | — | **Blocked** (fail closed). | + +`1` blocks MXC because everything MXC emits is classified as +*product-and-service-usage* data — optional diagnostic data in Windows' +taxonomy. MXC emits no required diagnostic data, so there is nothing left to +send at level `1`. Value `2` is not a defined level on modern Windows and is +therefore treated as unrecognized. + +Any value MXC cannot read or cannot parse — a wrong type, a corrupt value, a +registry error — is treated as `Blocked`. Telemetry collection is never the +outcome of a failure. + +## Deploying it + +### Registry deployment + +Use any deployment mechanism that writes +HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry directly. For example: + +- A startup/admin script: + + ```powershell + $key = 'HKLM:\SOFTWARE\Policies\Mxc' + New-Item -Path $key -Force | Out-Null + Set-ItemProperty -Path $key -Name 'AllowTelemetry' -Value 0 -Type DWord + ``` + +- Any installer or configuration package that writes the same value. + +### Verifying + +Whatever consent-management/status surface ships must report the user's +recorded choice separately from the administrative ceiling. + +For example, if a user previously granted consent and an administrator later +sets `AllowTelemetry=0`, the status must still make both facts visible: + +- the stored user choice remains **granted** +- the administrative policy is **blocked** +- effective collection is therefore **off** +- no prompt is needed while the policy blocks + +Keeping those states separate lets hosts explain why telemetry is currently +disabled without losing the user's prior choice. + +## How the policy interacts with user consent + +MXC collects diagnostic data only when **every** gate is open: + +``` +collect = policy_permits AND user_consented AND build_has_telemetry_enabled +``` + +Concretely: + +| Policy | User consent | Result | +|--------|--------------|--------| +| unrestricted | granted | collects | +| unrestricted | denied / undetermined | **no collection** | +| allowed (`3`) | granted | collects | +| allowed (`3`) | denied / undetermined | **no collection** | +| blocked (`0`/`1`/other) | *anything* | **no collection** | + +Two consequences worth calling out: + +1. **A blocking policy suppresses the first-run consent prompt.** Asking a user + to permit something the administrator has already refused is a question with + no meaning. `needsPrompt` reports `false` while the policy blocks. +2. **A blocking policy does not erase the user's recorded choice.** If a user + had already consented and an administrator later blocks telemetry, + collection stops immediately but the recorded consent is preserved. If the + policy is later relaxed, the user's own prior decision takes effect again + rather than the user being re-prompted. + +### Why policy cannot substitute for consent + +An administrator setting `AllowTelemetry=3` does **not** cause MXC to start +collecting. It only removes MXC's administrative restriction; the user is still +asked, and still decides. + +In particular, **if the user has opted out and the policy permits collection, +the result is opt-out.** A policy can only ever subtract. There is no policy +value, and no combination of policy and configuration, that causes MXC to +collect from a user who has not explicitly granted consent — including a user +who has never been asked. + +This is a product rule MXC holds itself to, not merely a reading of external +guidance. It is also consistent with that guidance: Microsoft's privacy +direction for components classified as *apps* (rather than as parts of the +operating system) requires them to build their own notice-and-consent +experience and not to rely on the Windows diagnostic-data consent. An +administrative policy is an availability control, not an expression of a +user's informed choice, and no Microsoft guidance treats an admin "allow" as +consent on the user's behalf. + +The implementation should enforce that rule in one place rather than +re-deriving it in each host surface. + +## Relationship to the Windows `AllowTelemetry` policy + +MXC reads **only** its own key. It does not read, and is not affected by: + +- `HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection\AllowTelemetry` +- `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\DataCollection\AllowTelemetry` +- the Windows Settings → Diagnostics & feedback choice +- the `System/AllowTelemetry` Policy CSP node + +Two reasons: + +1. **Microsoft documents that it doesn't apply.** The Policy CSP documentation + for `System/AllowTelemetry` states that it "impacts the operating system and + apps that are considered part of Windows and doesn't apply to any additional + apps installed by your organization." MXC is such an app. +2. **Reading it would leak the user's Windows choice into MXC.** The supported + OS APIs for evaluating that policy deliberately combine the administrative + setting with the user's own Settings-app selection. Consuming them would + make MXC's behaviour depend on Windows consent state, which MXC's design + forbids. + +The dominant pattern among Microsoft first-party applications — Office, +Visual Studio, Visual Studio Code, WinGet, PowerToys — is likewise an +application-specific policy under `SOFTWARE\Policies\Microsoft\`. + +## See also + +- [Telemetry overview](telemetry.md) +- [Telemetry consent design](telemetry-consent-design.md) diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md new file mode 100644 index 000000000..e6e7a0041 --- /dev/null +++ b/docs/telemetry/telemetry-consent-design.md @@ -0,0 +1,203 @@ +# Telemetry consent design + +This document defines the Windows telemetry-consent contract. + +MXC telemetry is Windows-only and fails closed. Collection requires all four +conditions at the time each event is written: + +```text +per-run telemetry requested +&& current-version user consent is Granted +&& administrative policy does not block +&& the platform/provider is available +``` + +No prompt, no response, the literal string "No", dismissal, withdrawal, +malformed state, an unknown prompt version, or any read/write failure means no +telemetry. Administrative policy is a deny-only ceiling: it may block +collection but can never opt a user in. + +## Canonical consent resource + +MXC owns one immutable, versioned consent resource. Every EXE and SDK presenter +must show every supplied field verbatim. Hosts control layout, accessibility, +and native UI, but may not substitute wording. + +Current resource: + +- Resource version: `1` +- Locale: `en-US` +- Mandatory fallback: `en-US` +- Privacy link: + +### Title + +> Help improve Microsoft eXecution Container (MXC) + +### Body + +> Help improve MXC by sharing optional diagnostic data with Microsoft. +> If enabled, MXC sends diagnostic information about product usage, +> performance, and reliability. MXC does not send your commands, file paths, +> credentials, or other customer content. +> You can change your choice at any time. + +### Actions + +- Affirmative: **Yes** +- Negative: **No** +- Learn more: **Privacy Statement** + +Each field has a stable message ID. Locale lookup normalizes BCP 47 tags and +falls back to `en-US`; complete messages are translated as units rather than +assembled from fragments. Adding a translation does not change the public API. +A material wording or data-inventory change requires a new resource version and +explicit re-consent. + +## SDK presenter requirements + +An SDK host owns the native presentation experience, while MXC owns the +resource, decision validation, and persistence. Implementers must follow this +contract: + +1. Route consent through the MXC-owned request flow. Do not write the consent + store directly or create a separate grant path. +2. Render every supplied field verbatim: title, body, affirmative label, + negative label, learn-more label, and learn-more URL. Hosts may control + layout, accessibility, and platform-native styling, but must not hardcode, + shorten, reorder within a message, or replace the supplied wording. +3. Map the affirmative control to `Yes` and the negative control to `No`. +4. Map window close, cancel, timeout, or no response to `Dismissed`. If the UI + cannot be presented or otherwise fails, return a presenter error instead. + Never infer `Yes` from a default button, policy, prior application + preference, or absence of a response. +5. Make the learn-more control open the supplied URL, normally in the user's + default browser. Do not substitute a different privacy destination. +6. Return only the typed decision to MXC. MXC persists the decision together + with the resource version and locale that were actually presented. +7. Use the typed status API to explain the current stored/effective state and + administrative ceiling. Provide an explicit withdrawal control in the + host's settings or other appropriate telemetry-controls surface. + +MXC does not invoke the presenter when the result is already determined: + +- `AlreadyGranted`: the current resource version is already granted. +- `PolicyBlocked`: administrative policy prevents collection. +- `NotApplicable`: telemetry is unavailable on this platform. + +If the host never calls the request API, telemetry remains off. A presenter +failure must be surfaced as an error to the host and must not create or alter a +grant. + +## State and persistence + +MXC stores consent per user at: + +```text +%LOCALAPPDATA%\mxc\telemetry-consent.json +``` + +The persisted consent record is a versioned JSON document. The current schema +version is 2 and records: + +- `consent`: `granted` or `denied` +- `promptResourceVersion` +- `promptLocale` +- MXC version, source, and update timestamp for local audit/support provenance + +Stored and effective state are reported separately. A legacy grant without the +current prompt version remains visible as `storedState: "granted"` but is +`effectiveState: "undetermined"` with reason `prompt-version-missing` or +`prompt-version-unsupported`. It never authorizes collection. Legacy denial +remains denied. + +Only two operations mutate the store: + +1. A presenter invoked by MXC returns an explicit `yes` or `no` for the prompt + supplied in that same invocation. +2. The explicit withdrawal operation persists `denied`. + +Dismissal, EOF, presenter failure, localization fallback failure, or never +requesting consent does not fabricate or rewrite a decision. + +### Atomicity, locking, and recovery + +Every consent-store mutation must be atomic across processes. Synchronization +must use a separate primitive keyed by the consent-store path, such as a named +mutex or a sibling lock file. It must never keep the live JSON file open +because that can prevent atomic replacement on Windows. + +1. Acquire the cross-process writer lock before reading, validating, or + replacing the file. +2. Write the full replacement document to a sibling temporary file in the same + directory. +3. Flush the temporary file, then replace the live file with a single + same-volume atomic rename/replace operation. +4. Release the lock only after the replacement is durable. + +Readers must coordinate through the same separate primitive or use an +equivalent close-and-retry strategy; they must not hold the live JSON handle +while a writer replaces it. They must never observe or accept a +partially-written document. Any malformed file, missing required field, +version mismatch, unreadable path, or lock/replace failure is treated as +`undetermined`/`denied` as appropriate and must not authorize collection. +Recovery is fail-closed: MXC may surface a typed error or status reason to the +host, but it must not silently recreate a grant from a corrupted file. + +## Administrative ceiling + +Policy is read from: + +```text +HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry (REG_DWORD) +``` + +Only value `3` permits MXC optional diagnostic data. Other values, wrong value +types, unreadable state, and ambiguity fail closed to `blocked`. An absent key +is `unrestricted`. `allowed` and `unrestricted` merely leave the user's choice +in control; neither creates consent. + +A consent request made while policy is blocked does not invoke the presenter or +persist dormant consent. Withdrawal remains available while blocked. + +## Per-run enablement + +Consent is not an execution switch. Each run must also request telemetry with +the stable top-level `telemetry.enabled: true` setting. The switch never +prompts, never persists consent, and never bypasses consent or administrative +policy. + +Consent-management surfaces should still support the three behavior classes +defined here: + +- request consent through a presenter when a decision is needed +- withdraw consent by persisting `denied` +- report stored consent, effective consent, blocking policy, and whether a + prompt is still required + +## Live enforcement and event compatibility + +Consent and policy are checked immediately before each logical telemetry +emission. A terminal failure may emit paired `MXC.Error` and `MXC.Execution` +events under the same authorization decision so collectors never receive half +of the pair. A withdrawal or newly blocking policy stops the next logical +emission from an already-running process even if the provider was registered +earlier. + +The public ETW event identities remain: + +- `MXC.Execution` +- `MXC.Error` + +Collectors may continue filtering those names; consent stabilization does not +rename the provider or events. + +## Validation and release gate + +Implementations should include deterministic coverage for policy evaluation, +prompt gating, withdrawal, corruption recovery, and concurrent consent-store +mutation. The checked-in ETW smoke test only validates event emission; it is +not a substitute for consent-flow coverage. + +The versioned wording and data/control inventory still require Microsoft +privacy/legal approval before release. diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index 6de0c1117..a734d7195 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -61,6 +61,11 @@ shared Part C custom event fields: ## Events +The provider-qualified uploaded event identities are +`Microsoft.MXC/MXC.Execution` and `Microsoft.MXC/MXC.Error`. `Microsoft.MXC` +is the TraceLogging provider name; `MXC.Execution` and `MXC.Error` are the +event names. + ### MXC.Execution Emitted when a one-shot execution completes (success or failure). It is also @@ -77,111 +82,31 @@ the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error. | Field | Type | Description | |-------|------|-------------| -| `mxc.backend` | string | Containment backend name | +| `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) | +| `mxc.backend` | string | Concrete containment backend selected on the host | | `mxc.exit_code` | int32 | Process exit code | | `mxc.outcome` | string | `"success"` or `"failure"` | | `mxc.duration_ms` | uint64 | Total execution time | | `mxc.failure_reason` | string | Failure category (if applicable) | | `mxc.phase` | string | State-aware lifecycle phase (`provision`\|`start`\|`exec`\|`stop`\|`deprovision`); empty for one-shot executions | -| `__TlgCV__` | string | Microsoft Correlation Vector (MS-CV) — the lifecycle correlation key (see [Correlating a lifecycle](#correlating-a-lifecycle)); empty for one-shot executions | + +### MXC.Error Emitted on execution errors. | Field | Type | Description | |-------|------|-------------| -| `mxc.backend` | string | Containment backend name | +| `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) | +| `mxc.backend` | string | Concrete containment backend selected on the host | | `mxc.error_type` | string | Error category (`config_error`, `policy_error`, `process_error`, `timeout`, `init_error`, `internal_error`, `cancelled`, `unknown`) | | `mxc.exit_code` | int32 | Process exit code | | `mxc.phase` | string | State-aware lifecycle phase; empty for one-shot executions | -| `__TlgCV__` | string | Microsoft Correlation Vector (MS-CV) — the lifecycle correlation key (see [Correlating a lifecycle](#correlating-a-lifecycle)); empty for one-shot executions | > **No free-form error text is emitted.** Error messages can contain paths, > usernames, or credentials, so `MXC.Error` deliberately carries only the > bounded `error_type` category and the numeric `exit_code` — never the > message string itself. -### Correlating a lifecycle - -The state-aware lifecycle runs each phase (`provision` → `start` → `exec` → -`stop` → `deprovision`) as a **separate `wxc-exec` process**. The -`UTCReplace_AppSessionGuid` common field is therefore per-process and cannot join -events from different phases of the same sandbox. The stable join key is the -**Microsoft Correlation Vector (MS-CV)**, emitted under TraceLogging's reserved -`__TlgCV__` field. - -MS-CV is a hierarchical, propagatable identifier of the form -`..…` — a random base (128 bits, 22 base64 chars) plus a -dotted chain of decimal elements. MXC uses it as follows: - -- **`provision`** seeds a fresh random base (`correlation_vector::seed()`) and - returns it to the client in its result envelope as `result.correlationVector`. - This is the lifecycle's root vector. -- The client **relays** that vector verbatim into every later phase (the SDK - surfaces it as `ProvisionResult.correlationVector` and accepts it back as - `SandboxSpawnOptions.correlationVector`, sent on the wire as the top-level - `correlationVector` field). -- Each non-`provision` phase **spins** the relayed base - (`correlation_vector::spin()`) to derive a distinct child vector that still - shares the lifecycle's base prefix. Spin (rather than a plain extend) is used - because `exec` is multi-invocation and the client is a dumb relay — a plain - extend would collapse every phase to the same `base.0`, whereas spin folds in a - coarse timestamp + entropy so sibling phases get distinct, ordered vectors. - -An analyst groups all phases of one lifecycle by the shared **base prefix** of -`__TlgCV__`, and orders/​distinguishes phases within it by the spun elements. - -An MS-CV is capped at 127 characters. In the unlikely event a long-lived -lifecycle grows the vector to that cap, the next operator **freezes** it: it -appends the `!` terminator (dropping the trailing element **whole**, at an -element boundary, if needed to stay within the cap — never truncating a -multi-digit element to a falsified value like `.42` → `.4`) and the vector is -never mutated again. `increment` likewise freezes when its trailing element is -already at `u32::MAX` and so cannot advance (the spec's value-overflow -behaviour). Every later phase then relays and emits that same frozen vector -verbatim, so correlation by base prefix still holds — only the per-phase -ordering elements stop advancing. - -**Canonical validation.** A relayed vector is only built on (spun) when it is -*relayable* — a well-formed mutable vector (canonical 22-char base64 base whose -final char encodes a zero low nibble, followed by one or more canonical decimal -`u32` elements) or a valid frozen (`!`-terminated) vector. Elements must be -canonical: no leading zeros (`0` is allowed, `01` is not), no sign, no -whitespace — a non-canonical element such as `01` would silently reshape the -vector (`01` → `2` on increment) and break lexical sortability, so it is -rejected. Anything not relayable — missing, empty, malformed, or a hostile -`!`-terminated value like `user@contoso.com!` — is reseeded to a fresh random -base rather than emitted verbatim. - -The correlation vector is **not** derived from the `sandbox_id`, so no -caller-supplied identity (e.g. a UPN embedded in an IsolationSession -`iso:` id) is ever involved: the base is pure randomness. Spin is defensive -— a missing, empty, or malformed relayed value falls back to a fresh seed rather -than panicking telemetry. `__TlgCV__` is empty for one-shot executions (which have -no lifecycle to correlate) and for a crash during `provision` before the vector is -stashed. It is only computed and emitted when experimental telemetry is active, so -provision output is unchanged when telemetry is off. - -**Why a relayed random vector rather than a hashed `sandbox_id`.** An alternative -design would derive the correlation key deterministically from the `sandbox_id` -(e.g. a hash), avoiding the wire/SDK relay entirely. We deliberately do **not** do -this, for two reasons: - -1. **PII-safety.** A `sandbox_id` is caller-influenced and can embed identity — an - IsolationSession id is literally `iso:`. Hashing narrows but does not - eliminate the exposure (a hash is a stable pseudonym and, for a low-entropy - input like a known UPN, is reversible by dictionary). A base seeded from pure - OS randomness carries no caller identity at all, which is the stronger and - simpler guarantee. -2. **WIL TraceLogging fidelity.** This implementation intentionally mirrors the WIL - TraceLogging correlation-vector design: a random 128-bit base extended/spun with - MS-CV v2 operators and emitted under the reserved `__TlgCV__` field. A bespoke - `sandbox_id`-hash scheme would diverge from that well-understood format and lose - the hierarchical parent/child structure (base prefix + spun elements) that lets - an analyst reconstruct phase ordering, not just group-by a flat key. - -The client relay is therefore a required part of the design, not incidental -plumbing: it is how the random root vector minted at `provision` reaches the later -per-phase executor processes, which otherwise share no state. ### Crash telemetry (panic hook) @@ -207,7 +132,7 @@ hook, so the default stderr backtrace still prints. > code, then claims the exactly-once terminal-emit slot. The recovered > `MXC.Execution` completion event is therefore suppressed, so telemetry reports > `mxc.exit_code` = 101 even though the recovered process ultimately exits with a -> different code (`-1`). The `101` here is a "a panic occurred" sentinel, not a +> different code (`-1`). the `101` here is a "a panic occurred" sentinel, not a > claim about the observed process exit code; `outcome` and `error_type` remain > accurate. Backends that do not catch panics (the Windows one-shot executor) > abort with `101`, so the recorded code matches the real exit. @@ -231,95 +156,41 @@ free-form text. | Linux | No-op — all telemetry functions return immediately | | macOS | No-op — all telemetry functions return immediately | -## Private GUID Substitution (Internal Builds) - -MXC supports an optional Microsoft telemetry group GUID for internal builds. -The mechanism is public; only the GUID value is private. - -### How it works - -``` -build.rs execution flow -======================== +## Consent -1. Check MXC_TELEMETRY_PROVIDER_GROUP_GUID env var - ├── NOT set → generate: define_provider!(MXC_PROVIDER, "Microsoft.MXC"); - └── SET → generate: define_provider!(MXC_PROVIDER, "Microsoft.MXC", - group_id("{guid}")); +Telemetry emission is gated by the per-run request, MXC-owned consent, +administrative policy, and provider availability. See +[`docs/telemetry/telemetry-consent-design.md`](telemetry-consent-design.md). -2. lib.rs includes the generated provider_def.rs via include!() -``` +## Privacy review status -The provider GUID is **not** specified in either branch. The `tracelogging` -crate's `define_provider!` macro derives it deterministically from the provider -name using the standard ETW name-hash algorithm (the same algorithm used by -``, WIL's `IMPLEMENT_TRACELOGGING_CLASS`, and .NET's -`EventSource`). For `"Microsoft.MXC"` the derived GUID is -`{7f10def4-a258-5fea-510e-2c3bb976687f}`. Keeping the name and GUID in lockstep -this way prevents drift and avoids hard-coding a literal that could collide -with another team's GUID. - -### CI pipeline steps - -Internal Microsoft builds set `MXC_TELEMETRY_PROVIDER_GROUP_GUID` to the real -Microsoft telemetry group GUID before `cargo build` on Windows, so events route -through the telemetry pipeline. Community forks that lack access to the private -GUID do not set this variable — the provider is registered without a group GUID -(plain ETW only). - -> **Follow-up:** The provider group GUID is now provided by a secret variable -> on the official Windows build pipeline, so official builds can route events -> through the telemetry pipeline. The build has always honored the variable -> (see *Local developer testing* below); public builds and community forks, -> which do not have access to the variable, continue to register the provider -> without a group GUID (plain ETW only). - -### Local developer testing - -```powershell -# Test with a dummy group GUID (not the real one) -$env:MXC_TELEMETRY_PROVIDER_GROUP_GUID = '00000000-1111-2222-3333-444444444444' -cargo build -p mxc_telemetry - -# Test without (public build) -Remove-Item Env:\MXC_TELEMETRY_PROVIDER_GROUP_GUID -cargo build -p mxc_telemetry -``` +The version 1 `en-US` consent wording is approved for release review. The +canonical title, body, action labels, and privacy link are documented in +[Telemetry consent design](telemetry-consent-design.md#canonical-consent-resource) +and must be rendered verbatim by every EXE and SDK presenter. -### What's public vs. private +### Data sent -| Item | Public? | Why | -|------|---------|-----| -| Provider name `"Microsoft.MXC"` | ✅ | Standard ETW naming | -| Provider GUID `{7f10def4-a258-5fea-510e-2c3bb976687f}` | ✅ | Derived from the name; identifies the provider, harmless | -| `build.rs` env var mechanism | ✅ | Mechanism is public | -| `MXC_TELEMETRY_PROVIDER_GROUP_GUID` env var name | ✅ | Key is public; value is private | -| Actual Microsoft telemetry group GUID | ❌ | Private — set in CI only | +MXC's optional diagnostic events contain: -## SDK License Override (EULA for npm Package) +- MXC version and channel +- Whether the build has debug assertions enabled (`IsDebugging`) +- Caller-requested sandbox kind and the concrete backend selected on the host +- Run outcome and exit code +- Run duration +- Bounded failure category +- State-aware lifecycle phase +- `UTCReplace_AppSessionGuid`, which asks the telemetry pipeline to supply a + random per-session app identifier -The public GitHub repo ships `sdk/node/LICENSE.md` as a plain MIT license. For -internal npm publishes, a separate EULA containing a **Section 2 — DATA** -clause (covering telemetry disclosure, opt-out, and GDPR) will be updated at -pack/publish time. +MXC does not emit commands, file paths, credentials, customer content, or +free-form error text. The consent notice's phrase “other customer content” +covers any such values that a host or sandbox may process but MXC does not +include in these events. -### How it works +### Review status -``` -1. CI pipeline (or local script) sets MXC_LICENSE_OVERRIDE env var - pointing to the markdown file of the EULA including additional telemetry language. - Note that the new EULA will include language outlining what data can be collected but - will otherwise remain MIT licensed. - -2. A license-override script (added in a follow-up build-integration PR) runs: - ├── MXC_LICENSE_OVERRIDE is set: - │ ├── Back up sdk/node/LICENSE.md → sdk/node/LICENSE.md.public - │ └── Copy new EULA over sdk/node/LICENSE.md - └── MXC_LICENSE_OVERRIDE is NOT set: - └── Restore sdk/node/LICENSE.md from .public backup (if exists) - -3. npm pack / npm publish picks up the new EULA as the LICENSE.md - in the published package (sdk/node/package.json "files" includes LICENSE.md). - -4. After publish, the revert path restores the original EULA document. -``` +The consent wording and canonical resource are approved for release review. +The broader data inventory, retention, access, regional processing, deletion, +and localization/accessibility decisions remain pending explicit privacy +review. diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index d9c3b55d2..3101dd2cc 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -1,6 +1,6 @@ # Microsoft.Mxc.Sdk (C# SDK) -A .NET binding for [MXC](../README.md) (Microsoft eXecution Container), +A .NET binding for [MXC](../../README.md) (Microsoft eXecution Container), implemented in C#. It runs a command inside a sandbox described by a `SandboxPolicy`, capturing the output — by P/Invoking the native `mxc_ffi` library, which wraps the Rust engine. @@ -155,6 +155,30 @@ failure, `MxcSandboxProcess.OutputMetadata` exposes the same structured feature outputs as `RunResult.OutputMetadata`. Disposing without waiting deletes an internal ETL even when retention was requested. +## Telemetry consent + +MXC telemetry is Windows-only and remains off until both of these are true: +1. the user has explicitly granted MXC-owned consent, and +2. the caller opts this invocation in via telemetry settings. + +Telemetry remains off by default unless the caller opts in with `SandboxPolicy.TelemetryEnabled = true` (or the equivalent phase-level `TelemetryEnabled` setting for state-aware requests) and applicable Windows consent/policy gates permit collection. + +Any .NET consent surface should stay UI-agnostic, present the canonical +resource verbatim through a host callback, persist only explicit yes/no +decisions, treat dismissal and failures as non-grants, and follow the rules in +[`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md) +and its +[SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements). + +### Administrative policy + +An IT administrator can still block MXC telemetry device-wide via MXC's own +registry policy setting. See +[`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md) +for the stable registry contract and interaction rules. Any eventual .NET +policy/consent query must fail closed rather than upgrading an unreadable +device state into collection. + ## Projects - **`Microsoft.Mxc.Sdk`** — the class library (public API + generated P/Invoke). @@ -163,6 +187,8 @@ internal ETL even when retention was requested. Build/test everything: `dotnet test sdk/dotnet/Microsoft.Mxc.Sdk.slnx`. +Run native telemetry-consent integration tests in the **Debug** configuration. + ## Native library loading `NativeLibraryResolver` locates `mxc_ffi` in this order: diff --git a/sdk/node/README.md b/sdk/node/README.md index 0a7b421a9..f5b6bbb99 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -241,16 +241,15 @@ import { } from '@microsoft/mxc-sdk'; // Every call takes a single options object (3rd arg). Experimental backends -// must pass `experimental: true`; relay `correlationVector` on every phase -// after provision so telemetry shares one base prefix. +// must pass `experimental: true`. // isolation_session provision requires the unrestricted-network acknowledgment: // the container's network cannot be filtered or denied, so you must opt in. -const { sandboxId, correlationVector } = await provisionSandbox( +const { sandboxId } = await provisionSandbox( 'isolation_session', { network: { defaultPolicy: 'allow', allowLocalNetwork: true } }, { experimental: true }, ); -const opts = { experimental: true, correlationVector }; +const opts = { experimental: true }; await startSandbox(sandboxId, undefined, opts); @@ -261,8 +260,6 @@ await stopSandbox(sandboxId, undefined, opts); await deprovisionSandbox(sandboxId, undefined, opts); ``` -> **Correlating telemetry across phases:** when experimental telemetry is enabled, `provisionSandbox` returns a `correlationVector` (a Microsoft Correlation Vector). Relay it verbatim as `options.correlationVector` on every later phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The client relays the value unchanged; the executor validates it on each phase and derives that phase's own vector from it (spinning a fresh child element off a mutable base, or reseeding if it is missing or malformed). It is `undefined` when telemetry is off, and safe to omit otherwise. - `windows_sandbox` follows the same shape (substitute the containment string and provide `filesystem.readwritePaths` / `readonlyPaths` at provision if needed). See [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) for the per-phase config matrix. `wslc` follows the same shape and needs no provision config at all (it defaults to an `alpine:latest` container with no network). Provide `filesystem.readwritePaths` / `readonlyPaths` (mounted for the sandbox's lifetime), `network.defaultPolicy: 'allow'` (a bridged container; the default `'block'` gives no network), and/or a backend-specific `image` / `imageTarPath` at provision; inject a cooperative `network.proxy: { url }` per-exec. WSLc state-aware requests default to schema `0.8.0-alpha`. See [`docs/wsl/wslc-state-aware.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wslc-state-aware.md) for the per-phase config matrix. @@ -430,6 +427,26 @@ Full TypeScript definitions ship with the package (`dist/index.d.ts`). All expor --- +## Telemetry Consent + +Telemetry is off-by-default unless the caller opts in with top-level `telemetry.enabled: true` and the applicable Windows consent/policy gates permit collection. + +Telemetry consent behavior follows +[`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md): +the SDK stays UI-agnostic, renders the canonical resource verbatim through a +host presenter, persists only explicit yes/no decisions, treats dismissal and +failures as non-grants, and never lets policy or transport failures opt a user +in. + +### Administrative policy + +An IT administrator can still block MXC telemetry device-wide via MXC's own +registry policy setting. See +[`docs/telemetry/telemetry-administrative-policy.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-administrative-policy.md) +for the stable registry contract and interaction rules. + +--- + ## Further Reading - [`docs/schema.md`](https://github.com/microsoft/mxc/blob/main/docs/schema.md) — full configuration schema reference diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 7c0243948..b27603fa4 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -8,7 +8,7 @@ that at least one event was captured. This test uses the PUBLIC provider GUID (already in the open-source - code) — it does NOT depend on or reveal the private telemetry group GUID. + code) - it does NOT depend on or reveal the private telemetry group GUID. Requires: Administrator privileges (for ETW session creation), wxc-exec.exe built, logman.exe (ships with Windows). @@ -111,6 +111,26 @@ if (Test-Path $etlDir) { Remove-Item -Recurse -Force $etlDir } New-Item -ItemType Directory -Path $etlDir -Force | Out-Null $etlFile = Join-Path $etlDir 'mxc_trace.etl' +# --------------------------------------------------------------------------- +# Setup: isolated consent seed for deterministic telemetry gating +# --------------------------------------------------------------------------- +$localAppDataOverride = Join-Path $etlDir 'localappdata' +$consentDir = Join-Path $localAppDataOverride 'mxc' +New-Item -ItemType Directory -Path $consentDir -Force | Out-Null +$consentFile = Join-Path $consentDir 'telemetry-consent.json' + +# Seed an explicit granted consent record for this test run so event capture +# does not depend on host-global consent state. +$consentRecord = @{ + version = 2 + consent = 'granted' + promptResourceVersion = '1.0' + promptLocale = 'en-US' + source = 'run_telemetry_etw_smoke_test.ps1' + updatedAtUtc = [DateTime]::UtcNow.ToString('o') +} +$consentRecord | ConvertTo-Json -Depth 4 | Set-Content -Path $consentFile -Encoding utf8 + # --------------------------------------------------------------------------- # Step 1: Start ETW trace session # --------------------------------------------------------------------------- @@ -132,18 +152,40 @@ Write-Host "ETW session started, writing to $etlFile" Write-Host "`n--- Running wxc-exec with telemetry ---" -ForegroundColor Yellow try { - # Run with --experimental to enable the telemetry section. The provider is - # registered during init (before execution); the MXC.Execution / MXC.Error - # events are emitted on completion, after the runner returns. The sandbox - # itself may fail (e.g. AppContainer prerequisites), but completion - # telemetry still fires for the failure, so events should be captured. - $proc = Start-Process -FilePath $wxcExe ` - -ArgumentList "--debug", "--experimental", $configFile ` - -PassThru -NoNewWindow -Wait - Write-Host "wxc-exec exited with code $($proc.ExitCode)" + $previousLocalAppData = $env:LOCALAPPDATA + $previousTestOverride = $env:MXC_TEST_LOCALAPPDATA_OVERRIDE + + $env:LOCALAPPDATA = $localAppDataOverride + $env:MXC_TEST_LOCALAPPDATA_OVERRIDE = $localAppDataOverride + try { + # Run with --experimental to enable the telemetry section in this branch. + # The provider is registered during init (before execution); completion + # telemetry events are emitted after dispatch returns. + $executionTimeoutSeconds = 60 + $proc = Start-Process -FilePath $wxcExe ` + -ArgumentList "--debug", "--experimental", $configFile ` + -PassThru -NoNewWindow + if (-not $proc.WaitForExit($executionTimeoutSeconds * 1000)) { + Stop-Process -Id $proc.Id -Force + $proc.WaitForExit() + throw "wxc-exec timed out after $executionTimeoutSeconds seconds" + } + Write-Host "wxc-exec exited with code $($proc.ExitCode)" + } finally { + if ($null -eq $previousLocalAppData) { + Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue + } else { + $env:LOCALAPPDATA = $previousLocalAppData + } + if ($null -eq $previousTestOverride) { + Remove-Item Env:MXC_TEST_LOCALAPPDATA_OVERRIDE -ErrorAction SilentlyContinue + } else { + $env:MXC_TEST_LOCALAPPDATA_OVERRIDE = $previousTestOverride + } + } } catch { Write-Host "wxc-exec failed to run: $_" -ForegroundColor Yellow - # Continue — even a crash after init may have emitted events. + # Continue - even a crash after init may have emitted events. } # Brief pause for ETW buffers to flush. @@ -168,8 +210,8 @@ $etlSize = (Get-Item $etlFile).Length Write-Host "ETL file size: $etlSize bytes" if ($etlSize -eq 0) { - Write-Host "FAILED: ETL file is empty — no events captured." -ForegroundColor Red - Write-Host "All prerequisites were met (admin, wxc-exec present, ETW session created)," -ForegroundColor Red + Write-Host "FAILED: ETL file is empty - no events captured." -ForegroundColor Red + Write-Host 'All prerequisites were met (admin, wxc-exec present, ETW session created),' -ForegroundColor Red Write-Host "so the provider should have emitted at least one completion event." -ForegroundColor Red exit 1 } @@ -187,21 +229,42 @@ $eventCount = ([regex]::Matches($xmlContent, '') + $matchingEvent = $null + + foreach ($eventBlock in $eventBlocks) { + $eventXml = $eventBlock.Value + $eventMissingFields = @( + $expectedFields | Where-Object { + $eventXml -notmatch ([regex]::Escape($_)) + } + ) + if ($eventMissingFields.Count -eq 0) { + $matchingEvent = $eventXml + break + } + } - # Check for expected field names (public, not private). - $expectedFields = @('mxc.backend', 'mxc.exit_code', 'mxc.outcome', 'mxc.duration_ms') + if (-not $matchingEvent) { + $missingFieldsMessage = 'No single captured event contained all required public fields: ' + ($expectedFields -join ', ') + '.' + Write-Host '' + Write-Host '=== ETW CAPTURE SMOKE TEST FAILED ===' -ForegroundColor Red + Write-Host $missingFieldsMessage -ForegroundColor Red + exit 1 + } + + Write-Host '' + Write-Host '=== ETW CAPTURE SMOKE TEST PASSED ===' -ForegroundColor Green + Write-Host ($eventCount.ToString() + ' event(s) captured from the MXC provider.') foreach ($field in $expectedFields) { - if ($xmlContent -match $field) { - Write-Host " [OK] Found field: $field" -ForegroundColor Green - } else { - Write-Host " [--] Field not found: $field (may not be in this event type)" -ForegroundColor Yellow - } + Write-Host (' [OK] Found field in one event: ' + $field) -ForegroundColor Green } } else { - Write-Host "`n=== ETW CAPTURE SMOKE TEST FAILED ===" -ForegroundColor Red - Write-Host "ETL file had content ($etlSize bytes) but no parseable events were found." -ForegroundColor Red + $noEventsMessage = 'ETL file had content (' + $etlSize + ' bytes) but no parseable events were found.' + Write-Host '' + Write-Host '=== ETW CAPTURE SMOKE TEST FAILED ===' -ForegroundColor Red + Write-Host $noEventsMessage -ForegroundColor Red exit 1 } From c35147a339836fb4a2e98069cab32a9db972a2a7 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Tue, 18 Aug 2026 11:59:41 -0700 Subject: [PATCH 02/47] Harden telemetry consent foundation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- .github/copilot-instructions.md | 20 +- docs/schema.md | 2 +- .../telemetry-administrative-policy.md | 10 +- docs/telemetry/telemetry-consent-design.md | 35 +- docs/telemetry/telemetry.md | 3 +- sdk/dotnet/README.md | 6 +- src/core/wxc/src/main.rs | 1 + src/core/wxc_common/Cargo.toml | 6 + src/core/wxc_common/build.rs | 133 + .../resources/telemetry/consent/en-US.json | 27 + src/core/wxc_common/src/telemetry/consent.rs | 2197 +++++++++++++++++ .../src/telemetry/consent_prompt.rs | 118 + src/core/wxc_common/src/telemetry/events.rs | 8 + src/core/wxc_common/src/telemetry/mod.rs | 116 +- src/core/wxc_common/src/telemetry/policy.rs | 577 +++++ src/mxc_telemetry/src/lib.rs | 137 +- .../scripts/run_telemetry_etw_smoke_test.ps1 | 66 +- 17 files changed, 3357 insertions(+), 105 deletions(-) create mode 100644 src/core/wxc_common/build.rs create mode 100644 src/core/wxc_common/resources/telemetry/consent/en-US.json create mode 100644 src/core/wxc_common/src/telemetry/consent.rs create mode 100644 src/core/wxc_common/src/telemetry/consent_prompt.rs create mode 100644 src/core/wxc_common/src/telemetry/policy.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6427bb4e1..68f191ad7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -135,7 +135,8 @@ npm test npm run test:integration # C# SDK (from sdk/dotnet/) -dotnet test Microsoft.Mxc.Sdk.slnx # requires mxc_ffi built (cargo build -p mxc_ffi); resolver finds it in src/target/{debug,release} +dotnet test Microsoft.Mxc.Sdk.slnx # Debug only; requires mxc_ffi built (cargo build -p mxc_ffi); resolver finds it in src/target/{debug,release} + # the telemetry tests need the debug-only MXC_TEST_LOCALAPPDATA_OVERRIDE, so `-c Release` fails by design # Local PowerShell helpers — run from repo root, require built binaries tests\scripts\run_test_configs.ps1 # All test configs via wxc_test_driver @@ -148,6 +149,7 @@ tests\scripts\run_windows_sandbox_one_shot_tests.ps1 # Windows Sandbox one tests\scripts\run_windows_sandbox_state_aware_tests.ps1 # Windows Sandbox state-aware lifecycle E2E (provision/start/exec*/stop/deprovision; requires the Windows Sandbox optional feature; skips if absent) tests\scripts\run_lxc_all_tests.sh # All LXC tests (Linux) tests\scripts\run_bwrap_all_tests.sh # All Bubblewrap tests (Linux, requires bwrap) +tests\scripts\run_telemetry_consent_smoke_test.ps1 # Telemetry consent + policy CLI E2E (Windows; debug binary only) # E2E test crate — Rust executor integration tests (from src/) cargo test -p wxc_e2e_tests # Invokes MXC binaries directly @@ -221,8 +223,9 @@ Core references: - `docs/examples.md` — annotated configuration examples (see also `tests/examples/` and `tests/configs/`) - `docs/diagnostics.md` — diagnostic logging knobs (env vars, log file format) - `docs/ci-validation-infrastructure.md` — validation (E2E) test matrix: workflows and job names, catalog format, per-backend coverage and status, and the runbook for adding/removing an OS, backend, or plan -- `docs/host-prep.md` — `wxc-host-prep.exe` host setup binary (`prepare-system-drive` / `unprepare-system-drive` for the AppContainer ACEs on the system-drive root, plus `prepare-null-device` / `verify-null-device` / `dump-null-device` for the `\Device\Null` security descriptor that AppContainer-based backends require). Owns elevation via embedded `requireAdministrator` manifest — `wxc-exec.exe` no longer self-elevates. - `docs/sandbox-policy/0.7.0/policy.md` — sandbox policy 0.7.0 specification +- `docs/sandbox-policy/v1/policy.md` — sandbox policy v1 specification +- `docs/telemetry/telemetry.md` — telemetry overview; `docs/telemetry/telemetry-consent-design.md` (Windows-only consent design and per-SDK surface) and `docs/telemetry/telemetry-administrative-policy.md` (the MDM / Group Policy ceiling) Per-backend guides: @@ -302,6 +305,18 @@ The parser deserializes JSON directly into the typed wire model (`wxc_common::wi - macOS: `mxc-exec-mac` (Seatbelt) - Target triples: `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `aarch64-apple-darwin` +### Telemetry consent + +Telemetry is **Windows-only** and is never collected without explicit user consent. Three independent conditions must all hold before anything is emitted: the user has granted consent, the administrative (MDM / Group Policy) ceiling permits it, and the config kill-switch has not disabled it. `wxc_common::telemetry::is_enabled()` is the single place those three terms are combined — do not re-derive enablement anywhere else. + +- **Everything fails closed.** Any error, unreadable value, corrupt file, missing native library, or ambiguity must resolve to "no telemetry" (`Undetermined` consent / `Blocked` policy), never to a permissive state. +- **Policy may restrict, but may never substitute for, consent.** The administrative policy is a deny-only ceiling: it can subtract from what a user permitted, never add to it. An administrator cannot opt a user in — a denied or never-asked user stays opted out even under `AllowTelemetry=3`. Keep the terms combined with `&&`; never add a policy value or config path that grants collection on its own. +- **MXC owns its own consent state.** It must never read or infer from the Windows system telemetry consent. The consent store is a per-user JSON file; the policy is `HKLM\SOFTWARE\Policies\Mxc` → `AllowTelemetry` (`REG_DWORD`). +- **One definition, distributed to the bindings.** The Rust `ConsentState` / `PolicyState` enums are the source of truth; the FFI, C#, and TypeScript layers marshal the same strings. Keep those spellings in sync anywhere this branch surfaces them. +- **Test isolation.** The consent store and the policy key are process-global, each behind its own mutex. Use `wxc_common::telemetry::test_support::TelemetryTestEnv` whenever a test needs both; constructing `PolicyKeyGuard` and `LocalAppDataGuard` directly in the same test risks a lock-order deadlock. The consent-store override is compiled into test binaries and into debug `test-support` builds only, so release binaries do not honor it. The `wxc_common` `test-support` feature re-exports the policy override for downstream crates' integration tests (`mxc_ffi` uses it) and must stay a dev-dependency-only feature. +- **Read-only queries must never be able to crash the host.** `NeedsConsentPrompt`/`needsTelemetryConsentPrompt` and `GetPolicy`/`getTelemetryPolicy` fail closed on *any* failure and never throw — including a non-`Success` FFI status, which covers a caught panic. The consent *read* and *write* still throw, because their callers must distinguish "not decided" from "could not read" and "did not persist"; when they do, they raise only the binding's documented exception type (`MxcException`), wrapping anything unexpected rather than letting a raw type escape. +- **Never swallow a failure silently.** Fail-closed return values are indistinguishable from legitimate ones, so a broken install would otherwise be invisible. Every swallowed failure is reported once per distinct failure per process (deduplicated — hosts poll these getters), and the reporter itself must never throw. At the FFI boundary, `catch_unwind` sites log the panic payload before returning `MXC_STATUS_PANIC`, which would otherwise be discarded. + ### Package versioning All Rust crates use `version.workspace = true` to inherit the version from `src/Cargo.toml` `[workspace.package]`. The npm SDK version in `sdk/node/package.json` and the C# SDK version (`` in `sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj`) must match. Run `node scripts/check-version-sync.js` to validate they are in sync. When bumping the version, update `src/Cargo.toml` (workspace version), `sdk/node/package.json`, and the C# csproj in the same commit. @@ -315,6 +330,7 @@ When changing behavior covered by existing documentation, update the relevant do - **SDK API changes** (new exports, changed signatures, new options) → update `sdk/node/README.md` and the JSDoc in `sdk/node/src/index.ts` (TypeScript SDK); the Rust `mxc-sdk` crate docs/`README.md`; and `sdk/dotnet/README.md` (C# SDK). If the `mxc_ffi` C ABI surface changes, the C# P/Invoke regenerates on the next C# build; keep the `ErrorCode` parity + bindings-codegen gates green. - **New containment backends or major backend changes** → update the relevant doc in `docs/` (e.g., `lxc-support/lxc-backend.md`, `windows-sandbox/windows-sandbox.md`) - **Versioning or promotion changes** → update `docs/versioning.md` +- **Telemetry consent or policy changes** → update `docs/telemetry/telemetry-consent-design.md` and/or `docs/telemetry/telemetry-administrative-policy.md`, and keep the Rust/C#/TypeScript spellings aligned in the files present on this branch ### Policy versioning diff --git a/docs/schema.md b/docs/schema.md index 68ff4135c..416d804cb 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -139,7 +139,7 @@ production configs and the dev schema when working on experimental features: > it is rejected as an unknown field because lifecycle correlation is internal > to MXC and is not part of the request or response contract. See > [`docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md`](state-aware-lifecycle/mxc-state-aware-sandbox-api.md) -> and [`docs/telemetry/telemetry.md`](telemetry/telemetry.md). +> and [`docs/telemetry/telemetry.md`](telemetry/telemetry.md#correlating-a-lifecycle). ### Working Directory diff --git a/docs/telemetry/telemetry-administrative-policy.md b/docs/telemetry/telemetry-administrative-policy.md index 00400aaaf..8d01b85c6 100644 --- a/docs/telemetry/telemetry-administrative-policy.md +++ b/docs/telemetry/telemetry-administrative-policy.md @@ -63,8 +63,9 @@ HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry directly. For example: ### Verifying -Whatever consent-management/status surface ships must report the user's -recorded choice separately from the administrative ceiling. +The consent status surface reports the user's recorded choice separately from +the administrative ceiling. The consent APIs expose the stored and effective +states, the policy state, and whether a prompt is needed. For example, if a user previously granted consent and an administrator later sets `AllowTelemetry=0`, the status must still make both facts visible: @@ -127,8 +128,9 @@ administrative policy is an availability control, not an expression of a user's informed choice, and no Microsoft guidance treats an admin "allow" as consent on the user's behalf. -The implementation should enforce that rule in one place rather than -re-deriving it in each host surface. +The implementation stack enforces this rule in one place rather than +re-deriving it in each host surface. The telemetry gate combines the per-run +request with the consent and administrative-policy states before each event. ## Relationship to the Windows `AllowTelemetry` policy diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index e6e7a0041..ad1e4eacf 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -1,6 +1,9 @@ # Telemetry consent design -This document defines the Windows telemetry-consent contract. +This document defines the implemented Windows telemetry-consent contract. +MXC ships the canonical consent resource, persistent consent storage, and +consent-management APIs described below. Telemetry remains off by default and +is enabled only when every gate in this contract permits collection. MXC telemetry is Windows-only and fails closed. Collection requires all four conditions at the time each event is written: @@ -19,9 +22,11 @@ collection but can never opt a user in. ## Canonical consent resource -MXC owns one immutable, versioned consent resource. Every EXE and SDK presenter -must show every supplied field verbatim. Hosts control layout, accessibility, -and native UI, but may not substitute wording. +The immutable, versioned authoring resource is +`src/core/wxc_common/resources/telemetry/consent/en-US.json`. The build embeds +it in `wxc_common::telemetry::consent_prompt`. Every EXE and SDK presenter must +show every supplied field verbatim. Hosts control layout, accessibility, and +native UI, but may not substitute wording. Current resource: @@ -32,15 +37,17 @@ Current resource: ### Title -> Help improve Microsoft eXecution Container (MXC) +```text +Help improve Microsoft eXecution Container (MXC) +``` ### Body -> Help improve MXC by sharing optional diagnostic data with Microsoft. -> If enabled, MXC sends diagnostic information about product usage, -> performance, and reliability. MXC does not send your commands, file paths, -> credentials, or other customer content. -> You can change your choice at any time. +```text +Help improve MXC by sharing optional diagnostic data with Microsoft. +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +You can change your choice at any time. +``` ### Actions @@ -160,7 +167,7 @@ in control; neither creates consent. A consent request made while policy is blocked does not invoke the presenter or persist dormant consent. Withdrawal remains available while blocked. -## Per-run enablement +## Per-run enablement and integration boundary Consent is not an execution switch. Each run must also request telemetry with the stable top-level `telemetry.enabled: true` setting. The switch never @@ -194,10 +201,10 @@ rename the provider or events. ## Validation and release gate -Implementations should include deterministic coverage for policy evaluation, +Implementations must include deterministic coverage for policy evaluation, prompt gating, withdrawal, corruption recovery, and concurrent consent-store mutation. The checked-in ETW smoke test only validates event emission; it is not a substitute for consent-flow coverage. -The versioned wording and data/control inventory still require Microsoft -privacy/legal approval before release. +Release requires Microsoft privacy/legal approval of the versioned wording and +data/control inventory. diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index a734d7195..ed8e9b630 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -97,7 +97,7 @@ Emitted on execution errors. | Field | Type | Description | |-------|------|-------------| | `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) | -| `mxc.backend` | string | Concrete containment backend selected on the host | +| `mxc.backend` | string | Containment backend name | | `mxc.error_type` | string | Error category (`config_error`, `policy_error`, `process_error`, `timeout`, `init_error`, `internal_error`, `cancelled`, `unknown`) | | `mxc.exit_code` | int32 | Process exit code | | `mxc.phase` | string | State-aware lifecycle phase; empty for one-shot executions | @@ -107,7 +107,6 @@ Emitted on execution errors. > bounded `error_type` category and the numeric `exit_code` — never the > message string itself. - ### Crash telemetry (panic hook) When telemetry is active, the executors install a global diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 3101dd2cc..bec7dd9d0 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -175,9 +175,9 @@ and its An IT administrator can still block MXC telemetry device-wide via MXC's own registry policy setting. See [`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md) -for the stable registry contract and interaction rules. Any eventual .NET -policy/consent query must fail closed rather than upgrading an unreadable -device state into collection. +for the stable registry contract and interaction rules. Policy and consent +queries fail closed rather than upgrading an unreadable device state into +collection. ## Projects diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 52ef9e01a..e4b51a8fe 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -422,6 +422,7 @@ fn run_state_aware_main( telemetry_active, telemetry::TelemetryContext { backend, + sandbox_kind: backend, phase, correlation_vector: &correlation, }, diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml index 6f1419c83..c88d83142 100644 --- a/src/core/wxc_common/Cargo.toml +++ b/src/core/wxc_common/Cargo.toml @@ -9,6 +9,8 @@ microvm = ["dep:nanvix_common", "dep:uuid"] # Enables JSON Schema generation from the dedicated wire model. Off by default # so normal builds don't carry schemars. schema-gen = ["dep:schemars"] +# Exposes telemetry test harnesses to downstream integration tests. +test-support = [] [dependencies] serde = { workspace = true } @@ -37,3 +39,7 @@ libc = { workspace = true } [dev-dependencies] tempfile.workspace = true + +[build-dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/src/core/wxc_common/build.rs b/src/core/wxc_common/build.rs new file mode 100644 index 000000000..684a14102 --- /dev/null +++ b/src/core/wxc_common/build.rs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::Deserialize; +use std::collections::HashSet; +use std::env; +use std::fs; +use std::io; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +struct ConsentResource { + #[serde(rename = "resourceVersion")] + resource_version: u32, + locale: String, + messages: ConsentMessages, + #[serde(rename = "learnMoreUrl")] + learn_more_url: String, +} + +#[derive(Debug, Deserialize)] +struct ConsentMessages { + title: ConsentMessage, + body: ConsentMessage, + #[serde(rename = "affirmativeLabel")] + affirmative_label: ConsentMessage, + #[serde(rename = "negativeLabel")] + negative_label: ConsentMessage, + #[serde(rename = "learnMoreLabel")] + learn_more_label: ConsentMessage, +} + +#[derive(Debug, Deserialize)] +struct ConsentMessage { + id: String, + text: String, +} + +fn invalid_resource(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn rust_string(value: &str) -> String { + format!("{value:?}") +} + +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let resource_path = manifest_dir.join("resources/telemetry/consent/en-US.json"); + println!("cargo:rerun-if-changed={}", resource_path.display()); + + let resource: ConsentResource = serde_json::from_str(&fs::read_to_string(&resource_path)?)?; + if resource.locale != "en-US" { + return Err(invalid_resource(format!( + "the fallback consent resource must use locale en-US, got {}", + resource.locale + )) + .into()); + } + if resource.resource_version == 0 { + return Err(invalid_resource("the consent resource version must be nonzero").into()); + } + if resource.learn_more_url.is_empty() { + return Err(invalid_resource("the consent resource must include a learn-more URL").into()); + } + + let messages = [ + &resource.messages.title, + &resource.messages.body, + &resource.messages.affirmative_label, + &resource.messages.negative_label, + &resource.messages.learn_more_label, + ]; + if messages + .iter() + .any(|message| message.id.is_empty() || message.text.is_empty()) + { + return Err(invalid_resource("consent message IDs and text must be nonempty").into()); + } + let ids: HashSet<&str> = messages.iter().map(|message| message.id.as_str()).collect(); + if ids.len() != messages.len() { + return Err(invalid_resource("consent message IDs must be unique").into()); + } + + let generated = format!( + r#" pub const CONSENT_RESOURCE_VERSION: u32 = {}; + pub const FALLBACK_LOCALE: &str = {}; + + pub static EN_US_CONSENT_PROMPT: ConsentPrompt = ConsentPrompt {{ + resource_version: CONSENT_RESOURCE_VERSION, + locale: FALLBACK_LOCALE, + title: ConsentMessage {{ + id: {}, + text: {}, + }}, + body: ConsentMessage {{ + id: {}, + text: {}, + }}, + affirmative_label: ConsentMessage {{ + id: {}, + text: {}, + }}, + negative_label: ConsentMessage {{ + id: {}, + text: {}, + }}, + learn_more_label: ConsentMessage {{ + id: {}, + text: {}, + }}, + learn_more_url: {}, +}}; +"#, + resource.resource_version, + rust_string(&resource.locale), + rust_string(&resource.messages.title.id), + rust_string(&resource.messages.title.text), + rust_string(&resource.messages.body.id), + rust_string(&resource.messages.body.text), + rust_string(&resource.messages.affirmative_label.id), + rust_string(&resource.messages.affirmative_label.text), + rust_string(&resource.messages.negative_label.id), + rust_string(&resource.messages.negative_label.text), + rust_string(&resource.messages.learn_more_label.id), + rust_string(&resource.messages.learn_more_label.text), + rust_string(&resource.learn_more_url), + ); + + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + fs::write(out_dir.join("telemetry_consent_resource.rs"), generated)?; + Ok(()) +} diff --git a/src/core/wxc_common/resources/telemetry/consent/en-US.json b/src/core/wxc_common/resources/telemetry/consent/en-US.json new file mode 100644 index 000000000..228efa0c7 --- /dev/null +++ b/src/core/wxc_common/resources/telemetry/consent/en-US.json @@ -0,0 +1,27 @@ +{ + "resourceVersion": 1, + "locale": "en-US", + "messages": { + "title": { + "id": "telemetry-consent-title", + "text": "Help improve Microsoft eXecution Container (MXC)" + }, + "body": { + "id": "telemetry-consent-body", + "text": "Help improve MXC by sharing optional diagnostic data with Microsoft.\nIf enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content.\nYou can change your choice at any time." + }, + "affirmativeLabel": { + "id": "telemetry-consent-affirmative-label", + "text": "Yes" + }, + "negativeLabel": { + "id": "telemetry-consent-negative-label", + "text": "No" + }, + "learnMoreLabel": { + "id": "telemetry-consent-learn-more-label", + "text": "Privacy Statement" + } + }, + "learnMoreUrl": "https://go.microsoft.com/fwlink/?linkid=521839" +} diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs new file mode 100644 index 000000000..773ab6088 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -0,0 +1,2197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Persisted, per-user telemetry consent. +//! +//! See `docs/telemetry/telemetry-consent-design.md` for the full design and +//! privacy rationale. In short: +//! +//! - MXC does not and must not collect telemetry on any platform other than +//! Windows, so this module is the **single** consent surface — and it is +//! compiled out entirely on non-Windows targets, replaced by a stub that +//! never touches disk and always reports [`ConsentState::NotApplicable`]. +//! - On Windows, consent is a per-user choice persisted at +//! `%LOCALAPPDATA%\mxc\telemetry-consent.json`. The default (no file, or an +//! unreadable/corrupt one) is [`ConsentState::Undetermined`], which is +//! treated as "not collecting" everywhere telemetry gating is decided +//! ([`super::is_enabled`]) — MXC fails closed, never open. +//! - This module never emits a telemetry event for a consent transition +//! itself; flipping the flag is a silent, local, atomic file write. + +// Serde, the persisted record, and its helpers exist only for the Windows +// consent store; the non-Windows stub persists nothing. +#[cfg(target_os = "windows")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +use super::consent_prompt::{prompt_for_locale, ConsentPrompt}; + +/// Current schema version for the persisted consent record. Bump when the +/// on-disk shape changes in a way that isn't purely additive; unknown/older +/// versions are treated as [`ConsentState::Undetermined`] on read (fail +/// closed) rather than guessed at. +#[cfg(target_os = "windows")] +const CONSENT_SCHEMA_VERSION: u32 = 2; + +/// The user's telemetry consent decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentState { + /// The user has explicitly agreed to telemetry collection. + Granted, + /// The user has explicitly declined telemetry collection. + Denied, + /// No decision has been recorded yet (fresh install, or a corrupt/missing + /// store). Treated identically to `Denied` for gating purposes — the only + /// difference is that a host application should offer a first-run prompt + /// when it sees this state. + Undetermined, + /// Not a Windows host. MXC does not collect telemetry here, so consent is + /// not a meaningful concept — hosts must not offer a consent prompt at + /// all on these platforms. + NotApplicable, +} + +/// Why persisted consent does not currently authorize collection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentStatusReason { + /// No consent record exists for this user. + NoRecord, + /// The consent store could not be read. + StoreUnreadable, + /// The consent record was not valid JSON or did not have a recognized + /// consent value. + StoreMalformed, + /// The persisted consent schema is not supported by this build. + ConsentSchemaUnsupported, + /// A stored grant predates versioned canonical consent language. + PromptVersionMissing, + /// A stored grant references a different canonical prompt version. + PromptVersionUnsupported, + /// Telemetry consent is not applicable on this platform. + NotApplicable, +} + +impl ConsentStatusReason { + /// Stable wire representation used by status APIs and bindings. + pub fn as_str(&self) -> &'static str { + match self { + Self::NoRecord => "no-record", + Self::StoreUnreadable => "store-unreadable", + Self::StoreMalformed => "store-malformed", + Self::ConsentSchemaUnsupported => "consent-schema-unsupported", + Self::PromptVersionMissing => "prompt-version-missing", + Self::PromptVersionUnsupported => "prompt-version-unsupported", + Self::NotApplicable => "not-applicable", + } + } +} + +/// Persisted and effective telemetry consent at one point in time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentStatus { + /// Decision found in the consent record, when one could be recovered. + pub stored_state: ConsentState, + /// State used by the telemetry gate after validating the record and prompt + /// version. + pub effective_state: ConsentState, + /// Fail-closed reason when the record is absent, invalid, or inapplicable. + pub reason: Option, +} + +impl ConsentStatus { + /// Whether an explicit consent request may offer the canonical prompt. + pub fn needs_prompt(&self) -> bool { + matches!(self.effective_state, ConsentState::Undetermined) + } +} + +/// Explicit result returned by a consent presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentDecision { + Yes, + No, + Dismissed, +} + +/// Result of requesting or withdrawing telemetry consent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentActionResult { + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +/// Consent action result together with the resulting status and policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentActionOutcome { + pub result: ConsentActionResult, + pub status: ConsentStatus, + pub policy: super::policy::PolicyState, +} + +/// Failure to present or persist a consent decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConsentActionError { + Presenter(String), + Persist(String), +} + +impl fmt::Display for ConsentActionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Presenter(message) => write!(formatter, "consent presenter failed: {message}"), + Self::Persist(message) => write!(formatter, "failed to persist consent: {message}"), + } + } +} + +impl std::error::Error for ConsentActionError {} + +impl ConsentState { + /// Stable, lowercase wire representation used by the CLI flags, the FFI + /// boundary, and the SDKs. Kept separate from `Debug` so the on-the-wire + /// strings never drift if the Rust variant names change. + pub fn as_str(&self) -> &'static str { + match self { + ConsentState::Granted => "granted", + ConsentState::Denied => "denied", + ConsentState::Undetermined => "undetermined", + ConsentState::NotApplicable => "not-applicable", + } + } + + /// Whether telemetry may be collected under this consent state alone + /// (still subject to the explicit config kill-switch — see + /// [`super::is_enabled`]). + pub fn allows_collection(&self) -> bool { + matches!(self, ConsentState::Granted) + } + + /// Whether a hosting application should offer its own first-run consent + /// prompt for this state. + /// + /// This shared predicate keeps all consumer surfaces consistent. It is + /// false for [`NotApplicable`](ConsentState::NotApplicable), because MXC + /// collects no telemetry off Windows. + pub fn needs_prompt(&self) -> bool { + matches!(self, ConsentState::Undetermined) + } +} + +/// Whether a hosting application should offer its own first-run telemetry +/// consent prompt right now. +/// +/// This is [`get_consent`]`().`[`needs_prompt`](ConsentState::needs_prompt)`()` +/// additionally suppressed when an administrator has denied telemetry via +/// [`super::policy`]. Prompting under an administrative denial would be asking +/// the user to decide something MXC would then ignore, so the answer there is +/// always `false` — regardless of whether a decision has been recorded. +pub fn needs_consent_prompt() -> bool { + if super::policy::is_blocked_by_policy() { + return false; + } + get_consent().needs_prompt() +} + +/// The on-disk consent record. Additive fields only; `source` and +/// `prompted_mxc_version` are provenance for support/debugging and are never +/// transmitted anywhere. +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConsentRecord { + #[serde(rename = "schemaVersion")] + schema_version: u32, + consent: String, + #[serde(default)] + source: String, + #[serde(rename = "promptedMxcVersion", default)] + prompted_mxc_version: String, + #[serde(rename = "promptResourceVersion", default)] + prompt_resource_version: Option, + #[serde(rename = "promptLocale", default)] + prompt_locale: String, + /// Unix seconds at the time of the last grant/revoke. Debug/support + /// provenance only — never used for gating — so a plain epoch integer is + /// enough; `#[serde(default)]` means older or foreign records missing + /// this field just read back as `0` rather than failing to parse. + #[serde(rename = "updatedAtEpoch", default)] + updated_at_epoch: u64, +} + +/// Returns the current, persisted telemetry consent state. +/// +/// Fail-closed: a missing file, an unreadable file, unparseable JSON, or an +/// unrecognized `schemaVersion` all resolve to [`ConsentState::Undetermined`] +/// — never to `Granted`. Always [`ConsentState::NotApplicable`] on +/// non-Windows platforms, without any filesystem access. +pub fn get_consent() -> ConsentState { + get_status().effective_state +} + +/// Returns persisted and effective consent plus a typed fail-closed reason. +pub fn get_status() -> ConsentStatus { + platform::read_status() +} + +#[cfg(test)] +pub(crate) fn set_consent(granted: bool, source: &str) -> Result<(), String> { + platform::write(granted, source) +} + +/// Request telemetry consent through a host-owned presenter. +/// +/// The presenter receives the complete canonical resource. Only an explicit +/// [`ConsentDecision::Yes`] returned from this invocation can create a grant. +pub fn request_consent( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(&ConsentPrompt) -> Result, +{ + let (prompt, policy, status) = match consent_preflight(locale) { + ConsentPreflight::Complete(outcome) => return Ok(outcome), + ConsentPreflight::Present { + prompt, + policy, + status, + } => (prompt, policy, status), + }; + + let decision = presenter(prompt).map_err(ConsentActionError::Presenter)?; + persist_presented_decision(decision, prompt, policy, status) +} + +/// Asynchronous counterpart to [`request_consent`]. +/// +/// The presenter callback is asynchronous. The surrounding synchronous +/// registry/filesystem work is offloaded to short-lived worker threads so the +/// caller's executor thread does not run the blocking persistence path itself. +pub async fn request_consent_async( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(&ConsentPrompt) -> Fut, + Fut: std::future::Future>, +{ + let locale = locale.map(str::to_owned); + match BlockingTask::spawn(move || consent_preflight(locale.as_deref())).await { + ConsentPreflight::Complete(outcome) => Ok(outcome), + ConsentPreflight::Present { + prompt, + policy, + status, + } => { + let decision = presenter(prompt) + .await + .map_err(ConsentActionError::Presenter)?; + BlockingTask::spawn(move || { + persist_presented_decision(decision, prompt, policy, status) + }) + .await + } + } +} + +struct BlockingTask { + shared: std::sync::Arc>, +} + +struct BlockingTaskState { + result: std::sync::Mutex>>, + waker: std::sync::Mutex>, +} + +impl BlockingTask { + fn spawn(operation: impl FnOnce() -> T + Send + 'static) -> Self { + let shared = std::sync::Arc::new(BlockingTaskState { + result: std::sync::Mutex::new(None), + waker: std::sync::Mutex::new(None), + }); + let worker = std::sync::Arc::clone(&shared); + std::thread::spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation)); + *worker.result.lock().unwrap_or_else(|e| e.into_inner()) = Some(result); + if let Some(waker) = worker + .waker + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + waker.wake(); + } + }); + Self { shared } + } +} + +impl std::future::Future for BlockingTask { + type Output = T; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + if let Some(result) = self + .shared + .result + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + return std::task::Poll::Ready(match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + }); + } + + *self.shared.waker.lock().unwrap_or_else(|e| e.into_inner()) = Some(cx.waker().clone()); + + match self + .shared + .result + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + Some(result) => { + self.shared + .waker + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + std::task::Poll::Ready(match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + }) + } + None => std::task::Poll::Pending, + } + } +} + +enum ConsentPreflight { + Complete(ConsentActionOutcome), + Present { + prompt: &'static ConsentPrompt, + policy: super::policy::PolicyState, + status: ConsentStatus, + }, +} + +fn consent_preflight(locale: Option<&str>) -> ConsentPreflight { + let policy = super::policy::get_policy(); + let status = get_status(); + + if status.effective_state == ConsentState::NotApplicable { + return ConsentPreflight::Complete(ConsentActionOutcome { + result: ConsentActionResult::NotApplicable, + status, + policy, + }); + } + if !policy.allows_collection() { + return ConsentPreflight::Complete(ConsentActionOutcome { + result: ConsentActionResult::PolicyBlocked, + status, + policy, + }); + } + if status.effective_state == ConsentState::Granted { + return ConsentPreflight::Complete(ConsentActionOutcome { + result: ConsentActionResult::AlreadyGranted, + status, + policy, + }); + } + + let prompt = prompt_for_locale(locale); + ConsentPreflight::Present { + prompt, + policy, + status, + } +} + +fn persist_presented_decision( + decision: ConsentDecision, + prompt: &ConsentPrompt, + policy: super::policy::PolicyState, + status: ConsentStatus, +) -> Result { + platform::with_store_lock(|| { + let current_policy = super::policy::get_policy(); + let current_status = platform::read_status_unlocked(); + if current_status.effective_state == ConsentState::NotApplicable { + return Ok(ConsentActionOutcome { + result: ConsentActionResult::NotApplicable, + status: current_status, + policy: current_policy, + }); + } + if !current_policy.allows_collection() { + return Ok(ConsentActionOutcome { + result: ConsentActionResult::PolicyBlocked, + status: current_status, + policy: current_policy, + }); + } + if current_status.effective_state == ConsentState::Granted + && !matches!(decision, ConsentDecision::No) + { + return Ok(ConsentActionOutcome { + result: ConsentActionResult::AlreadyGranted, + status: current_status, + policy: current_policy, + }); + } + let is_concurrent_grant_being_withdrawn = matches!(decision, ConsentDecision::No) + && current_status.effective_state == ConsentState::Granted + && status.effective_state != ConsentState::Granted; + if current_policy != policy + || (current_status.effective_state != status.effective_state + && !is_concurrent_grant_being_withdrawn) + { + return Err( + "consent state changed while the presenter was open; no decision was written" + .to_string(), + ); + } + + match decision { + ConsentDecision::Yes => { + platform::write_presented(true, "prompt", prompt)?; + Ok(ConsentActionOutcome { + result: ConsentActionResult::Granted, + status: platform::read_status_unlocked(), + policy: current_policy, + }) + } + ConsentDecision::No => { + platform::write_presented(false, "prompt", prompt)?; + Ok(ConsentActionOutcome { + result: ConsentActionResult::Denied, + status: platform::read_status_unlocked(), + policy: current_policy, + }) + } + ConsentDecision::Dismissed => Ok(ConsentActionOutcome { + result: ConsentActionResult::Dismissed, + status: current_status, + policy: current_policy, + }), + } + }) + .map_err(ConsentActionError::Persist) +} + +/// Idempotently withdraw telemetry consent. +/// +/// Withdrawal does not require a permitting administrative policy. Off +/// Windows it succeeds as a typed `NotApplicable` result without storage. +pub fn withdraw_consent() -> Result { + let current_status = get_status(); + if current_status.effective_state == ConsentState::NotApplicable { + return Ok(ConsentActionOutcome { + result: ConsentActionResult::NotApplicable, + status: current_status, + policy: super::policy::get_policy(), + }); + } + + platform::with_store_lock(|| { + platform::begin_withdrawal()?; + platform::write(false, "withdrawal")?; + platform::finish_withdrawal()?; + Ok(ConsentActionOutcome { + result: ConsentActionResult::Withdrawn, + status: platform::read_status_unlocked(), + policy: super::policy::get_policy(), + }) + }) + .map_err(ConsentActionError::Persist) +} + +/// Current time as Unix seconds. Debug/support provenance only (see +/// [`ConsentRecord::updated_at_epoch`]) — a raw epoch avoids pulling in a +/// date-formatting dependency, or hand-rolling calendar math, just to stamp +/// a field nothing ever gates on. +#[cfg(target_os = "windows")] +fn now_epoch_seconds() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Windows implementation — real, persisted, per-user consent store. +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +mod platform { + use super::{ + now_epoch_seconds, ConsentRecord, ConsentState, ConsentStatus, ConsentStatusReason, + CONSENT_SCHEMA_VERSION, + }; + use crate::telemetry::consent_prompt::{CONSENT_RESOURCE_VERSION, FALLBACK_LOCALE}; + use std::fs; + use std::io::{Read, Write}; + use std::path::{Path, PathBuf}; + use std::time::Duration; + + /// Number of attempts for filesystem operations that can transiently fail + /// under Windows file-lock contention (AV real-time scanning, the search + /// indexer, or a concurrent `wxc-exec` grant/revoke in another process). + const IO_RETRY_ATTEMPTS: u32 = 5; + #[cfg(test)] + const IO_RETRY_DELAY: Duration = Duration::ZERO; + #[cfg(not(test))] + const IO_RETRY_DELAY: Duration = Duration::from_millis(20); + #[cfg(test)] + const STORE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(1); + #[cfg(not(test))] + const STORE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(20); + + /// `ERROR_SHARING_VIOLATION` — another handle holds the file with a + /// conflicting share mode (AV scanner, indexer, racing sibling process). + const ERROR_SHARING_VIOLATION: i32 = 32; + /// `ERROR_LOCK_VIOLATION` — a byte-range lock is held on the file. + const ERROR_LOCK_VIOLATION: i32 = 33; + const MAX_CONSENT_FILE_BYTES: u64 = 16 * 1024; + const WITHDRAWAL_PENDING_FILE: &str = "telemetry-consent.withdrawal-pending"; + const CONSENT_LOCK_FILE: &str = "telemetry-consent.lock"; + const WITHDRAWAL_MARKER_STALE_AFTER: Duration = Duration::from_secs(5); + + /// Resolves the per-user `%LocalAppData%` directory through the Windows + /// known-folder API rather than trusting `LOCALAPPDATA`. A parent process + /// controls the child's environment, so trusting that variable could + /// redirect the consent store and forge a grant. The resolved path is + /// cached for the process, but only after a successful resolution so a + /// transient failure can be retried later in the same process. + fn known_folder_local_app_data() -> Option { + static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); + cache_successful_resolution(&CACHED, resolve_known_folder_local_app_data) + } + + fn cache_successful_resolution( + cache: &std::sync::OnceLock, + resolver: impl FnOnce() -> Option, + ) -> Option { + if let Some(cached) = cache.get() { + return Some(cached.clone()); + } + + let resolved = resolver()?; + let _ = cache.set(resolved.clone()); + Some(resolved) + } + + /// Opens the current **process** token with `TOKEN_QUERY | TOKEN_IMPERSONATE`. + /// + /// Passing `None` to `SHGetKnownFolderPath` would resolve against the + /// calling *thread's* token — the impersonated one, if the host has + /// impersonated another user. That would both invalidate the memoization + /// above and let an embedding host redirect the consent store simply by + /// impersonating, which is the same class of attack the known-folder API + /// is being used to prevent in the first place. Binding explicitly to the + /// process token keeps the answer a stable property of the process. + /// + /// `SHGetKnownFolderPath` requires the token it is handed to be opened + /// with both `TOKEN_QUERY` *and* `TOKEN_IMPERSONATE` (it duplicates the + /// handle into an impersonation token internally); on systems that + /// enforce that contract, a `TOKEN_QUERY`-only handle makes resolution + /// fail, so consent would always read as `Undetermined` and every + /// grant/revoke would fail closed. + fn process_token() -> Option { + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Security::{TOKEN_IMPERSONATE, TOKEN_QUERY}; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token = HANDLE::default(); + // SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no + // release, and `token` is a valid out-pointer for the duration of the + // call. On success the returned handle is immediately wrapped in + // `OwnedHandle`, whose `Drop` closes it exactly once. + unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_IMPERSONATE, + &mut token, + ) + } + .ok()?; + Some(crate::process_util::OwnedHandle::new(token)) + } + + fn resolve_known_folder_local_app_data() -> Option { + use crate::string_util::CoTaskMemPWSTR; + use windows::Win32::UI::Shell::{ + FOLDERID_LocalAppData, SHGetKnownFolderPath, KF_FLAG_DEFAULT, + }; + + // SAFETY: `FOLDERID_LocalAppData` is a valid, static, well-known GUID + // constant. The token argument is this process's own token, so the + // result is independent of any thread impersonation the host may have + // in effect (see `process_token`); it stays alive for the duration of + // the call because `token` is still in scope. The returned `PWSTR` is + // COM-allocated and is immediately wrapped in `CoTaskMemPWSTR`, whose + // `Drop` frees it via `CoTaskMemFree` exactly once. + let token = process_token()?; + let pwstr = unsafe { + SHGetKnownFolderPath(&FOLDERID_LocalAppData, KF_FLAG_DEFAULT, Some(token.get())) + } + .ok()?; + let owned = CoTaskMemPWSTR::new(pwstr.0); + let resolved = owned.to_string_lossy(); + if resolved.is_empty() { + None + } else { + Some(PathBuf::from(resolved)) + } + } + + /// Test-only escape hatch so deterministic tests — this crate's own unit + /// tests, `mxc_ffi`'s Rust tests, and a local `dotnet test`/`npm test` run + /// against a debug-profile native binary — can redirect the consent store + /// to a throwaway temp directory. + /// + /// Active only under `cfg(test)` (this crate's own test harness, which is + /// how CI exercises it — CI runs `cargo test --release`, so gating on + /// `debug_assertions` alone would silently drop every consent test from + /// CI *and* let them read and overwrite the real store) or in a debug + /// build (which is what lets `mxc_ffi`'s cross-crate tests, where + /// `cfg(test)` does not apply to this crate, redirect it). + /// + /// Neither condition holds for a binary MXC ships: a release + /// `wxc-exec.exe` is not compiled with `--test` and has + /// `debug_assertions` off, so this branch and the env-var read backing it + /// are compiled out entirely, and a parent process has no way to redirect + /// the store. + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + fn debug_local_app_data_override() -> Option { + crate::telemetry::consent::test_support::current_local_app_data_override() + .or_else(crate::telemetry::consent::test_support::inherited_local_app_data_override) + } + + fn local_app_data_dir() -> Option { + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + if let Some(over) = debug_local_app_data_override() { + return Some(over); + } + known_folder_local_app_data() + } + + struct StoreLock { + file: Option, + } + + impl Drop for StoreLock { + fn drop(&mut self) { + self.file.take(); + } + } + + pub(super) fn with_store_lock( + operation: impl FnOnce() -> Result, + ) -> Result { + use std::os::windows::fs::OpenOptionsExt; + + let path = consent_file_path().ok_or_else(|| { + "could not resolve %LocalAppData%; cannot lock telemetry consent".to_string() + })?; + let dir = path + .parent() + .ok_or_else(|| "invalid telemetry consent path".to_string())?; + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + let lock_path = dir.join(CONSENT_LOCK_FILE); + let mut attempts = 0; + let file = loop { + let mut options = fs::OpenOptions::new(); + options + .write(true) + .create_new(true) + .custom_flags(windows::Win32::Storage::FileSystem::FILE_FLAG_DELETE_ON_CLOSE.0); + match options.open(&lock_path) { + Ok(file) => break file, + Err(error) + if attempts + 1 < IO_RETRY_ATTEMPTS + && (error.kind() == std::io::ErrorKind::AlreadyExists + || is_transient_io_error(&error)) => + { + attempts += 1; + std::thread::sleep(STORE_LOCK_RETRY_DELAY); + } + Err(error) => { + return Err(format!("failed to lock telemetry consent: {error}")); + } + } + }; + let _lock = StoreLock { file: Some(file) }; + operation() + } + + /// Test-only accessor for [`known_folder_local_app_data`], which is + /// otherwise private to this module. + #[cfg(test)] + pub(super) fn known_folder_local_app_data_for_test() -> Option { + known_folder_local_app_data() + } + + #[cfg(test)] + pub(super) fn local_app_data_dir_for_test() -> Option { + local_app_data_dir() + } + + #[cfg(test)] + pub(super) fn cache_successful_resolution_for_test( + cache: &std::sync::OnceLock, + resolver: impl FnOnce() -> Option, + ) -> Option { + cache_successful_resolution(cache, resolver) + } + + /// Per-user consent store path: `\mxc\telemetry-consent.json`. + /// Per-user (not `%ProgramData%`) because consent is a personal choice — + /// multiple people sharing one machine each control their own, with no + /// elevation required to change it (mirrors `wxc-exec.exe` never + /// self-elevating; see `docs/host-prep.md`). + fn consent_file_path() -> Option { + local_app_data_dir().map(|dir| dir.join("mxc").join("telemetry-consent.json")) + } + + /// Whether an I/O error is plausibly a *transient* lock/share conflict + /// worth waiting out, as opposed to a settled answer. + /// + /// This distinction is load-bearing for startup latency, not just tidiness: + /// the overwhelmingly common state is "no consent file yet" (every fresh + /// install, and every user who has not yet been prompted), which surfaces + /// as `NotFound`. Retrying that would add `IO_RETRY_ATTEMPTS - 1` sleeps — + /// 80 ms — to the consent read on the critical path of *every* sandbox + /// launch, to re-confirm a result that cannot change. + fn is_transient_io_error(e: &std::io::Error) -> bool { + if matches!( + e.raw_os_error(), + Some(ERROR_SHARING_VIOLATION) | Some(ERROR_LOCK_VIOLATION) + ) { + return true; + } + matches!( + e.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Interrupted + ) + } + + /// Retries `op` up to [`IO_RETRY_ATTEMPTS`] times with a short fixed + /// delay, to ride out transient Windows file-lock contention (AV + /// scanning, indexing, a racing sibling process) rather than failing the + /// very first time a lock is briefly held by someone else. + /// + /// Only [`is_transient_io_error`] failures are retried; anything else + /// (notably `NotFound`) is returned immediately. + fn with_io_retry(mut op: impl FnMut() -> std::io::Result) -> std::io::Result { + let mut attempt = 0; + loop { + match op() { + Ok(v) => return Ok(v), + Err(e) if attempt + 1 < IO_RETRY_ATTEMPTS && is_transient_io_error(&e) => { + attempt += 1; + std::thread::sleep(IO_RETRY_DELAY); + } + Err(e) => return Err(e), + } + } + } + + /// Test-only accessor for [`with_io_retry`], which is otherwise private + /// to this module. + #[cfg(test)] + pub(super) fn with_io_retry_for_test( + op: impl FnMut() -> std::io::Result, + ) -> std::io::Result { + with_io_retry(op) + } + + /// Test-only constructor for a synthetic transient error, so retry tests + /// don't have to guess which raw OS codes this module treats as transient. + #[cfg(test)] + pub(super) fn transient_io_error_for_test() -> std::io::Error { + std::io::Error::from_raw_os_error(ERROR_SHARING_VIOLATION) + } + + #[cfg(test)] + pub(super) const IO_RETRY_ATTEMPTS_FOR_TEST: u32 = IO_RETRY_ATTEMPTS; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(super) enum WithdrawalMarkerState { + Absent, + Pending, + Stale, + } + + fn withdrawal_marker_state(marker: &Path) -> std::io::Result { + match with_io_retry(|| fs::metadata(marker)) { + Ok(metadata) => { + let is_stale = metadata + .modified() + .ok() + .and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= WITHDRAWAL_MARKER_STALE_AFTER); + Ok(if is_stale { + WithdrawalMarkerState::Stale + } else { + WithdrawalMarkerState::Pending + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(WithdrawalMarkerState::Absent) + } + Err(error) => Err(error), + } + } + + #[cfg(test)] + pub(super) fn read_status_unlocked_with_marker_for_test( + marker_state: std::io::Result, + ) -> ConsentStatus { + read_status_unlocked_inner(|_| marker_state, recover_stale_withdrawal_marker) + } + + pub(super) fn read_status() -> ConsentStatus { + read_status_unlocked_inner(withdrawal_marker_state, |path, marker| { + with_store_lock(|| recover_stale_withdrawal_marker(path, marker)) + }) + } + + pub(super) fn read_status_unlocked() -> ConsentStatus { + read_status_unlocked_inner(withdrawal_marker_state, recover_stale_withdrawal_marker) + } + + fn read_status_unlocked_inner( + marker_check: impl FnOnce(&Path) -> std::io::Result, + recover_stale: impl FnOnce(&Path, &Path) -> Result<(), String>, + ) -> ConsentStatus { + fn unreadable_status() -> ConsentStatus { + ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::StoreUnreadable), + } + } + + fn no_record_status() -> ConsentStatus { + ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::NoRecord), + } + } + + let Some(path) = consent_file_path() else { + return unreadable_status(); + }; + let Some(dir) = path.parent() else { + return unreadable_status(); + }; + let marker_path = dir.join(WITHDRAWAL_PENDING_FILE); + match marker_check(&marker_path) { + Ok(WithdrawalMarkerState::Absent) => {} + Ok(WithdrawalMarkerState::Pending) | Err(_) => { + return unreadable_status(); + } + Ok(WithdrawalMarkerState::Stale) => { + if recover_stale(&path, &marker_path).is_err() { + return unreadable_status(); + } + } + } + let data = match with_io_retry(|| read_bounded(&path)) { + Ok(data) => data, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return no_record_status() + } + Err(_) => return unreadable_status(), + }; + let Ok(record) = serde_json::from_str::(&data) else { + return ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::StoreMalformed), + }; + }; + + let stored_state = match record.consent.as_str() { + "granted" => ConsentState::Granted, + "denied" => ConsentState::Denied, + _ => { + return ConsentStatus { + stored_state: ConsentState::Undetermined, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::StoreMalformed), + }; + } + }; + + if stored_state == ConsentState::Denied { + return ConsentStatus { + stored_state, + effective_state: ConsentState::Denied, + reason: None, + }; + } + + if record.schema_version != CONSENT_SCHEMA_VERSION { + return ConsentStatus { + stored_state, + effective_state: ConsentState::Undetermined, + reason: Some(if record.prompt_resource_version.is_none() { + ConsentStatusReason::PromptVersionMissing + } else { + ConsentStatusReason::ConsentSchemaUnsupported + }), + }; + } + + match record.prompt_resource_version { + Some(CONSENT_RESOURCE_VERSION) if !record.prompt_locale.is_empty() => ConsentStatus { + stored_state, + effective_state: ConsentState::Granted, + reason: None, + }, + None => ConsentStatus { + stored_state, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::PromptVersionMissing), + }, + Some(_) => ConsentStatus { + stored_state, + effective_state: ConsentState::Undetermined, + reason: Some(ConsentStatusReason::PromptVersionUnsupported), + }, + } + } + + fn read_bounded(path: &Path) -> std::io::Result { + let file = fs::File::open(path)?; + let mut bytes = Vec::new(); + file.take(MAX_CONSENT_FILE_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_CONSENT_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "telemetry consent file exceeds the supported size", + )); + } + String::from_utf8(bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + } + + fn recover_stale_withdrawal_marker(_path: &Path, marker: &Path) -> Result<(), String> { + match withdrawal_marker_state(marker) + .map_err(|e| format!("failed to inspect stale withdrawal marker: {e}"))? + { + WithdrawalMarkerState::Absent => Ok(()), + WithdrawalMarkerState::Pending | WithdrawalMarkerState::Stale => write_record( + false, + "withdrawal-recovery", + Some(CONSENT_RESOURCE_VERSION), + FALLBACK_LOCALE, + ), + } + } + + /// Best-effort cleanup of a leftover temp file; failures here are not + /// reported since the operation they were cleaning up after has already + /// failed (or, on the success path, this is a pure best-effort tidy-up). + fn remove_best_effort(path: &Path) { + let _ = fs::remove_file(path); + } + + pub(super) fn write(granted: bool, source: &str) -> Result<(), String> { + write_record( + granted, + source, + Some(CONSENT_RESOURCE_VERSION), + FALLBACK_LOCALE, + ) + } + + pub(super) fn write_presented( + granted: bool, + source: &str, + prompt: &crate::telemetry::consent_prompt::ConsentPrompt, + ) -> Result<(), String> { + write_record( + granted, + source, + Some(prompt.resource_version), + prompt.locale, + ) + } + + pub(super) fn begin_withdrawal() -> Result<(), String> { + let path = consent_file_path().ok_or_else(|| { + "could not resolve %LocalAppData%; cannot persist telemetry consent".to_string() + })?; + let dir = path + .parent() + .ok_or_else(|| "invalid telemetry consent path".to_string())?; + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + with_io_retry(|| fs::write(dir.join(WITHDRAWAL_PENDING_FILE), b"pending")) + .map_err(|e| format!("failed to mark withdrawal pending: {e}")) + } + + pub(super) fn finish_withdrawal() -> Result<(), String> { + let path = consent_file_path().ok_or_else(|| { + "could not resolve %LocalAppData%; cannot persist telemetry consent".to_string() + })?; + clear_withdrawal_marker(&path) + } + + fn write_record( + granted: bool, + source: &str, + prompt_resource_version: Option, + prompt_locale: &str, + ) -> Result<(), String> { + let path = consent_file_path().ok_or_else(|| { + "could not resolve %LocalAppData%; cannot persist telemetry consent".to_string() + })?; + let dir = path + .parent() + .ok_or_else(|| "invalid telemetry consent path".to_string())?; + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + + let record = ConsentRecord { + schema_version: CONSENT_SCHEMA_VERSION, + consent: if granted { "granted" } else { "denied" }.to_string(), + source: source.to_string(), + prompted_mxc_version: crate::telemetry::version().to_string(), + prompt_resource_version, + prompt_locale: prompt_locale.to_string(), + updated_at_epoch: now_epoch_seconds(), + }; + let json = serde_json::to_string_pretty(&record) + .map_err(|e| format!("failed to serialize telemetry consent record: {e}"))?; + + // Atomic write: write to a *unique* temp file in the same directory + // (process id + a random suffix, so two concurrent `wxc-exec` + // grant/revoke invocations never share — and thus never race on — + // the same temp path), then rename over the real path. A crash + // mid-write never leaves a torn/corrupt file in place of a + // previously-valid one; if the rename itself fails, the temp file is + // removed rather than left behind as a leaked, orphaned artifact. + let unique = format!("{}-{:x}", std::process::id(), random_suffix()); + let tmp_path = path.with_extension(format!("json.{unique}.tmp")); + + if let Err(e) = with_io_retry(|| { + let mut file = fs::File::create(&tmp_path)?; + file.write_all(json.as_bytes())?; + file.sync_all() + }) { + remove_best_effort(&tmp_path); + return Err(format!("failed to write {}: {e}", tmp_path.display())); + } + if let Err(e) = with_io_retry(|| fs::rename(&tmp_path, &path)) { + remove_best_effort(&tmp_path); + return Err(format!("failed to finalize {}: {e}", path.display())); + } + clear_withdrawal_marker(&path) + } + + fn clear_withdrawal_marker(path: &Path) -> Result<(), String> { + let dir = path + .parent() + .ok_or_else(|| "invalid telemetry consent path".to_string())?; + match with_io_retry(|| fs::remove_file(dir.join(WITHDRAWAL_PENDING_FILE))) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("failed to clear withdrawal marker: {e}")), + } + } + + /// Returns a process-local unique suffix for a temporary filename. + fn random_suffix() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_SUFFIX: AtomicU64 = AtomicU64::new(0); + + NEXT_SUFFIX.fetch_add(1, Ordering::Relaxed) + } +} + +// --------------------------------------------------------------------------- +// Non-Windows stub — no file, no prompt, no pretend consent. +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "windows"))] +mod platform { + use super::{ConsentState, ConsentStatus, ConsentStatusReason}; + + pub(super) fn with_store_lock( + operation: impl FnOnce() -> Result, + ) -> Result { + operation() + } + + pub(super) fn read_status() -> ConsentStatus { + ConsentStatus { + stored_state: ConsentState::NotApplicable, + effective_state: ConsentState::NotApplicable, + reason: Some(ConsentStatusReason::NotApplicable), + } + } + + pub(super) fn read_status_unlocked() -> ConsentStatus { + read_status() + } + + pub(super) fn write(_granted: bool, _source: &str) -> Result<(), String> { + Err("telemetry is Windows-only; consent is not applicable on this platform".to_string()) + } + + pub(super) fn begin_withdrawal() -> Result<(), String> { + Err("telemetry is Windows-only; consent is not applicable on this platform".to_string()) + } + + pub(super) fn finish_withdrawal() -> Result<(), String> { + Ok(()) + } + + pub(super) fn write_presented( + _granted: bool, + _source: &str, + _prompt: &crate::telemetry::consent_prompt::ConsentPrompt, + ) -> Result<(), String> { + Err("telemetry is Windows-only; consent is not applicable on this platform".to_string()) + } +} + +// --------------------------------------------------------------------------- +// Test support — shared with `telemetry::mod`'s `is_enabled` tests so both +// modules can safely mutate the process-global consent-store override +// without racing each other under parallel test execution. +// +// This redirects `MXC_TEST_LOCALAPPDATA_OVERRIDE`, a debug-build-only escape +// hatch (see `platform::debug_local_app_data_override` above) — never the +// real `LOCALAPPDATA` variable, and never present at all in a release build. +// --------------------------------------------------------------------------- + +#[cfg(any(test, all(feature = "test-support", debug_assertions)))] +pub mod test_support { + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, MutexGuard}; + + const LOCAL_APPDATA_OVERRIDE_ENV: &str = "MXC_TEST_LOCALAPPDATA_OVERRIDE"; + const LOCAL_APPDATA_OVERRIDE_OWNER_ENV: &str = "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"; + + /// `get_consent`/`set_consent` read the debug-only override, which is + /// process-global state. Guarded internally by [`LocalAppDataGuard::set`] + /// so a caller can never forget to hold it — see that type's doc comment. + /// `pub(crate)` only so the rare test that must mutate the override + /// directly (bypassing the guard, e.g. to test the "no override set" + /// fallback path) can still serialize against guard-holding tests. + pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(()); + static LOCAL_APP_DATA_OVERRIDE: Mutex> = Mutex::new(None); + + /// Redirects the consent store (Windows only) to a fresh temp directory + /// for the lifetime of the guard, restoring the previous value on drop. + /// Acquires and holds [`ENV_LOCK`] for the guard's entire lifetime, so + /// tests cannot accidentally construct the guard without the lock. + /// A no-op holder on non-Windows, where consent is `NotApplicable` + /// regardless — kept as a real (if inert) guard type so callers don't + /// need `#[cfg]` at every call site. + pub struct LocalAppDataGuard { + _lock: MutexGuard<'static, ()>, + #[cfg(target_os = "windows")] + previous: Option, + #[cfg(target_os = "windows")] + previous_owner: Option, + #[cfg(target_os = "windows")] + previous_override: Option, + } + + impl LocalAppDataGuard { + #[cfg(target_os = "windows")] + pub fn set(path: &Path) -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var_os(LOCAL_APPDATA_OVERRIDE_ENV); + let previous_owner = std::env::var_os(LOCAL_APPDATA_OVERRIDE_OWNER_ENV); + let previous_override = LOCAL_APP_DATA_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + std::env::set_var(LOCAL_APPDATA_OVERRIDE_ENV, path); + std::env::set_var( + LOCAL_APPDATA_OVERRIDE_OWNER_ENV, + std::process::id().to_string(), + ); + *LOCAL_APP_DATA_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(path.to_path_buf()); + Self { + _lock: lock, + previous, + previous_owner, + previous_override, + } + } + + #[cfg(not(target_os = "windows"))] + pub fn set(_path: &Path) -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + Self { _lock: lock } + } + } + + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + pub(crate) fn current_local_app_data_override() -> Option { + LOCAL_APP_DATA_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + pub(crate) fn inherited_local_app_data_override() -> Option { + let path = std::env::var_os(LOCAL_APPDATA_OVERRIDE_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from)?; + let owner_pid = std::env::var(LOCAL_APPDATA_OVERRIDE_OWNER_ENV) + .ok() + .and_then(|value| value.parse::().ok()); + if owner_pid == Some(std::process::id()) { + return None; + } + Some(path) + } + + #[cfg(target_os = "windows")] + impl Drop for LocalAppDataGuard { + fn drop(&mut self) { + *LOCAL_APP_DATA_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) = self.previous_override.clone(); + match &self.previous { + Some(v) => std::env::set_var(LOCAL_APPDATA_OVERRIDE_ENV, v), + None => std::env::remove_var(LOCAL_APPDATA_OVERRIDE_ENV), + } + match &self.previous_owner { + Some(v) => std::env::set_var(LOCAL_APPDATA_OVERRIDE_OWNER_ENV, v), + None => std::env::remove_var(LOCAL_APPDATA_OVERRIDE_OWNER_ENV), + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::test_support::LocalAppDataGuard; + use super::*; + + fn block_on(future: F) -> F::Output { + fn raw_waker() -> std::task::RawWaker { + fn clone(_: *const ()) -> std::task::RawWaker { + raw_waker() + } + fn wake(_: *const ()) {} + fn wake_by_ref(_: *const ()) {} + fn drop(_: *const ()) {} + + std::task::RawWaker::new( + std::ptr::null(), + &std::task::RawWakerVTable::new(clone, wake, wake_by_ref, drop), + ) + } + + let waker = unsafe { std::task::Waker::from_raw(raw_waker()) }; + let mut future = std::pin::pin!(future); + let mut context = std::task::Context::from_waker(&waker); + + loop { + match future.as_mut().poll(&mut context) { + std::task::Poll::Ready(result) => return result, + std::task::Poll::Pending => std::thread::sleep(std::time::Duration::from_millis(1)), + } + } + } + + #[test] + fn consent_state_as_str_is_stable() { + assert_eq!(ConsentState::Granted.as_str(), "granted"); + assert_eq!(ConsentState::Denied.as_str(), "denied"); + assert_eq!(ConsentState::Undetermined.as_str(), "undetermined"); + assert_eq!(ConsentState::NotApplicable.as_str(), "not-applicable"); + } + + #[test] + fn only_granted_allows_collection() { + assert!(ConsentState::Granted.allows_collection()); + assert!(!ConsentState::Denied.allows_collection()); + assert!(!ConsentState::Undetermined.allows_collection()); + assert!(!ConsentState::NotApplicable.allows_collection()); + } + + #[test] + fn only_undetermined_needs_prompt() { + assert!(ConsentState::Undetermined.needs_prompt()); + assert!(!ConsentState::Granted.needs_prompt()); + assert!(!ConsentState::Denied.needs_prompt()); + // NotApplicable means "not Windows", where MXC collects nothing and + // therefore must never ask. Prompting here would be a privacy defect, + // not merely a redundant dialog. + assert!(!ConsentState::NotApplicable.needs_prompt()); + } + + #[cfg(target_os = "windows")] + #[test] + fn now_epoch_seconds_is_plausible() { + // Sanity bound: some time after this test was written, and not an + // absurd far-future value from an overflow/unit bug. + let secs = now_epoch_seconds(); + assert!(secs > 1_700_000_000, "epoch seconds too small: {secs}"); + assert!(secs < 4_000_000_000, "epoch seconds too large: {secs}"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn non_windows_is_always_not_applicable_and_write_fails() { + let _guard = LocalAppDataGuard::set(std::path::Path::new("unused")); + assert_eq!(get_consent(), ConsentState::NotApplicable); + assert_eq!( + request_consent(None, |_| panic!("non-Windows consent must not present")) + .unwrap() + .result, + ConsentActionResult::NotApplicable + ); + assert_eq!( + block_on(request_consent_async(None, |_| async { + panic!("non-Windows async consent must not present") + })) + .unwrap() + .result, + ConsentActionResult::NotApplicable + ); + assert_eq!( + withdraw_consent().unwrap().result, + ConsentActionResult::NotApplicable + ); + assert!(set_consent(true, "cli").is_err()); + assert!(set_consent(false, "cli").is_err()); + assert!( + !needs_consent_prompt(), + "must never ask for consent where nothing is collected" + ); + } + + #[cfg(target_os = "windows")] + mod windows_tests { + use super::{block_on, LocalAppDataGuard}; + use crate::telemetry::consent::{ + get_consent, get_status, needs_consent_prompt, platform, request_consent, + request_consent_async, set_consent, withdraw_consent, ConsentActionError, + ConsentActionResult, ConsentDecision, ConsentState, ConsentStatusReason, + }; + + fn age_withdrawal_marker(store_root: &std::path::Path) { + let marker = store_root + .join("mxc") + .join("telemetry-consent.withdrawal-pending"); + let file = std::fs::OpenOptions::new() + .write(true) + .open(&marker) + .expect("open withdrawal marker"); + let stale_time = std::time::SystemTime::now() - std::time::Duration::from_secs(10); + file.set_times(std::fs::FileTimes::new().set_modified(stale_time)) + .expect("age withdrawal marker"); + } + + #[test] + fn needs_consent_prompt_tracks_the_store() { + let tmp = tempfile::tempdir().unwrap(); + // Also isolates the policy key: `needs_consent_prompt` consults it, + // so without this the test reads the real machine policy and fails + // on an administratively managed device. + let _env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + assert!(needs_consent_prompt(), "fresh store must prompt"); + set_consent(false, "prompt").unwrap(); + assert!( + !needs_consent_prompt(), + "a recorded denial must not re-prompt" + ); + set_consent(true, "settings-toggle").unwrap(); + assert!( + !needs_consent_prompt(), + "a recorded grant must not re-prompt" + ); + } + + #[test] + fn needs_consent_prompt_is_suppressed_by_blocking_policy() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(0); + assert!( + !needs_consent_prompt(), + "a blocking policy must suppress a meaningless prompt" + ); + } + + #[test] + fn fresh_store_is_undetermined() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + assert_eq!(get_consent(), ConsentState::Undetermined); + } + + #[test] + fn grant_then_read_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(true, "cli").expect("grant should succeed"); + assert_eq!(get_consent(), ConsentState::Granted); + } + + #[test] + fn deny_then_read_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(false, "cli").expect("deny should succeed"); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn consent_can_be_flipped_repeatedly() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(true, "prompt").unwrap(); + assert_eq!(get_consent(), ConsentState::Granted); + set_consent(false, "settings-toggle").unwrap(); + assert_eq!(get_consent(), ConsentState::Denied); + set_consent(true, "settings-toggle").unwrap(); + assert_eq!(get_consent(), ConsentState::Granted); + } + + #[test] + fn presenter_yes_persists_a_current_version_grant() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + let outcome = request_consent(Some("en-US"), |prompt| { + assert_eq!(prompt.resource_version, 1); + assert_eq!(prompt.locale, "en-US"); + Ok(ConsentDecision::Yes) + }) + .expect("consent request"); + + assert_eq!(outcome.result, ConsentActionResult::Granted); + assert_eq!(outcome.status.stored_state, ConsentState::Granted); + assert_eq!(outcome.status.effective_state, ConsentState::Granted); + assert_eq!(outcome.status.reason, None); + } + + #[test] + fn async_presenter_yes_persists_a_current_version_grant() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + let outcome = block_on(request_consent_async(Some("en-US"), |prompt| { + assert_eq!(prompt.resource_version, 1); + assert_eq!(prompt.locale, "en-US"); + async { Ok(ConsentDecision::Yes) } + })) + .expect("async consent request"); + + assert_eq!(outcome.result, ConsentActionResult::Granted); + assert_eq!(outcome.status.stored_state, ConsentState::Granted); + assert_eq!(outcome.status.effective_state, ConsentState::Granted); + assert_eq!(outcome.status.reason, None); + } + + #[test] + fn async_presenter_no_persists_a_denial() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + let outcome = block_on(request_consent_async(None, |_| async { + Ok(ConsentDecision::No) + })) + .expect("async denial"); + + assert_eq!(outcome.result, ConsentActionResult::Denied); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn async_dismissal_preserves_the_prior_state() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "prompt").unwrap(); + + let outcome = block_on(request_consent_async(None, |_| async { + Ok(ConsentDecision::Dismissed) + })) + .expect("async dismissal"); + + assert_eq!(outcome.result, ConsentActionResult::Dismissed); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn async_presenter_error_preserves_the_prior_state() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "prompt").unwrap(); + + let error = block_on(request_consent_async(None, |_| async { + Err("host UI failed".to_string()) + })) + .expect_err("async presenter failure"); + + assert_eq!( + error, + ConsentActionError::Presenter("host UI failed".to_string()) + ); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn async_policy_block_skips_the_presenter() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(0); + + let outcome = block_on(request_consent_async(None, |_| async { + panic!("blocked policy must not present") + })) + .expect("blocked result"); + + assert_eq!(outcome.result, ConsentActionResult::PolicyBlocked); + } + + #[test] + fn policy_change_while_presenter_is_open_blocks_stale_grant() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + let outcome = request_consent(Some("en-US"), |_| { + env.set_policy_value(0); + Ok(ConsentDecision::Yes) + }) + .expect("policy change should produce a typed outcome"); + + assert_eq!(outcome.result, ConsentActionResult::PolicyBlocked); + assert_eq!(outcome.status.effective_state, ConsentState::Undetermined); + assert_eq!(get_consent(), ConsentState::Undetermined); + } + + #[test] + fn consent_change_while_presenter_is_open_rejects_stale_decision() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + let error = request_consent(Some("en-US"), |_| { + set_consent(false, "concurrent-withdrawal").unwrap(); + Ok(ConsentDecision::Yes) + }) + .expect_err("stale presenter decision must not overwrite a newer denial"); + + assert_eq!( + error, + ConsentActionError::Persist( + "consent state changed while the presenter was open; no decision was written" + .to_string() + ) + ); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn explicit_denial_withdraws_a_concurrent_grant() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + + let outcome = request_consent(Some("en-US"), |_| { + set_consent(true, "concurrent-grant").unwrap(); + Ok(ConsentDecision::No) + }) + .expect("explicit denial should be persisted"); + + assert_eq!(outcome.result, ConsentActionResult::Denied); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn explicit_request_can_replace_a_prior_denial() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "prompt").unwrap(); + + let outcome = request_consent(None, |_| Ok(ConsentDecision::Yes)).unwrap(); + + assert_eq!(outcome.result, ConsentActionResult::Granted); + assert_eq!(get_consent(), ConsentState::Granted); + } + + #[test] + fn dismissed_or_failed_presenter_does_not_rewrite_prior_state() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "prompt").unwrap(); + + let dismissed = + request_consent(None, |_| Ok(ConsentDecision::Dismissed)).expect("dismissal"); + assert_eq!(dismissed.result, ConsentActionResult::Dismissed); + assert_eq!(get_consent(), ConsentState::Denied); + + let error = request_consent(None, |_| Err("host UI failed".to_string())) + .expect_err("presenter failure"); + assert_eq!( + error, + ConsentActionError::Presenter("host UI failed".to_string()) + ); + assert_eq!(get_consent(), ConsentState::Denied); + } + + #[test] + fn policy_block_and_current_grant_skip_the_presenter() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(0); + let blocked = request_consent(None, |_| panic!("blocked policy must not present")) + .expect("blocked result"); + assert_eq!(blocked.result, ConsentActionResult::PolicyBlocked); + + env.set_policy_value(3); + set_consent(true, "prompt").unwrap(); + let granted = request_consent(None, |_| panic!("current grant must not re-present")) + .expect("already granted result"); + assert_eq!(granted.result, ConsentActionResult::AlreadyGranted); + } + + #[test] + fn withdrawal_is_idempotent_and_ignores_blocking_policy() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(true, "prompt").unwrap(); + env.set_policy_value(0); + + for _ in 0..2 { + let outcome = withdraw_consent().expect("withdrawal"); + assert_eq!(outcome.result, ConsentActionResult::Withdrawn); + assert_eq!(outcome.status.effective_state, ConsentState::Denied); + } + } + + #[test] + fn withdrawal_reports_the_current_policy_after_waiting_for_the_store_lock() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(true, "prompt").unwrap(); + + let outcome = std::thread::scope(|scope| { + let handle = platform::with_store_lock(|| { + let handle = scope.spawn(|| withdraw_consent().expect("withdrawal")); + env.set_policy_value(0); + Ok(handle) + }) + .expect("store lock"); + handle.join().unwrap() + }); + + assert_eq!(outcome.result, ConsentActionResult::Withdrawn); + assert_eq!(outcome.status.effective_state, ConsentState::Denied); + assert_eq!(outcome.policy, crate::telemetry::PolicyState::Blocked); + } + + #[test] + fn consent_read_does_not_wait_for_the_store_lock() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(true, "prompt").unwrap(); + + let (status_tx, status_rx) = std::sync::mpsc::channel(); + std::thread::scope(|scope| { + let read_handle = platform::with_store_lock(|| { + let status_tx = status_tx.clone(); + let handle = scope.spawn(move || { + status_tx.send(get_status()).expect("send consent status"); + }); + let status = status_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .expect("consent read must not wait for the store lock"); + assert_eq!(status.effective_state, ConsentState::Granted); + Ok(handle) + }) + .expect("store lock"); + read_handle.join().unwrap(); + }); + } + + #[test] + fn corrupt_file_is_undetermined_not_granted() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("telemetry-consent.json"), "not json at all").unwrap(); + assert_eq!(get_consent(), ConsentState::Undetermined); + } + + #[test] + fn oversized_file_is_rejected_without_unbounded_read() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("telemetry-consent.json"), + vec![b'x'; 16 * 1024 + 1], + ) + .unwrap(); + let status = get_status(); + assert_eq!(status.effective_state, ConsentState::Undetermined); + assert_eq!(status.reason, Some(ConsentStatusReason::StoreUnreadable)); + } + + #[test] + fn pending_withdrawal_marker_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + platform::begin_withdrawal().unwrap(); + + let status = get_status(); + assert_eq!(status.effective_state, ConsentState::Undetermined); + assert_eq!(status.reason, Some(ConsentStatusReason::StoreUnreadable)); + } + + #[test] + fn pending_withdrawal_marker_metadata_errors_fail_closed() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + set_consent(true, "prompt").unwrap(); + + let status = + platform::read_status_unlocked_with_marker_for_test(Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "marker temporarily unreadable", + ))); + + assert_eq!(status.stored_state, ConsentState::Undetermined); + assert_eq!(status.effective_state, ConsentState::Undetermined); + assert_eq!(status.reason, Some(ConsentStatusReason::StoreUnreadable)); + } + + #[test] + fn successful_grant_repairs_a_stale_withdrawal_marker() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + platform::begin_withdrawal().unwrap(); + + let outcome = request_consent(None, |_| Ok(ConsentDecision::Yes)).unwrap(); + + assert_eq!(outcome.result, ConsentActionResult::Granted); + assert_eq!(outcome.status.effective_state, ConsentState::Granted); + assert_eq!(get_consent(), ConsentState::Granted); + } + + #[test] + fn stale_withdrawal_marker_recovers_to_denied() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(true, "prompt").unwrap(); + platform::begin_withdrawal().unwrap(); + age_withdrawal_marker(tmp.path()); + + let status = get_status(); + + assert_eq!(status.stored_state, ConsentState::Denied); + assert_eq!(status.effective_state, ConsentState::Denied); + assert_eq!(status.reason, None); + assert_eq!(get_consent(), ConsentState::Denied); + let record = + std::fs::read_to_string(tmp.path().join("mxc").join("telemetry-consent.json")) + .unwrap(); + assert!(record.contains(r#""source": "withdrawal-recovery""#)); + assert!( + !tmp.path() + .join("mxc") + .join("telemetry-consent.withdrawal-pending") + .exists(), + "recovery must clear the stale withdrawal marker" + ); + } + + #[test] + fn stale_withdrawal_marker_on_denied_record_is_cleared() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "cli").unwrap(); + platform::begin_withdrawal().unwrap(); + age_withdrawal_marker(tmp.path()); + + let status = get_status(); + + assert_eq!(status.stored_state, ConsentState::Denied); + assert_eq!(status.effective_state, ConsentState::Denied); + assert_eq!(status.reason, None); + assert!( + !tmp.path() + .join("mxc") + .join("telemetry-consent.withdrawal-pending") + .exists(), + "recovery must eventually clear a leftover withdrawal marker" + ); + } + + #[test] + fn stale_withdrawal_recovery_is_serialized_with_concurrent_writers() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(true, "prompt").unwrap(); + platform::begin_withdrawal().unwrap(); + age_withdrawal_marker(tmp.path()); + + std::thread::scope(|scope| { + let (reader, writer) = platform::with_store_lock(|| { + let (reader_started_tx, reader_started_rx) = std::sync::mpsc::channel(); + let (writer_started_tx, writer_started_rx) = std::sync::mpsc::channel(); + let reader = scope.spawn(move || { + reader_started_tx.send(()).unwrap(); + get_status() + }); + let writer = scope.spawn(move || { + writer_started_tx.send(()).unwrap(); + withdraw_consent().map(|_| ()) + }); + reader_started_rx.recv().unwrap(); + writer_started_rx.recv().unwrap(); + assert!( + !reader.is_finished() && !writer.is_finished(), + "recovery and writer must wait for the store lock" + ); + Ok((reader, writer)) + }) + .unwrap(); + + let reader_status = reader.join().unwrap(); + writer.join().unwrap().unwrap(); + assert_eq!(reader_status.effective_state, ConsentState::Denied); + assert_eq!(get_consent(), ConsentState::Denied); + assert!(!tmp + .path() + .join("mxc") + .join("telemetry-consent.withdrawal-pending") + .exists()); + }); + } + + #[test] + fn unknown_schema_version_is_undetermined() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("telemetry-consent.json"), + r#"{"schemaVersion":999,"consent":"granted"}"#, + ) + .unwrap(); + assert_eq!(get_consent(), ConsentState::Undetermined); + } + + #[test] + fn unsupported_grant_schema_reports_a_typed_reason() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("telemetry-consent.json"), + r#"{"schemaVersion":999,"consent":"granted","promptResourceVersion":1,"promptLocale":"en-US"}"#, + ) + .unwrap(); + + let status = get_status(); + + assert_eq!(status.stored_state, ConsentState::Granted); + assert_eq!(status.effective_state, ConsentState::Undetermined); + assert_eq!( + status.reason, + Some(ConsentStatusReason::ConsentSchemaUnsupported) + ); + } + + #[test] + fn unsupported_prompt_version_reports_a_typed_reason() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("telemetry-consent.json"), + r#"{"schemaVersion":2,"consent":"granted","promptResourceVersion":999,"promptLocale":"en-US"}"#, + ) + .unwrap(); + + let status = get_status(); + + assert_eq!(status.stored_state, ConsentState::Granted); + assert_eq!(status.effective_state, ConsentState::Undetermined); + assert_eq!( + status.reason, + Some(ConsentStatusReason::PromptVersionUnsupported) + ); + } + + #[test] + fn concurrent_grant_revoke_never_corrupts_the_store() { + // Regression test for the write-race finding: many threads + // hammering set_consent() against the same redirected store + // concurrently (the override env var is process-global, so + // every spawned thread inherits the same redirected path from + // the outer guard) must never leave a torn/missing file behind; + // the final state must be a fully-formed, parseable record. + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + + // Every writer must *succeed*, not merely avoid corrupting the + // file. Asserting only on the final file's parseability would + // still pass if 15 of 16 writers lost a temp-name or rename + // race, which is precisely the failure this fix targets. + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..16) + .map(|i| scope.spawn(move || set_consent(i % 2 == 0, "cli"))) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + for (i, result) in results.iter().enumerate() { + assert!(result.is_ok(), "concurrent writer {i} failed: {result:?}"); + } + + // Whatever the last writer's outcome was, the store must be + // Granted or Denied — never Undetermined (which would mean a + // corrupt/missing file from a torn write). + let state = get_consent(); + assert!(matches!( + state, + ConsentState::Granted | ConsentState::Denied + )); + } + + /// Pre-versioned grants must be invalidated because they cannot prove + /// the canonical prompt was shown. A prior denial remains denied. + #[test] + fn legacy_records_preserve_provenance_but_require_reconsent_for_grants() { + for (stored, effective, reason) in [ + ( + "granted", + ConsentState::Undetermined, + Some(ConsentStatusReason::PromptVersionMissing), + ), + ("denied", ConsentState::Denied, None), + ] { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let dir = tmp.path().join("mxc"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("telemetry-consent.json"), + format!( + r#"{{"schemaVersion":1,"consent":"{stored}","source":"prompt","promptedMxcVersion":"0.7.0","updatedAtUtc":"2026-01-02T03:04:05Z"}}"# + ), + ) + .unwrap(); + let status = get_status(); + assert_eq!( + status.stored_state, + if stored == "granted" { + ConsentState::Granted + } else { + ConsentState::Denied + } + ); + assert_eq!(status.effective_state, effective); + assert_eq!(status.reason, reason); + } + } + + /// `with_io_retry` must ride out transient lock contention but must + /// not sleep through a settled answer. The `NotFound` case is the + /// startup-latency one: it is the state of every machine that has + /// never recorded a decision, on the critical path of every launch. + #[test] + fn io_retry_retries_transient_errors_then_succeeds() { + use crate::telemetry::consent::platform; + let mut calls = 0; + let result = platform::with_io_retry_for_test(|| { + calls += 1; + if calls < 3 { + Err(platform::transient_io_error_for_test()) + } else { + Ok(calls) + } + }); + assert_eq!(result.unwrap(), 3); + assert_eq!(calls, 3); + } + + #[test] + fn io_retry_gives_up_after_the_attempt_budget() { + use crate::telemetry::consent::platform; + let mut calls = 0; + let result = platform::with_io_retry_for_test(|| { + calls += 1; + Err::<(), _>(platform::transient_io_error_for_test()) + }); + assert!(result.is_err()); + assert_eq!(calls, platform::IO_RETRY_ATTEMPTS_FOR_TEST); + } + + #[test] + fn io_retry_does_not_retry_not_found() { + use crate::telemetry::consent::platform; + let mut calls = 0; + let started = std::time::Instant::now(); + let result = platform::with_io_retry_for_test(|| { + calls += 1; + Err::<(), _>(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no consent file", + )) + }); + assert!(result.is_err()); + assert_eq!(calls, 1, "NotFound must not be retried"); + assert!( + started.elapsed() < std::time::Duration::from_millis(20), + "NotFound must not sleep on the retry delay" + ); + } + + /// The whole point of the above: reading a fresh (nonexistent) store + /// is the common case and must be effectively instant. + #[test] + fn fresh_store_read_does_not_sleep() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let started = std::time::Instant::now(); + assert_eq!(get_consent(), ConsentState::Undetermined); + assert!( + started.elapsed() < std::time::Duration::from_millis(40), + "fresh-store read took {:?}; the retry loop is sleeping on NotFound again", + started.elapsed() + ); + } + + #[test] + fn missing_local_app_data_resolution_falls_back_to_real_known_folder() { + // With no debug override set, the real per-user known-folder + // path must still resolve (it always exists on a real Windows + // profile), so exercise that fallback without touching the real + // consent file itself. Locks ENV_LOCK directly (rather than via + // LocalAppDataGuard) since this test removes the override + // entirely instead of redirecting it. + let _lock = super::super::test_support::ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + let previous_owner = std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"); + std::env::remove_var("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + std::env::remove_var("MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"); + let resolved = + crate::telemetry::consent::platform::known_folder_local_app_data_for_test(); + if let Some(v) = previous { + std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE", v); + } + if let Some(v) = previous_owner { + std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", v); + } + assert!( + resolved.is_some(), + "known-folder API should resolve a real path" + ); + } + + #[test] + fn local_app_data_override_reaches_child_processes() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("child_process_local_app_data_override_probe") + .arg("--nocapture") + .env("MXC_CHILD_OVERRIDE_PROBE", tmp.path()) + .status() + .expect("spawn child test probe"); + + assert!(status.success(), "child probe failed: {status}"); + } + + #[test] + fn child_process_local_app_data_override_probe() { + let Some(expected) = std::env::var_os("MXC_CHILD_OVERRIDE_PROBE") else { + return; + }; + + assert_eq!( + platform::local_app_data_dir_for_test(), + Some(std::path::PathBuf::from(expected)) + ); + assert_eq!(get_status().reason, Some(ConsentStatusReason::NoRecord)); + } + + #[test] + fn known_folder_resolution_only_caches_successful_results() { + let cache = std::sync::OnceLock::new(); + let mut calls = 0; + + assert_eq!( + platform::cache_successful_resolution_for_test(&cache, || { + calls += 1; + None + }), + None + ); + assert_eq!( + platform::cache_successful_resolution_for_test(&cache, || { + calls += 1; + Some(std::path::PathBuf::from(r"C:\Users\test\AppData\Local")) + }), + Some(std::path::PathBuf::from(r"C:\Users\test\AppData\Local")) + ); + assert_eq!( + platform::cache_successful_resolution_for_test(&cache, || { + calls += 1; + Some(std::path::PathBuf::from(r"C:\Users\test\AppData\Roaming")) + }), + Some(std::path::PathBuf::from(r"C:\Users\test\AppData\Local")) + ); + assert_eq!(calls, 2, "a failed lookup must not be cached forever"); + } + } +} diff --git a/src/core/wxc_common/src/telemetry/consent_prompt.rs b/src/core/wxc_common/src/telemetry/consent_prompt.rs new file mode 100644 index 000000000..84597e5a0 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent_prompt.rs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Canonical, versioned telemetry consent resources. +//! +//! The versioned JSON resource is embedded at build time. Presenters must +//! render every field verbatim. + +/// One independently localizable consent message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentMessage { + /// Stable localization identifier. + pub id: &'static str, + /// Complete localized text. + pub text: &'static str, +} + +/// Canonical telemetry consent resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentPrompt { + /// Version stored with a grant. + pub resource_version: u32, + /// BCP 47 locale. + pub locale: &'static str, + /// Title. + pub title: ConsentMessage, + /// Disclosure body. + pub body: ConsentMessage, + /// Affirmative action. + pub affirmative_label: ConsentMessage, + /// Negative action. + pub negative_label: ConsentMessage, + /// Learn-more label. + pub learn_more_label: ConsentMessage, + /// Learn-more URL. + pub learn_more_url: &'static str, +} + +include!(concat!(env!("OUT_DIR"), "/telemetry_consent_resource.rs")); + +/// Resolve the resource for a locale, falling back to `en-US`. +pub fn prompt_for_locale(locale: Option<&str>) -> &'static ConsentPrompt { + locale + .and_then(find_prompt) + .unwrap_or(&EN_US_CONSENT_PROMPT) +} + +fn find_prompt(locale: &str) -> Option<&'static ConsentPrompt> { + is_english_locale(locale).then_some(&EN_US_CONSENT_PROMPT) +} + +fn is_english_locale(locale: &str) -> bool { + locale + .trim() + .split(['-', '_']) + .next() + .is_some_and(|language| language.eq_ignore_ascii_case("en")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_resource_is_complete_and_versioned() { + let prompt = prompt_for_locale(Some("en-US")); + assert_eq!(prompt.resource_version, CONSENT_RESOURCE_VERSION); + assert_eq!(prompt.locale, "en-US"); + assert_eq!( + prompt.title.text, + "Help improve Microsoft eXecution Container (MXC)" + ); + assert_eq!( + prompt.body.text, + "Help improve MXC by sharing optional diagnostic data with Microsoft.\n\ + If enabled, MXC sends diagnostic information about product usage, performance, \ + and reliability. MXC does not send your commands, file paths, credentials, or \ + other customer content.\n\ + You can change your choice at any time." + ); + assert_eq!(prompt.affirmative_label.text, "Yes"); + assert_eq!(prompt.negative_label.text, "No"); + assert_eq!(prompt.learn_more_label.text, "Privacy Statement"); + assert_eq!( + prompt.learn_more_url, + "https://go.microsoft.com/fwlink/?linkid=521839" + ); + } + + #[test] + fn locale_lookup_normalizes_english_and_falls_back_to_en_us() { + for locale in [ + None, + Some("en"), + Some("EN_us"), + Some("en-GB"), + Some("fr-FR"), + ] { + assert_eq!(prompt_for_locale(locale), &EN_US_CONSENT_PROMPT); + } + } + + #[test] + fn message_ids_are_stable_and_unique() { + let prompt = &EN_US_CONSENT_PROMPT; + let ids = [ + prompt.title.id, + prompt.body.id, + prompt.affirmative_label.id, + prompt.negative_label.id, + prompt.learn_more_label.id, + ]; + for (index, id) in ids.iter().enumerate() { + assert!(!id.is_empty()); + assert!(!ids[..index].contains(id), "duplicate message id: {id}"); + } + } +} diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index c4c011bbe..a9b9398a2 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -54,6 +54,7 @@ impl std::fmt::Display for FailureReason { #[derive(Debug, Clone, Copy)] pub struct TelemetryContext<'a> { pub backend: &'a str, + pub sandbox_kind: &'a str, /// State-aware lifecycle phase — one of `provision|start|exec|stop| /// deprovision`, or `""` for one-shot (non-state-aware) executions. pub phase: &'a str, @@ -66,6 +67,7 @@ pub struct TelemetryContext<'a> { /// Data for an MXC.Execution ETW event. pub struct ExecutionEvent<'a> { pub backend: &'a str, + pub sandbox_kind: &'a str, pub exit_code: i32, pub outcome: &'a str, pub duration_ms: u64, @@ -91,6 +93,7 @@ pub fn log_execution(event: &ExecutionEvent<'_>) { mxc_telemetry::log_execution( event.backend, + event.sandbox_kind, event.exit_code, event.outcome, event.duration_ms, @@ -113,6 +116,7 @@ pub fn log_execution(event: &ExecutionEvent<'_>) { pub fn log_error(ctx: TelemetryContext<'_>, error_type: FailureReason, exit_code: i32) { mxc_telemetry::log_error( ctx.backend, + ctx.sandbox_kind, error_type.as_str(), exit_code, ctx.phase, @@ -138,6 +142,7 @@ pub(super) mod test_sink { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedExecution { pub backend: String, + pub sandbox_kind: String, pub exit_code: i32, pub outcome: String, pub duration_ms: u64, @@ -150,6 +155,7 @@ pub(super) mod test_sink { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedError { pub backend: String, + pub sandbox_kind: String, pub error_type: FailureReason, pub exit_code: i32, pub phase: String, @@ -201,6 +207,7 @@ pub(super) mod test_sink { .unwrap_or_else(|e| e.into_inner()) .push(CapturedExecution { backend: event.backend.to_owned(), + sandbox_kind: event.sandbox_kind.to_owned(), exit_code: event.exit_code, outcome: event.outcome.to_owned(), duration_ms: event.duration_ms, @@ -223,6 +230,7 @@ pub(super) mod test_sink { .unwrap_or_else(|e| e.into_inner()) .push(CapturedError { backend: ctx.backend.to_owned(), + sandbox_kind: ctx.sandbox_kind.to_owned(), error_type, exit_code, phase: ctx.phase.to_owned(), diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 15a85d4eb..d777ed177 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -11,12 +11,15 @@ //! //! On non-Windows platforms, all telemetry functions are no-ops. +pub mod consent; +pub mod consent_prompt; pub mod correlation_vector; pub mod events; +pub mod policy; use std::time::Duration; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::Ordering; use std::sync::Mutex; use crate::logger::Logger; @@ -24,7 +27,36 @@ use crate::models::{ContainmentBackend, FailurePhase, ScriptResponse, TelemetryC use crate::mxc_error::{MxcError, MxcErrorCode}; use crate::state_aware_dispatch::DispatchOutcome; +pub use consent::ConsentState; pub use events::{log_error, log_execution, ExecutionEvent, FailureReason, TelemetryContext}; +pub use policy::PolicyState; + +#[cfg(any(test, all(feature = "test-support", debug_assertions)))] +pub mod test_support { + use super::consent::test_support::LocalAppDataGuard; + use super::policy::test_support::PolicyKeyGuard; + + /// Lock-order-safe redirect guard for tests that need both the consent + /// store and the policy key redirected away from real user/machine state. + pub struct TelemetryTestEnv { + _consent: LocalAppDataGuard, + policy: PolicyKeyGuard, + } + + impl TelemetryTestEnv { + /// Redirect both telemetry globals for the lifetime of the guard. + pub fn new(store: &std::path::Path) -> Self { + let policy = PolicyKeyGuard::new(); + let _consent = LocalAppDataGuard::set(store); + Self { _consent, policy } + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub fn set_policy_value(&self, value: u32) { + self.policy.set_value(value); + } + } +} /// Conventional process exit code for a Rust panic/abort. Used as the reported /// `exit_code` on crash telemetry, since the panicking process has not (and @@ -76,7 +108,7 @@ static PROCESS_CORRELATION_VECTOR: Mutex> = Mutex::new(None); /// handler) can race the main thread's normal completion emit; this guard makes /// emission exactly-once so a single dispatch never yields duplicate /// `MXC.Execution` records. -static HAS_EMITTED: AtomicBool = AtomicBool::new(false); +static HAS_EMITTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[cfg(test)] thread_local! { @@ -141,13 +173,16 @@ pub fn version() -> &'static str { /// Resolve whether telemetry is enabled for this invocation. /// /// Resolution: -/// - `experimental.telemetry.enabled` in JSON config — explicit override. -/// - Default: off (telemetry requires explicit opt-in). +/// - `telemetry.enabled` in JSON config — explicit kill-switch/opt-in. +/// - Persisted user consent state — must be `Granted`. +/// - Administrative policy ceiling — must allow collection. /// -/// Note: Consent is the SDK consumer's responsibility. MXC does not implement -/// consent prompts or persistent consent storage. +/// All three terms must hold. The function fails closed if either consent or +/// policy cannot be read: those helpers resolve to non-collecting states. pub fn is_enabled(config: &TelemetryConfig) -> bool { config.enabled.unwrap_or(false) + && consent::get_consent().allows_collection() + && policy::get_policy().allows_collection() } /// Initialize the TraceLogging ETW provider. @@ -223,6 +258,7 @@ pub fn emit_completion( log_execution(&ExecutionEvent { backend, + sandbox_kind: backend, exit_code: response.exit_code, outcome, duration_ms: elapsed.as_millis() as u64, @@ -241,6 +277,7 @@ pub fn emit_completion( log_error( TelemetryContext { backend, + sandbox_kind: backend, phase: "", correlation_vector: "", }, @@ -274,6 +311,7 @@ pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: F log_execution(&ExecutionEvent { backend, + sandbox_kind: backend, exit_code: 1, outcome: "failure", duration_ms: 0, @@ -287,6 +325,7 @@ pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: F log_error( TelemetryContext { backend, + sandbox_kind: backend, phase: "", correlation_vector: "", }, @@ -420,6 +459,7 @@ fn crash_event<'a>( ) -> ExecutionEvent<'a> { ExecutionEvent { backend: ctx.backend, + sandbox_kind: ctx.sandbox_kind, exit_code, outcome: "failure", duration_ms: 0, @@ -484,6 +524,7 @@ pub fn emit_panic() { emit_crash( TelemetryContext { backend: process_backend(), + sandbox_kind: process_backend(), phase: process_phase(), correlation_vector: &correlation_vector, }, @@ -513,6 +554,7 @@ pub fn emit_cancellation() { emit_crash( TelemetryContext { backend: process_backend(), + sandbox_kind: process_backend(), phase: process_phase(), correlation_vector: &correlation_vector, }, @@ -560,6 +602,7 @@ fn plan_state_aware<'a>( Ok(DispatchOutcome::Envelope(_)) => StateAwareEvents { execution: ExecutionEvent { backend: ctx.backend, + sandbox_kind: ctx.sandbox_kind, exit_code: 0, outcome: "success", duration_ms, @@ -574,6 +617,7 @@ fn plan_state_aware<'a>( StateAwareEvents { execution: ExecutionEvent { backend: ctx.backend, + sandbox_kind: ctx.sandbox_kind, exit_code: *exit_code, outcome: if failed { "failure" } else { "success" }, duration_ms, @@ -593,6 +637,7 @@ fn plan_state_aware<'a>( StateAwareEvents { execution: ExecutionEvent { backend: ctx.backend, + sandbox_kind: ctx.sandbox_kind, exit_code: 1, outcome: "failure", duration_ms, @@ -660,14 +705,6 @@ mod tests { /// Mirrors the `TEST_LOCK` pattern in `mxc_telemetry`. static TEST_LOCK: Mutex<()> = Mutex::new(()); - #[test] - fn is_enabled_explicit_true() { - let config = TelemetryConfig { - enabled: Some(true), - }; - assert!(is_enabled(&config)); - } - #[test] fn is_enabled_explicit_false() { let config = TelemetryConfig { @@ -682,6 +719,42 @@ mod tests { assert!(!is_enabled(&config)); } + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_explicit_true_requires_granted_consent_and_permitting_policy() { + let store = tempfile::tempdir().expect("temp dir"); + let env = test_support::TelemetryTestEnv::new(store.path()); + let config = TelemetryConfig { + enabled: Some(true), + }; + + assert!( + !is_enabled(&config), + "undetermined consent must fail closed" + ); + + consent::set_consent(true, "test").expect("set consent"); + assert!( + is_enabled(&config), + "granted + allowed policy should enable" + ); + + env.set_policy_value(0); + assert!( + !is_enabled(&config), + "blocked policy must suppress telemetry even with granted consent" + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn is_enabled_explicit_true_is_false_off_windows() { + let config = TelemetryConfig { + enabled: Some(true), + }; + assert!(!is_enabled(&config)); + } + #[test] fn version_is_not_empty() { assert!(!version().is_empty()); @@ -737,12 +810,14 @@ mod tests { assert_eq!(exec.outcome, "failure"); assert_eq!(exec.failure_reason, Some(FailureReason::InternalError)); assert_eq!(exec.phase, "exec"); + assert_eq!(exec.sandbox_kind, "isolation_session"); assert_eq!(exec.correlation_vector, "iso:wxc-abcd"); let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 1, "panic emits exactly one MXC.Error"); let error = &errors[0]; assert_eq!(error.backend, "isolation_session"); + assert_eq!(error.sandbox_kind, "isolation_session"); assert_eq!(error.error_type, FailureReason::InternalError); assert_eq!(error.exit_code, PANIC_EXIT_CODE); assert_eq!(error.phase, "exec"); @@ -772,10 +847,12 @@ mod tests { assert_eq!(exec.outcome, "failure"); assert_eq!(exec.failure_reason, Some(FailureReason::Cancelled)); assert_eq!(exec.phase, "start"); + assert_eq!(exec.sandbox_kind, "isolation_session"); assert_eq!(exec.correlation_vector, "iso:wxc-abcd"); let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 1); + assert_eq!(errors[0].sandbox_kind, "isolation_session"); assert_eq!(errors[0].error_type, FailureReason::Cancelled); assert_eq!(errors[0].exit_code, CANCELLED_EXIT_CODE); @@ -868,6 +945,7 @@ mod tests { false, TelemetryContext { backend: "isolation_session", + sandbox_kind: "isolation_session", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -934,6 +1012,7 @@ mod tests { let event = crash_event( TelemetryContext { backend: "lxc", + sandbox_kind: "lxc", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -950,6 +1029,7 @@ mod tests { let cancel = crash_event( TelemetryContext { backend: "appcontainer", + sandbox_kind: "appcontainer", phase: "", correlation_vector: "", }, @@ -970,6 +1050,7 @@ mod tests { let panic = plan_crash( TelemetryContext { backend: "lxc", + sandbox_kind: "lxc", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -992,6 +1073,7 @@ mod tests { let cancel = plan_crash( TelemetryContext { backend: "isolation_session", + sandbox_kind: "isolation_session", phase: "", correlation_vector: "", }, @@ -1028,6 +1110,7 @@ mod tests { for phase in PHASES { let ctx = TelemetryContext { backend: "isolation_session", + sandbox_kind: "isolation_session", phase, correlation_vector: correlation, }; @@ -1139,6 +1222,7 @@ mod tests { true, TelemetryContext { backend: "isolation_session", + sandbox_kind: "isolation_session", phase: "provision", correlation_vector: "corr-provision", }, @@ -1148,6 +1232,7 @@ mod tests { let execs = events::test_sink::take_executions(); assert_eq!(execs.len(), 1); assert_eq!(execs[0].phase, "provision"); + assert_eq!(execs[0].sandbox_kind, "isolation_session"); assert_eq!(execs[0].correlation_vector, "corr-provision"); assert_eq!(execs[0].outcome, "success"); assert!(events::test_sink::take_errors().is_empty()); @@ -1160,6 +1245,7 @@ mod tests { true, TelemetryContext { backend: "isolation_session", + sandbox_kind: "isolation_session", phase: "start", correlation_vector: "corr-start", }, @@ -1169,12 +1255,14 @@ mod tests { let execs = events::test_sink::take_executions(); assert_eq!(execs.len(), 1); assert_eq!(execs[0].phase, "start"); + assert_eq!(execs[0].sandbox_kind, "isolation_session"); assert_eq!(execs[0].correlation_vector, "corr-start"); assert_eq!(execs[0].outcome, "failure"); let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 1); assert_eq!(errors[0].error_type, FailureReason::PolicyError); assert_eq!(errors[0].phase, "start"); + assert_eq!(errors[0].sandbox_kind, "isolation_session"); assert_eq!(errors[0].correlation_vector, "corr-start"); reset_for_test(); diff --git a/src/core/wxc_common/src/telemetry/policy.rs b/src/core/wxc_common/src/telemetry/policy.rs new file mode 100644 index 000000000..2433b9c74 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/policy.rs @@ -0,0 +1,577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Administrative (MDM / Group Policy) telemetry policy. +//! +//! See `docs/telemetry/telemetry-administrative-policy.md` for the admin-facing reference and +//! `docs/telemetry/telemetry-consent-design.md` for how this composes with +//! user consent. In short: +//! +//! - An administrator (via Intune, another MDM, or Group Policy) may **deny** +//! MXC telemetry machine-wide by setting +//! `HKLM\SOFTWARE\Policies\Mxc\AllowTelemetry` (`REG_DWORD`). +//! - The policy is a **ceiling, never a grant**. An administrator who permits +//! telemetry does not thereby consent on the user's behalf: MXC still +//! requires an explicit, persisted [`super::consent::ConsentState::Granted`] +//! from the user. There is no policy value that can turn telemetry on. +//! - MXC deliberately does **not** read the Windows-wide `AllowTelemetry` +//! setting. Microsoft's Policy CSP documentation scopes that policy to "the +//! operating system and apps that are considered part of Windows" and states +//! it "doesn't apply to any additional apps installed by your organization", +//! and the Windows Business Division privacy guidance requires that +//! app-classified components "build their own notice and consent experience +//! ... and should not rely on the Windows diagnostic consent". Reading the +//! OS setting would also mean reading Windows *consent* state, which MXC is +//! expressly forbidden from doing — the supported OS APIs for this +//! (`TelIsTelemetryTypeAllowed` and friends) fold the user's Settings-app +//! choice into their answer. +//! - Windows-only, like every other telemetry surface. On other platforms MXC +//! collects nothing at all, so there is nothing for a policy to restrict and +//! this module compiles down to a stub. +//! - Fails closed: an unreadable or unrecognized policy value denies. + +/// The administrator's telemetry decision for this machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyState { + /// No administrative policy is configured. Telemetry is governed solely by + /// the user's own consent decision. This is *not* a grant. + Unrestricted, + /// An administrator has permitted optional (usage) telemetry, the category + /// MXC emits. Still requires user consent before anything is collected. + Allowed, + /// An administrator has denied MXC telemetry, or the configured value could + /// not be understood. Nothing is collected regardless of user consent, and + /// hosts must not offer a consent prompt. + Blocked, + /// Not a Windows host. MXC collects no telemetry here, so administrative + /// policy is not a meaningful concept. + NotApplicable, +} + +impl PolicyState { + /// Stable, lowercase wire representation used by the CLI flags, the FFI + /// boundary, and the SDKs. Kept separate from `Debug` so the on-the-wire + /// strings never drift if the Rust variant names change. + pub fn as_str(&self) -> &'static str { + match self { + PolicyState::Unrestricted => "unrestricted", + PolicyState::Allowed => "allowed", + PolicyState::Blocked => "blocked", + PolicyState::NotApplicable => "not-applicable", + } + } + + /// Whether telemetry collection is administratively permitted. + /// + /// True for every state except [`Blocked`](PolicyState::Blocked) — + /// including [`NotApplicable`](PolicyState::NotApplicable), because off + /// Windows the *consent* gate is what stops collection, and double-denying + /// here would wrongly imply an administrator had acted. + /// + /// This never means "collect": it is one conjunct of + /// [`super::is_enabled`], which also requires user consent. + pub fn allows_collection(&self) -> bool { + !matches!(self, PolicyState::Blocked) + } +} + +/// The `REG_DWORD` value an administrator sets to permit the optional (usage) +/// telemetry category that MXC emits, mirroring the Windows diagnostic-data +/// scale where `3` is Optional/Full. +#[cfg(target_os = "windows")] +const POLICY_VALUE_OPTIONAL: u32 = 3; + +#[cfg(all( + target_os = "windows", + any(test, all(feature = "test-support", debug_assertions)) +))] +static DEBUG_POLICY_KEY_OVERRIDE: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(all( + target_os = "windows", + any(test, all(feature = "test-support", debug_assertions)) +))] +const POLICY_KEY_OVERRIDE_ENV: &str = "MXC_TEST_POLICY_KEY_OVERRIDE"; +#[cfg(all( + target_os = "windows", + any(test, all(feature = "test-support", debug_assertions)) +))] +const POLICY_KEY_OVERRIDE_OWNER_ENV: &str = "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID"; + +/// Returns the current administrative telemetry policy. +/// +/// Fail-closed: a value that is present but not understood resolves to +/// [`PolicyState::Blocked`]. An *absent* policy resolves to +/// [`PolicyState::Unrestricted`] — the unmanaged default, where the user's own +/// consent decision governs. +pub fn get_policy() -> PolicyState { + platform::read() +} + +/// Whether an administrator has denied MXC telemetry on this machine. +/// +/// Convenience over [`get_policy`]; the inverse of +/// [`PolicyState::allows_collection`]. +pub fn is_blocked_by_policy() -> bool { + !get_policy().allows_collection() +} + +// --------------------------------------------------------------------------- +// Windows implementation +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +mod platform { + use super::{PolicyState, POLICY_VALUE_OPTIONAL}; + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + + /// Machine-wide policy key. Under `SOFTWARE\Policies`, so it is writable + /// only by administrators — a standard user cannot forge a permit here. + /// + /// Deliberately *not* under `Policies\Microsoft`, even though MXC is a + /// Microsoft product. Windows forbids ADMX-ingested policies from writing + /// under `System`, `Software\Microsoft`, or `Software\Policies\Microsoft` + /// except for a hardcoded allowlist (Office, Edge, OneDrive, VisualStudio, + /// …) that MXC is not on and cannot join without a Windows servicing + /// change. A key under those prefixes would make `Mxc.admx` un-ingestible + /// by Intune and every other MDM. `SOFTWARE\Policies\` is the shape + /// Microsoft's own ADMX-ingestion documentation uses for third-party apps. + /// + /// This is the administrator-facing contract documented in + /// `docs/telemetry/telemetry-administrative-policy.md`; changing it breaks deployed + /// policy. + const POLICY_SUBKEY: &str = r"SOFTWARE\Policies\Mxc"; + const POLICY_VALUE_NAME: &str = "AllowTelemetry"; + + /// The outcome of looking for the policy value. + /// + /// The distinction between [`Absent`](PolicyValue::Absent) and + /// [`Unreadable`](PolicyValue::Unreadable) is load-bearing: only a + /// genuinely missing policy means "unmanaged". Anything that exists but + /// cannot be understood must deny, or an administrator who misconfigured + /// the value would silently get collection instead of the block they + /// intended. + enum PolicyValue { + /// The key or the value genuinely does not exist — no policy is + /// configured, so the machine is unmanaged for MXC telemetry. + Absent, + /// A `REG_DWORD` was read successfully. + Value(u32), + /// A policy is configured but could not be read: wrong value type + /// (e.g. `REG_SZ`), access denied, or a corrupt/failing registry. + Unreadable, + } + + pub(super) fn read() -> PolicyState { + policy_state_from_value(read_policy_value()) + } + + fn policy_state_from_value(value: PolicyValue) -> PolicyState { + match value { + PolicyValue::Absent => PolicyState::Unrestricted, + PolicyValue::Value(POLICY_VALUE_OPTIONAL) => PolicyState::Allowed, + // Every other value — including `0` (off) and `1` (required-only, + // a category MXC does not emit) — denies. Unrecognized values deny + // too rather than being guessed at: fail closed. + PolicyValue::Value(_) => PolicyState::Blocked, + // Fail closed. An administrator who typed the value in as a string, + // or a machine whose registry we cannot read, must not be treated + // as unmanaged. + PolicyValue::Unreadable => PolicyState::Blocked, + } + } + + #[cfg(test)] + pub(super) fn unreadable_policy_state_for_test() -> PolicyState { + policy_state_from_value(PolicyValue::Unreadable) + } + + /// Reads the policy value, distinguishing "not configured" from + /// "configured but unreadable". + /// + /// `winreg` surfaces registry failures as [`std::io::Error`]; a missing key + /// or value is `ERROR_FILE_NOT_FOUND`, which maps to + /// [`std::io::ErrorKind::NotFound`]. Every other error — notably + /// `ErrorKind::InvalidData` for a non-`REG_DWORD` value, and + /// `PermissionDenied` for an ACL that hides the key — is a policy we cannot + /// evaluate, and therefore a deny. + fn read_policy_value() -> PolicyValue { + let (hive, subkey) = policy_location(); + let key = match RegKey::predef(hive).open_subkey(subkey) { + Ok(key) => key, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return PolicyValue::Absent, + Err(_) => return PolicyValue::Unreadable, + }; + match key.get_value::(POLICY_VALUE_NAME) { + Ok(value) => PolicyValue::Value(value), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => PolicyValue::Absent, + Err(_) => PolicyValue::Unreadable, + } + } + + /// Resolves the hive and subkey to read the policy from. + /// + /// Always the real machine policy location, except under test where + /// [`debug_policy_key_override`] can redirect it under `HKEY_CURRENT_USER` + /// so tests can exercise the real registry code path without requiring + /// administrator rights. The override is compiled out of every shipped + /// binary, so a release build can never be pointed at a user-writable key. + fn policy_location() -> (winreg::HKEY, String) { + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + if let Some(subkey) = debug_policy_key_override() { + return (winreg::enums::HKEY_CURRENT_USER, subkey); + } + (HKEY_LOCAL_MACHINE, POLICY_SUBKEY.to_string()) + } + + /// Test-only hook: redirects the policy read to `HKCU\`. + /// + /// Active under `cfg(test)` and debug `test-support` feature builds. + /// Never present in shipped or release-profile binaries. + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + fn debug_policy_key_override() -> Option { + super::DEBUG_POLICY_KEY_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .or_else(|| { + same_process_policy_key_override( + std::env::var_os(super::POLICY_KEY_OVERRIDE_ENV), + std::env::var_os(super::POLICY_KEY_OVERRIDE_OWNER_ENV), + std::process::id(), + ) + }) + } + + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + /// Environment bridge for language-binding tests that load the debug + /// native library in the same process. The owner PID prevents an ambient + /// override from leaking into child processes. + fn same_process_policy_key_override( + key: Option, + owner: Option, + process_id: u32, + ) -> Option { + let owner = owner?.to_str()?.parse::().ok()?; + if owner != process_id { + return None; + } + let key = key?.into_string().ok()?; + (!key.is_empty()).then_some(key) + } + + /// The production key path is not exercised by redirected policy tests, so + /// this guards the one property that could silently break administrators. + #[cfg(test)] + mod key_path { + use super::POLICY_SUBKEY; + + /// Windows refuses to let an ADMX-ingested policy write under these + /// prefixes (outside a hardcoded allowlist MXC is not on). A key under + /// one of them cannot be deployed by Intune or any other MDM, so moving + /// MXC's key back under `Policies\Microsoft` — a natural-looking + /// "correction" for a Microsoft product — must fail loudly here rather + /// than at an administrator's next ADMX import. + #[test] + fn is_not_under_an_admx_ingestion_blocked_prefix() { + const BLOCKED_PREFIXES: [&str; 3] = [ + r"SYSTEM\", + r"SOFTWARE\MICROSOFT\", + r"SOFTWARE\POLICIES\MICROSOFT\", + ]; + + let upper = POLICY_SUBKEY.to_ascii_uppercase(); + for blocked in BLOCKED_PREFIXES { + assert!( + !upper.starts_with(blocked), + "policy key {POLICY_SUBKEY:?} sits under {blocked:?}, which Windows \ + forbids ADMX-ingested policies from writing; see \ + docs/telemetry/telemetry-administrative-policy.md" + ); + } + } + } + + #[cfg(test)] + mod same_process_override { + use super::same_process_policy_key_override; + + #[test] + fn requires_the_current_process_as_owner() { + assert_eq!( + same_process_policy_key_override( + Some("Software\\MxcTest".into()), + Some("42".into()), + 42, + ), + Some("Software\\MxcTest".to_string()) + ); + assert_eq!( + same_process_policy_key_override( + Some("Software\\MxcTest".into()), + Some("41".into()), + 42, + ), + None + ); + assert_eq!( + same_process_policy_key_override(Some("Software\\MxcTest".into()), None, 42), + None + ); + } + } +} + +// --------------------------------------------------------------------------- +// Non-Windows stub — nothing is collected here, so nothing is restricted. +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "windows"))] +mod platform { + use super::PolicyState; + + pub(super) fn read() -> PolicyState { + PolicyState::NotApplicable + } +} + +// --------------------------------------------------------------------------- +// Test support — policy redirection stays in process and guard construction +// remains serialized to preserve the shared lock order. +// --------------------------------------------------------------------------- + +#[cfg(any(test, all(feature = "test-support", debug_assertions)))] +pub mod test_support { + use std::sync::{Mutex, MutexGuard}; + + /// Serializes guard construction and preserves the lock order shared with + /// the consent test environment. + pub static POLICY_LOCK: Mutex<()> = Mutex::new(()); + + /// Redirects the policy read (Windows only) to a freshly created, unique + /// `HKCU` subkey for the lifetime of the guard, deleting it and restoring + /// the previous override on drop. + /// + /// Using a real registry key rather than a stubbed value means the tests + /// exercise the actual `winreg` read path. `HKCU` needs no elevation. + /// + /// An inert holder on non-Windows, where policy is `NotApplicable` + /// regardless — kept as a real guard type so call sites need no `#[cfg]`. + /// + /// **Within `wxc_common`, never construct this directly alongside the + /// consent guard** — use `crate::telemetry::test_support::TelemetryTestEnv`, + /// which fixes the acquisition order of the two process-global locks. + pub struct PolicyKeyGuard { + _lock: MutexGuard<'static, ()>, + #[cfg(target_os = "windows")] + subkey: String, + #[cfg(target_os = "windows")] + previous_override: Option, + } + + // `Default` is deliberately not implemented: constructing this guard takes a + // process-global lock and mutates the environment, which is not what a + // caller reaching for `Default::default()` expects. + #[allow(clippy::new_without_default)] + impl PolicyKeyGuard { + /// Creates the guard with no policy value set (the unmanaged default). + #[cfg(target_os = "windows")] + pub fn new() -> Self { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + + let lock = POLICY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let subkey = format!( + r"Software\MxcTelemetryPolicyTest\{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER); + // Remove any leftover from a previously crashed run before use. + let _ = hkcu.delete_subkey_all(&subkey); + hkcu.create_subkey(&subkey).expect("create test policy key"); + let previous_override = super::DEBUG_POLICY_KEY_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .replace(subkey.clone()); + + Self { + _lock: lock, + subkey, + previous_override, + } + } + + #[cfg(not(target_os = "windows"))] + pub fn new() -> Self { + let lock = POLICY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + Self { _lock: lock } + } + + /// Writes the `AllowTelemetry` policy value into the redirected key. + #[cfg(target_os = "windows")] + pub fn set_value(&self, value: u32) { + let (key, _) = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER) + .create_subkey(&self.subkey) + .expect("open test policy key"); + key.set_value("AllowTelemetry", &value) + .expect("set test policy value"); + } + + #[cfg(not(target_os = "windows"))] + pub fn set_value(&self, _value: u32) {} + + /// Writes `AllowTelemetry` as a `REG_SZ` instead of a `REG_DWORD`, to + /// exercise the wrong-value-type path an administrator can easily hit + /// by typing the value in by hand. + #[cfg(target_os = "windows")] + pub fn set_string_value(&self, value: &str) { + let (key, _) = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER) + .create_subkey(&self.subkey) + .expect("open test policy key"); + key.set_value("AllowTelemetry", &value.to_string()) + .expect("set test policy string value"); + } + + #[cfg(not(target_os = "windows"))] + pub fn set_string_value(&self, _value: &str) {} + } + + #[cfg(target_os = "windows")] + impl Drop for PolicyKeyGuard { + fn drop(&mut self) { + *super::DEBUG_POLICY_KEY_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) = self.previous_override.take(); + let _ = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER) + .delete_subkey_all(&self.subkey); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_strings_are_stable() { + assert_eq!(PolicyState::Unrestricted.as_str(), "unrestricted"); + assert_eq!(PolicyState::Allowed.as_str(), "allowed"); + assert_eq!(PolicyState::Blocked.as_str(), "blocked"); + assert_eq!(PolicyState::NotApplicable.as_str(), "not-applicable"); + } + + #[test] + fn only_blocked_denies_collection() { + assert!(PolicyState::Unrestricted.allows_collection()); + assert!(PolicyState::Allowed.allows_collection()); + assert!(PolicyState::NotApplicable.allows_collection()); + assert!(!PolicyState::Blocked.allows_collection()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn policy_is_not_applicable_off_windows() { + assert_eq!(get_policy(), PolicyState::NotApplicable); + assert!(!is_blocked_by_policy()); + } +} + +#[cfg(all(test, target_os = "windows"))] +mod windows_tests { + use super::test_support::PolicyKeyGuard; + use super::*; + + #[test] + fn absent_policy_is_unrestricted() { + let _guard = PolicyKeyGuard::new(); + assert_eq!(get_policy(), PolicyState::Unrestricted); + assert!(!is_blocked_by_policy()); + } + + #[test] + fn optional_level_is_allowed() { + let guard = PolicyKeyGuard::new(); + guard.set_value(3); + assert_eq!(get_policy(), PolicyState::Allowed); + assert!(!is_blocked_by_policy()); + } + + #[test] + fn off_level_is_blocked() { + let guard = PolicyKeyGuard::new(); + guard.set_value(0); + assert_eq!(get_policy(), PolicyState::Blocked); + assert!(is_blocked_by_policy()); + } + + /// Level 1 is "required diagnostic data only". MXC emits + /// product-and-service-usage data, which is *optional*, so a + /// required-only machine must collect nothing. + #[test] + fn required_only_level_is_blocked() { + let guard = PolicyKeyGuard::new(); + guard.set_value(1); + assert_eq!(get_policy(), PolicyState::Blocked); + } + + /// Fail closed: a value outside the documented scale is a + /// misconfiguration, and MXC must not read it as permission. + #[test] + fn unrecognized_value_is_blocked() { + let guard = PolicyKeyGuard::new(); + for value in [2u32, 4, 99, u32::MAX] { + guard.set_value(value); + assert_eq!( + get_policy(), + PolicyState::Blocked, + "value {value} must fail closed" + ); + } + } + + /// An administrator who sets `AllowTelemetry` as a string rather than a + /// `REG_DWORD` has still expressed an intent to manage this machine. The + /// value cannot be evaluated, so it must deny — never be mistaken for an + /// unmanaged machine, which would let a prior consent grant re-enable + /// collection the administrator meant to stop. + #[test] + fn wrong_value_type_is_blocked_not_unrestricted() { + let guard = PolicyKeyGuard::new(); + for value in ["0", "3", "", "not-a-number"] { + guard.set_string_value(value); + assert_eq!( + get_policy(), + PolicyState::Blocked, + "REG_SZ {value:?} must fail closed, not read as unmanaged" + ); + assert!(is_blocked_by_policy()); + } + } + + /// The precise regression this guards: a wrong-typed value must not + /// resolve to the same state as no policy at all. + #[test] + fn wrong_value_type_is_distinguishable_from_absent() { + let guard = PolicyKeyGuard::new(); + assert_eq!(get_policy(), PolicyState::Unrestricted); + guard.set_string_value("0"); + assert_ne!( + get_policy(), + PolicyState::Unrestricted, + "a malformed policy must not be indistinguishable from an unmanaged machine" + ); + } + + #[test] + fn unreadable_policy_read_fails_closed() { + let state = crate::telemetry::policy::platform::unreadable_policy_state_for_test(); + assert_eq!(state, PolicyState::Blocked); + assert!(!state.allows_collection()); + } +} diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index a59dfefad..1e2218926 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -60,7 +60,7 @@ mod provider { /// concurrent `init`/`shutdown` race from leaving the wrapper flag and the /// provider's real state out of sync (e.g. flag set to registered after a /// `register()` that actually failed, or a double register/unregister). - static REGISTERED: Mutex = Mutex::new(false); + static REGISTERED: Mutex = Mutex::new(0); /// Lock-free "is the provider currently registered" flag. /// @@ -100,9 +100,10 @@ mod provider { }); let mut registered = REGISTERED.lock().unwrap_or_else(|e| e.into_inner()); - if *registered { + if *registered > 0 { // Already registered — the tracelogging crate panics on double - // register, so we must not call `register()` again. + // register, so retain a reference instead. + *registered += 1; return true; } @@ -110,12 +111,12 @@ mod provider { // executable (not a DLL), so unload ordering is not a concern. let status = unsafe { MXC_PROVIDER.register() }; if status != 0 { - // Registration failed; leave `*registered` false so the wrapper + // Registration failed; leave the reference count at zero so the wrapper // state matches reality and a later attempt can retry. return false; } - *registered = true; + *registered = 1; ACTIVE.store(true, Ordering::Release); true } @@ -123,9 +124,11 @@ mod provider { /// Unregister the ETW provider. pub fn shutdown() { let mut registered = REGISTERED.lock().unwrap_or_else(|e| e.into_inner()); - if *registered { + if *registered > 1 { + *registered -= 1; + } else if *registered == 1 { MXC_PROVIDER.unregister(); - *registered = false; + *registered = 0; ACTIVE.store(false, Ordering::Release); } } @@ -144,8 +147,10 @@ mod provider { /// the shared base prefix). Carries no `sandbox_id` / UPN or other caller /// identity — privacy-safe by construction. Empty for one-shot executions /// (which are a single process already correlated by `AppSessionGuid`). + #[allow(clippy::too_many_arguments)] pub fn log_execution( backend: &str, + sandbox_kind: &str, exit_code: i32, outcome: &str, duration_ms: u64, @@ -179,6 +184,7 @@ mod provider { bool8("UTCReplace_AppSessionGuid", &true), }), str8("mxc.backend", backend), + str8("mxc.sandbox_kind", sandbox_kind), i32("mxc.exit_code", &exit_code), str8("mxc.outcome", outcome), u64("mxc.duration_ms", &duration_ms), @@ -209,6 +215,7 @@ mod provider { /// under `__TlgCV__` (see [`log_execution`]); empty for one-shot executions. pub fn log_error( backend: &str, + sandbox_kind: &str, error_type: &str, exit_code: i32, phase: &str, @@ -240,6 +247,7 @@ mod provider { bool8("UTCReplace_AppSessionGuid", &true), }), str8("mxc.backend", backend), + str8("mxc.sandbox_kind", sandbox_kind), str8("mxc.error_type", error_type), i32("mxc.exit_code", &exit_code), // State-aware lifecycle phase (provision|start|exec|stop| @@ -267,8 +275,10 @@ mod provider { false } + #[allow(clippy::too_many_arguments)] pub fn log_execution( _backend: &str, + _sandbox_kind: &str, _exit_code: i32, _outcome: &str, _duration_ms: u64, @@ -280,6 +290,7 @@ mod provider { pub fn log_error( _backend: &str, + _sandbox_kind: &str, _error_type: &str, _exit_code: i32, _phase: &str, @@ -303,6 +314,35 @@ mod tests { /// `init`/`shutdown` must not run concurrently. static TEST_LOCK: Mutex<()> = Mutex::new(()); + #[test] + fn event_names_preserve_collector_contract() { + let source = include_str!("lib.rs"); + let provider_source = source + .split("// Tests") + .next() + .expect("provider source precedes tests"); + assert!( + provider_source.contains("\"MXC.Execution\","), + "execution event identity changed" + ); + assert!( + provider_source.contains("\"MXC.Error\","), + "error event identity changed" + ); + let error_section = provider_source + .split("\"MXC.Error\",") + .nth(1) + .unwrap_or_default(); + assert!( + error_section.contains("str8(\"mxc.backend\", backend)"), + "MXC.Error must emit mxc.backend" + ); + assert!( + error_section.contains("str8(\"mxc.sandbox_kind\", sandbox_kind)"), + "MXC.Error must emit mxc.sandbox_kind" + ); + } + #[test] fn init_shutdown_roundtrip() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -318,9 +358,20 @@ mod tests { #[test] fn double_init_is_safe() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let _ = init("0.0.0-test", "dev"); - let _ = init("0.0.0-test", "dev"); + let first = init("0.0.0-test", "dev"); + let second = init("0.0.0-test", "dev"); + assert_eq!(second, first); + shutdown(); + assert_eq!( + is_active(), + first, + "one retained registration must remain active only when init succeeded" + ); shutdown(); + assert!( + !is_active(), + "the final release must leave the provider inactive" + ); } #[test] @@ -345,6 +396,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _ = init("0.0.0-test", "dev"); log_execution( + "test_backend", "test_backend", 0, "success", @@ -361,6 +413,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _ = init("0.0.0-test", "dev"); log_error( + "test_backend", "test_backend", "config_error", 1, @@ -373,8 +426,17 @@ mod tests { #[test] fn log_without_init() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - log_execution("test_backend", 0, "success", 50, "none", "", ""); - log_error("test_backend", "unknown", 1, "", ""); + log_execution( + "test_backend", + "test_backend", + 0, + "success", + 50, + "none", + "", + "", + ); + log_error("test_backend", "test_backend", "unknown", 1, "", ""); } #[test] @@ -383,6 +445,7 @@ mod tests { let _ = init("0.0.0-test", "dev"); shutdown(); log_execution( + "test_backend", "test_backend", 1, "failure", @@ -391,17 +454,63 @@ mod tests { "exec", "iso:wxc-abcd", ); - log_error("test_backend", "process_error", 1, "exec", "iso:wxc-abcd"); + log_error( + "test_backend", + "test_backend", + "process_error", + 1, + "exec", + "iso:wxc-abcd", + ); } #[test] fn handles_empty_strings() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _ = init("", ""); - log_execution("", 0, "", 0, "", "", ""); - log_error("", "", 0, "", ""); + log_execution("", "", 0, "", 0, "", "", ""); + log_error("", "", "", 0, "", ""); shutdown(); } + + #[test] + fn concurrent_init_shutdown_is_safe() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let results = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + let active = init("0.0.0-concurrent-test", "dev"); + if active { + log_execution( + "test_backend", + "test_backend", + 0, + "success", + 0, + "", + "", + "", + ); + shutdown(); + } + active + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>() + }); + + if cfg!(target_os = "windows") { + assert!(results.iter().all(|active| *active)); + assert!(!is_active()); + } else { + assert!(results.iter().all(|active| !*active)); + } + } } // --------------------------------------------------------------------------- diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index b27603fa4..6c7642559 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -111,26 +111,6 @@ if (Test-Path $etlDir) { Remove-Item -Recurse -Force $etlDir } New-Item -ItemType Directory -Path $etlDir -Force | Out-Null $etlFile = Join-Path $etlDir 'mxc_trace.etl' -# --------------------------------------------------------------------------- -# Setup: isolated consent seed for deterministic telemetry gating -# --------------------------------------------------------------------------- -$localAppDataOverride = Join-Path $etlDir 'localappdata' -$consentDir = Join-Path $localAppDataOverride 'mxc' -New-Item -ItemType Directory -Path $consentDir -Force | Out-Null -$consentFile = Join-Path $consentDir 'telemetry-consent.json' - -# Seed an explicit granted consent record for this test run so event capture -# does not depend on host-global consent state. -$consentRecord = @{ - version = 2 - consent = 'granted' - promptResourceVersion = '1.0' - promptLocale = 'en-US' - source = 'run_telemetry_etw_smoke_test.ps1' - updatedAtUtc = [DateTime]::UtcNow.ToString('o') -} -$consentRecord | ConvertTo-Json -Depth 4 | Set-Content -Path $consentFile -Encoding utf8 - # --------------------------------------------------------------------------- # Step 1: Start ETW trace session # --------------------------------------------------------------------------- @@ -152,37 +132,21 @@ Write-Host "ETW session started, writing to $etlFile" Write-Host "`n--- Running wxc-exec with telemetry ---" -ForegroundColor Yellow try { - $previousLocalAppData = $env:LOCALAPPDATA - $previousTestOverride = $env:MXC_TEST_LOCALAPPDATA_OVERRIDE - - $env:LOCALAPPDATA = $localAppDataOverride - $env:MXC_TEST_LOCALAPPDATA_OVERRIDE = $localAppDataOverride - try { - # Run with --experimental to enable the telemetry section in this branch. - # The provider is registered during init (before execution); completion - # telemetry events are emitted after dispatch returns. - $executionTimeoutSeconds = 60 - $proc = Start-Process -FilePath $wxcExe ` - -ArgumentList "--debug", "--experimental", $configFile ` - -PassThru -NoNewWindow - if (-not $proc.WaitForExit($executionTimeoutSeconds * 1000)) { - Stop-Process -Id $proc.Id -Force - $proc.WaitForExit() - throw "wxc-exec timed out after $executionTimeoutSeconds seconds" - } - Write-Host "wxc-exec exited with code $($proc.ExitCode)" - } finally { - if ($null -eq $previousLocalAppData) { - Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue - } else { - $env:LOCALAPPDATA = $previousLocalAppData - } - if ($null -eq $previousTestOverride) { - Remove-Item Env:MXC_TEST_LOCALAPPDATA_OVERRIDE -ErrorAction SilentlyContinue - } else { - $env:MXC_TEST_LOCALAPPDATA_OVERRIDE = $previousTestOverride - } + # Run with --experimental to enable the telemetry section. The provider is + # registered during init (before execution); the MXC.Execution / MXC.Error + # events are emitted on completion, after the runner returns. The sandbox + # itself may fail (e.g. AppContainer prerequisites), but completion + # telemetry still fires for the failure, so events should be captured. + $executionTimeoutSeconds = 60 + $proc = Start-Process -FilePath $wxcExe ` + -ArgumentList "--debug", "--experimental", $configFile ` + -PassThru -NoNewWindow + if (-not $proc.WaitForExit($executionTimeoutSeconds * 1000)) { + Stop-Process -Id $proc.Id -Force + $proc.WaitForExit() + throw "wxc-exec timed out after $executionTimeoutSeconds seconds" } + Write-Host "wxc-exec exited with code $($proc.ExitCode)" } catch { Write-Host "wxc-exec failed to run: $_" -ForegroundColor Yellow # Continue - even a crash after init may have emitted events. @@ -229,7 +193,7 @@ $eventCount = ([regex]::Matches($xmlContent, '') $matchingEvent = $null From dc13cc7d378e8b2b14435d5146bbb2373b960cb2 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 11:11:25 -0700 Subject: [PATCH 03/47] Reject stale consent presenter decisions Track a durable consent-record revision so same-state concurrent writes cannot be overwritten by a stale presenter response. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- src/core/wxc_common/src/telemetry/consent.rs | 97 ++++++++++++++++++-- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 773ab6088..579c0957e 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -220,6 +220,10 @@ struct ConsentRecord { /// this field just read back as `0` rather than failing to parse. #[serde(rename = "updatedAtEpoch", default)] updated_at_epoch: u64, + /// Monotonic record identity used to reject a stale presenter decision + /// even when a concurrent writer preserves the effective consent state. + #[serde(default)] + revision: u64, } /// Returns the current, persisted telemetry consent state. @@ -239,7 +243,7 @@ pub fn get_status() -> ConsentStatus { #[cfg(test)] pub(crate) fn set_consent(granted: bool, source: &str) -> Result<(), String> { - platform::write(granted, source) + platform::with_store_lock(|| platform::write(granted, source)) } /// Request telemetry consent through a host-owned presenter. @@ -253,17 +257,19 @@ pub fn request_consent( where F: FnOnce(&ConsentPrompt) -> Result, { - let (prompt, policy, status) = match consent_preflight(locale) { + let (prompt, policy, status, revision) = match consent_preflight(locale) { ConsentPreflight::Complete(outcome) => return Ok(outcome), + ConsentPreflight::Failed(error) => return Err(ConsentActionError::Persist(error)), ConsentPreflight::Present { prompt, policy, status, - } => (prompt, policy, status), + revision, + } => (prompt, policy, status, revision), }; let decision = presenter(prompt).map_err(ConsentActionError::Presenter)?; - persist_presented_decision(decision, prompt, policy, status) + persist_presented_decision(decision, prompt, policy, status, revision) } /// Asynchronous counterpart to [`request_consent`]. @@ -282,16 +288,18 @@ where let locale = locale.map(str::to_owned); match BlockingTask::spawn(move || consent_preflight(locale.as_deref())).await { ConsentPreflight::Complete(outcome) => Ok(outcome), + ConsentPreflight::Failed(error) => Err(ConsentActionError::Persist(error)), ConsentPreflight::Present { prompt, policy, status, + revision, } => { let decision = presenter(prompt) .await .map_err(ConsentActionError::Presenter)?; BlockingTask::spawn(move || { - persist_presented_decision(decision, prompt, policy, status) + persist_presented_decision(decision, prompt, policy, status, revision) }) .await } @@ -377,16 +385,25 @@ impl std::future::Future for BlockingTask { enum ConsentPreflight { Complete(ConsentActionOutcome), + Failed(String), Present { prompt: &'static ConsentPrompt, policy: super::policy::PolicyState, status: ConsentStatus, + revision: Option, }, } fn consent_preflight(locale: Option<&str>) -> ConsentPreflight { let policy = super::policy::get_policy(); - let status = get_status(); + let (status, revision) = match platform::with_store_lock(|| { + let status = platform::read_status_unlocked(); + let revision = platform::read_revision_unlocked()?; + Ok((status, revision)) + }) { + Ok(snapshot) => snapshot, + Err(error) => return ConsentPreflight::Failed(error), + }; if status.effective_state == ConsentState::NotApplicable { return ConsentPreflight::Complete(ConsentActionOutcome { @@ -415,6 +432,7 @@ fn consent_preflight(locale: Option<&str>) -> ConsentPreflight { prompt, policy, status, + revision, } } @@ -423,10 +441,12 @@ fn persist_presented_decision( prompt: &ConsentPrompt, policy: super::policy::PolicyState, status: ConsentStatus, + revision: Option, ) -> Result { platform::with_store_lock(|| { let current_policy = super::policy::get_policy(); let current_status = platform::read_status_unlocked(); + let current_revision = platform::read_revision_unlocked()?; if current_status.effective_state == ConsentState::NotApplicable { return Ok(ConsentActionOutcome { result: ConsentActionResult::NotApplicable, @@ -454,7 +474,8 @@ fn persist_presented_decision( && current_status.effective_state == ConsentState::Granted && status.effective_state != ConsentState::Granted; if current_policy != policy - || (current_status.effective_state != status.effective_state + || ((current_status.effective_state != status.effective_state + || current_revision != revision) && !is_concurrent_grant_being_withdrawn) { return Err( @@ -558,6 +579,10 @@ mod platform { const STORE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(1); #[cfg(not(test))] const STORE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(20); + #[cfg(test)] + const STORE_LOCK_RETRY_ATTEMPTS: u32 = 1_000; + #[cfg(not(test))] + const STORE_LOCK_RETRY_ATTEMPTS: u32 = IO_RETRY_ATTEMPTS; /// `ERROR_SHARING_VIOLATION` — another handle holds the file with a /// conflicting share mode (AV scanner, indexer, racing sibling process). @@ -721,7 +746,7 @@ mod platform { match options.open(&lock_path) { Ok(file) => break file, Err(error) - if attempts + 1 < IO_RETRY_ATTEMPTS + if attempts + 1 < STORE_LOCK_RETRY_ATTEMPTS && (error.kind() == std::io::ErrorKind::AlreadyExists || is_transient_io_error(&error)) => { @@ -873,6 +898,13 @@ mod platform { read_status_unlocked_inner(withdrawal_marker_state, recover_stale_withdrawal_marker) } + pub(super) fn read_revision_unlocked() -> Result, String> { + let Some(path) = consent_file_path() else { + return Ok(None); + }; + Ok(read_record(&path)?.map(|record| record.revision)) + } + fn read_status_unlocked_inner( marker_check: impl FnOnce(&Path) -> std::io::Result, recover_stale: impl FnOnce(&Path, &Path) -> Result<(), String>, @@ -992,6 +1024,19 @@ mod platform { .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) } + fn read_record(path: &Path) -> Result, String> { + let data = match with_io_retry(|| read_bounded(path)) { + Ok(data) => data, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read telemetry consent record revision: {error}" + )) + } + }; + Ok(serde_json::from_str(&data).ok()) + } + fn recover_stale_withdrawal_marker(_path: &Path, marker: &Path) -> Result<(), String> { match withdrawal_marker_state(marker) .map_err(|e| format!("failed to inspect stale withdrawal marker: {e}"))? @@ -1067,6 +1112,10 @@ mod platform { .parent() .ok_or_else(|| "invalid telemetry consent path".to_string())?; fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + let revision = read_record(&path)? + .map_or(0, |record| record.revision) + .checked_add(1) + .ok_or_else(|| "telemetry consent record revision is exhausted".to_string())?; let record = ConsentRecord { schema_version: CONSENT_SCHEMA_VERSION, @@ -1076,6 +1125,7 @@ mod platform { prompt_resource_version, prompt_locale: prompt_locale.to_string(), updated_at_epoch: now_epoch_seconds(), + revision, }; let json = serde_json::to_string_pretty(&record) .map_err(|e| format!("failed to serialize telemetry consent record: {e}"))?; @@ -1151,6 +1201,10 @@ mod platform { read_status() } + pub(super) fn read_revision_unlocked() -> Result, String> { + Ok(None) + } + pub(super) fn write(_granted: bool, _source: &str) -> Result<(), String> { Err("telemetry is Windows-only; consent is not applicable on this platform".to_string()) } @@ -1622,6 +1676,33 @@ mod tests { assert_eq!(get_consent(), ConsentState::Denied); } + #[test] + fn same_state_change_while_presenter_is_open_rejects_stale_decision() { + let tmp = tempfile::tempdir().unwrap(); + let env = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); + set_consent(false, "initial-denial").unwrap(); + + let error = request_consent(Some("en-US"), |_| { + set_consent(false, "concurrent-withdrawal").unwrap(); + Ok(ConsentDecision::Yes) + }) + .expect_err("stale presenter decision must not overwrite a newer denial"); + + assert_eq!( + error, + ConsentActionError::Persist( + "consent state changed while the presenter was open; no decision was written" + .to_string() + ) + ); + assert_eq!(get_consent(), ConsentState::Denied); + let record = + std::fs::read_to_string(tmp.path().join("mxc").join("telemetry-consent.json")) + .unwrap(); + assert!(record.contains(r#""source": "concurrent-withdrawal""#)); + } + #[test] fn explicit_denial_withdraws_a_concurrent_grant() { let tmp = tempfile::tempdir().unwrap(); From f375e703fcabbcabf464845cf1d2775365737df5 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 17:44:00 -0700 Subject: [PATCH 04/47] Tag WINEXT telemetry with MXC Part A Emit privacy product 11 and Client Diagnostic Data category 1 on every MXC event while retaining the approved Product and Service Usage tag. Document the optional non-CORE classification and lock the fields with contract tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- docs/telemetry/telemetry.md | 24 ++++++++++++++++ src/mxc_telemetry/src/lib.rs | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index ed8e9b630..edd488a40 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -41,12 +41,36 @@ Rust constants and `write_event!` struct fields. | **Sampling keywords** | `MICROSOFT_KEYWORD_MEASURES` named constant | Raw `u64` in `keyword(...)` | `const MICROSOFT_KEYWORD_MEASURES: u64 = 0x0000_4000_0000_0000` | | **Common event fields** | `_GENERIC_PARTB_FIELDS_ENABLED` pattern | `struct("Name", { ... })` in `write_event!` | `struct("COMMON_MXC_PARAMS", { Version, Channel, IsDebugging, UTCReplace_AppSessionGuid })` | | **Provider lifecycle** | `IMPLEMENT_TRACELOGGING_CLASS` singleton | `define_provider!` static + `register()`/`unregister()` | `OnceLock` for version/channel, manual lifecycle | +| **Privacy Product** | Common Schema Part A product | `u16(...)` field | `PartA_PrivacyProduct = 11` (`MXC (Microsoft Execution Containers)`) on all events | +| **Privacy Data Category** | Common Schema Part A data category | `u16(...)` field | `PartA_PrivacyDataCategory = 1` (`Client Diagnostic Data`) on all events | | **Privacy Data Tags** | `TelemetryPrivacyDataTag(PDT_*)` | `u64("PartA_PrivTags", &val)` field | `PDT_PRODUCT_AND_SERVICE_USAGE` on all events | | **Activity tracking** | `DEFINE_TELEMETRY_ACTIVITY` | Manual `Opcode` | Not needed for current events | The remaining gap (activity tracking) is not needed for current events. If needed later, it can be added incrementally. +## Common Event Fields (Part A) + +Every MXC telemetry event carries the WinExt-required Common Schema privacy +metadata: + +| Field | Type | Value | +|-------|------|-------| +| `PartA_PrivacyProduct` | uint16 | `11` — `MXC (Microsoft Execution Containers)` | +| `PartA_PrivacyDataCategory` | uint16 | `1` — `Client Diagnostic Data` | +| `PartA_PrivTags` | uint64 | `Product and Service Usage Data` | + +Product `11` is privacy-approved and registered in the WinExt product list. +The OS/Common Schema catalog update is tracked by UTC bug `63666074`. MXC's +events use the sampled `MICROSOFT_KEYWORD_MEASURES` keyword and explicit +product consent, so they are optional diagnostic data rather than required +(CORE) or critical-optional events. If the sampling level, event purpose, or +collected fields change, the privacy tag and approval classification must be +re-evaluated. + +The WinExt provider-group identifier remains a secure build input. It must not +be copied into source, tests, documentation, or commit messages. + ## Common Event Fields (Part C) Every MXC telemetry event includes a `COMMON_MXC_PARAMS` struct grouping diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index 1e2218926..95fe28f2d 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -44,6 +44,13 @@ mod provider { /// Applied via a `PartA_PrivTags` field per the `TelemetryPrivacyDataTag` pattern. pub(crate) const PDT_PRODUCT_AND_SERVICE_USAGE: u64 = 0x0000_0000_0200_0000; + /// Privacy-approved WinExt product identifier for + /// MXC (Microsoft Execution Containers). + pub(crate) const PRIVACY_PRODUCT_MXC: u16 = 11; + + /// WinExt permits only Client Diagnostic Data. + pub(crate) const PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA: u16 = 1; + /// Cached provider state, set once at init and read on every event. struct ProviderState { version: String, @@ -176,6 +183,11 @@ mod provider { // MXC.Error (Warning) and any future provider malfunction. level(Informational), keyword(MICROSOFT_KEYWORD_MEASURES), + u16("PartA_PrivacyProduct", &PRIVACY_PRODUCT_MXC), + u16( + "PartA_PrivacyDataCategory", + &PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA, + ), u64("PartA_PrivTags", &PDT_PRODUCT_AND_SERVICE_USAGE), struct("COMMON_MXC_PARAMS", { str8("Version", &state.version), @@ -239,6 +251,11 @@ mod provider { // reserved for genuine provider/telemetry malfunctions. level(Warning), keyword(MICROSOFT_KEYWORD_MEASURES), + u16("PartA_PrivacyProduct", &PRIVACY_PRODUCT_MXC), + u16( + "PartA_PrivacyDataCategory", + &PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA, + ), u64("PartA_PrivTags", &PDT_PRODUCT_AND_SERVICE_USAGE), struct("COMMON_MXC_PARAMS", { str8("Version", &state.version), @@ -343,6 +360,43 @@ mod tests { ); } + #[test] + fn winext_part_a_fields_preserve_privacy_contract() { + let source = include_str!("lib.rs"); + let provider_source = source + .split("// Tests") + .next() + .expect("provider source precedes tests"); + + assert!(provider_source.contains("const PRIVACY_PRODUCT_MXC: u16 = 11;")); + assert!(provider_source + .contains("const PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA: u16 = 1;")); + assert_eq!( + provider_source + .matches("u16(\"PartA_PrivacyProduct\", &PRIVACY_PRODUCT_MXC)") + .count(), + 2, + "every MXC event must carry privacy product 11" + ); + assert_eq!( + provider_source + .matches( + "\"PartA_PrivacyDataCategory\",\n \ + &PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA," + ) + .count(), + 2, + "every MXC event must carry Client Diagnostic Data category 1" + ); + assert_eq!( + provider_source + .matches("u64(\"PartA_PrivTags\", &PDT_PRODUCT_AND_SERVICE_USAGE)") + .count(), + 2, + "every MXC event must retain its approved privacy tag" + ); + } + #[test] fn init_shutdown_roundtrip() { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); From 257c30623183f74e00f3c82f0b1e32c1e63f2bd5 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 18:10:02 -0700 Subject: [PATCH 05/47] Make WINEXT contract test format-independent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- src/mxc_telemetry/src/lib.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index 95fe28f2d..5caf02d53 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -380,13 +380,17 @@ mod tests { ); assert_eq!( provider_source - .matches( - "\"PartA_PrivacyDataCategory\",\n \ - &PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA," - ) + .matches("\"PartA_PrivacyDataCategory\"") .count(), 2, - "every MXC event must carry Client Diagnostic Data category 1" + "every MXC event must carry a privacy data category" + ); + assert_eq!( + provider_source + .matches("&PRIVACY_DATA_CATEGORY_CLIENT_DIAGNOSTIC_DATA") + .count(), + 2, + "every MXC event must use Client Diagnostic Data category 1" ); assert_eq!( provider_source From b58d4f81faf539c7c1d7183fdd284d1f63aaa8d8 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 20:25:16 -0700 Subject: [PATCH 06/47] Request delete access for consent lock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- src/core/wxc_common/src/telemetry/consent.rs | 23 +++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 579c0957e..8e0241689 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -727,6 +727,8 @@ mod platform { operation: impl FnOnce() -> Result, ) -> Result { use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Foundation::GENERIC_WRITE; + use windows::Win32::Storage::FileSystem::{DELETE, FILE_FLAG_DELETE_ON_CLOSE}; let path = consent_file_path().ok_or_else(|| { "could not resolve %LocalAppData%; cannot lock telemetry consent".to_string() @@ -742,7 +744,8 @@ mod platform { options .write(true) .create_new(true) - .custom_flags(windows::Win32::Storage::FileSystem::FILE_FLAG_DELETE_ON_CLOSE.0); + .access_mode(GENERIC_WRITE.0 | DELETE.0) + .custom_flags(FILE_FLAG_DELETE_ON_CLOSE.0); match options.open(&lock_path) { Ok(file) => break file, Err(error) @@ -1507,6 +1510,24 @@ mod tests { assert_eq!(get_consent(), ConsentState::Undetermined); } + #[test] + fn store_lock_is_deleted_when_released() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = LocalAppDataGuard::set(tmp.path()); + let lock_path = tmp.path().join("mxc").join("telemetry-consent.lock"); + + platform::with_store_lock(|| { + assert!(lock_path.exists(), "lock file must exist while held"); + Ok(()) + }) + .expect("acquire store lock"); + + assert!( + !lock_path.exists(), + "delete-on-close must remove the released lock" + ); + } + #[test] fn grant_then_read_round_trips() { let tmp = tempfile::tempdir().unwrap(); From e711eeb5377e4fae2481f901552108f277311097 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Tue, 18 Aug 2026 07:49:55 -0700 Subject: [PATCH 07/47] Integrate telemetry into MXC runtime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- .github/workflows/Build.Windows.Job.yml | 21 + docs/telemetry/telemetry.md | 14 +- schemas/dev/mxc-config.schema.0.8.0-dev.json | 34 +- .../dev/mxc-telemetry-consent.schema.1.json | 79 ++ scripts/versioning/check-schema-codegen.js | 94 +- sdk/node/src/generated/wire.ts | 17 +- sdk/node/src/sandbox.ts | 11 - sdk/node/src/state-aware-helper.ts | 9 +- sdk/node/src/state-aware-types.ts | 10 - sdk/node/src/state-aware.ts | 7 - sdk/node/src/types.ts | 8 +- sdk/node/tests/unit/state-aware.test.ts | 64 -- sdk/node/tests/unit/wire-conformance.test.ts | 9 +- src/Cargo.lock | 3 + src/core/lxc/src/main.rs | 182 +++- src/core/mxc_darwin/src/main.rs | 165 +++- src/core/mxc_engine/Cargo.toml | 4 + src/core/mxc_engine/src/lib.rs | 446 ++++++++- src/core/mxc_engine/src/policy.rs | 48 +- src/core/mxc_engine/src/state_aware.rs | 490 +++++++++- src/core/wxc/Cargo.toml | 1 + src/core/wxc/src/main.rs | 510 +++++----- src/core/wxc_common/Cargo.toml | 10 +- .../src/config_contract_adapters/v0_6.rs | 5 +- .../src/config_contract_adapters/v0_7.rs | 6 +- src/core/wxc_common/src/config_parser.rs | 543 +++++++++-- src/core/wxc_common/src/models.rs | 14 +- .../wxc_common/src/state_aware_dispatch.rs | 8 - .../wxc_common/src/state_aware_request.rs | 10 +- src/core/wxc_common/src/telemetry/consent.rs | 85 +- .../wxc_common/src/telemetry/consent_cli.rs | 844 ++++++++++++++++ .../src/telemetry/correlation_state.rs | 784 +++++++++++++++ .../src/telemetry/correlation_vector.rs | 146 +-- src/core/wxc_common/src/telemetry/events.rs | 20 +- src/core/wxc_common/src/telemetry/mod.rs | 922 ++++++++++++++---- src/core/wxc_common/src/telemetry/policy.rs | 87 +- src/core/wxc_common/src/wire.rs | 243 ++++- src/mxc_telemetry/provider_codegen.rs | 6 +- src/mxc_telemetry/src/lib.rs | 23 +- .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 333 +++++++ .../wxc_e2e_tests/tests/e2e_windows.rs | 2 +- src/tools/mxc_schema_gen/src/main.rs | 57 +- tests/examples/28_telemetry_enabled.json | 6 +- .../run_telemetry_consent_smoke_test.ps1 | 178 ++++ .../scripts/run_telemetry_etw_smoke_test.ps1 | 372 ++++--- 45 files changed, 5735 insertions(+), 1195 deletions(-) create mode 100644 schemas/dev/mxc-telemetry-consent.schema.1.json create mode 100644 src/core/wxc_common/src/telemetry/consent_cli.rs create mode 100644 src/core/wxc_common/src/telemetry/correlation_state.rs create mode 100644 src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs create mode 100644 tests/scripts/run_telemetry_consent_smoke_test.ps1 diff --git a/.github/workflows/Build.Windows.Job.yml b/.github/workflows/Build.Windows.Job.yml index 03b9579a6..f9c228195 100644 --- a/.github/workflows/Build.Windows.Job.yml +++ b/.github/workflows/Build.Windows.Job.yml @@ -66,11 +66,32 @@ jobs: --no-default-features --features "${{ matrix.features }}" + - name: Test telemetry consent and policy in release mode + run: | + cargo test --locked --release --target ${{ matrix.target }} -p wxc_common telemetry::consent + cargo test --locked --release --target ${{ matrix.target }} -p wxc_common telemetry::policy + - name: Test run: cargo test --locked --release --target ${{ matrix.target }} --no-default-features --features "${{ matrix.features }}" + - name: Build debug executor for isolated telemetry smoke tests + if: matrix.arch == 'x64' + run: cargo build --locked -p wxc --features test-support + + - name: Run isolated telemetry consent smoke test + if: matrix.arch == 'x64' + working-directory: ${{ github.workspace }} + shell: pwsh + run: tests\scripts\run_telemetry_consent_smoke_test.ps1 -BinDir src\target\debug + + - name: Run isolated telemetry ETW smoke test + if: matrix.arch == 'x64' + working-directory: ${{ github.workspace }} + shell: pwsh + run: tests\scripts\run_telemetry_etw_smoke_test.ps1 -BinDir src\target\debug + - name: Upload binaries uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index edd488a40..efcdbe19d 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -24,14 +24,11 @@ FFI is required. └──────────────────────────────────────────────────────┘ ``` -## Why the Rust `tracelogging` Crate (Not WIL C++ Shim) +## TraceLogging implementation -An earlier design used a WIL C++ shim compiled via the `cc` crate. PR review -feedback correctly noted that the WIL dependency added C++ compilation, NuGet -download, FFI unsafety, and blocked non-Windows contributors from building the -crate. The Rust `tracelogging` crate provides the core ETW primitives needed, -and the small set of WIL features MXC actually uses can be replicated with -Rust constants and `write_event!` struct fields. +The Rust `tracelogging` crate provides MXC's required ETW primitives without a +C++ build, NuGet dependency, or FFI boundary. MXC supplies the remaining +provider metadata through Rust constants and `write_event!` struct fields. ### Feature comparison @@ -120,7 +117,6 @@ Emitted on execution errors. | Field | Type | Description | |-------|------|-------------| -| `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) | | `mxc.backend` | string | Containment backend name | | `mxc.error_type` | string | Error category (`config_error`, `policy_error`, `process_error`, `timeout`, `init_error`, `internal_error`, `cancelled`, `unknown`) | | `mxc.exit_code` | int32 | Process exit code | @@ -205,6 +201,8 @@ MXC's optional diagnostic events contain: - State-aware lifecycle phase - `UTCReplace_AppSessionGuid`, which asks the telemetry pipeline to supply a random per-session app identifier +- An MXC-internal lifecycle correlation identifier. SDK callers neither supply + nor receive it. MXC does not emit commands, file paths, credentials, customer content, or free-form error text. The consent notice's phrase “other customer content” diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index d4d99bb99..af81c268d 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -212,17 +212,6 @@ ], "description": "Seatbelt backend config (pre-promotion alias)." }, - "telemetry": { - "anyOf": [ - { - "$ref": "#/definitions/Telemetry" - }, - { - "type": "null" - } - ], - "description": "Telemetry configuration." - }, "test": { "anyOf": [ { @@ -725,10 +714,11 @@ "type": "object" }, "Telemetry": { - "description": "Telemetry configuration (`experimental.telemetry`).", + "additionalProperties": false, + "description": "Telemetry configuration (`telemetry`).", "properties": { "enabled": { - "description": "Explicit telemetry override. `true` = force on, `false` = force off, omitted = disabled (default off).", + "description": "Explicit telemetry opt-in for this invocation. `true` = opt in (still subject to the user's consent and to administrative policy — it can never turn telemetry on for someone who has not consented), `false` = force off, omitted = off.", "type": [ "boolean", "null" @@ -956,13 +946,6 @@ ], "description": "Containment backend to use for execution. Accepts abstract intents (`process`, `vm`) and concrete backends; the binary resolves intents to a concrete backend per host at run time." }, - "correlationVector": { - "description": "Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in the provision result. The client relays it verbatim into every later state-aware phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The executor is the trust boundary: on each non-provision phase it validates the relayed value and *spins* a fresh child element off a mutable base (so multiple invocations of one phase stay distinct), passes an already-frozen vector through unchanged, and reseeds a brand-new base if the relayed value is absent or malformed — so a missing or hostile relay never reaches telemetry unvalidated. Ignored unless experimental telemetry is enabled; not valid on one-shot requests.", - "type": [ - "string", - "null" - ] - }, "experimental": { "anyOf": [ { @@ -1080,6 +1063,17 @@ ], "description": "macOS Seatbelt backend configuration. Used when containment is `seatbelt`." }, + "telemetry": { + "anyOf": [ + { + "$ref": "#/definitions/Telemetry" + }, + { + "type": "null" + } + ], + "description": "Telemetry configuration." + }, "ui": { "anyOf": [ { diff --git a/schemas/dev/mxc-telemetry-consent.schema.1.json b/schemas/dev/mxc-telemetry-consent.schema.1.json new file mode 100644 index 000000000..984eaeb43 --- /dev/null +++ b/schemas/dev/mxc-telemetry-consent.schema.1.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/microsoft/mxc/schemas/dev/mxc-telemetry-consent.schema.1.json", + "title": "MXC Telemetry Consent Maintenance Request", + "description": "Telemetry-consent maintenance request.\n\nThis is a separate contract from [`MxcConfig`], even though executors use the same JSON input loader for both. Its explicit `command` discriminator lets the loader route maintenance without widening the execution schema.", + "additionalProperties": false, + "definitions": { + "TelemetryConsentAction": { + "description": "Telemetry-consent maintenance action.", + "oneOf": [ + { + "description": "Present the canonical prompt when consent may be requested.", + "enum": [ + "request" + ], + "type": "string" + }, + { + "description": "Idempotently persist denied consent.", + "enum": [ + "withdraw" + ], + "type": "string" + }, + { + "description": "Read status without mutation.", + "enum": [ + "status" + ], + "type": "string" + } + ] + }, + "TelemetryConsentCommand": { + "description": "Fixed telemetry-consent maintenance command discriminator.", + "enum": [ + "telemetryConsent" + ], + "type": "string" + } + }, + "properties": { + "$schema": { + "description": "Optional JSON Schema reference for editor validation.", + "type": [ + "string", + "null" + ] + }, + "action": { + "allOf": [ + { + "$ref": "#/definitions/TelemetryConsentAction" + } + ], + "description": "Consent operation to perform." + }, + "command": { + "allOf": [ + { + "$ref": "#/definitions/TelemetryConsentCommand" + } + ], + "description": "Fixed maintenance discriminator. Must be `\"telemetryConsent\"`." + }, + "locale": { + "description": "Preferred BCP 47 locale for the canonical prompt. Unsupported locales fall back to `en-US`. Used only by `request`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "action", + "command" + ], + "type": "object" +} diff --git a/scripts/versioning/check-schema-codegen.js b/scripts/versioning/check-schema-codegen.js index c702c0c80..1110653ff 100644 --- a/scripts/versioning/check-schema-codegen.js +++ b/scripts/versioning/check-schema-codegen.js @@ -28,55 +28,79 @@ function fail(msg) { const schemaVer = JSON.parse( readFileSync(join(repoRoot, "schemas", "schema-version.json"), "utf8") ); -const committedPath = join( +const configSchemaPath = join( repoRoot, "schemas", "dev", `mxc-config.schema.${schemaVer.devSchemaFile}.json` ); - -let committed; -try { - committed = readFileSync(committedPath, "utf8"); -} catch (e) { - fail(`could not read committed schema ${committedPath}: ${e.message}`); -} +const telemetryConsentSchemaPath = join( + repoRoot, + "schemas", + "dev", + "mxc-telemetry-consent.schema.1.json" +); const tmpDir = mkdtempSync(join(os.tmpdir(), "mxc-schema-gen-")); -const tmpOut = join(tmpDir, "generated.json"); +const tmpConfigOut = join(tmpDir, "generated-config.json"); +const tmpTelemetryConsentOut = join(tmpDir, "generated-telemetry-consent.json"); try { - // Build + run the generator. Quiet so only our diagnostics surface. - execFileSync( - "cargo", - ["run", "-q", "-p", "mxc_schema_gen", "--", tmpOut], - { cwd: join(repoRoot, "src"), stdio: ["ignore", "ignore", "inherit"] } - ); - const generated = readFileSync(tmpOut, "utf8"); - - // Compare modulo line endings: the schema is committed with LF, but on a - // Windows checkout with core.autocrlf=true the working-tree copy has CRLF. - // The generator always writes LF, so normalize both sides to avoid a - // false-positive "stale" failure that only reproduces on Windows. const normalize = (s) => s.replace(/\r\n/g, "\n"); - if (normalize(generated) !== normalize(committed)) { - // Find the first differing line for a helpful pointer. - const g = normalize(generated).split("\n"); - const c = normalize(committed).split("\n"); - let line = 0; - while (line < g.length && line < c.length && g[line] === c[line]) line++; - fail( - `committed schema is stale at ${committedPath}.\n` + - ` First difference at line ${line + 1}:\n` + - ` committed: ${JSON.stringify(c[line])}\n` + - ` generated: ${JSON.stringify(g[line])}\n` + - ` Regenerate with (from the repo root; the Cargo workspace is in src/):\n` + - ` cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schemas/dev/mxc-config.schema.${schemaVer.devSchemaFile}.json` + + function compareGeneratedSchema({ + committedPath, + generatedPath, + generatorArgs, + regenCommand, + }) { + let committed; + try { + committed = readFileSync(committedPath, "utf8"); + } catch (e) { + fail(`could not read committed schema ${committedPath}: ${e.message}`); + } + + execFileSync( + "cargo", + ["run", "-q", "-p", "mxc_schema_gen", "--", ...generatorArgs, generatedPath], + { cwd: join(repoRoot, "src"), stdio: ["ignore", "ignore", "inherit"] } ); + + const generated = readFileSync(generatedPath, "utf8"); + if (normalize(generated) !== normalize(committed)) { + const g = normalize(generated).split("\n"); + const c = normalize(committed).split("\n"); + let line = 0; + while (line < g.length && line < c.length && g[line] === c[line]) line++; + fail( + `committed schema is stale at ${committedPath}.\n` + + ` First difference at line ${line + 1}:\n` + + ` committed: ${JSON.stringify(c[line])}\n` + + ` generated: ${JSON.stringify(g[line])}\n` + + ` Regenerate with (from the repo root; the Cargo workspace is in src/):\n` + + ` ${regenCommand}` + ); + } } + + compareGeneratedSchema({ + committedPath: configSchemaPath, + generatedPath: tmpConfigOut, + generatorArgs: [], + regenCommand: `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schemas/dev/mxc-config.schema.${schemaVer.devSchemaFile}.json`, + }); + + compareGeneratedSchema({ + committedPath: telemetryConsentSchemaPath, + generatedPath: tmpTelemetryConsentOut, + generatorArgs: ["--telemetry-consent"], + regenCommand: + "cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --telemetry-consent schemas/dev/mxc-telemetry-consent.schema.1.json", + }); } finally { rmSync(tmpDir, { recursive: true, force: true }); } console.log( - `Schema codegen OK: committed dev schema matches the generated output (${schemaVer.devSchemaFile}).` + `Schema codegen OK: committed config and telemetry consent schemas match generated output (${schemaVer.devSchemaFile}, telemetry consent v1).` ); diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 08363feef..a4900a205 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -82,10 +82,6 @@ export interface Experimental { * Seatbelt backend config (pre-promotion alias). */ seatbelt?: Seatbelt | null; - /** - * Telemetry configuration. - */ - telemetry?: Telemetry | null; /** * Placeholder feature for testing experimental infrastructure. */ @@ -347,14 +343,13 @@ export interface Seatbelt { } /** - * Telemetry configuration (`experimental.telemetry`). + * Telemetry configuration (`telemetry`). */ export interface Telemetry { /** - * Explicit telemetry override. `true` = force on, `false` = force off, omitted = disabled (default off). + * Explicit telemetry opt-in for this invocation. `true` = opt in (still subject to the user's consent and to administrative policy — it can never turn telemetry on for someone who has not consented), `false` = force off, omitted = off. */ enabled?: boolean | null; - [k: string]: unknown; } /** @@ -495,10 +490,6 @@ export interface MXCConfiguration { * Containment backend to use for execution. Accepts abstract intents (`process`, `vm`) and concrete backends; the binary resolves intents to a concrete backend per host at run time. */ containment?: Containment | null; - /** - * Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in the provision result. The client relays it verbatim into every later state-aware phase so all phases of one lifecycle share a telemetry base prefix (emitted under `__TlgCV__`). The executor is the trust boundary: on each non-provision phase it validates the relayed value and *spins* a fresh child element off a mutable base (so multiple invocations of one phase stay distinct), passes an already-frozen vector through unchanged, and reseeds a brand-new base if the relayed value is absent or malformed — so a missing or hostile relay never reaches telemetry unvalidated. Ignored unless experimental telemetry is enabled; not valid on one-shot requests. - */ - correlationVector?: string | null; /** * Experimental features. Only honored when `--experimental` is passed. */ @@ -543,6 +534,10 @@ export interface MXCConfiguration { * macOS Seatbelt backend configuration. Used when containment is `seatbelt`. */ seatbelt?: Seatbelt | null; + /** + * Telemetry configuration. + */ + telemetry?: Telemetry | null; /** * Cross-platform UI isolation policy. */ diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index 350032fd7..282101273 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -411,17 +411,6 @@ export interface SandboxSpawnOptions { */ allowTestingFeatures?: boolean; - /** - * State-aware lifecycle only: the correlation vector (MS-CV) returned by - * {@link provisionSandbox} as `correlationVector`. Relay it verbatim on every - * later phase (`start` / `exec` / `stop` / `deprovision`) so all phases of one - * lifecycle share a telemetry base prefix. The client relays it unchanged; the - * executor derives each phase's own vector from it (spinning a mutable base or - * reseeding a missing/malformed value). Ignored by one-shot spawns and by - * `provision` (which seeds its own). - */ - correlationVector?: string; - /** * Explicit path to the wxc-exec (or lxc-exec) binary. * When set, the SDK uses this path directly instead of searching. diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index 5174ce424..ab6a1950e 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -84,7 +84,6 @@ export interface BuildEnvelopeArgs { backendKey: StateAwareContainmentBackend; containment?: StateAwareContainmentBackend; // provision only sandboxId?: string; // non-provision only - correlationVector?: string; // non-provision relay (from provision) config?: Record; } @@ -95,7 +94,7 @@ export interface BuildEnvelopeArgs { * remaining backend-specific fields under `experimental..`. */ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record { - const { phase, backendKey, containment, sandboxId, correlationVector, config } = args; + const { phase, backendKey, containment, sandboxId, config } = args; // Copy of config; fields are removed as they are lifted into the envelope. // Anything left becomes experimental... const backendSpecific: Record = { ...(config ?? {}) }; @@ -110,12 +109,6 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record = Met export interface ProvisionResult { sandboxId: SandboxId; metadata?: ProvisionMetadataFor; - /** - * Correlation vector (MS-CV) seeded by the executor for this lifecycle when - * experimental telemetry is enabled. Relay it verbatim as - * {@link SandboxSpawnOptions.correlationVector} on every later phase so all - * phases of the lifecycle share a telemetry base prefix. The client relays it - * unchanged; the executor derives each phase's own vector from it (spinning a - * mutable base or reseeding a missing/malformed value). Absent when telemetry - * is not active. - */ - correlationVector?: string; } export interface StartResult { diff --git a/sdk/node/src/state-aware.ts b/sdk/node/src/state-aware.ts index 3c1cfa51a..4e2548a01 100644 --- a/sdk/node/src/state-aware.ts +++ b/sdk/node/src/state-aware.ts @@ -70,12 +70,10 @@ export async function provisionSandbox( const result = await nonExecCall<{ sandboxId: string; metadata?: ProvisionMetadataFor; - correlationVector?: string; }>(envelope, options); return { sandboxId: result.sandboxId as SandboxId, metadata: result.metadata, - correlationVector: result.correlationVector, }; } @@ -93,7 +91,6 @@ export async function startSandbox( phase: 'start', backendKey, sandboxId, - correlationVector: options.correlationVector, config: config as Record | undefined, }); return nonExecCall>(envelope, options); @@ -116,7 +113,6 @@ export function execInSandbox( phase: 'exec', backendKey, sandboxId, - correlationVector: options.correlationVector, config: config as unknown as Record, }); const { executablePath, args } = resolveBinaryAndCommonArgs(JSON.stringify(envelope), options); @@ -157,7 +153,6 @@ export async function execInSandboxAsync phase: 'exec', backendKey, sandboxId, - correlationVector: options.correlationVector, config: config as unknown as Record, }); const { stdout, stderr, exitCode } = await spawnAndCollect(envelope, options); @@ -186,7 +181,6 @@ export async function stopSandbox( phase: 'stop', backendKey, sandboxId, - correlationVector: options.correlationVector, config: config as Record | undefined, }); return nonExecCall>(envelope, options); @@ -206,7 +200,6 @@ export async function deprovisionSandbox phase: 'deprovision', backendKey, sandboxId, - correlationVector: options.correlationVector, config: config as Record | undefined, }); return nonExecCall>(envelope, options); diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index de029318b..3d864f3fa 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -280,9 +280,7 @@ export interface PortMapping { protocol?: 'tcp'; } -/** - * Telemetry configuration for experimental TraceLogging ETW support. - */ +/** Telemetry configuration for TraceLogging ETW support. */ export interface TelemetryConfig { /** * Explicit telemetry override. `true` = force on, `false` = force off, @@ -323,12 +321,12 @@ export interface ContainerConfig { filesystem?: FilesystemConfig; /** Network access configuration */ network?: NetworkConfig; + /** Telemetry configuration */ + telemetry?: TelemetryConfig; /** Experimental features (only applied when --experimental flag is set) */ experimental?: { /** WSLC SDK configuration for Linux containers from Windows */ wslc?: WslcConfig; - /** Telemetry configuration for experimental TraceLogging ETW support */ - telemetry?: TelemetryConfig; }; /** macOS Seatbelt sandbox configuration (macOS only) */ seatbelt?: SeatbeltConfig; diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index 57c9e3d0a..c9b790ff7 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -130,24 +130,6 @@ describe('buildStateAwareEnvelope', () => { assert.strictEqual(wire.experimental, undefined); }); - it('relays correlationVector onto non-provision envelopes and omits it from provision', () => { - const nonProvision = buildStateAwareEnvelope({ - phase: 'start', - backendKey: 'isolation_session', - sandboxId: 'iso:abc', - correlationVector: 'BASEbaseBASEbaseBASEba.1', - }); - assert.strictEqual(nonProvision.correlationVector, 'BASEbaseBASEbaseBASEba.1'); - - // Provision seeds its own cV in the executor; the builder never emits one. - const provision = buildStateAwareEnvelope({ - phase: 'provision', - backendKey: 'isolation_session', - containment: 'isolation_session', - }); - assert.strictEqual(provision.correlationVector, undefined); - }); - }); describe('parseNonExecResponse', () => { @@ -274,19 +256,6 @@ describe('provisionSandbox', { skip: platformSkip }, () => { assert.ok(fake.captured.args?.includes('--experimental')); }); - it('surfaces the correlationVector from the provision result envelope', async () => { - const fake = fakeSpawn({ - stdout: '{"result":{"sandboxId":"iso:reg-abc:prov-1","correlationVector":"BASEbaseBASEbaseBASEba.42"}}', - exitCode: 0, - }); - activeFake = fake; - _setSpawnImpl(fake.spawn); - const result = await provisionSandbox('isolation_session', ACK, testOptions()); - assert.strictEqual(result.correlationVector, 'BASEbaseBASEbaseBASEba.42'); - // Provision itself never sends a correlationVector on the wire. - assert.strictEqual(fake.captured.envelope?.correlationVector, undefined); - }); - it('throws an MxcError carrying backend_unavailable when the executor reports it', async () => { const fake = fakeSpawn({ stdout: '{"error":{"code":"backend_unavailable","message":"isolation session API not available on this host"}}', @@ -334,13 +303,6 @@ describe('startSandbox', { skip: platformSkip }, () => { ); }); - it('relays the correlationVector from options onto the start envelope', async () => { - const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 }); - _setSpawnImpl(fake.spawn); - const id = 'iso:reg-abc:prov-1' as SandboxId<'isolation_session'>; - await startSandbox(id, undefined, testOptions({ correlationVector: 'BASEbaseBASEbaseBASEba.7' })); - assert.strictEqual(fake.captured.envelope?.correlationVector, 'BASEbaseBASEbaseBASEba.7'); - }); }); describe('stopSandbox', { skip: platformSkip }, () => { @@ -367,13 +329,6 @@ describe('stopSandbox', { skip: platformSkip }, () => { ); }); - it('relays the correlationVector from options onto the stop envelope', async () => { - const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 }); - _setSpawnImpl(fake.spawn); - const id = 'iso:abc' as SandboxId<'isolation_session'>; - await stopSandbox(id, undefined, testOptions({ correlationVector: 'BASEbaseBASEbaseBASEba.9' })); - assert.strictEqual(fake.captured.envelope?.correlationVector, 'BASEbaseBASEbaseBASEba.9'); - }); }); describe('deprovisionSandbox', { skip: platformSkip }, () => { @@ -387,14 +342,6 @@ describe('deprovisionSandbox', { skip: platformSkip }, () => { assert.strictEqual(fake.captured.envelope?.phase, 'deprovision'); assert.strictEqual(fake.captured.envelope?.sandboxId, 'iso:abc'); }); - - it('relays the correlationVector from options onto the deprovision envelope', async () => { - const fake = fakeSpawn({ stdout: '{"result":{}}', exitCode: 0 }); - _setSpawnImpl(fake.spawn); - const id = 'iso:abc' as SandboxId<'isolation_session'>; - await deprovisionSandbox(id, undefined, testOptions({ correlationVector: 'BASEbaseBASEbaseBASEba.11' })); - assert.strictEqual(fake.captured.envelope?.correlationVector, 'BASEbaseBASEbaseBASEba.11'); - }); }); describe('execInSandboxAsync', { skip: platformSkip }, () => { @@ -453,17 +400,6 @@ describe('execInSandboxAsync', { skip: platformSkip }, () => { ); }); - it('relays the correlationVector from options onto the exec envelope', async () => { - const fake = fakeSpawn({ stdout: 'hi\n', stderr: '', exitCode: 0 }); - _setSpawnImpl(fake.spawn); - const id = 'iso:abc' as SandboxId<'isolation_session'>; - await execInSandboxAsync( - id, - { process: { commandLine: 'echo hi' } }, - testOptions({ correlationVector: 'BASEbaseBASEbaseBASEba.13' }), - ); - assert.strictEqual(fake.captured.envelope?.correlationVector, 'BASEbaseBASEbaseBASEba.13'); - }); }); describe('windows_sandbox state-aware lifecycle', () => { diff --git a/sdk/node/tests/unit/wire-conformance.test.ts b/sdk/node/tests/unit/wire-conformance.test.ts index 753c7c3f4..5e3289911 100644 --- a/sdk/node/tests/unit/wire-conformance.test.ts +++ b/sdk/node/tests/unit/wire-conformance.test.ts @@ -213,14 +213,13 @@ type _SeatbeltWireKeys = AssertTrue< >; // Root: the SDK's `ContainerConfig` intentionally omits the schema-metadata keys -// (`$schema`, `_comment`), the state-aware-only keys (`phase`, `sandboxId`, -// `correlationVector` — see `state-aware-types.ts`), and `fallback` (AppContainer -// DACL-mutation policy not surfaced through the one-shot policy API). Any OTHER -// new root wire field fails. +// (`$schema`, `_comment`), the state-aware-only keys (`phase`, `sandboxId`), +// and `fallback` (AppContainer DACL-mutation policy not surfaced through the +// one-shot policy API). Any OTHER new root wire field fails. type _RootWireKeys = AssertTrue< Equivalent< OnlyInWire, - '$schema' | '_comment' | 'phase' | 'sandboxId' | 'correlationVector' | 'fallback' + '$schema' | '_comment' | 'phase' | 'sandboxId' | 'fallback' > >; diff --git a/src/Cargo.lock b/src/Cargo.lock index 45fa4991d..a159f34a5 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1537,6 +1537,7 @@ dependencies = [ "seatbelt_common", "serde", "serde_json", + "tempfile", "windows", "windows_sandbox_lifecycle", "wslc_common", @@ -3172,6 +3173,7 @@ name = "wxc_common" version = "0.7.0" dependencies = [ "base64", + "filetime", "getrandom 0.2.17", "libc", "mxc_config_contract", @@ -3182,6 +3184,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "sha2 0.10.9", "tempfile", "thiserror", "unicode-general-category", diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 3aab93a1b..5cd53e6a8 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -6,7 +6,7 @@ use std::process; use std::time::Instant; use clap::Parser; -use wxc_common::config_parser::load_request; +use wxc_common::config_parser::load_request_from_json; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ExecutionRequest, ScriptResponse}; use wxc_common::script_runner::handle_dry_run_exit; @@ -73,6 +73,83 @@ struct Cli { /// the new bits. Requires --setup-hyperlight. #[arg(long, requires = "setup_hyperlight")] force: bool, + + /// Report the persisted telemetry consent state and exit. MXC only + /// ever collects telemetry on Windows, so on Linux this always + /// reports "not-applicable" — there is no consent to grant or + /// revoke here. See docs/telemetry/telemetry-consent-design.md. + #[arg(long = "telemetry-consent-status")] + telemetry_consent_status: bool, + + /// Legacy non-mutating tombstone; directs callers to maintenance JSON. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Legacy non-mutating tombstone; directs callers to maintenance JSON. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Legacy companion tombstone for the removed grant/revoke flow. + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] + telemetry_consent_source: Option, +} + +/// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this +/// mirrors. On Linux, `wxc_common::telemetry::consent` always reports +/// `NotApplicable`; legacy state-changing flags return the same non-mutating +/// maintenance-JSON migration error as every other executor. +/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so +/// this fast path can't drift from `wxc-exec`/`mxc-exec-mac`. The shared +/// handler returns the outcome as data; terminating the process is this +/// binary's job, not the foundation crate's. +fn handle_telemetry_consent_flags(cli: &Cli) -> bool { + let Some(outcome) = + telemetry::consent_cli::handle_consent_flags(&telemetry::consent_cli::ConsentCliFlags { + status: cli.telemetry_consent_status, + grant: cli.telemetry_consent_grant, + revoke: cli.telemetry_consent_revoke, + source: cli.telemetry_consent_source.as_deref(), + }) + else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + +fn handle_telemetry_consent_input(json: Option<&str>) -> bool { + let Some(json) = json else { + return false; + }; + let Some(outcome) = telemetry::consent_cli::handle_maintenance_input_json(json) else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + +/// Read the request source (file path / base64 blob) once, returning the +/// decoded JSON. Reused by the maintenance probe and the normal request +/// loader so a single source is only read once per invocation — a +/// correctness fix for named pipes, `/dev/stdin`, and process-substitution +/// paths that are drained by the first read. +fn decode_config_input_once(cli: &Cli) -> Option> { + let (input, is_base64) = if let Some(input) = cli.config_base64.as_ref() { + (input.clone(), true) + } else if let Some(input) = cli.config.as_ref().or(cli.config_path.as_ref()) { + (input.clone(), false) + } else { + return None; + }; + Some(wxc_common::config_parser::decode_request_input( + &input, is_base64, + )) } fn log_request(request: &ExecutionRequest, logger: &mut Logger) { @@ -113,6 +190,40 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { } fn main() { + let cli = Cli::parse(); + + // --telemetry-consent-{status,grant,revoke}: report/administer the + // (always not-applicable on Linux) consent state and exit. Runs before + // signal_cleanup::install(): + // this is a read-only/local-file fast path that never spawns a + // container, so it must not be gated on — or fail because of — signal + // handler installation, matching `wxc-exec`/`mxc-exec-mac`, where the + // consent fast path also runs unconditionally before any other setup. + if handle_telemetry_consent_flags(&cli) { + return; + } + // Decode the request source (file path / base64) once, up front, so the + // maintenance probe and the normal request loader below share the same + // decoded JSON — necessary for named pipes, `/dev/stdin`, and + // process-substitution paths that are drained by the first read. + let decoded_config: Option> = + decode_config_input_once(&cli); + let request_hint = decoded_config + .as_ref() + .and_then(|result| result.as_ref().ok()) + .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); + if matches!( + request_hint.as_ref().map(|hint| hint.kind()), + Some(wxc_common::config_parser::RequestKind::TelemetryConsent) + ) && handle_telemetry_consent_input( + decoded_config + .as_ref() + .and_then(|r| r.as_ref().ok()) + .map(String::as_str), + ) { + return; + } + // Install before spawning any other threads so the signal mask propagates. // Failure here is fatal: install() either succeeds with the watchdog // running, or restores the original signal mask and returns Err. We @@ -123,8 +234,6 @@ fn main() { process::exit(1); } - let cli = Cli::parse(); - // --setup-hyperlight: eagerly warm up the snapshot and exit. Runs // before config parsing so the user doesn't need a JSON file on // disk just to install. @@ -154,18 +263,26 @@ fn main() { } } - // Determine config input - let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { - (b64.clone(), true) - } else if let Some(ref path) = cli.config { - (path.clone(), false) - } else if let Some(ref path) = cli.config_path { - (path.clone(), false) - } else if !cli.delete { - eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); - process::exit(1); - } else { - (String::new(), false) + // Determine config input. In delete mode the config is optional; every + // other path requires it. `decoded_config` above already read the source + // once — if it's populated, unpack the decoded JSON (or surface the + // decode error). If it's absent, either accept the empty state for + // delete mode or report the missing-config error. + let config_json: Option = match decoded_config { + Some(Ok(json)) => Some(json), + Some(Err(error)) => { + eprintln!("Request error\n{error}"); + process::exit(1); + } + None => { + if !cli.delete { + eprintln!( + "Error: No config provided. Use a positional path, --config, or --config-base64" + ); + process::exit(1); + } + None + } }; let mut logger = Logger::new(if cli.debug { @@ -194,8 +311,22 @@ fn main() { process::exit(if success { 0 } else { 1 }); } + // Non-delete paths always have a config JSON at this point (or exited + // above with the missing-config error). + let config_json = config_json.expect("config_json is Some on non-delete paths"); + // Load request - let mut request = match load_request(&config_data, &mut logger, is_base64) { + let parsed_request = if let Some(hint) = request_hint.as_ref() { + wxc_common::config_parser::load_request_from_json_with_hint_and_options( + &config_json, + &mut logger, + wxc_common::config_parser::LoadOptions::default(), + hint, + ) + } else { + load_request_from_json(&config_json, &mut logger) + }; + let mut request = match parsed_request { Ok(r) => r, Err(_) => { eprint!("Request error\n{}", logger.get_buffer()); @@ -207,17 +338,12 @@ fn main() { request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; - // ── Telemetry init (experimental) ─────────────────────────────── - let telemetry_active = if request.experimental_enabled { - request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, &mut logger)) - .unwrap_or(false) - } else { - false - }; + // ── Telemetry init ────────────────────────────────────────────── + let telemetry_active = request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, &mut logger)) + .unwrap_or(false); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still @@ -260,7 +386,7 @@ fn main() { display_script_results(&response, &mut logger); - // ── Telemetry emit (experimental) ─────────────────────────────── + // ── Telemetry emit ────────────────────────────────────────────── telemetry::emit_completion( telemetry_active, &request.containment, diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index e5f466500..b18a19043 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -13,7 +13,7 @@ use std::fmt::Write; use std::process; use clap::Parser; -use wxc_common::config_parser::load_request; +use wxc_common::config_parser::load_request_from_json; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::ExecutionRequest; @@ -61,6 +61,90 @@ struct Cli { /// Path to diagnostic log file (appends, creates if missing) #[arg(long = "log-file")] log_file: Option, + + /// Report the persisted telemetry consent state and exit. MXC only + /// ever collects telemetry on Windows, so on macOS this always + /// reports "not-applicable" — there is no consent to grant or + /// revoke here. See docs/telemetry/telemetry-consent-design.md. + #[arg(long = "telemetry-consent-status")] + telemetry_consent_status: bool, + + /// Legacy non-mutating tombstone; directs callers to maintenance JSON. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Not supported on macOS; always fails. Present for CLI-surface + /// parity with wxc-exec.exe. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Optional source associated with a legacy grant/revoke flag. + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] + telemetry_consent_source: Option, +} + +/// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this +/// mirrors. On macOS, `wxc_common::telemetry::consent` always reports +/// `NotApplicable`; legacy state-changing flags return the same non-mutating +/// maintenance-JSON migration error as every other executor. +/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so +/// this fast path can't drift from `wxc-exec`/`lxc-exec`. The shared handler +/// returns the outcome as data; terminating the process is this binary's +/// job, not the foundation crate's. +fn handle_telemetry_consent_flags(cli: &Cli) -> bool { + let Some(outcome) = wxc_common::telemetry::consent_cli::handle_consent_flags( + &wxc_common::telemetry::consent_cli::ConsentCliFlags { + status: cli.telemetry_consent_status, + grant: cli.telemetry_consent_grant, + revoke: cli.telemetry_consent_revoke, + source: cli.telemetry_consent_source.as_deref(), + }, + ) else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + +fn handle_telemetry_consent_input(config_json: Option<&str>) -> bool { + let Some(input) = config_json else { + return false; + }; + let Some(outcome) = wxc_common::telemetry::consent_cli::handle_maintenance_input_json(input) + else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + +/// Decode the config input source (base64 arg / file path) exactly once. +/// +/// The maintenance-consent probe and the normal request loader both need the +/// decoded JSON. Named pipes, `/dev/stdin`, and process-substitution paths +/// are drained by the first read, so reading them twice fails or blocks; +/// regular files pay duplicate I/O for no reason. This helper reads the +/// source a single time and returns the JSON text (or a load error). +/// +/// Returns `None` if the CLI provided no config source at all — the caller +/// then reports the appropriate missing-config error for its mode. +fn decode_config_input_once(cli: &Cli) -> Option> { + let (input, is_base64) = if let Some(b64) = cli.config_base64.as_ref() { + (b64.as_str(), true) + } else if let Some(path) = cli.config.as_ref().or(cli.config_path.as_ref()) { + (path.as_str(), false) + } else { + return None; + }; + Some(wxc_common::config_parser::decode_request_input( + input, is_base64, + )) } fn log_request(request: &ExecutionRequest, logger: &mut Logger) { @@ -82,16 +166,46 @@ fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { fn main() { let cli = Cli::parse(); + // --telemetry-consent-{status,grant,revoke}: report/administer the + // (always not-applicable on macOS) consent state and exit. + if handle_telemetry_consent_flags(&cli) { + return; + } + // Decode the config source once so the maintenance probe and the normal + // request loader below share the same JSON — necessary for named pipes, + // `/dev/stdin`, and process-substitution paths that are drained by the + // first read. + let decoded_config: Option> = + decode_config_input_once(&cli); + let request_hint = decoded_config + .as_ref() + .and_then(|result| result.as_ref().ok()) + .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); + if matches!( + request_hint.as_ref().map(|hint| hint.kind()), + Some(wxc_common::config_parser::RequestKind::TelemetryConsent) + ) && handle_telemetry_consent_input( + decoded_config + .as_ref() + .and_then(|r| r.as_ref().ok()) + .map(String::as_str), + ) { + return; + } + // Determine config input. - let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { - (b64.clone(), true) - } else if let Some(ref path) = cli.config { - (path.clone(), false) - } else if let Some(ref path) = cli.config_path { - (path.clone(), false) - } else { - eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); - process::exit(1); + let config_json = match decoded_config { + Some(Ok(json)) => json, + Some(Err(error)) => { + eprintln!("Request error\n{error}"); + process::exit(1); + } + None => { + eprintln!( + "Error: No config provided. Use a positional path, --config, or --config-base64" + ); + process::exit(1); + } }; let mut logger = Logger::new(if cli.debug { @@ -106,7 +220,17 @@ fn main() { } } - let mut request = match load_request(&config_data, &mut logger, is_base64) { + let parsed_request = if let Some(hint) = request_hint.as_ref() { + wxc_common::config_parser::load_request_from_json_with_hint_and_options( + &config_json, + &mut logger, + wxc_common::config_parser::LoadOptions::default(), + hint, + ) + } else { + load_request_from_json(&config_json, &mut logger) + }; + let mut request = match parsed_request { Ok(r) => r, Err(_) => { eprint!("Request error\n{}", logger.get_buffer()); @@ -127,21 +251,16 @@ fn main() { fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { use wxc_common::telemetry; - // ── Telemetry init (experimental) ─────────────────────────────── + // ── Telemetry init ────────────────────────────────────────────── // Mirrors lxc-exec / wxc-exec. The ETW provider has no macOS backend today, // so `init` returns false and every emit below is a no-op; wiring it anyway // keeps the three executors structurally identical and ready the moment // telemetry gains a macOS sink. - let telemetry_active = if request.experimental_enabled { - request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, logger)) - .unwrap_or(false) - } else { - false - }; + let telemetry_active = request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, logger)) + .unwrap_or(false); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still @@ -172,7 +291,7 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { display_script_results(&response, logger); - // ── Telemetry emit (experimental) ─────────────────────────────── + // ── Telemetry emit ────────────────────────────────────────────── telemetry::emit_completion( telemetry_active, &request.containment, diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index e53e400db..3b76341af 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -58,3 +58,7 @@ isolation_session = [ ] # Forwards appcontainer_common/tier2_bfs. OFF by default; see appcontainer_common/Cargo.toml. tier2_bfs = ["appcontainer_common/tier2_bfs"] + +[dev-dependencies] +wxc_common = { workspace = true, features = ["test-support"] } +tempfile.workspace = true diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index e5fe40fb0..f3c661f88 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -55,7 +55,9 @@ pub use run::{resolve_runner, run, ResolvedRunner}; pub use state_aware::{exec_state_aware_json, run_state_aware, run_state_aware_json}; use wxc_common::logger::{Logger, Mode}; +use wxc_common::models::{ContainmentBackend, FailurePhase, ScriptResponse}; use wxc_common::sandbox_process::{SandboxProcess, StreamCloser}; +use wxc_common::telemetry; /// Spawn a streaming [`SandboxProcess`] handle for a [`SandboxRequest`] built /// by [`build_request`] (with the command, and any working directory / env, @@ -65,31 +67,349 @@ use wxc_common::sandbox_process::{SandboxProcess, StreamCloser}; /// with piped stdio, and returns the handle. No pty is allocated. Backends /// without a streaming implementation return an [`Error`] with /// [`ErrorCode::UnsupportedContainment`]. +/// +/// # Safety / lifetime contract for library-context callers +/// +/// When this crate is compiled into a dynamically-loadable library (the +/// `mxc_ffi` cdylib, or a host that dlopens `mxc-sdk`), the ETW telemetry +/// provider is registered while an invocation is live. The provider retains +/// callbacks into this module's code, so **the library must remain loaded +/// until every spawned handle produced by this function has been dropped** +/// (which releases the corresponding provider reference through +/// [`telemetry::shutdown`] via the [`TelemetryProcess`] `Drop` impl below). +/// Callers that dlclose / `FreeLibrary` while a spawned handle is still live +/// would leave ETW with dangling callbacks into unmapped memory. +/// +/// The registration is reference-counted per `telemetry::init` call and +/// released once when the returned handle is dropped, so multiple concurrent +/// spawns from the same load are safe as long as the library outlives them. pub fn spawn(request: &SandboxRequest) -> Result, Error> { let mut logger = Logger::new(Mode::Buffer); - let process = dispatch::spawn_runner(&request.inner, &mut logger).map_err(Error::from)?; - let mut warnings = process.warnings().to_vec(); - for warning in logger.take_warnings() { - if !warnings.contains(&warning) { - warnings.push(warning); + let telemetry_active = request + .inner + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + let requested_sandbox_kind = request + .inner + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); + let containment = request.inner.containment.clone(); + let started = std::time::Instant::now(); + let process = match dispatch::spawn_runner(&request.inner, &mut logger) { + Ok(process) => process, + Err(error) => { + // Preserve the actual error category so bounded telemetry + // categorises malformed requests, unsupported containment, and + // policy validation failures under their real reason — not the + // catch-all `InitError`. Shares the exhaustive `MxcErrorCode` → + // `FailureReason` mapping with the state-aware path. + telemetry::emit_sdk_early_exit_with_kind( + telemetry_active, + &containment, + requested_sandbox_kind, + telemetry::classify_mxc_error(&error), + ); + return Err(Error::from(error)); } - } - if warnings.is_empty() { - Ok(process) - } else { - Ok(Box::new(ProcessWithWarnings { + }; + let extra_warnings = logger.take_warnings(); + let process: Box = ProcessWithWarnings::wrap(process, extra_warnings); + if telemetry_active { + Ok(Box::new(TelemetryProcess { inner: process, - warnings, + active: true, + mode: TelemetryMode::OneShot { + containment, + requested_sandbox_kind, + }, + started, })) + } else { + Ok(process) + } +} + +/// Streaming process wrapper that owns one telemetry provider reference and +/// emits exactly one terminal event for this SDK invocation. +/// +/// # Terminal-event invariant +/// +/// Every non-nop path a caller can drive this wrapper through emits exactly +/// one terminal telemetry event before the provider reference is released: +/// +/// * [`SandboxProcess::wait`] — real completion / timeout / spawn error. +/// * [`SandboxProcess::try_wait`] — same, on the poll that observes the exit +/// or an error branch (a `Pending` poll leaves the invariant to a later +/// `wait` / `kill` / `Drop`). +/// * [`SandboxProcess::kill`] — synthesised cancellation event. +/// * [`Drop`] — synthesised process-error event, so a wrapper dropped without +/// `wait` never releases the provider without accounting for the run. +/// +/// `active` doubles as the exactly-once flag: it starts `true`, and every +/// terminal path calls [`TelemetryProcess::emit`] which flips it to `false` +/// after the event and after [`telemetry::shutdown`] releases the provider +/// reference. +struct TelemetryProcess { + inner: Box, + active: bool, + mode: TelemetryMode, + started: std::time::Instant, +} + +enum TelemetryMode { + OneShot { + containment: ContainmentBackend, + requested_sandbox_kind: Option<&'static str>, + }, + StateAware { + backend: String, + phase: String, + correlation_vector: String, + requested_sandbox_kind: Option<&'static str>, + }, +} + +pub(crate) fn wrap_state_aware_telemetry_process_with_kind( + process: Box, + active: bool, + backend: String, + phase: String, + correlation_vector: String, + requested_sandbox_kind: Option<&'static str>, + started: std::time::Instant, +) -> Box { + if active { + Box::new(TelemetryProcess { + inner: process, + active: true, + mode: TelemetryMode::StateAware { + backend, + phase, + correlation_vector, + requested_sandbox_kind, + }, + started, + }) + } else { + process + } +} + +impl TelemetryProcess { + /// Emit the single terminal event for this invocation and release the + /// provider reference. Idempotent — subsequent calls (from another exit + /// path or `Drop`) are silent no-ops. + fn emit(&mut self, result: &std::io::Result) { + if !self.active { + return; + } + let response = match result { + Ok(exit_code) => ScriptResponse { + exit_code: *exit_code, + ..Default::default() + }, + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => ScriptResponse { + error_message: "sandbox execution timed out".to_string(), + failure_phase: FailurePhase::Timeout, + ..Default::default() + }, + Err(error) => ScriptResponse { + error_message: error.to_string(), + failure_phase: FailurePhase::PostLaunchFailed, + ..Default::default() + }, + }; + match &self.mode { + TelemetryMode::OneShot { + containment, + requested_sandbox_kind, + } => telemetry::emit_sdk_completion_with_kind( + true, + containment, + *requested_sandbox_kind, + &response, + self.started.elapsed(), + ), + TelemetryMode::StateAware { + backend, + phase, + correlation_vector, + requested_sandbox_kind, + } => { + let outcome = match result { + Ok(exit_code) => Ok( + wxc_common::state_aware_dispatch::DispatchOutcome::ExecCompleted { + exit_code: *exit_code, + }, + ), + Err(error) => Err(wxc_common::mxc_error::MxcError::backend_error( + error.to_string(), + )), + }; + telemetry::emit_sdk_state_aware_with_kind( + true, + *requested_sandbox_kind, + telemetry::TelemetryContext { + backend, + phase, + correlation_vector, + }, + &outcome, + self.started.elapsed(), + ); + } + } + self.active = false; + } + + /// Emit a synthesised terminal event when no real completion result is + /// available (kill, poll-error, drop-without-wait). Routes through + /// [`Self::emit`] so it shares the exactly-once slot and the + /// provider-release path with the natural exit paths. + fn emit_synthetic(&mut self, kind: SyntheticTerminal) { + if !self.active { + return; + } + let (raw_kind, message) = match kind { + SyntheticTerminal::Killed => ( + std::io::ErrorKind::Interrupted, + "sandbox handle was killed before completion", + ), + SyntheticTerminal::Dropped => ( + std::io::ErrorKind::Other, + "sandbox handle was dropped before completion", + ), + SyntheticTerminal::PollError => ( + std::io::ErrorKind::Other, + "sandbox handle try_wait returned an error before completion", + ), + }; + // The Err path in `emit` classifies non-timeout errors as + // PostLaunchFailed → FailureReason::InitError (one-shot) or + // BackendError → FailureReason::ProcessError (state-aware). + self.emit(&Err(std::io::Error::new(raw_kind, message))); + } +} + +/// The three synthetic-terminal callsites, so [`TelemetryProcess::emit_synthetic`] +/// can attribute the event to the exit path that produced it. +#[derive(Debug, Clone, Copy)] +enum SyntheticTerminal { + /// `SandboxProcess::kill` was called. + Killed, + /// The wrapper was dropped without a preceding `wait` / successful `try_wait`. + Dropped, + /// `SandboxProcess::try_wait` observed an underlying error. + PollError, +} + +impl Drop for TelemetryProcess { + fn drop(&mut self) { + // If `wait` / `try_wait(Ok(Some))` / `kill` already emitted the + // terminal event, `active` is false and this is a no-op. Otherwise + // synthesise one so the "exactly-one terminal event" invariant holds + // for the drop-without-wait path as well. `emit` also releases the + // provider reference. + self.emit_synthetic(SyntheticTerminal::Dropped); + } +} + +impl SandboxProcess for TelemetryProcess { + fn warnings(&self) -> &[String] { + self.inner.warnings() + } + + fn output_metadata(&self) -> Option<&wxc_common::models::SandboxOutputMetadata> { + self.inner.output_metadata() + } + + fn take_stdin(&mut self) -> Option> { + self.inner.take_stdin() + } + + fn take_stdout(&mut self) -> Option> { + self.inner.take_stdout() + } + + fn take_stderr(&mut self) -> Option> { + self.inner.take_stderr() + } + + fn try_wait(&mut self) -> std::io::Result> { + let result = self.inner.try_wait(); + match &result { + // Exit observed: emit the terminal event now. + Ok(Some(exit_code)) => self.emit(&Ok(*exit_code)), + // Still running: leave the invariant to a later `wait` / `kill` / `Drop`. + Ok(None) => {} + // Poll-error branch — emit a synthesised terminal event so the + // provider reference isn't released without accounting for the + // run, then return the error unchanged to the caller. + Err(_) => self.emit_synthetic(SyntheticTerminal::PollError), + } + result + } + + fn id(&self) -> u32 { + self.inner.id() + } + + fn kill(&mut self) -> std::io::Result<()> { + // Forward the kill first so its I/O outcome is what we return; then + // emit the synthesised cancellation event before the provider + // reference is released. `emit_synthetic` is idempotent, so a + // subsequent `wait` on the (now-killed) child is a no-op. + let result = self.inner.kill(); + self.emit_synthetic(SyntheticTerminal::Killed); + result + } + + fn wait(&mut self) -> std::io::Result { + let result = self.inner.wait(); + self.emit(&result); + result + } + + fn stdout_closer(&self) -> Option> { + self.inner.stdout_closer() + } + + fn stderr_closer(&self) -> Option> { + self.inner.stderr_closer() } } /// A streaming process paired with security warnings emitted during spawn. -struct ProcessWithWarnings { +pub(crate) struct ProcessWithWarnings { inner: Box, warnings: Vec, } +impl ProcessWithWarnings { + /// Wrap `inner` so its `warnings()` reports the union of its own warnings + /// and `extra_warnings` (duplicates deduplicated). Returns `inner` + /// unchanged if the merged list is empty. + pub(crate) fn wrap( + inner: Box, + extra_warnings: Vec, + ) -> Box { + let mut warnings = inner.warnings().to_vec(); + for warning in extra_warnings { + if !warnings.contains(&warning) { + warnings.push(warning); + } + } + if warnings.is_empty() { + inner + } else { + Box::new(ProcessWithWarnings { inner, warnings }) + } + } +} + impl SandboxProcess for ProcessWithWarnings { fn warnings(&self) -> &[String] { &self.warnings @@ -135,3 +455,105 @@ impl SandboxProcess for ProcessWithWarnings { self.inner.stderr_closer() } } + +#[cfg(test)] +mod telemetry_process_tests { + use super::*; + + enum TryWaitResult { + Running, + Exited(i32), + Failed, + } + + struct StubProcess { + try_wait_result: TryWaitResult, + wait_result: std::io::Result, + } + + impl SandboxProcess for StubProcess { + fn take_stdin(&mut self) -> Option> { + None + } + + fn take_stdout(&mut self) -> Option> { + None + } + + fn take_stderr(&mut self) -> Option> { + None + } + + fn try_wait(&mut self) -> std::io::Result> { + match self.try_wait_result { + TryWaitResult::Running => Ok(None), + TryWaitResult::Exited(code) => Ok(Some(code)), + TryWaitResult::Failed => Err(std::io::Error::other("poll failed")), + } + } + + fn id(&self) -> u32 { + 1 + } + + fn kill(&mut self) -> std::io::Result<()> { + Ok(()) + } + + fn wait(&mut self) -> std::io::Result { + self.wait_result + .as_ref() + .copied() + .map_err(|error| std::io::Error::new(error.kind(), error.to_string())) + } + } + + fn wrapped(try_wait_result: TryWaitResult) -> TelemetryProcess { + TelemetryProcess { + inner: Box::new(StubProcess { + try_wait_result, + wait_result: Ok(0), + }), + active: true, + mode: TelemetryMode::StateAware { + backend: "test".to_string(), + phase: "exec".to_string(), + correlation_vector: String::new(), + requested_sandbox_kind: None, + }, + started: std::time::Instant::now(), + } + } + + #[test] + fn terminal_paths_consume_the_exactly_once_slot() { + let mut waited = wrapped(TryWaitResult::Running); + assert_eq!(waited.wait().unwrap(), 0); + assert!(!waited.active); + waited.kill().unwrap(); + assert!(!waited.active); + + let mut killed = wrapped(TryWaitResult::Running); + killed.kill().unwrap(); + assert!(!killed.active); + assert_eq!(killed.wait().unwrap(), 0); + assert!(!killed.active); + + let mut exited = wrapped(TryWaitResult::Exited(7)); + assert_eq!(exited.try_wait().unwrap(), Some(7)); + assert!(!exited.active); + + let mut poll_failed = wrapped(TryWaitResult::Failed); + assert!(poll_failed.try_wait().is_err()); + assert!(!poll_failed.active); + } + + #[test] + fn nonterminal_poll_leaves_the_exactly_once_slot_active() { + let mut process = wrapped(TryWaitResult::Running); + assert_eq!(process.try_wait().unwrap(), None); + assert!(process.active); + process.emit_synthetic(SyntheticTerminal::Dropped); + assert!(!process.active); + } +} diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 410cdfd85..37ec9b2e7 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -17,7 +17,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use wxc_common::logger::{Logger, Mode}; -use wxc_common::models::ExecutionRequest; +use wxc_common::models::{ExecutionRequest, TelemetryConfig}; use wxc_common::mxc_error::MxcError; // --------------------------------------------------------------------------- @@ -781,6 +781,19 @@ impl SandboxRequest { self.inner.experimental_enabled = enabled; self } + + /// Enable or disable telemetry for this invocation. + /// + /// Enabling this per-request switch is necessary but not sufficient: + /// telemetry still requires persisted user consent and an administrative + /// policy that permits collection. It is independent of experimental mode. + pub fn set_telemetry_enabled(&mut self, enabled: bool) -> &mut Self { + self.inner.telemetry = Some(TelemetryConfig { + enabled: Some(enabled), + ..Default::default() + }); + self + } } /// Build a [`SandboxRequest`] from a [`SandboxPolicy`], resolving the host's @@ -877,7 +890,6 @@ fn build_wire_config( "deniedPaths": fs.denied_paths, }, }); - // `ui` is emitted only when the caller actually supplied one. // // The parser records presence as `ContainerPolicy::ui_specified`, and @@ -1697,6 +1709,38 @@ mod tests { assert!(request.inner.experimental_enabled); } + #[test] + fn telemetry_enablement_is_stable_and_independent_of_experimental_mode() { + let mut request = build_request(&minimal_policy(), None).expect("build_request"); + assert!(request.inner.telemetry.is_none()); + assert!(!request.inner.experimental_enabled); + + request.set_telemetry_enabled(true); + assert_eq!( + request + .inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled), + Some(true) + ); + assert!( + !request.inner.experimental_enabled, + "stable telemetry enablement must not opt into experimental features" + ); + + request.set_telemetry_enabled(false); + assert_eq!( + request + .inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled), + Some(false) + ); + assert!(!request.inner.experimental_enabled); + } + #[test] fn wslc_rejects_an_invalid_port_mapping() { // Validation is the shared parser's, so a bad mapping is rejected at diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 2c2d4e532..24ff1b75b 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -22,13 +22,11 @@ use wxc_common::state_aware_dispatch::{ resolve_backend, run_state_aware as run_state_aware_fallback, DispatchOutcome, }; use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; +use wxc_common::telemetry; use crate::error::Error; +use crate::wrap_state_aware_telemetry_process_with_kind; -/// The `backend_unavailable` error returned when a state-aware WSLc request -/// reaches a build compiled without the `wslc` feature (or a non-Windows -/// target). Mirrors the documented error mapping so the availability probe can -/// skip a feature-off build instead of misreading `unsupported_phase`. #[cfg(not(all(target_os = "windows", feature = "wslc")))] fn wslc_unavailable() -> MxcError { MxcError::backend_unavailable( @@ -58,6 +56,47 @@ fn require_experimental_optin( Ok(()) } +/// This phase's telemetry correlation vector, purely internal to MXC: no +/// caller ever supplies or relays one. `provision` (whose `sandboxId` doesn't +/// exist yet) mints a fresh vector; every later phase recalls the same +/// lifecycle root persisted by `provision` and spins a distinct child off it — +/// see [`telemetry::correlation_state`]. +fn phase_correlation(active: bool, phase: Phase, sandbox_id: Option<&str>) -> String { + telemetry::correlation_state::pre_dispatch_vector(active, phase == Phase::Provision, sandbox_id) +} + +/// Merge `warnings` from telemetry initialisation into the envelope's +/// `result.warnings` array. Existing entries in the array are preserved and +/// duplicates suppressed so the field remains a stable set-like list. +/// +/// This mirrors what the ordinary SDK `spawn` path does for streaming +/// invocations (see `ProcessWithWarnings::wrap` in `lib.rs`): both entry +/// points buffer telemetry-init diagnostics in a `Logger` and must surface +/// them to the caller instead of dropping them on the floor. +fn inject_warnings(outcome: &mut Result, warnings: &[String]) { + if warnings.is_empty() { + return; + } + if let Ok(DispatchOutcome::Envelope(value)) = outcome { + if let Some(result) = value + .get_mut("result") + .and_then(|result| result.as_object_mut()) + { + let existing = result + .entry("warnings") + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + if let Some(array) = existing.as_array_mut() { + for warning in warnings { + let value = serde_json::Value::String(warning.clone()); + if !array.contains(&value) { + array.push(value); + } + } + } + } + } +} + /// Resolve `parsed`'s backend and run the requested state-aware phase. /// /// On envelope phases this returns [`DispatchOutcome::Envelope`]; on the exec @@ -86,8 +125,6 @@ pub fn run_state_aware( let mut runner = wslc_common::WslcStateAwareRunner::new(); wxc_common::state_aware_dispatch::dispatch_state_aware(&mut runner, parsed, dry_run) } - // Feature-off build: keep the documented `backend_unavailable` contract - // rather than falling through to the generic `unsupported_phase`. #[cfg(not(all(target_os = "windows", feature = "wslc")))] wxc_common::models::ContainmentBackend::Wslc => Err(wslc_unavailable()), _ => run_state_aware_fallback(parsed, dry_run), @@ -107,6 +144,15 @@ pub fn exec_state_aware( let backend = resolve_backend(&parsed)?; require_experimental_optin(&backend, &parsed)?; match backend { + #[cfg(target_os = "windows")] + wxc_common::models::ContainmentBackend::WindowsSandbox => { + let mut runner = windows_sandbox_lifecycle::WindowsSandboxRunner::new(); + let handle = + wxc_common::state_aware_dispatch::dispatch_state_aware_exec(&mut runner, parsed)?; + Ok(Box::new( + wxc_common::exec_stream::ExecSandboxProcess::from_exec_handle(handle)?, + )) + } #[cfg(all(target_os = "windows", feature = "isolation_session"))] wxc_common::models::ContainmentBackend::IsolationSession => { let mut runner = isolation_session_common::IsolationSessionRunner::new(); @@ -125,8 +171,6 @@ pub fn exec_state_aware( wxc_common::exec_stream::ExecSandboxProcess::from_exec_handle(handle)?, )) } - // Feature-off build: keep the documented `backend_unavailable` contract - // rather than falling through to the generic `unsupported_phase`. #[cfg(not(all(target_os = "windows", feature = "wslc")))] wxc_common::models::ContainmentBackend::Wslc => Err(wslc_unavailable()), _ => Err(MxcError::unsupported_phase(format!( @@ -146,9 +190,9 @@ pub fn exec_state_aware( fn parse_state_aware( request_json: &str, experimental: bool, + logger: &mut Logger, ) -> Result { - let mut logger = Logger::new(Mode::Buffer); - match wxc_common::config_parser::load_mxc_request_from_json(request_json, &mut logger) { + match wxc_common::config_parser::load_mxc_request_from_json(request_json, logger) { Ok(MxcRequest::StateAware(mut parsed)) => { parsed.request.experimental_enabled = experimental; Ok(parsed) @@ -188,7 +232,8 @@ pub fn run_state_aware_json( dry_run: bool, experimental: bool, ) -> Result { - let parsed = parse_state_aware(request_json, experimental)?; + let mut logger = Logger::new(Mode::Buffer); + let parsed = parse_state_aware(request_json, experimental, &mut logger)?; if matches!(parsed.phase, Phase::Exec) && !dry_run { return Err(Error::from(MxcError::malformed_request( @@ -197,7 +242,66 @@ pub fn run_state_aware_json( ))); } - match run_state_aware(parsed, dry_run).map_err(Error::from)? { + let phase = parsed.phase; + let phase_name = phase.as_str(); + let sandbox_id = parsed.sandbox_id.clone(); + let requested_sandbox_kind = parsed + .request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + let backend = resolve_backend(&parsed) + .map(|backend| backend.wire_name().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let correlation = phase_correlation(telemetry_active, phase, sandbox_id.as_deref()); + // Snapshot warnings buffered during telemetry init (e.g., provider + // registration failures) so we can surface them via the outgoing + // envelope. The ordinary streaming `spawn` path in lib.rs threads these + // through `ProcessWithWarnings::wrap`; the envelope-returning state-aware + // path had been silently dropping them. + let init_warnings = logger.take_warnings(); + let started = std::time::Instant::now(); + let mut outcome = run_state_aware(parsed, dry_run); + inject_warnings(&mut outcome, &init_warnings); + match phase { + Phase::Provision => { + telemetry::correlation_state::on_provision_outcome( + telemetry_active, + &correlation, + &outcome, + ); + } + Phase::Deprovision => { + if let Some(id) = sandbox_id.as_deref() { + telemetry::correlation_state::on_deprovision_outcome( + telemetry_active, + id, + dry_run, + &outcome, + ); + } + } + _ => {} + } + telemetry::emit_sdk_state_aware_with_kind( + telemetry_active, + requested_sandbox_kind, + telemetry::TelemetryContext { + backend: &backend, + phase: phase_name, + correlation_vector: &correlation, + }, + &outcome, + started.elapsed(), + ); + + match outcome.map_err(Error::from)? { DispatchOutcome::Envelope(value) => serde_json::to_string(&value).map_err(|e| { Error::from(MxcError::backend_error(format!( "serialising the response envelope failed: {e}" @@ -219,14 +323,65 @@ pub fn exec_state_aware_json( request_json: &str, experimental: bool, ) -> Result, Error> { - let parsed = parse_state_aware(request_json, experimental)?; + let mut logger = Logger::new(Mode::Buffer); + let parsed = parse_state_aware(request_json, experimental, &mut logger)?; if !matches!(parsed.phase, Phase::Exec) { return Err(Error::from(MxcError::malformed_request(format!( "streaming exec requires the exec phase, got {}", parsed.phase )))); } - exec_state_aware(parsed).map_err(Error::from) + let phase = parsed.phase; + let sandbox_id = parsed.sandbox_id.clone(); + let requested_sandbox_kind = parsed + .request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + let backend = resolve_backend(&parsed) + .map(|backend| backend.wire_name().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let correlation = phase_correlation(telemetry_active, phase, sandbox_id.as_deref()); + // Same warning-propagation contract as `run_state_aware_json` above: + // snapshot init-time warnings so the streaming caller sees them via the + // returned process handle's `warnings()`. + let init_warnings = logger.take_warnings(); + let started = std::time::Instant::now(); + match exec_state_aware(parsed) { + Ok(process) => { + let process = crate::ProcessWithWarnings::wrap(process, init_warnings); + Ok(wrap_state_aware_telemetry_process_with_kind( + process, + telemetry_active, + backend, + phase.as_str().to_string(), + correlation, + requested_sandbox_kind, + started, + )) + } + Err(error) => { + let outcome = Err(error.clone()); + telemetry::emit_sdk_state_aware_with_kind( + telemetry_active, + requested_sandbox_kind, + telemetry::TelemetryContext { + backend: &backend, + phase: phase.as_str(), + correlation_vector: &correlation, + }, + &outcome, + started.elapsed(), + ); + Err(Error::from(error)) + } + } } #[cfg(test)] @@ -235,6 +390,35 @@ mod tests { use wxc_common::models::{ContainmentBackend, ExecutionRequest}; use wxc_common::mxc_error::MxcErrorCode; use wxc_common::state_aware_request::Phase; + use wxc_common::telemetry::correlation_state::test_support::StoreDirGuard; + + #[test] + fn inactive_telemetry_does_not_create_a_correlation_vector() { + assert_eq!( + phase_correlation(false, Phase::Provision, None), + String::new() + ); + } + + #[test] + fn later_phase_without_a_persisted_record_seeds_a_disconnected_vector() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + + let a = phase_correlation(true, Phase::Start, Some("wsb:abcdef01")); + let b = phase_correlation(true, Phase::Start, Some("wsb:abcdef01")); + + assert!(telemetry::correlation_vector::is_relayable(&a)); + assert!(telemetry::correlation_vector::is_relayable(&b)); + // No provision ever persisted a root for this sandbox_id, so each call + // seeds its own fresh, disconnected vector instead of sharing a base. + let base_of = |cv: &str| cv.split('.').next().unwrap().to_string(); + assert_ne!( + base_of(&a), + base_of(&b), + "with no persisted record, repeated calls must not coincidentally share a base" + ); + } #[test] fn experimental_backend_requires_optin() { @@ -243,7 +427,6 @@ mod tests { phase: Phase::Provision, containment: Some(ContainmentBackend::WindowsSandbox), sandbox_id: None, - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -264,7 +447,6 @@ mod tests { phase: Phase::Exec, containment: Some(ContainmentBackend::Wslc), sandbox_id: Some("wslc:00000000000000000000000000000000".to_string()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -279,4 +461,280 @@ mod tests { assert_eq!(error.code, MxcErrorCode::BackendUnavailable); assert!(error.message.contains("experimental")); } + + #[cfg(not(all(target_os = "windows", feature = "wslc")))] + #[test] + fn feature_off_wslc_returns_backend_unavailable() { + let request = ExecutionRequest { + experimental_enabled: true, + ..ExecutionRequest::default() + }; + let parsed = ParsedStateAwareRequest { + request, + phase: Phase::Start, + containment: Some(ContainmentBackend::Wslc), + sandbox_id: Some("wslc:00000000000000000000000000000000".to_string()), + experimental_raw: None, + source_text: None, + }; + + let error = run_state_aware(parsed, false).unwrap_err(); + + assert_eq!(error.code, MxcErrorCode::BackendUnavailable); + assert!(error + .message + .contains("compiled without the `wslc` feature")); + } + + #[test] + fn parse_state_aware_preserves_parser_warnings_on_caller_logger() { + let mut logger = Logger::new(Mode::Buffer); + let parsed = super::parse_state_aware( + r#"{ + "phase": "provision", + "containment": "bubblewrap", + "experimental": {"bubblewrap": {"start": {}}}, + "process": {"commandLine": "echo hi"}, + "network": { + "proxy": {"builtinTestServer": true}, + "defaultPolicy": "block" + } + }"#, + false, + &mut logger, + ) + .unwrap(); + + assert_eq!(parsed.phase, Phase::Provision); + assert!( + logger + .take_warnings() + .iter() + .any(|warning| warning.contains("Bubblewrap network.proxy")), + "parser warnings should stay on the caller-owned logger" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn exec_state_aware_routes_windows_sandbox_exec_to_backend() { + let request = ExecutionRequest { + experimental_enabled: true, + ..ExecutionRequest::default() + }; + let parsed = ParsedStateAwareRequest { + request, + phase: Phase::Exec, + containment: Some(ContainmentBackend::WindowsSandbox), + sandbox_id: Some("wsb:abcd1234".to_string()), + experimental_raw: None, + source_text: None, + }; + + let error = match exec_state_aware(parsed) { + Ok(_) => panic!("expected the backend to reject the synthetic sandbox id"), + Err(error) => error, + }; + assert_ne!( + error.code, + MxcErrorCode::UnsupportedPhase, + "Windows Sandbox exec should dispatch to the backend-specific implementation" + ); + } + + /// `provision` seeds a vector and persists it once dispatch mints a + /// `sandbox_id`; every non-provision phase of the same lifecycle recalls + /// that persisted root and *spins* a distinct child off it, so all phases + /// share a telemetry base without any caller relay or sandbox_id-derived + /// base. + #[test] + fn engine_state_aware_correlation_base_shared_across_phases() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:12345678"; + + let provisioned = phase_correlation(true, Phase::Provision, None); + let provision_outcome: Result = Ok(DispatchOutcome::Envelope( + serde_json::json!({ "result": { "sandboxId": sandbox_id } }), + )); + telemetry::correlation_state::on_provision_outcome(true, &provisioned, &provision_outcome); + let base_prefix = provisioned + .split('.') + .next() + .expect("provisioned correlation vector has a base") + .to_string(); + + // Every phase in the same lifecycle spins that persisted root. + for phase in [Phase::Start, Phase::Exec, Phase::Stop, Phase::Deprovision] { + let spun = phase_correlation(true, phase, Some(sandbox_id)); + assert!( + spun.starts_with(&base_prefix), + "phase {phase:?} lost the base prefix: {spun}" + ); + assert_ne!(spun, provisioned, "phase {phase:?} did not spin"); + assert!( + telemetry::correlation_vector::is_relayable(&spun), + "phase {phase:?} produced a non-relayable vector: {spun}" + ); + } + } + + #[test] + fn dry_run_deprovision_keeps_the_shared_correlation_root() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:dryrun02"; + + let provisioned = phase_correlation(true, Phase::Provision, None); + let provision_outcome: Result = Ok(DispatchOutcome::Envelope( + serde_json::json!({ "result": { "sandboxId": sandbox_id } }), + )); + telemetry::correlation_state::on_provision_outcome(true, &provisioned, &provision_outcome); + telemetry::correlation_state::on_deprovision_outcome( + true, + sandbox_id, + true, + &Ok(DispatchOutcome::Envelope(serde_json::json!({}))), + ); + + let base_prefix = provisioned.split('.').next().unwrap().to_string(); + let later = phase_correlation(true, Phase::Stop, Some(sandbox_id)); + assert_eq!(later.split('.').next().unwrap(), base_prefix); + } + + /// `run_state_aware_json` maps engine errors through the shared + /// `telemetry::classify_mxc_error`. This is the same helper `spawn` in + /// `lib.rs` now uses, so if the mapping ever regresses in either + /// direction the two paths would drift; instead they drift together and + /// this test catches it in a single crate. + /// + /// We assert both halves independently: + /// * `run_state_aware_json` surfaces the expected `ErrorCode`s (so + /// `Error → MxcErrorCode` mapping is preserved). + /// * `telemetry::classify_mxc_error` maps those `MxcErrorCode`s to the + /// expected `FailureReason` (the actual shared classifier). + #[test] + fn engine_state_aware_error_codes_classify_via_shared_helper() { + use crate::error::ErrorCode; + use wxc_common::telemetry::FailureReason; + + // Malformed JSON — the JSON decoder rejects it, `parse_state_aware` + // wraps it as `MalformedRequest`, and the classifier maps that to + // `ConfigError`. This is the same path a user's typo takes. + let error = super::run_state_aware_json("{ not json", false, false).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + assert_eq!( + telemetry::classify_mxc_error(&MxcError::malformed_request(error.message)), + FailureReason::ConfigError + ); + + // Provision without containment — the dispatcher rejects it as + // `MalformedRequest` before ever reaching a backend. + let error = + super::run_state_aware_json(r#"{"phase":"provision"}"#, false, false).unwrap_err(); + assert_eq!(error.code, ErrorCode::MalformedRequest); + assert_eq!( + telemetry::classify_mxc_error(&MxcError::malformed_request(error.message)), + FailureReason::ConfigError + ); + + // Provision of an experimental backend without --experimental — + // `BackendUnavailable` → `InitError`; the shared classifier keeps + // streaming and state-aware attribution in lockstep. + let error = super::run_state_aware_json( + r#"{"phase":"provision","containment":"windows_sandbox"}"#, + false, + false, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::BackendUnavailable); + assert_eq!( + telemetry::classify_mxc_error(&MxcError::backend_unavailable(error.message)), + FailureReason::InitError + ); + } + + /// Telemetry providers are released between successive + /// `run_state_aware_json` calls, even on the error path. A leaked + /// provider reference on error would prevent shutdown from ever + /// happening — a follow-up call would then reuse a partially-torn-down + /// state. Two chained failing calls exercise this: if the first one + /// leaked, the second would see stale process context (or, worse, would + /// double-emit). + /// + /// We can't directly observe the ETW provider from user-space test code + /// on non-Windows platforms, but we can assert the observable contract: + /// each call finishes cleanly with the expected error, and the process + /// stays healthy across the two. + #[test] + fn engine_state_aware_provider_released_between_calls() { + use crate::error::ErrorCode; + for _ in 0..3 { + let error = super::run_state_aware_json( + r#"{"phase":"provision","containment":"windows_sandbox"}"#, + false, + false, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::BackendUnavailable); + } + } + + /// `inject_warnings` merges telemetry-init warnings into the + /// envelope's `result.warnings` array, dedupes across calls, and leaves + /// the outcome untouched when there are no warnings. + #[test] + fn engine_state_aware_inject_warnings_merges_into_envelope() { + // No warnings — outcome untouched (no `warnings` key added). + let mut outcome = Ok(DispatchOutcome::Envelope(serde_json::json!({ + "result": { "sandboxId": "iso:abc" } + }))); + super::inject_warnings(&mut outcome, &[]); + let value = match &outcome { + Ok(DispatchOutcome::Envelope(v)) => v, + other => panic!("unexpected: {other:?}"), + }; + assert!( + value["result"].get("warnings").is_none(), + "empty warning list should not add the field" + ); + + // With warnings — merged into `result.warnings`, order preserved. + let mut outcome = Ok(DispatchOutcome::Envelope(serde_json::json!({ + "result": { "sandboxId": "iso:abc" } + }))); + super::inject_warnings( + &mut outcome, + &["telemetry init failed".to_string(), "second".to_string()], + ); + let value = match &outcome { + Ok(DispatchOutcome::Envelope(v)) => v, + other => panic!("unexpected: {other:?}"), + }; + let warnings = value["result"]["warnings"] + .as_array() + .expect("warnings field should be a JSON array"); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0], "telemetry init failed"); + assert_eq!(warnings[1], "second"); + + // Duplicate suppression — merging the same set again is a no-op. + super::inject_warnings( + &mut outcome, + &["telemetry init failed".to_string(), "second".to_string()], + ); + let value = match &outcome { + Ok(DispatchOutcome::Envelope(v)) => v, + other => panic!("unexpected: {other:?}"), + }; + let warnings = value["result"]["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 2, "duplicates must be suppressed"); + + // Errors are untouched — `inject_warnings` only mutates envelope + // results (the error path renders separately). + let mut outcome: Result = + Err(MxcError::backend_unavailable("no backend")); + super::inject_warnings(&mut outcome, &["ignored".to_string()]); + assert!(outcome.is_err()); + } } diff --git a/src/core/wxc/Cargo.toml b/src/core/wxc/Cargo.toml index 346fb399f..2c4f1261b 100644 --- a/src/core/wxc/Cargo.toml +++ b/src/core/wxc/Cargo.toml @@ -37,6 +37,7 @@ nanvix_build_common = { path = "../../backends/nanvix/build_common" } [features] default = [] +test-support = ["wxc_common/test-support"] microvm = ["dep:nanvix_binaries", "nanvix_binaries/microvm", "mxc_engine/microvm"] wslc = ["dep:wslc_common", "wslc_common/link-wslcsdk", "mxc_engine/wslc"] hyperlight = ["dep:hyperlight_common", "hyperlight_common/hyperlight", "mxc_engine/hyperlight"] diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index e4b51a8fe..21db55b2a 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -5,7 +5,6 @@ mod audit; #[cfg(target_os = "windows")] use std::fmt::Write; -use std::fs; use std::process; use std::sync::{Mutex, OnceLock}; use std::time::Instant; @@ -13,9 +12,7 @@ use std::time::Instant; use appcontainer_common::appcontainer_runner::delete_app_container_profile; use clap::Parser; use wxc_common::cmdline::{cmdline_from_argv_for_context, CommandLineContext, CommandLineError}; -use wxc_common::config_parser::{ - load_mxc_request_with_options, load_request, LoadOptions, ParseError, -}; +use wxc_common::config_parser::{LoadOptions, ParseError}; use wxc_common::diagnostic::DiagnosticConfig; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ContainmentBackend, ExecutionRequest, ScriptResponse}; @@ -108,6 +105,29 @@ struct Cli { #[arg(long)] probe: bool, + /// Print the current persisted telemetry consent state as one-line JSON + /// and exit, without spawning a sandbox. The payload is the same typed + /// response as JSON maintenance action `status`. Windows-only: on + /// other platforms every field reports `not-applicable`/`false` and + /// nothing touches disk, since MXC does not collect telemetry there. See + /// `docs/telemetry/telemetry-consent-design.md`. + #[arg(long = "telemetry-consent-status")] + telemetry_consent_status: bool, + + /// Legacy non-mutating tombstone. Returns a migration error directing the + /// caller to the typed JSON consent maintenance envelope. + #[arg(long = "telemetry-consent-grant")] + telemetry_consent_grant: bool, + + /// Legacy non-mutating tombstone. Returns a migration error directing the + /// caller to JSON action `withdraw`. + #[arg(long = "telemetry-consent-revoke")] + telemetry_consent_revoke: bool, + + /// Legacy companion tombstone for the removed grant/revoke flow. + #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] + telemetry_consent_source: Option, + /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it /// launched, instead of refusing — clears a host wedged by an orphan left /// after a launcher hard-kill. DANGER: proofless, so it may also kill a @@ -208,6 +228,63 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { Ok(()) } +/// Handles the `--telemetry-consent-{status,grant,revoke}` fast paths. +/// +/// Returns `true` if one of the flags was handled (and the caller should +/// exit immediately), `false` if none were passed and normal execution +/// should proceed. Never spawns a sandbox, never touches config parsing, and +/// runs before COM/WinRT init — mirroring the `--probe` fast path. +/// +/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler +/// (identical across all three executors) so this fast path can't drift +/// between `wxc-exec`, `lxc-exec`, and `mxc-exec-mac`. The shared handler +/// returns the outcome as data; terminating the process is this binary's +/// job, not the foundation crate's. See +/// `docs/telemetry/telemetry-consent-design.md`. +fn handle_telemetry_consent_flags(cli: &Cli) -> bool { + let Some(outcome) = + telemetry::consent_cli::handle_consent_flags(&telemetry::consent_cli::ConsentCliFlags { + status: cli.telemetry_consent_status, + grant: cli.telemetry_consent_grant, + revoke: cli.telemetry_consent_revoke, + source: cli.telemetry_consent_source.as_deref(), + }) + else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + +fn handle_telemetry_consent_input(json: Option<&str>) -> bool { + let Some(json) = json else { + return false; + }; + let Some(outcome) = telemetry::consent_cli::handle_maintenance_input_json(json) else { + return false; + }; + let code = outcome.emit(); + if code != 0 { + std::process::exit(code); + } + true +} + +/// Read the request source (file path / base64 blob) once, returning the +/// decoded JSON. Reused by the maintenance probe, `--probe`, and the normal +/// request loader so a single source is only read once per invocation — a +/// correctness fix for named pipes, `/dev/stdin`, and process-substitution +/// paths that are drained by the first read. +fn decode_config_input_once(cli: &Cli) -> Option> { + let (input, is_base64) = config_input(cli)?; + Some(wxc_common::config_parser::decode_request_input( + &input, is_base64, + )) +} + fn command_override_from_cli( cli: &Cli, context: CommandLineContext, @@ -251,74 +328,6 @@ fn apply_command_override( } } -/// The plan for producing this phase's correlation vector, derived -/// from the phase and the relayed value. Returned by [`plan_correlation_vector`] -/// so the seed-vs-spin decision is unit-testable without touching the RNG/clock; -/// the caller executes the plan against the (nondeterministic) operators. -#[derive(Debug, PartialEq, Eq)] -enum CvPlan<'a> { - /// Mint a fresh vector. Used for `provision`, and for any non-provision phase - /// whose relayed value is missing, empty, or not relayable (malformed / - /// hostile) — so garbage never even reaches the `spin` operator. - Seed, - /// Spin the relayed value to derive this phase's child vector. Only planned - /// for a value [`is_relayable`](telemetry::correlation_vector::is_relayable) - /// vouches for, so `spin` here always builds on a real parent rather than - /// silently reseeding. - Spin(&'a str), -} - -/// Plans the Microsoft Correlation Vector (MS-CV) action for a state-aware phase. -/// -/// Provision always seeds a fresh random base. Every later phase spins the -/// relayed `incoming_cv` so sibling phases get distinct vectors that still share -/// the lifecycle prefix — but only when the relayed value is actually relayable -/// (a valid mutable or frozen vector). A missing, empty, or malformed relayed -/// value is planned as [`CvPlan::Seed`] so the `Spin` arm never stands in for a -/// reseed; the decision stays a pure function of `(is_provision, incoming_cv)` -/// with no RNG, so it is deterministically testable. -fn plan_correlation_vector(is_provision: bool, incoming_cv: Option<&str>) -> CvPlan<'_> { - match incoming_cv { - Some(cv) if !is_provision && telemetry::correlation_vector::is_relayable(cv) => { - CvPlan::Spin(cv) - } - _ => CvPlan::Seed, - } -} - -/// Executes the pure [`plan_correlation_vector`] plan against the (nondeterministic) -/// MS-CV operators, returning this phase's correlation vector. Empty when -/// telemetry is inactive so an inactive provider does no RNG/clock work and -/// provision output is unchanged. -fn compute_phase_correlation( - telemetry_active: bool, - is_provision: bool, - incoming_cv: Option<&str>, -) -> String { - if !telemetry_active { - return String::new(); - } - match plan_correlation_vector(is_provision, incoming_cv) { - CvPlan::Seed => telemetry::correlation_vector::seed(), - CvPlan::Spin(cv) => telemetry::correlation_vector::spin(cv), - } -} - -/// Injects the freshly-seeded correlation vector into a provision result -/// envelope (`{ "result": { ..., "correlationVector": "" } }`) so the client -/// can relay it into every later phase of the lifecycle. No-op when the outcome -/// is not a result envelope (exec-completed / error paths carry no cV). -fn inject_correlation_vector(outcome: &mut Result, cv: &str) { - if let Ok(DispatchOutcome::Envelope(value)) = outcome { - if let Some(result) = value.get_mut("result").and_then(|r| r.as_object_mut()) { - result.insert( - "correlationVector".to_string(), - serde_json::Value::String(cv.to_string()), - ); - } - } -} - /// On a state-aware dispatch failure, record the error only on the auxiliary /// diagnostic sinks (`--log-file` and the diagnostic pipe) via /// [`Logger::log_diagnostic_line`]. It is deliberately kept out of the primary @@ -335,57 +344,44 @@ fn log_state_aware_dispatch_error(logger: &mut Logger, error: &MxcError) { /// envelope to stdout and exits 1. Diagnostic logger output goes to stderr /// regardless of mode (per design §7.3 stream protocol — stdout reserved /// for the response envelope). -fn run_state_aware_main( - parsed: ParsedStateAwareRequest, - dry_run: bool, - experimental: bool, - logger: &mut Logger, -) -> ! { +fn run_state_aware_main(parsed: ParsedStateAwareRequest, dry_run: bool, logger: &mut Logger) -> ! { // Resolve attribution (phase + backend) and telemetry enablement BEFORE - // dispatch consumes `parsed`. State-aware telemetry is gated on - // `--experimental` exactly like the one-shot path, and reads the same typed - // `experimental.telemetry` field — the state-aware parser populates it while - // keeping the per-backend `experimental_raw` block for dispatch. A malformed - // telemetry block is rejected at parse time (as a state-aware envelope), so - // no client-error handling is needed here. + // dispatch consumes `parsed`. Telemetry is stable and independent of the + // `--experimental` gate used by experimental containment backends. let phase = parsed.phase.as_str(); - // Whether this invocation is the provision phase. Provision seeds a fresh - // random correlation-vector base and returns it in the result envelope; - // every later phase relays that base back and spins it. We deliberately - // ignore any client-supplied `correlationVector` on provision so a lifecycle - // can never be seeded with a stale or foreign vector. + // Whether this invocation is the provision phase: its `sandbox_id` doesn't + // exist yet, so it always seeds a fresh base rather than deriving one. let is_provision = phase == "provision"; - // The relayed correlation vector for non-provision phases (the base seeded at - // provision). Captured before `dispatch` consumes `parsed`. `None` for - // provision (which seeds its own below). - let incoming_cv = if is_provision { + // `sandbox_id` for non-provision phases, from which the lifecycle's shared + // correlation base is derived internally. Captured before `dispatch` + // consumes `parsed`. `None` for provision. + let sandbox_id = if is_provision { None } else { - parsed.correlation_vector.clone() + parsed.sandbox_id.clone() }; let resolved_backend = resolve_backend(&parsed).ok(); let backend = resolved_backend .as_ref() .map(|b| b.wire_name()) .unwrap_or("unknown"); - let telemetry_active = if experimental { - parsed - .request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, logger)) - .unwrap_or(false) - } else { - false - }; - - // Compute this phase's Microsoft Correlation Vector (MS-CV), executing the - // pure seed-vs-spin plan against the operators. Only computed when telemetry - // is active so an inactive provider does no work and provision output is - // unchanged. - let correlation = - compute_phase_correlation(telemetry_active, is_provision, incoming_cv.as_deref()); + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, logger)) + .unwrap_or(false); + + // This phase's Microsoft Correlation Vector (MS-CV), purely internal to + // MXC — no caller supplies or relays one. `provision` seeds a fresh + // vector (persisted post-dispatch below, once its `sandbox_id` exists); + // every later phase recalls that persisted lifecycle root and spins a + // child off it. Empty when telemetry is inactive. + let correlation = telemetry::correlation_state::pre_dispatch_vector( + telemetry_active, + is_provision, + sandbox_id.as_deref(), + ); // Attribute out-of-band emit paths (the console-control handler installed in // `main`, and the panic hook installed just below) to the resolved backend @@ -405,15 +401,27 @@ fn run_state_aware_main( } let started = Instant::now(); - let mut outcome = mxc_engine::run_state_aware(parsed, dry_run); + let outcome = mxc_engine::run_state_aware(parsed, dry_run); let elapsed = started.elapsed(); - // For provision, return the freshly-seeded correlation vector to the client - // by injecting it into the result envelope so it can be relayed into later - // phases. Gated on telemetry so provision output is unchanged when telemetry - // is off. - if is_provision && telemetry_active { - inject_correlation_vector(&mut outcome, &correlation); + // Persist (provision) or forget (deprovision) this lifecycle's + // correlation root now that the outcome — and, for provision, the + // freshly minted `sandbox_id` — is known. + if is_provision { + telemetry::correlation_state::on_provision_outcome( + telemetry_active, + &correlation, + &outcome, + ); + } else if phase == "deprovision" { + if let Some(id) = sandbox_id.as_deref() { + telemetry::correlation_state::on_deprovision_outcome( + telemetry_active, + id, + dry_run, + &outcome, + ); + } } // Emit lifecycle telemetry (and shut the provider down) before flushing the @@ -422,7 +430,6 @@ fn run_state_aware_main( telemetry_active, telemetry::TelemetryContext { backend, - sandbox_kind: backend, phase, correlation_vector: &correlation, }, @@ -711,6 +718,40 @@ fn install_dacl_ctrl_handler() { fn main() { let cli = Cli::parse().normalize_named_config_command(); + // --telemetry-consent-{status,grant,revoke}: administer the persisted + // consent flag and exit. Runs BEFORE `force_reclaim` env propagation and + // `recover_orphaned_state()` below: this is a read-only/local-file fast path with a 5-second + // client-side timeout (Node's consent getter), so it must not be gated + // behind unrelated recovery work that scans state files and may restore + // host DACLs — that could both time out the query (suppressing the + // prompt) and let a plain status read unexpectedly mutate filesystem + // ACLs. Matches the Linux/macOS executors, where this fast path also + // runs unconditionally immediately after CLI parsing. + if handle_telemetry_consent_flags(&cli) { + return; + } + // Decode the request source (file path / base64) once, up front, so the + // maintenance probe and the normal request loader below share the same + // decoded JSON — necessary for named pipes, `/dev/stdin`, and + // process-substitution paths that are drained by the first read. + let decoded_config: Option> = + decode_config_input_once(&cli); + let request_hint = decoded_config + .as_ref() + .and_then(|result| result.as_ref().ok()) + .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); + if matches!( + request_hint.as_ref().map(|hint| hint.kind()), + Some(wxc_common::config_parser::RequestKind::TelemetryConsent) + ) && handle_telemetry_consent_input( + decoded_config + .as_ref() + .and_then(|r| r.as_ref().ok()) + .map(String::as_str), + ) { + return; + } + // Propagate --force-reclaim via the environment so it reaches both the // in-process one-shot reconcile and the detached daemon. Set before any // backend dispatch or daemon spawn. @@ -756,16 +797,25 @@ fn main() { // (which probe doesn't need; deferring them shaves cold-start cost // off the SDK warm path). if cli.probe { - let policy = if let Some((data, is_b64)) = config_input(&cli) { + let policy = if let Some(decoded) = decoded_config.as_ref() { // Parse using the existing pipeline but route logger output to // an in-memory buffer that we discard — the probe must not // emit anything other than its JSON line on stdout. let mut probe_logger = Logger::new(Mode::Buffer); - match load_request(&data, &mut probe_logger, is_b64) { - Ok(r) => r.policy, + match decoded { + Ok(json) => { + match wxc_common::config_parser::load_request_from_json(json, &mut probe_logger) + { + Ok(r) => r.policy, + Err(_) => { + eprintln!("Error: failed to load probe config"); + eprint!("{}", probe_logger.get_buffer()); + process::exit(1); + } + } + } Err(_) => { eprintln!("Error: failed to load probe config"); - eprint!("{}", probe_logger.get_buffer()); process::exit(1); } } @@ -903,18 +953,27 @@ fn main() { // --probe is handled at the top of `main` (before COM init) for // SDK first-call latency. See note there. - // Determine config input and whether it's base64 - let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { - (b64.clone(), true) - } else if let Some(ref path) = cli.config { - (path.clone(), false) - } else if let Some(ref path) = cli.config_path { - (path.clone(), false) - } else if !cli.delete { - eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); - process::exit(1); - } else { - (String::new(), false) + // Determine config input. In delete mode the config is optional; every + // other path requires it. `decoded_config` above already read the source + // once — if it's populated, unpack the decoded JSON (or surface the + // decode error). If it's absent, either accept the empty state for + // delete mode or report the missing-config error. + let config_json: Option = match decoded_config { + Some(Ok(json)) => Some(json), + Some(Err(error)) => { + eprintln!("Request error"); + eprintln!("{error}"); + process::exit(1); + } + None => { + if !cli.delete { + eprintln!( + "Error: No config provided. Use a positional path, --config, or --config-base64" + ); + process::exit(1); + } + None + } }; let mut logger = Logger::new(if cli.debug { @@ -943,16 +1002,34 @@ fn main() { process::exit(if success { 0 } else { 1 }); } + // Non-delete paths always have a config JSON at this point (or exited + // above with the missing-config error). + let config_json = config_json.expect("config_json is Some on non-delete paths"); + // Load request — discriminates state-aware (top-level `phase` field) from // one-shot. State-aware failures emit a JSON envelope on stdout; one-shot // and pre-discrimination failures keep the existing diagnostic-on-stderr // convention. let has_command_override = has_cli_command(&cli); let load_opts = LoadOptions { - is_base64, + is_base64: false, allow_missing_command: has_command_override, }; - let request = match load_mxc_request_with_options(&config_data, &mut logger, load_opts) { + let parsed_request = if let Some(hint) = request_hint.as_ref() { + wxc_common::config_parser::load_mxc_request_from_json_with_hint_and_options( + &config_json, + &mut logger, + load_opts, + hint, + ) + } else { + wxc_common::config_parser::load_mxc_request_from_json_with_options( + &config_json, + &mut logger, + load_opts, + ) + }; + let request = match parsed_request { Ok(MxcRequest::OneShot(req)) => req, Ok(MxcRequest::StateAware(mut parsed)) => { let context = @@ -992,7 +1069,7 @@ fn main() { // `--experimental` on the CLI. parsed.request.experimental_enabled = cli.experimental; parsed.request.dry_run = cli.dry_run; - run_state_aware_main(parsed, cli.dry_run, cli.experimental, &mut logger) + run_state_aware_main(parsed, cli.dry_run, &mut logger) } Err(ParseError::OneShot(_)) | Err(ParseError::Decode(_)) => { eprint!("Request error\n{}", logger.get_buffer()); @@ -1010,17 +1087,12 @@ fn main() { request.testing_features_enabled = cli.allow_testing_features; request.dry_run = cli.dry_run; - // ── Telemetry init (experimental) ─────────────────────────────── - let telemetry_active = if request.experimental_enabled { - request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, &mut logger)) - .unwrap_or(false) - } else { - false - }; + // ── Telemetry init ────────────────────────────────────────────── + let telemetry_active = request + .telemetry + .as_ref() + .map(|c| telemetry::init(c, &mut logger)) + .unwrap_or(false); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still @@ -1154,18 +1226,13 @@ fn main() { ); } - // Log the raw input JSON config before any transformation. - let raw_json = if is_base64 { - wxc_common::encoding::base64_decode(&config_data) - .ok() - .and_then(|b| String::from_utf8(b).ok()) - } else { - fs::read_to_string(&config_data).ok() - }; - if let Some(json) = raw_json { - let _ = writeln!(logger, "SECTION: JSON Config"); - let _ = writeln!(logger, "{}", json.trim()); - } + // Log the raw input JSON config before any transformation. The input + // was already decoded exactly once at the top of `main` (see + // `decode_config_input_once`), so reuse the pre-decoded string + // instead of re-reading the source (a named pipe / `/dev/stdin` / + // process-substitution path can only be read once). + let _ = writeln!(logger, "SECTION: JSON Config"); + let _ = writeln!(logger, "{}", config_json.trim()); } let _ = writeln!(logger, "SECTION: Request simplified"); @@ -1267,13 +1334,6 @@ fn main() { eprintln!("{warning}"); } - if cli.dry_run { - handle_dry_run_exit(&response, &mut logger); - } - - display_script_results(&response, &mut logger); - - // ── Telemetry emit (experimental) ─────────────────────────────── telemetry::emit_completion( telemetry_active, &request.containment, @@ -1281,6 +1341,12 @@ fn main() { run_elapsed, ); + if cli.dry_run { + handle_dry_run_exit(&response, &mut logger); + } + + display_script_results(&response, &mut logger); + // Close diagnostic pipe. logger.close_diagnostics(); @@ -1317,10 +1383,12 @@ mod tests { use super::*; use clap::{CommandFactory, Parser}; + use wxc_common::config_parser::load_mxc_request_with_options; use wxc_common::encoding::base64_encode; use wxc_common::logger::Mode; use wxc_common::mxc_error::MxcErrorCode; use wxc_common::state_aware_request::MxcRequest; + use wxc_common::telemetry::correlation_state::test_support::StoreDirGuard; fn parse_cli(argv: &[&str]) -> Cli { Cli::try_parse_from(argv) @@ -1713,7 +1781,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("iso:wxc-1234".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1773,95 +1840,38 @@ mod tests { } #[test] - fn plan_correlation_vector_provision_seeds_fresh_vector() { - // Provision ignores any relayed value and always plans a fresh seed. - assert_eq!( - plan_correlation_vector(true, Some("BBBBBBBBBBBBBBBBBBBBBB.5")), - CvPlan::Seed - ); - assert_eq!(plan_correlation_vector(true, None), CvPlan::Seed); - } - - #[test] - fn plan_correlation_vector_phase_spins_relayed_base() { - // A relayable relayed value is spun to derive this phase's child vector. - let base = "AAAAAAAAAAAAAAAAAAAAAA.0"; - assert_eq!( - plan_correlation_vector(false, Some(base)), - CvPlan::Spin(base) - ); - // A valid frozen relayed value is also relayable (spin passes it through). - let frozen = "AAAAAAAAAAAAAAAAAAAAAA.0!"; - assert_eq!( - plan_correlation_vector(false, Some(frozen)), - CvPlan::Spin(frozen) - ); - } - - #[test] - fn plan_correlation_vector_phase_reseeds_for_missing_empty_or_malformed() { - // Missing / empty / non-relayable relayed values plan a fresh `Seed`, so - // the `Spin` arm never stands in for a reseed (garbage never reaches the - // `spin` operator). - for incoming in [None, Some(""), Some("garbage"), Some("short.0")] { - assert_eq!( - plan_correlation_vector(false, incoming), - CvPlan::Seed, - "non-relayable relay {incoming:?} must plan Seed" - ); - } + fn pre_dispatch_vector_is_empty_when_telemetry_inactive() { + // Inactive telemetry does no RNG/clock work regardless of phase. + assert!(telemetry::correlation_state::pre_dispatch_vector(false, true, None).is_empty()); + assert!(telemetry::correlation_state::pre_dispatch_vector( + false, + false, + Some("wsb:12345678") + ) + .is_empty()); } #[test] - fn compute_phase_correlation_is_empty_when_telemetry_inactive() { - // Inactive telemetry does no RNG/clock work regardless of phase/relay. - assert!(compute_phase_correlation(false, true, None).is_empty()); - assert!( - compute_phase_correlation(false, false, Some("AAAAAAAAAAAAAAAAAAAAAA.0")).is_empty() - ); - } + fn provision_seeds_and_later_phase_recalls_the_persisted_root() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:12345678"; - #[test] - fn compute_phase_correlation_spins_relayable_and_reseeds_garbage() { // Active provision seeds a fresh valid vector. - let provisioned = compute_phase_correlation(true, true, None); + let provisioned = telemetry::correlation_state::pre_dispatch_vector(true, true, None); assert!(telemetry::correlation_vector::is_relayable(&provisioned)); - // Active non-provision spins a relayable relay onto the shared prefix. - let base = "AAAAAAAAAAAAAAAAAAAAAA.0"; - let spun = compute_phase_correlation(true, false, Some(base)); - assert!(spun.starts_with(&format!("{base}.")), "{spun:?}"); - // Active non-provision with garbage reseeds to a fresh, unrelated vector. - let reseeded = compute_phase_correlation(true, false, Some("garbage")); - assert!(telemetry::correlation_vector::is_relayable(&reseeded)); - assert!(!reseeded.starts_with("garbage")); - } - #[test] - fn inject_correlation_vector_sets_field_on_envelope() { - let mut outcome: Result = Ok(DispatchOutcome::Envelope( - serde_json::json!({ "result": { "sandboxId": "iso:wxc-abc" } }), + // Persisting it (as `run_state_aware_main` does post-dispatch) lets a + // later phase recall it and spin a distinct child off the same base. + let outcome: Result = Ok(DispatchOutcome::Envelope( + serde_json::json!({ "result": { "sandboxId": sandbox_id } }), )); - inject_correlation_vector(&mut outcome, "AAAAAAAAAAAAAAAAAAAAAA.0"); - match outcome { - Ok(DispatchOutcome::Envelope(v)) => assert_eq!( - v["result"]["correlationVector"], - serde_json::json!("AAAAAAAAAAAAAAAAAAAAAA.0") - ), - _ => panic!("expected envelope"), - } - } + telemetry::correlation_state::on_provision_outcome(true, &provisioned, &outcome); - #[test] - fn inject_correlation_vector_noop_on_non_envelope() { - // Exec-completed / error outcomes carry no result envelope: injection is - // a no-op and must not panic. - let mut exit: Result = - Ok(DispatchOutcome::ExecCompleted { exit_code: 0 }); - inject_correlation_vector(&mut exit, "AAAAAAAAAAAAAAAAAAAAAA.0"); - assert!(matches!( - exit, - Ok(DispatchOutcome::ExecCompleted { exit_code: 0 }) - )); + let base_prefix = provisioned.split('.').next().unwrap(); + let spun = telemetry::correlation_state::pre_dispatch_vector(true, false, Some(sandbox_id)); + assert!(spun.starts_with(&format!("{base_prefix}.")), "{spun:?}"); + assert_ne!(spun, provisioned); } #[test] diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml index c88d83142..3dd9e7d94 100644 --- a/src/core/wxc_common/Cargo.toml +++ b/src/core/wxc_common/Cargo.toml @@ -9,7 +9,11 @@ microvm = ["dep:nanvix_common", "dep:uuid"] # Enables JSON Schema generation from the dedicated wire model. Off by default # so normal builds don't carry schemars. schema-gen = ["dep:schemars"] -# Exposes telemetry test harnesses to downstream integration tests. +# Exposes the telemetry test harnesses (the policy-key redirector) outside this +# crate's own unit tests, so downstream crates — notably `mxc_ffi` — can drive +# exact administrative-policy states instead of asserting weak set membership +# against whatever policy the host machine happens to have. Test-only: never +# enable it for a shipping build. test-support = [] [dependencies] @@ -21,11 +25,12 @@ thiserror = { workspace = true } base64 = { workspace = true } url = { workspace = true } getrandom = { workspace = true } +sha2 = { workspace = true } semver = "1" schemars = { version = "0.8", optional = true } nanvix_common = { path = "../../backends/nanvix/common", optional = true } uuid = { workspace = true, optional = true } -mxc_config_contract = { workspace = true } +mxc_config_contract = { workspace = true } mxc_telemetry = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] @@ -38,6 +43,7 @@ winreg = { workspace = true } libc = { workspace = true } [dev-dependencies] +filetime = "0.2" tempfile.workspace = true [build-dependencies] diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_6.rs b/src/core/wxc_common/src/config_contract_adapters/v0_6.rs index e0d41832d..4dfcd2abc 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_6.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_6.rs @@ -224,7 +224,7 @@ pub(crate) fn into_wire(request: contract::Request) -> wire::MxcConfig { version: Some(convert_version(version).to_owned()), phase: None, sandbox_id: None, - correlation_vector: None, + telemetry: None, container_id: container_id.into_option(), containment: containment.into_option().map(convert_containment), process: Some(convert_process(process)), @@ -506,7 +506,6 @@ mod tests { assert_eq!(wire.version, Some("0.6.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); @@ -539,7 +538,6 @@ mod tests { assert_eq!(wire.version, Some("0.6.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert_eq!(wire.container_id.as_deref(), Some("container-id")); assert!(matches!( wire.containment, @@ -642,7 +640,6 @@ mod tests { assert_eq!(wire.version, Some("0.6.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert_eq!(wire.container_id.as_deref(), Some("container-id")); assert!(matches!( wire.containment, diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs index d387b7281..20ab3e13e 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs @@ -254,7 +254,7 @@ pub(crate) fn into_wire(request: contract::Request) -> wire::MxcConfig { version: Some(convert_version(version).to_owned()), phase: None, sandbox_id: None, - correlation_vector: None, + telemetry: None, container_id: container_id.into_option(), containment: containment.into_option().map(convert_containment), process: Some(convert_process(process)), @@ -642,7 +642,6 @@ mod tests { assert_eq!(wire.version, Some("0.7.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); @@ -675,7 +674,6 @@ mod tests { assert_eq!(wire.version, Some("0.7.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert_eq!(wire.container_id.as_deref(), Some("container-id")); assert!(matches!( wire.containment, @@ -778,7 +776,6 @@ mod tests { assert_eq!(wire.version, Some("0.7.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert_eq!(wire.container_id.as_deref(), Some("container-id")); assert!(matches!( wire.containment, @@ -851,7 +848,6 @@ mod tests { assert_eq!(wire.version, Some("0.7.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(matches!( wire.containment, diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index c3e393771..52d744dce 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -57,9 +57,11 @@ impl ParseError { } } -#[derive(Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize)] #[serde(expecting = "a configuration object")] struct RequestDiscriminator<'a> { + #[serde(borrow, default)] + command: Option<&'a str>, #[serde(borrow, default, deserialize_with = "deserialize_present_raw")] phase: Option<&'a RawValue>, #[serde(borrow, default, deserialize_with = "deserialize_present_raw")] @@ -73,6 +75,92 @@ where <&RawValue>::deserialize(deserializer).map(Some) } +fn reject_legacy_telemetry_raw(experimental: Option<&str>) -> Result<(), WxcError> { + let Some(experimental) = experimental else { + return Ok(()); + }; + let value: serde_json::Value = serde_json::from_str(experimental) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + if value + .as_object() + .is_some_and(|object| object.contains_key("telemetry")) + { + return Err(WxcError::ConfigParse( + "'experimental.telemetry' has moved to the stable section; \ + use top-level 'telemetry' instead." + .to_string(), + )); + } + Ok(()) +} + +fn reject_legacy_telemetry_value(config: &serde_json::Value) -> Result<(), WxcError> { + if config + .get("experimental") + .and_then(serde_json::Value::as_object) + .is_some_and(|object| object.contains_key("telemetry")) + { + return Err(WxcError::ConfigParse( + "'experimental.telemetry' has moved to the stable section; \ + use top-level 'telemetry' instead." + .to_string(), + )); + } + Ok(()) +} + +/// Top-level decoded-request classification shared by the executor binaries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestKind { + TelemetryConsent, + OneShot, + StateAware, +} + +/// Borrowed parse hints extracted from the top level of a decoded request JSON. +/// +/// Executor binaries can parse this once, route telemetry-consent maintenance +/// requests immediately, and pass the same hint into the normal loaders so +/// one-shot requests do not pay a second discriminator parse. +#[derive(Debug, Clone)] +pub struct RequestParseHint { + kind: RequestKind, + experimental: Option>, + experimental_span: Option<(usize, usize)>, +} + +impl RequestParseHint { + pub fn kind(&self) -> RequestKind { + self.kind + } +} + +/// Parse the top-level request discriminator from an already-decoded JSON +/// string. +pub fn parse_request_hint_from_json(json_str: &str) -> Result { + config_deserialize::from_str::>(json_str) + .map_err(|error| WxcError::ConfigParse(error.to_string())) + .and_then(|discriminator| { + let experimental_span = discriminator + .experimental + .map(|raw| experimental_source_span(json_str, raw.get())) + .transpose()?; + Ok(RequestParseHint { + kind: if discriminator.command == Some("telemetryConsent") { + RequestKind::TelemetryConsent + } else if discriminator.phase.is_some() { + RequestKind::StateAware + } else { + RequestKind::OneShot + }, + experimental: discriminator + .experimental + .map(|raw| raw.get().to_string().into_boxed_str()), + experimental_span, + }) + }) +} + // ---------- Public API ---------- /// Options for [`load_mxc_request_with_options`]. @@ -120,7 +208,10 @@ pub fn load_request_with_options( opts: LoadOptions, ) -> Result { let result = (|| { - let json_str = decode_request_input_without_logging(input, opts.is_base64)?; + let json_str = decode_request_input(input, opts.is_base64)?; + let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(&json_str) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + reject_legacy_telemetry_raw(discriminator.experimental.map(|raw| raw.get()))?; let cfg: wire::MxcConfig = config_deserialize::from_str(&json_str) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; @@ -131,6 +222,73 @@ pub fn load_request_with_options( result } +/// Parse a one-shot request from a **raw JSON string** (already decoded — not +/// a file path or base64). For executors that decode the request once and +/// thread the JSON string through both the maintenance-probe check and this +/// loader, avoiding the double-read that would otherwise drain named pipes, +/// `/dev/stdin`, and process-substitution paths. +pub fn load_request_from_json( + json_str: &str, + logger: &mut Logger, +) -> Result { + load_request_from_json_with_options( + json_str, + logger, + LoadOptions { + is_base64: false, + allow_missing_command: false, + }, + ) +} + +/// Options-aware variant of [`load_request_from_json`]. It remains +/// crate-private because the options are only needed by the executor's +/// request-loading paths. +pub(crate) fn load_request_from_json_with_options( + json_str: &str, + logger: &mut Logger, + opts: LoadOptions, +) -> Result { + let hint = parse_request_hint_from_json(json_str)?; + load_request_from_json_with_hint_and_options(json_str, logger, opts, &hint) +} + +pub fn load_request_from_json_with_hint_and_options( + json_str: &str, + logger: &mut Logger, + opts: LoadOptions, + hint: &RequestParseHint, +) -> Result { + // `is_base64` is meaningless on an already-decoded JSON string; the field + // is kept in `LoadOptions` for signature parity with the from-input path. + let _ = opts.is_base64; + let result = (|| { + match hint.kind() { + RequestKind::TelemetryConsent => { + return Err(WxcError::ConfigParse( + "expected an execution request, got telemetry consent maintenance request" + .to_string(), + )); + } + RequestKind::StateAware => { + return Err(WxcError::ConfigParse( + "expected a one-shot execution request, got a state-aware lifecycle request" + .to_string(), + )); + } + RequestKind::OneShot => {} + } + reject_legacy_telemetry_raw(hint.experimental.as_deref())?; + + let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + + convert_wire_config(cfg, logger, true, opts.allow_missing_command) + })(); + log_one_shot_error(logger, &result); + result +} + /// Build a request from an already-parsed wire-format config [`Value`], running /// the same validation and wire→model mapping as [`load_request_with_options`] /// but without a base64 (or file) round-trip. For in-process callers (e.g. the @@ -144,6 +302,7 @@ pub fn load_request_from_value( allow_missing_command: bool, ) -> Result { let result = (|| { + reject_legacy_telemetry_value(&config)?; let cfg: wire::MxcConfig = config_deserialize::from_value(config) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; @@ -180,8 +339,7 @@ pub fn load_mxc_request_with_options( opts: LoadOptions, ) -> Result { let result = (|| { - let json_str = decode_request_input_without_logging(input, opts.is_base64) - .map_err(ParseError::Decode)?; + let json_str = decode_request_input(input, opts.is_base64).map_err(ParseError::Decode)?; parse_mxc_request_json(&json_str, logger, opts.allow_missing_command) })(); @@ -199,7 +357,45 @@ pub fn load_mxc_request_from_json( json_str: &str, logger: &mut Logger, ) -> Result { - let result = parse_mxc_request_json(json_str, logger, /*allow_missing_command=*/ false); + load_mxc_request_from_json_with_options( + json_str, + logger, + LoadOptions { + is_base64: false, + allow_missing_command: false, + }, + ) +} + +/// Options-aware variant of [`load_mxc_request_from_json`]. When +/// `LoadOptions::allow_missing_command` is set, a missing or empty +/// `process.commandLine` in the policy is tolerated and `script_code` is left +/// empty for the driver to fill in from a CLI override. +/// +/// Executor binaries call this after [`decode_request_input`] to avoid a +/// second read of the input source (file / named pipe / `/dev/stdin` / +/// process-substitution path) that the top-level [`load_mxc_request_with_options`] +/// would perform internally. +pub fn load_mxc_request_from_json_with_options( + json_str: &str, + logger: &mut Logger, + opts: LoadOptions, +) -> Result { + let hint = parse_request_hint_from_json(json_str).map_err(ParseError::Decode)?; + load_mxc_request_from_json_with_hint_and_options(json_str, logger, opts, &hint) +} + +pub fn load_mxc_request_from_json_with_hint_and_options( + json_str: &str, + logger: &mut Logger, + opts: LoadOptions, + hint: &RequestParseHint, +) -> Result { + // `is_base64` is meaningless on an already-decoded JSON string; the field + // is kept in `LoadOptions` for signature parity with the from-input path. + let _ = opts.is_base64; + let result = + parse_mxc_request_json_with_hint(json_str, hint, logger, opts.allow_missing_command); if let Err(error) = &result { log_error(logger, &error.message(), error.output()); } @@ -216,19 +412,28 @@ fn parse_mxc_request_json( logger: &mut Logger, allow_missing_command: bool, ) -> Result { - let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json_str) - .map_err(|error| ParseError::Decode(WxcError::ConfigParse(error.to_string())))?; + let hint = parse_request_hint_from_json(json_str).map_err(ParseError::Decode)?; + parse_mxc_request_json_with_hint(json_str, &hint, logger, allow_missing_command) +} - if discriminator.phase.is_some() { +fn parse_mxc_request_json_with_hint( + json_str: &str, + hint: &RequestParseHint, + logger: &mut Logger, + allow_missing_command: bool, +) -> Result { + if hint.kind() == RequestKind::StateAware { convert_wire_state_aware( json_str, - discriminator.experimental, + hint.experimental.as_deref(), + hint.experimental_span, logger, allow_missing_command, ) .map(MxcRequest::StateAware) .map_err(|e| ParseError::StateAware(MxcError::malformed_request(e.to_string()))) } else { + reject_legacy_telemetry_raw(hint.experimental.as_deref()).map_err(ParseError::OneShot)?; let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; convert_wire_config(cfg, logger, true, allow_missing_command) @@ -250,8 +455,11 @@ fn log_error(logger: &mut Logger, message: &str, output: ErrorOutput) { } } -/// Reads a request from disk or decodes it from base64. -fn decode_request_input_without_logging(input: &str, is_base64: bool) -> Result { +/// Decode a config/maintenance input supplied as a file path or base64 JSON. +/// +/// This performs no logging so callers can apply the correct output contract +/// after discriminating execution requests from maintenance commands. +pub fn decode_request_input(input: &str, is_base64: bool) -> Result { if is_base64 { let bytes = base64_decode(input).map_err(|_| { WxcError::ConfigParse("Failed to decode base64 configuration".to_string()) @@ -698,6 +906,22 @@ fn map_wire_containment(c: Option<&wire::Containment>) -> ContainmentBackend { } } +fn requested_sandbox_kind(c: Option<&wire::Containment>) -> &'static str { + match c { + None | Some(wire::Containment::Process) => "process", + Some(wire::Containment::ProcessContainer) => "processcontainer", + Some(wire::Containment::Vm) => "vm", + Some(wire::Containment::WindowsSandbox) => "windows_sandbox", + Some(wire::Containment::Lxc) => "lxc", + Some(wire::Containment::Microvm) => "microvm", + Some(wire::Containment::Hyperlight) => "hyperlight", + Some(wire::Containment::Wslc) => "wslc", + Some(wire::Containment::Seatbelt) => "seatbelt", + Some(wire::Containment::IsolationSession) => "isolation_session", + Some(wire::Containment::Bubblewrap) => "bubblewrap", + } +} + /// Validates a caller-specified `processContainer.captureDenials.outputPath`: it /// must be an absolute path whose parent directory already exists (the runner /// writes the JSON denials output file there after the workload exits). The @@ -776,11 +1000,6 @@ fn convert_wire_config( "'sandboxId' is only valid on state-aware lifecycle requests".to_string(), )); } - if cfg.correlation_vector.is_some() { - let msg = "'correlationVector' is only valid on state-aware lifecycle requests".to_string(); - logger.log_line(&msg); - return Err(WxcError::ConfigParse(msg)); - } // Backend sections present in the config (captured before fields move out). let present_backend_sections = present_backend_sections(&cfg); @@ -1320,7 +1539,7 @@ fn convert_wire_config( && policy.allowed_hosts.is_empty() && policy.blocked_hosts.is_empty() { - logger.log_line( + logger.warning_line( "WARNING: Bubblewrap network.proxy with defaultPolicy='block' is \ cooperative. HTTP_PROXY-aware clients (curl, requests, etc.) are \ denied at the proxy, but raw-socket clients that ignore HTTP_PROXY \ @@ -1426,14 +1645,10 @@ fn convert_wire_config( .to_string(); return Err(WxcError::ConfigParse(msg)); } - let telemetry = raw_exp.telemetry.map(|raw_t| TelemetryConfig { - enabled: raw_t.enabled, - }); ExperimentalConfig { test, windows_sandbox, wslc, - telemetry, } } else { ExperimentalConfig::default() @@ -1442,6 +1657,10 @@ fn convert_wire_config( // Top-level `seatbelt` config. Configs using `experimental.seatbelt` are // rejected above. let seatbelt = cfg.seatbelt.map(make_seatbelt_config); + let telemetry = cfg.telemetry.map(|raw| TelemetryConfig { + enabled: raw.enabled, + requested_sandbox_kind: Some(requested_sandbox_kind(cfg.containment.as_ref())), + }); // UI section. Capture presence before the typed mapping consumes `ui`: // `UiPolicy::default()` is full lockdown, so an explicit lockdown `ui` is @@ -1470,6 +1689,7 @@ fn convert_wire_config( policy, lxc_config, seatbelt, + telemetry, experimental_enabled: false, testing_features_enabled: false, experimental, @@ -1479,13 +1699,14 @@ fn convert_wire_config( fn convert_wire_state_aware( json: &str, - experimental: Option<&RawValue>, + experimental: Option<&str>, + experimental_span: Option<(usize, usize)>, logger: &mut Logger, allow_missing_command: bool, ) -> Result { let experimental_raw = experimental .map(|raw| { - config_deserialize::from_str::(raw.get()) + config_deserialize::from_str::(raw) .map_err(|error| WxcError::ConfigParse(error.to_string())) }) .transpose()?; @@ -1505,7 +1726,7 @@ fn convert_wire_state_aware( } } - let base_json = mask_state_aware_experimental(json, experimental)?; + let base_json = mask_state_aware_experimental_with_span(json, experimental, experimental_span)?; let mut cfg: wire::MxcConfig = config_deserialize::from_str(&base_json) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; @@ -1556,12 +1777,18 @@ fn convert_wire_state_aware( return Err(WxcError::ConfigParse(msg)); } } + if exp.contains_key("telemetry") { + return Err(WxcError::ConfigParse( + "'experimental.telemetry' has moved to the stable section; \ + use top-level 'telemetry' instead." + .to_string(), + )); + } } validate_experimental_backend_keys(containment.as_ref(), experimental_raw.as_ref())?; let sandbox_id = cfg.sandbox_id.clone(); - let correlation_vector = cfg.correlation_vector.clone(); // State-aware requests carry only cross-cutting fields (process / // filesystem / network / ui) plus the experimental backend block. One-shot- @@ -1595,7 +1822,6 @@ fn convert_wire_state_aware( // now-validated-absent stable sections so the shared one-shot converter // sees a clean surrogate and its `phase`/`sandboxId` guard passes. cfg.sandbox_id = None; - cfg.correlation_vector = None; cfg.experimental = None; cfg.seatbelt = None; cfg.process_container = None; @@ -1603,38 +1829,13 @@ fn convert_wire_state_aware( cfg.lifecycle = None; let require_process = phase == Phase::Exec; - let mut request = convert_wire_config(cfg, logger, require_process, allow_missing_command)?; - - // Populate the typed `experimental.telemetry` field from the raw block that - // was peeled off above. The rest of `experimental` is typed per-backend at - // dispatch time (from `experimental_raw`), but telemetry is a cross-cutting, - // backend-independent setting consumed the same way as the one-shot path — - // so it belongs on the typed request, not in a parallel raw-JSON reader. A - // present-but-malformed `telemetry` object is a client error (rejected here, - // exactly like the one-shot parser), not a silent disable. - if let Some(telemetry_val) = experimental_raw - .as_ref() - .and_then(|exp| exp.get("telemetry")) - { - let telemetry: TelemetryConfig = - serde_json::from_value(telemetry_val.clone()).map_err(|e| { - // Do not log here: state-aware parse errors are routed centrally - // and exactly once by the outer `load_mxc_request*` wrapper via - // `log_error(..., ErrorOutput::DiagnosticOnly)`. Logging here as - // well would produce a duplicate auxiliary diagnostic. - // Returning the error keeps stdout clean (envelope-owned) and - // yields a single auxiliary-sink line. - WxcError::ConfigParse(format!("invalid experimental.telemetry: {e}")) - })?; - request.experimental.telemetry = Some(telemetry); - } + let request = convert_wire_config(cfg, logger, require_process, allow_missing_command)?; Ok(ParsedStateAwareRequest { request, phase, containment, sandbox_id, - correlation_vector, experimental_raw, // Retain the decoded request text so the dispatcher can deserialize each // `experimental..` sub-slice positionally and report @@ -1667,16 +1868,29 @@ fn experimental_source_span(json: &str, raw: &str) -> Result<(usize, usize), Wxc Ok((start, end)) } +#[cfg(test)] fn mask_state_aware_experimental<'a>( json: &'a str, - experimental: Option<&RawValue>, + experimental: Option<&str>, +) -> Result, WxcError> { + let span = experimental + .map(|raw| experimental_source_span(json, raw)) + .transpose()?; + mask_state_aware_experimental_with_span(json, experimental, span) +} + +fn mask_state_aware_experimental_with_span<'a>( + json: &'a str, + experimental: Option<&str>, + span: Option<(usize, usize)>, ) -> Result, WxcError> { let Some(experimental) = experimental else { return Ok(Cow::Borrowed(json)); }; - let raw = experimental.get(); - let (start, end) = experimental_source_span(json, raw)?; + let (start, end) = span.ok_or_else(|| { + WxcError::ConfigParse("Unable to locate the experimental configuration block".to_string()) + })?; let (prefix, suffix) = match (json.get(..start), json.get(end..)) { (Some(prefix), Some(suffix)) => (prefix, suffix), _ => { @@ -1694,7 +1908,7 @@ fn mask_state_aware_experimental<'a>( let mut masked = String::with_capacity(json.len()); masked.push_str(prefix); let mut braces = ['{', '}'].into_iter(); - for byte in raw.bytes() { + for byte in experimental.bytes() { match byte { b'\r' => masked.push('\r'), b'\n' => masked.push('\n'), @@ -1866,22 +2080,17 @@ mod tests { #[test] fn state_aware_telemetry_populates_typed_field() { - // Telemetry is a cross-cutting setting: the state-aware parser must - // populate the typed `experimental.telemetry` field (consumed the same - // way as one-shot) while leaving the per-backend `experimental_raw` - // block intact for dispatch. + // Telemetry is a stable cross-cutting setting parsed identically for + // one-shot and state-aware requests. let json = r#"{ "phase": "provision", "containment": "isolation_session", - "experimental": {"telemetry": {"enabled": true}} + "telemetry": {"enabled": true}, + "experimental": {"isolation_session": {"provision": {}}} }"#; match load_mxc(json).unwrap() { MxcRequest::StateAware(p) => { - let telem = p - .request - .experimental - .telemetry - .expect("telemetry should be populated"); + let telem = p.request.telemetry.expect("telemetry should be populated"); assert_eq!(telem.enabled, Some(true)); // The raw block is still available for per-backend dispatch. assert!(p.experimental_raw.is_some()); @@ -1898,7 +2107,7 @@ mod tests { "experimental": {"isolation_session": {"start": {"opaqueFutureField": true}}} }"#; match load_mxc(json).unwrap() { - MxcRequest::StateAware(p) => assert!(p.request.experimental.telemetry.is_none()), + MxcRequest::StateAware(p) => assert!(p.request.telemetry.is_none()), MxcRequest::OneShot(_) => panic!("expected state-aware"), } } @@ -1910,7 +2119,7 @@ mod tests { let json = r#"{ "phase": "provision", "containment": "isolation_session", - "experimental": {"telemetry": 42} + "telemetry": 42 }"#; let r = load_mxc(json); assert!(matches!(r, Err(ParseError::StateAware(_))), "got {:?}", r); @@ -2001,7 +2210,7 @@ mod tests { let json = r#"{ "phase": "provision", "containment": "isolation_session", - "experimental": {"telemetry": 42} + "telemetry": 42 }"#; let encoded = base64_encode(json.as_bytes()); @@ -2024,12 +2233,30 @@ mod tests { let logged = std::fs::read_to_string(&log_path).unwrap(); assert_eq!( - logged.matches("invalid experimental.telemetry").count(), + logged.matches("telemetry").count(), 1, "expected exactly one auxiliary diagnostic, got: {logged:?}" ); } + #[test] + fn state_aware_experimental_telemetry_reports_migration() { + let json = r#"{ + "phase": "provision", + "containment": "isolation_session", + "experimental": {"telemetry": {"enabled": true}} + }"#; + let error = load_mxc(json).unwrap_err(); + let message = match &error { + ParseError::StateAware(error) => error.message.as_str(), + _ => panic!("expected state-aware error, got {error:?}"), + }; + assert!( + message.contains("'experimental.telemetry' has moved to the stable section"), + "got {error:?}" + ); + } + #[test] fn state_aware_non_object_experimental_is_rejected() { // A non-object `experimental` (here a bare number) is a hard parse error @@ -2056,7 +2283,7 @@ mod tests { "experimental": null }"#; match load_mxc(json).unwrap() { - MxcRequest::StateAware(p) => assert!(p.request.experimental.telemetry.is_none()), + MxcRequest::StateAware(p) => assert!(p.request.telemetry.is_none()), MxcRequest::OneShot(_) => panic!("expected state-aware"), } } @@ -2161,7 +2388,11 @@ mod tests { ] { let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); - let masked = mask_state_aware_experimental(json, discriminator.experimental).unwrap(); + let masked = mask_state_aware_experimental( + json, + discriminator.experimental.map(|raw| raw.get()), + ) + .unwrap(); assert_eq!(masked.len(), json.len()); let config: wire::MxcConfig = config_deserialize::from_str(&masked).unwrap(); @@ -2173,7 +2404,9 @@ mod tests { fn state_aware_mask_handles_whitespace_only_multiline_object() { let json = "{\n \"phase\": \"provision\",\n \"experimental\": {\r\n \r\n }\n}"; let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); - let masked = mask_state_aware_experimental(json, discriminator.experimental).unwrap(); + let masked = + mask_state_aware_experimental(json, discriminator.experimental.map(|raw| raw.get())) + .unwrap(); assert_eq!(masked.len(), json.len()); assert_eq!( @@ -2212,7 +2445,9 @@ mod tests { let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json).unwrap(); let raw = discriminator.experimental.unwrap().get(); let (start, end) = experimental_source_span(json, raw).unwrap(); - let masked = mask_state_aware_experimental(json, discriminator.experimental).unwrap(); + let masked = + mask_state_aware_experimental(json, discriminator.experimental.map(|raw| raw.get())) + .unwrap(); // The masked span replaces content with exactly one `{`, one `}`, // spaces, and preserved newlines; everything outside is byte-identical. @@ -3285,8 +3520,8 @@ mod tests { assert!(result.is_err()); } - /// A blank grant names nothing, and previously flowed through to backends - /// that treat an empty path as "unset" (e.g. a NULL working directory). + /// A blank grant names nothing and must be rejected before backend + /// execution, rather than being interpreted as an unset path. #[test] fn block_blank_filesystem_paths() { for blank in ["", " "] { @@ -4341,8 +4576,13 @@ mod tests { let req = load_request(&encoded, &mut logger, true).unwrap(); assert!(req.policy.network_proxy.is_enabled()); assert_eq!(req.policy.default_network_policy, NetworkPolicy::Block); - // Warning is best-effort surfaced via the logger; the request still - // succeeds. + assert!( + logger + .take_warnings() + .iter() + .any(|warning| warning.contains("Bubblewrap network.proxy")), + "warning should be retained for callers" + ); } #[test] @@ -4928,9 +5168,10 @@ mod tests { } #[test] - fn one_shot_rejects_correlation_vector_field() { - // `correlationVector` is a state-aware-only relay field; a one-shot - // payload carrying it must be rejected, mirroring `phase`/`sandboxId`. + fn correlation_vector_is_not_an_accepted_wire_field() { + // The correlation vector is purely internal to MXC and generated from + // the state-aware `sandboxId`; no config surface accepts one from a + // caller. `deny_unknown_fields` rejects it like any other unknown key. let json = r#"{"process": {"commandLine": "echo hi"}, "correlationVector": "AAAAAAAAAAAAAAAAAAAAAA.0"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -4938,8 +5179,29 @@ mod tests { let err = load_request(&encoded, &mut logger, true).unwrap_err(); let msg = format!("{err}"); assert!( - msg.contains("correlationVector") && msg.contains("state-aware"), - "one-shot path should reject 'correlationVector', got: {msg}" + msg.contains("correlationVector"), + "unknown field 'correlationVector' should be rejected, got: {msg}" + ); + } + + #[test] + fn state_aware_request_rejects_correlation_vector_field() { + let json = r#"{ + "phase": "start", + "sandboxId": "wsb:12345678", + "containment": "windows_sandbox", + "correlationVector": "AAAAAAAAAAAAAAAAAAAAAA.0" + }"#; + let mut logger = test_logger(); + + let err = load_mxc_request_from_json(json, &mut logger).unwrap_err(); + let msg = match err { + ParseError::StateAware(error) => error.to_string(), + ParseError::Decode(error) | ParseError::OneShot(error) => error.to_string(), + }; + assert!( + msg.contains("correlationVector"), + "state-aware path should reject 'correlationVector', got: {msg}" ); } @@ -6112,36 +6374,137 @@ mod tests { let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - assert!(req.experimental.telemetry.is_none()); + assert!(req.telemetry.is_none()); } #[test] fn telemetry_enabled_true() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":true}}}"#; + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let telem = req.experimental.telemetry.expect("telemetry should be set"); + let telem = req.telemetry.expect("telemetry should be set"); assert_eq!(telem.enabled, Some(true)); + assert_eq!(telem.requested_sandbox_kind, Some("process")); + } + + #[test] + fn telemetry_records_abstract_requested_sandbox_kind() { + let json = r#"{"process":{"commandLine":"echo hi"},"containment":"vm","telemetry":{"enabled":true}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let req = load_request(&encoded, &mut logger, true).unwrap(); + let telem = req.telemetry.expect("telemetry should be set"); + assert_eq!(telem.requested_sandbox_kind, Some("vm")); } #[test] fn telemetry_enabled_false() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{"enabled":false}}}"#; + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{"enabled":false}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let telem = req.experimental.telemetry.expect("telemetry should be set"); + let telem = req.telemetry.expect("telemetry should be set"); assert_eq!(telem.enabled, Some(false)); } #[test] fn telemetry_empty_object() { - let json = r#"{"process":{"commandLine":"echo hi"},"experimental":{"telemetry":{}}}"#; + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); - let telem = req.experimental.telemetry.expect("telemetry should be set"); + let telem = req.telemetry.expect("telemetry should be set"); assert_eq!(telem.enabled, None); } + + #[test] + fn telemetry_rejects_unknown_fields() { + let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{"enable":true}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + + assert!(error.to_string().contains("telemetry.enable")); + } + + #[test] + fn experimental_telemetry_reports_migration() { + let json = r#"{ + "process":{"commandLine":"echo hi"}, + "experimental":{"telemetry":{"enabled":true}} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + error + .to_string() + .contains("'experimental.telemetry' has moved to the stable section"), + "got {error:?}" + ); + } + + #[test] + fn null_experimental_telemetry_reports_migration() { + let json = r#"{ + "process":{"commandLine":"echo hi"}, + "experimental":{"telemetry":null} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!( + error + .to_string() + .contains("'experimental.telemetry' has moved to the stable section"), + "got {error:?}" + ); + } + + #[test] + fn current_schema_rejects_experimental_telemetry() { + let json = r#"{ + "version":"0.8.0-alpha", + "process":{"commandLine":"echo hi"}, + "experimental":{"telemetry":{"enabled":true}} + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!(error + .to_string() + .contains("'experimental.telemetry' has moved")); + } + + #[test] + fn load_request_from_value_legacy_schema_rejects_experimental_telemetry() { + let config = serde_json::json!({ + "version": "0.7.0-alpha", + "process": { "commandLine": "echo hi" }, + "experimental": { "telemetry": { "enabled": true } } + }); + let mut logger = test_logger(); + let error = load_request_from_value(config, &mut logger, false).unwrap_err(); + assert!( + error + .to_string() + .contains("'experimental.telemetry' has moved to the stable section"), + "got {error:?}" + ); + } + + #[test] + fn load_request_from_value_current_schema_rejects_experimental_telemetry() { + let config = serde_json::json!({ + "version": "0.8.0-alpha", + "process": { "commandLine": "echo hi" }, + "experimental": { "telemetry": { "enabled": true } } + }); + let mut logger = test_logger(); + let error = load_request_from_value(config, &mut logger, false).unwrap_err(); + assert!(error + .to_string() + .contains("'experimental.telemetry' has moved")); + } } diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 0e18d36c6..60391d08e 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -831,17 +831,19 @@ pub struct ExperimentalConfig { pub windows_sandbox: Option, /// WSL Container (WSLC SDK) backend (experimental). pub wslc: Option, - /// Telemetry configuration (experimental). - pub telemetry: Option, } -/// Telemetry configuration parsed from the JSON config `experimental.telemetry` section. +/// Telemetry configuration parsed from the top-level JSON config `telemetry` section. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct TelemetryConfig { - /// Explicit telemetry override. - /// `Some(true)` = force on, `Some(false)` = force off, `None` = disabled (default off). + /// Explicit telemetry opt-in for this invocation. + /// `Some(true)` = opt in (still subject to consent and policy), + /// `Some(false)` = force off, `None` = off. pub enabled: Option, + /// Caller-requested containment kind, retained for telemetry attribution. + #[serde(skip)] + pub requested_sandbox_kind: Option<&'static str>, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -866,6 +868,8 @@ pub struct ExecutionRequest { pub lxc_config: LxcConfig, /// Seatbelt (macOS) backend configuration (used when containment == Seatbelt). pub seatbelt: Option, + /// Per-invocation telemetry configuration. + pub telemetry: Option, /// Whether the --experimental flag was passed. pub experimental_enabled: bool, /// Whether the --allow-testing-features flag was passed. Gates testing-only, diff --git a/src/core/wxc_common/src/state_aware_dispatch.rs b/src/core/wxc_common/src/state_aware_dispatch.rs index 521fa9202..38bb33ba0 100644 --- a/src/core/wxc_common/src/state_aware_dispatch.rs +++ b/src/core/wxc_common/src/state_aware_dispatch.rs @@ -817,7 +817,6 @@ mod tests { phase, containment: Some(ContainmentBackend::IsolationSession), sandbox_id: sandbox_id.map(String::from), - correlation_vector: None, experimental_raw: exp, source_text: None, } @@ -1065,7 +1064,6 @@ mod tests { phase: Phase::Provision, containment: Some(ContainmentBackend::Wslc), sandbox_id: None, - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1080,7 +1078,6 @@ mod tests { phase: Phase::Provision, containment: None, sandbox_id: None, - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1095,7 +1092,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("iso:wxc-abcd1234".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1112,7 +1108,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("wsb:deadbeef".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1129,7 +1124,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("wslc:deadbeef".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1143,7 +1137,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("unknownxyz:abc".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1158,7 +1151,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("no-colon".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; diff --git a/src/core/wxc_common/src/state_aware_request.rs b/src/core/wxc_common/src/state_aware_request.rs index f1cb9d3fa..d4f330046 100644 --- a/src/core/wxc_common/src/state_aware_request.rs +++ b/src/core/wxc_common/src/state_aware_request.rs @@ -85,11 +85,6 @@ pub struct ParsedStateAwareRequest { /// Present iff the request carried a `sandboxId` field. Required for all /// non-provision phases; absent for `provision`. pub sandbox_id: Option, - /// Present iff the request carried a `correlationVector` field — the MS-CV - /// seeded at `provision` and relayed by the client into every later phase so - /// all phases of one lifecycle share a telemetry base prefix. Absent for - /// `provision` (which seeds its own) and when telemetry is not in use. - pub correlation_vector: Option, /// Raw `experimental` JSON object (un-narrowed). Shape: /// `{ : { : , ... }, ... }`. /// `deserialize_config` navigates the two layers. @@ -248,7 +243,6 @@ mod tests { phase, containment: None, sandbox_id: None, - correlation_vector: None, experimental_raw: exp, source_text: None, } @@ -266,7 +260,6 @@ mod tests { phase, containment: None, sandbox_id: None, - correlation_vector: None, experimental_raw, source_text: Some(source_text.to_owned().into_boxed_str()), } @@ -383,7 +376,7 @@ mod tests { let source_text = "\ { \"sandboxId\": \"iso:wxc-abcd1234\", - \"correlationVector\": \"cv.1\", + \"_comment\": \"filler line to shift the offending value's line number\", \"experimental\": { \"isolation_session\": { \"start\": { @@ -577,7 +570,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: None, - correlation_vector: None, experimental_raw: Some(json!({ "isolation_session": { "start": { "wrong_field": 42 } } })), diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 8e0241689..50d1d93e7 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -241,6 +241,10 @@ pub fn get_status() -> ConsentStatus { platform::read_status() } +pub(crate) fn local_app_data_dir() -> Option { + platform::local_app_data_dir() +} + #[cfg(test)] pub(crate) fn set_consent(granted: bool, source: &str) -> Result<(), String> { platform::with_store_lock(|| platform::write(granted, source)) @@ -705,7 +709,7 @@ mod platform { .or_else(crate::telemetry::consent::test_support::inherited_local_app_data_override) } - fn local_app_data_dir() -> Option { + pub(super) fn local_app_data_dir() -> Option { #[cfg(any(test, all(feature = "test-support", debug_assertions)))] if let Some(over) = debug_local_app_data_override() { return Some(over); @@ -1185,6 +1189,7 @@ mod platform { #[cfg(not(target_os = "windows"))] mod platform { use super::{ConsentState, ConsentStatus, ConsentStatusReason}; + use std::path::PathBuf; pub(super) fn with_store_lock( operation: impl FnOnce() -> Result, @@ -1200,6 +1205,10 @@ mod platform { } } + pub(super) fn local_app_data_dir() -> Option { + None + } + pub(super) fn read_status_unlocked() -> ConsentStatus { read_status() } @@ -1234,9 +1243,11 @@ mod platform { // modules can safely mutate the process-global consent-store override // without racing each other under parallel test execution. // -// This redirects `MXC_TEST_LOCALAPPDATA_OVERRIDE`, a debug-build-only escape -// hatch (see `platform::debug_local_app_data_override` above) — never the -// real `LOCALAPPDATA` variable, and never present at all in a release build. +// This keeps the debug-only override in process-local test state by default, +// and only stamps `MXC_TEST_LOCALAPPDATA_OVERRIDE` onto an explicit child +// `Command` when a test asks for inheritance. The real `LOCALAPPDATA` variable +// is never touched here, and the override path is compiled out of release +// binaries entirely. // --------------------------------------------------------------------------- #[cfg(any(test, all(feature = "test-support", debug_assertions)))] @@ -1266,10 +1277,6 @@ pub mod test_support { pub struct LocalAppDataGuard { _lock: MutexGuard<'static, ()>, #[cfg(target_os = "windows")] - previous: Option, - #[cfg(target_os = "windows")] - previous_owner: Option, - #[cfg(target_os = "windows")] previous_override: Option, } @@ -1277,24 +1284,15 @@ pub mod test_support { #[cfg(target_os = "windows")] pub fn set(path: &Path) -> Self { let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let previous = std::env::var_os(LOCAL_APPDATA_OVERRIDE_ENV); - let previous_owner = std::env::var_os(LOCAL_APPDATA_OVERRIDE_OWNER_ENV); let previous_override = LOCAL_APP_DATA_OVERRIDE .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); - std::env::set_var(LOCAL_APPDATA_OVERRIDE_ENV, path); - std::env::set_var( - LOCAL_APPDATA_OVERRIDE_OWNER_ENV, - std::process::id().to_string(), - ); *LOCAL_APP_DATA_OVERRIDE .lock() .unwrap_or_else(|e| e.into_inner()) = Some(path.to_path_buf()); Self { _lock: lock, - previous, - previous_owner, previous_override, } } @@ -1328,20 +1326,20 @@ pub mod test_support { Some(path) } + #[cfg(test)] + pub(crate) fn apply_inherited_override(command: &mut std::process::Command, path: &Path) { + command.env(LOCAL_APPDATA_OVERRIDE_ENV, path).env( + LOCAL_APPDATA_OVERRIDE_OWNER_ENV, + std::process::id().to_string(), + ); + } + #[cfg(target_os = "windows")] impl Drop for LocalAppDataGuard { fn drop(&mut self) { *LOCAL_APP_DATA_OVERRIDE .lock() .unwrap_or_else(|e| e.into_inner()) = self.previous_override.clone(); - match &self.previous { - Some(v) => std::env::set_var(LOCAL_APPDATA_OVERRIDE_ENV, v), - None => std::env::remove_var(LOCAL_APPDATA_OVERRIDE_ENV), - } - match &self.previous_owner { - Some(v) => std::env::set_var(LOCAL_APPDATA_OVERRIDE_OWNER_ENV, v), - None => std::env::remove_var(LOCAL_APPDATA_OVERRIDE_OWNER_ENV), - } } } } @@ -1382,6 +1380,22 @@ mod tests { } } + #[test] + fn blocking_task_propagates_worker_panic_without_hanging() { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let propagated = std::panic::catch_unwind(|| { + block_on(BlockingTask::spawn(|| panic!("worker panic"))); + }) + .is_err(); + sender.send(propagated).unwrap(); + }); + + assert!(receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("blocking task did not complete after its worker panicked")); + } + #[test] fn consent_state_as_str_is_stable() { assert_eq!(ConsentState::Granted.as_str(), "granted"); @@ -2215,24 +2229,9 @@ mod tests { // With no debug override set, the real per-user known-folder // path must still resolve (it always exists on a real Windows // profile), so exercise that fallback without touching the real - // consent file itself. Locks ENV_LOCK directly (rather than via - // LocalAppDataGuard) since this test removes the override - // entirely instead of redirecting it. - let _lock = super::super::test_support::ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let previous = std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE"); - let previous_owner = std::env::var_os("MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"); - std::env::remove_var("MXC_TEST_LOCALAPPDATA_OVERRIDE"); - std::env::remove_var("MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"); + // file itself. let resolved = crate::telemetry::consent::platform::known_folder_local_app_data_for_test(); - if let Some(v) = previous { - std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE", v); - } - if let Some(v) = previous_owner { - std::env::set_var("MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", v); - } assert!( resolved.is_some(), "known-folder API should resolve a real path" @@ -2244,7 +2243,9 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let _guard = LocalAppDataGuard::set(tmp.path()); - let status = std::process::Command::new(std::env::current_exe().unwrap()) + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + super::super::test_support::apply_inherited_override(&mut command, tmp.path()); + let status = command .arg("child_process_local_app_data_override_probe") .arg("--nocapture") .env("MXC_CHILD_OVERRIDE_PROBE", tmp.path()) diff --git a/src/core/wxc_common/src/telemetry/consent_cli.rs b/src/core/wxc_common/src/telemetry/consent_cli.rs new file mode 100644 index 000000000..f9b569cdb --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent_cli.rs @@ -0,0 +1,844 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared telemetry-consent maintenance handling for executor binaries. +//! +//! The executor binaries share this CLI surface; persistence remains +//! platform-dependent in [`super::consent`]. + +use std::io::{IsTerminal, Write}; + +use super::{consent, consent_prompt, policy}; +use crate::wire; + +const PRESENTER_PROTOCOL_ENV: &str = "MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL"; + +/// Telemetry-consent CLI flags. +#[derive(Debug, Clone, Copy)] +pub struct ConsentCliFlags<'a> { + /// `--telemetry-consent-status` + pub status: bool, + /// `--telemetry-consent-grant` + pub grant: bool, + /// `--telemetry-consent-revoke` + pub revoke: bool, + /// `--telemetry-consent-source`; defaults to `"cli"` when absent. + pub source: Option<&'a str>, +} + +/// Output and exit code for a handled consent command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsentCliOutcome { + /// Line to print to stdout, if any (the JSON status contract). + pub stdout: Option, + /// Line to print to stderr, if any. + pub stderr: Option, + /// Process exit code: `0` on success, `64` (`EX_USAGE`) for mutually + /// exclusive flags, `1` for a failed write or serialization. + pub exit_code: i32, +} + +impl ConsentCliOutcome { + /// Prints the outcome's stdout/stderr lines and returns the exit code the + /// caller should terminate with. + pub fn emit(&self) -> i32 { + if let Some(out) = &self.stdout { + println!("{out}"); + } + if let Some(err) = &self.stderr { + eprintln!("{err}"); + } + self.exit_code + } + + fn failure(message: String, exit_code: i32) -> Self { + Self { + stdout: None, + stderr: Some(message), + exit_code, + } + } + + fn json(response: &wire::TelemetryConsentMaintenanceResponse) -> Self { + match serde_json::to_string(response) { + Ok(json) => Self { + stdout: Some(json), + stderr: None, + exit_code: 0, + }, + Err(error) => Self::failure( + format!("Error: failed to serialize telemetry consent response: {error}"), + 1, + ), + } + } +} + +/// Handle legacy consent flags. Returns `None` when no flag was supplied. +pub fn handle_consent_flags(flags: &ConsentCliFlags<'_>) -> Option { + if !(flags.status || flags.grant || flags.revoke || flags.source.is_some()) { + return None; + } + + if flags.grant || flags.revoke || flags.source.is_some() { + return Some(ConsentCliOutcome::failure( + "Error: --telemetry-consent-grant, --telemetry-consent-revoke, and \ + --telemetry-consent-source no longer change consent. Use the typed \ + telemetry-consent JSON maintenance envelope instead." + .to_string(), + 64, + )); + } + + Some(ConsentCliOutcome::json(&maintenance_response( + wire::TelemetryConsentAction::Status, + wire::TelemetryConsentResult::Status, + None, + None, + None, + ))) +} + +/// Handle a typed consent-maintenance envelope, if present. +/// +/// Convenience wrapper that decodes `input` (either a file path or a base64 +/// blob) into JSON and delegates to [`handle_maintenance_input_json`]. Callers +/// that already hold the decoded JSON string — typically executor binaries +/// that decode the request once and thread the JSON through to both the +/// maintenance probe and the normal request loader — should call +/// [`handle_maintenance_input_json`] directly to avoid re-reading the input +/// source. Re-reading matters for regular files (duplicate I/O + parsing) and +/// is a correctness issue for named pipes, `/dev/stdin`, and process- +/// substitution paths, which are drained by the first read and fail on the +/// second. +pub fn handle_maintenance_input(input: &str, is_base64: bool) -> Option { + let json = match crate::config_parser::decode_request_input(input, is_base64) { + Ok(json) => json, + Err(_) => return None, + }; + handle_maintenance_input_json(&json) +} + +/// Handle a typed consent-maintenance envelope, given an already-decoded JSON +/// string. +/// +/// The single-source-of-truth path shared with [`handle_maintenance_input`]. +/// Returns `None` when the JSON does not carry the `telemetryConsent` +/// discriminator; executor binaries treat that as "not a maintenance request" +/// and continue to the normal request loader with the same JSON. +/// +/// This function is deliberately `pub` (not `pub(crate)`) because the sibling +/// executor crates `wxc`, `lxc`, and `mxc_darwin` all call it from their +/// respective `main.rs` entry points. Threading the pre-decoded JSON through +/// this entry point avoids re-reading named pipes, `/dev/stdin`, and +/// process-substitution inputs, which are drained by the first read. +pub fn handle_maintenance_input_json(json: &str) -> Option { + let Ok(hint) = crate::config_parser::parse_request_hint_from_json(json) else { + return None; + }; + if hint.kind() != crate::config_parser::RequestKind::TelemetryConsent { + return None; + } + + let request: wire::TelemetryConsentMaintenanceRequest = + match crate::config_deserialize::from_str(json) { + Ok(request) => request, + Err(error) => { + return Some(ConsentCliOutcome::failure( + format!("Error: invalid telemetry consent maintenance request: {error}"), + 64, + )); + } + }; + + Some(handle_maintenance_request(request)) +} + +fn handle_maintenance_request( + request: wire::TelemetryConsentMaintenanceRequest, +) -> ConsentCliOutcome { + match request.action { + wire::TelemetryConsentAction::Status => ConsentCliOutcome::json(&maintenance_response( + request.action, + wire::TelemetryConsentResult::Status, + None, + None, + None, + )), + wire::TelemetryConsentAction::Withdraw => match consent::withdraw_consent() { + Ok(outcome) => ConsentCliOutcome::json(&maintenance_response( + request.action, + action_result_to_wire(outcome.result), + None, + None, + None, + )), + Err(error) => ConsentCliOutcome::failure(format!("Error: {error}"), 1), + }, + wire::TelemetryConsentAction::Request => { + let protocol = + std::env::var_os(PRESENTER_PROTOCOL_ENV).is_some_and(|value| value == "1"); + let result = consent::request_consent(request.locale.as_deref(), |prompt| { + if protocol { + present_over_stdio_protocol(request.action, prompt) + } else { + present_on_terminal(prompt) + } + }); + match result { + Ok(outcome) => ConsentCliOutcome::json(&maintenance_response( + request.action, + action_result_to_wire(outcome.result), + None, + None, + None, + )), + Err(consent::ConsentActionError::Presenter(error)) => { + let response = maintenance_response( + request.action, + wire::TelemetryConsentResult::PresentationUnavailable, + Some(wire::TelemetryConsentStatusReason::PresentationUnavailable), + None, + None, + ); + let mut outcome = ConsentCliOutcome::json(&response); + outcome.stderr = Some(format!("Error: {error}")); + outcome.exit_code = 1; + outcome + } + Err(error) => ConsentCliOutcome::failure(format!("Error: {error}"), 1), + } + } + } +} + +fn present_on_terminal( + prompt: &consent_prompt::ConsentPrompt, +) -> Result { + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + return Err("interactive telemetry consent presentation is unavailable".to_string()); + } + + eprintln!("{}", prompt.title.text); + eprintln!(); + eprintln!("{}", prompt.body.text); + eprintln!(); + eprintln!( + "{}: {}", + prompt.learn_more_label.text, prompt.learn_more_url + ); + eprintln!(); + eprintln!("[Y] {}", prompt.affirmative_label.text); + eprintln!("[N] {}", prompt.negative_label.text); + eprint!("Enter Y or N: "); + std::io::stderr() + .flush() + .map_err(|error| format!("failed to display telemetry consent prompt: {error}"))?; + + let mut input = String::new(); + let read = std::io::stdin() + .read_line(&mut input) + .map_err(|error| format!("failed to read telemetry consent response: {error}"))?; + if read == 0 { + return Ok(consent::ConsentDecision::Dismissed); + } + match input.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(consent::ConsentDecision::Yes), + "n" | "no" => Ok(consent::ConsentDecision::No), + _ => Ok(consent::ConsentDecision::Dismissed), + } +} + +fn present_over_stdio_protocol( + action: wire::TelemetryConsentAction, + prompt: &consent_prompt::ConsentPrompt, +) -> Result { + let mut transport = StdioPresenterTransport; + present_over_transport(action, prompt, &mut transport) +} + +fn validate_presenter_response( + response: wire::TelemetryConsentPresenterResponse, + expected_challenge: &str, + expected_resource_version: u32, +) -> Result { + if response.challenge != expected_challenge { + return Err("consent presenter challenge did not match this request".to_string()); + } + if response.resource_version != expected_resource_version { + return Err("consent presenter prompt version did not match this request".to_string()); + } + Ok(match response.decision { + wire::TelemetryConsentDecision::Yes => consent::ConsentDecision::Yes, + wire::TelemetryConsentDecision::No => consent::ConsentDecision::No, + wire::TelemetryConsentDecision::Dismissed => consent::ConsentDecision::Dismissed, + }) +} + +fn random_challenge() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::getrandom(&mut bytes) + .map_err(|error| format!("failed to create consent presenter challenge: {error}"))?; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}") + .map_err(|error| format!("failed to encode consent presenter challenge: {error}"))?; + } + Ok(encoded) +} + +trait PresenterTransport { + fn next_challenge(&mut self) -> Result; + fn write_presentation(&mut self, json: &str) -> Result<(), String>; + fn flush_presentation(&mut self) -> Result<(), String>; + fn read_response_line(&mut self, input: &mut String) -> Result; +} + +struct StdioPresenterTransport; + +impl PresenterTransport for StdioPresenterTransport { + fn next_challenge(&mut self) -> Result { + random_challenge() + } + + fn write_presentation(&mut self, json: &str) -> Result<(), String> { + // Write the presentation envelope via `writeln!` on a locked stdout + // handle so a BrokenPipe (host closed the pipe before we emitted) + // becomes a clean transport error rather than a panic. `println!` + // panics on any write failure, which turns a routine IPC disconnect + // into an uncontrolled crash during executor setup. + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + writeln!(handle, "{json}").map_err(|error| { + if error.kind() == std::io::ErrorKind::BrokenPipe { + "consent presenter pipe closed before presentation envelope was delivered" + .to_string() + } else { + format!("failed to write consent presentation: {error}") + } + }) + } + + fn flush_presentation(&mut self) -> Result<(), String> { + std::io::stdout().flush().map_err(|error| { + if error.kind() == std::io::ErrorKind::BrokenPipe { + "consent presenter pipe closed before presentation envelope was flushed".to_string() + } else { + format!("failed to flush consent presentation: {error}") + } + }) + } + + fn read_response_line(&mut self, input: &mut String) -> Result { + std::io::stdin() + .read_line(input) + .map_err(|error| format!("failed to read consent presenter response: {error}")) + } +} + +fn present_over_transport( + action: wire::TelemetryConsentAction, + prompt: &consent_prompt::ConsentPrompt, + transport: &mut dyn PresenterTransport, +) -> Result { + let challenge = transport.next_challenge()?; + let presentation = maintenance_response( + action, + wire::TelemetryConsentResult::PresentationRequired, + None, + Some(prompt_to_wire(prompt)), + Some(challenge.clone()), + ); + let json = serde_json::to_string(&presentation) + .map_err(|error| format!("failed to serialize consent presentation: {error}"))?; + transport.write_presentation(&json)?; + transport.flush_presentation()?; + + let mut input = String::new(); + let read = transport.read_response_line(&mut input)?; + if read == 0 { + return Ok(consent::ConsentDecision::Dismissed); + } + let response: wire::TelemetryConsentPresenterResponse = serde_json::from_str(&input) + .map_err(|error| format!("invalid consent presenter response for this request: {error}"))?; + validate_presenter_response(response, &challenge, prompt.resource_version) +} + +fn maintenance_response( + action: wire::TelemetryConsentAction, + result: wire::TelemetryConsentResult, + reason: Option, + prompt: Option, + challenge: Option, +) -> wire::TelemetryConsentMaintenanceResponse { + let status = consent::get_status(); + let policy_state = policy::get_policy(); + wire::TelemetryConsentMaintenanceResponse { + action, + result, + stored_state: consent_state_to_wire(status.stored_state), + effective_state: consent_state_to_wire(status.effective_state), + reason: reason.or_else(|| status.reason.map(status_reason_to_wire)), + policy: policy_state_to_wire(policy_state), + needs_prompt: policy_state.allows_collection() && status.needs_prompt(), + prompt, + challenge, + } +} + +fn prompt_to_wire(prompt: &consent_prompt::ConsentPrompt) -> wire::TelemetryConsentPrompt { + fn message(value: consent_prompt::ConsentMessage) -> wire::TelemetryConsentMessage { + wire::TelemetryConsentMessage { + id: value.id.to_string(), + text: value.text.to_string(), + } + } + + wire::TelemetryConsentPrompt { + resource_version: prompt.resource_version, + locale: prompt.locale.to_string(), + title: message(prompt.title), + body: message(prompt.body), + affirmative_label: message(prompt.affirmative_label), + negative_label: message(prompt.negative_label), + learn_more_label: message(prompt.learn_more_label), + learn_more_url: prompt.learn_more_url.to_string(), + } +} + +fn consent_state_to_wire(state: consent::ConsentState) -> wire::TelemetryConsentState { + match state { + consent::ConsentState::Granted => wire::TelemetryConsentState::Granted, + consent::ConsentState::Denied => wire::TelemetryConsentState::Denied, + consent::ConsentState::Undetermined => wire::TelemetryConsentState::Undetermined, + consent::ConsentState::NotApplicable => wire::TelemetryConsentState::NotApplicable, + } +} + +fn status_reason_to_wire( + reason: consent::ConsentStatusReason, +) -> wire::TelemetryConsentStatusReason { + match reason { + consent::ConsentStatusReason::NoRecord => wire::TelemetryConsentStatusReason::NoRecord, + consent::ConsentStatusReason::StoreUnreadable => { + wire::TelemetryConsentStatusReason::StoreUnreadable + } + consent::ConsentStatusReason::StoreMalformed => { + wire::TelemetryConsentStatusReason::StoreMalformed + } + consent::ConsentStatusReason::ConsentSchemaUnsupported => { + wire::TelemetryConsentStatusReason::ConsentSchemaUnsupported + } + consent::ConsentStatusReason::PromptVersionMissing => { + wire::TelemetryConsentStatusReason::PromptVersionMissing + } + consent::ConsentStatusReason::PromptVersionUnsupported => { + wire::TelemetryConsentStatusReason::PromptVersionUnsupported + } + consent::ConsentStatusReason::NotApplicable => { + wire::TelemetryConsentStatusReason::NotApplicable + } + } +} + +fn policy_state_to_wire(state: policy::PolicyState) -> wire::TelemetryConsentPolicyState { + match state { + policy::PolicyState::Unrestricted => wire::TelemetryConsentPolicyState::Unrestricted, + policy::PolicyState::Allowed => wire::TelemetryConsentPolicyState::Allowed, + policy::PolicyState::Blocked => wire::TelemetryConsentPolicyState::Blocked, + policy::PolicyState::NotApplicable => wire::TelemetryConsentPolicyState::NotApplicable, + } +} + +fn action_result_to_wire(result: consent::ConsentActionResult) -> wire::TelemetryConsentResult { + match result { + consent::ConsentActionResult::Granted => wire::TelemetryConsentResult::Granted, + consent::ConsentActionResult::Denied => wire::TelemetryConsentResult::Denied, + consent::ConsentActionResult::Dismissed => wire::TelemetryConsentResult::Dismissed, + consent::ConsentActionResult::Withdrawn => wire::TelemetryConsentResult::Withdrawn, + consent::ConsentActionResult::AlreadyGranted => { + wire::TelemetryConsentResult::AlreadyGranted + } + consent::ConsentActionResult::PolicyBlocked => wire::TelemetryConsentResult::PolicyBlocked, + consent::ConsentActionResult::NotApplicable => wire::TelemetryConsentResult::NotApplicable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::telemetry::consent_prompt::{ConsentPrompt, EN_US_CONSENT_PROMPT}; + + fn flags(status: bool, grant: bool, revoke: bool) -> ConsentCliFlags<'static> { + ConsentCliFlags { + status, + grant, + revoke, + source: None, + } + } + + fn prompt() -> ConsentPrompt { + EN_US_CONSENT_PROMPT + } + + struct FakeTransport { + challenge: Result, + write_result: Result<(), String>, + flush_result: Result<(), String>, + read_result: Result, + response_line: String, + writes: Vec, + } + + impl FakeTransport { + fn with_response(response_line: String) -> Self { + Self { + challenge: Ok("request-a".to_string()), + write_result: Ok(()), + flush_result: Ok(()), + read_result: Ok(response_line.len()), + response_line, + writes: Vec::new(), + } + } + } + + impl PresenterTransport for FakeTransport { + fn next_challenge(&mut self) -> Result { + self.challenge.clone() + } + + fn write_presentation(&mut self, json: &str) -> Result<(), String> { + self.writes.push(json.to_string()); + self.write_result.clone() + } + + fn flush_presentation(&mut self) -> Result<(), String> { + self.flush_result.clone() + } + + fn read_response_line(&mut self, input: &mut String) -> Result { + input.push_str(&self.response_line); + self.read_result.clone() + } + } + + #[test] + fn no_flags_is_a_noop() { + assert!(handle_consent_flags(&flags(false, false, false)).is_none()); + } + + fn assert_status_json( + outcome: &ConsentCliOutcome, + stored: &str, + effective: &str, + policy: &str, + needs_prompt: bool, + ) { + let value: serde_json::Value = + serde_json::from_str(outcome.stdout.as_deref().expect("status JSON")).unwrap(); + assert_eq!(value["action"], "status"); + assert_eq!(value["result"], "status"); + assert_eq!(value["storedState"], stored); + assert_eq!(value["effectiveState"], effective); + assert_eq!(value["policy"], policy); + assert_eq!(value["needsPrompt"], needs_prompt); + } + + /// Previously unreachable in-process: this branch called + /// `std::process::exit(64)` and would have killed the test runner. + #[test] + fn legacy_state_changing_flags_return_a_migration_error() { + let outcome = handle_consent_flags(&flags(false, true, true)).expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(outcome.stdout, None); + assert!(outcome + .stderr + .as_deref() + .unwrap() + .contains("JSON maintenance envelope")); + } + + #[test] + fn presenter_response_is_bound_to_challenge_and_prompt_version() { + let valid = wire::TelemetryConsentPresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: wire::TelemetryConsentDecision::Yes, + }; + assert_eq!( + validate_presenter_response(valid, "request-a", 1).unwrap(), + consent::ConsentDecision::Yes + ); + + let replay = wire::TelemetryConsentPresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: wire::TelemetryConsentDecision::Yes, + }; + assert!(validate_presenter_response(replay, "request-b", 1) + .unwrap_err() + .contains("challenge")); + + let stale_prompt = wire::TelemetryConsentPresenterResponse { + challenge: "request-a".to_string(), + resource_version: 0, + decision: wire::TelemetryConsentDecision::Yes, + }; + assert!(validate_presenter_response(stale_prompt, "request-a", 1) + .unwrap_err() + .contains("version")); + } + + #[test] + fn presenter_transport_reports_broken_write_without_touching_stdio() { + let mut transport = FakeTransport { + challenge: Ok("request-a".to_string()), + write_result: Err("consent presenter pipe closed".to_string()), + flush_result: Ok(()), + read_result: Ok(0), + response_line: String::new(), + writes: Vec::new(), + }; + let error = present_over_transport( + wire::TelemetryConsentAction::Request, + &prompt(), + &mut transport, + ) + .unwrap_err(); + assert!(error.contains("pipe closed")); + assert_eq!(transport.writes.len(), 1); + } + + #[test] + fn presenter_transport_treats_eof_as_dismissed() { + let mut transport = FakeTransport { + challenge: Ok("request-a".to_string()), + write_result: Ok(()), + flush_result: Ok(()), + read_result: Ok(0), + response_line: String::new(), + writes: Vec::new(), + }; + let decision = present_over_transport( + wire::TelemetryConsentAction::Request, + &prompt(), + &mut transport, + ) + .unwrap(); + assert_eq!(decision, consent::ConsentDecision::Dismissed); + } + + #[test] + fn presenter_transport_rejects_malformed_json() { + let mut transport = FakeTransport::with_response("not json".to_string()); + let error = present_over_transport( + wire::TelemetryConsentAction::Request, + &prompt(), + &mut transport, + ) + .unwrap_err(); + assert!(error.contains("invalid consent presenter response")); + } + + #[test] + fn presenter_transport_rejects_mismatched_response() { + let response = serde_json::to_string(&wire::TelemetryConsentPresenterResponse { + challenge: "wrong-request".to_string(), + resource_version: 1, + decision: wire::TelemetryConsentDecision::Yes, + }) + .unwrap(); + let mut transport = FakeTransport::with_response(response); + let error = present_over_transport( + wire::TelemetryConsentAction::Request, + &prompt(), + &mut transport, + ) + .unwrap_err(); + assert!(error.contains("challenge")); + } + + /// The non-Windows contract every executor must honor: status is a + /// successful `not-applicable` report, and grant/revoke are refused + /// rather than silently accepted. MXC must never offer — or appear to + /// record — consent on a platform where it cannot collect telemetry. + /// + /// Gated to non-Windows (rather than merged into the Windows tests) + /// because it asserts the *stub* behavior; without it, Linux/macOS CI + /// would run no test at all over this shared handler. + #[cfg(not(target_os = "windows"))] + mod non_windows_tests { + use super::*; + + #[test] + fn status_reports_not_applicable_and_succeeds() { + let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_status_json( + &outcome, + "not-applicable", + "not-applicable", + "not-applicable", + false, + ); + assert_eq!(outcome.stderr, None); + } + + #[test] + fn grant_is_refused() { + let outcome = handle_consent_flags(&flags(false, true, false)).expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(outcome.stdout, None); + assert!(outcome + .stderr + .as_deref() + .unwrap() + .contains("JSON maintenance envelope")); + } + + #[test] + fn revoke_is_refused() { + let outcome = handle_consent_flags(&flags(false, false, true)).expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(outcome.stdout, None); + assert!(outcome + .stderr + .as_deref() + .unwrap() + .contains("JSON maintenance envelope")); + } + } + + /// End-to-end coverage of the `handle_consent_flags` paths (grant, + /// revoke, status, and a forced write failure) against an isolated + /// consent store — this is the same fast path all three executors + /// (`wxc-exec`, `lxc-exec`, `mxc-exec-mac`) delegate to, so exercising it + /// here covers the shared behavior for all three executors. + #[cfg(target_os = "windows")] + mod windows_tests { + use super::*; + use crate::telemetry::test_support::TelemetryTestEnv; + + /// Isolates both process-global test hooks: the policy key (so a real + /// machine policy on the dev box cannot change the expected output) + /// and the consent store. See [`TelemetryTestEnv`] for why acquiring + /// them together, in one place, is what keeps the pair deadlock-free. + fn isolate(tmp: &std::path::Path) -> TelemetryTestEnv { + TelemetryTestEnv::new(tmp) + } + + #[test] + fn grant_flag_is_a_non_mutating_migration_tombstone() { + let tmp = tempfile::tempdir().unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_flags(&ConsentCliFlags { + status: false, + grant: true, + revoke: false, + source: Some("prompt"), + }) + .expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(outcome.stdout, None); + assert_eq!(consent::get_consent().as_str(), "undetermined"); + } + + #[test] + fn revoke_flag_is_a_non_mutating_migration_tombstone() { + let tmp = tempfile::tempdir().unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_flags(&ConsentCliFlags { + status: false, + grant: false, + revoke: true, + source: Some("settings-toggle"), + }) + .expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(outcome.stdout, None); + assert_eq!(consent::get_consent().as_str(), "undetermined"); + } + + #[test] + fn status_flag_reports_current_state_without_mutating_it() { + let tmp = tempfile::tempdir().unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_status_json( + &outcome, + "undetermined", + "undetermined", + "unrestricted", + true, + ); + assert_eq!(consent::get_consent().as_str(), "undetermined"); + } + + /// Under an administrative denial the status must still report the + /// user's own recorded decision truthfully — the grant is preserved, + /// not erased — while advertising the block and suppressing the + /// prompt. + #[test] + fn blocked_policy_is_reported_and_suppresses_the_prompt() { + let tmp = tempfile::tempdir().unwrap(); + let env = isolate(tmp.path()); + env.set_policy_value(0); + + let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + assert_eq!(outcome.exit_code, 0); + assert_status_json(&outcome, "undetermined", "undetermined", "blocked", false); + } + + /// A user may still record a decision while policy blocks collection; + /// it is honoured if the administrator later relaxes the policy. What + /// must not happen is the grant being treated as collectable. + #[test] + fn grant_tombstone_does_not_record_under_a_blocking_policy() { + let tmp = tempfile::tempdir().unwrap(); + let env = isolate(tmp.path()); + env.set_policy_value(0); + + let outcome = handle_consent_flags(&ConsentCliFlags { + status: false, + grant: true, + revoke: false, + source: Some("cli"), + }) + .expect("handled"); + assert_eq!(outcome.exit_code, 64); + assert_eq!(consent::get_consent(), consent::ConsentState::Undetermined); + assert!(!crate::telemetry::is_enabled( + &crate::models::TelemetryConfig::default() + )); + } + + /// Previously unreachable in-process: this branch called + /// `std::process::exit(1)`. A regular *file* named `mxc` where the + /// store's parent directory belongs makes `create_dir_all` fail, so + /// the write path errors out deterministically without needing + /// permissions games. + #[test] + fn withdrawal_write_failure_reports_error_and_nonzero_exit() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("mxc"), b"not a directory").unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_maintenance_request(wire::TelemetryConsentMaintenanceRequest { + schema: None, + command: wire::TelemetryConsentCommand::TelemetryConsent, + action: wire::TelemetryConsentAction::Withdraw, + locale: None, + }); + assert_eq!(outcome.exit_code, 1); + assert_eq!(outcome.stdout, None); + assert!(outcome.stderr.as_deref().unwrap().starts_with("Error: ")); + } + } +} diff --git a/src/core/wxc_common/src/telemetry/correlation_state.rs b/src/core/wxc_common/src/telemetry/correlation_state.rs new file mode 100644 index 000000000..ecf271bf1 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/correlation_state.rs @@ -0,0 +1,784 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Per-lifecycle persistence for the [`super::correlation_vector`] root, so a +//! state-aware sandbox's phases share one telemetry lineage without a caller +//! ever holding or relaying a vector. +//! +//! `provision` mints its vector the ordinary way ([`super::correlation_vector::seed`], +//! before its `sandbox_id` exists) and, once dispatch mints that id, this module +//! persists the vector under it. Every later phase recalls the persisted root +//! and [`super::correlation_vector::spin`]s a child off it; `deprovision` +//! forgets the record on success. A record that cannot be found or read (never +//! persisted, removed, corrupted, or the store is unavailable) degrades to a +//! fresh, disconnected [`super::correlation_vector::seed`] — lineage is lost, +//! but a phase never derives a vector from `sandbox_id` itself, which — unlike +//! a persisted root — is not guaranteed to carry 128 bits of entropy and, on +//! some backends, can embed caller-supplied data. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime}; + +use crate::mxc_error::{MxcError, MxcErrorCode}; +use crate::state_aware_dispatch::DispatchOutcome; + +use super::correlation_vector::{is_relayable, seed, spin}; + +const RECORD_EXTENSION: &str = "cv"; +const STALE_RECORD_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const STALE_TMP_FILE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); +const PRUNE_DELETE_LIMIT: usize = 256; + +/// Pre-dispatch correlation vector for a state-aware phase. `provision` always +/// seeds a fresh vector (its `sandbox_id` doesn't exist yet — persist it via +/// [`on_provision_outcome`] once dispatch mints one). Every other phase recalls +/// the persisted lifecycle root for `sandbox_id` and spins a child off it, or +/// seeds a fresh, disconnected vector if `sandbox_id` is absent or has no +/// record. Returns an empty string when `active` is false, so an inactive +/// telemetry provider pays for no RNG/clock/file-system work. +pub fn pre_dispatch_vector(active: bool, is_provision: bool, sandbox_id: Option<&str>) -> String { + if !active { + return String::new(); + } + prune_stale_records(); + match sandbox_id { + Some(id) if !is_provision => phase_vector(id), + _ => seed(), + } +} + +/// After a `provision` dispatch, persist `root` (this phase's already-computed +/// vector, from [`pre_dispatch_vector`]) keyed by the freshly minted +/// `sandbox_id` extracted from the success envelope, so later phases of this +/// lifecycle can spin off it. A no-op when `active` is false, the dispatch +/// failed, or the envelope did not carry `result.sandboxId` — later phases then +/// simply find no record and seed their own disconnected vector. +pub fn on_provision_outcome(active: bool, root: &str, outcome: &Result) { + if !active { + return; + } + if let Ok(DispatchOutcome::Envelope(value)) = outcome { + if let Some(sandbox_id) = value + .get("result") + .and_then(|result| result.get("sandboxId")) + .and_then(|id| id.as_str()) + { + persist(sandbox_id, root); + } + } +} + +/// After a `deprovision` dispatch succeeds, forget `sandbox_id`'s persisted +/// lifecycle root. Dry runs keep the record intact, because the sandbox still +/// exists. Terminal `not_provisioned` outcomes are also safe to forget: the +/// backend has already proved the sandbox is absent, so the correlation record +/// is stale. Cleanup deliberately does **not** depend on `active`; a sandbox +/// can outlive telemetry authorization, and its best-effort correlation record +/// should still be reaped when the lifecycle ends. +pub fn on_deprovision_outcome( + _active: bool, + sandbox_id: &str, + dry_run: bool, + outcome: &Result, +) { + if dry_run || !should_forget_after_deprovision(outcome) { + return; + } + with_store(|store| store.forget(sandbox_id)); +} + +fn should_forget_after_deprovision(outcome: &Result) -> bool { + match outcome { + Ok(_) => true, + Err(error) => error.code == MxcErrorCode::NotProvisioned, + } +} + +/// Recall the persisted lifecycle root for `sandbox_id` and spin a child off +/// it. Seeds a fresh, disconnected vector if no valid record exists. +fn phase_vector(sandbox_id: &str) -> String { + match with_store(|store| store.load(sandbox_id)) { + Some(root) if is_relayable(&root) => spin(&root), + _ => seed(), + } +} + +fn persist(sandbox_id: &str, root: &str) { + with_store(|store| store.persist(sandbox_id, root)); +} + +/// Directory holding one record per live sandbox lifecycle. Uses the same +/// per-user root resolution as the telemetry consent store. +fn store_dir() -> Option { + store_dir_from_local_app_data(super::consent::local_app_data_dir()) +} + +fn store_dir_from_local_app_data(base: Option) -> Option { + Some(base?.join("mxc").join("correlation")) +} + +/// Filesystem-safe, non-reversible lookup key for `sandbox_id`. +/// +/// Hashed rather than used verbatim so (a) an id containing characters unsafe +/// for a path component on some future backend can never reach the file +/// system, and (b) caller-supplied data a backend may embed in its sandbox id +/// (e.g. IsolationSession's `appId`) is never written to disk as a +/// directory-listable plaintext name. This hash is a local lookup key only — +/// it never appears in emitted telemetry. +fn record_key(sandbox_id: &str) -> String { + use sha2::{Digest, Sha256}; + + let digest = Sha256::digest(sandbox_id.as_bytes()); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut encoded, "{byte:02x}"); + } + encoded +} + +fn prune_stale_records() { + with_store(|store| store.prune_stale()); +} + +fn with_store(f: impl FnOnce(&dyn CorrelationStore) -> R) -> R { + #[cfg(any(test, feature = "test-support", debug_assertions))] + if let Some(store) = test_support::store_override() { + return f(store.as_ref()); + } + + let store = FilesystemCorrelationStore::from_env(); + f(&store) +} + +pub(crate) trait CorrelationStore: Send + Sync { + fn persist(&self, sandbox_id: &str, root: &str); + fn load(&self, sandbox_id: &str) -> Option; + fn forget(&self, sandbox_id: &str); + fn prune_stale(&self); +} + +struct FilesystemCorrelationStore { + dir: Option, +} + +impl FilesystemCorrelationStore { + fn from_env() -> Self { + Self { dir: store_dir() } + } + + #[cfg(any(test, feature = "test-support", debug_assertions))] + fn from_store_root(dir: PathBuf) -> Self { + Self { dir: Some(dir) } + } + + fn record_path(&self, sandbox_id: &str) -> Option { + self.dir + .as_ref() + .map(|dir| dir.join(format!("{}.{}", record_key(sandbox_id), RECORD_EXTENSION))) + } + + fn tmp_path(&self, sandbox_id: &str) -> Option { + self.dir.as_ref().map(|dir| { + dir.join(format!( + ".{}.tmp-{}-{}", + record_key(sandbox_id), + std::process::id(), + tmp_nonce(), + )) + }) + } + + fn read_record(path: &Path) -> Option { + fs::read_to_string(path).ok().map(|s| s.trim().to_string()) + } + + fn write_record(&self, sandbox_id: &str, path: &Path, root: &str) { + let Some(dir) = path.parent() else { + return; + }; + if fs::create_dir_all(dir).is_err() { + return; + } + + let Some(tmp) = self.tmp_path(sandbox_id) else { + return; + }; + if fs::write(&tmp, root).is_err() { + return; + } + if fs::rename(&tmp, path).is_err() { + let _ = fs::remove_file(path); + if fs::rename(&tmp, path).is_err() { + let _ = fs::remove_file(&tmp); + } + } + } + + fn tmp_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.') && name.contains(".tmp-")) + } + + fn file_age(path: &Path, now: SystemTime) -> Option { + let Ok(metadata) = fs::metadata(path) else { + return None; + }; + let Ok(modified) = metadata.modified() else { + return None; + }; + now.duration_since(modified).ok() + } + + fn stale_tmp_file(path: &Path, now: SystemTime) -> bool { + Self::file_age(path, now) + .map(|age| age > STALE_TMP_FILE_MAX_AGE) + .unwrap_or(false) + } + + fn stale_record(path: &Path, now: SystemTime) -> bool { + Self::file_age(path, now) + .map(|age| age > STALE_RECORD_MAX_AGE) + .unwrap_or(false) + } +} + +impl CorrelationStore for FilesystemCorrelationStore { + fn persist(&self, sandbox_id: &str, root: &str) { + let Some(path) = self.record_path(sandbox_id) else { + return; + }; + self.write_record(sandbox_id, &path, root); + } + + fn load(&self, sandbox_id: &str) -> Option { + let path = self.record_path(sandbox_id)?; + if let Some(root) = Self::read_record(&path) { + if is_relayable(&root) { + return Some(root); + } + let _ = fs::remove_file(&path); + return None; + } + + None + } + + fn forget(&self, sandbox_id: &str) { + if let Some(path) = self.record_path(sandbox_id) { + let _ = fs::remove_file(path); + } + } + + fn prune_stale(&self) { + let Some(dir) = self.dir.as_ref() else { + return; + }; + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let now = SystemTime::now(); + let mut deleted = 0; + for entry in entries.flatten() { + let path = entry.path(); + if Self::tmp_file(&path) { + if deleted < PRUNE_DELETE_LIMIT && Self::stale_tmp_file(&path, now) { + let _ = fs::remove_file(path); + deleted += 1; + } + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some(RECORD_EXTENSION) { + continue; + } + if deleted < PRUNE_DELETE_LIMIT && Self::stale_record(&path, now) { + let _ = fs::remove_file(path); + deleted += 1; + } + } + } +} + +#[cfg(any(test, feature = "test-support", debug_assertions))] +pub mod test_support { + use std::path::Path; + use std::sync::{Arc, Mutex, MutexGuard}; + + use super::{CorrelationStore, FilesystemCorrelationStore}; + + static STORE_OVERRIDE: Mutex>> = Mutex::new(None); + static OVERRIDE_LOCK: Mutex<()> = Mutex::new(()); + + pub(crate) fn lock_test_environment() -> MutexGuard<'static, ()> { + OVERRIDE_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub(crate) fn store_override() -> Option> { + STORE_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + pub(crate) fn replace_store(store: Arc) -> StoreGuard { + let lock = lock_test_environment(); + let previous = STORE_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .replace(store); + StoreGuard { + previous, + _lock: lock, + } + } + + pub(crate) struct StoreGuard { + previous: Option>, + _lock: MutexGuard<'static, ()>, + } + + impl Drop for StoreGuard { + fn drop(&mut self) { + *STORE_OVERRIDE.lock().unwrap_or_else(|e| e.into_inner()) = self.previous.take(); + } + } + + pub struct StoreDirGuard { + _guard: StoreGuard, + } + + impl StoreDirGuard { + pub fn set(dir: &Path) -> Self { + Self { + _guard: replace_store(Arc::new(FilesystemCorrelationStore::from_store_root( + dir.to_path_buf(), + ))), + } + } + } +} + +/// Process-wide monotonic nonce so concurrent persists within the same +/// process never collide on the same temp-file name. +fn tmp_nonce() -> u64 { + static COUNTER: AtomicU64 = AtomicU64::new(0); + COUNTER.fetch_add(1, Ordering::Relaxed) +} + +#[cfg(test)] +mod tests { + use super::*; + use filetime::FileTime; + use serde_json::json; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use test_support::{replace_store, StoreDirGuard}; + + #[derive(Default)] + struct MemoryStore { + records: Mutex>, + prune_count: Mutex, + } + + impl CorrelationStore for MemoryStore { + fn persist(&self, sandbox_id: &str, root: &str) { + self.records + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(sandbox_id.to_string(), root.to_string()); + } + + fn load(&self, sandbox_id: &str) -> Option { + self.records + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(sandbox_id) + .cloned() + } + + fn forget(&self, sandbox_id: &str) { + self.records + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(sandbox_id); + } + + fn prune_stale(&self) { + *self.prune_count.lock().unwrap_or_else(|e| e.into_inner()) += 1; + } + } + + impl MemoryStore { + fn record(&self, sandbox_id: &str) -> Option { + self.records + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(sandbox_id) + .cloned() + } + } + + fn provision_envelope(sandbox_id: &str) -> Result { + Ok(DispatchOutcome::Envelope( + json!({ "result": { "sandboxId": sandbox_id } }), + )) + } + + #[test] + fn inactive_pre_dispatch_vector_is_empty() { + assert_eq!(pre_dispatch_vector(false, true, None), ""); + assert_eq!(pre_dispatch_vector(false, false, Some("wsb:abcdef01")), ""); + } + + #[test] + fn provision_seeds_and_non_provision_without_sandbox_id_seeds() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + + let provisioned = pre_dispatch_vector(true, true, None); + assert!(is_relayable(&provisioned)); + + // A non-provision phase with no sandbox_id (should never happen in + // practice; the parser requires one) still degrades to a fresh seed + // rather than panicking or emitting an empty vector. + let no_id = pre_dispatch_vector(true, false, None); + assert!(is_relayable(&no_id)); + } + + #[test] + fn full_lifecycle_shares_one_root_until_deprovision() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:12345678"; + + // provision: seed, then persist once the id is known. + let provisioned = pre_dispatch_vector(true, true, None); + on_provision_outcome(true, &provisioned, &provision_envelope(sandbox_id)); + + let base_of = |cv: &str| cv.split('.').next().unwrap().to_string(); + let provisioned_base = base_of(&provisioned); + + // start / exec / stop all recall the same root and spin off it. + for _ in 0..3 { + let spun = pre_dispatch_vector(true, false, Some(sandbox_id)); + assert!(is_relayable(&spun)); + assert_eq!(base_of(&spun), provisioned_base); + assert_ne!( + spun, provisioned, + "phase must spin, not pass the root through" + ); + } + + // deprovision recalls the same root one last time, then forgets it. + let deprovisioned = pre_dispatch_vector(true, false, Some(sandbox_id)); + assert_eq!(base_of(&deprovisioned), provisioned_base); + on_deprovision_outcome( + true, + sandbox_id, + false, + &Ok(DispatchOutcome::Envelope(json!({}))), + ); + + // A later phase (e.g. a caller mistakenly reusing the id after + // teardown) finds no record and gets a disconnected vector. + let after_teardown = pre_dispatch_vector(true, false, Some(sandbox_id)); + assert_ne!(base_of(&after_teardown), provisioned_base); + } + + #[test] + fn failed_deprovision_keeps_the_record_for_a_retry() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:87654321"; + + let provisioned = pre_dispatch_vector(true, true, None); + on_provision_outcome(true, &provisioned, &provision_envelope(sandbox_id)); + let provisioned_base = provisioned.split('.').next().unwrap().to_string(); + + on_deprovision_outcome( + true, + sandbox_id, + false, + &Err(MxcError::backend_error("teardown failed, please retry")), + ); + + let retried = pre_dispatch_vector(true, false, Some(sandbox_id)); + assert_eq!( + retried.split('.').next().unwrap(), + provisioned_base, + "a failed deprovision must not drop the lineage a retry needs" + ); + } + + #[test] + fn malformed_provision_envelope_persists_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + + let provisioned = pre_dispatch_vector(true, true, None); + // No `result.sandboxId` at all. + on_provision_outcome( + true, + &provisioned, + &Ok(DispatchOutcome::Envelope(json!({}))), + ); + + // Nothing to recall for any id: every subsequent phase seeds fresh. + let a = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); + let b = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); + assert_ne!( + a.split('.').next().unwrap(), + b.split('.').next().unwrap(), + "with nothing persisted, repeated calls must not coincidentally share a base" + ); + } + + #[test] + fn failed_provision_persists_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + + let provisioned = pre_dispatch_vector(true, true, None); + on_provision_outcome( + true, + &provisioned, + &Err(MxcError::backend_error("provisioning failed")), + ); + + let a = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); + let b = pre_dispatch_vector(true, false, Some("wsb:abcdef01")); + assert_ne!(a.split('.').next().unwrap(), b.split('.').next().unwrap()); + } + + #[test] + fn inactive_provision_does_not_persist_and_terminal_deprovision_still_reaps() { + let store = Arc::new(MemoryStore::default()); + let _guard = replace_store(store.clone()); + let sandbox_id = "wsb:00000000"; + + on_provision_outcome(false, "unused", &provision_envelope(sandbox_id)); + assert!(store.record(sandbox_id).is_none()); + + // Persist a record the normal way, then confirm the inactive + // deprovision hook still reaps terminal state. + store.persist(sandbox_id, &seed()); + on_deprovision_outcome( + false, + sandbox_id, + false, + &Ok(DispatchOutcome::Envelope(json!({}))), + ); + assert!(store.record(sandbox_id).is_none()); + } + + #[test] + fn corrupted_record_is_ignored_in_favor_of_a_fresh_seed() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:deadbeef"; + + let store = FilesystemCorrelationStore::from_store_root(tmp.path().to_path_buf()); + let dir = store.dir.clone().unwrap(); + fs::create_dir_all(&dir).unwrap(); + fs::write( + store.record_path(sandbox_id).unwrap(), + "not a correlation vector", + ) + .unwrap(); + + let cv = phase_vector(sandbox_id); + assert!(is_relayable(&cv)); + } + + #[test] + fn concurrent_persists_for_distinct_ids_do_not_collide_on_temp_names() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + + std::thread::scope(|scope| { + for i in 0..8 { + scope.spawn(move || { + let id = format!("wsb:{i:08x}"); + with_store(|store| store.persist(&id, &seed())); + assert!(with_store(|store| store.load(&id)).is_some()); + }); + } + }); + } + + #[test] + fn record_key_is_stable_for_known_ids() { + assert_eq!( + record_key("wsb:12345678"), + "a07f3dda1aa7493db2d1d4b1d1b4ed42c1d46b3065dca6f06213250159e1d508" + ); + assert_eq!( + record_key("iso:reg-abc:prov-1"), + "d197043201237934ad09c7caf1e56ee322f07189c2c3918ee24e9243e355b959" + ); + } + + #[test] + fn pruning_runs_on_each_initialization() { + let store = Arc::new(MemoryStore::default()); + let _guard = replace_store(store.clone()); + + let _ = pre_dispatch_vector(true, true, None); + let _ = pre_dispatch_vector(true, true, None); + + assert_eq!( + *store.prune_count.lock().unwrap_or_else(|e| e.into_inner()), + 2 + ); + } + + #[test] + fn dry_run_deprovision_preserves_the_record() { + let store = Arc::new(MemoryStore::default()); + let _guard = replace_store(store.clone()); + let sandbox_id = "wsb:dryrun01"; + let root = seed(); + let base = root.split('.').next().unwrap().to_string(); + + on_provision_outcome(true, &root, &provision_envelope(sandbox_id)); + on_deprovision_outcome( + true, + sandbox_id, + true, + &Ok(DispatchOutcome::Envelope(json!({}))), + ); + + assert_eq!( + store.record(sandbox_id).unwrap().split('.').next().unwrap(), + base + ); + let later = pre_dispatch_vector(true, false, Some(sandbox_id)); + assert_eq!(later.split('.').next().unwrap(), base); + } + + #[test] + fn not_provisioned_deprovision_forgets_the_record() { + let store = Arc::new(MemoryStore::default()); + let _guard = replace_store(store.clone()); + let sandbox_id = "wsb:missing01"; + on_provision_outcome(true, &seed(), &provision_envelope(sandbox_id)); + + on_deprovision_outcome( + true, + sandbox_id, + false, + &Err(MxcError::not_provisioned("sandbox already removed")), + ); + + assert!(store.record(sandbox_id).is_none()); + } + + #[test] + fn missing_localappdata_disables_persistence_instead_of_using_shared_temp() { + assert_eq!(store_dir_from_local_app_data(None), None); + } + + #[test] + fn stale_record_is_pruned_during_startup_sweep() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:stale000"; + let root = seed(); + let base = root.split('.').next().unwrap().to_string(); + let store = FilesystemCorrelationStore::from_store_root(tmp.path().to_path_buf()); + let path = store.record_path(sandbox_id).unwrap(); + fs::create_dir_all(tmp.path()).unwrap(); + fs::write(&path, &root).unwrap(); + filetime::set_file_mtime( + &path, + FileTime::from_system_time( + SystemTime::now() - STALE_RECORD_MAX_AGE - Duration::from_secs(1), + ), + ) + .unwrap(); + let spun = pre_dispatch_vector(true, false, Some(sandbox_id)); + assert_ne!(spun.split('.').next().unwrap(), base); + assert!(!path.exists()); + } + + #[test] + fn future_dated_record_survives_startup_sweep() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let sandbox_id = "wsb:future00"; + let root = seed(); + let base = root.split('.').next().unwrap().to_string(); + let store = FilesystemCorrelationStore::from_store_root(tmp.path().to_path_buf()); + let path = store.record_path(sandbox_id).unwrap(); + fs::create_dir_all(tmp.path()).unwrap(); + fs::write(&path, &root).unwrap(); + filetime::set_file_mtime( + &path, + FileTime::from_system_time(SystemTime::now() + Duration::from_secs(60)), + ) + .unwrap(); + + let spun = pre_dispatch_vector(true, false, Some(sandbox_id)); + assert_eq!(spun.split('.').next().unwrap(), base); + assert!(path.exists()); + } + + #[test] + fn startup_sweep_bounds_stale_deletions() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + fs::create_dir_all(tmp.path()).unwrap(); + let stale_time = SystemTime::now() - STALE_RECORD_MAX_AGE - Duration::from_secs(1); + for index in 0..(PRUNE_DELETE_LIMIT + 10) { + let path = tmp.path().join(format!("{index}.{RECORD_EXTENSION}")); + fs::write(&path, seed()).unwrap(); + filetime::set_file_mtime(&path, FileTime::from_system_time(stale_time)).unwrap(); + } + + let _ = pre_dispatch_vector(true, true, None); + + assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 10); + } + + #[test] + fn fresh_tmp_file_survives_startup_sweep() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let store = FilesystemCorrelationStore::from_store_root(tmp.path().to_path_buf()); + let tmp_path = store.tmp_path("wsb:fresh000").unwrap(); + fs::create_dir_all(tmp.path()).unwrap(); + fs::write(&tmp_path, seed()).unwrap(); + let _ = pre_dispatch_vector(true, true, None); + + assert!( + tmp_path.exists(), + "fresh in-progress temp files must not be reaped by startup pruning" + ); + } + + #[test] + fn stale_tmp_file_is_pruned_during_startup_sweep() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = StoreDirGuard::set(tmp.path()); + let store = FilesystemCorrelationStore::from_store_root(tmp.path().to_path_buf()); + let tmp_path = store.tmp_path("wsb:staletmp").unwrap(); + fs::create_dir_all(tmp.path()).unwrap(); + fs::write(&tmp_path, seed()).unwrap(); + filetime::set_file_mtime( + &tmp_path, + FileTime::from_system_time( + SystemTime::now() - STALE_TMP_FILE_MAX_AGE - Duration::from_secs(1), + ), + ) + .unwrap(); + let _ = pre_dispatch_vector(true, true, None); + + assert!( + !tmp_path.exists(), + "stale temp files should still be reaped" + ); + } +} diff --git a/src/core/wxc_common/src/telemetry/correlation_vector.rs b/src/core/wxc_common/src/telemetry/correlation_vector.rs index 36d50d5ae..64d47dc8b 100644 --- a/src/core/wxc_common/src/telemetry/correlation_vector.rs +++ b/src/core/wxc_common/src/telemetry/correlation_vector.rs @@ -1,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Microsoft Correlation Vector (MS-CV) v2, the correlation primitive that +//! Microsoft Correlation Vector (MS-CV) v2.1, the correlation primitive that //! [WIL TraceLogging](https://github.com/microsoft/wil) stamps into the //! reserved `__TlgCV__` event field. //! //! An MS-CV is a lightweight, sortable string identity that threads a single //! logical operation across process, service, and (here) state-aware lifecycle -//! boundaries. Format (v2): `base "." element ("." element)*` where `base` is +//! boundaries. Format (v2.1): `base "." element ("." element)*` where `base` is //! 22 base64 characters encoding a random 128-bit value, and each `element` is -//! a decimal `u32`. The whole vector is capped at 127 characters; on overflow +//! a decimal `u32`. The whole vector is capped at 128 characters; on overflow //! the vector is frozen by appending a `!` terminator and is never mutated //! again. //! @@ -20,14 +20,14 @@ //! (state-aware `provision`). The base is random, so no `sandbox_id` / UPN or //! other caller identity is embedded — the vector is privacy-safe by //! construction. -//! - [`spin`] — derive a child vector (`V..0`) for a downstream received -//! flow whose parent cannot atomically increment per child. Each MXC -//! non-provision phase (`start` / `exec` / `stop` / `deprovision`) is a -//! separate, stateless `wxc-exec` process that receives the *same* relayed -//! vector, so a plain [`extend`] would collapse every phase — and every -//! repeated `exec` — to an identical `V.0`. `spin` mixes a coarse timestamp -//! and random entropy so each phase/invocation gets a distinct, still-sortable -//! child. +//! - [`spin`] — derive a child vector (`V.A.B.0`) for a downstream flow whose +//! parent cannot atomically increment per child. Each MXC non-provision phase +//! (`start` / `exec` / `stop` / `deprovision`) is a separate, stateless +//! `wxc-exec` process that recalls the *same* lifecycle root persisted by +//! `provision` (see [`super::correlation_state`]), so a plain [`extend`] would +//! collapse every phase — and every repeated `exec` — to an identical `V.0`. +//! `spin` mixes a coarse timestamp and random entropy so each phase/invocation +//! gets a distinct, still-sortable child. //! - [`extend`] / [`increment`] — provided for completeness (single incoming //! flow, sequential ticks); not currently on MXC's hot path but kept so the //! module is a faithful, reusable MS-CV implementation. @@ -42,8 +42,8 @@ use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; /// Number of base64 characters in a v2 base (128 random bits). const BASE_LEN: usize = 22; -/// Maximum length of a v2 vector before it must be terminated (spec §-max). -const MAX_LEN: usize = 127; +/// Maximum wire length of a v2.1 vector before it must be terminated. +const MAX_LEN: usize = 128; /// Character appended to freeze a vector that would otherwise overflow [`MAX_LEN`]. const TERMINATOR: char = '!'; @@ -53,9 +53,11 @@ const TERMINATOR: char = '!'; const SPIN_INTERVAL_BITS: u32 = 24; /// Default `spin` entropy: 2 random bytes (16 bits), matching the reference -/// default (`spin_entropy::two`). Combined with the 16-bit coarse counter this -/// yields a single 32-bit `u32` element. +/// default (`spin_entropy::two`). const SPIN_ENTROPY_BYTES: usize = 2; +/// Default `spin` counter periodicity: retain 16 bits of the coarse counter, +/// matching the reference `spin_counter_periodicity::short`. +const SPIN_COUNTER_PERIODICITY_BITS: u32 = 16; /// Mint a fresh v2 correlation vector `base.0`. /// @@ -175,13 +177,13 @@ pub fn increment(cv: &str) -> String { } /// Derive a child vector for a downstream received flow whose parent cannot -/// atomically increment per child: `V => V..0`. +/// atomically increment per child: `V => V.A.B.0`. /// -/// `` is a single `u32` built from the low 16 bits of a coarse 100ns tick -/// counter (high half) and 16 bits of random entropy (low half), matching the -/// reference default spin parameters (coarse interval, short periodicity, two -/// entropy bytes). The timestamp keeps sibling spins roughly time-sortable; the -/// entropy keeps concurrent spins distinct. +/// `A` is the low 16 bits of the coarse 100ns tick counter and `B` is a +/// separately encoded 16-bit random value, matching the reference default spin +/// parameters (coarse interval, short periodicity, two entropy bytes). The +/// timestamp keeps sibling spins roughly time-sortable; the entropy keeps +/// concurrent spins distinct. pub fn spin(cv: &str) -> String { // Validate / short-circuit BEFORE sourcing entropy or the clock: a valid // frozen or a malformed input never reaches `spin_with`, so we neither waste @@ -202,7 +204,7 @@ pub fn spin(cv: &str) -> String { spin_with(m.full, now_ticks_100ns(), entropy, nonce) } -/// Deterministic core of [`spin`]: derive `V..0` from explicit tick and +/// Deterministic core of [`spin`]: derive `V.A.B.0` from explicit tick and /// entropy sources. Split out from [`spin`] so tests can pin the spin element /// and drive the RNG-failure fallback (`entropy == None`). Assumes `cv` is a /// valid mutable vector — [`spin`] runs [`classify`] before calling this, so @@ -213,16 +215,6 @@ fn spin_with( entropy: Option<[u8; SPIN_ENTROPY_BYTES]>, fallback_nonce: u64, ) -> String { - // The spin element is a single `u32` (32 bits): the entropy occupies the low - // `8 * SPIN_ENTROPY_BYTES` bits and the coarse counter the remainder. The - // math below is width-generic in `SPIN_ENTROPY_BYTES`; the real constraint is - // that the entropy leave room for the counter *inside* the 32-bit element, so - // the counter still contributes to the low 32 bits after the shift-and-mix - // below. That requires strictly fewer than 4 entropy bytes (< 32 bits): at - // exactly 4 bytes the counter is shifted entirely above bit 32 and dropped by - // `as u32`, erasing time-sortability. `<< 8` is a logical shift (bits shifted - // out are simply dropped, no overflow), and `as u32` keeps the low 32 bits. - const _: () = assert!(SPIN_ENTROPY_BYTES < 4); let entropy = entropy.unwrap_or_else(|| { // RNG unavailable: derive the entropy bytes from the process-wide nonce // so concurrent same-tick sibling spins still get distinct elements. @@ -235,15 +227,10 @@ fn spin_with( bytes.copy_from_slice(&nonce[..SPIN_ENTROPY_BYTES]); bytes }); - // Coarse counter: high bits of the tick count. Mix the entropy bytes into - // the low bits, then keep the low 32 bits as a single element. - let mut value: u64 = now_ticks >> SPIN_INTERVAL_BITS; - for b in entropy { - value = (value << 8) | u64::from(b); - } - let element = value as u32; - - let spun = format!("{cv}.{element}.0"); + let counter_mask = (1u64 << SPIN_COUNTER_PERIODICITY_BITS) - 1; + let counter = (now_ticks >> SPIN_INTERVAL_BITS) & counter_mask; + let entropy = u16::from_be_bytes(entropy); + let spun = format!("{cv}.{counter}.{entropy}.0"); terminate_if_oversized(cv, spun) } @@ -265,7 +252,7 @@ fn is_immutable(cv: &str) -> bool { cv.ends_with(TERMINATOR) } -/// A validated, mutable v2 vector, split at its rightmost element so operators +/// A validated, mutable v2.1 vector, split at its rightmost element so operators /// can advance it without re-parsing (and without an `.unwrap()` resting on an /// invariant established in a different function). Built only by [`parse_mutable`], /// so holding one is proof the vector is a valid, mutable MS-CV. @@ -303,7 +290,7 @@ enum Class<'a> { /// that must not spin the RNG) and the operators run one and the same parse. /// /// The immutable check is deliberately gated on [`is_valid_frozen`]: a bare -/// "ends with `!`" test would let an attacker-controlled `correlationVector` +/// "ends with `!`" test would let an attacker-controlled value /// (e.g. `"user@contoso.com!"` or a multi-KB string) bypass validation and be /// emitted verbatim under `__TlgCV__`. fn classify(cv: &str) -> Class<'_> { @@ -320,10 +307,9 @@ fn classify(cv: &str) -> Class<'_> { } } -/// If `candidate` exceeds [`MAX_LEN`], freeze the original `cv` with a -/// terminator instead (the spec's overflow behaviour); otherwise return -/// `candidate`. The frozen result is truncated so it still fits within -/// [`MAX_LEN`] rather than emitting a `MAX_LEN + 1`-character value. +/// If `candidate` exceeds 127 bytes, freeze the original `cv` with a terminator +/// instead (the spec's overflow behaviour). A terminated vector is allowed to +/// occupy the full 128-byte maximum. fn terminate_if_oversized(cv: &str, candidate: String) -> String { if candidate.len() <= MAX_LEN { return candidate; @@ -360,14 +346,14 @@ pub fn is_relayable(cv: &str) -> bool { matches!(classify(cv), Class::Frozen | Class::Mutable(_)) } -/// Whether `cv` is a well-formed, mutable v2 vector: a canonical 22-char base64 +/// Whether `cv` is a well-formed, mutable v2.1 vector: a canonical 22-char base64 /// `base` followed by at least one canonical decimal-`u32` element, within the /// length cap, with no whitespace and no terminator. fn is_valid(cv: &str) -> bool { parse_mutable(cv).is_some() } -/// Parse `cv` as a valid, mutable v2 vector, returning it split at its rightmost +/// Parse `cv` as a valid, mutable v2.1 vector, returning it split at its rightmost /// element (see [`MutableCv`]). Returns `None` for anything that is empty, over /// the length cap, terminated, or not `base "." element ("." element)*` with a /// canonical base and canonical decimal-`u32` elements. This is the single place @@ -537,7 +523,8 @@ mod tests { assert!(spun.starts_with(&format!("{parent}."))); assert!(spun.ends_with(".0")); // parent had 2 parts (base, 0); spin adds the spin element and a fresh 0. - assert_eq!(spun.split('.').count(), 4); + // parent had 2 parts; v2.1 spin adds counter, entropy, and fresh 0. + assert_eq!(spun.split('.').count(), 5); assert!(is_valid(&spun)); } @@ -575,7 +562,7 @@ mod tests { fn operators_reseed_on_malformed_terminated_input() { // A '!'-terminated value that is not a valid frozen vector must NOT pass // through the immutable fast path to telemetry — it reseeds instead. - // Guards against a hostile relayed correlationVector bypassing validation. + // Guards against a hostile input string bypassing validation. let huge = format!("{}!", "x".repeat(200)); for bad in ["user@contoso.com!", "!", "not-a-cv!", huge.as_str()] { assert!(is_valid(&extend(bad)), "extend({bad:?}) should reseed"); @@ -666,7 +653,7 @@ mod tests { fn increment_terminates_and_caps_on_length_overflow() { // Final element "99" rolls to "100", gaining a char at the cap. let cv = max_len_vector(); - assert!(cv.ends_with(".99")); + assert!(cv.ends_with(".9")); let out = increment(&cv); assert!(out.ends_with(TERMINATOR)); assert!(out.len() <= MAX_LEN, "increment {out:?} exceeds MAX_LEN"); @@ -780,10 +767,10 @@ mod tests { /// element (so incrementing or extending it overflows the length cap). fn max_len_vector() -> String { let mut cv = BASE.to_string(); // 22 - for _ in 0..51 { + for _ in 0..52 { cv.push_str(".9"); // +2 each => 22 + 102 = 124 } - cv.push_str(".99"); // +3 => 127 + cv.push_str(".9"); // +2 => 128 debug_assert_eq!(cv.len(), MAX_LEN); cv } @@ -793,9 +780,8 @@ mod tests { /// chopping the final digit and leaving a dangling `.`. fn max_len_vector_single_digit_tail() -> String { let mut cv = BASE.to_string(); // 22 - cv.push_str(".99"); // +3 => 25 (odd, so the trailing ".9"s land on 127) - for _ in 0..51 { - cv.push_str(".9"); // +2 each => 25 + 102 = 127 + for _ in 0..53 { + cv.push_str(".9"); // +2 each => 22 + 106 = 128 } debug_assert_eq!(cv.len(), MAX_LEN); cv @@ -809,10 +795,11 @@ mod tests { /// preserves the vector's meaning. fn max_len_vector_multi_digit_tail() -> String { let mut cv = BASE.to_string(); // 22 - for _ in 0..51 { + for _ in 0..50 { cv.push_str(".9"); // +2 each => 22 + 102 = 124 } - cv.push_str(".42"); // +3 => 127 + cv.push_str(".99"); // +3 => 125 + cv.push_str(".42"); // +3 => 128 debug_assert_eq!(cv.len(), MAX_LEN); cv } @@ -864,4 +851,45 @@ mod tests { assert!(incremented.ends_with(".43"), "{incremented:?}"); assert!(is_valid(&incremented)); } + + #[test] + fn relay_boundaries_reject_truncated_and_hostile_vectors() { + let valid = max_len_vector_single_digit_tail(); + assert_eq!(valid.len(), MAX_LEN); + assert!(is_relayable(&valid)); + let frozen = extend(&valid); + assert!(is_relayable(&frozen)); + + // Every truncation at the wire boundary is invalid unless it is a + // complete mutable vector or a complete frozen vector. In particular, + // a dangling separator and a cut terminator must never be relayed. + let truncated = vec![ + valid[..MAX_LEN - 1].to_string(), + format!("{valid}."), + format!("{}!", &valid[..MAX_LEN - 1]), + ]; + for truncated in truncated { + assert!( + !is_relayable(&truncated), + "truncated or malformed relay vector was accepted: {truncated:?}" + ); + } + + // A hostile value ending in the immutable marker is not a valid + // frozen vector and must not pass through unchanged. + assert!(!is_relayable(&format!("{}!", "A".repeat(MAX_LEN)))); + assert!(!is_relayable(&format!("{}!", "user@contoso.com"))); + } + + #[test] + fn relay_boundary_never_emits_a_falsified_partial_element() { + let cv = max_len_vector_multi_digit_tail(); + let truncated = format!("{}!", &cv[..MAX_LEN - 2]); + assert!(!is_relayable(&truncated)); + + let frozen = extend(&cv); + let (body, _) = cv.rsplit_once('.').unwrap(); + assert_eq!(frozen, format!("{body}{TERMINATOR}")); + assert!(is_relayable(&frozen)); + } } diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index a9b9398a2..a4208b85c 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -54,7 +54,6 @@ impl std::fmt::Display for FailureReason { #[derive(Debug, Clone, Copy)] pub struct TelemetryContext<'a> { pub backend: &'a str, - pub sandbox_kind: &'a str, /// State-aware lifecycle phase — one of `provision|start|exec|stop| /// deprovision`, or `""` for one-shot (non-state-aware) executions. pub phase: &'a str, @@ -64,9 +63,10 @@ pub struct TelemetryContext<'a> { pub correlation_vector: &'a str, } -/// Data for an MXC.Execution ETW event. +/// Data for an Execution ETW event. pub struct ExecutionEvent<'a> { pub backend: &'a str, + /// Containment kind requested by the caller before host-specific resolution. pub sandbox_kind: &'a str, pub exit_code: i32, pub outcome: &'a str, @@ -84,7 +84,7 @@ pub struct ExecutionEvent<'a> { pub correlation_vector: &'a str, } -/// Log an MXC.Execution ETW event. +/// Log an Execution ETW event. /// /// Delegates to the `mxc_telemetry` provider which adds common fields /// (Version, Channel, IsDebugging, UTCReplace_AppSessionGuid). @@ -106,7 +106,7 @@ pub fn log_execution(event: &ExecutionEvent<'_>) { test_sink::record_execution(event); } -/// Log an MXC.Error ETW event. +/// Log an Error ETW event. /// /// To avoid leaking PII (paths, usernames, credentials embedded in error /// strings), MXC deliberately does **not** emit the free-form error message. @@ -116,7 +116,7 @@ pub fn log_execution(event: &ExecutionEvent<'_>) { pub fn log_error(ctx: TelemetryContext<'_>, error_type: FailureReason, exit_code: i32) { mxc_telemetry::log_error( ctx.backend, - ctx.sandbox_kind, + super::sandbox_kind_for(ctx.backend, None), error_type.as_str(), exit_code, ctx.phase, @@ -138,7 +138,7 @@ pub(super) mod test_sink { use std::cell::Cell; use std::sync::Mutex; - /// Owned copy of an `MXC.Execution` record as captured for a test. + /// Owned copy of an `Execution` record as captured for a test. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedExecution { pub backend: String, @@ -151,11 +151,10 @@ pub(super) mod test_sink { pub correlation_vector: String, } - /// Owned copy of an `MXC.Error` record as captured for a test. + /// Owned copy of an `Error` record as captured for a test. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedError { pub backend: String, - pub sandbox_kind: String, pub error_type: FailureReason, pub exit_code: i32, pub phase: String, @@ -188,12 +187,12 @@ pub(super) mod test_sink { ERRORS.lock().unwrap_or_else(|e| e.into_inner()).clear(); } - /// Drain and return the captured `MXC.Execution` records. + /// Drain and return the captured `Execution` records. pub fn take_executions() -> Vec { std::mem::take(&mut *EXECUTIONS.lock().unwrap_or_else(|e| e.into_inner())) } - /// Drain and return the captured `MXC.Error` records. + /// Drain and return the captured `Error` records. pub fn take_errors() -> Vec { std::mem::take(&mut *ERRORS.lock().unwrap_or_else(|e| e.into_inner())) } @@ -230,7 +229,6 @@ pub(super) mod test_sink { .unwrap_or_else(|e| e.into_inner()) .push(CapturedError { backend: ctx.backend.to_owned(), - sandbox_kind: ctx.sandbox_kind.to_owned(), error_type, exit_code, phase: ctx.phase.to_owned(), diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index d777ed177..dbe29df00 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -12,14 +12,16 @@ //! On non-Windows platforms, all telemetry functions are no-ops. pub mod consent; +pub mod consent_cli; pub mod consent_prompt; +pub mod correlation_state; pub mod correlation_vector; pub mod events; pub mod policy; use std::time::Duration; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use crate::logger::Logger; @@ -31,84 +33,23 @@ pub use consent::ConsentState; pub use events::{log_error, log_execution, ExecutionEvent, FailureReason, TelemetryContext}; pub use policy::PolicyState; -#[cfg(any(test, all(feature = "test-support", debug_assertions)))] -pub mod test_support { - use super::consent::test_support::LocalAppDataGuard; - use super::policy::test_support::PolicyKeyGuard; - - /// Lock-order-safe redirect guard for tests that need both the consent - /// store and the policy key redirected away from real user/machine state. - pub struct TelemetryTestEnv { - _consent: LocalAppDataGuard, - policy: PolicyKeyGuard, - } - - impl TelemetryTestEnv { - /// Redirect both telemetry globals for the lifetime of the guard. - pub fn new(store: &std::path::Path) -> Self { - let policy = PolicyKeyGuard::new(); - let _consent = LocalAppDataGuard::set(store); - Self { _consent, policy } - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub fn set_policy_value(&self, value: u32) { - self.policy.set_value(value); - } - } -} - -/// Conventional process exit code for a Rust panic/abort. Used as the reported -/// `exit_code` on crash telemetry, since the panicking process has not (and -/// will not) produce a real [`ScriptResponse`]. +/// Reported exit code for a Rust panic or abort. const PANIC_EXIT_CODE: i32 = 101; -/// Reported `exit_code` for a cancelled run. The OS terminates the process with -/// its own status (e.g. `STATUS_CONTROL_C_EXIT`) after the control handler -/// returns, so this is a bounded sentinel for attribution only: 130 is the -/// conventional "terminated by Ctrl-C" (128 + SIGINT) code. +/// Reported exit code for a cancelled run. const CANCELLED_EXIT_CODE: i32 = 130; -/// Containment backend for this process, stashed at telemetry init so that -/// out-of-band emit paths with no [`ScriptResponse`] in scope — the global -/// panic hook and the console control (Ctrl-C / close) handler — can still -/// attribute their events to the correct backend. -/// -/// A `Mutex>` (rather than a `OnceLock`) so `#[cfg(test)]` -/// [`reset_for_test`] can clear it between tests; the setter still enforces -/// set-once semantics (only writes when currently unset). +/// Backend attribution for out-of-band events. static PROCESS_BACKEND: Mutex> = Mutex::new(None); -/// State-aware lifecycle phase for this process, stashed at dispatch so the -/// out-of-band emit paths (panic hook, console control handler) can attribute -/// their events to the phase that was executing. A `wxc-exec` invocation runs -/// exactly one state-aware phase, so a set-once value is sufficient. One-shot -/// executions never set it, leaving the phase `""` (no lifecycle phase). -/// -/// Set-once (only written when unset); resettable in tests via -/// [`reset_for_test`]. +/// State-aware phase attribution for out-of-band events. static PROCESS_PHASE: Mutex> = Mutex::new(None); -/// The Microsoft Correlation Vector (MS-CV) span for this process — the value -/// emitted under `__TlgCV__`. It is a random-seeded MS-CV for the state-aware -/// lifecycle this `wxc-exec` invocation is a phase of (seeded at `provision`, -/// spun per phase — see [`correlation_vector`]). Stashed at dispatch so the -/// out-of-band emit paths (panic hook, console control handler) can tag their -/// events with it, letting a crash / cancellation during any phase be correlated -/// back to the full lifecycle (join on the shared base prefix). Carries no -/// `sandbox_id` / UPN — privacy-safe by construction. One-shot executions never -/// set it, leaving it `""` (no lifecycle to join). -/// -/// Set-once (only written when unset); resettable in tests via -/// [`reset_for_test`]. +/// Correlation-vector attribution for out-of-band events. static PROCESS_CORRELATION_VECTOR: Mutex> = Mutex::new(None); -/// Set once the first terminal telemetry event has been emitted for this -/// process. The best-effort out-of-band paths (panic hook, cancellation -/// handler) can race the main thread's normal completion emit; this guard makes -/// emission exactly-once so a single dispatch never yields duplicate -/// `MXC.Execution` records. -static HAS_EMITTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +/// Prevents duplicate terminal events for one executor process. +static HAS_EMITTED: AtomicBool = AtomicBool::new(false); #[cfg(test)] thread_local! { @@ -120,11 +61,14 @@ thread_local! { /// telemetry test's forced-active state and trip the global panic hook into /// the sink. Never set outside tests. static TEST_FORCE_ACTIVE: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Optional authorization result used to prove the live gate independently + /// of platform consent storage. + static TEST_AUTHORIZATION_OVERRIDE: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; } -/// Whether the emit glue should proceed. In production this is exactly -/// [`mxc_telemetry::is_active`]; in tests a forced override (thread-local, set -/// under `TEST_LOCK`) can make it report active without a real ETW provider. +/// Whether the provider is active, including the test override. fn emit_active() -> bool { #[cfg(test)] if TEST_FORCE_ACTIVE.with(|f| f.get()) { @@ -133,9 +77,61 @@ fn emit_active() -> bool { mxc_telemetry::is_active() } -/// Claim the single terminal-emit slot for this process. Returns `true` if a -/// terminal event has *already* been emitted (so the caller should skip), -/// `false` for the first caller (which then owns emission). +/// Evaluate consent and policy for one complete logical emission. +fn authorization_allows_emission() -> bool { + #[cfg(test)] + if let Some(allowed) = TEST_AUTHORIZATION_OVERRIDE.with(|value| value.get()) { + return allowed; + } + #[cfg(test)] + if TEST_FORCE_ACTIVE.with(|active| active.get()) { + return true; + } + + consent::get_consent().allows_collection() && policy::get_policy().allows_collection() +} + +fn invocation_can_emit(active: bool) -> bool { + active && authorization_allows_emission() +} + +fn process_can_emit() -> bool { + emit_active() && authorization_allows_emission() +} + +/// Proof that one complete logical telemetry emission is authorized. +/// +/// Pair-writing helpers require this token so their `Execution` and `Error` +/// events share one consent and policy decision without repeating blocking +/// storage reads between the two writes. +/// +/// The type is zero-sized, not `Copy`/`Clone`, and has a private constructor, +/// so it cannot be forged, split across unrelated emissions, or reused after +/// authorization has already been consumed by a previous emission. +#[must_use = "an emission authorization does nothing unless events are written under it"] +struct EmissionAuthorization { + _private: (), +} + +impl EmissionAuthorization { + /// Evaluate the authorization decision for a request-scoped emission + /// (the one-shot completion / early-exit / state-aware paths). Returns + /// `Some` only when the caller's cached `active` flag *and* the current + /// authorization state (consent && policy) both permit emission. + fn for_invocation(active: bool) -> Option { + invocation_can_emit(active).then_some(Self { _private: () }) + } + + /// Evaluate the authorization decision for a process-scoped, out-of-band + /// emission — used by the panic hook and the console-control (Ctrl-C) + /// handler, where no request context is in scope. Consults the live + /// provider state via [`emit_active`] rather than a cached `active` flag. + fn for_process() -> Option { + process_can_emit().then_some(Self { _private: () }) + } +} + +/// Claim the terminal-event slot for this process. fn already_emitted() -> bool { HAS_EMITTED.swap(true, Ordering::SeqCst) } @@ -153,6 +149,7 @@ fn reset_for_test() { .lock() .unwrap_or_else(|e| e.into_inner()) = None; TEST_FORCE_ACTIVE.with(|f| f.set(false)); + TEST_AUTHORIZATION_OVERRIDE.with(|value| value.set(None)); events::test_sink::clear(); } @@ -170,35 +167,19 @@ pub fn version() -> &'static str { MXC_VERSION } -/// Resolve whether telemetry is enabled for this invocation. -/// -/// Resolution: -/// - `telemetry.enabled` in JSON config — explicit kill-switch/opt-in. -/// - Persisted user consent state — must be `Granted`. -/// - Administrative policy ceiling — must allow collection. -/// -/// All three terms must hold. The function fails closed if either consent or -/// policy cannot be read: those helpers resolve to non-collecting states. +/// Returns whether this invocation may emit telemetry. pub fn is_enabled(config: &TelemetryConfig) -> bool { - config.enabled.unwrap_or(false) - && consent::get_consent().allows_collection() - && policy::get_policy().allows_collection() + // Only an explicit opt-in enables telemetry for this invocation. + if config.enabled != Some(true) { + return false; + } + policy::get_policy().allows_collection() && consent::get_consent().allows_collection() } -/// Initialize the TraceLogging ETW provider. -/// -/// If telemetry is enabled, registers the `Microsoft.MXC` provider with ETW. -/// Returns `true` if telemetry was activated, `false` if disabled or if -/// registration failed. -/// -/// Registration failures never affect execution: they are logged as a -/// diagnostic via the supplied [`Logger`] (so the failure is visible on the -/// console when running with diagnostics) and otherwise swallowed — the caller -/// simply proceeds with telemetry inactive. ETW is Windows-only; on other -/// platforms `mxc_telemetry::init` is a no-op stub that always returns `false`, -/// which is expected rather than a failure, so no diagnostic is emitted there. +/// Initialize the telemetry provider when the gates allow it. pub fn init(config: &TelemetryConfig, logger: &mut Logger) -> bool { if !is_enabled(config) { + log_suppression_reason(config, logger); return false; } @@ -207,18 +188,48 @@ pub fn init(config: &TelemetryConfig, logger: &mut Logger) -> bool { logger .log_line("telemetry: ETW provider registration failed; continuing without telemetry"); } + if activated && !mxc_telemetry::IS_UTC_ROUTED { + logger.log_line( + "telemetry: events are emitted to local ETW only; this build has no provider group \ + GUID, so nothing is routed to the Microsoft pipeline (set \ + MXC_TELEMETRY_PROVIDER_GROUP_GUID at build time for an internal build)", + ); + } activated } -/// Unregister the TraceLogging ETW provider. +/// Explain, on the diagnostic log, exactly which gate turned telemetry off. /// -/// Should be called before process exit if `init()` returned `true`. -/// On early-exit paths where `shutdown()` cannot be called, the OS -/// will clean up the provider registration at process termination. +/// Without this an operator who set `telemetry.enabled: true` sees no events +/// and no reason — every gate fails closed and silently. Each conjunct of +/// [`is_enabled`] is reported independently so the log distinguishes "the run +/// never asked" from "the user has not consented" from "an administrator +/// blocked it". +fn log_suppression_reason(config: &TelemetryConfig, logger: &mut Logger) { + if config.enabled != Some(true) { + logger.log_line("telemetry: not requested for this run (telemetry.enabled is not true)"); + return; + } + + let policy = policy::get_policy(); + let consent = consent::get_consent(); + logger.log_line(&format!( + "telemetry: requested but suppressed (consent={}, policy={}); \ + MXC consent is independent of the Windows diagnostic-data setting", + consent.as_str(), + policy.as_str() + )); +} + +/// Unregister the telemetry provider. pub fn shutdown() { mxc_telemetry::shutdown(); } +fn sandbox_kind_for<'a>(backend: &'a str, requested: Option<&'a str>) -> &'a str { + requested.unwrap_or(backend) +} + /// Classify a failed execution into a bounded [`FailureReason`]. fn classify_failure(phase: &FailurePhase) -> FailureReason { match phase { @@ -231,26 +242,33 @@ fn classify_failure(phase: &FailurePhase) -> FailureReason { } } -/// Emit completion telemetry for a finished execution and shut the provider -/// down. No-op when `active` is `false`. -/// -/// This is the single shared emit path for the `wxc` and `lxc` executors: -/// it records an `MXC.Execution` event and, for failures that carry an error -/// message, an `MXC.Error` event (category + exit code only — never the -/// message text), then calls [`shutdown`]. +/// Emit completion telemetry for an executor invocation. pub fn emit_completion( active: bool, containment: &ContainmentBackend, response: &ScriptResponse, elapsed: Duration, ) { - if !active { + let Some(auth) = EmissionAuthorization::for_invocation(active) else { return; - } + }; if already_emitted() { return; } + emit_completion_event(&auth, containment, response, elapsed, None); + shutdown(); +} +fn emit_completion_event( + _auth: &EmissionAuthorization, + containment: &ContainmentBackend, + response: &ScriptResponse, + elapsed: Duration, + requested_sandbox_kind: Option<&str>, +) { + // The `_auth` parameter is the single authorization token for this + // emission (see `EmissionAuthorization`). Both writes below execute under + // it — there is no per-write reread of consent/policy state. let backend = containment.wire_name(); let failed = response.exit_code != 0; let outcome = if failed { "failure" } else { "success" }; @@ -258,7 +276,7 @@ pub fn emit_completion( log_execution(&ExecutionEvent { backend, - sandbox_kind: backend, + sandbox_kind: sandbox_kind_for(backend, requested_sandbox_kind), exit_code: response.exit_code, outcome, duration_ms: elapsed.as_millis() as u64, @@ -277,7 +295,6 @@ pub fn emit_completion( log_error( TelemetryContext { backend, - sandbox_kind: backend, phase: "", correlation_vector: "", }, @@ -285,8 +302,33 @@ pub fn emit_completion( response.exit_code, ); } +} - shutdown(); +/// Emit completion telemetry for an in-process SDK invocation. +/// +/// Unlike [`emit_completion`], this does not use the executable-wide +/// exactly-once slot: SDK processes may run multiple or concurrent sandboxes, +/// and their handle wrappers enforce exactly-once emission per invocation. +pub fn emit_sdk_completion( + active: bool, + containment: &ContainmentBackend, + response: &ScriptResponse, + elapsed: Duration, +) { + emit_sdk_completion_with_kind(active, containment, None, response, elapsed); +} + +/// Emit SDK completion telemetry with the request-scoped sandbox kind. +pub fn emit_sdk_completion_with_kind( + active: bool, + containment: &ContainmentBackend, + requested_sandbox_kind: Option<&'static str>, + response: &ScriptResponse, + elapsed: Duration, +) { + emit_sdk_with_release(active, |auth| { + emit_completion_event(auth, containment, response, elapsed, requested_sandbox_kind) + }); } /// Emit failure telemetry for an early-exit path that terminates **before** a @@ -295,23 +337,35 @@ pub fn emit_completion( /// /// One-shot executors validate configuration and select a backend before /// running; failures there call `process::exit` directly and would otherwise -/// bypass [`emit_completion`] entirely. This records an `MXC.Execution` event -/// (exit code 1, `failure` outcome) plus an `MXC.Error` event carrying the +/// bypass [`emit_completion`] entirely. This records an `Execution` event +/// (exit code 1, `failure` outcome) plus an `Error` event carrying the /// bounded `reason` category and exit code, so config/policy/init failures are /// observable. `duration_ms` is reported as `0` because no execution occurred. pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: FailureReason) { - if !active { + let Some(auth) = EmissionAuthorization::for_invocation(active) else { return; - } + }; if already_emitted() { return; } + emit_early_exit_event(&auth, containment, reason, None); + shutdown(); +} +fn emit_early_exit_event( + _auth: &EmissionAuthorization, + containment: &ContainmentBackend, + reason: FailureReason, + requested_sandbox_kind: Option<&str>, +) { + // The `_auth` parameter is the single authorization token for this + // emission (see `EmissionAuthorization`). Both writes below execute under + // it — there is no per-write reread of consent/policy state. let backend = containment.wire_name(); log_execution(&ExecutionEvent { backend, - sandbox_kind: backend, + sandbox_kind: sandbox_kind_for(backend, requested_sandbox_kind), exit_code: 1, outcome: "failure", duration_ms: 0, @@ -325,14 +379,38 @@ pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: F log_error( TelemetryContext { backend, - sandbox_kind: backend, phase: "", correlation_vector: "", }, reason, 1, ); +} + +/// Emit an SDK spawn failure without claiming the executable-wide terminal slot. +pub fn emit_sdk_early_exit(active: bool, containment: &ContainmentBackend, reason: FailureReason) { + emit_sdk_early_exit_with_kind(active, containment, None, reason); +} + +/// Emit SDK early-exit telemetry with the request-scoped sandbox kind. +pub fn emit_sdk_early_exit_with_kind( + active: bool, + containment: &ContainmentBackend, + requested_sandbox_kind: Option<&'static str>, + reason: FailureReason, +) { + emit_sdk_with_release(active, |auth| { + emit_early_exit_event(auth, containment, reason, requested_sandbox_kind) + }); +} +fn emit_sdk_with_release(active: bool, emit: impl FnOnce(&EmissionAuthorization)) { + if !active { + return; + } + if let Some(auth) = EmissionAuthorization::for_invocation(active) { + emit(&auth); + } shutdown(); } @@ -450,7 +528,7 @@ pub fn install_panic_hook() { })); } -/// Build the `MXC.Execution` event for an out-of-band crash/cancellation. Pure +/// Build the `Execution` event for an out-of-band crash/cancellation. Pure /// (no ETW I/O) so the exit-code/reason/attribution mapping can be unit-tested. fn crash_event<'a>( ctx: TelemetryContext<'a>, @@ -459,7 +537,7 @@ fn crash_event<'a>( ) -> ExecutionEvent<'a> { ExecutionEvent { backend: ctx.backend, - sandbox_kind: ctx.sandbox_kind, + sandbox_kind: ctx.backend, exit_code, outcome: "failure", duration_ms: 0, @@ -470,7 +548,7 @@ fn crash_event<'a>( } /// The pair of events an out-of-band crash/cancellation emits: one failure -/// `MXC.Execution` and one `MXC.Error`, both attributed to the same backend, +/// `Execution` and one `Error`, both attributed to the same backend, /// phase, exit code, and reason. struct CrashTelemetry<'a> { execution: ExecutionEvent<'a>, @@ -498,7 +576,16 @@ fn plan_crash<'a>( /// [`emit_panic`] and [`emit_cancellation`]: it performs the two ETW writes and /// deliberately does **not** call [`shutdown`] (see the callers' docs). Takes /// resolved attribution so the pure [`plan_crash`] mapping stays testable. -fn emit_crash(ctx: TelemetryContext<'_>, exit_code: i32, reason: FailureReason) { +/// +/// The `_auth` parameter is the single authorization token for this emission +/// (see [`EmissionAuthorization`]). Both writes below execute under it — no +/// per-write reread of consent/policy state. +fn emit_crash( + _auth: &EmissionAuthorization, + ctx: TelemetryContext<'_>, + exit_code: i32, + reason: FailureReason, +) { let plan = plan_crash(ctx, exit_code, reason); log_execution(&plan.execution); log_error(ctx, plan.error, plan.exit_code); @@ -508,7 +595,7 @@ fn emit_crash(ctx: TelemetryContext<'_>, exit_code: i32, reason: FailureReason) /// /// Guarded by [`mxc_telemetry::is_active`], so it is a cheap no-op when /// telemetry is disabled or the provider is already shut down. It records a -/// failure `MXC.Execution` and an `MXC.Error` categorised as +/// failure `Execution` and an `Error` categorised as /// [`FailureReason::InternalError`], attributed to the process backend stashed /// by [`set_process_context`] and the phase stashed by [`set_process_phase`]. /// @@ -517,14 +604,17 @@ fn emit_crash(ctx: TelemetryContext<'_>, exit_code: i32, reason: FailureReason) /// where the OS reclaims the ETW registration at process exit. It also carries /// **no** panic message text, which can contain paths or other PII. pub fn emit_panic() { - if !emit_active() || already_emitted() { + let Some(auth) = EmissionAuthorization::for_process() else { + return; + }; + if already_emitted() { return; } let correlation_vector = process_correlation_vector(); emit_crash( + &auth, TelemetryContext { backend: process_backend(), - sandbox_kind: process_backend(), phase: process_phase(), correlation_vector: &correlation_vector, }, @@ -538,7 +628,7 @@ pub fn emit_panic() { /// /// Guarded by [`mxc_telemetry::is_active`], so it is a cheap no-op when /// telemetry is disabled or already shut down. It records a failure -/// `MXC.Execution` and an `MXC.Error` categorised as [`FailureReason::Cancelled`], +/// `Execution` and an `Error` categorised as [`FailureReason::Cancelled`], /// attributed to the process backend stashed by [`set_process_context`] and the /// phase stashed by [`set_process_phase`]. /// @@ -547,14 +637,17 @@ pub fn emit_panic() { /// tears the process down via `ExitProcess`, and the main thread may still be /// live. It is allocation-light and emits no free-form text. pub fn emit_cancellation() { - if !emit_active() || already_emitted() { + let Some(auth) = EmissionAuthorization::for_process() else { + return; + }; + if already_emitted() { return; } let correlation_vector = process_correlation_vector(); emit_crash( + &auth, TelemetryContext { backend: process_backend(), - sandbox_kind: process_backend(), phase: process_phase(), correlation_vector: &correlation_vector, }, @@ -566,7 +659,11 @@ pub fn emit_cancellation() { /// Map an [`MxcError`] surfaced by state-aware dispatch to a bounded /// [`FailureReason`]. Exhaustive over [`MxcErrorCode`] so a newly-added code /// forces a compile error here rather than silently classifying as `Unknown`. -fn classify_mxc_error(err: &MxcError) -> FailureReason { +/// +/// Public so the streaming SDK path (`mxc_engine::spawn`) can preserve the +/// actual error category on early-exit telemetry rather than reporting every +/// dispatch failure as `InitError`. +pub fn classify_mxc_error(err: &MxcError) -> FailureReason { match err.code { MxcErrorCode::MalformedRequest | MxcErrorCode::MalformedId => FailureReason::ConfigError, MxcErrorCode::PolicyValidation => FailureReason::PolicyError, @@ -583,7 +680,7 @@ fn classify_mxc_error(err: &MxcError) -> FailureReason { } /// The telemetry a completed state-aware dispatch should emit: one -/// `MXC.Execution`, plus an optional `MXC.Error` category when the dispatch was +/// `Execution`, plus an optional `Error` category when the dispatch was /// an MXC infrastructure failure. Pure (no ETW I/O) so the outcome→event mapping /// can be unit-tested deterministically without an active provider. struct StateAwareEvents<'a> { @@ -593,16 +690,26 @@ struct StateAwareEvents<'a> { /// Pure outcome→events mapping shared by [`emit_state_aware`] and its tests. /// See [`emit_state_aware`] for the mapping rationale. +#[cfg(test)] fn plan_state_aware<'a>( ctx: TelemetryContext<'a>, outcome: &Result, duration_ms: u64, +) -> StateAwareEvents<'a> { + plan_state_aware_with_kind(ctx, outcome, duration_ms, ctx.backend) +} + +fn plan_state_aware_with_kind<'a>( + ctx: TelemetryContext<'a>, + outcome: &Result, + duration_ms: u64, + sandbox_kind: &'a str, ) -> StateAwareEvents<'a> { match outcome { Ok(DispatchOutcome::Envelope(_)) => StateAwareEvents { execution: ExecutionEvent { backend: ctx.backend, - sandbox_kind: ctx.sandbox_kind, + sandbox_kind, exit_code: 0, outcome: "success", duration_ms, @@ -617,13 +724,13 @@ fn plan_state_aware<'a>( StateAwareEvents { execution: ExecutionEvent { backend: ctx.backend, - sandbox_kind: ctx.sandbox_kind, + sandbox_kind, exit_code: *exit_code, outcome: if failed { "failure" } else { "success" }, duration_ms, // A non-zero guest exit is a faithfully propagated sandbox // exit code, not an MXC infrastructure error — leave the - // reason unset and emit no MXC.Error (mirrors one-shot + // reason unset and emit no Error (mirrors one-shot // emit_completion). failure_reason: None, phase: ctx.phase, @@ -637,7 +744,7 @@ fn plan_state_aware<'a>( StateAwareEvents { execution: ExecutionEvent { backend: ctx.backend, - sandbox_kind: ctx.sandbox_kind, + sandbox_kind, exit_code: 1, outcome: "failure", duration_ms, @@ -663,10 +770,10 @@ fn plan_state_aware<'a>( /// This is the state-aware counterpart to [`emit_completion`]. Outcome mapping: /// - [`DispatchOutcome::Envelope`] (non-exec phases and exec dry-run) — success, /// exit code 0. -/// - [`DispatchOutcome::ExecCompleted`] — mirrors one-shot: an `MXC.Execution` +/// - [`DispatchOutcome::ExecCompleted`] — mirrors one-shot: an `Execution` /// with the sandbox exit code. A clean non-zero *sandbox* exit is not an MXC -/// failure, so no `MXC.Error` is emitted. -/// - `Err(MxcError)` — an `MXC.Execution` failure plus an `MXC.Error` carrying +/// failure, so no `Error` is emitted. +/// - `Err(MxcError)` — an `Execution` failure plus an `Error` carrying /// the [`classify_mxc_error`] category. /// /// Terminal path (`run_state_aware_main` exits immediately after), so it calls @@ -677,26 +784,111 @@ pub fn emit_state_aware( outcome: &Result, elapsed: Duration, ) { - if !active { + let Some(auth) = EmissionAuthorization::for_invocation(active) else { return; - } + }; if already_emitted() { return; } + emit_state_aware_event(&auth, ctx, outcome, elapsed, None); + shutdown(); +} +fn emit_state_aware_event( + _auth: &EmissionAuthorization, + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, + requested_sandbox_kind: Option<&str>, +) { + // The `_auth` parameter is the single authorization token for this + // emission (see `EmissionAuthorization`). Both writes below execute under + // it — there is no per-write reread of consent/policy state. let duration_ms = elapsed.as_millis() as u64; - let plan = plan_state_aware(ctx, outcome, duration_ms); + let sandbox_kind = sandbox_kind_for(ctx.backend, requested_sandbox_kind); + let plan = plan_state_aware_with_kind(ctx, outcome, duration_ms, sandbox_kind); log_execution(&plan.execution); if let Some(reason) = plan.error { log_error(ctx, reason, plan.execution.exit_code); } +} - shutdown(); +/// Emit telemetry for an in-process SDK state-aware invocation. +/// +/// The SDK wrapper owns exactly-once emission for its request/handle, so this +/// bypasses the executable-wide terminal slot used by `wxc-exec`. +pub fn emit_sdk_state_aware( + active: bool, + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, +) { + emit_sdk_state_aware_with_kind(active, None, ctx, outcome, elapsed); +} + +/// Emit SDK state-aware telemetry with request-scoped attribution. +pub fn emit_sdk_state_aware_with_kind( + active: bool, + requested_sandbox_kind: Option<&'static str>, + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, +) { + emit_sdk_with_release(active, |auth| { + emit_state_aware_event(auth, ctx, outcome, elapsed, requested_sandbox_kind) + }); +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::consent::test_support::LocalAppDataGuard; + use super::policy::test_support::PolicyKeyGuard; + + /// A fully isolated telemetry environment: both the administrative policy + /// key and the user consent store are redirected to throwaway, per-test + /// locations. + /// + /// This is the **only** supported way to hold both guards at once. They + /// protect separate process-global mutexes, so acquiring them in + /// inconsistent orders across tests would deadlock under `cargo test`'s + /// multithreaded runner. Constructing them here — policy first, then + /// consent — is what establishes the total order that makes the pair + /// deadlock-free, and a caller cannot get it wrong because a caller never + /// sees the individual acquisitions. + /// + /// Every test that reaches [`super::is_enabled`], + /// [`super::consent::needs_consent_prompt`], or [`super::policy::get_policy`] + /// must hold this — *including* tests that only care about consent. + /// Otherwise they read the real machine policy and fail on an + /// administratively managed device. + pub(crate) struct TelemetryTestEnv { + // Fields drop in declaration order, so consent is released before + // policy: the exact reverse of the acquisition order below. + _consent: LocalAppDataGuard, + policy: PolicyKeyGuard, + } + + impl TelemetryTestEnv { + /// Redirects the consent store to `store` and the policy key to a + /// fresh, empty one (i.e. an unmanaged machine). + pub(crate) fn new(store: &std::path::Path) -> Self { + let policy = PolicyKeyGuard::new(); + let _consent = LocalAppDataGuard::set(store); + Self { _consent, policy } + } + + /// Sets the administrative `AllowTelemetry` policy value. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_policy_value(&self, value: u32) { + self.policy.set_value(value); + } + } } #[cfg(test)] mod tests { + use super::test_support::TelemetryTestEnv; use super::*; /// Serializes tests that touch the process-global emit slot / context @@ -706,55 +898,156 @@ mod tests { static TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] - fn is_enabled_explicit_false() { + fn is_enabled_explicit_true_alone_does_not_bypass_consent() { + // Consent isolated to a fresh, empty store (Undetermined) — an + // explicit `enabled: true` in the config must not be able to turn + // telemetry on for someone who has not granted consent. + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); let config = TelemetryConfig { - enabled: Some(false), + enabled: Some(true), + ..Default::default() }; assert!(!is_enabled(&config)); } + #[cfg(target_os = "windows")] #[test] - fn is_enabled_default_off() { - let config = TelemetryConfig::default(); - assert!(!is_enabled(&config)); + fn is_enabled_true_when_consent_granted() { + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(true, "cli").unwrap(); + // An explicit opt-in is required in addition to consent. + assert!(is_enabled(&TelemetryConfig { + enabled: Some(true), + ..Default::default() + })); } + /// An administrative denial overrides an explicit user grant. This is the + /// MDM/Intune ceiling: policy can only ever subtract. #[cfg(target_os = "windows")] #[test] - fn is_enabled_explicit_true_requires_granted_consent_and_permitting_policy() { - let store = tempfile::tempdir().expect("temp dir"); - let env = test_support::TelemetryTestEnv::new(store.path()); - let config = TelemetryConfig { + fn is_enabled_false_when_policy_blocks_despite_consent() { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(true, "cli").unwrap(); + env.set_policy_value(0); + + assert!(!is_enabled(&TelemetryConfig { enabled: Some(true), - }; + ..Default::default() + })); + } - assert!( - !is_enabled(&config), - "undetermined consent must fail closed" - ); + /// The converse, and the load-bearing privacy invariant: a permissive + /// administrative policy is *not* consent. An admin who allows telemetry + /// has not decided on the user's behalf. + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_policy_allows_but_consent_is_absent() { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + env.set_policy_value(3); - consent::set_consent(true, "test").expect("set consent"); - assert!( - is_enabled(&config), - "granted + allowed policy should enable" - ); + assert_eq!(policy::get_policy(), policy::PolicyState::Allowed); + assert!(!is_enabled(&TelemetryConfig { + enabled: Some(true), + ..Default::default() + })); + } - env.set_policy_value(0); - assert!( - !is_enabled(&config), - "blocked policy must suppress telemetry even with granted consent" - ); + /// Explicit user denial must beat every policy state — including a + /// permissive one. Completes the consent × policy matrix. + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_consent_denied_under_every_policy() { + for policy_value in [None, Some(0u32), Some(1), Some(3)] { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(false, "cli").unwrap(); + if let Some(value) = policy_value { + env.set_policy_value(value); + } + + assert!( + !is_enabled(&TelemetryConfig { + enabled: Some(true), + ..Default::default() + }), + "denied consent must win over explicit enable under policy {policy_value:?}" + ); + } } - #[cfg(not(target_os = "windows"))] + /// The policy is a ceiling, never a grant: no policy value may enable + /// telemetry for a user who has never recorded a decision. `Denied` is + /// covered above; this covers the fresh-machine `Undetermined` case, which + /// is the one a permissive policy could plausibly be mistaken for a grant. + #[cfg(target_os = "windows")] #[test] - fn is_enabled_explicit_true_is_false_off_windows() { + fn is_enabled_false_when_consent_undetermined_under_every_policy() { + for policy_value in [None, Some(0u32), Some(1), Some(3)] { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + if let Some(value) = policy_value { + env.set_policy_value(value); + } + + assert_eq!(consent::get_consent(), consent::ConsentState::Undetermined); + assert!( + !is_enabled(&TelemetryConfig { + enabled: Some(true), + ..Default::default() + }), + "undetermined consent must block an explicit enable under policy {policy_value:?}" + ); + } + } + + #[test] + fn is_enabled_explicit_false() { + // The kill switch wins even when consent has been granted. + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); + #[cfg(target_os = "windows")] + consent::set_consent(true, "cli").unwrap(); let config = TelemetryConfig { - enabled: Some(true), + enabled: Some(false), + ..Default::default() }; assert!(!is_enabled(&config)); } + #[test] + fn is_enabled_default_off() { + let tmp = tempfile::tempdir().unwrap(); + let _env = TelemetryTestEnv::new(tmp.path()); + let config = TelemetryConfig::default(); + assert!(!is_enabled(&config)); + } + + /// The load-bearing half of "omitted = off". Without granting consent + /// first this test would pass for the wrong reason — a fresh store is + /// `Undetermined`, which disables telemetry on its own — and would keep + /// passing if omission silently started meaning "defer to consent". + #[cfg(target_os = "windows")] + #[test] + fn is_enabled_false_when_enabled_omitted_despite_consent_and_permissive_policy() { + let tmp = tempfile::tempdir().unwrap(); + let env = TelemetryTestEnv::new(tmp.path()); + consent::set_consent(true, "cli").unwrap(); + env.set_policy_value(3); + + assert_eq!(consent::get_consent(), consent::ConsentState::Granted); + assert_eq!(policy::get_policy(), policy::PolicyState::Allowed); + // Every other gate is open; only the omitted opt-in keeps it off. + assert!(!is_enabled(&TelemetryConfig { + enabled: None, + ..Default::default() + })); + } + #[test] fn version_is_not_empty() { assert!(!version().is_empty()); @@ -787,14 +1080,36 @@ mod tests { ); } + #[test] + fn live_authorization_gate_suppresses_and_then_allows_emission() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_FORCE_ACTIVE.with(|active| active.set(true)); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); + set_process_context(&ContainmentBackend::IsolationSession); + + emit_panic(); + assert!(events::test_sink::take_executions().is_empty()); + assert!(events::test_sink::take_errors().is_empty()); + + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + emit_panic(); + assert_eq!(events::test_sink::take_executions().len(), 1); + assert_eq!(events::test_sink::take_errors().len(), 1); + + reset_for_test(); + } + #[test] fn emit_panic_active_captures_execution_and_error() { // Drive the real emit glue (globals read → active guard → paired write) // with the provider forced active and the capture sink installed, then - // assert the exact MXC.Execution + MXC.Error records a panic produces. + // assert the exact Execution + Error records a panic produces. let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); TEST_FORCE_ACTIVE.with(|f| f.set(true)); set_process_context(&ContainmentBackend::IsolationSession); set_process_phase("exec"); @@ -803,21 +1118,20 @@ mod tests { emit_panic(); let execs = events::test_sink::take_executions(); - assert_eq!(execs.len(), 1, "panic emits exactly one MXC.Execution"); + assert_eq!(execs.len(), 1, "panic emits exactly one Execution"); let exec = &execs[0]; assert_eq!(exec.backend, "isolation_session"); + assert_eq!(exec.sandbox_kind, "isolation_session"); assert_eq!(exec.exit_code, PANIC_EXIT_CODE); assert_eq!(exec.outcome, "failure"); assert_eq!(exec.failure_reason, Some(FailureReason::InternalError)); assert_eq!(exec.phase, "exec"); - assert_eq!(exec.sandbox_kind, "isolation_session"); assert_eq!(exec.correlation_vector, "iso:wxc-abcd"); let errors = events::test_sink::take_errors(); - assert_eq!(errors.len(), 1, "panic emits exactly one MXC.Error"); + assert_eq!(errors.len(), 1, "panic emits exactly one Error"); let error = &errors[0]; assert_eq!(error.backend, "isolation_session"); - assert_eq!(error.sandbox_kind, "isolation_session"); assert_eq!(error.error_type, FailureReason::InternalError); assert_eq!(error.exit_code, PANIC_EXIT_CODE); assert_eq!(error.phase, "exec"); @@ -833,6 +1147,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); TEST_FORCE_ACTIVE.with(|f| f.set(true)); set_process_context(&ContainmentBackend::IsolationSession); set_process_phase("start"); @@ -847,18 +1162,93 @@ mod tests { assert_eq!(exec.outcome, "failure"); assert_eq!(exec.failure_reason, Some(FailureReason::Cancelled)); assert_eq!(exec.phase, "start"); - assert_eq!(exec.sandbox_kind, "isolation_session"); assert_eq!(exec.correlation_vector, "iso:wxc-abcd"); let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 1); - assert_eq!(errors[0].sandbox_kind, "isolation_session"); assert_eq!(errors[0].error_type, FailureReason::Cancelled); assert_eq!(errors[0].exit_code, CANCELLED_EXIT_CODE); reset_for_test(); } + #[test] + fn emit_completion_captures_success_and_failure_records() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + + emit_completion( + true, + &ContainmentBackend::IsolationSession, + &ScriptResponse { + exit_code: 0, + ..Default::default() + }, + Duration::from_millis(12), + ); + let executions = events::test_sink::take_executions(); + assert_eq!(executions.len(), 1); + assert_eq!(executions[0].outcome, "success"); + assert_eq!(executions[0].exit_code, 0); + assert_eq!(executions[0].failure_reason, None); + assert!(events::test_sink::take_errors().is_empty()); + + reset_for_test(); + events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + emit_completion( + true, + &ContainmentBackend::IsolationSession, + &ScriptResponse { + exit_code: 1, + error_message: "launch failed".to_string(), + failure_phase: FailurePhase::LaunchFailed, + ..Default::default() + }, + Duration::from_millis(3), + ); + let executions = events::test_sink::take_executions(); + assert_eq!(executions.len(), 1); + assert_eq!(executions[0].outcome, "failure"); + assert_eq!(executions[0].exit_code, 1); + assert_eq!(executions[0].failure_reason, Some(FailureReason::InitError)); + let errors = events::test_sink::take_errors(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].error_type, FailureReason::InitError); + + reset_for_test(); + } + + #[test] + fn emit_early_exit_captures_execution_and_error() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + + emit_early_exit( + true, + &ContainmentBackend::IsolationSession, + FailureReason::PolicyError, + ); + + let executions = events::test_sink::take_executions(); + assert_eq!(executions.len(), 1); + assert_eq!(executions[0].outcome, "failure"); + assert_eq!(executions[0].exit_code, 1); + assert_eq!( + executions[0].failure_reason, + Some(FailureReason::PolicyError) + ); + let errors = events::test_sink::take_errors(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].error_type, FailureReason::PolicyError); + + reset_for_test(); + } + #[test] fn second_terminal_emit_is_suppressed_end_to_end() { // With the provider active, the first out-of-band emit claims the slot @@ -877,12 +1267,12 @@ mod tests { assert_eq!( events::test_sink::take_executions().len(), 1, - "second emit must not add an MXC.Execution" + "second emit must not add an Execution" ); assert_eq!( events::test_sink::take_errors().len(), 1, - "second emit must not add an MXC.Error" + "second emit must not add an Error" ); reset_for_test(); @@ -893,7 +1283,7 @@ mod tests { // The exactly-once slot (`HAS_EMITTED`) is concurrency-critical: the // out-of-band panic/cancellation paths race the main completion emit, // and the guard is what keeps a single dispatch from producing - // duplicate MXC.Execution records. Lock the global state, reset to a + // duplicate Execution records. Lock the global state, reset to a // known baseline, and assert claim-once semantics end-to-end. let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); @@ -914,7 +1304,7 @@ mod tests { #[test] fn reset_clears_stashed_process_context() { - // reset_for_test must also clear the stashed backend/phase/correlation-id + // reset_for_test must clear the stashed backend/phase/correlation-id // so one test's context can't bleed into another's out-of-band // attribution. let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -945,7 +1335,6 @@ mod tests { false, TelemetryContext { backend: "isolation_session", - sandbox_kind: "isolation_session", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -1012,7 +1401,6 @@ mod tests { let event = crash_event( TelemetryContext { backend: "lxc", - sandbox_kind: "lxc", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -1029,7 +1417,6 @@ mod tests { let cancel = crash_event( TelemetryContext { backend: "appcontainer", - sandbox_kind: "appcontainer", phase: "", correlation_vector: "", }, @@ -1050,7 +1437,6 @@ mod tests { let panic = plan_crash( TelemetryContext { backend: "lxc", - sandbox_kind: "lxc", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -1066,14 +1452,13 @@ mod tests { panic.execution.failure_reason, Some(FailureReason::InternalError) ); - // The MXC.Error carries the same reason/exit code as the execution event. + // The Error carries the same reason/exit code as the execution event. assert_eq!(panic.error, FailureReason::InternalError); assert_eq!(panic.exit_code, PANIC_EXIT_CODE); let cancel = plan_crash( TelemetryContext { backend: "isolation_session", - sandbox_kind: "isolation_session", phase: "", correlation_vector: "", }, @@ -1110,7 +1495,6 @@ mod tests { for phase in PHASES { let ctx = TelemetryContext { backend: "isolation_session", - sandbox_kind: "isolation_session", phase, correlation_vector: correlation, }; @@ -1136,7 +1520,7 @@ mod tests { assert!(zero_plan.error.is_none()); // Non-zero guest exit → failure with the propagated exit code, but - // NO MXC.Error (a faithfully-propagated script exit, not an MXC + // NO Error (a faithfully-propagated script exit, not an MXC // failure). let nonzero = Ok(DispatchOutcome::ExecCompleted { exit_code: 42 }); let nonzero_plan = plan_state_aware(ctx, &nonzero, 3); @@ -1147,7 +1531,7 @@ mod tests { assert!(nonzero_plan.execution.failure_reason.is_none()); assert!(nonzero_plan.error.is_none()); - // MxcError → failure / exit 1 / classified MXC.Error. + // MxcError → failure / exit 1 / classified Error. let err = Err(MxcError::backend_unavailable("no host")); let err_plan = plan_state_aware(ctx, &err, 5); assert_eq!(err_plan.execution.phase, phase); @@ -1215,14 +1599,14 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); - // Provision-style success envelope → one MXC.Execution, no MXC.Error. + // Provision-style success envelope → one Execution, no Error. let envelope = Ok(DispatchOutcome::Envelope(serde_json::json!({}))); emit_state_aware( true, TelemetryContext { backend: "isolation_session", - sandbox_kind: "isolation_session", phase: "provision", correlation_vector: "corr-provision", }, @@ -1232,7 +1616,6 @@ mod tests { let execs = events::test_sink::take_executions(); assert_eq!(execs.len(), 1); assert_eq!(execs[0].phase, "provision"); - assert_eq!(execs[0].sandbox_kind, "isolation_session"); assert_eq!(execs[0].correlation_vector, "corr-provision"); assert_eq!(execs[0].outcome, "success"); assert!(events::test_sink::take_errors().is_empty()); @@ -1240,12 +1623,12 @@ mod tests { // Fresh slot for the error case (the emit above claimed it once). reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); let err = Err(MxcError::policy_validation("bad policy")); emit_state_aware( true, TelemetryContext { backend: "isolation_session", - sandbox_kind: "isolation_session", phase: "start", correlation_vector: "corr-start", }, @@ -1255,19 +1638,163 @@ mod tests { let execs = events::test_sink::take_executions(); assert_eq!(execs.len(), 1); assert_eq!(execs[0].phase, "start"); - assert_eq!(execs[0].sandbox_kind, "isolation_session"); assert_eq!(execs[0].correlation_vector, "corr-start"); assert_eq!(execs[0].outcome, "failure"); let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 1); assert_eq!(errors[0].error_type, FailureReason::PolicyError); assert_eq!(errors[0].phase, "start"); - assert_eq!(errors[0].sandbox_kind, "isolation_session"); assert_eq!(errors[0].correlation_vector, "corr-start"); reset_for_test(); } + #[test] + fn sdk_state_aware_events_keep_request_scoped_sandbox_kind() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + + let outcome = Ok(DispatchOutcome::Envelope(serde_json::json!({}))); + emit_sdk_state_aware_with_kind( + true, + Some("process"), + TelemetryContext { + backend: "windows_sandbox", + phase: "exec", + correlation_vector: "corr-process", + }, + &outcome, + Duration::ZERO, + ); + emit_sdk_state_aware_with_kind( + true, + Some("vm"), + TelemetryContext { + backend: "windows_sandbox", + phase: "exec", + correlation_vector: "corr-vm", + }, + &outcome, + Duration::ZERO, + ); + + let executions = events::test_sink::take_executions(); + assert_eq!(executions.len(), 2); + assert_eq!(executions[0].sandbox_kind, "process"); + assert_eq!(executions[1].sandbox_kind, "vm"); + assert_eq!(executions[0].correlation_vector, "corr-process"); + assert_eq!(executions[1].correlation_vector, "corr-vm"); + + reset_for_test(); + } + + #[cfg(windows)] + #[test] + fn sdk_emit_releases_provider_when_live_authorization_closes() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); + + assert!(mxc_telemetry::init(version(), MXC_CHANNEL)); + assert!(mxc_telemetry::is_active()); + + emit_sdk_with_release(true, |_auth| { + panic!("authorization must suppress the event") + }); + + assert!(!mxc_telemetry::is_active()); + reset_for_test(); + } + + /// Regression: the paired-write authorization decision must be captured + /// *once* per logical emission and shared by both writes. If authorization + /// flips from allowed → denied *after* the token is minted but *before* + /// the second write in a pair, the second write must still fire (they + /// share the one decision proven by the `EmissionAuthorization` token). + /// This structurally forbids a revocation-race window between the paired + /// `log_execution` and `log_error` calls, without re-introducing blocking + /// consent/policy I/O on the hot path. + #[test] + fn paired_write_shares_single_authorization_decision() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_FORCE_ACTIVE.with(|f| f.set(true)); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + + // Precondition: authorization initially allows — the token can be + // minted. + assert!(EmissionAuthorization::for_invocation(true).is_some()); + + // Drive the paired-write path (Execution + Error) on the state-aware + // emit, which is representative of the completion / early-exit / + // crash pairs (all share the same token-threading structure). + let err = Err(MxcError::policy_validation("simulated")); + emit_state_aware( + true, + TelemetryContext { + backend: "isolation_session", + phase: "exec", + correlation_vector: "corr-race", + }, + &err, + Duration::from_millis(1), + ); + + // Revoke authorization after the emission. Prior to the token + // refactor this override could have been read a second time between + // the paired writes; under the token, both writes were already + // committed under the up-front decision. + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); + assert!(EmissionAuthorization::for_invocation(true).is_none()); + + let execs = events::test_sink::take_executions(); + let errors = events::test_sink::take_errors(); + assert_eq!(execs.len(), 1, "Execution must be emitted under the token"); + assert_eq!( + errors.len(), + 1, + "Error must be emitted under the same token as its paired Execution" + ); + assert_eq!(execs[0].outcome, "failure"); + assert_eq!(errors[0].error_type, FailureReason::PolicyError); + + reset_for_test(); + } + + /// Regression: a denied initial authorization must skip the pair entirely + /// — neither `log_execution` nor `log_error` may fire when the token + /// cannot be minted. + #[test] + fn paired_write_denies_pair_when_token_cannot_be_minted() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_FORCE_ACTIVE.with(|f| f.set(true)); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(false))); + + assert!(EmissionAuthorization::for_invocation(true).is_none()); + + let err = Err(MxcError::policy_validation("simulated")); + emit_state_aware( + true, + TelemetryContext { + backend: "isolation_session", + phase: "exec", + correlation_vector: "corr-denied", + }, + &err, + Duration::from_millis(1), + ); + + assert!(events::test_sink::take_executions().is_empty()); + assert!(events::test_sink::take_errors().is_empty()); + + reset_for_test(); + } + // Validates that the emit guard honors the *real* `mxc_telemetry` provider — // not just the `TEST_FORCE_ACTIVE` override — by registering the provider for // real (only possible on Windows) and asserting `emit_panic` captures without @@ -1278,6 +1805,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); // Register the real ETW provider; on Windows this makes is_active() true. assert!( diff --git a/src/core/wxc_common/src/telemetry/policy.rs b/src/core/wxc_common/src/telemetry/policy.rs index 2433b9c74..0efbd011d 100644 --- a/src/core/wxc_common/src/telemetry/policy.rs +++ b/src/core/wxc_common/src/telemetry/policy.rs @@ -236,31 +236,66 @@ mod platform { .unwrap_or_else(|e| e.into_inner()) .clone() .or_else(|| { - same_process_policy_key_override( + scoped_policy_key_override( std::env::var_os(super::POLICY_KEY_OVERRIDE_ENV), std::env::var_os(super::POLICY_KEY_OVERRIDE_OWNER_ENV), std::process::id(), + direct_parent_process_id(), ) }) } #[cfg(any(test, all(feature = "test-support", debug_assertions)))] - /// Environment bridge for language-binding tests that load the debug - /// native library in the same process. The owner PID prevents an ambient - /// override from leaking into child processes. - fn same_process_policy_key_override( + /// Environment bridge for language-binding tests and smoke-test children. + /// + /// The owner PID is mandatory so an unpaired ambient key override is + /// ignored. It may identify this process (an in-process binding test) or + /// the test harness that explicitly launched this child. + fn scoped_policy_key_override( key: Option, owner: Option, process_id: u32, + parent_process_id: Option, ) -> Option { let owner = owner?.to_str()?.parse::().ok()?; - if owner != process_id { + if owner != process_id && Some(owner) != parent_process_id { return None; } let key = key?.into_string().ok()?; (!key.is_empty()).then_some(key) } + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + fn direct_parent_process_id() -> Option { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut parent = None; + unsafe { + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + if entry.th32ProcessID == std::process::id() { + parent = Some(entry.th32ParentProcessID); + break; + } + if Process32NextW(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + } + parent + } + /// The production key path is not exercised by redirected policy tests, so /// this guards the one property that could silently break administrators. #[cfg(test)] @@ -294,29 +329,57 @@ mod platform { } #[cfg(test)] - mod same_process_override { - use super::same_process_policy_key_override; + mod scoped_override { + use super::scoped_policy_key_override; #[test] - fn requires_the_current_process_as_owner() { + fn accepts_an_explicit_same_process_or_parent_owner() { assert_eq!( - same_process_policy_key_override( + scoped_policy_key_override( Some("Software\\MxcTest".into()), Some("42".into()), 42, + Some(41), ), Some("Software\\MxcTest".to_string()) ); assert_eq!( - same_process_policy_key_override( + scoped_policy_key_override( Some("Software\\MxcTest".into()), Some("41".into()), 42, + Some(41), + ), + Some("Software\\MxcTest".to_string()) + ); + assert_eq!( + scoped_policy_key_override( + Some("Software\\MxcTest".into()), + Some("40".into()), + 42, + Some(41), + ), + None + ); + } + + #[test] + fn rejects_an_unowned_or_malformed_override() { + assert_eq!( + scoped_policy_key_override(Some("Software\\MxcTest".into()), None, 42, Some(41),), + None + ); + assert_eq!( + scoped_policy_key_override( + Some("Software\\MxcTest".into()), + Some("not-a-pid".into()), + 42, + Some(41), ), None ); assert_eq!( - same_process_policy_key_override(Some("Software\\MxcTest".into()), None, 42), + scoped_policy_key_override(Some("".into()), Some("42".into()), 42, Some(41)), None ); } diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index ec489d19f..b05bf2910 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -62,18 +62,6 @@ pub struct MxcConfig { /// non-provision state-aware phases. pub sandbox_id: Option, - /// Microsoft Correlation Vector (MS-CV) seeded at `provision` and returned in - /// the provision result. The client relays it verbatim into every later - /// state-aware phase so all phases of one lifecycle share a telemetry base - /// prefix (emitted under `__TlgCV__`). The executor is the trust boundary: on - /// each non-provision phase it validates the relayed value and *spins* a fresh - /// child element off a mutable base (so multiple invocations of one phase stay - /// distinct), passes an already-frozen vector through unchanged, and reseeds a - /// brand-new base if the relayed value is absent or malformed — so a missing - /// or hostile relay never reaches telemetry unvalidated. Ignored unless - /// experimental telemetry is enabled; not valid on one-shot requests. - pub correlation_vector: Option, - /// Externally assigned container identifier. pub container_id: Option, @@ -113,10 +101,182 @@ pub struct MxcConfig { #[serde(alias = "macos_sandbox")] pub seatbelt: Option, + /// Telemetry configuration. + pub telemetry: Option, + /// Experimental features. Only honored when `--experimental` is passed. pub experimental: Option, } +/// Telemetry-consent maintenance request. +/// +/// This is a separate contract from [`MxcConfig`], even though executors use +/// the same JSON input loader for both. Its explicit `command` discriminator +/// lets the loader route maintenance without widening the execution schema. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[cfg_attr( + feature = "schema-gen", + schemars(title = "MXC Telemetry Consent Maintenance Request") +)] +#[serde( + rename_all = "camelCase", + deny_unknown_fields, + expecting = "a telemetry consent maintenance request" +)] +pub struct TelemetryConsentMaintenanceRequest { + /// Optional JSON Schema reference for editor validation. + #[serde(rename = "$schema")] + pub schema: Option, + /// Fixed maintenance discriminator. Must be `"telemetryConsent"`. + pub command: TelemetryConsentCommand, + /// Consent operation to perform. + pub action: TelemetryConsentAction, + /// Preferred BCP 47 locale for the canonical prompt. Unsupported locales + /// fall back to `en-US`. Used only by `request`. + pub locale: Option, +} + +/// Fixed telemetry-consent maintenance command discriminator. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentCommand { + TelemetryConsent, +} + +/// Telemetry-consent maintenance action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentAction { + /// Present the canonical prompt when consent may be requested. + Request, + /// Idempotently persist denied consent. + Withdraw, + /// Read status without mutation. + Status, +} + +/// Typed result of a telemetry-consent maintenance action. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentResult { + Status, + PresentationRequired, + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + PresentationUnavailable, + NotApplicable, +} + +/// Typed decision returned by a consent presenter. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TelemetryConsentDecision { + Yes, + No, + Dismissed, +} + +/// Private, session-bound presenter response consumed by the same executor +/// process that emitted the challenge. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentPresenterResponse { + pub challenge: String, + pub resource_version: u32, + pub decision: TelemetryConsentDecision, +} + +/// Stable consent-state strings used by maintenance responses. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TelemetryConsentState { + Granted, + Denied, + Undetermined, + NotApplicable, +} + +/// Stable policy-state strings used by maintenance responses. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TelemetryConsentPolicyState { + Unrestricted, + Allowed, + Blocked, + NotApplicable, +} + +/// Why persisted consent is not currently effective. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum TelemetryConsentStatusReason { + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + PolicyBlocked, + PresentationUnavailable, + NotApplicable, +} + +/// One canonical prompt message. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentMessage { + pub id: String, + pub text: String, +} + +/// Canonical prompt data supplied to an SDK presenter. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentPrompt { + pub resource_version: u32, + pub locale: String, + pub title: TelemetryConsentMessage, + pub body: TelemetryConsentMessage, + pub affirmative_label: TelemetryConsentMessage, + pub negative_label: TelemetryConsentMessage, + pub learn_more_label: TelemetryConsentMessage, + pub learn_more_url: String, +} + +/// Typed telemetry-consent maintenance response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryConsentMaintenanceResponse { + pub action: TelemetryConsentAction, + pub result: TelemetryConsentResult, + pub stored_state: TelemetryConsentState, + pub effective_state: TelemetryConsentState, + pub reason: Option, + pub policy: TelemetryConsentPolicyState, + pub needs_prompt: bool, + /// Present only during the private SDK presenter handshake. + pub prompt: Option, + /// Opaque, single-request challenge for the private SDK presenter + /// handshake. Never persisted. + pub challenge: Option, +} + /// State-aware lifecycle phase. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -478,17 +638,17 @@ pub struct Experimental { /// Seatbelt backend config (pre-promotion alias). #[serde(alias = "macos_sandbox")] pub seatbelt: Option, - /// Telemetry configuration. - pub telemetry: Option, } -/// Telemetry configuration (`experimental.telemetry`). +/// Telemetry configuration (`telemetry`). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Telemetry { - /// Explicit telemetry override. `true` = force on, `false` = force off, - /// omitted = disabled (default off). + /// Explicit telemetry opt-in for this invocation. `true` = opt in (still + /// subject to the user's consent and to administrative policy — it can + /// never turn telemetry on for someone who has not consented), `false` = + /// force off, omitted = off. pub enabled: Option, } @@ -627,12 +787,18 @@ pub struct IsolationSessionProvisionPhase { /// re-exported below as `generate_config_schema_json`. #[cfg(feature = "schema-gen")] mod schema_gen { - use super::MxcConfig; + use super::{ + MxcConfig, TelemetryConsentMaintenanceRequest, TelemetryConsentMaintenanceResponse, + TelemetryConsentPresenterResponse, + }; + use schemars::JsonSchema; /// Canonical `$id` for the generated dev schema. Bump alongside the dev schema /// version/filename (see `schemas/schema-version.json`). const SCHEMA_ID: &str = "https://github.com/microsoft/mxc/schemas/dev/mxc-config.schema.0.8.0-dev.json"; + const TELEMETRY_CONSENT_SCHEMA_ID: &str = + "https://github.com/microsoft/mxc/schemas/dev/mxc-telemetry-consent.schema.1.json"; /// Generate the JSON Schema for the MXC config from the dedicated `MxcConfig` /// model. The schema is post-processed to (a) inject the canonical `$id`, @@ -656,13 +822,17 @@ mod schema_gen { /// JSON-schema renderer and the TypeScript emitter so both consume exactly the /// same model. fn schema_value() -> serde_json::Value { - let schema = schemars::schema_for!(MxcConfig); + schema_value_for::(SCHEMA_ID) + } + + fn schema_value_for(schema_id: &str) -> serde_json::Value { + let schema = schemars::schema_for!(T); let mut value = serde_json::to_value(&schema).expect("schema serialises to JSON value"); normalize_integer_formats(&mut value); if let serde_json::Value::Object(map) = &mut value { map.insert( "$id".to_string(), - serde_json::Value::String(SCHEMA_ID.to_string()), + serde_json::Value::String(schema_id.to_string()), ); } value @@ -680,6 +850,32 @@ mod schema_gen { crate::ts_emit::emit_ts(&value) } + /// Generate the separate telemetry-consent maintenance request schema. + pub fn generate_telemetry_consent_schema_json() -> String { + let value = + schema_value_for::(TELEMETRY_CONSENT_SCHEMA_ID); + if let serde_json::Value::Object(map) = &value { + return render_root_ordered(map); + } + serde_json::to_string_pretty(&value).expect("schema serialises to JSON") + } + + #[derive(JsonSchema)] + #[allow(dead_code)] + struct TelemetryConsentMaintenanceTypes { + request: TelemetryConsentMaintenanceRequest, + response: TelemetryConsentMaintenanceResponse, + presenter_response: TelemetryConsentPresenterResponse, + } + + /// Generate TypeScript wire types for both maintenance requests and + /// responses without adding them to the execution-config drift oracle. + pub fn generate_telemetry_consent_sdk_types_ts() -> String { + let value = + schema_value_for::(TELEMETRY_CONSENT_SCHEMA_ID); + crate::ts_emit::emit_ts(&value) + } + /// Render the root object as pretty JSON with a fixed key order — the schema /// metadata (`$schema`, `$id`, `title`, `description`) first, then the /// structural keys — without disturbing nested key order. @@ -762,4 +958,7 @@ mod schema_gen { } #[cfg(feature = "schema-gen")] -pub use schema_gen::{generate_config_schema_json, generate_sdk_types_ts}; +pub use schema_gen::{ + generate_config_schema_json, generate_sdk_types_ts, generate_telemetry_consent_schema_json, + generate_telemetry_consent_sdk_types_ts, +}; diff --git a/src/mxc_telemetry/provider_codegen.rs b/src/mxc_telemetry/provider_codegen.rs index cbb274866..6ca50bb91 100644 --- a/src/mxc_telemetry/provider_codegen.rs +++ b/src/mxc_telemetry/provider_codegen.rs @@ -60,11 +60,13 @@ fn generate_provider_def(group_guid: Option<&str>) -> String { format!( "tracelogging::define_provider!(\ MXC_PROVIDER, \"Microsoft.MXC\", \ - group_id(\"{canonical}\"));\n" + group_id(\"{canonical}\"));\n\ + pub const IS_UTC_ROUTED: bool = true;\n" ) } _ => "tracelogging::define_provider!(\ - MXC_PROVIDER, \"Microsoft.MXC\");\n" + MXC_PROVIDER, \"Microsoft.MXC\");\n\ + pub const IS_UTC_ROUTED: bool = false;\n" .to_string(), } } diff --git a/src/mxc_telemetry/src/lib.rs b/src/mxc_telemetry/src/lib.rs index 5caf02d53..67ae2e881 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -114,8 +114,21 @@ mod provider { return true; } - // SAFETY: MXC_PROVIDER is a process-lifetime static. MXC is an - // executable (not a DLL), so unload ordering is not a concern. + // SAFETY: MXC_PROVIDER is a process-lifetime static declared inside + // this module (via `include!("provider_def.rs")`), so its storage + // outlives every ETW callback the tracelogging runtime installs + // against it. This function is however now called from library + // contexts too (`mxc_engine::spawn` is compiled into the `mxc_ffi` + // cdylib and the `mxc-sdk` library, both of which may be dynamically + // loaded by a host). To keep the "callbacks always point at mapped + // code" invariant, every `init` here is refcounted and paired with a + // `shutdown` in the wrapper that returned by `mxc_engine::spawn` + // (see the `TelemetryProcess` `Drop` contract in `mxc_engine`), so + // the ETW registration is released before the caller can dlclose / + // FreeLibrary the containing module. Callers that unload the library + // while a spawned handle is still live violate this precondition + // (documented on `mxc_engine::spawn`) and can leave ETW holding + // callbacks into unmapped memory. let status = unsafe { MXC_PROVIDER.register() }; if status != 0 { // Registration failed; leave the reference count at zero so the wrapper @@ -282,6 +295,9 @@ mod provider { #[cfg(not(target_os = "windows"))] mod provider { + /// Non-Windows builds never route to the Microsoft telemetry pipeline. + pub const IS_UTC_ROUTED: bool = false; + pub fn init(_version: &str, _channel: &str) -> bool { false } @@ -588,18 +604,21 @@ mod provider_codegen_tests { let def = generate_provider_def(None); assert!(def.contains("\"Microsoft.MXC\"")); assert!(!def.contains("group_id")); + assert!(def.contains("IS_UTC_ROUTED: bool = false")); } #[test] fn empty_guid_yields_plain_provider() { let def = generate_provider_def(Some("")); assert!(!def.contains("group_id")); + assert!(def.contains("IS_UTC_ROUTED: bool = false")); } #[test] fn valid_guid_adds_group_id() { let def = generate_provider_def(Some("7f10def4-a258-5fea-510e-2c3bb976687f")); assert!(def.contains("group_id(\"7f10def4-a258-5fea-510e-2c3bb976687f\")")); + assert!(def.contains("IS_UTC_ROUTED: bool = true")); } #[test] diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs new file mode 100644 index 000000000..a8a2bcc95 --- /dev/null +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! End-to-end proof that MXC actually writes telemetry to ETW. +//! +//! Every other telemetry test asserts only that a run *succeeds* with +//! `telemetry.enabled: true`, which passes just as happily when nothing is +//! emitted at all. That blind spot hid two real defects: a build with no +//! provider group GUID never routes anywhere, and a run whose consent is +//! `undetermined` is silently suppressed. This test closes it by observing the +//! events themselves. +//! +//! It builds a private `wxc-exec.exe` with a **fake** provider group GUID (so +//! the group-joined code path — the one internal builds ship — is the one under +//! test), grants MXC telemetry consent to that binary through the debug-only +//! consent-store override, runs a real sandboxed execution while an ETW session +//! is collecting, and decodes the trace. +//! +//! Two directions are asserted, because only the pair is meaningful: +//! consent granted must produce a decodable `Microsoft.MXC` event, and consent +//! denied must produce none. +//! +//! `#[ignore]` by default: it shells out to a full `cargo build` and needs +//! rights to create an ETW session. + +#![cfg(target_os = "windows")] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use wxc_e2e_tests::{examples_dir, repo_root}; + +/// A syntactically valid GUID that is deliberately **not** the real Microsoft +/// telemetry group. The real value is injected by internal build pipelines and +/// must never be committed; what this test needs is only that *some* group is +/// compiled in, so the `group_id(...)` provider definition is exercised. +const FAKE_GROUP_GUID: &str = "4d584354-0000-4000-8000-e2e7e57fa4e0"; + +/// GUID that ETW derives from the provider name `"Microsoft.MXC"`. The +/// `tracelogging` crate hashes the name rather than taking a literal, so this +/// is the only handle an out-of-process collector has on the provider. +const PROVIDER_GUID: &str = "{7f10def4-a258-5fea-510e-2c3bb976687f}"; + +/// Target directory for the instrumented build. Kept apart from `target/debug` +/// so the group-joined binary can never be picked up by `find_binary` and so +/// this build does not contend for the main target directory's lock. +fn build_target_dir() -> PathBuf { + repo_root().join("src").join("target").join("telemetry-e2e") +} + +fn skip(reason: &str) { + println!("SKIPPED: {reason}"); +} + +/// Stops the ETW session on drop. A session outlives the process that created +/// it, so an early panic without this would leave a live collector (and its +/// buffers) behind on the host until someone stopped it by hand. +struct EtwSession { + name: String, + stopped: bool, +} + +impl EtwSession { + /// Starts a private ETW session collecting the MXC provider into `etl`. + /// Returns `None` when the session cannot be created — typically because + /// the test is not running with the rights ETW requires — so the caller can + /// skip rather than fail on an environmental limitation. + fn start(name: &str, etl: &Path) -> Option { + // A stale session from an aborted earlier run would make `create` fail; + // clearing it first makes the test re-runnable. + let _ = Command::new("logman") + .args(["stop", name, "-ets"]) + .output() + .ok(); + + let output = Command::new("logman") + .args([ + "create", + "trace", + name, + "-ets", + "-p", + PROVIDER_GUID, + "0xffffffffffffffff", + "0xff", + "-o", + ]) + .arg(etl) + .args(["-bs", "64", "-nb", "16", "64"]) + .output() + .ok()?; + + if !output.status.success() { + println!( + "logman create failed: {}", + String::from_utf8_lossy(&output.stdout) + ); + return None; + } + + Some(Self { + name: name.to_string(), + stopped: false, + }) + } + + /// Flushes and closes the session so the `.etl` is complete and readable. + fn stop(&mut self) { + if self.stopped { + return; + } + self.stopped = true; + let _ = Command::new("logman") + .args(["stop", &self.name, "-ets"]) + .output(); + } +} + +impl Drop for EtwSession { + fn drop(&mut self) { + self.stop(); + } +} + +/// Builds `wxc-exec.exe` with `FAKE_GROUP_GUID` compiled into the provider +/// definition, and with the debug-only consent-store override enabled so the +/// test can grant consent without touching the developer's real consent state. +fn build_instrumented_wxc_exec() -> PathBuf { + let target_dir = build_target_dir(); + let output = Command::new("cargo") + .current_dir(repo_root().join("src")) + .args([ + "build", + "-p", + "wxc", + // `test-support` is what makes `MXC_TEST_LOCALAPPDATA_OVERRIDE` + // observable to the child; without it the consent store is the real + // per-user one and this test would mutate developer state. + "--features", + "wxc_common/test-support", + "--target-dir", + ]) + .arg(&target_dir) + .env("MXC_TELEMETRY_PROVIDER_GROUP_GUID", FAKE_GROUP_GUID) + .output() + .expect("failed to invoke cargo"); + + assert!( + output.status.success(), + "building wxc with a provider group GUID failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let exe = target_dir.join("debug").join("wxc-exec.exe"); + assert!(exe.exists(), "instrumented wxc-exec.exe not at {exe:?}"); + exe +} + +/// Reads back the build script's generated provider definition. +/// +/// This is the direct evidence that the environment variable reached codegen. +/// It matters on its own: an ETW collector cannot distinguish "provider is in +/// group X" from "provider is in no group" (see the note on +/// `assert_events_recorded`), so without this check a build that silently +/// dropped the group would still pass the trace assertions. +fn generated_provider_def() -> String { + let build_dir = build_target_dir().join("debug").join("build"); + let entry = std::fs::read_dir(&build_dir) + .expect("build directory missing") + .filter_map(Result::ok) + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with("mxc_telemetry-") + }) + .map(|e| e.path().join("out").join("provider_def.rs")) + .find(|p| p.exists()) + .expect("generated provider_def.rs not found"); + std::fs::read_to_string(entry).expect("failed to read provider_def.rs") +} + +/// Writes a consent record directly into the overridden consent store. +/// +/// Seeding the file is deliberate: the supported way to grant consent is an +/// interactive prompt exchange, which a test cannot drive, and the goal here is +/// to test *emission* under a given consent state rather than the consent +/// protocol itself (`run_telemetry_consent_smoke_test.ps1` covers that). +fn seed_consent(local_app_data: &Path, state: &str) { + let dir = local_app_data.join("mxc"); + std::fs::create_dir_all(&dir).expect("failed to create consent directory"); + let record = format!( + r#"{{"schemaVersion":2,"consent":"{state}","source":"telemetry-etw-e2e", +"promptedMxcVersion":"0.0.0-e2e","promptResourceVersion":1, +"promptLocale":"en-US","updatedAtEpoch":0}}"# + ); + std::fs::write(dir.join("telemetry-consent.json"), record.replace('\n', "")) + .expect("failed to write consent record"); +} + +/// Confirms the child actually observed the seeded consent state. Without this +/// a silently ineffective override would turn the "denied" case into a +/// vacuous pass — it would record no events for the wrong reason. +fn assert_effective_consent(exe: &Path, local_app_data: &Path, expected: &str) { + let output = Command::new(exe) + .arg("--telemetry-consent-status") + .env("MXC_TEST_LOCALAPPDATA_OVERRIDE", local_app_data) + .output() + .expect("failed to query consent status"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!("\"effectiveState\":\"{expected}\"")), + "consent override did not take effect (wanted {expected}): {stdout}" + ); +} + +/// Runs one sandboxed execution with telemetry requested. +fn run_traced_execution(exe: &Path, local_app_data: &Path) -> std::process::Output { + Command::new(exe) + .arg("--config") + .arg(examples_dir().join("28_telemetry_enabled.json")) + .env("MXC_TEST_LOCALAPPDATA_OVERRIDE", local_app_data) + .output() + .expect("failed to run wxc-exec") +} + +/// Decodes an `.etl` to XML and returns the text, or `None` if it holds no +/// decodable events (`tracerpt` reports failure on an empty trace). +fn decode_trace(etl: &Path, workdir: &Path) -> Option { + let dump = workdir.join("dump.xml"); + let _ = std::fs::remove_file(&dump); + Command::new("tracerpt") + .arg(etl) + .arg("-o") + .arg(&dump) + .args(["-of", "XML", "-y"]) + .output() + .ok()?; + std::fs::read_to_string(&dump).ok() +} + +/// Asserts the decoded trace contains a real MXC execution event. +/// +/// The assertion is on the **provider** GUID, not the group GUID, because a +/// trace session enabled on a group GUID does not deliver the group's members' +/// events on this host — the provider GUID is the only reliable subscription. +/// Group membership is therefore verified at the source, in +/// `generated_provider_def`. +fn assert_events_recorded(dump: &str) { + assert!( + dump.contains("Microsoft.MXC"), + "no Microsoft.MXC events were recorded while telemetry consent was granted" + ); + // Field-level checks, so the test fails if the event is emitted but its + // payload has been gutted. + for field in ["mxc.sandbox_kind", "mxc.outcome", "PartA_PrivTags"] { + assert!( + dump.contains(field), + "recorded MXC event is missing the {field} field" + ); + } +} + +#[test] +#[ignore] // Builds a second wxc-exec.exe and needs rights to create an ETW session. +fn test_telemetry_emits_etw_events_when_consent_granted() { + let exe = build_instrumented_wxc_exec(); + + let provider_def = generated_provider_def(); + assert!( + provider_def.contains(&format!("group_id(\"{FAKE_GROUP_GUID}\")")), + "the provider group GUID did not reach codegen: {provider_def}" + ); + assert!( + provider_def.contains("IS_UTC_ROUTED: bool = true"), + "a group-joined build must report itself as UTC-routed: {provider_def}" + ); + + let workdir = build_target_dir().join("etw-e2e"); + let _ = std::fs::remove_dir_all(&workdir); + std::fs::create_dir_all(&workdir).expect("failed to create work directory"); + + // ---- granted: events must be recorded --------------------------------- + let granted_store = workdir.join("granted"); + seed_consent(&granted_store, "granted"); + assert_effective_consent(&exe, &granted_store, "granted"); + + let granted_etl = workdir.join("granted.etl"); + let session_name = format!("MxcTelemetryE2E_{}", std::process::id()); + let Some(mut session) = EtwSession::start(&session_name, &granted_etl) else { + skip("could not create an ETW session — rerun with rights to collect traces"); + return; + }; + let run = run_traced_execution(&exe, &granted_store); + session.stop(); + + if !run.status.success() { + skip(&format!( + "sandboxed execution did not run on this host: {}", + String::from_utf8_lossy(&run.stderr) + )); + return; + } + + let granted_dump = decode_trace(&granted_etl, &workdir) + .expect("tracerpt produced no output for the granted run"); + assert_events_recorded(&granted_dump); + + // ---- denied: the same run must record nothing -------------------------- + let denied_store = workdir.join("denied"); + seed_consent(&denied_store, "denied"); + assert_effective_consent(&exe, &denied_store, "denied"); + + let denied_etl = workdir.join("denied.etl"); + let Some(mut denied_session) = EtwSession::start(&session_name, &denied_etl) else { + skip("could not create the second ETW session"); + return; + }; + let denied_run = run_traced_execution(&exe, &denied_store); + denied_session.stop(); + assert!( + denied_run.status.success(), + "sandboxed execution failed under denied consent: {}", + String::from_utf8_lossy(&denied_run.stderr) + ); + + // An empty trace decodes to nothing at all, which is itself a pass. + if let Some(denied_dump) = decode_trace(&denied_etl, &workdir) { + assert!( + !denied_dump.contains("Microsoft.MXC"), + "telemetry was emitted despite consent being denied" + ); + } +} diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs index 7333aeb46..b4dfd249a 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs @@ -680,7 +680,7 @@ fn test_on_repeat() { // --------------------------------------------------------------------------- fn telemetry_enabled() { - let result = run_wxc_example("28_telemetry_enabled.json", &["--debug", "--experimental"]); + let result = run_wxc_example("28_telemetry_enabled.json", &["--debug"]); assert_success_or_skip_missing_prerequisite(&result); } diff --git a/src/tools/mxc_schema_gen/src/main.rs b/src/tools/mxc_schema_gen/src/main.rs index e6c098c15..0ac7e4d5f 100644 --- a/src/tools/mxc_schema_gen/src/main.rs +++ b/src/tools/mxc_schema_gen/src/main.rs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Schema codegen tool. Emits the MXC config JSON Schema — or, with `--ts`, the -//! SDK wire TypeScript types — generated from the dedicated `wxc_common::wire` -//! model. +//! Schema codegen tool. Emits execution-config or telemetry-consent JSON +//! Schema/TypeScript artifacts generated from `wxc_common::wire`. //! //! Usage (run from the repo root; the Cargo workspace lives in `src/`): //! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- [output-path] //! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts [output-path] +//! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --telemetry-consent [output-path] +//! cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --telemetry-consent-ts [output-path] //! //! With no path the artifact is written to stdout. @@ -17,23 +18,43 @@ fn main() -> ExitCode { let mut args = std::env::args().skip(1); let first = args.next(); - let (emit_ts, path) = match first.as_deref() { - Some("--ts") => (true, args.next()), - Some(other) => (false, Some(other.to_string())), - None => (false, None), - }; + enum Artifact { + ConfigSchema, + ConfigTypes, + TelemetryConsentSchema, + TelemetryConsentTypes, + } - let content = if emit_ts { - wxc_common::wire::generate_sdk_types_ts() - } else { - // Preserve the historical schema output: the rendered string + trailing - // newline, byte-for-byte (the schema codegen gate diffs against it). - format!("{}\n", wxc_common::wire::generate_config_schema_json()) + let (artifact, path) = match first.as_deref() { + Some("--ts") => (Artifact::ConfigTypes, args.next()), + Some("--telemetry-consent") => (Artifact::TelemetryConsentSchema, args.next()), + Some("--telemetry-consent-ts") => (Artifact::TelemetryConsentTypes, args.next()), + Some(other) => (Artifact::ConfigSchema, Some(other.to_string())), + None => (Artifact::ConfigSchema, None), }; - let label = if emit_ts { - "SDK TypeScript types" - } else { - "generated schema" + + let (content, label) = match artifact { + Artifact::ConfigSchema => ( + // Preserve the historical schema output: the rendered string + + // trailing newline, byte-for-byte. + format!("{}\n", wxc_common::wire::generate_config_schema_json()), + "generated schema", + ), + Artifact::ConfigTypes => ( + wxc_common::wire::generate_sdk_types_ts(), + "SDK TypeScript types", + ), + Artifact::TelemetryConsentSchema => ( + format!( + "{}\n", + wxc_common::wire::generate_telemetry_consent_schema_json() + ), + "telemetry consent schema", + ), + Artifact::TelemetryConsentTypes => ( + wxc_common::wire::generate_telemetry_consent_sdk_types_ts(), + "telemetry consent TypeScript types", + ), }; match path { diff --git a/tests/examples/28_telemetry_enabled.json b/tests/examples/28_telemetry_enabled.json index 7ac84537b..9bfa702bf 100644 --- a/tests/examples/28_telemetry_enabled.json +++ b/tests/examples/28_telemetry_enabled.json @@ -4,9 +4,7 @@ "process": { "commandLine": "cmd.exe /c echo Hello from telemetry-enabled sandbox" }, - "experimental": { - "telemetry": { - "enabled": true - } + "telemetry": { + "enabled": true } } diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 new file mode 100644 index 000000000..c0c9a5daf --- /dev/null +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -0,0 +1,178 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +param([string]$BinDir) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +if (-not $BinDir) { $BinDir = Join-Path $repoRoot 'src\target\debug' } +elseif (-not [IO.Path]::IsPathRooted($BinDir)) { $BinDir = Join-Path $repoRoot $BinDir } +$BinDir = [IO.Path]::GetFullPath($BinDir) +$wxcExec = Join-Path $BinDir 'wxc-exec.exe' +if (-not (Test-Path $wxcExec)) { + throw "Debug wxc-exec.exe not found at '$wxcExec'. Run 'cargo build -p wxc --features test-support'." +} +if ($wxcExec -match '\\release\\') { + throw 'Release binaries are refused because consent isolation uses debug-only overrides.' +} +$expectedBody = @' +Help improve MXC by sharing optional diagnostic data with Microsoft. +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +You can change your choice at any time. +'@ -replace "`r`n", "`n" + +function ConvertTo-Base64Json([object]$Value) { + $json = $Value | ConvertTo-Json -Compress -Depth 10 + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) +} + +function Invoke-Maintenance([string]$Action) { + $request = [pscustomobject]@{ command = 'telemetryConsent'; action = $Action } + $output = & $wxcExec --config-base64 (ConvertTo-Base64Json $request) + if ($LASTEXITCODE -ne 0) { throw "$Action failed with exit code $LASTEXITCODE" } + $output | ConvertFrom-Json +} + +function Invoke-ConsentRequest([string]$Decision) { + $request = [pscustomobject]@{ command = 'telemetryConsent'; action = 'request'; locale = 'en-US' } + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $wxcExec + $start.Arguments = "--config-base64 $(ConvertTo-Base64Json $request)" + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.CreateNoWindow = $true + $start.Environment['MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL'] = '1' + $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir + [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') + $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey + $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID'] = "$PID" + $process = New-Object Diagnostics.Process + $process.StartInfo = $start + if (-not $process.Start()) { throw 'Failed to start consent process.' } + $firstLine = $process.StandardOutput.ReadLine() + $first = $firstLine | ConvertFrom-Json + if ($first.result -ne 'presentationRequired') { + $process.WaitForExit() + return $first + } + if (-not $first.prompt -or -not $first.challenge) { + throw "Unexpected consent presentation: $firstLine" + } + if ($first.prompt.resourceVersion -ne 1 -or + $first.prompt.locale -ne 'en-US' -or + $first.prompt.title.text -ne 'Help improve Microsoft eXecution Container (MXC)' -or + $first.prompt.body.text -ne $expectedBody -or + $first.prompt.affirmativeLabel.text -ne 'Yes' -or + $first.prompt.negativeLabel.text -ne 'No' -or + $first.prompt.learnMoreLabel.text -ne 'Privacy Statement' -or + $first.prompt.learnMoreUrl -ne 'https://go.microsoft.com/fwlink/?linkid=521839') { + throw 'The canonical consent resource drifted.' + } + $response = [pscustomobject]@{ + challenge = $first.challenge + resourceVersion = $first.prompt.resourceVersion + decision = $Decision + } + $process.StandardInput.WriteLine(($response | ConvertTo-Json -Compress)) + $process.StandardInput.Close() + $finalLine = $process.StandardOutput.ReadLine() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "Consent process failed: $stderr" } + $finalLine | ConvertFrom-Json +} + +function Assert-Status( + [object]$Value, + [string]$Stored, + [string]$Effective, + [string]$Policy, + [bool]$NeedsPrompt, + [string]$Step +) { + if ($Value.storedState -ne $Stored -or + $Value.effectiveState -ne $Effective -or + $Value.policy -ne $Policy -or + $Value.needsPrompt -ne $NeedsPrompt) { + throw "$Step returned an unexpected response: $($Value | ConvertTo-Json -Compress)" + } +} + +$overrideName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE' +$policyOverrideName = 'MXC_TEST_POLICY_KEY_OVERRIDE' +$policyOverrideOwnerName = 'MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID' +$originalOverride = [Environment]::GetEnvironmentVariable($overrideName) +$originalPolicyOverride = [Environment]::GetEnvironmentVariable($policyOverrideName) +$originalPolicyOverrideOwner = [Environment]::GetEnvironmentVariable($policyOverrideOwnerName) +$runId = [guid]::NewGuid().ToString('N') +$tempDir = Join-Path ([IO.Path]::GetTempPath()) "mxc_consent_smoke_$runId" +$consentFile = Join-Path $tempDir 'mxc\telemetry-consent.json' +$policySubkey = "Software\MxcTelemetryConsentSmoke\$runId" +$policyPath = "HKCU:\$policySubkey" + +try { + New-Item -ItemType Directory -Path (Split-Path -Parent $consentFile) -Force | Out-Null + New-Item -Path $policyPath -Force | Out-Null + Set-Item "Env:$overrideName" $tempDir + Set-Item "Env:$policyOverrideName" $policySubkey + Set-Item "Env:$policyOverrideOwnerName" $PID + + foreach ($seed in @('granted', 'denied')) { + $record = [pscustomobject]@{ + schemaVersion = 2 + consent = $seed + source = 'smoke-proof' + promptedMxcVersion = '0.0.0-smoke' + promptResourceVersion = 1 + promptLocale = 'en-US' + updatedAtEpoch = 0 + } + [IO.File]::WriteAllText( + $consentFile, + ($record | ConvertTo-Json -Compress), + (New-Object Text.UTF8Encoding $false)) + $probe = Invoke-Maintenance 'status' + if ($probe.effectiveState -ne $seed) { + throw "Consent override proof failed for '$seed'; refusing all MXC writes." + } + } + Remove-Item $consentFile -Force + + $fresh = Invoke-Maintenance 'status' + Assert-Status $fresh 'undetermined' 'undetermined' 'unrestricted' $true 'fresh status' + + $denied = Invoke-ConsentRequest 'no' + if ($denied.result -ne 'denied') { throw 'Explicit No was not persisted as Denied.' } + if (-not (Test-Path $consentFile)) { + throw "Explicit No did not create the isolated consent record at '$consentFile'." + } + Assert-Status (Invoke-Maintenance 'status') 'denied' 'denied' 'unrestricted' $false 'denied status' + + $granted = Invoke-ConsentRequest 'yes' + if ($granted.result -ne 'granted') { throw 'Explicit Yes was not persisted as Granted.' } + Assert-Status (Invoke-Maintenance 'status') 'granted' 'granted' 'unrestricted' $false 'granted status' + + Set-ItemProperty $policyPath -Name AllowTelemetry -Value 0 -Type DWord + $blocked = Invoke-ConsentRequest 'yes' + if ($blocked.result -ne 'policyBlocked') { throw 'Blocked policy did not suppress presentation.' } + Assert-Status $blocked 'granted' 'granted' 'blocked' $false 'blocked status' + + $withdrawn = Invoke-Maintenance 'withdraw' + if ($withdrawn.result -ne 'withdrawn') { throw 'Withdrawal did not report withdrawn.' } + Assert-Status $withdrawn 'denied' 'denied' 'blocked' $false 'withdrawal while blocked' + $withdrawnAgain = Invoke-Maintenance 'withdraw' + if ($withdrawnAgain.result -ne 'withdrawn') { throw 'Repeated withdrawal was not idempotent.' } + + Write-Host 'PASSED: isolated telemetry consent smoke test' -ForegroundColor Green +} finally { + if ($null -eq $originalOverride) { Remove-Item "Env:$overrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$overrideName" $originalOverride } + if ($null -eq $originalPolicyOverride) { Remove-Item "Env:$policyOverrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$policyOverrideName" $originalPolicyOverride } + if ($null -eq $originalPolicyOverrideOwner) { Remove-Item "Env:$policyOverrideOwnerName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$policyOverrideOwnerName" $originalPolicyOverrideOwner } + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $policyPath -ErrorAction SilentlyContinue +} diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 6c7642559..bf3355324 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -1,240 +1,238 @@ <# .SYNOPSIS - ETW capture smoke test for MXC telemetry. + Isolated ETW capture smoke test for MXC telemetry. .DESCRIPTION - Starts an ETW trace session targeting the MXC public provider GUID, - runs wxc-exec with telemetry enabled, stops the session, and verifies - that at least one event was captured. - - This test uses the PUBLIC provider GUID (already in the open-source - code) - it does NOT depend on or reveal the private telemetry group GUID. - - Requires: Administrator privileges (for ETW session creation), - wxc-exec.exe built, logman.exe (ships with Windows). - - Run from the repo root. + Uses a debug wxc-exec only, proves the debug-only consent and policy + redirects before any MXC write, grants consent through the canonical + session-bound presenter protocol, captures the public MXC ETW provider, + and restores all process/machine state in finally. #> [CmdletBinding()] param( + [string]$BinDir, [switch]$SkipClean ) $ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$srcDir = Join-Path $repoRoot 'src' +if (-not $BinDir) { + $BinDir = Join-Path $srcDir 'target\debug' +} +elseif (-not [IO.Path]::IsPathRooted($BinDir)) { + $BinDir = Join-Path $repoRoot $BinDir +} +$BinDir = [IO.Path]::GetFullPath($BinDir) +$wxcExe = Join-Path $BinDir 'wxc-exec.exe' +if (-not (Test-Path $wxcExe)) { + throw "Debug wxc-exec.exe not found at '$wxcExe'. Run 'cargo build -p wxc --features test-support' (without --release)." +} +if ($wxcExe -match '\\release\\') { + throw 'Release binaries are refused: telemetry smoke isolation depends on debug-only test overrides.' +} +$expectedBody = @' +Help improve MXC by sharing optional diagnostic data with Microsoft. +If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. +You can change your choice at any time. +'@ -replace "`r`n", "`n" -# MXC public provider name. The provider GUID is derived deterministically from -# this name by `tracelogging::define_provider!` using the standard ETW name-hash -# algorithm (the same algorithm used by , WIL's -# IMPLEMENT_TRACELOGGING_CLASS, and .NET's EventSource). We compute the GUID from -# the name here rather than hard-coding a literal, so the test stays in lockstep -# with the provider name and never embeds a magic constant. -$providerName = 'Microsoft.MXC' +$configFile = Join-Path $repoRoot 'tests\examples\28_telemetry_enabled.json' +if (-not (Test-Path $configFile)) { + throw "Config not found: $configFile" +} +$providerName = 'Microsoft.MXC' function Get-TraceLoggingProviderGuid { param([Parameter(Mandatory)][string]$Name) - - # EventSource/TraceLogging name->GUID: SHA1 over a fixed namespace seed - # followed by the UTF-16BE bytes of the upper-cased name; first 16 bytes of - # the digest become the GUID with the version nibble forced to 5. - $seed = [byte[]]@( - 0x48, 0x2C, 0x2D, 0xB2, 0xC3, 0x90, 0x47, 0xC8, - 0x87, 0xF8, 0x1A, 0x15, 0xBF, 0xC1, 0x30, 0xFB - ) + $seed = [byte[]]@(0x48,0x2C,0x2D,0xB2,0xC3,0x90,0x47,0xC8,0x87,0xF8,0x1A,0x15,0xBF,0xC1,0x30,0xFB) $nameBytes = [System.Text.Encoding]::BigEndianUnicode.GetBytes($Name.ToUpperInvariant()) $buffer = New-Object byte[] ($seed.Length + $nameBytes.Length) [Array]::Copy($seed, 0, $buffer, 0, $seed.Length) [Array]::Copy($nameBytes, 0, $buffer, $seed.Length, $nameBytes.Length) - $sha1 = [System.Security.Cryptography.SHA1]::Create() - try { - $hash = $sha1.ComputeHash($buffer) - } finally { - $sha1.Dispose() - } - + try { $hash = $sha1.ComputeHash($buffer) } finally { $sha1.Dispose() } $guidBytes = New-Object byte[] 16 [Array]::Copy($hash, 0, $guidBytes, 0, 16) $guidBytes[7] = ($guidBytes[7] -band 0x0F) -bor 0x50 return '{' + ([guid]::new($guidBytes)).ToString() + '}' } -$providerGuid = Get-TraceLoggingProviderGuid -Name $providerName -$sessionName = 'MxcTelemetryTest' -$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +function ConvertTo-Base64Json { + param([Parameter(Mandatory)][object]$Value) + $json = $Value | ConvertTo-Json -Compress -Depth 10 + return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) +} + +function Invoke-Status { + $output = & $wxcExe --telemetry-consent-status + if ($LASTEXITCODE -ne 0) { throw "Consent status failed with exit code $LASTEXITCODE" } + return ($output | ConvertFrom-Json) +} + +function Invoke-CanonicalGrant { + $request = [pscustomobject]@{ command = 'telemetryConsent'; action = 'request'; locale = 'en-US' } + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $wxcExe + $start.Arguments = "--config-base64 $(ConvertTo-Base64Json $request)" + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.CreateNoWindow = $true + $start.Environment['MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL'] = '1' + $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir + [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') + $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey + $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID'] = "$PID" + $process = New-Object Diagnostics.Process + $process.StartInfo = $start + if (-not $process.Start()) { throw 'Failed to start consent maintenance process.' } + + $presentationLine = $process.StandardOutput.ReadLine() + if (-not $presentationLine) { throw "Consent process emitted no presentation: $($process.StandardError.ReadToEnd())" } + $presentation = $presentationLine | ConvertFrom-Json + if ($presentation.result -eq 'alreadyGranted') { + $process.WaitForExit() + return $presentation + } + if ($presentation.result -ne 'presentationRequired' -or -not $presentation.challenge -or -not $presentation.prompt) { + throw "Unexpected consent presentation: $presentationLine" + } + if ($presentation.prompt.resourceVersion -ne 1 -or + $presentation.prompt.locale -ne 'en-US' -or + $presentation.prompt.title.text -ne 'Help improve Microsoft eXecution Container (MXC)' -or + $presentation.prompt.body.text -ne $expectedBody -or + $presentation.prompt.affirmativeLabel.text -ne 'Yes' -or + $presentation.prompt.negativeLabel.text -ne 'No' -or + $presentation.prompt.learnMoreLabel.text -ne 'Privacy Statement' -or + $presentation.prompt.learnMoreUrl -ne 'https://go.microsoft.com/fwlink/?linkid=521839') { + throw 'The executor did not present the canonical telemetry consent resource.' + } -Write-Host "=== MXC ETW Capture Smoke Test ===" -ForegroundColor Cyan -Write-Host "Provider: $providerName $providerGuid" + $response = [pscustomobject]@{ + challenge = $presentation.challenge + resourceVersion = $presentation.prompt.resourceVersion + decision = 'yes' + } + $process.StandardInput.WriteLine(($response | ConvertTo-Json -Compress)) + $process.StandardInput.Close() + $finalLine = $process.StandardOutput.ReadLine() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "Consent request failed: $stderr" } + if (-not $finalLine) { throw 'Consent process emitted no final response.' } + $final = $finalLine | ConvertFrom-Json + if ($final.result -ne 'granted' -or $final.effectiveState -ne 'granted') { + throw "Consent was not granted: $finalLine" + } + return $final +} -# --------------------------------------------------------------------------- -# Pre-flight: elevation check -# --------------------------------------------------------------------------- $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - Write-Host "SKIPPED: this test requires Administrator privileges for ETW session creation." -ForegroundColor Yellow - exit 0 -} - -# --------------------------------------------------------------------------- -# Pre-flight: locate wxc-exec.exe -# --------------------------------------------------------------------------- -$srcDir = Join-Path $repoRoot 'src' -$candidates = @( - (Join-Path $srcDir 'target\debug\wxc-exec.exe'), - (Join-Path $srcDir 'target\release\wxc-exec.exe'), - (Join-Path $srcDir 'target\x86_64-pc-windows-msvc\debug\wxc-exec.exe'), - (Join-Path $srcDir 'target\x86_64-pc-windows-msvc\release\wxc-exec.exe'), - (Join-Path $srcDir 'target\aarch64-pc-windows-msvc\debug\wxc-exec.exe'), - (Join-Path $srcDir 'target\aarch64-pc-windows-msvc\release\wxc-exec.exe') -) -$wxcExe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -if (-not $wxcExe) { - Write-Host "SKIPPED: wxc-exec.exe not found. Build first with build.bat." -ForegroundColor Yellow + Write-Host 'SKIPPED: Administrator privileges are required for ETW session creation.' -ForegroundColor Yellow exit 0 } -Write-Host "Using wxc-exec: $wxcExe" - -# --------------------------------------------------------------------------- -# Pre-flight: locate telemetry example config -# --------------------------------------------------------------------------- -$configFile = Join-Path $repoRoot 'tests\examples\28_telemetry_enabled.json' -if (-not (Test-Path $configFile)) { - throw "Config not found: $configFile" -} -# --------------------------------------------------------------------------- -# Setup: ETL output path -# --------------------------------------------------------------------------- -$etlDir = Join-Path $env:TEMP 'mxc_etw_test' -if (Test-Path $etlDir) { Remove-Item -Recurse -Force $etlDir } -New-Item -ItemType Directory -Path $etlDir -Force | Out-Null +$overrideName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE' +$policyOverrideName = 'MXC_TEST_POLICY_KEY_OVERRIDE' +$policyOverrideOwnerName = 'MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID' +$originalOverride = [Environment]::GetEnvironmentVariable($overrideName) +$originalPolicyOverride = [Environment]::GetEnvironmentVariable($policyOverrideName) +$originalPolicyOverrideOwner = [Environment]::GetEnvironmentVariable($policyOverrideOwnerName) +$runId = [guid]::NewGuid().ToString('N') +$tempDir = Join-Path ([IO.Path]::GetTempPath()) "mxc_etw_test_$runId" +$consentFile = Join-Path $tempDir 'mxc\telemetry-consent.json' +$policySubkey = "Software\MxcTelemetryEtwSmoke\$runId" +$policyPath = "HKCU:\$policySubkey" +$etlDir = Join-Path $tempDir 'etl' $etlFile = Join-Path $etlDir 'mxc_trace.etl' +$xmlFile = Join-Path $etlDir 'mxc_trace.xml' +$sessionName = "MxcTelemetryTest_$runId" +$traceStarted = $false -# --------------------------------------------------------------------------- -# Step 1: Start ETW trace session -# --------------------------------------------------------------------------- -Write-Host "`n--- Starting ETW trace session '$sessionName' ---" -ForegroundColor Yellow - -# Remove any stale session from a previous interrupted run. -logman stop $sessionName -ets 2>$null | Out-Null -logman delete $sessionName -ets 2>$null | Out-Null +try { + New-Item -ItemType Directory -Path (Split-Path -Parent $consentFile) -Force | Out-Null + New-Item -ItemType Directory -Path $etlDir -Force | Out-Null + New-Item -Path $policyPath -Force | Out-Null + Set-Item -Path "Env:$overrideName" -Value $tempDir + Set-Item -Path "Env:$policyOverrideName" -Value $policySubkey + Set-Item -Path "Env:$policyOverrideOwnerName" -Value $PID + + foreach ($seed in @('granted', 'denied')) { + $record = [pscustomobject]@{ + schemaVersion = 2 + consent = $seed + source = 'etw-smoke-proof' + promptedMxcVersion = '0.0.0-smoke' + promptResourceVersion = 1 + promptLocale = 'en-US' + updatedAtEpoch = 0 + } + [IO.File]::WriteAllText( + $consentFile, + ($record | ConvertTo-Json -Compress), + (New-Object Text.UTF8Encoding $false)) + $status = Invoke-Status + if ($status.effectiveState -ne $seed) { + throw "Consent override proof failed for '$seed'; refusing all MXC writes." + } + } + Remove-Item $consentFile -Force -logman create trace $sessionName -ets -o "$etlFile" -p $providerGuid 2>&1 | Out-Host -if ($LASTEXITCODE -ne 0) { - throw "Failed to create ETW trace session" -} -Write-Host "ETW session started, writing to $etlFile" + $grant = Invoke-CanonicalGrant + Set-ItemProperty -Path $policyPath -Name AllowTelemetry -Value 3 -Type DWord + $authorized = Invoke-Status + if ($authorized.effectiveState -ne 'granted' -or $authorized.policy -ne 'allowed') { + throw 'Consent/policy authorization was not effective before ETW capture.' + } -# --------------------------------------------------------------------------- -# Step 2: Run wxc-exec with telemetry enabled -# --------------------------------------------------------------------------- -Write-Host "`n--- Running wxc-exec with telemetry ---" -ForegroundColor Yellow + $providerGuid = Get-TraceLoggingProviderGuid -Name $providerName + logman stop $sessionName -ets 2>$null | Out-Null + logman create trace $sessionName -ets -o "$etlFile" -p $providerGuid 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw 'Failed to create ETW trace session.' } + $traceStarted = $true -try { - # Run with --experimental to enable the telemetry section. The provider is - # registered during init (before execution); the MXC.Execution / MXC.Error - # events are emitted on completion, after the runner returns. The sandbox - # itself may fail (e.g. AppContainer prerequisites), but completion - # telemetry still fires for the failure, so events should be captured. $executionTimeoutSeconds = 60 - $proc = Start-Process -FilePath $wxcExe ` - -ArgumentList "--debug", "--experimental", $configFile ` - -PassThru -NoNewWindow + $proc = Start-Process -FilePath $wxcExe -ArgumentList '--debug', $configFile -PassThru -NoNewWindow if (-not $proc.WaitForExit($executionTimeoutSeconds * 1000)) { Stop-Process -Id $proc.Id -Force $proc.WaitForExit() throw "wxc-exec timed out after $executionTimeoutSeconds seconds" } Write-Host "wxc-exec exited with code $($proc.ExitCode)" -} catch { - Write-Host "wxc-exec failed to run: $_" -ForegroundColor Yellow - # Continue - even a crash after init may have emitted events. -} + Start-Sleep -Seconds 2 -# Brief pause for ETW buffers to flush. -Start-Sleep -Seconds 2 - -# --------------------------------------------------------------------------- -# Step 3: Stop ETW trace session -# --------------------------------------------------------------------------- -Write-Host "`n--- Stopping ETW trace session ---" -ForegroundColor Yellow -logman stop $sessionName -ets 2>&1 | Out-Host - -# --------------------------------------------------------------------------- -# Step 4: Validate captured events -# --------------------------------------------------------------------------- -Write-Host "`n--- Validating captured events ---" -ForegroundColor Yellow - -if (-not (Test-Path $etlFile)) { - throw "ETL file not found: $etlFile" -} - -$etlSize = (Get-Item $etlFile).Length -Write-Host "ETL file size: $etlSize bytes" - -if ($etlSize -eq 0) { - Write-Host "FAILED: ETL file is empty - no events captured." -ForegroundColor Red - Write-Host 'All prerequisites were met (admin, wxc-exec present, ETW session created),' -ForegroundColor Red - Write-Host "so the provider should have emitted at least one completion event." -ForegroundColor Red - exit 1 -} - -# Convert .etl to XML for inspection. -$xmlFile = Join-Path $etlDir 'mxc_trace.xml' -tracerpt "$etlFile" -o "$xmlFile" -of XML -y 2>&1 | Out-Host - -if (-not (Test-Path $xmlFile)) { - throw "tracerpt failed to produce XML output" -} - -$xmlContent = Get-Content -Path $xmlFile -Raw -$eventCount = ([regex]::Matches($xmlContent, '') - $matchingEvent = $null - - foreach ($eventBlock in $eventBlocks) { - $eventXml = $eventBlock.Value - $eventMissingFields = @( - $expectedFields | Where-Object { - $eventXml -notmatch ([regex]::Escape($_)) - } - ) - if ($eventMissingFields.Count -eq 0) { - $matchingEvent = $eventXml - break - } + logman stop $sessionName -ets 2>&1 | Out-Host + $traceStarted = $false + if (-not (Test-Path $etlFile) -or (Get-Item $etlFile).Length -eq 0) { + throw 'ETL output was absent or empty.' } - - if (-not $matchingEvent) { - $missingFieldsMessage = 'No single captured event contained all required public fields: ' + ($expectedFields -join ', ') + '.' - Write-Host '' - Write-Host '=== ETW CAPTURE SMOKE TEST FAILED ===' -ForegroundColor Red - Write-Host $missingFieldsMessage -ForegroundColor Red - exit 1 + tracerpt "$etlFile" -o "$xmlFile" -of XML -lr -y 2>&1 | Out-Host + if (-not (Test-Path $xmlFile)) { throw 'tracerpt did not produce XML.' } + $xml = Get-Content $xmlFile -Raw + if (([regex]::Matches($xml, '$null | Out-Null } + if ($null -eq $originalOverride) { Remove-Item "Env:$overrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$overrideName" $originalOverride } + if ($null -eq $originalPolicyOverride) { Remove-Item "Env:$policyOverrideName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$policyOverrideName" $originalPolicyOverride } + if ($null -eq $originalPolicyOverrideOwner) { Remove-Item "Env:$policyOverrideOwnerName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$policyOverrideOwnerName" $originalPolicyOverrideOwner } + Remove-Item -Recurse -Force $policyPath -ErrorAction SilentlyContinue + if (-not $SkipClean) { Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue } } -# --------------------------------------------------------------------------- -# Cleanup -# --------------------------------------------------------------------------- -if (-not $SkipClean) { - Remove-Item -Recurse -Force $etlDir -ErrorAction SilentlyContinue -} +# Native cleanup commands may legitimately report "session not found." Do not +# leak their `$LASTEXITCODE` after the test has completed successfully. +exit 0 From 435b71e3e66f735ce84327477af951b4cd215b55 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 16:46:50 -0700 Subject: [PATCH 08/47] Stabilize blocking-task panic regression Exercise the task's wake notification directly instead of depending on two nested threads completing within a two-second scheduler window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- src/core/wxc_common/src/telemetry/consent.rs | 41 ++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 50d1d93e7..86746d763 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -1382,18 +1382,37 @@ mod tests { #[test] fn blocking_task_propagates_worker_panic_without_hanging() { - let (sender, receiver) = std::sync::mpsc::sync_channel(1); - std::thread::spawn(move || { - let propagated = std::panic::catch_unwind(|| { - block_on(BlockingTask::spawn(|| panic!("worker panic"))); - }) - .is_err(); - sender.send(propagated).unwrap(); - }); + struct SignalWaker(std::sync::mpsc::Sender<()>); - assert!(receiver - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("blocking task did not complete after its worker panicked")); + impl std::task::Wake for SignalWaker { + fn wake(self: std::sync::Arc) { + let _ = self.0.send(()); + } + + fn wake_by_ref(self: &std::sync::Arc) { + let _ = self.0.send(()); + } + } + + let (sender, receiver) = std::sync::mpsc::channel(); + let waker = std::task::Waker::from(std::sync::Arc::new(SignalWaker(sender))); + let mut context = std::task::Context::from_waker(&waker); + let mut task = std::pin::pin!(BlockingTask::spawn(|| -> () { panic!("worker panic") })); + + let propagated = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if std::future::Future::poll(task.as_mut(), &mut context).is_pending() { + receiver + .recv_timeout(std::time::Duration::from_secs(10)) + .expect("blocking task did not wake after its worker panicked"); + let _ = std::future::Future::poll(task.as_mut(), &mut context); + } + })) + .is_err(); + + assert!( + propagated, + "blocking task did not propagate its worker panic" + ); } #[test] From 153cb7ae2e7804f25666104ff69021a08ce1782a Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 17:53:22 -0700 Subject: [PATCH 09/47] Report confirmed SDK kills as cancellation Keep terminal telemetry active when the wrapped kill fails, and classify successful one-shot and state-aware kills as cancelled with the documented exit code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- src/core/mxc_engine/src/lib.rs | 86 ++++++++++++++++++++---- src/core/wxc_common/src/telemetry/mod.rs | 86 ++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 14 deletions(-) diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index f3c661f88..c3aec3bb8 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -144,7 +144,9 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> /// * [`SandboxProcess::try_wait`] — same, on the poll that observes the exit /// or an error branch (a `Pending` poll leaves the invariant to a later /// `wait` / `kill` / `Drop`). -/// * [`SandboxProcess::kill`] — synthesised cancellation event. +/// * [`SandboxProcess::kill`] — cancellation event after the wrapped process +/// confirms the kill succeeded. A failed kill leaves the slot active for a +/// later `wait` / successful `try_wait`. /// * [`Drop`] — synthesised process-error event, so a wrapper dropped without /// `wait` never releases the provider without accounting for the run. /// @@ -274,10 +276,6 @@ impl TelemetryProcess { return; } let (raw_kind, message) = match kind { - SyntheticTerminal::Killed => ( - std::io::ErrorKind::Interrupted, - "sandbox handle was killed before completion", - ), SyntheticTerminal::Dropped => ( std::io::ErrorKind::Other, "sandbox handle was dropped before completion", @@ -292,14 +290,51 @@ impl TelemetryProcess { // BackendError → FailureReason::ProcessError (state-aware). self.emit(&Err(std::io::Error::new(raw_kind, message))); } + + /// Emit the terminal event for a confirmed explicit kill. + fn emit_cancellation(&mut self) { + if !self.active { + return; + } + match &self.mode { + TelemetryMode::OneShot { + containment, + requested_sandbox_kind, + } => telemetry::emit_sdk_cancellation_with_kind( + true, + *requested_sandbox_kind, + telemetry::TelemetryContext { + backend: containment.wire_name(), + phase: "", + correlation_vector: "", + }, + self.started.elapsed(), + ), + TelemetryMode::StateAware { + backend, + phase, + correlation_vector, + requested_sandbox_kind, + } => telemetry::emit_sdk_cancellation_with_kind( + true, + *requested_sandbox_kind, + telemetry::TelemetryContext { + backend, + phase, + correlation_vector, + }, + self.started.elapsed(), + ), + } + self.active = false; + } } -/// The three synthetic-terminal callsites, so [`TelemetryProcess::emit_synthetic`] +/// Synthetic terminal callsites for which no real completion result is +/// available, so [`TelemetryProcess::emit_synthetic`] /// can attribute the event to the exit path that produced it. #[derive(Debug, Clone, Copy)] enum SyntheticTerminal { - /// `SandboxProcess::kill` was called. - Killed, /// The wrapper was dropped without a preceding `wait` / successful `try_wait`. Dropped, /// `SandboxProcess::try_wait` observed an underlying error. @@ -358,12 +393,12 @@ impl SandboxProcess for TelemetryProcess { } fn kill(&mut self) -> std::io::Result<()> { - // Forward the kill first so its I/O outcome is what we return; then - // emit the synthesised cancellation event before the provider - // reference is released. `emit_synthetic` is idempotent, so a - // subsequent `wait` on the (now-killed) child is a no-op. + // A failed kill does not prove termination, so retain the exactly-once + // slot for a later wait/try_wait that can report the real outcome. let result = self.inner.kill(); - self.emit_synthetic(SyntheticTerminal::Killed); + if result.is_ok() { + self.emit_cancellation(); + } result } @@ -469,6 +504,7 @@ mod telemetry_process_tests { struct StubProcess { try_wait_result: TryWaitResult, wait_result: std::io::Result, + kill_fails: bool, } impl SandboxProcess for StubProcess { @@ -497,7 +533,11 @@ mod telemetry_process_tests { } fn kill(&mut self) -> std::io::Result<()> { - Ok(()) + if self.kill_fails { + Err(std::io::Error::other("kill failed")) + } else { + Ok(()) + } } fn wait(&mut self) -> std::io::Result { @@ -509,10 +549,18 @@ mod telemetry_process_tests { } fn wrapped(try_wait_result: TryWaitResult) -> TelemetryProcess { + wrapped_with_kill_failure(try_wait_result, false) + } + + fn wrapped_with_kill_failure( + try_wait_result: TryWaitResult, + kill_fails: bool, + ) -> TelemetryProcess { TelemetryProcess { inner: Box::new(StubProcess { try_wait_result, wait_result: Ok(0), + kill_fails, }), active: true, mode: TelemetryMode::StateAware { @@ -556,4 +604,14 @@ mod telemetry_process_tests { process.emit_synthetic(SyntheticTerminal::Dropped); assert!(!process.active); } + + #[test] + fn failed_kill_leaves_the_exactly_once_slot_active() { + let mut process = wrapped_with_kill_failure(TryWaitResult::Running, true); + + assert!(process.kill().is_err()); + assert!(process.active); + assert_eq!(process.wait().unwrap(), 0); + assert!(!process.active); + } } diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index dbe29df00..f5fb969f0 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -840,6 +840,34 @@ pub fn emit_sdk_state_aware_with_kind( }); } +/// Emit a confirmed SDK streaming-handle cancellation and release the +/// invocation's provider reference. +/// +/// This is separate from generic completion mapping because an explicit, +/// successful [`SandboxProcess::kill`](crate::sandbox_process::SandboxProcess::kill) +/// is a cancellation, not an initialization or process failure. +pub fn emit_sdk_cancellation_with_kind( + active: bool, + requested_sandbox_kind: Option<&'static str>, + ctx: TelemetryContext<'_>, + elapsed: Duration, +) { + emit_sdk_with_release(active, |auth| { + let _auth = auth; + log_execution(&ExecutionEvent { + backend: ctx.backend, + sandbox_kind: sandbox_kind_for(ctx.backend, requested_sandbox_kind), + exit_code: CANCELLED_EXIT_CODE, + outcome: "failure", + duration_ms: elapsed.as_millis() as u64, + failure_reason: Some(FailureReason::Cancelled), + phase: ctx.phase, + correlation_vector: ctx.correlation_vector, + }); + log_error(ctx, FailureReason::Cancelled, CANCELLED_EXIT_CODE); + }); +} + #[cfg(test)] pub(crate) mod test_support { use super::consent::test_support::LocalAppDataGuard; @@ -1690,6 +1718,64 @@ mod tests { reset_for_test(); } + #[test] + fn sdk_cancellation_uses_cancelled_reason_and_request_context() { + let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + events::test_sink::install(); + TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); + + emit_sdk_cancellation_with_kind( + true, + Some("process"), + TelemetryContext { + backend: "appcontainer", + phase: "", + correlation_vector: "", + }, + Duration::from_millis(9), + ); + emit_sdk_cancellation_with_kind( + true, + Some("vm"), + TelemetryContext { + backend: "windows_sandbox", + phase: "exec", + correlation_vector: "corr-cancelled", + }, + Duration::from_millis(17), + ); + + let executions = events::test_sink::take_executions(); + assert_eq!(executions.len(), 2); + assert_eq!(executions[0].backend, "appcontainer"); + assert_eq!(executions[0].sandbox_kind, "process"); + assert_eq!(executions[0].duration_ms, 9); + assert_eq!(executions[0].phase, ""); + assert_eq!(executions[0].correlation_vector, ""); + assert_eq!(executions[1].backend, "windows_sandbox"); + assert_eq!(executions[1].sandbox_kind, "vm"); + assert_eq!(executions[1].exit_code, CANCELLED_EXIT_CODE); + assert_eq!(executions[1].outcome, "failure"); + assert_eq!(executions[1].duration_ms, 17); + assert_eq!(executions[1].failure_reason, Some(FailureReason::Cancelled)); + assert_eq!(executions[1].phase, "exec"); + assert_eq!(executions[1].correlation_vector, "corr-cancelled"); + + let errors = events::test_sink::take_errors(); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].error_type, FailureReason::Cancelled); + assert_eq!(errors[0].exit_code, CANCELLED_EXIT_CODE); + assert_eq!(errors[0].phase, ""); + assert_eq!(errors[0].correlation_vector, ""); + assert_eq!(errors[1].error_type, FailureReason::Cancelled); + assert_eq!(errors[1].exit_code, CANCELLED_EXIT_CODE); + assert_eq!(errors[1].phase, "exec"); + assert_eq!(errors[1].correlation_vector, "corr-cancelled"); + + reset_for_test(); + } + #[cfg(windows)] #[test] fn sdk_emit_releases_provider_when_live_authorization_closes() { From bd60b66ccfc0406d60799e834a768f8dee82e8ba Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 17:53:23 -0700 Subject: [PATCH 10/47] Verify WINEXT Part A fields in ETW Require live decoded MXC events to carry the mandatory privacy product and Client Diagnostic Data category fields alongside the approved privacy tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index a8a2bcc95..bb144ffd3 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -252,7 +252,13 @@ fn assert_events_recorded(dump: &str) { ); // Field-level checks, so the test fails if the event is emitted but its // payload has been gutted. - for field in ["mxc.sandbox_kind", "mxc.outcome", "PartA_PrivTags"] { + for field in [ + "mxc.sandbox_kind", + "mxc.outcome", + "PartA_PrivacyProduct", + "PartA_PrivacyDataCategory", + "PartA_PrivTags", + ] { assert!( dump.contains(field), "recorded MXC event is missing the {field} field" From 253228a17e460133c070f7f36fc6209a4f32ffc5 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 19 Aug 2026 19:43:19 -0700 Subject: [PATCH 11/47] Align telemetry with the 0.8 config adapter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- .../src/dev/experimental.rs | 12 -------- src/core/mxc_config_contract/src/dev/mod.rs | 6 ++-- .../mxc_config_contract/src/dev/one_shot.rs | 5 +++- .../mxc_config_contract/src/dev/stable.rs | 9 ++++++ .../experimental/test_and_telemetry.rs | 30 ++++++++----------- .../tests/version_boundaries/experimental.rs | 4 +-- .../config_contract_adapters/dev/one_shot.rs | 5 ++-- .../dev/one_shot_tests/experimental.rs | 14 ++++----- .../dev/one_shot_tests/stable_candidate.rs | 5 +--- 9 files changed, 39 insertions(+), 51 deletions(-) diff --git a/src/core/mxc_config_contract/src/dev/experimental.rs b/src/core/mxc_config_contract/src/dev/experimental.rs index 2f8d3dfb0..c01109162 100644 --- a/src/core/mxc_config_contract/src/dev/experimental.rs +++ b/src/core/mxc_config_contract/src/dev/experimental.rs @@ -13,15 +13,6 @@ pub struct TestFeature { pub message: OptionalField, } -/// One-shot telemetry override. -#[derive(Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Telemetry { - /// Whether telemetry is enabled. - #[serde(default)] - pub enabled: OptionalField, -} - /// Compatibility settings accepted for one-shot Windows Sandbox requests. #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -103,7 +94,4 @@ pub struct OneShotExperimental { /// Optional one-shot WSLC backend settings. #[serde(default)] pub wslc: OptionalField, - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, } diff --git a/src/core/mxc_config_contract/src/dev/mod.rs b/src/core/mxc_config_contract/src/dev/mod.rs index 78626ba09..32d7b2463 100644 --- a/src/core/mxc_config_contract/src/dev/mod.rs +++ b/src/core/mxc_config_contract/src/dev/mod.rs @@ -111,7 +111,7 @@ mod primitives; mod stable; pub use experimental::{ - OneShotExperimental, OneShotWindowsSandbox, OneShotWslc, PortMapping, Telemetry, TestFeature, + OneShotExperimental, OneShotWindowsSandbox, OneShotWslc, PortMapping, TestFeature, TransportProtocol, }; pub use network::{DefaultNetworkPolicy, Network, NetworkEnforcementMode, NetworkProxy}; @@ -119,6 +119,6 @@ pub use one_shot::{Containment as OneShotContainment, Request as OneShotRequest} pub use primitives::{NonEmptyString, OptionalField, True}; pub use stable::{ CaptureDenials, CaptureDenialsMode, Fallback, Filesystem, LaunchMethod, Lifecycle, Lxc, - Process, ProcessContainer, ProcessContainerUi, ProcessContainerUiIsolation, Seatbelt, Ui, - UiClipboard, + Process, ProcessContainer, ProcessContainerUi, ProcessContainerUiIsolation, Seatbelt, + Telemetry, Ui, UiClipboard, }; diff --git a/src/core/mxc_config_contract/src/dev/one_shot.rs b/src/core/mxc_config_contract/src/dev/one_shot.rs index 3b09d7550..30fb94ac4 100644 --- a/src/core/mxc_config_contract/src/dev/one_shot.rs +++ b/src/core/mxc_config_contract/src/dev/one_shot.rs @@ -5,7 +5,7 @@ use super::experimental::OneShotExperimental; use super::network::Network; use super::primitives::OptionalField; use super::stable::{ - Fallback, Filesystem, Lifecycle, Lxc, Process, ProcessContainer, Seatbelt, Ui, + Fallback, Filesystem, Lifecycle, Lxc, Process, ProcessContainer, Seatbelt, Telemetry, Ui, }; use crate::dev::Version; @@ -87,6 +87,9 @@ pub struct Request { /// Optional macOS Seatbelt configuration. #[serde(alias = "macos_sandbox", default)] pub seatbelt: OptionalField, + /// Optional telemetry settings. + #[serde(default)] + pub telemetry: OptionalField, /// Optional experimental settings. #[serde(default)] pub experimental: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/stable.rs b/src/core/mxc_config_contract/src/dev/stable.rs index 3ab5b8bc8..0899c0719 100644 --- a/src/core/mxc_config_contract/src/dev/stable.rs +++ b/src/core/mxc_config_contract/src/dev/stable.rs @@ -15,6 +15,15 @@ pub struct Lifecycle { pub preserve_policy: OptionalField, } +/// Telemetry settings. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Telemetry { + /// Whether telemetry is enabled. + #[serde(default)] + pub enabled: OptionalField, +} + /// Process execution settings. #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/core/mxc_config_contract/tests/v0_8_0_alpha/experimental/test_and_telemetry.rs b/src/core/mxc_config_contract/tests/v0_8_0_alpha/experimental/test_and_telemetry.rs index 5ae2ac250..0e12db22e 100644 --- a/src/core/mxc_config_contract/tests/v0_8_0_alpha/experimental/test_and_telemetry.rs +++ b/src/core/mxc_config_contract/tests/v0_8_0_alpha/experimental/test_and_telemetry.rs @@ -92,7 +92,7 @@ fn rejects_duplicate_test_fields() { fn accepts_empty_telemetry_object() { let json = r#"{ "version": "0.8.0-alpha", - "experimental": {"telemetry": {}}, + "telemetry": {}, "process": {"commandLine": "echo"} }"#; @@ -105,10 +105,8 @@ fn accepts_telemetry_enabled_values() { let json = format!( r#"{{ "version": "0.8.0-alpha", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }}, "process": {{"commandLine": "echo"}} }}"# @@ -124,10 +122,8 @@ fn rejects_non_boolean_telemetry_enabled_values() { let json = format!( r#"{{ "version": "0.8.0-alpha", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }}, "process": {{"commandLine": "echo"}} }}"# @@ -141,10 +137,8 @@ fn rejects_non_boolean_telemetry_enabled_values() { fn rejects_unknown_telemetry_field() { let json = r#"{ "version": "0.8.0-alpha", - "experimental": { - "telemetry": { - "unknownField": "value" - } + "telemetry": { + "unknownField": "value" }, "process": {"commandLine": "echo"} }"#; @@ -159,16 +153,16 @@ fn rejects_duplicate_telemetry_fields() { assert_invalid_cases( [ ( - "experimental.telemetry", + "telemetry", version_and_process, - r#""experimental": {"telemetry": {}, "telemetry": {}}"#, + r#""telemetry": {}, "telemetry": {}"#, ), ( - "experimental.telemetry.enabled", + "telemetry.enabled", version_and_process, - r#""experimental": {"telemetry": {"enabled": true, "enabled": false}}"#, + r#""telemetry": {"enabled": true, "enabled": false}"#, ), ], - "duplicate experimental field", + "duplicate telemetry field", ); } diff --git a/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs b/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs index 5da0014a5..e7808c45b 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs @@ -9,8 +9,8 @@ fn experimental_test_is_introduced_in_v08() { } #[test] -fn experimental_telemetry_is_introduced_in_v08() { - assert_v08_introduces(r#""experimental": {"telemetry": {}}"#); +fn telemetry_is_introduced_in_v08() { + assert_v08_introduces(r#""telemetry": {}"#); } #[test] diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs index 5291720a3..c950caae1 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs @@ -339,7 +339,6 @@ fn convert_experimental(value: contract::OneShotExperimental) -> wire::Experimen test, windows_sandbox, wslc, - telemetry, } = value; wire::Experimental { test: test.into_option().map(convert_test), @@ -347,7 +346,6 @@ fn convert_experimental(value: contract::OneShotExperimental) -> wire::Experimen wslc: wslc.into_option().map(convert_wslc), isolation_session: None, seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } @@ -367,6 +365,7 @@ pub(crate) fn into_wire(request: contract::OneShotRequest) -> wire::MxcConfig { process_container, ui, seatbelt, + telemetry, experimental, } = request; wire::MxcConfig { @@ -375,7 +374,6 @@ pub(crate) fn into_wire(request: contract::OneShotRequest) -> wire::MxcConfig { version: Some(convert_version(version).to_owned()), phase: None, sandbox_id: None, - correlation_vector: None, container_id: container_id.into_option(), containment: containment.into_option().map(convert_containment), process: Some(convert_process(process)), @@ -389,6 +387,7 @@ pub(crate) fn into_wire(request: contract::OneShotRequest) -> wire::MxcConfig { network: network.into_option().map(convert_network), ui: ui.into_option().map(convert_ui), seatbelt: seatbelt.into_option().map(convert_seatbelt), + telemetry: telemetry.into_option().map(convert_telemetry), experimental: experimental.into_option().map(convert_experimental), } } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs index cc5d42df8..d359b70ec 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/experimental.rs @@ -9,12 +9,12 @@ const TEST_FEATURE_AND_TELEMETRY_REQUEST_JSON: &str = r#"{ "process": { "commandLine": "echo hello" }, + "telemetry": { + "enabled": false + }, "experimental": { "test": { "message": "test message" - }, - "telemetry": { - "enabled": false } } }"#; @@ -90,7 +90,7 @@ fn windows_sandbox_maps_expected_wire_fields() { assert!(experimental.wslc.is_none()); assert!(experimental.isolation_session.is_none()); assert!(experimental.seatbelt.is_none()); - assert!(experimental.telemetry.is_none()); + assert!(wire.telemetry.is_none()); } #[test] @@ -102,9 +102,7 @@ fn test_feature_and_telemetry_map_expected_wire_fields() { let test = experimental.test.expect("test should be populated"); assert_eq!(test.message.as_deref(), Some("test message")); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert_eq!(telemetry.enabled, Some(false)); assert!(experimental.windows_sandbox.is_none()); @@ -157,7 +155,7 @@ fn wslc_maps_expected_wire_fields() { assert!(experimental.windows_sandbox.is_none()); assert!(experimental.isolation_session.is_none()); assert!(experimental.seatbelt.is_none()); - assert!(experimental.telemetry.is_none()); + assert!(wire.telemetry.is_none()); } #[test] diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs index f91d4a254..fad2207a5 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot_tests/stable_candidate.rs @@ -385,7 +385,6 @@ fn minimal_request_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.8.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); @@ -403,6 +402,7 @@ fn minimal_request_maps_expected_wire_fields() { assert!(wire.network.is_none()); assert!(wire.ui.is_none()); assert!(wire.seatbelt.is_none()); + assert!(wire.telemetry.is_none()); assert!(wire.experimental.is_none()); } @@ -417,7 +417,6 @@ fn process_container_request_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.8.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert_eq!(wire.container_id.as_deref(), Some("container-id")); assert!(matches!( wire.containment, @@ -519,7 +518,6 @@ fn lxc_request_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.8.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert_eq!(wire.container_id.as_deref(), Some("container-id")); assert!(matches!( wire.containment, @@ -591,7 +589,6 @@ fn seatbelt_request_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.8.0-alpha".to_string())); assert!(wire.phase.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(matches!( wire.containment, From 59656df1b4b49b306a76ab5fbcc1a0604030bc4f Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Tue, 18 Aug 2026 07:49:56 -0700 Subject: [PATCH 12/47] Expose telemetry through Rust SDK and C ABI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- scripts/check-dotnet-bindings-codegen.js | 24 + .../MxcTelemetryTests.cs | 139 ++ sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs | 3 + sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 476 +++++++ sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs | 27 + src/Cargo.lock | 3 + src/core/mxc-sdk/README.md | 125 +- src/core/mxc-sdk/src/lib.rs | 2 + src/core/mxc-sdk/src/telemetry.rs | 528 ++++++++ src/core/mxc_engine/src/policy.rs | 26 +- .../src/telemetry/correlation_vector.rs | 1 - src/core/wxc_common/src/telemetry/mod.rs | 10 +- src/ffi/mxc_ffi/Cargo.toml | 8 + src/ffi/mxc_ffi/src/lib.rs | 1188 ++++++++++++++++- src/ffi/mxc_ffi/src/state_aware.rs | 14 +- src/ffi/mxc_ffi/src/streaming.rs | 223 +++- 16 files changed, 2681 insertions(+), 116 deletions(-) create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs create mode 100644 src/core/mxc-sdk/src/telemetry.rs diff --git a/scripts/check-dotnet-bindings-codegen.js b/scripts/check-dotnet-bindings-codegen.js index 60706a3cc..7856510a6 100644 --- a/scripts/check-dotnet-bindings-codegen.js +++ b/scripts/check-dotnet-bindings-codegen.js @@ -40,6 +40,30 @@ const REQUIRED_ENTRY_POINTS = [ "mxc_error_detail_free", "mxc_string_free", "mxc_version", + "mxc_telemetry_get_consent", + "mxc_telemetry_request_consent", + "mxc_telemetry_withdraw_consent", + "mxc_telemetry_get_consent_status", + "mxc_telemetry_needs_consent_prompt", + "mxc_telemetry_get_policy", + "mxc_spawn", + "mxc_sandbox_take_stdin", + "mxc_sandbox_take_stdout", + "mxc_sandbox_take_stderr", + "mxc_stream_read", + "mxc_stream_write", + "mxc_stream_flush", + "mxc_sandbox_id", + "mxc_sandbox_output_metadata_json", + "mxc_sandbox_try_wait", + "mxc_sandbox_wait", + "mxc_sandbox_kill", + "mxc_sandbox_free", + "mxc_read_stream_free", + "mxc_write_stream_free", + "mxc_state_aware", + "mxc_state_aware_exec", + "mxc_state_aware_result_free", ]; // Remove any stale copy so we prove codegen actually (re)produces it. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs new file mode 100644 index 000000000..510ab3097 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Runtime.Versioning; +using Microsoft.Win32; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +public sealed class MxcTelemetryTests +{ + private static readonly JsonSerializerOptions PolicyJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + [Fact] + public void SandboxPolicy_TelemetryEnabledSerializesCanonically() + { + var policy = new SandboxPolicy + { + Version = "0.8.0-alpha", + TelemetryEnabled = true, + }; + + var json = JsonSerializer.Serialize(policy, PolicyJsonOptions); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + Assert.False(root.TryGetProperty("telemetryEnabled", out _)); + } + + [Fact] + public void GetPolicy_NeverThrowsAndFailsClosed() + { + var policy = MxcTelemetry.GetPolicy(); + Assert.True(Enum.IsDefined(policy)); + } + + [Fact] + public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var called = false; + var outcome = MxcTelemetry.RequestConsent(_ => + { + called = true; + return TelemetryConsentDecision.Yes; + }); + + Assert.False(called); + Assert.Equal(TelemetryConsentResult.NotApplicable, outcome.Result); + Assert.Equal(TelemetryPolicyState.NotApplicable, outcome.Policy); + } + +#if DEBUG + [Fact] + public void RequestConsent_PresenterExceptionReturnsBackendError_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + var ex = Assert.Throws(() => + MxcTelemetry.RequestConsent(_ => throw new InvalidOperationException("boom"))); + Assert.Equal(ErrorCode.BackendError, ex.Code); + } + + [SupportedOSPlatform("windows")] + private sealed class TelemetryTestEnv : IDisposable + { + private static readonly SemaphoreSlim Gate = new(1, 1); + + private readonly string? _originalLocalAppData; + private readonly string? _originalPolicyKey; + private readonly string _storeDir; + private readonly string _policySubkey; + + public TelemetryTestEnv() + { + Gate.Wait(); + try + { + _originalLocalAppData = Environment.GetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + _originalPolicyKey = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE"); + + _storeDir = Directory.CreateTempSubdirectory("mxc-dotnet-telemetry-").FullName; + _policySubkey = $@"Software\MxcTelemetryDotNetTests\{Guid.NewGuid():N}"; + + Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose(); + Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _storeDir); + Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _policySubkey); + } + catch + { + Gate.Release(); + throw; + } + } + + public void Dispose() + { + Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _originalLocalAppData); + Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _originalPolicyKey); + + try + { + if (Directory.Exists(_storeDir)) + { + Directory.Delete(_storeDir, recursive: true); + } + } + catch + { + } + + try + { + Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false); + } + catch + { + } + + Gate.Release(); + } + } +#endif +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs index 6bbde7798..488abed94 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs @@ -58,4 +58,7 @@ public enum ErrorCode /// The native side panicked and was caught at the boundary (FFI-local). Panic = 102, + + /// Telemetry consent could not be persisted (FFI-local). + ConsentWriteFailed = 103, } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs new file mode 100644 index 000000000..a328c2dca --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -0,0 +1,476 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Microsoft.Mxc.Sdk.Native; + +namespace Microsoft.Mxc.Sdk; + +/// The user's recorded telemetry consent decision. +public enum TelemetryConsentState +{ + Granted, + Denied, + Undetermined, + NotApplicable, +} + +/// The administrative telemetry policy for this machine. +public enum TelemetryPolicyState +{ + Unrestricted, + Allowed, + Blocked, + NotApplicable, +} + +/// The presenter decision returned to MXC. +public enum TelemetryConsentDecision +{ + No, + Yes, + Dismissed, +} + +/// Why stored consent is not currently effective. +public enum TelemetryConsentStatusReason +{ + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + NotApplicable, +} + +/// Result of a consent request or withdrawal. +public enum TelemetryConsentResult +{ + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +/// One canonical consent message. +public sealed class TelemetryConsentMessage +{ + public string Id { get; init; } = string.Empty; + public string Text { get; init; } = string.Empty; +} + +/// The canonical consent prompt resource passed to a host presenter. +public sealed class TelemetryConsentPrompt +{ + public uint ResourceVersion { get; init; } + public string Locale { get; init; } = string.Empty; + public TelemetryConsentMessage Title { get; init; } = new(); + public TelemetryConsentMessage Body { get; init; } = new(); + public TelemetryConsentMessage AffirmativeLabel { get; init; } = new(); + public TelemetryConsentMessage NegativeLabel { get; init; } = new(); + public TelemetryConsentMessage LearnMoreLabel { get; init; } = new(); + public string LearnMoreUrl { get; init; } = string.Empty; +} + +/// Stored and effective consent plus the current administrative policy. +public sealed class TelemetryConsentStatus +{ + public TelemetryConsentState StoredState { get; init; } + public TelemetryConsentState EffectiveState { get; init; } + public TelemetryConsentStatusReason? Reason { get; init; } + public TelemetryPolicyState Policy { get; init; } +} + +/// Telemetry consent action result with the resulting status snapshot. +public sealed class TelemetryConsentOutcome +{ + public TelemetryConsentResult Result { get; init; } + public TelemetryConsentState StoredState { get; init; } + public TelemetryConsentState EffectiveState { get; init; } + public TelemetryConsentStatusReason? Reason { get; init; } + public TelemetryPolicyState Policy { get; init; } +} + +/// Telemetry consent helpers over the native mxc_ffi surface. +public static class MxcTelemetry +{ + private const int NativeConsentDecisionNo = 0; + private const int NativeConsentDecisionYes = 1; + private const int NativeConsentDecisionDismissed = 2; + private const int NativeConsentPresenterError = -1; + + private sealed class PresenterContext + { + public required Func Presenter { get; init; } + } + + /// + /// Return the user's recorded consent state. + /// Fail-closed native-load failures return . + /// + public static TelemetryConsentState GetConsent() + { + try + { + EnsureNativeInitialized(); + unsafe + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_consent(&value); + try + { + if (status != (int)ErrorCode.Success) + { + throw new MxcException((ErrorCode)status, "retrieving telemetry consent failed"); + } + + return ParseConsentState(ReadNativeUtf8(value) ?? "undetermined"); + } + finally + { + FreeNativeString(value); + } + } + } + catch (MxcException) + { + throw; + } + catch + { + return TelemetryConsentState.Undetermined; + } + } + + /// + /// Read stored and effective consent, plus the current administrative policy. + /// + public static TelemetryConsentStatus GetConsentStatus() + { + EnsureNativeInitialized(); + unsafe + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_consent_status(&value); + try + { + EnsureSuccess( + status, + "retrieving telemetry consent status failed", + ReadNativeUtf8(value)); + return ParseConsentStatus(ReadRequiredJson(value, "telemetry consent status")); + } + finally + { + FreeNativeString(value); + } + } + } + + /// + /// Invoke a host presenter, then persist its decision. + /// + public static TelemetryConsentOutcome RequestConsent( + Func presenter, + string? locale = null) + { + ArgumentNullException.ThrowIfNull(presenter); + EnsureNativeInitialized(); + + var localeBuf = locale is null ? null : ToNullTerminatedUtf8(locale); + var presenterContext = GCHandle.Alloc(new PresenterContext { Presenter = presenter }); + try + { + unsafe + { + fixed (byte* localePtr = localeBuf) + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_request_consent( + localePtr, + &PresentConsentBridge, + (void*)GCHandle.ToIntPtr(presenterContext), + &value); + try + { + EnsureSuccess( + status, + "requesting telemetry consent failed", + ReadNativeUtf8(value)); + return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome")); + } + finally + { + FreeNativeString(value); + } + } + } + } + finally + { + presenterContext.Free(); + } + } + + /// + /// Asynchronous wrapper over . + /// The native API is synchronous and blocking, so this offloads the whole call to the thread pool. + /// + public static Task RequestConsentAsync( + Func> presenter, + string? locale = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(presenter); + return Task.Run( + () => RequestConsent( + prompt => presenter(prompt).ConfigureAwait(false).GetAwaiter().GetResult(), + locale), + cancellationToken); + } + + /// Persist an idempotent telemetry-consent withdrawal. + public static TelemetryConsentOutcome WithdrawConsent() + { + EnsureNativeInitialized(); + unsafe + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_withdraw_consent(&value); + try + { + EnsureSuccess( + status, + "withdrawing telemetry consent failed", + ReadNativeUtf8(value)); + return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome")); + } + finally + { + FreeNativeString(value); + } + } + } + + /// + /// Whether a host should show its first-run consent prompt. + /// Fails closed to and never throws. + /// + public static bool NeedsConsentPrompt() + { + try + { + EnsureNativeInitialized(); + unsafe + { + int needsPrompt = 0; + var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt); + return status == (int)ErrorCode.Success && needsPrompt != 0; + } + } + catch + { + return false; + } + } + + /// + /// Read the administrative telemetry policy. + /// Fails closed to and never throws. + /// + public static TelemetryPolicyState GetPolicy() + { + try + { + EnsureNativeInitialized(); + unsafe + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_policy(&value); + try + { + if (status != (int)ErrorCode.Success) + { + return TelemetryPolicyState.Blocked; + } + + return ParsePolicyState(ReadNativeUtf8(value) ?? "blocked"); + } + finally + { + FreeNativeString(value); + } + } + } + catch + { + return TelemetryPolicyState.Blocked; + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context) + { + try + { + var handle = GCHandle.FromIntPtr((IntPtr)context); + if (handle.Target is not PresenterContext presenterContext) + { + return NativeConsentPresenterError; + } + + var prompt = ParseConsentPrompt(ReadRequiredJson(promptJsonUtf8, "telemetry consent prompt")); + return presenterContext.Presenter(prompt) switch + { + TelemetryConsentDecision.Yes => NativeConsentDecisionYes, + TelemetryConsentDecision.No => NativeConsentDecisionNo, + TelemetryConsentDecision.Dismissed => NativeConsentDecisionDismissed, + _ => NativeConsentPresenterError, + }; + } + catch + { + return NativeConsentPresenterError; + } + } + + private static void EnsureNativeInitialized() => NativeLibraryResolver.Initialize(); + + private static void EnsureSuccess(int status, string fallbackMessage, string? nativeMessage) + { + if (status == (int)ErrorCode.Success) + { + return; + } + + throw new MxcException( + (ErrorCode)status, + string.IsNullOrWhiteSpace(nativeMessage) ? fallbackMessage : nativeMessage); + } + + private static unsafe string ReadRequiredJson(byte* value, string description) => + ReadNativeUtf8(value) ?? throw new MxcException(ErrorCode.BackendError, $"{description} was missing"); + + private static unsafe string? ReadNativeUtf8(byte* value) => + value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value); + + private static unsafe void FreeNativeString(byte* value) + { + if (value is not null) + { + NativeMethods.mxc_string_free(value); + } + } + + private static byte[] ToNullTerminatedUtf8(string value) + { + var byteCount = Encoding.UTF8.GetByteCount(value); + var buffer = new byte[byteCount + 1]; + Encoding.UTF8.GetBytes(value, 0, value.Length, buffer, 0); + buffer[byteCount] = 0; + return buffer; + } + + private static TelemetryConsentPrompt ParseConsentPrompt(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + return new TelemetryConsentPrompt + { + ResourceVersion = root.GetProperty("resourceVersion").GetUInt32(), + Locale = root.GetProperty("locale").GetString() ?? string.Empty, + Title = ParseConsentMessage(root.GetProperty("title")), + Body = ParseConsentMessage(root.GetProperty("body")), + AffirmativeLabel = ParseConsentMessage(root.GetProperty("affirmativeLabel")), + NegativeLabel = ParseConsentMessage(root.GetProperty("negativeLabel")), + LearnMoreLabel = ParseConsentMessage(root.GetProperty("learnMoreLabel")), + LearnMoreUrl = root.GetProperty("learnMoreUrl").GetString() ?? string.Empty, + }; + } + + private static TelemetryConsentMessage ParseConsentMessage(JsonElement value) => new() + { + Id = value.GetProperty("id").GetString() ?? string.Empty, + Text = value.GetProperty("text").GetString() ?? string.Empty, + }; + + private static TelemetryConsentStatus ParseConsentStatus(string json) + { + using var doc = JsonDocument.Parse(json); + return ParseConsentStatus(doc.RootElement); + } + + private static TelemetryConsentStatus ParseConsentStatus(JsonElement root) => new() + { + StoredState = ParseConsentState(root.GetProperty("storedState").GetString() ?? string.Empty), + EffectiveState = ParseConsentState(root.GetProperty("effectiveState").GetString() ?? string.Empty), + Reason = root.TryGetProperty("reason", out var reason) && reason.ValueKind != JsonValueKind.Null + ? ParseConsentStatusReason(reason.GetString() ?? string.Empty) + : null, + Policy = ParsePolicyState(root.GetProperty("policy").GetString() ?? string.Empty), + }; + + private static TelemetryConsentOutcome ParseConsentOutcome(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var status = ParseConsentStatus(root); + return new TelemetryConsentOutcome + { + Result = ParseConsentResult(root.GetProperty("result").GetString() ?? string.Empty), + StoredState = status.StoredState, + EffectiveState = status.EffectiveState, + Reason = status.Reason, + Policy = status.Policy, + }; + } + + private static TelemetryConsentState ParseConsentState(string value) => value switch + { + "granted" => TelemetryConsentState.Granted, + "denied" => TelemetryConsentState.Denied, + "undetermined" => TelemetryConsentState.Undetermined, + "not-applicable" => TelemetryConsentState.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent state '{value}'"), + }; + + private static TelemetryPolicyState ParsePolicyState(string value) => value switch + { + "unrestricted" => TelemetryPolicyState.Unrestricted, + "allowed" => TelemetryPolicyState.Allowed, + "blocked" => TelemetryPolicyState.Blocked, + "not-applicable" => TelemetryPolicyState.NotApplicable, + _ => throw new JsonException($"unknown telemetry policy state '{value}'"), + }; + + private static TelemetryConsentStatusReason ParseConsentStatusReason(string value) => value switch + { + "no-record" => TelemetryConsentStatusReason.NoRecord, + "store-unreadable" => TelemetryConsentStatusReason.StoreUnreadable, + "store-malformed" => TelemetryConsentStatusReason.StoreMalformed, + "consent-schema-unsupported" => TelemetryConsentStatusReason.ConsentSchemaUnsupported, + "prompt-version-missing" => TelemetryConsentStatusReason.PromptVersionMissing, + "prompt-version-unsupported" => TelemetryConsentStatusReason.PromptVersionUnsupported, + "not-applicable" => TelemetryConsentStatusReason.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent status reason '{value}'"), + }; + + private static TelemetryConsentResult ParseConsentResult(string value) => value switch + { + "granted" => TelemetryConsentResult.Granted, + "denied" => TelemetryConsentResult.Denied, + "dismissed" => TelemetryConsentResult.Dismissed, + "withdrawn" => TelemetryConsentResult.Withdrawn, + "alreadyGranted" => TelemetryConsentResult.AlreadyGranted, + "policyBlocked" => TelemetryConsentResult.PolicyBlocked, + "notApplicable" => TelemetryConsentResult.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent result '{value}'"), + }; +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index d5a2ae12e..d74bf0359 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -38,6 +38,33 @@ public sealed class SandboxPolicy /// Execution timeout in milliseconds (null = no timeout). [JsonPropertyName("timeoutMs")] public uint? TimeoutMs { get; set; } + + /// + /// Canonical per-invocation telemetry settings serialized as + /// {"telemetry":{"enabled":...}}. + /// + [JsonPropertyName("telemetry")] + public TelemetrySettings? Telemetry { get; set; } + + /// + /// Convenience projection for . Serializes through + /// the canonical nested telemetry.enabled field, not the legacy + /// top-level alias accepted by the native FFI for compatibility. + /// + [JsonIgnore] + public bool? TelemetryEnabled + { + get => Telemetry?.Enabled; + set => Telemetry = value is null ? null : new TelemetrySettings { Enabled = value.Value }; + } +} + +/// Telemetry section of a . +public sealed class TelemetrySettings +{ + /// Whether this invocation opts telemetry on, subject to consent and policy. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } } /// diff --git a/src/Cargo.lock b/src/Cargo.lock index a159f34a5..0373b674a 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1550,7 +1550,10 @@ version = "0.7.0" dependencies = [ "csbindgen", "mxc-sdk", + "serde", "serde_json", + "tempfile", + "wxc_common", ] [[package]] diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 030ba5457..f6d722f63 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -12,9 +12,10 @@ sandboxed process — no pty is ever allocated. ## Usage ```rust,no_run +use std::error::Error; use mxc_sdk::{build_request, run, SandboxPolicy, WaitOutcome}; -// Describe what to restrict, turn it into a request, fill in the command. +fn main() -> Result<(), Box> { let policy = SandboxPolicy { version: "0.7.0-alpha".to_string(), filesystem: None, @@ -24,13 +25,13 @@ let policy = SandboxPolicy { capture_denials: None, }; let mut request = build_request(&policy, None)?; -request.set_script("echo hello"); +request.set_script("echo hello").set_telemetry_enabled(true); -// Run to completion and capture the output. let output = run(request)?; assert_eq!(output.outcome, WaitOutcome::Exited(0)); assert_eq!(String::from_utf8_lossy(&output.stdout), "hello\n"); -# Ok::<(), Box>(()) +Ok(()) +} ``` [`run`] is the run-to-completion convenience (spawn + `wait_with_output`); use @@ -45,6 +46,10 @@ through the shared parser. The returned [`SandboxRequest`] has an empty command line — set the command with [`SandboxRequest::set_script`] (and any working directory / env) before spawning. +Telemetry remains off unless `SandboxRequest::set_telemetry_enabled(true)` is +called. Enabling that per-invocation switch still requires persisted user +consent and a permitting administrative policy. + To target a specific backend instead of the host default, use [`build_request_with_containment`] with a [`Containment`] — the same choice the TypeScript SDK makes with `createConfigFromPolicy(policy, containment)`. @@ -149,14 +154,6 @@ And a backend appearing in `available_backends()` is a host-capability signal, **not** a guarantee this SDK can launch it — cross-check [`platform_support`] for that. -> **Before / after.** Host-and-backend discovery previously lived only in the -> TypeScript SDK (`getPlatformSupport`), so Rust callers and the executor -> binaries had no in-process way to ask "what backends does this host support?" -> and could only learn a backend was unusable by trying to launch it. Now the -> engine answers both in-process — [`platform_support`] for the SDK-launchable -> subset and [`available_backends`] for the full host-capability set with tiers — -> with no TypeScript dependency and no trial spawn. - ## Denial capture (Windows) `SandboxPolicy::capture_denials` enables the Windows ProcessContainer's @@ -203,9 +200,11 @@ while it runs — persistent bidirectional stdio plus termination. No pty is allocated; the streams are ordinary pipes. ```rust,no_run +use std::error::Error; use std::io::{Read, Write}; use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, WaitOutcome}; +fn main() -> Result<(), Box> { let policy = SandboxPolicy { version: "0.7.0-alpha".to_string(), filesystem: None, @@ -228,7 +227,8 @@ stdout.read_to_string(&mut out)?; // "hello\n" let outcome = proc.wait()?; // any untaken stream is drained and discarded assert_eq!(outcome, WaitOutcome::Exited(0)); -# Ok::<(), Box>(()) +Ok(()) +} ``` The handle is modelled on [`std::process::Child`]: @@ -301,8 +301,10 @@ The example below is illustrative: it selects IsolationSession, which needs today — so it returns `ErrorCode::UnsupportedPhase` as written. ```rust,no_run +use std::error::Error; use mxc_sdk::{run_state_aware_json, exec_sandbox}; +fn main() -> Result<(), Box> { // Provision. IsolationSession accepts only the canonical unrestricted-network // acknowledgment; an absent policy defaults to `block`, which it refuses. let provisioned = run_state_aware_json( @@ -318,7 +320,9 @@ let mut proc = exec_sandbox( true, // experimental )?; let _ = proc.wait(); -# Ok::<(), Box>(()) +let _ = provisioned; +Ok(()) +} ``` Three backends implement the state-aware lifecycle — IsolationSession, WSLc and @@ -355,17 +359,22 @@ go through the same parser the executor uses — so a rejected value (e.g. a por mapping with a zero or duplicated host port) fails at build time, not at spawn. ```rust,no_run +use std::error::Error; use mxc_sdk::{build_request_with_containment, run, Containment, SandboxPolicy, WslcSection}; -# let policy = SandboxPolicy { -# version: "0.7.0-alpha".to_string(), -# filesystem: None, network: None, ui: None, timeout_ms: None, -# }; +fn main() -> Result<(), Box> { +let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, network: None, ui: None, timeout_ms: None, + capture_denials: None, +}; let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() }; let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?; request.set_script("python3 -c 'print(42)'").set_experimental(true); let output = run(request)?; -# Ok::<(), mxc_sdk::Error>(()) +let _ = output; +Ok(()) +} ``` Two WSLC-specific limits follow from the SDK's surface: the container has no @@ -373,6 +382,84 @@ stdin (`Sandbox::take_stdin()` returns `None`), and its process has no host process id (`Sandbox::id()` is `0`) — `kill()` stops the whole container. [`platform_support`] reports `"wslc"` only on a host that can actually run it. +## Telemetry consent + +MXC only ever collects telemetry on Windows, and only after the end user has +explicitly opted in — a persisted, MXC-owned consent flag gates every +emission (never a Windows-level setting like Diagnostics & feedback). See +[`docs/telemetry/telemetry-consent-design.md`](../../../docs/telemetry/telemetry-consent-design.md) +for the full design. + +The crate is UI-agnostic: it does not render a prompt. A host may call +`request_consent()` or `request_consent_async()` and render every field of the +canonical prompt supplied to its presenter callback verbatim. The prompt text +comes from the versioned `wxc_common` consent resource. MXC persists a grant +only from the typed decision returned by that callback. If the host never +requests consent, telemetry remains off. See the normative +[SDK presenter requirements](../../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements) +for control mappings, dismissal behavior, learn-more handling, status, and +withdrawal. + +Telemetry is also off per invocation unless the request explicitly enables it +with `SandboxRequest::set_telemetry_enabled(true)`. This stable switch does not +require `set_experimental(true)` and cannot bypass consent or administrative +policy. The same configured `SandboxRequest` can be passed to either +run-to-completion (`run`) or streaming (`spawn_sandbox`). + +```rust,no_run +use std::error::Error; +use mxc_sdk::telemetry; + +fn main() -> Result<(), Box> { +let outcome = telemetry::request_consent(Some("en-US"), |prompt| { + assert_eq!(prompt.locale, "en-US"); + Ok(telemetry::ConsentDecision::Yes) +})?; + +let status = telemetry::get_consent_status(); +let withdrawal = telemetry::withdraw_consent()?; +let _ = (outcome, status, withdrawal); +Ok(()) +} +``` + +Off Windows `get_consent()` always returns `ConsentState::NotApplicable`, +`needs_consent_prompt()` is always `false`, and consent requests return +`ConsentActionResult::NotApplicable` — MXC neither collects nor offers consent +for telemetry there, so a host can call these unconditionally without +special-casing the platform. + +`ConsentState` and `PolicyState` are SDK-owned facades over the shared +telemetry implementation. + +### Administrative policy + +An IT administrator can block MXC telemetry device-wide via MXC's own +registry policy setting. `telemetry::get_policy()` reports the result: + +```rust,no_run +use mxc_sdk::telemetry::{self, PolicyState}; + +if telemetry::get_policy() == PolicyState::Blocked { + // Don't show a consent toggle; telemetry is unavailable on this device. +} +``` + +Two things worth designing around: + +- The policy is a **ceiling, never a grant**. `PolicyState::Allowed` does not + mean telemetry is on — the user must still consent. Only + `ConsentState::Granted` *and* a non-blocking policy result in collection. +- When the policy blocks, `needs_consent_prompt()` is `false`, because asking + for permission an administrator has already refused is a meaningless + question. Word any UI as "telemetry is unavailable on this device" rather + than blaming the user's own choice. + +It never fails: any unreadable or unrecognized value reads back as +`PolicyState::Blocked`. Off Windows it is always `PolicyState::NotApplicable`. +`telemetry::is_blocked_by_policy()` is the convenience predicate. See +[`docs/telemetry/telemetry-policy.md`](../../../docs/telemetry/telemetry-policy.md). + ## No pty The child's stdio is always wired to ordinary pipes — the library never diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 9c997e94a..9bf7fee4a 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -136,6 +136,8 @@ mod sandbox; +pub mod telemetry; + pub use mxc_engine::policy; pub use mxc_engine::{ available_backends, available_tools_policy, build_request, build_request_with_containment, diff --git a/src/core/mxc-sdk/src/telemetry.rs b/src/core/mxc-sdk/src/telemetry.rs new file mode 100644 index 000000000..b41412080 --- /dev/null +++ b/src/core/mxc-sdk/src/telemetry.rs @@ -0,0 +1,528 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Telemetry consent administration. +//! +//! A host supplies presentation for the canonical consent resource; the SDK +//! persists the typed decision. See +//! `docs/telemetry/telemetry-consent-design.md`. +//! +//! ```no_run +//! use std::error::Error; +//! use mxc_sdk::telemetry::{self, ConsentDecision, ConsentState}; +//! +//! fn main() -> Result<(), Box> { +//! let outcome = telemetry::request_consent(Some("en-US"), |prompt| { +//! assert_eq!(prompt.locale, "en-US"); +//! Ok(ConsentDecision::Yes) +//! })?; +//! +//! match telemetry::get_consent() { +//! ConsentState::Granted => println!("telemetry on"), +//! ConsentState::Denied | ConsentState::Undetermined => println!("telemetry off"), +//! ConsentState::NotApplicable => {} +//! } +//! let _ = outcome; +//! Ok(()) +//! } +//! ``` + +use std::fmt; + +use wxc_common::telemetry::consent as inner_consent; +use wxc_common::telemetry::policy as inner_policy; + +/// The user's recorded telemetry consent decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConsentState { + /// The user has explicitly agreed to telemetry collection. + Granted, + /// The user has explicitly declined telemetry collection. + Denied, + /// No decision has been recorded yet (fresh install, or a corrupt or + /// unreadable store). Treated identically to [`ConsentState::Denied`] for + /// gating purposes — it differs only in that a host should still prompt. + Undetermined, + /// Not a Windows host. MXC collects no telemetry on other platforms, so + /// there is nothing to consent to. + NotApplicable, +} + +impl ConsentState { + /// The stable wire string for this state, identical across every SDK. + pub fn as_str(&self) -> &'static str { + Self::to_inner(*self).as_str() + } + + /// Whether this state permits collection. + pub fn allows_collection(&self) -> bool { + Self::to_inner(*self).allows_collection() + } + + /// Whether this state needs a consent prompt, without policy evaluation. + pub fn needs_prompt(&self) -> bool { + Self::to_inner(*self).needs_prompt() + } + + fn to_inner(self) -> inner_consent::ConsentState { + match self { + Self::Granted => inner_consent::ConsentState::Granted, + Self::Denied => inner_consent::ConsentState::Denied, + Self::Undetermined => inner_consent::ConsentState::Undetermined, + Self::NotApplicable => inner_consent::ConsentState::NotApplicable, + } + } +} + +impl From for ConsentState { + fn from(value: inner_consent::ConsentState) -> Self { + match value { + inner_consent::ConsentState::Granted => Self::Granted, + inner_consent::ConsentState::Denied => Self::Denied, + inner_consent::ConsentState::Undetermined => Self::Undetermined, + inner_consent::ConsentState::NotApplicable => Self::NotApplicable, + } + } +} + +/// One independently localizable canonical consent message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentMessage { + pub id: &'static str, + pub text: &'static str, +} + +/// Canonical consent resource supplied to a host presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentPrompt { + pub resource_version: u32, + pub locale: &'static str, + pub title: ConsentMessage, + pub body: ConsentMessage, + pub affirmative_label: ConsentMessage, + pub negative_label: ConsentMessage, + pub learn_more_label: ConsentMessage, + pub learn_more_url: &'static str, +} + +impl From<&wxc_common::telemetry::consent_prompt::ConsentPrompt> for ConsentPrompt { + fn from(value: &wxc_common::telemetry::consent_prompt::ConsentPrompt) -> Self { + fn message(value: wxc_common::telemetry::consent_prompt::ConsentMessage) -> ConsentMessage { + ConsentMessage { + id: value.id, + text: value.text, + } + } + + Self { + resource_version: value.resource_version, + locale: value.locale, + title: message(value.title), + body: message(value.body), + affirmative_label: message(value.affirmative_label), + negative_label: message(value.negative_label), + learn_more_label: message(value.learn_more_label), + learn_more_url: value.learn_more_url, + } + } +} + +/// Explicit result returned by a host consent presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentDecision { + Yes, + No, + Dismissed, +} + +impl ConsentDecision { + fn to_inner(self) -> inner_consent::ConsentDecision { + match self { + Self::Yes => inner_consent::ConsentDecision::Yes, + Self::No => inner_consent::ConsentDecision::No, + Self::Dismissed => inner_consent::ConsentDecision::Dismissed, + } + } +} + +/// Why stored consent is not currently effective. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentStatusReason { + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + NotApplicable, +} + +impl ConsentStatusReason { + /// Stable wire string used by the maintenance and binding contracts. + pub fn as_str(&self) -> &'static str { + match self { + Self::NoRecord => "no-record", + Self::StoreUnreadable => "store-unreadable", + Self::StoreMalformed => "store-malformed", + Self::ConsentSchemaUnsupported => "consent-schema-unsupported", + Self::PromptVersionMissing => "prompt-version-missing", + Self::PromptVersionUnsupported => "prompt-version-unsupported", + Self::NotApplicable => "not-applicable", + } + } +} + +impl From for ConsentStatusReason { + fn from(value: inner_consent::ConsentStatusReason) -> Self { + match value { + inner_consent::ConsentStatusReason::NoRecord => Self::NoRecord, + inner_consent::ConsentStatusReason::StoreUnreadable => Self::StoreUnreadable, + inner_consent::ConsentStatusReason::StoreMalformed => Self::StoreMalformed, + inner_consent::ConsentStatusReason::ConsentSchemaUnsupported => { + Self::ConsentSchemaUnsupported + } + inner_consent::ConsentStatusReason::PromptVersionMissing => Self::PromptVersionMissing, + inner_consent::ConsentStatusReason::PromptVersionUnsupported => { + Self::PromptVersionUnsupported + } + inner_consent::ConsentStatusReason::NotApplicable => Self::NotApplicable, + } + } +} + +/// Stored and effective consent returned by read-only status APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentStatus { + pub stored_state: ConsentState, + pub effective_state: ConsentState, + pub reason: Option, +} + +impl From for ConsentStatus { + fn from(value: inner_consent::ConsentStatus) -> Self { + Self { + stored_state: value.stored_state.into(), + effective_state: value.effective_state.into(), + reason: value.reason.map(Into::into), + } + } +} + +/// Result of a presenter request or withdrawal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentActionResult { + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +impl ConsentActionResult { + /// Stable wire string used by the maintenance and binding contracts. + /// + /// The strings are the camelCase spellings of + /// [`wxc_common::wire::TelemetryConsentResult`] (its `#[serde(rename_all = + /// "camelCase")]` serialization), so the FFI JSON `result` field, the + /// generated Node wire types, and this facade all agree on one canonical + /// wire representation. + pub fn as_str(&self) -> &'static str { + match self { + Self::Granted => "granted", + Self::Denied => "denied", + Self::Dismissed => "dismissed", + Self::Withdrawn => "withdrawn", + Self::AlreadyGranted => "alreadyGranted", + Self::PolicyBlocked => "policyBlocked", + Self::NotApplicable => "notApplicable", + } + } +} + +impl From for ConsentActionResult { + fn from(value: inner_consent::ConsentActionResult) -> Self { + match value { + inner_consent::ConsentActionResult::Granted => Self::Granted, + inner_consent::ConsentActionResult::Denied => Self::Denied, + inner_consent::ConsentActionResult::Dismissed => Self::Dismissed, + inner_consent::ConsentActionResult::Withdrawn => Self::Withdrawn, + inner_consent::ConsentActionResult::AlreadyGranted => Self::AlreadyGranted, + inner_consent::ConsentActionResult::PolicyBlocked => Self::PolicyBlocked, + inner_consent::ConsentActionResult::NotApplicable => Self::NotApplicable, + } + } +} + +/// Consent action result with resulting status and policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentActionOutcome { + pub result: ConsentActionResult, + pub status: ConsentStatus, + pub policy: PolicyState, +} + +impl From for ConsentActionOutcome { + fn from(value: inner_consent::ConsentActionOutcome) -> Self { + Self { + result: value.result.into(), + status: value.status.into(), + policy: value.policy.into(), + } + } +} + +/// Failure to present or persist telemetry consent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConsentError { + Presenter(String), + Persist(String), +} + +impl fmt::Display for ConsentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Presenter(message) => write!(formatter, "consent presenter failed: {message}"), + Self::Persist(message) => write!(formatter, "failed to persist consent: {message}"), + } + } +} + +impl std::error::Error for ConsentError {} + +impl From for ConsentError { + fn from(value: inner_consent::ConsentActionError) -> Self { + match value { + inner_consent::ConsentActionError::Presenter(message) => Self::Presenter(message), + inner_consent::ConsentActionError::Persist(message) => Self::Persist(message), + } + } +} + +/// The administrative telemetry policy for this machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PolicyState { + /// No policy is configured. + Unrestricted, + /// A policy is configured and permits collection. + Allowed, + /// A policy blocks collection or could not be evaluated. + Blocked, + /// Not a Windows host. + NotApplicable, +} + +impl PolicyState { + /// The stable wire string for this state, identical across every SDK. + pub fn as_str(&self) -> &'static str { + Self::to_inner(*self).as_str() + } + + /// Whether this policy permits collection. + pub fn allows_collection(&self) -> bool { + Self::to_inner(*self).allows_collection() + } + + fn to_inner(self) -> inner_policy::PolicyState { + match self { + Self::Unrestricted => inner_policy::PolicyState::Unrestricted, + Self::Allowed => inner_policy::PolicyState::Allowed, + Self::Blocked => inner_policy::PolicyState::Blocked, + Self::NotApplicable => inner_policy::PolicyState::NotApplicable, + } + } +} + +impl From for PolicyState { + fn from(value: inner_policy::PolicyState) -> Self { + match value { + inner_policy::PolicyState::Unrestricted => Self::Unrestricted, + inner_policy::PolicyState::Allowed => Self::Allowed, + inner_policy::PolicyState::Blocked => Self::Blocked, + inner_policy::PolicyState::NotApplicable => Self::NotApplicable, + } + } +} + +/// Return the user's recorded consent decision. +pub fn get_consent() -> ConsentState { + inner_consent::get_consent().into() +} + +/// Read stored and effective consent. +pub fn get_consent_status() -> ConsentStatus { + inner_consent::get_status().into() +} + +/// Invoke a host presenter and persist its decision. +pub fn request_consent( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(&ConsentPrompt) -> Result, +{ + inner_consent::request_consent(locale, |prompt| { + presenter(&ConsentPrompt::from(prompt)).map(ConsentDecision::to_inner) + }) + .map(Into::into) + .map_err(Into::into) +} + +/// Asynchronous counterpart to [`request_consent`]. +/// +/// The synchronous consent-store work runs on short-lived dedicated worker +/// threads. The presenter itself is awaited on the caller's executor. +pub async fn request_consent_async( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(ConsentPrompt) -> Fut, + Fut: std::future::Future>, +{ + inner_consent::request_consent_async(locale, |prompt| { + let prompt = ConsentPrompt::from(prompt); + async move { presenter(prompt).await.map(ConsentDecision::to_inner) } + }) + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Idempotently withdraw telemetry consent. +pub fn withdraw_consent() -> Result { + inner_consent::withdraw_consent() + .map(Into::into) + .map_err(Into::into) +} + +/// Whether a host should show the first-run consent prompt. +pub fn needs_consent_prompt() -> bool { + inner_consent::needs_consent_prompt() +} + +/// Read the administrative telemetry policy. +pub fn get_policy() -> PolicyState { + inner_policy::get_policy().into() +} + +/// Whether an administrator has blocked telemetry on this machine. +pub fn is_blocked_by_policy() -> bool { + inner_policy::is_blocked_by_policy() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn consent_states_round_trip_and_keep_their_wire_strings() { + for (facade, inner) in [ + (ConsentState::Granted, inner_consent::ConsentState::Granted), + (ConsentState::Denied, inner_consent::ConsentState::Denied), + ( + ConsentState::Undetermined, + inner_consent::ConsentState::Undetermined, + ), + ( + ConsentState::NotApplicable, + inner_consent::ConsentState::NotApplicable, + ), + ] { + assert_eq!(ConsentState::from(inner), facade); + assert_eq!(facade.as_str(), inner.as_str()); + assert_eq!(facade.allows_collection(), inner.allows_collection()); + assert_eq!(facade.needs_prompt(), inner.needs_prompt()); + } + } + + #[test] + fn policy_states_round_trip_and_keep_their_wire_strings() { + for (facade, inner) in [ + ( + PolicyState::Unrestricted, + inner_policy::PolicyState::Unrestricted, + ), + (PolicyState::Allowed, inner_policy::PolicyState::Allowed), + (PolicyState::Blocked, inner_policy::PolicyState::Blocked), + ( + PolicyState::NotApplicable, + inner_policy::PolicyState::NotApplicable, + ), + ] { + assert_eq!(PolicyState::from(inner), facade); + assert_eq!(facade.as_str(), inner.as_str()); + assert_eq!(facade.allows_collection(), inner.allows_collection()); + } + } + + /// Only an explicit grant may permit collection. Locks in that the facade + /// did not accidentally widen the shared rule. + #[test] + fn only_granted_consent_and_a_permitting_policy_allow_collection() { + assert!(ConsentState::Granted.allows_collection()); + for denied in [ + ConsentState::Denied, + ConsentState::Undetermined, + ConsentState::NotApplicable, + ] { + assert!(!denied.allows_collection(), "{denied:?} must not permit"); + } + + // Only an explicit block denies on the policy side. `NotApplicable` + // (off Windows) deliberately does *not* deny: the consent gate above + // already reports `NotApplicable`, and denying here too would wrongly + // imply an administrator had acted. + assert!(!PolicyState::Blocked.allows_collection()); + for permitted in [ + PolicyState::Unrestricted, + PolicyState::Allowed, + PolicyState::NotApplicable, + ] { + assert!(permitted.allows_collection(), "{permitted:?} must permit"); + } + } + + #[test] + fn canonical_prompt_facade_preserves_every_rust_owned_field() { + let inner = wxc_common::telemetry::consent_prompt::prompt_for_locale(Some("en-US")); + let facade = ConsentPrompt::from(inner); + + assert_eq!(facade.resource_version, inner.resource_version); + assert_eq!(facade.locale, inner.locale); + assert_eq!(facade.title.id, inner.title.id); + assert_eq!(facade.title.text, inner.title.text); + assert_eq!(facade.body.id, inner.body.id); + assert_eq!(facade.body.text, inner.body.text); + assert_eq!(facade.affirmative_label.text, inner.affirmative_label.text); + assert_eq!(facade.negative_label.text, inner.negative_label.text); + assert_eq!(facade.learn_more_label.text, inner.learn_more_label.text); + assert_eq!(facade.learn_more_url, inner.learn_more_url); + } + + #[test] + fn consent_result_wire_strings_are_closed_and_stable() { + // These camelCase strings are the canonical wire representation + // (`wxc_common::wire::TelemetryConsentResult`) also emitted by the FFI + // JSON `result` field and the generated Node wire types. + assert_eq!(ConsentActionResult::Granted.as_str(), "granted"); + assert_eq!(ConsentActionResult::Denied.as_str(), "denied"); + assert_eq!(ConsentActionResult::Dismissed.as_str(), "dismissed"); + assert_eq!(ConsentActionResult::Withdrawn.as_str(), "withdrawn"); + assert_eq!( + ConsentActionResult::AlreadyGranted.as_str(), + "alreadyGranted" + ); + assert_eq!(ConsentActionResult::PolicyBlocked.as_str(), "policyBlocked"); + assert_eq!(ConsentActionResult::NotApplicable.as_str(), "notApplicable"); + // `ConsentStatusReason` intentionally stays kebab-case: the canonical + // wire enum (`wxc_common::wire::TelemetryConsentStatusReason`) is + // itself `#[serde(rename_all = "kebab-case")]`, so kebab is correct. + assert_eq!( + ConsentStatusReason::PromptVersionUnsupported.as_str(), + "prompt-version-unsupported" + ); + } +} diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 37ec9b2e7..c4b702fd7 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -794,6 +794,14 @@ impl SandboxRequest { }); self } + + /// Return the explicit per-request telemetry switch for this invocation. + pub fn telemetry_enabled(&self) -> Option { + self.inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled) + } } /// Build a [`SandboxRequest`] from a [`SandboxPolicy`], resolving the host's @@ -1716,28 +1724,14 @@ mod tests { assert!(!request.inner.experimental_enabled); request.set_telemetry_enabled(true); - assert_eq!( - request - .inner - .telemetry - .as_ref() - .and_then(|telemetry| telemetry.enabled), - Some(true) - ); + assert_eq!(request.telemetry_enabled(), Some(true)); assert!( !request.inner.experimental_enabled, "stable telemetry enablement must not opt into experimental features" ); request.set_telemetry_enabled(false); - assert_eq!( - request - .inner - .telemetry - .as_ref() - .and_then(|telemetry| telemetry.enabled), - Some(false) - ); + assert_eq!(request.telemetry_enabled(), Some(false)); assert!(!request.inner.experimental_enabled); } diff --git a/src/core/wxc_common/src/telemetry/correlation_vector.rs b/src/core/wxc_common/src/telemetry/correlation_vector.rs index 64d47dc8b..85bd2ecf2 100644 --- a/src/core/wxc_common/src/telemetry/correlation_vector.rs +++ b/src/core/wxc_common/src/telemetry/correlation_vector.rs @@ -523,7 +523,6 @@ mod tests { assert!(spun.starts_with(&format!("{parent}."))); assert!(spun.ends_with(".0")); // parent had 2 parts (base, 0); spin adds the spin element and a fresh 0. - // parent had 2 parts; v2.1 spin adds counter, entropy, and fresh 0. assert_eq!(spun.split('.').count(), 5); assert!(is_valid(&spun)); } diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index f5fb969f0..1ec5bf005 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -868,8 +868,8 @@ pub fn emit_sdk_cancellation_with_kind( }); } -#[cfg(test)] -pub(crate) mod test_support { +#[cfg(any(test, all(feature = "test-support", debug_assertions)))] +pub mod test_support { use super::consent::test_support::LocalAppDataGuard; use super::policy::test_support::PolicyKeyGuard; @@ -890,7 +890,7 @@ pub(crate) mod test_support { /// must hold this — *including* tests that only care about consent. /// Otherwise they read the real machine policy and fail on an /// administratively managed device. - pub(crate) struct TelemetryTestEnv { + pub struct TelemetryTestEnv { // Fields drop in declaration order, so consent is released before // policy: the exact reverse of the acquisition order below. _consent: LocalAppDataGuard, @@ -900,7 +900,7 @@ pub(crate) mod test_support { impl TelemetryTestEnv { /// Redirects the consent store to `store` and the policy key to a /// fresh, empty one (i.e. an unmanaged machine). - pub(crate) fn new(store: &std::path::Path) -> Self { + pub fn new(store: &std::path::Path) -> Self { let policy = PolicyKeyGuard::new(); let _consent = LocalAppDataGuard::set(store); Self { _consent, policy } @@ -908,7 +908,7 @@ pub(crate) mod test_support { /// Sets the administrative `AllowTelemetry` policy value. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_policy_value(&self, value: u32) { + pub fn set_policy_value(&self, value: u32) { self.policy.set_value(value); } } diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index 279af6b8b..1151ad492 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib", "staticlib", "lib"] [dependencies] mxc-sdk = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } [features] @@ -24,5 +25,12 @@ serde_json = { workspace = true } # regenerate the committed bindings. dotnetsdk = ["dep:csbindgen"] +[dev-dependencies] +# Gives the FFI tests the policy-key redirector, so they can assert the exact +# string this layer marshals for each administrative policy state rather than +# accepting whatever the host machine's real policy happens to produce. +wxc_common = { workspace = true, features = ["test-support"] } +tempfile.workspace = true + [build-dependencies] csbindgen = { version = "1", optional = true } diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 82b65ac0d..39895ea49 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -3,8 +3,7 @@ //! C ABI over the MXC public Rust SDK ([`mxc_sdk`]). //! -//! This is the flat, panic-safe C surface that language bindings (currently the -//! C# SDK) load. It spans three surfaces: +//! This is the flat, panic-safe C surface loaded by language bindings. //! //! - **Run to completion** — [`mxc_run`] builds a request from a `SandboxPolicy` //! JSON + a command string, runs the sandbox to completion, and returns the @@ -35,27 +34,30 @@ //! ([`MXC_STATUS_PANIC`]), never an unwind across the boundary. //! - **Data contract**: JSON in, captured bytes + status out. The status codes //! mirror `mxc_sdk::ErrorCode` one-for-one (plus a few FFI-local codes). +//! - **Per-invocation telemetry opt-in**: [`mxc_run`] / [`mxc_spawn`] accept +//! the canonical top-level execution field `telemetry.enabled` inside the +//! policy JSON they already take. For compatibility, the legacy top-level +//! `telemetryEnabled` boolean remains accepted as an alias; if both are +//! present they must agree. +//! - **Telemetry consent** — [`mxc_telemetry_get_consent`], +//! [`mxc_telemetry_get_consent_status`], [`mxc_telemetry_request_consent`], +//! [`mxc_telemetry_withdraw_consent`], [`mxc_telemetry_needs_consent_prompt`], +//! and [`mxc_telemetry_get_policy`] expose the consent and policy surface to +//! language bindings. //! //! ## ABI stability //! -//! **This C ABI is not (yet) a stable external contract.** The native library -//! and every binding that loads it (currently the C# SDK) are built and -//! versioned **together** from this repository at the same workspace version, -//! and the C# P/Invoke layer is *generated* from this surface by csbindgen (a -//! CI drift gate keeps the two in lockstep). Because both halves always ship -//! together, this surface is free to evolve — entry points may be added, and -//! the layout of `#[repr(C)]` types such as [`MxcRunResult`] may change — -//! between releases **without** a compatibility shim, so long as the generated -//! binding is regenerated in the same change. Do not treat `mxc_ffi` as a -//! frozen ABI to link third-party consumers against; consume MXC through a -//! versioned binding (the C# SDK) matched to the same release. - -use std::ffi::{c_char, CStr, CString}; +//! This C ABI is versioned with the native library and generated bindings. It +//! is not a stable external ABI; regenerate bindings when this surface changes. + +use std::any::Any; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::io::{self, Write}; use std::panic::catch_unwind; use std::ptr; use std::sync::OnceLock; -use mxc_sdk::{build_request, run, ErrorCode, SandboxPolicy, WaitOutcome}; +use mxc_sdk::{build_request, run, ErrorCode, SandboxPolicy, SandboxRequest, WaitOutcome}; mod error_detail; mod state_aware; @@ -64,13 +66,102 @@ pub use error_detail::*; pub use state_aware::*; pub use streaming::*; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_NO: i32 = 0; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_YES: i32 = 1; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_DISMISSED: i32 = 2; +/// Return code indicating that the host presenter failed. +pub const MXC_TELEMETRY_CONSENT_PRESENTER_ERROR: i32 = -1; + +/// Host callback invoked synchronously with the canonical consent prompt JSON. +/// +/// The JSON pointer remains valid only for the duration of the callback. The +/// callback must return one of the `MXC_TELEMETRY_CONSENT_DECISION_*` values, +/// or [`MXC_TELEMETRY_CONSENT_PRESENTER_ERROR`] to signal presenter failure. +/// +/// # Safety +/// +/// **The callback must not unwind across the FFI boundary.** Only Rust panics +/// are caught by [`std::panic::catch_unwind`] on the Rust side; a C++ +/// exception, a .NET/CLR exception, an Objective-C exception, a Go panic, or +/// any other foreign unwind mechanism escaping this callback into Rust is +/// undefined behavior. Binding authors wrapping this callback (C#, Node +/// native addons, C++ hosts, and so on) must wrap the trampoline that invokes +/// the caller's presenter in a `try` / `catch (...)` (or the runtime's +/// equivalent) that catches every foreign exception and returns +/// [`MXC_TELEMETRY_CONSENT_PRESENTER_ERROR`] instead of allowing the exception +/// to propagate. +pub type MxcTelemetryConsentPresenter = + Option i32>; + +/// Write a diagnostic line to stderr without panicking. +fn report_to_stderr(args: std::fmt::Arguments<'_>) { + let stderr = io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_fmt(args); + let _ = handle.write_all(b"\n"); + let _ = handle.flush(); +} + +/// Report a panic caught at the FFI boundary. +fn report_panic(operation: &str, payload: &(dyn Any + Send)) { + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + report_to_stderr(format_args!( + "mxc: internal error: panic caught at the FFI boundary in {operation}: {message}. \ + Failing closed; the calling process is unaffected." + )); +} + +fn report_diagnostic_once(flag: &OnceLock<()>, args: std::fmt::Arguments<'_>) { + if flag.set(()).is_ok() { + report_to_stderr(args); + } +} + +fn report_consent_persist_failure_once(flag: &OnceLock<()>, operation: &str) { + report_diagnostic_once( + flag, + format_args!( + "mxc: {operation} failed because telemetry consent could not be persisted. \ + Returning a fail-closed status without host-specific details." + ), + ); +} + +fn report_request_consent_persist_failure_once() { + static REQUEST_REPORTED: OnceLock<()> = OnceLock::new(); + report_consent_persist_failure_once(&REQUEST_REPORTED, "mxc_telemetry_request_consent"); +} + +fn report_withdraw_consent_persist_failure_once() { + static WITHDRAW_REPORTED: OnceLock<()> = OnceLock::new(); + report_consent_persist_failure_once(&WITHDRAW_REPORTED, "mxc_telemetry_withdraw_consent"); +} + +fn report_consent_presenter_failure_once() { + static REPORTED: OnceLock<()> = OnceLock::new(); + report_diagnostic_once( + &REPORTED, + format_args!( + "mxc: telemetry consent presenter failed. Returning MXC_STATUS_BACKEND_ERROR \ + without host-supplied details." + ), + ); +} + // --------------------------------------------------------------------------- // Status codes // --------------------------------------------------------------------------- /// Success. pub const MXC_STATUS_SUCCESS: i32 = 0; -// 1..=12 mirror `mxc_sdk::ErrorCode` (kept in lockstep with a CI drift gate). +// 1..=12 mirror `mxc_sdk::ErrorCode`. /// The request/policy was malformed. pub const MXC_STATUS_MALFORMED_REQUEST: i32 = 1; /// The requested containment backend is not supported by this library. @@ -103,6 +194,13 @@ pub const MXC_STATUS_NULL_ARGUMENT: i32 = 100; pub const MXC_STATUS_INVALID_UTF8: i32 = 101; /// The Rust side panicked; the panic was caught at the boundary. pub const MXC_STATUS_PANIC: i32 = 102; +/// Telemetry consent could not be persisted (for example `%LOCALAPPDATA%` +/// unavailable/unwritable on Windows). On non-Windows hosts consent actions +/// return a `"notApplicable"` outcome with [`MXC_STATUS_SUCCESS`], not this +/// status. +/// Consent reads do not return this status; an unexpected panic is reported as +/// [`MXC_STATUS_PANIC`]. +pub const MXC_STATUS_CONSENT_WRITE_FAILED: i32 = 103; /// Map an [`ErrorCode`] to its stable FFI status code. pub(crate) fn status_from_error_code(code: ErrorCode) -> i32 { @@ -194,6 +292,14 @@ impl MxcRunResult { } } + fn from_error_detail(status: i32, error: MxcErrorDetail) -> Self { + Self { + status, + error, + ..Self::empty() + } + } + /// Free any owned out-strings, resetting them to null. Idempotent. fn free_strings(&mut self) { free_cstr(&mut self.stdout_utf8); @@ -243,6 +349,98 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { CStr::from_ptr(p).to_str().ok() } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FfiTelemetrySection { + enabled: Option, +} + +fn deserialize_legacy_telemetry_enabled<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + match as serde::Deserialize>::deserialize(deserializer)? { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Bool(enabled)) => Ok(Some(enabled)), + Some(_) => Err(serde::de::Error::custom( + "telemetryEnabled must be a boolean", + )), + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct FfiPolicyEnvelope { + #[serde(flatten)] + policy: SandboxPolicy, + #[serde(default)] + telemetry: Option, + #[serde(default, deserialize_with = "deserialize_legacy_telemetry_enabled")] + telemetry_enabled: Option, +} + +fn telemetry_enabled_from_policy(policy: &FfiPolicyEnvelope) -> Result, String> { + let canonical = policy + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled); + let legacy = policy.telemetry_enabled; + match (canonical, legacy) { + (Some(current), Some(alias)) if current != alias => Err( + "failed to parse policy JSON: telemetry.enabled and telemetryEnabled must agree" + .to_string(), + ), + (Some(current), _) => Ok(Some(current)), + (None, legacy) => Ok(legacy), + } +} + +pub(crate) fn parse_policy_json( + policy_json: &str, +) -> Result<(SandboxPolicy, Option), String> { + let envelope: FfiPolicyEnvelope = serde_json::from_str(policy_json) + .map_err(|error| format!("failed to parse policy JSON: {error}"))?; + let telemetry_enabled = telemetry_enabled_from_policy(&envelope)?; + Ok((envelope.policy, telemetry_enabled)) +} + +pub(crate) fn build_run_request( + policy_json: &str, + command: &str, +) -> Result { + build_ffi_request(policy_json, command) +} + +pub(crate) fn build_spawn_request( + policy_json: &str, + command: &str, +) -> Result { + build_ffi_request(policy_json, command) +} + +fn build_ffi_request( + policy_json: &str, + command: &str, +) -> Result { + let (policy, telemetry_enabled) = parse_policy_json(policy_json).map_err(|error| { + ( + MXC_STATUS_MALFORMED_REQUEST, + MxcErrorDetail::from_message(error), + ) + })?; + let mut request = build_request(&policy, None).map_err(|error| { + ( + status_from_error_code(error.code), + MxcErrorDetail::from_error(&error), + ) + })?; + request.set_script(command); + if let Some(enabled) = telemetry_enabled { + request.set_telemetry_enabled(enabled); + } + Ok(request) +} + // --------------------------------------------------------------------------- // Entry points // --------------------------------------------------------------------------- @@ -267,8 +465,10 @@ pub unsafe extern "C" fn mxc_run( command_utf8: *const c_char, out: *mut MxcRunResult, ) -> i32 { - let result = catch_unwind(|| run_inner(policy_json_utf8, command_utf8)) - .unwrap_or_else(|_| MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); + let result = catch_unwind(|| run_inner(policy_json_utf8, command_utf8)).unwrap_or_else(|p| { + report_panic("mxc_run", &*p); + MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") + }); if out.is_null() { // Nowhere to hand ownership; free anything we allocated to avoid a leak. @@ -303,21 +503,10 @@ fn run_inner(policy_json_utf8: *const c_char, command_utf8: *const c_char) -> Mx None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "command is not UTF-8"), }; - let policy: SandboxPolicy = match serde_json::from_str(policy_json) { - Ok(p) => p, - Err(e) => { - return MxcRunResult::error( - MXC_STATUS_MALFORMED_REQUEST, - format!("failed to parse policy JSON: {e}"), - ) - } - }; - - let mut request = match build_request(&policy, None) { - Ok(r) => r, - Err(e) => return MxcRunResult::from_sdk_error(&e), + let request = match build_run_request(policy_json, command) { + Ok(request) => request, + Err((status, error)) => return MxcRunResult::from_error_detail(status, error), }; - request.set_script(command); match run(request) { Ok(output) => { @@ -385,10 +574,12 @@ pub unsafe extern "C" fn mxc_run_result_free(r: *mut MxcRunResult) { if r.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(p) = catch_unwind(|| { // SAFETY: caller guarantees `r` points to a valid, not-yet-freed result. unsafe { (*r).free_strings() }; - }); + }) { + report_panic("mxc_run_result_free", &*p); + } } /// Free a single heap C string returned by this library. @@ -401,10 +592,12 @@ pub unsafe extern "C" fn mxc_string_free(s: *mut c_char) { if s.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(p) = catch_unwind(|| { let mut p = s; free_cstr(&mut p); - }); + }) { + report_panic("mxc_string_free", &*p); + } } /// Return the library version as a static, NUL-terminated C string. @@ -428,6 +621,331 @@ pub extern "C" fn mxc_version() -> *const c_char { .unwrap_or(c"".as_ptr()) } +// --------------------------------------------------------------------------- +// Telemetry consent +// --------------------------------------------------------------------------- + +/// Read the persisted telemetry consent state. +/// +/// On success, writes one of `"granted"`, `"denied"`, `"undetermined"`, or +/// `"not-applicable"` (non-Windows hosts) into +/// `*out_utf8` as a heap-allocated, NUL-terminated UTF-8 string. The caller +/// must free it with [`mxc_string_free`]. +/// +/// After any non-success return with a non-null `out_utf8`, `*out_utf8` is +/// null and must not be freed. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_utf8` if `out_utf8` is null. +/// +/// # Safety +/// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_consent(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. Clear it + // before any fallible work so every subsequent failure path (including a + // caught panic) leaves the caller with a well-defined null pointer rather + // than a stale/uninitialized value. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + + let result = catch_unwind(|| mxc_sdk::telemetry::get_consent().as_str()); + let state_str = match result { + Ok(s) => s, + Err(p) => { + report_panic("mxc_telemetry_get_consent", &*p); + return MXC_STATUS_PANIC; + } + }; + + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, alloc_cstring(state_str.as_bytes())) }; + MXC_STATUS_SUCCESS +} + +fn consent_status_json( + status: mxc_sdk::telemetry::ConsentStatus, + policy: mxc_sdk::telemetry::PolicyState, +) -> serde_json::Value { + serde_json::json!({ + "storedState": status.stored_state.as_str(), + "effectiveState": status.effective_state.as_str(), + "reason": status.reason.map(|reason| reason.as_str()), + "policy": policy.as_str(), + }) +} + +fn consent_outcome_json(outcome: mxc_sdk::telemetry::ConsentActionOutcome) -> serde_json::Value { + let mut value = consent_status_json(outcome.status, outcome.policy); + value["result"] = serde_json::Value::String(outcome.result.as_str().to_string()); + value +} + +fn consent_prompt_json(prompt: &mxc_sdk::telemetry::ConsentPrompt) -> serde_json::Value { + fn message(value: mxc_sdk::telemetry::ConsentMessage) -> serde_json::Value { + serde_json::json!({ "id": value.id, "text": value.text }) + } + + serde_json::json!({ + "resourceVersion": prompt.resource_version, + "locale": prompt.locale, + "title": message(prompt.title), + "body": message(prompt.body), + "affirmativeLabel": message(prompt.affirmative_label), + "negativeLabel": message(prompt.negative_label), + "learnMoreLabel": message(prompt.learn_more_label), + "learnMoreUrl": prompt.learn_more_url, + }) +} + +unsafe fn write_json_out(value: serde_json::Value, out_utf8: *mut *mut c_char) -> i32 { + let bytes = match serde_json::to_vec(&value) { + Ok(bytes) => bytes, + Err(error) => { + report_to_stderr(format_args!( + "mxc: failed to serialize telemetry consent result: {error}" + )); + return MXC_STATUS_BACKEND_ERROR; + } + }; + // SAFETY: caller guarantees that the non-null out pointer is writable. + unsafe { ptr::write(out_utf8, alloc_cstring(&bytes)) }; + MXC_STATUS_SUCCESS +} + +/// Request telemetry consent through a host presenter callback. +/// +/// On success, `*out_utf8` owns a heap-allocated, NUL-terminated JSON string; +/// the caller must free it with [`mxc_string_free`]. After any non-success +/// return with a non-null `out_utf8`, `*out_utf8` is null and must not be +/// freed. +/// +/// # Safety +/// `locale_utf8` must be null or valid NUL-terminated UTF-8. `presenter` must +/// be a valid callback when non-null. `context` is passed through untouched. +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_request_consent( + locale_utf8: *const c_char, + presenter: MxcTelemetryConsentPresenter, + context: *mut c_void, + out_utf8: *mut *mut c_char, +) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // Every failure path below (including a caught panic) must leave the + // out-pointer in a well-defined null state, not the caller's prior value. + // Do this before any other work. + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + if presenter.is_none() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: caller contract above. + let locale = match unsafe { cstr_to_str(locale_utf8) } { + Some(value) => Some(value.to_string()), + None if locale_utf8.is_null() => None, + None => return MXC_STATUS_INVALID_UTF8, + }; + let presenter = presenter.expect("checked above"); + + let result = catch_unwind(|| { + mxc_sdk::telemetry::request_consent(locale.as_deref(), |prompt| { + let prompt_json = serde_json::to_vec(&consent_prompt_json(prompt)) + .map_err(|error| error.to_string())?; + let prompt_json = CString::new(prompt_json).map_err(|error| error.to_string())?; + // SAFETY: the host supplied this callback and context. The prompt + // pointer remains valid for the duration of this invocation. + let decision = unsafe { presenter(prompt_json.as_ptr(), context) }; + match decision { + MXC_TELEMETRY_CONSENT_DECISION_YES => Ok(mxc_sdk::telemetry::ConsentDecision::Yes), + MXC_TELEMETRY_CONSENT_DECISION_NO => Ok(mxc_sdk::telemetry::ConsentDecision::No), + MXC_TELEMETRY_CONSENT_DECISION_DISMISSED => { + Ok(mxc_sdk::telemetry::ConsentDecision::Dismissed) + } + MXC_TELEMETRY_CONSENT_PRESENTER_ERROR => { + Err("host consent presenter failed".to_string()) + } + value => Err(format!( + "host consent presenter returned invalid decision {value}" + )), + } + }) + }); + + match result { + Ok(Ok(outcome)) => { + // SAFETY: validated above. + unsafe { write_json_out(consent_outcome_json(outcome), out_utf8) } + } + Ok(Err(mxc_sdk::telemetry::ConsentError::Persist(error))) => { + let _ = error; + report_request_consent_persist_failure_once(); + MXC_STATUS_CONSENT_WRITE_FAILED + } + Ok(Err(mxc_sdk::telemetry::ConsentError::Presenter(error))) => { + let _ = error; + report_consent_presenter_failure_once(); + MXC_STATUS_BACKEND_ERROR + } + Err(panic) => { + report_panic("mxc_telemetry_request_consent", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Persist an idempotent telemetry-consent withdrawal. +/// +/// On non-Windows hosts this returns a `"notApplicable"` JSON outcome with +/// [`MXC_STATUS_SUCCESS`]. +/// +/// On success, `*out_utf8` owns a heap-allocated, NUL-terminated JSON string; +/// the caller must free it with [`mxc_string_free`]. After any non-success +/// return with a non-null `out_utf8`, `*out_utf8` is null and must not be +/// freed. +/// +/// # Safety +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_withdraw_consent(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // Every failure path below (including a caught panic) must leave the + // out-pointer in a well-defined null state, not the caller's prior value. + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + match catch_unwind(mxc_sdk::telemetry::withdraw_consent) { + Ok(Ok(outcome)) => { + // SAFETY: validated above. + unsafe { write_json_out(consent_outcome_json(outcome), out_utf8) } + } + Ok(Err(error)) => { + let _ = error; + report_withdraw_consent_persist_failure_once(); + MXC_STATUS_CONSENT_WRITE_FAILED + } + Err(panic) => { + report_panic("mxc_telemetry_withdraw_consent", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Return the typed persisted/effective consent and policy snapshot as JSON. +/// +/// On success, `*out_utf8` owns a heap-allocated, NUL-terminated JSON string; +/// the caller must free it with [`mxc_string_free`]. After any non-success +/// return with a non-null `out_utf8`, `*out_utf8` is null and must not be +/// freed. +/// +/// # Safety +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_consent_status(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // Every failure path below (including a caught panic) must leave the + // out-pointer in a well-defined null state, not the caller's prior value. + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + let result = catch_unwind(|| { + consent_status_json( + mxc_sdk::telemetry::get_consent_status(), + mxc_sdk::telemetry::get_policy(), + ) + }); + match result { + Ok(value) => { + // SAFETY: validated above. + unsafe { write_json_out(value, out_utf8) } + } + Err(panic) => { + report_panic("mxc_telemetry_get_consent_status", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Whether a hosting application should offer its first-run consent prompt. +/// +/// Writes `1` or `0` into `*out_needs_prompt`. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_needs_prompt` if it is null. +/// +/// # Safety +/// `out_needs_prompt` must be null or point to writable `i32`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_needs_consent_prompt(out_needs_prompt: *mut i32) -> i32 { + if out_needs_prompt.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_needs_prompt` is non-null and caller-guaranteed writable. + // Zero it before any fallible work so a caught panic still leaves the + // caller with well-defined, non-garbage memory. + unsafe { ptr::write(out_needs_prompt, 0) }; + + let needs_prompt = match catch_unwind(mxc_sdk::telemetry::needs_consent_prompt) { + Ok(b) => b, + Err(p) => { + report_panic("mxc_telemetry_needs_consent_prompt", &*p); + return MXC_STATUS_PANIC; + } + }; + + // SAFETY: `out_needs_prompt` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_needs_prompt, i32::from(needs_prompt)) }; + MXC_STATUS_SUCCESS +} + +/// Read the administrative (MDM / Group Policy) telemetry policy. +/// +/// On success, writes one of `"unrestricted"` (no policy configured), +/// `"allowed"`, `"blocked"`, or `"not-applicable"` (non-Windows hosts) into +/// `*out_utf8` as a heap-allocated, NUL-terminated UTF-8 string. The caller +/// must free it with [`mxc_string_free`]. +/// +/// After any non-success return with a non-null `out_utf8`, `*out_utf8` is +/// null and must not be freed. +/// +/// `"allowed"` does not grant user consent; `"blocked"` suppresses collection +/// and the consent prompt. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_utf8` if `out_utf8` is null. +/// +/// # Safety +/// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_policy(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. Clear it + // before any fallible work so a caught panic still leaves the caller with + // a well-defined null pointer rather than a stale/uninitialized value. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + + let result = catch_unwind(|| mxc_sdk::telemetry::get_policy().as_str()); + let state_str = match result { + Ok(s) => s, + Err(p) => { + report_panic("mxc_telemetry_get_policy", &*p); + return MXC_STATUS_PANIC; + } + }; + + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, alloc_cstring(state_str.as_bytes())) }; + MXC_STATUS_SUCCESS +} + #[cfg(test)] mod tests { use super::*; @@ -443,6 +961,71 @@ mod tests { out } + #[test] + fn policy_telemetry_switch_accepts_canonical_shape() { + let (policy, telemetry_enabled) = + parse_policy_json(r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#) + .expect("policy should parse"); + + assert_eq!(policy.version, "0.8.0-alpha"); + assert_eq!(telemetry_enabled, Some(true)); + } + + #[test] + fn policy_telemetry_switch_keeps_legacy_alias_for_compatibility() { + let (policy, telemetry_enabled) = + parse_policy_json(r#"{"version":"0.8.0-alpha","telemetryEnabled":true}"#) + .expect("policy should parse"); + + assert_eq!(policy.version, "0.8.0-alpha"); + assert_eq!(telemetry_enabled, Some(true)); + } + + #[test] + fn policy_telemetry_switch_rejects_non_boolean_values() { + let error = parse_policy_json(r#"{"version":"0.8.0-alpha","telemetryEnabled":"yes"}"#) + .expect_err("non-boolean telemetry switch must fail"); + + assert!(error.contains("telemetryEnabled must be a boolean")); + } + + #[test] + fn policy_telemetry_switch_rejects_conflicting_shapes() { + let error = parse_policy_json( + r#"{"version":"0.8.0-alpha","telemetry":{"enabled":false},"telemetryEnabled":true}"#, + ) + .expect_err("conflicting telemetry fields must fail"); + + assert!(error.contains("must agree")); + } + + #[test] + fn build_run_request_propagates_telemetry_enablement() { + for (policy_json, expected) in [ + (r#"{"version":"0.8.0-alpha"}"#, None), + ( + r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#, + Some(true), + ), + ( + r#"{"version":"0.8.0-alpha","telemetry":{"enabled":false}}"#, + Some(false), + ), + ( + r#"{"version":"0.8.0-alpha","telemetryEnabled":true}"#, + Some(true), + ), + ] { + let request = build_run_request(policy_json, "echo hi") + .unwrap_or_else(|_| panic!("build_run_request failed for {policy_json}")); + assert_eq!( + request.telemetry_enabled(), + expected, + "policy: {policy_json}" + ); + } + } + #[test] fn malformed_policy_json_reports_malformed_request() { let mut out = run_with("{ not json", Some("echo hi")); @@ -569,4 +1152,535 @@ mod tests { // SAFETY: `out` was filled by `mxc_run`. unsafe { mxc_run_result_free(&mut out) }; } + + // ----------------------------------------------------------------- + // Telemetry consent + // ----------------------------------------------------------------- + // + // Telemetry overrides are debug-only, so these tests are excluded from + // release builds rather than reading the host's real consent and policy + // state. `CONSENT_ENV_LOCK` and `TelemetryTestEnv`'s policy lock serialize + // the process-global overrides; unrelated `mxc_run` tests remain parallel. + #[cfg(debug_assertions)] + struct TelemetryTestEnv { + _dir: tempfile::TempDir, + _env: wxc_common::telemetry::test_support::TelemetryTestEnv, + } + + #[cfg(debug_assertions)] + impl TelemetryTestEnv { + fn new(_label: &str) -> Self { + let dir = tempfile::tempdir().expect("create temp dir"); + let env = wxc_common::telemetry::test_support::TelemetryTestEnv::new(dir.path()); + Self { + _dir: dir, + _env: env, + } + } + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_reports_string_and_never_errors() { + let _guard = TelemetryTestEnv::new("get_default"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + #[cfg(target_os = "windows")] + assert_eq!(s, "undetermined"); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + // SAFETY: `out` was allocated via `alloc_cstring`/`CString::into_raw`. + unsafe { mxc_string_free(out) }; + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("get_null_out"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_consent(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[cfg(debug_assertions)] + #[test] + fn presenter_request_then_get_consent_round_trips() { + let _guard = TelemetryTestEnv::new("round_trip"); + unsafe extern "C" fn presenter( + prompt_json_utf8: *const c_char, + context: *mut c_void, + ) -> i32 { + // SAFETY: the FFI request provides valid pointers for this call. + let prompt = unsafe { CStr::from_ptr(prompt_json_utf8) } + .to_str() + .unwrap(); + let prompt: serde_json::Value = serde_json::from_str(prompt).unwrap(); + let canonical = wxc_common::telemetry::consent_prompt::prompt_for_locale(Some("en-US")); + assert_eq!(prompt["resourceVersion"], canonical.resource_version); + assert_eq!(prompt["locale"], canonical.locale); + assert_eq!(prompt["title"]["id"], canonical.title.id); + assert_eq!(prompt["title"]["text"], canonical.title.text); + assert_eq!(prompt["body"]["id"], canonical.body.id); + assert_eq!(prompt["body"]["text"], canonical.body.text); + assert_eq!( + prompt["affirmativeLabel"]["text"], + canonical.affirmative_label.text + ); + assert_eq!( + prompt["negativeLabel"]["text"], + canonical.negative_label.text + ); + assert_eq!( + prompt["learnMoreLabel"]["text"], + canonical.learn_more_label.text + ); + assert_eq!(prompt["learnMoreUrl"], canonical.learn_more_url); + // SAFETY: the test passes a pointer to this bool as context. + unsafe { *(context as *mut bool) = true }; + MXC_TELEMETRY_CONSENT_DECISION_YES + } + + let mut called = false; + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback, context, and out pointer remain valid for the call. + let request_status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(presenter), + (&mut called as *mut bool).cast(), + &mut outcome, + ) + }; + assert_eq!(request_status, MXC_STATUS_SUCCESS); + assert!(!outcome.is_null()); + // SAFETY: allocated by the request export. + let outcome_json = unsafe { CStr::from_ptr(outcome) }.to_str().unwrap(); + let outcome_json: serde_json::Value = serde_json::from_str(outcome_json).unwrap(); + unsafe { mxc_string_free(outcome) }; + + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let get_status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(get_status, MXC_STATUS_SUCCESS); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert!(called); + assert_eq!(outcome_json["result"], "granted"); + assert_eq!(s, "granted"); + } + #[cfg(not(target_os = "windows"))] + { + assert!(!called); + assert_eq!(outcome_json["result"], "notApplicable"); + assert_eq!(s, "not-applicable"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn request_consent_requires_a_presenter() { + let _guard = TelemetryTestEnv::new("null_presenter"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: null presenter is explicitly rejected. + let status = + unsafe { mxc_telemetry_request_consent(ptr::null(), None, ptr::null_mut(), &mut out) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + assert!(out.is_null()); + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_tracks_the_store() { + let _guard = TelemetryTestEnv::new("needs_prompt"); + let mut needs: i32 = -1; + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + // Fresh store on Windows must prompt; off Windows nothing is collected + // so nothing may be asked. + #[cfg(target_os = "windows")] + assert_eq!(needs, 1); + #[cfg(not(target_os = "windows"))] + assert_eq!(needs, 0); + + #[cfg(target_os = "windows")] + { + unsafe extern "C" fn deny( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + MXC_TELEMETRY_CONSENT_DECISION_NO + } + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback and out pointer remain valid for the call. + assert_eq!( + unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(deny), + ptr::null_mut(), + &mut outcome, + ) + }, + MXC_STATUS_SUCCESS + ); + // SAFETY: allocated by the request export. + unsafe { mxc_string_free(outcome) }; + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert_eq!(needs, 0, "a recorded denial must not re-prompt"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("needs_prompt_null"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_needs_consent_prompt(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[cfg(debug_assertions)] + #[test] + fn withdraw_consent_reports_success_and_is_idempotent() { + let _guard = TelemetryTestEnv::new("withdraw_idempotent"); + + for _ in 0..2 { + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_withdraw_consent`. + let outcome_json = unsafe { CStr::from_ptr(out) }.to_str().unwrap(); + let outcome_json: serde_json::Value = serde_json::from_str(outcome_json).unwrap(); + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert_eq!(outcome_json["result"], "withdrawn"); + assert_eq!(outcome_json["effectiveState"], "denied"); + assert_eq!(outcome_json["storedState"], "denied"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(outcome_json["result"], "notApplicable"); + assert_eq!(outcome_json["effectiveState"], "not-applicable"); + assert_eq!(outcome_json["storedState"], "not-applicable"); + } + } + } + + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn withdraw_consent_write_failure_maps_to_status_103_with_null_output() { + let dir = tempfile::tempdir().unwrap(); + let bogus_localappdata = dir.path().join("localappdata-file"); + std::fs::write(&bogus_localappdata, b"not a directory").unwrap(); + let _guard = + wxc_common::telemetry::test_support::TelemetryTestEnv::new(&bogus_localappdata); + + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_CONSENT_WRITE_FAILED); + assert!(out.is_null()); + } + + /// Verifies the export marshals a valid state string that the caller can + /// free. Policy semantics are tested in `wxc_common::telemetry::policy`. + #[test] + fn get_policy_returns_a_valid_state_string() { + let s = read_policy_string(); + #[cfg(target_os = "windows")] + assert!( + ["unrestricted", "allowed", "blocked"].contains(&s.as_str()), + "unexpected policy state {s:?}" + ); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + } + + /// Calls the export and returns the marshalled string, freeing the + /// allocation. Shared by the policy tests below. + fn read_policy_string() -> String { + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_policy(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_policy`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + s + } + + /// Drives every administrative policy state through a redirected registry + /// key and asserts the marshaled string. The redirect is debug-only. + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn get_policy_marshals_the_exact_state_for_each_registry_value() { + use wxc_common::telemetry::policy::test_support::PolicyKeyGuard; + + let guard = PolicyKeyGuard::new(); + // No value set: an unmanaged machine. + assert_eq!(read_policy_string(), "unrestricted"); + + guard.set_value(3); + assert_eq!(read_policy_string(), "allowed"); + + for blocked in [0u32, 1, 2, 99, u32::MAX] { + guard.set_value(blocked); + assert_eq!( + read_policy_string(), + "blocked", + "value {blocked} must marshal as blocked" + ); + } + + // A wrong-typed value is a policy we cannot evaluate: it must fail + // closed all the way out through the ABI, not read as unmanaged. + guard.set_string_value("0"); + assert_eq!(read_policy_string(), "blocked"); + } + + #[test] + fn get_policy_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_policy(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + // Failure paths must clear caller-owned output pointers before validation + // or other fallible work. + + /// Return an obvious sentinel `*mut c_char` value that isn't null and + /// isn't a legal allocation from this library, so an accidental free + /// would be caught. We only ever *read* the pointer's value; the exports + /// under test must overwrite it with null before any failure path. + fn stale_out_sentinel() -> *mut c_char { + // Use an aligned, obviously-invalid address. Never dereferenced. + 0xDEAD_BEEF_usize as *mut c_char + } + + #[test] + fn request_consent_null_presenter_clears_stale_out_pointer() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable; + // null presenter is explicitly rejected. + let status = + unsafe { mxc_telemetry_request_consent(ptr::null(), None, ptr::null_mut(), &mut out) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + assert!( + out.is_null(), + "failure path must leave *out_utf8 null, not the caller's sentinel" + ); + } + + #[test] + fn request_consent_invalid_utf8_locale_clears_stale_out_pointer() { + // A presenter that would panic if invoked. The invalid-UTF-8 locale + // must be rejected before we ever reach the presenter, so this + // proves the failure path both returns the right status *and* null + // the out-pointer. + unsafe extern "C" fn never_called_presenter( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + panic!("presenter must not be invoked when locale rejection precedes it"); + } + + // A raw non-UTF-8 byte sequence with a valid NUL terminator. + let bad_locale = [0xffu8, 0xfe, 0xfd, 0x00]; + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `bad_locale` is a NUL-terminated byte string; `out` is a + // valid writable pointer to a local variable. + let status = unsafe { + mxc_telemetry_request_consent( + bad_locale.as_ptr() as *const c_char, + Some(never_called_presenter), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_INVALID_UTF8); + assert!( + out.is_null(), + "invalid-UTF-8 failure path must leave *out_utf8 null" + ); + } + + #[test] + fn withdraw_consent_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_withdraw_consent(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[test] + fn get_consent_status_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_consent_status(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + // The read-only exports also overwrite stale output pointers on success. + + #[test] + fn get_consent_overwrites_stale_out_pointer_on_success() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null(), "success path must produce a real string"); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + unsafe { mxc_string_free(out) }; + } + + #[test] + fn get_policy_overwrites_stale_out_pointer_on_success() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_policy(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null(), "success path must produce a real string"); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_policy`. + unsafe { mxc_string_free(out) }; + } + + // Use a synthetic store so the snapshot is deterministic on Windows. + #[cfg(debug_assertions)] + #[test] + fn get_consent_status_returns_a_valid_json_snapshot() { + let _guard = TelemetryTestEnv::new("get_status"); + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent_status(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent_status`. + let json = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + // SAFETY: allocated by the export above. + unsafe { mxc_string_free(out) }; + + // The shape is fixed; the values depend on the platform. + assert!(json.get("storedState").is_some()); + assert!(json.get("effectiveState").is_some()); + assert!(json.get("policy").is_some()); + // `reason` is `Option<..>` and may be JSON null. + assert!(json.get("reason").is_some()); + + #[cfg(target_os = "windows")] + { + // A fresh synthetic env has no persisted record. + assert_eq!(json["storedState"], "undetermined"); + assert_eq!(json["effectiveState"], "undetermined"); + assert_eq!(json["reason"], "no-record"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(json["storedState"], "not-applicable"); + assert_eq!(json["effectiveState"], "not-applicable"); + assert_eq!(json["reason"], "not-applicable"); + assert_eq!(json["policy"], "not-applicable"); + } + } + + // Presenter failures are mapped to a backend error without persisting. + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn request_consent_presenter_error_maps_to_backend_error() { + let _guard = TelemetryTestEnv::new("presenter_error"); + unsafe extern "C" fn broken_presenter( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + MXC_TELEMETRY_CONSENT_PRESENTER_ERROR + } + + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: callback and out pointer remain valid for the call. + let status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(broken_presenter), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_BACKEND_ERROR); + assert!( + out.is_null(), + "presenter-error failure path must leave *out_utf8 null" + ); + } + + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn request_consent_invalid_decision_maps_to_backend_error() { + let _guard = TelemetryTestEnv::new("invalid_decision"); + unsafe extern "C" fn bad_decision( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + // Neither of the four defined return values. + 42 + } + + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: callback and out pointer remain valid for the call. + let status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(bad_decision), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_BACKEND_ERROR); + assert!( + out.is_null(), + "invalid-decision failure path must leave *out_utf8 null" + ); + } + + /// Verify the withdrawal export returns its typed JSON outcome. + #[cfg(debug_assertions)] + #[test] + fn withdraw_consent_success_returns_typed_outcome() { + let _guard = TelemetryTestEnv::new("withdraw_success"); + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_withdraw_consent`. + let json = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + // SAFETY: allocated by the export above. + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert_eq!(json["result"], "withdrawn"); + assert_eq!(json["effectiveState"], "denied"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(json["result"], "notApplicable"); + assert_eq!(json["effectiveState"], "not-applicable"); + } + } } diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index 24293bae1..e56df1794 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -117,7 +117,10 @@ pub unsafe extern "C" fn mxc_state_aware( let result = catch_unwind(AssertUnwindSafe(|| { state_aware_inner(request_json_utf8, dry_run != 0, experimental != 0) })) - .unwrap_or_else(|_| MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); + .unwrap_or_else(|panic| { + crate::report_panic("mxc_state_aware", &*panic); + MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") + }); let status = result.status; // SAFETY: `out` is non-null and caller-guaranteed writable; ownership of the @@ -166,10 +169,12 @@ pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult if r.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: caller guarantees `r` points to a valid, not-yet-freed result. unsafe { (*r).free_strings() }; - })); + })) { + crate::report_panic("mxc_state_aware_result_free", &*panic); + } } /// Run the `exec` phase of a state-aware request as a **live streaming** process. @@ -244,7 +249,8 @@ pub unsafe extern "C" fn mxc_state_aware_exec( ) }) })) - .unwrap_or_else(|_| { + .unwrap_or_else(|panic| { + crate::report_panic("mxc_state_aware_exec", &*panic); Err(( MXC_STATUS_PANIC, MxcErrorDetail::from_message("the mxc engine panicked"), diff --git a/src/ffi/mxc_ffi/src/streaming.rs b/src/ffi/mxc_ffi/src/streaming.rs index 723e38db4..423846f00 100644 --- a/src/ffi/mxc_ffi/src/streaming.rs +++ b/src/ffi/mxc_ffi/src/streaming.rs @@ -54,12 +54,12 @@ use std::io::{Read, Write}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -use mxc_sdk::{build_request, spawn_sandbox, Sandbox, SandboxPolicy, WaitOutcome}; +use mxc_sdk::{spawn_sandbox, Sandbox, WaitOutcome}; use crate::{ - alloc_cstring, cstr_to_str, status_from_error_code, MxcErrorDetail, MXC_STATUS_BACKEND_ERROR, - MXC_STATUS_INVALID_UTF8, MXC_STATUS_MALFORMED_REQUEST, MXC_STATUS_NULL_ARGUMENT, - MXC_STATUS_PANIC, MXC_STATUS_SUCCESS, + alloc_cstring, build_spawn_request, cstr_to_str, status_from_error_code, MxcErrorDetail, + MXC_STATUS_BACKEND_ERROR, MXC_STATUS_INVALID_UTF8, MXC_STATUS_NULL_ARGUMENT, MXC_STATUS_PANIC, + MXC_STATUS_SUCCESS, }; // --------------------------------------------------------------------------- @@ -154,7 +154,8 @@ pub unsafe extern "C" fn mxc_spawn( let outcome = catch_unwind(AssertUnwindSafe(|| { spawn_inner(policy_json_utf8, command_utf8) })) - .unwrap_or_else(|_| { + .unwrap_or_else(|panic| { + crate::report_panic("mxc_spawn", &*panic); Err(( MXC_STATUS_PANIC, MxcErrorDetail::from_message("the mxc engine panicked"), @@ -239,15 +240,7 @@ fn spawn_inner( } }; - let policy: SandboxPolicy = serde_json::from_str(policy_json).map_err(|e| { - ( - MXC_STATUS_MALFORMED_REQUEST, - MxcErrorDetail::from_message(format!("failed to parse policy JSON: {e}")), - ) - })?; - - let mut request = build_request(&policy, None).map_err(sdk_error_detail)?; - request.set_script(command); + let request = build_spawn_request(policy_json, command)?; spawn_sandbox(request).map_err(sdk_error_detail) } @@ -311,7 +304,12 @@ fn take_stream( let sandbox = unsafe { &mut *handle }; take(sandbox).map(|s| Box::into_raw(Box::new(s))) })); - result.unwrap_or(None).unwrap_or(ptr::null_mut()) + result + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_take_stdin", &*panic); + None + }) + .unwrap_or(ptr::null_mut()) } fn take_read_stream( @@ -326,7 +324,12 @@ fn take_read_stream( let sandbox = unsafe { &mut *handle }; take(sandbox).map(|inner| Box::into_raw(Box::new(MxcReadStream { inner }))) })); - result.unwrap_or(None).unwrap_or(ptr::null_mut()) + result + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_take_stdout_or_stderr", &*panic); + None + }) + .unwrap_or(ptr::null_mut()) } // --------------------------------------------------------------------------- @@ -354,6 +357,10 @@ pub unsafe extern "C" fn mxc_stream_read( if stream.is_null() || buf.is_null() || out_read.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: `out_read` is non-null and caller-guaranteed writable. Zero it + // before any fallible work so a caught panic or I/O error never leaves the + // caller's prior value behind. + unsafe { *out_read = 0 }; let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: `stream` non-null live handle; `buf`/`cap` describe a valid // writable region per the caller contract. @@ -368,7 +375,10 @@ pub unsafe extern "C" fn mxc_stream_read( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_stream_read", &*panic); + MXC_STATUS_PANIC + }) } /// Write up to `len` bytes from `buf` to `stream`, writing the number of bytes @@ -391,6 +401,10 @@ pub unsafe extern "C" fn mxc_stream_write( if stream.is_null() || buf.is_null() || out_written.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: `out_written` is non-null and caller-guaranteed writable. Zero + // it before any fallible work so a caught panic or I/O error never leaves + // the caller's prior value behind. + unsafe { *out_written = 0 }; let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: `stream` non-null live handle; `buf`/`len` describe a valid // readable region per the caller contract. @@ -405,7 +419,10 @@ pub unsafe extern "C" fn mxc_stream_write( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_stream_write", &*panic); + MXC_STATUS_PANIC + }) } /// Flush any buffered bytes on a stdin stream. @@ -425,7 +442,10 @@ pub unsafe extern "C" fn mxc_stream_flush(stream: *mut MxcWriteStream) -> i32 { Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_stream_flush", &*panic); + MXC_STATUS_PANIC + }) } // --------------------------------------------------------------------------- @@ -446,7 +466,10 @@ pub unsafe extern "C" fn mxc_sandbox_id(handle: *mut MxcSandbox) -> u32 { let sandbox = unsafe { &*handle }; sandbox.inner.id() })) - .unwrap_or(0) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_id", &*panic); + 0 + }) } /// Return structured output metadata as owned JSON. A successful call leaves @@ -486,12 +509,15 @@ pub unsafe extern "C" fn mxc_sandbox_output_metadata_json( unsafe { *out_json_utf8 = alloc_cstring(&json) }; MXC_STATUS_SUCCESS })) - .unwrap_or(MXC_STATUS_PANIC) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_output_metadata_json", &*panic); + MXC_STATUS_PANIC + }) } /// Non-blocking exit check. On return, `*out_running` is `1` if the child is -/// still running (and `*out_exit` is untouched) or `0` if it has exited (and -/// `*out_exit` holds its exit code). +/// still running or `0` if it has exited; `*out_exit` is defined only when +/// `*out_running == 0`. /// /// Returns [`MXC_STATUS_SUCCESS`], [`MXC_STATUS_NULL_ARGUMENT`] if any pointer /// is null, or [`MXC_STATUS_BACKEND_ERROR`] on a wait error. @@ -508,6 +534,13 @@ pub unsafe extern "C" fn mxc_sandbox_try_wait( if handle.is_null() || out_exit.is_null() || out_running.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: out-params are non-null and caller-guaranteed writable. Zero + // them before any fallible work so a caught panic or backend error never + // leaks the caller's previous values back out. + unsafe { + *out_exit = 0; + *out_running = 0; + } let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null live handle per the caller contract. let sandbox = unsafe { &mut *handle }; @@ -528,7 +561,10 @@ pub unsafe extern "C" fn mxc_sandbox_try_wait( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_try_wait", &*panic); + MXC_STATUS_PANIC + }) } /// Block until the child exits (honouring the request's `scriptTimeout`), @@ -551,6 +587,13 @@ pub unsafe extern "C" fn mxc_sandbox_wait( if handle.is_null() || out_exit.is_null() || out_timed_out.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: out-params are non-null and caller-guaranteed writable. Zero + // them before any fallible work so a caught panic or backend error never + // leaks the caller's previous values back out. + unsafe { + *out_exit = 0; + *out_timed_out = 0; + } let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null live handle per the caller contract. let sandbox = unsafe { &mut *handle }; @@ -574,7 +617,10 @@ pub unsafe extern "C" fn mxc_sandbox_wait( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_wait", &*panic); + MXC_STATUS_PANIC + }) } /// Kill the child and its whole process tree. Reaping happens in a subsequent @@ -595,7 +641,10 @@ pub unsafe extern "C" fn mxc_sandbox_kill(handle: *mut MxcSandbox) -> i32 { Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_kill", &*panic); + MXC_STATUS_PANIC + }) } // --------------------------------------------------------------------------- @@ -613,11 +662,13 @@ pub unsafe extern "C" fn mxc_sandbox_free(handle: *mut MxcSandbox) { if handle.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null handle produced by `Box::into_raw` in `mxc_spawn`, // not yet freed; reconstructing the Box drops it (and its child). drop(unsafe { Box::from_raw(handle) }); - })); + })) { + crate::report_panic("mxc_sandbox_free", &*panic); + } } /// Free a readable stream handle. Safe to call with null (no-op). Must be @@ -631,10 +682,12 @@ pub unsafe extern "C" fn mxc_read_stream_free(stream: *mut MxcReadStream) { if stream.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null handle produced by `Box::into_raw`, not yet freed. drop(unsafe { Box::from_raw(stream) }); - })); + })) { + crate::report_panic("mxc_read_stream_free", &*panic); + } } /// Free a writable (stdin) stream handle, closing stdin and sending EOF to the @@ -648,10 +701,12 @@ pub unsafe extern "C" fn mxc_write_stream_free(stream: *mut MxcWriteStream) { if stream.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null handle produced by `Box::into_raw`, not yet freed. drop(unsafe { Box::from_raw(stream) }); - })); + })) { + crate::report_panic("mxc_write_stream_free", &*panic); + } } #[cfg(test)] @@ -659,6 +714,40 @@ mod tests { use super::*; use std::ffi::CString; + use crate::MXC_STATUS_MALFORMED_REQUEST; + + struct PanicReader; + + impl Read for PanicReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + panic!("forced panic in test reader"); + } + } + + struct PanicWriter; + + impl Write for PanicWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + panic!("forced panic in test writer"); + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + struct PanicFlushWriter; + + impl Write for PanicFlushWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Ok(0) + } + + fn flush(&mut self) -> std::io::Result<()> { + panic!("forced panic in test flush"); + } + } + #[test] fn spawn_null_out_handle_is_null_argument() { let policy = CString::new(r#"{"version":"0.7.0-alpha"}"#).unwrap(); @@ -675,6 +764,33 @@ mod tests { assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } + #[test] + fn build_spawn_request_propagates_telemetry_enablement() { + for (policy_json, expected) in [ + (r#"{"version":"0.8.0-alpha"}"#, None), + ( + r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#, + Some(true), + ), + ( + r#"{"version":"0.8.0-alpha","telemetry":{"enabled":false}}"#, + Some(false), + ), + ( + r#"{"version":"0.8.0-alpha","telemetryEnabled":false}"#, + Some(false), + ), + ] { + let request = build_spawn_request(policy_json, "echo hi") + .unwrap_or_else(|_| panic!("build_spawn_request failed for {policy_json}")); + assert_eq!( + request.telemetry_enabled(), + expected, + "policy: {policy_json}" + ); + } + } + #[test] fn spawn_null_policy_reports_null_argument() { let command = CString::new("echo hi").unwrap(); @@ -779,6 +895,44 @@ mod tests { } } + #[test] + fn stream_read_panic_zeroes_out_read_before_returning_panic() { + let mut stream = MxcReadStream { + inner: Box::new(PanicReader), + }; + let mut buf = [0u8; 8]; + let mut out_read = usize::MAX; + // SAFETY: `stream`, `buf`, and `out_read` are valid for this call. + let status = + unsafe { mxc_stream_read(&mut stream, buf.as_mut_ptr(), buf.len(), &mut out_read) }; + assert_eq!(status, MXC_STATUS_PANIC); + assert_eq!(out_read, 0); + } + + #[test] + fn stream_write_panic_zeroes_out_written_before_returning_panic() { + let mut stream = MxcWriteStream { + inner: Box::new(PanicWriter), + }; + let buf = *b"panic"; + let mut out_written = usize::MAX; + // SAFETY: `stream`, `buf`, and `out_written` are valid for this call. + let status = + unsafe { mxc_stream_write(&mut stream, buf.as_ptr(), buf.len(), &mut out_written) }; + assert_eq!(status, MXC_STATUS_PANIC); + assert_eq!(out_written, 0); + } + + #[test] + fn stream_flush_panic_returns_panic_status() { + let mut stream = MxcWriteStream { + inner: Box::new(PanicFlushWriter), + }; + // SAFETY: `stream` is a valid live stream handle. + let status = unsafe { mxc_stream_flush(&mut stream) }; + assert_eq!(status, MXC_STATUS_PANIC); + } + /// Full streaming round-trip against a real sandbox: spawn `echo`, drain /// stdout to EOF, and wait for a clean exit. Ignored by default because it /// requires a host able to launch a sandboxed process (host-prepped Windows @@ -917,12 +1071,13 @@ mod tests { assert_eq!(status, MXC_STATUS_SUCCESS, "spawn failed (status {status})"); // Child should still be running. - let mut exit = 0; - let mut running = 0; + let mut exit = i32::MIN; + let mut running = -1; // SAFETY: live handle + out pointers. let rc = unsafe { mxc_sandbox_try_wait(handle, &mut exit, &mut running) }; assert_eq!(rc, MXC_STATUS_SUCCESS); assert_eq!(running, 1, "blocked child should still be running"); + assert_eq!(exit, 0, "running poll should leave the zero sentinel"); // SAFETY: live handle. let rc = unsafe { mxc_sandbox_kill(handle) }; From cbc42ea6963ecf5def65c5b8fd607747cc64ee37 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Mon, 31 Aug 2026 16:17:50 -0700 Subject: [PATCH 13/47] Fix dependency and API parity CI gates Restore the approved locked crate versions while recording the new local dependency edges, and expose Hyperlight through the C# platform-discovery surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- .../MxcSandboxTests.cs | 1 + sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs | 1 + .../Microsoft.Mxc.Sdk/PlatformDiscovery.cs | 3 + src/Cargo.lock | 762 +++++++++++------- 4 files changed, 468 insertions(+), 299 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 00e30f9b1..91864d802 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -187,6 +187,7 @@ public void GetPlatformSupport_ReturnsTypedNativeProbe() [InlineData("seatbelt", ContainmentBackend.Seatbelt)] [InlineData("isolation_session", ContainmentBackend.IsolationSession)] [InlineData("bubblewrap", ContainmentBackend.Bubblewrap)] + [InlineData("hyperlight", ContainmentBackend.Hyperlight)] public void Discovery_MapsEveryNativeBackend( string wireName, ContainmentBackend expected) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs index eaa064026..12810c042 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs @@ -347,6 +347,7 @@ internal static ContainmentBackend ParseBackend(string value) => "seatbelt" => ContainmentBackend.Seatbelt, "isolation_session" => ContainmentBackend.IsolationSession, "bubblewrap" => ContainmentBackend.Bubblewrap, + "hyperlight" => ContainmentBackend.Hyperlight, _ => ContainmentBackend.Unknown, }; diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/PlatformDiscovery.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/PlatformDiscovery.cs index b2b9744e7..da2e9cc3f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/PlatformDiscovery.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/PlatformDiscovery.cs @@ -31,6 +31,9 @@ public enum ContainmentBackend /// Linux Bubblewrap. Bubblewrap, + + /// Hyperlight micro-VM. + Hyperlight, } /// The effective Windows ProcessContainer isolation tier. diff --git a/src/Cargo.lock b/src/Cargo.lock index a8d8d31b8..104f07376 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -10,18 +10,18 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "appcontainer_common" @@ -112,11 +112,17 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" -version = "0.7.8" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "atomic-waker" @@ -136,12 +142,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bitflags" version = "1.3.2" @@ -150,21 +150,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake3" -version = "1.8.7" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ + "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", ] [[package]] @@ -187,12 +188,12 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", - "serde_core", + "serde", ] [[package]] @@ -228,35 +229,35 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.25.2" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.12.0" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] name = "bytes" -version = "1.12.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.4.4" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -272,18 +273,18 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "rand_core", ] @@ -306,9 +307,9 @@ checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" [[package]] name = "clap" -version = "4.6.6" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -316,9 +317,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -328,14 +329,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -400,36 +401,36 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" @@ -457,7 +458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "950f59b281d7e20f050b4efd56d7c36c0deb853bf9ea1f20b985a75ae5b03b34" dependencies = [ "regex", - "syn 2.0.119", + "syn", ] [[package]] @@ -481,7 +482,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn", ] [[package]] @@ -492,7 +493,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -503,7 +504,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -524,7 +525,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -534,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.119", + "syn", ] [[package]] @@ -581,13 +582,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -620,9 +621,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" @@ -636,9 +637,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -652,19 +653,18 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "rustc_version", ] [[package]] name = "flate2" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -673,6 +673,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -684,30 +690,30 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-task", @@ -738,14 +744,28 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", "rand_core", + "wasip2", + "wasip3", ] [[package]] @@ -756,7 +776,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -765,7 +785,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "libc", "libgit2-sys", "log", @@ -773,9 +793,9 @@ dependencies = [ [[package]] name = "globset" -version = "0.4.20" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", @@ -795,6 +815,15 @@ dependencies = [ "scroll", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -815,9 +844,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.5.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -825,9 +854,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", @@ -835,9 +864,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", @@ -860,18 +889,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -909,7 +938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "436dac5cc08da3db27de38b174a9b22498ab6443aaffd83b97f66a5d2f1d74cc" dependencies = [ "anyhow", - "bitflags 2.13.1", + "bitflags 2.13.0", "bytemuck", "bytes", "fixedbitset", @@ -929,7 +958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef3edc7875e70a3b5cc291a17f78cfc352e24c9a665a5a1bbdbc46ada3df43b5" dependencies = [ "anyhow", - "bitflags 2.13.1", + "bitflags 2.13.0", "blake3", "built", "bytemuck", @@ -975,7 +1004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5252ab6a1e32dbe44cccc7fd7e73f4c891f9ce44f312c42261984a3289655d2a" dependencies = [ "anyhow", - "base64 0.22.1", + "base64", "clap", "flate2", "hyperlight-host", @@ -1023,9 +1052,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -1037,9 +1066,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1050,9 +1079,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1064,17 +1093,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1085,15 +1113,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1104,6 +1132,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1133,12 +1167,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1162,7 +1198,7 @@ dependencies = [ name = "isolation_session_common" version = "0.8.0" dependencies = [ - "base64 0.22.1", + "base64", "isolation_session_bindings", "serde", "serde_json", @@ -1181,19 +1217,19 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", @@ -1230,7 +1266,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ac372c120eb893b086d1a12027669cf2b478d1f71204021ffa7adf57948d63" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "kvm-bindings", "libc", "vmm-sys-util", @@ -1268,17 +1304,23 @@ dependencies = [ "wxc_common", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" -version = "0.18.8+1.9.7" +version = "0.18.5+1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" dependencies = [ "cc", "libc", @@ -1298,9 +1340,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.21" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -1325,9 +1367,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1340,9 +1382,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lxc" @@ -1375,9 +1417,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -1398,27 +1440,11 @@ dependencies = [ "rapidhash", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" -version = "0.9.1" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", @@ -1426,9 +1452,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1618,7 +1644,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -1652,7 +1678,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1737,9 +1763,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -1768,24 +1794,34 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.15.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1808,13 +1844,19 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1823,12 +1865,12 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core", ] @@ -1840,9 +1882,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rapidhash" -version = "4.5.1" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" dependencies = [ "rustversion", ] @@ -1853,7 +1895,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", ] [[package]] @@ -1869,9 +1911,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.1" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -1881,9 +1923,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1912,9 +1954,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.12.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -1923,27 +1965,26 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.12.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ - "mime_guess", "proc-macro2", "quote", "rust-embed-utils", "shellexpand", - "syn 2.0.119", + "syn", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.12.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", - "sha2 0.11.0", + "sha2 0.10.9", "walkdir", ] @@ -1962,7 +2003,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -1971,9 +2012,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -1986,18 +2027,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2006,9 +2047,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "same-file" @@ -2047,7 +2088,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.119", + "syn", ] [[package]] @@ -2067,13 +2108,13 @@ dependencies = [ [[package]] name = "scroll_derive" -version = "0.13.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -2092,9 +2133,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -2102,22 +2143,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -2128,14 +2169,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2173,7 +2214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "digest 0.11.3", ] @@ -2204,9 +2245,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -2232,9 +2273,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2242,9 +2283,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.12.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" +checksum = "bd5231412d905519dca6a5deb0327d407be68d6c941feec004533401d3a0a715" dependencies = [ "lock_api", ] @@ -2276,7 +2317,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2287,20 +2328,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.4" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2315,7 +2345,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2336,7 +2366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2353,29 +2383,29 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2383,9 +2413,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2393,20 +2423,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -2444,7 +2474,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2469,12 +2499,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-general-category" version = "1.1.0" @@ -2514,11 +2538,11 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64 0.23.1", + "base64", "flate2", "log", "percent-encoding", @@ -2531,11 +2555,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64 0.23.1", + "base64", "http", "httparse", "log", @@ -2573,11 +2597,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -2635,11 +2659,29 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -2650,9 +2692,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2660,31 +2702,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -2779,7 +2855,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2790,7 +2866,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2981,18 +3057,112 @@ dependencies = [ "version_check", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wslc_common" version = "0.8.0" dependencies = [ "anyhow", - "base64 0.22.1", + "base64", "libloading", "serde", "serde_json", @@ -3030,7 +3200,7 @@ dependencies = [ name = "wxc_common" version = "0.8.0" dependencies = [ - "base64 0.22.1", + "base64", "cidr", "filetime", "getrandom 0.2.17", @@ -3060,7 +3230,7 @@ dependencies = [ name = "wxc_e2e_tests" version = "0.8.0" dependencies = [ - "base64 0.22.1", + "base64", "bwrap_common", "serde", "serde_json", @@ -3194,28 +3364,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -3235,7 +3405,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] @@ -3247,9 +3417,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3258,9 +3428,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3269,13 +3439,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -3295,17 +3465,11 @@ dependencies = [ "zopfli", ] -[[package]] -name = "zlib-rs" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" - [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zopfli" From 944c69860ff29dd786d11ab5e06d788bc1477796 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Mon, 31 Aug 2026 16:49:59 -0700 Subject: [PATCH 14/47] Preserve telemetry sandbox attribution Retain caller-requested containment intent across Rust SDK and executor telemetry, including error and out-of-band paths, and align the public telemetry documentation with the emitted contract and authorization gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- docs/schema.md | 3 +- docs/telemetry/telemetry.md | 7 +- src/core/lxc/src/main.rs | 12 +- src/core/mxc_darwin/src/main.rs | 9 +- src/core/mxc_engine/src/policy.rs | 49 ++++++- src/core/wxc/src/main.rs | 34 +++-- src/core/wxc_common/src/telemetry/events.rs | 19 ++- src/core/wxc_common/src/telemetry/mod.rs | 155 +++++++++++++++++--- 8 files changed, 245 insertions(+), 43 deletions(-) diff --git a/docs/schema.md b/docs/schema.md index 9433ccba3..1b9b909ca 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -200,7 +200,8 @@ cannot mix both formats in one request. }, "telemetry": { // Telemetry (Windows only) - "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate + "enabled": true // Request emission for this run; MXC-owned user consent + // and a permitting administrative policy are also required }, "experimental": { // Experimental features (requires --experimental) diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index efcdbe19d..e3221282e 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -117,15 +117,16 @@ Emitted on execution errors. | Field | Type | Description | |-------|------|-------------| +| `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) | | `mxc.backend` | string | Containment backend name | | `mxc.error_type` | string | Error category (`config_error`, `policy_error`, `process_error`, `timeout`, `init_error`, `internal_error`, `cancelled`, `unknown`) | | `mxc.exit_code` | int32 | Process exit code | | `mxc.phase` | string | State-aware lifecycle phase; empty for one-shot executions | > **No free-form error text is emitted.** Error messages can contain paths, -> usernames, or credentials, so `MXC.Error` deliberately carries only the -> bounded `error_type` category and the numeric `exit_code` — never the -> message string itself. +> usernames, or credentials, so `MXC.Error` deliberately carries only bounded +> attribution fields, the `error_type` category, and the numeric `exit_code` — +> never the message string itself. ### Crash telemetry (panic hook) diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 9f43d5e1c..eb0a610a8 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -354,12 +354,16 @@ fn main() { .as_ref() .map(|c| telemetry::init(c, &mut logger)) .unwrap_or(false); + let requested_sandbox_kind = request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still // prints. The hook body is panic-free and emits no message text. if telemetry_active { - telemetry::set_process_context(&request.containment); + telemetry::set_process_context_with_kind(&request.containment, requested_sandbox_kind); telemetry::install_panic_hook(); } @@ -379,9 +383,10 @@ fn main() { Err(e) => { eprintln!("error: {}", e.message); eprint!("{}", logger.get_buffer()); - telemetry::emit_early_exit( + telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, telemetry::FailureReason::InitError, ); process::exit(1); @@ -397,9 +402,10 @@ fn main() { display_script_results(&response, &mut logger); // ── Telemetry emit ────────────────────────────────────────────── - telemetry::emit_completion( + telemetry::emit_completion_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, &response, run_elapsed, ); diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index b18a19043..be4d20494 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -261,12 +261,16 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { .as_ref() .map(|c| telemetry::init(c, logger)) .unwrap_or(false); + let requested_sandbox_kind = request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still // prints. The hook body is panic-free and emits no message text. if telemetry_active { - telemetry::set_process_context(&request.containment); + telemetry::set_process_context_with_kind(&request.containment, requested_sandbox_kind); telemetry::install_panic_hook(); } @@ -292,9 +296,10 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { display_script_results(&response, logger); // ── Telemetry emit ────────────────────────────────────────────── - telemetry::emit_completion( + telemetry::emit_completion_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, &response, run_elapsed, ); diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 3868930ca..208736225 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -486,6 +486,17 @@ pub enum Containment { IsolationSession, } +impl Containment { + fn telemetry_kind(&self) -> &'static str { + match self { + Self::Process => "process", + Self::ProcessContainer(_) => "processcontainer", + Self::Wslc(_) => "wslc", + Self::IsolationSession => "isolation_session", + } + } +} + /// WSL Container settings carried by [`Containment::Wslc`]. /// /// [`Default`] matches the native backend's defaults: the `alpine:latest` @@ -606,6 +617,7 @@ pub struct SandboxRequest { /// The internal execution model. `pub(crate)` so the SDK's own modules and /// unit tests can map/inspect it, while it stays out of the public API. pub(crate) inner: ExecutionRequest, + requested_sandbox_kind: &'static str, } impl SandboxRequest { @@ -694,7 +706,7 @@ impl SandboxRequest { pub fn set_telemetry_enabled(&mut self, enabled: bool) -> &mut Self { self.inner.telemetry = Some(TelemetryConfig { enabled: Some(enabled), - ..Default::default() + requested_sandbox_kind: Some(self.requested_sandbox_kind), }); self } @@ -763,7 +775,10 @@ pub fn build_request_with_containment( // `script_code` before running), so tolerate a missing command. let inner = wxc_common::config_parser::load_request_from_value(config, &mut logger, true) .map_err(|e| MxcError::malformed_request(format!("failed to build request: {e}")))?; - Ok(SandboxRequest { inner }) + Ok(SandboxRequest { + inner, + requested_sandbox_kind: containment.telemetry_kind(), + }) } /// Construct the wire-format `ContainerConfig` JSON value for the supported @@ -1629,6 +1644,14 @@ mod tests { .and_then(|telemetry| telemetry.enabled), Some(true) ); + assert_eq!( + request + .inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.requested_sandbox_kind), + Some("process") + ); assert!( !request.inner.experimental_enabled, "stable telemetry enablement must not opt into experimental features" @@ -1646,6 +1669,28 @@ mod tests { assert!(!request.inner.experimental_enabled); } + #[test] + fn telemetry_enablement_preserves_explicit_containment_intent() { + let containment = ProcessContainer::default(); + let mut request = build_request_with_containment( + &minimal_policy(), + &Containment::ProcessContainer(containment), + None, + ) + .expect("build_request_with_containment"); + + request.set_telemetry_enabled(true); + + assert_eq!( + request + .inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.requested_sandbox_kind), + Some("processcontainer") + ); + } + #[test] fn wslc_rejects_an_invalid_port_mapping() { // Validation is the shared parser's, so a bad mapping is rejected at diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index cbe54c36e..9d3f351f2 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -371,6 +371,11 @@ fn run_state_aware_main(parsed: ParsedStateAwareRequest, dry_run: bool, logger: .as_ref() .map(|c| telemetry::init(c, logger)) .unwrap_or(false); + let requested_sandbox_kind = parsed + .request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); // This phase's Microsoft Correlation Vector (MS-CV), purely internal to // MXC — no caller supplies or relays one. `provision` seeds a fresh @@ -391,7 +396,7 @@ fn run_state_aware_main(parsed: ParsedStateAwareRequest, dry_run: bool, logger: // backtrace still prints) and is panic-free. if telemetry_active { if let Some(containment) = resolved_backend.as_ref() { - telemetry::set_process_context(containment); + telemetry::set_process_context_with_kind(containment, requested_sandbox_kind); } telemetry::set_process_phase(phase); // Stash this phase's correlation vector so out-of-band events @@ -426,8 +431,9 @@ fn run_state_aware_main(parsed: ParsedStateAwareRequest, dry_run: bool, logger: // Emit lifecycle telemetry (and shut the provider down) before flushing the // diagnostic buffer / envelope. Terminal path — safe to shutdown here. - telemetry::emit_state_aware( + telemetry::emit_state_aware_with_kind( telemetry_active, + requested_sandbox_kind, telemetry::TelemetryContext { backend, phase, @@ -1110,13 +1116,17 @@ fn main() { .as_ref() .map(|c| telemetry::init(c, &mut logger)) .unwrap_or(false); + let requested_sandbox_kind = request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); // Install a crash-telemetry panic hook once telemetry is active, chaining // the previously-installed hook so the default stderr backtrace still // prints (also satisfying the "always emit a diagnostic" contract for the // panic case). The hook body is panic-free and emits no message text. if telemetry_active { - telemetry::set_process_context(&request.containment); + telemetry::set_process_context_with_kind(&request.containment, requested_sandbox_kind); telemetry::install_panic_hook(); } @@ -1130,9 +1140,10 @@ fn main() { Err(e) => { eprintln!("Request error\ninvalid CLI command override: {e}"); eprint!("{}", logger.get_buffer()); - telemetry::emit_early_exit( + telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, telemetry::FailureReason::ConfigError, ); process::exit(1); @@ -1157,9 +1168,10 @@ fn main() { if cli.audit { if let Err(message) = validate_audit_request(&request) { eprintln!("Error: {message}"); - telemetry::emit_early_exit( + telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, telemetry::FailureReason::ConfigError, ); process::exit(1); @@ -1169,9 +1181,10 @@ fn main() { Ok(context) => context, Err(message) => { eprintln!("Error: {message}"); - telemetry::emit_early_exit( + telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, telemetry::FailureReason::ConfigError, ); process::exit(1); @@ -1193,9 +1206,10 @@ fn main() { "Error: no command to run. Provide `process.commandLine` in the policy or pass the command as arguments after the config path." ); eprint!("{}", logger.get_buffer()); - telemetry::emit_early_exit( + telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, telemetry::FailureReason::ConfigError, ); process::exit(1); @@ -1287,9 +1301,10 @@ fn main() { Err(e) => { eprintln!("error: {}", e.message); eprint!("{}", logger.get_buffer()); - telemetry::emit_early_exit( + telemetry::emit_early_exit_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, telemetry::FailureReason::InitError, ); process::exit(1); @@ -1358,9 +1373,10 @@ fn main() { eprintln!("{warning}"); } - telemetry::emit_completion( + telemetry::emit_completion_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, &response, run_elapsed, ); diff --git a/src/core/wxc_common/src/telemetry/events.rs b/src/core/wxc_common/src/telemetry/events.rs index a4208b85c..eecc4def5 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -111,12 +111,18 @@ pub fn log_execution(event: &ExecutionEvent<'_>) { /// To avoid leaking PII (paths, usernames, credentials embedded in error /// strings), MXC deliberately does **not** emit the free-form error message. /// The event carries only the bounded `error_type` category, the process -/// `exit_code`, and the [`TelemetryContext`] attribution (backend, lifecycle -/// phase, and correlation vector — the latter two empty for one-shot). -pub fn log_error(ctx: TelemetryContext<'_>, error_type: FailureReason, exit_code: i32) { +/// `exit_code`, the caller-requested `sandbox_kind`, and the +/// [`TelemetryContext`] attribution (backend, lifecycle phase, and correlation +/// vector — the latter two empty for one-shot). +pub fn log_error( + ctx: TelemetryContext<'_>, + sandbox_kind: &str, + error_type: FailureReason, + exit_code: i32, +) { mxc_telemetry::log_error( ctx.backend, - super::sandbox_kind_for(ctx.backend, None), + sandbox_kind, error_type.as_str(), exit_code, ctx.phase, @@ -124,7 +130,7 @@ pub fn log_error(ctx: TelemetryContext<'_>, error_type: FailureReason, exit_code ); #[cfg(test)] - test_sink::record_error(ctx, error_type, exit_code); + test_sink::record_error(ctx, sandbox_kind, error_type, exit_code); } /// In-memory capture sink for the two ETW emit calls, so tests can assert the @@ -155,6 +161,7 @@ pub(super) mod test_sink { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedError { pub backend: String, + pub sandbox_kind: String, pub error_type: FailureReason, pub exit_code: i32, pub phase: String, @@ -218,6 +225,7 @@ pub(super) mod test_sink { pub(super) fn record_error( ctx: TelemetryContext<'_>, + sandbox_kind: &str, error_type: FailureReason, exit_code: i32, ) { @@ -229,6 +237,7 @@ pub(super) mod test_sink { .unwrap_or_else(|e| e.into_inner()) .push(CapturedError { backend: ctx.backend.to_owned(), + sandbox_kind: sandbox_kind.to_owned(), error_type, exit_code, phase: ctx.phase.to_owned(), diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 14db3d01c..bffded308 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -91,6 +91,9 @@ const CANCELLED_EXIT_CODE: i32 = 130; /// Backend attribution for out-of-band events. static PROCESS_BACKEND: Mutex> = Mutex::new(None); +/// Caller-requested containment attribution for out-of-band events. +static PROCESS_SANDBOX_KIND: Mutex> = Mutex::new(None); + /// State-aware phase attribution for out-of-band events. static PROCESS_PHASE: Mutex> = Mutex::new(None); @@ -188,6 +191,9 @@ fn already_emitted() -> bool { fn reset_for_test() { HAS_EMITTED.store(false, Ordering::SeqCst); *PROCESS_BACKEND.lock().unwrap_or_else(|e| e.into_inner()) = None; + *PROCESS_SANDBOX_KIND + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; *PROCESS_PHASE.lock().unwrap_or_else(|e| e.into_inner()) = None; *PROCESS_CORRELATION_VECTOR .lock() @@ -292,6 +298,17 @@ pub fn emit_completion( containment: &ContainmentBackend, response: &ScriptResponse, elapsed: Duration, +) { + emit_completion_with_kind(active, containment, None, response, elapsed); +} + +/// Emit completion telemetry with the caller-requested containment kind. +pub fn emit_completion_with_kind( + active: bool, + containment: &ContainmentBackend, + requested_sandbox_kind: Option<&'static str>, + response: &ScriptResponse, + elapsed: Duration, ) { let Some(auth) = EmissionAuthorization::for_invocation(active) else { return; @@ -299,7 +316,13 @@ pub fn emit_completion( if already_emitted() { return; } - emit_completion_event(&auth, containment, response, elapsed, None); + emit_completion_event( + &auth, + containment, + response, + elapsed, + requested_sandbox_kind, + ); shutdown(); } @@ -314,13 +337,14 @@ fn emit_completion_event( // emission (see `EmissionAuthorization`). Both writes below execute under // it — there is no per-write reread of consent/policy state. let backend = containment.wire_name(); + let sandbox_kind = sandbox_kind_for(backend, requested_sandbox_kind); let failed = response.exit_code != 0; let outcome = if failed { "failure" } else { "success" }; let failure_reason = failed.then(|| classify_failure(&response.failure_phase)); log_execution(&ExecutionEvent { backend, - sandbox_kind: sandbox_kind_for(backend, requested_sandbox_kind), + sandbox_kind, exit_code: response.exit_code, outcome, duration_ms: elapsed.as_millis() as u64, @@ -342,6 +366,7 @@ fn emit_completion_event( phase: "", correlation_vector: "", }, + sandbox_kind, classify_failure(&response.failure_phase), response.exit_code, ); @@ -386,13 +411,23 @@ pub fn emit_sdk_completion_with_kind( /// bounded `reason` category and exit code, so config/policy/init failures are /// observable. `duration_ms` is reported as `0` because no execution occurred. pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: FailureReason) { + emit_early_exit_with_kind(active, containment, None, reason); +} + +/// Emit early-exit telemetry with the caller-requested containment kind. +pub fn emit_early_exit_with_kind( + active: bool, + containment: &ContainmentBackend, + requested_sandbox_kind: Option<&'static str>, + reason: FailureReason, +) { let Some(auth) = EmissionAuthorization::for_invocation(active) else { return; }; if already_emitted() { return; } - emit_early_exit_event(&auth, containment, reason, None); + emit_early_exit_event(&auth, containment, reason, requested_sandbox_kind); shutdown(); } @@ -406,10 +441,11 @@ fn emit_early_exit_event( // emission (see `EmissionAuthorization`). Both writes below execute under // it — there is no per-write reread of consent/policy state. let backend = containment.wire_name(); + let sandbox_kind = sandbox_kind_for(backend, requested_sandbox_kind); log_execution(&ExecutionEvent { backend, - sandbox_kind: sandbox_kind_for(backend, requested_sandbox_kind), + sandbox_kind, exit_code: 1, outcome: "failure", duration_ms: 0, @@ -426,6 +462,7 @@ fn emit_early_exit_event( phase: "", correlation_vector: "", }, + sandbox_kind, reason, 1, ); @@ -465,9 +502,27 @@ fn emit_sdk_with_release(active: bool, emit: impl FnOnce(&EmissionAuthorization) /// Call once, immediately after a successful [`init`]. Later calls are ignored /// (the value is set-once). pub fn set_process_context(containment: &ContainmentBackend) { + set_process_context_with_kind(containment, None); +} + +/// Record both the resolved backend and the caller-requested containment kind +/// for out-of-band events. +pub fn set_process_context_with_kind( + containment: &ContainmentBackend, + requested_sandbox_kind: Option<&'static str>, +) { + let backend = containment.wire_name(); let mut slot = PROCESS_BACKEND.lock().unwrap_or_else(|e| e.into_inner()); if slot.is_none() { - *slot = Some(containment.wire_name()); + *slot = Some(backend); + } + drop(slot); + + let mut slot = PROCESS_SANDBOX_KIND + .lock() + .unwrap_or_else(|e| e.into_inner()); + if slot.is_none() { + *slot = Some(sandbox_kind_for(backend, requested_sandbox_kind)); } } @@ -488,6 +543,16 @@ fn process_backend() -> &'static str { resolve_backend_name(stored) } +/// The caller-requested containment kind, defaulting to the resolved backend +/// when no request-scoped value was recorded. +fn process_sandbox_kind() -> &'static str { + PROCESS_SANDBOX_KIND + .try_lock() + .ok() + .and_then(|slot| *slot) + .unwrap_or_else(process_backend) +} + /// Pure defaulting for the process backend: the stashed value, or /// [`UNKNOWN_BACKEND`] when unset. Split out (global-free) so the fallback /// behaviour is unit-testable without writing the set-once [`PROCESS_BACKEND`]. @@ -574,14 +639,24 @@ pub fn install_panic_hook() { /// Build the `Execution` event for an out-of-band crash/cancellation. Pure /// (no ETW I/O) so the exit-code/reason/attribution mapping can be unit-tested. +#[cfg(test)] fn crash_event<'a>( ctx: TelemetryContext<'a>, exit_code: i32, reason: FailureReason, +) -> ExecutionEvent<'a> { + crash_event_with_kind(ctx, ctx.backend, exit_code, reason) +} + +fn crash_event_with_kind<'a>( + ctx: TelemetryContext<'a>, + sandbox_kind: &'a str, + exit_code: i32, + reason: FailureReason, ) -> ExecutionEvent<'a> { ExecutionEvent { backend: ctx.backend, - sandbox_kind: ctx.backend, + sandbox_kind, exit_code, outcome: "failure", duration_ms: 0, @@ -604,13 +679,23 @@ struct CrashTelemetry<'a> { /// and their tests. Takes the [`TelemetryContext`] attribution as a parameter /// (rather than reading the process globals) so the mapping can be asserted /// deterministically for any attribution without writing the globals. +#[cfg(test)] fn plan_crash<'a>( ctx: TelemetryContext<'a>, exit_code: i32, reason: FailureReason, +) -> CrashTelemetry<'a> { + plan_crash_with_kind(ctx, ctx.backend, exit_code, reason) +} + +fn plan_crash_with_kind<'a>( + ctx: TelemetryContext<'a>, + sandbox_kind: &'a str, + exit_code: i32, + reason: FailureReason, ) -> CrashTelemetry<'a> { CrashTelemetry { - execution: crash_event(ctx, exit_code, reason), + execution: crash_event_with_kind(ctx, sandbox_kind, exit_code, reason), error: reason, exit_code, } @@ -627,12 +712,13 @@ fn plan_crash<'a>( fn emit_crash( _auth: &EmissionAuthorization, ctx: TelemetryContext<'_>, + sandbox_kind: &str, exit_code: i32, reason: FailureReason, ) { - let plan = plan_crash(ctx, exit_code, reason); + let plan = plan_crash_with_kind(ctx, sandbox_kind, exit_code, reason); log_execution(&plan.execution); - log_error(ctx, plan.error, plan.exit_code); + log_error(ctx, sandbox_kind, plan.error, plan.exit_code); } /// Emit crash telemetry from a global panic hook. @@ -662,6 +748,7 @@ pub fn emit_panic() { phase: process_phase(), correlation_vector: &correlation_vector, }, + process_sandbox_kind(), PANIC_EXIT_CODE, FailureReason::InternalError, ); @@ -695,6 +782,7 @@ pub fn emit_cancellation() { phase: process_phase(), correlation_vector: &correlation_vector, }, + process_sandbox_kind(), CANCELLED_EXIT_CODE, FailureReason::Cancelled, ); @@ -827,6 +915,17 @@ pub fn emit_state_aware( ctx: TelemetryContext<'_>, outcome: &Result, elapsed: Duration, +) { + emit_state_aware_with_kind(active, None, ctx, outcome, elapsed); +} + +/// Emit state-aware telemetry with the caller-requested containment kind. +pub fn emit_state_aware_with_kind( + active: bool, + requested_sandbox_kind: Option<&'static str>, + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, ) { let Some(auth) = EmissionAuthorization::for_invocation(active) else { return; @@ -834,7 +933,7 @@ pub fn emit_state_aware( if already_emitted() { return; } - emit_state_aware_event(&auth, ctx, outcome, elapsed, None); + emit_state_aware_event(&auth, ctx, outcome, elapsed, requested_sandbox_kind); shutdown(); } @@ -854,7 +953,7 @@ fn emit_state_aware_event( log_execution(&plan.execution); if let Some(reason) = plan.error { - log_error(ctx, reason, plan.execution.exit_code); + log_error(ctx, sandbox_kind, reason, plan.execution.exit_code); } } @@ -898,9 +997,10 @@ pub fn emit_sdk_cancellation_with_kind( ) { emit_sdk_with_release(active, |auth| { let _auth = auth; + let sandbox_kind = sandbox_kind_for(ctx.backend, requested_sandbox_kind); log_execution(&ExecutionEvent { backend: ctx.backend, - sandbox_kind: sandbox_kind_for(ctx.backend, requested_sandbox_kind), + sandbox_kind, exit_code: CANCELLED_EXIT_CODE, outcome: "failure", duration_ms: elapsed.as_millis() as u64, @@ -908,7 +1008,12 @@ pub fn emit_sdk_cancellation_with_kind( phase: ctx.phase, correlation_vector: ctx.correlation_vector, }); - log_error(ctx, FailureReason::Cancelled, CANCELLED_EXIT_CODE); + log_error( + ctx, + sandbox_kind, + FailureReason::Cancelled, + CANCELLED_EXIT_CODE, + ); }); } @@ -1137,7 +1242,7 @@ mod tests { events::test_sink::install(); TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); TEST_FORCE_ACTIVE.with(|f| f.set(true)); - set_process_context(&ContainmentBackend::IsolationSession); + set_process_context_with_kind(&ContainmentBackend::IsolationSession, Some("vm")); set_process_phase("exec"); set_process_correlation_vector("iso:wxc-abcd"); @@ -1147,7 +1252,7 @@ mod tests { assert_eq!(execs.len(), 1, "panic emits exactly one Execution"); let exec = &execs[0]; assert_eq!(exec.backend, "isolation_session"); - assert_eq!(exec.sandbox_kind, "isolation_session"); + assert_eq!(exec.sandbox_kind, "vm"); assert_eq!(exec.exit_code, PANIC_EXIT_CODE); assert_eq!(exec.outcome, "failure"); assert_eq!(exec.failure_reason, Some(FailureReason::InternalError)); @@ -1158,6 +1263,7 @@ mod tests { assert_eq!(errors.len(), 1, "panic emits exactly one Error"); let error = &errors[0]; assert_eq!(error.backend, "isolation_session"); + assert_eq!(error.sandbox_kind, "vm"); assert_eq!(error.error_type, FailureReason::InternalError); assert_eq!(error.exit_code, PANIC_EXIT_CODE); assert_eq!(error.phase, "exec"); @@ -1224,9 +1330,10 @@ mod tests { reset_for_test(); events::test_sink::install(); TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); - emit_completion( + emit_completion_with_kind( true, &ContainmentBackend::IsolationSession, + Some("process"), &ScriptResponse { exit_code: 1, error_message: "launch failed".to_string(), @@ -1239,9 +1346,11 @@ mod tests { assert_eq!(executions.len(), 1); assert_eq!(executions[0].outcome, "failure"); assert_eq!(executions[0].exit_code, 1); + assert_eq!(executions[0].sandbox_kind, "process"); assert_eq!(executions[0].failure_reason, Some(FailureReason::InitError)); let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 1); + assert_eq!(errors[0].sandbox_kind, "process"); assert_eq!(errors[0].error_type, FailureReason::InitError); reset_for_test(); @@ -1254,9 +1363,10 @@ mod tests { events::test_sink::install(); TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); - emit_early_exit( + emit_early_exit_with_kind( true, &ContainmentBackend::IsolationSession, + Some("vm"), FailureReason::PolicyError, ); @@ -1264,12 +1374,14 @@ mod tests { assert_eq!(executions.len(), 1); assert_eq!(executions[0].outcome, "failure"); assert_eq!(executions[0].exit_code, 1); + assert_eq!(executions[0].sandbox_kind, "vm"); assert_eq!( executions[0].failure_reason, Some(FailureReason::PolicyError) ); let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 1); + assert_eq!(errors[0].sandbox_kind, "vm"); assert_eq!(errors[0].error_type, FailureReason::PolicyError); reset_for_test(); @@ -1684,6 +1796,7 @@ mod tests { TEST_AUTHORIZATION_OVERRIDE.with(|allowed| allowed.set(Some(true))); let outcome = Ok(DispatchOutcome::Envelope(serde_json::json!({}))); + let error = Err(MxcError::policy_validation("simulated")); emit_sdk_state_aware_with_kind( true, Some("process"), @@ -1703,7 +1816,7 @@ mod tests { phase: "exec", correlation_vector: "corr-vm", }, - &outcome, + &error, Duration::ZERO, ); @@ -1713,6 +1826,10 @@ mod tests { assert_eq!(executions[1].sandbox_kind, "vm"); assert_eq!(executions[0].correlation_vector, "corr-process"); assert_eq!(executions[1].correlation_vector, "corr-vm"); + let errors = events::test_sink::take_errors(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].sandbox_kind, "vm"); + assert_eq!(errors[0].backend, "windows_sandbox"); reset_for_test(); } @@ -1763,10 +1880,12 @@ mod tests { let errors = events::test_sink::take_errors(); assert_eq!(errors.len(), 2); + assert_eq!(errors[0].sandbox_kind, "process"); assert_eq!(errors[0].error_type, FailureReason::Cancelled); assert_eq!(errors[0].exit_code, CANCELLED_EXIT_CODE); assert_eq!(errors[0].phase, ""); assert_eq!(errors[0].correlation_vector, ""); + assert_eq!(errors[1].sandbox_kind, "vm"); assert_eq!(errors[1].error_type, FailureReason::Cancelled); assert_eq!(errors[1].exit_code, CANCELLED_EXIT_CODE); assert_eq!(errors[1].phase, "exec"); From bfb0ecb76b1505923c993718ec6d75a914dc15f4 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Mon, 31 Aug 2026 17:33:40 -0700 Subject: [PATCH 15/47] Complete telemetry contract and lifecycle handling Enforce the 0.9 telemetry schema boundary across runtime and Node state-aware requests, preserve real completion on dropped handles, surface initialization warnings, and apply the full telemetry lifecycle to attached state-aware execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- sdk/node/README.md | 4 +- sdk/node/src/state-aware-helper.ts | 15 +++- sdk/node/tests/unit/state-aware.test.ts | 16 +++++ src/core/mxc-sdk/README.md | 11 +-- src/core/mxc-sdk/src/lib.rs | 5 +- src/core/mxc-sdk/src/sandbox.rs | 4 +- src/core/mxc_engine/src/lib.rs | 61 +++++++++++++--- src/core/mxc_engine/src/state_aware.rs | 63 +++++++++++++++-- src/core/wxc_common/src/config_parser.rs | 82 ++++++++++++++++++++-- src/core/wxc_common/src/logger.rs | 24 ++++++- src/core/wxc_common/src/sandbox_process.rs | 5 +- src/core/wxc_common/src/telemetry/mod.rs | 7 +- 12 files changed, 262 insertions(+), 35 deletions(-) diff --git a/sdk/node/README.md b/sdk/node/README.md index c2c046766..47e2fcee3 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -64,7 +64,7 @@ child.on('close', (code) => console.log('exit:', code)); Pick `0.8.0-alpha` for new code on any supported platform. -> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts them when paired with `--experimental` regardless of which schema your config validates against — schema choice affects editor validation, not runtime behavior. +> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts experimental backend settings when paired with `--experimental`; however, fields introduced in a specific contract, including top-level `telemetry` in `0.9.0-alpha`, are rejected when the request explicitly declares an older schema version. > **Network host allow/block lists are not implemented on Windows.** `network.allowedHosts` / `network.blockedHosts` have no enforcement on this platform — use `network.defaultPolicy` (`allow` / `block`) or `network.proxy` to constrain network access. @@ -329,7 +329,7 @@ await deprovisionSandbox(sandboxId, undefined, opts); `windows_sandbox` follows the same shape (substitute the containment string and provide `filesystem.readwritePaths` / `readonlyPaths` at provision if needed). See [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) for the per-phase config matrix. -`wslc` follows the same shape and needs no provision config at all (it defaults to an `alpine:latest` container with no network). Provide `filesystem.readwritePaths` / `readonlyPaths` (mounted for the sandbox's lifetime), `network.defaultPolicy: 'allow'` (a bridged container; the default `'block'` gives no network), and/or a backend-specific `image` / `imageTarPath` at provision; inject a cooperative `network.proxy: { url }` per-exec. WSLc state-aware requests default to schema `0.8.0-alpha`. See [`docs/wsl/wslc-state-aware.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wslc-state-aware.md) for the per-phase config matrix. +`wslc` follows the same shape and needs no provision config at all (it defaults to an `alpine:latest` container with no network). Provide `filesystem.readwritePaths` / `readonlyPaths` (mounted for the sandbox's lifetime), `network.defaultPolicy: 'allow'` (a bridged container; the default `'block'` gives no network), and/or a backend-specific `image` / `imageTarPath` at provision; inject a cooperative `network.proxy: { url }` per-exec. WSLc state-aware requests normally default to schema `0.8.0-alpha`; requests that include `telemetry` default to `0.9.0-alpha`. See [`docs/wsl/wslc-state-aware.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wslc-state-aware.md) for the per-phase config matrix. **Handling failures.** Every lifecycle call rejects with a typed `MxcError`. Branch on `code` first: diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index 92d4efc81..800b611b0 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { spawn } from 'child_process'; +import { parse as semverParse } from 'semver'; import { resolveBinaryAndCommonArgs } from './helper.js'; import { SandboxSpawnOptions } from './sandbox.js'; import { mxcErrorFromCode, mxcErrorFromEnvelope, WireError } from './errors.js'; @@ -16,6 +17,7 @@ export const STATE_AWARE_VERSION = '0.6.0-alpha'; // backends were promoted independently, so WSLc carries its own later default. // See `DEFAULT_STATE_AWARE_VERSION`. export const WSLC_STATE_AWARE_VERSION = '0.8.0-alpha'; +export const TELEMETRY_STATE_AWARE_VERSION = '0.9.0-alpha'; // Wire-format cross-cutting fields that live at the envelope's top level. // Anything else on a per-(backend, phase) Config is backend-specific and is @@ -99,7 +101,18 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record.. const backendSpecific: Record = { ...(config ?? {}) }; const defaultVersion = DEFAULT_STATE_AWARE_VERSION[backendKey] ?? STATE_AWARE_VERSION; - const version = (typeof backendSpecific.version === 'string' && backendSpecific.version) || defaultVersion; + const suppliedVersion = typeof backendSpecific.version === 'string' && backendSpecific.version; + const hasTelemetry = backendSpecific.telemetry !== undefined; + const version = suppliedVersion || (hasTelemetry ? TELEMETRY_STATE_AWARE_VERSION : defaultVersion); + if (hasTelemetry && suppliedVersion) { + const parsed = semverParse(suppliedVersion); + if (parsed && parsed.major === 0 && parsed.minor < 9) { + throw mxcErrorFromCode( + 'malformed_request', + `telemetry requires schema version ${TELEMETRY_STATE_AWARE_VERSION} or later; got ${suppliedVersion}`, + ); + } + } delete backendSpecific.version; const envelope: Record = { version, phase }; diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index f1d575080..c9d364034 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -29,9 +29,25 @@ describe('buildStateAwareEnvelope', () => { config: { telemetry: { enabled: true } }, }); assert.deepEqual(env.telemetry, { enabled: true }); + assert.equal(env.version, '0.9.0-alpha'); assert.equal(env.experimental, undefined); }); + it('rejects telemetry with an explicitly older schema version', () => { + assert.throws( + () => buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'windows_sandbox', + sandboxId: 'wsb:01234567', + config: { version: '0.8.0-alpha', telemetry: { enabled: true } }, + }), + (error: unknown) => + error instanceof MxcError && + error.code === 'malformed_request' && + error.message.includes('telemetry requires schema version 0.9.0-alpha'), + ); + }); + it('produces a provision envelope with cross-cutting fields lifted to top-level', () => { const env = buildStateAwareEnvelope({ phase: 'provision', diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 4a09928f6..134f857be 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -260,8 +260,9 @@ The handle is modelled on [`std::process::Child`]: - `id()` returns the child's OS process id, for external monitoring or a caller-driven process-tree kill. - `try_wait()` for a non-blocking exit check. -- `warnings()` returns policy security warnings detected while spawning the - sandbox, such as `permissiveLearningMode` weakening deny-by-default. +- `warnings()` returns policy and operational warnings detected while spawning + the sandbox, such as `permissiveLearningMode` weakening deny-by-default or + telemetry being unavailable/routed only to local ETW. - `output_metadata()` returns structured feature outputs after a terminal wait. For `captureDenials`, it contains the generated JSON file path and summary, plus the retained ETL path when requested. Post-seal failures expose @@ -276,7 +277,7 @@ The handle is modelled on [`std::process::Child`]: `Exited(code)` or `TimedOut` if the timeout elapses (`Err` is reserved for an actual OS/wait failure). - `wait_with_output()` consumes the handle and returns an `Output` with the - `WaitOutcome`, policy security `warnings`, and captured `stdout`/`stderr` — it + `WaitOutcome`, policy/operational `warnings`, and captured `stdout`/`stderr` — it also includes structured `output_metadata` produced during backend teardown. The method drains both streams concurrently for you, the safe alternative to @@ -312,7 +313,9 @@ state-aware sandbox lifecycle from a wire-format request JSON string: - `exec_attached(request_json, experimental)` runs the `exec` phase **attached to this process's stdio**, blocking until the workload exits and returning a `WaitOutcome`. See *Pty allocation* for the terminal requirement and what each - backend does with the streams. + backend does with the streams. Parser and telemetry-initialization warnings + are written to the attached host stderr because this API returns no process + handle with a `warnings()` channel. - `exec_sandbox(request_json, experimental)` runs the same `exec` phase as a **live streaming** `Sandbox` (the same handle `spawn_sandbox` returns), for a caller that drives the pipes itself. The child sees no TTY and this process's diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 6a9aeafd3..f2bf0d03d 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -129,8 +129,9 @@ //! //! [`exec_attached`] is verified against IsolationSession only. //! -//! Policy security warnings are available through [`Sandbox::warnings`] and -//! [`Output::warnings`]. +//! Policy and operational warnings are available through [`Sandbox::warnings`] +//! and [`Output::warnings`]. [`exec_attached`] has no returned handle, so it +//! writes those warnings to the host stderr that the caller explicitly attached. //! //! ## Relationship to `mxc_engine` //! diff --git a/src/core/mxc-sdk/src/sandbox.rs b/src/core/mxc-sdk/src/sandbox.rs index 3c96124fd..7036a7990 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -51,7 +51,7 @@ pub enum WaitOutcome { pub struct Output { /// How the process finished. pub outcome: WaitOutcome, - /// Security warnings emitted while applying the sandbox policy. + /// Policy and operational warnings emitted while starting the sandbox. pub warnings: Vec, /// Everything the child wrote to stdout. pub stdout: Vec, @@ -76,7 +76,7 @@ impl Sandbox { Self { inner } } - /// Security warnings emitted while applying the sandbox policy. + /// Policy and operational warnings emitted while starting the sandbox. pub fn warnings(&self) -> &[String] { self.inner.warnings() } diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 051e49355..552091585 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -153,8 +153,8 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> /// * [`SandboxProcess::kill`] — cancellation event after the wrapped process /// confirms the kill succeeded. A failed kill leaves the slot active for a /// later `wait` / successful `try_wait`. -/// * [`Drop`] — synthesised process-error event, so a wrapper dropped without -/// `wait` never releases the provider without accounting for the run. +/// * [`Drop`] — polls once for a completed child and emits its real exit code; +/// only a still-running or unpollable child is reported as abandoned. /// /// `active` doubles as the exactly-once flag: it starts `true`, and every /// terminal path calls [`TelemetryProcess::emit`] which flips it to `false` @@ -347,14 +347,32 @@ enum SyntheticTerminal { PollError, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DropDisposition { + Exited(i32), + Abandoned, +} + +fn observe_before_drop(process: &mut dyn SandboxProcess) -> DropDisposition { + match process.try_wait() { + Ok(Some(exit_code)) => DropDisposition::Exited(exit_code), + Ok(None) | Err(_) => DropDisposition::Abandoned, + } +} + impl Drop for TelemetryProcess { fn drop(&mut self) { // If `wait` / `try_wait(Ok(Some))` / `kill` already emitted the - // terminal event, `active` is false and this is a no-op. Otherwise - // synthesise one so the "exactly-one terminal event" invariant holds - // for the drop-without-wait path as well. `emit` also releases the - // provider reference. - self.emit_synthetic(SyntheticTerminal::Dropped); + // terminal event, `active` is false and this is a no-op. Otherwise, + // preserve a completion that raced with Drop instead of misreporting a + // successful fire-and-forget run as abandonment. + if !self.active { + return; + } + match observe_before_drop(self.inner.as_mut()) { + DropDisposition::Exited(exit_code) => self.emit(&Ok(exit_code)), + DropDisposition::Abandoned => self.emit_synthetic(SyntheticTerminal::Dropped), + } } } @@ -423,7 +441,7 @@ impl SandboxProcess for TelemetryProcess { } } -/// A streaming process paired with security warnings emitted during spawn. +/// A streaming process paired with warnings emitted during spawn. pub(crate) struct ProcessWithWarnings { inner: Box, warnings: Vec, @@ -611,6 +629,33 @@ mod telemetry_process_tests { assert!(!process.active); } + #[test] + fn drop_observation_preserves_completed_exit_code() { + let mut exited = StubProcess { + try_wait_result: TryWaitResult::Exited(7), + wait_result: Ok(0), + kill_fails: false, + }; + assert_eq!(observe_before_drop(&mut exited), DropDisposition::Exited(7)); + + let mut running = StubProcess { + try_wait_result: TryWaitResult::Running, + wait_result: Ok(0), + kill_fails: false, + }; + assert_eq!( + observe_before_drop(&mut running), + DropDisposition::Abandoned + ); + + let mut failed = StubProcess { + try_wait_result: TryWaitResult::Failed, + wait_result: Ok(0), + kill_fails: false, + }; + assert_eq!(observe_before_drop(&mut failed), DropDisposition::Abandoned); + } + #[test] fn failed_kill_leaves_the_exactly_once_slot_active() { let mut process = wrapped_with_kill_failure(TryWaitResult::Running, true); diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 3d2758cfc..6c062b1e5 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -15,7 +15,7 @@ //! [`wxc_common::state_aware_dispatch::run_state_aware`], which surfaces the //! `unsupported_phase` envelope. -use std::io::IsTerminal; +use std::io::{IsTerminal, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use wxc_common::logger::{Logger, Mode}; @@ -78,6 +78,12 @@ fn phase_correlation(active: bool, phase: Phase, sandbox_id: Option<&str>) -> St telemetry::correlation_state::pre_dispatch_vector(active, phase == Phase::Provision, sandbox_id) } +fn surface_attached_warnings(logger: &mut Logger, mut surface: impl FnMut(&str)) { + for warning in logger.take_warnings() { + surface(&warning); + } +} + /// Merge `warnings` from telemetry initialisation into the envelope's /// `result.warnings` array. Existing entries in the array are preserved and /// duplicates suppressed so the field remains a stable set-like list. @@ -342,14 +348,50 @@ fn exec_state_aware_attached_with( } exec_attached_gate(host_is_interactive)?; + let phase = parsed.phase; + let sandbox_id = parsed.sandbox_id.clone(); + let requested_sandbox_kind = parsed + .request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); + // This API explicitly attaches the workload to the host's stdio and has no + // warning-bearing return handle. Surface retained parser/init warnings on + // host stderr rather than silently dropping them as the buffered logger + // goes out of scope. + surface_attached_warnings(&mut logger, |warning| { + let _ = writeln!(std::io::stderr().lock(), "{warning}"); + }); + let backend = resolve_backend(&parsed) + .map(|backend| backend.wire_name().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let correlation = phase_correlation(telemetry_active, phase, sandbox_id.as_deref()); + let started = std::time::Instant::now(); let dispatched = with_attached_exec_claim(|| run_state_aware(parsed, /* dry_run */ false)) - .ok_or_else(|| { - Error::from(MxcError::malformed_request( + .unwrap_or_else(|| { + Err(MxcError::malformed_request( "another attached exec is already running in this process. An attached exec \ owns this process's console mode and control handler, so only one can run at \ a time. Nothing has been run.", )) - })?; + }); + telemetry::emit_sdk_state_aware_with_kind( + telemetry_active, + requested_sandbox_kind, + telemetry::TelemetryContext { + backend: &backend, + phase: phase.as_str(), + correlation_vector: &correlation, + }, + &dispatched, + started.elapsed(), + ); match dispatched.map_err(Error::from)? { DispatchOutcome::ExecCompleted { exit_code } => Ok(ExecOutcome::Exited(exit_code)), @@ -545,6 +587,19 @@ mod tests { ); } + #[test] + fn attached_warning_surface_drains_retained_warnings() { + let mut logger = Logger::new(Mode::Buffer); + logger.warning_line("first"); + logger.warning_line("second"); + let mut surfaced = Vec::new(); + + surface_attached_warnings(&mut logger, |warning| surfaced.push(warning.to_string())); + + assert_eq!(surfaced, ["first", "second"]); + assert!(logger.warnings().is_empty()); + } + #[test] fn later_phase_without_a_persisted_record_seeds_a_disconnected_vector() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 1f522db01..67348a741 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -221,7 +221,7 @@ pub fn load_request_with_options( .map_err(|error| WxcError::ConfigParse(error.to_string()))?; let raw: serde_json::Value = config_deserialize::from_str(&json_str) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; - validate_directional_network_field_versions(&raw)?; + validate_versioned_fields(&raw)?; convert_wire_config(cfg, logger, true, opts.allow_missing_command, false) })(); @@ -289,6 +289,9 @@ pub fn load_request_from_json_with_hint_and_options( let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + let raw: serde_json::Value = config_deserialize::from_str(json_str) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + validate_versioned_fields(&raw)?; convert_wire_config(cfg, logger, true, opts.allow_missing_command, false) })(); @@ -313,7 +316,7 @@ pub fn load_request_from_value( reject_legacy_telemetry_value(&config)?; let cfg: wire::MxcConfig = config_deserialize::from_value(config) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; - validate_directional_network_field_versions(&raw)?; + validate_versioned_fields(&raw)?; convert_wire_config(cfg, logger, true, allow_missing_command, false) })(); @@ -434,7 +437,7 @@ fn parse_mxc_request_json_with_hint( if hint.kind() == RequestKind::StateAware { let raw: serde_json::Value = config_deserialize::from_str(json_str) .map_err(|error| ParseError::Decode(WxcError::ConfigParse(error.to_string())))?; - validate_directional_network_field_versions(&raw).map_err(|error| { + validate_versioned_fields(&raw).map_err(|error| { ParseError::StateAware(MxcError::malformed_request(error.to_string())) })?; convert_wire_state_aware( @@ -452,13 +455,18 @@ fn parse_mxc_request_json_with_hint( .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; let raw: serde_json::Value = config_deserialize::from_str(json_str) .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; - validate_directional_network_field_versions(&raw).map_err(ParseError::OneShot)?; + validate_versioned_fields(&raw).map_err(ParseError::OneShot)?; convert_wire_config(cfg, logger, true, allow_missing_command, false) .map(MxcRequest::OneShot) .map_err(ParseError::OneShot) } } +fn validate_versioned_fields(config: &serde_json::Value) -> Result<(), WxcError> { + validate_directional_network_field_versions(config)?; + validate_telemetry_field_version(config) +} + fn validate_directional_network_field_versions(config: &serde_json::Value) -> Result<(), WxcError> { let Some(config) = config.as_object() else { return Ok(()); @@ -486,6 +494,29 @@ fn validate_directional_network_field_versions(config: &serde_json::Value) -> Re Ok(()) } +fn validate_telemetry_field_version(config: &serde_json::Value) -> Result<(), WxcError> { + let Some(config) = config.as_object() else { + return Ok(()); + }; + if !config.contains_key("telemetry") { + return Ok(()); + } + let Some(version) = config.get("version").and_then(serde_json::Value::as_str) else { + return Ok(()); + }; + let Ok(version) = semver::Version::parse(version) else { + // The ordinary schema-version validator owns malformed-version + // diagnostics so this feature gate does not mask the more useful error. + return Ok(()); + }; + if version.major == 0 && version.minor < 9 { + return Err(WxcError::ConfigParse( + "top-level 'telemetry' requires config schema version 0.9.0-alpha or later".to_string(), + )); + } + Ok(()) +} + fn log_one_shot_error(logger: &mut Logger, result: &Result) { if let Err(error) = result { log_error(logger, &error.to_string(), ErrorOutput::Primary); @@ -2004,6 +2035,7 @@ mod tests { // Telemetry is a stable cross-cutting setting parsed identically for // one-shot and state-aware requests. let json = r#"{ + "version": "0.9.0-alpha", "phase": "provision", "containment": "isolation_session", "telemetry": {"enabled": true}, @@ -2020,6 +2052,25 @@ mod tests { } } + #[test] + fn state_aware_telemetry_rejects_pre_09_schema_version() { + let error = load_mxc( + r#"{ + "version": "0.8.0-alpha", + "phase": "start", + "sandboxId": "iso:abcd1234", + "telemetry": {"enabled": true} + }"#, + ) + .unwrap_err(); + assert!( + error + .message() + .contains("telemetry' requires config schema version 0.9.0-alpha"), + "got {error:?}" + ); + } + #[test] fn state_aware_without_telemetry_leaves_typed_field_unset() { let json = r#"{ @@ -6759,7 +6810,7 @@ mod tests { #[test] fn telemetry_enabled_true() { - let json = r#"{"process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#; + let json = r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); @@ -6768,6 +6819,27 @@ mod tests { assert_eq!(telem.requested_sandbox_kind, Some("process")); } + #[test] + fn telemetry_rejects_pre_09_schema_version_across_one_shot_loaders() { + let json = r#"{"version":"0.8.0-alpha","process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#; + let expected = "telemetry' requires config schema version 0.9.0-alpha"; + + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + let error = load_request(&encoded, &mut logger, true).unwrap_err(); + assert!(error.to_string().contains(expected), "got {error:?}"); + + let mut logger = test_logger(); + let error = load_request_from_json(json, &mut logger).unwrap_err(); + assert!(error.to_string().contains(expected), "got {error:?}"); + + let mut logger = test_logger(); + let error = + load_request_from_value(serde_json::from_str(json).unwrap(), &mut logger, false) + .unwrap_err(); + assert!(error.to_string().contains(expected), "got {error:?}"); + } + #[test] fn telemetry_records_abstract_requested_sandbox_kind() { let json = r#"{"process":{"commandLine":"echo hi"},"containment":"vm","telemetry":{"enabled":true}}"#; diff --git a/src/core/wxc_common/src/logger.rs b/src/core/wxc_common/src/logger.rs index 69005038d..ae95800ba 100644 --- a/src/core/wxc_common/src/logger.rs +++ b/src/core/wxc_common/src/logger.rs @@ -28,7 +28,7 @@ pub enum Mode { pub struct Logger { mode: Mode, buffer: String, - /// Security warnings emitted during the run, retained for library callers. + /// Warnings emitted during the run, retained for library callers. warnings: Vec, /// Optional CLI-driven log file sink (`--log-file`). file: Option, @@ -264,7 +264,17 @@ impl Logger { self.log_diagnostic_line(msg); } - /// Security warnings emitted during the run. + /// Record a warning for library callers and the primary diagnostic stream. + /// + /// Use this when executor users must see the message in the ordinary + /// buffered/console output while in-process callers also need it through + /// [`warnings`](Self::warnings). + pub fn retained_warning_line(&mut self, msg: &str) { + self.warnings.push(msg.to_string()); + self.log_line(msg); + } + + /// Warnings emitted during the run. pub fn warnings(&self) -> &[String] { &self.warnings } @@ -358,6 +368,16 @@ mod tests { assert!(logger.warnings().is_empty()); } + #[test] + fn retained_warning_line_reaches_primary_output_and_callers() { + let mut logger = Logger::new(Mode::Buffer); + + logger.retained_warning_line("operational warning"); + + assert_eq!(logger.warnings(), ["operational warning"]); + assert_eq!(logger.get_buffer(), "operational warning\n"); + } + /// The body of the child process spawned by /// [`warning_line_writes_nothing_to_stderr`]. Ignored so the normal suite /// run skips it; the parent invokes it by name with `--ignored`. diff --git a/src/core/wxc_common/src/sandbox_process.rs b/src/core/wxc_common/src/sandbox_process.rs index 8d2c626df..ba69956d2 100644 --- a/src/core/wxc_common/src/sandbox_process.rs +++ b/src/core/wxc_common/src/sandbox_process.rs @@ -68,8 +68,9 @@ use crate::validator::{validate_common, validate_network_policy_support, Network /// before touching the other can hang on output-heavy children. Taking only /// one stream (leaving the other for `wait()` to drain) is always safe. pub trait SandboxProcess: Send { - /// Security warnings associated with this sandbox, such as a policy that - /// intentionally relaxes containment. The default is empty. + /// Warnings associated with this sandbox, such as a policy that + /// intentionally relaxes containment or an unavailable telemetry route. + /// The default is empty. fn warnings(&self) -> &[String] { &[] } diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index bffded308..06b882216 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -235,11 +235,12 @@ pub fn init(config: &TelemetryConfig, logger: &mut Logger) -> bool { let activated = mxc_telemetry::init(MXC_VERSION, MXC_CHANNEL); if !activated && cfg!(target_os = "windows") { - logger - .log_line("telemetry: ETW provider registration failed; continuing without telemetry"); + logger.retained_warning_line( + "telemetry: ETW provider registration failed; continuing without telemetry", + ); } if activated && !mxc_telemetry::IS_UTC_ROUTED { - logger.log_line( + logger.retained_warning_line( "telemetry: events are emitted to local ETW only; this build has no provider group \ GUID, so nothing is routed to the Microsoft pipeline (set \ MXC_TELEMETRY_PROVIDER_GROUP_GUID at build time for an internal build)", From 0ad1b035e59c1f3b44edd6cf76feb4192bc16fa4 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Tue, 1 Sep 2026 12:20:51 -0700 Subject: [PATCH 16/47] Fix telemetry lifecycle edge cases Refresh active lifecycle correlation records under a cross-process lock, preserve terminal telemetry across polling errors, and remove obsolete consent CLI tombstones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da373b9-0a51-4aa4-a968-14f05f27b5c7 --- src/core/lxc/src/main.rs | 22 +- src/core/mxc_darwin/src/main.rs | 26 +- src/core/mxc_engine/src/lib.rs | 56 +++- src/core/wxc/src/main.rs | 24 +- src/core/wxc_common/Cargo.toml | 7 +- .../wxc_common/src/telemetry/consent_cli.rs | 158 +----------- .../src/telemetry/correlation_state.rs | 243 ++++++++++++++---- 7 files changed, 261 insertions(+), 275 deletions(-) diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index eb0a610a8..2eb26f33a 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -80,36 +80,16 @@ struct Cli { /// revoke here. See docs/telemetry/telemetry-consent-design.md. #[arg(long = "telemetry-consent-status")] telemetry_consent_status: bool, - - /// Legacy non-mutating tombstone; directs callers to maintenance JSON. - #[arg(long = "telemetry-consent-grant")] - telemetry_consent_grant: bool, - - /// Legacy non-mutating tombstone; directs callers to maintenance JSON. - #[arg(long = "telemetry-consent-revoke")] - telemetry_consent_revoke: bool, - - /// Legacy companion tombstone for the removed grant/revoke flow. - #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] - telemetry_consent_source: Option, } /// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this /// mirrors. On Linux, `wxc_common::telemetry::consent` always reports -/// `NotApplicable`; legacy state-changing flags return the same non-mutating -/// maintenance-JSON migration error as every other executor. /// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so /// this fast path can't drift from `wxc-exec`/`mxc-exec-mac`. The shared /// handler returns the outcome as data; terminating the process is this /// binary's job, not the foundation crate's. fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(outcome) = - telemetry::consent_cli::handle_consent_flags(&telemetry::consent_cli::ConsentCliFlags { - status: cli.telemetry_consent_status, - grant: cli.telemetry_consent_grant, - revoke: cli.telemetry_consent_revoke, - source: cli.telemetry_consent_source.as_deref(), - }) + let Some(outcome) = telemetry::consent_cli::handle_consent_status(cli.telemetry_consent_status) else { return false; }; diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index be4d20494..a049bb7cf 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -68,38 +68,18 @@ struct Cli { /// revoke here. See docs/telemetry/telemetry-consent-design.md. #[arg(long = "telemetry-consent-status")] telemetry_consent_status: bool, - - /// Legacy non-mutating tombstone; directs callers to maintenance JSON. - #[arg(long = "telemetry-consent-grant")] - telemetry_consent_grant: bool, - - /// Not supported on macOS; always fails. Present for CLI-surface - /// parity with wxc-exec.exe. - #[arg(long = "telemetry-consent-revoke")] - telemetry_consent_revoke: bool, - - /// Optional source associated with a legacy grant/revoke flag. - #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] - telemetry_consent_source: Option, } /// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this /// mirrors. On macOS, `wxc_common::telemetry::consent` always reports -/// `NotApplicable`; legacy state-changing flags return the same non-mutating -/// maintenance-JSON migration error as every other executor. /// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so /// this fast path can't drift from `wxc-exec`/`lxc-exec`. The shared handler /// returns the outcome as data; terminating the process is this binary's /// job, not the foundation crate's. fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(outcome) = wxc_common::telemetry::consent_cli::handle_consent_flags( - &wxc_common::telemetry::consent_cli::ConsentCliFlags { - status: cli.telemetry_consent_status, - grant: cli.telemetry_consent_grant, - revoke: cli.telemetry_consent_revoke, - source: cli.telemetry_consent_source.as_deref(), - }, - ) else { + let Some(outcome) = + wxc_common::telemetry::consent_cli::handle_consent_status(cli.telemetry_consent_status) + else { return false; }; let code = outcome.emit(); diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 552091585..8beb57b2c 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -147,9 +147,9 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> /// one terminal telemetry event before the provider reference is released: /// /// * [`SandboxProcess::wait`] — real completion / timeout / spawn error. -/// * [`SandboxProcess::try_wait`] — same, on the poll that observes the exit -/// or an error branch (a `Pending` poll leaves the invariant to a later -/// `wait` / `kill` / `Drop`). +/// * [`SandboxProcess::try_wait`] — emits when a poll observes an exit or a +/// terminal timeout. A pending or otherwise failed poll leaves the invariant +/// to a later successful `try_wait`, `wait`, `kill`, or `Drop`. /// * [`SandboxProcess::kill`] — cancellation event after the wrapped process /// confirms the kill succeeded. A failed kill leaves the slot active for a /// later `wait` / successful `try_wait`. @@ -274,7 +274,7 @@ impl TelemetryProcess { } /// Emit a synthesised terminal event when no real completion result is - /// available (kill, poll-error, drop-without-wait). Routes through + /// available (drop-without-wait). Routes through /// [`Self::emit`] so it shares the exactly-once slot and the /// provider-release path with the natural exit paths. fn emit_synthetic(&mut self, kind: SyntheticTerminal) { @@ -286,10 +286,6 @@ impl TelemetryProcess { std::io::ErrorKind::Other, "sandbox handle was dropped before completion", ), - SyntheticTerminal::PollError => ( - std::io::ErrorKind::Other, - "sandbox handle try_wait returned an error before completion", - ), }; // The Err path in `emit` classifies non-timeout errors as // PostLaunchFailed → FailureReason::InitError (one-shot) or @@ -343,19 +339,19 @@ impl TelemetryProcess { enum SyntheticTerminal { /// The wrapper was dropped without a preceding `wait` / successful `try_wait`. Dropped, - /// `SandboxProcess::try_wait` observed an underlying error. - PollError, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DropDisposition { Exited(i32), + TimedOut, Abandoned, } fn observe_before_drop(process: &mut dyn SandboxProcess) -> DropDisposition { match process.try_wait() { Ok(Some(exit_code)) => DropDisposition::Exited(exit_code), + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => DropDisposition::TimedOut, Ok(None) | Err(_) => DropDisposition::Abandoned, } } @@ -371,6 +367,10 @@ impl Drop for TelemetryProcess { } match observe_before_drop(self.inner.as_mut()) { DropDisposition::Exited(exit_code) => self.emit(&Ok(exit_code)), + DropDisposition::TimedOut => self.emit(&Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "sandbox execution timed out", + ))), DropDisposition::Abandoned => self.emit_synthetic(SyntheticTerminal::Dropped), } } @@ -404,10 +404,14 @@ impl SandboxProcess for TelemetryProcess { Ok(Some(exit_code)) => self.emit(&Ok(*exit_code)), // Still running: leave the invariant to a later `wait` / `kill` / `Drop`. Ok(None) => {} - // Poll-error branch — emit a synthesised terminal event so the - // provider reference isn't released without accounting for the - // run, then return the error unchanged to the caller. - Err(_) => self.emit_synthetic(SyntheticTerminal::PollError), + // Backends use TimedOut only for a settled terminal timeout. + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => { + self.emit(&Err(std::io::Error::new(error.kind(), error.to_string()))); + } + // A poll error does not prove termination. Preserve the slot and + // provider so a later wait, successful poll, kill, or Drop can + // report the actual terminal outcome. + Err(_) => {} } result } @@ -522,6 +526,7 @@ mod telemetry_process_tests { enum TryWaitResult { Running, Exited(i32), + TimedOut, Failed, } @@ -548,6 +553,10 @@ mod telemetry_process_tests { match self.try_wait_result { TryWaitResult::Running => Ok(None), TryWaitResult::Exited(code) => Ok(Some(code)), + TryWaitResult::TimedOut => Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out", + )), TryWaitResult::Failed => Err(std::io::Error::other("poll failed")), } } @@ -617,7 +626,16 @@ mod telemetry_process_tests { let mut poll_failed = wrapped(TryWaitResult::Failed); assert!(poll_failed.try_wait().is_err()); + assert!(poll_failed.active); + assert_eq!(poll_failed.wait().unwrap(), 0); assert!(!poll_failed.active); + + let mut timed_out = wrapped(TryWaitResult::TimedOut); + assert_eq!( + timed_out.try_wait().unwrap_err().kind(), + std::io::ErrorKind::TimedOut + ); + assert!(!timed_out.active); } #[test] @@ -654,6 +672,16 @@ mod telemetry_process_tests { kill_fails: false, }; assert_eq!(observe_before_drop(&mut failed), DropDisposition::Abandoned); + + let mut timed_out = StubProcess { + try_wait_result: TryWaitResult::TimedOut, + wait_result: Ok(0), + kill_fails: false, + }; + assert_eq!( + observe_before_drop(&mut timed_out), + DropDisposition::TimedOut + ); } #[test] diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 9d3f351f2..2688a8780 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -114,20 +114,6 @@ struct Cli { #[arg(long = "telemetry-consent-status")] telemetry_consent_status: bool, - /// Legacy non-mutating tombstone. Returns a migration error directing the - /// caller to the typed JSON consent maintenance envelope. - #[arg(long = "telemetry-consent-grant")] - telemetry_consent_grant: bool, - - /// Legacy non-mutating tombstone. Returns a migration error directing the - /// caller to JSON action `withdraw`. - #[arg(long = "telemetry-consent-revoke")] - telemetry_consent_revoke: bool, - - /// Legacy companion tombstone for the removed grant/revoke flow. - #[arg(long = "telemetry-consent-source", allow_hyphen_values = true)] - telemetry_consent_source: Option, - /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it /// launched, instead of refusing — clears a host wedged by an orphan left /// after a launcher hard-kill. DANGER: proofless, so it may also kill a @@ -228,7 +214,7 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { Ok(()) } -/// Handles the `--telemetry-consent-{status,grant,revoke}` fast paths. +/// Handles the `--telemetry-consent-status` fast path. /// /// Returns `true` if one of the flags was handled (and the caller should /// exit immediately), `false` if none were passed and normal execution @@ -242,13 +228,7 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { /// job, not the foundation crate's. See /// `docs/telemetry/telemetry-consent-design.md`. fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(outcome) = - telemetry::consent_cli::handle_consent_flags(&telemetry::consent_cli::ConsentCliFlags { - status: cli.telemetry_consent_status, - grant: cli.telemetry_consent_grant, - revoke: cli.telemetry_consent_revoke, - source: cli.telemetry_consent_source.as_deref(), - }) + let Some(outcome) = telemetry::consent_cli::handle_consent_status(cli.telemetry_consent_status) else { return false; }; diff --git a/src/core/wxc_common/Cargo.toml b/src/core/wxc_common/Cargo.toml index e58b3b1ab..155a7db56 100644 --- a/src/core/wxc_common/Cargo.toml +++ b/src/core/wxc_common/Cargo.toml @@ -32,7 +32,12 @@ mxc_schema_support = { workspace = true, optional = true } mxc_telemetry = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] -windows = { workspace = true, features = ["Win32_Globalization"] } +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Globalization", + "Win32_Storage_FileSystem", + "Win32_System_IO", +] } windows-core = { workspace = true } widestring = { workspace = true } winreg = { workspace = true } diff --git a/src/core/wxc_common/src/telemetry/consent_cli.rs b/src/core/wxc_common/src/telemetry/consent_cli.rs index f9b569cdb..636b13e9e 100644 --- a/src/core/wxc_common/src/telemetry/consent_cli.rs +++ b/src/core/wxc_common/src/telemetry/consent_cli.rs @@ -13,19 +13,6 @@ use crate::wire; const PRESENTER_PROTOCOL_ENV: &str = "MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL"; -/// Telemetry-consent CLI flags. -#[derive(Debug, Clone, Copy)] -pub struct ConsentCliFlags<'a> { - /// `--telemetry-consent-status` - pub status: bool, - /// `--telemetry-consent-grant` - pub grant: bool, - /// `--telemetry-consent-revoke` - pub revoke: bool, - /// `--telemetry-consent-source`; defaults to `"cli"` when absent. - pub source: Option<&'a str>, -} - /// Output and exit code for a handled consent command. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConsentCliOutcome { @@ -33,8 +20,7 @@ pub struct ConsentCliOutcome { pub stdout: Option, /// Line to print to stderr, if any. pub stderr: Option, - /// Process exit code: `0` on success, `64` (`EX_USAGE`) for mutually - /// exclusive flags, `1` for a failed write or serialization. + /// Process exit code: `0` on success, `1` for failed serialization. pub exit_code: i32, } @@ -74,22 +60,12 @@ impl ConsentCliOutcome { } } -/// Handle legacy consent flags. Returns `None` when no flag was supplied. -pub fn handle_consent_flags(flags: &ConsentCliFlags<'_>) -> Option { - if !(flags.status || flags.grant || flags.revoke || flags.source.is_some()) { +/// Handle the telemetry consent status flag. Returns `None` when absent. +pub fn handle_consent_status(status: bool) -> Option { + if !status { return None; } - if flags.grant || flags.revoke || flags.source.is_some() { - return Some(ConsentCliOutcome::failure( - "Error: --telemetry-consent-grant, --telemetry-consent-revoke, and \ - --telemetry-consent-source no longer change consent. Use the typed \ - telemetry-consent JSON maintenance envelope instead." - .to_string(), - 64, - )); - } - Some(ConsentCliOutcome::json(&maintenance_response( wire::TelemetryConsentAction::Status, wire::TelemetryConsentResult::Status, @@ -470,15 +446,6 @@ mod tests { use super::*; use crate::telemetry::consent_prompt::{ConsentPrompt, EN_US_CONSENT_PROMPT}; - fn flags(status: bool, grant: bool, revoke: bool) -> ConsentCliFlags<'static> { - ConsentCliFlags { - status, - grant, - revoke, - source: None, - } - } - fn prompt() -> ConsentPrompt { EN_US_CONSENT_PROMPT } @@ -526,8 +493,8 @@ mod tests { } #[test] - fn no_flags_is_a_noop() { - assert!(handle_consent_flags(&flags(false, false, false)).is_none()); + fn absent_status_flag_is_a_noop() { + assert!(handle_consent_status(false).is_none()); } fn assert_status_json( @@ -547,20 +514,6 @@ mod tests { assert_eq!(value["needsPrompt"], needs_prompt); } - /// Previously unreachable in-process: this branch called - /// `std::process::exit(64)` and would have killed the test runner. - #[test] - fn legacy_state_changing_flags_return_a_migration_error() { - let outcome = handle_consent_flags(&flags(false, true, true)).expect("handled"); - assert_eq!(outcome.exit_code, 64); - assert_eq!(outcome.stdout, None); - assert!(outcome - .stderr - .as_deref() - .unwrap() - .contains("JSON maintenance envelope")); - } - #[test] fn presenter_response_is_bound_to_challenge_and_prompt_version() { let valid = wire::TelemetryConsentPresenterResponse { @@ -662,9 +615,8 @@ mod tests { } /// The non-Windows contract every executor must honor: status is a - /// successful `not-applicable` report, and grant/revoke are refused - /// rather than silently accepted. MXC must never offer — or appear to - /// record — consent on a platform where it cannot collect telemetry. + /// successful `not-applicable` report. MXC must never offer — or appear + /// to record — consent on a platform where it cannot collect telemetry. /// /// Gated to non-Windows (rather than merged into the Windows tests) /// because it asserts the *stub* behavior; without it, Linux/macOS CI @@ -675,7 +627,7 @@ mod tests { #[test] fn status_reports_not_applicable_and_succeeds() { - let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + let outcome = handle_consent_status(true).expect("handled"); assert_eq!(outcome.exit_code, 0); assert_status_json( &outcome, @@ -686,37 +638,10 @@ mod tests { ); assert_eq!(outcome.stderr, None); } - - #[test] - fn grant_is_refused() { - let outcome = handle_consent_flags(&flags(false, true, false)).expect("handled"); - assert_eq!(outcome.exit_code, 64); - assert_eq!(outcome.stdout, None); - assert!(outcome - .stderr - .as_deref() - .unwrap() - .contains("JSON maintenance envelope")); - } - - #[test] - fn revoke_is_refused() { - let outcome = handle_consent_flags(&flags(false, false, true)).expect("handled"); - assert_eq!(outcome.exit_code, 64); - assert_eq!(outcome.stdout, None); - assert!(outcome - .stderr - .as_deref() - .unwrap() - .contains("JSON maintenance envelope")); - } } - /// End-to-end coverage of the `handle_consent_flags` paths (grant, - /// revoke, status, and a forced write failure) against an isolated - /// consent store — this is the same fast path all three executors - /// (`wxc-exec`, `lxc-exec`, `mxc-exec-mac`) delegate to, so exercising it - /// here covers the shared behavior for all three executors. + /// End-to-end coverage of the status path against an isolated consent + /// store. This is the same fast path all three executors delegate to. #[cfg(target_os = "windows")] mod windows_tests { use super::*; @@ -730,46 +655,12 @@ mod tests { TelemetryTestEnv::new(tmp) } - #[test] - fn grant_flag_is_a_non_mutating_migration_tombstone() { - let tmp = tempfile::tempdir().unwrap(); - let _guards = isolate(tmp.path()); - - let outcome = handle_consent_flags(&ConsentCliFlags { - status: false, - grant: true, - revoke: false, - source: Some("prompt"), - }) - .expect("handled"); - assert_eq!(outcome.exit_code, 64); - assert_eq!(outcome.stdout, None); - assert_eq!(consent::get_consent().as_str(), "undetermined"); - } - - #[test] - fn revoke_flag_is_a_non_mutating_migration_tombstone() { - let tmp = tempfile::tempdir().unwrap(); - let _guards = isolate(tmp.path()); - - let outcome = handle_consent_flags(&ConsentCliFlags { - status: false, - grant: false, - revoke: true, - source: Some("settings-toggle"), - }) - .expect("handled"); - assert_eq!(outcome.exit_code, 64); - assert_eq!(outcome.stdout, None); - assert_eq!(consent::get_consent().as_str(), "undetermined"); - } - #[test] fn status_flag_reports_current_state_without_mutating_it() { let tmp = tempfile::tempdir().unwrap(); let _guards = isolate(tmp.path()); - let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + let outcome = handle_consent_status(true).expect("handled"); assert_eq!(outcome.exit_code, 0); assert_status_json( &outcome, @@ -791,34 +682,11 @@ mod tests { let env = isolate(tmp.path()); env.set_policy_value(0); - let outcome = handle_consent_flags(&flags(true, false, false)).expect("handled"); + let outcome = handle_consent_status(true).expect("handled"); assert_eq!(outcome.exit_code, 0); assert_status_json(&outcome, "undetermined", "undetermined", "blocked", false); } - /// A user may still record a decision while policy blocks collection; - /// it is honoured if the administrator later relaxes the policy. What - /// must not happen is the grant being treated as collectable. - #[test] - fn grant_tombstone_does_not_record_under_a_blocking_policy() { - let tmp = tempfile::tempdir().unwrap(); - let env = isolate(tmp.path()); - env.set_policy_value(0); - - let outcome = handle_consent_flags(&ConsentCliFlags { - status: false, - grant: true, - revoke: false, - source: Some("cli"), - }) - .expect("handled"); - assert_eq!(outcome.exit_code, 64); - assert_eq!(consent::get_consent(), consent::ConsentState::Undetermined); - assert!(!crate::telemetry::is_enabled( - &crate::models::TelemetryConfig::default() - )); - } - /// Previously unreachable in-process: this branch called /// `std::process::exit(1)`. A regular *file* named `mxc` where the /// store's parent directory belongs makes `create_dir_all` fail, so diff --git a/src/core/wxc_common/src/telemetry/correlation_state.rs b/src/core/wxc_common/src/telemetry/correlation_state.rs index ecf271bf1..0046642c4 100644 --- a/src/core/wxc_common/src/telemetry/correlation_state.rs +++ b/src/core/wxc_common/src/telemetry/correlation_state.rs @@ -29,6 +29,8 @@ use super::correlation_vector::{is_relayable, seed, spin}; const RECORD_EXTENSION: &str = "cv"; const STALE_RECORD_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60); const STALE_TMP_FILE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); +const STORE_LOCK_RETRY_COUNT: usize = 200; +const STORE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(5); const PRUNE_DELETE_LIMIT: usize = 256; /// Pre-dispatch correlation vector for a state-aware phase. `provision` always @@ -42,10 +44,17 @@ pub fn pre_dispatch_vector(active: bool, is_provision: bool, sandbox_id: Option< if !active { return String::new(); } - prune_stale_records(); match sandbox_id { - Some(id) if !is_provision => phase_vector(id), - _ => seed(), + Some(id) if !is_provision => { + // Load and refresh the active lifecycle before sweeping abandoned + // records. Otherwise a valid sandbox whose last phase was over the + // retention threshold ago would delete its own root here. + phase_vector(id) + } + _ => { + prune_stale_records(None); + seed() + } } } @@ -99,7 +108,7 @@ fn should_forget_after_deprovision(outcome: &Result) /// Recall the persisted lifecycle root for `sandbox_id` and spin a child off /// it. Seeds a fresh, disconnected vector if no valid record exists. fn phase_vector(sandbox_id: &str) -> String { - match with_store(|store| store.load(sandbox_id)) { + match with_store(|store| store.load_and_prune(sandbox_id)) { Some(root) if is_relayable(&root) => spin(&root), _ => seed(), } @@ -139,8 +148,8 @@ fn record_key(sandbox_id: &str) -> String { encoded } -fn prune_stale_records() { - with_store(|store| store.prune_stale()); +fn prune_stale_records(protected_sandbox_id: Option<&str>) { + with_store(|store| store.prune_stale(protected_sandbox_id)); } fn with_store(f: impl FnOnce(&dyn CorrelationStore) -> R) -> R { @@ -156,14 +165,56 @@ fn with_store(f: impl FnOnce(&dyn CorrelationStore) -> R) -> R { pub(crate) trait CorrelationStore: Send + Sync { fn persist(&self, sandbox_id: &str, root: &str); fn load(&self, sandbox_id: &str) -> Option; + fn load_and_prune(&self, sandbox_id: &str) -> Option { + let root = self.load(sandbox_id); + self.prune_stale(Some(sandbox_id)); + root + } fn forget(&self, sandbox_id: &str); - fn prune_stale(&self); + fn prune_stale(&self, protected_sandbox_id: Option<&str>); } struct FilesystemCorrelationStore { dir: Option, } +struct StoreOperationLock { + _file: fs::File, +} + +#[cfg(unix)] +fn try_lock_file(file: &fs::File) -> bool { + use std::os::fd::AsRawFd; + + // SAFETY: `file` owns a valid descriptor for the duration of this call. + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) == 0 } +} + +#[cfg(windows)] +fn try_lock_file(file: &fs::File) -> bool { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, + }; + use windows::Win32::System::IO::OVERLAPPED; + + // SAFETY: the handle remains valid for the lock's lifetime because the + // returned guard owns `file`; the zeroed OVERLAPPED selects byte offset 0. + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + unsafe { + LockFileEx( + HANDLE(file.as_raw_handle()), + LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + None, + u32::MAX, + u32::MAX, + &mut overlapped, + ) + .is_ok() + } +} + impl FilesystemCorrelationStore { fn from_env() -> Self { Self { dir: store_dir() } @@ -191,10 +242,51 @@ impl FilesystemCorrelationStore { }) } + fn operation_lock(&self) -> Option { + let dir = self.dir.as_ref()?; + if fs::create_dir_all(dir).is_err() { + return None; + } + let path = dir.join(".store-lock"); + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) + .ok()?; + for _ in 0..STORE_LOCK_RETRY_COUNT { + if try_lock_file(&file) { + return Some(StoreOperationLock { _file: file }); + } + std::thread::sleep(STORE_LOCK_RETRY_DELAY); + } + None + } + fn read_record(path: &Path) -> Option { fs::read_to_string(path).ok().map(|s| s.trim().to_string()) } + fn refresh_record(path: &Path) { + let times = fs::FileTimes::new().set_modified(SystemTime::now()); + if let Ok(file) = fs::OpenOptions::new().write(true).open(path) { + let _ = file.set_times(times); + } + } + + fn load_unlocked(&self, sandbox_id: &str) -> Option { + let path = self.record_path(sandbox_id)?; + if let Some(root) = Self::read_record(&path) { + if is_relayable(&root) { + Self::refresh_record(&path); + return Some(root); + } + let _ = fs::remove_file(&path); + } + None + } + fn write_record(&self, sandbox_id: &str, path: &Path, root: &str) { let Some(dir) = path.parent() else { return; @@ -244,39 +336,13 @@ impl FilesystemCorrelationStore { .map(|age| age > STALE_RECORD_MAX_AGE) .unwrap_or(false) } -} - -impl CorrelationStore for FilesystemCorrelationStore { - fn persist(&self, sandbox_id: &str, root: &str) { - let Some(path) = self.record_path(sandbox_id) else { - return; - }; - self.write_record(sandbox_id, &path, root); - } - - fn load(&self, sandbox_id: &str) -> Option { - let path = self.record_path(sandbox_id)?; - if let Some(root) = Self::read_record(&path) { - if is_relayable(&root) { - return Some(root); - } - let _ = fs::remove_file(&path); - return None; - } - - None - } - - fn forget(&self, sandbox_id: &str) { - if let Some(path) = self.record_path(sandbox_id) { - let _ = fs::remove_file(path); - } - } - fn prune_stale(&self) { + fn prune_stale_unlocked(&self, protected_sandbox_id: Option<&str>) { let Some(dir) = self.dir.as_ref() else { return; }; + let protected_path = + protected_sandbox_id.and_then(|sandbox_id| self.record_path(sandbox_id)); let Ok(entries) = fs::read_dir(dir) else { return; }; @@ -294,6 +360,9 @@ impl CorrelationStore for FilesystemCorrelationStore { if path.extension().and_then(|ext| ext.to_str()) != Some(RECORD_EXTENSION) { continue; } + if protected_path.as_deref() == Some(path.as_path()) { + continue; + } if deleted < PRUNE_DELETE_LIMIT && Self::stale_record(&path, now) { let _ = fs::remove_file(path); deleted += 1; @@ -302,6 +371,46 @@ impl CorrelationStore for FilesystemCorrelationStore { } } +impl CorrelationStore for FilesystemCorrelationStore { + fn persist(&self, sandbox_id: &str, root: &str) { + let Some(_lock) = self.operation_lock() else { + return; + }; + let Some(path) = self.record_path(sandbox_id) else { + return; + }; + self.write_record(sandbox_id, &path, root); + } + + fn load(&self, sandbox_id: &str) -> Option { + let _lock = self.operation_lock()?; + self.load_unlocked(sandbox_id) + } + + fn load_and_prune(&self, sandbox_id: &str) -> Option { + let _lock = self.operation_lock()?; + let root = self.load_unlocked(sandbox_id); + self.prune_stale_unlocked(Some(sandbox_id)); + root + } + + fn forget(&self, sandbox_id: &str) { + let Some(_lock) = self.operation_lock() else { + return; + }; + if let Some(path) = self.record_path(sandbox_id) { + let _ = fs::remove_file(path); + } + } + + fn prune_stale(&self, protected_sandbox_id: Option<&str>) { + let Some(_lock) = self.operation_lock() else { + return; + }; + self.prune_stale_unlocked(protected_sandbox_id); + } +} + #[cfg(any(test, feature = "test-support", debug_assertions))] pub mod test_support { use std::path::Path; @@ -406,7 +515,7 @@ mod tests { .remove(sandbox_id); } - fn prune_stale(&self) { + fn prune_stale(&self, _protected_sandbox_id: Option<&str>) { *self.prune_count.lock().unwrap_or_else(|e| e.into_inner()) += 1; } } @@ -682,26 +791,55 @@ mod tests { } #[test] - fn stale_record_is_pruned_during_startup_sweep() { + fn requested_long_lived_record_is_refreshed_while_unrelated_stale_record_is_pruned() { let tmp = tempfile::tempdir().unwrap(); let _guard = StoreDirGuard::set(tmp.path()); - let sandbox_id = "wsb:stale000"; + let sandbox_id = "wsb:active00"; + let abandoned_id = "wsb:stale000"; let root = seed(); let base = root.split('.').next().unwrap().to_string(); let store = FilesystemCorrelationStore::from_store_root(tmp.path().to_path_buf()); let path = store.record_path(sandbox_id).unwrap(); + let abandoned_path = store.record_path(abandoned_id).unwrap(); fs::create_dir_all(tmp.path()).unwrap(); fs::write(&path, &root).unwrap(); - filetime::set_file_mtime( - &path, - FileTime::from_system_time( - SystemTime::now() - STALE_RECORD_MAX_AGE - Duration::from_secs(1), - ), - ) - .unwrap(); + fs::write(&abandoned_path, seed()).unwrap(); + let stale_time = SystemTime::now() - STALE_RECORD_MAX_AGE - Duration::from_secs(1); + filetime::set_file_mtime(&path, FileTime::from_system_time(stale_time)).unwrap(); + filetime::set_file_mtime(&abandoned_path, FileTime::from_system_time(stale_time)).unwrap(); + let spun = pre_dispatch_vector(true, false, Some(sandbox_id)); - assert_ne!(spun.split('.').next().unwrap(), base); - assert!(!path.exists()); + assert_eq!(spun.split('.').next().unwrap(), base); + assert!(path.exists()); + assert!( + FilesystemCorrelationStore::file_age(&path, SystemTime::now()).unwrap() + < Duration::from_secs(10), + "successful load must refresh the active record's age" + ); + assert!(!abandoned_path.exists()); + } + + #[test] + fn store_lock_excludes_a_second_file_handle_until_release() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".store-lock"); + let first = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .unwrap(); + let second = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + + assert!(try_lock_file(&first)); + assert!(!try_lock_file(&second)); + drop(first); + assert!(try_lock_file(&second)); } #[test] @@ -740,7 +878,14 @@ mod tests { let _ = pre_dispatch_vector(true, true, None); - assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 10); + let remaining_records = fs::read_dir(tmp.path()) + .unwrap() + .flatten() + .filter(|entry| { + entry.path().extension().and_then(|ext| ext.to_str()) == Some(RECORD_EXTENSION) + }) + .count(); + assert_eq!(remaining_records, 10); } #[test] From 4169b06f5d13daef4e94afc0256d6b675aca4366 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 11:12:45 -0700 Subject: [PATCH 17/47] Consolidate telemetry runtime consent pivot Keep the native 0.9 telemetry boundary and move consent maintenance to dedicated executor APIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .github/copilot-instructions.md | 1 + README.md | 11 + docs/telemetry/telemetry-consent-design.md | 38 + docs/telemetry/telemetry.md | 12 + .../dev/mxc-telemetry-consent.schema.1.json | 79 -- scripts/versioning/check-schema-codegen.js | 18 +- src/core/lxc/src/main.rs | 106 ++- src/core/mxc_darwin/src/main.rs | 106 +-- src/core/wxc/src/main.rs | 199 +++-- src/core/wxc_common/src/config_parser.rs | 30 +- .../wxc_common/src/telemetry/consent_cli.rs | 796 ++++++++++++------ .../src/telemetry/consent_protocol.rs | 108 +++ src/core/wxc_common/src/telemetry/mod.rs | 1 + src/core/wxc_common/src/wire.rs | 207 +---- .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 2 +- src/tools/mxc_schema_gen/src/main.rs | 25 +- .../stdio-v1-decision-dismissed.json | 1 + .../stdio-v1-decision-no.json | 1 + .../stdio-v1-decision-unknown-field.json | 1 + .../stdio-v1-decision-yes.json | 1 + .../run_telemetry_consent_smoke_test.ps1 | 79 +- .../scripts/run_telemetry_etw_smoke_test.ps1 | 12 +- 22 files changed, 1071 insertions(+), 763 deletions(-) delete mode 100644 schemas/dev/mxc-telemetry-consent.schema.1.json create mode 100644 src/core/wxc_common/src/telemetry/consent_protocol.rs create mode 100644 tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json create mode 100644 tests/fixtures/telemetry-consent/stdio-v1-decision-no.json create mode 100644 tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json create mode 100644 tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d134f4b2a..7fa102e37 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -332,6 +332,7 @@ Telemetry is **Windows-only** and is never collected without explicit user conse - **Everything fails closed.** Any error, unreadable value, corrupt file, missing native library, or ambiguity must resolve to "no telemetry" (`Undetermined` consent / `Blocked` policy), never to a permissive state. - **Policy may restrict, but may never substitute for, consent.** The administrative policy is a deny-only ceiling: it can subtract from what a user permitted, never add to it. An administrator cannot opt a user in — a denied or never-asked user stays opted out even under `AllowTelemetry=3`. Keep the terms combined with `&&`; never add a policy value or config path that grants collection on its own. - **MXC owns its own consent state.** It must never read or infer from the Windows system telemetry consent. The consent store is a per-user JSON file; the policy is `HKLM\SOFTWARE\Policies\Mxc` → `AllowTelemetry` (`REG_DWORD`). +- **Consent is a separate control plane.** Executors expose `--telemetry-consent `; never route consent maintenance through `--config` / `--config-base64`, add it to `MxcConfig`, or publish a consent JSON schema. `telemetry.enabled` remains only the per-run opt-in. - **One definition.** The Rust `ConsentState` / `PolicyState` enums and their stable string representations are the source of truth. Keep those spellings aligned anywhere this branch surfaces them. - **Test isolation.** The consent store and the policy key are process-global, each behind its own mutex. Use `wxc_common::telemetry::test_support::TelemetryTestEnv` whenever a test needs both; constructing `PolicyKeyGuard` and `LocalAppDataGuard` directly in the same test risks a lock-order deadlock. The consent-store override is compiled into test binaries and into debug `test-support` builds only, so release binaries do not honor it. Keep the `test-support` feature confined to test/development dependencies. - **Never swallow a failure silently.** Fail-closed return values are indistinguishable from legitimate ones, so a broken install would otherwise be invisible. Every swallowed failure is reported once per distinct failure per process, and the reporter itself must never throw. diff --git a/README.md b/README.md index 22d913db7..be6b8b1e6 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,17 @@ Telemetry is **off by default**. To keep it off, do not set If telemetry is enabled in config, collection still does not occur unless Windows user consent is granted and administrative policy allows collection. +Consent is managed separately from execution config: + +```powershell +wxc-exec.exe --telemetry-consent status +wxc-exec.exe --telemetry-consent request +wxc-exec.exe --telemetry-consent withdraw +``` + +`telemetry.enabled` cannot request, grant, or withdraw consent. Consent +maintenance is not accepted through `--config` or `--config-base64`. + #### What official builds send Official/shipped Microsoft builds set a TraceLogging provider group GUID at build time and route `MXC.Execution` and `MXC.Error` events to Microsoft through the UTC pipeline when telemetry is enabled. **Local and open-source builds send nothing to Microsoft by default** — the public source ships without a provider group GUID, so events are emitted to the local ETW subsystem only and are not routed to any Microsoft collection pipeline. Internal builds that set the `MXC_TELEMETRY_PROVIDER_GROUP_GUID` environment variable at build time enable the Microsoft-routed path. diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index 084823e89..1e4cfa96a 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -96,6 +96,44 @@ If the host never calls the request API, telemetry remains off. A presenter failure must be surfaced as an error to the host and must not create or alter a grant. +## Executor consent command + +Consent maintenance is a dedicated executor control plane, not an MXC +execution configuration: + +```text +wxc-exec --telemetry-consent status +wxc-exec --telemetry-consent withdraw +wxc-exec --telemetry-consent request +``` + +`lxc-exec` and `mxc-exec-mac` expose the same command and return +`notApplicable` domain outcomes because telemetry collection is Windows-only. +`request` optionally accepts `--telemetry-consent-locale `; missing or +unsupported locales use the canonical `en-US` fallback. Interactive requests +write the prompt to stderr, read `Y` or `N` from a terminal, and write one final +JSON outcome to stdout. A request without terminal stdin and stderr fails +without changing consent. + +The Node SDK uses the hidden +`--telemetry-consent-protocol stdio-v1` request mode because it owns the host +presentation callback. Native MXC emits at most one `presentationRequired` +JSON line followed by one final JSON line. The host returns exactly one JSON +decision line containing the emitted challenge, prompt resource version, and +`yes`, `no`, or `dismissed`. Input is limited to 64 KiB; malformed input, +unknown fields, extra lines, challenge mismatch, and resource-version mismatch +are rejected without persistence. + +Domain outcomes exit `0`. Invalid command/protocol input exits `64`. +Operational failures such as a missing terminal, required-decision EOF, broken +pipe, presenter failure, or persistence failure exit `1`. `status`, +`withdraw`, and short-circuit request outcomes never invoke a presenter. + +Consent maintenance objects are deliberately not accepted through `--config` +or `--config-base64`, are not part of `MxcConfig`, and have no published JSON +Schema. The stable `telemetry.enabled` execution setting remains only the +per-run opt-in and cannot request, grant, or withdraw consent. + ## State and persistence MXC stores consent per user at: diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index e3221282e..b1bd7f2a6 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -182,6 +182,18 @@ Telemetry emission is gated by the per-run request, MXC-owned consent, administrative policy, and provider availability. See [`docs/telemetry/telemetry-consent-design.md`](telemetry-consent-design.md). +Consent is managed through the dedicated executor control plane: + +```text +wxc-exec --telemetry-consent status +wxc-exec --telemetry-consent request +wxc-exec --telemetry-consent withdraw +``` + +These actions are not MXC execution configurations and are never accepted +through `--config` or `--config-base64`. The stable `telemetry.enabled` +setting remains only the additional per-run opt-in. + ## Privacy review status The version 1 `en-US` consent wording is approved for release review. The diff --git a/schemas/dev/mxc-telemetry-consent.schema.1.json b/schemas/dev/mxc-telemetry-consent.schema.1.json deleted file mode 100644 index 984eaeb43..000000000 --- a/schemas/dev/mxc-telemetry-consent.schema.1.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/microsoft/mxc/schemas/dev/mxc-telemetry-consent.schema.1.json", - "title": "MXC Telemetry Consent Maintenance Request", - "description": "Telemetry-consent maintenance request.\n\nThis is a separate contract from [`MxcConfig`], even though executors use the same JSON input loader for both. Its explicit `command` discriminator lets the loader route maintenance without widening the execution schema.", - "additionalProperties": false, - "definitions": { - "TelemetryConsentAction": { - "description": "Telemetry-consent maintenance action.", - "oneOf": [ - { - "description": "Present the canonical prompt when consent may be requested.", - "enum": [ - "request" - ], - "type": "string" - }, - { - "description": "Idempotently persist denied consent.", - "enum": [ - "withdraw" - ], - "type": "string" - }, - { - "description": "Read status without mutation.", - "enum": [ - "status" - ], - "type": "string" - } - ] - }, - "TelemetryConsentCommand": { - "description": "Fixed telemetry-consent maintenance command discriminator.", - "enum": [ - "telemetryConsent" - ], - "type": "string" - } - }, - "properties": { - "$schema": { - "description": "Optional JSON Schema reference for editor validation.", - "type": [ - "string", - "null" - ] - }, - "action": { - "allOf": [ - { - "$ref": "#/definitions/TelemetryConsentAction" - } - ], - "description": "Consent operation to perform." - }, - "command": { - "allOf": [ - { - "$ref": "#/definitions/TelemetryConsentCommand" - } - ], - "description": "Fixed maintenance discriminator. Must be `\"telemetryConsent\"`." - }, - "locale": { - "description": "Preferred BCP 47 locale for the canonical prompt. Unsupported locales fall back to `en-US`. Used only by `request`.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "action", - "command" - ], - "type": "object" -} diff --git a/scripts/versioning/check-schema-codegen.js b/scripts/versioning/check-schema-codegen.js index 9980ba0e3..e48fa70c2 100644 --- a/scripts/versioning/check-schema-codegen.js +++ b/scripts/versioning/check-schema-codegen.js @@ -34,16 +34,8 @@ const configSchemaPath = join( "dev", `mxc-config.schema.${schemaVer.devSchemaFile}.json` ); -const telemetryConsentSchemaPath = join( - repoRoot, - "schemas", - "dev", - "mxc-telemetry-consent.schema.1.json" -); - const tmpDir = mkdtempSync(join(os.tmpdir(), "mxc-schema-gen-")); const tmpConfigOut = join(tmpDir, "generated-config.json"); -const tmpTelemetryConsentOut = join(tmpDir, "generated-telemetry-consent.json"); try { const normalize = (s) => s.replace(/\r\n/g, "\n"); @@ -98,18 +90,10 @@ try { generatorArgs: ["schema", "--legacy-wire"], regenCommand: `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schema --legacy-wire --out schemas/dev/mxc-config.schema.${schemaVer.devSchemaFile}.json`, }); - - compareGeneratedSchema({ - committedPath: telemetryConsentSchemaPath, - generatedPath: tmpTelemetryConsentOut, - generatorArgs: ["schema", "--telemetry-consent"], - regenCommand: - "cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schema --telemetry-consent --out schemas/dev/mxc-telemetry-consent.schema.1.json", - }); } finally { rmSync(tmpDir, { recursive: true, force: true }); } console.log( - `Schema codegen OK: committed config and telemetry consent schemas match generated output (${schemaVer.devSchemaFile}, telemetry consent v1).` + `Schema codegen OK: committed config schema matches generated output (${schemaVer.devSchemaFile}).` ); diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 88761316a..5a857c992 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -74,12 +74,57 @@ struct Cli { #[arg(long, requires = "setup_hyperlight")] force: bool, - /// Report the persisted telemetry consent state and exit. MXC only - /// ever collects telemetry on Windows, so on Linux this always - /// reports "not-applicable" — there is no consent to grant or - /// revoke here. See docs/telemetry/telemetry-consent-design.md. - #[arg(long = "telemetry-consent-status")] - telemetry_consent_status: bool, + /// Manage telemetry consent without spawning a sandbox. + #[arg( + long = "telemetry-consent", + value_name = "ACTION", + conflicts_with_all = [ + "config_path", + "config", + "config_base64", + "delete", + "containername", + "experimental", + "allow_testing_features", + "dry_run", + "setup_hyperlight", + "force" + ] + )] + telemetry_consent: Option, + + /// Preferred BCP 47 locale for a telemetry consent request. + #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] + telemetry_consent_locale: Option, + + /// Private SDK presenter protocol. + #[arg( + long = "telemetry-consent-protocol", + requires = "telemetry_consent", + hide = true + )] + telemetry_consent_protocol: Option, +} + +fn parse_cli() -> Cli { + match Cli::try_parse() { + Ok(cli) => cli, + Err(error) => { + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) { + error.exit(); + } + let is_consent_command = + telemetry::consent_cli::invocation_uses_consent_options(std::env::args_os()); + if is_consent_command { + let _ = error.print(); + process::exit(64); + } + error.exit(); + } + } } /// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this @@ -89,24 +134,14 @@ struct Cli { /// handler returns the outcome as data; terminating the process is this /// binary's job, not the foundation crate's. fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(outcome) = telemetry::consent_cli::handle_consent_status(cli.telemetry_consent_status) - else { - return false; - }; - let code = outcome.emit(); - if code != 0 { - std::process::exit(code); - } - true -} - -fn handle_telemetry_consent_input(json: Option<&str>) -> bool { - let Some(json) = json else { - return false; - }; - let Some(outcome) = telemetry::consent_cli::handle_maintenance_input_json(json) else { + let Some(action) = cli.telemetry_consent else { return false; }; + let outcome = telemetry::consent_cli::handle_consent_command( + action, + cli.telemetry_consent_locale.as_deref(), + cli.telemetry_consent_protocol, + ); let code = outcome.emit(); if code != 0 { std::process::exit(code); @@ -114,11 +149,7 @@ fn handle_telemetry_consent_input(json: Option<&str>) -> bool { true } -/// Read the request source (file path / base64 blob) once, returning the -/// decoded JSON. Reused by the maintenance probe and the normal request -/// loader so a single source is only read once per invocation — a -/// correctness fix for named pipes, `/dev/stdin`, and process-substitution -/// paths that are drained by the first read. +/// Read the request source (file path / base64 blob) once. fn decode_config_input_once(cli: &Cli) -> Option> { let (input, is_base64) = if let Some(input) = cli.config_base64.as_ref() { (input.clone(), true) @@ -183,9 +214,9 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { } fn main() { - let cli = Cli::parse(); + let cli = parse_cli(); - // --telemetry-consent-{status,grant,revoke}: report/administer the + // --telemetry-consent: report/administer the // (always not-applicable on Linux) consent state and exit. Runs before // signal_cleanup::install(): // this is a read-only/local-file fast path that never spawns a @@ -195,28 +226,13 @@ fn main() { if handle_telemetry_consent_flags(&cli) { return; } - // Decode the request source (file path / base64) once, up front, so the - // maintenance probe and the normal request loader below share the same - // decoded JSON — necessary for named pipes, `/dev/stdin`, and - // process-substitution paths that are drained by the first read. + // Decode the request source (file path / base64) once, up front. let decoded_config: Option> = decode_config_input_once(&cli); let request_hint = decoded_config .as_ref() .and_then(|result| result.as_ref().ok()) .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); - if matches!( - request_hint.as_ref().map(|hint| hint.kind()), - Some(wxc_common::config_parser::RequestKind::TelemetryConsent) - ) && handle_telemetry_consent_input( - decoded_config - .as_ref() - .and_then(|r| r.as_ref().ok()) - .map(String::as_str), - ) { - return; - } - // Install before spawning any other threads so the signal mask propagates. // Failure here is fatal: install() either succeeds with the watchdog // running, or restores the original signal mask and returns Err. We diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index a049bb7cf..ff45169df 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -62,12 +62,55 @@ struct Cli { #[arg(long = "log-file")] log_file: Option, - /// Report the persisted telemetry consent state and exit. MXC only - /// ever collects telemetry on Windows, so on macOS this always - /// reports "not-applicable" — there is no consent to grant or - /// revoke here. See docs/telemetry/telemetry-consent-design.md. - #[arg(long = "telemetry-consent-status")] - telemetry_consent_status: bool, + /// Manage telemetry consent without spawning a sandbox. + #[arg( + long = "telemetry-consent", + value_name = "ACTION", + conflicts_with_all = [ + "config_path", + "config", + "config_base64", + "experimental", + "allow_testing_features", + "dry_run" + ] + )] + telemetry_consent: Option, + + /// Preferred BCP 47 locale for a telemetry consent request. + #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] + telemetry_consent_locale: Option, + + /// Private SDK presenter protocol. + #[arg( + long = "telemetry-consent-protocol", + requires = "telemetry_consent", + hide = true + )] + telemetry_consent_protocol: Option, +} + +fn parse_cli() -> Cli { + match Cli::try_parse() { + Ok(cli) => cli, + Err(error) => { + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) { + error.exit(); + } + let is_consent_command = + wxc_common::telemetry::consent_cli::invocation_uses_consent_options( + std::env::args_os(), + ); + if is_consent_command { + let _ = error.print(); + process::exit(64); + } + error.exit(); + } + } } /// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this @@ -77,26 +120,14 @@ struct Cli { /// returns the outcome as data; terminating the process is this binary's /// job, not the foundation crate's. fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(outcome) = - wxc_common::telemetry::consent_cli::handle_consent_status(cli.telemetry_consent_status) - else { - return false; - }; - let code = outcome.emit(); - if code != 0 { - std::process::exit(code); - } - true -} - -fn handle_telemetry_consent_input(config_json: Option<&str>) -> bool { - let Some(input) = config_json else { - return false; - }; - let Some(outcome) = wxc_common::telemetry::consent_cli::handle_maintenance_input_json(input) - else { + let Some(action) = cli.telemetry_consent else { return false; }; + let outcome = wxc_common::telemetry::consent_cli::handle_consent_command( + action, + cli.telemetry_consent_locale.as_deref(), + cli.telemetry_consent_protocol, + ); let code = outcome.emit(); if code != 0 { std::process::exit(code); @@ -106,12 +137,6 @@ fn handle_telemetry_consent_input(config_json: Option<&str>) -> bool { /// Decode the config input source (base64 arg / file path) exactly once. /// -/// The maintenance-consent probe and the normal request loader both need the -/// decoded JSON. Named pipes, `/dev/stdin`, and process-substitution paths -/// are drained by the first read, so reading them twice fails or blocks; -/// regular files pay duplicate I/O for no reason. This helper reads the -/// source a single time and returns the JSON text (or a load error). -/// /// Returns `None` if the CLI provided no config source at all — the caller /// then reports the appropriate missing-config error for its mode. fn decode_config_input_once(cli: &Cli) -> Option> { @@ -144,35 +169,20 @@ fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { } fn main() { - let cli = Cli::parse(); + let cli = parse_cli(); - // --telemetry-consent-{status,grant,revoke}: report/administer the + // --telemetry-consent: report/administer the // (always not-applicable on macOS) consent state and exit. if handle_telemetry_consent_flags(&cli) { return; } - // Decode the config source once so the maintenance probe and the normal - // request loader below share the same JSON — necessary for named pipes, - // `/dev/stdin`, and process-substitution paths that are drained by the - // first read. + // Decode the config source once for normal request loading. let decoded_config: Option> = decode_config_input_once(&cli); let request_hint = decoded_config .as_ref() .and_then(|result| result.as_ref().ok()) .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); - if matches!( - request_hint.as_ref().map(|hint| hint.kind()), - Some(wxc_common::config_parser::RequestKind::TelemetryConsent) - ) && handle_telemetry_consent_input( - decoded_config - .as_ref() - .and_then(|r| r.as_ref().ok()) - .map(String::as_str), - ) { - return; - } - // Determine config input. let config_json = match decoded_config { Some(Ok(json)) => json, diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 2688a8780..cfcd4e797 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -105,14 +105,46 @@ struct Cli { #[arg(long)] probe: bool, - /// Print the current persisted telemetry consent state as one-line JSON - /// and exit, without spawning a sandbox. The payload is the same typed - /// response as JSON maintenance action `status`. Windows-only: on - /// other platforms every field reports `not-applicable`/`false` and - /// nothing touches disk, since MXC does not collect telemetry there. See - /// `docs/telemetry/telemetry-consent-design.md`. - #[arg(long = "telemetry-consent-status")] - telemetry_consent_status: bool, + /// Manage telemetry consent without spawning a sandbox. + #[arg( + long = "telemetry-consent", + value_name = "ACTION", + conflicts_with_all = [ + "config_path", + "config", + "config_base64", + "command", + "delete", + "containername", + "experimental", + "allow_testing_features", + "dry_run", + "setup_hyperlight", + "force", + "setup_wslc", + "image", + "storage_path", + "probe", + "force_reclaim" + ] + )] + #[cfg_attr( + target_os = "windows", + arg(conflicts_with_all = ["audit", "audit_verbose"]) + )] + telemetry_consent: Option, + + /// Preferred BCP 47 locale for a telemetry consent request. + #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] + telemetry_consent_locale: Option, + + /// Private SDK presenter protocol. + #[arg( + long = "telemetry-consent-protocol", + requires = "telemetry_consent", + hide = true + )] + telemetry_consent_protocol: Option, /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it /// launched, instead of refusing — clears a host wedged by an orphan left @@ -154,6 +186,27 @@ impl Cli { } } +fn parse_cli() -> Cli { + match Cli::try_parse() { + Ok(cli) => cli, + Err(error) => { + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) { + error.exit(); + } + let is_consent_command = + telemetry::consent_cli::invocation_uses_consent_options(std::env::args_os()); + if is_consent_command { + let _ = error.print(); + process::exit(64); + } + error.exit(); + } + } +} + fn log_request(request: &ExecutionRequest, logger: &mut Logger) { if !request.container_id.is_empty() { let _ = writeln!(logger, "Container ID: {}", request.container_id); @@ -214,7 +267,7 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { Ok(()) } -/// Handles the `--telemetry-consent-status` fast path. +/// Handles the dedicated `--telemetry-consent` fast path. /// /// Returns `true` if one of the flags was handled (and the caller should /// exit immediately), `false` if none were passed and normal execution @@ -228,24 +281,14 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { /// job, not the foundation crate's. See /// `docs/telemetry/telemetry-consent-design.md`. fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(outcome) = telemetry::consent_cli::handle_consent_status(cli.telemetry_consent_status) - else { - return false; - }; - let code = outcome.emit(); - if code != 0 { - std::process::exit(code); - } - true -} - -fn handle_telemetry_consent_input(json: Option<&str>) -> bool { - let Some(json) = json else { - return false; - }; - let Some(outcome) = telemetry::consent_cli::handle_maintenance_input_json(json) else { + let Some(action) = cli.telemetry_consent else { return false; }; + let outcome = telemetry::consent_cli::handle_consent_command( + action, + cli.telemetry_consent_locale.as_deref(), + cli.telemetry_consent_protocol, + ); let code = outcome.emit(); if code != 0 { std::process::exit(code); @@ -254,10 +297,8 @@ fn handle_telemetry_consent_input(json: Option<&str>) -> bool { } /// Read the request source (file path / base64 blob) once, returning the -/// decoded JSON. Reused by the maintenance probe, `--probe`, and the normal -/// request loader so a single source is only read once per invocation — a -/// correctness fix for named pipes, `/dev/stdin`, and process-substitution -/// paths that are drained by the first read. +/// decoded JSON. Reused by `--probe` and the normal request loader so a single +/// source is only read once per invocation. fn decode_config_input_once(cli: &Cli) -> Option> { let (input, is_base64) = config_input(cli)?; Some(wxc_common::config_parser::decode_request_input( @@ -702,10 +743,10 @@ fn install_dacl_ctrl_handler() { } fn main() { - let cli = Cli::parse().normalize_named_config_command(); + let cli = parse_cli().normalize_named_config_command(); - // --telemetry-consent-{status,grant,revoke}: administer the persisted - // consent flag and exit. Runs BEFORE `force_reclaim` env propagation and + // --telemetry-consent: administer the persisted consent state and exit. + // Runs BEFORE `force_reclaim` env propagation and // `recover_orphaned_state()` below: this is a read-only/local-file fast path with a 5-second // client-side timeout (Node's consent getter), so it must not be gated // behind unrelated recovery work that scans state files and may restore @@ -716,27 +757,13 @@ fn main() { if handle_telemetry_consent_flags(&cli) { return; } - // Decode the request source (file path / base64) once, up front, so the - // maintenance probe and the normal request loader below share the same - // decoded JSON — necessary for named pipes, `/dev/stdin`, and - // process-substitution paths that are drained by the first read. + // Decode the request source (file path / base64) once, up front. let decoded_config: Option> = decode_config_input_once(&cli); let request_hint = decoded_config .as_ref() .and_then(|result| result.as_ref().ok()) .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); - if matches!( - request_hint.as_ref().map(|hint| hint.kind()), - Some(wxc_common::config_parser::RequestKind::TelemetryConsent) - ) && handle_telemetry_consent_input( - decoded_config - .as_ref() - .and_then(|r| r.as_ref().ok()) - .map(String::as_str), - ) { - return; - } // Propagate --force-reclaim via the environment so it reaches both the // in-process one-shot reconcile and the detached daemon. Set before any @@ -1618,6 +1645,84 @@ mod tests { ); } + #[test] + fn cli_parses_dedicated_telemetry_consent_request() { + let cli = parse_cli(&[ + "wxc-exec", + "--telemetry-consent", + "request", + "--telemetry-consent-locale", + "en-US", + "--telemetry-consent-protocol", + "stdio-v1", + ]); + + assert_eq!( + cli.telemetry_consent, + Some(telemetry::consent_cli::ConsentAction::Request) + ); + assert_eq!(cli.telemetry_consent_locale.as_deref(), Some("en-US")); + assert_eq!( + cli.telemetry_consent_protocol, + Some(telemetry::consent_cli::ConsentProtocol::StdioV1) + ); + } + + #[test] + fn cli_rejects_execution_config_with_telemetry_consent() { + let error = Cli::try_parse_from([ + "wxc-exec", + "--telemetry-consent", + "status", + "--config", + "policy.json", + ]) + .err() + .unwrap(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn cli_rejects_audit_with_telemetry_consent() { + for audit_option in ["--audit", "--audit-verbose"] { + let error = + Cli::try_parse_from(["wxc-exec", "--telemetry-consent", "status", audit_option]) + .err() + .unwrap(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + } + + #[test] + fn cli_rejects_consent_locale_without_action() { + let error = Cli::try_parse_from(["wxc-exec", "--telemetry-consent-locale", "en-US"]) + .err() + .unwrap(); + + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn cli_rejects_removed_telemetry_consent_status_flag() { + let error = Cli::try_parse_from(["wxc-exec", "--telemetry-consent-status"]) + .err() + .unwrap(); + + assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); + } + + #[test] + fn private_consent_protocol_is_hidden_from_help() { + let help = Cli::command().render_long_help().to_string(); + assert!(!help.contains("--telemetry-consent-protocol")); + assert!(help.contains("--telemetry-consent")); + } + #[test] fn cli_captures_hyphenated_command_after_separator() { let cli = parse_cli(&[ diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 67348a741..15b68f266 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -64,8 +64,6 @@ impl ParseError { #[derive(Debug, Clone, Copy, Deserialize)] #[serde(expecting = "a configuration object")] struct RequestDiscriminator<'a> { - #[serde(borrow, default)] - command: Option<&'a str>, #[serde(borrow, default, deserialize_with = "deserialize_present_raw")] phase: Option<&'a RawValue>, #[serde(borrow, default, deserialize_with = "deserialize_present_raw")] @@ -116,16 +114,15 @@ fn reject_legacy_telemetry_value(config: &serde_json::Value) -> Result<(), WxcEr /// Top-level decoded-request classification shared by the executor binaries. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RequestKind { - TelemetryConsent, OneShot, StateAware, } /// Borrowed parse hints extracted from the top level of a decoded request JSON. /// -/// Executor binaries can parse this once, route telemetry-consent maintenance -/// requests immediately, and pass the same hint into the normal loaders so -/// one-shot requests do not pay a second discriminator parse. +/// Executor binaries can parse this once and pass the same hint into the +/// normal loaders so one-shot requests do not pay a second discriminator +/// parse. #[derive(Debug, Clone)] pub struct RequestParseHint { kind: RequestKind, @@ -150,9 +147,7 @@ pub fn parse_request_hint_from_json(json_str: &str) -> Result { - return Err(WxcError::ConfigParse( - "expected an execution request, got telemetry consent maintenance request" - .to_string(), - )); - } RequestKind::StateAware => { return Err(WxcError::ConfigParse( "expected a one-shot execution request, got a state-aware lifecycle request" @@ -6808,6 +6797,17 @@ mod tests { assert!(req.telemetry.is_none()); } + #[test] + fn telemetry_consent_maintenance_is_not_an_execution_request() { + let json = r#"{"command":"telemetryConsent","action":"status"}"#; + let mut logger = test_logger(); + let error = load_request_from_json(json, &mut logger).unwrap_err(); + assert!( + error.to_string().contains("unknown field `command`"), + "got {error:?}" + ); + } + #[test] fn telemetry_enabled_true() { let json = r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#; diff --git a/src/core/wxc_common/src/telemetry/consent_cli.rs b/src/core/wxc_common/src/telemetry/consent_cli.rs index 636b13e9e..4264827a7 100644 --- a/src/core/wxc_common/src/telemetry/consent_cli.rs +++ b/src/core/wxc_common/src/telemetry/consent_cli.rs @@ -6,12 +6,86 @@ //! The executor binaries share this CLI surface; persistence remains //! platform-dependent in [`super::consent`]. -use std::io::{IsTerminal, Write}; +use std::ffi::OsStr; +use std::io::{BufRead, BufReader, IsTerminal, Read, Write}; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use super::{consent, consent_prompt, consent_protocol as protocol, policy}; + +const MAX_DECISION_BYTES: u64 = 64 * 1024; + +/// Consent operation selected by `--telemetry-consent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ConsentAction { + /// Read consent and policy status without mutation. + Status, + /// Idempotently persist denied consent. + Withdraw, + /// Request consent through the native terminal or SDK presenter. + Request, +} + +impl FromStr for ConsentAction { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "status" => Ok(Self::Status), + "withdraw" => Ok(Self::Withdraw), + "request" => Ok(Self::Request), + _ => Err(format!( + "invalid telemetry consent action '{value}'; expected status, withdraw, or request" + )), + } + } +} + +/// Optional private presenter protocol selected by SDK hosts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentProtocol { + /// Newline-delimited JSON over the executor's standard streams. + StdioV1, +} -use super::{consent, consent_prompt, policy}; -use crate::wire; +impl FromStr for ConsentProtocol { + type Err = String; -const PRESENTER_PROTOCOL_ENV: &str = "MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL"; + fn from_str(value: &str) -> Result { + match value { + "stdio-v1" => Ok(Self::StdioV1), + _ => Err(format!( + "invalid telemetry consent protocol '{value}'; expected stdio-v1" + )), + } + } +} + +/// Whether an argv sequence invokes the dedicated consent command surface. +/// +/// Executor binaries use this to map clap failures involving consent options +/// to the consent API's invalid-input exit code without changing unrelated CLI +/// error behavior. +pub fn invocation_uses_consent_options(arguments: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + arguments + .into_iter() + .take_while(|argument| argument.as_ref() != OsStr::new("--")) + .any(|argument| { + let argument = argument.as_ref().to_string_lossy(); + argument == "--telemetry-consent" + || argument.starts_with("--telemetry-consent=") + || argument == "--telemetry-consent-locale" + || argument.starts_with("--telemetry-consent-locale=") + || argument == "--telemetry-consent-protocol" + || argument.starts_with("--telemetry-consent-protocol=") + }) +} /// Output and exit code for a handled consent command. #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,7 +94,8 @@ pub struct ConsentCliOutcome { pub stdout: Option, /// Line to print to stderr, if any. pub stderr: Option, - /// Process exit code: `0` on success, `1` for failed serialization. + /// Process exit code: `0` for domain outcomes, `64` for invalid input, and + /// `1` for operational failures. pub exit_code: i32, } @@ -29,7 +104,12 @@ impl ConsentCliOutcome { /// caller should terminate with. pub fn emit(&self) -> i32 { if let Some(out) = &self.stdout { - println!("{out}"); + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + if let Err(error) = writeln!(handle, "{out}") { + eprintln!("Error: failed to write telemetry consent response: {error}"); + return 1; + } } if let Some(err) = &self.stderr { eprintln!("{err}"); @@ -45,7 +125,7 @@ impl ConsentCliOutcome { } } - fn json(response: &wire::TelemetryConsentMaintenanceResponse) -> Self { + fn json(response: &protocol::ConsentResponse) -> Self { match serde_json::to_string(response) { Ok(json) => Self { stdout: Some(json), @@ -60,120 +140,75 @@ impl ConsentCliOutcome { } } -/// Handle the telemetry consent status flag. Returns `None` when absent. -pub fn handle_consent_status(status: bool) -> Option { - if !status { - return None; +/// Execute one dedicated telemetry-consent command. +pub fn handle_consent_command( + action: ConsentAction, + locale: Option<&str>, + selected_protocol: Option, +) -> ConsentCliOutcome { + if action != ConsentAction::Request && locale.is_some() { + return ConsentCliOutcome::failure( + "Error: --telemetry-consent-locale is valid only with \ + --telemetry-consent request" + .to_string(), + 64, + ); + } + if action != ConsentAction::Request && selected_protocol.is_some() { + return ConsentCliOutcome::failure( + "Error: --telemetry-consent-protocol is valid only with \ + --telemetry-consent request" + .to_string(), + 64, + ); } - Some(ConsentCliOutcome::json(&maintenance_response( - wire::TelemetryConsentAction::Status, - wire::TelemetryConsentResult::Status, - None, - None, - None, - ))) -} - -/// Handle a typed consent-maintenance envelope, if present. -/// -/// Convenience wrapper that decodes `input` (either a file path or a base64 -/// blob) into JSON and delegates to [`handle_maintenance_input_json`]. Callers -/// that already hold the decoded JSON string — typically executor binaries -/// that decode the request once and thread the JSON through to both the -/// maintenance probe and the normal request loader — should call -/// [`handle_maintenance_input_json`] directly to avoid re-reading the input -/// source. Re-reading matters for regular files (duplicate I/O + parsing) and -/// is a correctness issue for named pipes, `/dev/stdin`, and process- -/// substitution paths, which are drained by the first read and fail on the -/// second. -pub fn handle_maintenance_input(input: &str, is_base64: bool) -> Option { - let json = match crate::config_parser::decode_request_input(input, is_base64) { - Ok(json) => json, - Err(_) => return None, - }; - handle_maintenance_input_json(&json) -} - -/// Handle a typed consent-maintenance envelope, given an already-decoded JSON -/// string. -/// -/// The single-source-of-truth path shared with [`handle_maintenance_input`]. -/// Returns `None` when the JSON does not carry the `telemetryConsent` -/// discriminator; executor binaries treat that as "not a maintenance request" -/// and continue to the normal request loader with the same JSON. -/// -/// This function is deliberately `pub` (not `pub(crate)`) because the sibling -/// executor crates `wxc`, `lxc`, and `mxc_darwin` all call it from their -/// respective `main.rs` entry points. Threading the pre-decoded JSON through -/// this entry point avoids re-reading named pipes, `/dev/stdin`, and -/// process-substitution inputs, which are drained by the first read. -pub fn handle_maintenance_input_json(json: &str) -> Option { - let Ok(hint) = crate::config_parser::parse_request_hint_from_json(json) else { - return None; - }; - if hint.kind() != crate::config_parser::RequestKind::TelemetryConsent { - return None; - } - - let request: wire::TelemetryConsentMaintenanceRequest = - match crate::config_deserialize::from_str(json) { - Ok(request) => request, - Err(error) => { - return Some(ConsentCliOutcome::failure( - format!("Error: invalid telemetry consent maintenance request: {error}"), - 64, - )); - } - }; - - Some(handle_maintenance_request(request)) -} - -fn handle_maintenance_request( - request: wire::TelemetryConsentMaintenanceRequest, -) -> ConsentCliOutcome { - match request.action { - wire::TelemetryConsentAction::Status => ConsentCliOutcome::json(&maintenance_response( - request.action, - wire::TelemetryConsentResult::Status, + match action { + ConsentAction::Status => ConsentCliOutcome::json(&consent_response( + action, + protocol::ConsentResult::Status, None, None, None, )), - wire::TelemetryConsentAction::Withdraw => match consent::withdraw_consent() { - Ok(outcome) => ConsentCliOutcome::json(&maintenance_response( - request.action, - action_result_to_wire(outcome.result), + ConsentAction::Withdraw => match consent::withdraw_consent() { + Ok(outcome) => ConsentCliOutcome::json(&consent_response( + action, + action_result_to_protocol(outcome.result), None, None, None, )), Err(error) => ConsentCliOutcome::failure(format!("Error: {error}"), 1), }, - wire::TelemetryConsentAction::Request => { - let protocol = - std::env::var_os(PRESENTER_PROTOCOL_ENV).is_some_and(|value| value == "1"); - let result = consent::request_consent(request.locale.as_deref(), |prompt| { - if protocol { - present_over_stdio_protocol(request.action, prompt) - } else { - present_on_terminal(prompt) - } + ConsentAction::Request => { + let mut presenter_failure_kind = None; + let result = consent::request_consent(locale, |prompt| { + let presented = match selected_protocol { + Some(ConsentProtocol::StdioV1) => present_over_stdio_protocol(action, prompt), + None => present_on_terminal(prompt).map_err(ProtocolFailure::operational), + }; + presented.map_err(|failure| { + presenter_failure_kind = Some(failure.kind); + failure.message + }) }); match result { - Ok(outcome) => ConsentCliOutcome::json(&maintenance_response( - request.action, - action_result_to_wire(outcome.result), + Ok(outcome) => ConsentCliOutcome::json(&consent_response( + action, + action_result_to_protocol(outcome.result), None, None, None, )), Err(consent::ConsentActionError::Presenter(error)) => { - let response = maintenance_response( - request.action, - wire::TelemetryConsentResult::PresentationUnavailable, - Some(wire::TelemetryConsentStatusReason::PresentationUnavailable), + if presenter_failure_kind == Some(ProtocolFailureKind::InvalidInput) { + return ConsentCliOutcome::failure(format!("Error: {error}"), 64); + } + let response = consent_response( + action, + protocol::ConsentResult::PresentationUnavailable, + Some(protocol::StatusReason::PresentationUnavailable), None, None, ); @@ -188,6 +223,34 @@ fn handle_maintenance_request( } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProtocolFailureKind { + InvalidInput, + Operational, +} + +#[derive(Debug, Clone)] +struct ProtocolFailure { + kind: ProtocolFailureKind, + message: String, +} + +impl ProtocolFailure { + fn invalid(message: impl Into) -> Self { + Self { + kind: ProtocolFailureKind::InvalidInput, + message: message.into(), + } + } + + fn operational(message: impl Into) -> Self { + Self { + kind: ProtocolFailureKind::Operational, + message: message.into(), + } + } +} + fn present_on_terminal( prompt: &consent_prompt::ConsentPrompt, ) -> Result { @@ -218,67 +281,81 @@ fn present_on_terminal( if read == 0 { return Ok(consent::ConsentDecision::Dismissed); } + Ok(parse_terminal_decision(&input)) +} + +fn parse_terminal_decision(input: &str) -> consent::ConsentDecision { match input.trim().to_ascii_lowercase().as_str() { - "y" | "yes" => Ok(consent::ConsentDecision::Yes), - "n" | "no" => Ok(consent::ConsentDecision::No), - _ => Ok(consent::ConsentDecision::Dismissed), + "y" | "yes" => consent::ConsentDecision::Yes, + "n" | "no" => consent::ConsentDecision::No, + _ => consent::ConsentDecision::Dismissed, } } fn present_over_stdio_protocol( - action: wire::TelemetryConsentAction, + action: ConsentAction, prompt: &consent_prompt::ConsentPrompt, -) -> Result { +) -> Result { let mut transport = StdioPresenterTransport; present_over_transport(action, prompt, &mut transport) } fn validate_presenter_response( - response: wire::TelemetryConsentPresenterResponse, + response: protocol::PresenterResponse, expected_challenge: &str, expected_resource_version: u32, -) -> Result { +) -> Result { if response.challenge != expected_challenge { - return Err("consent presenter challenge did not match this request".to_string()); + return Err(ProtocolFailure::invalid( + "consent presenter challenge did not match this request", + )); } if response.resource_version != expected_resource_version { - return Err("consent presenter prompt version did not match this request".to_string()); + return Err(ProtocolFailure::invalid( + "consent presenter prompt version did not match this request", + )); } Ok(match response.decision { - wire::TelemetryConsentDecision::Yes => consent::ConsentDecision::Yes, - wire::TelemetryConsentDecision::No => consent::ConsentDecision::No, - wire::TelemetryConsentDecision::Dismissed => consent::ConsentDecision::Dismissed, + protocol::ConsentDecision::Yes => consent::ConsentDecision::Yes, + protocol::ConsentDecision::No => consent::ConsentDecision::No, + protocol::ConsentDecision::Dismissed => consent::ConsentDecision::Dismissed, }) } -fn random_challenge() -> Result { +fn random_challenge() -> Result { let mut bytes = [0_u8; 32]; - getrandom::getrandom(&mut bytes) - .map_err(|error| format!("failed to create consent presenter challenge: {error}"))?; + getrandom::getrandom(&mut bytes).map_err(|error| { + ProtocolFailure::operational(format!( + "failed to create consent presenter challenge: {error}" + )) + })?; let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { use std::fmt::Write as _; - write!(&mut encoded, "{byte:02x}") - .map_err(|error| format!("failed to encode consent presenter challenge: {error}"))?; + write!(&mut encoded, "{byte:02x}").map_err(|error| { + ProtocolFailure::operational(format!( + "failed to encode consent presenter challenge: {error}" + )) + })?; } Ok(encoded) } trait PresenterTransport { - fn next_challenge(&mut self) -> Result; - fn write_presentation(&mut self, json: &str) -> Result<(), String>; - fn flush_presentation(&mut self) -> Result<(), String>; - fn read_response_line(&mut self, input: &mut String) -> Result; + fn next_challenge(&mut self) -> Result; + fn write_presentation(&mut self, json: &str) -> Result<(), ProtocolFailure>; + fn flush_presentation(&mut self) -> Result<(), ProtocolFailure>; + fn read_response(&mut self) -> Result; } struct StdioPresenterTransport; impl PresenterTransport for StdioPresenterTransport { - fn next_challenge(&mut self) -> Result { + fn next_challenge(&mut self) -> Result { random_challenge() } - fn write_presentation(&mut self, json: &str) -> Result<(), String> { + fn write_presentation(&mut self, json: &str) -> Result<(), ProtocolFailure> { // Write the presentation envelope via `writeln!` on a locked stdout // handle so a BrokenPipe (host closed the pipe before we emitted) // becomes a clean transport error rather than a panic. `println!` @@ -288,90 +365,146 @@ impl PresenterTransport for StdioPresenterTransport { let mut handle = stdout.lock(); writeln!(handle, "{json}").map_err(|error| { if error.kind() == std::io::ErrorKind::BrokenPipe { - "consent presenter pipe closed before presentation envelope was delivered" - .to_string() + ProtocolFailure::operational( + "consent presenter pipe closed before presentation envelope was delivered", + ) } else { - format!("failed to write consent presentation: {error}") + ProtocolFailure::operational(format!( + "failed to write consent presentation: {error}" + )) } }) } - fn flush_presentation(&mut self) -> Result<(), String> { + fn flush_presentation(&mut self) -> Result<(), ProtocolFailure> { std::io::stdout().flush().map_err(|error| { if error.kind() == std::io::ErrorKind::BrokenPipe { - "consent presenter pipe closed before presentation envelope was flushed".to_string() + ProtocolFailure::operational( + "consent presenter pipe closed before presentation envelope was flushed", + ) } else { - format!("failed to flush consent presentation: {error}") + ProtocolFailure::operational(format!( + "failed to flush consent presentation: {error}" + )) } }) } - fn read_response_line(&mut self, input: &mut String) -> Result { - std::io::stdin() - .read_line(input) - .map_err(|error| format!("failed to read consent presenter response: {error}")) + fn read_response(&mut self) -> Result { + let stdin = std::io::stdin(); + let mut reader = BufReader::new(stdin.lock().take(MAX_DECISION_BYTES + 1)); + let mut input = String::new(); + let read = reader.read_line(&mut input).map_err(|error| { + if error.kind() == std::io::ErrorKind::InvalidData { + ProtocolFailure::invalid("consent presenter response was not valid UTF-8") + } else { + ProtocolFailure::operational(format!( + "failed to read consent presenter response: {error}" + )) + } + })?; + if read == 0 { + return Err(ProtocolFailure::operational( + "consent presenter closed without returning a decision", + )); + } + if read as u64 > MAX_DECISION_BYTES { + return Err(ProtocolFailure::invalid(format!( + "consent presenter response exceeded the {MAX_DECISION_BYTES}-byte limit" + ))); + } + if !reader.buffer().is_empty() { + return Err(ProtocolFailure::invalid( + "consent presenter returned more than one decision line", + )); + } + Ok(input) } } fn present_over_transport( - action: wire::TelemetryConsentAction, + action: ConsentAction, prompt: &consent_prompt::ConsentPrompt, transport: &mut dyn PresenterTransport, -) -> Result { +) -> Result { let challenge = transport.next_challenge()?; - let presentation = maintenance_response( + let presentation = consent_response( action, - wire::TelemetryConsentResult::PresentationRequired, + protocol::ConsentResult::PresentationRequired, None, - Some(prompt_to_wire(prompt)), + Some(prompt_to_protocol(prompt)), Some(challenge.clone()), ); - let json = serde_json::to_string(&presentation) - .map_err(|error| format!("failed to serialize consent presentation: {error}"))?; + let json = serde_json::to_string(&presentation).map_err(|error| { + ProtocolFailure::operational(format!("failed to serialize consent presentation: {error}")) + })?; transport.write_presentation(&json)?; transport.flush_presentation()?; - let mut input = String::new(); - let read = transport.read_response_line(&mut input)?; - if read == 0 { - return Ok(consent::ConsentDecision::Dismissed); - } - let response: wire::TelemetryConsentPresenterResponse = serde_json::from_str(&input) - .map_err(|error| format!("invalid consent presenter response for this request: {error}"))?; - validate_presenter_response(response, &challenge, prompt.resource_version) + let input = transport.read_response()?; + parse_presenter_response(&input, &challenge, prompt.resource_version) } -fn maintenance_response( - action: wire::TelemetryConsentAction, - result: wire::TelemetryConsentResult, - reason: Option, - prompt: Option, +fn parse_presenter_response( + input: &str, + expected_challenge: &str, + expected_resource_version: u32, +) -> Result { + if input.len() > MAX_DECISION_BYTES as usize { + return Err(ProtocolFailure::invalid(format!( + "consent presenter response exceeded the {MAX_DECISION_BYTES}-byte limit" + ))); + } + let Some(line) = input.strip_suffix('\n') else { + return Err(ProtocolFailure::invalid( + "consent presenter decision must end with a newline", + )); + }; + let line = line.strip_suffix('\r').unwrap_or(line); + if line.is_empty() || line.contains(['\r', '\n']) { + return Err(ProtocolFailure::invalid( + "consent presenter must return exactly one JSON decision line", + )); + } + let response: protocol::PresenterResponse = serde_json::from_str(line).map_err(|error| { + ProtocolFailure::invalid(format!( + "invalid consent presenter response for this request: {error}" + )) + })?; + validate_presenter_response(response, expected_challenge, expected_resource_version) +} + +fn consent_response( + action: ConsentAction, + result: protocol::ConsentResult, + reason: Option, + prompt: Option, challenge: Option, -) -> wire::TelemetryConsentMaintenanceResponse { +) -> protocol::ConsentResponse { let status = consent::get_status(); let policy_state = policy::get_policy(); - wire::TelemetryConsentMaintenanceResponse { + protocol::ConsentResponse { action, result, - stored_state: consent_state_to_wire(status.stored_state), - effective_state: consent_state_to_wire(status.effective_state), - reason: reason.or_else(|| status.reason.map(status_reason_to_wire)), - policy: policy_state_to_wire(policy_state), + stored_state: consent_state_to_protocol(status.stored_state), + effective_state: consent_state_to_protocol(status.effective_state), + reason: reason.or_else(|| status.reason.map(status_reason_to_protocol)), + policy: policy_state_to_protocol(policy_state), needs_prompt: policy_state.allows_collection() && status.needs_prompt(), prompt, challenge, } } -fn prompt_to_wire(prompt: &consent_prompt::ConsentPrompt) -> wire::TelemetryConsentPrompt { - fn message(value: consent_prompt::ConsentMessage) -> wire::TelemetryConsentMessage { - wire::TelemetryConsentMessage { +fn prompt_to_protocol(prompt: &consent_prompt::ConsentPrompt) -> protocol::ConsentPrompt { + fn message(value: consent_prompt::ConsentMessage) -> protocol::ConsentMessage { + protocol::ConsentMessage { id: value.id.to_string(), text: value.text.to_string(), } } - wire::TelemetryConsentPrompt { + protocol::ConsentPrompt { resource_version: prompt.resource_version, locale: prompt.locale.to_string(), title: message(prompt.title), @@ -383,61 +516,51 @@ fn prompt_to_wire(prompt: &consent_prompt::ConsentPrompt) -> wire::TelemetryCons } } -fn consent_state_to_wire(state: consent::ConsentState) -> wire::TelemetryConsentState { +fn consent_state_to_protocol(state: consent::ConsentState) -> protocol::ConsentState { match state { - consent::ConsentState::Granted => wire::TelemetryConsentState::Granted, - consent::ConsentState::Denied => wire::TelemetryConsentState::Denied, - consent::ConsentState::Undetermined => wire::TelemetryConsentState::Undetermined, - consent::ConsentState::NotApplicable => wire::TelemetryConsentState::NotApplicable, + consent::ConsentState::Granted => protocol::ConsentState::Granted, + consent::ConsentState::Denied => protocol::ConsentState::Denied, + consent::ConsentState::Undetermined => protocol::ConsentState::Undetermined, + consent::ConsentState::NotApplicable => protocol::ConsentState::NotApplicable, } } -fn status_reason_to_wire( - reason: consent::ConsentStatusReason, -) -> wire::TelemetryConsentStatusReason { +fn status_reason_to_protocol(reason: consent::ConsentStatusReason) -> protocol::StatusReason { match reason { - consent::ConsentStatusReason::NoRecord => wire::TelemetryConsentStatusReason::NoRecord, - consent::ConsentStatusReason::StoreUnreadable => { - wire::TelemetryConsentStatusReason::StoreUnreadable - } - consent::ConsentStatusReason::StoreMalformed => { - wire::TelemetryConsentStatusReason::StoreMalformed - } + consent::ConsentStatusReason::NoRecord => protocol::StatusReason::NoRecord, + consent::ConsentStatusReason::StoreUnreadable => protocol::StatusReason::StoreUnreadable, + consent::ConsentStatusReason::StoreMalformed => protocol::StatusReason::StoreMalformed, consent::ConsentStatusReason::ConsentSchemaUnsupported => { - wire::TelemetryConsentStatusReason::ConsentSchemaUnsupported + protocol::StatusReason::ConsentSchemaUnsupported } consent::ConsentStatusReason::PromptVersionMissing => { - wire::TelemetryConsentStatusReason::PromptVersionMissing + protocol::StatusReason::PromptVersionMissing } consent::ConsentStatusReason::PromptVersionUnsupported => { - wire::TelemetryConsentStatusReason::PromptVersionUnsupported - } - consent::ConsentStatusReason::NotApplicable => { - wire::TelemetryConsentStatusReason::NotApplicable + protocol::StatusReason::PromptVersionUnsupported } + consent::ConsentStatusReason::NotApplicable => protocol::StatusReason::NotApplicable, } } -fn policy_state_to_wire(state: policy::PolicyState) -> wire::TelemetryConsentPolicyState { +fn policy_state_to_protocol(state: policy::PolicyState) -> protocol::PolicyState { match state { - policy::PolicyState::Unrestricted => wire::TelemetryConsentPolicyState::Unrestricted, - policy::PolicyState::Allowed => wire::TelemetryConsentPolicyState::Allowed, - policy::PolicyState::Blocked => wire::TelemetryConsentPolicyState::Blocked, - policy::PolicyState::NotApplicable => wire::TelemetryConsentPolicyState::NotApplicable, + policy::PolicyState::Unrestricted => protocol::PolicyState::Unrestricted, + policy::PolicyState::Allowed => protocol::PolicyState::Allowed, + policy::PolicyState::Blocked => protocol::PolicyState::Blocked, + policy::PolicyState::NotApplicable => protocol::PolicyState::NotApplicable, } } -fn action_result_to_wire(result: consent::ConsentActionResult) -> wire::TelemetryConsentResult { +fn action_result_to_protocol(result: consent::ConsentActionResult) -> protocol::ConsentResult { match result { - consent::ConsentActionResult::Granted => wire::TelemetryConsentResult::Granted, - consent::ConsentActionResult::Denied => wire::TelemetryConsentResult::Denied, - consent::ConsentActionResult::Dismissed => wire::TelemetryConsentResult::Dismissed, - consent::ConsentActionResult::Withdrawn => wire::TelemetryConsentResult::Withdrawn, - consent::ConsentActionResult::AlreadyGranted => { - wire::TelemetryConsentResult::AlreadyGranted - } - consent::ConsentActionResult::PolicyBlocked => wire::TelemetryConsentResult::PolicyBlocked, - consent::ConsentActionResult::NotApplicable => wire::TelemetryConsentResult::NotApplicable, + consent::ConsentActionResult::Granted => protocol::ConsentResult::Granted, + consent::ConsentActionResult::Denied => protocol::ConsentResult::Denied, + consent::ConsentActionResult::Dismissed => protocol::ConsentResult::Dismissed, + consent::ConsentActionResult::Withdrawn => protocol::ConsentResult::Withdrawn, + consent::ConsentActionResult::AlreadyGranted => protocol::ConsentResult::AlreadyGranted, + consent::ConsentActionResult::PolicyBlocked => protocol::ConsentResult::PolicyBlocked, + consent::ConsentActionResult::NotApplicable => protocol::ConsentResult::NotApplicable, } } @@ -450,12 +573,26 @@ mod tests { EN_US_CONSENT_PROMPT } + fn present_for_test( + transport: &mut dyn PresenterTransport, + ) -> Result { + #[cfg(target_os = "windows")] + { + let tmp = tempfile::tempdir().unwrap(); + let _environment = crate::telemetry::test_support::TelemetryTestEnv::new(tmp.path()); + present_over_transport(ConsentAction::Request, &prompt(), transport) + } + #[cfg(not(target_os = "windows"))] + { + present_over_transport(ConsentAction::Request, &prompt(), transport) + } + } + struct FakeTransport { - challenge: Result, - write_result: Result<(), String>, - flush_result: Result<(), String>, - read_result: Result, - response_line: String, + challenge: Result, + write_result: Result<(), ProtocolFailure>, + flush_result: Result<(), ProtocolFailure>, + read_result: Result, writes: Vec, } @@ -465,36 +602,103 @@ mod tests { challenge: Ok("request-a".to_string()), write_result: Ok(()), flush_result: Ok(()), - read_result: Ok(response_line.len()), - response_line, + read_result: Ok(format!("{response_line}\n")), writes: Vec::new(), } } } impl PresenterTransport for FakeTransport { - fn next_challenge(&mut self) -> Result { + fn next_challenge(&mut self) -> Result { self.challenge.clone() } - fn write_presentation(&mut self, json: &str) -> Result<(), String> { + fn write_presentation(&mut self, json: &str) -> Result<(), ProtocolFailure> { self.writes.push(json.to_string()); self.write_result.clone() } - fn flush_presentation(&mut self) -> Result<(), String> { + fn flush_presentation(&mut self) -> Result<(), ProtocolFailure> { self.flush_result.clone() } - fn read_response_line(&mut self, input: &mut String) -> Result { - input.push_str(&self.response_line); + fn read_response(&mut self) -> Result { self.read_result.clone() } } #[test] - fn absent_status_flag_is_a_noop() { - assert!(handle_consent_status(false).is_none()); + fn locale_is_rejected_for_non_request_actions() { + let outcome = handle_consent_command(ConsentAction::Status, Some("en-US"), None); + assert_eq!(outcome.exit_code, 64); + assert!(outcome.stderr.as_deref().unwrap().contains("valid only")); + } + + #[test] + fn consent_option_detection_is_testable_without_process_arguments() { + assert!(invocation_uses_consent_options([ + "wxc-exec", + "--telemetry-consent=request" + ])); + assert!(invocation_uses_consent_options([ + "wxc-exec", + "--telemetry-consent-locale", + "en-US" + ])); + assert!(!invocation_uses_consent_options([ + "wxc-exec", + "--config", + "policy.json" + ])); + assert!(!invocation_uses_consent_options([ + "wxc-exec", + "--telemetry-consent-status" + ])); + assert!(!invocation_uses_consent_options([ + "wxc-exec", + "--unknown-flag", + "--", + "--telemetry-consent=request" + ])); + assert!(invocation_uses_consent_options([ + "wxc-exec", + "--telemetry-consent=request", + "--", + "--telemetry-consent=withdraw" + ])); + } + + #[test] + fn terminal_decisions_are_parsed_without_live_stdio() { + for affirmative in ["y", "Y", "yes", " YES "] { + assert_eq!( + parse_terminal_decision(affirmative), + consent::ConsentDecision::Yes + ); + } + for negative in ["n", "N", "no", " NO "] { + assert_eq!( + parse_terminal_decision(negative), + consent::ConsentDecision::No + ); + } + for dismissed in ["", " ", "later", "cancel"] { + assert_eq!( + parse_terminal_decision(dismissed), + consent::ConsentDecision::Dismissed + ); + } + } + + #[test] + fn protocol_is_rejected_for_non_request_actions() { + let outcome = handle_consent_command( + ConsentAction::Withdraw, + None, + Some(ConsentProtocol::StdioV1), + ); + assert_eq!(outcome.exit_code, 64); + assert!(outcome.stderr.as_deref().unwrap().contains("valid only")); } fn assert_status_json( @@ -516,32 +720,34 @@ mod tests { #[test] fn presenter_response_is_bound_to_challenge_and_prompt_version() { - let valid = wire::TelemetryConsentPresenterResponse { + let valid = protocol::PresenterResponse { challenge: "request-a".to_string(), resource_version: 1, - decision: wire::TelemetryConsentDecision::Yes, + decision: protocol::ConsentDecision::Yes, }; assert_eq!( validate_presenter_response(valid, "request-a", 1).unwrap(), consent::ConsentDecision::Yes ); - let replay = wire::TelemetryConsentPresenterResponse { + let replay = protocol::PresenterResponse { challenge: "request-a".to_string(), resource_version: 1, - decision: wire::TelemetryConsentDecision::Yes, + decision: protocol::ConsentDecision::Yes, }; assert!(validate_presenter_response(replay, "request-b", 1) .unwrap_err() + .message .contains("challenge")); - let stale_prompt = wire::TelemetryConsentPresenterResponse { + let stale_prompt = protocol::PresenterResponse { challenge: "request-a".to_string(), resource_version: 0, - decision: wire::TelemetryConsentDecision::Yes, + decision: protocol::ConsentDecision::Yes, }; assert!(validate_presenter_response(stale_prompt, "request-a", 1) .unwrap_err() + .message .contains("version")); } @@ -549,69 +755,132 @@ mod tests { fn presenter_transport_reports_broken_write_without_touching_stdio() { let mut transport = FakeTransport { challenge: Ok("request-a".to_string()), - write_result: Err("consent presenter pipe closed".to_string()), + write_result: Err(ProtocolFailure::operational( + "consent presenter pipe closed", + )), flush_result: Ok(()), - read_result: Ok(0), - response_line: String::new(), + read_result: Ok(String::new()), writes: Vec::new(), }; - let error = present_over_transport( - wire::TelemetryConsentAction::Request, - &prompt(), - &mut transport, - ) - .unwrap_err(); - assert!(error.contains("pipe closed")); + let error = present_for_test(&mut transport).unwrap_err(); + assert!(error.message.contains("pipe closed")); assert_eq!(transport.writes.len(), 1); } #[test] - fn presenter_transport_treats_eof_as_dismissed() { + fn presenter_transport_treats_eof_as_operational_failure() { let mut transport = FakeTransport { challenge: Ok("request-a".to_string()), write_result: Ok(()), flush_result: Ok(()), - read_result: Ok(0), - response_line: String::new(), + read_result: Err(ProtocolFailure::operational( + "consent presenter closed without returning a decision", + )), writes: Vec::new(), }; - let decision = present_over_transport( - wire::TelemetryConsentAction::Request, - &prompt(), - &mut transport, - ) - .unwrap(); - assert_eq!(decision, consent::ConsentDecision::Dismissed); + let error = present_for_test(&mut transport).unwrap_err(); + assert_eq!(error.kind, ProtocolFailureKind::Operational); } #[test] fn presenter_transport_rejects_malformed_json() { let mut transport = FakeTransport::with_response("not json".to_string()); - let error = present_over_transport( - wire::TelemetryConsentAction::Request, - &prompt(), - &mut transport, - ) - .unwrap_err(); - assert!(error.contains("invalid consent presenter response")); + let error = present_for_test(&mut transport).unwrap_err(); + assert_eq!(error.kind, ProtocolFailureKind::InvalidInput); + assert!(error.message.contains("invalid consent presenter response")); } #[test] fn presenter_transport_rejects_mismatched_response() { - let response = serde_json::to_string(&wire::TelemetryConsentPresenterResponse { + let response = serde_json::to_string(&protocol::PresenterResponse { challenge: "wrong-request".to_string(), resource_version: 1, - decision: wire::TelemetryConsentDecision::Yes, + decision: protocol::ConsentDecision::Yes, }) .unwrap(); let mut transport = FakeTransport::with_response(response); - let error = present_over_transport( - wire::TelemetryConsentAction::Request, - &prompt(), - &mut transport, - ) - .unwrap_err(); - assert!(error.contains("challenge")); + let error = present_for_test(&mut transport).unwrap_err(); + assert_eq!(error.kind, ProtocolFailureKind::InvalidInput); + assert!(error.message.contains("challenge")); + } + + #[test] + fn presenter_transport_rejects_extra_decision_lines() { + let response = serde_json::to_string(&protocol::PresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: protocol::ConsentDecision::Yes, + }) + .unwrap(); + let mut transport = FakeTransport::with_response(format!("{response}\n{response}")); + let error = present_for_test(&mut transport).unwrap_err(); + assert_eq!(error.kind, ProtocolFailureKind::InvalidInput); + assert!(error.message.contains("exactly one")); + } + + #[test] + fn presenter_transport_requires_newline_terminated_decision() { + let response = serde_json::to_string(&protocol::PresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: protocol::ConsentDecision::Yes, + }) + .unwrap(); + let error = parse_presenter_response(&response, "request-a", 1).unwrap_err(); + assert_eq!(error.kind, ProtocolFailureKind::InvalidInput); + assert!(error.message.contains("end with a newline")); + } + + #[test] + fn presenter_transport_rejects_oversized_decision() { + let input = format!("{}\n", "x".repeat(MAX_DECISION_BYTES as usize)); + let error = parse_presenter_response(&input, "request-a", 1).unwrap_err(); + assert_eq!(error.kind, ProtocolFailureKind::InvalidInput); + assert!(error.message.contains("byte limit")); + } + + #[test] + fn shared_decision_fixtures_match_the_private_protocol() { + let fixtures = [ + ( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json" + )), + consent::ConsentDecision::Yes, + ), + ( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-no.json" + )), + consent::ConsentDecision::No, + ), + ( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json" + )), + consent::ConsentDecision::Dismissed, + ), + ]; + + for (fixture, expected) in fixtures { + assert_eq!( + parse_presenter_response(fixture, "request-a", 1).unwrap(), + expected + ); + } + let invalid = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json" + )); + assert_eq!( + parse_presenter_response(invalid, "request-a", 1) + .unwrap_err() + .kind, + ProtocolFailureKind::InvalidInput + ); } /// The non-Windows contract every executor must honor: status is a @@ -627,7 +896,7 @@ mod tests { #[test] fn status_reports_not_applicable_and_succeeds() { - let outcome = handle_consent_status(true).expect("handled"); + let outcome = handle_consent_command(ConsentAction::Status, None, None); assert_eq!(outcome.exit_code, 0); assert_status_json( &outcome, @@ -660,7 +929,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let _guards = isolate(tmp.path()); - let outcome = handle_consent_status(true).expect("handled"); + let outcome = handle_consent_command(ConsentAction::Status, None, None); assert_eq!(outcome.exit_code, 0); assert_status_json( &outcome, @@ -682,7 +951,7 @@ mod tests { let env = isolate(tmp.path()); env.set_policy_value(0); - let outcome = handle_consent_status(true).expect("handled"); + let outcome = handle_consent_command(ConsentAction::Status, None, None); assert_eq!(outcome.exit_code, 0); assert_status_json(&outcome, "undetermined", "undetermined", "blocked", false); } @@ -698,12 +967,7 @@ mod tests { std::fs::write(tmp.path().join("mxc"), b"not a directory").unwrap(); let _guards = isolate(tmp.path()); - let outcome = handle_maintenance_request(wire::TelemetryConsentMaintenanceRequest { - schema: None, - command: wire::TelemetryConsentCommand::TelemetryConsent, - action: wire::TelemetryConsentAction::Withdraw, - locale: None, - }); + let outcome = handle_consent_command(ConsentAction::Withdraw, None, None); assert_eq!(outcome.exit_code, 1); assert_eq!(outcome.stdout, None); assert!(outcome.stderr.as_deref().unwrap().starts_with("Error: ")); diff --git a/src/core/wxc_common/src/telemetry/consent_protocol.rs b/src/core/wxc_common/src/telemetry/consent_protocol.rs new file mode 100644 index 000000000..704c41e54 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent_protocol.rs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Private JSON messages exchanged by the executor consent command. +//! +//! These types are not part of the MXC execution configuration contract and +//! intentionally have no JSON Schema or generated SDK wire surface. + +use serde::{Deserialize, Serialize}; + +use super::consent_cli::ConsentAction; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum ConsentResult { + Status, + PresentationRequired, + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + PresentationUnavailable, + NotApplicable, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum ConsentDecision { + Yes, + No, + Dismissed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct PresenterResponse { + pub challenge: String, + pub resource_version: u32, + pub decision: ConsentDecision, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum ConsentState { + Granted, + Denied, + Undetermined, + NotApplicable, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum PolicyState { + Unrestricted, + Allowed, + Blocked, + NotApplicable, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum StatusReason { + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + PolicyBlocked, + PresentationUnavailable, + NotApplicable, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ConsentMessage { + pub id: String, + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ConsentPrompt { + pub resource_version: u32, + pub locale: String, + pub title: ConsentMessage, + pub body: ConsentMessage, + pub affirmative_label: ConsentMessage, + pub negative_label: ConsentMessage, + pub learn_more_label: ConsentMessage, + pub learn_more_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ConsentResponse { + pub action: ConsentAction, + pub result: ConsentResult, + pub stored_state: ConsentState, + pub effective_state: ConsentState, + pub reason: Option, + pub policy: PolicyState, + pub needs_prompt: bool, + pub prompt: Option, + pub challenge: Option, +} diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 06b882216..ecceb3039 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -14,6 +14,7 @@ pub mod consent; pub mod consent_cli; pub mod consent_prompt; +mod consent_protocol; pub mod correlation_state; pub mod correlation_vector; pub mod events; diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 87f179a23..ea0ffa789 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -111,175 +111,6 @@ pub struct MxcConfig { pub experimental: Option, } -/// Telemetry-consent maintenance request. -/// -/// This is a separate contract from [`MxcConfig`], even though executors use -/// the same JSON input loader for both. Its explicit `command` discriminator -/// lets the loader route maintenance without widening the execution schema. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[cfg_attr( - feature = "schema-gen", - schemars(title = "MXC Telemetry Consent Maintenance Request") -)] -#[serde( - rename_all = "camelCase", - deny_unknown_fields, - expecting = "a telemetry consent maintenance request" -)] -pub struct TelemetryConsentMaintenanceRequest { - /// Optional JSON Schema reference for editor validation. - #[serde(rename = "$schema")] - pub schema: Option, - /// Fixed maintenance discriminator. Must be `"telemetryConsent"`. - pub command: TelemetryConsentCommand, - /// Consent operation to perform. - pub action: TelemetryConsentAction, - /// Preferred BCP 47 locale for the canonical prompt. Unsupported locales - /// fall back to `en-US`. Used only by `request`. - pub locale: Option, -} - -/// Fixed telemetry-consent maintenance command discriminator. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum TelemetryConsentCommand { - TelemetryConsent, -} - -/// Telemetry-consent maintenance action. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum TelemetryConsentAction { - /// Present the canonical prompt when consent may be requested. - Request, - /// Idempotently persist denied consent. - Withdraw, - /// Read status without mutation. - Status, -} - -/// Typed result of a telemetry-consent maintenance action. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum TelemetryConsentResult { - Status, - PresentationRequired, - Granted, - Denied, - Dismissed, - Withdrawn, - AlreadyGranted, - PolicyBlocked, - PresentationUnavailable, - NotApplicable, -} - -/// Typed decision returned by a consent presenter. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum TelemetryConsentDecision { - Yes, - No, - Dismissed, -} - -/// Private, session-bound presenter response consumed by the same executor -/// process that emitted the challenge. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TelemetryConsentPresenterResponse { - pub challenge: String, - pub resource_version: u32, - pub decision: TelemetryConsentDecision, -} - -/// Stable consent-state strings used by maintenance responses. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "kebab-case")] -pub enum TelemetryConsentState { - Granted, - Denied, - Undetermined, - NotApplicable, -} - -/// Stable policy-state strings used by maintenance responses. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "kebab-case")] -pub enum TelemetryConsentPolicyState { - Unrestricted, - Allowed, - Blocked, - NotApplicable, -} - -/// Why persisted consent is not currently effective. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "kebab-case")] -pub enum TelemetryConsentStatusReason { - NoRecord, - StoreUnreadable, - StoreMalformed, - ConsentSchemaUnsupported, - PromptVersionMissing, - PromptVersionUnsupported, - PolicyBlocked, - PresentationUnavailable, - NotApplicable, -} - -/// One canonical prompt message. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TelemetryConsentMessage { - pub id: String, - pub text: String, -} - -/// Canonical prompt data supplied to an SDK presenter. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TelemetryConsentPrompt { - pub resource_version: u32, - pub locale: String, - pub title: TelemetryConsentMessage, - pub body: TelemetryConsentMessage, - pub affirmative_label: TelemetryConsentMessage, - pub negative_label: TelemetryConsentMessage, - pub learn_more_label: TelemetryConsentMessage, - pub learn_more_url: String, -} - -/// Typed telemetry-consent maintenance response. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct TelemetryConsentMaintenanceResponse { - pub action: TelemetryConsentAction, - pub result: TelemetryConsentResult, - pub stored_state: TelemetryConsentState, - pub effective_state: TelemetryConsentState, - pub reason: Option, - pub policy: TelemetryConsentPolicyState, - pub needs_prompt: bool, - /// Present only during the private SDK presenter handshake. - pub prompt: Option, - /// Opaque, single-request challenge for the private SDK presenter - /// handshake. Never persisted. - pub challenge: Option, -} - /// State-aware lifecycle phase. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -900,18 +731,13 @@ pub struct IsolationSessionProvisionPhase { /// re-exported below as `generate_config_schema_json`. #[cfg(feature = "schema-gen")] mod schema_gen { - use super::{ - MxcConfig, TelemetryConsentMaintenanceRequest, TelemetryConsentMaintenanceResponse, - TelemetryConsentPresenterResponse, - }; + use super::MxcConfig; use schemars::JsonSchema; /// Canonical `$id` for the generated dev schema. Bump alongside the dev schema /// version/filename (see `schemas/schema-version.json`). const SCHEMA_ID: &str = "https://github.com/microsoft/mxc/schemas/dev/mxc-config.schema.0.9.0-dev.json"; - const TELEMETRY_CONSENT_SCHEMA_ID: &str = - "https://github.com/microsoft/mxc/schemas/dev/mxc-telemetry-consent.schema.1.json"; /// Generate the JSON Schema for the MXC config from the dedicated `MxcConfig` /// model. The schema is post-processed to (a) inject the canonical `$id`, @@ -956,36 +782,7 @@ mod schema_gen { let value = schema_value(); mxc_schema_support::emit_ts(&value) } - - /// Generate the separate telemetry-consent maintenance request schema. - pub fn generate_telemetry_consent_schema_json() -> String { - let value = - schema_value_for::(TELEMETRY_CONSENT_SCHEMA_ID); - if let serde_json::Value::Object(map) = &value { - return mxc_schema_support::render_root_ordered(map); - } - serde_json::to_string_pretty(&value).expect("schema serialises to JSON") - } - - #[derive(JsonSchema)] - #[allow(dead_code)] - struct TelemetryConsentMaintenanceTypes { - request: TelemetryConsentMaintenanceRequest, - response: TelemetryConsentMaintenanceResponse, - presenter_response: TelemetryConsentPresenterResponse, - } - - /// Generate TypeScript wire types for both maintenance requests and - /// responses without adding them to the execution-config drift oracle. - pub fn generate_telemetry_consent_sdk_types_ts() -> String { - let value = - schema_value_for::(TELEMETRY_CONSENT_SCHEMA_ID); - mxc_schema_support::emit_ts(&value) - } } #[cfg(feature = "schema-gen")] -pub use schema_gen::{ - generate_config_schema_json, generate_sdk_types_ts, generate_telemetry_consent_schema_json, - generate_telemetry_consent_sdk_types_ts, -}; +pub use schema_gen::{generate_config_schema_json, generate_sdk_types_ts}; diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index bb144ffd3..426363224 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -202,7 +202,7 @@ fn seed_consent(local_app_data: &Path, state: &str) { /// vacuous pass — it would record no events for the wrong reason. fn assert_effective_consent(exe: &Path, local_app_data: &Path, expected: &str) { let output = Command::new(exe) - .arg("--telemetry-consent-status") + .args(["--telemetry-consent", "status"]) .env("MXC_TEST_LOCALAPPDATA_OVERRIDE", local_app_data) .output() .expect("failed to query consent status"); diff --git a/src/tools/mxc_schema_gen/src/main.rs b/src/tools/mxc_schema_gen/src/main.rs index d0ce1f528..e85426179 100644 --- a/src/tools/mxc_schema_gen/src/main.rs +++ b/src/tools/mxc_schema_gen/src/main.rs @@ -34,14 +34,11 @@ enum Command { #[derive(Debug, Args)] struct GenerateArgs { /// Exact registered contract version. - #[arg(long, conflicts_with_all = ["legacy_wire", "telemetry_consent"])] + #[arg(long, conflicts_with = "legacy_wire")] version: Option, /// Generate from the rolling legacy wire model. - #[arg(long, conflicts_with_all = ["version", "telemetry_consent"])] + #[arg(long, conflicts_with = "version")] legacy_wire: bool, - /// Generate the telemetry-consent maintenance contract. - #[arg(long, conflicts_with_all = ["version", "legacy_wire"])] - telemetry_consent: bool, /// Output path. Omit to write the artifact to standard output. #[arg(long)] out: Option, @@ -51,20 +48,15 @@ struct GenerateArgs { enum Target { LegacyWire, Contract(ContractVersion), - TelemetryConsent, } fn target(args: &GenerateArgs) -> Result { - match (&args.version, args.legacy_wire, args.telemetry_consent) { - (Some(version), false, false) => ContractVersion::parse_exact(version) + match (&args.version, args.legacy_wire) { + (Some(version), false) => ContractVersion::parse_exact(version) .map(Target::Contract) .ok_or_else(|| format!("unsupported exact contract version: {version}")), - (None, true, false) => Ok(Target::LegacyWire), - (None, false, true) => Ok(Target::TelemetryConsent), - (None, false, false) => Err( - "one of --version , --legacy-wire, or --telemetry-consent is required" - .to_string(), - ), + (None, true) => Ok(Target::LegacyWire), + (None, false) => Err("one of --version or --legacy-wire is required".to_string()), _ => unreachable!("clap rejects conflicting target options"), } } @@ -108,10 +100,6 @@ fn schema_content(target: Target) -> Result { mxc_schema_support::render_root_ordered(root) )) } - Target::TelemetryConsent => Ok(format!( - "{}\n", - wxc_common::wire::generate_telemetry_consent_schema_json() - )), } } @@ -125,7 +113,6 @@ fn types_content(target: Target) -> Result { version.as_str(), )) } - Target::TelemetryConsent => Ok(wxc_common::wire::generate_telemetry_consent_sdk_types_ts()), } } diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json b/tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json new file mode 100644 index 000000000..1a490f365 --- /dev/null +++ b/tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json @@ -0,0 +1 @@ +{"challenge":"request-a","resourceVersion":1,"decision":"dismissed"} diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-no.json b/tests/fixtures/telemetry-consent/stdio-v1-decision-no.json new file mode 100644 index 000000000..ad50808cc --- /dev/null +++ b/tests/fixtures/telemetry-consent/stdio-v1-decision-no.json @@ -0,0 +1 @@ +{"challenge":"request-a","resourceVersion":1,"decision":"no"} diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json b/tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json new file mode 100644 index 000000000..4f84093c1 --- /dev/null +++ b/tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json @@ -0,0 +1 @@ +{"challenge":"request-a","resourceVersion":1,"decision":"yes","grant":true} diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json b/tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json new file mode 100644 index 000000000..3e936376a --- /dev/null +++ b/tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json @@ -0,0 +1 @@ +{"challenge":"request-a","resourceVersion":1,"decision":"yes"} diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index c0c9a5daf..7228f8c84 100644 --- a/tests/scripts/run_telemetry_consent_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -15,35 +15,51 @@ if (-not (Test-Path $wxcExec)) { if ($wxcExec -match '\\release\\') { throw 'Release binaries are refused because consent isolation uses debug-only overrides.' } + +function Assert-ConsentParseExitCodes { + $executors = @($wxcExec) + foreach ($name in @('lxc-exec.exe', 'mxc-exec-mac.exe')) { + $candidate = Join-Path $BinDir $name + if (Test-Path $candidate) { $executors += $candidate } + } + foreach ($executor in $executors) { + & $executor --telemetry-consent invalid *> $null + if ($LASTEXITCODE -ne 64) { + throw "$executor returned $LASTEXITCODE for an invalid consent action; expected 64." + } + & $executor --telemetry-consent status --config missing.json *> $null + if ($LASTEXITCODE -ne 64) { + throw "$executor returned $LASTEXITCODE for a consent/config conflict; expected 64." + } + + & $executor --unknown-flag -- --telemetry-consent=request *> $null + if ($LASTEXITCODE -ne 2) { + throw "$executor returned $LASTEXITCODE when consent-like text followed '--'; expected 2." + } + } +} + $expectedBody = @' Help improve MXC by sharing optional diagnostic data with Microsoft. If enabled, MXC sends diagnostic information about product usage, performance, and reliability. MXC does not send your commands, file paths, credentials, or other customer content. You can change your choice at any time. '@ -replace "`r`n", "`n" -function ConvertTo-Base64Json([object]$Value) { - $json = $Value | ConvertTo-Json -Compress -Depth 10 - [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) -} - function Invoke-Maintenance([string]$Action) { - $request = [pscustomobject]@{ command = 'telemetryConsent'; action = $Action } - $output = & $wxcExec --config-base64 (ConvertTo-Base64Json $request) + $output = & $wxcExec --telemetry-consent $Action if ($LASTEXITCODE -ne 0) { throw "$Action failed with exit code $LASTEXITCODE" } $output | ConvertFrom-Json } function Invoke-ConsentRequest([string]$Decision) { - $request = [pscustomobject]@{ command = 'telemetryConsent'; action = 'request'; locale = 'en-US' } $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $wxcExec - $start.Arguments = "--config-base64 $(ConvertTo-Base64Json $request)" + $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US --telemetry-consent-protocol stdio-v1' $start.UseShellExecute = $false $start.RedirectStandardInput = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true $start.CreateNoWindow = $true - $start.Environment['MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL'] = '1' $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey @@ -76,14 +92,51 @@ function Invoke-ConsentRequest([string]$Decision) { decision = $Decision } $process.StandardInput.WriteLine(($response | ConvertTo-Json -Compress)) + $finalRead = $process.StandardOutput.ReadLineAsync() + if (-not $finalRead.Wait(5000)) { + $process.Kill() + throw 'Consent process waited for stdin EOF instead of accepting the decision line.' + } + $finalLine = $finalRead.Result $process.StandardInput.Close() - $finalLine = $process.StandardOutput.ReadLine() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() if ($process.ExitCode -ne 0) { throw "Consent process failed: $stderr" } $finalLine | ConvertFrom-Json } +function Assert-NonInteractiveRequestFails { + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $wxcExec + $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US' + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.CreateNoWindow = $true + $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir + [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') + $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey + $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID'] = "$PID" + $process = New-Object Diagnostics.Process + $process.StartInfo = $start + if (-not $process.Start()) { throw 'Failed to start non-interactive consent process.' } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 1) { + throw "Non-interactive request exited $($process.ExitCode), expected 1: $stderr" + } + $response = $stdout | ConvertFrom-Json + if ($response.result -ne 'presentationUnavailable') { + throw "Non-interactive request returned an unexpected response: $stdout" + } + if (Test-Path $consentFile) { + throw 'Non-interactive request changed the consent store.' + } +} + function Assert-Status( [object]$Value, [string]$Stored, @@ -113,6 +166,8 @@ $policySubkey = "Software\MxcTelemetryConsentSmoke\$runId" $policyPath = "HKCU:\$policySubkey" try { + Assert-ConsentParseExitCodes + New-Item -ItemType Directory -Path (Split-Path -Parent $consentFile) -Force | Out-Null New-Item -Path $policyPath -Force | Out-Null Set-Item "Env:$overrideName" $tempDir @@ -140,6 +195,8 @@ try { } Remove-Item $consentFile -Force + Assert-NonInteractiveRequestFails + $fresh = Invoke-Maintenance 'status' Assert-Status $fresh 'undetermined' 'undetermined' 'unrestricted' $true 'fresh status' diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index bf3355324..84b32a6b3 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -59,29 +59,21 @@ function Get-TraceLoggingProviderGuid { return '{' + ([guid]::new($guidBytes)).ToString() + '}' } -function ConvertTo-Base64Json { - param([Parameter(Mandatory)][object]$Value) - $json = $Value | ConvertTo-Json -Compress -Depth 10 - return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) -} - function Invoke-Status { - $output = & $wxcExe --telemetry-consent-status + $output = & $wxcExe --telemetry-consent status if ($LASTEXITCODE -ne 0) { throw "Consent status failed with exit code $LASTEXITCODE" } return ($output | ConvertFrom-Json) } function Invoke-CanonicalGrant { - $request = [pscustomobject]@{ command = 'telemetryConsent'; action = 'request'; locale = 'en-US' } $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $wxcExe - $start.Arguments = "--config-base64 $(ConvertTo-Base64Json $request)" + $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US --telemetry-consent-protocol stdio-v1' $start.UseShellExecute = $false $start.RedirectStandardInput = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true $start.CreateNoWindow = $true - $start.Environment['MXC_TELEMETRY_CONSENT_PRESENTER_PROTOCOL'] = '1' $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey From 2e6de69d43a93f81c5afdbc391dee1837a1f27e3 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 12:26:18 -0700 Subject: [PATCH 18/47] Fix telemetry propagation through FFI requests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- scripts/check-dotnet-api-parity.js | 23 +++-- .../MxcSandboxTests.cs | 17 ++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs | 1 + src/ffi/mxc_ffi/src/lib.rs | 62 ++++++++++++-- src/ffi/mxc_ffi/src/request.rs | 84 ++++++++++++++++++- src/ffi/mxc_ffi/src/streaming.rs | 4 +- 6 files changed, 170 insertions(+), 21 deletions(-) diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js index 68201386f..0dcf07950 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -206,22 +206,21 @@ function managedJsonFields(source, className) { }); } -// `legacyManagedOnly` lists managed JSON fields that intentionally have no Rust -// counterpart because they are deprecated compatibility aliases. They must stay -// serializable so legacy JSON round-trips, but the managed request path strips -// them before the native layer sees them. +// `managedOnly` lists managed JSON fields intentionally represented outside the +// Rust policy builder. Compatibility aliases are stripped before native parsing; +// telemetry is extracted by the FFI and applied through SandboxRequest. function compareStructFields( label, rustSource, rustName, managedSource, managedName, - legacyManagedOnly = [] + managedOnly = [] ) { compare( `${label} fields`, managedJsonFields(managedSource, managedName).filter( - (field) => !legacyManagedOnly.includes(field) + (field) => !managedOnly.includes(field) ), rustStructFields(rustSource, rustName) ); @@ -312,12 +311,18 @@ for (const [ rustSource, rustName, managedName, - legacyManagedOnly, + managedOnly, ] of [ // `captureDenials` is an obsolete managed-only alias (MXC0001, removed in // 1.0). Rust only accepts it under `containment.captureDenials`, and // MxcSandbox.PrepareRequest strips it from the policy before serialization. - ["sandbox policy", rustPolicy, "SandboxPolicy", "SandboxPolicy", ["captureDenials"]], + [ + "sandbox policy", + rustPolicy, + "SandboxPolicy", + "SandboxPolicy", + ["captureDenials", "telemetry"], + ], ["filesystem policy", rustPolicy, "FilesystemSection", "FilesystemPolicy"], ["UI policy", rustPolicy, "UiSection", "UiPolicy"], ["network policy", rustNetworkPolicy, "NetworkSection", "NetworkPolicy"], @@ -334,7 +339,7 @@ for (const [ rustName, managedPolicy, managedName, - legacyManagedOnly + managedOnly ); } const managedOneShot = [ diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 91864d802..662f2ba8f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -827,6 +827,23 @@ public void SerializeRequest_StripsLegacyCaptureDenialsFromThePolicy() .GetString()); } + [Fact] + public void SerializeRequest_PreservesTelemetryWhenMigratingLegacyCaptureDenials() + { + var policy = CreateLegacyCaptureDenialsPolicy( + new CaptureDenialsPolicy(), + "0.9.0-alpha"); + policy.Telemetry = new TelemetrySettings { Enabled = true }; + var request = new SandboxRequest(policy, "echo hi"); + + using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); + var serializedPolicy = doc.RootElement.GetProperty("policy"); + + Assert.True( + serializedPolicy.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + Assert.False(serializedPolicy.TryGetProperty("captureDenials", out _)); + } + private static SandboxPolicy CreateLegacyCaptureDenialsPolicy( CaptureDenialsPolicy captureDenials, string version = "0.8.0-alpha") diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs index 12810c042..146a94f38 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs @@ -320,6 +320,7 @@ private static SandboxPolicy ClonePolicyWithoutCaptureDenials(SandboxPolicy poli Network = policy.Network, Ui = policy.Ui, TimeoutMs = policy.TimeoutMs, + Telemetry = policy.Telemetry, }; private static bool CaptureDenialsEqual( diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 3c28d6c37..694b3ced7 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -401,10 +401,52 @@ fn telemetry_enabled_from_policy(policy: &FfiPolicyEnvelope) -> Result Result<(), String> { + let Some(policy) = value.as_object() else { + return Ok(()); + }; + if !policy.contains_key("telemetry") { + return Ok(()); + } + let Some(version) = policy.get("version").and_then(serde_json::Value::as_str) else { + return Ok(()); + }; + let Some(core) = version.split(['-', '+']).next() else { + return Ok(()); + }; + let mut components = core.split('.'); + let (Some(major), Some(minor), Some(patch), None) = ( + components.next(), + components.next(), + components.next(), + components.next(), + ) else { + return Ok(()); + }; + let (Ok(major), Ok(minor), Ok(_patch)) = ( + major.parse::(), + minor.parse::(), + patch.parse::(), + ) else { + return Ok(()); + }; + if major == 0 && minor < 9 { + return Err( + "failed to parse policy JSON: top-level 'telemetry' requires config schema version \ + 0.9.0-alpha or later" + .to_string(), + ); + } + Ok(()) +} + pub(crate) fn parse_policy_json( policy_json: &str, ) -> Result<(SandboxPolicy, Option), String> { - let envelope: FfiPolicyEnvelope = serde_json::from_str(policy_json) + let value: serde_json::Value = serde_json::from_str(policy_json) + .map_err(|error| format!("failed to parse policy JSON: {error}"))?; + validate_canonical_telemetry_version(&value)?; + let envelope: FfiPolicyEnvelope = serde_json::from_value(value) .map_err(|error| format!("failed to parse policy JSON: {error}"))?; let telemetry_enabled = telemetry_enabled_from_policy(&envelope)?; Ok((envelope.policy, telemetry_enabled)) @@ -1045,13 +1087,21 @@ mod tests { #[test] fn policy_telemetry_switch_accepts_canonical_shape() { let (policy, telemetry_enabled) = - parse_policy_json(r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#) + parse_policy_json(r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true}}"#) .expect("policy should parse"); - assert_eq!(policy.version, "0.8.0-alpha"); + assert_eq!(policy.version, "0.9.0-alpha"); assert_eq!(telemetry_enabled, Some(true)); } + #[test] + fn policy_telemetry_switch_rejects_canonical_shape_before_schema_09() { + let error = parse_policy_json(r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#) + .expect_err("canonical telemetry must honor the native schema boundary"); + + assert!(error.contains("requires config schema version 0.9.0-alpha")); + } + #[test] fn policy_telemetry_switch_keeps_legacy_alias_for_compatibility() { let (policy, telemetry_enabled) = @@ -1073,7 +1123,7 @@ mod tests { #[test] fn policy_telemetry_switch_rejects_conflicting_shapes() { let error = parse_policy_json( - r#"{"version":"0.8.0-alpha","telemetry":{"enabled":false},"telemetryEnabled":true}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":false},"telemetryEnabled":true}"#, ) .expect_err("conflicting telemetry fields must fail"); @@ -1085,11 +1135,11 @@ mod tests { for (policy_json, expected) in [ (r#"{"version":"0.8.0-alpha"}"#, None), ( - r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true}}"#, Some(true), ), ( - r#"{"version":"0.8.0-alpha","telemetry":{"enabled":false}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":false}}"#, Some(false), ), ( diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index ca9ab5f77..ec576b7b5 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -15,10 +15,37 @@ use mxc_sdk::{ }; use serde_json::Value; +struct RequestPolicy { + policy: SandboxPolicy, + telemetry_enabled: Option, +} + +impl RequestPolicy { + fn sdk_policy(&self) -> &SandboxPolicy { + &self.policy + } +} + +impl<'de> serde::Deserialize<'de> for RequestPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + let policy_json = serde_json::to_string(&value).map_err(serde::de::Error::custom)?; + let (policy, telemetry_enabled) = + crate::parse_policy_json(&policy_json).map_err(serde::de::Error::custom)?; + Ok(Self { + policy, + telemetry_enabled, + }) + } +} + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RequestSpec { - policy: SandboxPolicy, + policy: RequestPolicy, command: String, #[serde(default)] containment: RequestContainment, @@ -235,14 +262,20 @@ pub(crate) fn build_request_from_json(request_json: &str) -> Result Date: Wed, 2 Sep 2026 13:08:52 -0700 Subject: [PATCH 19/47] Remove consent transport versioning Auto-select terminal or piped presenter transport for consent requests, treat terminal EOF as failure, and remove redundant timing and documentation changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .github/copilot-instructions.md | 1 - README.md | 11 --- docs/telemetry/telemetry-consent-design.md | 8 +- docs/telemetry/telemetry.md | 12 --- scripts/versioning/check-schema-codegen.js | 99 +++++++++---------- sdk/node/README.md | 2 +- src/core/lxc/src/main.rs | 9 -- src/core/mxc_darwin/src/main.rs | 9 -- src/core/wxc/src/main.rs | 22 ----- src/core/wxc_common/src/telemetry/consent.rs | 20 ---- .../wxc_common/src/telemetry/consent_cli.rs | 98 +++++++----------- ...json => presenter-decision-dismissed.json} | 0 ...ion-no.json => presenter-decision-no.json} | 0 ... => presenter-decision-unknown-field.json} | 0 ...n-yes.json => presenter-decision-yes.json} | 0 .../run_telemetry_consent_smoke_test.ps1 | 2 +- .../scripts/run_telemetry_etw_smoke_test.ps1 | 2 +- 17 files changed, 87 insertions(+), 208 deletions(-) rename tests/fixtures/telemetry-consent/{stdio-v1-decision-dismissed.json => presenter-decision-dismissed.json} (100%) rename tests/fixtures/telemetry-consent/{stdio-v1-decision-no.json => presenter-decision-no.json} (100%) rename tests/fixtures/telemetry-consent/{stdio-v1-decision-unknown-field.json => presenter-decision-unknown-field.json} (100%) rename tests/fixtures/telemetry-consent/{stdio-v1-decision-yes.json => presenter-decision-yes.json} (100%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7fa102e37..d134f4b2a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -332,7 +332,6 @@ Telemetry is **Windows-only** and is never collected without explicit user conse - **Everything fails closed.** Any error, unreadable value, corrupt file, missing native library, or ambiguity must resolve to "no telemetry" (`Undetermined` consent / `Blocked` policy), never to a permissive state. - **Policy may restrict, but may never substitute for, consent.** The administrative policy is a deny-only ceiling: it can subtract from what a user permitted, never add to it. An administrator cannot opt a user in — a denied or never-asked user stays opted out even under `AllowTelemetry=3`. Keep the terms combined with `&&`; never add a policy value or config path that grants collection on its own. - **MXC owns its own consent state.** It must never read or infer from the Windows system telemetry consent. The consent store is a per-user JSON file; the policy is `HKLM\SOFTWARE\Policies\Mxc` → `AllowTelemetry` (`REG_DWORD`). -- **Consent is a separate control plane.** Executors expose `--telemetry-consent `; never route consent maintenance through `--config` / `--config-base64`, add it to `MxcConfig`, or publish a consent JSON schema. `telemetry.enabled` remains only the per-run opt-in. - **One definition.** The Rust `ConsentState` / `PolicyState` enums and their stable string representations are the source of truth. Keep those spellings aligned anywhere this branch surfaces them. - **Test isolation.** The consent store and the policy key are process-global, each behind its own mutex. Use `wxc_common::telemetry::test_support::TelemetryTestEnv` whenever a test needs both; constructing `PolicyKeyGuard` and `LocalAppDataGuard` directly in the same test risks a lock-order deadlock. The consent-store override is compiled into test binaries and into debug `test-support` builds only, so release binaries do not honor it. Keep the `test-support` feature confined to test/development dependencies. - **Never swallow a failure silently.** Fail-closed return values are indistinguishable from legitimate ones, so a broken install would otherwise be invisible. Every swallowed failure is reported once per distinct failure per process, and the reporter itself must never throw. diff --git a/README.md b/README.md index be6b8b1e6..22d913db7 100644 --- a/README.md +++ b/README.md @@ -257,17 +257,6 @@ Telemetry is **off by default**. To keep it off, do not set If telemetry is enabled in config, collection still does not occur unless Windows user consent is granted and administrative policy allows collection. -Consent is managed separately from execution config: - -```powershell -wxc-exec.exe --telemetry-consent status -wxc-exec.exe --telemetry-consent request -wxc-exec.exe --telemetry-consent withdraw -``` - -`telemetry.enabled` cannot request, grant, or withdraw consent. Consent -maintenance is not accepted through `--config` or `--config-base64`. - #### What official builds send Official/shipped Microsoft builds set a TraceLogging provider group GUID at build time and route `MXC.Execution` and `MXC.Error` events to Microsoft through the UTC pipeline when telemetry is enabled. **Local and open-source builds send nothing to Microsoft by default** — the public source ships without a provider group GUID, so events are emitted to the local ETW subsystem only and are not routed to any Microsoft collection pipeline. Internal builds that set the `MXC_TELEMETRY_PROVIDER_GROUP_GUID` environment variable at build time enable the Microsoft-routed path. diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index 1e4cfa96a..4633cf591 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -115,8 +115,7 @@ write the prompt to stderr, read `Y` or `N` from a terminal, and write one final JSON outcome to stdout. A request without terminal stdin and stderr fails without changing consent. -The Node SDK uses the hidden -`--telemetry-consent-protocol stdio-v1` request mode because it owns the host +When standard input and output are pipes, `request` uses the Node host presentation callback. Native MXC emits at most one `presentationRequired` JSON line followed by one final JSON line. The host returns exactly one JSON decision line containing the emitted challenge, prompt resource version, and @@ -129,11 +128,6 @@ Operational failures such as a missing terminal, required-decision EOF, broken pipe, presenter failure, or persistence failure exit `1`. `status`, `withdraw`, and short-circuit request outcomes never invoke a presenter. -Consent maintenance objects are deliberately not accepted through `--config` -or `--config-base64`, are not part of `MxcConfig`, and have no published JSON -Schema. The stable `telemetry.enabled` execution setting remains only the -per-run opt-in and cannot request, grant, or withdraw consent. - ## State and persistence MXC stores consent per user at: diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index b1bd7f2a6..e3221282e 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -182,18 +182,6 @@ Telemetry emission is gated by the per-run request, MXC-owned consent, administrative policy, and provider availability. See [`docs/telemetry/telemetry-consent-design.md`](telemetry-consent-design.md). -Consent is managed through the dedicated executor control plane: - -```text -wxc-exec --telemetry-consent status -wxc-exec --telemetry-consent request -wxc-exec --telemetry-consent withdraw -``` - -These actions are not MXC execution configurations and are never accepted -through `--config` or `--config-base64`. The stable `telemetry.enabled` -setting remains only the additional per-run opt-in. - ## Privacy review status The version 1 `en-US` consent wording is approved for release review. The diff --git a/scripts/versioning/check-schema-codegen.js b/scripts/versioning/check-schema-codegen.js index e48fa70c2..c0e99393c 100644 --- a/scripts/versioning/check-schema-codegen.js +++ b/scripts/versioning/check-schema-codegen.js @@ -28,72 +28,65 @@ function fail(msg) { const schemaVer = JSON.parse( readFileSync(join(repoRoot, "schemas", "schema-version.json"), "utf8") ); -const configSchemaPath = join( +const committedPath = join( repoRoot, "schemas", "dev", `mxc-config.schema.${schemaVer.devSchemaFile}.json` ); -const tmpDir = mkdtempSync(join(os.tmpdir(), "mxc-schema-gen-")); -const tmpConfigOut = join(tmpDir, "generated-config.json"); + +let committed; try { - const normalize = (s) => s.replace(/\r\n/g, "\n"); + committed = readFileSync(committedPath, "utf8"); +} catch (e) { + fail(`could not read committed schema ${committedPath}: ${e.message}`); +} - function compareGeneratedSchema({ - committedPath, - generatedPath, - generatorArgs, - regenCommand, - }) { - let committed; - try { - committed = readFileSync(committedPath, "utf8"); - } catch (e) { - fail(`could not read committed schema ${committedPath}: ${e.message}`); - } +const tmpDir = mkdtempSync(join(os.tmpdir(), "mxc-schema-gen-")); +const tmpOut = join(tmpDir, "generated.json"); +try { + // Build + run the generator. Quiet so only our diagnostics surface. + execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "mxc_schema_gen", + "--", + "schema", + "--legacy-wire", + "--out", + tmpOut, + ], + { cwd: join(repoRoot, "src"), stdio: ["ignore", "ignore", "inherit"] } + ); + const generated = readFileSync(tmpOut, "utf8"); - execFileSync( - "cargo", - [ - "run", - "-q", - "-p", - "mxc_schema_gen", - "--", - ...generatorArgs, - "--out", - generatedPath, - ], - { cwd: join(repoRoot, "src"), stdio: ["ignore", "ignore", "inherit"] } + // Compare modulo line endings: the schema is committed with LF, but on a + // Windows checkout with core.autocrlf=true the working-tree copy has CRLF. + // The generator always writes LF, so normalize both sides to avoid a + // false-positive "stale" failure that only reproduces on Windows. + const normalize = (s) => s.replace(/\r\n/g, "\n"); + if (normalize(generated) !== normalize(committed)) { + // Find the first differing line for a helpful pointer. + const g = normalize(generated).split("\n"); + const c = normalize(committed).split("\n"); + let line = 0; + while (line < g.length && line < c.length && g[line] === c[line]) line++; + fail( + `committed schema is stale at ${committedPath}.\n` + + ` First difference at line ${line + 1}:\n` + + ` committed: ${JSON.stringify(c[line])}\n` + + ` generated: ${JSON.stringify(g[line])}\n` + + ` Regenerate with (from the repo root; the Cargo workspace is in src/):\n` + + ` cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schema --legacy-wire --out schemas/dev/mxc-config.schema.${schemaVer.devSchemaFile}.json` ); - - const generated = readFileSync(generatedPath, "utf8"); - if (normalize(generated) !== normalize(committed)) { - const g = normalize(generated).split("\n"); - const c = normalize(committed).split("\n"); - let line = 0; - while (line < g.length && line < c.length && g[line] === c[line]) line++; - fail( - `committed schema is stale at ${committedPath}.\n` + - ` First difference at line ${line + 1}:\n` + - ` committed: ${JSON.stringify(c[line])}\n` + - ` generated: ${JSON.stringify(g[line])}\n` + - ` Regenerate with (from the repo root; the Cargo workspace is in src/):\n` + - ` ${regenCommand}` - ); - } } - - compareGeneratedSchema({ - committedPath: configSchemaPath, - generatedPath: tmpConfigOut, - generatorArgs: ["schema", "--legacy-wire"], - regenCommand: `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schema --legacy-wire --out schemas/dev/mxc-config.schema.${schemaVer.devSchemaFile}.json`, - }); } finally { rmSync(tmpDir, { recursive: true, force: true }); } console.log( - `Schema codegen OK: committed config schema matches generated output (${schemaVer.devSchemaFile}).` + `Schema codegen OK: committed dev schema matches the generated output (${schemaVer.devSchemaFile}).` ); diff --git a/sdk/node/README.md b/sdk/node/README.md index 47e2fcee3..d01f9cae9 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -64,7 +64,7 @@ child.on('close', (code) => console.log('exit:', code)); Pick `0.8.0-alpha` for new code on any supported platform. -> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts experimental backend settings when paired with `--experimental`; however, fields introduced in a specific contract, including top-level `telemetry` in `0.9.0-alpha`, are rejected when the request explicitly declares an older schema version. +> **Stable schemas document only the non-experimental surface.** Experimental backends (`windows_sandbox`, `wslc`, `microvm`, `hyperlight`, `isolation_session`), the `experimental.*` block, and state-aware lifecycle live in `0.9.0-dev`. The parser still accepts them when paired with `--experimental` regardless of which schema your config validates against — schema choice affects editor validation, not runtime behavior. > **Network host allow/block lists are not implemented on Windows.** `network.allowedHosts` / `network.blockedHosts` have no enforcement on this platform — use `network.defaultPolicy` (`allow` / `block`) or `network.proxy` to constrain network access. diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 5a857c992..286b0ec96 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -96,14 +96,6 @@ struct Cli { /// Preferred BCP 47 locale for a telemetry consent request. #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] telemetry_consent_locale: Option, - - /// Private SDK presenter protocol. - #[arg( - long = "telemetry-consent-protocol", - requires = "telemetry_consent", - hide = true - )] - telemetry_consent_protocol: Option, } fn parse_cli() -> Cli { @@ -140,7 +132,6 @@ fn handle_telemetry_consent_flags(cli: &Cli) -> bool { let outcome = telemetry::consent_cli::handle_consent_command( action, cli.telemetry_consent_locale.as_deref(), - cli.telemetry_consent_protocol, ); let code = outcome.emit(); if code != 0 { diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index ff45169df..1fa8fc99e 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -80,14 +80,6 @@ struct Cli { /// Preferred BCP 47 locale for a telemetry consent request. #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] telemetry_consent_locale: Option, - - /// Private SDK presenter protocol. - #[arg( - long = "telemetry-consent-protocol", - requires = "telemetry_consent", - hide = true - )] - telemetry_consent_protocol: Option, } fn parse_cli() -> Cli { @@ -126,7 +118,6 @@ fn handle_telemetry_consent_flags(cli: &Cli) -> bool { let outcome = wxc_common::telemetry::consent_cli::handle_consent_command( action, cli.telemetry_consent_locale.as_deref(), - cli.telemetry_consent_protocol, ); let code = outcome.emit(); if code != 0 { diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index cfcd4e797..e850d7cc7 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -138,14 +138,6 @@ struct Cli { #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] telemetry_consent_locale: Option, - /// Private SDK presenter protocol. - #[arg( - long = "telemetry-consent-protocol", - requires = "telemetry_consent", - hide = true - )] - telemetry_consent_protocol: Option, - /// Windows Sandbox: tear down a running WSB VM that mxc cannot prove it /// launched, instead of refusing — clears a host wedged by an orphan left /// after a launcher hard-kill. DANGER: proofless, so it may also kill a @@ -287,7 +279,6 @@ fn handle_telemetry_consent_flags(cli: &Cli) -> bool { let outcome = telemetry::consent_cli::handle_consent_command( action, cli.telemetry_consent_locale.as_deref(), - cli.telemetry_consent_protocol, ); let code = outcome.emit(); if code != 0 { @@ -1653,8 +1644,6 @@ mod tests { "request", "--telemetry-consent-locale", "en-US", - "--telemetry-consent-protocol", - "stdio-v1", ]); assert_eq!( @@ -1662,10 +1651,6 @@ mod tests { Some(telemetry::consent_cli::ConsentAction::Request) ); assert_eq!(cli.telemetry_consent_locale.as_deref(), Some("en-US")); - assert_eq!( - cli.telemetry_consent_protocol, - Some(telemetry::consent_cli::ConsentProtocol::StdioV1) - ); } #[test] @@ -1716,13 +1701,6 @@ mod tests { assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); } - #[test] - fn private_consent_protocol_is_hidden_from_help() { - let help = Cli::command().render_long_help().to_string(); - assert!(!help.contains("--telemetry-consent-protocol")); - assert!(help.contains("--telemetry-consent")); - } - #[test] fn cli_captures_hyphenated_command_after_separator() { let cli = parse_cli(&[ diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 2a86d5eed..73bc8d5a3 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -2446,7 +2446,6 @@ mod tests { fn io_retry_does_not_retry_not_found() { use crate::telemetry::consent::platform; let mut calls = 0; - let started = std::time::Instant::now(); let result = platform::with_io_retry_for_test(|| { calls += 1; Err::<(), _>(std::io::Error::new( @@ -2456,25 +2455,6 @@ mod tests { }); assert!(result.is_err()); assert_eq!(calls, 1, "NotFound must not be retried"); - assert!( - started.elapsed() < std::time::Duration::from_millis(20), - "NotFound must not sleep on the retry delay" - ); - } - - /// The whole point of the above: reading a fresh (nonexistent) store - /// is the common case and must be effectively instant. - #[test] - fn fresh_store_read_does_not_sleep() { - let tmp = tempfile::tempdir().unwrap(); - let _guard = LocalAppDataGuard::set(tmp.path()); - let started = std::time::Instant::now(); - assert_eq!(get_consent(), ConsentState::Undetermined); - assert!( - started.elapsed() < std::time::Duration::from_millis(40), - "fresh-store read took {:?}; the retry loop is sleeping on NotFound again", - started.elapsed() - ); } #[test] diff --git a/src/core/wxc_common/src/telemetry/consent_cli.rs b/src/core/wxc_common/src/telemetry/consent_cli.rs index 4264827a7..0eb2da795 100644 --- a/src/core/wxc_common/src/telemetry/consent_cli.rs +++ b/src/core/wxc_common/src/telemetry/consent_cli.rs @@ -43,26 +43,6 @@ impl FromStr for ConsentAction { } } -/// Optional private presenter protocol selected by SDK hosts. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConsentProtocol { - /// Newline-delimited JSON over the executor's standard streams. - StdioV1, -} - -impl FromStr for ConsentProtocol { - type Err = String; - - fn from_str(value: &str) -> Result { - match value { - "stdio-v1" => Ok(Self::StdioV1), - _ => Err(format!( - "invalid telemetry consent protocol '{value}'; expected stdio-v1" - )), - } - } -} - /// Whether an argv sequence invokes the dedicated consent command surface. /// /// Executor binaries use this to map clap failures involving consent options @@ -82,8 +62,6 @@ where || argument.starts_with("--telemetry-consent=") || argument == "--telemetry-consent-locale" || argument.starts_with("--telemetry-consent-locale=") - || argument == "--telemetry-consent-protocol" - || argument.starts_with("--telemetry-consent-protocol=") }) } @@ -141,11 +119,7 @@ impl ConsentCliOutcome { } /// Execute one dedicated telemetry-consent command. -pub fn handle_consent_command( - action: ConsentAction, - locale: Option<&str>, - selected_protocol: Option, -) -> ConsentCliOutcome { +pub fn handle_consent_command(action: ConsentAction, locale: Option<&str>) -> ConsentCliOutcome { if action != ConsentAction::Request && locale.is_some() { return ConsentCliOutcome::failure( "Error: --telemetry-consent-locale is valid only with \ @@ -154,15 +128,6 @@ pub fn handle_consent_command( 64, ); } - if action != ConsentAction::Request && selected_protocol.is_some() { - return ConsentCliOutcome::failure( - "Error: --telemetry-consent-protocol is valid only with \ - --telemetry-consent request" - .to_string(), - 64, - ); - } - match action { ConsentAction::Status => ConsentCliOutcome::json(&consent_response( action, @@ -184,10 +149,7 @@ pub fn handle_consent_command( ConsentAction::Request => { let mut presenter_failure_kind = None; let result = consent::request_consent(locale, |prompt| { - let presented = match selected_protocol { - Some(ConsentProtocol::StdioV1) => present_over_stdio_protocol(action, prompt), - None => present_on_terminal(prompt).map_err(ProtocolFailure::operational), - }; + let presented = present_request(action, prompt); presented.map_err(|failure| { presenter_failure_kind = Some(failure.kind); failure.message @@ -223,6 +185,21 @@ pub fn handle_consent_command( } } +fn present_request( + action: ConsentAction, + prompt: &consent_prompt::ConsentPrompt, +) -> Result { + if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() { + return present_on_terminal(prompt).map_err(ProtocolFailure::operational); + } + if !std::io::stdin().is_terminal() && !std::io::stdout().is_terminal() { + return present_over_stdio(action, prompt); + } + Err(ProtocolFailure::operational( + "interactive telemetry consent presentation is unavailable", + )) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProtocolFailureKind { InvalidInput, @@ -278,10 +255,14 @@ fn present_on_terminal( let read = std::io::stdin() .read_line(&mut input) .map_err(|error| format!("failed to read telemetry consent response: {error}"))?; - if read == 0 { - return Ok(consent::ConsentDecision::Dismissed); + terminal_decision(read, &input) +} + +fn terminal_decision(bytes_read: usize, input: &str) -> Result { + if bytes_read == 0 { + return Err("terminal closed without returning a telemetry consent decision".to_string()); } - Ok(parse_terminal_decision(&input)) + Ok(parse_terminal_decision(input)) } fn parse_terminal_decision(input: &str) -> consent::ConsentDecision { @@ -292,7 +273,7 @@ fn parse_terminal_decision(input: &str) -> consent::ConsentDecision { } } -fn present_over_stdio_protocol( +fn present_over_stdio( action: ConsentAction, prompt: &consent_prompt::ConsentPrompt, ) -> Result { @@ -629,7 +610,7 @@ mod tests { #[test] fn locale_is_rejected_for_non_request_actions() { - let outcome = handle_consent_command(ConsentAction::Status, Some("en-US"), None); + let outcome = handle_consent_command(ConsentAction::Status, Some("en-US")); assert_eq!(outcome.exit_code, 64); assert!(outcome.stderr.as_deref().unwrap().contains("valid only")); } @@ -691,14 +672,9 @@ mod tests { } #[test] - fn protocol_is_rejected_for_non_request_actions() { - let outcome = handle_consent_command( - ConsentAction::Withdraw, - None, - Some(ConsentProtocol::StdioV1), - ); - assert_eq!(outcome.exit_code, 64); - assert!(outcome.stderr.as_deref().unwrap().contains("valid only")); + fn terminal_eof_is_a_presentation_failure() { + let error = terminal_decision(0, "").unwrap_err(); + assert!(error.contains("closed without returning")); } fn assert_status_json( @@ -845,21 +821,21 @@ mod tests { ( include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json" + "/../../../tests/fixtures/telemetry-consent/presenter-decision-yes.json" )), consent::ConsentDecision::Yes, ), ( include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-no.json" + "/../../../tests/fixtures/telemetry-consent/presenter-decision-no.json" )), consent::ConsentDecision::No, ), ( include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json" + "/../../../tests/fixtures/telemetry-consent/presenter-decision-dismissed.json" )), consent::ConsentDecision::Dismissed, ), @@ -873,7 +849,7 @@ mod tests { } let invalid = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../../tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json" + "/../../../tests/fixtures/telemetry-consent/presenter-decision-unknown-field.json" )); assert_eq!( parse_presenter_response(invalid, "request-a", 1) @@ -896,7 +872,7 @@ mod tests { #[test] fn status_reports_not_applicable_and_succeeds() { - let outcome = handle_consent_command(ConsentAction::Status, None, None); + let outcome = handle_consent_command(ConsentAction::Status, None); assert_eq!(outcome.exit_code, 0); assert_status_json( &outcome, @@ -929,7 +905,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let _guards = isolate(tmp.path()); - let outcome = handle_consent_command(ConsentAction::Status, None, None); + let outcome = handle_consent_command(ConsentAction::Status, None); assert_eq!(outcome.exit_code, 0); assert_status_json( &outcome, @@ -951,7 +927,7 @@ mod tests { let env = isolate(tmp.path()); env.set_policy_value(0); - let outcome = handle_consent_command(ConsentAction::Status, None, None); + let outcome = handle_consent_command(ConsentAction::Status, None); assert_eq!(outcome.exit_code, 0); assert_status_json(&outcome, "undetermined", "undetermined", "blocked", false); } @@ -967,7 +943,7 @@ mod tests { std::fs::write(tmp.path().join("mxc"), b"not a directory").unwrap(); let _guards = isolate(tmp.path()); - let outcome = handle_consent_command(ConsentAction::Withdraw, None, None); + let outcome = handle_consent_command(ConsentAction::Withdraw, None); assert_eq!(outcome.exit_code, 1); assert_eq!(outcome.stdout, None); assert!(outcome.stderr.as_deref().unwrap().starts_with("Error: ")); diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json b/tests/fixtures/telemetry-consent/presenter-decision-dismissed.json similarity index 100% rename from tests/fixtures/telemetry-consent/stdio-v1-decision-dismissed.json rename to tests/fixtures/telemetry-consent/presenter-decision-dismissed.json diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-no.json b/tests/fixtures/telemetry-consent/presenter-decision-no.json similarity index 100% rename from tests/fixtures/telemetry-consent/stdio-v1-decision-no.json rename to tests/fixtures/telemetry-consent/presenter-decision-no.json diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json b/tests/fixtures/telemetry-consent/presenter-decision-unknown-field.json similarity index 100% rename from tests/fixtures/telemetry-consent/stdio-v1-decision-unknown-field.json rename to tests/fixtures/telemetry-consent/presenter-decision-unknown-field.json diff --git a/tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json b/tests/fixtures/telemetry-consent/presenter-decision-yes.json similarity index 100% rename from tests/fixtures/telemetry-consent/stdio-v1-decision-yes.json rename to tests/fixtures/telemetry-consent/presenter-decision-yes.json diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index 7228f8c84..f1ff0af3f 100644 --- a/tests/scripts/run_telemetry_consent_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -54,7 +54,7 @@ function Invoke-Maintenance([string]$Action) { function Invoke-ConsentRequest([string]$Decision) { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $wxcExec - $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US --telemetry-consent-protocol stdio-v1' + $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US' $start.UseShellExecute = $false $start.RedirectStandardInput = $true $start.RedirectStandardOutput = $true diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index 84b32a6b3..b10fbc19a 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -68,7 +68,7 @@ function Invoke-Status { function Invoke-CanonicalGrant { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $wxcExe - $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US --telemetry-consent-protocol stdio-v1' + $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US' $start.UseShellExecute = $false $start.RedirectStandardInput = $true $start.RedirectStandardOutput = $true From 2dc3c0b845f3d6b2fb17913dd710d628cc564f22 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 13:23:35 -0700 Subject: [PATCH 20/47] Simplify consent command dispatch Keep process exit ownership in executor main functions and remove the unrelated public request-hint optimization from the telemetry runtime change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- src/core/lxc/src/main.rs | 51 ++-------- src/core/mxc_darwin/src/main.rs | 46 ++------- src/core/wxc/src/main.rs | 68 +++----------- src/core/wxc_common/src/config_parser.rs | 115 +++++------------------ 4 files changed, 46 insertions(+), 234 deletions(-) diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 286b0ec96..3cbb9e188 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -119,27 +119,6 @@ fn parse_cli() -> Cli { } } -/// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this -/// mirrors. On Linux, `wxc_common::telemetry::consent` always reports -/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so -/// this fast path can't drift from `wxc-exec`/`mxc-exec-mac`. The shared -/// handler returns the outcome as data; terminating the process is this -/// binary's job, not the foundation crate's. -fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(action) = cli.telemetry_consent else { - return false; - }; - let outcome = telemetry::consent_cli::handle_consent_command( - action, - cli.telemetry_consent_locale.as_deref(), - ); - let code = outcome.emit(); - if code != 0 { - std::process::exit(code); - } - true -} - /// Read the request source (file path / base64 blob) once. fn decode_config_input_once(cli: &Cli) -> Option> { let (input, is_base64) = if let Some(input) = cli.config_base64.as_ref() { @@ -207,23 +186,16 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { fn main() { let cli = parse_cli(); - // --telemetry-consent: report/administer the - // (always not-applicable on Linux) consent state and exit. Runs before - // signal_cleanup::install(): - // this is a read-only/local-file fast path that never spawns a - // container, so it must not be gated on — or fail because of — signal - // handler installation, matching `wxc-exec`/`mxc-exec-mac`, where the - // consent fast path also runs unconditionally before any other setup. - if handle_telemetry_consent_flags(&cli) { - return; + if let Some(action) = cli.telemetry_consent { + let outcome = telemetry::consent_cli::handle_consent_command( + action, + cli.telemetry_consent_locale.as_deref(), + ); + process::exit(outcome.emit()); } // Decode the request source (file path / base64) once, up front. let decoded_config: Option> = decode_config_input_once(&cli); - let request_hint = decoded_config - .as_ref() - .and_then(|result| result.as_ref().ok()) - .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); // Install before spawning any other threads so the signal mask propagates. // Failure here is fatal: install() either succeeds with the watchdog // running, or restores the original signal mask and returns Err. We @@ -326,16 +298,7 @@ fn main() { let config_json = config_json.expect("config_json is Some on non-delete paths"); // Load request - let parsed_request = if let Some(hint) = request_hint.as_ref() { - wxc_common::config_parser::load_request_from_json_with_hint_and_options( - &config_json, - &mut logger, - wxc_common::config_parser::LoadOptions::default(), - hint, - ) - } else { - load_request_from_json(&config_json, &mut logger) - }; + let parsed_request = load_request_from_json(&config_json, &mut logger); let mut request = match parsed_request { Ok(r) => r, Err(_) => { diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index 1fa8fc99e..fff57e3ff 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -105,27 +105,6 @@ fn parse_cli() -> Cli { } } -/// See `wxc::handle_telemetry_consent_flags` for the Windows behavior this -/// mirrors. On macOS, `wxc_common::telemetry::consent` always reports -/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler so -/// this fast path can't drift from `wxc-exec`/`lxc-exec`. The shared handler -/// returns the outcome as data; terminating the process is this binary's -/// job, not the foundation crate's. -fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(action) = cli.telemetry_consent else { - return false; - }; - let outcome = wxc_common::telemetry::consent_cli::handle_consent_command( - action, - cli.telemetry_consent_locale.as_deref(), - ); - let code = outcome.emit(); - if code != 0 { - std::process::exit(code); - } - true -} - /// Decode the config input source (base64 arg / file path) exactly once. /// /// Returns `None` if the CLI provided no config source at all — the caller @@ -162,18 +141,16 @@ fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { fn main() { let cli = parse_cli(); - // --telemetry-consent: report/administer the - // (always not-applicable on macOS) consent state and exit. - if handle_telemetry_consent_flags(&cli) { - return; + if let Some(action) = cli.telemetry_consent { + let outcome = wxc_common::telemetry::consent_cli::handle_consent_command( + action, + cli.telemetry_consent_locale.as_deref(), + ); + process::exit(outcome.emit()); } // Decode the config source once for normal request loading. let decoded_config: Option> = decode_config_input_once(&cli); - let request_hint = decoded_config - .as_ref() - .and_then(|result| result.as_ref().ok()) - .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); // Determine config input. let config_json = match decoded_config { Some(Ok(json)) => json, @@ -201,16 +178,7 @@ fn main() { } } - let parsed_request = if let Some(hint) = request_hint.as_ref() { - wxc_common::config_parser::load_request_from_json_with_hint_and_options( - &config_json, - &mut logger, - wxc_common::config_parser::LoadOptions::default(), - hint, - ) - } else { - load_request_from_json(&config_json, &mut logger) - }; + let parsed_request = load_request_from_json(&config_json, &mut logger); let mut request = match parsed_request { Ok(r) => r, Err(_) => { diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index e850d7cc7..d932a63e1 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -259,34 +259,6 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { Ok(()) } -/// Handles the dedicated `--telemetry-consent` fast path. -/// -/// Returns `true` if one of the flags was handled (and the caller should -/// exit immediately), `false` if none were passed and normal execution -/// should proceed. Never spawns a sandbox, never touches config parsing, and -/// runs before COM/WinRT init — mirroring the `--probe` fast path. -/// -/// Delegates to the shared `wxc_common::telemetry::consent_cli` handler -/// (identical across all three executors) so this fast path can't drift -/// between `wxc-exec`, `lxc-exec`, and `mxc-exec-mac`. The shared handler -/// returns the outcome as data; terminating the process is this binary's -/// job, not the foundation crate's. See -/// `docs/telemetry/telemetry-consent-design.md`. -fn handle_telemetry_consent_flags(cli: &Cli) -> bool { - let Some(action) = cli.telemetry_consent else { - return false; - }; - let outcome = telemetry::consent_cli::handle_consent_command( - action, - cli.telemetry_consent_locale.as_deref(), - ); - let code = outcome.emit(); - if code != 0 { - std::process::exit(code); - } - true -} - /// Read the request source (file path / base64 blob) once, returning the /// decoded JSON. Reused by `--probe` and the normal request loader so a single /// source is only read once per invocation. @@ -736,25 +708,16 @@ fn install_dacl_ctrl_handler() { fn main() { let cli = parse_cli().normalize_named_config_command(); - // --telemetry-consent: administer the persisted consent state and exit. - // Runs BEFORE `force_reclaim` env propagation and - // `recover_orphaned_state()` below: this is a read-only/local-file fast path with a 5-second - // client-side timeout (Node's consent getter), so it must not be gated - // behind unrelated recovery work that scans state files and may restore - // host DACLs — that could both time out the query (suppressing the - // prompt) and let a plain status read unexpectedly mutate filesystem - // ACLs. Matches the Linux/macOS executors, where this fast path also - // runs unconditionally immediately after CLI parsing. - if handle_telemetry_consent_flags(&cli) { - return; + if let Some(action) = cli.telemetry_consent { + let outcome = telemetry::consent_cli::handle_consent_command( + action, + cli.telemetry_consent_locale.as_deref(), + ); + process::exit(outcome.emit()); } // Decode the request source (file path / base64) once, up front. let decoded_config: Option> = decode_config_input_once(&cli); - let request_hint = decoded_config - .as_ref() - .and_then(|result| result.as_ref().ok()) - .and_then(|json| wxc_common::config_parser::parse_request_hint_from_json(json).ok()); // Propagate --force-reclaim via the environment so it reaches both the // in-process one-shot reconcile and the detached daemon. Set before any @@ -1036,20 +999,11 @@ fn main() { is_base64: false, allow_missing_command: has_command_override, }; - let parsed_request = if let Some(hint) = request_hint.as_ref() { - wxc_common::config_parser::load_mxc_request_from_json_with_hint_and_options( - &config_json, - &mut logger, - load_opts, - hint, - ) - } else { - wxc_common::config_parser::load_mxc_request_from_json_with_options( - &config_json, - &mut logger, - load_opts, - ) - }; + let parsed_request = wxc_common::config_parser::load_mxc_request_from_json_with_options( + &config_json, + &mut logger, + load_opts, + ); let request = match parsed_request { Ok(MxcRequest::OneShot(req)) => req, Ok(MxcRequest::StateAware(mut parsed)) => { diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 15b68f266..9c4b77bcd 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -111,55 +111,6 @@ fn reject_legacy_telemetry_value(config: &serde_json::Value) -> Result<(), WxcEr Ok(()) } -/// Top-level decoded-request classification shared by the executor binaries. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RequestKind { - OneShot, - StateAware, -} - -/// Borrowed parse hints extracted from the top level of a decoded request JSON. -/// -/// Executor binaries can parse this once and pass the same hint into the -/// normal loaders so one-shot requests do not pay a second discriminator -/// parse. -#[derive(Debug, Clone)] -pub struct RequestParseHint { - kind: RequestKind, - experimental: Option>, - experimental_span: Option<(usize, usize)>, -} - -impl RequestParseHint { - pub fn kind(&self) -> RequestKind { - self.kind - } -} - -/// Parse the top-level request discriminator from an already-decoded JSON -/// string. -pub fn parse_request_hint_from_json(json_str: &str) -> Result { - config_deserialize::from_str::>(json_str) - .map_err(|error| WxcError::ConfigParse(error.to_string())) - .and_then(|discriminator| { - let experimental_span = discriminator - .experimental - .map(|raw| experimental_source_span(json_str, raw.get())) - .transpose()?; - Ok(RequestParseHint { - kind: if discriminator.phase.is_some() { - RequestKind::StateAware - } else { - RequestKind::OneShot - }, - experimental: discriminator - .experimental - .map(|raw| raw.get().to_string().into_boxed_str()), - experimental_span, - }) - }) -} - // ---------- Public API ---------- /// Options for [`load_mxc_request_with_options`]. @@ -250,31 +201,20 @@ pub(crate) fn load_request_from_json_with_options( json_str: &str, logger: &mut Logger, opts: LoadOptions, -) -> Result { - let hint = parse_request_hint_from_json(json_str)?; - load_request_from_json_with_hint_and_options(json_str, logger, opts, &hint) -} - -pub fn load_request_from_json_with_hint_and_options( - json_str: &str, - logger: &mut Logger, - opts: LoadOptions, - hint: &RequestParseHint, ) -> Result { // `is_base64` is meaningless on an already-decoded JSON string; the field // is kept in `LoadOptions` for signature parity with the from-input path. let _ = opts.is_base64; let result = (|| { - match hint.kind() { - RequestKind::StateAware => { - return Err(WxcError::ConfigParse( - "expected a one-shot execution request, got a state-aware lifecycle request" - .to_string(), - )); - } - RequestKind::OneShot => {} + let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json_str) + .map_err(|error| WxcError::ConfigParse(error.to_string()))?; + if discriminator.phase.is_some() { + return Err(WxcError::ConfigParse( + "expected a one-shot execution request, got a state-aware lifecycle request" + .to_string(), + )); } - reject_legacy_telemetry_raw(hint.experimental.as_deref())?; + reject_legacy_telemetry_raw(discriminator.experimental.map(|raw| raw.get()))?; let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) .map_err(|error| WxcError::ConfigParse(error.to_string()))?; @@ -381,22 +321,11 @@ pub fn load_mxc_request_from_json_with_options( json_str: &str, logger: &mut Logger, opts: LoadOptions, -) -> Result { - let hint = parse_request_hint_from_json(json_str).map_err(ParseError::Decode)?; - load_mxc_request_from_json_with_hint_and_options(json_str, logger, opts, &hint) -} - -pub fn load_mxc_request_from_json_with_hint_and_options( - json_str: &str, - logger: &mut Logger, - opts: LoadOptions, - hint: &RequestParseHint, ) -> Result { // `is_base64` is meaningless on an already-decoded JSON string; the field // is kept in `LoadOptions` for signature parity with the from-input path. let _ = opts.is_base64; - let result = - parse_mxc_request_json_with_hint(json_str, hint, logger, opts.allow_missing_command); + let result = parse_mxc_request_json(json_str, logger, opts.allow_missing_command); if let Err(error) = &result { log_error(logger, &error.message(), error.output()); } @@ -413,17 +342,14 @@ fn parse_mxc_request_json( logger: &mut Logger, allow_missing_command: bool, ) -> Result { - let hint = parse_request_hint_from_json(json_str).map_err(ParseError::Decode)?; - parse_mxc_request_json_with_hint(json_str, &hint, logger, allow_missing_command) -} - -fn parse_mxc_request_json_with_hint( - json_str: &str, - hint: &RequestParseHint, - logger: &mut Logger, - allow_missing_command: bool, -) -> Result { - if hint.kind() == RequestKind::StateAware { + let discriminator: RequestDiscriminator<'_> = config_deserialize::from_str(json_str) + .map_err(|error| ParseError::Decode(WxcError::ConfigParse(error.to_string())))?; + if discriminator.phase.is_some() { + let experimental = discriminator.experimental.map(|raw| raw.get()); + let experimental_span = experimental + .map(|raw| experimental_source_span(json_str, raw)) + .transpose() + .map_err(ParseError::Decode)?; let raw: serde_json::Value = config_deserialize::from_str(json_str) .map_err(|error| ParseError::Decode(WxcError::ConfigParse(error.to_string())))?; validate_versioned_fields(&raw).map_err(|error| { @@ -431,15 +357,16 @@ fn parse_mxc_request_json_with_hint( })?; convert_wire_state_aware( json_str, - hint.experimental.as_deref(), - hint.experimental_span, + experimental, + experimental_span, logger, allow_missing_command, ) .map(MxcRequest::StateAware) .map_err(|e| ParseError::StateAware(MxcError::malformed_request(e.to_string()))) } else { - reject_legacy_telemetry_raw(hint.experimental.as_deref()).map_err(ParseError::OneShot)?; + reject_legacy_telemetry_raw(discriminator.experimental.map(|raw| raw.get())) + .map_err(ParseError::OneShot)?; let cfg: wire::MxcConfig = config_deserialize::from_str(json_str) .map_err(|error| ParseError::OneShot(WxcError::ConfigParse(error.to_string())))?; let raw: serde_json::Value = config_deserialize::from_str(json_str) From 8a077c2759472a8989374abaad4b99f1b993e994 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 13:50:02 -0700 Subject: [PATCH 21/47] Fix state-aware telemetry diagnostics Keep state-aware envelopes isolated from diagnostic output and align timeout and non-zero exit classification with one-shot telemetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- src/core/mxc_engine/src/lib.rs | 32 ++++++++++++- src/core/wxc/src/main.rs | 3 ++ src/core/wxc_common/src/logger.rs | 20 --------- src/core/wxc_common/src/telemetry/mod.rs | 57 +++++++++++++++++++----- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 8beb57b2c..921166c2c 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -257,7 +257,8 @@ impl TelemetryProcess { error.to_string(), )), }; - telemetry::emit_sdk_state_aware_with_kind( + let failure_reason = Self::state_aware_wait_failure_reason(result); + telemetry::emit_sdk_state_aware_with_kind_and_failure( true, *requested_sandbox_kind, telemetry::TelemetryContext { @@ -267,12 +268,23 @@ impl TelemetryProcess { }, &outcome, self.started.elapsed(), + failure_reason, ); } } self.active = false; } + fn state_aware_wait_failure_reason( + result: &std::io::Result, + ) -> Option { + result + .as_ref() + .err() + .filter(|error| error.kind() == std::io::ErrorKind::TimedOut) + .map(|_| telemetry::FailureReason::Timeout) + } + /// Emit a synthesised terminal event when no real completion result is /// available (drop-without-wait). Routes through /// [`Self::emit`] so it shares the exactly-once slot and the @@ -638,6 +650,24 @@ mod telemetry_process_tests { assert!(!timed_out.active); } + #[test] + fn state_aware_wait_timeout_preserves_timeout_failure_reason() { + let timed_out = Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out", + )); + assert_eq!( + TelemetryProcess::state_aware_wait_failure_reason(&timed_out), + Some(telemetry::FailureReason::Timeout) + ); + + let failed = Err(std::io::Error::other("failed")); + assert_eq!( + TelemetryProcess::state_aware_wait_failure_reason(&failed), + None + ); + } + #[test] fn nonterminal_poll_leaves_the_exactly_once_slot_active() { let mut process = wrapped(TryWaitResult::Running); diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index d932a63e1..eb6a68f90 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -434,6 +434,9 @@ fn run_state_aware_main(parsed: ParsedStateAwareRequest, dry_run: bool, logger: if let Err(error) = &outcome { log_state_aware_dispatch_error(logger, error); } + for warning in logger.take_warnings() { + eprintln!("{warning}"); + } // Diagnostic buffer flushes to stderr regardless of success/failure so it // never interleaves with the stdout envelope. let buffered = logger.get_buffer().to_string(); diff --git a/src/core/wxc_common/src/logger.rs b/src/core/wxc_common/src/logger.rs index 4884c6dea..f34ddb7e6 100644 --- a/src/core/wxc_common/src/logger.rs +++ b/src/core/wxc_common/src/logger.rs @@ -269,16 +269,6 @@ impl Logger { self.log_diagnostic_line(msg); } - /// Record a warning for library callers and the primary diagnostic stream. - /// - /// Use this when executor users must see the message in the ordinary - /// buffered/console output while in-process callers also need it through - /// [`warnings`](Self::warnings). - pub fn retained_warning_line(&mut self, msg: &str) { - self.warnings.push(msg.to_string()); - self.log_line(msg); - } - /// Warnings emitted during the run. pub fn warnings(&self) -> &[String] { &self.warnings @@ -373,16 +363,6 @@ mod tests { assert!(logger.warnings().is_empty()); } - #[test] - fn retained_warning_line_reaches_primary_output_and_callers() { - let mut logger = Logger::new(Mode::Buffer); - - logger.retained_warning_line("operational warning"); - - assert_eq!(logger.warnings(), ["operational warning"]); - assert_eq!(logger.get_buffer(), "operational warning\n"); - } - /// The body of the child process spawned by /// [`warning_line_writes_nothing_to_stderr`]. Ignored so the normal suite /// run skips it; the parent invokes it by name with `--ignored`. diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index ecceb3039..0e665b1d4 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -236,12 +236,12 @@ pub fn init(config: &TelemetryConfig, logger: &mut Logger) -> bool { let activated = mxc_telemetry::init(MXC_VERSION, MXC_CHANNEL); if !activated && cfg!(target_os = "windows") { - logger.retained_warning_line( + logger.warning_line( "telemetry: ETW provider registration failed; continuing without telemetry", ); } if activated && !mxc_telemetry::IS_UTC_ROUTED { - logger.retained_warning_line( + logger.warning_line( "telemetry: events are emitted to local ETW only; this build has no provider group \ GUID, so nothing is routed to the Microsoft pipeline (set \ MXC_TELEMETRY_PROVIDER_GROUP_GUID at build time for an internal build)", @@ -862,11 +862,9 @@ fn plan_state_aware_with_kind<'a>( exit_code: *exit_code, outcome: if failed { "failure" } else { "success" }, duration_ms, - // A non-zero guest exit is a faithfully propagated sandbox - // exit code, not an MXC infrastructure error — leave the - // reason unset and emit no Error (mirrors one-shot - // emit_completion). - failure_reason: None, + // The sandbox exit is not an MXC infrastructure error, so + // suppress Error while retaining the failure category. + failure_reason: failed.then_some(FailureReason::ProcessError), phase: ctx.phase, correlation_vector: ctx.correlation_vector, }, @@ -935,7 +933,7 @@ pub fn emit_state_aware_with_kind( if already_emitted() { return; } - emit_state_aware_event(&auth, ctx, outcome, elapsed, requested_sandbox_kind); + emit_state_aware_event(&auth, ctx, outcome, elapsed, requested_sandbox_kind, None); shutdown(); } @@ -945,13 +943,20 @@ fn emit_state_aware_event( outcome: &Result, elapsed: Duration, requested_sandbox_kind: Option<&str>, + failure_reason_override: Option, ) { // The `_auth` parameter is the single authorization token for this // emission (see `EmissionAuthorization`). Both writes below execute under // it — there is no per-write reread of consent/policy state. let duration_ms = elapsed.as_millis() as u64; let sandbox_kind = sandbox_kind_for(ctx.backend, requested_sandbox_kind); - let plan = plan_state_aware_with_kind(ctx, outcome, duration_ms, sandbox_kind); + let mut plan = plan_state_aware_with_kind(ctx, outcome, duration_ms, sandbox_kind); + if outcome.is_err() { + if let Some(reason) = failure_reason_override { + plan.execution.failure_reason = Some(reason); + plan.error = Some(reason); + } + } log_execution(&plan.execution); if let Some(reason) = plan.error { @@ -979,9 +984,36 @@ pub fn emit_sdk_state_aware_with_kind( ctx: TelemetryContext<'_>, outcome: &Result, elapsed: Duration, +) { + emit_sdk_state_aware_with_kind_and_failure( + active, + requested_sandbox_kind, + ctx, + outcome, + elapsed, + None, + ); +} + +/// Emit SDK state-aware telemetry with an optional terminal failure override. +#[doc(hidden)] +pub fn emit_sdk_state_aware_with_kind_and_failure( + active: bool, + requested_sandbox_kind: Option<&'static str>, + ctx: TelemetryContext<'_>, + outcome: &Result, + elapsed: Duration, + failure_reason_override: Option, ) { emit_sdk_with_release(active, |auth| { - emit_state_aware_event(auth, ctx, outcome, elapsed, requested_sandbox_kind) + emit_state_aware_event( + auth, + ctx, + outcome, + elapsed, + requested_sandbox_kind, + failure_reason_override, + ) }); } @@ -1669,7 +1701,10 @@ mod tests { assert_eq!(nonzero_plan.execution.correlation_vector, correlation); assert_eq!(nonzero_plan.execution.outcome, "failure"); assert_eq!(nonzero_plan.execution.exit_code, 42); - assert!(nonzero_plan.execution.failure_reason.is_none()); + assert_eq!( + nonzero_plan.execution.failure_reason, + Some(FailureReason::ProcessError) + ); assert!(nonzero_plan.error.is_none()); // MxcError → failure / exit 1 / classified Error. From 682627675eb45fbfa1ae486b96720b85ff13485c Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 14:27:21 -0700 Subject: [PATCH 22/47] Harden FFI telemetry policy parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../Microsoft.Mxc.Sdk.Tests.csproj | 19 +++ .../MxcTelemetryTests.cs | 2 +- .../Microsoft.Mxc.Sdk.csproj | 1 - sdk/dotnet/README.md | 11 +- src/Cargo.toml | 2 +- src/core/mxc-sdk/Cargo.toml | 2 + src/core/wxc_common/src/telemetry/consent.rs | 9 +- src/ffi/mxc_ffi/Cargo.toml | 2 + src/ffi/mxc_ffi/src/lib.rs | 128 +++++++++--------- src/ffi/mxc_ffi/src/request.rs | 85 ++++-------- src/ffi/mxc_ffi/src/streaming.rs | 4 - 11 files changed, 123 insertions(+), 142 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj index 0c8048dc1..05e3a6d19 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj @@ -12,12 +12,31 @@ true $(DefineConstants);MXC_WITH_WSLC $(DefineConstants);MXC_WITH_ISOLATION_SESSION + $(MSBuildProjectDirectory)/../../../src + $(MxcSrcDir)/target/dotnet-test-support + --release + release + debug + $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/mxc_ffi.dll + $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/libmxc_ffi.dylib + $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/libmxc_ffi.so + + + + + + + diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 897c9cdf8..030f1ecee 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -22,7 +22,7 @@ public void SandboxPolicy_TelemetryEnabledSerializesCanonically() { var policy = new SandboxPolicy { - Version = "0.8.0-alpha", + Version = SchemaVersions.MaximumSupported, TelemetryEnabled = true, }; diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj index f1eb8c19a..4f27fdf3c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj @@ -34,7 +34,6 @@ false dotnetsdk - $(MxcCargoFeatures) wxc_common/test-support $(MxcCargoFeatures) isolation_session $(MxcCargoFeatures) wslc diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index b427c315b..1241753bc 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -486,9 +486,11 @@ MXC telemetry is Windows-only and remains off until both of these are true: Telemetry remains off by default unless the caller opts in with `SandboxPolicy.TelemetryEnabled = true` (or the equivalent phase-level `TelemetryEnabled` setting for state-aware requests) and applicable Windows consent/policy gates permit collection. -Any .NET consent surface should stay UI-agnostic, present the canonical -resource verbatim through a host callback, persist only explicit yes/no -decisions, treat dismissal and failures as non-grants, and follow the rules in +The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies +the canonical resource to a host callback, while `GetConsentStatus`, +`NeedsConsentPrompt`, and `WithdrawConsent` provide maintenance operations. +They persist only explicit yes/no decisions and treat dismissal and failures +as non-grants. See [`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md) and its [SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements). @@ -498,8 +500,7 @@ and its An IT administrator can still block MXC telemetry device-wide via MXC's own registry policy setting. See [`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md) -for the stable registry contract and interaction rules. Policy and consent -queries are not yet exposed by the .NET SDK; any eventual query must fail +for the stable registry contract and interaction rules. Read-only queries fail closed rather than upgrading an unreadable device state into collection. ## Projects diff --git a/src/Cargo.toml b/src/Cargo.toml index 4a5ed2640..e884b5080 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -95,7 +95,7 @@ sandbox_spec = { path = "core/generated/base_container_specification" } seatbelt_common = { path = "backends/seatbelt/common" } same-file = "1" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } serde_path_to_error = "0.1" schemars = "0.8" sha2 = "0.10" diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index b3a4bd747..af870cde6 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -16,6 +16,8 @@ default = [] wslc = ["mxc_engine/wslc"] # IsolationSession backend (Windows only, experimental). isolation_session = ["mxc_engine/isolation_session"] +# Exposes the debug-only telemetry test seams to downstream binding tests. +test-support = ["wxc_common/test-support"] [dependencies] wxc_common.workspace = true diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 10629257c..ed4a33db9 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -173,12 +173,9 @@ impl ConsentState { /// Whether this consent state represents an absent user decision. /// - /// Policy-blind: this only reflects the stored consent state and does not - /// consult administrative policy. Host applications should prefer - /// [`needs_consent_prompt`] (or [`ConsentStatus::needs_prompt`]), which - /// additionally suppresses the prompt under an administrative block; the - /// public SDK surface re-exposes this predicate for parity checks and - /// diagnostics. + /// Policy-blind: this only reflects the stored consent state. Host + /// applications must use [`needs_consent_prompt`], which also suppresses + /// the prompt under an administrative block. pub fn needs_prompt(&self) -> bool { matches!(self, ConsentState::Undetermined) } diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index f57bd1f8b..786e67fb4 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -32,6 +32,8 @@ isolation_session = ["mxc-sdk/isolation_session"] # gate (scripts/check-dotnet-bindings-codegen.js) builds with this feature to # regenerate the committed bindings. dotnetsdk = ["dep:csbindgen"] +# Exposes the debug-only telemetry test seams to language-binding tests. +test-support = ["mxc-sdk/test-support"] [dev-dependencies] # Gives the FFI tests the policy-key redirector, so they can assert the exact diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 694b3ced7..054abb9bb 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -38,9 +38,7 @@ //! mirror `mxc_sdk::ErrorCode` one-for-one (plus a few FFI-local codes). //! - **Per-invocation telemetry opt-in**: [`mxc_run`] / [`mxc_spawn`] accept //! the canonical top-level execution field `telemetry.enabled` inside the -//! policy JSON they already take. For compatibility, the legacy top-level -//! `telemetryEnabled` boolean remains accepted as an alias; if both are -//! present they must agree. +//! policy JSON they already take. //! - **Telemetry consent** — [`mxc_telemetry_get_consent`], //! [`mxc_telemetry_get_consent_status`], [`mxc_telemetry_request_consent`], //! [`mxc_telemetry_withdraw_consent`], [`mxc_telemetry_needs_consent_prompt`], @@ -358,19 +356,21 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct FfiTelemetrySection { - enabled: Option, + enabled: bool, } -fn deserialize_legacy_telemetry_enabled<'de, D>(deserializer: D) -> Result, D::Error> +#[derive(Default)] +struct OptionalTelemetrySection(Option); + +impl<'de> serde::Deserialize<'de> for OptionalTelemetrySection where - D: serde::Deserializer<'de>, + Self: Sized, { - match as serde::Deserialize>::deserialize(deserializer)? { - None | Some(serde_json::Value::Null) => Ok(None), - Some(serde_json::Value::Bool(enabled)) => Ok(Some(enabled)), - Some(_) => Err(serde::de::Error::custom( - "telemetryEnabled must be a boolean", - )), + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + FfiTelemetrySection::deserialize(deserializer).map(|section| Self(Some(section))) } } @@ -380,38 +380,38 @@ struct FfiPolicyEnvelope { #[serde(flatten)] policy: SandboxPolicy, #[serde(default)] - telemetry: Option, - #[serde(default, deserialize_with = "deserialize_legacy_telemetry_enabled")] - telemetry_enabled: Option, + telemetry: OptionalTelemetrySection, + #[serde( + default, + rename = "telemetryEnabled", + deserialize_with = "reject_legacy_telemetry_enabled" + )] + _telemetry_enabled: (), +} + +fn reject_legacy_telemetry_enabled<'de, D>(deserializer: D) -> Result<(), D::Error> +where + D: serde::Deserializer<'de>, +{ + let _ = ::deserialize(deserializer)?; + Err(serde::de::Error::custom( + "telemetryEnabled is not supported; use telemetry.enabled", + )) } -fn telemetry_enabled_from_policy(policy: &FfiPolicyEnvelope) -> Result, String> { - let canonical = policy +fn telemetry_enabled_from_policy(policy: &FfiPolicyEnvelope) -> Option { + policy .telemetry + .0 .as_ref() - .and_then(|telemetry| telemetry.enabled); - let legacy = policy.telemetry_enabled; - match (canonical, legacy) { - (Some(current), Some(alias)) if current != alias => Err( - "failed to parse policy JSON: telemetry.enabled and telemetryEnabled must agree" - .to_string(), - ), - (Some(current), _) => Ok(Some(current)), - (None, legacy) => Ok(legacy), - } + .map(|telemetry| telemetry.enabled) } -fn validate_canonical_telemetry_version(value: &serde_json::Value) -> Result<(), String> { - let Some(policy) = value.as_object() else { - return Ok(()); - }; - if !policy.contains_key("telemetry") { +fn validate_canonical_telemetry_version(policy: &FfiPolicyEnvelope) -> Result<(), String> { + if policy.telemetry.0.is_none() { return Ok(()); } - let Some(version) = policy.get("version").and_then(serde_json::Value::as_str) else { - return Ok(()); - }; - let Some(core) = version.split(['-', '+']).next() else { + let Some(core) = policy.policy.version.split(['-', '+']).next() else { return Ok(()); }; let mut components = core.split('.'); @@ -443,12 +443,10 @@ fn validate_canonical_telemetry_version(value: &serde_json::Value) -> Result<(), pub(crate) fn parse_policy_json( policy_json: &str, ) -> Result<(SandboxPolicy, Option), String> { - let value: serde_json::Value = serde_json::from_str(policy_json) - .map_err(|error| format!("failed to parse policy JSON: {error}"))?; - validate_canonical_telemetry_version(&value)?; - let envelope: FfiPolicyEnvelope = serde_json::from_value(value) + let envelope: FfiPolicyEnvelope = serde_json::from_str(policy_json) .map_err(|error| format!("failed to parse policy JSON: {error}"))?; - let telemetry_enabled = telemetry_enabled_from_policy(&envelope)?; + validate_canonical_telemetry_version(&envelope)?; + let telemetry_enabled = telemetry_enabled_from_policy(&envelope); Ok((envelope.policy, telemetry_enabled)) } @@ -1103,31 +1101,39 @@ mod tests { } #[test] - fn policy_telemetry_switch_keeps_legacy_alias_for_compatibility() { - let (policy, telemetry_enabled) = - parse_policy_json(r#"{"version":"0.8.0-alpha","telemetryEnabled":true}"#) - .expect("policy should parse"); + fn policy_telemetry_switch_rejects_legacy_alias() { + let error = parse_policy_json(r#"{"version":"0.9.0-alpha","telemetryEnabled":true}"#) + .expect_err("the legacy telemetry alias must be rejected"); - assert_eq!(policy.version, "0.8.0-alpha"); - assert_eq!(telemetry_enabled, Some(true)); + assert!(error.contains("telemetryEnabled")); } #[test] - fn policy_telemetry_switch_rejects_non_boolean_values() { - let error = parse_policy_json(r#"{"version":"0.8.0-alpha","telemetryEnabled":"yes"}"#) - .expect_err("non-boolean telemetry switch must fail"); - - assert!(error.contains("telemetryEnabled must be a boolean")); + fn policy_telemetry_switch_rejects_null_and_missing_enabled() { + for policy_json in [ + r#"{"version":"0.9.0-alpha","telemetry":null}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":null}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{}}"#, + ] { + assert!( + parse_policy_json(policy_json).is_err(), + "{policy_json} must be rejected" + ); + } } #[test] - fn policy_telemetry_switch_rejects_conflicting_shapes() { - let error = parse_policy_json( - r#"{"version":"0.9.0-alpha","telemetry":{"enabled":false},"telemetryEnabled":true}"#, - ) - .expect_err("conflicting telemetry fields must fail"); - - assert!(error.contains("must agree")); + fn policy_telemetry_switch_rejects_duplicate_fields() { + for policy_json in [ + r#"{"version":"0.9.0-alpha","version":"0.9.0-alpha"}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true},"telemetry":{"enabled":false}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true,"enabled":false}}"#, + ] { + assert!( + parse_policy_json(policy_json).is_err(), + "{policy_json} must be rejected" + ); + } } #[test] @@ -1142,10 +1148,6 @@ mod tests { r#"{"version":"0.9.0-alpha","telemetry":{"enabled":false}}"#, Some(false), ), - ( - r#"{"version":"0.8.0-alpha","telemetryEnabled":true}"#, - Some(true), - ), ] { let request = build_run_request(policy_json, "echo hi") .unwrap_or_else(|_| panic!("build_run_request failed for {policy_json}")); diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index ec576b7b5..30b30bc9f 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -10,42 +10,15 @@ use mxc_sdk::configs::{ ProcessContainerUi, ProcessContainerUiIsolation, }; use mxc_sdk::{ - build_request_with_containment, Containment, Error, ErrorCode, SandboxPolicy, SandboxRequest, - WslcSection, + build_request_with_containment, Containment, Error, ErrorCode, SandboxRequest, WslcSection, }; -use serde_json::Value; - -struct RequestPolicy { - policy: SandboxPolicy, - telemetry_enabled: Option, -} - -impl RequestPolicy { - fn sdk_policy(&self) -> &SandboxPolicy { - &self.policy - } -} - -impl<'de> serde::Deserialize<'de> for RequestPolicy { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = ::deserialize(deserializer)?; - let policy_json = serde_json::to_string(&value).map_err(serde::de::Error::custom)?; - let (policy, telemetry_enabled) = - crate::parse_policy_json(&policy_json).map_err(serde::de::Error::custom)?; - Ok(Self { - policy, - telemetry_enabled, - }) - } -} +use serde_json::{value::RawValue, Value}; #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RequestSpec { - policy: RequestPolicy, +struct RequestSpec<'a> { + #[serde(borrow)] + policy: &'a RawValue, command: String, #[serde(default)] containment: RequestContainment, @@ -246,12 +219,11 @@ impl ProcessContainerNetworkSpec { /// Parse a binding request and build the public Rust SDK request it describes. pub(crate) fn build_request_from_json(request_json: &str) -> Result { - let value: Value = serde_json::from_str(request_json).map_err(malformed_request)?; - if value - .get("policy") - .and_then(|policy| policy.get("captureDenials")) - .is_some() - { + let spec: RequestSpec<'_> = serde_json::from_str(request_json).map_err(malformed_request)?; + let (policy, telemetry_enabled) = crate::parse_policy_json(spec.policy.get()) + .map_err(|error| Error::new(ErrorCode::MalformedRequest, error))?; + let policy_value: Value = serde_json::from_str(spec.policy.get()).map_err(malformed_request)?; + if policy_value.get("captureDenials").is_some() { return Err(Error::new( ErrorCode::MalformedRequest, "policy.captureDenials is not supported; set containment.type to \ @@ -259,21 +231,17 @@ pub(crate) fn build_request_from_json(request_json: &str) -> Result Date: Wed, 2 Sep 2026 15:03:18 -0700 Subject: [PATCH 23/47] Fix piped consent smoke assertion Validate the presentation request and terminal failure emitted when a piped consent request reaches stdin EOF. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../run_telemetry_consent_smoke_test.ps1 | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index f1ff0af3f..1a856baca 100644 --- a/tests/scripts/run_telemetry_consent_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -105,7 +105,7 @@ function Invoke-ConsentRequest([string]$Decision) { $finalLine | ConvertFrom-Json } -function Assert-NonInteractiveRequestFails { +function Assert-PipedRequestEofFails { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $wxcExec $start.Arguments = '--telemetry-consent request --telemetry-consent-locale en-US' @@ -120,20 +120,24 @@ function Assert-NonInteractiveRequestFails { $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID'] = "$PID" $process = New-Object Diagnostics.Process $process.StartInfo = $start - if (-not $process.Start()) { throw 'Failed to start non-interactive consent process.' } + if (-not $process.Start()) { throw 'Failed to start piped consent process.' } $process.StandardInput.Close() $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() if ($process.ExitCode -ne 1) { - throw "Non-interactive request exited $($process.ExitCode), expected 1: $stderr" + throw "Piped request exited $($process.ExitCode), expected 1: $stderr" } - $response = $stdout | ConvertFrom-Json - if ($response.result -ne 'presentationUnavailable') { - throw "Non-interactive request returned an unexpected response: $stdout" + $responses = @($stdout -split '\r?\n' | + Where-Object { $_ } | + ForEach-Object { $_ | ConvertFrom-Json }) + if ($responses.Count -ne 2 -or + $responses[0].result -ne 'presentationRequired' -or + $responses[1].result -ne 'presentationUnavailable') { + throw "Piped request returned an unexpected response: $stdout" } if (Test-Path $consentFile) { - throw 'Non-interactive request changed the consent store.' + throw 'Piped request EOF changed the consent store.' } } @@ -195,7 +199,7 @@ try { } Remove-Item $consentFile -Force - Assert-NonInteractiveRequestFails + Assert-PipedRequestEofFails $fresh = Invoke-Maintenance 'status' Assert-Status $fresh 'undetermined' 'undetermined' 'unrestricted' $true 'fresh status' From 47217f9d57d0a5be9a4f21abe274c247579513d5 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 15:26:23 -0700 Subject: [PATCH 24/47] Preserve optional backends in test FFI Mirror enabled Windows backend features in the isolated test-support native build and correct the telemetry alias documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj | 5 ++++- sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj index 05e3a6d19..0d674fd03 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj @@ -17,6 +17,9 @@ --release release debug + test-support + $(MxcTestCargoFeatures) isolation_session + $(MxcTestCargoFeatures) wslc $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/mxc_ffi.dll $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/libmxc_ffi.dylib $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/libmxc_ffi.so @@ -30,7 +33,7 @@ AfterTargets="Build" Condition="'$(DesignTimeBuild)' != 'true'"> - + /// Convenience projection for . Serializes through - /// the canonical nested telemetry.enabled field, not the legacy - /// top-level alias accepted by the native FFI for compatibility. + /// the canonical nested telemetry.enabled field. The native FFI + /// rejects the legacy top-level telemetryEnabled alias. /// [JsonIgnore] public bool? TelemetryEnabled From 8879a7dae343a14f88325aeec537666a112602b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:34:57 +0000 Subject: [PATCH 25/47] Remove non-Windows executor detail Co-authored-by: RamonArjona4 <25335379+RamonArjona4@users.noreply.github.com> --- docs/telemetry/telemetry-consent-design.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index 4633cf591..2b290da67 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -107,8 +107,6 @@ wxc-exec --telemetry-consent withdraw wxc-exec --telemetry-consent request ``` -`lxc-exec` and `mxc-exec-mac` expose the same command and return -`notApplicable` domain outcomes because telemetry collection is Windows-only. `request` optionally accepts `--telemetry-consent-locale `; missing or unsupported locales use the canonical `en-US` fallback. Interactive requests write the prompt to stderr, read `Y` or `N` from a terminal, and write one final From c4aa355a392c3bc210bf019a5c3bf75ecd8b2b6c Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 15:52:43 -0700 Subject: [PATCH 26/47] Apply requested telemetry removals Remove the remaining consent command documentation and the non-Windows consent CLI and parsing refactors, while retaining the stable telemetry wiring required by the promoted runtime model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- docs/telemetry/telemetry-consent-design.md | 30 ------ src/core/lxc/src/main.rs | 113 +++------------------ src/core/mxc_darwin/src/main.rs | 99 +++--------------- 3 files changed, 29 insertions(+), 213 deletions(-) diff --git a/docs/telemetry/telemetry-consent-design.md b/docs/telemetry/telemetry-consent-design.md index 2b290da67..084823e89 100644 --- a/docs/telemetry/telemetry-consent-design.md +++ b/docs/telemetry/telemetry-consent-design.md @@ -96,36 +96,6 @@ If the host never calls the request API, telemetry remains off. A presenter failure must be surfaced as an error to the host and must not create or alter a grant. -## Executor consent command - -Consent maintenance is a dedicated executor control plane, not an MXC -execution configuration: - -```text -wxc-exec --telemetry-consent status -wxc-exec --telemetry-consent withdraw -wxc-exec --telemetry-consent request -``` - -`request` optionally accepts `--telemetry-consent-locale `; missing or -unsupported locales use the canonical `en-US` fallback. Interactive requests -write the prompt to stderr, read `Y` or `N` from a terminal, and write one final -JSON outcome to stdout. A request without terminal stdin and stderr fails -without changing consent. - -When standard input and output are pipes, `request` uses the Node host -presentation callback. Native MXC emits at most one `presentationRequired` -JSON line followed by one final JSON line. The host returns exactly one JSON -decision line containing the emitted challenge, prompt resource version, and -`yes`, `no`, or `dismissed`. Input is limited to 64 KiB; malformed input, -unknown fields, extra lines, challenge mismatch, and resource-version mismatch -are rejected without persistence. - -Domain outcomes exit `0`. Invalid command/protocol input exits `64`. -Operational failures such as a missing terminal, required-decision EOF, broken -pipe, presenter failure, or persistence failure exit `1`. `status`, -`withdraw`, and short-circuit request outcomes never invoke a presenter. - ## State and persistence MXC stores consent per user at: diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 3cbb9e188..ea26499e9 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -6,7 +6,7 @@ use std::process; use std::time::Instant; use clap::Parser; -use wxc_common::config_parser::load_request_from_json; +use wxc_common::config_parser::load_request; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ExecutionRequest, ScriptResponse}; use wxc_common::script_runner::handle_dry_run_exit; @@ -73,64 +73,6 @@ struct Cli { /// the new bits. Requires --setup-hyperlight. #[arg(long, requires = "setup_hyperlight")] force: bool, - - /// Manage telemetry consent without spawning a sandbox. - #[arg( - long = "telemetry-consent", - value_name = "ACTION", - conflicts_with_all = [ - "config_path", - "config", - "config_base64", - "delete", - "containername", - "experimental", - "allow_testing_features", - "dry_run", - "setup_hyperlight", - "force" - ] - )] - telemetry_consent: Option, - - /// Preferred BCP 47 locale for a telemetry consent request. - #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] - telemetry_consent_locale: Option, -} - -fn parse_cli() -> Cli { - match Cli::try_parse() { - Ok(cli) => cli, - Err(error) => { - if matches!( - error.kind(), - clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion - ) { - error.exit(); - } - let is_consent_command = - telemetry::consent_cli::invocation_uses_consent_options(std::env::args_os()); - if is_consent_command { - let _ = error.print(); - process::exit(64); - } - error.exit(); - } - } -} - -/// Read the request source (file path / base64 blob) once. -fn decode_config_input_once(cli: &Cli) -> Option> { - let (input, is_base64) = if let Some(input) = cli.config_base64.as_ref() { - (input.clone(), true) - } else if let Some(input) = cli.config.as_ref().or(cli.config_path.as_ref()) { - (input.clone(), false) - } else { - return None; - }; - Some(wxc_common::config_parser::decode_request_input( - &input, is_base64, - )) } fn log_request(request: &ExecutionRequest, logger: &mut Logger) { @@ -184,18 +126,6 @@ fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { } fn main() { - let cli = parse_cli(); - - if let Some(action) = cli.telemetry_consent { - let outcome = telemetry::consent_cli::handle_consent_command( - action, - cli.telemetry_consent_locale.as_deref(), - ); - process::exit(outcome.emit()); - } - // Decode the request source (file path / base64) once, up front. - let decoded_config: Option> = - decode_config_input_once(&cli); // Install before spawning any other threads so the signal mask propagates. // Failure here is fatal: install() either succeeds with the watchdog // running, or restores the original signal mask and returns Err. We @@ -206,6 +136,8 @@ fn main() { process::exit(1); } + let cli = Cli::parse(); + // --setup-hyperlight: eagerly warm up the snapshot and exit. Runs // before config parsing so the user doesn't need a JSON file on // disk just to install. @@ -245,26 +177,18 @@ fn main() { } } - // Determine config input. In delete mode the config is optional; every - // other path requires it. `decoded_config` above already read the source - // once — if it's populated, unpack the decoded JSON (or surface the - // decode error). If it's absent, either accept the empty state for - // delete mode or report the missing-config error. - let config_json: Option = match decoded_config { - Some(Ok(json)) => Some(json), - Some(Err(error)) => { - eprintln!("Request error\n{error}"); - process::exit(1); - } - None => { - if !cli.delete { - eprintln!( - "Error: No config provided. Use a positional path, --config, or --config-base64" - ); - process::exit(1); - } - None - } + // Determine config input + let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { + (b64.clone(), true) + } else if let Some(ref path) = cli.config { + (path.clone(), false) + } else if let Some(ref path) = cli.config_path { + (path.clone(), false) + } else if !cli.delete { + eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); + process::exit(1); + } else { + (String::new(), false) }; let mut logger = Logger::new(if cli.debug { @@ -293,13 +217,8 @@ fn main() { process::exit(if success { 0 } else { 1 }); } - // Non-delete paths always have a config JSON at this point (or exited - // above with the missing-config error). - let config_json = config_json.expect("config_json is Some on non-delete paths"); - // Load request - let parsed_request = load_request_from_json(&config_json, &mut logger); - let mut request = match parsed_request { + let mut request = match load_request(&config_data, &mut logger, is_base64) { Ok(r) => r, Err(_) => { eprint!("Request error\n{}", logger.get_buffer()); diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index fff57e3ff..254f7d5d6 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -13,7 +13,7 @@ use std::fmt::Write; use std::process; use clap::Parser; -use wxc_common::config_parser::load_request_from_json; +use wxc_common::config_parser::load_request; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::ExecutionRequest; @@ -61,65 +61,6 @@ struct Cli { /// Path to diagnostic log file (appends, creates if missing) #[arg(long = "log-file")] log_file: Option, - - /// Manage telemetry consent without spawning a sandbox. - #[arg( - long = "telemetry-consent", - value_name = "ACTION", - conflicts_with_all = [ - "config_path", - "config", - "config_base64", - "experimental", - "allow_testing_features", - "dry_run" - ] - )] - telemetry_consent: Option, - - /// Preferred BCP 47 locale for a telemetry consent request. - #[arg(long = "telemetry-consent-locale", requires = "telemetry_consent")] - telemetry_consent_locale: Option, -} - -fn parse_cli() -> Cli { - match Cli::try_parse() { - Ok(cli) => cli, - Err(error) => { - if matches!( - error.kind(), - clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion - ) { - error.exit(); - } - let is_consent_command = - wxc_common::telemetry::consent_cli::invocation_uses_consent_options( - std::env::args_os(), - ); - if is_consent_command { - let _ = error.print(); - process::exit(64); - } - error.exit(); - } - } -} - -/// Decode the config input source (base64 arg / file path) exactly once. -/// -/// Returns `None` if the CLI provided no config source at all — the caller -/// then reports the appropriate missing-config error for its mode. -fn decode_config_input_once(cli: &Cli) -> Option> { - let (input, is_base64) = if let Some(b64) = cli.config_base64.as_ref() { - (b64.as_str(), true) - } else if let Some(path) = cli.config.as_ref().or(cli.config_path.as_ref()) { - (path.as_str(), false) - } else { - return None; - }; - Some(wxc_common::config_parser::decode_request_input( - input, is_base64, - )) } fn log_request(request: &ExecutionRequest, logger: &mut Logger) { @@ -139,31 +80,18 @@ fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { } fn main() { - let cli = parse_cli(); - - if let Some(action) = cli.telemetry_consent { - let outcome = wxc_common::telemetry::consent_cli::handle_consent_command( - action, - cli.telemetry_consent_locale.as_deref(), - ); - process::exit(outcome.emit()); - } - // Decode the config source once for normal request loading. - let decoded_config: Option> = - decode_config_input_once(&cli); + let cli = Cli::parse(); + // Determine config input. - let config_json = match decoded_config { - Some(Ok(json)) => json, - Some(Err(error)) => { - eprintln!("Request error\n{error}"); - process::exit(1); - } - None => { - eprintln!( - "Error: No config provided. Use a positional path, --config, or --config-base64" - ); - process::exit(1); - } + let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { + (b64.clone(), true) + } else if let Some(ref path) = cli.config { + (path.clone(), false) + } else if let Some(ref path) = cli.config_path { + (path.clone(), false) + } else { + eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); + process::exit(1); }; let mut logger = Logger::new(if cli.debug { @@ -178,8 +106,7 @@ fn main() { } } - let parsed_request = load_request_from_json(&config_json, &mut logger); - let mut request = match parsed_request { + let mut request = match load_request(&config_data, &mut logger, is_base64) { Ok(r) => r, Err(_) => { eprint!("Request error\n{}", logger.get_buffer()); From 55a45d18da664d541977688315eeb818ddc1442b Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 16:02:08 -0700 Subject: [PATCH 27/47] Close telemetry ownership gaps Hold SDK telemetry registration with RAII until a streaming process wrapper takes ownership, and scope inherited consent-store overrides to direct child processes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- src/core/mxc_engine/src/lib.rs | 33 ++++++- src/core/mxc_engine/src/state_aware.rs | 7 +- src/core/wxc_common/src/telemetry/consent.rs | 86 +++++++++++++++++-- .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 8 ++ .../run_telemetry_consent_smoke_test.ps1 | 4 +- .../scripts/run_telemetry_etw_smoke_test.ps1 | 2 +- 6 files changed, 125 insertions(+), 15 deletions(-) diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 921166c2c..81bfd0e64 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -97,6 +97,7 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> .as_ref() .map(|config| telemetry::init(config, &mut logger)) .unwrap_or(false); + let mut telemetry_registration = TelemetryRegistration::new(telemetry_active); let requested_sandbox_kind = request .inner .telemetry @@ -113,7 +114,7 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> // catch-all `InitError`. Shares the exhaustive `MxcErrorCode` → // `FailureReason` mapping with the state-aware path. telemetry::emit_sdk_early_exit_with_kind( - telemetry_active, + telemetry_registration.transfer(), &containment, requested_sandbox_kind, telemetry::classify_mxc_error(&error), @@ -123,10 +124,10 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> }; let extra_warnings = logger.take_warnings(); let process: Box = ProcessWithWarnings::wrap(process, extra_warnings); - if telemetry_active { + if telemetry_registration.active() { Ok(Box::new(TelemetryProcess { inner: process, - active: true, + active: telemetry_registration.transfer(), mode: TelemetryMode::OneShot { containment, requested_sandbox_kind, @@ -138,6 +139,32 @@ pub fn spawn(request: &SandboxRequest) -> Result, Error> } } +pub(crate) struct TelemetryRegistration { + active: bool, +} + +impl TelemetryRegistration { + pub(crate) fn new(active: bool) -> Self { + Self { active } + } + + pub(crate) fn active(&self) -> bool { + self.active + } + + pub(crate) fn transfer(&mut self) -> bool { + std::mem::take(&mut self.active) + } +} + +impl Drop for TelemetryRegistration { + fn drop(&mut self) { + if self.active { + telemetry::shutdown(); + } + } +} + /// Streaming process wrapper that owns one telemetry provider reference and /// emits exactly one terminal event for this SDK invocation. /// diff --git a/src/core/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 6c062b1e5..9dca6b785 100644 --- a/src/core/mxc_engine/src/state_aware.rs +++ b/src/core/mxc_engine/src/state_aware.rs @@ -29,7 +29,7 @@ use wxc_common::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase use wxc_common::telemetry; use crate::error::Error; -use crate::wrap_state_aware_telemetry_process_with_kind; +use crate::{wrap_state_aware_telemetry_process_with_kind, TelemetryRegistration}; #[cfg(not(all(target_os = "windows", feature = "wslc")))] fn wslc_unavailable() -> MxcError { @@ -531,6 +531,7 @@ pub fn exec_state_aware_json( .as_ref() .map(|config| telemetry::init(config, &mut logger)) .unwrap_or(false); + let mut telemetry_registration = TelemetryRegistration::new(telemetry_active); let backend = resolve_backend(&parsed) .map(|backend| backend.wire_name().to_string()) .unwrap_or_else(|_| "unknown".to_string()); @@ -545,7 +546,7 @@ pub fn exec_state_aware_json( let process = crate::ProcessWithWarnings::wrap(process, init_warnings); Ok(wrap_state_aware_telemetry_process_with_kind( process, - telemetry_active, + telemetry_registration.transfer(), backend, phase.as_str().to_string(), correlation, @@ -556,7 +557,7 @@ pub fn exec_state_aware_json( Err(error) => { let outcome = Err(error.clone()); telemetry::emit_sdk_state_aware_with_kind( - telemetry_active, + telemetry_registration.transfer(), requested_sandbox_kind, telemetry::TelemetryContext { backend: &backend, diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 73bc8d5a3..f83c533ee 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -1431,13 +1431,63 @@ pub mod test_support { let path = std::env::var_os(LOCAL_APPDATA_OVERRIDE_ENV) .filter(|value| !value.is_empty()) .map(PathBuf::from)?; - let owner_pid = std::env::var(LOCAL_APPDATA_OVERRIDE_OWNER_ENV) - .ok() - .and_then(|value| value.parse::().ok()); - if owner_pid == Some(std::process::id()) { - return None; + scoped_local_app_data_override( + path, + std::env::var_os(LOCAL_APPDATA_OVERRIDE_OWNER_ENV), + direct_parent_process_id(), + ) + } + + #[cfg(any(test, all(feature = "test-support", debug_assertions)))] + fn scoped_local_app_data_override( + path: PathBuf, + owner: Option, + parent_process_id: Option, + ) -> Option { + let owner = owner?.to_str()?.parse::().ok()?; + (Some(owner) == parent_process_id).then_some(path) + } + + #[cfg(all( + target_os = "windows", + any(test, all(feature = "test-support", debug_assertions)) + ))] + fn direct_parent_process_id() -> Option { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut parent = None; + unsafe { + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + if entry.th32ProcessID == std::process::id() { + parent = Some(entry.th32ParentProcessID); + break; + } + if Process32NextW(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); } - Some(path) + parent + } + + #[cfg(all( + not(target_os = "windows"), + any(test, all(feature = "test-support", debug_assertions)) + ))] + fn direct_parent_process_id() -> Option { + None } #[cfg(test)] @@ -1448,6 +1498,30 @@ pub mod test_support { ); } + #[cfg(test)] + mod tests { + use super::scoped_local_app_data_override; + use std::path::PathBuf; + + #[test] + fn inherited_override_requires_direct_parent_owner() { + let path = PathBuf::from("isolated"); + assert_eq!( + scoped_local_app_data_override(path.clone(), Some("41".into()), Some(41)), + Some(path.clone()) + ); + assert_eq!( + scoped_local_app_data_override(path.clone(), Some("42".into()), Some(41)), + None + ); + assert_eq!( + scoped_local_app_data_override(path.clone(), Some("invalid".into()), Some(41)), + None + ); + assert_eq!(scoped_local_app_data_override(path, None, Some(41)), None); + } + } + #[cfg(target_os = "windows")] impl Drop for LocalAppDataGuard { fn drop(&mut self) { diff --git a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index 426363224..74ddee517 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -204,6 +204,10 @@ fn assert_effective_consent(exe: &Path, local_app_data: &Path, expected: &str) { let output = Command::new(exe) .args(["--telemetry-consent", "status"]) .env("MXC_TEST_LOCALAPPDATA_OVERRIDE", local_app_data) + .env( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + std::process::id().to_string(), + ) .output() .expect("failed to query consent status"); let stdout = String::from_utf8_lossy(&output.stdout); @@ -219,6 +223,10 @@ fn run_traced_execution(exe: &Path, local_app_data: &Path) -> std::process::Outp .arg("--config") .arg(examples_dir().join("28_telemetry_enabled.json")) .env("MXC_TEST_LOCALAPPDATA_OVERRIDE", local_app_data) + .env( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + std::process::id().to_string(), + ) .output() .expect("failed to run wxc-exec") } diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index 1a856baca..5149a31a1 100644 --- a/tests/scripts/run_telemetry_consent_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -61,7 +61,7 @@ function Invoke-ConsentRequest([string]$Decision) { $start.RedirectStandardError = $true $start.CreateNoWindow = $true $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir - [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') + $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID'] = "$PID" $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID'] = "$PID" $process = New-Object Diagnostics.Process @@ -115,7 +115,7 @@ function Assert-PipedRequestEofFails { $start.RedirectStandardError = $true $start.CreateNoWindow = $true $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir - [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') + $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID'] = "$PID" $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID'] = "$PID" $process = New-Object Diagnostics.Process diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index b10fbc19a..b2c764363 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -75,7 +75,7 @@ function Invoke-CanonicalGrant { $start.RedirectStandardError = $true $start.CreateNoWindow = $true $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE'] = $tempDir - [void]$start.Environment.Remove('MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID') + $start.Environment['MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID'] = "$PID" $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE'] = $policySubkey $start.Environment['MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID'] = "$PID" $process = New-Object Diagnostics.Process From 416f6916910309b5c2777f792cd6c66bec8e7ebf Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 16:06:03 -0700 Subject: [PATCH 28/47] Scope smoke-test consent overrides Stamp and restore the consent-store override owner for direct executor children as well as ProcessStartInfo launches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- tests/scripts/run_telemetry_consent_smoke_test.ps1 | 5 +++++ tests/scripts/run_telemetry_etw_smoke_test.ps1 | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/tests/scripts/run_telemetry_consent_smoke_test.ps1 b/tests/scripts/run_telemetry_consent_smoke_test.ps1 index 5149a31a1..f4a837471 100644 --- a/tests/scripts/run_telemetry_consent_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -158,9 +158,11 @@ function Assert-Status( } $overrideName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE' +$overrideOwnerName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID' $policyOverrideName = 'MXC_TEST_POLICY_KEY_OVERRIDE' $policyOverrideOwnerName = 'MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID' $originalOverride = [Environment]::GetEnvironmentVariable($overrideName) +$originalOverrideOwner = [Environment]::GetEnvironmentVariable($overrideOwnerName) $originalPolicyOverride = [Environment]::GetEnvironmentVariable($policyOverrideName) $originalPolicyOverrideOwner = [Environment]::GetEnvironmentVariable($policyOverrideOwnerName) $runId = [guid]::NewGuid().ToString('N') @@ -175,6 +177,7 @@ try { New-Item -ItemType Directory -Path (Split-Path -Parent $consentFile) -Force | Out-Null New-Item -Path $policyPath -Force | Out-Null Set-Item "Env:$overrideName" $tempDir + Set-Item "Env:$overrideOwnerName" $PID Set-Item "Env:$policyOverrideName" $policySubkey Set-Item "Env:$policyOverrideOwnerName" $PID @@ -230,6 +233,8 @@ try { } finally { if ($null -eq $originalOverride) { Remove-Item "Env:$overrideName" -ErrorAction SilentlyContinue } else { Set-Item "Env:$overrideName" $originalOverride } + if ($null -eq $originalOverrideOwner) { Remove-Item "Env:$overrideOwnerName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$overrideOwnerName" $originalOverrideOwner } if ($null -eq $originalPolicyOverride) { Remove-Item "Env:$policyOverrideName" -ErrorAction SilentlyContinue } else { Set-Item "Env:$policyOverrideName" $originalPolicyOverride } if ($null -eq $originalPolicyOverrideOwner) { Remove-Item "Env:$policyOverrideOwnerName" -ErrorAction SilentlyContinue } diff --git a/tests/scripts/run_telemetry_etw_smoke_test.ps1 b/tests/scripts/run_telemetry_etw_smoke_test.ps1 index b2c764363..013dba827 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -130,9 +130,11 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra } $overrideName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE' +$overrideOwnerName = 'MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID' $policyOverrideName = 'MXC_TEST_POLICY_KEY_OVERRIDE' $policyOverrideOwnerName = 'MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID' $originalOverride = [Environment]::GetEnvironmentVariable($overrideName) +$originalOverrideOwner = [Environment]::GetEnvironmentVariable($overrideOwnerName) $originalPolicyOverride = [Environment]::GetEnvironmentVariable($policyOverrideName) $originalPolicyOverrideOwner = [Environment]::GetEnvironmentVariable($policyOverrideOwnerName) $runId = [guid]::NewGuid().ToString('N') @@ -151,6 +153,7 @@ try { New-Item -ItemType Directory -Path $etlDir -Force | Out-Null New-Item -Path $policyPath -Force | Out-Null Set-Item -Path "Env:$overrideName" -Value $tempDir + Set-Item -Path "Env:$overrideOwnerName" -Value $PID Set-Item -Path "Env:$policyOverrideName" -Value $policySubkey Set-Item -Path "Env:$policyOverrideOwnerName" -Value $PID @@ -217,6 +220,8 @@ try { if ($traceStarted) { logman stop $sessionName -ets 2>$null | Out-Null } if ($null -eq $originalOverride) { Remove-Item "Env:$overrideName" -ErrorAction SilentlyContinue } else { Set-Item "Env:$overrideName" $originalOverride } + if ($null -eq $originalOverrideOwner) { Remove-Item "Env:$overrideOwnerName" -ErrorAction SilentlyContinue } + else { Set-Item "Env:$overrideOwnerName" $originalOverrideOwner } if ($null -eq $originalPolicyOverride) { Remove-Item "Env:$policyOverrideName" -ErrorAction SilentlyContinue } else { Set-Item "Env:$policyOverrideName" $originalPolicyOverride } if ($null -eq $originalPolicyOverrideOwner) { Remove-Item "Env:$policyOverrideOwnerName" -ErrorAction SilentlyContinue } From e24bf5f3814d4b9c638954b3d44b945f5538c68a Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 16:20:12 -0700 Subject: [PATCH 29/47] Fix consent test module ordering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- src/core/wxc_common/src/telemetry/consent.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index f83c533ee..4e15ec465 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -1498,6 +1498,15 @@ pub mod test_support { ); } + #[cfg(target_os = "windows")] + impl Drop for LocalAppDataGuard { + fn drop(&mut self) { + *LOCAL_APP_DATA_OVERRIDE + .lock() + .unwrap_or_else(|e| e.into_inner()) = self.previous_override.clone(); + } + } + #[cfg(test)] mod tests { use super::scoped_local_app_data_override; @@ -1521,15 +1530,6 @@ pub mod test_support { assert_eq!(scoped_local_app_data_override(path, None, Some(41)), None); } } - - #[cfg(target_os = "windows")] - impl Drop for LocalAppDataGuard { - fn drop(&mut self) { - *LOCAL_APP_DATA_OVERRIDE - .lock() - .unwrap_or_else(|e| e.into_inner()) = self.previous_override.clone(); - } - } } // --------------------------------------------------------------------------- From 77ab5bbde6b1027b4c762c7c50454566d1497e17 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 16:51:07 -0700 Subject: [PATCH 30/47] Fix .NET consent review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../Microsoft.Mxc.Sdk.Tests.csproj | 29 +++++++-- .../MxcTelemetryTests.cs | 64 +++++++++++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 44 ++++++++++++- 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj index 0d674fd03..00aebe79a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj @@ -20,9 +20,9 @@ test-support $(MxcTestCargoFeatures) isolation_session $(MxcTestCargoFeatures) wslc - $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/mxc_ffi.dll - $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/libmxc_ffi.dylib - $(MxcTestCargoTargetDir)/$(MxcTestCargoProfileDir)/libmxc_ffi.so + mxc_ffi.dll + libmxc_ffi.dylib + libmxc_ffi.so @@ -32,8 +32,27 @@ - - + + + + + + + + @(MxcTestRustcHostLine) + + + + $(MxcTestRustcHostLine.Replace('host: ', '').Trim()) + $(MxcTestCargoTargetDir)/$(MxcTestRustHostTriple)/$(MxcTestCargoProfileDir)/$(MxcTestNativeFileName) + + + + { + Assert.Same(context, SynchronizationContext.Current); + Assert.Equal(callerThread, Environment.CurrentManagedThreadId); + await Task.Yield(); + Assert.Same(context, SynchronizationContext.Current); + Assert.Equal(callerThread, Environment.CurrentManagedThreadId); + return TelemetryConsentDecision.Yes; + }); + + context.RunUntilCompleted(request); + Assert.Equal(TelemetryConsentResult.Granted, request.GetAwaiter().GetResult().Result); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + } + [Fact] public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows() { @@ -165,5 +200,34 @@ public void Dispose() Gate.Release(); } } + + private sealed class PumpSynchronizationContext : SynchronizationContext, IDisposable + { + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _work = new(); + private readonly AutoResetEvent _workAvailable = new(initialState: false); + + public override void Post(SendOrPostCallback callback, object? state) + { + _work.Enqueue((callback, state)); + _workAvailable.Set(); + } + + public void RunUntilCompleted(Task task) + { + while (!task.IsCompleted) + { + if (_work.TryDequeue(out var work)) + { + work.Callback(work.State); + } + else + { + _workAvailable.WaitOne(TimeSpan.FromSeconds(5)); + } + } + } + + public void Dispose() => _workAvailable.Dispose(); + } #endif } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index a328c2dca..5658ab34a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -221,7 +221,8 @@ public static TelemetryConsentOutcome RequestConsent( /// /// Asynchronous wrapper over . - /// The native API is synchronous and blocking, so this offloads the whole call to the thread pool. + /// The native API is synchronous and blocking, so the native work runs on the thread pool while + /// the presenter is dispatched to the caller's synchronization context when one is available. /// public static Task RequestConsentAsync( Func> presenter, @@ -229,9 +230,12 @@ public static Task RequestConsentAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(presenter); + var synchronizationContext = SynchronizationContext.Current; return Task.Run( () => RequestConsent( - prompt => presenter(prompt).ConfigureAwait(false).GetAwaiter().GetResult(), + prompt => InvokePresenterAsync(presenter, prompt, synchronizationContext) + .GetAwaiter() + .GetResult(), locale), cancellationToken); } @@ -341,6 +345,42 @@ private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* conte } } + private static Task InvokePresenterAsync( + Func> presenter, + TelemetryConsentPrompt prompt, + SynchronizationContext? synchronizationContext) + { + if (synchronizationContext is null) + { + return presenter(prompt); + } + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + try + { + synchronizationContext.Post( + async _ => + { + try + { + completion.SetResult(await presenter(prompt)); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }, + null); + } + catch (Exception ex) + { + completion.SetException(ex); + } + + return completion.Task; + } + private static void EnsureNativeInitialized() => NativeLibraryResolver.Initialize(); private static void EnsureSuccess(int status, string fallbackMessage, string? nativeMessage) From 1d6278820880b2db137c4080e576478d9c2b6924 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 16:53:54 -0700 Subject: [PATCH 31/47] Satisfy async consent test analyzers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 71a5804b7..b372b11f8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -78,7 +78,7 @@ public void RequestConsent_PresenterExceptionReturnsBackendError_OnWindows() } [Fact] - public void RequestConsentAsync_PreservesCallerSynchronizationContext_OnWindows() + public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWindows() { if (!OperatingSystem.IsWindows()) { @@ -100,10 +100,10 @@ public void RequestConsentAsync_PreservesCallerSynchronizationContext_OnWindows( Assert.Same(context, SynchronizationContext.Current); Assert.Equal(callerThread, Environment.CurrentManagedThreadId); return TelemetryConsentDecision.Yes; - }); + }, cancellationToken: TestContext.Current.CancellationToken); context.RunUntilCompleted(request); - Assert.Equal(TelemetryConsentResult.Granted, request.GetAwaiter().GetResult().Result); + Assert.Equal(TelemetryConsentResult.Granted, (await request).Result); } finally { From 93de678eeadbf5101f07f32d524be8b9764371ab Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 16:59:25 -0700 Subject: [PATCH 32/47] Scope .NET consent test overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index b372b11f8..14c74de1b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; using System.Runtime.Versioning; @@ -141,6 +143,7 @@ private sealed class TelemetryTestEnv : IDisposable private static readonly SemaphoreSlim Gate = new(1, 1); private readonly string? _originalLocalAppData; + private readonly string? _originalLocalAppDataOwnerPid; private readonly string? _originalPolicyKey; private readonly string? _originalPolicyOwnerPid; private readonly string _storeDir; @@ -152,6 +155,8 @@ public TelemetryTestEnv() try { _originalLocalAppData = Environment.GetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + _originalLocalAppDataOwnerPid = Environment.GetEnvironmentVariable( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"); _originalPolicyKey = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE"); _originalPolicyOwnerPid = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID"); @@ -160,6 +165,9 @@ public TelemetryTestEnv() Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose(); Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _storeDir); + Environment.SetEnvironmentVariable( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + GetParentProcessId().ToString()); Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _policySubkey); Environment.SetEnvironmentVariable( "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", @@ -175,6 +183,9 @@ public TelemetryTestEnv() public void Dispose() { Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _originalLocalAppData); + Environment.SetEnvironmentVariable( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + _originalLocalAppDataOwnerPid); Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _originalPolicyKey); Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", _originalPolicyOwnerPid); @@ -199,6 +210,43 @@ public void Dispose() Gate.Release(); } + + private static int GetParentProcessId() + { + using var process = Process.GetCurrentProcess(); + var status = NtQueryInformationProcess( + process.Handle, + processInformationClass: 0, + out var processInformation, + Marshal.SizeOf(), + out _); + if (status != 0) + { + throw new InvalidOperationException( + $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}"); + } + + return checked((int)processInformation.InheritedFromUniqueProcessId); + } + + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + out ProcessBasicInformation processInformation, + int processInformationLength, + out int returnLength); + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessBasicInformation + { + internal IntPtr Reserved1; + internal IntPtr PebBaseAddress; + internal IntPtr Reserved2_0; + internal IntPtr Reserved2_1; + internal IntPtr UniqueProcessId; + internal IntPtr InheritedFromUniqueProcessId; + } } private sealed class PumpSynchronizationContext : SynchronizationContext, IDisposable From d7e00a6c9f0782552cfd7f849ec80e7d8e492090 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 18:24:44 -0700 Subject: [PATCH 33/47] Remove duplicate telemetry policy projection Keep the .NET run-to-completion API on the canonical nested Telemetry settings and remove internal implementation language from the Rust SDK documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs | 5 ++--- sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs | 12 ------------ sdk/dotnet/README.md | 5 ++++- src/core/mxc-sdk/README.md | 10 +++------- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 14c74de1b..2b743b5ca 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -21,12 +21,12 @@ public sealed class MxcTelemetryTests }; [Fact] - public void SandboxPolicy_TelemetryEnabledSerializesCanonically() + public void SandboxPolicy_TelemetrySerializesCanonically() { var policy = new SandboxPolicy { Version = SchemaVersions.MaximumSupported, - TelemetryEnabled = true, + Telemetry = new TelemetrySettings { Enabled = true }, }; var json = JsonSerializer.Serialize(policy, PolicyJsonOptions); @@ -34,7 +34,6 @@ public void SandboxPolicy_TelemetryEnabledSerializesCanonically() var root = doc.RootElement; Assert.True(root.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); - Assert.False(root.TryGetProperty("telemetryEnabled", out _)); } [Fact] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index 3b3e6668f..691d9899d 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -63,18 +63,6 @@ public sealed class SandboxPolicy /// [JsonPropertyName("telemetry")] public TelemetrySettings? Telemetry { get; set; } - - /// - /// Convenience projection for . Serializes through - /// the canonical nested telemetry.enabled field. The native FFI - /// rejects the legacy top-level telemetryEnabled alias. - /// - [JsonIgnore] - public bool? TelemetryEnabled - { - get => Telemetry?.Enabled; - set => Telemetry = value is null ? null : new TelemetrySettings { Enabled = value.Value }; - } } /// Telemetry section of a . diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index a73ab4980..20cbd3be1 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -484,7 +484,10 @@ MXC telemetry is Windows-only and remains off until both of these are true: 1. the user has explicitly granted MXC-owned consent, and 2. the caller opts this invocation in via telemetry settings. -Telemetry remains off by default unless the caller opts in with `SandboxPolicy.TelemetryEnabled = true` (or the equivalent phase-level `TelemetryEnabled` setting for state-aware requests) and applicable Windows consent/policy gates permit collection. +Telemetry remains off by default unless the caller opts in with +`SandboxPolicy.Telemetry = new TelemetrySettings { Enabled = true }` (or the +equivalent phase-level `TelemetryEnabled` setting for state-aware requests) +and applicable Windows consent/policy gates permit collection. The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies the canonical resource to a host callback, while `GetConsentStatus`, diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index d4bac61b8..c641801d8 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -460,10 +460,9 @@ for the full design. The crate is UI-agnostic: it does not render a prompt. A host may call `request_consent()` or `request_consent_async()` and render every field of the -canonical prompt supplied to its presenter callback verbatim. The prompt text -comes from the versioned `wxc_common` consent resource. MXC persists a grant -only from the typed decision returned by that callback. If the host never -requests consent, telemetry remains off. See the normative +canonical prompt supplied to its presenter callback verbatim. MXC persists a +grant only from the typed decision returned by that callback. If the host +never requests consent, telemetry remains off. See the normative [SDK presenter requirements](../../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements) for control mappings, dismissal behavior, learn-more handling, status, and withdrawal. @@ -497,9 +496,6 @@ Off Windows `get_consent()` always returns `ConsentState::NotApplicable`, for telemetry there, so a host can call these unconditionally without special-casing the platform. -`ConsentState` and `PolicyState` are SDK-owned facades over the shared -telemetry implementation. - ### Administrative policy An IT administrator can block MXC telemetry device-wide via MXC's own From c2c39c6d35b1d53a4db43f7862a4ac36c226cd8b Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 2 Sep 2026 18:45:32 -0700 Subject: [PATCH 34/47] Correct telemetry SDK guidance Require all FFI presenter callbacks to contain failures internally and remove the unavailable state-aware telemetry option from the standalone .NET SDK documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- sdk/dotnet/README.md | 5 ++--- src/ffi/mxc_ffi/src/lib.rs | 13 ++++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 20cbd3be1..29f78e22f 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -485,9 +485,8 @@ MXC telemetry is Windows-only and remains off until both of these are true: 2. the caller opts this invocation in via telemetry settings. Telemetry remains off by default unless the caller opts in with -`SandboxPolicy.Telemetry = new TelemetrySettings { Enabled = true }` (or the -equivalent phase-level `TelemetryEnabled` setting for state-aware requests) -and applicable Windows consent/policy gates permit collection. +`SandboxPolicy.Telemetry = new TelemetrySettings { Enabled = true }` and +applicable Windows consent/policy gates permit collection. The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies the canonical resource to a host callback, while `GetConsentStatus`, diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 054abb9bb..007fd6d33 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -87,16 +87,11 @@ pub const MXC_TELEMETRY_CONSENT_PRESENTER_ERROR: i32 = -1; /// /// # Safety /// -/// **The callback must not unwind across the FFI boundary.** Only Rust panics -/// are caught by [`std::panic::catch_unwind`] on the Rust side; a C++ -/// exception, a .NET/CLR exception, an Objective-C exception, a Go panic, or -/// any other foreign unwind mechanism escaping this callback into Rust is -/// undefined behavior. Binding authors wrapping this callback (C#, Node -/// native addons, C++ hosts, and so on) must wrap the trampoline that invokes -/// the caller's presenter in a `try` / `catch (...)` (or the runtime's -/// equivalent) that catches every foreign exception and returns +/// **The callback must not unwind across the FFI boundary.** Every callback +/// or trampoline, including one written in Rust, must contain failures +/// internally and return /// [`MXC_TELEMETRY_CONSENT_PRESENTER_ERROR`] instead of allowing the exception -/// to propagate. +/// or panic to propagate. pub type MxcTelemetryConsentPresenter = Option i32>; From 614c947e536c1325f1d7e04e68aad8defafd2b0b Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 20:17:28 -0700 Subject: [PATCH 35/47] Address PR 821 review feedback Centralize binding policy parsing in the native engine, clarify effective consent APIs, and make asynchronous consent cancellation state-consistent across stalled, faulted, and synchronous presenters. Refs #1090, #1111, #1112, #1113, #1114 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 211 ++++++++++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 112 +++++++++- src/core/mxc-sdk/Cargo.toml | 3 + src/core/mxc-sdk/src/lib.rs | 6 + src/core/mxc-sdk/src/telemetry.rs | 6 +- src/core/mxc_engine/Cargo.toml | 3 + src/core/mxc_engine/src/binding.rs | 165 ++++++++++++++ src/core/mxc_engine/src/lib.rs | 3 + src/ffi/mxc_ffi/Cargo.toml | 2 +- src/ffi/mxc_ffi/src/lib.rs | 167 +------------- src/ffi/mxc_ffi/src/request.rs | 19 +- 11 files changed, 519 insertions(+), 178 deletions(-) create mode 100644 src/core/mxc_engine/src/binding.rs diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 2b743b5ca..d91301072 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -112,6 +112,163 @@ public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWi } } + [Fact] + public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNotPersistDecision_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var cancellation = new CancellationTokenSource(); + var presenterStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var stalledPresenter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var request = MxcTelemetry.RequestConsentAsync(_ => + { + presenterStarted.SetResult(); + return stalledPresenter.Task; + }, cancellationToken: cancellation.Token); + + await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + + var finished = await Task.WhenAny( + request, + Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.Same(request, finished); + await Assert.ThrowsAnyAsync(() => request); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + + [Fact] + public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var cancellation = new CancellationTokenSource(); + using var listener = new CapturingTraceListener( + "MXC telemetry consent presenter faulted after cancellation"); + Trace.Listeners.Add(listener); + try + { + var presenterStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var presenter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var request = MxcTelemetry.RequestConsentAsync(_ => + { + presenterStarted.SetResult(); + return presenter.Task; + }, cancellationToken: cancellation.Token); + + await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => request); + + presenter.SetException(new InvalidOperationException("late presenter failure")); + var diagnostic = await listener.Message.WaitAsync(TestContext.Current.CancellationToken); + Assert.Contains("late presenter failure", diagnostic); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [Fact] + public async Task RequestConsentAsync_ExplicitDismissalCompletesWithoutCancellation_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + var outcome = await MxcTelemetry.RequestConsentAsync( + _ => Task.FromResult(TelemetryConsentDecision.Dismissed), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(TelemetryConsentResult.Dismissed, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + } + + [Fact] + public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPersistDecision_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var cancellation = new CancellationTokenSource(); + var previousContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var request = MxcTelemetry.RequestConsentAsync(_ => + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + return Task.FromResult(TelemetryConsentDecision.Yes); + }, cancellationToken: cancellation.Token); + + await Assert.ThrowsAnyAsync(() => request); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + } + + [Fact] + public void FinalizeAsyncOutcome_PersistedDecisionWinsLateCancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Granted, + StoredState = TelemetryConsentState.Granted, + EffectiveState = TelemetryConsentState.Granted, + }; + + Assert.Same(outcome, MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + + [Fact] + public void FinalizeAsyncOutcome_CanceledDismissalThrows() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Dismissed, + StoredState = TelemetryConsentState.Undetermined, + EffectiveState = TelemetryConsentState.Undetermined, + }; + + Assert.ThrowsAny( + () => MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + [Fact] public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows() { @@ -124,6 +281,10 @@ public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows() Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); Assert.True(MxcTelemetry.NeedsConsentPrompt()); + var dismissed = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Dismissed); + Assert.Equal(TelemetryConsentResult.Dismissed, dismissed.Result); + Assert.Equal(TelemetryConsentState.Undetermined, dismissed.StoredState); + var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); Assert.Equal(TelemetryConsentResult.Granted, granted.Result); Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); @@ -276,5 +437,55 @@ public void RunUntilCompleted(Task task) public void Dispose() => _workAvailable.Dispose(); } + + private sealed class CapturingTraceListener(string messagePrefix) : TraceListener + { + private readonly TaskCompletionSource _message = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Message => _message.Task; + + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? message) + { + Capture(eventType, message); + } + + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? format, + params object?[]? args) + { + Capture( + eventType, + args is null || args.Length == 0 + ? format + : string.Format(format ?? string.Empty, args)); + } + + public override void Write(string? message) + { + } + + public override void WriteLine(string? message) + { + } + + private void Capture(TraceEventType eventType, string? message) + { + if (eventType == TraceEventType.Error && + message?.StartsWith(messagePrefix, StringComparison.Ordinal) == true) + { + _message.TrySetResult(message); + } + } + } #endif } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 5658ab34a..4fe1d3f97 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; @@ -9,7 +10,7 @@ namespace Microsoft.Mxc.Sdk; -/// The user's recorded telemetry consent decision. +/// A stored or effective telemetry consent state. public enum TelemetryConsentState { Granted, @@ -109,10 +110,12 @@ public static class MxcTelemetry private sealed class PresenterContext { public required Func Presenter { get; init; } + public CancellationToken CancellationToken { get; init; } } /// - /// Return the user's recorded consent state. + /// Return the consent state currently effective for telemetry authorization. + /// Use to read the persisted decision. /// Fail-closed native-load failures return . /// public static TelemetryConsentState GetConsent() @@ -182,10 +185,22 @@ public static TelemetryConsentOutcome RequestConsent( string? locale = null) { ArgumentNullException.ThrowIfNull(presenter); + return RequestConsentCore(presenter, locale, CancellationToken.None); + } + + private static TelemetryConsentOutcome RequestConsentCore( + Func presenter, + string? locale, + CancellationToken cancellationToken) + { EnsureNativeInitialized(); var localeBuf = locale is null ? null : ToNullTerminatedUtf8(locale); - var presenterContext = GCHandle.Alloc(new PresenterContext { Presenter = presenter }); + var presenterContext = GCHandle.Alloc(new PresenterContext + { + Presenter = presenter, + CancellationToken = cancellationToken, + }); try { unsafe @@ -223,6 +238,8 @@ public static TelemetryConsentOutcome RequestConsent( /// Asynchronous wrapper over . /// The native API is synchronous and blocking, so the native work runs on the thread pool while /// the presenter is dispatched to the caller's synchronization context when one is available. + /// Cancellation stops waiting for the presenter and prevents its decision from being accepted + /// when observed before native persistence. It does not cancel the presenter's underlying task. /// public static Task RequestConsentAsync( Func> presenter, @@ -232,14 +249,32 @@ public static Task RequestConsentAsync( ArgumentNullException.ThrowIfNull(presenter); var synchronizationContext = SynchronizationContext.Current; return Task.Run( - () => RequestConsent( - prompt => InvokePresenterAsync(presenter, prompt, synchronizationContext) - .GetAwaiter() - .GetResult(), - locale), + () => + { + var outcome = RequestConsentCore( + prompt => InvokePresenterWithCancellation( + presenter, + prompt, + synchronizationContext, + cancellationToken), + locale, + cancellationToken); + return FinalizeAsyncOutcome(outcome, cancellationToken); + }, cancellationToken); } + internal static TelemetryConsentOutcome FinalizeAsyncOutcome( + TelemetryConsentOutcome outcome, + CancellationToken cancellationToken) + { + if (outcome.Result == TelemetryConsentResult.Dismissed) + { + cancellationToken.ThrowIfCancellationRequested(); + } + return outcome; + } + /// Persist an idempotent telemetry-consent withdrawal. public static TelemetryConsentOutcome WithdrawConsent() { @@ -331,7 +366,13 @@ private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* conte } var prompt = ParseConsentPrompt(ReadRequiredJson(promptJsonUtf8, "telemetry consent prompt")); - return presenterContext.Presenter(prompt) switch + var decision = presenterContext.Presenter(prompt); + if (presenterContext.CancellationToken.IsCancellationRequested) + { + return NativeConsentDecisionDismissed; + } + + return decision switch { TelemetryConsentDecision.Yes => NativeConsentDecisionYes, TelemetryConsentDecision.No => NativeConsentDecisionNo, @@ -345,6 +386,59 @@ private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* conte } } + private static TelemetryConsentDecision InvokePresenterWithCancellation( + Func> presenter, + TelemetryConsentPrompt prompt, + SynchronizationContext? synchronizationContext, + CancellationToken cancellationToken) + { + Task? presenterTask = null; + try + { + presenterTask = InvokePresenterAsync(presenter, prompt, synchronizationContext); + return presenterTask + .WaitAsync(cancellationToken) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (presenterTask is not null) + { + ObserveAbandonedPresenterFailure(presenterTask); + } + return TelemetryConsentDecision.Dismissed; + } + } + + private static void ObserveAbandonedPresenterFailure( + Task presenterTask) + { + _ = presenterTask.ContinueWith( + static faulted => + { + var exception = faulted.Exception; + if (exception is null) + { + return; + } + + try + { + Trace.TraceError( + "MXC telemetry consent presenter faulted after cancellation: {0}", + exception.GetBaseException()); + } + catch + { + // Diagnostic listeners must not fault an unobserved continuation. + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + private static Task InvokePresenterAsync( Func> presenter, TelemetryConsentPrompt prompt, diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index af870cde6..8411cdab0 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -18,6 +18,9 @@ wslc = ["mxc_engine/wslc"] isolation_session = ["mxc_engine/isolation_session"] # Exposes the debug-only telemetry test seams to downstream binding tests. test-support = ["wxc_common/test-support"] +# Enables the hidden policy-JSON bridge consumed by co-versioned language +# bindings. +ffi-internals = ["mxc_engine/ffi-internals"] [dependencies] wxc_common.workspace = true diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index d3960ee21..82125e1b1 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -146,6 +146,12 @@ mod sandbox; pub mod telemetry; +#[cfg(feature = "ffi-internals")] +#[doc(hidden)] +pub mod ffi_internals { + pub use mxc_engine::binding::{parse_policy_json, ParsedPolicy}; +} + pub use mxc_engine::configs; pub use mxc_engine::policy; pub use mxc_engine::{ diff --git a/src/core/mxc-sdk/src/telemetry.rs b/src/core/mxc-sdk/src/telemetry.rs index f18c35f48..3d43cd6cb 100644 --- a/src/core/mxc-sdk/src/telemetry.rs +++ b/src/core/mxc-sdk/src/telemetry.rs @@ -32,7 +32,7 @@ use std::fmt; use wxc_common::telemetry::consent as inner_consent; use wxc_common::telemetry::policy as inner_policy; -/// The user's recorded telemetry consent decision. +/// A stored or effective telemetry consent state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ConsentState { /// The user has explicitly agreed to telemetry collection. @@ -342,7 +342,9 @@ impl From for PolicyState { } } -/// Return the user's recorded consent decision. +/// Return the consent state currently effective for telemetry authorization. +/// +/// Use [`get_consent_status`] when the persisted decision is required. pub fn get_consent() -> ConsentState { inner_consent::get_consent().into() } diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index 3b76341af..6fb13470d 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -45,6 +45,9 @@ seatbelt_common = { workspace = true } [features] default = [] +# Enables the hidden policy-JSON bridge consumed by co-versioned language +# bindings through mxc-sdk. +ffi-internals = [] # Enables constructing the run-to-completion runner for each experimental # backend. Mirrors the executor binaries' feature set so backend selection can # live in one place. The build-time binary-staging that some backends also need diff --git a/src/core/mxc_engine/src/binding.rs b/src/core/mxc_engine/src/binding.rs new file mode 100644 index 000000000..f7f7f031e --- /dev/null +++ b/src/core/mxc_engine/src/binding.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Internal policy parsing shared by language-binding adapters. + +use crate::policy::SandboxPolicy; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TelemetrySection { + enabled: bool, +} + +#[derive(Default)] +struct OptionalTelemetrySection(Option); + +impl<'de> serde::Deserialize<'de> for OptionalTelemetrySection { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ::deserialize(deserializer) + .map(|section| Self(Some(section))) + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PolicyEnvelope { + #[serde(flatten)] + policy: SandboxPolicy, + #[serde(default)] + telemetry: OptionalTelemetrySection, + #[serde( + default, + rename = "telemetryEnabled", + deserialize_with = "reject_legacy_telemetry_enabled" + )] + _telemetry_enabled: (), +} + +fn reject_legacy_telemetry_enabled<'de, D>(deserializer: D) -> Result<(), D::Error> +where + D: serde::Deserializer<'de>, +{ + let _ = ::deserialize(deserializer)?; + Err(serde::de::Error::custom( + "telemetryEnabled is not supported; use telemetry.enabled", + )) +} + +fn validate_canonical_telemetry_version(envelope: &PolicyEnvelope) -> Result<(), String> { + if envelope.telemetry.0.is_none() { + return Ok(()); + } + let Some(core) = envelope.policy.version.split(['-', '+']).next() else { + return Ok(()); + }; + let mut components = core.split('.'); + let (Some(major), Some(minor), Some(patch), None) = ( + components.next(), + components.next(), + components.next(), + components.next(), + ) else { + return Ok(()); + }; + let (Ok(major), Ok(minor), Ok(_patch)) = ( + major.parse::(), + minor.parse::(), + patch.parse::(), + ) else { + return Ok(()); + }; + if major == 0 && minor < 9 { + return Err( + "failed to parse policy JSON: top-level 'telemetry' requires config schema version \ + 0.9.0-alpha or later" + .to_string(), + ); + } + Ok(()) +} + +/// A policy decoded for a language-binding adapter. +#[derive(Debug)] +pub struct ParsedPolicy { + pub policy: SandboxPolicy, + pub telemetry_enabled: Option, +} + +/// Parse SDK policy JSON through the native policy contract. +pub fn parse_policy_json(policy_json: &str) -> Result { + let envelope: PolicyEnvelope = serde_json::from_str(policy_json) + .map_err(|error| format!("failed to parse policy JSON: {error}"))?; + validate_canonical_telemetry_version(&envelope)?; + let telemetry_enabled = envelope + .telemetry + .0 + .as_ref() + .map(|telemetry| telemetry.enabled); + Ok(ParsedPolicy { + policy: envelope.policy, + telemetry_enabled, + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn accepts_canonical_telemetry() { + let parsed = + super::parse_policy_json(r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true}}"#) + .expect("policy should parse"); + + assert_eq!(parsed.policy.version, "0.9.0-alpha"); + assert_eq!(parsed.telemetry_enabled, Some(true)); + } + + #[test] + fn rejects_canonical_telemetry_before_schema_09() { + let error = + super::parse_policy_json(r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#) + .expect_err("canonical telemetry must honor the native schema boundary"); + + assert!(error.contains("requires config schema version 0.9.0-alpha")); + } + + #[test] + fn rejects_legacy_telemetry_alias() { + let error = + super::parse_policy_json(r#"{"version":"0.9.0-alpha","telemetryEnabled":true}"#) + .expect_err("the legacy telemetry alias must be rejected"); + + assert!(error.contains("telemetryEnabled")); + } + + #[test] + fn rejects_null_and_missing_telemetry_enabled() { + for policy_json in [ + r#"{"version":"0.9.0-alpha","telemetry":null}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":null}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{}}"#, + ] { + assert!( + super::parse_policy_json(policy_json).is_err(), + "{policy_json} must be rejected" + ); + } + } + + #[test] + fn rejects_duplicate_fields() { + for policy_json in [ + r#"{"version":"0.9.0-alpha","version":"0.9.0-alpha"}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true},"telemetry":{"enabled":false}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true,"enabled":false}}"#, + ] { + assert!( + super::parse_policy_json(policy_json).is_err(), + "{policy_json} must be rejected" + ); + } + } +} diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index b7d09acb3..6a11c8883 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -29,6 +29,9 @@ //! - [`Error`] / [`ErrorCode`] — the crate-owned error facade over //! `wxc_common`'s internal error type. +#[cfg(feature = "ffi-internals")] +#[doc(hidden)] +pub mod binding; pub mod configs; mod dispatch; mod error; diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index 786e67fb4..e921ddf63 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -13,7 +13,7 @@ name = "mxc_ffi" crate-type = ["cdylib", "staticlib", "lib"] [dependencies] -mxc-sdk = { workspace = true } +mxc-sdk = { workspace = true, features = ["ffi-internals"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 007fd6d33..c2966ca10 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -58,8 +58,8 @@ use std::ptr; use std::sync::OnceLock; use mxc_sdk::{ - available_backends, build_request, platform_support, run, ErrorCode, SandboxPolicy, - SandboxRequest, WaitOutcome, + available_backends, build_request, platform_support, run, ErrorCode, SandboxRequest, + WaitOutcome, }; mod error_detail; @@ -348,103 +348,6 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { CStr::from_ptr(p).to_str().ok() } -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct FfiTelemetrySection { - enabled: bool, -} - -#[derive(Default)] -struct OptionalTelemetrySection(Option); - -impl<'de> serde::Deserialize<'de> for OptionalTelemetrySection -where - Self: Sized, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - FfiTelemetrySection::deserialize(deserializer).map(|section| Self(Some(section))) - } -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct FfiPolicyEnvelope { - #[serde(flatten)] - policy: SandboxPolicy, - #[serde(default)] - telemetry: OptionalTelemetrySection, - #[serde( - default, - rename = "telemetryEnabled", - deserialize_with = "reject_legacy_telemetry_enabled" - )] - _telemetry_enabled: (), -} - -fn reject_legacy_telemetry_enabled<'de, D>(deserializer: D) -> Result<(), D::Error> -where - D: serde::Deserializer<'de>, -{ - let _ = ::deserialize(deserializer)?; - Err(serde::de::Error::custom( - "telemetryEnabled is not supported; use telemetry.enabled", - )) -} - -fn telemetry_enabled_from_policy(policy: &FfiPolicyEnvelope) -> Option { - policy - .telemetry - .0 - .as_ref() - .map(|telemetry| telemetry.enabled) -} - -fn validate_canonical_telemetry_version(policy: &FfiPolicyEnvelope) -> Result<(), String> { - if policy.telemetry.0.is_none() { - return Ok(()); - } - let Some(core) = policy.policy.version.split(['-', '+']).next() else { - return Ok(()); - }; - let mut components = core.split('.'); - let (Some(major), Some(minor), Some(patch), None) = ( - components.next(), - components.next(), - components.next(), - components.next(), - ) else { - return Ok(()); - }; - let (Ok(major), Ok(minor), Ok(_patch)) = ( - major.parse::(), - minor.parse::(), - patch.parse::(), - ) else { - return Ok(()); - }; - if major == 0 && minor < 9 { - return Err( - "failed to parse policy JSON: top-level 'telemetry' requires config schema version \ - 0.9.0-alpha or later" - .to_string(), - ); - } - Ok(()) -} - -pub(crate) fn parse_policy_json( - policy_json: &str, -) -> Result<(SandboxPolicy, Option), String> { - let envelope: FfiPolicyEnvelope = serde_json::from_str(policy_json) - .map_err(|error| format!("failed to parse policy JSON: {error}"))?; - validate_canonical_telemetry_version(&envelope)?; - let telemetry_enabled = telemetry_enabled_from_policy(&envelope); - Ok((envelope.policy, telemetry_enabled)) -} - pub(crate) fn build_run_request( policy_json: &str, command: &str, @@ -463,20 +366,20 @@ fn build_ffi_request( policy_json: &str, command: &str, ) -> Result { - let (policy, telemetry_enabled) = parse_policy_json(policy_json).map_err(|error| { + let parsed = mxc_sdk::ffi_internals::parse_policy_json(policy_json).map_err(|error| { ( MXC_STATUS_MALFORMED_REQUEST, MxcErrorDetail::from_message(error), ) })?; - let mut request = build_request(&policy, None).map_err(|error| { + let mut request = build_request(&parsed.policy, None).map_err(|error| { ( status_from_error_code(error.code), MxcErrorDetail::from_error(&error), ) })?; request.set_script(command); - if let Some(enabled) = telemetry_enabled { + if let Some(enabled) = parsed.telemetry_enabled { request.set_telemetry_enabled(enabled); } Ok(request) @@ -739,12 +642,14 @@ pub extern "C" fn mxc_version() -> *const c_char { // Telemetry consent // --------------------------------------------------------------------------- -/// Read the persisted telemetry consent state. +/// Read the telemetry consent state currently effective for authorization. /// /// On success, writes one of `"granted"`, `"denied"`, `"undetermined"`, or /// `"not-applicable"` (non-Windows hosts) into /// `*out_utf8` as a heap-allocated, NUL-terminated UTF-8 string. The caller -/// must free it with [`mxc_string_free`]. +/// must free it with [`mxc_string_free`]. Call +/// [`mxc_telemetry_get_consent_status`] and read `storedState` when the +/// persisted decision is required. /// /// After any non-success return with a non-null `out_utf8`, `*out_utf8` is /// null and must not be freed. @@ -1077,60 +982,6 @@ mod tests { out } - #[test] - fn policy_telemetry_switch_accepts_canonical_shape() { - let (policy, telemetry_enabled) = - parse_policy_json(r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true}}"#) - .expect("policy should parse"); - - assert_eq!(policy.version, "0.9.0-alpha"); - assert_eq!(telemetry_enabled, Some(true)); - } - - #[test] - fn policy_telemetry_switch_rejects_canonical_shape_before_schema_09() { - let error = parse_policy_json(r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#) - .expect_err("canonical telemetry must honor the native schema boundary"); - - assert!(error.contains("requires config schema version 0.9.0-alpha")); - } - - #[test] - fn policy_telemetry_switch_rejects_legacy_alias() { - let error = parse_policy_json(r#"{"version":"0.9.0-alpha","telemetryEnabled":true}"#) - .expect_err("the legacy telemetry alias must be rejected"); - - assert!(error.contains("telemetryEnabled")); - } - - #[test] - fn policy_telemetry_switch_rejects_null_and_missing_enabled() { - for policy_json in [ - r#"{"version":"0.9.0-alpha","telemetry":null}"#, - r#"{"version":"0.9.0-alpha","telemetry":{"enabled":null}}"#, - r#"{"version":"0.9.0-alpha","telemetry":{}}"#, - ] { - assert!( - parse_policy_json(policy_json).is_err(), - "{policy_json} must be rejected" - ); - } - } - - #[test] - fn policy_telemetry_switch_rejects_duplicate_fields() { - for policy_json in [ - r#"{"version":"0.9.0-alpha","version":"0.9.0-alpha"}"#, - r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true},"telemetry":{"enabled":false}}"#, - r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true,"enabled":false}}"#, - ] { - assert!( - parse_policy_json(policy_json).is_err(), - "{policy_json} must be rejected" - ); - } - } - #[test] fn build_run_request_propagates_telemetry_enablement() { for (policy_json, expected) in [ diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index 30b30bc9f..bd3f1cbff 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -220,7 +220,7 @@ impl ProcessContainerNetworkSpec { /// Parse a binding request and build the public Rust SDK request it describes. pub(crate) fn build_request_from_json(request_json: &str) -> Result { let spec: RequestSpec<'_> = serde_json::from_str(request_json).map_err(malformed_request)?; - let (policy, telemetry_enabled) = crate::parse_policy_json(spec.policy.get()) + let parsed = mxc_sdk::ffi_internals::parse_policy_json(spec.policy.get()) .map_err(|error| Error::new(ErrorCode::MalformedRequest, error))?; let policy_value: Value = serde_json::from_str(spec.policy.get()).map_err(malformed_request)?; if policy_value.get("captureDenials").is_some() { @@ -233,15 +233,18 @@ pub(crate) fn build_request_from_json(request_json: &str) -> Result Date: Fri, 4 Sep 2026 20:45:56 -0700 Subject: [PATCH 36/47] Report managed telemetry query failures Emit non-throwing, process-deduplicated diagnostics whenever managed consent and policy queries return fail-closed fallback values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 41 ++++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 4fe1d3f97..6b02aa102 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -106,6 +106,7 @@ public static class MxcTelemetry private const int NativeConsentDecisionYes = 1; private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; + private static readonly HashSet ReportedFailureCategories = new(StringComparer.Ordinal); private sealed class PresenterContext { @@ -146,8 +147,9 @@ public static TelemetryConsentState GetConsent() { throw; } - catch + catch (Exception ex) { + ReportFailClosed("GetConsent", "Undetermined", ex); return TelemetryConsentState.Undetermined; } } @@ -311,11 +313,18 @@ public static bool NeedsConsentPrompt() { int needsPrompt = 0; var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt); - return status == (int)ErrorCode.Success && needsPrompt != 0; + if (status != (int)ErrorCode.Success) + { + ReportFailClosed("NeedsConsentPrompt", "false", (ErrorCode)status); + return false; + } + + return needsPrompt != 0; } } - catch + catch (Exception ex) { + ReportFailClosed("NeedsConsentPrompt", "false", ex); return false; } } @@ -337,6 +346,7 @@ public static TelemetryPolicyState GetPolicy() { if (status != (int)ErrorCode.Success) { + ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)status); return TelemetryPolicyState.Blocked; } @@ -348,12 +358,35 @@ public static TelemetryPolicyState GetPolicy() } } } - catch + catch (Exception ex) { + ReportFailClosed("GetPolicy", "Blocked", ex); return TelemetryPolicyState.Blocked; } } + private static void ReportFailClosed(string operation, string safeResult, object detail) + { + try + { + var category = $"{operation}:{safeResult}:{detail}"; + lock (ReportedFailureCategories) + { + if (!ReportedFailureCategories.Add(category)) + { + return; + } + } + + Console.Error.WriteLine( + $"mxc: {operation} failed and is reporting '{safeResult}' to stay fail-closed: {detail}"); + } + catch + { + // Diagnostics must not affect the fail-closed result. + } + } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context) { From e8537826f3a8a6233f1cb95c4947b09c2cc32d75 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 20:53:25 -0700 Subject: [PATCH 37/47] Harden managed fail-closed diagnostics Use stable bounded diagnostic categories, redact exception details, and add deterministic deduplication coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 22 +++++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 58 +++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index d91301072..6567f5d21 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -43,6 +43,28 @@ public void GetPolicy_NeverThrowsAndFailsClosed() Assert.True(Enum.IsDefined(policy)); } + [Fact] + public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText() + { + var messages = new List(); + using var _ = MxcTelemetry.OverrideFailClosedDiagnosticSinkForTesting(messages.Add); + var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage"); + + MxcTelemetry.ReportFailClosed("GetPolicy", "Blocked", failure); + MxcTelemetry.ReportFailClosed("GetPolicy", "Blocked", failure); + MxcTelemetry.ReportFailClosed( + "NeedsConsentPrompt", + "false", + ErrorCode.BackendError); + + Assert.Equal(2, messages.Count); + Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]); + Assert.Contains("HRESULT 0x", messages[0]); + Assert.DoesNotContain("sensitive", messages[0]); + Assert.DoesNotContain('\u001b', messages[0]); + Assert.Contains("BackendError", messages[1]); + } + [Fact] public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 6b02aa102..371c2c5fb 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -106,7 +106,10 @@ public static class MxcTelemetry private const int NativeConsentDecisionYes = 1; private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; + private const int MaxReportedFailureCategories = 64; private static readonly HashSet ReportedFailureCategories = new(StringComparer.Ordinal); + private static Action failClosedDiagnosticSink = + static message => Console.Error.WriteLine(message); private sealed class PresenterContext { @@ -365,21 +368,35 @@ public static TelemetryPolicyState GetPolicy() } } - private static void ReportFailClosed(string operation, string safeResult, object detail) + internal static void ReportFailClosed(string operation, string safeResult, object detail) { try { - var category = $"{operation}:{safeResult}:{detail}"; + var category = detail switch + { + Exception ex => $"{operation}:{safeResult}:{ex.GetType().FullName}:{ex.HResult:X8}", + ErrorCode code => $"{operation}:{safeResult}:ErrorCode:{(int)code}", + _ => $"{operation}:{safeResult}:{detail.GetType().FullName}", + }; + Action sink; lock (ReportedFailureCategories) { - if (!ReportedFailureCategories.Add(category)) + if (ReportedFailureCategories.Contains(category) || + ReportedFailureCategories.Count >= MaxReportedFailureCategories) { return; } + ReportedFailureCategories.Add(category); + sink = failClosedDiagnosticSink; } - Console.Error.WriteLine( - $"mxc: {operation} failed and is reporting '{safeResult}' to stay fail-closed: {detail}"); + var description = detail switch + { + Exception ex => $"{ex.GetType().FullName} (HRESULT 0x{ex.HResult:X8})", + ErrorCode code => $"{code} ({(int)code})", + _ => detail.GetType().FullName ?? "unknown failure", + }; + sink($"mxc: {operation} failed and is reporting '{safeResult}' to stay fail-closed: {description}"); } catch { @@ -387,6 +404,37 @@ private static void ReportFailClosed(string operation, string safeResult, object } } + internal static IDisposable OverrideFailClosedDiagnosticSinkForTesting(Action sink) + { + ArgumentNullException.ThrowIfNull(sink); + lock (ReportedFailureCategories) + { + var previous = failClosedDiagnosticSink; + failClosedDiagnosticSink = sink; + ReportedFailureCategories.Clear(); + return new FailClosedDiagnosticScope(previous); + } + } + + private sealed class FailClosedDiagnosticScope(Action previous) : IDisposable + { + private bool disposed; + + public void Dispose() + { + lock (ReportedFailureCategories) + { + if (disposed) + { + return; + } + disposed = true; + failClosedDiagnosticSink = previous; + ReportedFailureCategories.Clear(); + } + } + } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context) { From 78af63c926664950105e47fe19fd04fe830a7193 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 21:12:06 -0700 Subject: [PATCH 38/47] Unify managed telemetry diagnostics Route fail-closed reports through Trace, use typed allocation-free deduplication keys, remove the global sink override, and document the managed diagnostic channel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 39 ++++---- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 94 ++++++++++--------- sdk/dotnet/README.md | 5 +- 3 files changed, 76 insertions(+), 62 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 6567f5d21..71c5bb501 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -46,23 +46,30 @@ public void GetPolicy_NeverThrowsAndFailsClosed() [Fact] public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText() { - var messages = new List(); - using var _ = MxcTelemetry.OverrideFailClosedDiagnosticSinkForTesting(messages.Add); + var operation = $"TestGetPolicy{Guid.NewGuid():N}"; + using var output = new StringWriter(); + using var listener = new TextWriterTraceListener(output); var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage"); - - MxcTelemetry.ReportFailClosed("GetPolicy", "Blocked", failure); - MxcTelemetry.ReportFailClosed("GetPolicy", "Blocked", failure); - MxcTelemetry.ReportFailClosed( - "NeedsConsentPrompt", - "false", - ErrorCode.BackendError); - - Assert.Equal(2, messages.Count); - Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]); - Assert.Contains("HRESULT 0x", messages[0]); - Assert.DoesNotContain("sensitive", messages[0]); - Assert.DoesNotContain('\u001b', messages[0]); - Assert.Contains("BackendError", messages[1]); + Trace.Listeners.Add(listener); + try + { + MxcTelemetry.ReportFailClosed(operation, "Blocked", failure); + MxcTelemetry.ReportFailClosed(operation, "Blocked", failure); + MxcTelemetry.ReportFailClosed(operation, "false", ErrorCode.BackendError); + Trace.Flush(); + + var message = output.ToString(); + Assert.Equal(2, message.Split(operation).Length - 1); + Assert.Contains(typeof(InvalidOperationException).FullName!, message); + Assert.Contains("HRESULT 0x", message); + Assert.DoesNotContain("sensitive", message); + Assert.DoesNotContain('\u001b', message); + Assert.Contains("BackendError", message); + } + finally + { + Trace.Listeners.Remove(listener); + } } [Fact] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 371c2c5fb..120d2a0a9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -107,9 +107,13 @@ public static class MxcTelemetry private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; private const int MaxReportedFailureCategories = 64; - private static readonly HashSet ReportedFailureCategories = new(StringComparer.Ordinal); - private static Action failClosedDiagnosticSink = - static message => Console.Error.WriteLine(message); + private static readonly HashSet ReportedFailureCategories = []; + + private readonly record struct FailureCategory( + string Operation, + string SafeResult, + string Kind, + int Code); private sealed class PresenterContext { @@ -368,35 +372,26 @@ public static TelemetryPolicyState GetPolicy() } } - internal static void ReportFailClosed(string operation, string safeResult, object detail) + internal static void ReportFailClosed( + string operation, + string safeResult, + Exception exception) { try { - var category = detail switch - { - Exception ex => $"{operation}:{safeResult}:{ex.GetType().FullName}:{ex.HResult:X8}", - ErrorCode code => $"{operation}:{safeResult}:ErrorCode:{(int)code}", - _ => $"{operation}:{safeResult}:{detail.GetType().FullName}", - }; - Action sink; - lock (ReportedFailureCategories) + var exceptionType = exception.GetType().FullName ?? exception.GetType().Name; + if (!TryBeginFailureReport( + new FailureCategory(operation, safeResult, exceptionType, exception.HResult))) { - if (ReportedFailureCategories.Contains(category) || - ReportedFailureCategories.Count >= MaxReportedFailureCategories) - { - return; - } - ReportedFailureCategories.Add(category); - sink = failClosedDiagnosticSink; + return; } - var description = detail switch - { - Exception ex => $"{ex.GetType().FullName} (HRESULT 0x{ex.HResult:X8})", - ErrorCode code => $"{code} ({(int)code})", - _ => detail.GetType().FullName ?? "unknown failure", - }; - sink($"mxc: {operation} failed and is reporting '{safeResult}' to stay fail-closed: {description}"); + Trace.TraceError( + "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2} (HRESULT 0x{3:X8})", + operation, + safeResult, + exceptionType, + exception.HResult); } catch { @@ -404,34 +399,43 @@ internal static void ReportFailClosed(string operation, string safeResult, objec } } - internal static IDisposable OverrideFailClosedDiagnosticSinkForTesting(Action sink) + internal static void ReportFailClosed( + string operation, + string safeResult, + ErrorCode errorCode) { - ArgumentNullException.ThrowIfNull(sink); - lock (ReportedFailureCategories) + try + { + if (!TryBeginFailureReport( + new FailureCategory(operation, safeResult, nameof(ErrorCode), (int)errorCode))) + { + return; + } + + Trace.TraceError( + "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2} ({3})", + operation, + safeResult, + errorCode, + (int)errorCode); + } + catch { - var previous = failClosedDiagnosticSink; - failClosedDiagnosticSink = sink; - ReportedFailureCategories.Clear(); - return new FailClosedDiagnosticScope(previous); + // Diagnostics must not affect the fail-closed result. } } - private sealed class FailClosedDiagnosticScope(Action previous) : IDisposable + private static bool TryBeginFailureReport(FailureCategory category) { - private bool disposed; - - public void Dispose() + lock (ReportedFailureCategories) { - lock (ReportedFailureCategories) + if (ReportedFailureCategories.Contains(category) || + ReportedFailureCategories.Count >= MaxReportedFailureCategories) { - if (disposed) - { - return; - } - disposed = true; - failClosedDiagnosticSink = previous; - ReportedFailureCategories.Clear(); + return false; } + ReportedFailureCategories.Add(category); + return true; } } diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 29f78e22f..7a68bd667 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -503,7 +503,10 @@ An IT administrator can still block MXC telemetry device-wide via MXC's own registry policy setting. See [`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md) for the stable registry contract and interaction rules. Read-only queries fail -closed rather than upgrading an unreadable device state into collection. +closed rather than upgrading an unreadable device state into collection. When +that fallback hides a native or parsing failure, the SDK reports the failure +once through `System.Diagnostics.Trace` without including exception messages or +stack traces. ## Projects From b4fe3af01a42f61e68bbff056267ee06f14bbaab Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 21:27:07 -0700 Subject: [PATCH 39/47] Isolate fail-closed diagnostic tracking Make deduplication and capacity independently testable, scope Trace assertions to unique operation tags, and cover concurrent reports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 46 +++++++++++--- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 60 +++++++++++-------- 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 71c5bb501..09ff9e53b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -58,13 +58,17 @@ public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText() MxcTelemetry.ReportFailClosed(operation, "false", ErrorCode.BackendError); Trace.Flush(); - var message = output.ToString(); - Assert.Equal(2, message.Split(operation).Length - 1); - Assert.Contains(typeof(InvalidOperationException).FullName!, message); - Assert.Contains("HRESULT 0x", message); - Assert.DoesNotContain("sensitive", message); - Assert.DoesNotContain('\u001b', message); - Assert.Contains("BackendError", message); + var messages = output + .ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.Contains(operation, StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(2, messages.Length); + Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]); + Assert.Contains("HRESULT 0x", messages[0]); + Assert.DoesNotContain("sensitive", messages[0]); + Assert.DoesNotContain('\u001b', messages[0]); + Assert.Contains("BackendError", messages[1]); } finally { @@ -72,6 +76,34 @@ public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText() } } + [Fact] + public void FailClosedDiagnosticTracker_DeduplicatesAndEnforcesCapacity() + { + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 2); + + Assert.True(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)); + Assert.False(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)); + Assert.True(tracker.TryAdd("NeedsConsentPrompt", "false", "ErrorCode", 2)); + Assert.False(tracker.TryAdd("GetConsent", "Undetermined", "ExceptionB", 3)); + } + + [Fact] + public void FailClosedDiagnosticTracker_DeduplicatesConcurrentReports() + { + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + var accepted = 0; + + Parallel.For(0, 1_000, _ => + { + if (tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)) + { + Interlocked.Increment(ref accepted); + } + }); + + Assert.Equal(1, accepted); + } + [Fact] public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 120d2a0a9..33c146018 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -106,14 +106,32 @@ public static class MxcTelemetry private const int NativeConsentDecisionYes = 1; private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; - private const int MaxReportedFailureCategories = 64; - private static readonly HashSet ReportedFailureCategories = []; + private static readonly FailureCategoryTracker ReportedFailures = new(capacity: 64); - private readonly record struct FailureCategory( - string Operation, - string SafeResult, - string Kind, - int Code); + internal sealed class FailureCategoryTracker(int capacity) + { + private readonly HashSet categories = []; + + private readonly record struct FailureCategory( + string Operation, + string SafeResult, + string Kind, + int Code); + + internal bool TryAdd(string operation, string safeResult, string kind, int code) + { + var category = new FailureCategory(operation, safeResult, kind, code); + lock (categories) + { + if (categories.Contains(category) || categories.Count >= capacity) + { + return false; + } + categories.Add(category); + return true; + } + } + } private sealed class PresenterContext { @@ -380,8 +398,11 @@ internal static void ReportFailClosed( try { var exceptionType = exception.GetType().FullName ?? exception.GetType().Name; - if (!TryBeginFailureReport( - new FailureCategory(operation, safeResult, exceptionType, exception.HResult))) + if (!ReportedFailures.TryAdd( + operation, + safeResult, + exceptionType, + exception.HResult)) { return; } @@ -406,8 +427,11 @@ internal static void ReportFailClosed( { try { - if (!TryBeginFailureReport( - new FailureCategory(operation, safeResult, nameof(ErrorCode), (int)errorCode))) + if (!ReportedFailures.TryAdd( + operation, + safeResult, + nameof(ErrorCode), + (int)errorCode)) { return; } @@ -425,20 +449,6 @@ internal static void ReportFailClosed( } } - private static bool TryBeginFailureReport(FailureCategory category) - { - lock (ReportedFailureCategories) - { - if (ReportedFailureCategories.Contains(category) || - ReportedFailureCategories.Count >= MaxReportedFailureCategories) - { - return false; - } - ReportedFailureCategories.Add(category); - return true; - } - } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context) { From ca7546d4024c9510d22426d3dad481a2fe6d8191 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 21:39:55 -0700 Subject: [PATCH 40/47] Cover managed telemetry query failures Add an internal query seam for deterministic fail-closed integration tests, cover concurrent capacity and throwing Trace listeners, and document retained diagnostic fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 107 ++++++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 138 ++++++++++++------ sdk/dotnet/README.md | 4 +- 3 files changed, 204 insertions(+), 45 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 09ff9e53b..2be3eaaeb 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -12,6 +12,12 @@ namespace Microsoft.Mxc.Sdk.Tests; +[CollectionDefinition("MxcTelemetry", DisableParallelization = true)] +public sealed class MxcTelemetryCollectionDefinition +{ +} + +[Collection("MxcTelemetry")] public sealed class MxcTelemetryTests { private static readonly JsonSerializerOptions PolicyJsonOptions = new() @@ -104,6 +110,75 @@ public void FailClosedDiagnosticTracker_DeduplicatesConcurrentReports() Assert.Equal(1, accepted); } + [Fact] + public void FailClosedDiagnosticTracker_EnforcesCapacityUnderConcurrentLoad() + { + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + var accepted = 0; + + Parallel.For(0, 1_000, index => + { + if (tracker.TryAdd("GetPolicy", "Blocked", "Exception", index)) + { + Interlocked.Increment(ref accepted); + } + }); + + Assert.Equal(64, accepted); + } + + [Fact] + public void FailClosedDiagnostics_DoNotPropagateTraceListenerFailures() + { + using var listener = new ThrowingTraceListener(); + Trace.Listeners.Add(listener); + try + { + var exception = Record.Exception(() => + MxcTelemetry.ReportFailClosed( + $"ThrowingTrace{Guid.NewGuid():N}", + "Blocked", + ErrorCode.BackendError)); + Assert.Null(exception); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [Fact] + public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() + { + var native = new FakeTelemetryQueryApi + { + GetConsentImpl = () => throw new DllNotFoundException("sensitive path"), + NeedsConsentPromptImpl = () => new((int)ErrorCode.BackendError, true), + GetPolicyImpl = () => throw new InvalidOperationException("sensitive policy"), + }; + using var nativeScope = MxcTelemetry.OverrideTelemetryQueryApiForTesting(native); + using var output = new StringWriter(); + using var listener = new TextWriterTraceListener(output); + Trace.Listeners.Add(listener); + try + { + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + Trace.Flush(); + + var message = output.ToString(); + Assert.Contains("GetConsent", message); + Assert.Contains("NeedsConsentPrompt", message); + Assert.Contains("GetPolicy", message); + Assert.DoesNotContain("sensitive", message); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + [Fact] public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() { @@ -549,4 +624,36 @@ private void Capture(TraceEventType eventType, string? message) } } #endif + + private sealed class FakeTelemetryQueryApi : MxcTelemetry.ITelemetryQueryApi + { + internal required Func GetConsentImpl { get; init; } + internal required Func NeedsConsentPromptImpl { get; init; } + internal required Func GetPolicyImpl { get; init; } + + public MxcTelemetry.NativePayloadResult GetConsent() => GetConsentImpl(); + + public MxcTelemetry.NativeBooleanResult NeedsConsentPrompt() => + NeedsConsentPromptImpl(); + + public MxcTelemetry.NativePayloadResult GetPolicy() => GetPolicyImpl(); + } + + private sealed class ThrowingTraceListener : TraceListener + { + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? format, + params object?[]? args) => + throw new InvalidOperationException("listener failure"); + + public override void Write(string? message) => + throw new InvalidOperationException("listener failure"); + + public override void WriteLine(string? message) => + throw new InvalidOperationException("listener failure"); + } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 33c146018..fa4db470c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -107,6 +107,79 @@ public static class MxcTelemetry private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; private static readonly FailureCategoryTracker ReportedFailures = new(capacity: 64); + private static ITelemetryQueryApi telemetryQueryApi = new PInvokeTelemetryQueryApi(); + + internal readonly record struct NativePayloadResult(int Status, string? Payload); + internal readonly record struct NativeBooleanResult(int Status, bool Value); + + internal interface ITelemetryQueryApi + { + NativePayloadResult GetConsent(); + NativeBooleanResult NeedsConsentPrompt(); + NativePayloadResult GetPolicy(); + } + + private sealed class PInvokeTelemetryQueryApi : ITelemetryQueryApi + { + public unsafe NativePayloadResult GetConsent() + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_consent(&value); + try + { + return new(status, ReadNativeUtf8(value)); + } + finally + { + FreeNativeString(value); + } + } + + public unsafe NativeBooleanResult NeedsConsentPrompt() + { + int needsPrompt = 0; + var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt); + return new(status, needsPrompt != 0); + } + + public unsafe NativePayloadResult GetPolicy() + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_policy(&value); + try + { + return new(status, ReadNativeUtf8(value)); + } + finally + { + FreeNativeString(value); + } + } + } + + internal static IDisposable OverrideTelemetryQueryApiForTesting( + ITelemetryQueryApi replacement) + { + ArgumentNullException.ThrowIfNull(replacement); + var previous = telemetryQueryApi; + telemetryQueryApi = replacement; + return new TelemetryQueryApiScope(previous); + } + + private sealed class TelemetryQueryApiScope(ITelemetryQueryApi previous) : IDisposable + { + private bool disposed; + + public void Dispose() + { + if (disposed) + { + return; + } + disposed = true; + telemetryQueryApi = previous; + } + } internal sealed class FailureCategoryTracker(int capacity) { @@ -149,24 +222,15 @@ public static TelemetryConsentState GetConsent() try { EnsureNativeInitialized(); - unsafe + var result = telemetryQueryApi.GetConsent(); + if (result.Status != (int)ErrorCode.Success) { - byte* value = null; - var status = NativeMethods.mxc_telemetry_get_consent(&value); - try - { - if (status != (int)ErrorCode.Success) - { - throw new MxcException((ErrorCode)status, "retrieving telemetry consent failed"); - } - - return ParseConsentState(ReadNativeUtf8(value) ?? "undetermined"); - } - finally - { - FreeNativeString(value); - } + throw new MxcException( + (ErrorCode)result.Status, + "retrieving telemetry consent failed"); } + + return ParseConsentState(result.Payload ?? "undetermined"); } catch (MxcException) { @@ -334,18 +398,17 @@ public static bool NeedsConsentPrompt() try { EnsureNativeInitialized(); - unsafe + var result = telemetryQueryApi.NeedsConsentPrompt(); + if (result.Status != (int)ErrorCode.Success) { - int needsPrompt = 0; - var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt); - if (status != (int)ErrorCode.Success) - { - ReportFailClosed("NeedsConsentPrompt", "false", (ErrorCode)status); - return false; - } - - return needsPrompt != 0; + ReportFailClosed( + "NeedsConsentPrompt", + "false", + (ErrorCode)result.Status); + return false; } + + return result.Value; } catch (Exception ex) { @@ -363,25 +426,14 @@ public static TelemetryPolicyState GetPolicy() try { EnsureNativeInitialized(); - unsafe + var result = telemetryQueryApi.GetPolicy(); + if (result.Status != (int)ErrorCode.Success) { - byte* value = null; - var status = NativeMethods.mxc_telemetry_get_policy(&value); - try - { - if (status != (int)ErrorCode.Success) - { - ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)status); - return TelemetryPolicyState.Blocked; - } - - return ParsePolicyState(ReadNativeUtf8(value) ?? "blocked"); - } - finally - { - FreeNativeString(value); - } + ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status); + return TelemetryPolicyState.Blocked; } + + return ParsePolicyState(result.Payload ?? "blocked"); } catch (Exception ex) { diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 7a68bd667..5156d4a35 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -505,8 +505,8 @@ registry policy setting. See for the stable registry contract and interaction rules. Read-only queries fail closed rather than upgrading an unreadable device state into collection. When that fallback hides a native or parsing failure, the SDK reports the failure -once through `System.Diagnostics.Trace` without including exception messages or -stack traces. +once through `System.Diagnostics.Trace`. Reports include the exception type and +HRESULT or native error code, but exclude exception messages and stack traces. ## Projects From 81aae6630bad0ed786d44996a0b87172d7106117 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 21:55:52 -0700 Subject: [PATCH 41/47] Make telemetry query test scope atomic Use atomic override restoration, cover concurrent disposal, and name the seam for the fail-closed reads it intentionally isolates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 20 +++++++++-- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 34 ++++++++++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 2be3eaaeb..8587bd86e 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -156,7 +156,7 @@ public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() NeedsConsentPromptImpl = () => new((int)ErrorCode.BackendError, true), GetPolicyImpl = () => throw new InvalidOperationException("sensitive policy"), }; - using var nativeScope = MxcTelemetry.OverrideTelemetryQueryApiForTesting(native); + using var nativeScope = MxcTelemetry.OverrideFailClosedTelemetryQueryApiForTesting(native); using var output = new StringWriter(); using var listener = new TextWriterTraceListener(output); Trace.Listeners.Add(listener); @@ -179,6 +179,22 @@ public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() } } + [Fact] + public void TelemetryQueryApiOverride_AllowsConcurrentDispose() + { + var native = new FakeTelemetryQueryApi + { + GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"), + NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), + GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"), + }; + var scope = MxcTelemetry.OverrideFailClosedTelemetryQueryApiForTesting(native); + + Parallel.Invoke(scope.Dispose, scope.Dispose); + + Assert.True(Enum.IsDefined(MxcTelemetry.GetPolicy())); + } + [Fact] public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() { @@ -625,7 +641,7 @@ private void Capture(TraceEventType eventType, string? message) } #endif - private sealed class FakeTelemetryQueryApi : MxcTelemetry.ITelemetryQueryApi + private sealed class FakeTelemetryQueryApi : MxcTelemetry.IFailClosedTelemetryQueryApi { internal required Func GetConsentImpl { get; init; } internal required Func NeedsConsentPromptImpl { get; init; } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index fa4db470c..471a49bf9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -107,19 +107,22 @@ public static class MxcTelemetry private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; private static readonly FailureCategoryTracker ReportedFailures = new(capacity: 64); - private static ITelemetryQueryApi telemetryQueryApi = new PInvokeTelemetryQueryApi(); + private static IFailClosedTelemetryQueryApi failClosedQueryApi = + new PInvokeFailClosedTelemetryQueryApi(); internal readonly record struct NativePayloadResult(int Status, string? Payload); internal readonly record struct NativeBooleanResult(int Status, bool Value); - internal interface ITelemetryQueryApi + // Only soft-failing reads use this seam; other telemetry APIs retain their + // direct P/Invoke and throwing contracts. + internal interface IFailClosedTelemetryQueryApi { NativePayloadResult GetConsent(); NativeBooleanResult NeedsConsentPrompt(); NativePayloadResult GetPolicy(); } - private sealed class PInvokeTelemetryQueryApi : ITelemetryQueryApi + private sealed class PInvokeFailClosedTelemetryQueryApi : IFailClosedTelemetryQueryApi { public unsafe NativePayloadResult GetConsent() { @@ -157,27 +160,26 @@ public unsafe NativePayloadResult GetPolicy() } } - internal static IDisposable OverrideTelemetryQueryApiForTesting( - ITelemetryQueryApi replacement) + internal static IDisposable OverrideFailClosedTelemetryQueryApiForTesting( + IFailClosedTelemetryQueryApi replacement) { ArgumentNullException.ThrowIfNull(replacement); - var previous = telemetryQueryApi; - telemetryQueryApi = replacement; - return new TelemetryQueryApiScope(previous); + var previous = Interlocked.Exchange(ref failClosedQueryApi, replacement); + return new FailClosedTelemetryQueryApiScope(previous); } - private sealed class TelemetryQueryApiScope(ITelemetryQueryApi previous) : IDisposable + private sealed class FailClosedTelemetryQueryApiScope( + IFailClosedTelemetryQueryApi previous) : IDisposable { - private bool disposed; + private int disposed; public void Dispose() { - if (disposed) + if (Interlocked.Exchange(ref disposed, 1) != 0) { return; } - disposed = true; - telemetryQueryApi = previous; + Interlocked.Exchange(ref failClosedQueryApi, previous); } } @@ -222,7 +224,7 @@ public static TelemetryConsentState GetConsent() try { EnsureNativeInitialized(); - var result = telemetryQueryApi.GetConsent(); + var result = failClosedQueryApi.GetConsent(); if (result.Status != (int)ErrorCode.Success) { throw new MxcException( @@ -398,7 +400,7 @@ public static bool NeedsConsentPrompt() try { EnsureNativeInitialized(); - var result = telemetryQueryApi.NeedsConsentPrompt(); + var result = failClosedQueryApi.NeedsConsentPrompt(); if (result.Status != (int)ErrorCode.Success) { ReportFailClosed( @@ -426,7 +428,7 @@ public static TelemetryPolicyState GetPolicy() try { EnsureNativeInitialized(); - var result = telemetryQueryApi.GetPolicy(); + var result = failClosedQueryApi.GetPolicy(); if (result.Status != (int)ErrorCode.Success) { ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status); From 0a91c0b1c36dd597bb879838d5b3efd8565a09e4 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 22:13:46 -0700 Subject: [PATCH 42/47] Retry failed managed diagnostics Rollback diagnostic reservations when Trace listeners fail, isolate production tracker state in tests, strengthen override restoration coverage, and consolidate reporter control flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 37 ++++- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 132 ++++++++++++------ 2 files changed, 117 insertions(+), 52 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 8587bd86e..a4060956b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -130,13 +130,16 @@ public void FailClosedDiagnosticTracker_EnforcesCapacityUnderConcurrentLoad() [Fact] public void FailClosedDiagnostics_DoNotPropagateTraceListenerFailures() { + var operation = $"ThrowingTrace{Guid.NewGuid():N}"; + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); using var listener = new ThrowingTraceListener(); Trace.Listeners.Add(listener); try { var exception = Record.Exception(() => MxcTelemetry.ReportFailClosed( - $"ThrowingTrace{Guid.NewGuid():N}", + operation, "Blocked", ErrorCode.BackendError)); Assert.Null(exception); @@ -145,6 +148,20 @@ public void FailClosedDiagnostics_DoNotPropagateTraceListenerFailures() { Trace.Listeners.Remove(listener); } + + using var output = new StringWriter(); + using var capture = new TextWriterTraceListener(output); + Trace.Listeners.Add(capture); + try + { + MxcTelemetry.ReportFailClosed(operation, "Blocked", ErrorCode.BackendError); + Trace.Flush(); + Assert.Contains(operation, output.ToString()); + } + finally + { + Trace.Listeners.Remove(capture); + } } [Fact] @@ -156,7 +173,9 @@ public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() NeedsConsentPromptImpl = () => new((int)ErrorCode.BackendError, true), GetPolicyImpl = () => throw new InvalidOperationException("sensitive policy"), }; - using var nativeScope = MxcTelemetry.OverrideFailClosedTelemetryQueryApiForTesting(native); + using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); using var output = new StringWriter(); using var listener = new TextWriterTraceListener(output); Trace.Listeners.Add(listener); @@ -182,17 +201,23 @@ public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() [Fact] public void TelemetryQueryApiOverride_AllowsConcurrentDispose() { + var calls = 0; var native = new FakeTelemetryQueryApi { GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"), NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), - GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"), + GetPolicyImpl = () => + { + Interlocked.Increment(ref calls); + return new((int)ErrorCode.Success, "blocked"); + }, }; - var scope = MxcTelemetry.OverrideFailClosedTelemetryQueryApiForTesting(native); + var scope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); Parallel.Invoke(scope.Dispose, scope.Dispose); - Assert.True(Enum.IsDefined(MxcTelemetry.GetPolicy())); + _ = MxcTelemetry.GetPolicy(); + Assert.Equal(0, calls); } [Fact] @@ -641,7 +666,7 @@ private void Capture(TraceEventType eventType, string? message) } #endif - private sealed class FakeTelemetryQueryApi : MxcTelemetry.IFailClosedTelemetryQueryApi + private sealed class FakeTelemetryQueryApi : MxcTelemetry.ITelemetryReadApi { internal required Func GetConsentImpl { get; init; } internal required Func NeedsConsentPromptImpl { get; init; } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 471a49bf9..e7e8798d2 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -106,23 +106,22 @@ public static class MxcTelemetry private const int NativeConsentDecisionYes = 1; private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; - private static readonly FailureCategoryTracker ReportedFailures = new(capacity: 64); - private static IFailClosedTelemetryQueryApi failClosedQueryApi = - new PInvokeFailClosedTelemetryQueryApi(); + private static FailureCategoryTracker reportedFailures = new(capacity: 64); + private static ITelemetryReadApi telemetryReadApi = new PInvokeTelemetryReadApi(); internal readonly record struct NativePayloadResult(int Status, string? Payload); internal readonly record struct NativeBooleanResult(int Status, bool Value); - // Only soft-failing reads use this seam; other telemetry APIs retain their - // direct P/Invoke and throwing contracts. - internal interface IFailClosedTelemetryQueryApi + // Read methods share native ownership plumbing, but preserve their public + // status contracts: GetConsent may throw while the other reads fail closed. + internal interface ITelemetryReadApi { NativePayloadResult GetConsent(); NativeBooleanResult NeedsConsentPrompt(); NativePayloadResult GetPolicy(); } - private sealed class PInvokeFailClosedTelemetryQueryApi : IFailClosedTelemetryQueryApi + private sealed class PInvokeTelemetryReadApi : ITelemetryReadApi { public unsafe NativePayloadResult GetConsent() { @@ -160,16 +159,38 @@ public unsafe NativePayloadResult GetPolicy() } } - internal static IDisposable OverrideFailClosedTelemetryQueryApiForTesting( - IFailClosedTelemetryQueryApi replacement) + internal static IDisposable OverrideTelemetryReadApiForTesting( + ITelemetryReadApi replacement) { ArgumentNullException.ThrowIfNull(replacement); - var previous = Interlocked.Exchange(ref failClosedQueryApi, replacement); - return new FailClosedTelemetryQueryApiScope(previous); + var previous = Interlocked.Exchange(ref telemetryReadApi, replacement); + return new TelemetryReadApiScope(previous); } - private sealed class FailClosedTelemetryQueryApiScope( - IFailClosedTelemetryQueryApi previous) : IDisposable + internal static IDisposable OverrideFailureCategoryTrackerForTesting( + FailureCategoryTracker replacement) + { + ArgumentNullException.ThrowIfNull(replacement); + var previous = Interlocked.Exchange(ref reportedFailures, replacement); + return new FailureCategoryTrackerScope(previous); + } + + private sealed class TelemetryReadApiScope(ITelemetryReadApi previous) : IDisposable + { + private int disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + Interlocked.Exchange(ref telemetryReadApi, previous); + } + } + + private sealed class FailureCategoryTrackerScope( + FailureCategoryTracker previous) : IDisposable { private int disposed; @@ -179,7 +200,7 @@ public void Dispose() { return; } - Interlocked.Exchange(ref failClosedQueryApi, previous); + Interlocked.Exchange(ref reportedFailures, previous); } } @@ -206,6 +227,14 @@ internal bool TryAdd(string operation, string safeResult, string kind, int code) return true; } } + + internal void Remove(string operation, string safeResult, string kind, int code) + { + lock (categories) + { + categories.Remove(new FailureCategory(operation, safeResult, kind, code)); + } + } } private sealed class PresenterContext @@ -224,7 +253,7 @@ public static TelemetryConsentState GetConsent() try { EnsureNativeInitialized(); - var result = failClosedQueryApi.GetConsent(); + var result = telemetryReadApi.GetConsent(); if (result.Status != (int)ErrorCode.Success) { throw new MxcException( @@ -400,7 +429,7 @@ public static bool NeedsConsentPrompt() try { EnsureNativeInitialized(); - var result = failClosedQueryApi.NeedsConsentPrompt(); + var result = telemetryReadApi.NeedsConsentPrompt(); if (result.Status != (int)ErrorCode.Success) { ReportFailClosed( @@ -428,7 +457,7 @@ public static TelemetryPolicyState GetPolicy() try { EnsureNativeInitialized(); - var result = failClosedQueryApi.GetPolicy(); + var result = telemetryReadApi.GetPolicy(); if (result.Status != (int)ErrorCode.Success) { ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status); @@ -449,29 +478,13 @@ internal static void ReportFailClosed( string safeResult, Exception exception) { - try - { - var exceptionType = exception.GetType().FullName ?? exception.GetType().Name; - if (!ReportedFailures.TryAdd( - operation, - safeResult, - exceptionType, - exception.HResult)) - { - return; - } - - Trace.TraceError( - "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2} (HRESULT 0x{3:X8})", - operation, - safeResult, - exceptionType, - exception.HResult); - } - catch - { - // Diagnostics must not affect the fail-closed result. - } + var exceptionType = exception.GetType().FullName ?? exception.GetType().Name; + ReportFailClosedCore( + operation, + safeResult, + exceptionType, + exception.HResult, + errorCode: null); } internal static void ReportFailClosed( @@ -479,26 +492,53 @@ internal static void ReportFailClosed( string safeResult, ErrorCode errorCode) { + ReportFailClosedCore( + operation, + safeResult, + nameof(ErrorCode), + (int)errorCode, + errorCode); + } + + private static void ReportFailClosedCore( + string operation, + string safeResult, + string kind, + int code, + ErrorCode? errorCode) + { + var registered = false; try { - if (!ReportedFailures.TryAdd( + if (!reportedFailures.TryAdd( operation, safeResult, - nameof(ErrorCode), - (int)errorCode)) + kind, + code)) { return; } + registered = true; + var description = errorCode is { } nativeError + ? $"{nativeError} ({code})" + : $"{kind} (HRESULT 0x{code:X8})"; Trace.TraceError( - "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2} ({3})", + "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2}", operation, safeResult, - errorCode, - (int)errorCode); + description); } catch { + if (registered) + { + reportedFailures.Remove( + operation, + safeResult, + kind, + code); + } // Diagnostics must not affect the fail-closed result. } } From cc198643b89da181fdff969d48465da2a83b9e6c Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 22:30:21 -0700 Subject: [PATCH 43/47] Harden telemetry test overrides Reject overlapping process-global test overrides, cover each read failure branch, and clarify diagnostic documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 59 +++++++++++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 50 ++++++++++++---- sdk/dotnet/README.md | 6 +- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index a4060956b..1cbdca068 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -198,6 +198,40 @@ public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadOnlyQueryFailures_CoverStatusAndExceptionPaths(bool throwException) + { + var native = new FakeTelemetryQueryApi + { + GetConsentImpl = () => throwException + ? throw new InvalidOperationException("consent") + : new((int)ErrorCode.BackendError, null), + NeedsConsentPromptImpl = () => throwException + ? throw new InvalidOperationException("prompt") + : new((int)ErrorCode.BackendError, true), + GetPolicyImpl = () => throwException + ? throw new InvalidOperationException("policy") + : new((int)ErrorCode.BackendError, null), + }; + using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); + + if (throwException) + { + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + } + else + { + var exception = Assert.Throws(() => MxcTelemetry.GetConsent()); + Assert.Equal(ErrorCode.BackendError, exception.Code); + } + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + } + [Fact] public void TelemetryQueryApiOverride_AllowsConcurrentDispose() { @@ -220,6 +254,31 @@ public void TelemetryQueryApiOverride_AllowsConcurrentDispose() Assert.Equal(0, calls); } + [Fact] + public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose() + { + var native = new FakeTelemetryQueryApi + { + GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"), + NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), + GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"), + }; + var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + Assert.Throws( + () => MxcTelemetry.OverrideTelemetryReadApiForTesting(native)); + Parallel.Invoke(nativeScope.Dispose, nativeScope.Dispose); + + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); + Assert.Throws( + () => MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker)); + Parallel.Invoke(trackerScope.Dispose, trackerScope.Dispose); + + using var restoredNativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + using var restoredTrackerScope = + MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); + } + [Fact] public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index e7e8798d2..e90fa8ffa 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -106,14 +106,16 @@ public static class MxcTelemetry private const int NativeConsentDecisionYes = 1; private const int NativeConsentDecisionDismissed = 2; private const int NativeConsentPresenterError = -1; - private static FailureCategoryTracker reportedFailures = new(capacity: 64); - private static ITelemetryReadApi telemetryReadApi = new PInvokeTelemetryReadApi(); + private static readonly FailureCategoryTracker DefaultFailureCategoryTracker = new(capacity: 64); + private static readonly ITelemetryReadApi DefaultTelemetryReadApi = new PInvokeTelemetryReadApi(); + private static FailureCategoryTracker reportedFailures = DefaultFailureCategoryTracker; + private static ITelemetryReadApi telemetryReadApi = DefaultTelemetryReadApi; internal readonly record struct NativePayloadResult(int Status, string? Payload); internal readonly record struct NativeBooleanResult(int Status, bool Value); - // Read methods share native ownership plumbing, but preserve their public - // status contracts: GetConsent may throw while the other reads fail closed. + // The public wrappers interpret these native status results according to + // their individual throw-or-fail-closed contracts. internal interface ITelemetryReadApi { NativePayloadResult GetConsent(); @@ -163,19 +165,37 @@ internal static IDisposable OverrideTelemetryReadApiForTesting( ITelemetryReadApi replacement) { ArgumentNullException.ThrowIfNull(replacement); - var previous = Interlocked.Exchange(ref telemetryReadApi, replacement); - return new TelemetryReadApiScope(previous); + if (!ReferenceEquals( + Interlocked.CompareExchange( + ref telemetryReadApi, + replacement, + DefaultTelemetryReadApi), + DefaultTelemetryReadApi)) + { + throw new InvalidOperationException( + "A telemetry read API test override is already active."); + } + return new TelemetryReadApiScope(replacement); } internal static IDisposable OverrideFailureCategoryTrackerForTesting( FailureCategoryTracker replacement) { ArgumentNullException.ThrowIfNull(replacement); - var previous = Interlocked.Exchange(ref reportedFailures, replacement); - return new FailureCategoryTrackerScope(previous); + if (!ReferenceEquals( + Interlocked.CompareExchange( + ref reportedFailures, + replacement, + DefaultFailureCategoryTracker), + DefaultFailureCategoryTracker)) + { + throw new InvalidOperationException( + "A failure category tracker test override is already active."); + } + return new FailureCategoryTrackerScope(replacement); } - private sealed class TelemetryReadApiScope(ITelemetryReadApi previous) : IDisposable + private sealed class TelemetryReadApiScope(ITelemetryReadApi replacement) : IDisposable { private int disposed; @@ -185,12 +205,15 @@ public void Dispose() { return; } - Interlocked.Exchange(ref telemetryReadApi, previous); + Interlocked.CompareExchange( + ref telemetryReadApi, + DefaultTelemetryReadApi, + replacement); } } private sealed class FailureCategoryTrackerScope( - FailureCategoryTracker previous) : IDisposable + FailureCategoryTracker replacement) : IDisposable { private int disposed; @@ -200,7 +223,10 @@ public void Dispose() { return; } - Interlocked.Exchange(ref reportedFailures, previous); + Interlocked.CompareExchange( + ref reportedFailures, + DefaultFailureCategoryTracker, + replacement); } } diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 5156d4a35..9a81abfac 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -505,8 +505,10 @@ registry policy setting. See for the stable registry contract and interaction rules. Read-only queries fail closed rather than upgrading an unreadable device state into collection. When that fallback hides a native or parsing failure, the SDK reports the failure -once through `System.Diagnostics.Trace`. Reports include the exception type and -HRESULT or native error code, but exclude exception messages and stack traces. +once per distinct failure signature through `System.Diagnostics.Trace`. If a +listener rejects a report, the SDK permits a later retry. Reports include the +exception type and HRESULT or native error code, but exclude exception messages +and stack traces. ## Projects From 1faac2438b4cfec086a5d2fb24662be9d2b3bdd5 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 22:46:36 -0700 Subject: [PATCH 44/47] Isolate telemetry diagnostic tests Use a fresh dedup tracker and assert the distinct exception and status diagnostic paths without consuming process-global state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 44 +++++++++++++++---- sdk/dotnet/README.md | 6 +-- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 1cbdca068..f41e59788 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -53,6 +53,8 @@ public void GetPolicy_NeverThrowsAndFailsClosed() public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText() { var operation = $"TestGetPolicy{Guid.NewGuid():N}"; + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); using var output = new StringWriter(); using var listener = new TextWriterTraceListener(output); var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage"); @@ -218,18 +220,44 @@ public void ReadOnlyQueryFailures_CoverStatusAndExceptionPaths(bool throwExcepti using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( new MxcTelemetry.FailureCategoryTracker(capacity: 64)); - - if (throwException) + using var output = new StringWriter(); + using var listener = new TextWriterTraceListener(output); + Trace.Listeners.Add(listener); + try { - Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + if (throwException) + { + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + } + else + { + var exception = Assert.Throws(() => MxcTelemetry.GetConsent()); + Assert.Equal(ErrorCode.BackendError, exception.Code); + } + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + Trace.Flush(); + + var messages = output + .ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.Contains("mxc:", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(throwException ? 3 : 2, messages.Length); + Assert.Contains(messages, line => line.Contains("NeedsConsentPrompt", StringComparison.Ordinal)); + Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal)); + Assert.All( + messages, + line => Assert.Contains( + throwException + ? typeof(InvalidOperationException).FullName! + : nameof(ErrorCode.BackendError), + line)); } - else + finally { - var exception = Assert.Throws(() => MxcTelemetry.GetConsent()); - Assert.Equal(ErrorCode.BackendError, exception.Code); + Trace.Listeners.Remove(listener); } - Assert.False(MxcTelemetry.NeedsConsentPrompt()); - Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); } [Fact] diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 9a81abfac..a4bd68d25 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -506,9 +506,9 @@ for the stable registry contract and interaction rules. Read-only queries fail closed rather than upgrading an unreadable device state into collection. When that fallback hides a native or parsing failure, the SDK reports the failure once per distinct failure signature through `System.Diagnostics.Trace`. If a -listener rejects a report, the SDK permits a later retry. Reports include the -exception type and HRESULT or native error code, but exclude exception messages -and stack traces. +listener rejects a report, a future occurrence of the same signature may try +again. Reports include the exception type and HRESULT or native error code, but +exclude exception messages and stack traces. ## Projects From 2d94ffd32564d86fc389fe45210fdcee9a034884 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 22:59:50 -0700 Subject: [PATCH 45/47] Make telemetry override test exception-safe Dispose process-global test overrides during assertion unwinding and document why the telemetry collection is nonparallel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index f41e59788..399c0e5bc 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -12,6 +12,7 @@ namespace Microsoft.Mxc.Sdk.Tests; +// These tests mutate process-global telemetry seams and Trace listeners. [CollectionDefinition("MxcTelemetry", DisableParallelization = true)] public sealed class MxcTelemetryCollectionDefinition { @@ -291,13 +292,13 @@ public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose() NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"), }; - var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); Assert.Throws( () => MxcTelemetry.OverrideTelemetryReadApiForTesting(native)); Parallel.Invoke(nativeScope.Dispose, nativeScope.Dispose); var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); - var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); Assert.Throws( () => MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker)); Parallel.Invoke(trackerScope.Dispose, trackerScope.Dispose); From b3b659a0cdafc56d76fc22059015e09e01ebfa7a Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Fri, 4 Sep 2026 23:10:53 -0700 Subject: [PATCH 46/47] Clarify bounded telemetry diagnostics Document that managed fail-closed reporting retains a bounded set of distinct process-lifetime failure signatures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- sdk/dotnet/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index a4bd68d25..ecb6ef3bb 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -504,11 +504,11 @@ registry policy setting. See [`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md) for the stable registry contract and interaction rules. Read-only queries fail closed rather than upgrading an unreadable device state into collection. When -that fallback hides a native or parsing failure, the SDK reports the failure -once per distinct failure signature through `System.Diagnostics.Trace`. If a -listener rejects a report, a future occurrence of the same signature may try -again. Reports include the exception type and HRESULT or native error code, but -exclude exception messages and stack traces. +that fallback hides a native or parsing failure, the SDK reports a bounded set +of distinct failure signatures through `System.Diagnostics.Trace`. Each +signature is reported once; if a listener rejects it, a future occurrence may +try again. Reports include the exception type and HRESULT or native error code, +but exclude exception messages and stack traces. ## Projects From ae0d8f0aca79beb99232ebc11155d2d3f378d451 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Sat, 5 Sep 2026 12:22:34 -0700 Subject: [PATCH 47/47] Redact abandoned presenter diagnostics Route late presenter faults through the bounded fail-closed reporter so diagnostics include only exception type and HRESULT, never exception message or stack details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs | 8 ++++++-- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 14 ++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 399c0e5bc..5033d25fc 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -421,8 +421,10 @@ public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_O using var _ = new TelemetryTestEnv(); using var cancellation = new CancellationTokenSource(); + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); using var listener = new CapturingTraceListener( - "MXC telemetry consent presenter faulted after cancellation"); + "mxc: RequestConsentAsync presenter after cancellation failed"); Trace.Listeners.Add(listener); try { @@ -443,7 +445,9 @@ public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_O presenter.SetException(new InvalidOperationException("late presenter failure")); var diagnostic = await listener.Message.WaitAsync(TestContext.Current.CancellationToken); - Assert.Contains("late presenter failure", diagnostic); + Assert.Contains(typeof(InvalidOperationException).FullName!, diagnostic); + Assert.Contains("HRESULT 0x", diagnostic); + Assert.DoesNotContain("late presenter failure", diagnostic); Assert.Equal( TelemetryConsentState.Undetermined, MxcTelemetry.GetConsentStatus().StoredState); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index e90fa8ffa..1684a8385 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -638,16 +638,10 @@ private static void ObserveAbandonedPresenterFailure( return; } - try - { - Trace.TraceError( - "MXC telemetry consent presenter faulted after cancellation: {0}", - exception.GetBaseException()); - } - catch - { - // Diagnostic listeners must not fault an unobserved continuation. - } + ReportFailClosed( + "RequestConsentAsync presenter after cancellation", + "canceled", + exception.GetBaseException()); }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted,