diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml index 125416c27..ce37b6246 100644 --- a/.github/workflows/Versioning.Checks.Job.yml +++ b/.github/workflows/Versioning.Checks.Job.yml @@ -59,6 +59,9 @@ jobs: - name: Check exact contract generated artifacts and fixtures run: node scripts/versioning/check-contract-codegen.js + - name: Check telemetry policy binding parity + run: node scripts/check-telemetry-policy-parity.js + - name: Check C# bindings codegen (regenerates and asserts entry points) run: node scripts/check-dotnet-bindings-codegen.js diff --git a/docs/telemetry/telemetry.md b/docs/telemetry/telemetry.md index b9d0262cd..6d73cc00c 100644 --- a/docs/telemetry/telemetry.md +++ b/docs/telemetry/telemetry.md @@ -167,7 +167,7 @@ the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error. | Field | Type | Description | |-------|------|-------------| | `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) | -| `mxc.backend` | string | Containment backend name | +| `mxc.backend` | string | Backend identifier; ProcessContainer reports `processcontainer`, not its runtime-selected tier | | `mxc.exit_code` | int32 | Process exit code | | `mxc.outcome` | string | `"success"` or `"failure"` | | `mxc.duration_ms` | uint64 | Total execution time | @@ -182,7 +182,7 @@ Emitted on execution errors. | Field | Type | Description | |-------|------|-------------| | `mxc.sandbox_kind` | string | Containment kind requested by the caller (`process`, `vm`, or a concrete backend name) | -| `mxc.backend` | string | Containment backend name | +| `mxc.backend` | string | Backend identifier; ProcessContainer reports `processcontainer`, not its runtime-selected tier | | `mxc.error_type` | string | Error category (`config_error`, `policy_error`, `process_error`, `timeout`, `init_error`, `internal_error`, `cancelled`, `unknown`) | | `mxc.exit_code` | int32 | Process exit code | | `mxc.phase` | string | State-aware lifecycle phase; empty for one-shot executions | diff --git a/scripts/check-telemetry-policy-parity.js b/scripts/check-telemetry-policy-parity.js new file mode 100644 index 000000000..1bf077a6b --- /dev/null +++ b/scripts/check-telemetry-policy-parity.js @@ -0,0 +1,240 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const { readFileSync } = require("fs"); +const { join } = require("path"); + +const root = join(__dirname, ".."); +const protocol = readFileSync( + join(root, "src", "core", "wxc_common", "src", "telemetry", "consent_protocol.rs"), + "utf8" +); +const policy = readFileSync( + join(root, "src", "core", "wxc_common", "src", "telemetry", "policy.rs"), + "utf8" +); +const csharp = readFileSync( + join(root, "sdk", "dotnet", "Microsoft.Mxc.Sdk", "MxcTelemetry.cs"), + "utf8" +); +const rustSdk = readFileSync( + join(root, "src", "core", "mxc-sdk", "src", "telemetry.rs"), + "utf8" +); +const typescript = readFileSync( + join(root, "sdk", "node", "src", "telemetry.ts"), + "utf8" +); +const errors = []; + +function requiredMatch(source, expression, label) { + const match = source.match(expression); + if (!match) { + console.error(`ERROR: could not parse ${label}`); + process.exit(1); + } + return match; +} + +function rustWireName(variant, rename) { + const kebab = variant.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); + return rename === "camelCase" + ? kebab.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) + : kebab; +} + +function rustEnum(name) { + const match = requiredMatch( + protocol, + new RegExp( + `#\\[serde\\(rename_all = "([^"]+)"(?:,[^\\]]*)?\\)\\]\\s*` + + `pub\\(super\\)\\s+enum\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}` + ), + `Rust enum ${name}` + ); + if (!["camelCase", "kebab-case"].includes(match[1])) { + console.error(`ERROR: unsupported serde rename '${match[1]}' on ${name}`); + process.exit(1); + } + return new Set( + [...match[2].matchAll(/^\s*([A-Z][A-Za-z0-9_]*)\s*,/gm)] + .map((item) => rustWireName(item[1], match[1])) + ); +} + +// The C# binding consumes the FFI, whose results come from the Rust SDK enum, +// not from the executor's private CLI protocol. +function rustSdkConsentResults() { + const body = requiredMatch( + rustSdk, + /impl ConsentActionResult \{[\s\S]*?match self \{([\s\S]*?)\n\s*\}/, + "Rust SDK ConsentActionResult::as_str" + )[1]; + return new Set([...body.matchAll(/Self::\w+\s*=>\s*"([^"]+)"/g)].map((item) => item[1])); +} + +function policyStates() { + const body = requiredMatch( + policy, + /fn as_str\(&self\)\s*->\s*&'static str\s*\{[\s\S]*?match self \{([\s\S]*?)\n\s*\}/, + "PolicyState::as_str" + )[1]; + return new Map( + [...body.matchAll(/PolicyState::(\w+)\s*=>\s*"([^"]+)"/g)] + .map((item) => [item[2], item[1]]) + ); +} + +function typescriptValues(name) { + const body = requiredMatch( + typescript, + new RegExp(`const ${name}\\s*=\\s*\\[([\\s\\S]*?)\\]\\s*as const`), + `TypeScript literal set ${name}` + )[1]; + return new Set([...body.matchAll(/["']([^"']+)["']/g)].map((item) => item[1])); +} + +function csharpSwitch(functionName, enumName) { + const body = requiredMatch( + csharp, + new RegExp( + `private\\s+static\\s+[\\w?<>]+\\s+${functionName}\\s*\\([^)]*\\)[\\s\\S]*?` + + `(?:=>\\s*value|return\\s+value\\.GetString\\(\\))\\s+switch\\s*\\{([\\s\\S]*?)\\};` + ), + `C# ${functionName}` + )[1]; + return new Map( + [...body.matchAll(new RegExp(`"([^"]+)"\\s*=>\\s*${enumName}\\.(\\w+)`, "g"))] + .map((item) => [item[1], item[2]]) + ); +} + +function csharpStatusReasons() { + const body = requiredMatch( + csharp, + /private\s+static\s+bool\s+IsKnownConsentStatusReason\s*\([^)]*\)[\s\S]*?return\s+value\.GetString\(\)\s+switch\s*\{([\s\S]*?)\};/, + "C# IsKnownConsentStatusReason" + )[1]; + return new Set([...body.matchAll(/"([^"]+)"/g)].map((item) => item[1])); +} + +function functionBody(name) { + const start = requiredMatch( + csharp, + new RegExp(`private\\s+static\\s+[\\w?<>]+\\s+${name}\\s*\\(`), + `C# ${name}` + ).index; + const end = csharp.indexOf("\n private static ", start + 1); + return csharp.slice(start, end < 0 ? undefined : end); +} + +function compareSets(label, expected, actual) { + for (const value of expected) { + if (!actual.has(value)) errors.push(`${label} is missing '${value}'`); + } + for (const value of actual) { + if (!expected.has(value)) errors.push(`${label} has unexpected '${value}'`); + } +} + +function expectedVariant(wire) { + return wire + .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) + .replace(/^./, (letter) => letter.toUpperCase()); +} + +function compareMappings(label, expected, actual) { + for (const wire of expected) { + const variant = actual.get(wire); + if (variant === undefined) { + errors.push(`${label} does not handle '${wire}'`); + } else if (variant !== expectedVariant(wire)) { + errors.push(`${label} maps '${wire}' to ${variant}`); + } + } + for (const wire of actual.keys()) { + if (!expected.has(wire)) errors.push(`${label} handles unexpected '${wire}'`); + } +} + +const runtimePolicy = policyStates(); +const protocolPolicy = rustEnum("PolicyState"); +compareMappings("Rust PolicyState::as_str", protocolPolicy, runtimePolicy); +compareSets("TypeScript policy", protocolPolicy, typescriptValues("TELEMETRY_POLICY_STATES")); +compareMappings( + "C# ParsePolicyState", + protocolPolicy, + csharpSwitch("ParseKnownPolicyState", "TelemetryPolicyState") +); + +const consentStates = rustEnum("ConsentState"); +compareSets("TypeScript consent state", consentStates, typescriptValues("TELEMETRY_CONSENT_STATES")); +compareMappings( + "C# ParseConsentState", + consentStates, + csharpSwitch("ParseKnownConsentState", "TelemetryConsentState") +); + +const bindingResults = rustSdkConsentResults(); +const protocolResults = rustEnum("ConsentResult"); +for (const value of bindingResults) { + if (!protocolResults.has(value)) { + errors.push(`Rust SDK consent result '${value}' is missing from the CLI protocol`); + } +} +const protocolOnlyResults = new Set( + [...protocolResults].filter((value) => + value === "status" || value === "presentationRequired" + ) +); +const terminalResults = new Set( + [...protocolResults].filter((value) => !protocolOnlyResults.has(value)) +); +compareSets( + "TypeScript private protocol result", + protocolOnlyResults, + typescriptValues("CONSENT_PROTOCOL_ONLY_RESULTS") +); +compareSets( + "TypeScript consent result", + terminalResults, + typescriptValues("TELEMETRY_CONSENT_RESULTS") +); +compareMappings( + "C# ParseConsentActionResult", + bindingResults, + csharpSwitch("ParseConsentActionResult", "TelemetryConsentActionResult") +); + +const statusReasons = rustEnum("StatusReason"); +compareSets( + "TypeScript private status reason", + statusReasons, + typescriptValues("CONSENT_STATUS_REASONS") +); +compareSets("C# private status reason", statusReasons, csharpStatusReasons()); + +for (const [name, sentinel] of [ + ["UnrecognizedConsentState", "return TelemetryConsentState.Undetermined;"], + ["UnrecognizedConsentActionResult", "return TelemetryConsentActionResult.Unknown;"], + ["ReportUnrecognizedConsentStatusReason", "return false;"], + ["UnrecognizedPolicyState", "return TelemetryPolicyState.Blocked;"], +]) { + if (!functionBody(name).includes(sentinel)) { + errors.push(`C# ${name} must contain '${sentinel}'`); + } +} + +if (errors.length > 0) { + console.error("ERROR: telemetry wire parity check failed:"); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); +} + +console.log( + `Telemetry wire parity OK: ${protocolPolicy.size} policy states, ` + + `${consentStates.size} consent states, ${terminalResults.size} terminal results, ` + + `${protocolOnlyResults.size} private protocol results, ` + + `and ${statusReasons.size} private status reasons match` +); diff --git a/scripts/versioning/check-schema-versions.js b/scripts/versioning/check-schema-versions.js index e78a1411b..dccd8cc32 100644 --- a/scripts/versioning/check-schema-versions.js +++ b/scripts/versioning/check-schema-versions.js @@ -115,6 +115,13 @@ expectConst( /const WSLC_STATE_AWARE_VERSION\s*=\s*'([^']+)'/, stateAwareWslc ); +expectConst( + "state-aware-helper.ts", + stateAwareTs, + "TELEMETRY_STATE_AWARE_VERSION", + /const TELEMETRY_STATE_AWARE_VERSION\s*=\s*'([^']+)'/, + maxSupported +); // -- C# SDK (sdk/dotnet/Microsoft.Mxc.Sdk/SchemaVersions.cs) -- const schemaVersionsCs = read( diff --git a/sdk/dotnet/CHANGELOG.md b/sdk/dotnet/CHANGELOG.md index 23ad5c4d6..1312186c8 100644 --- a/sdk/dotnet/CHANGELOG.md +++ b/sdk/dotnet/CHANGELOG.md @@ -45,6 +45,8 @@ a 0.8.x patch**. Package versions are bumped in a dedicated release PR (see call sites keep compiling. Because adding a parameter changes the emitted method signature, assemblies compiled against 0.8.0 must be recompiled rather than dropped in place. +- Non-exec phases reject `StateAwareExecOptions` instead of ignoring exec-only + fields. Use `StateAwarePhaseOptions`. ### Deprecated diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj index 00aebe79a..aa63ff71a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj @@ -14,9 +14,9 @@ $(DefineConstants);MXC_WITH_ISOLATION_SESSION $(MSBuildProjectDirectory)/../../../src $(MxcSrcDir)/target/dotnet-test-support - --release - release - debug + + debug test-support $(MxcTestCargoFeatures) isolation_session $(MxcTestCargoFeatures) wslc @@ -52,7 +52,7 @@ $(MxcTestCargoTargetDir)/$(MxcTestRustHostTriple)/$(MxcTestCargoProfileDir)/$(MxcTestNativeFileName) - + ()); + Assert.Equal(SchemaVersions.MaximumSupported, root["version"]?.GetValue()); + Assert.Null(root["experimental"]); + } + + [Fact] + public void BuildExecEnvelope_RelaysStableTelemetryWithoutCorrelationVector() + { + var options = new StateAwareExecOptions + { + Telemetry = new TelemetrySettings { Enabled = true }, + }; + var root = MxcLifecycle.BuildExecEnvelope( + new SandboxId("iso:abc"), + "echo hi", + options); + + Assert.False(root.ContainsKey("correlationVector")); + Assert.True(root["telemetry"]?["enabled"]?.GetValue()); + Assert.Equal(SchemaVersions.MaximumSupported, root["version"]?.GetValue()); + Assert.Null(root["experimental"]); + } + + [Fact] + public void ExecOptions_RemainAssignableButAreRejectedByNonExecPhases() + { + Assert.True( + typeof(StateAwarePhaseOptions).IsAssignableFrom( + typeof(StateAwareExecOptions))); + Assert.True( + typeof(StateAwarePhaseOptions).IsAssignableFrom( + typeof(WslcExecOptions))); + + var id = new SandboxId("iso:abc"); + var options = new StateAwareExecOptions { WorkingDirectory = "C:\\" }; + + foreach (var build in new Action[] + { + () => _ = MxcLifecycle.BuildStartEnvelope(id, options), + () => _ = MxcLifecycle.BuildStopEnvelope(id, options), + () => _ = MxcLifecycle.BuildDeprovisionEnvelope(id, options), + }) + { + var ex = Assert.Throws(build); + Assert.Contains(nameof(StateAwarePhaseOptions), ex.Message, StringComparison.Ordinal); + } + } + + [Fact] + public void BuildStartEnvelope_LeavesExplicitTelemetryVersionForNativeValidation() + { + var options = new StateAwarePhaseOptions + { + Version = SchemaVersions.LatestStable, + Telemetry = new TelemetrySettings { Enabled = false }, + }; + + var root = MxcLifecycle.BuildStartEnvelope(new SandboxId("iso:abc"), options); + Assert.Equal(SchemaVersions.LatestStable, root["version"]?.GetValue()); + Assert.False(root["telemetry"]?["enabled"]?.GetValue()); + } + + [Fact] + public void ExecInSandboxAsync_PreservesCancellationTokenAsThirdParameter() + { + static Task InvokeWithDefaultLiteral(SandboxId id, string command) => + MxcLifecycle.ExecInSandboxAsync(id, command, default); + + Assert.NotNull((Func>)InvokeWithDefaultLiteral); + } + + [Fact] + public async Task RunBlockingOperationAsync_CancellationCleansUpLateResult() + { + using var releaseOperation = new ManualResetEventSlim(); + var operationStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var cleanedUp = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + + var task = MxcLifecycle.RunBlockingOperationAsync( + () => + { + operationStarted.SetResult(); + releaseOperation.Wait(); + return 42; + }, + cleanedUp.SetResult, + cancellation.Token); + + await operationStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => task); + + releaseOperation.Set(); + Assert.Equal(42, await cleanedUp.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RunBlockingOperationAsync_PreCancelledTokenDoesNotStartOperation() + { + var started = false; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => MxcLifecycle.RunBlockingOperationAsync( + () => + { + started = true; + return 42; + }, + _ => { }, + cancellation.Token)); + + Assert.False(started); + } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index c3c01cad9..29d1e79a0 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -627,6 +627,78 @@ public void SandboxPolicy_OmitsCaptureDenialsWhenNotConfigured() Assert.False(doc.RootElement.TryGetProperty("captureDenials", out _)); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SandboxPolicy_TelemetrySerializesCanonicalNestedShape(bool enabled) + { + var policy = new SandboxPolicy + { + Version = "0.9.0-alpha", + Telemetry = new TelemetrySettings { Enabled = enabled }, + }; + + var json = MxcSandbox.SerializePolicy(policy); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal(enabled, root.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + Assert.False(root.TryGetProperty("telemetryEnabled", out _)); + + var roundTrip = JsonSerializer.Deserialize(json); + Assert.NotNull(roundTrip); + Assert.Equal(enabled, roundTrip.Telemetry?.Enabled); + } + + [Fact] + public void SandboxPolicy_LeavesTelemetryVersionForNativeValidation() + { + var policy = new SandboxPolicy + { + Version = "0.8.0-alpha", + Telemetry = new TelemetrySettings { Enabled = true }, + }; + + using var policyDocument = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy)); + Assert.Equal("0.8.0-alpha", policyDocument.RootElement.GetProperty("version").GetString()); + Assert.True(policyDocument.RootElement.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + + using var requestDocument = JsonDocument.Parse( + MxcSandbox.SerializeRequest(new SandboxRequest(policy, "echo hi"))); + var requestPolicy = requestDocument.RootElement.GetProperty("policy"); + Assert.Equal("0.8.0-alpha", requestPolicy.GetProperty("version").GetString()); + Assert.True(requestPolicy.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + } + + [Fact] + public void SandboxPolicy_OmittedTelemetrySerializesNoTelemetryField() + { + var policy = new SandboxPolicy { Version = "0.8.0-alpha" }; + + using var document = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy)); + var root = document.RootElement; + Assert.False(root.TryGetProperty("telemetry", out _)); + Assert.False(root.TryGetProperty("telemetryEnabled", out _)); + } + + [Fact] + public void SandboxPolicy_DefaultTelemetrySettingsSerializeDisabled() + { + var policy = new SandboxPolicy + { + Version = "0.9.0-alpha", + Telemetry = new TelemetrySettings(), + }; + + using var document = JsonDocument.Parse(MxcSandbox.SerializePolicy(policy)); + + Assert.False( + document.RootElement + .GetProperty("telemetry") + .GetProperty("enabled") + .GetBoolean()); + } + [Fact] public void SandboxPolicy_RoundTripsLegacyCaptureDenials() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index cfdb5aa48..121abc44a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -6,9 +6,7 @@ using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Runtime.Versioning; -using Microsoft.Win32; +using Microsoft.Mxc.Sdk; using Xunit; namespace Microsoft.Mxc.Sdk.Tests; @@ -19,32 +17,75 @@ public sealed class MxcTelemetryCollectionDefinition { } +/// +/// Redirects the debug-build-only MXC_TEST_LOCALAPPDATA_OVERRIDE +/// environment variable (read by wxc_common::telemetry::consent in +/// place of the real LOCALAPPDATA — see that module for the security +/// rationale; a release-profile native build compiles this override out +/// entirely and always resolves the real per-user known-folder path) to a +/// fresh temp directory for the lifetime of the fixture, so these tests +/// never touch the real developer/CI-machine telemetry consent file, and +/// restores the original value on dispose. xUnit runs the [Fact]s within a +/// single class sequentially, while the MxcTelemetry collection keeps +/// this class and MxcTelemetryTestsReleaseSafe from running in parallel. +/// The constructor verifies the redirect took effect and fails +/// the fixture loudly if it did not (see AssertStoreIsRedirected), +/// rather than silently operating on the real per-user store when the native +/// library under test happens to be a release build. +/// [Collection("MxcTelemetry")] -public sealed class MxcTelemetryTests +public sealed class MxcTelemetryTests : IDisposable { - private static readonly JsonSerializerOptions PolicyJsonOptions = new() - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, - }; + private const string OverrideEnvVar = "MXC_TEST_LOCALAPPDATA_OVERRIDE"; + private const string OverrideOwnerEnvVar = "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"; + private const string PolicyOverrideEnvVar = "MXC_TEST_POLICY_KEY_OVERRIDE"; + private const string PolicyOverrideOwnerEnvVar = "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID"; + private readonly string? _originalOverride; + private readonly string? _originalOverrideOwner; + private readonly string? _originalPolicyOverride; + private readonly string? _originalPolicyOverrideOwner; + private readonly string _tempDir; + private readonly string _policySubkey; + + public MxcTelemetryTests() + { + _originalOverride = Environment.GetEnvironmentVariable(OverrideEnvVar); + _originalOverrideOwner = Environment.GetEnvironmentVariable(OverrideOwnerEnvVar); + _tempDir = Path.Combine(Path.GetTempPath(), $"mxc_dotnet_consent_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + Environment.SetEnvironmentVariable(OverrideEnvVar, _tempDir); + if (OperatingSystem.IsWindows()) + { + Environment.SetEnvironmentVariable( + OverrideOwnerEnvVar, + GetParentProcessId().ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + // Redirect the administrative policy read to a throwaway HKCU key too, + // so these tests are unaffected by a real MXC telemetry policy on the + // build machine and can exercise the policy path without elevation. + _originalPolicyOverride = Environment.GetEnvironmentVariable(PolicyOverrideEnvVar); + _originalPolicyOverrideOwner = Environment.GetEnvironmentVariable(PolicyOverrideOwnerEnvVar); + _policySubkey = $@"Software\MxcTelemetryPolicyDotnetTest\{Guid.NewGuid():N}"; + if (OperatingSystem.IsWindows()) + { + Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose(); + } + Environment.SetEnvironmentVariable(PolicyOverrideEnvVar, _policySubkey); + Environment.SetEnvironmentVariable( + PolicyOverrideOwnerEnvVar, + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + AssertStoreIsRedirected(); + AssertPolicyKeyIsRedirected(); + } [Fact] public void DefaultTelemetryValuesFailClosed() { - Assert.Equal(TelemetryConsentState.Undetermined, default); - Assert.Equal(TelemetryPolicyState.Blocked, default); - Assert.Equal(TelemetryConsentResult.Unknown, default); - - var status = new TelemetryConsentStatus(); - Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState); - Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState); - Assert.Equal(TelemetryPolicyState.Blocked, status.Policy); - - var outcome = new TelemetryConsentOutcome(); - Assert.Equal(TelemetryConsentResult.Unknown, outcome.Result); - Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); - Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState); - Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy); + Assert.Equal(TelemetryConsentState.Undetermined, default(TelemetryConsentState)); + Assert.Equal(TelemetryPolicyState.Blocked, default(TelemetryPolicyState)); + Assert.Equal(TelemetryConsentActionResult.Unknown, default(TelemetryConsentActionResult)); } [Fact] @@ -56,336 +97,261 @@ public void SandboxPolicy_TelemetrySerializesCanonically() Telemetry = new TelemetrySettings { Enabled = true }, }; - var json = JsonSerializer.Serialize(policy, PolicyJsonOptions); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + var json = System.Text.Json.JsonSerializer.Serialize(policy); + using var doc = System.Text.Json.JsonDocument.Parse(json); - Assert.True(root.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + Assert.True(doc.RootElement.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); } - [Fact] - public void GetPolicy_NeverThrowsAndFailsClosed() - { - var policy = MxcTelemetry.GetPolicy(); - Assert.True(Enum.IsDefined(policy)); - } - - [Fact] - public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText() + /// + /// The policy counterpart to : prove + /// the native library honours MXC_TEST_POLICY_KEY_OVERRIDE before + /// any policy test runs. A release-profile mxc_ffi compiles the + /// override out and would silently read the machine's real + /// HKLM\SOFTWARE\Policies\Mxc key instead, making every + /// policy assertion below meaningless — passing or failing on the build + /// agent's administrative state rather than on the code under test. + /// + /// Two probes are used for the same reason as the consent check: a single + /// one could coincidentally match the real machine policy, but the real + /// policy cannot be both Blocked and Allowed. + /// + private void AssertPolicyKeyIsRedirected() { - var operation = $"TestGetPolicy{Guid.NewGuid():N}"; - using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( - new MxcTelemetry.FailureCategoryTracker(capacity: 64)); - using var output = new StringWriter(); - using var listener = new TextWriterTraceListener(output); - var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage"); - Trace.Listeners.Add(listener); - try - { - MxcTelemetry.ReportFailClosed(operation, "Blocked", failure); - MxcTelemetry.ReportFailClosed(operation, "Blocked", failure); - MxcTelemetry.ReportFailClosed(operation, "false", ErrorCode.BackendError); - Trace.Flush(); - - var messages = output - .ToString() - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) - .Where(line => line.Contains(operation, StringComparison.Ordinal)) - .ToArray(); - Assert.Equal(2, messages.Length); - Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]); - Assert.Contains("HRESULT 0x", messages[0]); - Assert.DoesNotContain("sensitive", messages[0]); - Assert.DoesNotContain('\u001b', messages[0]); - Assert.Contains("BackendError", messages[1]); - } - finally + if (!OperatingSystem.IsWindows()) { - Trace.Listeners.Remove(listener); + // Off Windows GetPolicy short-circuits to NotApplicable without + // reading any registry, so there is nothing to redirect. + return; } - } - - [Fact] - public void FailClosedDiagnosticTracker_DeduplicatesAndEnforcesCapacity() - { - var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 2); - Assert.True(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)); - Assert.False(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)); - Assert.True(tracker.TryAdd("NeedsConsentPrompt", "false", "ErrorCode", 2)); - Assert.False(tracker.TryAdd("GetConsent", "Undetermined", "ExceptionB", 3)); - } - - [Fact] - public void FailClosedDiagnosticTracker_DeduplicatesConcurrentReports() - { - var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); - var accepted = 0; - - Parallel.For(0, 1_000, _ => + foreach (var (value, expected) in new[] + { + (0, TelemetryPolicyState.Blocked), + (3, TelemetryPolicyState.Allowed), + }) { - if (tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)) + SetPolicyValue(value); + var observed = MxcTelemetry.GetPolicy(); + if (observed != expected) { - Interlocked.Increment(ref accepted); + throw new InvalidOperationException( + $"telemetry policy key is NOT redirected to 'HKCU\\{_policySubkey}': wrote " + + $"AllowTelemetry={value} there but GetPolicy() returned {observed}. These tests " + + "refuse to run against the real machine policy. The native mxc_ffi library under " + + "test is most likely a release build, which compiles out the " + + PolicyOverrideEnvVar + " override. Rebuild it with " + + "`cargo build -p mxc_ffi --features dotnetsdk,test-support` (debug) and re-run."); } - }); + } - Assert.Equal(1, accepted); + // Leave the fixture in the unmanaged default state. + SetPolicyValue(null); } - [Fact] - public void FailClosedDiagnosticTracker_EnforcesCapacityUnderConcurrentLoad() + /// + /// Writes the administrative AllowTelemetry policy value into the + /// redirected key, or removes it when is null. + /// + private void SetPolicyValue(int? value) { - var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); - var accepted = 0; - - Parallel.For(0, 1_000, index => + if (!OperatingSystem.IsWindows()) { - if (tracker.TryAdd("GetPolicy", "Blocked", "Exception", index)) - { - Interlocked.Increment(ref accepted); - } - }); - - Assert.Equal(64, accepted); - } + return; + } - [Fact] - public void FailClosedDiagnostics_DoNotPropagateTraceListenerFailures() - { - var operation = $"ThrowingTrace{Guid.NewGuid():N}"; - using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( - new MxcTelemetry.FailureCategoryTracker(capacity: 64)); - using var listener = new ThrowingTraceListener(); - Trace.Listeners.Add(listener); - try + using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)!; + if (value is null) { - var exception = Record.Exception(() => - MxcTelemetry.ReportFailClosed( - operation, - "Blocked", - ErrorCode.BackendError)); - Assert.Null(exception); + key.DeleteValue("AllowTelemetry", throwOnMissingValue: false); } - finally + else { - Trace.Listeners.Remove(listener); + key.SetValue("AllowTelemetry", value.Value, Microsoft.Win32.RegistryValueKind.DWord); } + } - using var output = new StringWriter(); - using var capture = new TextWriterTraceListener(output); - Trace.Listeners.Add(capture); - try - { - MxcTelemetry.ReportFailClosed(operation, "Blocked", ErrorCode.BackendError); - Trace.Flush(); - Assert.Contains(operation, output.ToString()); - } - finally + /// + /// Writes AllowTelemetry as a REG_SZ rather than a + /// REG_DWORD — the mistake an administrator makes by typing the + /// value in by hand. + /// + private void SetPolicyStringValue(string value) + { + if (!OperatingSystem.IsWindows()) { - Trace.Listeners.Remove(capture); + return; } + + using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(_policySubkey)!; + key.SetValue("AllowTelemetry", value, Microsoft.Win32.RegistryValueKind.String); } - [Fact] - public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() + /// + /// Prove — without writing anything through the SDK — that the native + /// library under test actually honours MXC_TEST_LOCALAPPDATA_OVERRIDE. + /// A release-profile mxc_ffi compiles the override out, in which + /// case every test below would silently read and (worse) overwrite the + /// real per-user consent file. Two probes are used because a single one + /// could coincidentally match the real store's state; the real store + /// cannot be both granted and denied, so two matching reads prove the + /// redirect. The test seeds records directly and exercises only the SDK + /// read path. + /// + private void AssertStoreIsRedirected() { - var native = new FakeTelemetryQueryApi - { - GetConsentImpl = () => throw new DllNotFoundException("sensitive path"), - NeedsConsentPromptImpl = () => new((int)ErrorCode.BackendError, true), - GetPolicyImpl = () => throw new InvalidOperationException("sensitive policy"), - }; - using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); - using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( - new MxcTelemetry.FailureCategoryTracker(capacity: 64)); - using var output = new StringWriter(); - using var listener = new TextWriterTraceListener(output); - Trace.Listeners.Add(listener); - try + if (!OperatingSystem.IsWindows()) { - Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); - Assert.False(MxcTelemetry.NeedsConsentPrompt()); - Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); - Trace.Flush(); - - var message = output.ToString(); - Assert.Contains("GetConsent", message); - Assert.Contains("NeedsConsentPrompt", message); - Assert.Contains("GetPolicy", message); - Assert.DoesNotContain("sensitive", message); + // Off Windows GetConsent short-circuits to NotApplicable without + // touching any store, so there is nothing to redirect. + return; } - finally + + foreach (var (value, expected) in new[] + { + ("granted", TelemetryConsentState.Granted), + ("denied", TelemetryConsentState.Denied), + }) { - Trace.Listeners.Remove(listener); + WriteConsentRecord(value); + var observed = MxcTelemetry.GetConsent(); + if (observed != expected) + { + throw new InvalidOperationException( + $"telemetry consent store is NOT redirected to '{_tempDir}': wrote '{value}' " + + $"there but GetConsent() returned {observed}. These tests refuse to run against " + + "the real per-user consent file. The native mxc_ffi library under test is most " + + "likely a release build, which compiles out the " + OverrideEnvVar + " override. " + + "Rebuild it with `cargo build -p mxc_ffi --features dotnetsdk,test-support` (debug) and re-run."); + } } + + File.Delete(ConsentFilePath()); } - [Theory] - [InlineData(true)] - [InlineData(false)] - public void ReadOnlyQueryFailures_CoverStatusAndExceptionPaths(bool throwException) - { - var native = new FakeTelemetryQueryApi - { - GetConsentImpl = () => throwException - ? throw new InvalidOperationException("consent") - : new((int)ErrorCode.BackendError, null), - NeedsConsentPromptImpl = () => throwException - ? throw new InvalidOperationException("prompt") - : new((int)ErrorCode.BackendError, true), - GetPolicyImpl = () => throwException - ? throw new InvalidOperationException("policy") - : new((int)ErrorCode.BackendError, null), - }; - using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); - using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( - new MxcTelemetry.FailureCategoryTracker(capacity: 64)); - using var output = new StringWriter(); - using var listener = new TextWriterTraceListener(output); - Trace.Listeners.Add(listener); - try + private string ConsentFilePath() => Path.Combine(_tempDir, "mxc", "telemetry-consent.json"); + + private void WriteConsentRecord(string consent) + { + var path = ConsentFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText( + path, + $$"""{"schemaVersion":2,"consent":"{{consent}}","source":"test","promptedMxcVersion":"0.0.0","promptResourceVersion":3,"promptLocale":"en-US","updatedAtEpoch":0}"""); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(OverrideEnvVar, _originalOverride); + Environment.SetEnvironmentVariable(OverrideOwnerEnvVar, _originalOverrideOwner); + Environment.SetEnvironmentVariable(PolicyOverrideEnvVar, _originalPolicyOverride); + Environment.SetEnvironmentVariable(PolicyOverrideOwnerEnvVar, _originalPolicyOverrideOwner); + if (OperatingSystem.IsWindows()) { - if (throwException) + try { - Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false); } - else + catch (UnauthorizedAccessException) { - var exception = Assert.Throws(() => MxcTelemetry.GetConsent()); - Assert.Equal(ErrorCode.BackendError, exception.Code); + // Best-effort cleanup; not worth failing the test run over. } - Assert.False(MxcTelemetry.NeedsConsentPrompt()); - Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); - Trace.Flush(); - - var messages = output - .ToString() - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) - .Where(line => line.Contains("mxc:", StringComparison.Ordinal)) - .ToArray(); - Assert.Equal(throwException ? 3 : 2, messages.Length); - Assert.Contains(messages, line => line.Contains("NeedsConsentPrompt", StringComparison.Ordinal)); - Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal)); - Assert.All( - messages, - line => Assert.Contains( - throwException - ? typeof(InvalidOperationException).FullName! - : nameof(ErrorCode.BackendError), - line)); } - finally + try { - Trace.Listeners.Remove(listener); + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup; not worth failing the test run over. } } [Fact] - public void ReadOnlyQueryNullPayloads_ReportAndReturnFailClosedValues() + public void GetConsent_FreshStore_ReportsUndeterminedOnWindowsOrNotApplicableElsewhere() { - var native = new FakeTelemetryQueryApi - { - GetConsentImpl = () => new((int)ErrorCode.Success, null), - NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), - GetPolicyImpl = () => new((int)ErrorCode.Success, null), - }; - using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); - using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( - new MxcTelemetry.FailureCategoryTracker(capacity: 64)); - using var output = new StringWriter(); - using var listener = new TextWriterTraceListener(output); - Trace.Listeners.Add(listener); - try - { - Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); - Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); - Trace.Flush(); - - var messages = output - .ToString() - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) - .Where(line => line.Contains("mxc:", StringComparison.Ordinal)) - .ToArray(); - Assert.Equal(2, messages.Length); - Assert.Contains(messages, line => line.Contains("GetConsent", StringComparison.Ordinal)); - Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal)); - Assert.All( - messages, - line => Assert.Contains(typeof(InvalidOperationException).FullName!, line)); - Assert.DoesNotContain("payload was missing", output.ToString()); - } - finally - { - Trace.Listeners.Remove(listener); - } + var state = MxcTelemetry.GetConsent(); + var expected = OperatingSystem.IsWindows() + ? TelemetryConsentState.Undetermined + : TelemetryConsentState.NotApplicable; + Assert.Equal(expected, state); } [Fact] - public void TelemetryQueryApiOverride_AllowsConcurrentDispose() + public void RequestConsent_ThenWithdraw_RoundTrips_OnWindows() { - var calls = 0; - var native = new FakeTelemetryQueryApi + if (!OperatingSystem.IsWindows()) { - GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"), - NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), - GetPolicyImpl = () => - { - Interlocked.Increment(ref calls); - return new((int)ErrorCode.Success, "blocked"); - }, - }; - var scope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + return; + } - Parallel.Invoke(scope.Dispose, scope.Dispose); + var outcome = MxcTelemetry.RequestConsent(prompt => + { + Assert.Equal(3u, prompt.ResourceVersion); + Assert.Equal("en-US", prompt.Locale); + Assert.Equal("Help improve Microsoft Products", prompt.Title.Text); + Assert.Equal( + """ + Help improve MXC and other Microsoft product including Windows 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. + """.ReplaceLineEndings("\n"), + prompt.Body.Text); + Assert.Equal("Yes", prompt.AffirmativeLabel.Text); + Assert.Equal("No", prompt.NegativeLabel.Text); + Assert.Equal("Privacy Statement", prompt.LearnMoreLabel.Text); + Assert.Equal("https://go.microsoft.com/fwlink/?linkid=521839", prompt.LearnMoreUrl); + return TelemetryConsentDecision.Yes; + }); + Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + + var withdrawal = MxcTelemetry.WithdrawConsent(); + Assert.Equal(TelemetryConsentActionResult.Withdrawn, withdrawal.Result); + Assert.Equal(TelemetryConsentState.Denied, MxcTelemetry.GetConsent()); + } - _ = MxcTelemetry.GetPolicy(); - Assert.Equal(0, calls); + [Fact] + public void NeedsConsentPrompt_FreshStore_IsTrueOnWindowsAndFalseElsewhere() + { + // Off Windows MXC collects nothing, so a host must never be told to + // ask — prompting there would be a privacy defect, not just noise. + Assert.Equal(OperatingSystem.IsWindows(), MxcTelemetry.NeedsConsentPrompt()); } [Fact] - public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose() + public void NeedsConsentPrompt_AfterAnyDecision_IsFalse_OnWindows() { - var native = new FakeTelemetryQueryApi + if (!OperatingSystem.IsWindows()) { - GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"), - NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), - GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"), - }; - using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); - Assert.Throws( - () => MxcTelemetry.OverrideTelemetryReadApiForTesting(native)); - Parallel.Invoke(nativeScope.Dispose, nativeScope.Dispose); + return; + } - var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); - using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); - Assert.Throws( - () => MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker)); - Parallel.Invoke(trackerScope.Dispose, trackerScope.Dispose); + var denied = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.No); + Assert.Equal(TelemetryConsentActionResult.Denied, denied.Result); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); - using var restoredNativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); - using var restoredTrackerScope = - MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); + var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + Assert.Equal(TelemetryConsentActionResult.Granted, granted.Result); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); } [Fact] - public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() + public void RequestConsent_NonWindows_IsNotApplicableAndDoesNotPresent() { if (OperatingSystem.IsWindows()) { return; } - var called = false; + var presented = false; var outcome = MxcTelemetry.RequestConsent(_ => { - called = true; + presented = true; return TelemetryConsentDecision.Yes; }); - - Assert.False(called); - Assert.Equal(TelemetryConsentResult.NotApplicable, outcome.Result); - Assert.Equal(TelemetryPolicyState.NotApplicable, outcome.Policy); + Assert.False(presented); + Assert.Equal(TelemetryConsentActionResult.NotApplicable, outcome.Result); } [Fact] @@ -406,7 +372,7 @@ public void RequestConsentAsync_RejectsLocaleWithEmbeddedNulSynchronously() () => { _ = MxcTelemetry.RequestConsentAsync( - _ => Task.FromResult(TelemetryConsentDecision.Yes), + _ => ValueTask.FromResult(TelemetryConsentDecision.Yes), "en-US\0ignored", TestContext.Current.CancellationToken); }); @@ -456,19 +422,135 @@ public void ParseConsentPrompt_RejectsNullRequiredStrings(string path) Assert.Contains(path, error.Message, StringComparison.Ordinal); } -#if DEBUG [Fact] - public void RequestConsent_PresenterExceptionReturnsBackendError_OnWindows() + public void GetPolicy_NoPolicyConfigured_IsUnrestrictedOnWindowsAndNotApplicableElsewhere() + { + SetPolicyValue(null); + var expected = OperatingSystem.IsWindows() + ? TelemetryPolicyState.Unrestricted + : TelemetryPolicyState.NotApplicable; + Assert.Equal(expected, MxcTelemetry.GetPolicy()); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(42)] + [InlineData(-1)] + public void GetPolicy_AnyValueOtherThanOptional_IsBlocked_OnWindows(int value) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // MXC's data is product-and-service-usage (optional) diagnostic data, + // so only level 3 permits it. Unrecognised values fail closed rather + // than being treated as "no policy". + SetPolicyValue(value); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + } + + [Fact] + public void GetPolicy_Optional_IsAllowed_OnWindows() { if (!OperatingSystem.IsWindows()) { return; } - using var _ = new TelemetryTestEnv(); - var ex = Assert.Throws(() => - MxcTelemetry.RequestConsent(_ => throw new InvalidOperationException("boom"))); - Assert.Equal(ErrorCode.BackendError, ex.Code); + SetPolicyValue(3); + Assert.Equal(TelemetryPolicyState.Allowed, MxcTelemetry.GetPolicy()); + } + + /// + /// An administrator who sets AllowTelemetry as a string instead of a + /// DWORD has still expressed an intent to manage this machine. The value + /// cannot be evaluated, so it must fail closed to Blocked — never be read + /// as an unmanaged machine, which would let a prior consent grant + /// re-enable the collection the administrator meant to stop. + /// + [Theory] + [InlineData("0")] + [InlineData("3")] + [InlineData("")] + [InlineData("not-a-number")] + public void GetPolicy_WrongValueType_IsBlockedNotUnrestricted_OnWindows(string value) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + SetPolicyStringValue(value); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + } + + [Fact] + public void GetPolicy_IsNeverAGrant_ConsentIsStillRequired_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // An administrator permitting telemetry must not stand in for the + // user's own decision: the prompt is still owed. + SetPolicyValue(3); + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.True(MxcTelemetry.NeedsConsentPrompt()); + } + + [Fact] + public void GetPolicy_BlockedSuppressesTheConsentPrompt_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // There is no point asking a user for permission an administrator has + // already refused, but the recorded consent state is left untouched so + // relaxing the policy later restores the user's real choice. + SetPolicyValue(0); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + + var presented = false; + var blocked = MxcTelemetry.RequestConsent(_ => + { + presented = true; + return TelemetryConsentDecision.Yes; + }); + Assert.False(presented); + Assert.Equal(TelemetryConsentActionResult.PolicyBlocked, blocked.Result); + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + + SetPolicyValue(null); + Assert.Equal(TelemetryPolicyState.Unrestricted, MxcTelemetry.GetPolicy()); + var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + Assert.Equal(TelemetryConsentActionResult.Granted, granted.Result); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + } + + [Fact] + public async Task RequestConsentAsync_PersistsPresenterDecision_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var outcome = await MxcTelemetry.RequestConsentAsync( + prompt => ValueTask.FromResult( + prompt.Locale == "en-US" + ? TelemetryConsentDecision.Yes + : TelemetryConsentDecision.Dismissed), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); } [Fact] @@ -479,7 +561,6 @@ public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWi return; } - using var _ = new TelemetryTestEnv(); using var context = new PumpSynchronizationContext(); var previousContext = SynchronizationContext.Current; SynchronizationContext.SetSynchronizationContext(context); @@ -497,7 +578,7 @@ public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWi }, cancellationToken: TestContext.Current.CancellationToken); context.RunUntilCompleted(request); - Assert.Equal(TelemetryConsentResult.Granted, (await request).Result); + Assert.Equal(TelemetryConsentActionResult.Granted, (await request).Result); } finally { @@ -513,7 +594,6 @@ public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNot return; } - using var _ = new TelemetryTestEnv(); using var cancellation = new CancellationTokenSource(); var presenterStarted = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); @@ -523,7 +603,7 @@ public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNot var request = MxcTelemetry.RequestConsentAsync(_ => { presenterStarted.SetResult(); - return stalledPresenter.Task; + return new ValueTask(stalledPresenter.Task); }, cancellationToken: cancellation.Token); await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken); @@ -534,6 +614,7 @@ public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNot Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); Assert.Same(request, finished); await Assert.ThrowsAnyAsync(() => request); + Assert.True(request.IsCanceled); Assert.Equal( TelemetryConsentState.Undetermined, MxcTelemetry.GetConsentStatus().StoredState); @@ -547,7 +628,6 @@ public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_O return; } - using var _ = new TelemetryTestEnv(); using var cancellation = new CancellationTokenSource(); using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( new MxcTelemetry.FailureCategoryTracker(capacity: 64)); @@ -564,12 +644,13 @@ public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_O var request = MxcTelemetry.RequestConsentAsync(_ => { presenterStarted.SetResult(); - return presenter.Task; + return new ValueTask(presenter.Task); }, cancellationToken: cancellation.Token); await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => request); + Assert.True(request.IsCanceled); presenter.SetException(new InvalidOperationException("late presenter failure")); var diagnostic = await listener.Message.WaitAsync(TestContext.Current.CancellationToken); @@ -594,12 +675,11 @@ public async Task RequestConsentAsync_ExplicitDismissalCompletesWithoutCancellat return; } - using var _ = new TelemetryTestEnv(); var outcome = await MxcTelemetry.RequestConsentAsync( - _ => Task.FromResult(TelemetryConsentDecision.Dismissed), + _ => ValueTask.FromResult(TelemetryConsentDecision.Dismissed), cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(TelemetryConsentResult.Dismissed, outcome.Result); + Assert.Equal(TelemetryConsentActionResult.Dismissed, outcome.Result); Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); } @@ -611,7 +691,6 @@ public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPer return; } - using var _ = new TelemetryTestEnv(); using var cancellation = new CancellationTokenSource(); var previousContext = SynchronizationContext.Current; SynchronizationContext.SetSynchronizationContext(null); @@ -621,10 +700,11 @@ public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPer { cancellation.Cancel(); cancellation.Token.ThrowIfCancellationRequested(); - return Task.FromResult(TelemetryConsentDecision.Yes); + return ValueTask.FromResult(TelemetryConsentDecision.Yes); }, cancellationToken: cancellation.Token); await Assert.ThrowsAnyAsync(() => request); + Assert.True(request.IsCanceled); Assert.Equal( TelemetryConsentState.Undetermined, MxcTelemetry.GetConsentStatus().StoredState); @@ -640,12 +720,11 @@ public void FinalizeAsyncOutcome_PersistedDecisionWinsConcurrentCancellation() { using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); - var outcome = new TelemetryConsentOutcome - { - Result = TelemetryConsentResult.Granted, - StoredState = TelemetryConsentState.Granted, - EffectiveState = TelemetryConsentState.Granted, - }; + var outcome = new TelemetryConsentOutcome( + TelemetryConsentActionResult.Granted, + TelemetryConsentState.Granted, + TelemetryConsentState.Granted, + TelemetryPolicyState.Unrestricted); Assert.Same(outcome, MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); } @@ -655,155 +734,108 @@ public void FinalizeAsyncOutcome_CanceledDismissalThrows() { using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); - var outcome = new TelemetryConsentOutcome - { - Result = TelemetryConsentResult.Dismissed, - StoredState = TelemetryConsentState.Undetermined, - EffectiveState = TelemetryConsentState.Undetermined, - }; + var outcome = new TelemetryConsentOutcome( + TelemetryConsentActionResult.Dismissed, + TelemetryConsentState.Undetermined, + TelemetryConsentState.Undetermined, + TelemetryPolicyState.Unrestricted); Assert.ThrowsAny( () => MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); } + [Theory] + [InlineData(typeof(DllNotFoundException))] + [InlineData(typeof(EntryPointNotFoundException))] + [InlineData(typeof(TypeInitializationException))] + [InlineData(typeof(BadImageFormatException))] + public void NativeLoadFailures_AreTreatedAsFailClosed(Type exceptionType) + { + // These are the failures GetConsent must swallow into Undetermined + // rather than throwing out of a read-only status query. + // EntryPointNotFoundException in particular covers an mxc_ffi that + // loads but predates the consent entry points. + var ex = exceptionType == typeof(TypeInitializationException) + ? new TypeInitializationException("Microsoft.Mxc.Sdk.Native.NativeMethods", null) + : (Exception)Activator.CreateInstance(exceptionType)!; + Assert.True(MxcTelemetry.IsNativeLoadFailure(ex)); + } + [Fact] - public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows() + public void GenuineNativeFailures_AreNotSwallowed() { - if (!OperatingSystem.IsWindows()) - { - return; - } - - using var _ = new TelemetryTestEnv(); - Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); - Assert.True(MxcTelemetry.NeedsConsentPrompt()); - - var dismissed = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Dismissed); - Assert.Equal(TelemetryConsentResult.Dismissed, dismissed.Result); - Assert.Equal(TelemetryConsentState.Undetermined, dismissed.StoredState); - - var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); - Assert.Equal(TelemetryConsentResult.Granted, granted.Result); - Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); - Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsentStatus().EffectiveState); - Assert.False(MxcTelemetry.NeedsConsentPrompt()); - - var withdrawn = MxcTelemetry.WithdrawConsent(); - Assert.Equal(TelemetryConsentResult.Withdrawn, withdrawn.Result); - Assert.Equal(TelemetryConsentState.Denied, MxcTelemetry.GetConsent()); - Assert.False(MxcTelemetry.NeedsConsentPrompt()); + // A real failure reported by the native layer must not be + // misclassified as "library missing" and silently downgraded. + Assert.False(MxcTelemetry.IsNativeLoadFailure( + new MxcException(ErrorCode.ConsentWriteFailed, "boom"))); + Assert.False(MxcTelemetry.IsNativeLoadFailure(new InvalidOperationException())); } - [SupportedOSPlatform("windows")] - private sealed class TelemetryTestEnv : IDisposable + [Fact] + public void ReadOnlyQueries_NeverThrow() { - private static readonly SemaphoreSlim Gate = new(1, 1); + // Read-only status queries must fail closed without throwing. + var prompt = Record.Exception(() => MxcTelemetry.NeedsConsentPrompt()); + Assert.Null(prompt); - private readonly string? _originalLocalAppData; - private readonly string? _originalLocalAppDataOwnerPid; - private readonly string? _originalPolicyKey; - private readonly string? _originalPolicyOwnerPid; - private readonly string _storeDir; - private readonly string _policySubkey; - - public TelemetryTestEnv() - { - Gate.Wait(); - try - { - _originalLocalAppData = Environment.GetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE"); - _originalLocalAppDataOwnerPid = Environment.GetEnvironmentVariable( - "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"); - _originalPolicyKey = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE"); - _originalPolicyOwnerPid = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID"); - - _storeDir = Directory.CreateTempSubdirectory("mxc-dotnet-telemetry-").FullName; - _policySubkey = $@"Software\MxcTelemetryDotNetTests\{Guid.NewGuid():N}"; - - Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose(); - Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _storeDir); - Environment.SetEnvironmentVariable( - "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", - GetParentProcessId().ToString()); - Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _policySubkey); - Environment.SetEnvironmentVariable( - "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", - Environment.ProcessId.ToString()); - } - catch - { - Gate.Release(); - throw; - } - } - - public void Dispose() - { - Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _originalLocalAppData); - Environment.SetEnvironmentVariable( - "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", - _originalLocalAppDataOwnerPid); - Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _originalPolicyKey); - Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", _originalPolicyOwnerPid); + var policy = Record.Exception(() => MxcTelemetry.GetPolicy()); + Assert.Null(policy); - try - { - if (Directory.Exists(_storeDir)) - { - Directory.Delete(_storeDir, recursive: true); - } - } - catch - { - } - - try - { - Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false); - } - catch - { - } + var status = Record.Exception(() => MxcTelemetry.GetConsentStatus()); + Assert.Null(status); + } - Gate.Release(); - } + [Fact] + public void MxcException_PreservesTheUnderlyingCause() + { + // The read/write paths convert unexpected exceptions to MxcException so + // a raw type cannot escape a documented contract; that conversion must + // not lose the original, which is the only thing that explains why a + // broken install failed. + var cause = new DllNotFoundException("mxc_ffi not found"); + var ex = new MxcException(ErrorCode.ConsentWriteFailed, "could not persist", cause); + + Assert.Same(cause, ex.InnerException); + Assert.Equal(ErrorCode.ConsentWriteFailed, ex.Code); + Assert.Contains("could not persist", ex.ToString(), StringComparison.Ordinal); + Assert.Contains("mxc_ffi not found", ex.ToString(), StringComparison.Ordinal); + } - private static int GetParentProcessId() + private static int GetParentProcessId() + { + using var process = Process.GetCurrentProcess(); + var status = NtQueryInformationProcess( + process.Handle, + processInformationClass: 0, + out var processInformation, + Marshal.SizeOf(), + out _); + if (status != 0) { - using var process = Process.GetCurrentProcess(); - var status = NtQueryInformationProcess( - process.Handle, - processInformationClass: 0, - out var processInformation, - Marshal.SizeOf(), - out _); - if (status != 0) - { - throw new InvalidOperationException( - $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}"); - } - - return checked((int)processInformation.InheritedFromUniqueProcessId); + throw new InvalidOperationException( + $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}"); } - [DllImport("ntdll.dll", ExactSpelling = true)] - private static extern int NtQueryInformationProcess( - IntPtr processHandle, - int processInformationClass, - out ProcessBasicInformation processInformation, - int processInformationLength, - out int returnLength); + return checked((int)processInformation.InheritedFromUniqueProcessId); + } - [StructLayout(LayoutKind.Sequential)] - private struct ProcessBasicInformation - { - internal IntPtr Reserved1; - internal IntPtr PebBaseAddress; - internal IntPtr Reserved2_0; - internal IntPtr Reserved2_1; - internal IntPtr UniqueProcessId; - internal IntPtr InheritedFromUniqueProcessId; - } + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + out ProcessBasicInformation processInformation, + int processInformationLength, + out int returnLength); + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessBasicInformation + { + internal IntPtr Reserved1; + internal IntPtr PebBaseAddress; + internal IntPtr Reserved2_0; + internal IntPtr Reserved2_1; + internal IntPtr UniqueProcessId; + internal IntPtr InheritedFromUniqueProcessId; } private sealed class PumpSynchronizationContext : SynchronizationContext, IDisposable @@ -862,17 +894,7 @@ public override void TraceEvent( { Capture( eventType, - args is null || args.Length == 0 - ? format - : string.Format(format ?? string.Empty, args)); - } - - public override void Write(string? message) - { - } - - public override void WriteLine(string? message) - { + args is null ? format : string.Format(format ?? string.Empty, args)); } private void Capture(TraceEventType eventType, string? message) @@ -883,38 +905,13 @@ private void Capture(TraceEventType eventType, string? message) _message.TrySetResult(message); } } - } -#endif - - private sealed class FakeTelemetryQueryApi : MxcTelemetry.ITelemetryReadApi - { - internal required Func GetConsentImpl { get; init; } - internal required Func NeedsConsentPromptImpl { get; init; } - internal required Func GetPolicyImpl { get; init; } - public MxcTelemetry.NativePayloadResult GetConsent() => GetConsentImpl(); - - public MxcTelemetry.NativeBooleanResult NeedsConsentPrompt() => - NeedsConsentPromptImpl(); - - public MxcTelemetry.NativePayloadResult GetPolicy() => GetPolicyImpl(); - } - - private sealed class ThrowingTraceListener : TraceListener - { - public override void TraceEvent( - TraceEventCache? eventCache, - string? source, - TraceEventType eventType, - int id, - string? format, - params object?[]? args) => - throw new InvalidOperationException("listener failure"); - - public override void Write(string? message) => - throw new InvalidOperationException("listener failure"); + public override void Write(string? message) + { + } - public override void WriteLine(string? message) => - throw new InvalidOperationException("listener failure"); + public override void WriteLine(string? message) + { + } } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTestsReleaseSafe.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTestsReleaseSafe.cs new file mode 100644 index 000000000..e873e2fc4 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTestsReleaseSafe.cs @@ -0,0 +1,897 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Diagnostics; +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +[Collection("MxcTelemetry")] +public sealed class MxcTelemetryTestsReleaseSafe +{ + private static string ConsentPromptJson() => + """ + {"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"} + """; + + private static string ConsentOutcomeJson(string result, string storedState, string effectiveState, string policy = "unrestricted") => + $$"""{"result":"{{result}}","storedState":"{{storedState}}","effectiveState":"{{effectiveState}}","reason":null,"policy":"{{policy}}"}"""; + + private static string ConsentStatusJson(string storedState, string effectiveState, string policy, string? reason = null) + { + var reasonJson = reason is null ? "null" : $"\"{reason}\""; + return $$"""{"storedState":"{{storedState}}","effectiveState":"{{effectiveState}}","reason":{{reasonJson}},"policy":"{{policy}}"}"""; + } + + [Fact] + public void MockedTelemetrySeam_ExercisesThePublicStatusApisWithoutNativeBits() + { + var native = new FakeTelemetryNativeApi + { + GetConsentImpl = () => new((int)ErrorCode.Success, "granted"), + NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, true), + GetPolicyImpl = () => new((int)ErrorCode.Success, "allowed"), + GetConsentStatusImpl = () => new((int)ErrorCode.Success, ConsentStatusJson("granted", "granted", "allowed")), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + Assert.True(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Allowed, MxcTelemetry.GetPolicy()); + + var status = MxcTelemetry.GetConsentStatus(); + Assert.Equal(TelemetryConsentState.Granted, status.StoredState); + Assert.Equal(TelemetryConsentState.Granted, status.EffectiveState); + Assert.Equal(TelemetryPolicyState.Allowed, status.Policy); + } + + [Theory] + [InlineData(typeof(DllNotFoundException), TelemetryConsentState.Undetermined)] + [InlineData(typeof(EntryPointNotFoundException), TelemetryConsentState.Undetermined)] + [InlineData(typeof(BadImageFormatException), TelemetryConsentState.Undetermined)] + public void GetConsent_NativeLoadFailuresFailClosedThroughThePublicApi(Type exceptionType, TelemetryConsentState expected) + { + var native = new FakeTelemetryNativeApi + { + GetConsentImpl = () => throw (Exception)Activator.CreateInstance(exceptionType)!, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + Assert.Equal(expected, MxcTelemetry.GetConsent()); + } + + [Fact] + public void ReadOnlyQueries_NeverThrow_WhenTheNativeLayerFails() + { + var native = new FakeTelemetryNativeApi + { + GetConsentImpl = () => new((int)ErrorCode.Panic, null), + NeedsConsentPromptImpl = () => new((int)ErrorCode.Panic, false), + GetPolicyImpl = () => throw new DllNotFoundException("missing mxc_ffi"), + GetConsentStatusImpl = () => new((int)ErrorCode.Success, "{"), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + var status = MxcTelemetry.GetConsentStatus(); + Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, status.Policy); + } + + [Fact] + public void FailClosedDiagnostics_AreDeduplicatedAndRedacted() + { + var operation = $"TestGetPolicy{Guid.NewGuid():N}"; + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); + using var output = new StringWriter(); + using var listener = new TextWriterTraceListener(output); + var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage"); + Trace.Listeners.Add(listener); + try + { + MxcTelemetry.ReportFailClosed(operation, "Blocked", failure); + MxcTelemetry.ReportFailClosed(operation, "Blocked", failure); + MxcTelemetry.ReportFailClosed(operation, "false", ErrorCode.BackendError); + Trace.Flush(); + + var messages = output + .ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.Contains(operation, StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(2, messages.Length); + Assert.Contains(typeof(InvalidOperationException).FullName!, messages[0]); + Assert.Contains("HRESULT 0x", messages[0]); + Assert.DoesNotContain("sensitive", messages[0]); + Assert.DoesNotContain('\u001b', messages[0]); + Assert.Contains("BackendError", messages[1]); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [Fact] + public void FailClosedDiagnosticTracker_IsBoundedAndConcurrent() + { + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + var duplicateAccepted = 0; + Parallel.For(0, 1_000, _ => + { + if (tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)) + { + Interlocked.Increment(ref duplicateAccepted); + } + }); + Assert.Equal(1, duplicateAccepted); + + var capacityTracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + var capacityAccepted = 0; + Parallel.For(0, 1_000, index => + { + if (capacityTracker.TryAdd("GetPolicy", "Blocked", "Exception", index)) + { + Interlocked.Increment(ref capacityAccepted); + } + }); + Assert.Equal(64, capacityAccepted); + } + + [Fact] + public void FailClosedDiagnostics_RetryAfterTraceListenerFailure() + { + var operation = $"ThrowingTrace{Guid.NewGuid():N}"; + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); + using var throwingListener = new ThrowingTraceListener(); + Trace.Listeners.Add(throwingListener); + try + { + var exception = Record.Exception(() => + MxcTelemetry.ReportFailClosed( + operation, + "Blocked", + ErrorCode.BackendError)); + Assert.Null(exception); + } + finally + { + Trace.Listeners.Remove(throwingListener); + } + + using var output = new StringWriter(); + using var capture = new TextWriterTraceListener(output); + Trace.Listeners.Add(capture); + try + { + MxcTelemetry.ReportFailClosed(operation, "Blocked", ErrorCode.BackendError); + Trace.Flush(); + Assert.Contains(operation, output.ToString()); + } + finally + { + Trace.Listeners.Remove(capture); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadOnlyQueryFailures_ReportDistinctFailureKinds(bool throwException) + { + var native = new FakeTelemetryNativeApi + { + GetConsentImpl = () => throwException + ? throw new InvalidOperationException("consent") + : new((int)ErrorCode.BackendError, null), + NeedsConsentPromptImpl = () => throwException + ? throw new InvalidOperationException("prompt") + : new((int)ErrorCode.BackendError, true), + GetPolicyImpl = () => throwException + ? throw new InvalidOperationException("policy") + : new((int)ErrorCode.BackendError, null), + }; + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); + using var output = new StringWriter(); + using var listener = new TextWriterTraceListener(output); + Trace.Listeners.Add(listener); + try + { + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + Trace.Flush(); + + var messages = output + .ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.Contains("mxc:", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(3, messages.Length); + Assert.Contains(messages, line => line.Contains("GetConsent", StringComparison.Ordinal)); + Assert.Contains(messages, line => line.Contains("NeedsConsentPrompt", StringComparison.Ordinal)); + Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal)); + if (throwException) + { + Assert.All( + messages, + line => Assert.Contains( + typeof(InvalidOperationException).FullName!, + line)); + } + else + { + Assert.Contains( + messages, + line => line.Contains(typeof(MxcException).FullName!, StringComparison.Ordinal)); + Assert.Equal( + 2, + messages.Count(line => + line.Contains(nameof(ErrorCode.BackendError), StringComparison.Ordinal))); + } + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [Fact] + public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose() + { + var native = new FakeTelemetryNativeApi(); + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + Assert.Throws( + () => MxcTelemetry.OverrideNativeApiForTesting(native)); + Parallel.Invoke(nativeScope.Dispose, nativeScope.Dispose); + + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); + Assert.Throws( + () => MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker)); + Parallel.Invoke(trackerScope.Dispose, trackerScope.Dispose); + + using var restoredNativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var restoredTrackerScope = + MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); + } + + [Fact] + public void GetConsent_UnexpectedReadFailureFailsClosed() + { + var native = new FakeTelemetryNativeApi + { + GetConsentImpl = () => throw new InvalidOperationException("unexpected read failure"), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + } + + [Fact] + public void GetConsentStatus_NativeFailureDoesNotClaimTheStoreWasUnreadable() + { + var native = new FakeTelemetryNativeApi + { + GetConsentStatusImpl = () => new((int)ErrorCode.Panic, null), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var status = MxcTelemetry.GetConsentStatus(); + Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, status.Policy); + } + + [Fact] + public void RequestConsent_PresenterExceptionsPropagateWithTheOriginalCause() + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(-1, presenter(ConsentPromptJson())); + return new((int)ErrorCode.BackendError, null); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var ex = Assert.Throws(() => MxcTelemetry.RequestConsent(_ => throw new InvalidOperationException("UI unavailable"))); + Assert.Equal(ErrorCode.BackendError, ex.Code); + Assert.IsType(ex.InnerException); + Assert.Equal("UI unavailable", ex.InnerException!.Message); + } + + [Fact] + public async Task RequestConsentAsync_CancellationStopsWaitingForThePresenter() + { + var presenterEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var presenterCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(2, presenter(ConsentPromptJson())); + return new( + (int)ErrorCode.Success, + ConsentOutcomeJson("dismissed", "undetermined", "undetermined", "unrestricted")); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + using var cancellation = new CancellationTokenSource(); + + var request = MxcTelemetry.RequestConsentAsync( + _ => + { + presenterEntered.SetResult(); + return new ValueTask(presenterCompletion.Task); + }, + cancellationToken: cancellation.Token); + await presenterEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => request); + Assert.True(request.IsCanceled); + } + + [Fact] + public async Task RequestConsentAsync_CancellationBeforeUiDispatchSkipsPresenter() + { + var synchronizationContext = new QueuedSynchronizationContext(); + var presenterCalls = 0; + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(2, presenter(ConsentPromptJson())); + return new( + (int)ErrorCode.Success, + ConsentOutcomeJson("dismissed", "undetermined", "undetermined", "unrestricted")); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + using var cancellation = new CancellationTokenSource(); + + var previousContext = SynchronizationContext.Current; + Task request; + try + { + SynchronizationContext.SetSynchronizationContext(synchronizationContext); + request = MxcTelemetry.RequestConsentAsync( + _ => + { + presenterCalls += 1; + return ValueTask.FromResult(TelemetryConsentDecision.Yes); + }, + cancellationToken: cancellation.Token); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + + await synchronizationContext.WaitForPostAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => request); + Assert.True(request.IsCanceled); + + synchronizationContext.RunAll(); + Assert.Equal(0, presenterCalls); + } + + [Fact] + public async Task RequestConsentAsync_PostSuccessCancellationDoesNotHidePersistence() + { + using var cancellation = new CancellationTokenSource(); + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + cancellation.Cancel(); + return new( + (int)ErrorCode.Success, + ConsentOutcomeJson("granted", "granted", "granted", "unrestricted")); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = await MxcTelemetry.RequestConsentAsync( + _ => ValueTask.FromResult(TelemetryConsentDecision.Yes), + cancellationToken: cancellation.Token); + + Assert.Equal(TelemetryConsentActionResult.Granted, outcome.Result); + } + + [Theory] + [InlineData("{")] + [InlineData("""{"storedState":"granted"}""")] + [InlineData("""{"storedState":"granted","effectiveState":"granted","reason":null}""")] + public void GetConsentStatus_MalformedOrTruncatedJsonFailsClosed(string payload) + { + var native = new FakeTelemetryNativeApi + { + GetConsentStatusImpl = () => new((int)ErrorCode.Success, payload), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var status = MxcTelemetry.GetConsentStatus(); + Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, status.Policy); + } + + [Theory] + [InlineData("alreadyGranted", TelemetryConsentActionResult.AlreadyGranted)] + [InlineData("policyBlocked", TelemetryConsentActionResult.PolicyBlocked)] + [InlineData("notApplicable", TelemetryConsentActionResult.NotApplicable)] + public void RequestConsent_ParsesCamelCaseWireResults(string result, TelemetryConsentActionResult expected) + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new((int)ErrorCode.Success, ConsentOutcomeJson(result, "undetermined", "undetermined", "blocked")); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + Assert.Equal(expected, outcome.Result); + } + + [Fact] + public void RequestConsent_UnknownWireResultReturnsFailClosedSentinel() + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new( + (int)ErrorCode.Success, + ConsentOutcomeJson("futureResult", "granted", "granted", "allowed")); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + + Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy); + } + + [Fact] + public void WithdrawConsent_UnknownWireResultReturnsFailClosedSentinel() + { + var native = new FakeTelemetryNativeApi + { + WithdrawConsentImpl = () => new( + (int)ErrorCode.Success, + ConsentOutcomeJson("futureResult", "granted", "granted", "allowed")), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = MxcTelemetry.WithdrawConsent(); + + Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy); + } + + [Fact] + public void RequestConsent_WithdrawnResultReturnsFailClosedSentinel() + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new( + (int)ErrorCode.Success, + ConsentOutcomeJson("withdrawn", "granted", "granted", "allowed")); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + + Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy); + } + + [Theory] + [InlineData("granted")] + [InlineData("denied")] + [InlineData("dismissed")] + [InlineData("alreadyGranted")] + [InlineData("policyBlocked")] + [InlineData("presentationUnavailable")] + public void WithdrawConsent_RequestResultReturnsFailClosedSentinel(string result) + { + var native = new FakeTelemetryNativeApi + { + WithdrawConsentImpl = () => new( + (int)ErrorCode.Success, + ConsentOutcomeJson(result, "granted", "granted", "allowed")), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = MxcTelemetry.WithdrawConsent(); + + Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy); + } + + [Fact] + public void RequestConsent_UnknownWireReasonReturnsFailClosedSentinel() + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new( + (int)ErrorCode.Success, + """{"result":"granted","storedState":"granted","effectiveState":"granted","reason":"future-reason","policy":"allowed"}"""); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + + Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy); + } + + [Theory] + [InlineData(null)] + [InlineData("{")] + [InlineData("""{"result":"granted"}""")] + public void RequestConsent_MalformedSuccessPayloadIsBackendError(string? payload) + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new((int)ErrorCode.Success, payload); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var ex = Assert.Throws( + () => MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes)); + Assert.Equal(ErrorCode.BackendError, ex.Code); + Assert.NotNull(ex.InnerException); + } + + [Theory] + [InlineData("policy-blocked")] + [InlineData("presentation-unavailable")] + public void GetConsentStatus_AcceptsPrivateStatusReasons(string reason) + { + var native = new FakeTelemetryNativeApi + { + GetConsentStatusImpl = () => new( + (int)ErrorCode.Success, + ConsentStatusJson("undetermined", "undetermined", "blocked", reason)), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var status = MxcTelemetry.GetConsentStatus(); + Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, status.Policy); + } + + [Fact] + public void GetConsentStatus_UnknownWireReasonReturnsFailClosedSentinel() + { + var native = new FakeTelemetryNativeApi + { + GetConsentStatusImpl = () => new( + (int)ErrorCode.Success, + ConsentStatusJson("granted", "granted", "allowed", "future-reason")), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var status = MxcTelemetry.GetConsentStatus(); + + Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, status.Policy); + } + + [Theory] + [InlineData("future-state", "granted", "allowed")] + [InlineData("granted", "future-state", "allowed")] + [InlineData("granted", "granted", "future-policy")] + public void GetConsentStatus_UnknownFieldReturnsWholeFailClosedSentinel( + string storedState, + string effectiveState, + string policy) + { + var native = new FakeTelemetryNativeApi + { + GetConsentStatusImpl = () => new( + (int)ErrorCode.Success, + ConsentStatusJson(storedState, effectiveState, policy)), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var status = MxcTelemetry.GetConsentStatus(); + + Assert.Equal(TelemetryConsentState.Undetermined, status.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, status.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, status.Policy); + } + + [Theory] + [InlineData("future-state", "granted", "allowed")] + [InlineData("granted", "future-state", "allowed")] + [InlineData("granted", "granted", "future-policy")] + public void RequestConsent_UnknownFieldReturnsWholeFailClosedSentinel( + string storedState, + string effectiveState, + string policy) + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new( + (int)ErrorCode.Success, + ConsentOutcomeJson("granted", storedState, effectiveState, policy)); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var outcome = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + + Assert.Equal(TelemetryConsentActionResult.Unknown, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.EffectiveState); + Assert.Equal(TelemetryPolicyState.Blocked, outcome.Policy); + } + + [Fact] + public void RequestAndWithdrawConsent_ReturnNotApplicableOffWindowsWithoutTouchingNative() + { + var nativeCalls = 0; + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, _presenter) => + { + nativeCalls += 1; + return new((int)ErrorCode.Success, ConsentOutcomeJson("granted", "granted", "granted")); + }, + WithdrawConsentImpl = () => + { + nativeCalls += 1; + return new((int)ErrorCode.Success, ConsentOutcomeJson("withdrawn", "denied", "denied")); + }, + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(false); + + var presented = false; + var request = MxcTelemetry.RequestConsent(_ => + { + presented = true; + return TelemetryConsentDecision.Yes; + }); + var withdraw = MxcTelemetry.WithdrawConsent(); + + Assert.False(presented); + Assert.Equal(0, nativeCalls); + Assert.Equal(TelemetryConsentActionResult.NotApplicable, request.Result); + Assert.Equal(TelemetryConsentState.NotApplicable, request.StoredState); + Assert.Equal(TelemetryPolicyState.NotApplicable, request.Policy); + Assert.Equal(TelemetryConsentActionResult.NotApplicable, withdraw.Result); + Assert.Equal(TelemetryConsentState.NotApplicable, withdraw.StoredState); + Assert.Equal(TelemetryPolicyState.NotApplicable, withdraw.Policy); + } + + [Fact] + public void WithdrawConsent_UnexpectedNativeStatusSurfacesAsMxcException() + { + var native = new FakeTelemetryNativeApi + { + WithdrawConsentImpl = () => new(777, null), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var ex = Assert.Throws(() => MxcTelemetry.WithdrawConsent()); + Assert.Equal(777, (int)ex.Code); + } + + [Theory] + [InlineData(null)] + [InlineData("{")] + [InlineData("""{"result":"withdrawn"}""")] + public void WithdrawConsent_MalformedSuccessPayloadIsBackendError(string? payload) + { + var native = new FakeTelemetryNativeApi + { + WithdrawConsentImpl = () => new((int)ErrorCode.Success, payload), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var ex = Assert.Throws(() => MxcTelemetry.WithdrawConsent()); + Assert.Equal(ErrorCode.BackendError, ex.Code); + Assert.NotNull(ex.InnerException); + } + + [Fact] + public void ConsentWriteFailureStatusIsPreservedAcrossRequestAndWithdrawal() + { + var native = new FakeTelemetryNativeApi + { + RequestConsentImpl = (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new((int)ErrorCode.ConsentWriteFailed, null); + }, + WithdrawConsentImpl = () => new((int)ErrorCode.ConsentWriteFailed, null), + }; + + using var nativeScope = MxcTelemetry.OverrideNativeApiForTesting(native); + using var platformScope = MxcTelemetry.OverrideWindowsHostForTesting(true); + + var request = Assert.Throws( + () => MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes)); + Assert.Equal(ErrorCode.ConsentWriteFailed, request.Code); + + var withdraw = Assert.Throws(() => MxcTelemetry.WithdrawConsent()); + Assert.Equal(ErrorCode.ConsentWriteFailed, withdraw.Code); + } + + private sealed class FakeTelemetryNativeApi : MxcTelemetry.ITelemetryNativeApi + { + public Func GetConsentImpl { get; init; } = + () => new((int)ErrorCode.Success, "undetermined"); + + public Func NeedsConsentPromptImpl { get; init; } = + () => new((int)ErrorCode.Success, false); + + public Func GetPolicyImpl { get; init; } = + () => new((int)ErrorCode.Success, "unrestricted"); + + public Func GetConsentStatusImpl { get; init; } = + () => new((int)ErrorCode.Success, ConsentStatusJson("undetermined", "undetermined", "blocked", "store-unreadable")); + + public Func WithdrawConsentImpl { get; init; } = + () => new((int)ErrorCode.Success, ConsentOutcomeJson("withdrawn", "denied", "denied", "unrestricted")); + + public Func, MxcTelemetry.NativePayloadResult> RequestConsentImpl { get; init; } = + (_locale, presenter) => + { + Assert.Equal(1, presenter(ConsentPromptJson())); + return new((int)ErrorCode.Success, ConsentOutcomeJson("granted", "granted", "granted", "unrestricted")); + }; + + public MxcTelemetry.NativePayloadResult GetConsent() => GetConsentImpl(); + + public MxcTelemetry.NativeBooleanResult NeedsConsentPrompt() => NeedsConsentPromptImpl(); + + public MxcTelemetry.NativePayloadResult GetPolicy() => GetPolicyImpl(); + + public MxcTelemetry.NativePayloadResult GetConsentStatus() => GetConsentStatusImpl(); + + public MxcTelemetry.NativePayloadResult WithdrawConsent() => WithdrawConsentImpl(); + + public MxcTelemetry.NativePayloadResult RequestConsent(string? locale, Func presenter) => + RequestConsentImpl(locale, presenter); + } + + private sealed class ThrowingTraceListener : TraceListener + { + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? format, + params object?[]? args) => + throw new InvalidOperationException("listener failure"); + + public override void Write(string? message) => + throw new InvalidOperationException("listener failure"); + + public override void WriteLine(string? message) => + throw new InvalidOperationException("listener failure"); + } + + private sealed class QueuedSynchronizationContext : SynchronizationContext + { + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> callbacks = new(); + private readonly TaskCompletionSource posted = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public override void Post(SendOrPostCallback callback, object? state) + { + callbacks.Enqueue((callback, state)); + posted.TrySetResult(); + } + + public Task WaitForPostAsync(CancellationToken cancellationToken) => + posted.Task.WaitAsync(cancellationToken); + + public void RunAll() + { + while (callbacks.TryDequeue(out var item)) + { + item.Callback(item.State); + } + } + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs index 8483096de..215634eab 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcException.cs @@ -62,20 +62,30 @@ internal MxcException( Remediation = remediation; } + /// + /// Create an exception with the given code and message while preserving + /// the underlying cause for diagnosis. + /// + internal MxcException(ErrorCode code, string message, Exception innerException) + : base(message, innerException) + { + Code = code; + } + /// /// /// Appends the operation and status when present, so a caller that only - /// logs the exception keeps the diagnosis rather than losing it. + /// logs the exception keeps the diagnosis rather than losing it. An + /// underlying cause is appended when present. /// public override string ToString() { - if (Operation is null) - { - return $"{Code}: {Message}"; - } + string detail = Operation is null + ? $"{Code}: {Message}" + : NativeCode is null + ? $"{Code}: {Message} [{Operation}]" + : $"{Code}: {Message} [{Operation} {NativeCode}]"; - return NativeCode is null - ? $"{Code}: {Message} [{Operation}]" - : $"{Code}: {Message} [{Operation} {NativeCode}]"; + return InnerException is null ? detail : $"{detail} ---> {InnerException}"; } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs index f58f94f6d..cd01655db 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs @@ -142,6 +142,8 @@ internal static JsonObject BuildProvisionEnvelope( break; } + ApplyTelemetry(envelope, options?.Telemetry, options?.Version); + return envelope; } @@ -159,8 +161,13 @@ public static void DryRunStartSandbox(SandboxId id, StateAwarePhaseOptions? opti internal static JsonObject BuildStartEnvelope( SandboxId id, - StateAwarePhaseOptions? options = null) => - BuildIdEnvelope("start", id, options?.Version); + StateAwarePhaseOptions? options = null) + { + ValidateNonExecOptions("start", options); + var envelope = BuildIdEnvelope("start", id, options?.Version); + ApplyTelemetry(envelope, options?.Telemetry, options?.Version); + return envelope; + } /// /// Run a command in a started sandbox and return live stdio streams. @@ -280,6 +287,7 @@ internal static JsonObject BuildExecEnvelope( { envelope["network"] = SerializeToNode(network); } + ApplyTelemetry(envelope, options?.Telemetry, options?.Version); return envelope; } @@ -299,8 +307,9 @@ public static async Task ExecInSandboxAsync( StateAwareExecOptions? options, CancellationToken cancellationToken = default) { - var proc = await Task.Run( + var proc = await RunBlockingOperationAsync( () => ExecInSandbox(id, command, options), + lateProc => lateProc.Dispose(), cancellationToken) .ConfigureAwait(false); try @@ -324,6 +333,45 @@ public static async Task ExecInSandboxAsync( } } + // Runs a synchronous blocking call on a background thread so it can be + // awaited with cancellation. If the caller cancels while the operation is + // still running the returned Task faults with an OperationCanceledException + // immediately, but the background call is *not* aborted — it continues to + // completion and, if it produced a resource the caller would otherwise own, + // the late-result cleanup callback is invoked so the resource isn't leaked. + internal static async Task RunBlockingOperationAsync( + Func operation, + Action disposeLateResult, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var task = Task.Run(operation, cancellationToken); + try + { + return await task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _ = task.ContinueWith( + t => + { + if (t.Status == TaskStatus.RanToCompletion) + { + try { disposeLateResult(t.Result); } + catch { /* best-effort cleanup */ } + } + else if (t.IsFaulted) + { + _ = t.Exception; + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw; + } + } + /// Stop a running sandbox. public static void StopSandbox(SandboxId id, StateAwarePhaseOptions? options = null) { @@ -338,8 +386,13 @@ public static void DryRunStopSandbox(SandboxId id, StateAwarePhaseOptions? optio internal static JsonObject BuildStopEnvelope( SandboxId id, - StateAwarePhaseOptions? options = null) => - BuildIdEnvelope("stop", id, options?.Version); + StateAwarePhaseOptions? options = null) + { + ValidateNonExecOptions("stop", options); + var envelope = BuildIdEnvelope("stop", id, options?.Version); + ApplyTelemetry(envelope, options?.Telemetry, options?.Version); + return envelope; + } /// Destroy a sandbox and release its resources. public static void DeprovisionSandbox( @@ -359,8 +412,26 @@ public static void DryRunDeprovisionSandbox( internal static JsonObject BuildDeprovisionEnvelope( SandboxId id, - StateAwarePhaseOptions? options = null) => - BuildIdEnvelope("deprovision", id, options?.Version); + StateAwarePhaseOptions? options = null) + { + ValidateNonExecOptions("deprovision", options); + var envelope = BuildIdEnvelope("deprovision", id, options?.Version); + ApplyTelemetry(envelope, options?.Telemetry, options?.Version); + return envelope; + } + + private static void ValidateNonExecOptions( + string phase, + StateAwarePhaseOptions? options) + { + if (options is StateAwareExecOptions) + { + throw new ArgumentException( + $"{options.GetType().Name} cannot configure the {phase} phase; " + + $"use {nameof(StateAwarePhaseOptions)}.", + nameof(options)); + } + } private static JsonObject BuildIdEnvelope( string phase, @@ -381,6 +452,24 @@ private static JsonObject BuildIdEnvelope( ["phase"] = phase, }; + // Stable, top-level telemetry request for this phase. Consent and + // administrative policy still gate emission independently. Never carries a + // caller-supplied correlationVector — that identifier is internal-only. + private static void ApplyTelemetry( + JsonObject envelope, + TelemetrySettings? telemetry, + string? suppliedVersion) + { + if (telemetry is not null) + { + if (suppliedVersion is null) + { + envelope["version"] = SchemaVersions.MaximumSupported; + } + envelope["telemetry"] = SerializeToNode(telemetry); + } + } + private static string ContainmentKey(StateAwareContainment containment) => containment switch { StateAwareContainment.IsolationSession => IsolationSessionContainment, diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 79c5524b4..5b3fc3bee 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -2,181 +2,144 @@ // Licensed under the MIT License. using System.Diagnostics; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Text; +using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Mxc.Sdk.Native; namespace Microsoft.Mxc.Sdk; -/// A stored or effective telemetry consent state. -public enum TelemetryConsentState -{ - Undetermined = 0, - Granted = 1, - Denied = 2, - NotApplicable = 3, -} - -/// The administrative telemetry policy for this machine. -public enum TelemetryPolicyState -{ - Blocked = 0, - Unrestricted = 1, - Allowed = 2, - NotApplicable = 3, -} - -/// The presenter decision returned to MXC. -public enum TelemetryConsentDecision -{ - No, - Yes, - Dismissed, -} - -/// Why stored consent is not currently effective. -public enum TelemetryConsentStatusReason -{ - NoRecord, - StoreUnreadable, - StoreMalformed, - ConsentSchemaUnsupported, - PromptVersionMissing, - PromptVersionUnsupported, - NotApplicable, -} - -/// Result of a consent request or withdrawal. -public enum TelemetryConsentResult -{ - Unknown = 0, - Granted = 1, - Denied = 2, - Dismissed = 3, - Withdrawn = 4, - AlreadyGranted = 5, - PolicyBlocked = 6, - NotApplicable = 7, -} - -/// One canonical consent message. -public sealed class TelemetryConsentMessage -{ - public string Id { get; init; } = string.Empty; - public string Text { get; init; } = string.Empty; -} - -/// The canonical consent prompt resource passed to a host presenter. -public sealed class TelemetryConsentPrompt -{ - public uint ResourceVersion { get; init; } - public string Locale { get; init; } = string.Empty; - public TelemetryConsentMessage Title { get; init; } = new(); - public TelemetryConsentMessage Body { get; init; } = new(); - public TelemetryConsentMessage AffirmativeLabel { get; init; } = new(); - public TelemetryConsentMessage NegativeLabel { get; init; } = new(); - public TelemetryConsentMessage LearnMoreLabel { get; init; } = new(); - public string LearnMoreUrl { get; init; } = string.Empty; -} - -/// Stored and effective consent plus the current administrative policy. -public sealed class TelemetryConsentStatus -{ - public TelemetryConsentState StoredState { get; init; } - public TelemetryConsentState EffectiveState { get; init; } - public TelemetryConsentStatusReason? Reason { get; init; } - public TelemetryPolicyState Policy { get; init; } -} - -/// Telemetry consent action result with the resulting status snapshot. -public sealed class TelemetryConsentOutcome -{ - public TelemetryConsentResult Result { get; init; } - public TelemetryConsentState StoredState { get; init; } - public TelemetryConsentState EffectiveState { get; init; } - public TelemetryConsentStatusReason? Reason { get; init; } - public TelemetryPolicyState Policy { get; init; } -} - -/// Telemetry consent helpers over the native mxc_ffi surface. +/// +/// Administers MXC telemetry consent. See +/// docs/telemetry/telemetry-consent-design.md for the contract. +/// public static class MxcTelemetry { - private const int NativeConsentDecisionNo = 0; - private const int NativeConsentDecisionYes = 1; - private const int NativeConsentDecisionDismissed = 2; - private const int NativeConsentPresenterError = -1; - private static readonly FailureCategoryTracker DefaultFailureCategoryTracker = new(capacity: 64); - private static readonly ITelemetryReadApi DefaultTelemetryReadApi = new PInvokeTelemetryReadApi(); - private static FailureCategoryTracker reportedFailures = DefaultFailureCategoryTracker; - private static ITelemetryReadApi telemetryReadApi = DefaultTelemetryReadApi; - internal readonly record struct NativePayloadResult(int Status, string? Payload); internal readonly record struct NativeBooleanResult(int Status, bool Value); - // The public wrappers interpret these native status results according to - // their individual throw-or-fail-closed contracts. - internal interface ITelemetryReadApi + internal interface ITelemetryNativeApi { NativePayloadResult GetConsent(); NativeBooleanResult NeedsConsentPrompt(); NativePayloadResult GetPolicy(); + NativePayloadResult GetConsentStatus(); + NativePayloadResult WithdrawConsent(); + NativePayloadResult RequestConsent(string? locale, Func presenter); } - private sealed class PInvokeTelemetryReadApi : ITelemetryReadApi + private sealed class PInvokeTelemetryNativeApi : ITelemetryNativeApi { public unsafe NativePayloadResult GetConsent() { byte* value = null; var status = NativeMethods.mxc_telemetry_get_consent(&value); + return Payload(status, value); + } + + public unsafe NativeBooleanResult NeedsConsentPrompt() + { + int value = 0; + var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&value); + return new(status, value != 0); + } + + public unsafe NativePayloadResult GetPolicy() + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_policy(&value); + return Payload(status, value); + } + + public unsafe NativePayloadResult GetConsentStatus() + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_consent_status(&value); + return Payload(status, value); + } + + public unsafe NativePayloadResult WithdrawConsent() + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_withdraw_consent(&value); + return Payload(status, value); + } + + public unsafe NativePayloadResult RequestConsent(string? locale, Func presenter) + { + var localeBuffer = locale is null ? null : ToNullTerminatedUtf8(locale); + using var callback = new PresenterCallback(presenter); + fixed (byte* localePtr = localeBuffer) + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_request_consent( + localePtr, + &PresentConsent, + (void*)GCHandle.ToIntPtr(callback.Handle), + &value); + return Payload(status, value); + } + } + + private static unsafe NativePayloadResult Payload(int status, byte* value) + { try { - return new(status, ReadNativeUtf8(value)); + return new(status, value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value)); } finally { - FreeNativeString(value); + if (value is not null) + { + NativeMethods.mxc_string_free(value); + } } } + } - public unsafe NativeBooleanResult NeedsConsentPrompt() + private sealed class PresenterCallback : IDisposable + { + private readonly Func presenter; + internal GCHandle Handle { get; } + + internal PresenterCallback(Func presenter) { - int needsPrompt = 0; - var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt); - return new(status, needsPrompt != 0); + this.presenter = presenter; + Handle = GCHandle.Alloc(this); } - public unsafe NativePayloadResult GetPolicy() + internal int Invoke(string prompt) { - byte* value = null; - var status = NativeMethods.mxc_telemetry_get_policy(&value); try { - return new(status, ReadNativeUtf8(value)); + return presenter(prompt); } - finally + catch { - FreeNativeString(value); + return -1; } } + + public void Dispose() => Handle.Free(); } - internal static IDisposable OverrideTelemetryReadApiForTesting( - ITelemetryReadApi replacement) + private static readonly ITelemetryNativeApi DefaultNativeApi = new PInvokeTelemetryNativeApi(); + private static readonly FailureCategoryTracker DefaultFailureCategoryTracker = new(capacity: 64); + private static ITelemetryNativeApi nativeApi = DefaultNativeApi; + private static FailureCategoryTracker reportedFailures = DefaultFailureCategoryTracker; + private static bool? windowsHostOverride; + + internal static IDisposable OverrideNativeApiForTesting(ITelemetryNativeApi replacement) { ArgumentNullException.ThrowIfNull(replacement); if (!ReferenceEquals( - Interlocked.CompareExchange( - ref telemetryReadApi, - replacement, - DefaultTelemetryReadApi), - DefaultTelemetryReadApi)) + Interlocked.CompareExchange(ref nativeApi, replacement, DefaultNativeApi), + DefaultNativeApi)) { throw new InvalidOperationException( - "A telemetry read API test override is already active."); + "A telemetry native API test override is already active."); } - return new TelemetryReadApiScope(replacement); + return new NativeApiScope(replacement); } internal static IDisposable OverrideFailureCategoryTrackerForTesting( @@ -196,7 +159,16 @@ internal static IDisposable OverrideFailureCategoryTrackerForTesting( return new FailureCategoryTrackerScope(replacement); } - private sealed class TelemetryReadApiScope(ITelemetryReadApi replacement) : IDisposable + internal static IDisposable OverrideWindowsHostForTesting(bool isWindows) + { + var previous = windowsHostOverride; + windowsHostOverride = isWindows; + return new Scope(() => windowsHostOverride = previous); + } + + private static bool IsWindowsHost => windowsHostOverride ?? OperatingSystem.IsWindows(); + + private sealed class NativeApiScope(ITelemetryNativeApi replacement) : IDisposable { private int disposed; @@ -206,10 +178,7 @@ public void Dispose() { return; } - Interlocked.CompareExchange( - ref telemetryReadApi, - DefaultTelemetryReadApi, - replacement); + Interlocked.CompareExchange(ref nativeApi, DefaultNativeApi, replacement); } } @@ -231,6 +200,58 @@ public void Dispose() } } + private sealed class Scope(Action release) : IDisposable + { + private bool released; + public void Dispose() + { + if (!released) + { + released = true; + release(); + } + } + } + + static MxcTelemetry() + { + NativeLibraryResolver.Initialize(); + } + + /// + /// Read effective telemetry consent. Returns + /// off Windows and when consent cannot be + /// determined. + /// + public static TelemetryConsentState GetConsent() + { + if (!IsWindowsHost) + { + return TelemetryConsentState.NotApplicable; + } + + try + { + var result = nativeApi.GetConsent(); + EnsureSuccess(result.Status, "failed to read telemetry consent state"); + return ParseConsentState(result.Payload, "GetConsent"); + } + catch (Exception ex) + { + ReportFailClosed("GetConsent", "Undetermined", ex); + return TelemetryConsentState.Undetermined; + } + } + + /// + /// Whether an exception indicates that the native library is unavailable. + /// + internal static bool IsNativeLoadFailure(Exception ex) => + ex is DllNotFoundException + or EntryPointNotFoundException + or TypeInitializationException + or BadImageFormatException; + internal sealed class FailureCategoryTracker(int capacity) { private readonly HashSet categories = []; @@ -264,353 +285,356 @@ internal void Remove(string operation, string safeResult, string kind, int code) } } - private sealed class PresenterContext + internal static void ReportFailClosed( + string operation, + string safeResult, + Exception exception) { - public required Func Presenter { get; init; } - public CancellationToken CancellationToken { get; init; } + var exceptionType = exception.GetType().FullName ?? exception.GetType().Name; + ReportFailClosedCore( + operation, + safeResult, + exceptionType, + exception.HResult, + errorCode: null, + safeDescription: null); } - /// - /// Return the consent state currently effective for telemetry authorization. - /// Use to read the persisted decision. - /// Fail-closed native-load failures return . - /// - public static TelemetryConsentState GetConsent() + internal static void ReportFailClosed( + string operation, + string safeResult, + ErrorCode errorCode) + { + ReportFailClosedCore( + operation, + safeResult, + nameof(ErrorCode), + (int)errorCode, + errorCode, + safeDescription: null); + } + + private static void ReportFailClosedCategory( + string operation, + string safeResult, + string category) { + ReportFailClosedCore( + operation, + safeResult, + category, + code: 0, + errorCode: null, + safeDescription: category); + } + + private static void ReportFailClosedCore( + string operation, + string safeResult, + string kind, + int code, + ErrorCode? errorCode, + string? safeDescription) + { + var registered = false; try { - EnsureNativeInitialized(); - var result = telemetryReadApi.GetConsent(); - if (result.Status != (int)ErrorCode.Success) + if (!reportedFailures.TryAdd(operation, safeResult, kind, code)) { - throw new MxcException( - (ErrorCode)result.Status, - "retrieving telemetry consent failed"); + return; } + registered = true; - return ParseConsentState(RequirePayload(result.Payload, "telemetry consent")); - } - catch (MxcException) - { - throw; + var description = safeDescription ?? (errorCode is { } nativeError + ? $"{nativeError} ({code})" + : $"{kind} (HRESULT 0x{code:X8})"); + Trace.TraceError( + "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2}", + operation, + safeResult, + description); } - catch (Exception ex) + catch { - ReportFailClosed("GetConsent", "Undetermined", ex); - return TelemetryConsentState.Undetermined; + if (registered) + { + reportedFailures.Remove(operation, safeResult, kind, code); + } + // Diagnostics must not affect the fail-closed result. } } /// - /// Read stored and effective consent, plus the current administrative policy. + /// Whether the host should show the first-run consent prompt. + /// + /// Returns on non-Windows hosts or any failure. /// - public static TelemetryConsentStatus GetConsentStatus() + public static bool NeedsConsentPrompt() { - EnsureNativeInitialized(); - unsafe + if (!IsWindowsHost) { - byte* value = null; - var status = NativeMethods.mxc_telemetry_get_consent_status(&value); - try - { - EnsureSuccess( - status, - "retrieving telemetry consent status failed", - ReadNativeUtf8(value)); - return ParseConsentStatus(ReadRequiredJson(value, "telemetry consent status")); - } - finally + return false; + } + + try + { + var result = nativeApi.NeedsConsentPrompt(); + if (result.Status != (int)ErrorCode.Success) { - FreeNativeString(value); + ReportFailClosed("NeedsConsentPrompt", "false", (ErrorCode)result.Status); + return false; } + return result.Value; + } + catch (Exception ex) + { + ReportFailClosed("NeedsConsentPrompt", "false", ex); + return false; } } /// - /// Invoke a host presenter, then persist its decision. + /// Read the administrative telemetry policy. Failures return + /// . /// - public static TelemetryConsentOutcome RequestConsent( - Func presenter, - string? locale = null) - { - ArgumentNullException.ThrowIfNull(presenter); - ValidateLocale(locale); - return RequestConsentCore(presenter, locale, CancellationToken.None); - } - - private static TelemetryConsentOutcome RequestConsentCore( - Func presenter, - string? locale, - CancellationToken cancellationToken) + public static TelemetryPolicyState GetPolicy() { - EnsureNativeInitialized(); - - var localeBuf = locale is null ? null : ToNullTerminatedUtf8(locale); - var presenterContext = GCHandle.Alloc(new PresenterContext + if (!IsWindowsHost) { - Presenter = presenter, - CancellationToken = cancellationToken, - }); + return TelemetryPolicyState.NotApplicable; + } + try { - unsafe + var result = nativeApi.GetPolicy(); + if (result.Status != (int)ErrorCode.Success) { - fixed (byte* localePtr = localeBuf) - { - byte* value = null; - var status = NativeMethods.mxc_telemetry_request_consent( - localePtr, - &PresentConsentBridge, - (void*)GCHandle.ToIntPtr(presenterContext), - &value); - try - { - EnsureSuccess( - status, - "requesting telemetry consent failed", - ReadNativeUtf8(value)); - return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome")); - } - finally - { - FreeNativeString(value); - } - } + ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status); + return TelemetryPolicyState.Blocked; } + return ParsePolicyState(result.Payload, "GetPolicy"); + } + catch (Exception ex) + { + ReportFailClosed("GetPolicy", "Blocked", ex); + return TelemetryPolicyState.Blocked; } - finally + } + + private sealed class PresenterContext + { + internal required Func> Presenter { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal SynchronizationContext? SynchronizationContext { get; init; } + internal Exception? Error; + } + + /// Request consent through a synchronous host presenter. + public static TelemetryConsentOutcome RequestConsent( + Func presenter, + string? locale = null) + { + ArgumentNullException.ThrowIfNull(presenter); + ValidateLocale(locale); + if (!IsWindowsHost) { - presenterContext.Free(); + return NotApplicableOutcome(); } + return RequestConsentCore( + prompt => ValueTask.FromResult(presenter(prompt)), + locale, + CancellationToken.None, + synchronizationContext: null); } /// - /// Asynchronous wrapper over . - /// The native API is synchronous and blocking, so the native work runs on the thread pool while - /// the presenter is dispatched to the caller's synchronization context when one is available. + /// Request consent through an asynchronous host presenter. /// /// - /// Cancellation is best-effort relative to persistence. It stops waiting for an unfinished - /// presenter and returns a canceled task when native code observes the dismissal first. Once a - /// completed presenter decision wins the race and native persistence begins, the persisted - /// outcome is authoritative and is returned even if the token is canceled concurrently. - /// Cancellation does not cancel the presenter's underlying task. + /// The native request runs on a worker thread. Await this method rather than + /// blocking a UI thread that the presenter needs to resume on. Cancellation is + /// best-effort relative to persistence: it stops waiting for an unfinished + /// presenter and returns a canceled task when native code observes the + /// dismissal first, but once a completed presenter decision wins the race and + /// native persistence begins, the persisted outcome is authoritative and is + /// returned even if the token is canceled concurrently. Cancellation does not + /// cancel the presenter's underlying operation. /// public static Task RequestConsentAsync( - Func> presenter, + Func> presenter, string? locale = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(presenter); ValidateLocale(locale); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + if (!IsWindowsHost) + { + return Task.FromResult(NotApplicableOutcome()); + } var synchronizationContext = SynchronizationContext.Current; - return Task.Run( + return Task.Factory.StartNew( () => { var outcome = RequestConsentCore( - prompt => InvokePresenterWithCancellation( - presenter, - prompt, - synchronizationContext, - cancellationToken), + presenter, locale, - cancellationToken); + cancellationToken, + synchronizationContext); return FinalizeAsyncOutcome(outcome, cancellationToken); }, - cancellationToken); + cancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); } internal static TelemetryConsentOutcome FinalizeAsyncOutcome( TelemetryConsentOutcome outcome, CancellationToken cancellationToken) { - if (outcome.Result == TelemetryConsentResult.Dismissed) + if (outcome.Result == TelemetryConsentActionResult.Dismissed) { cancellationToken.ThrowIfCancellationRequested(); } return outcome; } - /// Persist an idempotent telemetry-consent withdrawal. - public static TelemetryConsentOutcome WithdrawConsent() + /// Read stored/effective consent and the administrative ceiling. + public static TelemetryConsentStatus GetConsentStatus() { - EnsureNativeInitialized(); - unsafe + if (!IsWindowsHost) { - byte* value = null; - var status = NativeMethods.mxc_telemetry_withdraw_consent(&value); - try - { - EnsureSuccess( - status, - "withdrawing telemetry consent failed", - ReadNativeUtf8(value)); - return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome")); - } - finally - { - FreeNativeString(value); - } + return new( + TelemetryConsentState.NotApplicable, + TelemetryConsentState.NotApplicable, + TelemetryPolicyState.NotApplicable); } - } - /// - /// Whether a host should show its first-run consent prompt. - /// Fails closed to and never throws. - /// - public static bool NeedsConsentPrompt() - { try { - EnsureNativeInitialized(); - var result = telemetryReadApi.NeedsConsentPrompt(); - if (result.Status != (int)ErrorCode.Success) - { - ReportFailClosed( - "NeedsConsentPrompt", - "false", - (ErrorCode)result.Status); - return false; - } - - return result.Value; + var result = nativeApi.GetConsentStatus(); + EnsureSuccess(result.Status, "failed to read telemetry consent status"); + return ParseConsentStatus(result.Payload); } - catch (Exception ex) + catch (Exception ex) when ( + ex is JsonException or KeyNotFoundException or InvalidOperationException) { - ReportFailClosed("NeedsConsentPrompt", "false", ex); - return false; + ReportFailClosed("GetConsentStatus", "Undetermined/Blocked", ex); + return new( + TelemetryConsentState.Undetermined, + TelemetryConsentState.Undetermined, + TelemetryPolicyState.Blocked); } - } - - /// - /// Read the administrative telemetry policy. - /// Fails closed to and never throws. - /// - public static TelemetryPolicyState GetPolicy() - { - try + catch (MxcException ex) { - EnsureNativeInitialized(); - var result = telemetryReadApi.GetPolicy(); - if (result.Status != (int)ErrorCode.Success) - { - ReportFailClosed("GetPolicy", "Blocked", (ErrorCode)result.Status); - return TelemetryPolicyState.Blocked; - } - - return ParsePolicyState(RequirePayload(result.Payload, "telemetry policy")); + ReportFailClosed("GetConsentStatus", "Undetermined/Blocked", ex); + return new( + TelemetryConsentState.Undetermined, + TelemetryConsentState.Undetermined, + TelemetryPolicyState.Blocked); } catch (Exception ex) { - ReportFailClosed("GetPolicy", "Blocked", ex); - return TelemetryPolicyState.Blocked; + ReportFailClosed("GetConsentStatus", "Undetermined/Blocked", ex); + return new( + TelemetryConsentState.Undetermined, + TelemetryConsentState.Undetermined, + TelemetryPolicyState.Blocked); } } - internal static void ReportFailClosed( - string operation, - string safeResult, - Exception exception) - { - var exceptionType = exception.GetType().FullName ?? exception.GetType().Name; - ReportFailClosedCore( - operation, - safeResult, - exceptionType, - exception.HResult, - errorCode: null); - } - - internal static void ReportFailClosed( - string operation, - string safeResult, - ErrorCode errorCode) - { - ReportFailClosedCore( - operation, - safeResult, - nameof(ErrorCode), - (int)errorCode, - errorCode); - } - - private static void ReportFailClosedCore( - string operation, - string safeResult, - string kind, - int code, - ErrorCode? errorCode) + /// Idempotently withdraw telemetry consent. + public static TelemetryConsentOutcome WithdrawConsent() { - var registered = false; + if (!IsWindowsHost) + { + return NotApplicableOutcome(); + } try { - if (!reportedFailures.TryAdd( - operation, - safeResult, - kind, - code)) - { - return; - } - registered = true; - - var description = errorCode is { } nativeError - ? $"{nativeError} ({code})" - : $"{kind} (HRESULT 0x{code:X8})"; - Trace.TraceError( - "mxc: {0} failed and is reporting '{1}' to stay fail-closed: {2}", - operation, - safeResult, - description); + var result = nativeApi.WithdrawConsent(); + EnsureSuccess(result.Status, "failed to withdraw telemetry consent"); + return ParseConsentOutcome(result.Payload, ConsentOutcomeOperation.Withdraw); } - catch + catch (Exception ex) when (ex is not MxcException) { - if (registered) - { - reportedFailures.Remove( - operation, - safeResult, - kind, - code); - } - // Diagnostics must not affect the fail-closed result. + throw new MxcException( + ErrorCode.BackendError, + "failed to withdraw telemetry consent", + ex); } } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context) + private static TelemetryConsentOutcome RequestConsentCore( + Func> presenter, + string? locale, + CancellationToken cancellationToken, + SynchronizationContext? synchronizationContext) { try { - var handle = GCHandle.FromIntPtr((IntPtr)context); - if (handle.Target is not PresenterContext presenterContext) + cancellationToken.ThrowIfCancellationRequested(); + var context = new PresenterContext { - return NativeConsentPresenterError; - } - - var prompt = ParseConsentPrompt(ReadRequiredJson(promptJsonUtf8, "telemetry consent prompt")); - var decision = presenterContext.Presenter(prompt); - if (presenterContext.CancellationToken.IsCancellationRequested) + Presenter = presenter, + CancellationToken = cancellationToken, + SynchronizationContext = synchronizationContext, + }; + var result = nativeApi.RequestConsent( + locale, + promptJson => + { + try + { + var prompt = ParseConsentPrompt(promptJson); + return InvokePresenterWithCancellation( + context.Presenter, + prompt, + context.SynchronizationContext, + context.CancellationToken) switch + { + TelemetryConsentDecision.No => 0, + TelemetryConsentDecision.Yes => 1, + TelemetryConsentDecision.Dismissed => 2, + _ => -1, + }; + } + catch (Exception ex) + { + Interlocked.CompareExchange(ref context.Error, ex, null); + return -1; + } + }); + if (context.Error is OperationCanceledException && + cancellationToken.IsCancellationRequested) { - return NativeConsentDecisionDismissed; + cancellationToken.ThrowIfCancellationRequested(); } - - return decision switch + if (context.Error is not null) { - TelemetryConsentDecision.Yes => NativeConsentDecisionYes, - TelemetryConsentDecision.No => NativeConsentDecisionNo, - TelemetryConsentDecision.Dismissed => NativeConsentDecisionDismissed, - _ => NativeConsentPresenterError, - }; + throw new MxcException( + ErrorCode.BackendError, + "telemetry consent presenter failed", + context.Error); + } + EnsureSuccess(result.Status, "failed to request telemetry consent"); + return ParseConsentOutcome(result.Payload, ConsentOutcomeOperation.Request); } - catch + catch (Exception ex) when ( + ex is not MxcException and not OperationCanceledException) { - return NativeConsentPresenterError; + throw new MxcException( + ErrorCode.BackendError, + "telemetry consent request failed", + ex); } } private static TelemetryConsentDecision InvokePresenterWithCancellation( - Func> presenter, + Func> presenter, TelemetryConsentPrompt prompt, SynchronizationContext? synchronizationContext, CancellationToken cancellationToken) @@ -618,7 +642,11 @@ private static TelemetryConsentDecision InvokePresenterWithCancellation( Task? presenterTask = null; try { - presenterTask = InvokePresenterAsync(presenter, prompt, synchronizationContext); + presenterTask = InvokePresenterAsync( + presenter, + prompt, + synchronizationContext, + cancellationToken); return presenterTask .WaitAsync(cancellationToken) .GetAwaiter() @@ -657,13 +685,15 @@ private static void ObserveAbandonedPresenterFailure( } private static Task InvokePresenterAsync( - Func> presenter, + Func> presenter, TelemetryConsentPrompt prompt, - SynchronizationContext? synchronizationContext) + SynchronizationContext? synchronizationContext, + CancellationToken cancellationToken) { if (synchronizationContext is null) { - return presenter(prompt); + cancellationToken.ThrowIfCancellationRequested(); + return presenter(prompt).AsTask(); } var completion = new TaskCompletionSource( @@ -673,6 +703,11 @@ private static Task InvokePresenterAsync( synchronizationContext.Post( async _ => { + if (cancellationToken.IsCancellationRequested) + { + completion.TrySetCanceled(cancellationToken); + return; + } try { completion.SetResult(await presenter(prompt)); @@ -692,170 +727,316 @@ private static Task InvokePresenterAsync( return completion.Task; } - private static void EnsureNativeInitialized() => NativeLibraryResolver.Initialize(); + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int PresentConsent(byte* promptJsonUtf8, void* contextPointer) + { + try + { + var callback = (PresenterCallback)GCHandle.FromIntPtr((IntPtr)contextPointer).Target!; + return callback.Invoke(Marshal.PtrToStringUTF8((IntPtr)promptJsonUtf8) ?? string.Empty); + } + catch + { + return -1; + } + } - private static void EnsureSuccess(int status, string fallbackMessage, string? nativeMessage) + private static void EnsureSuccess(int status, string message) { - if (status == (int)ErrorCode.Success) + if (status != (int)ErrorCode.Success) { - return; + throw new MxcException((ErrorCode)status, message); } + } + + private static TelemetryConsentOutcome NotApplicableOutcome() => + new( + TelemetryConsentActionResult.NotApplicable, + TelemetryConsentState.NotApplicable, + TelemetryConsentState.NotApplicable, + TelemetryPolicyState.NotApplicable); - throw new MxcException( - (ErrorCode)status, - string.IsNullOrWhiteSpace(nativeMessage) ? fallbackMessage : nativeMessage); + internal static TelemetryConsentPrompt ParseConsentPrompt(string? json) + { + using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent prompt")); + var root = document.RootElement; + return new( + root.GetProperty("resourceVersion").GetUInt32(), + ParseRequiredString(root, "locale", "locale"), + ParseConsentMessage(root.GetProperty("title"), "title"), + ParseConsentMessage(root.GetProperty("body"), "body"), + ParseConsentMessage(root.GetProperty("affirmativeLabel"), "affirmativeLabel"), + ParseConsentMessage(root.GetProperty("negativeLabel"), "negativeLabel"), + ParseConsentMessage(root.GetProperty("learnMoreLabel"), "learnMoreLabel"), + ParseRequiredString(root, "learnMoreUrl", "learnMoreUrl")); } - private static unsafe string ReadRequiredJson(byte* value, string description) => - ReadNativeUtf8(value) ?? throw new MxcException(ErrorCode.BackendError, $"{description} was missing"); + private static TelemetryConsentMessage ParseConsentMessage(JsonElement value, string path) => + new( + ParseRequiredString(value, "id", $"{path}.id"), + ParseRequiredString(value, "text", $"{path}.text")); - private static string RequirePayload(string? value, string description) => - value ?? throw new InvalidOperationException($"{description} payload was missing"); + private static string ParseRequiredString( + JsonElement parent, + string propertyName, + string path) + { + var value = parent.GetProperty(propertyName); + if (value.ValueKind != JsonValueKind.String) + { + throw new JsonException( + $"Telemetry consent prompt field '{path}' must be a string."); + } - private static unsafe string? ReadNativeUtf8(byte* value) => - value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value); + return value.GetString()!; + } - private static unsafe void FreeNativeString(byte* value) + private static TelemetryConsentStatus ParseConsentStatus(string? json) { - if (value is not null) + using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent status")); + var root = document.RootElement; + if (!IsKnownConsentStatusReason(root.GetProperty("reason"), "GetConsentStatus")) { - NativeMethods.mxc_string_free(value); + return FailClosedStatus(); } + if (!TryParseConsentFields( + root, + "GetConsentStatus", + out var storedState, + out var effectiveState, + out var policy)) + { + return FailClosedStatus(); + } + return new(storedState, effectiveState, policy); } - private static byte[] ToNullTerminatedUtf8(string value) + private enum ConsentOutcomeOperation { - var byteCount = Encoding.UTF8.GetByteCount(value); - var buffer = new byte[byteCount + 1]; - Encoding.UTF8.GetBytes(value, 0, value.Length, buffer, 0); - buffer[byteCount] = 0; - return buffer; + Request, + Withdraw, } - private static void ValidateLocale(string? locale) + private static TelemetryConsentOutcome ParseConsentOutcome( + string? json, + ConsentOutcomeOperation operation) { - if (locale?.Contains('\0') == true) + using var document = JsonDocument.Parse(json ?? throw new JsonException("missing consent outcome")); + var root = document.RootElement; + var result = ParseConsentActionResult(root.GetProperty("result").GetString()); + var operationName = $"{operation}Consent"; + if (!IsResultForOperation(result, operation) || + !IsKnownConsentStatusReason(root.GetProperty("reason"), operationName)) { - throw new ArgumentException( - "Telemetry consent locale cannot contain embedded NUL characters.", - nameof(locale)); + return FailClosedOutcome(); } + if (!TryParseConsentFields( + root, + operationName, + out var storedState, + out var effectiveState, + out var policy)) + { + return FailClosedOutcome(); + } + return new(result, storedState, effectiveState, policy); } - internal static TelemetryConsentPrompt ParseConsentPrompt(string json) + private static bool IsResultForOperation( + TelemetryConsentActionResult result, + ConsentOutcomeOperation operation) { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - return new TelemetryConsentPrompt + var valid = operation switch { - ResourceVersion = root.GetProperty("resourceVersion").GetUInt32(), - Locale = ParseRequiredString(root, "locale", "locale"), - Title = ParseConsentMessage(root.GetProperty("title"), "title"), - Body = ParseConsentMessage(root.GetProperty("body"), "body"), - AffirmativeLabel = ParseConsentMessage( - root.GetProperty("affirmativeLabel"), - "affirmativeLabel"), - NegativeLabel = ParseConsentMessage( - root.GetProperty("negativeLabel"), - "negativeLabel"), - LearnMoreLabel = ParseConsentMessage( - root.GetProperty("learnMoreLabel"), - "learnMoreLabel"), - LearnMoreUrl = ParseRequiredString(root, "learnMoreUrl", "learnMoreUrl"), + ConsentOutcomeOperation.Request => + result is TelemetryConsentActionResult.Granted + or TelemetryConsentActionResult.Denied + or TelemetryConsentActionResult.Dismissed + or TelemetryConsentActionResult.AlreadyGranted + or TelemetryConsentActionResult.PolicyBlocked + or TelemetryConsentActionResult.NotApplicable, + ConsentOutcomeOperation.Withdraw => + result is TelemetryConsentActionResult.Withdrawn + or TelemetryConsentActionResult.NotApplicable, + _ => false, }; + if (!valid && result != TelemetryConsentActionResult.Unknown) + { + ReportFailClosedCategory( + $"{operation}Consent", + "Unknown", + "native returned an invalid consent result"); + } + return valid; } - private static TelemetryConsentMessage ParseConsentMessage(JsonElement value, string path) => new() + private static TelemetryConsentStatus FailClosedStatus() => + new( + TelemetryConsentState.Undetermined, + TelemetryConsentState.Undetermined, + TelemetryPolicyState.Blocked); + + private static TelemetryConsentOutcome FailClosedOutcome() => + new( + TelemetryConsentActionResult.Unknown, + TelemetryConsentState.Undetermined, + TelemetryConsentState.Undetermined, + TelemetryPolicyState.Blocked); + + private static bool TryParseConsentFields( + JsonElement root, + string operation, + out TelemetryConsentState storedState, + out TelemetryConsentState effectiveState, + out TelemetryPolicyState policy) { - Id = ParseRequiredString(value, "id", $"{path}.id"), - Text = ParseRequiredString(value, "text", $"{path}.text"), - }; + storedState = TelemetryConsentState.Undetermined; + effectiveState = TelemetryConsentState.Undetermined; + policy = TelemetryPolicyState.Blocked; - private static string ParseRequiredString( - JsonElement parent, - string propertyName, - string path) - { - var value = parent.GetProperty(propertyName); - if (value.ValueKind != JsonValueKind.String) + var storedValue = root.GetProperty("storedState").GetString(); + var parsedStoredState = ParseKnownConsentState(storedValue); + if (parsedStoredState is null) { - throw new JsonException( - $"Telemetry consent prompt field '{path}' must be a string."); + _ = UnrecognizedConsentState(storedValue, operation); + return false; } - return value.GetString()!; - } + var effectiveValue = root.GetProperty("effectiveState").GetString(); + var parsedEffectiveState = ParseKnownConsentState(effectiveValue); + if (parsedEffectiveState is null) + { + _ = UnrecognizedConsentState(effectiveValue, operation); + return false; + } - private static TelemetryConsentStatus ParseConsentStatus(string json) - { - using var doc = JsonDocument.Parse(json); - return ParseConsentStatus(doc.RootElement); + var policyValue = root.GetProperty("policy").GetString(); + var parsedPolicy = ParseKnownPolicyState(policyValue); + if (parsedPolicy is null) + { + _ = UnrecognizedPolicyState(policyValue, operation); + return false; + } + + storedState = parsedStoredState.Value; + effectiveState = parsedEffectiveState.Value; + policy = parsedPolicy.Value; + return true; } - private static TelemetryConsentStatus ParseConsentStatus(JsonElement root) => new() + private static TelemetryConsentActionResult ParseConsentActionResult(string? value) => value switch { - StoredState = ParseConsentState(root.GetProperty("storedState").GetString() ?? string.Empty), - EffectiveState = ParseConsentState(root.GetProperty("effectiveState").GetString() ?? string.Empty), - Reason = root.TryGetProperty("reason", out var reason) && reason.ValueKind != JsonValueKind.Null - ? ParseConsentStatusReason(reason.GetString() ?? string.Empty) - : null, - Policy = ParsePolicyState(root.GetProperty("policy").GetString() ?? string.Empty), + "granted" => TelemetryConsentActionResult.Granted, + "denied" => TelemetryConsentActionResult.Denied, + "dismissed" => TelemetryConsentActionResult.Dismissed, + "withdrawn" => TelemetryConsentActionResult.Withdrawn, + "alreadyGranted" => TelemetryConsentActionResult.AlreadyGranted, + "policyBlocked" => TelemetryConsentActionResult.PolicyBlocked, + "notApplicable" => TelemetryConsentActionResult.NotApplicable, + _ => UnrecognizedConsentActionResult(value), }; - private static TelemetryConsentOutcome ParseConsentOutcome(string json) + private static TelemetryConsentActionResult UnrecognizedConsentActionResult(string? value) { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - var status = ParseConsentStatus(root); - return new TelemetryConsentOutcome + ReportFailClosedCategory( + "ConsentOutcome", + "Unknown", + "native returned an unrecognized consent action result"); + return TelemetryConsentActionResult.Unknown; + } + + private static bool IsKnownConsentStatusReason( + JsonElement value, + string operation) + { + if (value.ValueKind == JsonValueKind.Null) + { + return true; + } + + return value.GetString() switch { - Result = ParseConsentResult(root.GetProperty("result").GetString() ?? string.Empty), - StoredState = status.StoredState, - EffectiveState = status.EffectiveState, - Reason = status.Reason, - Policy = status.Policy, + "no-record" or + "store-unreadable" or + "store-malformed" or + "consent-schema-unsupported" or + "prompt-version-missing" or + "prompt-version-unsupported" or + "policy-blocked" or + "presentation-unavailable" or + "not-applicable" => true, + var reason => ReportUnrecognizedConsentStatusReason(reason, operation), }; } - private static TelemetryConsentState ParseConsentState(string value) => value switch + private static bool ReportUnrecognizedConsentStatusReason( + string? value, + string operation) + { + ReportFailClosedCategory( + operation, + "Unknown", + "native returned an unrecognized consent status reason"); + return false; + } + + /// + /// Map the native consent string, failing closed for unknown values. + /// + private static TelemetryConsentState? ParseKnownConsentState(string? value) => value switch { "granted" => TelemetryConsentState.Granted, "denied" => TelemetryConsentState.Denied, "undetermined" => TelemetryConsentState.Undetermined, "not-applicable" => TelemetryConsentState.NotApplicable, - _ => throw new JsonException($"unknown telemetry consent state '{value}'"), + _ => null, }; - private static TelemetryPolicyState ParsePolicyState(string value) => value switch + private static TelemetryConsentState ParseConsentState(string? value, string operation) => + ParseKnownConsentState(value) ?? UnrecognizedConsentState(value, operation); + + private static TelemetryConsentState UnrecognizedConsentState(string? value, string operation) + { + ReportFailClosedCategory( + operation, + "Undetermined", + "native returned an unrecognized consent state"); + return TelemetryConsentState.Undetermined; + } + + /// + /// Map the native policy string, failing closed for unknown values. + /// + private static TelemetryPolicyState? ParseKnownPolicyState(string? value) => value switch { "unrestricted" => TelemetryPolicyState.Unrestricted, "allowed" => TelemetryPolicyState.Allowed, "blocked" => TelemetryPolicyState.Blocked, "not-applicable" => TelemetryPolicyState.NotApplicable, - _ => throw new JsonException($"unknown telemetry policy state '{value}'"), + _ => null, }; - private static TelemetryConsentStatusReason ParseConsentStatusReason(string value) => value switch + private static TelemetryPolicyState ParsePolicyState(string? value, string operation) => + ParseKnownPolicyState(value) ?? UnrecognizedPolicyState(value, operation); + + private static TelemetryPolicyState UnrecognizedPolicyState(string? value, string operation) { - "no-record" => TelemetryConsentStatusReason.NoRecord, - "store-unreadable" => TelemetryConsentStatusReason.StoreUnreadable, - "store-malformed" => TelemetryConsentStatusReason.StoreMalformed, - "consent-schema-unsupported" => TelemetryConsentStatusReason.ConsentSchemaUnsupported, - "prompt-version-missing" => TelemetryConsentStatusReason.PromptVersionMissing, - "prompt-version-unsupported" => TelemetryConsentStatusReason.PromptVersionUnsupported, - "not-applicable" => TelemetryConsentStatusReason.NotApplicable, - _ => throw new JsonException($"unknown telemetry consent status reason '{value}'"), - }; + ReportFailClosedCategory( + operation, + "Blocked", + "native returned an unrecognized policy state"); + return TelemetryPolicyState.Blocked; + } - private static TelemetryConsentResult ParseConsentResult(string value) => value switch + private static void ValidateLocale(string? locale) { - "granted" => TelemetryConsentResult.Granted, - "denied" => TelemetryConsentResult.Denied, - "dismissed" => TelemetryConsentResult.Dismissed, - "withdrawn" => TelemetryConsentResult.Withdrawn, - "alreadyGranted" => TelemetryConsentResult.AlreadyGranted, - "policyBlocked" => TelemetryConsentResult.PolicyBlocked, - "notApplicable" => TelemetryConsentResult.NotApplicable, - _ => throw new JsonException($"unknown telemetry consent result '{value}'"), - }; + if (locale?.Contains('\0') == true) + { + throw new ArgumentException( + "Telemetry consent locale cannot contain embedded NUL characters.", + nameof(locale)); + } + } + + private static byte[] ToNullTerminatedUtf8(string value) => System.Text.Encoding.UTF8.GetBytes(value + "\0"); } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs index dfb339a37..625d3a204 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeLibraryResolver.cs @@ -20,7 +20,7 @@ internal static class NativeLibraryResolver /// /// Register the resolver once. Called from the static constructor of the - /// SDK's public entry point so it runs before the first P/Invoke. + /// SDK's public entry points so it runs before the first P/Invoke. /// internal static void Initialize() { @@ -29,7 +29,22 @@ internal static void Initialize() return; } - NativeLibrary.SetDllImportResolver(typeof(NativeLibraryResolver).Assembly, Resolve); + try + { + NativeLibrary.SetDllImportResolver(typeof(NativeLibraryResolver).Assembly, Resolve); + } + catch (Exception ex) + { + try + { + Console.Error.WriteLine( + $"mxc: could not register the native library resolver ({ex.GetType().Name}: {ex.Message}). " + + "Falling back to the default loader."); + } + catch + { + } + } } private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) @@ -54,7 +69,6 @@ private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSe private static IEnumerable CandidatePaths() { var file = NativeFileName(); - var overrideDir = Environment.GetEnvironmentVariable("MXC_FFI_DIR"); if (!string.IsNullOrEmpty(overrideDir)) { @@ -65,11 +79,21 @@ private static IEnumerable CandidatePaths() yield return Path.Combine(baseDir, file); yield return Path.Combine(baseDir, "runtimes", RuntimeInformation.RuntimeIdentifier, "native", file); - // Dev layout: walk up looking for the Cargo target dir. - for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent) + var dir = new DirectoryInfo(baseDir); + var depth = 0; + while (dir is not null && depth++ < 8) { - yield return Path.Combine(dir.FullName, "src", "target", "debug", file); - yield return Path.Combine(dir.FullName, "src", "target", "release", file); + if (File.Exists(Path.Combine(dir.FullName, ".git")) || + File.Exists(Path.Combine(dir.FullName, "src", "Cargo.toml"))) + { +#if DEBUG + yield return Path.Combine(dir.FullName, "src", "target", "debug", file); +#endif + yield return Path.Combine(dir.FullName, "src", "target", "release", file); + yield break; + } + + dir = dir.Parent; } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index d37df55cf..8b7a0b1d4 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -77,7 +77,10 @@ internal SandboxPolicy WithoutLegacyCaptureDenials() /// Telemetry section of a . public sealed class TelemetrySettings { - /// Whether this invocation opts telemetry on, subject to consent and policy. + /// + /// Opt this invocation into telemetry, subject to persisted user consent + /// and administrative policy. + /// [JsonPropertyName("enabled")] public bool Enabled { get; set; } } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs index 3460420ac..3a09d1bdd 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs @@ -71,6 +71,12 @@ public abstract class StateAwareProvisionOptions { /// Overrides the backend's default state-aware schema version. public string? Version { get; set; } + + /// + /// Optional per-phase telemetry request. Emission is still gated by the + /// MXC-owned user consent and administrative policy. + /// + public TelemetrySettings? Telemetry { get; set; } } /// IsolationSession provision options. @@ -160,6 +166,12 @@ public class StateAwarePhaseOptions { /// Overrides the schema version inferred from the sandbox id. public string? Version { get; set; } + + /// + /// Optional per-phase telemetry request. Emission is still gated by the + /// MXC-owned user consent and administrative policy. + /// + public TelemetrySettings? Telemetry { get; set; } } /// Process and schema options for a state-aware exec phase. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs new file mode 100644 index 000000000..c42882624 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsent.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk; + +/// An independently localizable canonical consent message. +public sealed record TelemetryConsentMessage(string Id, string Text); + +/// The complete Rust-owned consent resource a host must render verbatim. +public sealed record TelemetryConsentPrompt( + uint ResourceVersion, + string Locale, + TelemetryConsentMessage Title, + TelemetryConsentMessage Body, + TelemetryConsentMessage AffirmativeLabel, + TelemetryConsentMessage NegativeLabel, + TelemetryConsentMessage LearnMoreLabel, + string LearnMoreUrl); + +/// The explicit result returned by a host consent presenter. +public enum TelemetryConsentDecision +{ + No, + Yes, + Dismissed, +} + +/// Result of a consent request or withdrawal. +public enum TelemetryConsentActionResult +{ + Unknown = 0, + Granted = 1, + Denied = 2, + Dismissed = 3, + Withdrawn = 4, + AlreadyGranted = 5, + PolicyBlocked = 6, + NotApplicable = 7, +} + +/// Persisted and effective consent together with the policy ceiling. +public sealed record TelemetryConsentStatus( + TelemetryConsentState StoredState, + TelemetryConsentState EffectiveState, + TelemetryPolicyState Policy); + +/// Result and resulting status of a consent-changing operation. +public sealed record TelemetryConsentOutcome( + TelemetryConsentActionResult Result, + TelemetryConsentState StoredState, + TelemetryConsentState EffectiveState, + TelemetryPolicyState Policy); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs new file mode 100644 index 000000000..0ea58fad7 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryConsentState.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk; + +/// +/// Telemetry consent state used for both persisted and effective values. +/// Telemetry is collected only when the effective state is . +/// +public enum TelemetryConsentState +{ + /// + /// Consent is unavailable or cannot currently authorize telemetry. Treated + /// as for gating; use MxcTelemetry.NeedsConsentPrompt() + /// to determine whether to present consent. + /// + Undetermined = 0, + + /// Consent is granted. + Granted = 1, + + /// Consent is denied. + Denied = 2, + + /// + /// Not a Windows host. MXC does not collect telemetry here, so consent is not + /// a meaningful concept — hosts must not offer a consent prompt at all on + /// these platforms. + /// + NotApplicable = 3, +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs new file mode 100644 index 000000000..2c6966059 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/TelemetryPolicyState.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Mxc.Sdk; + +/// +/// The administrative (MDM / Group Policy) telemetry decision for this machine. +/// See docs/telemetry/telemetry-administrative-policy.md for the admin-facing reference. +/// +/// An administrator can disable MXC telemetry machine-wide via Intune, another +/// MDM, or Group Policy. The policy is a ceiling, never a grant: an +/// administrator who permits telemetry has not consented on the user's behalf, +/// so an explicit is still +/// required before anything is collected. +/// +/// MXC deliberately does not read the Windows-wide diagnostic data setting: +/// Microsoft's Policy CSP documentation scopes that policy to Windows itself +/// and states it does not apply to additional installed apps, and the Windows +/// privacy guidance for app-classified components requires them to own their +/// own notice and consent experience. +/// +public enum TelemetryPolicyState +{ + /// + /// An administrator has denied MXC telemetry, the configured policy value + /// could not be understood, or the policy could not be determined at all. + /// Nothing is collected regardless of user consent, and hosts must not + /// offer a consent prompt. + /// + /// Because this state also covers "could not be determined", a host should + /// word its UI as "telemetry is unavailable on this device" rather than + /// asserting that an administrator is responsible. + /// + Blocked = 0, + + /// + /// No administrative policy is configured. Telemetry is governed solely by + /// the user's own consent decision. This is not a grant. + /// + Unrestricted = 1, + + /// + /// An administrator has permitted the optional (usage) telemetry category + /// MXC emits. Still requires user consent before anything is collected. + /// + Allowed = 2, + + /// + /// Not a Windows host. MXC collects no telemetry here, so administrative + /// policy is not a meaningful concept. + /// + NotApplicable = 3, +} diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index f0c91946a..212b6ab30 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -494,15 +494,51 @@ sanitized technical signatures. It does not include commands, credentials, complete file paths, usernames, sandbox output, raw ETL, or general logger text. See the [telemetry data inventory](../../docs/telemetry/telemetry.md#data-inventory). -The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies -the canonical resource to a host callback, while `GetConsentStatus`, -`NeedsConsentPrompt`, and `WithdrawConsent` provide maintenance operations. -They persist only explicit yes/no decisions and treat dismissal and failures -as non-grants. See +`MxcTelemetry` is UI-agnostic: it passes the canonical prompt to your +presenter and persists only the typed decision you return. +`GetConsentStatus`, `NeedsConsentPrompt`, and `WithdrawConsent` provide the +remaining maintenance operations. Dismissal and failures never grant consent. +For presenter rules and consent semantics, see [`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md) and its [SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements). +Per-invocation opt-in: +- One-shot (`Run`/`Spawn`): set + `SandboxPolicy.Telemetry = new TelemetrySettings { Enabled = true }`. + An explicit policy version must be `0.9.0-alpha` or later. +- State-aware phases: set each phase's + `Telemetry = new TelemetrySettings { Enabled = true }` independently. + `ProvisionResult` contains the sandbox identity used by later phases; no + telemetry context needs to be forwarded between phases. + +These switches do not bypass persisted consent or administrative policy. + +```csharp +using Microsoft.Mxc.Sdk; + +var outcome = MxcTelemetry.RequestConsent(prompt => +{ + return ShowTelemetryConsentDialog( + title: prompt.Title.Text, + body: prompt.Body.Text, + affirmativeLabel: prompt.AffirmativeLabel.Text, + negativeLabel: prompt.NegativeLabel.Text, + learnMoreLabel: prompt.LearnMoreLabel.Text, + learnMoreUrl: prompt.LearnMoreUrl); +}); +// ShowTelemetryConsentDialog returns Yes, No, or Dismissed from the user's +// action; closing or cancelling the dialog must return Dismissed. + +TelemetryConsentStatus status = MxcTelemetry.GetConsentStatus(); +MxcTelemetry.WithdrawConsent(); +``` + +`RequestConsentAsync` accepts an asynchronous presenter. If consent is never +requested, the presenter fails, or it returns `Dismissed`, telemetry remains +off. On non-Windows hosts requests and withdrawals return `NotApplicable` +without invoking a presenter. + `RequestConsentAsync` cancellation is best-effort relative to persistence. It stops waiting for an unfinished presenter, but once a completed decision wins the race and native persistence begins, the persisted outcome is authoritative @@ -511,16 +547,25 @@ cancel the host's presenter task. ### Administrative policy -An IT administrator can still block MXC telemetry device-wide via MXC's own -registry policy setting. See -[`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md) -for the stable registry contract and interaction rules. Read-only queries fail -closed rather than upgrading an unreadable device state into collection. When -that fallback hides a native or parsing failure, the SDK reports a bounded set -of distinct failure signatures through `System.Diagnostics.Trace`. Each -signature is reported once; if a listener rejects it, a future occurrence may -try again. Reports include the exception type and HRESULT or native error code, -but exclude exception messages and stack traces. +An administrator can block MXC telemetry device-wide through MXC's registry +policy. `MxcTelemetry.GetPolicy()` reports the result: + +```csharp +if (MxcTelemetry.GetPolicy() == TelemetryPolicyState.Blocked) +{ + // Don't show a consent toggle; telemetry is unavailable on this device. +} +``` + +`Allowed` does not grant user consent, while `Blocked` disables collection and +the consent prompt. Failures return a fail-closed state and never grant +consent; non-Windows hosts return `NotApplicable`. When a read-only fallback +hides a native or parsing failure, the SDK reports a bounded set of distinct +failure signatures through `System.Diagnostics.Trace`. Each signature is +reported once; if a listener rejects it, a future occurrence may try again. +Reports include the exception type and HRESULT or native error code, but exclude +exception messages and stack traces. See +[`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md). ## Projects diff --git a/sdk/node/README.md b/sdk/node/README.md index 257bf4bd3..ddce52994 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -480,6 +480,11 @@ getAvailableToolsPolicy(env?, options?) → FilesystemPolicyResult getUserProfilePolicy() → FilesystemPolicyResult getTemporaryFilesPolicy(env?) → FilesystemPolicyResult +// Telemetry consent (Windows-only; see Telemetry Consent section below) +queryTelemetryConsentAsync() → Promise<{ storedState, effectiveState, needsPrompt, policy, error? }> +requestTelemetryConsent(presenter, locale?) → Promise +withdrawTelemetryConsentAsync() → Promise + // Capability types UiCapabilitySupport @@ -496,7 +501,11 @@ Full TypeScript definitions ship with the package (`dist/index.d.ts`). All expor ## Telemetry Consent -Telemetry is off-by-default unless the caller opts in with top-level `telemetry.enabled: true` and the applicable Windows consent/policy gates permit collection. +MXC only ever collects telemetry on Windows, and only after the end user has +explicitly opted in — a persisted, MXC-owned consent flag gates every +emission (never a Windows-level setting like Diagnostics & feedback). See +[`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md) +for the full design. When a telemetry-enabled Windows ProcessContainer run successfully produces a Learning Mode `captureDenials` verbose artifact, telemetry can include its @@ -505,19 +514,80 @@ complete file paths, usernames, sandbox output, raw ETL, or general logger text. See the [telemetry data inventory](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry.md#data-inventory). -Telemetry consent behavior follows -[`docs/telemetry/telemetry-consent-design.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md): -the SDK stays UI-agnostic, renders the canonical resource verbatim through a -host presenter, persists only explicit yes/no decisions, treats dismissal and -failures as non-grants, and never lets policy or transport failures opt a user -in. +Telemetry is additionally off unless a one-shot `SandboxPolicy` or +`ContainerConfig` using schema 0.9 or later includes +`telemetry: { enabled: true }`, or a state-aware call passes +`config.telemetry: { enabled: true }`. This switch does not require +`options.experimental` and cannot bypass consent or administrative policy. + +The SDK does not ship a consent UI. It passes the canonical prompt to your +presenter. Render its supplied fields verbatim and return a typed decision. +Follow the +[SDK presenter requirements](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements) +for control mappings, dismissal behavior, and withdrawal: + +```typescript +import { + requestTelemetryConsent, + queryTelemetryConsentAsync, + withdrawTelemetryConsentAsync, +} from '@microsoft/mxc-sdk'; + +const outcome = await requestTelemetryConsent( + (prompt, signal) => showTelemetryConsentDialog({ + title: prompt.title.text, + body: prompt.body.text, + affirmativeLabel: prompt.affirmativeLabel.text, + negativeLabel: prompt.negativeLabel.text, + learnMoreLabel: prompt.learnMoreLabel.text, + learnMoreUrl: prompt.learnMoreUrl, + signal, + }), + 'en-US', +); +// showTelemetryConsentDialog returns 'yes', 'no', or 'dismissed' from the +// user's action and dismisses pending UI if signal is aborted. + +const status = await queryTelemetryConsentAsync(); +await withdrawTelemetryConsentAsync(); +``` + +If the API is never called, the presenter fails, or it returns `dismissed`, +telemetry remains off. On non-Windows hosts requests and withdrawals return +`notApplicable` without invoking the presenter. + +`queryTelemetryConsentAsync()` fails closed to `'undetermined'` rather than +`'granted'`. Its `error` field is present when the command fails or returns an +invalid response. A valid native fail-closed response can return +`'undetermined'` or a blocked policy without `error`; any accompanying native +diagnostic is reported once through `console.warn`: + +```typescript +const { effectiveState, storedState, needsPrompt, policy, error } = + await queryTelemetryConsentAsync(); +if (error) { + console.warn(`mxc: could not read telemetry consent: ${error}`); +} +``` ### Administrative policy -An IT administrator can still block MXC telemetry device-wide via MXC's own -registry policy setting. See -[`docs/telemetry/telemetry-administrative-policy.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-administrative-policy.md) -for the stable registry contract and interaction rules. +An IT administrator can block MXC telemetry device-wide via MXC's own +Group Policy / MDM setting. The query result's `policy` field reports the +result: + +```typescript +const { policy } = await queryTelemetryConsentAsync(); +// 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable' +if (policy === 'blocked') { + // Don't show a consent toggle; telemetry is unavailable on this device. +} +``` + +`'allowed'` does not grant user consent, while `'blocked'` disables collection +and the consent prompt. An unreadable or missing `policy` field reads back as +`'blocked'`; non-Windows hosts return `'not-applicable'`. See +[`docs/telemetry/telemetry-administrative-policy.md`](https://github.com/microsoft/mxc/blob/main/docs/telemetry/telemetry-administrative-policy.md). --- diff --git a/sdk/node/package.json b/sdk/node/package.json index e446f5b22..bfb4535e5 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -23,7 +23,7 @@ "watch": "tsc --watch", "clean": "rimraf dist", "test": "npm run test:unit", - "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", + "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/default-consent-protocol-runner.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", "test:integration": "cd tests/integration && npm install && npm run build && npm test", "prepublishOnly": "npm run build" }, diff --git a/sdk/node/src/helper.ts b/sdk/node/src/helper.ts index f5d2f3281..afb25fdfc 100644 --- a/sdk/node/src/helper.ts +++ b/sdk/node/src/helper.ts @@ -10,6 +10,7 @@ import { ContainerConfig, ContainmentBackend, ContainmentTypes, ExperimentalBack import { findWxcExecutable, findLxcExecutable, findSeatbeltExecutable, getPlatformSupport } from './platform.js'; import { SandboxSpawnOptions } from './sandbox.js'; import { diagLog } from './diagnostic.js'; +import { mxcErrorFromCode } from './errors.js'; /** SDK version read from package.json at module load time. */ export const SDK_VERSION: string = (() => { @@ -208,6 +209,15 @@ export function resolveExecutableAndArgs( config: ContainerConfig, options: SandboxSpawnOptions = {}, ): { executablePath: string; args: string[] } { + if ( + config.experimental !== null + && typeof config.experimental === 'object' + && 'telemetry' in config.experimental + ) { + throw new Error( + "'experimental.telemetry' is no longer accepted; use top-level 'telemetry' instead.", + ); + } if (!config.process?.commandLine) { throw new Error('script is required. Set process.commandLine on the config or pass a script to spawnSandbox().'); } diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index 1f7101e6c..a4bd9cb9f 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -146,3 +146,23 @@ export { stopSandbox, deprovisionSandbox, } from './state-aware.js'; + +// Export telemetry consent functions and types +export { + TelemetryConfig, +} from './types.js'; + +export { + TelemetryConsentMessage, + TelemetryConsentResult, + TelemetryConsentState, + TelemetryConsentPrompt, + TelemetryConsentDecision, + TelemetryConsentOutcome, + TelemetryConsentPresenter, + TelemetryConsentQuery, + TelemetryPolicyState, + requestTelemetryConsent, + queryTelemetryConsentAsync, + withdrawTelemetryConsentAsync, +} from './telemetry.js'; diff --git a/sdk/node/src/platform.ts b/sdk/node/src/platform.ts index c99f2bf86..b8e98b62e 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -36,6 +36,8 @@ const bwrapProbeScriptDirectory = fs.existsSync( ? __dirname : path.join(getSdkPackageRoot(), 'dist'); let windowsSandboxAvailableCache: boolean | undefined; +let wxcExecutableCache: { binDir: string | undefined; executable: string } | undefined; +let wxcExecutableVerifier = verifyWxcExecutable; /** * Check if Windows Sandbox feature is enabled via DISM. @@ -736,10 +738,25 @@ function getDarwinRustTargetTriple(): string { * @returns Path to wxc-exec.exe if found, null otherwise */ export function findWxcExecutable(): string | null { + const binDir = process.env.MXC_BIN_DIR; + const overridePath = binDir + ? path.join(binDir, getSdkArch(), 'wxc-exec.exe') + : undefined; + const cached = wxcExecutableCache; + if ( + cached !== undefined + && cached.binDir === binDir + && (overridePath === undefined || cached.executable === overridePath) + && wxcExecutableVerifier(cached.executable) + ) { + return cached.executable; + } + wxcExecutableCache = undefined; + // Allow override for bundled deployments (debugging/testing) - if (process.env.MXC_BIN_DIR) { - const overridePath = path.join(process.env.MXC_BIN_DIR, getSdkArch(), 'wxc-exec.exe'); - if (verifyWxcExecutable(overridePath)) { + if (overridePath !== undefined) { + if (wxcExecutableVerifier(overridePath)) { + wxcExecutableCache = { binDir, executable: overridePath }; return overridePath; } } @@ -762,7 +779,8 @@ export function findWxcExecutable(): string | null { ]; for (const wxcPath of possiblePaths) { - if (verifyWxcExecutable(wxcPath)) { + if (wxcExecutableVerifier(wxcPath)) { + wxcExecutableCache = { binDir, executable: wxcPath }; return wxcPath; } } @@ -770,6 +788,18 @@ export function findWxcExecutable(): string | null { return null; } +/** @internal Test-only: clear the resolved executable path. */ +export function _resetWxcExecutableCache(): void { + wxcExecutableCache = undefined; +} + +/** @internal Test-only: override executable verification. */ +export function _setWxcExecutableVerifier( + verifier: ((execPath: string) => boolean) | null, +): void { + wxcExecutableVerifier = verifier ?? verifyWxcExecutable; +} + /** * Verify that an executable exists at the given path * @param execPath - Path to verify diff --git a/sdk/node/src/sandbox.ts b/sdk/node/src/sandbox.ts index b71577791..007242ea3 100644 --- a/sdk/node/src/sandbox.ts +++ b/sdk/node/src/sandbox.ts @@ -6,7 +6,12 @@ import * as os from 'os'; import { spawn, ChildProcess } from 'child_process'; import { randomBytes } from "crypto"; import { parse as semverParse } from 'semver'; -import { SandboxPolicy, ContainerConfig, ContainmentType, ContainmentBackend } from './types.js'; +import { + SandboxPolicy, + ContainerConfig, + ContainmentType, + ContainmentBackend, +} from './types.js'; import { prepareSpawn, diagLogVersion, applyLinuxNetworkPolicy } from './helper.js'; import { diagLog } from './diagnostic.js'; import { MxcError, mxcErrorFromEnvelope } from './errors.js'; @@ -301,6 +306,7 @@ export function createConfigFromPolicy( commandLine: '', timeout: policy.timeoutMs ?? 0, }, + telemetry: policy.telemetry === undefined ? undefined : { ...policy.telemetry }, }; // Microvm: delegate to dedicated builder diff --git a/sdk/node/src/state-aware-helper.ts b/sdk/node/src/state-aware-helper.ts index 800b611b0..e39f56fdf 100644 --- a/sdk/node/src/state-aware-helper.ts +++ b/sdk/node/src/state-aware-helper.ts @@ -2,12 +2,12 @@ // 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'; import { diagLog } from './diagnostic.js'; import { Phase, StateAwareContainmentBackend } from './state-aware-types.js'; +import { TelemetryConfig } from './types.js'; export const STATE_AWARE_VERSION = '0.6.0-alpha'; @@ -96,23 +96,21 @@ export interface BuildEnvelopeArgs { * remaining backend-specific fields under `experimental..`. */ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record { - const { phase, backendKey, containment, sandboxId, 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 suppliedVersion = typeof backendSpecific.version === 'string' && backendSpecific.version; - const hasTelemetry = backendSpecific.telemetry !== undefined; + const telemetry = backendSpecific.telemetry as TelemetryConfig | undefined; + const hasTelemetry = 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 }; @@ -122,6 +120,10 @@ export function buildStateAwareEnvelope(args: BuildEnvelopeArgs): Record = string & { readonly __mxcBrand: 'SandboxId'; readonly __mxcBackend: C }; +interface StateAwareConfig { + /** + * Schema version. When omitted, the SDK selects `0.9.0-alpha` if telemetry + * is present; otherwise it selects the backend default. + */ + version?: string; + /** Optional telemetry request for this phase. */ + telemetry?: TelemetryConfig; +} + // IsolationSession per-(backend, phase) Configs. Each declares only // the fields the SDK currently exposes at that phase — scoped to // what the backend honors per the policy honor matrix and currently // implements. TypeScript rejects passing fields outside this set. -export interface IsolationSessionProvisionConfig { - /** Schema version (semver). When omitted, the SDK fills in its own SUPPORTED_VERSION. */ - version?: string; - telemetry?: TelemetryConfig; +export interface IsolationSessionProvisionConfig extends StateAwareConfig { /** * Optional identifier for the calling application. * @@ -75,30 +82,15 @@ export interface IsolationSessionProvisionConfig { network: { defaultPolicy: 'allow'; allowLocalNetwork: true }; } -export interface IsolationSessionStartConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type IsolationSessionStartConfig = StateAwareConfig; -export interface IsolationSessionExecConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; +export interface IsolationSessionExecConfig extends StateAwareConfig { process: ProcessConfig; } -export interface IsolationSessionStopConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type IsolationSessionStopConfig = StateAwareConfig; -export interface IsolationSessionDeprovisionConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type IsolationSessionDeprovisionConfig = StateAwareConfig; /** * IsolationSession's provision-phase metadata surfaced to the caller: the @@ -118,10 +110,7 @@ export interface IsolationSessionProvisionMetadata { // (readwrite/readonly/denied HOST paths) is honored at provision and is // immutable thereafter. -export interface WindowsSandboxProvisionConfig { - /** Schema version (semver). When omitted, the SDK fills in its own SUPPORTED_VERSION. */ - version?: string; - telemetry?: TelemetryConfig; +export interface WindowsSandboxProvisionConfig extends StateAwareConfig { /** * Filesystem policy applied at provision and frozen for the life of the * sandbox. `readwritePaths` / `readonlyPaths` are mapped into the guest at @@ -133,30 +122,15 @@ export interface WindowsSandboxProvisionConfig { filesystem?: FilesystemConfig; } -export interface WindowsSandboxStartConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type WindowsSandboxStartConfig = StateAwareConfig; -export interface WindowsSandboxExecConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; +export interface WindowsSandboxExecConfig extends StateAwareConfig { process: ProcessConfig; } -export interface WindowsSandboxStopConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type WindowsSandboxStopConfig = StateAwareConfig; -export interface WindowsSandboxDeprovisionConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type WindowsSandboxDeprovisionConfig = StateAwareConfig; // WSLc per-(backend, phase) Configs. WSLc runs each sandbox as a warm // container behind a persistent host-side daemon (one amortized WSL session @@ -164,10 +138,7 @@ export interface WindowsSandboxDeprovisionConfig { // provision and frozen for the sandbox's lifetime; a cooperative env-var proxy // may be injected per-exec. -export interface WslcProvisionConfig { - /** Schema version (semver). When omitted, the SDK fills in `0.8.0-alpha`. */ - version?: string; - telemetry?: TelemetryConfig; +export interface WslcProvisionConfig extends StateAwareConfig { /** * Filesystem policy applied at provision and frozen for the life of the * sandbox. `readwritePaths` / `readonlyPaths` become container volume mounts @@ -200,16 +171,9 @@ export interface WslcProvisionConfig { imageTarPath?: string; } -export interface WslcStartConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type WslcStartConfig = StateAwareConfig; -export interface WslcExecConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; +export interface WslcExecConfig extends StateAwareConfig { process: ProcessConfig; /** * Per-exec network overrides. Only `proxy` is honored: it injects a @@ -224,17 +188,9 @@ export interface WslcExecConfig { network?: NetworkConfig; } -export interface WslcStopConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type WslcStopConfig = StateAwareConfig; -export interface WslcDeprovisionConfig { - /** Schema version (semver). */ - version?: string; - telemetry?: TelemetryConfig; -} +export type WslcDeprovisionConfig = StateAwareConfig; /** * The five per-phase Config slots every state-aware backend must declare. diff --git a/sdk/node/src/telemetry.ts b/sdk/node/src/telemetry.ts new file mode 100644 index 000000000..775c63288 --- /dev/null +++ b/sdk/node/src/telemetry.ts @@ -0,0 +1,751 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFile, spawn } from 'node:child_process'; +import { findWxcExecutable } from './platform.js'; + +const TELEMETRY_CONSENT_STATES = ['granted', 'denied', 'undetermined', 'not-applicable'] as const; +const TELEMETRY_POLICY_STATES = ['unrestricted', 'allowed', 'blocked', 'not-applicable'] as const; +const TELEMETRY_CONSENT_DECISIONS = ['yes', 'no', 'dismissed'] as const; +const TELEMETRY_CONSENT_RESULTS = [ + 'granted', + 'denied', + 'dismissed', + 'withdrawn', + 'alreadyGranted', + 'policyBlocked', + 'presentationUnavailable', + 'notApplicable', +] as const; +const CONSENT_STATUS_REASONS = [ + 'no-record', + 'store-unreadable', + 'store-malformed', + 'consent-schema-unsupported', + 'prompt-version-missing', + 'prompt-version-unsupported', + 'policy-blocked', + 'presentation-unavailable', + 'not-applicable', +] as const; +// Protocol-only results: never a successful outcome for a caller. +const CONSENT_PROTOCOL_ONLY_RESULTS = [ + 'status', + 'presentationRequired', +] as const; +const CONSENT_PROTOCOL_RESULTS = [ + ...CONSENT_PROTOCOL_ONLY_RESULTS, + ...TELEMETRY_CONSENT_RESULTS, +] as const; + +export type TelemetryConsentState = (typeof TELEMETRY_CONSENT_STATES)[number]; +export type TelemetryPolicyState = (typeof TELEMETRY_POLICY_STATES)[number]; +export type TelemetryConsentDecision = (typeof TELEMETRY_CONSENT_DECISIONS)[number]; +export type TelemetryConsentResult = (typeof TELEMETRY_CONSENT_RESULTS)[number]; +type ConsentStatusReason = (typeof CONSENT_STATUS_REASONS)[number]; +type TelemetryConsentProtocolResult = (typeof CONSENT_PROTOCOL_RESULTS)[number]; + +export interface TelemetryConsentMessage { + id: string; + text: string; +} + +export interface TelemetryConsentPrompt { + resourceVersion: number; + locale: string; + title: TelemetryConsentMessage; + body: TelemetryConsentMessage; + affirmativeLabel: TelemetryConsentMessage; + negativeLabel: TelemetryConsentMessage; + learnMoreLabel: TelemetryConsentMessage; + learnMoreUrl: string; +} + +export interface TelemetryConsentOutcome { + action: 'request' | 'withdraw'; + result: TelemetryConsentResult; + storedState: TelemetryConsentState; + effectiveState: TelemetryConsentState; + policy: TelemetryPolicyState; + needsPrompt: boolean; +} + +interface TelemetryConsentProtocolResponse + extends Omit { + action: ConsentAction; + result: TelemetryConsentProtocolResult; + reason: ConsentStatusReason | null; + prompt?: TelemetryConsentPrompt | null; + challenge?: string | null; +} + +export type TelemetryConsentPresenter = ( + prompt: TelemetryConsentPrompt, + signal?: AbortSignal, +) => TelemetryConsentDecision | Promise; + +export interface TelemetryConsentQuery { + state: TelemetryConsentState; + storedState: TelemetryConsentState; + effectiveState: TelemetryConsentState; + needsPrompt: boolean; + policy: TelemetryPolicyState; + error?: string; +} + +interface ConsentCommandOutput { + stdout: string; + stderr: string; +} + +type ConsentAsyncRunner = (args: readonly string[]) => Promise; +type ConsentAction = 'request' | 'withdraw' | 'status'; +type ConsentProtocolRunner = ( + locale: string | undefined, + presenter: TelemetryConsentPresenter, +) => Promise; +type ConsentChildFactory = (args: readonly string[]) => ReturnType; + +const DEFAULT_CONSENT_REQUEST_TIMEOUT_MS = 30_000; +const MAX_CONSENT_STDOUT_BYTES = 1024 * 1024; +const MAX_CONSENT_STDERR_BYTES = 64 * 1024; +const MAX_CONSENT_PROTOCOL_LINES = 16; +let consentRequestTimeoutMs = DEFAULT_CONSENT_REQUEST_TIMEOUT_MS; +const defaultConsentChildFactory: ConsentChildFactory = (args) => + spawn(executable(), [...args], { + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); +let consentChildFactory: ConsentChildFactory = defaultConsentChildFactory; + +function maintenanceArgs(action: 'request' | 'withdraw' | 'status', locale?: string): string[] { + const args = ['--telemetry-consent', action]; + if (action === 'request') { + if (locale !== undefined) { + args.push(`--telemetry-consent-locale=${locale}`); + } + } + return args; +} + +function executable(): string { + const path = findWxcExecutable(); + if (!path) { + throw new Error('wxc-exec was not found; the MXC native binary is missing from this installation'); + } + return path; +} + +function defaultConsentAsyncRunner(args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + executable(), + [...args], + { + timeout: 5000, + encoding: 'utf-8', + windowsHide: true, + }, + (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve({ stdout, stderr }); + } + }, + ); + }); +} + +async function defaultConsentProtocolRunner( + locale: string | undefined, + presenter: TelemetryConsentPresenter, +): Promise { + return new Promise((resolve, reject) => { + const child = consentChildFactory(maintenanceArgs('request', locale)); + const childStdin = child.stdin; + const childStdout = child.stdout; + const childStderr = child.stderr; + if (childStdin === null || childStdout === null || childStderr === null) { + child.kill(); + reject(new Error('telemetry consent process did not expose stdio pipes')); + return; + } + let stdout = ''; + let stderr = ''; + let finalResponse: TelemetryConsentOutcome | undefined; + let presentationSeen = false; + let presenterResponsePending = false; + let terminalSeen = false; + let settled = false; + let timeout: NodeJS.Timeout | null = null; + let timeoutStartedAt = 0; + let timeoutRemainingMs = consentRequestTimeoutMs; + let lineQueue: Promise = Promise.resolve(); + let childKilled = false; + let stdoutBytes = 0; + let stderrBytes = 0; + let protocolLines = 0; + let bufferedOutputReceivedWhilePresenterPending = false; + let childExitCode: number | null | undefined; + const presenterAbort = new AbortController(); + let rejectPresenterWait: ((error: Error) => void) | undefined; + + const clearProtocolDeadline = (): void => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; + const pauseProtocolDeadline = (): boolean => { + if (timeout !== null) { + timeoutRemainingMs -= Date.now() - timeoutStartedAt; + clearProtocolDeadline(); + } + if (timeoutRemainingMs <= 0) { + fail(new Error('telemetry consent request timed out')); + return false; + } + return true; + }; + const resumeProtocolDeadline = (): void => { + if (settled) { + return; + } + clearProtocolDeadline(); + if (timeoutRemainingMs <= 0) { + fail(new Error('telemetry consent request timed out')); + return; + } + timeoutStartedAt = Date.now(); + timeout = setTimeout(() => { + fail(new Error('telemetry consent request timed out')); + }, timeoutRemainingMs); + }; + + const fail = (error: unknown): void => { + if (!settled) { + settled = true; + clearProtocolDeadline(); + presenterAbort.abort(); + rejectPresenterWait?.( + error instanceof Error ? error : new Error(String(error)), + ); + rejectPresenterWait = undefined; + if (!childKilled) { + childKilled = true; + child.kill(); + } + reject(error instanceof Error ? error : new Error(String(error))); + } + }; + resumeProtocolDeadline(); + + const processResponse = async ( + response: TelemetryConsentProtocolResponse, + ): Promise => { + if (settled) { + return; + } + + if (response.result !== 'presentationRequired') { + if (terminalSeen) { + fail(new Error('telemetry consent protocol emitted multiple terminal responses')); + return; + } + terminalSeen = true; + finalResponse = toConsentOutcome(response); + return; + } + if (terminalSeen) { + fail(new Error('telemetry consent protocol emitted a presentation after its terminal response')); + return; + } + if (presentationSeen) { + fail(new Error('telemetry consent protocol emitted multiple presentations')); + return; + } + presentationSeen = true; + if (!isConsentPrompt(response.prompt) || !isChallenge(response.challenge)) { + fail(new Error('telemetry consent presentation omitted its prompt or challenge')); + return; + } + if (childExitCode !== undefined) { + fail(new Error( + `telemetry consent process exited before presentation completed (${childExitCode ?? 'no exit code'})`, + )); + return; + } + + const resourceVersion = response.prompt.resourceVersion; + let decision: TelemetryConsentDecision = 'dismissed'; + if (!pauseProtocolDeadline()) { + return; + } + try { + const processEnded = new Promise((_resolve, reject) => { + rejectPresenterWait = reject; + }); + decision = await Promise.race([ + Promise.resolve(presenter(response.prompt, presenterAbort.signal)), + processEnded, + ]); + if (!isDecision(decision)) { + throw new Error(`consent presenter returned invalid decision '${String(decision)}'`); + } + } catch (error) { + rejectPresenterWait = undefined; + fail(error); + return; + } finally { + rejectPresenterWait = undefined; + } + if (settled) { + return; + } + resumeProtocolDeadline(); + if (settled) { + return; + } + presenterResponsePending = false; + childStdin.write(`${JSON.stringify({ + challenge: response.challenge, + resourceVersion, + decision, + })}\n`); + childStdin.end(); + }; + + const receiveLine = ( + line: string, + receivedWhilePresenterPending = false, + ): void => { + if (settled) { + return; + } + protocolLines += 1; + if (protocolLines > MAX_CONSENT_PROTOCOL_LINES) { + fail(new Error('telemetry consent process exceeded the protocol line limit')); + return; + } + if (line.trim() === '') { + return; + } + + let response: TelemetryConsentProtocolResponse; + try { + response = parseMaintenanceResponse(line, 'request'); + } catch (error) { + fail(error); + return; + } + if ( + (receivedWhilePresenterPending || presenterResponsePending) + && response.result !== 'presentationRequired' + ) { + fail(new Error( + 'telemetry consent protocol emitted a terminal response before the presenter decision was written', + )); + return; + } + if (response.result === 'presentationRequired') { + presenterResponsePending = true; + } + lineQueue = lineQueue + .then(() => processResponse(response)) + .catch(fail); + }; + + childStdout.setEncoding('utf8'); + childStdout.on('data', (chunk: string) => { + if (settled) { + return; + } + stdoutBytes += Buffer.byteLength(chunk, 'utf8'); + if (stdoutBytes > MAX_CONSENT_STDOUT_BYTES) { + fail(new Error('telemetry consent process exceeded the stdout limit')); + return; + } + stdout += chunk; + const lines = stdout.split(/\r?\n/); + stdout = lines.pop() ?? ''; + const bufferedLineWasPremature = bufferedOutputReceivedWhilePresenterPending; + bufferedOutputReceivedWhilePresenterPending = false; + for (const [index, line] of lines.entries()) { + receiveLine(line, index === 0 && bufferedLineWasPremature); + } + if (lines.length === 0 && bufferedLineWasPremature) { + bufferedOutputReceivedWhilePresenterPending = true; + } + if (presenterResponsePending && stdout.trim() !== '') { + bufferedOutputReceivedWhilePresenterPending = true; + } + }); + childStdout.on('error', fail); + childStderr.setEncoding('utf8'); + childStderr.on('data', (chunk: string) => { + if (settled) { + return; + } + stderrBytes += Buffer.byteLength(chunk, 'utf8'); + if (stderrBytes > MAX_CONSENT_STDERR_BYTES) { + fail(new Error('telemetry consent process exceeded the stderr limit')); + return; + } + stderr += chunk; + }); + childStderr.on('error', fail); + childStdin.on('error', (error) => { + if (!settled) { + fail(error); + } + }); + child.on('error', fail); + child.on('close', (code) => { + childExitCode = code; + if (rejectPresenterWait !== undefined) { + presenterAbort.abort(); + rejectPresenterWait( + new Error(`telemetry consent process exited before presentation completed (${code ?? 'no exit code'})`), + ); + rejectPresenterWait = undefined; + } + void (async (): Promise => { + await lineQueue; + if (settled) return; + clearProtocolDeadline(); + if (stdout.trim() !== '') { + receiveLine(stdout, bufferedOutputReceivedWhilePresenterPending); + await lineQueue; + if (settled) return; + } + if (finalResponse === undefined) { + fail(new Error(`telemetry consent process failed (${code ?? 'no exit code'}): ${stderr.trim()}`)); + return; + } + const validTerminalExit = ( + (finalResponse.result === 'presentationUnavailable' && code === 1) + || (finalResponse.result !== 'presentationUnavailable' && code === 0) + ); + if (!validTerminalExit) { + fail(new Error(`telemetry consent process failed (${code ?? 'no exit code'}): ${stderr.trim()}`)); + return; + } + reportNativeDiagnostic('requestTelemetryConsent', stderr); + settled = true; + resolve(finalResponse); + })().catch(fail); + }); + }); +} + +let consentAsyncRunner: ConsentAsyncRunner = defaultConsentAsyncRunner; +let protocolRunner: ConsentProtocolRunner = defaultConsentProtocolRunner; +let platformOverride: NodeJS.Platform | null = null; + +/** @internal Test-only. */ +export function _setTelemetryConsentAsyncRunner(runner: ConsentAsyncRunner | null): void { + consentAsyncRunner = runner ?? defaultConsentAsyncRunner; +} + +/** @internal Test-only. */ +export function _setTelemetryConsentProtocolRunner(runner: ConsentProtocolRunner | null): void { + protocolRunner = runner ?? defaultConsentProtocolRunner; +} + +/** @internal Test-only. */ +export function _setTelemetryConsentChildFactory(factory: ConsentChildFactory | null): void { + consentChildFactory = factory ?? defaultConsentChildFactory; +} + +/** @internal Test-only. */ +export function _setTelemetryConsentTimeoutMs(timeoutMs: number | null): void { + consentRequestTimeoutMs = timeoutMs ?? DEFAULT_CONSENT_REQUEST_TIMEOUT_MS; +} + +/** @internal Test-only. */ +export function _setTelemetryPlatform(platform: NodeJS.Platform | null): void { + platformOverride = platform; +} + +function isWindows(): boolean { + return (platformOverride ?? process.platform) === 'win32'; +} + +function includes(values: readonly T[], value: unknown): value is T { + return typeof value === 'string' && values.includes(value as T); +} + +function isConsentState(value: unknown): value is TelemetryConsentState { + return includes(TELEMETRY_CONSENT_STATES, value); +} + +function isPolicyState(value: unknown): value is TelemetryPolicyState { + return includes(TELEMETRY_POLICY_STATES, value); +} + +function isDecision(value: unknown): value is TelemetryConsentDecision { + return includes(TELEMETRY_CONSENT_DECISIONS, value); +} + +function isStatusReason(value: unknown): value is ConsentStatusReason { + return includes(CONSENT_STATUS_REASONS, value); +} + +function isConsentMessage(value: unknown): value is TelemetryConsentMessage { + if (value === null || typeof value !== 'object') { + return false; + } + const message = value as Record; + return typeof message.id === 'string' && typeof message.text === 'string'; +} + +function isConsentPrompt(value: unknown): value is TelemetryConsentPrompt { + if (value === null || typeof value !== 'object') { + return false; + } + const prompt = value as Record; + return Number.isSafeInteger(prompt.resourceVersion) + && (prompt.resourceVersion as number) > 0 + && typeof prompt.locale === 'string' + && isConsentMessage(prompt.title) + && isConsentMessage(prompt.body) + && isConsentMessage(prompt.affirmativeLabel) + && isConsentMessage(prompt.negativeLabel) + && isConsentMessage(prompt.learnMoreLabel) + && typeof prompt.learnMoreUrl === 'string'; +} + +function isChallenge(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isResult(value: unknown): value is TelemetryConsentProtocolResult { + return includes(CONSENT_PROTOCOL_RESULTS, value); +} + +function isResultForAction( + action: ConsentAction, + result: TelemetryConsentProtocolResult, +): boolean { + switch (action) { + case 'status': + return result === 'status' || result === 'notApplicable'; + case 'withdraw': + return result === 'withdrawn' || result === 'notApplicable'; + case 'request': + return [ + 'presentationRequired', + 'granted', + 'denied', + 'dismissed', + 'alreadyGranted', + 'policyBlocked', + 'presentationUnavailable', + 'notApplicable', + ].includes(result); + } +} + +function shouldPrompt( + effectiveState: TelemetryConsentState, + policy: TelemetryPolicyState, +): boolean { + return effectiveState === 'undetermined' + && (policy === 'unrestricted' || policy === 'allowed'); +} + +function parseMaintenanceResponse( + stdout: string, + expectedAction: ConsentAction, +): TelemetryConsentProtocolResponse { + const parsed: unknown = JSON.parse(stdout); + if (parsed === null || typeof parsed !== 'object') { + throw new Error('unrecognised telemetry consent output'); + } + const value = parsed as Record; + if ( + !isConsentState(value.storedState) + || !isConsentState(value.effectiveState) + || !isPolicyState(value.policy) + || !isResult(value.result) + || value.action !== expectedAction + || !isResultForAction(expectedAction, value.result) + || typeof value.needsPrompt !== 'boolean' + || value.needsPrompt !== shouldPrompt(value.effectiveState, value.policy) + || !Object.hasOwn(value, 'reason') + || ( + value.reason !== null + && !isStatusReason(value.reason) + ) + ) { + throw new Error(`unrecognised telemetry consent output: ${stdout.trim().slice(0, 200)}`); + } + if ( + value.result === 'presentationRequired' + && (!isConsentPrompt(value.prompt) || !isChallenge(value.challenge)) + ) { + throw new Error(`unrecognised telemetry consent output: ${stdout.trim().slice(0, 200)}`); + } + return parsed as TelemetryConsentProtocolResponse; +} + +function toConsentOutcome(response: TelemetryConsentProtocolResponse): TelemetryConsentOutcome { + if ( + response.action === 'status' + || includes(CONSENT_PROTOCOL_ONLY_RESULTS, response.result) + ) { + throw new Error('unrecognised telemetry consent terminal output'); + } + return { + action: response.action, + result: response.result, + storedState: response.storedState, + effectiveState: response.effectiveState, + policy: response.policy, + needsPrompt: response.needsPrompt, + }; +} + +const MAX_REPORTED_FAILURE_CATEGORIES = 64; +const MAX_DIAGNOSTIC_LENGTH = 512; +const reportedFailureCategories = new Set(); + +function tryRegisterFailureCategory(category: string): boolean { + if ( + reportedFailureCategories.has(category) + || reportedFailureCategories.size >= MAX_REPORTED_FAILURE_CATEGORIES + ) { + return false; + } + reportedFailureCategories.add(category); + return true; +} + +function boundedDiagnostic(detail: string): string { + const normalized = detail.replace(/\s+/g, ' ').trim(); + return normalized.length <= MAX_DIAGNOSTIC_LENGTH + ? normalized + : `${normalized.slice(0, MAX_DIAGNOSTIC_LENGTH)}...`; +} + +function reportFailClosed(operation: string, safeResult: string, detail: string): void { + try { + const category = `${operation}:${safeResult}:failure`; + if (tryRegisterFailureCategory(category)) { + const message = `mxc-sdk: ${operation} failed and is reporting '${safeResult}' to stay fail-closed: ${boundedDiagnostic(detail)}`; + console.warn(message); + } + } catch { + // Reporting must not affect the fail-closed result. + } +} + +function reportNativeDiagnostic(operation: string, stderr: string): void { + const detail = stderr.trim(); + if (detail === '') { + return; + } + try { + const category = `${operation}:native`; + if (tryRegisterFailureCategory(category)) { + console.warn(`mxc-sdk: ${operation} native diagnostic: ${boundedDiagnostic(detail)}`); + } + } catch { + // Reporting must not affect the native result. + } +} + +/** @internal Test-only. */ +export function _resetTelemetryFailureReporting(): void { + reportedFailureCategories.clear(); +} + +function notApplicable(action: 'request' | 'withdraw'): TelemetryConsentOutcome { + return { + action, + result: 'notApplicable', + storedState: 'not-applicable', + effectiveState: 'not-applicable', + needsPrompt: false, + policy: 'not-applicable', + }; +} + +function consentQueryFromResponse( + response: TelemetryConsentProtocolResponse, +): TelemetryConsentQuery { + return { + state: response.effectiveState, + storedState: response.storedState, + effectiveState: response.effectiveState, + needsPrompt: shouldPrompt(response.effectiveState, response.policy), + policy: response.policy, + }; +} + +function failedConsentQuery(operation: string, error: unknown): TelemetryConsentQuery { + const detail = error instanceof Error ? error.message : String(error); + reportFailClosed(operation, 'undetermined', detail); + return { + state: 'undetermined', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: false, + policy: 'blocked', + error: `failed to read telemetry consent: ${detail}`, + }; +} + +/** Read persisted/effective consent and policy without blocking the event loop. */ +export async function queryTelemetryConsentAsync(): Promise { + if (!isWindows()) { + return { + state: 'not-applicable', + storedState: 'not-applicable', + effectiveState: 'not-applicable', + needsPrompt: false, + policy: 'not-applicable', + }; + } + try { + const output = await consentAsyncRunner(maintenanceArgs('status')); + reportNativeDiagnostic('queryTelemetryConsentAsync', output.stderr); + return consentQueryFromResponse( + parseMaintenanceResponse(output.stdout, 'status'), + ); + } catch (error) { + return failedConsentQuery('queryTelemetryConsentAsync', error); + } +} + +/** Request consent with the versioned canonical consent resource. */ +export async function requestTelemetryConsent( + presenter: TelemetryConsentPresenter, + locale?: string, +): Promise { + if (!isWindows()) { + return notApplicable('request'); + } + return protocolRunner(locale, presenter); +} + +/** Idempotently withdraw telemetry consent without blocking the event loop. */ +export async function withdrawTelemetryConsentAsync(): Promise { + if (!isWindows()) { + return notApplicable('withdraw'); + } + try { + const output = await consentAsyncRunner(maintenanceArgs('withdraw')); + reportNativeDiagnostic('withdrawTelemetryConsentAsync', output.stderr); + return toConsentOutcome(parseMaintenanceResponse( + output.stdout, + 'withdraw', + )); + } catch (error) { + throw new Error( + `failed to withdraw telemetry consent: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index e2b5d3d8d..5b945979b 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -345,11 +345,16 @@ export interface PortMapping { protocol?: 'tcp'; } -/** Telemetry configuration for TraceLogging ETW support. */ +/** + * Telemetry configuration for TraceLogging ETW support. + */ export interface TelemetryConfig { /** - * Explicit telemetry opt-in. `true` requests telemetry subject to user - * consent and administrative policy; `false` or `undefined` keeps it off. + * Per-invocation telemetry opt-in. + * + * `true` requests telemetry for this invocation; emission is still gated by + * persisted user consent and administrative policy. `false` (or `undefined`) + * disables telemetry for this invocation. */ enabled?: boolean; } @@ -388,11 +393,11 @@ export interface ContainerConfig { network?: NetworkConfig; /** Runtime values supplied separately from sandbox policy. */ runtimeConfig?: RuntimeConfig; - /** Telemetry configuration */ + /** Telemetry configuration for TraceLogging ETW support */ telemetry?: TelemetryConfig; /** Experimental features (only applied when --experimental flag is set) */ experimental?: { - /** WSLC SDK configuration for Linux containers from Windows */ + /** WSLC SDK configuration for Linux containers from Windows */ wslc?: WslcConfig; }; /** macOS Seatbelt sandbox configuration (macOS only) */ @@ -452,6 +457,8 @@ export type SandboxPolicy = { }; /** Schema 0.8 runtime values supplied separately from sandbox policy. */ runtimeConfig?: RuntimeConfig; + /** Per-invocation telemetry opt-in, subject to consent and policy. */ + telemetry?: TelemetryConfig; /** Schema 0.8 ProcessContainer-specific policy. */ processContainer?: { /** ProcessContainer-specific networking settings. */ diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts index 8240cf8f7..8e3e14b4d 100644 --- a/sdk/node/tests/integration/test-helpers.ts +++ b/sdk/node/tests/integration/test-helpers.ts @@ -71,6 +71,9 @@ export const EXPECTED_MACOS_BINARIES = [ const OPTIONAL_BINARIES = [ 'wslcsdk.dll', // Only built with --with-wslc 'wxc-wslc-daemon.exe', // Only built with --with-wslc + 'nanvixd.exe', // Only built with --with-microvm + 'nanvix_rootfs.img', // Only built with --with-microvm + 'python3.initrd', // Only built with --with-microvm 'plm.exe', // Permissive Learning Mode helper (Windows-only); staged // only when the plm crate is included in the build. // Test-only binaries. The GitHub build artifact carries them so the diff --git a/sdk/node/tests/unit/default-consent-protocol-runner.test.ts b/sdk/node/tests/unit/default-consent-protocol-runner.test.ts new file mode 100644 index 000000000..46e8dfb8a --- /dev/null +++ b/sdk/node/tests/unit/default-consent-protocol-runner.test.ts @@ -0,0 +1,799 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Exercises the production protocol runner with controlled child-process I/O. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { PassThrough, type Readable, type Writable } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; + +import { + requestTelemetryConsent, + _setTelemetryPlatform, + _setTelemetryConsentChildFactory, + _setTelemetryConsentProtocolRunner, + _setTelemetryConsentTimeoutMs, + _resetTelemetryFailureReporting, + type TelemetryConsentPrompt, +} from '../../src/telemetry.js'; + +// A minimal ChildProcess-shaped fake wxc-exec that lets a test drive the +// stdout/stderr/exit sequence turn-by-turn. +interface FakeChild extends EventEmitter { + stdout: Readable; + stderr: Readable; + stdin: Writable; + kill(signal?: NodeJS.Signals | number): boolean; + killed: boolean; + killCount: number; + emitClose(code: number): void; + writeStdout(chunk: string): void; + writeStderr(chunk: string): void; + stdinChunks: string[]; + stdinEnded: boolean; +} + +function makeFakeChild(): FakeChild { + const emitter = new EventEmitter() as FakeChild; + emitter.stdout = new PassThrough(); + emitter.stderr = new PassThrough(); + emitter.stdin = new PassThrough(); + emitter.killed = false; + emitter.killCount = 0; + emitter.stdinChunks = []; + emitter.stdinEnded = false; + emitter.stdin.on('data', (chunk) => { + emitter.stdinChunks.push(chunk.toString('utf8')); + }); + emitter.stdin.on('end', () => { + emitter.stdinEnded = true; + }); + emitter.kill = (): boolean => { + emitter.killCount += 1; + emitter.killed = true; + (emitter.stdout as PassThrough).end(); + (emitter.stderr as PassThrough).end(); + return true; + }; + emitter.emitClose = (code: number): void => { + (emitter.stdout as PassThrough).end(); + (emitter.stderr as PassThrough).end(); + emitter.emit('close', code); + }; + emitter.writeStdout = (chunk: string): void => { + (emitter.stdout as PassThrough).write(chunk); + }; + emitter.writeStderr = (chunk: string): void => { + (emitter.stderr as PassThrough).write(chunk); + }; + return emitter; +} + +// The runner types the factory as returning a `ChildProcess`. Our fake covers +// only the subset the runner uses. Cast once here rather than everywhere. +function installFakeChildFactory(): { current: FakeChild; args: readonly string[] } { + const box: { current: FakeChild; args: readonly string[] } = { + current: null as unknown as FakeChild, + args: [], + }; + _setTelemetryConsentChildFactory((args) => { + box.args = args; + box.current = makeFakeChild(); + return box.current as unknown as ChildProcess; + }); + return box; +} + +const prompt: TelemetryConsentPrompt = { + resourceVersion: 1, + locale: 'en-US', + title: { id: 'telemetry.consent.title', text: 'Help improve MXC' }, + body: { id: 'telemetry.consent.body', text: 'canonical body' }, + affirmativeLabel: { id: 'telemetry.consent.yes', text: 'Yes' }, + negativeLabel: { id: 'telemetry.consent.no', text: 'No' }, + learnMoreLabel: { id: 'telemetry.consent.learnMore', text: 'Learn more' }, + learnMoreUrl: 'https://example.microsoft.com/consent', +}; +const yesDecisionFixture = JSON.parse(readFileSync( + new URL('../../../../../tests/fixtures/telemetry-consent/presenter-decision-yes.json', import.meta.url), + 'utf8', +)) as Record; + +function presentationLine(challenge = 'request-a'): string { + return `${JSON.stringify({ + action: 'request', + result: 'presentationRequired', + challenge, + prompt, + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: true, + policy: 'unrestricted', + reason: null, + })}\n`; +} + +function grantedLine(): string { + return `${JSON.stringify({ + action: 'request', + result: 'granted', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'unrestricted', + reason: null, + })}\n`; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitFor: predicate did not become true within ${timeoutMs}ms`); + } + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe('defaultConsentProtocolRunner (real code path)', () => { + beforeEach(() => { + _setTelemetryPlatform('win32'); + // Ensure the DEFAULT runner is exercised, not one previously injected. + _setTelemetryConsentProtocolRunner(null); + _resetTelemetryFailureReporting(); + }); + + afterEach(() => { + _setTelemetryPlatform(null); + _setTelemetryConsentChildFactory(null); + _setTelemetryConsentProtocolRunner(null); + _setTelemetryConsentTimeoutMs(null); + }); + + it('assembles a presentationRequired line split across multiple stdout chunks', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + assert.deepStrictEqual(box.args, [ + '--telemetry-consent', + 'request', + ]); + assert.ok(!box.args.includes('--config-base64')); + + const line = presentationLine(); + // Split the line in three fragments; the runner must accumulate them. + child.writeStdout(line.slice(0, 10)); + await new Promise((r) => setImmediate(r)); + child.writeStdout(line.slice(10, 40)); + await new Promise((r) => setImmediate(r)); + child.writeStdout(line.slice(40)); + + await waitFor(() => child.stdinEnded); + const echo = JSON.parse(child.stdinChunks.join('').trim()); + assert.strictEqual(echo.decision, 'yes'); + assert.deepStrictEqual(echo, yesDecisionFixture); + + child.writeStdout(grantedLine()); + child.emitClose(0); + const outcome = await promise; + assert.strictEqual(outcome.result, 'granted'); + assert.strictEqual(Object.hasOwn(outcome, 'challenge'), false); + assert.strictEqual(Object.hasOwn(outcome, 'prompt'), false); + }); + + it('echoes the native resource version even if the presenter mutates its prompt', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent((presentedPrompt) => { + presentedPrompt.resourceVersion = 99; + return 'yes'; + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => child.stdinEnded); + const echo = JSON.parse(child.stdinChunks.join('').trim()); + assert.strictEqual(echo.resourceVersion, prompt.resourceVersion); + + child.writeStdout(grantedLine()); + child.emitClose(0); + assert.strictEqual((await promise).result, 'granted'); + }); + + it('reports successful native request diagnostics once', async () => { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); + try { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStderr('mxc: telemetry administrative policy failure\n'); + child.writeStdout(presentationLine()); + await waitFor(() => child.stdinEnded); + child.writeStdout(grantedLine()); + child.emitClose(0); + + assert.strictEqual((await promise).result, 'granted'); + } finally { + console.warn = originalWarn; + } + assert.deepStrictEqual(warnings, [ + 'mxc-sdk: requestTelemetryConsent native diagnostic: ' + + 'mxc: telemetry administrative policy failure', + ]); + }); + + it('passes the requested locale only on the dedicated request command', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'dismissed', 'fr-FR'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + assert.deepStrictEqual(box.args, [ + '--telemetry-consent', + 'request', + '--telemetry-consent-locale=fr-FR', + ]); + child.writeStdout(`${JSON.stringify({ + action: 'request', + result: 'dismissed', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: true, + policy: 'unrestricted', + reason: null, + })}\n`); + child.emitClose(0); + assert.strictEqual((await promise).result, 'dismissed'); + }); + + it('rejects multiple terminal responses', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + + box.current.writeStdout(grantedLine() + grantedLine()); + + await assert.rejects(promise, /multiple terminal responses/); + assert.strictEqual(box.current.killCount, 1); + }); + + it('rejects a presentation after a terminal response', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + + box.current.writeStdout(grantedLine() + presentationLine()); + + await assert.rejects(promise, /presentation after its terminal response/); + assert.strictEqual(box.current.killCount, 1); + }); + + it('returns a presenter-unavailable terminal response', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + + box.current.writeStdout(`${JSON.stringify({ + action: 'request', + result: 'presentationUnavailable', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: true, + policy: 'unrestricted', + reason: 'presentation-unavailable', + })}\n`); + box.current.writeStderr('mxc: host presenter unavailable'); + box.current.emitClose(1); + + assert.deepStrictEqual(await promise, { + action: 'request', + result: 'presentationUnavailable', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: true, + policy: 'unrestricted', + }); + assert.strictEqual(box.current.killCount, 0); + }); + + it('rejects multiple presentations', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + + box.current.writeStdout(presentationLine() + presentationLine('request-b')); + + await assert.rejects(promise, /multiple presentations/); + assert.strictEqual(box.current.killCount, 1); + }); + + it('suspends the IO timeout while the presenter is thinking', async () => { + // The runner is documented to clear the IO timeout right before awaiting + // the presenter and rearm it after. A very short timeout would trip if + // the presenter's think time were counted toward it. + _setTelemetryConsentTimeoutMs(200); + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(async (): Promise<'yes'> => { + await new Promise((r) => setTimeout(r, 500)); + return 'yes'; + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + // The 500 ms sleep would trip a naive 200 ms timeout if suspend/resume + // were broken; we wait for the presenter echo instead. + await waitFor(() => child.stdinEnded, 3_000); + child.writeStdout(grantedLine()); + child.emitClose(0); + const outcome = await promise; + assert.strictEqual(outcome.result, 'granted'); + }); + + it('does not invoke the presenter when the IO deadline has already expired', async () => { + _setTelemetryConsentTimeoutMs(1_000); + const originalNow = Date.now; + let now = 1_000; + Date.now = () => now; + try { + const box = installFakeChildFactory(); + let presenterCalls = 0; + const promise = requestTelemetryConsent(() => { + presenterCalls += 1; + return 'yes'; + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + now = 2_000; + child.writeStdout(presentationLine()); + + await assert.rejects(promise, /timed out/); + assert.strictEqual(presenterCalls, 0); + assert.deepStrictEqual(child.stdinChunks, []); + assert.strictEqual(child.killCount, 1); + } finally { + Date.now = originalNow; + } + }); + + it('does not re-arm the IO timeout from stdout or stderr while the presenter is active', async () => { + _setTelemetryConsentTimeoutMs(100); + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(async (): Promise<'yes'> => { + box.current.writeStderr('still presenting\n'); + box.current.writeStdout('\n'); + await new Promise((r) => setTimeout(r, 300)); + return 'yes'; + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => child.stdinEnded, 3_000); + child.writeStdout(grantedLine()); + child.emitClose(0); + const outcome = await promise; + assert.strictEqual(outcome.result, 'granted'); + assert.strictEqual(child.killed, false); + }); + + it('rejects terminal output received before the presenter decision is written', async () => { + const box = installFakeChildFactory(); + let presenterStarted = false; + const promise = requestTelemetryConsent(() => { + presenterStarted = true; + return new Promise<'yes'>(() => {}); + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => presenterStarted); + child.writeStdout(grantedLine()); + + await assert.rejects(promise, /before the presenter decision was written/); + assert.strictEqual(child.killCount, 1); + assert.deepStrictEqual(child.stdinChunks, []); + }); + + it('rejects unterminated terminal output buffered before the presenter decision', async () => { + const box = installFakeChildFactory(); + let resolvePresenter!: (decision: 'no') => void; + const promise = requestTelemetryConsent( + () => new Promise<'no'>((resolve) => { + resolvePresenter = resolve; + }), + ); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => resolvePresenter !== undefined); + child.writeStdout(grantedLine().trimEnd()); + resolvePresenter('no'); + await waitFor(() => child.stdinEnded); + child.emitClose(0); + + await assert.rejects(promise, /before the presenter decision was written/); + const echo = JSON.parse(child.stdinChunks.join('').trim()); + assert.strictEqual(echo.decision, 'no'); + }); + + it('does not start a queued presenter after the child closes', async () => { + const box = installFakeChildFactory(); + let presenterCalls = 0; + const promise = requestTelemetryConsent(() => { + presenterCalls += 1; + return new Promise<'yes'>(() => {}); + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + child.emitClose(0); + + await assert.rejects(promise, /exited before presentation completed \(0\)/); + assert.strictEqual(presenterCalls, 0); + assert.deepStrictEqual(child.stdinChunks, []); + }); + + it('fails closed when the presenter throws and never writes a fallback decision', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => { + throw new Error('UI unavailable'); + }); + const rejection = assert.rejects(promise, /UI unavailable/); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + assert.deepStrictEqual(child.stdinChunks, []); + assert.strictEqual(child.stdinEnded, false); + }); + + it('fails closed when a dynamically typed presenter returns an invalid decision', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'maybe' as unknown as 'yes'); + const rejection = assert.rejects(promise, /invalid decision 'maybe'/); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + assert.deepStrictEqual(child.stdinChunks, []); + assert.strictEqual(child.stdinEnded, false); + }); + + it('rejects cleanly on a malformed presentation line and kills the child', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + const rejection = assert.rejects(promise); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout('this is not json at all\n'); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + assert.strictEqual(child.killCount, 1); + }); + + it('rejects a status response on the request protocol', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + const rejection = assert.rejects(promise, /unrecognised telemetry consent output/); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(`${JSON.stringify({ + action: 'status', + result: 'status', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'allowed', + reason: null, + })}\n`); + + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + assert.strictEqual(child.killCount, 1); + assert.deepStrictEqual(child.stdinChunks, []); + }); + + it('does not process queued lines after a protocol failure', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + const rejection = assert.rejects(promise); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(`not json\n${grantedLine()}`); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + assert.strictEqual(child.killCount, 1); + assert.deepStrictEqual(child.stdinChunks, []); + }); + + it('kills the child when stdin fails while replying to the presenter', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(async (): Promise<'yes'> => { + box.current.stdin.emit('error', new Error('stdin broke')); + return 'yes'; + }); + const rejection = assert.rejects(promise, /stdin broke/); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + }); + + it('fails closed when the child stdout stream errors', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.stdout.emit('error', new Error('stdout broke')); + + await assert.rejects(promise, /stdout broke/); + assert.strictEqual(child.killed, true); + assert.strictEqual(child.killCount, 1); + }); + + it('fails closed when the child stderr stream errors', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.stderr.emit('error', new Error('stderr broke')); + + await assert.rejects(promise, /stderr broke/); + assert.strictEqual(child.killed, true); + assert.strictEqual(child.killCount, 1); + }); + + it('rejects cleanly when the child exits before responding', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStderr('boom\n'); + child.emitClose(2); + await assert.rejects(promise, /telemetry consent process failed \(2\)/); + }); + + it('fires the IO timeout when the child produces no output at all', async () => { + _setTelemetryConsentTimeoutMs(50); + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + // Do not write to stdout/stderr; the timeout must fire. + await assert.rejects(promise, /timed out/); + assert.strictEqual(child.killed, true); + }); + + it('does not let continuous stderr output extend the IO timeout', async () => { + _setTelemetryConsentTimeoutMs(50); + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + const interval = setInterval(() => child.writeStderr('diagnostic\n'), 10); + try { + await assert.rejects(promise, /timed out/); + } finally { + clearInterval(interval); + } + assert.strictEqual(child.killed, true); + }); + + it('does not let partial stdout keep the protocol alive past its deadline', async () => { + _setTelemetryConsentTimeoutMs(50); + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + const interval = setInterval(() => child.writeStdout('x'), 10); + try { + await assert.rejects(promise, /timed out/); + } finally { + clearInterval(interval); + } + assert.strictEqual(child.killed, true); + }); + + it('rejects stdout that exceeds the protocol buffer limit', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout('x'.repeat(1024 * 1024 + 1)); + + await assert.rejects(promise, /stdout limit/); + assert.strictEqual(child.killed, true); + }); + + it('rejects stderr that exceeds the diagnostic buffer limit', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStderr('x'.repeat(64 * 1024 + 1)); + + await assert.rejects(promise, /stderr limit/); + assert.strictEqual(child.killed, true); + }); + + it('rejects more than sixteen protocol lines and kills the child', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout('\n'.repeat(17)); + + await assert.rejects(promise, /protocol line limit/); + assert.strictEqual(child.killed, true); + }); + + it('counts an unterminated final line against the protocol limit', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(`${'\n'.repeat(16)}partial`); + child.emitClose(0); + + await assert.rejects(promise, /protocol line limit/); + assert.strictEqual(child.killed, true); + }); + + it('aborts a pending presenter when the child fails', async () => { + const box = installFakeChildFactory(); + let observedSignal: AbortSignal | undefined; + const promise = requestTelemetryConsent((_prompt, signal) => { + observedSignal = signal; + return new Promise<'yes'>(() => {}); + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => observedSignal !== undefined); + child.emitClose(2); + + await assert.rejects(promise, /exited before presentation completed/); + assert.strictEqual(observedSignal?.aborted, true); + }); + + it('aborts a pending presenter when the child exits successfully', async () => { + const box = installFakeChildFactory(); + let observedSignal: AbortSignal | undefined; + const promise = requestTelemetryConsent((_prompt, signal) => { + observedSignal = signal; + return new Promise<'yes'>(() => {}); + }); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(presentationLine()); + await waitFor(() => observedSignal !== undefined); + child.emitClose(0); + + await assert.rejects(promise, /exited before presentation completed \(0\)/); + assert.strictEqual(observedSignal?.aborted, true); + }); + + it('kills the child on a presentation missing its challenge', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + const rejection = assert.rejects(promise); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(`${JSON.stringify({ + action: 'request', + result: 'presentationRequired', + prompt, + // Missing `challenge`. + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: true, + policy: 'unrestricted', + reason: null, + })}\n`); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + }); + + it('kills the child on a presentation with a malformed prompt', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + const rejection = assert.rejects(promise); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(`${JSON.stringify({ + action: 'request', + result: 'presentationRequired', + prompt: { ...prompt, title: { id: 1, text: 'invalid' } }, + challenge: 'request-a', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: true, + policy: 'unrestricted', + reason: null, + })}\n`); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + }); + + it('kills the child when a response omits its required reason field', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + const rejection = assert.rejects(promise, /unrecognised telemetry consent output/); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(`${JSON.stringify({ + action: 'request', + result: 'policyBlocked', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: false, + policy: 'blocked', + })}\n`); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + }); + + it('kills the child on an unknown status reason', async () => { + const box = installFakeChildFactory(); + const promise = requestTelemetryConsent(() => 'yes'); + const rejection = assert.rejects(promise); + await new Promise((r) => setImmediate(r)); + const child = box.current; + + child.writeStdout(`${JSON.stringify({ + action: 'request', + result: 'policyBlocked', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: false, + reason: 'future-reason', + policy: 'blocked', + })}\n`); + await waitFor(() => child.killed); + child.emitClose(1); + await rejection; + }); +}); diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index aa7f6421a..e5ea79aac 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -21,6 +21,8 @@ import { _setLxcAvailabilityProbe, _setPlatformDiagnosticLogger, findWxcExecutable, + _resetWxcExecutableCache, + _setWxcExecutableVerifier, } from '../../src/platform.js'; const isWindows = os.platform() === 'win32'; @@ -352,6 +354,7 @@ describe('findWxcExecutable failure modes', () => { beforeEach(() => { prevBinDir = process.env.MXC_BIN_DIR; + _resetWxcExecutableCache(); }); afterEach(() => { @@ -360,6 +363,8 @@ describe('findWxcExecutable failure modes', () => { } else { process.env.MXC_BIN_DIR = prevBinDir; } + _setWxcExecutableVerifier(null); + _resetWxcExecutableCache(); }); it('returns a string or null and never throws under a nonexistent MXC_BIN_DIR', () => { @@ -381,6 +386,65 @@ describe('findWxcExecutable failure modes', () => { const result = findWxcExecutable(); assert.ok(result === null || typeof result === 'string'); }); + + it('does not cache a failed executable lookup', () => { + process.env.MXC_BIN_DIR = path.join( + os.tmpdir(), + `mxc-sdk-unit-no-such-dir-${process.pid}`, + ); + _setWxcExecutableVerifier(() => false); + assert.strictEqual(findWxcExecutable(), null); + + _setWxcExecutableVerifier((candidate) => candidate.startsWith(process.env.MXC_BIN_DIR!)); + assert.ok(findWxcExecutable()?.startsWith(process.env.MXC_BIN_DIR)); + }); + + it('honors an MXC_BIN_DIR override configured after a cached lookup', () => { + delete process.env.MXC_BIN_DIR; + _setWxcExecutableVerifier(() => true); + const initial = findWxcExecutable(); + assert.ok(initial); + + const override = path.join(os.tmpdir(), `mxc-sdk-unit-override-${process.pid}`); + process.env.MXC_BIN_DIR = override; + + const resolved = findWxcExecutable(); + assert.ok(resolved?.startsWith(override)); + assert.notStrictEqual(resolved, initial); + }); + + it('honors an MXC_BIN_DIR executable staged after caching a fallback', () => { + const override = path.join(os.tmpdir(), `mxc-sdk-unit-override-${process.pid}`); + const overrideExecutable = path.join( + override, + os.arch() === 'arm64' ? 'arm64' : 'x64', + 'wxc-exec.exe', + ); + process.env.MXC_BIN_DIR = override; + + let overrideAvailable = false; + _setWxcExecutableVerifier((candidate) => + candidate === overrideExecutable ? overrideAvailable : true, + ); + + const fallback = findWxcExecutable(); + assert.ok(fallback); + assert.notStrictEqual(fallback, overrideExecutable); + + overrideAvailable = true; + assert.strictEqual(findWxcExecutable(), overrideExecutable); + }); + + it('revalidates a cached executable before returning it', () => { + let cachedPath: string | null = null; + _setWxcExecutableVerifier((candidate) => candidate !== cachedPath); + cachedPath = findWxcExecutable(); + assert.ok(cachedPath); + + const resolved = findWxcExecutable(); + assert.ok(resolved); + assert.notStrictEqual(resolved, cachedPath); + }); }); // IsolationSession availability is now reported by the native probe diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index 526cda2c0..2e23a4970 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -11,6 +11,7 @@ import { _setLxcAvailabilityProbe, } from '../../src/platform.js'; import { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js'; +import { MxcError } from '../../src/errors.js'; import { platformSkip } from './test-helpers.js'; describe('buildSandboxPayload', () => { @@ -1915,4 +1916,64 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk ); }); }); + + describe('one-shot telemetry', () => { + function decodeConfig(args: string[]): ContainerConfig { + const index = args.indexOf('--config-base64'); + assert.ok(index >= 0, '--config-base64 should be present in args'); + return JSON.parse( + Buffer.from(args[index + 1], 'base64').toString('utf-8'), + ) as ContainerConfig; + } + + it('serializes config telemetry with schema 0.9', () => { + const config = makeConfig('process'); + config.version = '0.9.0-alpha'; + config.telemetry = { enabled: true }; + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + }); + + assert.deepStrictEqual(decodeConfig(args).telemetry, { enabled: true }); + }); + + it('propagates SandboxPolicy telemetry through the primary one-shot API', () => { + const payload = buildSandboxPayload('echo hi', { + version: '0.9.0-alpha', + telemetry: { enabled: true }, + }); + + assert.deepStrictEqual(payload.telemetry, { enabled: true }); + }); + + it('leaves config telemetry schema validation to the native parser', () => { + const config = makeConfig('process'); + config.telemetry = { enabled: true }; + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + }); + + const serialized = decodeConfig(args); + assert.deepStrictEqual(serialized.telemetry, { enabled: true }); + assert.strictEqual(serialized.version, config.version); + }); + + for (const experimental of [true, 'invalid']) { + it(`leaves malformed experimental value ${JSON.stringify(experimental)} to the native parser`, () => { + const config = { + ...makeConfig('process'), + experimental, + } as unknown as ContainerConfig; + + const { args } = resolveExecutableAndArgs(config, { + executablePath: fakeExe, + skipPlatformCheck: true, + }); + + assert.strictEqual(decodeConfig(args).experimental, experimental); + }); + } + }); }); diff --git a/sdk/node/tests/unit/state-aware.test.ts b/sdk/node/tests/unit/state-aware.test.ts index c9d364034..1c5f24f0f 100644 --- a/sdk/node/tests/unit/state-aware.test.ts +++ b/sdk/node/tests/unit/state-aware.test.ts @@ -33,19 +33,15 @@ describe('buildStateAwareEnvelope', () => { 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('leaves explicit telemetry schema validation to the native parser', () => { + const env = buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'windows_sandbox', + sandboxId: 'wsb:01234567', + config: { version: '0.8.0-alpha', telemetry: { enabled: true } }, + }); + assert.deepEqual(env.telemetry, { enabled: true }); + assert.equal(env.version, '0.8.0-alpha'); }); it('produces a provision envelope with cross-cutting fields lifted to top-level', () => { @@ -157,6 +153,34 @@ describe('buildStateAwareEnvelope', () => { assert.strictEqual(wire.experimental, undefined); }); + it('never emits correlationVector on state-aware envelopes', () => { + const nonProvision = buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'isolation_session', + sandboxId: 'iso:abc', + }); + assert.strictEqual(nonProvision.correlationVector, undefined); + + const provision = buildStateAwareEnvelope({ + phase: 'provision', + backendKey: 'isolation_session', + containment: 'isolation_session', + }); + assert.strictEqual(provision.correlationVector, undefined); + }); + + it('places stable telemetry at the envelope top level', () => { + const env = buildStateAwareEnvelope({ + phase: 'start', + backendKey: 'isolation_session', + sandboxId: 'iso:abc', + config: { telemetry: { enabled: true } }, + }); + assert.deepStrictEqual(env.telemetry, { enabled: true }); + assert.strictEqual(env.version, '0.9.0-alpha'); + assert.strictEqual(env.experimental, undefined); + }); + }); describe('parseNonExecResponse', () => { @@ -335,6 +359,24 @@ describe('startSandbox', { skip: platformSkip }, () => { ); }); + it('does not serialize correlationVector 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()); + assert.strictEqual(fake.captured.envelope?.correlationVector, undefined); + }); + + it('relays stable telemetry from phase config 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, { telemetry: { enabled: false } }, testOptions()); + assert.deepStrictEqual(fake.captured.envelope?.telemetry, { enabled: false }); + assert.strictEqual(fake.captured.envelope?.version, '0.9.0-alpha'); + assert.strictEqual(fake.captured.envelope?.experimental, undefined); + }); + }); describe('stopSandbox', { skip: platformSkip }, () => { @@ -361,6 +403,13 @@ describe('stopSandbox', { skip: platformSkip }, () => { ); }); + it('does not serialize correlationVector 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()); + assert.strictEqual(fake.captured.envelope?.correlationVector, undefined); + }); }); describe('deprovisionSandbox', { skip: platformSkip }, () => { @@ -374,6 +423,14 @@ describe('deprovisionSandbox', { skip: platformSkip }, () => { assert.strictEqual(fake.captured.envelope?.phase, 'deprovision'); assert.strictEqual(fake.captured.envelope?.sandboxId, 'iso:abc'); }); + + it('does not serialize correlationVector 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()); + assert.strictEqual(fake.captured.envelope?.correlationVector, undefined); + }); }); describe('execInSandboxAsync', { skip: platformSkip }, () => { @@ -432,6 +489,17 @@ describe('execInSandboxAsync', { skip: platformSkip }, () => { ); }); + it('does not serialize correlationVector 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(), + ); + assert.strictEqual(fake.captured.envelope?.correlationVector, undefined); + }); }); describe('windows_sandbox state-aware lifecycle', () => { diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts new file mode 100644 index 000000000..7e254d8a9 --- /dev/null +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { + queryTelemetryConsentAsync, + requestTelemetryConsent, + withdrawTelemetryConsentAsync, + _setTelemetryConsentAsyncRunner, + _setTelemetryConsentProtocolRunner, + _setTelemetryPlatform, + _resetTelemetryFailureReporting, + type TelemetryConsentPrompt, +} from '../../src/telemetry.js'; + +const prompt: TelemetryConsentPrompt = { + resourceVersion: 1, + locale: 'en-US', + title: { id: 'telemetry.consent.title', text: 'Help improve Microsoft eXecution Container (MXC)' }, + body: { id: 'telemetry.consent.body', text: 'canonical body' }, + affirmativeLabel: { id: 'telemetry.consent.yes', text: 'Yes' }, + negativeLabel: { id: 'telemetry.consent.no', text: 'No' }, + learnMoreLabel: { id: 'telemetry.consent.learnMore', text: 'Privacy Statement' }, + learnMoreUrl: 'https://go.microsoft.com/fwlink/?linkid=521839', +}; + +function status( + effectiveState: 'granted' | 'denied' | 'undetermined' | 'not-applicable', + policy: 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable' = 'unrestricted', + needsPrompt = false, +): string { + return JSON.stringify({ + action: 'status', + result: 'status', + storedState: effectiveState, + effectiveState, + needsPrompt, + policy, + reason: null, + }); +} + +function commandOutput(stdout: string, stderr = ''): { stdout: string; stderr: string } { + return { stdout, stderr }; +} + +describe('telemetry consent', () => { + beforeEach(() => { + _setTelemetryPlatform('win32'); + }); + + afterEach(() => { + _setTelemetryConsentAsyncRunner(null); + _setTelemetryConsentProtocolRunner(null); + _setTelemetryPlatform(null); + }); + + it('parses typed stored/effective status', async () => { + _setTelemetryConsentAsyncRunner(async () => commandOutput(status('granted', 'allowed'))); + assert.deepStrictEqual(await queryTelemetryConsentAsync(), { + state: 'granted', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'allowed', + }); + }); + + it('queries status through the dedicated consent command', async () => { + let args: readonly string[] = []; + _setTelemetryConsentAsyncRunner(async (value) => { + args = value; + return commandOutput(status('undetermined', 'unrestricted', true)); + }); + assert.strictEqual((await queryTelemetryConsentAsync()).needsPrompt, true); + assert.deepStrictEqual(args, ['--telemetry-consent', 'status']); + assert.ok(!args.includes('--config-base64')); + }); + + it('deduplicates variable fail-closed details by operation and safe result', async () => { + _resetTelemetryFailureReporting(); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); + try { + _setTelemetryConsentAsyncRunner(async () => commandOutput('not json')); + assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); + assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); + _setTelemetryConsentAsyncRunner(async () => commandOutput('different invalid output')); + assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); + } finally { + console.warn = originalWarn; + } + assert.strictEqual(warnings.length, 1); + assert.ok(warnings.every((warning) => /fail-closed/.test(warning))); + }); + + it('deduplicates variable native status diagnostics without changing the typed result', async () => { + _resetTelemetryFailureReporting(); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); + try { + let call = 0; + _setTelemetryConsentAsyncRunner(async () => { + call += 1; + return commandOutput( + JSON.stringify({ + action: 'status', + result: 'status', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: false, + policy: 'blocked', + reason: 'store-unreadable', + }), + `mxc: telemetry consent store could not be read (${call})`, + ); + }); + const first = await queryTelemetryConsentAsync(); + const second = await queryTelemetryConsentAsync(); + assert.strictEqual(first.effectiveState, 'undetermined'); + assert.strictEqual(first.policy, 'blocked'); + assert.strictEqual(first.error, undefined); + assert.deepStrictEqual(second, first); + } finally { + console.warn = originalWarn; + } + assert.deepStrictEqual(warnings, [ + 'mxc-sdk: queryTelemetryConsentAsync native diagnostic: ' + + 'mxc: telemetry consent store could not be read (1)', + ]); + }); + + it('fails closed when native prompt eligibility conflicts with consent state or policy', async () => { + for (const response of [ + status('granted', 'allowed', true), + status('undetermined', 'blocked', true), + ]) { + _setTelemetryConsentAsyncRunner(async () => commandOutput(response)); + const query = await queryTelemetryConsentAsync(); + assert.deepStrictEqual({ + ...query, + error: undefined, + }, { + state: 'undetermined', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: false, + policy: 'blocked', + error: undefined, + }); + assert.match(query.error ?? '', /unrecognised telemetry consent output/); + } + }); + + it('fails status queries closed for mismatched actions and invalid results', async () => { + _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ + action: 'status', + result: 'withdrawn', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'allowed', + reason: null, + }))); + const asyncQuery = await queryTelemetryConsentAsync(); + assert.strictEqual(asyncQuery.effectiveState, 'undetermined'); + assert.strictEqual(asyncQuery.policy, 'blocked'); + assert.strictEqual(asyncQuery.needsPrompt, false); + }); + + it('fails status queries closed when the native response omits reason', async () => { + _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ + action: 'status', + result: 'status', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'allowed', + }))); + const query = await queryTelemetryConsentAsync(); + assert.strictEqual(query.state, 'undetermined'); + assert.strictEqual(query.storedState, 'undetermined'); + assert.strictEqual(query.effectiveState, 'undetermined'); + assert.strictEqual(query.policy, 'blocked'); + assert.strictEqual(query.needsPrompt, false); + }); + + it('binds a synchronous presenter decision to the canonical prompt', async () => { + let observedLocale: string | undefined; + _setTelemetryConsentProtocolRunner(async (locale, presenter) => { + observedLocale = locale; + const decision = await presenter(prompt); + assert.strictEqual(decision, 'yes'); + return { + action: 'request', + result: 'granted', + storedState: 'granted', + effectiveState: 'granted', + needsPrompt: false, + policy: 'unrestricted', + }; + }); + + const outcome = await requestTelemetryConsent((value) => { + assert.deepStrictEqual(value, prompt); + return 'yes'; + }, 'en-US'); + assert.strictEqual(observedLocale, 'en-US'); + assert.strictEqual(outcome.result, 'granted'); + }); + + it('supports an asynchronous presenter and propagates presenter failure', async () => { + _setTelemetryConsentProtocolRunner(async (_locale, presenter) => { + await presenter(prompt); + throw new Error('should not continue'); + }); + await assert.rejects( + requestTelemetryConsent(async () => { + await Promise.resolve(); + throw new Error('UI unavailable'); + }), + /UI unavailable/, + ); + }); + + it('queries and withdraws through the non-blocking runner', async () => { + const actions: string[] = []; + _setTelemetryConsentAsyncRunner(async (args) => { + assert.deepStrictEqual(args.slice(0, 1), ['--telemetry-consent']); + const action = args[1]!; + actions.push(action); + return commandOutput(action === 'status' + ? status('granted', 'allowed') + : JSON.stringify({ + action: 'withdraw', + result: 'withdrawn', + storedState: 'denied', + effectiveState: 'denied', + needsPrompt: false, + policy: 'unrestricted', + reason: null, + })); + }); + + assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'granted'); + assert.strictEqual((await withdrawTelemetryConsentAsync()).result, 'withdrawn'); + assert.deepStrictEqual(actions, ['status', 'withdraw']); + }); + + it('rejects withdrawal responses with mismatched actions or invalid results', async () => { + _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ + action: 'withdraw', + result: 'status', + storedState: 'denied', + effectiveState: 'denied', + needsPrompt: false, + policy: 'blocked', + reason: null, + }))); + await assert.rejects( + withdrawTelemetryConsentAsync(), + /unrecognised telemetry consent output/, + ); + }); + + it('rejects withdrawal responses that omit reason', async () => { + _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ + action: 'withdraw', + result: 'withdrawn', + storedState: 'denied', + effectiveState: 'denied', + needsPrompt: false, + policy: 'unrestricted', + }))); + await assert.rejects( + withdrawTelemetryConsentAsync(), + /unrecognised telemetry consent output/, + ); + }); +}); + +describe('telemetry consent is Windows-only', () => { + afterEach(() => { + _setTelemetryConsentAsyncRunner(null); + _setTelemetryConsentProtocolRunner(null); + _setTelemetryPlatform(null); + }); + + for (const platform of ['linux', 'darwin'] as const) { + it(`does not query or present consent on ${platform}`, async () => { + _setTelemetryPlatform(platform); + let called = false; + _setTelemetryConsentAsyncRunner(async () => { + called = true; + throw new Error('must not run'); + }); + _setTelemetryConsentProtocolRunner(async () => { + called = true; + throw new Error('must not run'); + }); + + const request = await requestTelemetryConsent(() => { + called = true; + return 'yes'; + }); + assert.strictEqual(request.result, 'notApplicable'); + assert.strictEqual((await queryTelemetryConsentAsync()).state, 'not-applicable'); + assert.strictEqual((await withdrawTelemetryConsentAsync()).result, 'notApplicable'); + assert.strictEqual(called, false); + }); + } +}); diff --git a/sdk/node/tests/unit/wire-conformance.test.ts b/sdk/node/tests/unit/wire-conformance.test.ts index cbb06dc70..4fe23dbaa 100644 --- a/sdk/node/tests/unit/wire-conformance.test.ts +++ b/sdk/node/tests/unit/wire-conformance.test.ts @@ -62,6 +62,7 @@ import type { PortMapping as PublicPortMapping, LxcConfig, SeatbeltConfig, + TelemetryConfig, ContainerConfig, ClipboardPolicy as PublicClipboardPolicy, ContainmentType, @@ -86,6 +87,7 @@ import type { PortMapping as WirePortMapping, Lxc as WireLxc, Seatbelt as WireSeatbelt, + Telemetry as WireTelemetry, MXCConfiguration as WireMxcConfig, ClipboardPolicy as WireClipboardPolicy, Containment as WireContainment, @@ -167,6 +169,7 @@ type _BaseProcessUiVals = AssertTrue>; type _PortMappingVals = AssertTrue>; type _SeatbeltVals = AssertTrue>; +type _TelemetryVals = AssertTrue>; type _LxcVals = AssertTrue, WireLxc>>; // --- key conformance (rename / removal detection) ------------------------- @@ -187,6 +190,7 @@ type _BaseProcessUiKeys = AssertTrue, never>>; type _PortMappingKeys = AssertTrue, never>>; type _SeatbeltKeys = AssertTrue, never>>; +type _TelemetryKeys = AssertTrue, never>>; // `FilesystemConfig.clearPolicyOnExit` is an SDK-side convenience flag mapped // into `lifecycle.preservePolicy`; it is not a wire `filesystem` field. @@ -251,6 +255,7 @@ type _ProcessContainerWireKeys = AssertTrue< type _SeatbeltWireKeys = AssertTrue< Equivalent, 'guiAccess' | 'launchMethod'> >; +type _TelemetryWireKeys = AssertTrue, never>>; // Root: the SDK's `ContainerConfig` intentionally omits the schema-metadata keys // (`$schema`, `_comment`), the state-aware-only keys (`phase`, `sandboxId`),