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/schema.md b/docs/schema.md index 248af03b3..74094f9d9 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -199,6 +199,11 @@ cannot mix both formats in one request. "extraMachLookups": [] // Additional Mach service global-names the inner process may resolve }, + "telemetry": { // Telemetry (Windows only) + "enabled": true // Request emission for this run; MXC-owned user consent + // and a permitting administrative policy are also required + }, + "experimental": { // Experimental features (requires --experimental) "wslc": { // WSL Container settings "image": "alpine:latest", // Container image name @@ -210,9 +215,6 @@ cannot mix both formats in one request. "portMappings": [ // Host<->container port forwarding. TCP only -- the WSLC SDK runtime returns E_NOTIMPL for UDP, so the parser hard-rejects "udp" entries with a clear message. { "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" } ] - }, - "telemetry": { // Telemetry (Windows only) - "enabled": true // Emit TraceLogging ETW events via pure Rust tracelogging crate } } } diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index ea59e3a1b..7db7618ff 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -31,14 +31,11 @@ C++ shim, WIL, or 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 @@ -183,9 +180,9 @@ Emitted on execution errors. | `__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. +> 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. ### Correlating a lifecycle diff --git a/schemas/dev/mxc-config.schema.0.9.0-alpha.json b/schemas/dev/mxc-config.schema.0.9.0-alpha.json index 84b796cc2..0abca8741 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-alpha.json +++ b/schemas/dev/mxc-config.schema.0.9.0-alpha.json @@ -194,12 +194,6 @@ "DeprovisionExperimental": { "additionalProperties": false, "description": "Experimental settings accepted by the `deprovision` phase.", - "properties": { - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." - } - }, "type": "object" }, "DeprovisionPhase": { @@ -219,10 +213,6 @@ "_comment": { "description": "Optional human-readable annotation ignored by the runtime." }, - "correlationVector": { - "description": "Optional correlation vector relayed from provision.", - "type": "string" - }, "experimental": { "$ref": "#/definitions/DeprovisionExperimental", "description": "Optional closed post-provision experimental settings." @@ -235,6 +225,10 @@ "description": "Identifier of the sandbox to deprovision.", "type": "string" }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." @@ -250,12 +244,6 @@ "ExecExperimental": { "additionalProperties": false, "description": "Experimental settings accepted by the `exec` phase.", - "properties": { - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." - } - }, "type": "object" }, "ExecPhase": { @@ -275,10 +263,6 @@ "_comment": { "description": "Optional human-readable annotation ignored by the runtime." }, - "correlationVector": { - "description": "Optional correlation vector relayed from provision.", - "type": "string" - }, "experimental": { "$ref": "#/definitions/ExecExperimental", "description": "Optional closed exec experimental settings." @@ -299,6 +283,10 @@ "description": "Identifier of the sandbox to execute in.", "type": "string" }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." @@ -400,10 +388,6 @@ "isolation_session": { "$ref": "#/definitions/StateAwareIsolationSession", "description": "Optional IsolationSession backend settings." - }, - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." } }, "type": "object" @@ -435,6 +419,10 @@ "$ref": "#/definitions/ProvisionPhase", "description": "Exact `provision` phase marker." }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." @@ -876,10 +864,6 @@ "additionalProperties": false, "description": "Experimental settings.", "properties": { - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." - }, "test": { "$ref": "#/definitions/TestFeature", "description": "Optional placeholder test feature." @@ -980,6 +964,10 @@ "$ref": "#/definitions/Seatbelt", "description": "Optional macOS Seatbelt configuration." }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "ui": { "$ref": "#/definitions/Ui", "description": "Optional cross-platform user-interface policy." @@ -1280,12 +1268,6 @@ "StartExperimental": { "additionalProperties": false, "description": "Experimental settings accepted by the `start` phase.", - "properties": { - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." - } - }, "type": "object" }, "StartPhase": { @@ -1305,10 +1287,6 @@ "_comment": { "description": "Optional human-readable annotation ignored by the runtime." }, - "correlationVector": { - "description": "Optional correlation vector relayed from provision.", - "type": "string" - }, "experimental": { "$ref": "#/definitions/StartExperimental", "description": "Optional closed post-provision experimental settings." @@ -1321,6 +1299,10 @@ "description": "Identifier returned by the provision phase.", "type": "string" }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." @@ -1358,12 +1340,6 @@ "StopExperimental": { "additionalProperties": false, "description": "Experimental settings accepted by the `stop` phase.", - "properties": { - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." - } - }, "type": "object" }, "StopPhase": { @@ -1383,10 +1359,6 @@ "_comment": { "description": "Optional human-readable annotation ignored by the runtime." }, - "correlationVector": { - "description": "Optional correlation vector relayed from provision.", - "type": "string" - }, "experimental": { "$ref": "#/definitions/StopExperimental", "description": "Optional closed post-provision experimental settings." @@ -1399,6 +1371,10 @@ "description": "Identifier returned by the provision phase.", "type": "string" }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." @@ -1413,7 +1389,7 @@ }, "Telemetry": { "additionalProperties": false, - "description": "One-shot telemetry override.", + "description": "Telemetry settings.", "properties": { "enabled": { "description": "Whether telemetry is enabled.", @@ -1521,12 +1497,6 @@ "WindowsSandboxExperimental": { "additionalProperties": false, "description": "Experimental settings accepted by a Windows Sandbox provision request.", - "properties": { - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." - } - }, "type": "object" }, "WindowsSandboxProvisionRequest": { @@ -1556,6 +1526,10 @@ "$ref": "#/definitions/ProvisionPhase", "description": "Exact `provision` phase marker." }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." @@ -1593,10 +1567,6 @@ "additionalProperties": false, "description": "Experimental settings accepted by a WSLC provision request.", "properties": { - "telemetry": { - "$ref": "#/definitions/Telemetry", - "description": "Optional telemetry override." - }, "wslc": { "$ref": "#/definitions/StateAwareWslc", "description": "Optional WSLC backend settings." @@ -1635,6 +1605,10 @@ "$ref": "#/definitions/ProvisionPhase", "description": "Exact `provision` phase marker." }, + "telemetry": { + "$ref": "#/definitions/Telemetry", + "description": "Optional telemetry configuration." + }, "version": { "$ref": "#/definitions/Version", "description": "Exact development contract version." diff --git a/schemas/dev/mxc-config.schema.0.9.0-dev.json b/schemas/dev/mxc-config.schema.0.9.0-dev.json index a3a3fa3a4..abd0a78d6 100644 --- a/schemas/dev/mxc-config.schema.0.9.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.9.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": [ { @@ -961,10 +950,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" @@ -1196,13 +1186,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": [ { @@ -1331,6 +1314,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/sdk/node/README.md b/sdk/node/README.md index c2c046766..d01f9cae9 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -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/generated/v0_9_0_alpha/wire.ts b/sdk/node/src/generated/v0_9_0_alpha/wire.ts index 842f81921..4900947d8 100644 --- a/sdk/node/src/generated/v0_9_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_9_0_alpha/wire.ts @@ -38,10 +38,6 @@ export type DefaultNetworkPolicy = "allow" | "block"; * Experimental settings accepted by the `deprovision` phase. */ export interface DeprovisionExperimental { - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; } export type DeprovisionPhase = "deprovision"; @@ -58,10 +54,6 @@ export interface DeprovisionRequest { * Optional human-readable annotation ignored by the runtime. */ _comment?: unknown; - /** - * Optional correlation vector relayed from provision. - */ - correlationVector?: string; /** * Optional closed post-provision experimental settings. */ @@ -74,6 +66,10 @@ export interface DeprovisionRequest { * Identifier of the sandbox to deprovision. */ sandboxId: string; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Exact development contract version. */ @@ -84,10 +80,6 @@ export interface DeprovisionRequest { * Experimental settings accepted by the `exec` phase. */ export interface ExecExperimental { - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; } export type ExecPhase = "exec"; @@ -104,10 +96,6 @@ export interface ExecRequest { * Optional human-readable annotation ignored by the runtime. */ _comment?: unknown; - /** - * Optional correlation vector relayed from provision. - */ - correlationVector?: string; /** * Optional closed exec experimental settings. */ @@ -128,6 +116,10 @@ export interface ExecRequest { * Identifier of the sandbox to execute in. */ sandboxId: string; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Exact development contract version. */ @@ -198,10 +190,6 @@ export interface IsolationSessionProvisionExperimental { * Optional IsolationSession backend settings. */ isolation_session?: StateAwareIsolationSession; - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; } /** @@ -232,6 +220,10 @@ export interface IsolationSessionProvisionRequest { * Exact `provision` phase marker. */ phase: ProvisionPhase; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Exact development contract version. */ @@ -403,10 +395,6 @@ export type OneShotContainment = "process" | "processcontainer" | "appcontainer" * Experimental settings. */ export interface OneShotExperimental { - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; /** * Optional placeholder test feature. */ @@ -489,6 +477,10 @@ export type OneShotRequest = { * Optional macOS Seatbelt configuration. */ seatbelt?: Seatbelt; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Optional cross-platform user-interface policy. */ @@ -707,10 +699,6 @@ export interface Seatbelt { * Experimental settings accepted by the `start` phase. */ export interface StartExperimental { - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; } export type StartPhase = "start"; @@ -727,10 +715,6 @@ export interface StartRequest { * Optional human-readable annotation ignored by the runtime. */ _comment?: unknown; - /** - * Optional correlation vector relayed from provision. - */ - correlationVector?: string; /** * Optional closed post-provision experimental settings. */ @@ -743,6 +727,10 @@ export interface StartRequest { * Identifier returned by the provision phase. */ sandboxId: string; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Exact development contract version. */ @@ -773,10 +761,6 @@ export interface StateAwareWslc { * Experimental settings accepted by the `stop` phase. */ export interface StopExperimental { - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; } export type StopPhase = "stop"; @@ -793,10 +777,6 @@ export interface StopRequest { * Optional human-readable annotation ignored by the runtime. */ _comment?: unknown; - /** - * Optional correlation vector relayed from provision. - */ - correlationVector?: string; /** * Optional closed post-provision experimental settings. */ @@ -809,6 +789,10 @@ export interface StopRequest { * Identifier returned by the provision phase. */ sandboxId: string; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Exact development contract version. */ @@ -816,7 +800,7 @@ export interface StopRequest { } /** - * One-shot telemetry override. + * Telemetry settings. */ export interface Telemetry { /** @@ -867,10 +851,6 @@ export type WindowsSandboxContainment = "windows_sandbox"; * Experimental settings accepted by a Windows Sandbox provision request. */ export interface WindowsSandboxExperimental { - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; } /** @@ -901,6 +881,10 @@ export interface WindowsSandboxProvisionRequest { * Exact `provision` phase marker. */ phase: ProvisionPhase; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Exact development contract version. */ @@ -927,10 +911,6 @@ export interface WslcProvision { * Experimental settings accepted by a WSLC provision request. */ export interface WslcProvisionExperimental { - /** - * Optional telemetry override. - */ - telemetry?: Telemetry; /** * Optional WSLC backend settings. */ @@ -969,6 +949,10 @@ export interface WslcProvisionRequest { * Exact `provision` phase marker. */ phase: ProvisionPhase; + /** + * Optional telemetry configuration. + */ + telemetry?: Telemetry; /** * Exact development contract version. */ diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 811d621cc..9d7c221cd 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -83,10 +83,6 @@ export interface Experimental { * Seatbelt backend config (pre-promotion alias). */ seatbelt?: Seatbelt | null; - /** - * Telemetry configuration. - */ - telemetry?: Telemetry | null; /** * Placeholder feature for testing experimental infrastructure. */ @@ -470,14 +466,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; } /** @@ -618,10 +613,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. */ @@ -670,6 +661,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 0094845a5..b71577791 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -481,17 +481,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..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,11 +17,12 @@ 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 // nested under `experimental..`. -export const CROSS_CUTTING_FIELDS = ['filesystem', 'network', 'ui', 'process'] as const; +export const CROSS_CUTTING_FIELDS = ['filesystem', 'network', 'ui', 'process', 'telemetry'] as const; // Per-backend wire-format prefix. Each value mirrors the corresponding // Rust `Runner::ID_PREFIX` const and is the leading segment of a @@ -84,23 +86,33 @@ export interface BuildEnvelopeArgs { backendKey: StateAwareContainmentBackend; containment?: StateAwareContainmentBackend; // provision only sandboxId?: string; // non-provision only - correlationVector?: string; // non-provision relay (from provision) config?: Record; } /** * Constructs the wire-format JSON-shaped envelope for a state-aware request * from a per-(backend, phase) Config. Lifts cross-cutting fields - * (filesystem, network, ui, process) to envelope top-level; nests any + * (filesystem, network, ui, process, telemetry) to envelope top-level; nests any * 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 ?? {}) }; 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 }; @@ -110,12 +122,6 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record = export interface IsolationSessionProvisionConfig { /** Schema version (semver). When omitted, the SDK fills in its own SUPPORTED_VERSION. */ version?: string; + telemetry?: TelemetryConfig; /** * Optional identifier for the calling application. * @@ -76,22 +78,26 @@ export interface IsolationSessionProvisionConfig { export interface IsolationSessionStartConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } export interface IsolationSessionExecConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; process: ProcessConfig; } export interface IsolationSessionStopConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } export interface IsolationSessionDeprovisionConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } /** @@ -115,6 +121,7 @@ export interface IsolationSessionProvisionMetadata { export interface WindowsSandboxProvisionConfig { /** Schema version (semver). When omitted, the SDK fills in its own SUPPORTED_VERSION. */ version?: string; + telemetry?: TelemetryConfig; /** * Filesystem policy applied at provision and frozen for the life of the * sandbox. `readwritePaths` / `readonlyPaths` are mapped into the guest at @@ -129,22 +136,26 @@ export interface WindowsSandboxProvisionConfig { export interface WindowsSandboxStartConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } export interface WindowsSandboxExecConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; process: ProcessConfig; } export interface WindowsSandboxStopConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } export interface WindowsSandboxDeprovisionConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } // WSLc per-(backend, phase) Configs. WSLc runs each sandbox as a warm @@ -156,6 +167,7 @@ export interface WindowsSandboxDeprovisionConfig { export interface WslcProvisionConfig { /** Schema version (semver). When omitted, the SDK fills in `0.8.0-alpha`. */ version?: string; + telemetry?: TelemetryConfig; /** * Filesystem policy applied at provision and frozen for the life of the * sandbox. `readwritePaths` / `readonlyPaths` become container volume mounts @@ -191,11 +203,13 @@ export interface WslcProvisionConfig { export interface WslcStartConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } export interface WslcExecConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; process: ProcessConfig; /** * Per-exec network overrides. Only `proxy` is honored: it injects a @@ -213,11 +227,13 @@ export interface WslcExecConfig { export interface WslcStopConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } export interface WslcDeprovisionConfig { /** Schema version (semver). */ version?: string; + telemetry?: TelemetryConfig; } /** @@ -380,16 +396,6 @@ export type DeprovisionMetadataFor = 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 0d87a78ed..e2b5d3d8d 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -345,13 +345,11 @@ 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, - * `undefined` = off (default). + * Explicit telemetry opt-in. `true` requests telemetry subject to user + * consent and administrative policy; `false` or `undefined` keeps it off. */ enabled?: boolean; } @@ -390,12 +388,12 @@ export interface ContainerConfig { network?: NetworkConfig; /** Runtime values supplied separately from sandbox policy. */ runtimeConfig?: RuntimeConfig; + /** 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 f74727167..c9d364034 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -21,6 +21,33 @@ import { SandboxId } from '../../src/state-aware-types.js'; import { fakeSpawn, testOptions, platformSkip } from './test-helpers.js'; describe('buildStateAwareEnvelope', () => { + it('lifts telemetry to the top-level envelope', () => { + const env = buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'windows_sandbox', + sandboxId: 'wsb:01234567', + 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', @@ -130,24 +157,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', () => { @@ -279,19 +288,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"}}', @@ -339,13 +335,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 }, () => { @@ -372,13 +361,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 }, () => { @@ -392,14 +374,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 }, () => { @@ -458,17 +432,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-state-aware.test.ts b/sdk/node/tests/unit/wire-conformance-state-aware.test.ts index 0ea7488c1..ec9277638 100644 --- a/sdk/node/tests/unit/wire-conformance-state-aware.test.ts +++ b/sdk/node/tests/unit/wire-conformance-state-aware.test.ts @@ -81,11 +81,11 @@ type _Phase = AssertTrue>; // field legal only on provision cannot satisfy the oracle by appearing // on the start config, or vice versa. // -// `filesystem` is a lifted top-level wire field (like `network`): WSLc provision -// surfaces it publicly but it maps to the envelope's top-level `filesystem`, not -// under `experimental.wslc.provision`. Listing it here keeps the backend-key set -// limited to genuinely per-phase wire fields. -type LiftedPhaseKey = 'version' | 'process' | 'network' | 'filesystem'; +// `filesystem` and `telemetry` are lifted top-level wire fields (like `network`): +// phase configs surface them publicly but they map to the envelope top level, +// not under `experimental..`. Listing them here keeps the +// backend-key set limited to genuinely per-phase wire fields. +type LiftedPhaseKey = 'version' | 'process' | 'network' | 'filesystem' | 'telemetry'; type BackendKeys = Exclude; type WireKeys = keyof StripIndex; diff --git a/sdk/node/tests/unit/wire-conformance.test.ts b/sdk/node/tests/unit/wire-conformance.test.ts index 96bc3601f..cbb06dc70 100644 --- a/sdk/node/tests/unit/wire-conformance.test.ts +++ b/sdk/node/tests/unit/wire-conformance.test.ts @@ -253,14 +253,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 ff8076103..104f07376 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1552,6 +1552,7 @@ dependencies = [ "seatbelt_common", "serde", "serde_json", + "tempfile", "windows", "windows_sandbox_lifecycle", "wslc_common", @@ -3201,6 +3202,7 @@ version = "0.8.0" dependencies = [ "base64", "cidr", + "filetime", "getrandom 0.2.17", "libc", "mxc_config_contract", diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index ae71c9f7a..ea26499e9 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -230,23 +230,22 @@ 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); + 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(); } @@ -267,9 +266,10 @@ fn main() { eprintln!("error: {}", e.message); emit_warnings(&logger); 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); @@ -287,10 +287,11 @@ fn main() { display_script_results(&response, &mut logger); - // ── Telemetry emit (experimental) ─────────────────────────────── - telemetry::emit_completion( + // ── Telemetry emit ────────────────────────────────────────────── + telemetry::emit_completion_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, &response, run_elapsed, ); diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index e5679fc4b..0d8755d6a 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -260,9 +260,10 @@ 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 warnings detected while spawning the sandbox — - security warnings such as `permissiveLearningMode` weakening deny-by-default, - and warnings such as a network rule that installs but cannot carry traffic. +- `warnings()` returns policy and operational warnings detected while spawning + the sandbox, such as `permissiveLearningMode` weakening deny-by-default, a + network rule that installs but cannot carry traffic, 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 @@ -277,7 +278,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 @@ -313,7 +314,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 2de423421..16af6f428 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -129,9 +129,11 @@ //! //! [`exec_attached`] is verified against IsolationSession only. //! -//! Policy warnings — security warnings, and warnings such as a network rule -//! that installs but cannot carry traffic — 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. +//! These include security warnings, network rules that cannot carry traffic, +//! and operational warnings such as unavailable telemetry routing. //! //! ## Relationship to `mxc_engine` //! diff --git a/src/core/mxc-sdk/src/sandbox.rs b/src/core/mxc-sdk/src/sandbox.rs index 2f098ef92..ae6a377a8 100644 --- a/src/core/mxc-sdk/src/sandbox.rs +++ b/src/core/mxc-sdk/src/sandbox.rs @@ -51,8 +51,8 @@ pub enum WaitOutcome { pub struct Output { /// How the process finished. pub outcome: WaitOutcome, - /// Warnings emitted while applying the sandbox policy — security warnings, - /// and policy warnings such as a network rule that cannot carry traffic. + /// Policy and operational warnings emitted while starting the sandbox, + /// including security warnings and network rules that cannot carry traffic. pub warnings: Vec, /// Everything the child wrote to stdout. pub stdout: Vec, @@ -77,8 +77,8 @@ impl Sandbox { Self { inner } } - /// Warnings emitted while applying the sandbox policy — security warnings, - /// and policy warnings such as a network rule that cannot carry traffic. + /// Policy and operational warnings emitted while starting the sandbox, + /// including security warnings and network rules that cannot carry traffic. pub fn warnings(&self) -> &[String] { self.inner.warnings() } diff --git a/src/core/mxc_config_contract/src/dev/experimental.rs b/src/core/mxc_config_contract/src/dev/experimental.rs index 656019f53..0f6875d80 100644 --- a/src/core/mxc_config_contract/src/dev/experimental.rs +++ b/src/core/mxc_config_contract/src/dev/experimental.rs @@ -14,16 +14,6 @@ pub struct TestFeature { pub message: OptionalField, } -/// One-shot telemetry override. -#[derive(Debug, serde::Deserialize)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -#[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)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -108,7 +98,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 cacf3e9c1..031319de3 100644 --- a/src/core/mxc_config_contract/src/dev/mod.rs +++ b/src/core/mxc_config_contract/src/dev/mod.rs @@ -231,7 +231,7 @@ mod stable; mod state_aware; pub use experimental::{ - OneShotExperimental, OneShotWindowsSandbox, OneShotWslc, PortMapping, Telemetry, TestFeature, + OneShotExperimental, OneShotWindowsSandbox, OneShotWslc, PortMapping, TestFeature, TransportProtocol, }; pub use network::{ @@ -246,7 +246,8 @@ pub use schema::development_schema; pub use stable::{ CaptureDenials, CaptureDenialsMode, Fallback, Filesystem, LaunchMethod, Lifecycle, Lxc, Process, ProcessContainer, ProcessContainerCapability, ProcessContainerNetwork, - ProcessContainerUi, ProcessContainerUiIsolation, RuntimeConfig, Seatbelt, Ui, UiClipboard, + ProcessContainerUi, ProcessContainerUiIsolation, RuntimeConfig, Seatbelt, Telemetry, Ui, + UiClipboard, }; pub use state_aware::{probe_containment, Containment, ContainmentProbeError}; pub use state_aware::{probe_phase, Phase, PhaseProbeError}; 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 0d683bc23..593b54d41 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,8 @@ use super::experimental::OneShotExperimental; use super::network::Network; use super::primitives::OptionalField; use super::stable::{ - Fallback, Filesystem, Lifecycle, Lxc, Process, ProcessContainer, RuntimeConfig, Seatbelt, Ui, + Fallback, Filesystem, Lifecycle, Lxc, Process, ProcessContainer, RuntimeConfig, Seatbelt, + Telemetry, Ui, }; use crate::dev::Version; @@ -91,6 +92,9 @@ pub struct Request { /// Optional runtime configuration settings. #[serde(default)] pub runtime_config: OptionalField, + /// Optional telemetry configuration. + #[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 8f03e7db3..fc32c3232 100644 --- a/src/core/mxc_config_contract/src/dev/stable.rs +++ b/src/core/mxc_config_contract/src/dev/stable.rs @@ -17,6 +17,16 @@ pub struct Lifecycle { pub preserve_policy: OptionalField, } +/// Telemetry settings. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[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)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] diff --git a/src/core/mxc_config_contract/src/dev/state_aware/deprovision.rs b/src/core/mxc_config_contract/src/dev/state_aware/deprovision.rs index 08ab76200..a20621dc9 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/deprovision.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/deprovision.rs @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::dev::experimental::Telemetry; -use crate::dev::{OptionalField, Version}; +use crate::dev::{OptionalField, Telemetry, Version}; use serde::Deserialize; string_marker! { @@ -14,11 +13,7 @@ string_marker! { #[derive(Debug, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct DeprovisionExperimental { - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, -} +pub struct DeprovisionExperimental {} /// A complete state-aware `deprovision` request. #[derive(Debug, Deserialize)] @@ -38,9 +33,9 @@ pub struct DeprovisionRequest { /// Identifier of the sandbox to deprovision. pub sandbox_id: String, - /// Optional correlation vector relayed from provision. + /// Optional telemetry configuration. #[serde(default)] - pub correlation_vector: OptionalField, + pub telemetry: OptionalField, /// Optional closed post-provision experimental settings. #[serde(default)] diff --git a/src/core/mxc_config_contract/src/dev/state_aware/exec.rs b/src/core/mxc_config_contract/src/dev/state_aware/exec.rs index 4b1fba4e0..bfd0ad9fc 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/exec.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/exec.rs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::dev::experimental::Telemetry; -use crate::dev::{Network, Process}; -use crate::dev::{OptionalField, Version}; +use crate::dev::{Network, OptionalField, Process, Telemetry, Version}; use serde::Deserialize; string_marker! { @@ -15,11 +13,7 @@ string_marker! { #[derive(Debug, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExecExperimental { - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, -} +pub struct ExecExperimental {} /// A complete state-aware `exec` request. #[derive(Debug, Deserialize)] @@ -39,10 +33,6 @@ pub struct ExecRequest { /// Identifier of the sandbox to execute in. pub sandbox_id: String, - /// Optional correlation vector relayed from provision. - #[serde(default)] - pub correlation_vector: OptionalField, - /// Process to execute in the sandbox. pub process: Process, @@ -50,6 +40,10 @@ pub struct ExecRequest { #[serde(default)] pub network: OptionalField, + /// Optional telemetry configuration. + #[serde(default)] + pub telemetry: OptionalField, + /// Optional closed exec experimental settings. #[serde(default)] pub experimental: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/state_aware/provision/isolation_session.rs b/src/core/mxc_config_contract/src/dev/state_aware/provision/isolation_session.rs index 08cd513c3..e301f72f4 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/provision/isolation_session.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/provision/isolation_session.rs @@ -44,9 +44,6 @@ pub struct IsolationSessionProvisionExperimental { /// Optional IsolationSession backend settings. #[serde(rename = "isolation_session", default)] pub isolation_session: OptionalField, - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, } /// The exact unrestricted-network acknowledgment required when provisioning an IsolationSession. @@ -79,6 +76,9 @@ pub struct IsolationSessionProvisionRequest { pub containment: IsolationSessionContainment, /// Required unrestricted-network acknowledgment. pub network: IsolationSessionNetwork, + /// Optional telemetry configuration. + #[serde(default)] + pub telemetry: OptionalField, /// Optional closed experimental settings. #[serde(default)] pub experimental: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/state_aware/provision/windows_sandbox.rs b/src/core/mxc_config_contract/src/dev/state_aware/provision/windows_sandbox.rs index cbbfd1832..62308d546 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/provision/windows_sandbox.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/provision/windows_sandbox.rs @@ -15,11 +15,7 @@ string_marker! { #[derive(Debug, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct WindowsSandboxExperimental { - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, -} +pub struct WindowsSandboxExperimental {} /// A complete state-aware `provision` request for windows_sandbox #[derive(Debug, Deserialize)] @@ -41,6 +37,9 @@ pub struct WindowsSandboxProvisionRequest { /// Optional filesystem policy. #[serde(default)] pub filesystem: OptionalField, + /// Optional telemetry configuration. + #[serde(default)] + pub telemetry: OptionalField, /// Optional closed experimental settings. #[serde(default)] pub experimental: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs b/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs index e53730962..b6634e0fe 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/provision/wslc.rs @@ -42,9 +42,6 @@ pub struct WslcProvisionExperimental { /// Optional WSLC backend settings. #[serde(default)] pub wslc: OptionalField, - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, } /// A complete state-aware `provision` request for wslc @@ -70,6 +67,9 @@ pub struct WslcProvisionRequest { /// Optional network policy fixed at provision time. #[serde(default)] pub network: OptionalField, + /// Optional telemetry configuration. + #[serde(default)] + pub telemetry: OptionalField, /// Optional closed experimental settings. #[serde(default)] pub experimental: OptionalField, diff --git a/src/core/mxc_config_contract/src/dev/state_aware/start.rs b/src/core/mxc_config_contract/src/dev/state_aware/start.rs index 69637cbf2..6f8b53ab9 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/start.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/start.rs @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::dev::experimental::Telemetry; -use crate::dev::{OptionalField, Version}; +use crate::dev::{OptionalField, Telemetry, Version}; use serde::Deserialize; string_marker! { @@ -14,11 +13,7 @@ string_marker! { #[derive(Debug, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct StartExperimental { - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, -} +pub struct StartExperimental {} /// A complete state-aware `start` request. #[derive(Debug, Deserialize)] @@ -38,9 +33,9 @@ pub struct StartRequest { /// Identifier returned by the provision phase. pub sandbox_id: String, - /// Optional correlation vector relayed from provision. + /// Optional telemetry configuration. #[serde(default)] - pub correlation_vector: OptionalField, + pub telemetry: OptionalField, /// Optional closed post-provision experimental settings. #[serde(default)] diff --git a/src/core/mxc_config_contract/src/dev/state_aware/stop.rs b/src/core/mxc_config_contract/src/dev/state_aware/stop.rs index e13b0fde1..abe6c4dd8 100644 --- a/src/core/mxc_config_contract/src/dev/state_aware/stop.rs +++ b/src/core/mxc_config_contract/src/dev/state_aware/stop.rs @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::dev::experimental::Telemetry; -use crate::dev::{OptionalField, Version}; +use crate::dev::{OptionalField, Telemetry, Version}; use serde::Deserialize; string_marker! { @@ -14,11 +13,7 @@ string_marker! { #[derive(Debug, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct StopExperimental { - /// Optional telemetry override. - #[serde(default)] - pub telemetry: OptionalField, -} +pub struct StopExperimental {} /// A complete state-aware `stop` request. #[derive(Debug, Deserialize)] @@ -38,9 +33,9 @@ pub struct StopRequest { /// Identifier returned by the provision phase. pub sandbox_id: String, - /// Optional correlation vector relayed from provision. + /// Optional telemetry configuration. #[serde(default)] - pub correlation_vector: OptionalField, + pub telemetry: OptionalField, /// Optional closed post-provision experimental settings. #[serde(default)] diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/test_and_telemetry.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/test_and_telemetry.rs index 4cdebe290..1743d5b0f 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/experimental/test_and_telemetry.rs +++ b/src/core/mxc_config_contract/tests/v0_9_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.9.0-alpha", - "experimental": {"telemetry": {}}, + "telemetry": {}, "process": {"commandLine": "echo"} }"#; @@ -105,10 +105,8 @@ fn accepts_telemetry_enabled_values() { let json = format!( r#"{{ "version": "0.9.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.9.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.9.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/v0_9_0_alpha/optional_fields.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs index ca482c495..40c868f7f 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/optional_fields.rs @@ -322,14 +322,14 @@ fn rejects_null_optional_fields() { r#""experimental": {"test": {"message": null}}"#, ), ( - "experimental.telemetry", + "telemetry", version_and_process.as_str(), - r#""experimental": {"telemetry": null}"#, + r#""telemetry": null"#, ), ( - "experimental.telemetry.enabled", + "telemetry.enabled", version_and_process.as_str(), - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": {"enabled": null}"#, ), ( "experimental.windows_sandbox", diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/deprovision.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/deprovision.rs index 1a60e71cc..c36ff282f 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/deprovision.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/deprovision.rs @@ -43,12 +43,9 @@ fn accepts_deprovision_request_with_optional_fields() { "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "test123456", - "correlationVector": "test-correlation-vector", - "experimental": { - "telemetry": { + "telemetry": { "enabled": true } - } }"#; assert_valid(json); } @@ -67,9 +64,7 @@ fn accepts_empty_deprovision_experimental_objects() { "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "test123456", - "experimental": { - "telemetry": {} - } + "telemetry": {} }"#; assert_valid(json); } @@ -82,10 +77,8 @@ fn accepts_deprovision_telemetry_enabled_values() { "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "test123456", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -218,10 +211,8 @@ fn rejects_non_boolean_experimental_telemetry_enabled_field() { "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "test123456", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -235,8 +226,8 @@ fn rejects_null_optional_fields() { r#""$schema": null"#, r#""correlationVector": null"#, r#""experimental": null"#, - r#""experimental": {"telemetry": null}"#, - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": null"#, + r#""telemetry": {"enabled": null}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -272,11 +263,9 @@ fn rejects_unknown_deprovision_experimental_telemetry_fields() { "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "test123456", - "experimental": { - "telemetry": { + "telemetry": { "unknownField": "value" } - } }"#; assert_invalid(json); } @@ -394,8 +383,7 @@ fn rejects_invalid_experimental_object_types() { let json = request_with_additional_fields(&format!(r#""experimental": {value}"#)); assert_invalid(&json); - let json = - request_with_additional_fields(&format!(r#""experimental": {{"telemetry": {value}}}"#)); + let json = request_with_additional_fields(&format!(r#""telemetry": {value}"#)); assert_invalid(&json); } } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/exec.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/exec.rs index 08be91176..f55ea6181 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/exec.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/exec.rs @@ -45,18 +45,15 @@ fn accepts_exec_request_with_optional_fields() { "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "test123456", - "correlationVector": "test-correlation-vector", "process": {"commandLine": "echo"}, "network": { "proxy": { "url": "http://proxy.example" } }, - "experimental": { - "telemetry": { + "telemetry": { "enabled": true } - } }"#; assert_valid(json); } @@ -66,7 +63,7 @@ fn accepts_empty_exec_optional_objects() { for field in [ r#""network": {}"#, r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, + r#""telemetry": {}"#, ] { assert_valid(&request_with_additional_fields(field)); } @@ -81,10 +78,8 @@ fn accepts_exec_telemetry_enabled_values() { "phase": "exec", "sandboxId": "test123456", "process": {{"commandLine": "echo"}}, - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -246,10 +241,8 @@ fn rejects_non_boolean_experimental_telemetry_enabled_field() { "phase": "exec", "sandboxId": "test123456", "process": {{"commandLine": "echo"}}, - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -264,8 +257,8 @@ fn rejects_null_optional_fields() { r#""correlationVector": null"#, r#""network": null"#, r#""experimental": null"#, - r#""experimental": {"telemetry": null}"#, - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": null"#, + r#""telemetry": {"enabled": null}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -304,11 +297,9 @@ fn rejects_unknown_exec_experimental_telemetry_fields() { "phase": "exec", "sandboxId": "test123456", "process": {"commandLine": "echo"}, - "experimental": { - "telemetry": { + "telemetry": { "unknownField": "value" } - } }"#; assert_invalid(json); } @@ -450,8 +441,7 @@ fn rejects_invalid_experimental_object_types() { let json = request_with_additional_fields(&format!(r#""experimental": {value}"#)); assert_invalid(&json); - let json = - request_with_additional_fields(&format!(r#""experimental": {{"telemetry": {value}}}"#)); + let json = request_with_additional_fields(&format!(r#""telemetry": {value}"#)); assert_invalid(&json); } } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/isolation_session.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/isolation_session.rs index d7a11a48c..a0a39700a 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/isolation_session.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/isolation_session.rs @@ -80,13 +80,13 @@ fn accepts_provision_request_with_optional_fields() { "defaultPolicy": "allow", "allowLocalNetwork": true }, + "telemetry": { + "enabled": true + }, "experimental": { - "telemetry": { - "enabled": true - }, "isolation_session": { "provision": { - "appId": "someAppId" + "appId": "someAppId" } } } @@ -98,7 +98,7 @@ fn accepts_provision_request_with_optional_fields() { fn accepts_empty_provision_experimental_objects() { for field in [ r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, + r#""telemetry": {}"#, r#""experimental": {"isolation_session": {}}"#, r#""experimental": {"isolation_session": {"provision": {}}}"#, ] { @@ -118,10 +118,8 @@ fn accepts_provision_telemetry_enabled_values() { "defaultPolicy": "allow", "allowLocalNetwork": true }}, - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -339,10 +337,8 @@ fn rejects_non_boolean_experimental_telemetry_enabled_field() { "defaultPolicy": "allow", "allowLocalNetwork": true }}, - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -355,8 +351,8 @@ fn rejects_null_optional_fields() { for field in [ r#""$schema": null"#, r#""experimental": null"#, - r#""experimental": {"telemetry": null}"#, - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": null"#, + r#""telemetry": {"enabled": null}"#, r#""experimental": {"isolation_session": null }"#, r#""experimental": {"isolation_session": {"provision": null }}"#, r#""experimental": {"isolation_session": {"provision": {"appId": null }}}"#, @@ -407,11 +403,9 @@ fn rejects_unknown_provision_experimental_telemetry_fields() { "defaultPolicy": "allow", "allowLocalNetwork": true }, - "experimental": { - "telemetry": { + "telemetry": { "unknownField": "unknown" } - } }"#; assert_invalid(json); } @@ -618,8 +612,7 @@ fn rejects_invalid_experimental_object_types() { let json = request_with_additional_fields(&format!(r#""experimental": {value}"#)); assert_invalid(&json); - let json = - request_with_additional_fields(&format!(r#""experimental": {{"telemetry": {value}}}"#)); + let json = request_with_additional_fields(&format!(r#""telemetry": {value}"#)); assert_invalid(&json); let json = request_with_additional_fields(&format!( diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/windows_sandbox.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/windows_sandbox.rs index 3ebd80b5d..7f5106c37 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/windows_sandbox.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/windows_sandbox.rs @@ -60,11 +60,9 @@ fn accepts_provision_request_with_optional_fields() { "readonlyPaths": ["C:\\ro"], "deniedPaths": ["C:\\denied"] }, - "experimental": { - "telemetry": { + "telemetry": { "enabled": true } - } }"#, ); } @@ -74,7 +72,7 @@ fn accepts_empty_optional_objects() { for field in [ r#""filesystem": {}"#, r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, + r#""telemetry": {}"#, ] { assert_valid(&request_with_additional_fields(field)); } @@ -84,7 +82,7 @@ fn accepts_empty_optional_objects() { fn accepts_telemetry_enabled_values() { for enabled in [true, false] { assert_valid(&request_with_additional_fields(&format!( - r#""experimental": {{"telemetry": {{"enabled": {enabled}}}}}"# + r#""telemetry": {{"enabled": {enabled}}}"# ))); } } @@ -184,8 +182,8 @@ fn rejects_null_optional_fields() { r#""filesystem": {"readonlyPaths": null}"#, r#""filesystem": {"deniedPaths": null}"#, r#""experimental": null"#, - r#""experimental": {"telemetry": null}"#, - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": null"#, + r#""telemetry": {"enabled": null}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -197,7 +195,7 @@ fn rejects_unknown_fields_at_each_object_level() { r#""unknownField": true"#, r#""filesystem": {"unknownField": true}"#, r#""experimental": {"unknownField": true}"#, - r#""experimental": {"telemetry": {"unknownField": true}}"#, + r#""telemetry": {"unknownField": true}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -261,8 +259,8 @@ fn rejects_duplicate_nested_fields() { r#""filesystem": {"readwritePaths": [], "readwritePaths": []}"#, r#""filesystem": {"readonlyPaths": [], "readonlyPaths": []}"#, r#""filesystem": {"deniedPaths": [], "deniedPaths": []}"#, - r#""experimental": {"telemetry": {}, "telemetry": {}}"#, - r#""experimental": {"telemetry": {"enabled": true, "enabled": false}}"#, + r#""telemetry": {}, "telemetry": {}"#, + r#""telemetry": {"enabled": true, "enabled": false}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -316,7 +314,7 @@ fn rejects_invalid_optional_object_types() { for field in [ format!(r#""filesystem": {value}"#), format!(r#""experimental": {value}"#), - format!(r#""experimental": {{"telemetry": {value}}}"#), + format!(r#""telemetry": {value}"#), ] { assert_invalid(&request_with_additional_fields(&field)); } @@ -344,7 +342,7 @@ fn rejects_invalid_filesystem_field_types() { fn rejects_non_boolean_telemetry_enabled() { for value in ["123", r#""true""#, "[]", "{}"] { assert_invalid(&request_with_additional_fields(&format!( - r#""experimental": {{"telemetry": {{"enabled": {value}}}}}"# + r#""telemetry": {{"enabled": {value}}}"# ))); } } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs index 788366f6e..af2a3add8 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/provision/wslc.rs @@ -63,10 +63,10 @@ fn accepts_provision_request_with_optional_fields() { "network": { "defaultPolicy": "block" }, + "telemetry": { + "enabled": true + }, "experimental": { - "telemetry": { - "enabled": true - }, "wslc": { "provision": { "image": "alpine:latest", @@ -84,7 +84,7 @@ fn accepts_empty_optional_objects() { r#""filesystem": {}"#, r#""network": {}"#, r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, + r#""telemetry": {}"#, r#""experimental": {"wslc": {}}"#, r#""experimental": {"wslc": {"provision": {}}}"#, ] { @@ -96,7 +96,7 @@ fn accepts_empty_optional_objects() { fn accepts_telemetry_enabled_values() { for enabled in [true, false] { assert_valid(&request_with_additional_fields(&format!( - r#""experimental": {{"telemetry": {{"enabled": {enabled}}}}}"# + r#""telemetry": {{"enabled": {enabled}}}"# ))); } } @@ -212,8 +212,8 @@ fn rejects_null_optional_fields() { r#""network": null"#, r#""network": {"defaultPolicy": null}"#, r#""experimental": null"#, - r#""experimental": {"telemetry": null}"#, - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": null"#, + r#""telemetry": {"enabled": null}"#, r#""experimental": {"wslc": null}"#, r#""experimental": {"wslc": {"provision": null}}"#, r#""experimental": {"wslc": {"provision": {"image": null}}}"#, @@ -230,7 +230,7 @@ fn rejects_unknown_fields_at_each_object_level() { r#""filesystem": {"unknownField": true}"#, r#""network": {"unknownField": true}"#, r#""experimental": {"unknownField": true}"#, - r#""experimental": {"telemetry": {"unknownField": true}}"#, + r#""telemetry": {"unknownField": true}"#, r#""experimental": {"wslc": {"unknownField": true}}"#, r#""experimental": {"wslc": {"provision": {"unknownField": true}}}"#, ] { @@ -303,7 +303,7 @@ fn rejects_duplicate_nested_fields() { for field in [ r#""filesystem": {"readwritePaths": [], "readwritePaths": []}"#, r#""network": {"defaultPolicy": "block", "defaultPolicy": "allow"}"#, - r#""experimental": {"telemetry": {}, "telemetry": {}}"#, + r#""telemetry": {}, "telemetry": {}"#, r#""experimental": {"wslc": {}, "wslc": {}}"#, r#""experimental": {"wslc": {"provision": {}, "provision": {}}}"#, r#""experimental": {"wslc": {"provision": {"image": "a", "image": "b"}}}"#, @@ -362,7 +362,7 @@ fn rejects_invalid_optional_object_types() { format!(r#""filesystem": {value}"#), format!(r#""network": {value}"#), format!(r#""experimental": {value}"#), - format!(r#""experimental": {{"telemetry": {value}}}"#), + format!(r#""telemetry": {value}"#), format!(r#""experimental": {{"wslc": {value}}}"#), format!(r#""experimental": {{"wslc": {{"provision": {value}}}}}"#), ] { @@ -419,7 +419,7 @@ fn rejects_non_string_provision_fields() { fn rejects_non_boolean_telemetry_enabled() { for value in ["123", r#""true""#, "[]", "{}"] { assert_invalid(&request_with_additional_fields(&format!( - r#""experimental": {{"telemetry": {{"enabled": {value}}}}}"# + r#""telemetry": {{"enabled": {value}}}"# ))); } } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/start.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/start.rs index 6791136dc..159114212 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/start.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/start.rs @@ -43,12 +43,9 @@ fn accepts_start_request_with_optional_fields() { "version": "0.9.0-alpha", "phase": "start", "sandboxId": "test123456", - "correlationVector": "test-correlation-vector", - "experimental": { - "telemetry": { + "telemetry": { "enabled": true } - } }"#; assert_valid(json); } @@ -67,9 +64,7 @@ fn accepts_empty_start_experimental_objects() { "version": "0.9.0-alpha", "phase": "start", "sandboxId": "test123456", - "experimental": { - "telemetry": {} - } + "telemetry": {} }"#; assert_valid(json); } @@ -82,10 +77,8 @@ fn accepts_start_telemetry_enabled_values() { "version": "0.9.0-alpha", "phase": "start", "sandboxId": "test123456", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -218,10 +211,8 @@ fn rejects_non_boolean_experimental_telemetry_enabled_field() { "version": "0.9.0-alpha", "phase": "start", "sandboxId": "test123456", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -235,8 +226,8 @@ fn rejects_null_optional_fields() { r#""$schema": null"#, r#""correlationVector": null"#, r#""experimental": null"#, - r#""experimental": {"telemetry": null}"#, - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": null"#, + r#""telemetry": {"enabled": null}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -272,11 +263,9 @@ fn rejects_unknown_start_experimental_telemetry_fields() { "version": "0.9.0-alpha", "phase": "start", "sandboxId": "test123456", - "experimental": { - "telemetry": { + "telemetry": { "unknownField": "value" } - } }"#; assert_invalid(json); } @@ -394,8 +383,7 @@ fn rejects_invalid_experimental_object_types() { let json = request_with_additional_fields(&format!(r#""experimental": {value}"#)); assert_invalid(&json); - let json = - request_with_additional_fields(&format!(r#""experimental": {{"telemetry": {value}}}"#)); + let json = request_with_additional_fields(&format!(r#""telemetry": {value}"#)); assert_invalid(&json); } } diff --git a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/stop.rs b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/stop.rs index c7fa9dd02..c2e60dee6 100644 --- a/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/stop.rs +++ b/src/core/mxc_config_contract/tests/v0_9_0_alpha/state_aware/stop.rs @@ -43,12 +43,9 @@ fn accepts_stop_request_with_optional_fields() { "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "test123456", - "correlationVector": "test-correlation-vector", - "experimental": { - "telemetry": { + "telemetry": { "enabled": true } - } }"#; assert_valid(json); } @@ -67,9 +64,7 @@ fn accepts_empty_stop_experimental_objects() { "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "test123456", - "experimental": { - "telemetry": {} - } + "telemetry": {} }"#; assert_valid(json); } @@ -82,10 +77,8 @@ fn accepts_stop_telemetry_enabled_values() { "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "test123456", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -218,10 +211,8 @@ fn rejects_non_boolean_experimental_telemetry_enabled_field() { "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "test123456", - "experimental": {{ - "telemetry": {{ - "enabled": {enabled} - }} + "telemetry": {{ + "enabled": {enabled} }} }}"# ); @@ -235,8 +226,8 @@ fn rejects_null_optional_fields() { r#""$schema": null"#, r#""correlationVector": null"#, r#""experimental": null"#, - r#""experimental": {"telemetry": null}"#, - r#""experimental": {"telemetry": {"enabled": null}}"#, + r#""telemetry": null"#, + r#""telemetry": {"enabled": null}"#, ] { assert_invalid(&request_with_additional_fields(field)); } @@ -272,11 +263,9 @@ fn rejects_unknown_stop_experimental_telemetry_fields() { "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "test123456", - "experimental": { - "telemetry": { + "telemetry": { "unknownField": "value" } - } }"#; assert_invalid(json); } @@ -394,8 +383,7 @@ fn rejects_invalid_experimental_object_types() { let json = request_with_additional_fields(&format!(r#""experimental": {value}"#)); assert_invalid(&json); - let json = - request_with_additional_fields(&format!(r#""experimental": {{"telemetry": {value}}}"#)); + let json = request_with_additional_fields(&format!(r#""telemetry": {value}"#)); assert_invalid(&json); } } 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 51a7fc038..baf53f0f1 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs @@ -15,14 +15,8 @@ fn experimental_test_is_introduced_in_v09() { } #[test] -fn experimental_telemetry_is_introduced_in_v09() { - assert_v09_introduces( - r#""experimental": { - "telemetry": { - "enabled": true - } - }"#, - ); +fn telemetry_is_introduced_in_v09() { + assert_v09_introduces(r#""telemetry": {"enabled": true}"#); } #[test] diff --git a/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs b/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs index f6844b01a..b0b30a84e 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/state_aware.rs @@ -56,9 +56,7 @@ const CASES: &[StateAwareCase] = &[ "readonlyPaths": ["C:\\inputs"], "deniedPaths": ["C:\\secrets"] }, - "experimental": { - "telemetry": {"enabled": true} - } + "telemetry": {"enabled": true} }"#, expected: ExpectedRoot::WindowsSandboxProvision, }, @@ -72,13 +70,13 @@ const CASES: &[StateAwareCase] = &[ "defaultPolicy": "allow", "allowLocalNetwork": true }, + "telemetry": {"enabled": false}, "experimental": { "isolation_session": { "provision": { "appId": "Contoso.Sample_1234567890abc" } - }, - "telemetry": {"enabled": false} + } } }"#, expected: ExpectedRoot::IsolationSessionProvision, @@ -100,14 +98,14 @@ const CASES: &[StateAwareCase] = &[ "allowedHosts": ["packages.example"], "allowLocalNetwork": false }, + "telemetry": {"enabled": true}, "experimental": { "wslc": { "provision": { "image": "ubuntu:24.04", "imageTarPath": "C:\\images\\ubuntu.tar" } - }, - "telemetry": {"enabled": true} + } } }"#, expected: ExpectedRoot::WslcProvision, @@ -119,10 +117,7 @@ const CASES: &[StateAwareCase] = &[ "version": "0.9.0-alpha", "phase": "start", "sandboxId": "wsb:1234abcd", - "correlationVector": "test.0", - "experimental": { - "telemetry": {"enabled": true} - } + "telemetry": {"enabled": true} }"#, expected: ExpectedRoot::Start, }, @@ -132,7 +127,6 @@ const CASES: &[StateAwareCase] = &[ "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "wslc:1234abcd", - "correlationVector": "test.1", "process": { "commandLine": "curl -sS https://example.com", "cwd": "/workspace", @@ -142,9 +136,7 @@ const CASES: &[StateAwareCase] = &[ "network": { "proxy": {"url": "http://proxy.example:8080"} }, - "experimental": { - "telemetry": {"enabled": false} - } + "telemetry": {"enabled": false} }"#, expected: ExpectedRoot::Exec, }, @@ -154,10 +146,7 @@ const CASES: &[StateAwareCase] = &[ "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "wsb:1234abcd", - "correlationVector": "test.2", - "experimental": { - "telemetry": {"enabled": true} - } + "telemetry": {"enabled": true} }"#, expected: ExpectedRoot::Stop, }, @@ -167,10 +156,7 @@ const CASES: &[StateAwareCase] = &[ "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "wsb:1234abcd", - "correlationVector": "test.3", - "experimental": { - "telemetry": {"enabled": false} - } + "telemetry": {"enabled": false} }"#, expected: ExpectedRoot::Deprovision, }, diff --git a/src/core/mxc_darwin/src/main.rs b/src/core/mxc_darwin/src/main.rs index e5f466500..254f7d5d6 100644 --- a/src/core/mxc_darwin/src/main.rs +++ b/src/core/mxc_darwin/src/main.rs @@ -127,27 +127,26 @@ 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); + 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(); } @@ -172,10 +171,11 @@ fn run_seatbelt(request: &ExecutionRequest, logger: &mut Logger) -> ! { display_script_results(&response, logger); - // ── Telemetry emit (experimental) ─────────────────────────────── - telemetry::emit_completion( + // ── Telemetry emit ────────────────────────────────────────────── + telemetry::emit_completion_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, &response, run_elapsed, ); 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 b1ff33291..b7d09acb3 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -61,7 +61,9 @@ pub use state_aware::{ }; 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, @@ -71,31 +73,445 @@ 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 mut telemetry_registration = TelemetryRegistration::new(telemetry_active); + 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_registration.transfer(), + &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_registration.active() { + Ok(Box::new(TelemetryProcess { inner: process, - warnings, + active: telemetry_registration.transfer(), + mode: TelemetryMode::OneShot { + containment, + requested_sandbox_kind, + }, + started, })) + } else { + Ok(process) + } +} + +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. +/// +/// # 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`] — 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`. +/// * [`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` +/// 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(), + )), + }; + 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 { + backend, + phase, + correlation_vector, + }, + &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 + /// 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::Dropped => ( + std::io::ErrorKind::Other, + "sandbox handle was dropped 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))); + } + + /// 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; + } +} + +/// 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 { + /// The wrapper was dropped without a preceding `wait` / successful `try_wait`. + Dropped, +} + +#[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, + } +} + +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, + // 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::TimedOut => self.emit(&Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "sandbox execution timed out", + ))), + DropDisposition::Abandoned => 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) => {} + // 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 + } + + fn id(&self) -> u32 { + self.inner.id() + } + + fn kill(&mut self) -> std::io::Result<()> { + // 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(); + if result.is_ok() { + self.emit_cancellation(); + } + 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 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 @@ -141,3 +557,197 @@ impl SandboxProcess for ProcessWithWarnings { self.inner.stderr_closer() } } + +#[cfg(test)] +mod telemetry_process_tests { + use super::*; + + enum TryWaitResult { + Running, + Exited(i32), + TimedOut, + Failed, + } + + struct StubProcess { + try_wait_result: TryWaitResult, + wait_result: std::io::Result, + kill_fails: bool, + } + + 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::TimedOut => Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out", + )), + TryWaitResult::Failed => Err(std::io::Error::other("poll failed")), + } + } + + fn id(&self) -> u32 { + 1 + } + + fn kill(&mut self) -> std::io::Result<()> { + if self.kill_fails { + Err(std::io::Error::other("kill failed")) + } else { + 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 { + 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 { + 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); + 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] + 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); + assert_eq!(process.try_wait().unwrap(), None); + assert!(process.active); + process.emit_synthetic(SyntheticTerminal::Dropped); + 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); + + 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] + 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/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 77f99f60c..208736225 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -22,7 +22,7 @@ use crate::configs::ProcessContainer; #[cfg(test)] use crate::configs::{CaptureDenials, CaptureDenialsMode}; use wxc_common::logger::{Logger, Mode}; -use wxc_common::models::ExecutionRequest; +use wxc_common::models::{ExecutionRequest, TelemetryConfig}; use wxc_common::mxc_error::MxcError; #[cfg(target_os = "linux")] @@ -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 { @@ -685,6 +697,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), + requested_sandbox_kind: Some(self.requested_sandbox_kind), + }); + self + } } /// Build a [`SandboxRequest`] from a [`SandboxPolicy`], resolving the host's @@ -750,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 @@ -780,7 +808,6 @@ pub(crate) 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 @@ -1602,6 +1629,68 @@ 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_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" + ); + + 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 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/mxc_engine/src/state_aware.rs b/src/core/mxc_engine/src/state_aware.rs index 002d0c4b5..401c492a0 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}; @@ -26,13 +26,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, TelemetryRegistration}; -/// 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( @@ -71,6 +69,53 @@ 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) +} + +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. +/// +/// 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 @@ -99,8 +144,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()), #[cfg(not(all(target_os = "windows", feature = "isolation_session")))] @@ -124,6 +167,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(); @@ -142,8 +194,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()), #[cfg(not(all(target_os = "windows", feature = "isolation_session")))] @@ -167,9 +217,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) @@ -287,7 +337,8 @@ fn exec_state_aware_attached_with( experimental: bool, host_is_interactive: impl FnOnce() -> 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) { return Err(Error::from(MxcError::malformed_request(format!( @@ -297,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)), @@ -331,7 +418,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( @@ -341,7 +429,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}" @@ -363,14 +510,66 @@ 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 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()); + 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_registration.transfer(), + 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_registration.transfer(), + requested_sandbox_kind, + telemetry::TelemetryContext { + backend: &backend, + phase: phase.as_str(), + correlation_vector: &correlation, + }, + &outcome, + started.elapsed(), + ); + Err(Error::from(error)) + } + } } #[cfg(test)] @@ -379,6 +578,48 @@ 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 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(); + 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() { @@ -387,7 +628,6 @@ mod tests { phase: Phase::Provision, containment: Some(ContainmentBackend::WindowsSandbox), sandbox_id: None, - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -408,7 +648,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, }; @@ -549,4 +788,280 @@ mod tests { err.message ); } + + #[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 9dd6fe2f1..c5da7d58e 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::{Duration, Instant}; @@ -14,9 +13,7 @@ use appcontainer_common::appcontainer_runner::delete_app_container_profile; use clap::Parser; use wxc_common::audit::{AuditEvent, AuditEventName, RejectionReason}; 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}; #[cfg(target_os = "windows")] use wxc_common::diagnostic::DiagnosticConfig; use wxc_common::logger::{Logger, Mode}; @@ -110,6 +107,39 @@ struct Cli { #[arg(long)] probe: 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, + /// 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 @@ -150,6 +180,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); @@ -210,6 +261,16 @@ fn validate_audit_request(request: &ExecutionRequest) -> Result<(), String> { Ok(()) } +/// 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. +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, @@ -253,74 +314,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 @@ -449,12 +442,17 @@ fn emit_state_aware_early_rejection( error: &MxcError, ) { let backend = backend_name_for_state_aware(parsed); + let requested_sandbox_kind = parsed + .request + .telemetry + .as_ref() + .and_then(|config| config.requested_sandbox_kind); let outcome = Err(error.clone()); - telemetry::emit_state_aware( + telemetry::emit_state_aware_with_kind( telemetry_active, + requested_sandbox_kind, telemetry::TelemetryContext { backend: &backend, - sandbox_kind: &backend, phase: parsed.phase.as_str(), correlation_vector: "", }, @@ -502,38 +500,41 @@ fn run_state_aware_main( 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"); - // 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 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 + // 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 @@ -543,7 +544,7 @@ fn run_state_aware_main( // 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 @@ -553,9 +554,6 @@ fn run_state_aware_main( } let started = Instant::now(); - // Captured before `run_state_aware` consumes `parsed`. On provision the id - // does not exist yet and is read back out of the result envelope below. - let incoming_sandbox_id = parsed.sandbox_id.clone(); // State-aware dispatch bypasses the one-shot runner funnel, so anchor the // effective lifecycle policy here before the request is consumed. let phase_config = parsed.experimental_raw.as_ref().and_then(|raw| { @@ -596,17 +594,35 @@ fn run_state_aware_main( // panics -- so we never leak duplicated handles across independent // invocations even on an unwind. let _diag_sink_guard = logger.install_thread_diagnostic_sink(); - let mut outcome = mxc_engine::run_state_aware(parsed, dry_run); + let outcome = mxc_engine::run_state_aware(parsed, dry_run); drop(_diag_sink_guard); let elapsed = started.elapsed(); + // 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, + ); + } + } + // Record the sandbox identity join key. For `isolation_session` the // `sandboxId` tail is the OS-side `provisionId`, which is what joins an MXC // record to the `Microsoft.Windows.IsolationSession` OS records. Emitted on // success only: a failed phase produced no sandbox to identify. - if let Some(sandbox_id) = - sandbox_id_for_identity_record(&outcome, incoming_sandbox_id.as_deref()) - { + if let Some(sandbox_id) = sandbox_id_for_identity_record(&outcome, sandbox_id.as_deref()) { let record = AuditEvent::new(AuditEventName::SandboxIdentity) .str("backend", backend) .str( @@ -617,14 +633,6 @@ fn run_state_aware_main( logger.log_audit_event(&record); } - // 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); - } - // Emit lifecycle telemetry (and shut the provider down) before flushing the // diagnostic buffer / envelope. Terminal path — safe to shutdown here. if let Err(error) = &outcome { @@ -640,11 +648,11 @@ fn run_state_aware_main( } } - telemetry::emit_state_aware( + telemetry::emit_state_aware_with_kind( telemetry_active, + requested_sandbox_kind, telemetry::TelemetryContext { backend, - sandbox_kind: backend, phase, correlation_vector: &correlation, }, @@ -659,6 +667,9 @@ fn run_state_aware_main( 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(); @@ -931,7 +942,18 @@ fn install_dacl_ctrl_handler() { } fn main() { - let cli = Cli::parse().normalize_named_config_command(); + let cli = parse_cli().normalize_named_config_command(); + + 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); // Propagate --force-reclaim via the environment so it reaches both the // in-process one-shot reconcile and the detached daemon. Set before any @@ -978,16 +1000,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); } } @@ -1142,18 +1173,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 { @@ -1191,29 +1231,33 @@ 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 = 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 telemetry_active = if cli.experimental { - parsed - .request - .experimental - .telemetry - .as_ref() - .map(|c| telemetry::init(c, &mut logger)) - .unwrap_or(false) - } else { - false - }; + let telemetry_active = parsed + .request + .telemetry + .as_ref() + .map(|config| telemetry::init(config, &mut logger)) + .unwrap_or(false); let context = match command_override_context_for_state_aware(&parsed, has_command_override) { Ok(context) => context, @@ -1327,24 +1371,23 @@ 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); + 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(); } @@ -1365,9 +1408,10 @@ fn main() { ); 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); @@ -1399,9 +1443,10 @@ fn main() { "", ); 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); @@ -1411,9 +1456,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); @@ -1442,9 +1488,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); @@ -1502,32 +1549,16 @@ fn main() { } } - // Log the raw input JSON config before any transformation. Skipped - // entirely when no diagnostic sink is attached: this re-reads the - // config and redacts/renders it just to have `log_diagnostic_line` - // discard the result, so gate the work itself rather than only its - // output (mirrors `Logger::has_diagnostic_sink`'s existing contract - // for `log_audit_event`). + // Log the decoded input only when a diagnostic sink is attached. + // Reuse the already-decoded value so one-shot input sources are not + // read twice, and redact secret-bearing fields before writing it. if logger.has_diagnostic_sink() { - 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 { - // Redact secret-bearing fields (e.g. `experimental.isolationSession.user.{upn,wamToken}`) - // before writing: a one-shot IsolationSession request's credential - // bundle is only rejected by the runner after this point, so the - // raw config as received from the caller may still contain it. - let _ = writeln!(logger, "SECTION: JSON Config (redacted)"); - let _ = writeln!( - logger, - "{}", - wxc_common::diagnostic::redact_raw_config_json(json.trim()) - ); - } + let _ = writeln!(logger, "SECTION: JSON Config (redacted)"); + let _ = writeln!( + logger, + "{}", + wxc_common::diagnostic::redact_raw_config_json(config_json.trim()) + ); } } @@ -1581,9 +1612,10 @@ fn main() { ); 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); @@ -1652,20 +1684,20 @@ 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::emit_completion_with_kind( telemetry_active, &request.containment, + requested_sandbox_kind, &response, 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(); @@ -1702,10 +1734,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) @@ -2125,6 +2159,71 @@ mod tests { ); } + #[test] + fn cli_parses_dedicated_telemetry_consent_request() { + let cli = parse_cli(&[ + "wxc-exec", + "--telemetry-consent", + "request", + "--telemetry-consent-locale", + "en-US", + ]); + + assert_eq!( + cli.telemetry_consent, + Some(telemetry::consent_cli::ConsentAction::Request) + ); + assert_eq!(cli.telemetry_consent_locale.as_deref(), Some("en-US")); + } + + #[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 cli_captures_hyphenated_command_after_separator() { let cli = parse_cli(&[ @@ -2308,7 +2407,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("iso:wxc-1234".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -2368,95 +2466,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 353d87b09..c056fbc44 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 } @@ -41,6 +46,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/dev/mod.rs b/src/core/wxc_common/src/config_contract_adapters/dev/mod.rs index e7c19db83..9d70556e3 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/mod.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/mod.rs @@ -87,10 +87,8 @@ mod tests { "version": "0.9.0-alpha", "phase": "provision", "containment": "windows_sandbox", - "experimental": { - "telemetry": { - "enabled": false - } + "telemetry": { + "enabled": false } }"#; @@ -100,12 +98,8 @@ mod tests { assert_eq!(adapted.config.version, Some("0.9.0-alpha".to_string())); assert!(matches!(adapted.config.phase, Some(wire::Phase::Provision))); - assert_eq!( - adapted.experimental_raw, - Some(serde_json::json!({ - "telemetry": {"enabled": false} - })) - ); + assert_eq!(adapted.config.telemetry.unwrap().enabled, Some(false)); + assert!(adapted.experimental_raw.is_none()); assert_eq!(adapted.source_text.as_ref(), json); } @@ -115,10 +109,8 @@ mod tests { "version": "0.9.0-alpha", "phase": "start", "sandboxId": "sandbox-id", - "experimental": { - "telemetry": { - "enabled": false - } + "telemetry": { + "enabled": false } }"#; @@ -129,12 +121,8 @@ mod tests { assert_eq!(adapted.config.version, Some("0.9.0-alpha".to_string())); assert!(matches!(adapted.config.phase, Some(wire::Phase::Start))); assert_eq!(adapted.config.sandbox_id, Some("sandbox-id".to_string())); - assert_eq!( - adapted.experimental_raw, - Some(serde_json::json!({ - "telemetry": {"enabled": false} - })) - ); + assert_eq!(adapted.config.telemetry.unwrap().enabled, Some(false)); + assert!(adapted.experimental_raw.is_none()); assert_eq!(adapted.source_text.as_ref(), json); } @@ -147,10 +135,8 @@ mod tests { "process": { "commandLine": "echo hello" }, - "experimental": { - "telemetry": { - "enabled": false - } + "telemetry": { + "enabled": false } }"#; @@ -169,12 +155,8 @@ mod tests { assert!(process.env.is_none()); assert!(process.timeout.is_none()); - assert_eq!( - adapted.experimental_raw, - Some(serde_json::json!({ - "telemetry": {"enabled": false} - })) - ); + assert_eq!(adapted.config.telemetry.unwrap().enabled, Some(false)); + assert!(adapted.experimental_raw.is_none()); assert_eq!(adapted.source_text.as_ref(), json); } @@ -184,10 +166,8 @@ mod tests { "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "sandbox-id", - "experimental": { - "telemetry": { - "enabled": false - } + "telemetry": { + "enabled": false } }"#; @@ -198,12 +178,8 @@ mod tests { assert_eq!(adapted.config.version, Some("0.9.0-alpha".to_string())); assert!(matches!(adapted.config.phase, Some(wire::Phase::Stop))); assert_eq!(adapted.config.sandbox_id, Some("sandbox-id".to_string())); - assert_eq!( - adapted.experimental_raw, - Some(serde_json::json!({ - "telemetry": {"enabled": false} - })) - ); + assert_eq!(adapted.config.telemetry.unwrap().enabled, Some(false)); + assert!(adapted.experimental_raw.is_none()); assert_eq!(adapted.source_text.as_ref(), json); } @@ -213,10 +189,8 @@ mod tests { "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "sandbox-id", - "experimental": { - "telemetry": { - "enabled": false - } + "telemetry": { + "enabled": false } }"#; @@ -230,12 +204,8 @@ mod tests { Some(wire::Phase::Deprovision) )); assert_eq!(adapted.config.sandbox_id, Some("sandbox-id".to_string())); - assert_eq!( - adapted.experimental_raw, - Some(serde_json::json!({ - "telemetry": {"enabled": false} - })) - ); + assert_eq!(adapted.config.telemetry.unwrap().enabled, Some(false)); + assert!(adapted.experimental_raw.is_none()); assert_eq!(adapted.source_text.as_ref(), json); } } 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 cfd71c8e2..2b8395231 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 @@ -263,7 +263,6 @@ fn convert_experimental(value: contract::OneShotExperimental) -> wire::Experimen test, windows_sandbox, wslc, - telemetry, } = value; wire::Experimental { test: test.into_option().map(convert_test), @@ -271,7 +270,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), } } @@ -292,6 +290,7 @@ pub(super) fn into_wire(request: contract::OneShotRequest) -> wire::MxcConfig { ui, seatbelt, runtime_config, + telemetry, experimental, } = request; wire::MxcConfig { @@ -300,7 +299,6 @@ pub(super) 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)), @@ -313,6 +311,7 @@ pub(super) fn into_wire(request: contract::OneShotRequest) -> wire::MxcConfig { fallback: fallback.into_option().map(convert_fallback), network: network.into_option().map(convert_network), runtime_config: runtime_config.into_option().map(convert_runtime_config), + telemetry: telemetry.into_option().map(convert_telemetry), ui: ui.into_option().map(convert_ui), seatbelt: seatbelt.into_option().map(convert_seatbelt), 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 873ea82df..55462e641 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 7546a3af3..381abf846 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.9.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.9.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.9.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.9.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_contract_adapters/dev/state_aware.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs index 8ad394b64..2ea035cc8 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs @@ -43,10 +43,7 @@ fn convert_isolation_session_provision( fn convert_isolation_session_provision_experimental( value: contract::IsolationSessionProvisionExperimental, ) -> wire::Experimental { - let contract::IsolationSessionProvisionExperimental { - isolation_session, - telemetry, - } = value; + let contract::IsolationSessionProvisionExperimental { isolation_session } = value; wire::Experimental { test: None, windows_sandbox: None, @@ -55,7 +52,6 @@ fn convert_isolation_session_provision_experimental( .into_option() .map(convert_state_aware_isolation_session), seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } @@ -79,14 +75,13 @@ fn convert_isolation_session_network(value: contract::IsolationSessionNetwork) - fn convert_windows_sandbox_provision_experimental( value: contract::WindowsSandboxExperimental, ) -> wire::Experimental { - let contract::WindowsSandboxExperimental { telemetry } = value; + let contract::WindowsSandboxExperimental {} = value; wire::Experimental { test: None, windows_sandbox: None, wslc: None, isolation_session: None, seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } @@ -119,64 +114,59 @@ fn convert_state_aware_wslc(value: contract::StateAwareWslc) -> wire::Wslc { fn convert_wslc_provision_experimental( value: contract::WslcProvisionExperimental, ) -> wire::Experimental { - let contract::WslcProvisionExperimental { wslc, telemetry } = value; + let contract::WslcProvisionExperimental { wslc } = value; wire::Experimental { test: None, windows_sandbox: None, wslc: wslc.into_option().map(convert_state_aware_wslc), isolation_session: None, seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } fn convert_start_experimental(value: contract::StartExperimental) -> wire::Experimental { - let contract::StartExperimental { telemetry } = value; + let contract::StartExperimental {} = value; wire::Experimental { test: None, windows_sandbox: None, wslc: None, isolation_session: None, seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } fn convert_exec_experimental(value: contract::ExecExperimental) -> wire::Experimental { - let contract::ExecExperimental { telemetry } = value; + let contract::ExecExperimental {} = value; wire::Experimental { test: None, windows_sandbox: None, wslc: None, isolation_session: None, seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } fn convert_stop_experimental(value: contract::StopExperimental) -> wire::Experimental { - let contract::StopExperimental { telemetry } = value; + let contract::StopExperimental {} = value; wire::Experimental { test: None, windows_sandbox: None, wslc: None, isolation_session: None, seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } fn convert_deprovision_experimental( value: contract::DeprovisionExperimental, ) -> wire::Experimental { - let contract::DeprovisionExperimental { telemetry } = value; + let contract::DeprovisionExperimental {} = value; wire::Experimental { test: None, windows_sandbox: None, wslc: None, isolation_session: None, seatbelt: None, - telemetry: telemetry.into_option().map(convert_telemetry), } } @@ -202,6 +192,7 @@ fn isolation_session_provision_into_wire( phase: contract::ProvisionPhase, containment: contract::IsolationSessionContainment, network, + telemetry, experimental, } = request; wire::MxcConfig { @@ -214,13 +205,13 @@ fn isolation_session_provision_into_wire( .map(convert_isolation_session_provision_experimental), containment: Some(wire::Containment::IsolationSession), container_id: None, - correlation_vector: None, sandbox_id: None, process: None, filesystem: None, fallback: None, network: Some(convert_isolation_session_network(network)), runtime_config: None, + telemetry: telemetry.into_option().map(convert_telemetry), lifecycle: None, lxc: None, process_container: None, @@ -239,6 +230,7 @@ fn windows_sandbox_provision_into_wire( phase: contract::ProvisionPhase, containment: contract::WindowsSandboxContainment, filesystem, + telemetry, experimental, } = request; wire::MxcConfig { @@ -251,13 +243,13 @@ fn windows_sandbox_provision_into_wire( .map(convert_windows_sandbox_provision_experimental), containment: Some(wire::Containment::WindowsSandbox), container_id: None, - correlation_vector: None, sandbox_id: None, process: None, filesystem: filesystem.into_option().map(convert_filesystem), fallback: None, network: None, runtime_config: None, + telemetry: telemetry.into_option().map(convert_telemetry), lifecycle: None, lxc: None, process_container: None, @@ -275,6 +267,7 @@ fn wslc_provision_into_wire(request: contract::WslcProvisionRequest) -> wire::Mx containment: contract::WslcContainment, filesystem, network, + telemetry, experimental, } = request; wire::MxcConfig { @@ -287,13 +280,13 @@ fn wslc_provision_into_wire(request: contract::WslcProvisionRequest) -> wire::Mx .map(convert_wslc_provision_experimental), containment: Some(wire::Containment::Wslc), container_id: None, - correlation_vector: None, sandbox_id: None, process: None, filesystem: filesystem.into_option().map(convert_filesystem), fallback: None, network: network.into_option().map(convert_network), runtime_config: None, + telemetry: telemetry.into_option().map(convert_telemetry), lifecycle: None, lxc: None, process_container: None, @@ -309,7 +302,7 @@ pub(super) fn start_into_wire(request: contract::StartRequest) -> wire::MxcConfi version, phase: contract::StartPhase, sandbox_id, - correlation_vector, + telemetry, experimental, } = request; wire::MxcConfig { @@ -318,7 +311,6 @@ pub(super) fn start_into_wire(request: contract::StartRequest) -> wire::MxcConfi version: Some(convert_version(version).to_owned()), phase: Some(wire::Phase::Start), sandbox_id: Some(sandbox_id), - correlation_vector: correlation_vector.into_option(), experimental: experimental.into_option().map(convert_start_experimental), containment: None, container_id: None, @@ -327,6 +319,7 @@ pub(super) fn start_into_wire(request: contract::StartRequest) -> wire::MxcConfi fallback: None, network: None, runtime_config: None, + telemetry: telemetry.into_option().map(convert_telemetry), lifecycle: None, lxc: None, process_container: None, @@ -343,8 +336,8 @@ pub(super) fn exec_into_wire(request: contract::ExecRequest) -> wire::MxcConfig phase: contract::ExecPhase, sandbox_id, process, - correlation_vector, network, + telemetry, experimental, } = request; wire::MxcConfig { @@ -353,7 +346,6 @@ pub(super) fn exec_into_wire(request: contract::ExecRequest) -> wire::MxcConfig version: Some(convert_version(version).to_owned()), phase: Some(wire::Phase::Exec), sandbox_id: Some(sandbox_id), - correlation_vector: correlation_vector.into_option(), experimental: experimental.into_option().map(convert_exec_experimental), containment: None, container_id: None, @@ -362,6 +354,7 @@ pub(super) fn exec_into_wire(request: contract::ExecRequest) -> wire::MxcConfig fallback: None, network: network.into_option().map(convert_network), runtime_config: None, + telemetry: telemetry.into_option().map(convert_telemetry), lifecycle: None, lxc: None, process_container: None, @@ -377,7 +370,7 @@ pub(super) fn stop_into_wire(request: contract::StopRequest) -> wire::MxcConfig version, phase: contract::StopPhase, sandbox_id, - correlation_vector, + telemetry, experimental, } = request; wire::MxcConfig { @@ -386,7 +379,6 @@ pub(super) fn stop_into_wire(request: contract::StopRequest) -> wire::MxcConfig version: Some(convert_version(version).to_owned()), phase: Some(wire::Phase::Stop), sandbox_id: Some(sandbox_id), - correlation_vector: correlation_vector.into_option(), experimental: experimental.into_option().map(convert_stop_experimental), containment: None, container_id: None, @@ -395,6 +387,7 @@ pub(super) fn stop_into_wire(request: contract::StopRequest) -> wire::MxcConfig fallback: None, network: None, runtime_config: None, + telemetry: telemetry.into_option().map(convert_telemetry), lifecycle: None, lxc: None, process_container: None, @@ -410,7 +403,7 @@ pub(super) fn deprovision_into_wire(request: contract::DeprovisionRequest) -> wi version, phase: contract::DeprovisionPhase, sandbox_id, - correlation_vector, + telemetry, experimental, } = request; wire::MxcConfig { @@ -419,7 +412,6 @@ pub(super) fn deprovision_into_wire(request: contract::DeprovisionRequest) -> wi version: Some(convert_version(version).to_owned()), phase: Some(wire::Phase::Deprovision), sandbox_id: Some(sandbox_id), - correlation_vector: correlation_vector.into_option(), experimental: experimental .into_option() .map(convert_deprovision_experimental), @@ -430,6 +422,7 @@ pub(super) fn deprovision_into_wire(request: contract::DeprovisionRequest) -> wi fallback: None, network: None, runtime_config: None, + telemetry: telemetry.into_option().map(convert_telemetry), lifecycle: None, lxc: None, process_container: None, diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs index 41f24c4aa..a526cabe2 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/common.rs @@ -33,7 +33,7 @@ fn extract_experimental_value_preserves_empty_object() { } #[test] -fn extract_experimental_value_preserves_backend_payload_and_telemetry() { +fn extract_experimental_value_preserves_only_backend_payload() { let source = r#"{ "version": "0.9.0-alpha", "phase": "provision", @@ -42,14 +42,14 @@ fn extract_experimental_value_preserves_backend_payload_and_telemetry() { "defaultPolicy": "allow", "allowLocalNetwork": true }, + "telemetry": { + "enabled": false + }, "experimental": { "isolation_session": { "provision": { "appId": "someAppId" } - }, - "telemetry": { - "enabled": false } } }"#; @@ -61,9 +61,6 @@ fn extract_experimental_value_preserves_backend_payload_and_telemetry() { "provision": { "appId": "someAppId" } - }, - "telemetry": { - "enabled": false } })) ); @@ -75,10 +72,8 @@ fn into_state_aware_wire_input_packages_config_raw_value_and_source_text() { "version": "0.9.0-alpha", "phase": "start", "sandboxId": "sandbox-id", - "experimental": { - "telemetry": { - "enabled": false - } + "telemetry": { + "enabled": false } }"#; let request: contract::StartRequest = serde_json::from_str(source).unwrap(); @@ -92,13 +87,7 @@ fn into_state_aware_wire_input_packages_config_raw_value_and_source_text() { assert!(matches!(config.phase, Some(wire::Phase::Start))); assert_eq!(config.sandbox_id.as_deref(), Some("sandbox-id")); - assert_eq!( - experimental_raw, - Some(serde_json::json!({ - "telemetry": { - "enabled": false - } - })) - ); + assert!(experimental_raw.is_none()); + assert_eq!(config.telemetry.unwrap().enabled, Some(false)); assert_eq!(source_text.as_ref(), source); } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs index 90634a71a..c7037f82a 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/deprovision.rs @@ -15,12 +15,9 @@ const ALL_FIELDS_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", "phase": "deprovision", "sandboxId": "sandbox-id", - "correlationVector": "correlation-vector", - "experimental": { - "telemetry": { + "telemetry": { "enabled": false } - } }"#; fn request_with_fields(fields: &str) -> String { @@ -50,7 +47,6 @@ fn minimal_request_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.9.0-alpha".to_string())); assert!(matches!(wire.phase, Some(wire::Phase::Deprovision))); assert_eq!(wire.sandbox_id, Some("sandbox-id".to_string())); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); assert!(wire.process.is_none()); @@ -82,10 +78,6 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.9.0-alpha".to_string())); assert!(matches!(wire.phase, Some(wire::Phase::Deprovision))); assert_eq!(wire.sandbox_id, Some("sandbox-id".to_string())); - assert_eq!( - wire.correlation_vector, - Some("correlation-vector".to_string()) - ); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); assert!(wire.process.is_none()); @@ -97,32 +89,20 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert!(wire.network.is_none()); assert!(wire.ui.is_none()); assert!(wire.seatbelt.is_none()); - - let experimental = wire.experimental.expect("experimental should be populated"); - - 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.test.is_none()); - assert!(experimental.windows_sandbox.is_none()); - assert!(experimental.wslc.is_none()); - assert!(experimental.isolation_session.is_none()); - assert!(experimental.seatbelt.is_none()); + assert!(wire.experimental.is_none()); } #[test] fn empty_experimental_sections_map_to_present_empty_wire_sections() { let wire = adapt(&request_with_fields(r#""experimental": {}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - assert!(experimental.telemetry.is_none()); - - let wire = adapt(&request_with_fields(r#""experimental": {"telemetry": {}}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + assert!(wire.experimental.is_some()); + assert!(wire.telemetry.is_none()); + + let wire = adapt(&request_with_fields(r#""telemetry": {}"#)); + assert!(wire.experimental.is_none()); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); } @@ -131,13 +111,11 @@ fn empty_identifier_strings_map_expected_wire_fields() { let json = r#"{ "version": "0.9.0-alpha", "phase": "deprovision", - "sandboxId": "", - "correlationVector": "" + "sandboxId": "" }"#; let wire = adapt(json); assert_eq!(wire.sandbox_id.as_deref(), Some("")); - assert_eq!(wire.correlation_vector.as_deref(), Some("")); } #[test] @@ -171,10 +149,7 @@ fn request_with_all_fields_matches_current_wire_deserialization() { #[test] fn empty_experimental_sections_match_current_wire_deserialization() { - for fields in [ - r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, - ] { + for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { assert_matches_current_wire_deserialization(&request_with_fields(fields)); } } @@ -184,8 +159,7 @@ fn empty_identifier_strings_match_current_wire_deserialization() { let json = r#"{ "version": "0.9.0-alpha", "phase": "deprovision", - "sandboxId": "", - "correlationVector": "" + "sandboxId": "" }"#; assert_matches_current_wire_deserialization(json); diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs index 58e6c3d94..e9ea02b38 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/exec.rs @@ -18,7 +18,6 @@ const ALL_FIELDS_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "sandbox-id", - "correlationVector": "correlation-vector", "process": { "commandLine": "echo hello", "cwd": "/work", @@ -35,11 +34,9 @@ const ALL_FIELDS_REQUEST_JSON: &str = r#"{ "url": "http://127.0.0.1:8080" } }, - "experimental": { - "telemetry": { + "telemetry": { "enabled": false } - } }"#; fn request_with_fields(fields: &str) -> String { @@ -76,8 +73,6 @@ fn minimal_request_maps_expected_wire_fields() { assert!(process.cwd.is_none()); assert!(process.env.is_none()); assert!(process.timeout.is_none()); - - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); assert!(wire.lifecycle.is_none()); @@ -108,10 +103,6 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.9.0-alpha".to_string())); assert!(matches!(wire.phase, Some(wire::Phase::Exec))); assert_eq!(wire.sandbox_id, Some("sandbox-id".to_string())); - assert_eq!( - wire.correlation_vector, - Some("correlation-vector".to_string()) - ); let process = wire.process.expect("process should be populated"); assert_eq!(process.command_line.as_deref(), Some("echo hello")); @@ -154,31 +145,20 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert!(proxy.localhost.is_none()); assert!(proxy.builtin_test_server.is_none()); assert_eq!(proxy.url.as_deref(), Some("http://127.0.0.1:8080")); - - let experimental = wire.experimental.expect("experimental should be populated"); - 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.test.is_none()); - assert!(experimental.windows_sandbox.is_none()); - assert!(experimental.wslc.is_none()); - assert!(experimental.isolation_session.is_none()); - assert!(experimental.seatbelt.is_none()); + assert!(wire.experimental.is_none()); } #[test] fn empty_experimental_sections_map_to_present_empty_wire_sections() { let wire = adapt(&request_with_fields(r#""experimental": {}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - assert!(experimental.telemetry.is_none()); + assert!(wire.experimental.is_some()); + assert!(wire.telemetry.is_none()); - let wire = adapt(&request_with_fields(r#""experimental": {"telemetry": {}}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + let wire = adapt(&request_with_fields(r#""telemetry": {}"#)); + assert!(wire.experimental.is_none()); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); } @@ -200,7 +180,6 @@ fn empty_identifier_strings_map_expected_wire_fields() { "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "", - "correlationVector": "", "process": { "commandLine": "echo hello", "cwd": "" @@ -209,7 +188,6 @@ fn empty_identifier_strings_map_expected_wire_fields() { let wire = adapt(json); assert_eq!(wire.sandbox_id.as_deref(), Some("")); - assert_eq!(wire.correlation_vector.as_deref(), Some("")); assert_eq!( wire.process .as_ref() @@ -249,10 +227,7 @@ fn request_with_all_fields_matches_current_wire_deserialization() { #[test] fn empty_experimental_sections_match_current_wire_deserialization() { - for fields in [ - r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, - ] { + for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { assert_matches_current_wire_deserialization(&request_with_fields(fields)); } } @@ -268,7 +243,6 @@ fn empty_identifier_strings_match_current_wire_deserialization() { "version": "0.9.0-alpha", "phase": "exec", "sandboxId": "", - "correlationVector": "", "process": { "commandLine": "echo hello", "cwd": "" diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs index f3e04c195..57d43e694 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/provision.rs @@ -23,14 +23,14 @@ const ISOLATION_SESSION_ALL_FIELDS_REQUEST_JSON: &str = r#"{ "allowLocalNetwork": true, "defaultPolicy": "allow" }, + "telemetry": { + "enabled": false + }, "experimental": { "isolation_session": { "provision": { "appId": "someAppId" } - }, - "telemetry": { - "enabled": false } } }"#; @@ -52,11 +52,9 @@ const WINDOWS_SANDBOX_ALL_FIELDS_REQUEST_JSON: &str = r#"{ "readwritePaths": ["C:\\Users\\User\\Documents"], "deniedPaths": ["C:\\Users\\User\\Music"] }, - "experimental": { - "telemetry": { + "telemetry": { "enabled": false } - } }"#; const MINIMAL_WSLC_REQUEST_JSON: &str = r#"{ @@ -86,15 +84,15 @@ const WSLC_ALL_FIELDS_REQUEST_JSON: &str = r#"{ "url": "http://example.com/proxy" } }, + "telemetry": { + "enabled": false + }, "experimental": { "wslc": { "provision": { "image": "someImage", "imageTarPath": "someImageTarPath" } - }, - "telemetry": { - "enabled": false } } }"#; @@ -171,7 +169,6 @@ fn minimal_isolation_session_request_maps_expected_wire_fields() { assert!(network.blocked_hosts.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.process.is_none()); assert!(wire.lifecycle.is_none()); @@ -223,7 +220,7 @@ fn isolation_session_request_maps_expected_wire_fields() { .expect("provision should be present"); assert_eq!(provision.app_id.as_deref(), Some("someAppId")); - let telemetry = experimental.telemetry.expect("telemetry should be present"); + let telemetry = wire.telemetry.expect("telemetry should be present"); assert_eq!(telemetry.enabled, Some(false)); assert!(experimental.test.is_none()); @@ -232,7 +229,6 @@ fn isolation_session_request_maps_expected_wire_fields() { assert!(experimental.seatbelt.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.process.is_none()); assert!(wire.lifecycle.is_none()); @@ -260,7 +256,6 @@ fn minimal_windows_sandbox_request_maps_expected_wire_fields() { )); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.process.is_none()); assert!(wire.lifecycle.is_none()); @@ -309,20 +304,11 @@ fn windows_sandbox_request_maps_expected_wire_fields() { Some(vec!["C:\\Users\\User\\Music".to_string()]) ); - let experimental = wire.experimental.expect("experimental should be populated"); - - 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.test.is_none()); - assert!(experimental.isolation_session.is_none()); - assert!(experimental.wslc.is_none()); - assert!(experimental.seatbelt.is_none()); + assert!(wire.experimental.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.process.is_none()); assert!(wire.lifecycle.is_none()); @@ -347,7 +333,6 @@ fn minimal_wslc_request_maps_expected_wire_fields() { assert!(matches!(wire.containment, Some(wire::Containment::Wslc))); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.process.is_none()); assert!(wire.lifecycle.is_none()); @@ -432,9 +417,7 @@ fn wslc_request_maps_expected_wire_fields() { assert!(wslc.storage_path.is_none()); assert!(wslc.port_mappings.is_none()); - 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.test.is_none()); @@ -443,7 +426,6 @@ fn wslc_request_maps_expected_wire_fields() { assert!(experimental.seatbelt.is_none()); assert!(wire.sandbox_id.is_none()); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.process.is_none()); assert!(wire.lifecycle.is_none()); @@ -460,16 +442,12 @@ fn empty_isolation_session_sections_map_to_present_empty_wire_sections() { r#""experimental": {}"#, )); let experimental = wire.experimental.expect("experimental should be populated"); - assert!(experimental.telemetry.is_none()); + assert!(wire.telemetry.is_none()); assert!(experimental.isolation_session.is_none()); - let wire = adapt(&isolation_session_request_with_fields( - r#""experimental": {"telemetry": {}}"#, - )); - let experimental = wire.experimental.expect("experimental should be populated"); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + let wire = adapt(&isolation_session_request_with_fields(r#""telemetry": {}"#)); + assert!(wire.experimental.is_none()); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); let wire = adapt(&isolation_session_request_with_fields( @@ -505,16 +483,12 @@ fn empty_windows_sandbox_sections_map_to_present_empty_wire_sections() { let wire = adapt(&windows_sandbox_request_with_fields( r#""experimental": {}"#, )); - let experimental = wire.experimental.expect("experimental should be populated"); - assert!(experimental.telemetry.is_none()); + assert!(wire.experimental.is_some()); + assert!(wire.telemetry.is_none()); - let wire = adapt(&windows_sandbox_request_with_fields( - r#""experimental": {"telemetry": {}}"#, - )); - let experimental = wire.experimental.expect("experimental should be populated"); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + let wire = adapt(&windows_sandbox_request_with_fields(r#""telemetry": {}"#)); + assert!(wire.experimental.is_none()); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); } @@ -537,16 +511,12 @@ fn empty_wslc_sections_map_to_present_empty_wire_sections() { let wire = adapt(&wslc_request_with_fields(r#""experimental": {}"#)); let experimental = wire.experimental.expect("experimental should be populated"); - assert!(experimental.telemetry.is_none()); + assert!(wire.telemetry.is_none()); assert!(experimental.wslc.is_none()); - let wire = adapt(&wslc_request_with_fields( - r#""experimental": {"telemetry": {}}"#, - )); - let experimental = wire.experimental.expect("experimental should be populated"); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + let wire = adapt(&wslc_request_with_fields(r#""telemetry": {}"#)); + assert!(wire.experimental.is_none()); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); let wire = adapt(&wslc_request_with_fields(r#""experimental": {"wslc": {}}"#)); @@ -654,7 +624,7 @@ fn wslc_request_matches_current_wire_deserialization() { fn empty_isolation_session_sections_match_current_wire_deserialization() { for fields in [ r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, + r#""telemetry": {}"#, r#""experimental": {"isolation_session": {}}"#, r#""experimental": {"isolation_session": {"provision": {}}}"#, ] { @@ -667,7 +637,7 @@ fn empty_windows_sandbox_sections_match_current_wire_deserialization() { for fields in [ r#""filesystem": {}"#, r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, + r#""telemetry": {}"#, ] { assert_matches_current_wire_deserialization(&windows_sandbox_request_with_fields(fields)); } @@ -679,7 +649,7 @@ fn empty_wslc_sections_match_current_wire_deserialization() { r#""filesystem": {}"#, r#""network": {}"#, r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, + r#""telemetry": {}"#, r#""experimental": {"wslc": {}}"#, r#""experimental": {"wslc": {"provision": {}}}"#, ] { diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs index f6ee44508..bacd8e0e1 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/start.rs @@ -15,12 +15,9 @@ const ALL_FIELDS_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", "phase": "start", "sandboxId": "sandbox-id", - "correlationVector": "correlation-vector", - "experimental": { - "telemetry": { + "telemetry": { "enabled": false } - } }"#; fn request_with_fields(fields: &str) -> String { @@ -50,7 +47,6 @@ fn minimal_request_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.9.0-alpha".to_string())); assert!(matches!(wire.phase, Some(wire::Phase::Start))); assert_eq!(wire.sandbox_id, Some("sandbox-id".to_string())); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); assert!(wire.process.is_none()); @@ -82,10 +78,6 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.9.0-alpha".to_string())); assert!(matches!(wire.phase, Some(wire::Phase::Start))); assert_eq!(wire.sandbox_id, Some("sandbox-id".to_string())); - assert_eq!( - wire.correlation_vector, - Some("correlation-vector".to_string()) - ); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); assert!(wire.process.is_none()); @@ -97,32 +89,20 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert!(wire.network.is_none()); assert!(wire.ui.is_none()); assert!(wire.seatbelt.is_none()); - - let experimental = wire.experimental.expect("experimental should be populated"); - - 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.test.is_none()); - assert!(experimental.windows_sandbox.is_none()); - assert!(experimental.wslc.is_none()); - assert!(experimental.isolation_session.is_none()); - assert!(experimental.seatbelt.is_none()); + assert!(wire.experimental.is_none()); } #[test] fn empty_experimental_sections_map_to_present_empty_wire_sections() { let wire = adapt(&request_with_fields(r#""experimental": {}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - assert!(experimental.telemetry.is_none()); - - let wire = adapt(&request_with_fields(r#""experimental": {"telemetry": {}}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + assert!(wire.experimental.is_some()); + assert!(wire.telemetry.is_none()); + + let wire = adapt(&request_with_fields(r#""telemetry": {}"#)); + assert!(wire.experimental.is_none()); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); } @@ -131,13 +111,11 @@ fn empty_identifier_strings_map_expected_wire_fields() { let json = r#"{ "version": "0.9.0-alpha", "phase": "start", - "sandboxId": "", - "correlationVector": "" + "sandboxId": "" }"#; let wire = adapt(json); assert_eq!(wire.sandbox_id.as_deref(), Some("")); - assert_eq!(wire.correlation_vector.as_deref(), Some("")); } #[test] @@ -171,10 +149,7 @@ fn request_with_all_fields_matches_current_wire_deserialization() { #[test] fn empty_experimental_sections_match_current_wire_deserialization() { - for fields in [ - r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, - ] { + for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { assert_matches_current_wire_deserialization(&request_with_fields(fields)); } } @@ -184,8 +159,7 @@ fn empty_identifier_strings_match_current_wire_deserialization() { let json = r#"{ "version": "0.9.0-alpha", "phase": "start", - "sandboxId": "", - "correlationVector": "" + "sandboxId": "" }"#; assert_matches_current_wire_deserialization(json); diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs index 7b70d74f7..2b96134d6 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware_tests/stop.rs @@ -15,12 +15,9 @@ const ALL_FIELDS_REQUEST_JSON: &str = r#"{ "version": "0.9.0-alpha", "phase": "stop", "sandboxId": "sandbox-id", - "correlationVector": "correlation-vector", - "experimental": { - "telemetry": { + "telemetry": { "enabled": false } - } }"#; fn request_with_fields(fields: &str) -> String { @@ -50,7 +47,6 @@ fn minimal_request_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.9.0-alpha".to_string())); assert!(matches!(wire.phase, Some(wire::Phase::Stop))); assert_eq!(wire.sandbox_id, Some("sandbox-id".to_string())); - assert!(wire.correlation_vector.is_none()); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); assert!(wire.process.is_none()); @@ -82,10 +78,6 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert_eq!(wire.version, Some("0.9.0-alpha".to_string())); assert!(matches!(wire.phase, Some(wire::Phase::Stop))); assert_eq!(wire.sandbox_id, Some("sandbox-id".to_string())); - assert_eq!( - wire.correlation_vector, - Some("correlation-vector".to_string()) - ); assert!(wire.container_id.is_none()); assert!(wire.containment.is_none()); assert!(wire.process.is_none()); @@ -97,32 +89,20 @@ fn request_with_all_fields_maps_expected_wire_fields() { assert!(wire.network.is_none()); assert!(wire.ui.is_none()); assert!(wire.seatbelt.is_none()); - - let experimental = wire.experimental.expect("experimental should be populated"); - - 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.test.is_none()); - assert!(experimental.windows_sandbox.is_none()); - assert!(experimental.wslc.is_none()); - assert!(experimental.isolation_session.is_none()); - assert!(experimental.seatbelt.is_none()); + assert!(wire.experimental.is_none()); } #[test] fn empty_experimental_sections_map_to_present_empty_wire_sections() { let wire = adapt(&request_with_fields(r#""experimental": {}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - assert!(experimental.telemetry.is_none()); - - let wire = adapt(&request_with_fields(r#""experimental": {"telemetry": {}}"#)); - let experimental = wire.experimental.expect("experimental should be populated"); - let telemetry = experimental - .telemetry - .expect("telemetry should be populated"); + assert!(wire.experimental.is_some()); + assert!(wire.telemetry.is_none()); + + let wire = adapt(&request_with_fields(r#""telemetry": {}"#)); + assert!(wire.experimental.is_none()); + let telemetry = wire.telemetry.expect("telemetry should be populated"); assert!(telemetry.enabled.is_none()); } @@ -131,13 +111,11 @@ fn empty_identifier_strings_map_expected_wire_fields() { let json = r#"{ "version": "0.9.0-alpha", "phase": "stop", - "sandboxId": "", - "correlationVector": "" + "sandboxId": "" }"#; let wire = adapt(json); assert_eq!(wire.sandbox_id.as_deref(), Some("")); - assert_eq!(wire.correlation_vector.as_deref(), Some("")); } #[test] @@ -171,10 +149,7 @@ fn request_with_all_fields_matches_current_wire_deserialization() { #[test] fn empty_experimental_sections_match_current_wire_deserialization() { - for fields in [ - r#""experimental": {}"#, - r#""experimental": {"telemetry": {}}"#, - ] { + for fields in [r#""experimental": {}"#, r#""telemetry": {}"#] { assert_matches_current_wire_deserialization(&request_with_fields(fields)); } } @@ -184,8 +159,7 @@ fn empty_identifier_strings_match_current_wire_deserialization() { let json = r#"{ "version": "0.9.0-alpha", "phase": "stop", - "sandboxId": "", - "correlationVector": "" + "sandboxId": "" }"#; assert_matches_current_wire_deserialization(json); 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 1d3c5cdbf..1e4ec91ff 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 @@ -227,7 +227,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)), @@ -510,7 +510,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()); @@ -543,7 +542,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, @@ -646,7 +644,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 787c90b1d..618aeee9f 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 @@ -257,7 +257,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)), @@ -646,7 +646,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()); @@ -679,7 +678,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, @@ -782,7 +780,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, @@ -855,7 +852,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_contract_adapters/v0_8.rs b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs index 1f2f344d6..6a03d2ea7 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_8.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs @@ -389,7 +389,6 @@ 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, container_id: container_id.into_option(), containment: containment.into_option().map(convert_containment), process: Some(convert_process(process)), @@ -404,6 +403,7 @@ pub(crate) fn into_wire(request: contract::Request) -> wire::MxcConfig { ui: ui.into_option().map(convert_ui), seatbelt: seatbelt.into_option().map(convert_seatbelt), runtime_config: runtime_config.into_option().map(convert_runtime_config), + telemetry: None, experimental: None, } } @@ -943,7 +943,6 @@ mod tests { 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()); @@ -976,7 +975,6 @@ mod tests { 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, @@ -1079,7 +1077,6 @@ mod tests { 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, @@ -1152,7 +1149,6 @@ mod tests { 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, diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index e666274ab..ea07e366e 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -65,7 +65,7 @@ impl ParseError { } } -#[derive(Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize)] #[serde(expecting = "a configuration object")] struct RequestDiscriminator<'a> { #[serde(borrow, default, deserialize_with = "deserialize_present_raw")] @@ -81,6 +81,40 @@ 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(()) +} + // ---------- Public API ---------- /// Options for [`load_mxc_request_with_options`]. @@ -128,13 +162,69 @@ 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()))?; 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) + })(); + log_one_shot_error(logger, &result); + 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 { + // `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 = (|| { + 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(discriminator.experimental.map(|raw| raw.get()))?; + + 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) })(); @@ -156,9 +246,10 @@ pub fn load_request_from_value( ) -> Result { let result = (|| { let raw = config.clone(); + 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) })(); @@ -193,8 +284,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) })(); @@ -212,7 +302,34 @@ 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 { + // `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(json_str, logger, opts.allow_missing_command); if let Err(error) = &result { log_error(logger, &error.message(), error.output()); } @@ -231,22 +348,29 @@ fn parse_mxc_request_json( ) -> Result { 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_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( json_str, - discriminator.experimental, + 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(discriminator.experimental.map(|raw| raw.get())) + .map_err(ParseError::OneShot)?; let cfg: wire::MxcConfig = config_deserialize::from_str(json_str).map_err(|error| { let malformed = error.is_syntax_error(); let error = WxcError::ConfigParse(error.to_string()); @@ -258,13 +382,18 @@ fn parse_mxc_request_json( })?; 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(()); @@ -292,6 +421,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); @@ -305,8 +457,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()) @@ -655,6 +810,22 @@ fn state_aware_containment_from_id(sandbox_id: &str) -> Option) -> &'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 @@ -739,11 +910,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); @@ -1214,7 +1380,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 \ @@ -1320,14 +1486,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() @@ -1336,6 +1498,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 @@ -1364,6 +1530,7 @@ fn convert_wire_config( policy, lxc_config, seatbelt, + telemetry, experimental_enabled: false, testing_features_enabled: false, experimental, @@ -1373,13 +1540,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()?; @@ -1399,7 +1567,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()))?; @@ -1450,12 +1618,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(); let network_supplied = cfg.network.is_some(); // State-aware requests carry only cross-cutting fields (process / @@ -1490,7 +1664,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; @@ -1520,36 +1693,11 @@ fn convert_wire_state_aware( request.policy.network_ingress = None; } - // 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); - } - 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 @@ -1582,16 +1730,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), _ => { @@ -1609,7 +1770,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'), @@ -1798,22 +1959,18 @@ 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#"{ + "version": "0.9.0-alpha", "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()); @@ -1822,6 +1979,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#"{ @@ -1830,7 +2006,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"), } } @@ -1842,7 +2018,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); @@ -1989,7 +2165,7 @@ mod tests { let json = r#"{ "phase": "provision", "containment": "isolation_session", - "experimental": {"telemetry": 42} + "telemetry": 42 }"#; let encoded = base64_encode(json.as_bytes()); @@ -2012,12 +2188,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 @@ -2044,7 +2238,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"), } } @@ -2149,7 +2343,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(); @@ -2161,7 +2359,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!( @@ -2200,7 +2400,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. @@ -3297,8 +3499,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 ["", " "] { @@ -4242,8 +4444,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] @@ -4829,9 +5036,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(); @@ -4839,8 +5047,31 @@ 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::OneShotMalformed(error) + | ParseError::OneShot(error) => error.to_string(), + }; + assert!( + msg.contains("correlationVector"), + "state-aware path should reject 'correlationVector', got: {msg}" ); } @@ -6503,36 +6734,169 @@ 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_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#"{"process":{"commandLine":"echo hi"},"experimental":{"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(); - 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_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}}"#; + 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 8078029c4..e786c1504 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -949,17 +949,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)] @@ -984,6 +986,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/policy_identity.rs b/src/core/wxc_common/src/policy_identity.rs index 308cdb782..0e4b4b0c7 100644 --- a/src/core/wxc_common/src/policy_identity.rs +++ b/src/core/wxc_common/src/policy_identity.rs @@ -144,6 +144,8 @@ fn policy_projection(request: &ExecutionRequest) -> Value { policy, lxc_config, seatbelt, + // Telemetry settings do not affect enforcement. + telemetry: _excluded_telemetry, experimental_enabled, experimental, // --- deliberately excluded; see the module docs --- @@ -231,8 +233,6 @@ fn experimental_projection(experimental: &ExperimentalConfig) -> Value { wslc, // A placeholder feature with no enforcement effect. test: _excluded_test_feature, - // Telemetry settings do not affect enforcement. - telemetry: _excluded_telemetry, } = experimental; let mut out = Map::new(); @@ -566,8 +566,9 @@ mod tests { fn telemetry_settings_do_not_change_the_hash() { let baseline = policy_hash(&request()); let mut changed = request(); - changed.experimental.telemetry = Some(crate::models::TelemetryConfig { + changed.telemetry = Some(crate::models::TelemetryConfig { enabled: Some(true), + requested_sandbox_kind: Some("process"), }); assert_eq!( baseline, 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/state_aware_dispatch.rs b/src/core/wxc_common/src/state_aware_dispatch.rs index d27044696..fead6e3c4 100644 --- a/src/core/wxc_common/src/state_aware_dispatch.rs +++ b/src/core/wxc_common/src/state_aware_dispatch.rs @@ -814,7 +814,6 @@ mod tests { phase, containment: Some(ContainmentBackend::IsolationSession), sandbox_id: sandbox_id.map(String::from), - correlation_vector: None, experimental_raw: exp, source_text: None, } @@ -1062,7 +1061,6 @@ mod tests { phase: Phase::Provision, containment: Some(ContainmentBackend::Wslc), sandbox_id: None, - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1077,7 +1075,6 @@ mod tests { phase: Phase::Provision, containment: None, sandbox_id: None, - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1092,7 +1089,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("iso:wxc-abcd1234".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1109,7 +1105,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("wsb:deadbeef".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1126,7 +1121,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("wslc:deadbeef".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1140,7 +1134,6 @@ mod tests { phase: Phase::Start, containment: None, sandbox_id: Some("unknownxyz:abc".into()), - correlation_vector: None, experimental_raw: None, source_text: None, }; @@ -1155,7 +1148,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 8978a6681..4e15ec465 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -98,6 +98,13 @@ pub struct ConsentStatus { 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 { @@ -234,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)) @@ -721,7 +732,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); @@ -1299,6 +1310,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, @@ -1314,6 +1326,10 @@ mod platform { } } + pub(super) fn local_app_data_dir() -> Option { + None + } + pub(super) fn read_status_unlocked() -> ConsentStatus { read_status() } @@ -1341,9 +1357,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)))] @@ -1373,10 +1391,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, } @@ -1384,24 +1398,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, } } @@ -1426,13 +1431,71 @@ 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)] + 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")] @@ -1441,14 +1504,30 @@ pub mod test_support { *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), - } + } + } + + #[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); } } } @@ -1489,6 +1568,41 @@ mod tests { } } + #[test] + fn blocking_task_propagates_worker_panic_without_hanging() { + struct SignalWaker(std::sync::mpsc::Sender<()>); + + 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] fn consent_state_as_str_is_stable() { assert_eq!(ConsentState::Granted.as_str(), "granted"); @@ -2406,7 +2520,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( @@ -2416,25 +2529,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] @@ -2442,24 +2536,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" @@ -2471,7 +2550,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..0eb2da795 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/consent_cli.rs @@ -0,0 +1,952 @@ +// 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::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" + )), + } + } +} + +/// 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=") + }) +} + +/// 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` for domain outcomes, `64` for invalid input, and + /// `1` for operational failures. + 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 { + 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}"); + } + self.exit_code + } + + fn failure(message: String, exit_code: i32) -> Self { + Self { + stdout: None, + stderr: Some(message), + exit_code, + } + } + + fn json(response: &protocol::ConsentResponse) -> 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, + ), + } + } +} + +/// Execute one dedicated telemetry-consent command. +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 \ + --telemetry-consent request" + .to_string(), + 64, + ); + } + match action { + ConsentAction::Status => ConsentCliOutcome::json(&consent_response( + action, + protocol::ConsentResult::Status, + None, + None, + None, + )), + 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), + }, + ConsentAction::Request => { + let mut presenter_failure_kind = None; + let result = consent::request_consent(locale, |prompt| { + let presented = present_request(action, prompt); + presented.map_err(|failure| { + presenter_failure_kind = Some(failure.kind); + failure.message + }) + }); + match result { + Ok(outcome) => ConsentCliOutcome::json(&consent_response( + action, + action_result_to_protocol(outcome.result), + None, + None, + None, + )), + Err(consent::ConsentActionError::Presenter(error)) => { + 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, + ); + 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_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, + 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 { + 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}"))?; + 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)) +} + +fn parse_terminal_decision(input: &str) -> consent::ConsentDecision { + match input.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => consent::ConsentDecision::Yes, + "n" | "no" => consent::ConsentDecision::No, + _ => consent::ConsentDecision::Dismissed, + } +} + +fn present_over_stdio( + action: ConsentAction, + prompt: &consent_prompt::ConsentPrompt, +) -> Result { + let mut transport = StdioPresenterTransport; + present_over_transport(action, prompt, &mut transport) +} + +fn validate_presenter_response( + response: protocol::PresenterResponse, + expected_challenge: &str, + expected_resource_version: u32, +) -> Result { + if response.challenge != expected_challenge { + return Err(ProtocolFailure::invalid( + "consent presenter challenge did not match this request", + )); + } + if response.resource_version != expected_resource_version { + return Err(ProtocolFailure::invalid( + "consent presenter prompt version did not match this request", + )); + } + Ok(match response.decision { + protocol::ConsentDecision::Yes => consent::ConsentDecision::Yes, + protocol::ConsentDecision::No => consent::ConsentDecision::No, + protocol::ConsentDecision::Dismissed => consent::ConsentDecision::Dismissed, + }) +} + +fn random_challenge() -> Result { + let mut bytes = [0_u8; 32]; + 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| { + 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<(), 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 { + random_challenge() + } + + 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!` + // 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 { + ProtocolFailure::operational( + "consent presenter pipe closed before presentation envelope was delivered", + ) + } else { + ProtocolFailure::operational(format!( + "failed to write consent presentation: {error}" + )) + } + }) + } + + fn flush_presentation(&mut self) -> Result<(), ProtocolFailure> { + std::io::stdout().flush().map_err(|error| { + if error.kind() == std::io::ErrorKind::BrokenPipe { + ProtocolFailure::operational( + "consent presenter pipe closed before presentation envelope was flushed", + ) + } else { + ProtocolFailure::operational(format!( + "failed to flush consent presentation: {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: ConsentAction, + prompt: &consent_prompt::ConsentPrompt, + transport: &mut dyn PresenterTransport, +) -> Result { + let challenge = transport.next_challenge()?; + let presentation = consent_response( + action, + protocol::ConsentResult::PresentationRequired, + None, + Some(prompt_to_protocol(prompt)), + Some(challenge.clone()), + ); + 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 input = transport.read_response()?; + parse_presenter_response(&input, &challenge, prompt.resource_version) +} + +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, +) -> protocol::ConsentResponse { + let status = consent::get_status(); + let policy_state = policy::get_policy(); + protocol::ConsentResponse { + action, + result, + 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_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(), + } + } + + protocol::ConsentPrompt { + 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_protocol(state: consent::ConsentState) -> protocol::ConsentState { + match state { + 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_protocol(reason: consent::ConsentStatusReason) -> protocol::StatusReason { + match reason { + consent::ConsentStatusReason::NoRecord => protocol::StatusReason::NoRecord, + consent::ConsentStatusReason::StoreUnreadable => protocol::StatusReason::StoreUnreadable, + consent::ConsentStatusReason::StoreMalformed => protocol::StatusReason::StoreMalformed, + consent::ConsentStatusReason::ConsentSchemaUnsupported => { + protocol::StatusReason::ConsentSchemaUnsupported + } + consent::ConsentStatusReason::PromptVersionMissing => { + protocol::StatusReason::PromptVersionMissing + } + consent::ConsentStatusReason::PromptVersionUnsupported => { + protocol::StatusReason::PromptVersionUnsupported + } + consent::ConsentStatusReason::NotApplicable => protocol::StatusReason::NotApplicable, + } +} + +fn policy_state_to_protocol(state: policy::PolicyState) -> protocol::PolicyState { + match state { + 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_protocol(result: consent::ConsentActionResult) -> protocol::ConsentResult { + match result { + 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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::telemetry::consent_prompt::{ConsentPrompt, EN_US_CONSENT_PROMPT}; + + fn prompt() -> ConsentPrompt { + 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<(), ProtocolFailure>, + flush_result: Result<(), ProtocolFailure>, + read_result: Result, + 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(format!("{response_line}\n")), + writes: Vec::new(), + } + } + } + + impl PresenterTransport for FakeTransport { + fn next_challenge(&mut self) -> Result { + self.challenge.clone() + } + + 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<(), ProtocolFailure> { + self.flush_result.clone() + } + + fn read_response(&mut self) -> Result { + self.read_result.clone() + } + } + + #[test] + fn locale_is_rejected_for_non_request_actions() { + 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")); + } + + #[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 terminal_eof_is_a_presentation_failure() { + let error = terminal_decision(0, "").unwrap_err(); + assert!(error.contains("closed without returning")); + } + + 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); + } + + #[test] + fn presenter_response_is_bound_to_challenge_and_prompt_version() { + let valid = protocol::PresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: protocol::ConsentDecision::Yes, + }; + assert_eq!( + validate_presenter_response(valid, "request-a", 1).unwrap(), + consent::ConsentDecision::Yes + ); + + let replay = protocol::PresenterResponse { + challenge: "request-a".to_string(), + resource_version: 1, + decision: protocol::ConsentDecision::Yes, + }; + assert!(validate_presenter_response(replay, "request-b", 1) + .unwrap_err() + .message + .contains("challenge")); + + let stale_prompt = protocol::PresenterResponse { + challenge: "request-a".to_string(), + resource_version: 0, + decision: protocol::ConsentDecision::Yes, + }; + assert!(validate_presenter_response(stale_prompt, "request-a", 1) + .unwrap_err() + .message + .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(ProtocolFailure::operational( + "consent presenter pipe closed", + )), + flush_result: Ok(()), + read_result: Ok(String::new()), + writes: Vec::new(), + }; + 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_operational_failure() { + let mut transport = FakeTransport { + challenge: Ok("request-a".to_string()), + write_result: Ok(()), + flush_result: Ok(()), + read_result: Err(ProtocolFailure::operational( + "consent presenter closed without returning a decision", + )), + writes: Vec::new(), + }; + 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_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(&protocol::PresenterResponse { + challenge: "wrong-request".to_string(), + resource_version: 1, + decision: protocol::ConsentDecision::Yes, + }) + .unwrap(); + let mut transport = FakeTransport::with_response(response); + 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/presenter-decision-yes.json" + )), + consent::ConsentDecision::Yes, + ), + ( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/telemetry-consent/presenter-decision-no.json" + )), + consent::ConsentDecision::No, + ), + ( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/telemetry-consent/presenter-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/presenter-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 + /// 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 + /// 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_command(ConsentAction::Status, None); + assert_eq!(outcome.exit_code, 0); + assert_status_json( + &outcome, + "not-applicable", + "not-applicable", + "not-applicable", + false, + ); + assert_eq!(outcome.stderr, None); + } + } + + /// 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::*; + 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 status_flag_reports_current_state_without_mutating_it() { + let tmp = tempfile::tempdir().unwrap(); + let _guards = isolate(tmp.path()); + + let outcome = handle_consent_command(ConsentAction::Status, None); + 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_command(ConsentAction::Status, None); + assert_eq!(outcome.exit_code, 0); + assert_status_json(&outcome, "undetermined", "undetermined", "blocked", false); + } + + /// 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_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/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/correlation_state.rs b/src/core/wxc_common/src/telemetry/correlation_state.rs new file mode 100644 index 000000000..0046642c4 --- /dev/null +++ b/src/core/wxc_common/src/telemetry/correlation_state.rs @@ -0,0 +1,929 @@ +// 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 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 +/// 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(); + } + match sandbox_id { + 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() + } + } +} + +/// 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_and_prune(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(protected_sandbox_id: Option<&str>) { + with_store(|store| store.prune_stale(protected_sandbox_id)); +} + +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 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, 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() } + } + + #[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 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; + }; + 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) + } + + 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; + }; + 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 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; + } + } + } +} + +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; + 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, _protected_sandbox_id: Option<&str>) { + *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 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: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(); + 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_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] + 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); + + 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] + 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 a4d15e5f9..966a3ca1d 100644 --- a/src/core/wxc_common/src/telemetry/events.rs +++ b/src/core/wxc_common/src/telemetry/events.rs @@ -1,741 +1,748 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! TraceLogging ETW event emission for MXC telemetry. -//! -//! Event-specific data types and emission functions. The actual ETW -//! write is delegated to the `mxc_telemetry` crate, which adds -//! common fields automatically. - -/// Bounded set of failure categories for error classification. -/// Prevents free-form strings that could contain PII. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FailureReason { - ConfigError, - PolicyError, - ProcessError, - Timeout, - InitError, - /// An MXC-internal defect (e.g. a panic caught by the global panic hook), - /// as opposed to an expected operational failure of a sandboxed run. - InternalError, - /// Execution was interrupted by the operator (Ctrl-C, console close, or a - /// system shutdown/logoff) via the console control handler. - Cancelled, - Unknown, -} - -impl FailureReason { - pub fn as_str(&self) -> &'static str { - match self { - Self::ConfigError => "config_error", - Self::PolicyError => "policy_error", - Self::ProcessError => "process_error", - Self::Timeout => "timeout", - Self::InitError => "init_error", - Self::InternalError => "internal_error", - Self::Cancelled => "cancelled", - Self::Unknown => "unknown", - } - } -} - -impl std::fmt::Display for FailureReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Attribution shared by every telemetry event emitted for one dispatch: the -/// containment `backend`, the state-aware lifecycle `phase` (empty for one-shot), -/// and the `correlation_vector` MS-CV span (empty for one-shot). Grouped into one -/// struct so the three `&str`s can't be swapped positionally as they thread -/// through the emit helpers. -#[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, - /// Microsoft Correlation Vector (MS-CV) span for this event, emitted under - /// `__TlgCV__` (see [`crate::telemetry::correlation_vector`]), or `""` for - /// one-shot executions. - pub correlation_vector: &'a str, -} - -/// 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, - pub failure_reason: Option, - /// State-aware lifecycle phase that produced this event — one of - /// `provision|start|exec|stop|deprovision`. Empty (`""`) for one-shot - /// (non-state-aware) executions, which have no lifecycle phase. - pub phase: &'a str, - /// Microsoft Correlation Vector (MS-CV) span for this event — seeded at - /// `provision` and spun per phase so events from the separate per-phase - /// `wxc-exec` processes share a base prefix and can be joined (see - /// [`crate::telemetry::correlation_vector`]). Carries no `sandbox_id` / UPN. - /// Empty (`""`) for one-shot executions. - pub correlation_vector: &'a str, -} - -/// Log an MXC.Execution ETW event. -/// -/// Delegates to the `mxc_telemetry` provider which adds common fields -/// (Version, Channel, IsDebugging, UTCReplace_AppSessionGuid). -pub fn log_execution(event: &ExecutionEvent<'_>) { - let failure_str = event.failure_reason.map(|r| r.as_str()).unwrap_or(""); - - mxc_telemetry::log_execution( - event.backend, - event.sandbox_kind, - event.exit_code, - event.outcome, - event.duration_ms, - failure_str, - event.phase, - event.correlation_vector, - ); - - #[cfg(test)] - test_sink::record_execution(event); -} - -/// Log an MXC.Error ETW event. -/// -/// 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) { - mxc_telemetry::log_error( - ctx.backend, - ctx.sandbox_kind, - error_type.as_str(), - exit_code, - ctx.phase, - ctx.correlation_vector, - ); - - #[cfg(test)] - test_sink::record_error(ctx, error_type, exit_code); -} - -/// Emit a process lifecycle event required by the Windows diagnostics -/// contract. The event kind and payload are coupled in a single enum -/// to make mismatches unrepresentable at compile time. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProcessEvent<'a> { - Exited(i32), - TimedOut(u64), - /// Kill method attempted, plus the numeric OS error code returned by the - /// failed termination call (e.g. `error.code().0` on Windows), so ETW - /// consumers can see *why* the kill failed, not just what was attempted. - KillFailed(&'a str, i32), -} - -fn requirement_emission_allowed() -> bool { - super::emit_active() && super::emission_is_authorized() -} - -pub fn log_process_event(identity: &str, process_id: u32, event: ProcessEvent<'_>) { - if !requirement_emission_allowed() { - return; - } - - match event { - ProcessEvent::Exited(exit_code) => { - mxc_telemetry::log_process_exited(identity, process_id, exit_code); - } - ProcessEvent::TimedOut(timeout_ms) => { - mxc_telemetry::log_process_timed_out(identity, process_id, timeout_ms); - } - ProcessEvent::KillFailed(error_type, error_code) => { - mxc_telemetry::log_process_kill_failed(identity, process_id, error_type, error_code); - } - } - - #[cfg(test)] - test_sink::record_process(identity, process_id, event); -} - -pub fn log_enforcement_degraded( - identity: &str, - tier: &str, - needs_dacl_augmentation: bool, - degradation_reasons: &str, - effective_enforcement_level: &str, -) { - if !requirement_emission_allowed() { - return; - } - - mxc_telemetry::log_enforcement_degraded( - identity, - tier, - needs_dacl_augmentation, - degradation_reasons, - effective_enforcement_level, - ); - #[cfg(test)] - test_sink::record_requirement( - "MXC.EnforcementDegraded", - vec![ - ("identity".to_owned(), identity.to_owned()), - ("tier".to_owned(), tier.to_owned()), - ( - "needs_dacl_augmentation".to_owned(), - needs_dacl_augmentation.to_string(), - ), - ( - "degradation_reasons".to_owned(), - degradation_reasons.to_owned(), - ), - ( - "effective_enforcement_level".to_owned(), - effective_enforcement_level.to_owned(), - ), - ], - ); -} - -pub fn log_policy_hash(identity: &str, policy_hash: &str, config_schema_version: &str) { - if !requirement_emission_allowed() { - return; - } - - mxc_telemetry::log_policy_hash(identity, policy_hash, config_schema_version); - #[cfg(test)] - test_sink::record_requirement( - "MXC.PolicyHash", - vec![ - ("identity".to_owned(), identity.to_owned()), - ("policy_hash".to_owned(), policy_hash.to_owned()), - ( - "config_schema_version".to_owned(), - config_schema_version.to_owned(), - ), - ], - ); -} - -pub fn log_network_policy_applied( - identity: &str, - enforcement_mode: &str, - default_policy: &str, - proxy_port: u64, -) { - if !requirement_emission_allowed() { - return; - } - - mxc_telemetry::log_network_policy_applied( - identity, - enforcement_mode, - default_policy, - proxy_port, - ); - #[cfg(test)] - test_sink::record_requirement( - "MXC.SandboxNetworkPolicyApplied", - vec![ - ("identity".to_owned(), identity.to_owned()), - ("enforcement_mode".to_owned(), enforcement_mode.to_owned()), - ("default_policy".to_owned(), default_policy.to_owned()), - ("proxy_port".to_owned(), proxy_port.to_string()), - ], - ); -} - -pub fn log_sandbox_torn_down(identity: &str, status: &str, released_resources: &str) { - if !requirement_emission_allowed() { - return; - } - - mxc_telemetry::log_sandbox_torn_down(identity, status, released_resources); - #[cfg(test)] - test_sink::record_requirement( - "MXC.SandboxTornDown", - vec![ - ("identity".to_owned(), identity.to_owned()), - ("status".to_owned(), status.to_owned()), - ( - "released_resources".to_owned(), - released_resources.to_owned(), - ), - ], - ); -} - -pub fn log_config_rejected( - correlation_id: &str, - backend: &str, - reason: &str, - offending_field: &str, - phase: &str, -) { - if !requirement_emission_allowed() { - return; - } - - mxc_telemetry::log_config_rejected(correlation_id, backend, reason, offending_field, phase); - #[cfg(test)] - test_sink::record_requirement( - "MXC.ConfigRejected", - vec![ - ("correlation_id".to_owned(), correlation_id.to_owned()), - ("backend".to_owned(), backend.to_owned()), - ("reason".to_owned(), reason.to_owned()), - ("offending_field".to_owned(), offending_field.to_owned()), - ("phase".to_owned(), phase.to_owned()), - ], - ); -} - -/// In-memory capture sink for the two ETW emit calls, so tests can assert the -/// records that the real emit glue (`emit_panic` / `emit_cancellation` / -/// `emit_state_aware`) produces without an ETW consumer. Inert unless a test -/// explicitly installs it; the production path above always makes the direct -/// `mxc_telemetry` call regardless. -#[cfg(test)] -pub(super) mod test_sink { - use super::{ExecutionEvent, FailureReason, ProcessEvent, TelemetryContext}; - use std::cell::Cell; - use std::sync::Mutex; - - /// Serializes any test that installs/clears this capture sink or drives - /// the emit glue in `telemetry::tests`, since `EXECUTIONS`/`ERRORS`/ - /// `REQUIREMENTS` below (and the process-global emit-slot state in the - /// parent module) are shared regardless of which thread `cargo test` - /// happens to run a given test on. Every test that touches this module, - /// directly or via the parent module's emit helpers, must hold this lock - /// for its capture window. - pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(()); - - /// Owned copy of an `MXC.Execution` record as captured for a test. - #[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, - pub failure_reason: Option, - pub phase: String, - pub correlation_vector: String, - } - - /// Owned copy of an `MXC.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, - pub correlation_vector: String, - } - - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct CapturedRequirement { - pub name: String, - pub fields: Vec<(String, String)>, - } - - thread_local! { - /// Per-thread capture flag. Thread-local (not a global `AtomicBool`) so a - /// stray emit on another thread — e.g. a concurrent `#[should_panic]` - /// test tripping the global panic hook — can never leak a record into a - /// telemetry test's capture buffer. Only emits on the installing thread - /// are recorded, and telemetry tests drive the emit glue synchronously. - static INSTALLED: Cell = const { Cell::new(false) }; - } - - static EXECUTIONS: Mutex> = Mutex::new(Vec::new()); - static ERRORS: Mutex> = Mutex::new(Vec::new()); - static REQUIREMENTS: Mutex> = Mutex::new(Vec::new()); - - /// Start capturing emitted records into the sink (and clear any leftovers). - /// The caller must hold the telemetry `TEST_LOCK` for the capture window. - pub fn install() { - clear(); - INSTALLED.with(|f| f.set(true)); - } - - /// Stop capturing and drop any buffered records. - pub fn clear() { - INSTALLED.with(|f| f.set(false)); - EXECUTIONS.lock().unwrap_or_else(|e| e.into_inner()).clear(); - ERRORS.lock().unwrap_or_else(|e| e.into_inner()).clear(); - REQUIREMENTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clear(); - } - - /// Drain and return the captured `MXC.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. - pub fn take_errors() -> Vec { - std::mem::take(&mut *ERRORS.lock().unwrap_or_else(|e| e.into_inner())) - } - - pub fn take_requirements() -> Vec { - std::mem::take(&mut *REQUIREMENTS.lock().unwrap_or_else(|e| e.into_inner())) - } - - pub(super) fn record_requirement(name: &str, fields: Vec<(String, String)>) { - if INSTALLED.with(|f| f.get()) { - REQUIREMENTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(CapturedRequirement { - name: name.to_owned(), - fields, - }); - } - } - - pub(super) fn record_process(identity: &str, process_id: u32, event: ProcessEvent<'_>) { - let (name, fields) = match event { - ProcessEvent::Exited(exit_code) => ( - "MXC.ProcessExited", - vec![ - ("identity".to_owned(), identity.to_owned()), - ("process_id".to_owned(), process_id.to_string()), - ("ExitCode".to_owned(), exit_code.to_string()), - ], - ), - ProcessEvent::TimedOut(timeout_ms) => ( - "MXC.ProcessTimedOut", - vec![ - ("identity".to_owned(), identity.to_owned()), - ("process_id".to_owned(), process_id.to_string()), - ("TimeoutMs".to_owned(), timeout_ms.to_string()), - ], - ), - ProcessEvent::KillFailed(error_type, error_code) => ( - "MXC.ProcessKillFailed", - vec![ - ("identity".to_owned(), identity.to_owned()), - ("process_id".to_owned(), process_id.to_string()), - ("mxc.error_type".to_owned(), error_type.to_owned()), - ("mxc.error_code".to_owned(), error_code.to_string()), - ], - ), - }; - - if INSTALLED.with(|f| f.get()) { - REQUIREMENTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(CapturedRequirement { - name: name.to_owned(), - fields, - }); - } - } - - pub(super) fn record_execution(event: &ExecutionEvent<'_>) { - if !INSTALLED.with(|f| f.get()) { - return; - } - EXECUTIONS - .lock() - .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, - failure_reason: event.failure_reason, - phase: event.phase.to_owned(), - correlation_vector: event.correlation_vector.to_owned(), - }); - } - - pub(super) fn record_error( - ctx: TelemetryContext<'_>, - error_type: FailureReason, - exit_code: i32, - ) { - if !INSTALLED.with(|f| f.get()) { - return; - } - ERRORS - .lock() - .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(), - correlation_vector: ctx.correlation_vector.to_owned(), - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn failure_reason_as_str() { - assert_eq!(FailureReason::ConfigError.as_str(), "config_error"); - assert_eq!(FailureReason::PolicyError.as_str(), "policy_error"); - assert_eq!(FailureReason::ProcessError.as_str(), "process_error"); - assert_eq!(FailureReason::Timeout.as_str(), "timeout"); - assert_eq!(FailureReason::InitError.as_str(), "init_error"); - assert_eq!(FailureReason::InternalError.as_str(), "internal_error"); - assert_eq!(FailureReason::Cancelled.as_str(), "cancelled"); - assert_eq!(FailureReason::Unknown.as_str(), "unknown"); - } - - #[test] - fn requirement_events_use_bounded_event_names() { - let _lock = test_sink::TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - super::super::reset_for_test(); - super::super::TEST_FORCE_ACTIVE.with(|value| value.set(true)); - super::super::TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(true))); - test_sink::install(); - log_process_event("opaque", 42, ProcessEvent::Exited(0)); - log_process_event("opaque", 42, ProcessEvent::TimedOut(1000)); - log_process_event("opaque", 42, ProcessEvent::KillFailed("terminate", 5)); - log_enforcement_degraded("opaque", "base_container", true, "reason", "dacl_augmented"); - log_policy_hash("opaque", "sha256:abc", "0.8.0-alpha"); - log_network_policy_applied("opaque", "proxy", "deny", 8080); - log_sandbox_torn_down("opaque", "success", "released"); - log_config_rejected( - "corr", - "process_container", - "invalid", - "process.commandLine", - "validate", - ); - let events = test_sink::take_requirements(); - super::super::reset_for_test(); - - // Validate ProcessExited payload - let process_exited = events - .iter() - .find(|e| { - e.name == "MXC.ProcessExited" - && e.fields - .iter() - .any(|(n, v)| n == "identity" && v == "opaque") - }) - .expect("MXC.ProcessExited with opaque identity"); - assert!(process_exited - .fields - .iter() - .any(|(n, v)| n == "process_id" && v == "42")); - assert!( - process_exited - .fields - .iter() - .any(|(n, v)| n == "ExitCode" && v == "0"), - "ProcessExited must include ExitCode field with value 0" - ); - - // Validate ProcessTimedOut payload - let process_timedout = events - .iter() - .find(|e| e.name == "MXC.ProcessTimedOut") - .expect("MXC.ProcessTimedOut"); - assert!(process_timedout - .fields - .iter() - .any(|(n, v)| n == "identity" && v == "opaque")); - assert!(process_timedout - .fields - .iter() - .any(|(n, v)| n == "process_id" && v == "42")); - assert!( - process_timedout - .fields - .iter() - .any(|(n, v)| n == "TimeoutMs" && v == "1000"), - "ProcessTimedOut must include TimeoutMs field with value 1000" - ); - - // Validate ProcessKillFailed payload - let process_killfailed = events - .iter() - .find(|e| e.name == "MXC.ProcessKillFailed") - .expect("MXC.ProcessKillFailed"); - assert!(process_killfailed - .fields - .iter() - .any(|(n, v)| n == "identity" && v == "opaque")); - assert!(process_killfailed - .fields - .iter() - .any(|(n, v)| n == "process_id" && v == "42")); - assert!( - process_killfailed - .fields - .iter() - .any(|(n, v)| n == "mxc.error_type" && v == "terminate"), - "ProcessKillFailed must include mxc.error_type field" - ); - assert!( - process_killfailed - .fields - .iter() - .any(|(n, v)| n == "mxc.error_code" && v == "5"), - "ProcessKillFailed must include mxc.error_code field" - ); - - // Validate EnforcementDegraded payload - let enforcement = events - .iter() - .find(|e| e.name == "MXC.EnforcementDegraded") - .expect("enforcement event"); - assert!(enforcement - .fields - .iter() - .any(|(n, v)| n == "identity" && v == "opaque")); - assert!( - enforcement - .fields - .iter() - .any(|(n, v)| n == "tier" && v == "base_container"), - "EnforcementDegraded must include tier field with correct value" - ); - assert!(enforcement - .fields - .contains(&("needs_dacl_augmentation".to_owned(), "true".to_owned()))); - assert!( - enforcement - .fields - .iter() - .any(|(n, v)| !n.starts_with("degradation_reasons") || !v.is_empty()), - "EnforcementDegraded must include non-empty degradation_reasons" - ); - assert!( - enforcement - .fields - .iter() - .any(|(n, v)| !n.starts_with("effective_enforcement_level") || !v.is_empty()), - "EnforcementDegraded must include non-empty effective_enforcement_level" - ); - - // Validate PolicyHash payload - let policy_hash = events - .iter() - .find(|e| e.name == "MXC.PolicyHash") - .expect("policy hash event"); - assert!(policy_hash - .fields - .contains(&("identity".to_owned(), "opaque".to_owned()))); - assert!(policy_hash - .fields - .contains(&("policy_hash".to_owned(), "sha256:abc".to_owned()))); - assert!(policy_hash - .fields - .contains(&("config_schema_version".to_owned(), "0.8.0-alpha".to_owned()))); - - // Validate SandboxNetworkPolicyApplied payload - let network = events - .iter() - .find(|e| e.name == "MXC.SandboxNetworkPolicyApplied") - .expect("network event"); - assert!(network - .fields - .iter() - .any(|(n, v)| n == "identity" && v == "opaque")); - assert!(network - .fields - .iter() - .any(|(n, v)| n == "enforcement_mode" && v == "proxy")); - assert!(network - .fields - .iter() - .any(|(n, v)| n == "default_policy" && v == "deny")); - assert!(network - .fields - .contains(&("proxy_port".to_owned(), "8080".to_owned()))); - - // Validate SandboxTornDown payload - let teardown = events - .iter() - .find(|e| e.name == "MXC.SandboxTornDown") - .expect("teardown event"); - assert!(teardown - .fields - .iter() - .any(|(n, v)| n == "identity" && v == "opaque")); - assert!(teardown - .fields - .iter() - .any(|(n, v)| n == "status" && v == "success")); - assert!(teardown - .fields - .contains(&("released_resources".to_owned(), "released".to_owned()))); - - // Validate ConfigRejected payload - let rejected = events - .iter() - .find(|e| e.name == "MXC.ConfigRejected") - .expect("config rejection event"); - assert!(rejected - .fields - .iter() - .any(|(n, v)| n == "correlation_id" && v == "corr")); - assert!(rejected - .fields - .iter() - .any(|(n, v)| n == "backend" && v == "process_container")); - assert!(rejected - .fields - .iter() - .any(|(n, v)| n == "reason" && v == "invalid")); - assert!(rejected.fields.contains(&( - "offending_field".to_owned(), - "process.commandLine".to_owned() - ))); - assert!(rejected - .fields - .contains(&("phase".to_owned(), "validate".to_owned()))); - } - - #[test] - fn requirement_events_respect_live_authorization() { - let _lock = test_sink::TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - super::super::reset_for_test(); - super::super::TEST_FORCE_ACTIVE.with(|value| value.set(true)); - super::super::TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(false))); - test_sink::install(); - - log_process_event("opaque", 42, ProcessEvent::Exited(0)); - log_enforcement_degraded("opaque", "base_container", true, "reason", "dacl_augmented"); - log_policy_hash("opaque", "sha256:abc", "0.8.0-alpha"); - log_network_policy_applied("opaque", "proxy", "deny", 8080); - log_sandbox_torn_down("opaque", "success", "released"); - log_config_rejected( - "corr", - "process_container", - "invalid", - "process.commandLine", - "validate", - ); - - assert!(test_sink::take_requirements().is_empty()); - super::super::reset_for_test(); - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TraceLogging ETW event emission for MXC telemetry. +//! +//! Event-specific data types and emission functions. The actual ETW +//! write is delegated to the `mxc_telemetry` crate, which adds +//! common fields automatically. + +/// Bounded set of failure categories for error classification. +/// Prevents free-form strings that could contain PII. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureReason { + ConfigError, + PolicyError, + ProcessError, + Timeout, + InitError, + /// An MXC-internal defect (e.g. a panic caught by the global panic hook), + /// as opposed to an expected operational failure of a sandboxed run. + InternalError, + /// Execution was interrupted by the operator (Ctrl-C, console close, or a + /// system shutdown/logoff) via the console control handler. + Cancelled, + Unknown, +} + +impl FailureReason { + pub fn as_str(&self) -> &'static str { + match self { + Self::ConfigError => "config_error", + Self::PolicyError => "policy_error", + Self::ProcessError => "process_error", + Self::Timeout => "timeout", + Self::InitError => "init_error", + Self::InternalError => "internal_error", + Self::Cancelled => "cancelled", + Self::Unknown => "unknown", + } + } +} + +impl std::fmt::Display for FailureReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Attribution shared by every telemetry event emitted for one dispatch: the +/// containment `backend`, the state-aware lifecycle `phase` (empty for one-shot), +/// and the `correlation_vector` MS-CV span (empty for one-shot). Grouped into one +/// struct so the three `&str`s can't be swapped positionally as they thread +/// through the emit helpers. +#[derive(Debug, Clone, Copy)] +pub struct TelemetryContext<'a> { + pub backend: &'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, + /// Microsoft Correlation Vector (MS-CV) span for this event, emitted under + /// `__TlgCV__` (see [`crate::telemetry::correlation_vector`]), or `""` for + /// one-shot executions. + pub correlation_vector: &'a str, +} + +/// 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, + pub duration_ms: u64, + pub failure_reason: Option, + /// State-aware lifecycle phase that produced this event — one of + /// `provision|start|exec|stop|deprovision`. Empty (`""`) for one-shot + /// (non-state-aware) executions, which have no lifecycle phase. + pub phase: &'a str, + /// Microsoft Correlation Vector (MS-CV) span for this event — seeded at + /// `provision` and spun per phase so events from the separate per-phase + /// `wxc-exec` processes share a base prefix and can be joined (see + /// [`crate::telemetry::correlation_vector`]). Carries no `sandbox_id` / UPN. + /// Empty (`""`) for one-shot executions. + pub correlation_vector: &'a str, +} + +/// Log an Execution ETW event. +/// +/// Delegates to the `mxc_telemetry` provider which adds common fields +/// (Version, Channel, IsDebugging, UTCReplace_AppSessionGuid). +pub fn log_execution(event: &ExecutionEvent<'_>) { + let failure_str = event.failure_reason.map(|r| r.as_str()).unwrap_or(""); + + mxc_telemetry::log_execution( + event.backend, + event.sandbox_kind, + event.exit_code, + event.outcome, + event.duration_ms, + failure_str, + event.phase, + event.correlation_vector, + ); + + #[cfg(test)] + test_sink::record_execution(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. +/// The event carries only the bounded `error_type` category, the process +/// `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, + sandbox_kind, + error_type.as_str(), + exit_code, + ctx.phase, + ctx.correlation_vector, + ); + + #[cfg(test)] + test_sink::record_error(ctx, sandbox_kind, error_type, exit_code); +} + +/// Emit a process lifecycle event required by the Windows diagnostics +/// contract. The event kind and payload are coupled in a single enum +/// to make mismatches unrepresentable at compile time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessEvent<'a> { + Exited(i32), + TimedOut(u64), + /// Kill method attempted, plus the numeric OS error code returned by the + /// failed termination call (e.g. `error.code().0` on Windows), so ETW + /// consumers can see *why* the kill failed, not just what was attempted. + KillFailed(&'a str, i32), +} + +fn requirement_emission_allowed() -> bool { + super::process_can_emit() +} + +pub fn log_process_event(identity: &str, process_id: u32, event: ProcessEvent<'_>) { + if !requirement_emission_allowed() { + return; + } + + match event { + ProcessEvent::Exited(exit_code) => { + mxc_telemetry::log_process_exited(identity, process_id, exit_code); + } + ProcessEvent::TimedOut(timeout_ms) => { + mxc_telemetry::log_process_timed_out(identity, process_id, timeout_ms); + } + ProcessEvent::KillFailed(error_type, error_code) => { + mxc_telemetry::log_process_kill_failed(identity, process_id, error_type, error_code); + } + } + + #[cfg(test)] + test_sink::record_process(identity, process_id, event); +} + +pub fn log_enforcement_degraded( + identity: &str, + tier: &str, + needs_dacl_augmentation: bool, + degradation_reasons: &str, + effective_enforcement_level: &str, +) { + if !requirement_emission_allowed() { + return; + } + + mxc_telemetry::log_enforcement_degraded( + identity, + tier, + needs_dacl_augmentation, + degradation_reasons, + effective_enforcement_level, + ); + #[cfg(test)] + test_sink::record_requirement( + "MXC.EnforcementDegraded", + vec![ + ("identity".to_owned(), identity.to_owned()), + ("tier".to_owned(), tier.to_owned()), + ( + "needs_dacl_augmentation".to_owned(), + needs_dacl_augmentation.to_string(), + ), + ( + "degradation_reasons".to_owned(), + degradation_reasons.to_owned(), + ), + ( + "effective_enforcement_level".to_owned(), + effective_enforcement_level.to_owned(), + ), + ], + ); +} + +pub fn log_policy_hash(identity: &str, policy_hash: &str, config_schema_version: &str) { + if !requirement_emission_allowed() { + return; + } + + mxc_telemetry::log_policy_hash(identity, policy_hash, config_schema_version); + #[cfg(test)] + test_sink::record_requirement( + "MXC.PolicyHash", + vec![ + ("identity".to_owned(), identity.to_owned()), + ("policy_hash".to_owned(), policy_hash.to_owned()), + ( + "config_schema_version".to_owned(), + config_schema_version.to_owned(), + ), + ], + ); +} + +pub fn log_network_policy_applied( + identity: &str, + enforcement_mode: &str, + default_policy: &str, + proxy_port: u64, +) { + if !requirement_emission_allowed() { + return; + } + + mxc_telemetry::log_network_policy_applied( + identity, + enforcement_mode, + default_policy, + proxy_port, + ); + #[cfg(test)] + test_sink::record_requirement( + "MXC.SandboxNetworkPolicyApplied", + vec![ + ("identity".to_owned(), identity.to_owned()), + ("enforcement_mode".to_owned(), enforcement_mode.to_owned()), + ("default_policy".to_owned(), default_policy.to_owned()), + ("proxy_port".to_owned(), proxy_port.to_string()), + ], + ); +} + +pub fn log_sandbox_torn_down(identity: &str, status: &str, released_resources: &str) { + if !requirement_emission_allowed() { + return; + } + + mxc_telemetry::log_sandbox_torn_down(identity, status, released_resources); + #[cfg(test)] + test_sink::record_requirement( + "MXC.SandboxTornDown", + vec![ + ("identity".to_owned(), identity.to_owned()), + ("status".to_owned(), status.to_owned()), + ( + "released_resources".to_owned(), + released_resources.to_owned(), + ), + ], + ); +} + +pub fn log_config_rejected( + correlation_id: &str, + backend: &str, + reason: &str, + offending_field: &str, + phase: &str, +) { + if !requirement_emission_allowed() { + return; + } + + mxc_telemetry::log_config_rejected(correlation_id, backend, reason, offending_field, phase); + #[cfg(test)] + test_sink::record_requirement( + "MXC.ConfigRejected", + vec![ + ("correlation_id".to_owned(), correlation_id.to_owned()), + ("backend".to_owned(), backend.to_owned()), + ("reason".to_owned(), reason.to_owned()), + ("offending_field".to_owned(), offending_field.to_owned()), + ("phase".to_owned(), phase.to_owned()), + ], + ); +} + +/// In-memory capture sink for the two ETW emit calls, so tests can assert the +/// records that the real emit glue (`emit_panic` / `emit_cancellation` / +/// `emit_state_aware`) produces without an ETW consumer. Inert unless a test +/// explicitly installs it; the production path above always makes the direct +/// `mxc_telemetry` call regardless. +#[cfg(test)] +pub(super) mod test_sink { + use super::{ExecutionEvent, FailureReason, ProcessEvent, TelemetryContext}; + use std::cell::Cell; + use std::sync::Mutex; + + /// Serializes any test that installs/clears this capture sink or drives + /// the emit glue in `telemetry::tests`, since `EXECUTIONS`/`ERRORS`/ + /// `REQUIREMENTS` below (and the process-global emit-slot state in the + /// parent module) are shared regardless of which thread `cargo test` + /// happens to run a given test on. Every test that touches this module, + /// directly or via the parent module's emit helpers, must hold this lock + /// for its capture window. + pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(()); + + /// Owned copy of an `Execution` record as captured for a test. + #[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, + pub failure_reason: Option, + pub phase: String, + pub correlation_vector: String, + } + + /// 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, + pub correlation_vector: String, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct CapturedRequirement { + pub name: String, + pub fields: Vec<(String, String)>, + } + + thread_local! { + /// Per-thread capture flag. Thread-local (not a global `AtomicBool`) so a + /// stray emit on another thread — e.g. a concurrent `#[should_panic]` + /// test tripping the global panic hook — can never leak a record into a + /// telemetry test's capture buffer. Only emits on the installing thread + /// are recorded, and telemetry tests drive the emit glue synchronously. + static INSTALLED: Cell = const { Cell::new(false) }; + } + + static EXECUTIONS: Mutex> = Mutex::new(Vec::new()); + static ERRORS: Mutex> = Mutex::new(Vec::new()); + static REQUIREMENTS: Mutex> = Mutex::new(Vec::new()); + + /// Start capturing emitted records into the sink (and clear any leftovers). + /// The caller must hold the telemetry `TEST_LOCK` for the capture window. + pub fn install() { + clear(); + INSTALLED.with(|f| f.set(true)); + } + + /// Stop capturing and drop any buffered records. + pub fn clear() { + INSTALLED.with(|f| f.set(false)); + EXECUTIONS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + ERRORS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + REQUIREMENTS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clear(); + } + + /// 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 `Error` records. + pub fn take_errors() -> Vec { + std::mem::take(&mut *ERRORS.lock().unwrap_or_else(|e| e.into_inner())) + } + + pub fn take_requirements() -> Vec { + std::mem::take(&mut *REQUIREMENTS.lock().unwrap_or_else(|e| e.into_inner())) + } + + pub(super) fn record_requirement(name: &str, fields: Vec<(String, String)>) { + if INSTALLED.with(|f| f.get()) { + REQUIREMENTS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(CapturedRequirement { + name: name.to_owned(), + fields, + }); + } + } + + pub(super) fn record_process(identity: &str, process_id: u32, event: ProcessEvent<'_>) { + let (name, fields) = match event { + ProcessEvent::Exited(exit_code) => ( + "MXC.ProcessExited", + vec![ + ("identity".to_owned(), identity.to_owned()), + ("process_id".to_owned(), process_id.to_string()), + ("ExitCode".to_owned(), exit_code.to_string()), + ], + ), + ProcessEvent::TimedOut(timeout_ms) => ( + "MXC.ProcessTimedOut", + vec![ + ("identity".to_owned(), identity.to_owned()), + ("process_id".to_owned(), process_id.to_string()), + ("TimeoutMs".to_owned(), timeout_ms.to_string()), + ], + ), + ProcessEvent::KillFailed(error_type, error_code) => ( + "MXC.ProcessKillFailed", + vec![ + ("identity".to_owned(), identity.to_owned()), + ("process_id".to_owned(), process_id.to_string()), + ("mxc.error_type".to_owned(), error_type.to_owned()), + ("mxc.error_code".to_owned(), error_code.to_string()), + ], + ), + }; + + if INSTALLED.with(|f| f.get()) { + REQUIREMENTS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(CapturedRequirement { + name: name.to_owned(), + fields, + }); + } + } + + pub(super) fn record_execution(event: &ExecutionEvent<'_>) { + if !INSTALLED.with(|f| f.get()) { + return; + } + EXECUTIONS + .lock() + .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, + failure_reason: event.failure_reason, + phase: event.phase.to_owned(), + correlation_vector: event.correlation_vector.to_owned(), + }); + } + + pub(super) fn record_error( + ctx: TelemetryContext<'_>, + sandbox_kind: &str, + error_type: FailureReason, + exit_code: i32, + ) { + if !INSTALLED.with(|f| f.get()) { + return; + } + ERRORS + .lock() + .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(), + correlation_vector: ctx.correlation_vector.to_owned(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failure_reason_as_str() { + assert_eq!(FailureReason::ConfigError.as_str(), "config_error"); + assert_eq!(FailureReason::PolicyError.as_str(), "policy_error"); + assert_eq!(FailureReason::ProcessError.as_str(), "process_error"); + assert_eq!(FailureReason::Timeout.as_str(), "timeout"); + assert_eq!(FailureReason::InitError.as_str(), "init_error"); + assert_eq!(FailureReason::InternalError.as_str(), "internal_error"); + assert_eq!(FailureReason::Cancelled.as_str(), "cancelled"); + assert_eq!(FailureReason::Unknown.as_str(), "unknown"); + } + + #[test] + fn requirement_events_use_bounded_event_names() { + let _lock = test_sink::TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + super::super::reset_for_test(); + super::super::TEST_FORCE_ACTIVE.with(|value| value.set(true)); + super::super::TEST_AUTHORIZATION_OVERRIDE.with(|value| value.set(Some(true))); + test_sink::install(); + log_process_event("opaque", 42, ProcessEvent::Exited(0)); + log_process_event("opaque", 42, ProcessEvent::TimedOut(1000)); + log_process_event("opaque", 42, ProcessEvent::KillFailed("terminate", 5)); + log_enforcement_degraded("opaque", "base_container", true, "reason", "dacl_augmented"); + log_policy_hash("opaque", "sha256:abc", "0.8.0-alpha"); + log_network_policy_applied("opaque", "proxy", "deny", 8080); + log_sandbox_torn_down("opaque", "success", "released"); + log_config_rejected( + "corr", + "process_container", + "invalid", + "process.commandLine", + "validate", + ); + let events = test_sink::take_requirements(); + super::super::reset_for_test(); + + // Validate ProcessExited payload + let process_exited = events + .iter() + .find(|e| { + e.name == "MXC.ProcessExited" + && e.fields + .iter() + .any(|(n, v)| n == "identity" && v == "opaque") + }) + .expect("MXC.ProcessExited with opaque identity"); + assert!(process_exited + .fields + .iter() + .any(|(n, v)| n == "process_id" && v == "42")); + assert!( + process_exited + .fields + .iter() + .any(|(n, v)| n == "ExitCode" && v == "0"), + "ProcessExited must include ExitCode field with value 0" + ); + + // Validate ProcessTimedOut payload + let process_timedout = events + .iter() + .find(|e| e.name == "MXC.ProcessTimedOut") + .expect("MXC.ProcessTimedOut"); + assert!(process_timedout + .fields + .iter() + .any(|(n, v)| n == "identity" && v == "opaque")); + assert!(process_timedout + .fields + .iter() + .any(|(n, v)| n == "process_id" && v == "42")); + assert!( + process_timedout + .fields + .iter() + .any(|(n, v)| n == "TimeoutMs" && v == "1000"), + "ProcessTimedOut must include TimeoutMs field with value 1000" + ); + + // Validate ProcessKillFailed payload + let process_killfailed = events + .iter() + .find(|e| e.name == "MXC.ProcessKillFailed") + .expect("MXC.ProcessKillFailed"); + assert!(process_killfailed + .fields + .iter() + .any(|(n, v)| n == "identity" && v == "opaque")); + assert!(process_killfailed + .fields + .iter() + .any(|(n, v)| n == "process_id" && v == "42")); + assert!( + process_killfailed + .fields + .iter() + .any(|(n, v)| n == "mxc.error_type" && v == "terminate"), + "ProcessKillFailed must include mxc.error_type field" + ); + assert!( + process_killfailed + .fields + .iter() + .any(|(n, v)| n == "mxc.error_code" && v == "5"), + "ProcessKillFailed must include mxc.error_code field" + ); + + // Validate EnforcementDegraded payload + let enforcement = events + .iter() + .find(|e| e.name == "MXC.EnforcementDegraded") + .expect("enforcement event"); + assert!(enforcement + .fields + .iter() + .any(|(n, v)| n == "identity" && v == "opaque")); + assert!( + enforcement + .fields + .iter() + .any(|(n, v)| n == "tier" && v == "base_container"), + "EnforcementDegraded must include tier field with correct value" + ); + assert!(enforcement + .fields + .contains(&("needs_dacl_augmentation".to_owned(), "true".to_owned()))); + assert!( + enforcement + .fields + .iter() + .any(|(n, v)| !n.starts_with("degradation_reasons") || !v.is_empty()), + "EnforcementDegraded must include non-empty degradation_reasons" + ); + assert!( + enforcement + .fields + .iter() + .any(|(n, v)| !n.starts_with("effective_enforcement_level") || !v.is_empty()), + "EnforcementDegraded must include non-empty effective_enforcement_level" + ); + + // Validate PolicyHash payload + let policy_hash = events + .iter() + .find(|e| e.name == "MXC.PolicyHash") + .expect("policy hash event"); + assert!(policy_hash + .fields + .contains(&("identity".to_owned(), "opaque".to_owned()))); + assert!(policy_hash + .fields + .contains(&("policy_hash".to_owned(), "sha256:abc".to_owned()))); + assert!(policy_hash + .fields + .contains(&("config_schema_version".to_owned(), "0.8.0-alpha".to_owned()))); + + // Validate SandboxNetworkPolicyApplied payload + let network = events + .iter() + .find(|e| e.name == "MXC.SandboxNetworkPolicyApplied") + .expect("network event"); + assert!(network + .fields + .iter() + .any(|(n, v)| n == "identity" && v == "opaque")); + assert!(network + .fields + .iter() + .any(|(n, v)| n == "enforcement_mode" && v == "proxy")); + assert!(network + .fields + .iter() + .any(|(n, v)| n == "default_policy" && v == "deny")); + assert!(network + .fields + .contains(&("proxy_port".to_owned(), "8080".to_owned()))); + + // Validate SandboxTornDown payload + let teardown = events + .iter() + .find(|e| e.name == "MXC.SandboxTornDown") + .expect("teardown event"); + assert!(teardown + .fields + .iter() + .any(|(n, v)| n == "identity" && v == "opaque")); + assert!(teardown + .fields + .iter() + .any(|(n, v)| n == "status" && v == "success")); + assert!(teardown + .fields + .contains(&("released_resources".to_owned(), "released".to_owned()))); + + // Validate ConfigRejected payload + let rejected = events + .iter() + .find(|e| e.name == "MXC.ConfigRejected") + .expect("config rejection event"); + assert!(rejected + .fields + .iter() + .any(|(n, v)| n == "correlation_id" && v == "corr")); + assert!(rejected + .fields + .iter() + .any(|(n, v)| n == "backend" && v == "process_container")); + assert!(rejected + .fields + .iter() + .any(|(n, v)| n == "reason" && v == "invalid")); + assert!(rejected.fields.contains(&( + "offending_field".to_owned(), + "process.commandLine".to_owned() + ))); + assert!(rejected + .fields + .contains(&("phase".to_owned(), "validate".to_owned()))); + } + + #[test] + fn requirement_events_respect_live_authorization() { + let _lock = test_sink::TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + super::super::reset_for_test(); + super::super::TEST_FORCE_ACTIVE.with(|value| value.set(true)); + super::super::TEST_AUTHORIZATION_OVERRIDE.with(|value| value.set(Some(false))); + test_sink::install(); + + log_process_event("opaque", 42, ProcessEvent::Exited(0)); + log_enforcement_degraded("opaque", "base_container", true, "reason", "dacl_augmented"); + log_policy_hash("opaque", "sha256:abc", "0.8.0-alpha"); + log_network_policy_applied("opaque", "proxy", "deny", 8080); + log_sandbox_torn_down("opaque", "success", "released"); + log_config_rejected( + "corr", + "process_container", + "invalid", + "process.commandLine", + "validate", + ); + + assert!(test_sink::take_requirements().is_empty()); + super::super::reset_for_test(); + } +} diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 2fa9353c9..1d907a170 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -12,14 +12,17 @@ //! On non-Windows platforms, all telemetry functions are no-ops. 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; 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; @@ -87,52 +90,23 @@ pub mod test_support { /// will not) produce a real [`ScriptResponse`]. 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`]. +/// 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); -/// 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! { @@ -144,15 +118,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) }; - /// Test-only authorization override for emit-path tests that run on - /// non-Windows hosts or intentionally isolate event mapping from the - /// persisted consent and policy stores. - static TEST_FORCE_AUTHORIZED: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// 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()) { @@ -161,22 +134,56 @@ fn emit_active() -> bool { mxc_telemetry::is_active() } -/// Re-check live authorization immediately before one logical emission. -/// -/// Provider registration records only that telemetry was authorized at -/// initialization. Consent withdrawal or a newly blocking administrative -/// policy must suppress the next emission from an already-running process. -fn emission_is_authorized() -> bool { +/// Evaluate consent and policy for one complete logical emission. +fn authorization_allows_emission() -> bool { #[cfg(test)] - if let Some(authorized) = TEST_FORCE_AUTHORIZED.with(|value| value.get()) { - return authorized; + if let Some(allowed) = TEST_AUTHORIZATION_OVERRIDE.with(|value| value.get()) { + return allowed; } + consent::get_consent().allows_collection() && policy::get_policy().allows_collection() } -/// 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). +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) } @@ -189,12 +196,15 @@ 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() .unwrap_or_else(|e| e.into_inner()) = None; TEST_FORCE_ACTIVE.with(|f| f.set(false)); - TEST_FORCE_AUTHORIZED.with(|value| value.set(None)); + TEST_AUTHORIZATION_OVERRIDE.with(|value| value.set(None)); events::test_sink::clear(); } @@ -217,7 +227,7 @@ pub fn is_active() -> bool { mxc_telemetry::is_active() } -/// Resolve whether telemetry is enabled for this invocation. +/// Returns whether this invocation may emit telemetry. /// /// Resolution: /// - `telemetry.enabled` in JSON config — explicit kill-switch/opt-in. @@ -227,45 +237,68 @@ pub fn is_active() -> bool { /// 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() + // 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; } 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.warning_line( + "telemetry: ETW provider registration failed; continuing without telemetry", + ); + } + if activated && !mxc_telemetry::IS_UTC_ROUTED { + 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)", + ); } 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 { @@ -278,38 +311,59 @@ 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 { - return; - } - if !emission_is_authorized() { - shutdown(); + 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; - } + }; if already_emitted() { return; } + emit_completion_event( + &auth, + containment, + response, + elapsed, + requested_sandbox_kind, + ); + 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 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: backend, + sandbox_kind, exit_code: response.exit_code, outcome, duration_ms: elapsed.as_millis() as u64, @@ -328,16 +382,41 @@ pub fn emit_completion( log_error( TelemetryContext { backend, - sandbox_kind: backend, phase: "", correlation_vector: "", }, + sandbox_kind, classify_failure(&response.failure_phase), 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 @@ -346,27 +425,46 @@ 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 { - return; - } - if !emission_is_authorized() { - shutdown(); + 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, requested_sandbox_kind); + 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(); + let sandbox_kind = sandbox_kind_for(backend, requested_sandbox_kind); log_execution(&ExecutionEvent { backend, - sandbox_kind: backend, + sandbox_kind, exit_code: 1, outcome: "failure", duration_ms: 0, @@ -380,14 +478,39 @@ pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: F log_error( TelemetryContext { backend, - sandbox_kind: backend, phase: "", correlation_vector: "", }, + sandbox_kind, 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(); } @@ -398,9 +521,27 @@ pub fn emit_early_exit(active: bool, containment: &ContainmentBackend, reason: F /// 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)); } } @@ -421,6 +562,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`]. @@ -505,16 +656,26 @@ 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. +#[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.sandbox_kind, + sandbox_kind, exit_code, outcome: "failure", duration_ms: 0, @@ -525,7 +686,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>, @@ -537,13 +698,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, } @@ -553,17 +724,27 @@ 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) { - let plan = plan_crash(ctx, exit_code, reason); +/// +/// 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<'_>, + sandbox_kind: &str, + exit_code: i32, + reason: FailureReason, +) { + 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. /// /// 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`]. /// @@ -572,17 +753,21 @@ 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() || !emission_is_authorized() || 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, }, + process_sandbox_kind(), PANIC_EXIT_CODE, FailureReason::InternalError, ); @@ -593,7 +778,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`]. /// @@ -602,17 +787,21 @@ 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() || !emission_is_authorized() || 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, }, + process_sandbox_kind(), CANCELLED_EXIT_CODE, FailureReason::Cancelled, ); @@ -621,7 +810,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, @@ -638,7 +831,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> { @@ -648,16 +841,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, @@ -672,15 +875,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 - // 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, }, @@ -692,7 +893,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, @@ -718,10 +919,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 @@ -732,30 +933,144 @@ pub fn emit_state_aware( outcome: &Result, elapsed: Duration, ) { - if !active { - return; - } - if !emission_is_authorized() { - shutdown(); + 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; - } + }; if already_emitted() { return; } + emit_state_aware_event(&auth, ctx, outcome, elapsed, requested_sandbox_kind, None); + shutdown(); +} +fn emit_state_aware_event( + _auth: &EmissionAuthorization, + ctx: TelemetryContext<'_>, + 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 plan = plan_state_aware(ctx, outcome, duration_ms); + let sandbox_kind = sandbox_kind_for(ctx.backend, requested_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 { - log_error(ctx, reason, plan.execution.exit_code); + log_error(ctx, sandbox_kind, 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_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, + failure_reason_override, + ) + }); +} + +/// 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; + let sandbox_kind = sandbox_kind_for(ctx.backend, requested_sandbox_kind); + log_execution(&ExecutionEvent { + backend: ctx.backend, + 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, + sandbox_kind, + FailureReason::Cancelled, + CANCELLED_EXIT_CODE, + ); + }); } #[cfg(test)] mod tests { + use super::test_support::TelemetryTestEnv; use super::*; /// Serializes tests that touch the process-global emit slot / context @@ -765,55 +1080,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()); @@ -846,38 +1262,59 @@ 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)); - TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(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"); 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, "vm"); 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.sandbox_kind, "vm"); assert_eq!(error.error_type, FailureReason::InternalError); assert_eq!(error.exit_code, PANIC_EXIT_CODE); assert_eq!(error.phase, "exec"); @@ -893,8 +1330,8 @@ 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)); - TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(true))); set_process_context(&ContainmentBackend::IsolationSession); set_process_phase("start"); set_process_correlation_vector("iso:wxc-abcd"); @@ -908,18 +1345,99 @@ 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_with_kind( + true, + &ContainmentBackend::IsolationSession, + Some("process"), + &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].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(); + } + + #[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_with_kind( + true, + &ContainmentBackend::IsolationSession, + Some("vm"), + 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].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(); + } + #[test] fn second_terminal_emit_is_suppressed_end_to_end() { // With the provider active, the first out-of-band emit claims the slot @@ -929,7 +1447,7 @@ mod tests { reset_for_test(); events::test_sink::install(); TEST_FORCE_ACTIVE.with(|f| f.set(true)); - TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(true))); + TEST_AUTHORIZATION_OVERRIDE.with(|value| value.set(Some(true))); set_process_context(&ContainmentBackend::IsolationSession); set_process_phase("exec"); @@ -939,12 +1457,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(); @@ -955,7 +1473,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(); @@ -976,7 +1494,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()); @@ -1007,7 +1525,6 @@ mod tests { false, TelemetryContext { backend: "isolation_session", - sandbox_kind: "isolation_session", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -1074,7 +1591,6 @@ mod tests { let event = crash_event( TelemetryContext { backend: "lxc", - sandbox_kind: "lxc", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -1091,7 +1607,6 @@ mod tests { let cancel = crash_event( TelemetryContext { backend: "appcontainer", - sandbox_kind: "appcontainer", phase: "", correlation_vector: "", }, @@ -1112,7 +1627,6 @@ mod tests { let panic = plan_crash( TelemetryContext { backend: "lxc", - sandbox_kind: "lxc", phase: "exec", correlation_vector: "iso:wxc-abcd", }, @@ -1128,14 +1642,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: "", }, @@ -1172,7 +1685,6 @@ mod tests { for phase in PHASES { let ctx = TelemetryContext { backend: "isolation_session", - sandbox_kind: "isolation_session", phase, correlation_vector: correlation, }; @@ -1198,7 +1710,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); @@ -1206,10 +1718,13 @@ 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 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); @@ -1277,15 +1792,14 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); - TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(true))); + 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", }, @@ -1295,7 +1809,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()); @@ -1303,13 +1816,12 @@ mod tests { // Fresh slot for the error case (the emit above claimed it once). reset_for_test(); events::test_sink::install(); - TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(true))); + 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", }, @@ -1319,19 +1831,228 @@ 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!({}))); + let error = Err(MxcError::policy_validation("simulated")); + 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", + }, + &error, + 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"); + 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(); + } + + #[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].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"); + assert_eq!(errors[1].correlation_vector, "corr-cancelled"); + + 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 @@ -1342,7 +2063,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_for_test(); events::test_sink::install(); - TEST_FORCE_AUTHORIZED.with(|value| value.set(Some(true))); + 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 786f243d8..ad6c5bf9e 100644 --- a/src/core/wxc_common/src/telemetry/policy.rs +++ b/src/core/wxc_common/src/telemetry/policy.rs @@ -298,31 +298,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)] @@ -356,29 +391,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 eb7cc1890..ea0ffa789 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, @@ -116,6 +104,9 @@ 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, } @@ -589,17 +580,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, } @@ -741,6 +732,7 @@ pub struct IsolationSessionProvisionPhase { #[cfg(feature = "schema-gen")] mod schema_gen { 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`). @@ -769,9 +761,13 @@ 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"); - mxc_schema_support::prepare_schema(&mut value, SCHEMA_ID); + mxc_schema_support::prepare_schema(&mut value, schema_id); value } diff --git a/src/mxc_telemetry/provider_codegen.rs b/src/mxc_telemetry/provider_codegen.rs index ca4aee8e2..81e49296f 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 a4b938edf..d74ec5825 100644 --- a/src/mxc_telemetry/src/lib.rs +++ b/src/mxc_telemetry/src/lib.rs @@ -105,8 +105,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 @@ -512,6 +525,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 } @@ -852,18 +868,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..74ddee517 --- /dev/null +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -0,0 +1,347 @@ +// 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) + .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); + 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) + .env( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + std::process::id().to_string(), + ) + .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_PrivacyProduct", + "PartA_PrivacyDataCategory", + "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 dc6ce9082..e85426179 100644 --- a/src/tools/mxc_schema_gen/src/main.rs +++ b/src/tools/mxc_schema_gen/src/main.rs @@ -57,7 +57,7 @@ fn target(args: &GenerateArgs) -> Result { .ok_or_else(|| format!("unsupported exact contract version: {version}")), (None, true) => Ok(Target::LegacyWire), (None, false) => Err("one of --version or --legacy-wire is required".to_string()), - (Some(_), true) => unreachable!("clap rejects conflicting target options"), + _ => unreachable!("clap rejects conflicting target options"), } } diff --git a/tests/examples/28_telemetry_enabled.json b/tests/examples/28_telemetry_enabled.json index 6448b24b3..cb2b0032a 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/fixtures/telemetry-consent/presenter-decision-dismissed.json b/tests/fixtures/telemetry-consent/presenter-decision-dismissed.json new file mode 100644 index 000000000..1a490f365 --- /dev/null +++ b/tests/fixtures/telemetry-consent/presenter-decision-dismissed.json @@ -0,0 +1 @@ +{"challenge":"request-a","resourceVersion":1,"decision":"dismissed"} diff --git a/tests/fixtures/telemetry-consent/presenter-decision-no.json b/tests/fixtures/telemetry-consent/presenter-decision-no.json new file mode 100644 index 000000000..ad50808cc --- /dev/null +++ b/tests/fixtures/telemetry-consent/presenter-decision-no.json @@ -0,0 +1 @@ +{"challenge":"request-a","resourceVersion":1,"decision":"no"} diff --git a/tests/fixtures/telemetry-consent/presenter-decision-unknown-field.json b/tests/fixtures/telemetry-consent/presenter-decision-unknown-field.json new file mode 100644 index 000000000..4f84093c1 --- /dev/null +++ b/tests/fixtures/telemetry-consent/presenter-decision-unknown-field.json @@ -0,0 +1 @@ +{"challenge":"request-a","resourceVersion":1,"decision":"yes","grant":true} diff --git a/tests/fixtures/telemetry-consent/presenter-decision-yes.json b/tests/fixtures/telemetry-consent/presenter-decision-yes.json new file mode 100644 index 000000000..3e936376a --- /dev/null +++ b/tests/fixtures/telemetry-consent/presenter-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 new file mode 100644 index 000000000..f4a837471 --- /dev/null +++ b/tests/scripts/run_telemetry_consent_smoke_test.ps1 @@ -0,0 +1,244 @@ +# 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.' +} + +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 Invoke-Maintenance([string]$Action) { + $output = & $wxcExec --telemetry-consent $Action + if ($LASTEXITCODE -ne 0) { throw "$Action failed with exit code $LASTEXITCODE" } + $output | ConvertFrom-Json +} + +function Invoke-ConsentRequest([string]$Decision) { + $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 + $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 + $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)) + $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() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "Consent process failed: $stderr" } + $finalLine | ConvertFrom-Json +} + +function Assert-PipedRequestEofFails { + $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 + $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 + $process.StartInfo = $start + 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 "Piped request exited $($process.ExitCode), expected 1: $stderr" + } + $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 'Piped request EOF changed the consent store.' + } +} + +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' +$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') +$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 { + 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 + Set-Item "Env:$overrideOwnerName" $PID + 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 + + Assert-PipedRequestEofFails + + $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 $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 } + 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 82883c242..5fe6dcf26 100644 --- a/tests/scripts/run_telemetry_etw_smoke_test.ps1 +++ b/tests/scripts/run_telemetry_etw_smoke_test.ps1 @@ -1,198 +1,224 @@ <# .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 Invoke-Status { + $output = & $wxcExe --telemetry-consent status + if ($LASTEXITCODE -ne 0) { throw "Consent status failed with exit code $LASTEXITCODE" } + return ($output | ConvertFrom-Json) +} -Write-Host "=== MXC ETW Capture Smoke Test ===" -ForegroundColor Cyan -Write-Host "Provider: $providerName $providerGuid" +function Invoke-CanonicalGrant { + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $wxcExe + $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 + $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 + $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.' + } + + $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 + Write-Host 'SKIPPED: Administrator privileges are required 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 - 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' +$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') +$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:$overrideOwnerName" -Value $PID + 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 0xFFFFFFFFFFFFFFFF 5 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 0xFFFFFFFFFFFFFFFF 5 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. -} - -# 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" + Start-Sleep -Seconds 2 -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, '&1 | Out-Host + $traceStarted = $false + if (-not (Test-Path $etlFile) -or (Get-Item $etlFile).Length -eq 0) { + throw 'ETL output was absent or empty.' + } + 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 + $eventCount = ([regex]::Matches($xml, '') - $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 - } - } - - 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) { - Write-Host (' [OK] Found field in one event: ' + $field) -ForegroundColor Green + $eventBlocks = [regex]::Matches($xml, '(?s)') + $matchingEvent = $eventBlocks | Where-Object { + $eventXml = $_.Value + -not ($expectedFields | Where-Object { + $eventXml -notmatch [regex]::Escape($_) + }) + } | Select-Object -First 1 + if ($null -eq $matchingEvent) { + throw "No MXC event contained all required telemetry fields: $($expectedFields -join ', ')." } -} elseif ($eventCount -eq 0) { - $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 -} else { - Write-Host '' - Write-Host '=== ETW CAPTURE SMOKE TEST FAILED ===' -ForegroundColor Red - Write-Host "The ETL contains events, but none identify the Microsoft.MXC provider ($providerGuid)." -ForegroundColor Red - exit 1 + Write-Host 'PASSED: isolated MXC ETW capture smoke test' -ForegroundColor Green +} finally { + 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 } + 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