diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24953d0e9..69154ad32 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -279,7 +279,7 @@ The workspace is organized into six top-level directories under `src/`: WSLC SDK exposes neither); other backends return `ErrorCode::UnsupportedContainment`. - The lower-level execution surface lives in `wxc_common::sandbox_process`: the `SandboxBackend` trait (`validate` + `spawn(request, logger, StdioMode) -> Box` + a `diagnose_exit` hook) and the generic `Runner` adapter that bridges any `SandboxBackend` to the run-to-completion `ScriptRunner` (via `spawn(StdioMode::Inherit)` then `wait()`). `SandboxProcess::output_metadata()` carries backend-produced structured outputs after terminal teardown without writing to process-global stdio. `StdioMode::Pipes` hands the caller live stdin/stdout/stderr (what the `mxc-sdk` streaming path uses); `StdioMode::Inherit` lets the child inherit the host's stdio (what the executor binaries use, preserving the TTY under a pty). `SandboxBackend` is implemented for Seatbelt, Bubblewrap, Windows ProcessContainer, and WSLC (on `wslc_common::WSLContainerRunner` itself, which shares one container lifecycle — `start_container` — between its streaming `SandboxBackend` and run-to-completion `ScriptRunner` impls, differing only in where the WSLC SDK's output callbacks write). -- `mxc_ffi` (`ffi/mxc_ffi`, `crate-type = ["cdylib", "staticlib", "lib"]`) is a flat, panic-safe **C ABI over `mxc-sdk`** for language bindings. `mxc_run(policyJson, command, out)` runs a sandbox to completion, filling a `#[repr(C)] MxcRunResult` (status + exit_code + timed_out, owned stdout/stderr/output-metadata C strings, and an `MxcErrorDetail` carrying the failure message plus the failing API call and its platform status); every entry point is `catch_unwind`-wrapped so a panic becomes a status code, never an unwind. Its `build.rs` runs **csbindgen** to generate the C# P/Invoke (`sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeMethods.g.cs`), gated behind the crate's **`dotnetsdk`** feature (off by default, so the whole-workspace backend build matrix doesn't compile csbindgen). The generated file is **not committed** (gitignored); the C# csproj regenerates it at build time and `scripts/check-dotnet-bindings-codegen.js` runs the codegen in CI and asserts the expected entry points are produced. The C ABI is **not a stable external contract** (native + binding are co-versioned and generated together; see the crate docs). It exposes three surfaces: **run-to-completion** (`mxc_run`), **streaming** (`mxc_spawn` → opaque `MxcSandbox` handle; `mxc_stream_read`/`write`/`flush`, `mxc_sandbox_take_stdin`/`stdout`/`stderr`, `mxc_sandbox_id`/`try_wait`/`wait`/`kill`/`output_metadata_json`/`free`, in `src/streaming.rs`), and the **state-aware lifecycle** (`mxc_state_aware` for the envelope phases, `mxc_state_aware_exec` returning a live streaming handle, and `mxc_state_aware_exec_attached` relaying onto this process's stdio and returning an outcome, in `src/state_aware.rs`). All four `.rs` files are csbindgen inputs in `build.rs` (the shared `MxcErrorDetail` lives in `src/error_detail.rs`); the `MXC_STATUS_*` space already reserves the state-aware phase codes. +- `mxc_ffi` (`ffi/mxc_ffi`, `crate-type = ["cdylib", "staticlib", "lib"]`) is a flat, panic-safe **C ABI over `mxc-sdk`** for language bindings. The one-shot entry points accept the binding-oriented `RequestSpec` envelope; `src/request.rs` adapts its policy, command, containment, environment, experimental authorization, and telemetry opt-in into `mxc_sdk::SandboxRequest`. This keeps binding-shape parsing at the FFI boundary instead of requiring each binding to emit canonical MXC configuration JSON. `mxc_run_request` fills a `#[repr(C)] MxcRunResult` (status + exit_code + timed_out, owned stdout/stderr/output-metadata C strings, and an `MxcErrorDetail` carrying the failure message plus the failing API call and its platform status); every entry point is `catch_unwind`-wrapped so a panic becomes a status code, never an unwind. Its `build.rs` runs **csbindgen** to generate the C# P/Invoke (`sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeMethods.g.cs`), gated behind the crate's **`dotnetsdk`** feature (off by default, so the whole-workspace backend build matrix doesn't compile csbindgen). The generated file is **not committed** (gitignored); the C# csproj regenerates it at build time and `scripts/check-dotnet-bindings-codegen.js` runs the codegen in CI and asserts the expected entry points are produced. The C ABI is **not a stable external contract** (native + binding are co-versioned and generated together; see the crate docs). It exposes three surfaces: **run-to-completion** (`mxc_run_request`), **streaming** (`mxc_spawn_request` → opaque `MxcSandbox` handle; `mxc_stream_read`/`write`/`flush`, `mxc_sandbox_take_stdin`/`stdout`/`stderr`, `mxc_sandbox_id`/`try_wait`/`wait`/`kill`/`output_metadata_json`/`free`, in `src/streaming.rs`), and the **state-aware lifecycle** (`mxc_state_aware` for the envelope phases, `mxc_state_aware_exec` returning a live streaming handle, and `mxc_state_aware_exec_attached` relaying onto this process's stdio and returning an outcome, in `src/state_aware.rs`). All four `.rs` files are csbindgen inputs in `build.rs` (the shared `MxcErrorDetail` lives in `src/error_detail.rs`); the `MXC_STATUS_*` space already reserves the state-aware phase codes. - `mxc_pty` is the shared pty bridge used by the LXC backend (`lxc_common::lxc_bindings::attach_run`) so the inner shell sees a real TTY and host stdio is streamed live. (Seatbelt and Bubblewrap no longer use it: they spawn directly and let the child inherit the host's stdio — a TTY when the executor binary runs under a pty — via `SandboxBackend::spawn(StdioMode::Inherit)`.) - `learning_mode_core` is the **cross-platform learning-mode / captureDenials model + output emitter**: `DeniedResource` (+ `ResourceType`/`AccessType`), `DenialSummary`, the `DenialAnalyzer` decode trait, actionable `DenialsDocument` emission, bounded sensitive-value-redacted `VerboseLoggingDocument` emission, and transactional paired-file write/relocation helpers. Every successful analysis writes the actionable artifact plus its deterministic `*.verbose.json` sibling; relocation reports destination commit separately from source-cleanup warnings so callers keep metadata truthful. The crate also defines the serializable `DenialsOutputPointer` and carries no OS-specific code (it must not depend on any `backends/*` crate). The Windows ETL decoder implementing `DenialAnalyzer` lives in `backends/learning_mode/windows`. When `processContainer.captureDenials` is set, native PSEC/V2 seals and decodes a managed ETL locally, while guarded WPR relogs its host-wide source into a process-scoped ETL before analysis; both routes write the same output pair through shared plumbing and return neutral `wxc_common` metadata. Explicit `retainEtl` preserves the native sealed trace or the guarded process-scoped relogged trace after a terminal wait; abandonment discards it. `wxc-exec` serializes the metadata as the one-line stderr pointer at the CLI boundary; Rust/C#/FFI callers receive it programmatically. Each actionable denial's `resource` field holds an authorable file path, UI category, or AppContainer capability name; registry writes and recognized Section, SymbolicLink, and Timer checks remain verbose-only because MXC has no corresponding config grants. Capability denials resolve their capability SID to a friendly name via `backends/learning_mode/windows`'s `capability_names` (well-known `S-1-15-3-…` SID → policy name; custom hashed SIDs fall back to the SID string). - `mxc_build_common` is a build-time helper crate — all Windows binary crates use it in their `build.rs` to embed VersionInfo (ProductName, FileDescription, copyright, version+commit). When adding a new Windows binary crate, add `mxc_build_common` as a build-dependency and call `mxc_build_common::embed_version_info()` from `build.rs` diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js index 68201386f..ec7d8d096 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -66,6 +66,14 @@ function enumVariants(source, enumName, language) { return enumMembers(source, enumName, language).map((member) => member.name); } +function managedDerivedTypes(source, baseType) { + return [ + ...source.matchAll( + new RegExp(`public\\s+sealed\\s+class\\s+(\\w+)\\s*:\\s*${baseType}\\b`, "g") + ), + ].map((match) => match[1]); +} + function compare(label, actual, expected) { const actualSorted = [...new Set(actual)].sort(); const expectedSorted = [...new Set(expected)].sort(); @@ -206,28 +214,23 @@ function managedJsonFields(source, className) { }); } -// `legacyManagedOnly` lists managed JSON fields that intentionally have no Rust -// counterpart because they are deprecated compatibility aliases. They must stay -// serializable so legacy JSON round-trips, but the managed request path strips -// them before the native layer sees them. -function compareStructFields( - label, - rustSource, - rustName, - managedSource, - managedName, - legacyManagedOnly = [] -) { +function compareStructFields(label, rustSource, rustName, managedSource, managedName) { compare( `${label} fields`, - managedJsonFields(managedSource, managedName).filter( - (field) => !legacyManagedOnly.includes(field) - ), + managedJsonFields(managedSource, managedName), rustStructFields(rustSource, rustName) ); } const rustPolicy = read("src", "core", "mxc_engine", "src", "policy.rs"); +const rustRequest = read("src", "ffi", "mxc_ffi", "src", "request.rs"); +const rustWire = read( + "src", + "core", + "wxc_common", + "src", + "wire.rs" +); const rustNetworkPolicy = read( "src", "core", @@ -236,13 +239,6 @@ const rustNetworkPolicy = read( "policy", "network.rs" ); -const rustBindingRequest = read( - "src", - "ffi", - "mxc_ffi", - "src", - "request.rs" -); const managedRequest = read( "sdk", "dotnet", @@ -258,66 +254,92 @@ const managedPolicy = read( const rustOneShot = enumVariants(rustPolicy, "Containment", "rust").filter( (variant) => variant !== "IsolationSession" ); -const bindingContainment = enumMembers( - rustBindingRequest, - "RequestContainment", - "rust" -); compare( - "Rust SDK vs binding containment variants", - bindingContainment.map((member) => member.name), + "Rust SDK vs managed containment variants", + managedDerivedTypes(managedRequest, "SandboxContainment").map((name) => + name.replace(/Containment$/, "") + ), rustOneShot ); compareStructFields( - "one-shot request", - rustBindingRequest, - "RequestSpec", + "process-container canonical fields", + rustWire, + "ProcessContainer", managedRequest, - "SandboxRequest" -); -compare( - "process-container request fields", - managedJsonFields(managedRequest, "ProcessContainerContainment"), - rustVariantFields(rustBindingRequest, "RequestContainment", "ProcessContainer") -); -compare( - "WSLC request fields", - managedJsonFields(managedRequest, "WslcContainment"), - rustVariantFields(rustBindingRequest, "RequestContainment", "Wslc") + "ProcessContainerContainment" ); compareStructFields( - "process-container UI", - rustBindingRequest, - "ProcessContainerUiSpec", + "process-container UI canonical fields", + rustWire, + "BaseProcessUi", managedRequest, "ProcessContainerUiPolicy" ); compareStructFields( - "process-container network", - rustBindingRequest, - "ProcessContainerNetworkSpec", + "process-container network canonical fields", + rustWire, + "ProcessContainerNetwork", managedRequest, "ProcessContainerNetworkPolicy" ); compareStructFields( - "WSLC port mapping", - rustBindingRequest, - "WslcPortMappingSpec", - managedRequest, - "WslcPortMapping" + "capture-denials canonical fields", + rustWire, + "CaptureDenials", + managedPolicy, + "CaptureDenialsPolicy" +); +compare( + "WSLC one-shot canonical fields", + managedJsonFields(managedRequest, "WslcContainment"), + rustStructFields(rustWire, "Wslc").filter( + (field) => field !== "targetOs" && field !== "provision" + ) +); +compare( + "WSLC port-mapping canonical fields", + [...managedJsonFields(managedRequest, "WslcPortMapping"), "protocol"], + rustStructFields(rustWire, "PortMapping") +); + +const managedSandboxPolicyFields = managedJsonFields( + managedPolicy, + "SandboxPolicy" ); -for (const [ - label, - rustSource, - rustName, - managedName, - legacyManagedOnly, -] of [ - // `captureDenials` is an obsolete managed-only alias (MXC0001, removed in - // 1.0). Rust only accepts it under `containment.captureDenials`, and - // MxcSandbox.PrepareRequest strips it from the policy before serialization. - ["sandbox policy", rustPolicy, "SandboxPolicy", "SandboxPolicy", ["captureDenials"]], +// `captureDenials` is the one existing managed compatibility alias (MXC0001, +// removed in 1.0). MxcSandbox.PrepareRequest migrates it to +// processContainer.captureDenials and strips it before native serialization. +compare( + "sandbox policy compatibility aliases", + managedSandboxPolicyFields.filter((field) => field === "captureDenials"), + ["captureDenials"] +); +compare( + "sandbox policy fields", + managedSandboxPolicyFields.filter( + (field) => field !== "captureDenials" && field !== "telemetry" + ), + rustStructFields(rustPolicy, "SandboxPolicy") +); + +// Telemetry is execution metadata, not a containment restriction. The FFI +// RequestSpec adapter removes it from the binding policy before constructing +// the Rust SandboxPolicy, then applies it to SandboxRequest explicitly. +compare( + "sandbox policy binding telemetry field", + managedSandboxPolicyFields.filter((field) => field === "telemetry"), + ["telemetry"] +); +compareStructFields( + "telemetry settings", + rustRequest, + "TelemetrySpec", + managedPolicy, + "TelemetrySettings" +); + +for (const [label, rustSource, rustName, managedName] of [ ["filesystem policy", rustPolicy, "FilesystemSection", "FilesystemPolicy"], ["UI policy", rustPolicy, "UiSection", "UiPolicy"], ["network policy", rustNetworkPolicy, "NetworkSection", "NetworkPolicy"], @@ -333,26 +355,10 @@ for (const [ rustSource, rustName, managedPolicy, - managedName, - legacyManagedOnly + managedName ); } -const managedOneShot = [ - ...managedRequest.matchAll( - /\[JsonDerivedType\(typeof\(\w+\),\s*"([^"]+)"\)\]/g - ), -].map((match) => match[1]); const camelCase = (value) => value[0].toLowerCase() + value.slice(1); -compare( - "one-shot containment discriminators", - managedOneShot, - bindingContainment.map((member) => { - const renamed = /#\[serde\([^]]*\brename\s*=\s*"([^"]+)"/s.exec( - member.source - ); - return renamed?.[1] ?? camelCase(member.name); - }) -); const rustProbeFull = read("src", "core", "mxc_engine", "src", "probe.rs"); const rustProbe = rustProbeFull.split("#[cfg(test)]")[0]; diff --git a/scripts/check-dotnet-bindings-codegen.js b/scripts/check-dotnet-bindings-codegen.js index e97827d5e..40cc2ff08 100644 --- a/scripts/check-dotnet-bindings-codegen.js +++ b/scripts/check-dotnet-bindings-codegen.js @@ -93,6 +93,21 @@ if (missing.length > 0) { process.exit(1); } +const requiredSignatures = [ + "mxc_run_request(byte* request_json_utf8, MxcRunResult* @out)", + "mxc_spawn_request(byte* request_json_utf8, MxcSandbox** out_handle, MxcErrorDetail* out_error)", +]; +const missingSignatures = requiredSignatures.filter( + (signature) => !content.includes(signature) +); +if (missingSignatures.length > 0) { + console.error( + "ERROR: generated C# bindings have unexpected request signature(s):\n " + + missingSignatures.join("\n ") + ); + process.exit(1); +} + // build.rs names the same set of Rust sources twice: once for csbindgen to // read, and once as `cargo:rerun-if-changed`. Emitting any `rerun-if-changed` // replaces cargo's default "re-run when any package file changed" with exactly diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/JsonAssert.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/JsonAssert.cs index 730eeef52..1c877ee80 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/JsonAssert.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/JsonAssert.cs @@ -9,6 +9,15 @@ namespace Microsoft.Mxc.Sdk.Tests; internal static class JsonAssert { internal static void MatchesGolden(string actualJson, string fixtureName) + { + var expectedJson = ReadGolden(fixtureName); + using var expected = JsonDocument.Parse(expectedJson); + using var actual = JsonDocument.Parse(actualJson); + + Equivalent(expected.RootElement, actual.RootElement, fixtureName); + } + + internal static string ReadGolden(string fixtureName) { var assembly = typeof(JsonAssert).Assembly; var resourceName = assembly.GetManifestResourceNames() @@ -16,10 +25,7 @@ internal static void MatchesGolden(string actualJson, string fixtureName) using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Missing embedded fixture {resourceName}."); using var reader = new StreamReader(stream); - using var expected = JsonDocument.Parse(reader.ReadToEnd()); - using var actual = JsonDocument.Parse(actualJson); - - Equivalent(expected.RootElement, actual.RootElement, fixtureName); + return reader.ReadToEnd(); } private static void Equivalent(JsonElement expected, JsonElement actual, string path) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj index 0c8048dc1..00aebe79a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/Microsoft.Mxc.Sdk.Tests.csproj @@ -12,12 +12,53 @@ true $(DefineConstants);MXC_WITH_WSLC $(DefineConstants);MXC_WITH_ISOLATION_SESSION + $(MSBuildProjectDirectory)/../../../src + $(MxcSrcDir)/target/dotnet-test-support + --release + release + debug + test-support + $(MxcTestCargoFeatures) isolation_session + $(MxcTestCargoFeatures) wslc + mxc_ffi.dll + libmxc_ffi.dylib + libmxc_ffi.so + + + + + + + + + @(MxcTestRustcHostLine) + + + + $(MxcTestRustcHostLine.Replace('host: ', '').Trim()) + $(MxcTestCargoTargetDir)/$(MxcTestRustHostTriple)/$(MxcTestCargoProfileDir)/$(MxcTestNativeFileName) + + + + + + + diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 91864d802..c3c01cad9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -827,6 +827,26 @@ public void SerializeRequest_StripsLegacyCaptureDenialsFromThePolicy() .GetString()); } + [Fact] + public void SerializeRequest_PreservesTelemetryInTheBindingPolicy() + { + var policy = CreateLegacyCaptureDenialsPolicy( + new CaptureDenialsPolicy(), + "0.9.0-alpha"); + policy.Telemetry = new TelemetrySettings { Enabled = true }; + var request = new SandboxRequest(policy, "echo hi"); + + using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); + var root = doc.RootElement; + + Assert.True( + root.GetProperty("policy") + .GetProperty("telemetry") + .GetProperty("enabled") + .GetBoolean()); + Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); + } + private static SandboxPolicy CreateLegacyCaptureDenialsPolicy( CaptureDenialsPolicy captureDenials, string version = "0.8.0-alpha") diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs new file mode 100644 index 000000000..cfdb5aa48 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -0,0 +1,920 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Diagnostics; +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 Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +// These tests mutate process-global telemetry seams and Trace listeners. +[CollectionDefinition("MxcTelemetry", DisableParallelization = true)] +public sealed class MxcTelemetryCollectionDefinition +{ +} + +[Collection("MxcTelemetry")] +public sealed class MxcTelemetryTests +{ + private static readonly JsonSerializerOptions PolicyJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + [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); + } + + [Fact] + public void SandboxPolicy_TelemetrySerializesCanonically() + { + var policy = new SandboxPolicy + { + Version = SchemaVersions.MaximumSupported, + Telemetry = new TelemetrySettings { Enabled = true }, + }; + + var json = JsonSerializer.Serialize(policy, PolicyJsonOptions); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + } + + [Fact] + public void GetPolicy_NeverThrowsAndFailsClosed() + { + var policy = MxcTelemetry.GetPolicy(); + Assert.True(Enum.IsDefined(policy)); + } + + [Fact] + public void FailClosedDiagnostics_AreDeduplicatedAndDoNotExposeExceptionText() + { + var operation = $"TestGetPolicy{Guid.NewGuid():N}"; + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); + using var output = new StringWriter(); + using var listener = new TextWriterTraceListener(output); + var failure = new InvalidOperationException("sensitive\r\n\u001b[31mmessage"); + 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_DeduplicatesAndEnforcesCapacity() + { + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 2); + + Assert.True(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)); + Assert.False(tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)); + Assert.True(tracker.TryAdd("NeedsConsentPrompt", "false", "ErrorCode", 2)); + Assert.False(tracker.TryAdd("GetConsent", "Undetermined", "ExceptionB", 3)); + } + + [Fact] + public void FailClosedDiagnosticTracker_DeduplicatesConcurrentReports() + { + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + var accepted = 0; + + Parallel.For(0, 1_000, _ => + { + if (tracker.TryAdd("GetPolicy", "Blocked", "ExceptionA", 1)) + { + Interlocked.Increment(ref accepted); + } + }); + + Assert.Equal(1, accepted); + } + + [Fact] + public void FailClosedDiagnosticTracker_EnforcesCapacityUnderConcurrentLoad() + { + var tracker = new MxcTelemetry.FailureCategoryTracker(capacity: 64); + var accepted = 0; + + Parallel.For(0, 1_000, index => + { + if (tracker.TryAdd("GetPolicy", "Blocked", "Exception", index)) + { + Interlocked.Increment(ref accepted); + } + }); + + Assert.Equal(64, accepted); + } + + [Fact] + public void FailClosedDiagnostics_DoNotPropagateTraceListenerFailures() + { + var operation = $"ThrowingTrace{Guid.NewGuid():N}"; + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); + using var listener = new ThrowingTraceListener(); + Trace.Listeners.Add(listener); + try + { + var exception = Record.Exception(() => + MxcTelemetry.ReportFailClosed( + operation, + "Blocked", + ErrorCode.BackendError)); + Assert.Null(exception); + } + finally + { + Trace.Listeners.Remove(listener); + } + + using var output = new StringWriter(); + using var capture = new TextWriterTraceListener(output); + Trace.Listeners.Add(capture); + try + { + MxcTelemetry.ReportFailClosed(operation, "Blocked", ErrorCode.BackendError); + Trace.Flush(); + Assert.Contains(operation, output.ToString()); + } + finally + { + Trace.Listeners.Remove(capture); + } + } + + [Fact] + public void ReadOnlyQueryFailures_ReportAndReturnFailClosedValues() + { + var native = new FakeTelemetryQueryApi + { + GetConsentImpl = () => throw new DllNotFoundException("sensitive path"), + NeedsConsentPromptImpl = () => new((int)ErrorCode.BackendError, true), + GetPolicyImpl = () => throw new InvalidOperationException("sensitive policy"), + }; + using var nativeScope = MxcTelemetry.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.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + Trace.Flush(); + + var message = output.ToString(); + Assert.Contains("GetConsent", message); + Assert.Contains("NeedsConsentPrompt", message); + Assert.Contains("GetPolicy", message); + Assert.DoesNotContain("sensitive", message); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [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 + { + if (throwException) + { + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + } + else + { + var exception = Assert.Throws(() => MxcTelemetry.GetConsent()); + Assert.Equal(ErrorCode.BackendError, exception.Code); + } + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + Assert.Equal(TelemetryPolicyState.Blocked, MxcTelemetry.GetPolicy()); + Trace.Flush(); + + var messages = output + .ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.Contains("mxc:", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(throwException ? 3 : 2, messages.Length); + Assert.Contains(messages, line => line.Contains("NeedsConsentPrompt", StringComparison.Ordinal)); + Assert.Contains(messages, line => line.Contains("GetPolicy", StringComparison.Ordinal)); + Assert.All( + messages, + line => Assert.Contains( + throwException + ? typeof(InvalidOperationException).FullName! + : nameof(ErrorCode.BackendError), + line)); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [Fact] + public void ReadOnlyQueryNullPayloads_ReportAndReturnFailClosedValues() + { + 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); + } + } + + [Fact] + public void TelemetryQueryApiOverride_AllowsConcurrentDispose() + { + var calls = 0; + var native = new FakeTelemetryQueryApi + { + GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"), + NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), + GetPolicyImpl = () => + { + Interlocked.Increment(ref calls); + return new((int)ErrorCode.Success, "blocked"); + }, + }; + var scope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + + Parallel.Invoke(scope.Dispose, scope.Dispose); + + _ = MxcTelemetry.GetPolicy(); + Assert.Equal(0, calls); + } + + [Fact] + public void TestOverrides_RejectOverlapAndRestoreAfterConcurrentDispose() + { + var native = new FakeTelemetryQueryApi + { + GetConsentImpl = () => new((int)ErrorCode.Success, "undetermined"), + NeedsConsentPromptImpl = () => new((int)ErrorCode.Success, false), + GetPolicyImpl = () => new((int)ErrorCode.Success, "blocked"), + }; + using var nativeScope = MxcTelemetry.OverrideTelemetryReadApiForTesting(native); + Assert.Throws( + () => MxcTelemetry.OverrideTelemetryReadApiForTesting(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.OverrideTelemetryReadApiForTesting(native); + using var restoredTrackerScope = + MxcTelemetry.OverrideFailureCategoryTrackerForTesting(tracker); + } + + [Fact] + public void RequestConsent_IsNotApplicableWithoutInvokingPresenter_OffWindows() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var called = false; + var outcome = MxcTelemetry.RequestConsent(_ => + { + called = true; + return TelemetryConsentDecision.Yes; + }); + + Assert.False(called); + Assert.Equal(TelemetryConsentResult.NotApplicable, outcome.Result); + Assert.Equal(TelemetryPolicyState.NotApplicable, outcome.Policy); + } + + [Fact] + public void RequestConsent_RejectsLocaleWithEmbeddedNul() + { + var exception = Assert.Throws( + () => MxcTelemetry.RequestConsent( + _ => TelemetryConsentDecision.Yes, + "en-US\0ignored")); + + Assert.Equal("locale", exception.ParamName); + } + + [Fact] + public void RequestConsentAsync_RejectsLocaleWithEmbeddedNulSynchronously() + { + var exception = Assert.Throws( + () => + { + _ = MxcTelemetry.RequestConsentAsync( + _ => Task.FromResult(TelemetryConsentDecision.Yes), + "en-US\0ignored", + TestContext.Current.CancellationToken); + }); + + Assert.Equal("locale", exception.ParamName); + } + + [Theory] + [InlineData("locale")] + [InlineData("title.id")] + [InlineData("title.text")] + [InlineData("body.id")] + [InlineData("body.text")] + [InlineData("affirmativeLabel.id")] + [InlineData("affirmativeLabel.text")] + [InlineData("negativeLabel.id")] + [InlineData("negativeLabel.text")] + [InlineData("learnMoreLabel.id")] + [InlineData("learnMoreLabel.text")] + [InlineData("learnMoreUrl")] + public void ParseConsentPrompt_RejectsNullRequiredStrings(string path) + { + var root = JsonNode.Parse( + """ + { + "resourceVersion": 1, + "locale": "en-US", + "title": { "id": "title", "text": "Title" }, + "body": { "id": "body", "text": "Body" }, + "affirmativeLabel": { "id": "yes", "text": "Yes" }, + "negativeLabel": { "id": "no", "text": "No" }, + "learnMoreLabel": { "id": "learn", "text": "Learn more" }, + "learnMoreUrl": "https://example.com/privacy" + } + """)!.AsObject(); + var segments = path.Split('.'); + var parent = root; + for (var index = 0; index < segments.Length - 1; index++) + { + parent = parent[segments[index]]!.AsObject(); + } + parent[segments[^1]] = null; + + var error = Assert.Throws( + () => MxcTelemetry.ParseConsentPrompt(root.ToJsonString())); + + Assert.Contains(path, error.Message, StringComparison.Ordinal); + } + +#if DEBUG + [Fact] + public void RequestConsent_PresenterExceptionReturnsBackendError_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + var ex = Assert.Throws(() => + MxcTelemetry.RequestConsent(_ => throw new InvalidOperationException("boom"))); + Assert.Equal(ErrorCode.BackendError, ex.Code); + } + + [Fact] + public async Task RequestConsentAsync_PreservesCallerSynchronizationContext_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var context = new PumpSynchronizationContext(); + var previousContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + var callerThread = Environment.CurrentManagedThreadId; + var request = MxcTelemetry.RequestConsentAsync(async _ => + { + Assert.Same(context, SynchronizationContext.Current); + Assert.Equal(callerThread, Environment.CurrentManagedThreadId); + await Task.Yield(); + Assert.Same(context, SynchronizationContext.Current); + Assert.Equal(callerThread, Environment.CurrentManagedThreadId); + return TelemetryConsentDecision.Yes; + }, cancellationToken: TestContext.Current.CancellationToken); + + context.RunUntilCompleted(request); + Assert.Equal(TelemetryConsentResult.Granted, (await request).Result); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + } + + [Fact] + public async Task RequestConsentAsync_CancellationAfterPresentationStartsDoesNotPersistDecision_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var cancellation = new CancellationTokenSource(); + var presenterStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var stalledPresenter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var request = MxcTelemetry.RequestConsentAsync(_ => + { + presenterStarted.SetResult(); + return stalledPresenter.Task; + }, cancellationToken: cancellation.Token); + + await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + + var finished = await Task.WhenAny( + request, + Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.Same(request, finished); + await Assert.ThrowsAnyAsync(() => request); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + + [Fact] + public async Task RequestConsentAsync_ReportsPresenterFailureAfterCancellation_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var cancellation = new CancellationTokenSource(); + using var trackerScope = MxcTelemetry.OverrideFailureCategoryTrackerForTesting( + new MxcTelemetry.FailureCategoryTracker(capacity: 64)); + using var listener = new CapturingTraceListener( + "mxc: RequestConsentAsync presenter after cancellation failed"); + Trace.Listeners.Add(listener); + try + { + var presenterStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var presenter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var request = MxcTelemetry.RequestConsentAsync(_ => + { + presenterStarted.SetResult(); + return presenter.Task; + }, cancellationToken: cancellation.Token); + + await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => request); + + presenter.SetException(new InvalidOperationException("late presenter failure")); + var diagnostic = await listener.Message.WaitAsync(TestContext.Current.CancellationToken); + Assert.Contains(typeof(InvalidOperationException).FullName!, diagnostic); + Assert.Contains("HRESULT 0x", diagnostic); + Assert.DoesNotContain("late presenter failure", diagnostic); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [Fact] + public async Task RequestConsentAsync_ExplicitDismissalCompletesWithoutCancellation_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + var outcome = await MxcTelemetry.RequestConsentAsync( + _ => Task.FromResult(TelemetryConsentDecision.Dismissed), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(TelemetryConsentResult.Dismissed, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + } + + [Fact] + public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPersistDecision_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var cancellation = new CancellationTokenSource(); + var previousContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var request = MxcTelemetry.RequestConsentAsync(_ => + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + return Task.FromResult(TelemetryConsentDecision.Yes); + }, cancellationToken: cancellation.Token); + + await Assert.ThrowsAnyAsync(() => request); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + } + + [Fact] + public void FinalizeAsyncOutcome_PersistedDecisionWinsConcurrentCancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Granted, + StoredState = TelemetryConsentState.Granted, + EffectiveState = TelemetryConsentState.Granted, + }; + + Assert.Same(outcome, MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + + [Fact] + public void FinalizeAsyncOutcome_CanceledDismissalThrows() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Dismissed, + StoredState = TelemetryConsentState.Undetermined, + EffectiveState = TelemetryConsentState.Undetermined, + }; + + Assert.ThrowsAny( + () => MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + + [Fact] + public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows() + { + 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()); + } + + [SupportedOSPlatform("windows")] + private sealed class TelemetryTestEnv : IDisposable + { + private static readonly SemaphoreSlim Gate = new(1, 1); + + private readonly string? _originalLocalAppData; + private readonly string? _originalLocalAppDataOwnerPid; + private readonly string? _originalPolicyKey; + private readonly string? _originalPolicyOwnerPid; + private readonly string _storeDir; + 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); + + try + { + if (Directory.Exists(_storeDir)) + { + Directory.Delete(_storeDir, recursive: true); + } + } + catch + { + } + + try + { + Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false); + } + catch + { + } + + Gate.Release(); + } + + private static int GetParentProcessId() + { + using var process = Process.GetCurrentProcess(); + var status = NtQueryInformationProcess( + process.Handle, + processInformationClass: 0, + out var processInformation, + Marshal.SizeOf(), + out _); + if (status != 0) + { + throw new InvalidOperationException( + $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}"); + } + + return checked((int)processInformation.InheritedFromUniqueProcessId); + } + + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + out ProcessBasicInformation processInformation, + int processInformationLength, + out int returnLength); + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessBasicInformation + { + internal IntPtr Reserved1; + internal IntPtr PebBaseAddress; + internal IntPtr Reserved2_0; + internal IntPtr Reserved2_1; + internal IntPtr UniqueProcessId; + internal IntPtr InheritedFromUniqueProcessId; + } + } + + private sealed class PumpSynchronizationContext : SynchronizationContext, IDisposable + { + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _work = new(); + private readonly AutoResetEvent _workAvailable = new(initialState: false); + + public override void Post(SendOrPostCallback callback, object? state) + { + _work.Enqueue((callback, state)); + _workAvailable.Set(); + } + + public void RunUntilCompleted(Task task) + { + while (!task.IsCompleted) + { + if (_work.TryDequeue(out var work)) + { + work.Callback(work.State); + } + else + { + _workAvailable.WaitOne(TimeSpan.FromSeconds(5)); + } + } + } + + public void Dispose() => _workAvailable.Dispose(); + } + + private sealed class CapturingTraceListener(string messagePrefix) : TraceListener + { + private readonly TaskCompletionSource _message = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Message => _message.Task; + + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? message) + { + Capture(eventType, message); + } + + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? format, + params object?[]? args) + { + Capture( + eventType, + args is null || args.Length == 0 + ? format + : string.Format(format ?? string.Empty, args)); + } + + public override void Write(string? message) + { + } + + public override void WriteLine(string? message) + { + } + + private void Capture(TraceEventType eventType, string? message) + { + if (eventType == TraceEventType.Error && + message?.StartsWith(messagePrefix, StringComparison.Ordinal) == true) + { + _message.TrySetResult(message); + } + } + } +#endif + + 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 WriteLine(string? message) => + throw new InvalidOperationException("listener failure"); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs index 6bbde7798..488abed94 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs @@ -58,4 +58,7 @@ public enum ErrorCode /// The native side panicked and was caught at the boundary (FFI-local). Panic = 102, + + /// Telemetry consent could not be persisted (FFI-local). + ConsentWriteFailed = 103, } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs index 12810c042..8e90e3459 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs @@ -313,14 +313,7 @@ private static ProcessContainerContainment CloneProcessContainer( // obsolete CaptureDenials must be copied, or it is silently dropped from // any request that carries the legacy field. private static SandboxPolicy ClonePolicyWithoutCaptureDenials(SandboxPolicy policy) => - new() - { - Version = policy.Version, - Filesystem = policy.Filesystem, - Network = policy.Network, - Ui = policy.Ui, - TimeoutMs = policy.TimeoutMs, - }; + policy.WithoutLegacyCaptureDenials(); private static bool CaptureDenialsEqual( CaptureDenialsPolicy left, diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs new file mode 100644 index 000000000..79c5524b4 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -0,0 +1,861 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +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. +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 + { + NativePayloadResult GetConsent(); + NativeBooleanResult NeedsConsentPrompt(); + NativePayloadResult GetPolicy(); + } + + private sealed class PInvokeTelemetryReadApi : ITelemetryReadApi + { + public unsafe NativePayloadResult GetConsent() + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_consent(&value); + try + { + return new(status, ReadNativeUtf8(value)); + } + finally + { + FreeNativeString(value); + } + } + + public unsafe NativeBooleanResult NeedsConsentPrompt() + { + int needsPrompt = 0; + var status = NativeMethods.mxc_telemetry_needs_consent_prompt(&needsPrompt); + return new(status, needsPrompt != 0); + } + + public unsafe NativePayloadResult GetPolicy() + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_policy(&value); + try + { + return new(status, ReadNativeUtf8(value)); + } + finally + { + FreeNativeString(value); + } + } + } + + internal static IDisposable OverrideTelemetryReadApiForTesting( + ITelemetryReadApi replacement) + { + ArgumentNullException.ThrowIfNull(replacement); + if (!ReferenceEquals( + Interlocked.CompareExchange( + ref telemetryReadApi, + replacement, + DefaultTelemetryReadApi), + DefaultTelemetryReadApi)) + { + throw new InvalidOperationException( + "A telemetry read API test override is already active."); + } + return new TelemetryReadApiScope(replacement); + } + + internal static IDisposable OverrideFailureCategoryTrackerForTesting( + FailureCategoryTracker replacement) + { + ArgumentNullException.ThrowIfNull(replacement); + if (!ReferenceEquals( + Interlocked.CompareExchange( + ref reportedFailures, + replacement, + DefaultFailureCategoryTracker), + DefaultFailureCategoryTracker)) + { + throw new InvalidOperationException( + "A failure category tracker test override is already active."); + } + return new FailureCategoryTrackerScope(replacement); + } + + private sealed class TelemetryReadApiScope(ITelemetryReadApi replacement) : IDisposable + { + private int disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + Interlocked.CompareExchange( + ref telemetryReadApi, + DefaultTelemetryReadApi, + replacement); + } + } + + private sealed class FailureCategoryTrackerScope( + FailureCategoryTracker replacement) : IDisposable + { + private int disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + Interlocked.CompareExchange( + ref reportedFailures, + DefaultFailureCategoryTracker, + replacement); + } + } + + internal sealed class FailureCategoryTracker(int capacity) + { + private readonly HashSet categories = []; + + private readonly record struct FailureCategory( + string Operation, + string SafeResult, + string Kind, + int Code); + + internal bool TryAdd(string operation, string safeResult, string kind, int code) + { + var category = new FailureCategory(operation, safeResult, kind, code); + lock (categories) + { + if (categories.Contains(category) || categories.Count >= capacity) + { + return false; + } + categories.Add(category); + return true; + } + } + + internal void Remove(string operation, string safeResult, string kind, int code) + { + lock (categories) + { + categories.Remove(new FailureCategory(operation, safeResult, kind, code)); + } + } + } + + private sealed class PresenterContext + { + public required Func Presenter { get; init; } + public CancellationToken CancellationToken { get; init; } + } + + /// + /// 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() + { + try + { + EnsureNativeInitialized(); + var result = telemetryReadApi.GetConsent(); + if (result.Status != (int)ErrorCode.Success) + { + throw new MxcException( + (ErrorCode)result.Status, + "retrieving telemetry consent failed"); + } + + return ParseConsentState(RequirePayload(result.Payload, "telemetry consent")); + } + catch (MxcException) + { + throw; + } + catch (Exception ex) + { + ReportFailClosed("GetConsent", "Undetermined", ex); + return TelemetryConsentState.Undetermined; + } + } + + /// + /// Read stored and effective consent, plus the current administrative policy. + /// + public static TelemetryConsentStatus GetConsentStatus() + { + EnsureNativeInitialized(); + unsafe + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_get_consent_status(&value); + try + { + EnsureSuccess( + status, + "retrieving telemetry consent status failed", + ReadNativeUtf8(value)); + return ParseConsentStatus(ReadRequiredJson(value, "telemetry consent status")); + } + finally + { + FreeNativeString(value); + } + } + } + + /// + /// Invoke a host presenter, then persist its decision. + /// + public static TelemetryConsentOutcome RequestConsent( + Func presenter, + string? locale = null) + { + ArgumentNullException.ThrowIfNull(presenter); + ValidateLocale(locale); + return RequestConsentCore(presenter, locale, CancellationToken.None); + } + + private static TelemetryConsentOutcome RequestConsentCore( + Func presenter, + string? locale, + CancellationToken cancellationToken) + { + EnsureNativeInitialized(); + + var localeBuf = locale is null ? null : ToNullTerminatedUtf8(locale); + var presenterContext = GCHandle.Alloc(new PresenterContext + { + Presenter = presenter, + CancellationToken = cancellationToken, + }); + try + { + unsafe + { + fixed (byte* localePtr = localeBuf) + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_request_consent( + localePtr, + &PresentConsentBridge, + (void*)GCHandle.ToIntPtr(presenterContext), + &value); + try + { + EnsureSuccess( + status, + "requesting telemetry consent failed", + ReadNativeUtf8(value)); + return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome")); + } + finally + { + FreeNativeString(value); + } + } + } + } + finally + { + presenterContext.Free(); + } + } + + /// + /// Asynchronous wrapper over . + /// The native API is synchronous and blocking, so the native work runs on the thread pool while + /// the presenter is dispatched to the caller's synchronization context when one is available. + /// + /// + /// 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. + /// + public static Task RequestConsentAsync( + Func> presenter, + string? locale = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(presenter); + ValidateLocale(locale); + var synchronizationContext = SynchronizationContext.Current; + return Task.Run( + () => + { + var outcome = RequestConsentCore( + prompt => InvokePresenterWithCancellation( + presenter, + prompt, + synchronizationContext, + cancellationToken), + locale, + cancellationToken); + return FinalizeAsyncOutcome(outcome, cancellationToken); + }, + cancellationToken); + } + + internal static TelemetryConsentOutcome FinalizeAsyncOutcome( + TelemetryConsentOutcome outcome, + CancellationToken cancellationToken) + { + if (outcome.Result == TelemetryConsentResult.Dismissed) + { + cancellationToken.ThrowIfCancellationRequested(); + } + return outcome; + } + + /// Persist an idempotent telemetry-consent withdrawal. + public static TelemetryConsentOutcome WithdrawConsent() + { + EnsureNativeInitialized(); + unsafe + { + byte* value = null; + var status = NativeMethods.mxc_telemetry_withdraw_consent(&value); + try + { + EnsureSuccess( + status, + "withdrawing telemetry consent failed", + ReadNativeUtf8(value)); + return ParseConsentOutcome(ReadRequiredJson(value, "telemetry consent outcome")); + } + finally + { + FreeNativeString(value); + } + } + } + + /// + /// Whether a host should show its first-run consent prompt. + /// Fails closed to and never throws. + /// + public static bool NeedsConsentPrompt() + { + try + { + EnsureNativeInitialized(); + var result = telemetryReadApi.NeedsConsentPrompt(); + if (result.Status != (int)ErrorCode.Success) + { + ReportFailClosed( + "NeedsConsentPrompt", + "false", + (ErrorCode)result.Status); + return false; + } + + return result.Value; + } + catch (Exception ex) + { + ReportFailClosed("NeedsConsentPrompt", "false", ex); + return false; + } + } + + /// + /// Read the administrative telemetry policy. + /// Fails closed to and never throws. + /// + public static TelemetryPolicyState GetPolicy() + { + try + { + 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")); + } + catch (Exception ex) + { + ReportFailClosed("GetPolicy", "Blocked", ex); + return 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) + { + var registered = false; + 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); + } + catch + { + if (registered) + { + reportedFailures.Remove( + operation, + safeResult, + kind, + code); + } + // Diagnostics must not affect the fail-closed result. + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static unsafe int PresentConsentBridge(byte* promptJsonUtf8, void* context) + { + try + { + var handle = GCHandle.FromIntPtr((IntPtr)context); + if (handle.Target is not PresenterContext presenterContext) + { + return NativeConsentPresenterError; + } + + var prompt = ParseConsentPrompt(ReadRequiredJson(promptJsonUtf8, "telemetry consent prompt")); + var decision = presenterContext.Presenter(prompt); + if (presenterContext.CancellationToken.IsCancellationRequested) + { + return NativeConsentDecisionDismissed; + } + + return decision switch + { + TelemetryConsentDecision.Yes => NativeConsentDecisionYes, + TelemetryConsentDecision.No => NativeConsentDecisionNo, + TelemetryConsentDecision.Dismissed => NativeConsentDecisionDismissed, + _ => NativeConsentPresenterError, + }; + } + catch + { + return NativeConsentPresenterError; + } + } + + private static TelemetryConsentDecision InvokePresenterWithCancellation( + Func> presenter, + TelemetryConsentPrompt prompt, + SynchronizationContext? synchronizationContext, + CancellationToken cancellationToken) + { + Task? presenterTask = null; + try + { + presenterTask = InvokePresenterAsync(presenter, prompt, synchronizationContext); + return presenterTask + .WaitAsync(cancellationToken) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (presenterTask is not null) + { + ObserveAbandonedPresenterFailure(presenterTask); + } + return TelemetryConsentDecision.Dismissed; + } + } + + private static void ObserveAbandonedPresenterFailure( + Task presenterTask) + { + _ = presenterTask.ContinueWith( + static faulted => + { + var exception = faulted.Exception; + if (exception is null) + { + return; + } + + ReportFailClosed( + "RequestConsentAsync presenter after cancellation", + "canceled", + exception.GetBaseException()); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + private static Task InvokePresenterAsync( + Func> presenter, + TelemetryConsentPrompt prompt, + SynchronizationContext? synchronizationContext) + { + if (synchronizationContext is null) + { + return presenter(prompt); + } + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + try + { + synchronizationContext.Post( + async _ => + { + try + { + completion.SetResult(await presenter(prompt)); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }, + null); + } + catch (Exception ex) + { + completion.SetException(ex); + } + + return completion.Task; + } + + private static void EnsureNativeInitialized() => NativeLibraryResolver.Initialize(); + + private static void EnsureSuccess(int status, string fallbackMessage, string? nativeMessage) + { + if (status == (int)ErrorCode.Success) + { + return; + } + + throw new MxcException( + (ErrorCode)status, + string.IsNullOrWhiteSpace(nativeMessage) ? fallbackMessage : nativeMessage); + } + + private static unsafe string ReadRequiredJson(byte* value, string description) => + ReadNativeUtf8(value) ?? throw new MxcException(ErrorCode.BackendError, $"{description} was missing"); + + private static string RequirePayload(string? value, string description) => + value ?? throw new InvalidOperationException($"{description} payload was missing"); + + private static unsafe string? ReadNativeUtf8(byte* value) => + value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value); + + private static unsafe void FreeNativeString(byte* value) + { + if (value is not null) + { + NativeMethods.mxc_string_free(value); + } + } + + private static byte[] ToNullTerminatedUtf8(string value) + { + var byteCount = Encoding.UTF8.GetByteCount(value); + var buffer = new byte[byteCount + 1]; + Encoding.UTF8.GetBytes(value, 0, value.Length, buffer, 0); + buffer[byteCount] = 0; + return buffer; + } + + private static void ValidateLocale(string? locale) + { + if (locale?.Contains('\0') == true) + { + throw new ArgumentException( + "Telemetry consent locale cannot contain embedded NUL characters.", + nameof(locale)); + } + } + + internal static TelemetryConsentPrompt ParseConsentPrompt(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + return new TelemetryConsentPrompt + { + 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"), + }; + } + + private static TelemetryConsentMessage ParseConsentMessage(JsonElement value, string path) => new() + { + Id = ParseRequiredString(value, "id", $"{path}.id"), + Text = ParseRequiredString(value, "text", $"{path}.text"), + }; + + 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."); + } + + return value.GetString()!; + } + + private static TelemetryConsentStatus ParseConsentStatus(string json) + { + using var doc = JsonDocument.Parse(json); + return ParseConsentStatus(doc.RootElement); + } + + private static TelemetryConsentStatus ParseConsentStatus(JsonElement root) => new() + { + StoredState = ParseConsentState(root.GetProperty("storedState").GetString() ?? string.Empty), + EffectiveState = ParseConsentState(root.GetProperty("effectiveState").GetString() ?? string.Empty), + Reason = root.TryGetProperty("reason", out var reason) && reason.ValueKind != JsonValueKind.Null + ? ParseConsentStatusReason(reason.GetString() ?? string.Empty) + : null, + Policy = ParsePolicyState(root.GetProperty("policy").GetString() ?? string.Empty), + }; + + private static TelemetryConsentOutcome ParseConsentOutcome(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var status = ParseConsentStatus(root); + return new TelemetryConsentOutcome + { + Result = ParseConsentResult(root.GetProperty("result").GetString() ?? string.Empty), + StoredState = status.StoredState, + EffectiveState = status.EffectiveState, + Reason = status.Reason, + Policy = status.Policy, + }; + } + + private static TelemetryConsentState ParseConsentState(string value) => value switch + { + "granted" => TelemetryConsentState.Granted, + "denied" => TelemetryConsentState.Denied, + "undetermined" => TelemetryConsentState.Undetermined, + "not-applicable" => TelemetryConsentState.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent state '{value}'"), + }; + + private static TelemetryPolicyState ParsePolicyState(string value) => value switch + { + "unrestricted" => TelemetryPolicyState.Unrestricted, + "allowed" => TelemetryPolicyState.Allowed, + "blocked" => TelemetryPolicyState.Blocked, + "not-applicable" => TelemetryPolicyState.NotApplicable, + _ => throw new JsonException($"unknown telemetry policy state '{value}'"), + }; + + private static TelemetryConsentStatusReason ParseConsentStatusReason(string value) => value switch + { + "no-record" => TelemetryConsentStatusReason.NoRecord, + "store-unreadable" => TelemetryConsentStatusReason.StoreUnreadable, + "store-malformed" => TelemetryConsentStatusReason.StoreMalformed, + "consent-schema-unsupported" => TelemetryConsentStatusReason.ConsentSchemaUnsupported, + "prompt-version-missing" => TelemetryConsentStatusReason.PromptVersionMissing, + "prompt-version-unsupported" => TelemetryConsentStatusReason.PromptVersionUnsupported, + "not-applicable" => TelemetryConsentStatusReason.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent status reason '{value}'"), + }; + + private static TelemetryConsentResult ParseConsentResult(string value) => value switch + { + "granted" => TelemetryConsentResult.Granted, + "denied" => TelemetryConsentResult.Denied, + "dismissed" => TelemetryConsentResult.Dismissed, + "withdrawn" => TelemetryConsentResult.Withdrawn, + "alreadyGranted" => TelemetryConsentResult.AlreadyGranted, + "policyBlocked" => TelemetryConsentResult.PolicyBlocked, + "notApplicable" => TelemetryConsentResult.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent result '{value}'"), + }; +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index e99abb605..d37df55cf 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -56,6 +56,30 @@ public sealed class SandboxPolicy /// Execution timeout in milliseconds (null = no timeout). [JsonPropertyName("timeoutMs")] public uint? TimeoutMs { get; set; } + + /// + /// Per-invocation telemetry settings serialized in the binding policy as + /// {"telemetry":{"enabled":...}}. + /// + [JsonPropertyName("telemetry")] + public TelemetrySettings? Telemetry { get; set; } + + internal SandboxPolicy WithoutLegacyCaptureDenials() + { + var clone = (SandboxPolicy)MemberwiseClone(); +#pragma warning disable MXC0001 // Clears the compatibility alias on the clone only. + clone.CaptureDenials = null; +#pragma warning restore MXC0001 + return clone; + } +} + +/// Telemetry section of a . +public sealed class TelemetrySettings +{ + /// Whether this invocation opts telemetry on, subject to consent and policy. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } } /// diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 0d78e37ac..f0c91946a 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -484,7 +484,9 @@ MXC telemetry is Windows-only and remains off until both of these are true: 1. the user has explicitly granted MXC-owned consent, and 2. the caller opts this invocation in via telemetry settings. -Telemetry remains off by default unless the caller opts in with `SandboxPolicy.TelemetryEnabled = true` (or the equivalent phase-level `TelemetryEnabled` setting for state-aware requests) and applicable Windows consent/policy gates permit collection. +Telemetry remains off by default unless the caller opts in with +`SandboxPolicy.Telemetry = new TelemetrySettings { Enabled = true }` and +applicable Windows consent/policy gates permit collection. When a telemetry-enabled Windows ProcessContainer run successfully produces a Learning Mode `captureDenials` verbose artifact, telemetry can include its @@ -492,21 +494,33 @@ 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). -Any .NET consent surface should stay UI-agnostic, present the canonical -resource verbatim through a host callback, persist only explicit yes/no -decisions, treat dismissal and failures as non-grants, and follow the rules in +The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies +the canonical resource to a host callback, while `GetConsentStatus`, +`NeedsConsentPrompt`, and `WithdrawConsent` provide maintenance operations. +They persist only explicit yes/no decisions and treat dismissal and failures +as non-grants. See [`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md) and its [SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements). +`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 +and is returned even if cancellation occurs concurrently. Cancellation does not +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. Policy and consent -queries are not yet exposed by the .NET SDK; any eventual query must fail -closed rather than upgrading an unreadable device state into collection. +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. ## Projects diff --git a/src/Cargo.lock b/src/Cargo.lock index 018922434..4618472f9 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1567,8 +1567,12 @@ version = "0.8.0" dependencies = [ "csbindgen", "mxc-sdk", + "semver", "serde", + "serde_ignored", "serde_json", + "tempfile", + "wxc_common", ] [[package]] @@ -2174,6 +2178,16 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.150" diff --git a/src/Cargo.toml b/src/Cargo.toml index 4a5ed2640..e884b5080 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -95,7 +95,7 @@ sandbox_spec = { path = "core/generated/base_container_specification" } seatbelt_common = { path = "backends/seatbelt/common" } same-file = "1" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } serde_path_to_error = "0.1" schemars = "0.8" sha2 = "0.10" diff --git a/src/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index b3a4bd747..af870cde6 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -16,6 +16,8 @@ default = [] wslc = ["mxc_engine/wslc"] # IsolationSession backend (Windows only, experimental). isolation_session = ["mxc_engine/isolation_session"] +# Exposes the debug-only telemetry test seams to downstream binding tests. +test-support = ["wxc_common/test-support"] [dependencies] wxc_common.workspace = true diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 0d8755d6a..8ac2036f9 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -13,9 +13,10 @@ sandboxed process over ordinary pipes, with no pty. The state-aware ## Usage ```rust,no_run +use std::error::Error; use mxc_sdk::{build_request, run, SandboxPolicy, WaitOutcome}; -// Describe what to restrict, turn it into a request, fill in the command. +fn main() -> Result<(), Box> { let policy = SandboxPolicy { version: "0.7.0-alpha".to_string(), filesystem: None, @@ -24,13 +25,13 @@ let policy = SandboxPolicy { timeout_ms: Some(10_000), }; let mut request = build_request(&policy, None)?; -request.set_script("echo hello"); +request.set_script("echo hello").set_telemetry_opt_in(true); -// Run to completion and capture the output. let output = run(request)?; assert_eq!(output.outcome, WaitOutcome::Exited(0)); assert_eq!(String::from_utf8_lossy(&output.stdout), "hello\n"); -# Ok::<(), Box>(()) +Ok(()) +} ``` [`run`] is the run-to-completion convenience (spawn + `wait_with_output`); use @@ -43,6 +44,10 @@ through the shared parser. The returned [`SandboxRequest`] has an empty command line — set the command with [`SandboxRequest::set_script`] (and any working directory / env) before spawning. +Telemetry remains off unless `SandboxRequest::set_telemetry_opt_in(true)` is +called. Enabling that per-invocation switch still requires persisted user +consent and a permitting administrative policy. + To target a specific backend instead of the host default, use [`build_request_with_containment`] with a [`Containment`]. @@ -225,9 +230,11 @@ while it runs — persistent bidirectional stdio plus termination. No pty is allocated; the streams are ordinary pipes. ```rust,no_run +use std::error::Error; use std::io::{Read, Write}; use mxc_sdk::{build_request, spawn_sandbox, SandboxPolicy, WaitOutcome}; +fn main() -> Result<(), Box> { let policy = SandboxPolicy { version: "0.7.0-alpha".to_string(), filesystem: None, @@ -249,7 +256,8 @@ stdout.read_to_string(&mut out)?; // "hello\n" let outcome = proc.wait()?; // any untaken stream is drained and discarded assert_eq!(outcome, WaitOutcome::Exited(0)); -# Ok::<(), Box>(()) +Ok(()) +} ``` The handle is modelled on [`std::process::Child`]: @@ -334,8 +342,10 @@ The example needs this crate's `isolation_session` feature and a host running th OS-side service. ```rust,no_run +use std::error::Error; use mxc_sdk::{run_state_aware_json, exec_attached}; +fn main() -> Result<(), Box> { // Provision. IsolationSession accepts only the canonical unrestricted-network // acknowledgment; an absent policy defaults to `block`, which it refuses. let provisioned = run_state_aware_json( @@ -358,7 +368,10 @@ let outcome = exec_attached( r#"{"phase":"exec","sandboxId":"...","process":{"commandLine":"powershell.exe"}}"#, true, // experimental )?; -# Ok::<(), Box>(()) +let _ = outcome; +let _ = provisioned; +Ok(()) +} ``` Three backends implement the state-aware lifecycle — IsolationSession, WSLc and @@ -413,19 +426,23 @@ go through the same parser the executor uses — so a rejected value (e.g. a por mapping with a zero or duplicated host port) fails at build time, not at spawn. ```rust,no_run +use std::error::Error; use mxc_sdk::{ build_request_with_containment, run, Containment, SandboxPolicy, WslcSection, }; -# let policy = SandboxPolicy { -# version: "0.7.0-alpha".to_string(), -# filesystem: None, network: None, ui: None, timeout_ms: None, -# }; +fn main() -> Result<(), Box> { +let policy = SandboxPolicy { + version: "0.7.0-alpha".to_string(), + filesystem: None, network: None, ui: None, timeout_ms: None, +}; let wslc = WslcSection { image: "python:3.12".to_string(), ..Default::default() }; let mut request = build_request_with_containment(&policy, &Containment::Wslc(wslc), None)?; request.set_script("python3 -c 'print(42)'").set_experimental(true); let output = run(request)?; -# Ok::<(), mxc_sdk::Error>(()) +let _ = output; +Ok(()) +} ``` Two WSLC-specific limits follow from the SDK's surface: the container has no @@ -433,6 +450,74 @@ stdin (`Sandbox::take_stdin()` returns `None`), and its process has no host process id (`Sandbox::id()` is `0`) — `kill()` stops the whole container. [`platform_support`] reports `"wslc"` only on a host that can actually run it. +## Telemetry consent + +MXC only ever collects telemetry on Windows, and only after the end user has +explicitly opted in — a persisted, MXC-owned consent flag gates every +emission (never a Windows-level setting like Diagnostics & feedback). See +[`docs/telemetry/telemetry-consent-design.md`](../../../docs/telemetry/telemetry-consent-design.md) +for the full design. + +The crate is UI-agnostic: it does not render a prompt. A host may call +`request_consent()` or `request_consent_async()` and render every field of the +canonical prompt supplied to its presenter callback verbatim. MXC persists a +grant only from the typed decision returned by that callback. If the host +never requests consent, telemetry remains off. See the normative +[SDK presenter requirements](../../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements) +for control mappings, dismissal behavior, learn-more handling, status, and +withdrawal. + +```rust,no_run +use std::error::Error; +use mxc_sdk::telemetry; + +fn main() -> Result<(), Box> { +let outcome = telemetry::request_consent(Some("en-US"), |prompt| { + assert_eq!(prompt.locale, "en-US"); + Ok(telemetry::ConsentDecision::Yes) +})?; + +let status = telemetry::get_consent_status(); +let withdrawal = telemetry::withdraw_consent()?; +let _ = (outcome, status, withdrawal); +Ok(()) +} +``` + +Off Windows `get_consent()` always returns `ConsentState::NotApplicable`, +`needs_consent_prompt()` is always `false`, and consent requests return +`ConsentActionResult::NotApplicable` — MXC neither collects nor offers consent +for telemetry there, so a host can call these unconditionally without +special-casing the platform. + +### Administrative policy + +An IT administrator can block MXC telemetry device-wide via MXC's own +registry policy setting. `telemetry::get_policy()` reports the result: + +```rust,no_run +use mxc_sdk::telemetry::{self, PolicyState}; + +if telemetry::get_policy() == PolicyState::Blocked { + // Don't show a consent toggle; telemetry is unavailable on this device. +} +``` + +Two things worth designing around: + +- The policy is a **ceiling, never a grant**. `PolicyState::Allowed` does not + mean telemetry is on — the user must still consent. Only + `ConsentState::Granted` *and* a non-blocking policy result in collection. +- When the policy blocks, `needs_consent_prompt()` is `false`, because asking + for permission an administrator has already refused is a meaningless + question. Word any UI as "telemetry is unavailable on this device" rather + than blaming the user's own choice. + +It never fails: any unreadable or unrecognized value reads back as +`PolicyState::Blocked`. Off Windows it is always `PolicyState::NotApplicable`. +`telemetry::is_blocked_by_policy()` is the convenience predicate. See +[`docs/telemetry/telemetry-administrative-policy.md`](../../../docs/telemetry/telemetry-administrative-policy.md). + ## Pty allocation Every entry point except `exec_attached` wires the child's stdio to ordinary diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 16af6f428..d3960ee21 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -144,6 +144,8 @@ mod sandbox; +pub mod telemetry; + pub use mxc_engine::configs; pub use mxc_engine::policy; pub use mxc_engine::{ diff --git a/src/core/mxc-sdk/src/telemetry.rs b/src/core/mxc-sdk/src/telemetry.rs new file mode 100644 index 000000000..3d43cd6cb --- /dev/null +++ b/src/core/mxc-sdk/src/telemetry.rs @@ -0,0 +1,525 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Telemetry consent administration. +//! +//! A host supplies presentation for the canonical consent resource; the SDK +//! persists the typed decision. See +//! `docs/telemetry/telemetry-consent-design.md`. +//! +//! ```no_run +//! use std::error::Error; +//! use mxc_sdk::telemetry::{self, ConsentDecision, ConsentState}; +//! +//! fn main() -> Result<(), Box> { +//! let outcome = telemetry::request_consent(Some("en-US"), |prompt| { +//! assert_eq!(prompt.locale, "en-US"); +//! Ok(ConsentDecision::Yes) +//! })?; +//! +//! match telemetry::get_consent() { +//! ConsentState::Granted => println!("telemetry on"), +//! ConsentState::Denied | ConsentState::Undetermined => println!("telemetry off"), +//! ConsentState::NotApplicable => {} +//! } +//! let _ = outcome; +//! Ok(()) +//! } +//! ``` + +use std::fmt; + +use wxc_common::telemetry::consent as inner_consent; +use wxc_common::telemetry::policy as inner_policy; + +/// A stored or effective telemetry consent state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConsentState { + /// The user has explicitly agreed to telemetry collection. + Granted, + /// The user has explicitly declined telemetry collection. + Denied, + /// No decision has been recorded yet (fresh install, or a corrupt or + /// unreadable store). Treated identically to [`ConsentState::Denied`] for + /// gating purposes — it differs only in that a host should still prompt. + Undetermined, + /// Not a Windows host. MXC collects no telemetry on other platforms, so + /// there is nothing to consent to. + NotApplicable, +} + +impl ConsentState { + /// The stable wire string for this state, identical across every SDK. + pub fn as_str(&self) -> &'static str { + Self::to_inner(*self).as_str() + } + + /// Whether this state permits collection. + pub fn allows_collection(&self) -> bool { + Self::to_inner(*self).allows_collection() + } + + /// Whether this state needs a consent prompt, without policy evaluation. + pub fn needs_prompt(&self) -> bool { + Self::to_inner(*self).needs_prompt() + } + + fn to_inner(self) -> inner_consent::ConsentState { + match self { + Self::Granted => inner_consent::ConsentState::Granted, + Self::Denied => inner_consent::ConsentState::Denied, + Self::Undetermined => inner_consent::ConsentState::Undetermined, + Self::NotApplicable => inner_consent::ConsentState::NotApplicable, + } + } +} + +impl From for ConsentState { + fn from(value: inner_consent::ConsentState) -> Self { + match value { + inner_consent::ConsentState::Granted => Self::Granted, + inner_consent::ConsentState::Denied => Self::Denied, + inner_consent::ConsentState::Undetermined => Self::Undetermined, + inner_consent::ConsentState::NotApplicable => Self::NotApplicable, + } + } +} + +/// One independently localizable canonical consent message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentMessage { + pub id: &'static str, + pub text: &'static str, +} + +/// Canonical consent resource supplied to a host presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentPrompt { + pub resource_version: u32, + pub locale: &'static str, + pub title: ConsentMessage, + pub body: ConsentMessage, + pub affirmative_label: ConsentMessage, + pub negative_label: ConsentMessage, + pub learn_more_label: ConsentMessage, + pub learn_more_url: &'static str, +} + +impl From<&wxc_common::telemetry::consent_prompt::ConsentPrompt> for ConsentPrompt { + fn from(value: &wxc_common::telemetry::consent_prompt::ConsentPrompt) -> Self { + fn message(value: wxc_common::telemetry::consent_prompt::ConsentMessage) -> ConsentMessage { + ConsentMessage { + id: value.id, + text: value.text, + } + } + + Self { + resource_version: value.resource_version, + locale: value.locale, + title: message(value.title), + body: message(value.body), + affirmative_label: message(value.affirmative_label), + negative_label: message(value.negative_label), + learn_more_label: message(value.learn_more_label), + learn_more_url: value.learn_more_url, + } + } +} + +/// Explicit result returned by a host consent presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentDecision { + Yes, + No, + Dismissed, +} + +impl ConsentDecision { + fn to_inner(self) -> inner_consent::ConsentDecision { + match self { + Self::Yes => inner_consent::ConsentDecision::Yes, + Self::No => inner_consent::ConsentDecision::No, + Self::Dismissed => inner_consent::ConsentDecision::Dismissed, + } + } +} + +/// Why stored consent is not currently effective. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentStatusReason { + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + NotApplicable, +} + +impl ConsentStatusReason { + /// Stable wire string used by the maintenance and binding contracts. + pub fn as_str(&self) -> &'static str { + match self { + Self::NoRecord => "no-record", + Self::StoreUnreadable => "store-unreadable", + Self::StoreMalformed => "store-malformed", + Self::ConsentSchemaUnsupported => "consent-schema-unsupported", + Self::PromptVersionMissing => "prompt-version-missing", + Self::PromptVersionUnsupported => "prompt-version-unsupported", + Self::NotApplicable => "not-applicable", + } + } +} + +impl From for ConsentStatusReason { + fn from(value: inner_consent::ConsentStatusReason) -> Self { + match value { + inner_consent::ConsentStatusReason::NoRecord => Self::NoRecord, + inner_consent::ConsentStatusReason::StoreUnreadable => Self::StoreUnreadable, + inner_consent::ConsentStatusReason::StoreMalformed => Self::StoreMalformed, + inner_consent::ConsentStatusReason::ConsentSchemaUnsupported => { + Self::ConsentSchemaUnsupported + } + inner_consent::ConsentStatusReason::PromptVersionMissing => Self::PromptVersionMissing, + inner_consent::ConsentStatusReason::PromptVersionUnsupported => { + Self::PromptVersionUnsupported + } + inner_consent::ConsentStatusReason::NotApplicable => Self::NotApplicable, + } + } +} + +/// Stored and effective consent returned by read-only status APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentStatus { + pub stored_state: ConsentState, + pub effective_state: ConsentState, + pub reason: Option, +} + +impl From for ConsentStatus { + fn from(value: inner_consent::ConsentStatus) -> Self { + Self { + stored_state: value.stored_state.into(), + effective_state: value.effective_state.into(), + reason: value.reason.map(Into::into), + } + } +} + +/// Result of a presenter request or withdrawal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentActionResult { + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +impl ConsentActionResult { + /// Stable wire string used by the maintenance and binding contracts. + /// + /// The FFI JSON `result` field and language bindings use these exact + /// camelCase spellings. + pub fn as_str(&self) -> &'static str { + match self { + Self::Granted => "granted", + Self::Denied => "denied", + Self::Dismissed => "dismissed", + Self::Withdrawn => "withdrawn", + Self::AlreadyGranted => "alreadyGranted", + Self::PolicyBlocked => "policyBlocked", + Self::NotApplicable => "notApplicable", + } + } +} + +impl From for ConsentActionResult { + fn from(value: inner_consent::ConsentActionResult) -> Self { + match value { + inner_consent::ConsentActionResult::Granted => Self::Granted, + inner_consent::ConsentActionResult::Denied => Self::Denied, + inner_consent::ConsentActionResult::Dismissed => Self::Dismissed, + inner_consent::ConsentActionResult::Withdrawn => Self::Withdrawn, + inner_consent::ConsentActionResult::AlreadyGranted => Self::AlreadyGranted, + inner_consent::ConsentActionResult::PolicyBlocked => Self::PolicyBlocked, + inner_consent::ConsentActionResult::NotApplicable => Self::NotApplicable, + } + } +} + +/// Consent action result with resulting status and policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsentActionOutcome { + pub result: ConsentActionResult, + pub status: ConsentStatus, + pub policy: PolicyState, +} + +impl From for ConsentActionOutcome { + fn from(value: inner_consent::ConsentActionOutcome) -> Self { + Self { + result: value.result.into(), + status: value.status.into(), + policy: value.policy.into(), + } + } +} + +/// Failure to present or persist telemetry consent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConsentError { + Presenter(String), + Persist(String), +} + +impl fmt::Display for ConsentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Presenter(message) => write!(formatter, "consent presenter failed: {message}"), + Self::Persist(message) => write!(formatter, "failed to persist consent: {message}"), + } + } +} + +impl std::error::Error for ConsentError {} + +impl From for ConsentError { + fn from(value: inner_consent::ConsentActionError) -> Self { + match value { + inner_consent::ConsentActionError::Presenter(message) => Self::Presenter(message), + inner_consent::ConsentActionError::Persist(message) => Self::Persist(message), + } + } +} + +/// The administrative telemetry policy for this machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PolicyState { + /// No policy is configured. + Unrestricted, + /// A policy is configured and permits collection. + Allowed, + /// A policy blocks collection or could not be evaluated. + Blocked, + /// Not a Windows host. + NotApplicable, +} + +impl PolicyState { + /// The stable wire string for this state, identical across every SDK. + pub fn as_str(&self) -> &'static str { + Self::to_inner(*self).as_str() + } + + /// Whether this policy permits collection. + pub fn allows_collection(&self) -> bool { + Self::to_inner(*self).allows_collection() + } + + fn to_inner(self) -> inner_policy::PolicyState { + match self { + Self::Unrestricted => inner_policy::PolicyState::Unrestricted, + Self::Allowed => inner_policy::PolicyState::Allowed, + Self::Blocked => inner_policy::PolicyState::Blocked, + Self::NotApplicable => inner_policy::PolicyState::NotApplicable, + } + } +} + +impl From for PolicyState { + fn from(value: inner_policy::PolicyState) -> Self { + match value { + inner_policy::PolicyState::Unrestricted => Self::Unrestricted, + inner_policy::PolicyState::Allowed => Self::Allowed, + inner_policy::PolicyState::Blocked => Self::Blocked, + inner_policy::PolicyState::NotApplicable => Self::NotApplicable, + } + } +} + +/// Return the consent state currently effective for telemetry authorization. +/// +/// Use [`get_consent_status`] when the persisted decision is required. +pub fn get_consent() -> ConsentState { + inner_consent::get_consent().into() +} + +/// Read stored and effective consent. +pub fn get_consent_status() -> ConsentStatus { + inner_consent::get_status().into() +} + +/// Invoke a host presenter and persist its decision. +pub fn request_consent( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(&ConsentPrompt) -> Result, +{ + inner_consent::request_consent(locale, |prompt| { + presenter(&ConsentPrompt::from(prompt)).map(ConsentDecision::to_inner) + }) + .map(Into::into) + .map_err(Into::into) +} + +/// Asynchronous counterpart to [`request_consent`]. +/// +/// The synchronous consent-store work runs on short-lived dedicated worker +/// threads. The presenter itself is awaited on the caller's executor. +pub async fn request_consent_async( + locale: Option<&str>, + presenter: F, +) -> Result +where + F: FnOnce(ConsentPrompt) -> Fut, + Fut: std::future::Future>, +{ + inner_consent::request_consent_async(locale, |prompt| { + let prompt = ConsentPrompt::from(prompt); + async move { presenter(prompt).await.map(ConsentDecision::to_inner) } + }) + .await + .map(Into::into) + .map_err(Into::into) +} + +/// Idempotently withdraw telemetry consent. +pub fn withdraw_consent() -> Result { + inner_consent::withdraw_consent() + .map(Into::into) + .map_err(Into::into) +} + +/// Whether a host should show the first-run consent prompt. +pub fn needs_consent_prompt() -> bool { + inner_consent::needs_consent_prompt() +} + +/// Read the administrative telemetry policy. +pub fn get_policy() -> PolicyState { + inner_policy::get_policy().into() +} + +/// Whether an administrator has blocked telemetry on this machine. +pub fn is_blocked_by_policy() -> bool { + inner_policy::is_blocked_by_policy() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn consent_states_round_trip_and_keep_their_wire_strings() { + for (facade, inner) in [ + (ConsentState::Granted, inner_consent::ConsentState::Granted), + (ConsentState::Denied, inner_consent::ConsentState::Denied), + ( + ConsentState::Undetermined, + inner_consent::ConsentState::Undetermined, + ), + ( + ConsentState::NotApplicable, + inner_consent::ConsentState::NotApplicable, + ), + ] { + assert_eq!(ConsentState::from(inner), facade); + assert_eq!(facade.as_str(), inner.as_str()); + assert_eq!(facade.allows_collection(), inner.allows_collection()); + assert_eq!(facade.needs_prompt(), inner.needs_prompt()); + } + } + + #[test] + fn policy_states_round_trip_and_keep_their_wire_strings() { + for (facade, inner) in [ + ( + PolicyState::Unrestricted, + inner_policy::PolicyState::Unrestricted, + ), + (PolicyState::Allowed, inner_policy::PolicyState::Allowed), + (PolicyState::Blocked, inner_policy::PolicyState::Blocked), + ( + PolicyState::NotApplicable, + inner_policy::PolicyState::NotApplicable, + ), + ] { + assert_eq!(PolicyState::from(inner), facade); + assert_eq!(facade.as_str(), inner.as_str()); + assert_eq!(facade.allows_collection(), inner.allows_collection()); + } + } + + /// Only an explicit grant may permit collection. Locks in that the facade + /// did not accidentally widen the shared rule. + #[test] + fn only_granted_consent_and_a_permitting_policy_allow_collection() { + assert!(ConsentState::Granted.allows_collection()); + for denied in [ + ConsentState::Denied, + ConsentState::Undetermined, + ConsentState::NotApplicable, + ] { + assert!(!denied.allows_collection(), "{denied:?} must not permit"); + } + + // Only an explicit block denies on the policy side. `NotApplicable` + // (off Windows) deliberately does *not* deny: the consent gate above + // already reports `NotApplicable`, and denying here too would wrongly + // imply an administrator had acted. + assert!(!PolicyState::Blocked.allows_collection()); + for permitted in [ + PolicyState::Unrestricted, + PolicyState::Allowed, + PolicyState::NotApplicable, + ] { + assert!(permitted.allows_collection(), "{permitted:?} must permit"); + } + } + + #[test] + fn canonical_prompt_facade_preserves_every_rust_owned_field() { + let inner = wxc_common::telemetry::consent_prompt::prompt_for_locale(Some("en-US")); + let facade = ConsentPrompt::from(inner); + + assert_eq!(facade.resource_version, inner.resource_version); + assert_eq!(facade.locale, inner.locale); + assert_eq!(facade.title.id, inner.title.id); + assert_eq!(facade.title.text, inner.title.text); + assert_eq!(facade.body.id, inner.body.id); + assert_eq!(facade.body.text, inner.body.text); + assert_eq!(facade.affirmative_label.text, inner.affirmative_label.text); + assert_eq!(facade.negative_label.text, inner.negative_label.text); + assert_eq!(facade.learn_more_label.text, inner.learn_more_label.text); + assert_eq!(facade.learn_more_url, inner.learn_more_url); + } + + #[test] + fn consent_result_wire_strings_are_closed_and_stable() { + // These camelCase strings are emitted by the FFI JSON `result` field + // and consumed by the language bindings. + assert_eq!(ConsentActionResult::Granted.as_str(), "granted"); + assert_eq!(ConsentActionResult::Denied.as_str(), "denied"); + assert_eq!(ConsentActionResult::Dismissed.as_str(), "dismissed"); + assert_eq!(ConsentActionResult::Withdrawn.as_str(), "withdrawn"); + assert_eq!( + ConsentActionResult::AlreadyGranted.as_str(), + "alreadyGranted" + ); + assert_eq!(ConsentActionResult::PolicyBlocked.as_str(), "policyBlocked"); + assert_eq!(ConsentActionResult::NotApplicable.as_str(), "notApplicable"); + // `ConsentStatusReason` intentionally retains the established + // kebab-case binding contract. + assert_eq!( + ConsentStatusReason::PromptVersionUnsupported.as_str(), + "prompt-version-unsupported" + ); + } +} diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 208736225..f13a4b5ed 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -588,6 +588,11 @@ impl WslcSection { /// Cross-platform sandbox policy — the Rust analogue of the SDK /// `SandboxPolicy`. Describes *what* to restrict; omitted fields are /// most-restrictive (default-deny). +/// +/// Telemetry is intentionally not a policy field. It is invocation +/// instrumentation rather than a sandbox restriction, matching the global +/// sandbox-policy design. Build the request first, then use +/// [`SandboxRequest::set_telemetry_opt_in`] to opt that invocation in. #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxPolicy { @@ -703,13 +708,21 @@ impl SandboxRequest { /// Enabling this per-request switch is necessary but not sufficient: /// telemetry still requires persisted user consent and an administrative /// policy that permits collection. It is independent of experimental mode. - pub fn set_telemetry_enabled(&mut self, enabled: bool) -> &mut Self { + pub fn set_telemetry_opt_in(&mut self, enabled: bool) -> &mut Self { self.inner.telemetry = Some(TelemetryConfig { enabled: Some(enabled), requested_sandbox_kind: Some(self.requested_sandbox_kind), }); self } + + /// Return the explicit per-request telemetry switch for this invocation. + pub fn telemetry_enabled(&self) -> Option { + self.inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled) + } } /// Build a [`SandboxRequest`] from a [`SandboxPolicy`], resolving the host's @@ -762,11 +775,7 @@ pub fn build_request_with_containment( containment: &Containment, container_name: Option<&str>, ) -> Result { - // The shared parser tolerates an empty schema version (treats it as - // "unset"), but the SDK requires it; reject it here for parity. - if policy.version.is_empty() { - return Err(MxcError::malformed_request("Policy version is required").into()); - } + require_sdk_policy_version(policy)?; let config = build_wire_config(policy, containment, container_name)?; let mut logger = Logger::new(Mode::Buffer); @@ -781,6 +790,15 @@ pub fn build_request_with_containment( }) } +fn require_sdk_policy_version(policy: &SandboxPolicy) -> Result<(), crate::Error> { + // The config parser treats an empty version as "unset", but the public SDK + // policy contract requires one. + if policy.version.is_empty() { + return Err(MxcError::malformed_request("Policy version is required").into()); + } + Ok(()) +} + /// Construct the wire-format `ContainerConfig` JSON value for the supported /// backends, mirroring `createConfigFromPolicy` + the per-backend builders. pub(crate) fn build_wire_config( @@ -788,6 +806,15 @@ pub(crate) fn build_wire_config( containment: &Containment, container_name: Option<&str>, ) -> Result { + build_wire_config_with_network_format(policy, containment, container_name) + .map(|(config, _)| config) +} + +fn build_wire_config_with_network_format( + policy: &SandboxPolicy, + containment: &Containment, + container_name: Option<&str>, +) -> Result<(serde_json::Value, NetworkFormat), MxcError> { use serde_json::json; let container_id = container_name @@ -943,7 +970,7 @@ pub(crate) fn build_wire_config( config["containment"] = serde_json::json!("isolation_session"); } } - Ok(config) + Ok((config, network_format)) } /// Apply backend-specific fields, resolving the abstract `Process` intent the @@ -1635,15 +1662,8 @@ mod tests { assert!(request.inner.telemetry.is_none()); assert!(!request.inner.experimental_enabled); - request.set_telemetry_enabled(true); - assert_eq!( - request - .inner - .telemetry - .as_ref() - .and_then(|telemetry| telemetry.enabled), - Some(true) - ); + request.set_telemetry_opt_in(true); + assert_eq!(request.telemetry_enabled(), Some(true)); assert_eq!( request .inner @@ -1657,15 +1677,8 @@ mod tests { "stable telemetry enablement must not opt into experimental features" ); - request.set_telemetry_enabled(false); - assert_eq!( - request - .inner - .telemetry - .as_ref() - .and_then(|telemetry| telemetry.enabled), - Some(false) - ); + request.set_telemetry_opt_in(false); + assert_eq!(request.telemetry_enabled(), Some(false)); assert!(!request.inner.experimental_enabled); } @@ -1679,7 +1692,7 @@ mod tests { ) .expect("build_request_with_containment"); - request.set_telemetry_enabled(true); + request.set_telemetry_opt_in(true); assert_eq!( request diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index f50c70741..091b4b4e5 100644 --- a/src/core/wxc_common/src/telemetry/consent.rs +++ b/src/core/wxc_common/src/telemetry/consent.rs @@ -173,10 +173,10 @@ impl ConsentState { /// Whether this consent state represents an absent user decision. /// - /// This is deliberately private and policy-blind. Host applications must - /// use [`needs_consent_prompt`], which also suppresses the prompt under an - /// administrative block. - fn needs_prompt(&self) -> bool { + /// Policy-blind: this only reflects the stored consent state. Host + /// applications must use [`needs_consent_prompt`], which also suppresses + /// the prompt under an administrative block. + pub fn needs_prompt(&self) -> bool { matches!(self, ConsentState::Undetermined) } } diff --git a/src/core/wxc_common/src/telemetry/correlation_vector.rs b/src/core/wxc_common/src/telemetry/correlation_vector.rs index 64d47dc8b..0e921d473 100644 --- a/src/core/wxc_common/src/telemetry/correlation_vector.rs +++ b/src/core/wxc_common/src/telemetry/correlation_vector.rs @@ -522,7 +522,6 @@ mod tests { let spun = spin(&parent); assert!(spun.starts_with(&format!("{parent}."))); assert!(spun.ends_with(".0")); - // parent had 2 parts (base, 0); spin adds the spin element and a fresh 0. // parent had 2 parts; v2.1 spin adds counter, entropy, and fresh 0. assert_eq!(spun.split('.').count(), 5); assert!(is_valid(&spun)); diff --git a/src/core/wxc_common/src/telemetry/mod.rs b/src/core/wxc_common/src/telemetry/mod.rs index 74a83d35e..ecd1119cb 100644 --- a/src/core/wxc_common/src/telemetry/mod.rs +++ b/src/core/wxc_common/src/telemetry/mod.rs @@ -58,33 +58,6 @@ impl FailureReporter { } } -#[cfg(any(test, all(feature = "test-support", debug_assertions)))] -pub mod test_support { - use super::consent::test_support::LocalAppDataGuard; - use super::policy::test_support::PolicyKeyGuard; - - /// Lock-order-safe redirect guard for tests that need both the consent - /// store and the policy key redirected away from real user/machine state. - pub struct TelemetryTestEnv { - _consent: LocalAppDataGuard, - policy: PolicyKeyGuard, - } - - impl TelemetryTestEnv { - /// Redirect both telemetry globals for the lifetime of the guard. - pub fn new(store: &std::path::Path) -> Self { - let policy = PolicyKeyGuard::new(); - let _consent = LocalAppDataGuard::set(store); - Self { _consent, policy } - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub fn set_policy_value(&self, value: u32) { - self.policy.set_value(value); - } - } -} - /// Conventional process exit code for a Rust panic/abort. Used as the reported /// `exit_code` on crash telemetry, since the panicking process has not (and /// will not) produce a real [`ScriptResponse`]. @@ -1082,6 +1055,52 @@ pub fn emit_sdk_cancellation_with_kind( }); } +#[cfg(any(test, all(feature = "test-support", debug_assertions)))] +pub mod test_support { + use super::consent::test_support::LocalAppDataGuard; + use super::policy::test_support::PolicyKeyGuard; + + /// A fully isolated telemetry environment: both the administrative policy + /// key and the user consent store are redirected to throwaway, per-test + /// locations. + /// + /// This is the **only** supported way to hold both guards at once. They + /// protect separate process-global mutexes, so acquiring them in + /// inconsistent orders across tests would deadlock under `cargo test`'s + /// multithreaded runner. Constructing them here — policy first, then + /// consent — is what establishes the total order that makes the pair + /// deadlock-free, and a caller cannot get it wrong because a caller never + /// sees the individual acquisitions. + /// + /// Every test that reaches [`super::is_enabled`], + /// [`super::consent::needs_consent_prompt`], or [`super::policy::get_policy`] + /// must hold this — *including* tests that only care about consent. + /// Otherwise they read the real machine policy and fail on an + /// administratively managed device. + pub struct TelemetryTestEnv { + // Fields drop in declaration order, so consent is released before + // policy: the exact reverse of the acquisition order below. + _consent: LocalAppDataGuard, + policy: PolicyKeyGuard, + } + + impl TelemetryTestEnv { + /// Redirects the consent store to `store` and the policy key to a + /// fresh, empty one (i.e. an unmanaged machine). + pub fn new(store: &std::path::Path) -> Self { + let policy = PolicyKeyGuard::new(); + let _consent = LocalAppDataGuard::set(store); + Self { _consent, policy } + } + + /// Sets the administrative `AllowTelemetry` policy value. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub fn set_policy_value(&self, value: u32) { + self.policy.set_value(value); + } + } +} + #[cfg(test)] mod tests { use super::test_support::TelemetryTestEnv; diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index 53d140471..0e37150ea 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -14,8 +14,10 @@ crate-type = ["cdylib", "staticlib", "lib"] [dependencies] mxc-sdk = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_ignored = "0.1" serde_json = { workspace = true } +semver = "1" [features] # WSL Container backend (Windows only, experimental). Both sidecars resolve @@ -32,6 +34,15 @@ isolation_session = ["mxc-sdk/isolation_session"] # gate (scripts/check-dotnet-bindings-codegen.js) builds with this feature to # regenerate the committed bindings. dotnetsdk = ["dep:csbindgen"] +# Exposes the debug-only telemetry test seams to language-binding tests. +test-support = ["mxc-sdk/test-support"] + +[dev-dependencies] +# Gives the FFI tests the policy-key redirector, so they can assert the exact +# string this layer marshals for each administrative policy state rather than +# accepting whatever the host machine's real policy happens to produce. +wxc_common = { workspace = true, features = ["test-support"] } +tempfile.workspace = true [[example]] name = "attached_console_ffi" diff --git a/src/ffi/mxc_ffi/src/error_detail.rs b/src/ffi/mxc_ffi/src/error_detail.rs index ad4ce38b1..4ae03f994 100644 --- a/src/ffi/mxc_ffi/src/error_detail.rs +++ b/src/ffi/mxc_ffi/src/error_detail.rs @@ -108,10 +108,12 @@ pub unsafe extern "C" fn mxc_error_detail_free(detail: *mut MxcErrorDetail) { if detail.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(panic) = catch_unwind(|| { // SAFETY: non-null per the check above, and valid per the caller contract. unsafe { (*detail).free_strings() }; - }); + }) { + crate::report_panic("mxc_error_detail_free", &*panic); + } } #[cfg(test)] diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 0feced050..d4c376375 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -3,17 +3,14 @@ //! C ABI over the MXC public Rust SDK ([`mxc_sdk`]). //! -//! This is the flat, panic-safe C surface that language bindings (currently the -//! C# SDK) load. It spans three surfaces: +//! This is the flat, panic-safe C surface loaded by language bindings. //! -//! - **Run to completion** — [`mxc_run_request`] accepts the complete binding -//! request; [`mxc_run`] is the policy + command compatibility entry point. +//! - **Run to completion** — [`mxc_run_request`] accepts a binding request. //! - **Host discovery** — [`mxc_available_backends_json`] reports every //! host-available backend, while [`mxc_platform_support_json`] reports the //! subset this SDK can launch. //! - **Streaming** (`streaming` module) — [`mxc_spawn_request`] accepts the -//! complete binding request and returns an opaque live handle; -//! [`mxc_spawn`] is the compatibility entry point. +//! same binding request and returns an opaque live handle. //! - **State-aware lifecycle** (`state_aware` module) — [`mxc_state_aware`] //! drives the envelope phases (provision / start / stop / deprovision), and //! [`mxc_state_aware_exec`] runs the exec phase as a live streaming handle @@ -37,30 +34,27 @@ //! ([`MXC_STATUS_PANIC`]), never an unwind across the boundary. //! - **Data contract**: JSON in, captured bytes + status out. The status codes //! mirror `mxc_sdk::ErrorCode` one-for-one (plus a few FFI-local codes). +//! - **Per-invocation telemetry opt-in**: request JSON uses +//! `policy.telemetry.enabled`. +//! - **Telemetry consent** — [`mxc_telemetry_get_consent`], +//! [`mxc_telemetry_get_consent_status`], [`mxc_telemetry_request_consent`], +//! [`mxc_telemetry_withdraw_consent`], [`mxc_telemetry_needs_consent_prompt`], +//! and [`mxc_telemetry_get_policy`] expose the consent and policy surface to +//! language bindings. //! //! ## ABI stability //! -//! **This C ABI is not (yet) a stable external contract.** The native library -//! and every binding that loads it (currently the C# SDK) are built and -//! versioned **together** from this repository at the same workspace version, -//! and the C# P/Invoke layer is *generated* from this surface by csbindgen (a -//! CI drift gate keeps the two in lockstep). Because both halves always ship -//! together, this surface is free to evolve — entry points may be added, and -//! the layout of `#[repr(C)]` types such as [`MxcRunResult`] may change — -//! between releases **without** a compatibility shim, so long as the generated -//! binding is regenerated in the same change. Do not treat `mxc_ffi` as a -//! frozen ABI to link third-party consumers against; consume MXC through a -//! versioned binding (the C# SDK) matched to the same release. - -use std::ffi::{c_char, CStr, CString}; +//! This C ABI is versioned with the native library and generated bindings. It +//! is not a stable external ABI; regenerate bindings when this surface changes. + +use std::any::Any; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::io::{self, Write}; use std::panic::catch_unwind; use std::ptr; use std::sync::OnceLock; -use mxc_sdk::{ - available_backends, build_request, platform_support, run, ErrorCode, SandboxPolicy, - SandboxRequest, WaitOutcome, -}; +use mxc_sdk::{available_backends, platform_support, run, ErrorCode, SandboxRequest, WaitOutcome}; mod error_detail; mod request; @@ -70,13 +64,92 @@ pub use error_detail::*; pub use state_aware::*; pub use streaming::*; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_NO: i32 = 0; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_YES: i32 = 1; +/// Return code from an FFI telemetry-consent presenter callback. +pub const MXC_TELEMETRY_CONSENT_DECISION_DISMISSED: i32 = 2; +/// Return code indicating that the host presenter failed. +pub const MXC_TELEMETRY_CONSENT_PRESENTER_ERROR: i32 = -1; + +/// Host callback invoked synchronously with the canonical consent prompt JSON. +/// +/// The JSON pointer remains valid only for the duration of the callback. The +/// callback must return one of the `MXC_TELEMETRY_CONSENT_DECISION_*` values, +/// or [`MXC_TELEMETRY_CONSENT_PRESENTER_ERROR`] to signal presenter failure. +/// +/// # Safety +/// +/// **The callback must not unwind across the FFI boundary.** Every callback +/// or trampoline, including one written in Rust, must contain failures +/// internally and return +/// [`MXC_TELEMETRY_CONSENT_PRESENTER_ERROR`] instead of allowing the exception +/// or panic to propagate. +pub type MxcTelemetryConsentPresenter = + Option i32>; + +/// Write a diagnostic line to stderr without panicking. +fn report_to_stderr(args: std::fmt::Arguments<'_>) { + let stderr = io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_fmt(args); + let _ = handle.write_all(b"\n"); + let _ = handle.flush(); +} + +/// Report a panic caught at the FFI boundary. +fn report_panic(operation: &str, _payload: &(dyn Any + Send)) { + report_to_stderr(format_args!( + "mxc: internal error: panic caught at the FFI boundary in {operation}. \ + Failing closed; the calling process is unaffected." + )); +} + +fn report_diagnostic_once(flag: &OnceLock<()>, args: std::fmt::Arguments<'_>) { + if flag.set(()).is_ok() { + report_to_stderr(args); + } +} + +fn report_consent_persist_failure_once(flag: &OnceLock<()>, operation: &str) { + report_diagnostic_once( + flag, + format_args!( + "mxc: {operation} failed because telemetry consent could not be persisted. \ + Returning a fail-closed status without host-specific details." + ), + ); +} + +fn report_request_consent_persist_failure_once() { + static REQUEST_REPORTED: OnceLock<()> = OnceLock::new(); + report_consent_persist_failure_once(&REQUEST_REPORTED, "mxc_telemetry_request_consent"); +} + +fn report_withdraw_consent_persist_failure_once() { + static WITHDRAW_REPORTED: OnceLock<()> = OnceLock::new(); + report_consent_persist_failure_once(&WITHDRAW_REPORTED, "mxc_telemetry_withdraw_consent"); +} + +fn report_consent_presenter_failure_once() { + static REPORTED: OnceLock<()> = OnceLock::new(); + report_diagnostic_once( + &REPORTED, + format_args!( + "mxc: telemetry consent presenter failed. Returning MXC_STATUS_BACKEND_ERROR \ + without host-supplied details." + ), + ); +} + // --------------------------------------------------------------------------- // Status codes // --------------------------------------------------------------------------- /// Success. pub const MXC_STATUS_SUCCESS: i32 = 0; -// 1..=12 mirror `mxc_sdk::ErrorCode` (kept in lockstep with a CI drift gate). +// 1..=12 mirror `mxc_sdk::ErrorCode`. /// The request/policy was malformed. pub const MXC_STATUS_MALFORMED_REQUEST: i32 = 1; /// The requested containment backend is not supported by this library. @@ -109,6 +182,13 @@ pub const MXC_STATUS_NULL_ARGUMENT: i32 = 100; pub const MXC_STATUS_INVALID_UTF8: i32 = 101; /// The Rust side panicked; the panic was caught at the boundary. pub const MXC_STATUS_PANIC: i32 = 102; +/// Telemetry consent could not be persisted (for example `%LOCALAPPDATA%` +/// unavailable/unwritable on Windows). On non-Windows hosts consent actions +/// return a `"notApplicable"` outcome with [`MXC_STATUS_SUCCESS`], not this +/// status. +/// Consent reads do not return this status; an unexpected panic is reported as +/// [`MXC_STATUS_PANIC`]. +pub const MXC_STATUS_CONSENT_WRITE_FAILED: i32 = 103; /// Map an [`ErrorCode`] to its stable FFI status code. pub(crate) fn status_from_error_code(code: ErrorCode) -> i32 { @@ -132,7 +212,7 @@ pub(crate) fn status_from_error_code(code: ErrorCode) -> i32 { // Result struct // --------------------------------------------------------------------------- -/// The result of an [`mxc_run`] or [`mxc_run_request`] call. +/// The result of an [`mxc_run_request`] call. /// /// On success (`status == 0`), `exit_code` / `timed_out` describe how the /// process finished and `stdout_utf8` / `stderr_utf8` carry its captured output @@ -253,46 +333,9 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { // Entry points // --------------------------------------------------------------------------- -/// Run a sandbox to completion and capture its output. -/// -/// Parses `policy_json_utf8` as a `SandboxPolicy`, sets `command_utf8` as the -/// command to run (the `process.commandLine` equivalent), runs the sandbox to -/// completion, and writes the outcome into `*out`. -/// -/// Returns the resulting status code (also stored in `out->status`). Returns -/// [`MXC_STATUS_NULL_ARGUMENT`] without touching `*out` if `out` is null. -/// -/// # Safety -/// - `policy_json_utf8` and `command_utf8` must be null or valid NUL-terminated -/// UTF-8 C strings. -/// - `out` must be null or point to writable [`MxcRunResult`]-sized storage. -/// - On success the caller must release `*out` with [`mxc_run_result_free`]. -#[no_mangle] -pub unsafe extern "C" fn mxc_run( - policy_json_utf8: *const c_char, - command_utf8: *const c_char, - out: *mut MxcRunResult, -) -> i32 { - if out.is_null() { - return MXC_STATUS_NULL_ARGUMENT; - } - - let result = catch_unwind(|| run_inner(policy_json_utf8, command_utf8)) - .unwrap_or_else(|_| MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); - - let status = result.status; - // SAFETY: `out` is non-null and caller-guaranteed writable; ownership of the - // out-strings transfers to the caller (freed via `mxc_run_result_free`). - unsafe { ptr::write(out, result) }; - status -} - /// Run a complete one-shot request to completion and capture its output. /// -/// `request_json_utf8` describes the policy, command, containment backend, -/// container name, working directory, environment, and experimental opt-in. -/// The managed and native SDKs are co-versioned, so this JSON contract evolves -/// with them rather than becoming an independently stable C ABI. +/// `request_json_utf8` is the co-versioned binding request document. /// /// # Safety /// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. @@ -307,8 +350,10 @@ pub unsafe extern "C" fn mxc_run_request( return MXC_STATUS_NULL_ARGUMENT; } - let result = catch_unwind(|| run_request_inner(request_json_utf8)) - .unwrap_or_else(|_| MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); + let result = catch_unwind(|| run_request_inner(request_json_utf8)).unwrap_or_else(|panic| { + report_panic("mxc_run_request", &*panic); + MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") + }); let status = result.status; // SAFETY: `out` is non-null and caller-guaranteed writable. @@ -332,44 +377,6 @@ fn run_request_inner(request_json_utf8: *const c_char) -> MxcRunResult { execute_request(request) } -/// The bulk of [`mxc_run`], split out so the whole thing runs under -/// `catch_unwind`. Never panics deliberately; returns an error result instead. -fn run_inner(policy_json_utf8: *const c_char, command_utf8: *const c_char) -> MxcRunResult { - // SAFETY: caller contract on `mxc_run`; both are borrowed only within scope. - let policy_json = match unsafe { cstr_to_str(policy_json_utf8) } { - Some(s) => s, - None if policy_json_utf8.is_null() => { - return MxcRunResult::error(MXC_STATUS_NULL_ARGUMENT, "policy JSON pointer is null") - } - None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "policy JSON is not UTF-8"), - }; - let command = match unsafe { cstr_to_str(command_utf8) } { - Some(s) => s, - None if command_utf8.is_null() => { - return MxcRunResult::error(MXC_STATUS_NULL_ARGUMENT, "command pointer is null") - } - None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "command is not UTF-8"), - }; - - let policy: SandboxPolicy = match serde_json::from_str(policy_json) { - Ok(p) => p, - Err(e) => { - return MxcRunResult::error( - MXC_STATUS_MALFORMED_REQUEST, - format!("failed to parse policy JSON: {e}"), - ) - } - }; - - let mut request = match build_request(&policy, None) { - Ok(r) => r, - Err(e) => return MxcRunResult::from_sdk_error(&e), - }; - request.set_script(command); - - execute_request(request) -} - fn execute_request(request: SandboxRequest) -> MxcRunResult { match run(request) { Ok(output) => { @@ -423,7 +430,8 @@ fn execute_request(request: SandboxRequest) -> MxcRunResult { } } -/// Free the owned out-strings of an [`MxcRunResult`] produced by [`mxc_run`]. +/// Free the owned out-strings of an [`MxcRunResult`] produced by +/// [`mxc_run_request`]. /// /// Safe to call once per result. The result struct itself is caller-owned /// (typically stack storage); this frees only the heap strings it points to and @@ -431,16 +439,18 @@ fn execute_request(request: SandboxRequest) -> MxcRunResult { /// /// # Safety /// `r` must be null or point to an [`MxcRunResult`] previously filled by -/// [`mxc_run`], not already freed. +/// [`mxc_run_request`], not already freed. #[no_mangle] pub unsafe extern "C" fn mxc_run_result_free(r: *mut MxcRunResult) { if r.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(p) = catch_unwind(|| { // SAFETY: caller guarantees `r` points to a valid, not-yet-freed result. unsafe { (*r).free_strings() }; - }); + }) { + report_panic("mxc_run_result_free", &*p); + } } /// Free a single heap C string returned by this library. @@ -453,10 +463,12 @@ pub unsafe extern "C" fn mxc_string_free(s: *mut c_char) { if s.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(p) = catch_unwind(|| { let mut p = s; free_cstr(&mut p); - }); + }) { + report_panic("mxc_string_free", &*p); + } } /// Return every containment backend currently available on this host as JSON. @@ -467,7 +479,10 @@ pub unsafe extern "C" fn mxc_string_free(s: *mut c_char) { /// [`mxc_string_free`]. Returns null if probing panics or serialization fails. #[no_mangle] pub extern "C" fn mxc_available_backends_json() -> *mut c_char { - catch_unwind(|| serialize_owned_json(&available_backends())).unwrap_or(ptr::null_mut()) + catch_unwind(|| serialize_owned_json(&available_backends())).unwrap_or_else(|panic| { + report_panic("mxc_available_backends_json", &*panic); + ptr::null_mut() + }) } /// Return support for the backends this public SDK can launch as JSON. @@ -477,7 +492,10 @@ pub extern "C" fn mxc_available_backends_json() -> *mut c_char { /// probing panics or serialization fails. #[no_mangle] pub extern "C" fn mxc_platform_support_json() -> *mut c_char { - catch_unwind(|| serialize_owned_json(&platform_support())).unwrap_or(ptr::null_mut()) + catch_unwind(|| serialize_owned_json(&platform_support())).unwrap_or_else(|panic| { + report_panic("mxc_platform_support_json", &*panic); + ptr::null_mut() + }) } fn serialize_owned_json(value: &impl serde::Serialize) -> *mut c_char { @@ -504,49 +522,397 @@ pub extern "C" fn mxc_version() -> *const c_char { .get_or_init(|| CString::new(env!("CARGO_PKG_VERSION")).unwrap_or_default()) .as_ptr() }) - .unwrap_or(c"".as_ptr()) + .unwrap_or_else(|panic| { + report_panic("mxc_version", &*panic); + c"".as_ptr() + }) +} + +// --------------------------------------------------------------------------- +// Telemetry consent +// --------------------------------------------------------------------------- + +/// Read the telemetry consent state currently effective for authorization. +/// +/// On success, writes one of `"granted"`, `"denied"`, `"undetermined"`, or +/// `"not-applicable"` (non-Windows hosts) into +/// `*out_utf8` as a heap-allocated, NUL-terminated UTF-8 string. The caller +/// must free it with [`mxc_string_free`]. Call +/// [`mxc_telemetry_get_consent_status`] and read `storedState` when the +/// persisted decision is required. +/// +/// After any non-success return with a non-null `out_utf8`, `*out_utf8` is +/// null and must not be freed. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_utf8` if `out_utf8` is null. +/// +/// # Safety +/// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_consent(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. Clear it + // before any fallible work so every subsequent failure path (including a + // caught panic) leaves the caller with a well-defined null pointer rather + // than a stale/uninitialized value. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + + let result = catch_unwind(|| mxc_sdk::telemetry::get_consent().as_str()); + let state_str = match result { + Ok(s) => s, + Err(p) => { + report_panic("mxc_telemetry_get_consent", &*p); + return MXC_STATUS_PANIC; + } + }; + + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, alloc_cstring(state_str.as_bytes())) }; + MXC_STATUS_SUCCESS +} + +fn consent_status_json( + status: mxc_sdk::telemetry::ConsentStatus, + policy: mxc_sdk::telemetry::PolicyState, +) -> serde_json::Value { + serde_json::json!({ + "storedState": status.stored_state.as_str(), + "effectiveState": status.effective_state.as_str(), + "reason": status.reason.map(|reason| reason.as_str()), + "policy": policy.as_str(), + }) +} + +fn consent_outcome_json(outcome: mxc_sdk::telemetry::ConsentActionOutcome) -> serde_json::Value { + let mut value = consent_status_json(outcome.status, outcome.policy); + value["result"] = serde_json::Value::String(outcome.result.as_str().to_string()); + value +} + +fn consent_prompt_json(prompt: &mxc_sdk::telemetry::ConsentPrompt) -> serde_json::Value { + fn message(value: mxc_sdk::telemetry::ConsentMessage) -> serde_json::Value { + serde_json::json!({ "id": value.id, "text": value.text }) + } + + serde_json::json!({ + "resourceVersion": prompt.resource_version, + "locale": prompt.locale, + "title": message(prompt.title), + "body": message(prompt.body), + "affirmativeLabel": message(prompt.affirmative_label), + "negativeLabel": message(prompt.negative_label), + "learnMoreLabel": message(prompt.learn_more_label), + "learnMoreUrl": prompt.learn_more_url, + }) +} + +unsafe fn write_json_out(value: serde_json::Value, out_utf8: *mut *mut c_char) -> i32 { + let bytes = match serde_json::to_vec(&value) { + Ok(bytes) => bytes, + Err(error) => { + report_to_stderr(format_args!( + "mxc: failed to serialize telemetry consent result: {error}" + )); + return MXC_STATUS_BACKEND_ERROR; + } + }; + // SAFETY: caller guarantees that the non-null out pointer is writable. + unsafe { ptr::write(out_utf8, alloc_cstring(&bytes)) }; + MXC_STATUS_SUCCESS +} + +/// Request telemetry consent through a host presenter callback. +/// +/// On success, `*out_utf8` owns a heap-allocated, NUL-terminated JSON string; +/// the caller must free it with [`mxc_string_free`]. After any non-success +/// return with a non-null `out_utf8`, `*out_utf8` is null and must not be +/// freed. +/// On non-Windows hosts this returns a successful `"notApplicable"` outcome +/// without invoking the presenter. +/// +/// # Safety +/// `locale_utf8` must be null or valid NUL-terminated UTF-8. `presenter` must +/// be a valid callback when non-null. `context` is passed through untouched. +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_request_consent( + locale_utf8: *const c_char, + presenter: MxcTelemetryConsentPresenter, + context: *mut c_void, + out_utf8: *mut *mut c_char, +) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // Every failure path below (including a caught panic) must leave the + // out-pointer in a well-defined null state, not the caller's prior value. + // Do this before any other work. + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + if presenter.is_none() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: caller contract above. + let locale = match unsafe { cstr_to_str(locale_utf8) } { + Some(value) => Some(value.to_string()), + None if locale_utf8.is_null() => None, + None => return MXC_STATUS_INVALID_UTF8, + }; + let presenter = presenter.expect("checked above"); + + let result = catch_unwind(|| { + mxc_sdk::telemetry::request_consent(locale.as_deref(), |prompt| { + let prompt_json = serde_json::to_vec(&consent_prompt_json(prompt)) + .map_err(|error| error.to_string())?; + let prompt_json = CString::new(prompt_json).map_err(|error| error.to_string())?; + // SAFETY: the host supplied this callback and context. The prompt + // pointer remains valid for the duration of this invocation. + let decision = unsafe { presenter(prompt_json.as_ptr(), context) }; + match decision { + MXC_TELEMETRY_CONSENT_DECISION_YES => Ok(mxc_sdk::telemetry::ConsentDecision::Yes), + MXC_TELEMETRY_CONSENT_DECISION_NO => Ok(mxc_sdk::telemetry::ConsentDecision::No), + MXC_TELEMETRY_CONSENT_DECISION_DISMISSED => { + Ok(mxc_sdk::telemetry::ConsentDecision::Dismissed) + } + MXC_TELEMETRY_CONSENT_PRESENTER_ERROR => { + Err("host consent presenter failed".to_string()) + } + value => Err(format!( + "host consent presenter returned invalid decision {value}" + )), + } + }) + }); + + match result { + Ok(Ok(outcome)) => { + // SAFETY: validated above. + unsafe { write_json_out(consent_outcome_json(outcome), out_utf8) } + } + Ok(Err(mxc_sdk::telemetry::ConsentError::Persist(error))) => { + let _ = error; + report_request_consent_persist_failure_once(); + MXC_STATUS_CONSENT_WRITE_FAILED + } + Ok(Err(mxc_sdk::telemetry::ConsentError::Presenter(error))) => { + let _ = error; + report_consent_presenter_failure_once(); + MXC_STATUS_BACKEND_ERROR + } + Err(panic) => { + report_panic("mxc_telemetry_request_consent", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Persist an idempotent telemetry-consent withdrawal. +/// +/// On non-Windows hosts this returns a `"notApplicable"` JSON outcome with +/// [`MXC_STATUS_SUCCESS`]. +/// +/// On success, `*out_utf8` owns a heap-allocated, NUL-terminated JSON string; +/// the caller must free it with [`mxc_string_free`]. After any non-success +/// return with a non-null `out_utf8`, `*out_utf8` is null and must not be +/// freed. +/// +/// # Safety +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_withdraw_consent(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // Every failure path below (including a caught panic) must leave the + // out-pointer in a well-defined null state, not the caller's prior value. + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + match catch_unwind(mxc_sdk::telemetry::withdraw_consent) { + Ok(Ok(outcome)) => { + // SAFETY: validated above. + unsafe { write_json_out(consent_outcome_json(outcome), out_utf8) } + } + Ok(Err(error)) => { + let _ = error; + report_withdraw_consent_persist_failure_once(); + MXC_STATUS_CONSENT_WRITE_FAILED + } + Err(panic) => { + report_panic("mxc_telemetry_withdraw_consent", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Return the typed persisted/effective consent and policy snapshot as JSON. +/// +/// On success, `*out_utf8` owns a heap-allocated, NUL-terminated JSON string; +/// the caller must free it with [`mxc_string_free`]. After any non-success +/// return with a non-null `out_utf8`, `*out_utf8` is null and must not be +/// freed. +/// +/// # Safety +/// `out_utf8` must point to writable pointer-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_consent_status(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // Every failure path below (including a caught panic) must leave the + // out-pointer in a well-defined null state, not the caller's prior value. + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + let result = catch_unwind(|| { + consent_status_json( + mxc_sdk::telemetry::get_consent_status(), + mxc_sdk::telemetry::get_policy(), + ) + }); + match result { + Ok(value) => { + // SAFETY: validated above. + unsafe { write_json_out(value, out_utf8) } + } + Err(panic) => { + report_panic("mxc_telemetry_get_consent_status", &*panic); + MXC_STATUS_PANIC + } + } +} + +/// Whether a hosting application should offer its first-run consent prompt. +/// +/// Writes `1` or `0` into `*out_needs_prompt`. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_needs_prompt` if it is null. +/// +/// # Safety +/// `out_needs_prompt` must be null or point to writable `i32`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_needs_consent_prompt(out_needs_prompt: *mut i32) -> i32 { + if out_needs_prompt.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_needs_prompt` is non-null and caller-guaranteed writable. + // Zero it before any fallible work so a caught panic still leaves the + // caller with well-defined, non-garbage memory. + unsafe { ptr::write(out_needs_prompt, 0) }; + + let needs_prompt = match catch_unwind(mxc_sdk::telemetry::needs_consent_prompt) { + Ok(b) => b, + Err(p) => { + report_panic("mxc_telemetry_needs_consent_prompt", &*p); + return MXC_STATUS_PANIC; + } + }; + + // SAFETY: `out_needs_prompt` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_needs_prompt, i32::from(needs_prompt)) }; + MXC_STATUS_SUCCESS +} + +/// Read the administrative (MDM / Group Policy) telemetry policy. +/// +/// On success, writes one of `"unrestricted"` (no policy configured), +/// `"allowed"`, `"blocked"`, or `"not-applicable"` (non-Windows hosts) into +/// `*out_utf8` as a heap-allocated, NUL-terminated UTF-8 string. The caller +/// must free it with [`mxc_string_free`]. +/// +/// After any non-success return with a non-null `out_utf8`, `*out_utf8` is +/// null and must not be freed. +/// +/// `"allowed"` does not grant user consent; `"blocked"` suppresses collection +/// and the consent prompt. +/// +/// Returns [`MXC_STATUS_SUCCESS`] on success, or [`MXC_STATUS_NULL_ARGUMENT`] +/// without touching `*out_utf8` if `out_utf8` is null. +/// +/// # Safety +/// `out_utf8` must be null or point to writable `*mut c_char`-sized storage. +#[no_mangle] +pub unsafe extern "C" fn mxc_telemetry_get_policy(out_utf8: *mut *mut c_char) -> i32 { + if out_utf8.is_null() { + return MXC_STATUS_NULL_ARGUMENT; + } + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. Clear it + // before any fallible work so a caught panic still leaves the caller with + // a well-defined null pointer rather than a stale/uninitialized value. + unsafe { ptr::write(out_utf8, ptr::null_mut()) }; + + let result = catch_unwind(|| mxc_sdk::telemetry::get_policy().as_str()); + let state_str = match result { + Ok(s) => s, + Err(p) => { + report_panic("mxc_telemetry_get_policy", &*p); + return MXC_STATUS_PANIC; + } + }; + + // SAFETY: `out_utf8` is non-null and caller-guaranteed writable. + unsafe { ptr::write(out_utf8, alloc_cstring(state_str.as_bytes())) }; + MXC_STATUS_SUCCESS } #[cfg(test)] mod tests { use super::*; - fn run_with(policy_json: &str, command: Option<&str>) -> MxcRunResult { - let policy = CString::new(policy_json).unwrap(); - let command = command.map(|c| CString::new(c).unwrap()); - let command_ptr = command.as_ref().map(|c| c.as_ptr()).unwrap_or(ptr::null()); + fn run_with(request_json: &str) -> MxcRunResult { + let request = CString::new(request_json).unwrap(); let mut out = MxcRunResult::empty(); - // SAFETY: valid CStrings and a valid out pointer. - let status = unsafe { mxc_run(policy.as_ptr(), command_ptr, &mut out) }; + // SAFETY: a valid CString and out pointer. + let status = unsafe { mxc_run_request(request.as_ptr(), &mut out) }; assert_eq!(status, out.status); out } #[test] - fn malformed_policy_json_reports_malformed_request() { - let mut out = run_with("{ not json", Some("echo hi")); - assert_eq!(out.status, MXC_STATUS_MALFORMED_REQUEST); - assert!(!out.error.message_utf8.is_null()); - assert!(out.stdout_utf8.is_null()); - // SAFETY: `out` was filled by `mxc_run`. - unsafe { mxc_run_result_free(&mut out) }; - assert!(out.error.message_utf8.is_null()); + fn shared_request_builder_propagates_telemetry_enablement() { + for (request_json, expected) in [ + ( + r#"{"policy":{"version":"0.8.0-alpha"},"command":"echo hi"}"#, + None, + ), + ( + r#"{"policy":{"version":"0.9.0-alpha","telemetry":{"enabled":true}},"command":"echo hi"}"#, + Some(true), + ), + ( + r#"{"policy":{"version":"0.9.0-alpha","telemetry":{"enabled":false}},"command":"echo hi"}"#, + Some(false), + ), + ] { + let request = request::build_request_from_json(request_json) + .unwrap_or_else(|_| panic!("request builder failed for {request_json}")); + assert_eq!( + request.telemetry_enabled(), + expected, + "request: {request_json}" + ); + } } #[test] - fn null_command_reports_null_argument() { - let mut out = run_with(r#"{"version":"0.7.0-alpha"}"#, None); - assert_eq!(out.status, MXC_STATUS_NULL_ARGUMENT); + fn malformed_request_json_reports_malformed_request() { + let mut out = run_with("{ not json"); + assert_eq!(out.status, MXC_STATUS_MALFORMED_REQUEST); assert!(!out.error.message_utf8.is_null()); + assert!(out.stdout_utf8.is_null()); + // SAFETY: `out` was filled by `mxc_run_request`. unsafe { mxc_run_result_free(&mut out) }; + assert!(out.error.message_utf8.is_null()); } #[test] fn null_out_pointer_reports_null_argument_without_leaking() { - let policy = CString::new(r#"{"version":"0.7.0-alpha"}"#).unwrap(); - let command = CString::new("echo hi").unwrap(); - // SAFETY: valid strings, deliberately-null out pointer. - let status = unsafe { mxc_run(policy.as_ptr(), command.as_ptr(), ptr::null_mut()) }; + let request = + CString::new(r#"{"policy":{"version":"0.7.0-alpha"},"command":"echo hi"}"#).unwrap(); + // SAFETY: valid string, deliberately-null out pointer. + let status = unsafe { mxc_run_request(request.as_ptr(), ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -655,21 +1021,550 @@ mod tests { result.free_strings(); } - /// An empty version parses as JSON but fails `build_request`, which is the - /// arm that carries an SDK error rather than a message this library wrote. - /// The message is asserted because the JSON-parse arm returns the same - /// status. + /// A structurally valid binding document with no command reaches the SDK + /// error path rather than failing in the FFI string boundary. #[test] fn a_failing_build_request_reports_the_sdk_error() { - let mut out = run_with(r#"{"version":""}"#, Some("echo hi")); + let mut out = run_with(r#"{"policy":{"version":""},"command":"echo hi"}"#); assert_eq!(out.status, MXC_STATUS_MALFORMED_REQUEST); - // SAFETY: `out` was filled by `mxc_run`. + // SAFETY: `out` was filled by `mxc_run_request`. let message = unsafe { CStr::from_ptr(out.error.message_utf8) } .to_str() .unwrap() .to_string(); assert_eq!(message, "Policy version is required"); - // SAFETY: `out` was filled by `mxc_run`. + // SAFETY: `out` was filled by `mxc_run_request`. unsafe { mxc_run_result_free(&mut out) }; } + + // ----------------------------------------------------------------- + // Telemetry consent + // ----------------------------------------------------------------- + // + // Telemetry overrides are debug-only, so these tests are excluded from + // release builds rather than reading the host's real consent and policy + // state. `CONSENT_ENV_LOCK` and `TelemetryTestEnv`'s policy lock serialize + // the process-global overrides; unrelated request tests remain parallel. + #[cfg(debug_assertions)] + struct TelemetryTestEnv { + _dir: tempfile::TempDir, + _env: wxc_common::telemetry::test_support::TelemetryTestEnv, + } + + #[cfg(debug_assertions)] + impl TelemetryTestEnv { + fn new(_label: &str) -> Self { + let dir = tempfile::tempdir().expect("create temp dir"); + let env = wxc_common::telemetry::test_support::TelemetryTestEnv::new(dir.path()); + Self { + _dir: dir, + _env: env, + } + } + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_reports_string_and_never_errors() { + let _guard = TelemetryTestEnv::new("get_default"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + #[cfg(target_os = "windows")] + assert_eq!(s, "undetermined"); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + // SAFETY: `out` was allocated via `alloc_cstring`/`CString::into_raw`. + unsafe { mxc_string_free(out) }; + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("get_null_out"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_consent(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[cfg(debug_assertions)] + #[test] + fn presenter_request_then_get_consent_round_trips() { + let _guard = TelemetryTestEnv::new("round_trip"); + unsafe extern "C" fn presenter( + prompt_json_utf8: *const c_char, + context: *mut c_void, + ) -> i32 { + // SAFETY: the FFI request provides valid pointers for this call. + let prompt = unsafe { CStr::from_ptr(prompt_json_utf8) } + .to_str() + .unwrap(); + let prompt: serde_json::Value = serde_json::from_str(prompt).unwrap(); + let canonical = wxc_common::telemetry::consent_prompt::prompt_for_locale(Some("en-US")); + assert_eq!(prompt["resourceVersion"], canonical.resource_version); + assert_eq!(prompt["locale"], canonical.locale); + assert_eq!(prompt["title"]["id"], canonical.title.id); + assert_eq!(prompt["title"]["text"], canonical.title.text); + assert_eq!(prompt["body"]["id"], canonical.body.id); + assert_eq!(prompt["body"]["text"], canonical.body.text); + assert_eq!( + prompt["affirmativeLabel"]["text"], + canonical.affirmative_label.text + ); + assert_eq!( + prompt["negativeLabel"]["text"], + canonical.negative_label.text + ); + assert_eq!( + prompt["learnMoreLabel"]["text"], + canonical.learn_more_label.text + ); + assert_eq!(prompt["learnMoreUrl"], canonical.learn_more_url); + // SAFETY: the test passes a pointer to this bool as context. + unsafe { *(context as *mut bool) = true }; + MXC_TELEMETRY_CONSENT_DECISION_YES + } + + let mut called = false; + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback, context, and out pointer remain valid for the call. + let request_status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(presenter), + (&mut called as *mut bool).cast(), + &mut outcome, + ) + }; + assert_eq!(request_status, MXC_STATUS_SUCCESS); + assert!(!outcome.is_null()); + // SAFETY: allocated by the request export. + let outcome_json = unsafe { CStr::from_ptr(outcome) }.to_str().unwrap(); + let outcome_json: serde_json::Value = serde_json::from_str(outcome_json).unwrap(); + unsafe { mxc_string_free(outcome) }; + + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let get_status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(get_status, MXC_STATUS_SUCCESS); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert!(called); + assert_eq!(outcome_json["result"], "granted"); + assert_eq!(s, "granted"); + } + #[cfg(not(target_os = "windows"))] + { + assert!(!called); + assert_eq!(outcome_json["result"], "notApplicable"); + assert_eq!(s, "not-applicable"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn request_consent_requires_a_presenter() { + let _guard = TelemetryTestEnv::new("null_presenter"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: null presenter is explicitly rejected. + let status = + unsafe { mxc_telemetry_request_consent(ptr::null(), None, ptr::null_mut(), &mut out) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + assert!(out.is_null()); + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_tracks_the_store() { + let _guard = TelemetryTestEnv::new("needs_prompt"); + let mut needs: i32 = -1; + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + // Fresh store on Windows must prompt; off Windows nothing is collected + // so nothing may be asked. + #[cfg(target_os = "windows")] + assert_eq!(needs, 1); + #[cfg(not(target_os = "windows"))] + assert_eq!(needs, 0); + + #[cfg(target_os = "windows")] + { + unsafe extern "C" fn deny( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + MXC_TELEMETRY_CONSENT_DECISION_NO + } + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback and out pointer remain valid for the call. + assert_eq!( + unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(deny), + ptr::null_mut(), + &mut outcome, + ) + }, + MXC_STATUS_SUCCESS + ); + // SAFETY: allocated by the request export. + unsafe { mxc_string_free(outcome) }; + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert_eq!(needs, 0, "a recorded denial must not re-prompt"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("needs_prompt_null"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_needs_consent_prompt(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[cfg(debug_assertions)] + #[test] + fn withdraw_consent_reports_success_and_is_idempotent() { + let _guard = TelemetryTestEnv::new("withdraw_idempotent"); + + for _ in 0..2 { + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_withdraw_consent`. + let outcome_json = unsafe { CStr::from_ptr(out) }.to_str().unwrap(); + let outcome_json: serde_json::Value = serde_json::from_str(outcome_json).unwrap(); + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert_eq!(outcome_json["result"], "withdrawn"); + assert_eq!(outcome_json["effectiveState"], "denied"); + assert_eq!(outcome_json["storedState"], "denied"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(outcome_json["result"], "notApplicable"); + assert_eq!(outcome_json["effectiveState"], "not-applicable"); + assert_eq!(outcome_json["storedState"], "not-applicable"); + } + } + } + + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn withdraw_consent_write_failure_maps_to_status_103_with_null_output() { + let dir = tempfile::tempdir().unwrap(); + let bogus_localappdata = dir.path().join("localappdata-file"); + std::fs::write(&bogus_localappdata, b"not a directory").unwrap(); + let _guard = + wxc_common::telemetry::test_support::TelemetryTestEnv::new(&bogus_localappdata); + + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_CONSENT_WRITE_FAILED); + assert!(out.is_null()); + } + + /// Verifies the export marshals a valid state string that the caller can + /// free. Policy semantics are tested in `wxc_common::telemetry::policy`. + #[test] + fn get_policy_returns_a_valid_state_string() { + let s = read_policy_string(); + #[cfg(target_os = "windows")] + assert!( + ["unrestricted", "allowed", "blocked"].contains(&s.as_str()), + "unexpected policy state {s:?}" + ); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + } + + /// Calls the export and returns the marshalled string, freeing the + /// allocation. Shared by the policy tests below. + fn read_policy_string() -> String { + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_policy(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_policy`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + s + } + + /// Drives every administrative policy state through a redirected registry + /// key and asserts the marshaled string. The redirect is debug-only. + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn get_policy_marshals_the_exact_state_for_each_registry_value() { + use wxc_common::telemetry::policy::test_support::PolicyKeyGuard; + + let guard = PolicyKeyGuard::new(); + // No value set: an unmanaged machine. + assert_eq!(read_policy_string(), "unrestricted"); + + guard.set_value(3); + assert_eq!(read_policy_string(), "allowed"); + + for blocked in [0u32, 1, 2, 99, u32::MAX] { + guard.set_value(blocked); + assert_eq!( + read_policy_string(), + "blocked", + "value {blocked} must marshal as blocked" + ); + } + + // A wrong-typed value is a policy we cannot evaluate: it must fail + // closed all the way out through the ABI, not read as unmanaged. + guard.set_string_value("0"); + assert_eq!(read_policy_string(), "blocked"); + } + + #[test] + fn get_policy_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_policy(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + // Failure paths must clear caller-owned output pointers before validation + // or other fallible work. + + /// Return an obvious sentinel `*mut c_char` value that isn't null and + /// isn't a legal allocation from this library, so an accidental free + /// would be caught. We only ever *read* the pointer's value; the exports + /// under test must overwrite it with null before any failure path. + fn stale_out_sentinel() -> *mut c_char { + // Use an aligned, obviously-invalid address. Never dereferenced. + 0xDEAD_BEEF_usize as *mut c_char + } + + #[test] + fn request_consent_null_presenter_clears_stale_out_pointer() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable; + // null presenter is explicitly rejected. + let status = + unsafe { mxc_telemetry_request_consent(ptr::null(), None, ptr::null_mut(), &mut out) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + assert!( + out.is_null(), + "failure path must leave *out_utf8 null, not the caller's sentinel" + ); + } + + #[test] + fn request_consent_invalid_utf8_locale_clears_stale_out_pointer() { + // A presenter that would panic if invoked. The invalid-UTF-8 locale + // must be rejected before we ever reach the presenter, so this + // proves the failure path both returns the right status *and* null + // the out-pointer. + unsafe extern "C" fn never_called_presenter( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + panic!("presenter must not be invoked when locale rejection precedes it"); + } + + // A raw non-UTF-8 byte sequence with a valid NUL terminator. + let bad_locale = [0xffu8, 0xfe, 0xfd, 0x00]; + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `bad_locale` is a NUL-terminated byte string; `out` is a + // valid writable pointer to a local variable. + let status = unsafe { + mxc_telemetry_request_consent( + bad_locale.as_ptr() as *const c_char, + Some(never_called_presenter), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_INVALID_UTF8); + assert!( + out.is_null(), + "invalid-UTF-8 failure path must leave *out_utf8 null" + ); + } + + #[test] + fn withdraw_consent_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_withdraw_consent(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[test] + fn get_consent_status_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_consent_status(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + // The read-only exports also overwrite stale output pointers on success. + + #[test] + fn get_consent_overwrites_stale_out_pointer_on_success() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null(), "success path must produce a real string"); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + unsafe { mxc_string_free(out) }; + } + + #[test] + fn get_policy_overwrites_stale_out_pointer_on_success() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_policy(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null(), "success path must produce a real string"); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_policy`. + unsafe { mxc_string_free(out) }; + } + + // Use a synthetic store so the snapshot is deterministic on Windows. + #[cfg(debug_assertions)] + #[test] + fn get_consent_status_returns_a_valid_json_snapshot() { + let _guard = TelemetryTestEnv::new("get_status"); + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent_status(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent_status`. + let json = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + // SAFETY: allocated by the export above. + unsafe { mxc_string_free(out) }; + + // The shape is fixed; the values depend on the platform. + assert!(json.get("storedState").is_some()); + assert!(json.get("effectiveState").is_some()); + assert!(json.get("policy").is_some()); + // `reason` is `Option<..>` and may be JSON null. + assert!(json.get("reason").is_some()); + + #[cfg(target_os = "windows")] + { + // A fresh synthetic env has no persisted record. + assert_eq!(json["storedState"], "undetermined"); + assert_eq!(json["effectiveState"], "undetermined"); + assert_eq!(json["reason"], "no-record"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(json["storedState"], "not-applicable"); + assert_eq!(json["effectiveState"], "not-applicable"); + assert_eq!(json["reason"], "not-applicable"); + assert_eq!(json["policy"], "not-applicable"); + } + } + + // Presenter failures are mapped to a backend error without persisting. + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn request_consent_presenter_error_maps_to_backend_error() { + let _guard = TelemetryTestEnv::new("presenter_error"); + unsafe extern "C" fn broken_presenter( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + MXC_TELEMETRY_CONSENT_PRESENTER_ERROR + } + + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: callback and out pointer remain valid for the call. + let status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(broken_presenter), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_BACKEND_ERROR); + assert!( + out.is_null(), + "presenter-error failure path must leave *out_utf8 null" + ); + } + + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn request_consent_invalid_decision_maps_to_backend_error() { + let _guard = TelemetryTestEnv::new("invalid_decision"); + unsafe extern "C" fn bad_decision( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + // Neither of the four defined return values. + 42 + } + + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: callback and out pointer remain valid for the call. + let status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(bad_decision), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_BACKEND_ERROR); + assert!( + out.is_null(), + "invalid-decision failure path must leave *out_utf8 null" + ); + } + + /// Verify the withdrawal export returns its typed JSON outcome. + #[cfg(debug_assertions)] + #[test] + fn withdraw_consent_success_returns_typed_outcome() { + let _guard = TelemetryTestEnv::new("withdraw_success"); + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_withdraw_consent`. + let json = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + // SAFETY: allocated by the export above. + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert_eq!(json["result"], "withdrawn"); + assert_eq!(json["effectiveState"], "denied"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(json["result"], "notApplicable"); + assert_eq!(json["effectiveState"], "not-applicable"); + } + } } diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index ca9ab5f77..5c32aa5b4 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -9,16 +9,18 @@ use mxc_sdk::configs::{ CaptureDenials, ProcessContainer, ProcessContainerNetwork, ProcessContainerSystemSettings, ProcessContainerUi, ProcessContainerUiIsolation, }; +use mxc_sdk::policy::{FilesystemSection, NetworkSection, UiSection}; use mxc_sdk::{ build_request_with_containment, Containment, Error, ErrorCode, SandboxPolicy, SandboxRequest, WslcSection, }; -use serde_json::Value; +use serde::de::{Error as _, IgnoredAny}; +use serde::{Deserialize, Deserializer}; #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RequestSpec { - policy: SandboxPolicy, + policy: RequestPolicy, command: String, #[serde(default)] containment: RequestContainment, @@ -32,6 +34,91 @@ struct RequestSpec { experimental: bool, } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RequestPolicy { + version: String, + #[serde(default)] + filesystem: Option, + #[serde(default)] + network: Option, + #[serde(default)] + ui: Option, + #[serde(default)] + timeout_ms: Option, + #[serde( + default, + rename = "captureDenials", + deserialize_with = "reject_legacy_capture_denials" + )] + _capture_denials: (), + #[serde(default)] + telemetry: TelemetryField, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TelemetrySpec { + #[serde(default)] + enabled: Option, +} + +#[derive(Default)] +enum TelemetryField { + #[default] + Absent, + Present(Option), +} + +impl<'de> Deserialize<'de> for TelemetryField { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer).map(Self::Present) + } +} + +fn reject_legacy_capture_denials<'de, D>(deserializer: D) -> Result<(), D::Error> +where + D: Deserializer<'de>, +{ + IgnoredAny::deserialize(deserializer)?; + Err(D::Error::custom( + "policy.captureDenials is not supported; set containment.type to \ + processContainer and use containment.captureDenials", + )) +} + +impl RequestPolicy { + fn into_sdk(self) -> Result<(SandboxPolicy, Option), Error> { + let telemetry = match self.telemetry { + TelemetryField::Absent => None, + TelemetryField::Present(telemetry) => { + if let Ok(version) = semver::Version::parse(&self.version) { + if version.major == 0 && version.minor < 9 { + return Err(Error::new( + ErrorCode::MalformedRequest, + "policy.telemetry requires config schema version 0.9.0-alpha or later", + )); + } + } + telemetry + } + }; + Ok(( + SandboxPolicy { + version: self.version, + filesystem: self.filesystem, + network: self.network, + ui: self.ui, + timeout_ms: self.timeout_ms, + }, + telemetry, + )) + } +} + #[derive(Default, serde::Deserialize)] #[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] enum RequestContainment { @@ -219,30 +306,43 @@ impl ProcessContainerNetworkSpec { /// Parse a binding request and build the public Rust SDK request it describes. pub(crate) fn build_request_from_json(request_json: &str) -> Result { - let value: Value = serde_json::from_str(request_json).map_err(malformed_request)?; - if value - .get("policy") - .and_then(|policy| policy.get("captureDenials")) - .is_some() + let mut deserializer = serde_json::Deserializer::from_str(request_json); + let mut ignored_paths = Vec::new(); + let spec: RequestSpec = serde_ignored::deserialize(&mut deserializer, |path| { + ignored_paths.push(path.to_string().replace(".?.", ".")); + }) + .map_err(malformed_request)?; + deserializer.end().map_err(malformed_request)?; + if let Some(path) = ignored_paths.first() { + return Err(Error::new( + ErrorCode::MalformedRequest, + format!("unknown request field `{path}`"), + )); + } + if let Some(name) = spec + .environment + .keys() + .find(|name| name.is_empty() || name.contains('=')) { return Err(Error::new( ErrorCode::MalformedRequest, - "policy.captureDenials is not supported; set containment.type to \ - processContainer and use containment.captureDenials", + format!("invalid environment variable name `{name}`"), )); } - - let spec: RequestSpec = serde_json::from_value(value).map_err(malformed_request)?; + let (policy, telemetry) = spec.policy.into_sdk()?; let containment = spec.containment.into_sdk(); let mut request = - build_request_with_containment(&spec.policy, &containment, spec.container_name.as_deref())?; + build_request_with_containment(&policy, &containment, spec.container_name.as_deref())?; request.set_script(spec.command); if let Some(working_directory) = spec.working_directory { request.set_working_directory(working_directory); } request.set_env(spec.environment); request.set_experimental(spec.experimental); + if let Some(enabled) = telemetry.and_then(|telemetry| telemetry.enabled) { + request.set_telemetry_opt_in(enabled); + } Ok(request) } @@ -389,6 +489,208 @@ mod tests { build_request_from_json(wslc).expect("WSLC golden builds a public SDK request"); } + #[test] + fn telemetry_is_parsed_from_the_binding_policy() { + for (telemetry, expected) in [ + ("", None), + (r#","telemetry":{"enabled":true}"#, Some(true)), + (r#","telemetry":{"enabled":false}"#, Some(false)), + (r#","telemetry":null"#, None), + (r#","telemetry":{}"#, None), + (r#","telemetry":{"enabled":null}"#, None), + ] { + let request_json = format!( + r#"{{ + "policy": {{ + "version": "0.9.0-alpha" + {telemetry} + }}, + "command": "echo hi" + }}"# + ); + let request = build_request_from_json(&request_json) + .unwrap_or_else(|error| panic!("request failed: {error}")); + assert_eq!(request.telemetry_enabled(), expected); + } + } + + #[test] + fn telemetry_rejects_unknown_fields() { + let error = build_request_from_json( + r#"{ + "policy": { + "version": "0.9.0-alpha", + "telemetry": { + "enabled": true, + "unexpected": true + } + }, + "command": "echo hi" + }"#, + ) + .expect_err("unknown telemetry fields must fail"); + + assert!( + error.message.contains("unknown field `unexpected`"), + "unexpected error: {error}" + ); + } + + #[test] + fn telemetry_rejects_pre_0_9_policies() { + let error = build_request_from_json( + r#"{ + "policy": { + "version": "0.8.0-alpha", + "telemetry": null + }, + "command": "echo hi" + }"#, + ) + .expect_err("telemetry must require policy version 0.9 or later"); + + assert!( + error + .message + .contains("telemetry requires config schema version 0.9.0-alpha"), + "unexpected error: {error}" + ); + } + + #[test] + fn telemetry_rejects_malformed_enabled_values() { + let error = build_request_from_json( + r#"{ + "policy": { + "version": "0.9.0-alpha", + "telemetry": { + "enabled": "yes" + } + }, + "command": "echo hi" + }"#, + ) + .expect_err("non-boolean telemetry must fail"); + + assert!( + error.message.contains("invalid type"), + "unexpected error: {error}" + ); + } + + #[test] + fn duplicate_request_and_telemetry_fields_are_rejected() { + for request_json in [ + r#"{ + "policy": { "version": "0.9.0-alpha" }, + "command": "echo first", + "command": "echo second" + }"#, + r#"{ + "policy": { + "version": "0.9.0-alpha", + "telemetry": { "enabled": true }, + "telemetry": null + }, + "command": "echo hi" + }"#, + ] { + let error = + build_request_from_json(request_json).expect_err("duplicate fields must fail"); + assert!( + error.message.contains("duplicate field"), + "unexpected error: {error}" + ); + } + } + + #[test] + fn unknown_policy_fields_are_rejected() { + let error = build_request_from_json( + r#"{ + "policy": { + "version": "0.9.0-alpha", + "timeoutMS": 1 + }, + "command": "echo hi" + }"#, + ) + .expect_err("unknown policy fields must fail"); + + assert!( + error.message.contains("unknown field `timeoutMS`"), + "unexpected error: {error}" + ); + } + + #[test] + fn unknown_nested_policy_fields_are_rejected() { + for (request_json, expected_path) in [ + ( + r#"{ + "policy": { + "version": "0.9.0-alpha", + "filesystem": { + "readwritePaths": [], + "unexpected": true + } + }, + "command": "echo hi" + }"#, + "policy.filesystem.unexpected", + ), + ( + r#"{ + "policy": { + "version": "0.9.0-alpha", + "network": { + "allowOutboud": true + } + }, + "command": "echo hi" + }"#, + "policy.network.allowOutboud", + ), + ( + r#"{ + "policy": { + "version": "0.9.0-alpha", + "ui": { + "unexpected": true + } + }, + "command": "echo hi" + }"#, + "policy.ui.unexpected", + ), + ] { + let error = + build_request_from_json(request_json).expect_err("unknown nested fields must fail"); + assert!( + error.message.contains(expected_path), + "unexpected error for {expected_path}: {error}" + ); + } + } + + #[test] + fn invalid_environment_variable_names_are_rejected() { + for name in ["", "A=B"] { + let request_json = serde_json::json!({ + "policy": { "version": "0.9.0-alpha" }, + "command": "echo hi", + "environment": { name: "value" } + }) + .to_string(); + let error = + build_request_from_json(&request_json).expect_err("invalid names must fail"); + assert!( + error.message.contains("invalid environment variable name"), + "unexpected error for {name:?}: {error}" + ); + } + } + #[test] fn capture_denials_is_mapped_to_process_container_configuration() { let error = build_request_from_json( diff --git a/src/ffi/mxc_ffi/src/state_aware.rs b/src/ffi/mxc_ffi/src/state_aware.rs index ad32ea5b6..9f48ed826 100644 --- a/src/ffi/mxc_ffi/src/state_aware.rs +++ b/src/ffi/mxc_ffi/src/state_aware.rs @@ -14,7 +14,7 @@ //! interactive terminal. It blocks and reports an [`MxcExecOutcome`]. //! - [`mxc_state_aware_exec`] drives the **exec phase as a live streaming** //! process, returning the same opaque [`MxcSandbox`](crate::MxcSandbox) handle -//! as [`mxc_spawn`](crate::mxc_spawn) — so the caller reuses the +//! as [`mxc_spawn_request`](crate::mxc_spawn_request) — so the caller reuses the //! `mxc_stream_*` / `mxc_sandbox_*` externs to read/write/wait/kill. //! //! The two exec entry points take the **same** request JSON and differ only in @@ -132,7 +132,10 @@ pub unsafe extern "C" fn mxc_state_aware( let result = catch_unwind(AssertUnwindSafe(|| { state_aware_inner(request_json_utf8, dry_run != 0, experimental != 0) })) - .unwrap_or_else(|_| MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked")); + .unwrap_or_else(|panic| { + crate::report_panic("mxc_state_aware", &*panic); + MxcStateAwareResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") + }); let status = result.status; // SAFETY: `out` is non-null and caller-guaranteed writable; ownership of the @@ -181,10 +184,12 @@ pub unsafe extern "C" fn mxc_state_aware_result_free(r: *mut MxcStateAwareResult if r.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: caller guarantees `r` points to a valid, not-yet-freed result. unsafe { (*r).free_strings() }; - })); + })) { + crate::report_panic("mxc_state_aware_result_free", &*panic); + } } /// Run the `exec` phase of a state-aware request as a **live streaming** process. @@ -226,7 +231,7 @@ pub unsafe extern "C" fn mxc_state_aware_exec( unsafe { *out_handle = ptr::null_mut() }; } if !out_error.is_null() { - // `write` rather than assignment, for the reason given on `mxc_spawn`: + // `write` rather than assignment, for the reason given on `mxc_spawn_request`: // the storage may be uninitialised, and nothing here is dropped. // SAFETY: caller-guaranteed writable storage for one detail. unsafe { ptr::write(out_error, MxcErrorDetail::none()) }; @@ -259,7 +264,8 @@ pub unsafe extern "C" fn mxc_state_aware_exec( ) }) })) - .unwrap_or_else(|_| { + .unwrap_or_else(|panic| { + crate::report_panic("mxc_state_aware_exec", &*panic); Err(( MXC_STATUS_PANIC, MxcErrorDetail::from_message("the mxc engine panicked"), @@ -366,7 +372,8 @@ pub unsafe extern "C" fn mxc_state_aware_exec_attached( ) }) })) - .unwrap_or_else(|_| { + .unwrap_or_else(|panic| { + crate::report_panic("mxc_state_aware_exec_attached", &*panic); Err(( MXC_STATUS_PANIC, MxcErrorDetail::from_message("the mxc engine panicked"), diff --git a/src/ffi/mxc_ffi/src/streaming.rs b/src/ffi/mxc_ffi/src/streaming.rs index 19b42ce1a..6492617ce 100644 --- a/src/ffi/mxc_ffi/src/streaming.rs +++ b/src/ffi/mxc_ffi/src/streaming.rs @@ -3,15 +3,17 @@ //! Streaming (handle-based) C ABI over the MXC public Rust SDK. //! -//! Where [`mxc_run`](crate::mxc_run) runs a sandbox to completion and captures -//! its output, this surface hands the caller a live, opaque handle it can feed -//! stdin, read stdout/stderr from, wait on, and kill while the child runs — -//! mirroring [`mxc_sdk::spawn_sandbox`] / [`mxc_sdk::Sandbox`]. +//! Where [`mxc_run_request`](crate::mxc_run_request) runs a sandbox to +//! completion and captures its output, this surface hands the caller a live, +//! opaque handle it can feed stdin, read stdout/stderr from, wait on, and kill +//! while the child runs — mirroring [`mxc_sdk::spawn_sandbox`] / +//! [`mxc_sdk::Sandbox`]. //! //! ## Handles & ownership //! -//! - [`mxc_spawn`] returns an opaque `*mut MxcSandbox`. Free it exactly once -//! with [`mxc_sandbox_free`] (which kills the child tree if still running). +//! - [`mxc_spawn_request`] returns an opaque `*mut MxcSandbox`. Free it exactly +//! once with [`mxc_sandbox_free`] (which kills the child tree if still +//! running). //! - [`mxc_sandbox_take_stdin`] / [`mxc_sandbox_take_stdout`] / //! [`mxc_sandbox_take_stderr`] each hand out a **separate** opaque stream //! handle (`*mut MxcWriteStream` / `*mut MxcReadStream`) the first time they @@ -56,12 +58,12 @@ use std::io::{Read, Write}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -use mxc_sdk::{build_request, spawn_sandbox, Sandbox, SandboxPolicy, StreamCloser, WaitOutcome}; +use mxc_sdk::{spawn_sandbox, Sandbox, StreamCloser, WaitOutcome}; use crate::{ alloc_cstring, cstr_to_str, request, status_from_error_code, MxcErrorDetail, - MXC_STATUS_BACKEND_ERROR, MXC_STATUS_INVALID_UTF8, MXC_STATUS_MALFORMED_REQUEST, - MXC_STATUS_NULL_ARGUMENT, MXC_STATUS_PANIC, MXC_STATUS_SUCCESS, + MXC_STATUS_BACKEND_ERROR, MXC_STATUS_INVALID_UTF8, MXC_STATUS_NULL_ARGUMENT, MXC_STATUS_PANIC, + MXC_STATUS_SUCCESS, }; // --------------------------------------------------------------------------- @@ -69,14 +71,14 @@ use crate::{ // --------------------------------------------------------------------------- /// Opaque live-sandbox handle wrapping an [`mxc_sdk::Sandbox`]. Created by -/// [`mxc_spawn`], destroyed by [`mxc_sandbox_free`]. +/// [`mxc_spawn_request`], destroyed by [`mxc_sandbox_free`]. pub struct MxcSandbox { inner: Sandbox, } impl MxcSandbox { /// Wrap an [`mxc_sdk::Sandbox`] as an opaque FFI handle. Used by both the - /// one-shot spawn path ([`mxc_spawn`]) and the state-aware streaming exec + /// one-shot spawn path ([`mxc_spawn_request`]) and the state-aware streaming exec /// path (`mxc_state_aware_exec`). pub(crate) fn new(inner: Sandbox) -> Self { Self { inner } @@ -105,73 +107,6 @@ pub struct MxcStreamCloser { // Spawn // --------------------------------------------------------------------------- -/// Spawn a live sandboxed process and return an opaque handle to it. -/// -/// Parses `policy_json_utf8` as a `SandboxPolicy`, sets `command_utf8` as the -/// command to run, and spawns the process with piped stdio. On success writes -/// the handle to `*out_handle` and returns [`MXC_STATUS_SUCCESS`]. On failure -/// returns the status code and, if `out_error` is non-null, fills it with the -/// message plus the failing API call when there was one (release it with -/// [`mxc_error_detail_free`](crate::mxc_error_detail_free)); `*out_handle` is -/// set to null. -/// -/// # Safety -/// - `policy_json_utf8` / `command_utf8` must be null or valid NUL-terminated -/// UTF-8 C strings. -/// - `out_handle` must be non-null and point to writable pointer-sized storage -/// holding **no live handle**: this function overwrites it with null before -/// doing anything else, so a handle already stored there is stranded, and -/// [`mxc_sandbox_free`] is its only destructor. Free an existing handle -/// before reusing its storage. On success the caller owns `*out_handle` and -/// must free it with [`mxc_sandbox_free`]. -/// - `out_error` must be null, or point to writable storage for one -/// [`MxcErrorDetail`] that holds **no live detail**: either fresh or -/// uninitialised storage, or storage already released with -/// [`mxc_error_detail_free`](crate::mxc_error_detail_free). This function -/// overwrites that storage without freeing what was there, so handing it a -/// populated detail leaks that detail's strings. It cannot do otherwise: -/// uninitialised storage holds no pointers it could safely release, and -/// nothing distinguishes the two cases at runtime. -#[no_mangle] -pub unsafe extern "C" fn mxc_spawn( - policy_json_utf8: *const c_char, - command_utf8: *const c_char, - out_handle: *mut *mut MxcSandbox, - out_error: *mut MxcErrorDetail, -) -> i32 { - // Initialise out-params defensively so a partial/failed call never leaves - // stale pointers behind. - if !out_handle.is_null() { - // SAFETY: caller-guaranteed writable pointer-sized storage. - unsafe { *out_handle = ptr::null_mut() }; - } - if !out_error.is_null() { - // `write` rather than assignment: the storage may be uninitialised, and - // assigning would be a claim that a valid value is being overwritten. - // Nothing is dropped either way -- the type owns raw pointers and has no - // destructor -- which is exactly why the contract above requires the - // caller to hand over storage holding no live detail. - // SAFETY: caller-guaranteed writable storage for one detail. - unsafe { ptr::write(out_error, MxcErrorDetail::none()) }; - } - if out_handle.is_null() { - return MXC_STATUS_NULL_ARGUMENT; - } - - let outcome = catch_unwind(AssertUnwindSafe(|| { - spawn_inner(policy_json_utf8, command_utf8) - })) - .unwrap_or_else(|_| { - Err(( - MXC_STATUS_PANIC, - MxcErrorDetail::from_message("the mxc engine panicked"), - )) - }); - - // SAFETY: `out_handle` non-null (checked), `out_error` null or writable. - unsafe { finish_spawn(outcome, out_handle, out_error) } -} - /// Spawn a complete one-shot request as a live sandboxed process. /// /// Uses the same co-versioned request JSON contract as @@ -179,7 +114,10 @@ pub unsafe extern "C" fn mxc_spawn( /// /// # Safety /// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. -/// - `out_handle` and `out_error` follow the ownership contract of [`mxc_spawn`]. +/// - `out_handle` must point to writable pointer-sized storage holding no live +/// handle. +/// - `out_error` must be null or point to writable storage holding no live +/// detail. #[no_mangle] pub unsafe extern "C" fn mxc_spawn_request( request_json_utf8: *const c_char, @@ -199,7 +137,8 @@ pub unsafe extern "C" fn mxc_spawn_request( } let outcome = catch_unwind(AssertUnwindSafe(|| spawn_request_inner(request_json_utf8))) - .unwrap_or_else(|_| { + .unwrap_or_else(|panic| { + crate::report_panic("mxc_spawn_request", &*panic); Err(( MXC_STATUS_PANIC, MxcErrorDetail::from_message("the mxc engine panicked"), @@ -231,8 +170,9 @@ fn spawn_request_inner(request_json_utf8: *const c_char) -> Result Result { - // SAFETY: caller contract on `mxc_spawn`; borrowed only within scope. - let policy_json = match unsafe { cstr_to_str(policy_json_utf8) } { - Some(s) => s, - None if policy_json_utf8.is_null() => { - return Err(( - MXC_STATUS_NULL_ARGUMENT, - MxcErrorDetail::from_message("policy JSON pointer is null"), - )) - } - None => { - return Err(( - MXC_STATUS_INVALID_UTF8, - MxcErrorDetail::from_message("policy JSON is not UTF-8"), - )) - } - }; - let command = match unsafe { cstr_to_str(command_utf8) } { - Some(s) => s, - None if command_utf8.is_null() => { - return Err(( - MXC_STATUS_NULL_ARGUMENT, - MxcErrorDetail::from_message("command pointer is null"), - )) - } - None => { - return Err(( - MXC_STATUS_INVALID_UTF8, - MxcErrorDetail::from_message("command is not UTF-8"), - )) - } - }; - - let policy: SandboxPolicy = serde_json::from_str(policy_json).map_err(|e| { - ( - MXC_STATUS_MALFORMED_REQUEST, - MxcErrorDetail::from_message(format!("failed to parse policy JSON: {e}")), - ) - })?; - - let mut request = build_request(&policy, None).map_err(sdk_error_detail)?; - request.set_script(command); - - spawn_sandbox(request).map_err(sdk_error_detail) -} - /// Map an SDK error onto the status + detail pair the spawn chain carries, so /// the failing API call survives instead of being flattened to a message. fn sdk_error_detail(error: mxc_sdk::Error) -> (i32, MxcErrorDetail) { @@ -336,7 +226,7 @@ fn sdk_error_detail(error: mxc_sdk::Error) -> (i32, MxcErrorDetail) { /// with [`mxc_write_stream_free`] (which closes stdin, sending EOF). /// /// # Safety -/// `handle` must be null or a live handle from [`mxc_spawn`]. +/// `handle` must be null or a live handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_take_stdin(handle: *mut MxcSandbox) -> *mut MxcWriteStream { take_stream(handle, |s| { @@ -348,7 +238,7 @@ pub unsafe extern "C" fn mxc_sandbox_take_stdin(handle: *mut MxcSandbox) -> *mut /// not piped, or stdout was already taken. Free with [`mxc_read_stream_free`]. /// /// # Safety -/// `handle` must be null or a live handle from [`mxc_spawn`]. +/// `handle` must be null or a live handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_take_stdout(handle: *mut MxcSandbox) -> *mut MxcReadStream { take_read_stream(handle, |s| s.inner.take_stdout()) @@ -358,7 +248,7 @@ pub unsafe extern "C" fn mxc_sandbox_take_stdout(handle: *mut MxcSandbox) -> *mu /// not piped, or stderr was already taken. Free with [`mxc_read_stream_free`]. /// /// # Safety -/// `handle` must be null or a live handle from [`mxc_spawn`]. +/// `handle` must be null or a live handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_take_stderr(handle: *mut MxcSandbox) -> *mut MxcReadStream { take_read_stream(handle, |s| s.inner.take_stderr()) @@ -371,7 +261,7 @@ pub unsafe extern "C" fn mxc_sandbox_take_stderr(handle: *mut MxcSandbox) -> *mu /// freed with [`mxc_stream_closer_free`]. /// /// # Safety -/// `handle` must be null or a live handle from [`mxc_spawn`]. +/// `handle` must be null or a live handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_stdout_closer( handle: *mut MxcSandbox, @@ -385,7 +275,7 @@ pub unsafe extern "C" fn mxc_sandbox_stdout_closer( /// stream. Free the returned handle with [`mxc_stream_closer_free`]. /// /// # Safety -/// `handle` must be null or a live handle from [`mxc_spawn`]. +/// `handle` must be null or a live handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_stderr_closer( handle: *mut MxcSandbox, @@ -406,7 +296,12 @@ fn take_stream( let sandbox = unsafe { &mut *handle }; take(sandbox).map(|s| Box::into_raw(Box::new(s))) })); - result.unwrap_or(None).unwrap_or(ptr::null_mut()) + result + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_take_stdin", &*panic); + None + }) + .unwrap_or(ptr::null_mut()) } fn take_read_stream( @@ -421,7 +316,12 @@ fn take_read_stream( let sandbox = unsafe { &mut *handle }; take(sandbox).map(|inner| Box::into_raw(Box::new(MxcReadStream { inner }))) })); - result.unwrap_or(None).unwrap_or(ptr::null_mut()) + result + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_take_stdout_or_stderr", &*panic); + None + }) + .unwrap_or(ptr::null_mut()) } fn take_closer( @@ -436,7 +336,12 @@ fn take_closer( let sandbox = unsafe { &*handle }; take(sandbox).map(|inner| Box::into_raw(Box::new(MxcStreamCloser { inner }))) })); - result.unwrap_or(None).unwrap_or(ptr::null_mut()) + result + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_take_stdout_or_stderr_closer", &*panic); + None + }) + .unwrap_or(ptr::null_mut()) } // --------------------------------------------------------------------------- @@ -464,6 +369,10 @@ pub unsafe extern "C" fn mxc_stream_read( if stream.is_null() || buf.is_null() || out_read.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: `out_read` is non-null and caller-guaranteed writable. Zero it + // before any fallible work so a caught panic or I/O error never leaves the + // caller's prior value behind. + unsafe { *out_read = 0 }; let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: `stream` non-null live handle; `buf`/`cap` describe a valid // writable region per the caller contract. @@ -478,7 +387,10 @@ pub unsafe extern "C" fn mxc_stream_read( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_stream_read", &*panic); + MXC_STATUS_PANIC + }) } /// Write up to `len` bytes from `buf` to `stream`, writing the number of bytes @@ -501,6 +413,10 @@ pub unsafe extern "C" fn mxc_stream_write( if stream.is_null() || buf.is_null() || out_written.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: `out_written` is non-null and caller-guaranteed writable. Zero + // it before any fallible work so a caught panic or I/O error never leaves + // the caller's prior value behind. + unsafe { *out_written = 0 }; let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: `stream` non-null live handle; `buf`/`len` describe a valid // readable region per the caller contract. @@ -515,7 +431,10 @@ pub unsafe extern "C" fn mxc_stream_write( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_stream_write", &*panic); + MXC_STATUS_PANIC + }) } /// Flush any buffered bytes on a stdin stream. @@ -535,7 +454,10 @@ pub unsafe extern "C" fn mxc_stream_flush(stream: *mut MxcWriteStream) -> i32 { Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_stream_flush", &*panic); + MXC_STATUS_PANIC + }) } /// Close a stdout/stderr reader through its independent closer. @@ -559,7 +481,10 @@ pub unsafe extern "C" fn mxc_stream_closer_close(closer: *mut MxcStreamCloser) - unsafe { &*closer }.inner.close(); MXC_STATUS_SUCCESS })) - .unwrap_or(MXC_STATUS_PANIC) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_stream_closer_close", &*panic); + MXC_STATUS_PANIC + }) } // --------------------------------------------------------------------------- @@ -569,7 +494,7 @@ pub unsafe extern "C" fn mxc_stream_closer_close(closer: *mut MxcStreamCloser) - /// Return the child's OS process id, or `0` if `handle` is null. /// /// # Safety -/// `handle` must be null or a live handle from [`mxc_spawn`]. +/// `handle` must be null or a live handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_id(handle: *mut MxcSandbox) -> u32 { if handle.is_null() { @@ -580,7 +505,10 @@ pub unsafe extern "C" fn mxc_sandbox_id(handle: *mut MxcSandbox) -> u32 { let sandbox = unsafe { &*handle }; sandbox.inner.id() })) - .unwrap_or(0) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_id", &*panic); + 0 + }) } /// Return structured output metadata as owned JSON. A successful call leaves @@ -591,7 +519,7 @@ pub unsafe extern "C" fn mxc_sandbox_id(handle: *mut MxcSandbox) -> u32 { /// [`mxc_string_free`](crate::mxc_string_free). /// /// # Safety -/// - `handle` must be null or a live handle from [`mxc_spawn`]. +/// - `handle` must be null or a live handle from [`mxc_spawn_request`]. /// - `out_json_utf8` must be non-null and point to writable pointer storage. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_output_metadata_json( @@ -620,7 +548,10 @@ pub unsafe extern "C" fn mxc_sandbox_output_metadata_json( unsafe { *out_json_utf8 = alloc_cstring(&json) }; MXC_STATUS_SUCCESS })) - .unwrap_or(MXC_STATUS_PANIC) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_output_metadata_json", &*panic); + MXC_STATUS_PANIC + }) } /// Return the sandbox's security warnings as owned JSON. @@ -630,7 +561,7 @@ pub unsafe extern "C" fn mxc_sandbox_output_metadata_json( /// [`mxc_string_free`](crate::mxc_string_free). /// /// # Safety -/// - `handle` must be null or a live handle from [`mxc_spawn`]. +/// - `handle` must be null or a live handle from [`mxc_spawn_request`]. /// - `out_json_utf8` must be non-null and point to writable pointer storage. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_warnings_json( @@ -659,7 +590,10 @@ pub unsafe extern "C" fn mxc_sandbox_warnings_json( unsafe { *out_json_utf8 = alloc_cstring(&json) }; MXC_STATUS_SUCCESS })) - .unwrap_or(MXC_STATUS_PANIC) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_warnings_json", &*panic); + MXC_STATUS_PANIC + }) } /// Non-blocking completion check. On return, `*out_running` is `1` if the child @@ -671,7 +605,7 @@ pub unsafe extern "C" fn mxc_sandbox_warnings_json( /// is null, or [`MXC_STATUS_BACKEND_ERROR`] on a wait error. /// /// # Safety -/// - `handle` must be null or a live handle from [`mxc_spawn`]. +/// - `handle` must be null or a live handle from [`mxc_spawn_request`]. /// - `out_exit` / `out_running` / `out_timed_out` must be null or point to /// writable `i32` storage. #[no_mangle] @@ -684,6 +618,14 @@ pub unsafe extern "C" fn mxc_sandbox_try_wait( if handle.is_null() || out_exit.is_null() || out_running.is_null() || out_timed_out.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: out-params are non-null and caller-guaranteed writable. Zero + // them before any fallible work so a caught panic or backend error never + // leaks the caller's previous values back out. + unsafe { + *out_exit = 0; + *out_running = 0; + *out_timed_out = 0; + } let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null live handle per the caller contract. let sandbox = unsafe { &mut *handle }; @@ -700,7 +642,10 @@ pub unsafe extern "C" fn mxc_sandbox_try_wait( Err(status) => status, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_try_wait", &*panic); + MXC_STATUS_PANIC + }) } fn try_wait_result_to_abi(result: std::io::Result>) -> Result<(i32, i32, i32), i32> { @@ -721,7 +666,7 @@ fn try_wait_result_to_abi(result: std::io::Result>) -> Result<(i32, /// (see the module concurrency contract). /// /// # Safety -/// - `handle` must be null or a live handle from [`mxc_spawn`]. +/// - `handle` must be null or a live handle from [`mxc_spawn_request`]. /// - `out_exit` / `out_timed_out` must be null or writable `i32` storage. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_wait( @@ -732,6 +677,13 @@ pub unsafe extern "C" fn mxc_sandbox_wait( if handle.is_null() || out_exit.is_null() || out_timed_out.is_null() { return MXC_STATUS_NULL_ARGUMENT; } + // SAFETY: out-params are non-null and caller-guaranteed writable. Zero + // them before any fallible work so a caught panic or backend error never + // leaks the caller's previous values back out. + unsafe { + *out_exit = 0; + *out_timed_out = 0; + } let status = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null live handle per the caller contract. let sandbox = unsafe { &mut *handle }; @@ -755,14 +707,17 @@ pub unsafe extern "C" fn mxc_sandbox_wait( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_wait", &*panic); + MXC_STATUS_PANIC + }) } /// Kill the child and its whole process tree. Reaping happens in a subsequent /// [`mxc_sandbox_wait`] / [`mxc_sandbox_try_wait`] or in [`mxc_sandbox_free`]. /// /// # Safety -/// `handle` must be null or a live handle from [`mxc_spawn`]. +/// `handle` must be null or a live handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_kill(handle: *mut MxcSandbox) -> i32 { if handle.is_null() { @@ -776,29 +731,34 @@ pub unsafe extern "C" fn mxc_sandbox_kill(handle: *mut MxcSandbox) -> i32 { Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_kill", &*panic); + MXC_STATUS_PANIC + }) } // --------------------------------------------------------------------------- // Handle destructors // --------------------------------------------------------------------------- -/// Free a sandbox handle from [`mxc_spawn`], killing the child tree if it is +/// Free a sandbox handle from [`mxc_spawn_request`], killing the child tree if it is /// still running. Safe to call with null (no-op). Must be called exactly once /// per handle. /// /// # Safety -/// `handle` must be null or a live, not-yet-freed handle from [`mxc_spawn`]. +/// `handle` must be null or a live, not-yet-freed handle from [`mxc_spawn_request`]. #[no_mangle] pub unsafe extern "C" fn mxc_sandbox_free(handle: *mut MxcSandbox) { if handle.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { - // SAFETY: non-null handle produced by `Box::into_raw` in `mxc_spawn`, + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: non-null handle produced by `Box::into_raw` in `mxc_spawn_request`, // not yet freed; reconstructing the Box drops it (and its child). drop(unsafe { Box::from_raw(handle) }); - })); + })) { + crate::report_panic("mxc_sandbox_free", &*panic); + } } /// Free a readable stream handle. Safe to call with null (no-op). Must be @@ -812,10 +772,12 @@ pub unsafe extern "C" fn mxc_read_stream_free(stream: *mut MxcReadStream) { if stream.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null handle produced by `Box::into_raw`, not yet freed. drop(unsafe { Box::from_raw(stream) }); - })); + })) { + crate::report_panic("mxc_read_stream_free", &*panic); + } } /// Free a writable (stdin) stream handle, closing stdin and sending EOF to the @@ -829,10 +791,12 @@ pub unsafe extern "C" fn mxc_write_stream_free(stream: *mut MxcWriteStream) { if stream.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null handle produced by `Box::into_raw`, not yet freed. drop(unsafe { Box::from_raw(stream) }); - })); + })) { + crate::report_panic("mxc_write_stream_free", &*panic); + } } /// Free a stream-closer handle. Safe to call with null (no-op). Must be called @@ -846,10 +810,12 @@ pub unsafe extern "C" fn mxc_stream_closer_free(closer: *mut MxcStreamCloser) { if closer.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null handle produced by Box::into_raw, not yet freed. drop(unsafe { Box::from_raw(closer) }); - })); + })) { + crate::report_panic("mxc_stream_closer_free", &*panic); + } } #[cfg(test)] @@ -874,68 +840,96 @@ mod tests { } use std::ffi::CString; + use crate::MXC_STATUS_MALFORMED_REQUEST; + + fn request(command: &str) -> CString { + CString::new( + serde_json::json!({ + "policy": { "version": "0.8.0-alpha" }, + "command": command + }) + .to_string(), + ) + .unwrap() + } + + struct PanicReader; + + impl Read for PanicReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + panic!("forced panic in test reader"); + } + } + + struct PanicWriter; + + impl Write for PanicWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + panic!("forced panic in test writer"); + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + struct PanicFlushWriter; + + impl Write for PanicFlushWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Ok(0) + } + + fn flush(&mut self) -> std::io::Result<()> { + panic!("forced panic in test flush"); + } + } + #[test] fn spawn_null_out_handle_is_null_argument() { - let policy = CString::new(r#"{"version":"0.7.0-alpha"}"#).unwrap(); - let command = CString::new("echo hi").unwrap(); - // SAFETY: valid strings, deliberately-null out_handle. - let status = unsafe { - mxc_spawn( - policy.as_ptr(), - command.as_ptr(), - ptr::null_mut(), - ptr::null_mut(), - ) - }; + let request = request("echo hi"); + // SAFETY: valid string, deliberately-null out_handle. + let status = + unsafe { mxc_spawn_request(request.as_ptr(), ptr::null_mut(), ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } #[test] - fn spawn_null_policy_reports_null_argument() { - let command = CString::new("echo hi").unwrap(); + fn spawn_null_request_reports_null_argument() { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); - // SAFETY: null policy pointer is explicitly handled. - let status = unsafe { mxc_spawn(ptr::null(), command.as_ptr(), &mut handle, &mut err) }; + // SAFETY: null request pointer is explicitly handled. + let status = unsafe { mxc_spawn_request(ptr::null(), &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); assert!(handle.is_null()); assert!( !err.message_utf8.is_null(), "an error message should be provided" ); - // SAFETY: `err` was filled by `mxc_spawn` and not yet freed. + // SAFETY: `err` was filled by `mxc_spawn_request` and not yet freed. unsafe { crate::mxc_error_detail_free(&mut err) }; } #[test] - fn spawn_malformed_policy_reports_malformed_request() { - let policy = CString::new("{ not json").unwrap(); - let command = CString::new("echo hi").unwrap(); + fn spawn_malformed_request_reports_malformed_request() { + let request = CString::new("{ not json").unwrap(); let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); - // SAFETY: valid strings and valid out pointers. - let status = unsafe { mxc_spawn(policy.as_ptr(), command.as_ptr(), &mut handle, &mut err) }; + // SAFETY: valid string and valid out pointers. + let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_MALFORMED_REQUEST); assert!(handle.is_null()); assert!(!err.message_utf8.is_null()); - // SAFETY: `err` was filled by `mxc_spawn` and not yet freed. + // SAFETY: `err` was filled by `mxc_spawn_request` and not yet freed. unsafe { crate::mxc_error_detail_free(&mut err) }; } #[test] fn spawn_null_error_out_is_tolerated() { - let policy = CString::new("{ not json").unwrap(); - let command = CString::new("echo hi").unwrap(); + let request = CString::new("{ not json").unwrap(); let mut handle: *mut MxcSandbox = ptr::null_mut(); - // SAFETY: valid strings; null out_error must be tolerated. - let status = unsafe { - mxc_spawn( - policy.as_ptr(), - command.as_ptr(), - &mut handle, - ptr::null_mut(), - ) - }; + // SAFETY: valid string; null out_error must be tolerated. + let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_MALFORMED_REQUEST); assert!(handle.is_null()); } @@ -1006,6 +1000,44 @@ mod tests { } } + #[test] + fn stream_read_panic_zeroes_out_read_before_returning_panic() { + let mut stream = MxcReadStream { + inner: Box::new(PanicReader), + }; + let mut buf = [0u8; 8]; + let mut out_read = usize::MAX; + // SAFETY: `stream`, `buf`, and `out_read` are valid for this call. + let status = + unsafe { mxc_stream_read(&mut stream, buf.as_mut_ptr(), buf.len(), &mut out_read) }; + assert_eq!(status, MXC_STATUS_PANIC); + assert_eq!(out_read, 0); + } + + #[test] + fn stream_write_panic_zeroes_out_written_before_returning_panic() { + let mut stream = MxcWriteStream { + inner: Box::new(PanicWriter), + }; + let buf = *b"panic"; + let mut out_written = usize::MAX; + // SAFETY: `stream`, `buf`, and `out_written` are valid for this call. + let status = + unsafe { mxc_stream_write(&mut stream, buf.as_ptr(), buf.len(), &mut out_written) }; + assert_eq!(status, MXC_STATUS_PANIC); + assert_eq!(out_written, 0); + } + + #[test] + fn stream_flush_panic_returns_panic_status() { + let mut stream = MxcWriteStream { + inner: Box::new(PanicFlushWriter), + }; + // SAFETY: `stream` is a valid live stream handle. + let status = unsafe { mxc_stream_flush(&mut stream) }; + assert_eq!(status, MXC_STATUS_PANIC); + } + /// Full streaming round-trip against a real sandbox: spawn `echo`, drain /// stdout to EOF, and wait for a clean exit. Ignored by default because it /// requires a host able to launch a sandboxed process (host-prepped Windows @@ -1014,16 +1046,16 @@ mod tests { #[test] #[ignore] fn real_echo_streaming_roundtrip() { - let policy = CString::new(r#"{"version":"0.8.0-alpha"}"#).unwrap(); #[cfg(target_os = "windows")] - let command = CString::new("C:\\Windows\\System32\\cmd.exe /c echo mxc_stream_ok").unwrap(); + let command = "C:\\Windows\\System32\\cmd.exe /c echo mxc_stream_ok"; #[cfg(not(target_os = "windows"))] - let command = CString::new("echo mxc_stream_ok").unwrap(); + let command = "echo mxc_stream_ok"; + let request = request(command); let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); - // SAFETY: valid strings and out pointers. - let status = unsafe { mxc_spawn(policy.as_ptr(), command.as_ptr(), &mut handle, &mut err) }; + // SAFETY: valid string and out pointers. + let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_SUCCESS, "spawn failed (status {status})"); assert!(!handle.is_null()); @@ -1070,16 +1102,13 @@ mod tests { #[ignore] #[cfg(target_os = "windows")] fn real_stdin_write_stdout_read_roundtrip() { - let policy = CString::new(r#"{"version":"0.8.0-alpha"}"#).unwrap(); // A cmd builtin that reads one stdin line and echoes it back. - let command = - CString::new("C:\\Windows\\System32\\cmd.exe /v:on /c set /p x= & echo GOT:!x!") - .unwrap(); + let request = request("C:\\Windows\\System32\\cmd.exe /v:on /c set /p x= & echo GOT:!x!"); let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); - // SAFETY: valid strings and out pointers. - let status = unsafe { mxc_spawn(policy.as_ptr(), command.as_ptr(), &mut handle, &mut err) }; + // SAFETY: valid string and out pointers. + let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_SUCCESS, "spawn failed (status {status})"); // SAFETY: live handle. @@ -1131,26 +1160,25 @@ mod tests { #[ignore] #[cfg(target_os = "windows")] fn real_kill_terminates_blocked_child() { - let policy = CString::new(r#"{"version":"0.8.0-alpha"}"#).unwrap(); // Blocks reading a stdin line; we keep stdin open (never take it) so it // stays parked until killed. - let command = - CString::new("C:\\Windows\\System32\\cmd.exe /v:on /c set /p x= & echo done").unwrap(); + let request = request("C:\\Windows\\System32\\cmd.exe /v:on /c set /p x= & echo done"); let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); - // SAFETY: valid strings and out pointers. - let status = unsafe { mxc_spawn(policy.as_ptr(), command.as_ptr(), &mut handle, &mut err) }; + // SAFETY: valid string and out pointers. + let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_SUCCESS, "spawn failed (status {status})"); // Child should still be running. - let mut exit = 0; - let mut running = 0; + let mut exit = i32::MIN; + let mut running = -1; // SAFETY: live handle + out pointers. let mut timed_out = -1; let rc = unsafe { mxc_sandbox_try_wait(handle, &mut exit, &mut running, &mut timed_out) }; assert_eq!(rc, MXC_STATUS_SUCCESS); assert_eq!(running, 1, "blocked child should still be running"); + assert_eq!(exit, 0, "running poll should leave the zero sentinel"); assert_eq!(timed_out, 0); // SAFETY: live handle. diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index 19b52d787..0ac9e0a37 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -9,13 +9,13 @@ use std::ffi::{CStr, CString}; use std::ptr; use mxc_ffi::{ - mxc_available_backends_json, mxc_error_detail_free, mxc_platform_support_json, mxc_run, - mxc_run_request, mxc_run_result_free, mxc_sandbox_stderr_closer, mxc_sandbox_stdout_closer, + mxc_available_backends_json, mxc_error_detail_free, mxc_platform_support_json, mxc_run_request, + mxc_run_result_free, mxc_sandbox_stderr_closer, mxc_sandbox_stdout_closer, mxc_sandbox_warnings_json, mxc_spawn_request, mxc_stream_closer_close, mxc_stream_closer_free, mxc_string_free, mxc_version, MxcErrorDetail, MxcRunResult, MxcSandbox, }; -/// An empty, all-null result to hand to `mxc_run`. +/// An empty, all-null result to hand to `mxc_run_request`. fn zeroed_result() -> MxcRunResult { // SAFETY: `MxcRunResult` is `repr(C)` of `i32`s and nullable pointers, so an // all-zero value is valid (null pointers, zero status). @@ -23,24 +23,23 @@ fn zeroed_result() -> MxcRunResult { } #[test] -fn extern_run_rejects_malformed_policy() { - let policy = CString::new("not json").unwrap(); - let command = CString::new("echo hi").unwrap(); +fn extern_run_rejects_malformed_request() { + let request = CString::new("not json").unwrap(); let mut out = zeroed_result(); - // SAFETY: valid C strings and a valid out pointer. - let status = unsafe { mxc_run(policy.as_ptr(), command.as_ptr(), &mut out) }; + // SAFETY: valid C string and a valid out pointer. + let status = unsafe { mxc_run_request(request.as_ptr(), &mut out) }; assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); assert_eq!(out.status, status); assert!(!out.error.message_utf8.is_null()); - // SAFETY: the message is a valid C string filled by `mxc_run`. + // SAFETY: the message is a valid C string filled by `mxc_run_request`. let msg = unsafe { CStr::from_ptr(out.error.message_utf8) } .to_str() .unwrap(); - assert!(msg.contains("policy"), "unexpected message: {msg}"); + assert!(msg.contains("request"), "unexpected message: {msg}"); assert!(out.stdout_utf8.is_null()); - // SAFETY: `out` was filled by `mxc_run`; frees its owned strings. + // SAFETY: `out` was filled by `mxc_run_request`; frees its owned strings. unsafe { mxc_run_result_free(&mut out) }; assert!(out.error.message_utf8.is_null()); } @@ -181,14 +180,19 @@ fn extern_spawn_request_maps_capture_denials_to_process_container() { #[test] #[ignore = "requires an elevated, host-prepped Windows host (see docs/host-prep.md)"] fn extern_run_executes_command() { - let policy = CString::new( - r#"{"version":"0.7.0-alpha","filesystem":{"readwritePaths":["C:\\Windows\\Temp"]}}"#, + let request = CString::new( + r#"{ + "policy":{ + "version":"0.7.0-alpha", + "filesystem":{"readwritePaths":["C:\\Windows\\Temp"]} + }, + "command":"cmd /c echo hello-ffi" + }"#, ) .unwrap(); - let command = CString::new("cmd /c echo hello-ffi").unwrap(); let mut out = zeroed_result(); - // SAFETY: valid C strings and a valid out pointer. - let status = unsafe { mxc_run(policy.as_ptr(), command.as_ptr(), &mut out) }; + // SAFETY: valid C string and a valid out pointer. + let status = unsafe { mxc_run_request(request.as_ptr(), &mut out) }; assert_eq!(status, mxc_ffi::MXC_STATUS_SUCCESS, "status={status}"); assert_eq!(out.exit_code, 0); @@ -197,6 +201,6 @@ fn extern_run_executes_command() { let stdout = unsafe { CStr::from_ptr(out.stdout_utf8) }.to_str().unwrap(); assert!(stdout.contains("hello-ffi"), "stdout={stdout}"); - // SAFETY: `out` was filled by `mxc_run`. + // SAFETY: `out` was filled by `mxc_run_request`. unsafe { mxc_run_result_free(&mut out) }; }