From d2e37a4cbf554d2ee9afbbc4fe37437f7f24d85e Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 03:47:08 -0700 Subject: [PATCH 01/16] Expose telemetry through Rust SDK, C ABI, and .NET binding Route managed one-shot requests through the canonical MXC parser boundary and keep experimental authorization out of the wire document. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .github/copilot-instructions.md | 2 +- scripts/check-dotnet-api-parity.js | 171 +-- scripts/check-dotnet-bindings-codegen.js | 15 + .../Microsoft.Mxc.Sdk.Tests.csproj | 41 + .../MxcSandboxTests.cs | 120 +- .../MxcTelemetryTests.cs | 877 ++++++++++++ .../CanonicalRequestBuilder.cs | 324 +++++ sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs | 3 + sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs | 22 +- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 835 ++++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs | 24 + .../Microsoft.Mxc.Sdk/SandboxRequest.cs | 17 +- sdk/dotnet/README.md | 24 +- src/Cargo.lock | 2 + src/Cargo.toml | 2 +- src/core/mxc-sdk/Cargo.toml | 5 + src/core/mxc-sdk/README.md | 107 +- src/core/mxc-sdk/src/lib.rs | 8 + src/core/mxc-sdk/src/telemetry.rs | 525 ++++++++ src/core/mxc_engine/Cargo.toml | 3 + .../src/configs/process_container.rs | 15 +- src/core/mxc_engine/src/lib.rs | 6 + src/core/mxc_engine/src/policy.rs | 253 +++- src/core/wxc_common/src/config_parser.rs | 46 + src/core/wxc_common/src/telemetry/consent.rs | 8 +- .../src/telemetry/correlation_vector.rs | 1 - src/core/wxc_common/src/telemetry/mod.rs | 73 +- src/ffi/mxc_ffi/Cargo.toml | 13 +- src/ffi/mxc_ffi/src/error_detail.rs | 6 +- src/ffi/mxc_ffi/src/lib.rs | 1189 +++++++++++++++-- src/ffi/mxc_ffi/src/request.rs | 548 +------- src/ffi/mxc_ffi/src/state_aware.rs | 21 +- src/ffi/mxc_ffi/src/streaming.rs | 490 +++---- src/ffi/mxc_ffi/tests/ffi.rs | 62 +- tests/policy/request-directional-network.json | 89 +- tests/policy/request-process-container.json | 90 +- tests/policy/request-wslc.json | 54 +- 37 files changed, 4841 insertions(+), 1250 deletions(-) create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs create mode 100644 src/core/mxc-sdk/src/telemetry.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24953d0e9..5a6d7b404 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 one complete canonical MXC configuration JSON document and a separate experimental opt-in; they pass the raw JSON directly to `wxc_common::config_parser`, so the central parser remains the only schema/unknown-field boundary. `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..10a3ec2ac 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,22 @@ 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 rustWire = read( + "src", + "core", + "wxc_common", + "src", + "wire.rs" +); const rustNetworkPolicy = read( "src", "core", @@ -236,13 +238,6 @@ const rustNetworkPolicy = read( "policy", "network.rs" ); -const rustBindingRequest = read( - "src", - "ffi", - "mxc_ffi", - "src", - "request.rs" -); const managedRequest = read( "sdk", "dotnet", @@ -258,66 +253,94 @@ 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" +); + +// `captureDenials` is the one existing managed compatibility alias (MXC0001, +// removed in 1.0). MxcSandbox.PrepareRequest migrates it to +// containment.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") ); -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"]], +// Telemetry is execution metadata, not a containment restriction. Managed +// request serialization emits the canonical wire section directly, so compare +// its public shape with that central parser contract. +compare( + "sandbox policy canonical telemetry field", + managedSandboxPolicyFields.filter((field) => field === "telemetry"), + rustStructFields(rustWire, "MxcConfig").filter( + (field) => field === "telemetry" + ) +); +compareStructFields( + "telemetry settings", + rustWire, + "Telemetry", + 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 +356,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..3fd6be8a7 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, int experimental, MxcRunResult* @out)", + "mxc_spawn_request(byte* request_json_utf8, int experimental, 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 canonical 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/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..c671f4a4c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -54,7 +54,7 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() Capabilities = ["internetClient"], CaptureDenials = new CaptureDenialsPolicy { - OutputPath = "C:\\logs\\denials.json", + OutputPath = "C:\\denials.json", RetainEtl = true, }, Ui = new ProcessContainerUiPolicy @@ -112,7 +112,11 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() }, }, }, - "echo network"); + "echo network") + { + Containment = new ProcessContainerContainment(), + ContainerName = "golden-directional-network", + }; var wslc = new SandboxRequest( new SandboxPolicy { Version = "0.8.0-alpha" }, @@ -341,14 +345,13 @@ public void SandboxRequest_NestsCaptureDenialsUnderProcessContainer() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - var containment = root.GetProperty("containment"); - var capture = containment.GetProperty("captureDenials"); + var capture = root.GetProperty("processContainer").GetProperty("captureDenials"); - Assert.Equal("processContainer", containment.GetProperty("type").GetString()); + Assert.Equal("processcontainer", root.GetProperty("containment").GetString()); Assert.Equal("block", capture.GetProperty("mode").GetString()); Assert.False(capture.GetProperty("retainEtl").GetBoolean()); Assert.False(capture.TryGetProperty("outputPath", out _)); - Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); + Assert.False(root.TryGetProperty("captureDenials", out _)); } [Fact] @@ -366,15 +369,15 @@ public void SandboxRequest_MigratesLegacyCaptureDenialsWithoutMutation() using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - var containment = root.GetProperty("containment"); + var processContainer = root.GetProperty("processContainer"); - Assert.Equal("processContainer", containment.GetProperty("type").GetString()); + Assert.Equal("processcontainer", root.GetProperty("containment").GetString()); Assert.Equal( "allow", - containment.GetProperty("captureDenials").GetProperty("mode").GetString()); + processContainer.GetProperty("captureDenials").GetProperty("mode").GetString()); Assert.True( - containment.GetProperty("captureDenials").GetProperty("retainEtl").GetBoolean()); - Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); + processContainer.GetProperty("captureDenials").GetProperty("retainEtl").GetBoolean()); + Assert.False(root.TryGetProperty("captureDenials", out _)); Assert.Same(originalContainment, request.Containment); } @@ -425,12 +428,12 @@ public void SandboxRequest_AllowsEquivalentLegacyCaptureDenials() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - var containment = doc.RootElement.GetProperty("containment"); + var processContainer = doc.RootElement.GetProperty("processContainer"); - Assert.True(containment.GetProperty("leastPrivilege").GetBoolean()); + Assert.True(processContainer.GetProperty("leastPrivilege").GetBoolean()); Assert.Equal( captureDenials.OutputPath, - containment.GetProperty("captureDenials").GetProperty("outputPath").GetString()); + processContainer.GetProperty("captureDenials").GetProperty("outputPath").GetString()); } [Fact] @@ -471,12 +474,14 @@ public void SandboxRequest_SerializesExecutionSettings() using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - Assert.Equal("echo hi", root.GetProperty("command").GetString()); - Assert.Equal("process", root.GetProperty("containment").GetProperty("type").GetString()); - Assert.Equal("test-container", root.GetProperty("containerName").GetString()); - Assert.Equal(@"C:\work", root.GetProperty("workingDirectory").GetString()); - Assert.Equal("hello", root.GetProperty("environment").GetProperty("GREETING").GetString()); - Assert.True(root.GetProperty("experimental").GetBoolean()); + Assert.Equal( + "echo hi", + root.GetProperty("process").GetProperty("commandLine").GetString()); + Assert.Equal("process", root.GetProperty("containment").GetString()); + Assert.Equal("test-container", root.GetProperty("containerId").GetString()); + Assert.Equal(@"C:\work", root.GetProperty("process").GetProperty("cwd").GetString()); + Assert.Equal("GREETING=hello", root.GetProperty("process").GetProperty("env")[0].GetString()); + Assert.False(root.TryGetProperty("experimentalOptIn", out _)); } [Fact] @@ -511,7 +516,7 @@ public void SandboxRequest_SerializesCompleteProcessContainerOptions() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - var containment = doc.RootElement.GetProperty("containment"); + var containment = doc.RootElement.GetProperty("processContainer"); Assert.True(containment.GetProperty("leastPrivilege").GetBoolean()); Assert.True(containment.GetProperty("learningMode").GetBoolean()); @@ -546,9 +551,10 @@ public void SandboxRequest_SerializesWslcOptions() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - var containment = doc.RootElement.GetProperty("containment"); + var root = doc.RootElement; + var containment = root.GetProperty("experimental").GetProperty("wslc"); - Assert.Equal("wslc", containment.GetProperty("type").GetString()); + Assert.Equal("wslc", root.GetProperty("containment").GetString()); Assert.Equal("python:3.12", containment.GetProperty("image").GetString()); Assert.Equal(4, containment.GetProperty("cpuCount").GetInt32()); Assert.Equal(4096, containment.GetProperty("memoryMb").GetInt64()); @@ -557,6 +563,8 @@ public void SandboxRequest_SerializesWslcOptions() containment.GetProperty("portMappings")[0].GetProperty("windowsPort").GetInt32()); Assert.Equal(80, containment.GetProperty("portMappings")[0].GetProperty("containerPort").GetInt32()); + Assert.Equal("tcp", + containment.GetProperty("portMappings")[0].GetProperty("protocol").GetString()); } [Fact] @@ -618,6 +626,49 @@ public void SandboxPolicy_SerializesDirectionalNetworking() network.GetProperty("runtimeConfig").GetProperty("networkProxy").GetString()); } + [Fact] + public void SandboxRequest_RejectsMixedLegacyAndDirectionalNetworking() + { + var request = new SandboxRequest( + new SandboxPolicy + { + Version = "0.8.0-alpha", + Network = new NetworkPolicy + { + AllowOutbound = true, + Egress = new NetworkEgressPolicy { Default = NetworkAction.Deny }, + }, + }, + "echo hi"); + + var exception = Assert.Throws( + () => MxcSandbox.SerializeRequest(request)); + + Assert.Equal(ErrorCode.MalformedRequest, exception.Code); + Assert.Contains("cannot be combined", exception.Message); + } + + [Fact] + public void SandboxRequest_RejectsProcessContainerHostRulesWithoutOutbound() + { + var request = new SandboxRequest( + new SandboxPolicy + { + Version = "0.7.0-alpha", + Network = new NetworkPolicy { AllowedHosts = ["example.com"] }, + }, + "echo hi") + { + Containment = new ProcessContainerContainment(), + }; + + var exception = Assert.Throws( + () => MxcSandbox.SerializeRequest(request)); + + Assert.Equal(ErrorCode.MalformedRequest, exception.Code); + Assert.Contains("require allowOutbound", exception.Message); + } + [Fact] public void SandboxPolicy_OmitsCaptureDenialsWhenNotConfigured() { @@ -709,7 +760,7 @@ public void RunRequest_DirectionalNetworkingReachesNativeVersionValidation() var ex = Assert.Throws(() => MxcSandbox.Run(request)); Assert.Equal(ErrorCode.MalformedRequest, ex.Code); - Assert.Contains("network egress/ingress/runtimeConfig", ex.Message); + Assert.Contains("require schema version 0.8", ex.Message); } [Theory] @@ -818,15 +869,32 @@ public void SerializeRequest_StripsLegacyCaptureDenialsFromThePolicy() using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); + Assert.False(root.TryGetProperty("captureDenials", out _)); Assert.Equal( "block", - root.GetProperty("containment") + root.GetProperty("processContainer") .GetProperty("captureDenials") .GetProperty("mode") .GetString()); } + [Fact] + public void SerializeRequest_PreservesTelemetryWhenMigratingLegacyCaptureDenials() + { + var policy = CreateLegacyCaptureDenialsPolicy( + new CaptureDenialsPolicy(), + "0.9.0-alpha"); + policy.Telemetry = new TelemetrySettings { Enabled = true }; + var request = new SandboxRequest(policy, "echo hi"); + + using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); + var root = doc.RootElement; + + Assert.True( + root.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + Assert.False(root.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..b94d93e09 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -0,0 +1,877 @@ +// 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.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); + } + +#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_PersistedDecisionWinsLateCancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Granted, + StoredState = TelemetryConsentState.Granted, + EffectiveState = TelemetryConsentState.Granted, + }; + + Assert.Same(outcome, MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + + [Fact] + public void FinalizeAsyncOutcome_CanceledDismissalThrows() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Dismissed, + StoredState = TelemetryConsentState.Undetermined, + EffectiveState = TelemetryConsentState.Undetermined, + }; + + Assert.ThrowsAny( + () => MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + + [Fact] + public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows() + { + 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/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs new file mode 100644 index 000000000..e616ef5b8 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Microsoft.Mxc.Sdk; + +internal static class CanonicalRequestBuilder +{ + internal static string Serialize(SandboxRequest request, JsonSerializerOptions options) + { + if (string.IsNullOrEmpty(request.Policy.Version)) + { + throw new MxcException(ErrorCode.MalformedRequest, "Policy version is required."); + } + + var policy = request.Policy; + var filesystem = policy.Filesystem ?? new FilesystemPolicy(); + var clearPolicy = filesystem.ClearPolicyOnExit ?? true; + var root = new JsonObject + { + ["version"] = policy.Version, + ["containerId"] = request.ContainerName ?? Guid.NewGuid().ToString("N"), + ["lifecycle"] = new JsonObject + { + ["destroyOnExit"] = true, + ["preservePolicy"] = !clearPolicy, + }, + ["process"] = BuildProcess(request, policy), + ["filesystem"] = new JsonObject + { + ["readwritePaths"] = JsonSerializer.SerializeToNode( + filesystem.ReadwritePaths, + options), + ["readonlyPaths"] = JsonSerializer.SerializeToNode( + filesystem.ReadonlyPaths, + options), + ["deniedPaths"] = JsonSerializer.SerializeToNode( + filesystem.DeniedPaths, + options), + }, + }; + + if (policy.Ui is not null) + { + root["ui"] = new JsonObject + { + ["disable"] = !policy.Ui.AllowWindows, + ["clipboard"] = Wire(policy.Ui.Clipboard), + ["injection"] = policy.Ui.AllowInputInjection, + }; + } + + var directional = HasDirectionalNetwork(policy.Network, request.Containment); + if (directional && HasLegacyNetwork(policy.Network)) + { + throw new MxcException( + ErrorCode.MalformedRequest, + "Legacy network fields cannot be combined with directional network fields."); + } + if (RequiresOutboundForHostRules(request.Containment) + && policy.Network is { AllowOutbound: false } networkWithRules + && (networkWithRules.AllowedHosts.Count != 0 + || networkWithRules.BlockedHosts.Count != 0)) + { + throw new MxcException( + ErrorCode.MalformedRequest, + "allowedHosts/blockedHosts require allowOutbound to be true."); + } + AddNetwork(root, policy.Network, directional, options); + AddContainment(root, request.Containment, policy.Network, directional, options); + + if (policy.Telemetry is not null) + { + root["telemetry"] = new JsonObject { ["enabled"] = policy.Telemetry.Enabled }; + } + + return root.ToJsonString(options); + } + + private static JsonObject BuildProcess(SandboxRequest request, SandboxPolicy policy) + { + var process = new JsonObject + { + ["commandLine"] = request.Command, + ["timeout"] = policy.TimeoutMs ?? 0, + }; + if (request.WorkingDirectory is not null) + { + process["cwd"] = request.WorkingDirectory; + } + if (request.Environment.Count != 0) + { + process["env"] = JsonSerializer.SerializeToNode( + request.Environment.Select(pair => $"{pair.Key}={pair.Value}").ToArray()); + } + return process; + } + + private static bool HasDirectionalNetwork( + NetworkPolicy? network, + SandboxContainment containment) => + containment is ProcessContainerContainment + { + Network.AllowedProxyPeer: { } allowedProxyPeer, + } && !string.IsNullOrWhiteSpace(allowedProxyPeer) + || network?.Egress is not null + || network?.Ingress is not null + || network?.RuntimeConfig?.NetworkProxy is not null; + + private static bool HasLegacyNetwork(NetworkPolicy? network) => + network is not null + && (network.AllowOutbound + || network.AllowLocalNetwork + || network.AllowedHosts.Count != 0 + || network.BlockedHosts.Count != 0 + || network.Proxy is not null); + + private static bool RequiresOutboundForHostRules(SandboxContainment containment) => + containment is ProcessContainerContainment + || containment is ProcessContainment && OperatingSystem.IsWindows(); + + private static void AddNetwork( + JsonObject root, + NetworkPolicy? network, + bool directional, + JsonSerializerOptions options) + { + if (directional) + { + if (network?.Egress is not null || network?.Ingress is not null) + { + var wire = new JsonObject(); + if (network.Egress is not null) + { + wire["egress"] = JsonSerializer.SerializeToNode(network.Egress, options); + } + if (network.Ingress is not null) + { + wire["ingress"] = JsonSerializer.SerializeToNode(network.Ingress, options); + } + root["network"] = wire; + } + if (network?.RuntimeConfig is not null) + { + root["runtimeConfig"] = JsonSerializer.SerializeToNode( + network.RuntimeConfig, + options); + } + return; + } + + var legacy = new JsonObject + { + ["defaultPolicy"] = network?.AllowOutbound == true ? "allow" : "block", + }; + if (network is not null) + { + legacy["allowLocalNetwork"] = network.AllowLocalNetwork; + legacy["allowedHosts"] = JsonSerializer.SerializeToNode(network.AllowedHosts, options); + legacy["blockedHosts"] = JsonSerializer.SerializeToNode(network.BlockedHosts, options); + if (network.Proxy is not null) + { + legacy["proxy"] = JsonSerializer.SerializeToNode(network.Proxy, options); + } + } + root["network"] = legacy; + } + + private static void AddContainment( + JsonObject root, + SandboxContainment containment, + NetworkPolicy? network, + bool directional, + JsonSerializerOptions options) + { + switch (containment) + { + case ProcessContainment: + root["containment"] = "process"; + if (OperatingSystem.IsWindows()) + { + AddProcessContainer( + root, + new ProcessContainerContainment(), + network, + directional, + "process", + options); + } + break; + case ProcessContainerContainment processContainer: + AddProcessContainer( + root, + processContainer, + network, + directional, + "processcontainer", + options); + break; + case WslcContainment wslc: + root["containment"] = "wslc"; + root["experimental"] = new JsonObject + { + ["wslc"] = BuildWslc(wslc), + }; + break; + default: + throw new ArgumentException( + $"Unsupported containment type {containment.GetType().Name}.", + nameof(containment)); + } + } + + private static JsonObject BuildWslc(WslcContainment wslc) + { + var wire = new JsonObject + { + ["image"] = wslc.Image, + ["gpu"] = wslc.Gpu, + }; + if (wslc.ImageTarPath is not null) + { + wire["imageTarPath"] = wslc.ImageTarPath; + } + if (wslc.CpuCount is not null) + { + wire["cpuCount"] = wslc.CpuCount; + } + if (wslc.MemoryMb is not null) + { + wire["memoryMb"] = wslc.MemoryMb; + } + if (wslc.StoragePath is not null) + { + wire["storagePath"] = wslc.StoragePath; + } + if (wslc.PortMappings.Count != 0) + { + wire["portMappings"] = new JsonArray( + wslc.PortMappings + .Select(mapping => (JsonNode)new JsonObject + { + ["windowsPort"] = mapping.WindowsPort, + ["containerPort"] = mapping.ContainerPort, + ["protocol"] = "tcp", + }) + .ToArray()); + } + return wire; + } + + private static void AddProcessContainer( + JsonObject root, + ProcessContainerContainment containment, + NetworkPolicy? network, + bool directional, + string wireContainment, + JsonSerializerOptions options) + { + root["containment"] = wireContainment; + var capabilities = new List(containment.Capabilities); + var allowsInternet = directional + ? network?.Egress is { } egress + && (egress.Default == NetworkAction.Allow || egress.Allow?.Count > 0) + : network?.AllowOutbound == true; + var allowsLocalNetwork = directional + ? network?.Ingress?.Default == NetworkAction.Allow + : network?.AllowLocalNetwork == true; + AddCapability(capabilities, "internetClient", allowsInternet); + AddCapability(capabilities, "privateNetworkClientServer", allowsLocalNetwork); + + var section = new JsonObject + { + ["leastPrivilege"] = containment.LeastPrivilege, + ["capabilities"] = JsonSerializer.SerializeToNode(capabilities, options), + }; + if (containment.LearningMode) + { + section["learningMode"] = true; + } + if (containment.Ui is not null) + { + section["ui"] = JsonSerializer.SerializeToNode(containment.Ui, options); + } + if (containment.Network?.AllowedProxyPeer is not null) + { + section["network"] = JsonSerializer.SerializeToNode(containment.Network, options); + } + if (containment.CaptureDenials is not null) + { + section["captureDenials"] = JsonSerializer.SerializeToNode( + containment.CaptureDenials, + options); + } + root["processContainer"] = section; + + if (!directional && root["network"] is JsonObject legacy) + { + legacy["enforcementMode"] = + network is { AllowedHosts.Count: > 0 } or { BlockedHosts.Count: > 0 } + ? "both" + : "capabilities"; + } + } + + private static void AddCapability(List capabilities, string value, bool add) + { + if (add && !capabilities.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + capabilities.Add(value); + } + } + + private static string Wire(ClipboardPolicy value) => value switch + { + ClipboardPolicy.None => "none", + ClipboardPolicy.Read => "read", + ClipboardPolicy.Write => "write", + ClipboardPolicy.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; +} 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..5292d0905 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs @@ -121,7 +121,10 @@ public static RunResult Run(SandboxRequest request) fixed (byte* requestPtr = requestBuf) { MxcRunResult result = default; - var status = NativeMethods.mxc_run_request(requestPtr, &result); + var status = NativeMethods.mxc_run_request( + requestPtr, + request.Experimental ? 1 : 0, + &result); try { if (status != (int)ErrorCode.Success) @@ -199,7 +202,11 @@ public static MxcSandboxProcess Spawn(SandboxRequest request) { NativeSandbox* handle = null; MxcErrorDetail error = default; - var status = NativeMethods.mxc_spawn_request(requestPtr, &handle, &error); + var status = NativeMethods.mxc_spawn_request( + requestPtr, + request.Experimental ? 1 : 0, + &handle, + &error); if (status != (int)ErrorCode.Success) { // `finally`, not a straight-line free: marshalling the strings or @@ -245,7 +252,7 @@ internal static string SerializePolicy(SandboxPolicy policy) internal static string SerializeRequest(SandboxRequest request) { ArgumentNullException.ThrowIfNull(request); - return JsonSerializer.Serialize(PrepareRequest(request), JsonOptions); + return CanonicalRequestBuilder.Serialize(PrepareRequest(request), JsonOptions); } private static SandboxRequest PrepareRequest(SandboxRequest request) @@ -313,14 +320,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..78b674aad --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -0,0 +1,835 @@ +// 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 stops waiting for the presenter and prevents its decision from being accepted + /// when observed before native persistence. It does not cancel the presenter's underlying task. + /// + public static Task RequestConsentAsync( + Func> presenter, + 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)); + } + } + + private static TelemetryConsentPrompt ParseConsentPrompt(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + return new TelemetryConsentPrompt + { + ResourceVersion = root.GetProperty("resourceVersion").GetUInt32(), + Locale = root.GetProperty("locale").GetString() ?? string.Empty, + Title = ParseConsentMessage(root.GetProperty("title")), + Body = ParseConsentMessage(root.GetProperty("body")), + AffirmativeLabel = ParseConsentMessage(root.GetProperty("affirmativeLabel")), + NegativeLabel = ParseConsentMessage(root.GetProperty("negativeLabel")), + LearnMoreLabel = ParseConsentMessage(root.GetProperty("learnMoreLabel")), + LearnMoreUrl = root.GetProperty("learnMoreUrl").GetString() ?? string.Empty, + }; + } + + private static TelemetryConsentMessage ParseConsentMessage(JsonElement value) => new() + { + Id = value.GetProperty("id").GetString() ?? string.Empty, + Text = value.GetProperty("text").GetString() ?? string.Empty, + }; + + private static TelemetryConsentStatus ParseConsentStatus(string json) + { + using var doc = JsonDocument.Parse(json); + return ParseConsentStatus(doc.RootElement); + } + + private static TelemetryConsentStatus ParseConsentStatus(JsonElement root) => new() + { + StoredState = ParseConsentState(root.GetProperty("storedState").GetString() ?? string.Empty), + EffectiveState = ParseConsentState(root.GetProperty("effectiveState").GetString() ?? string.Empty), + Reason = root.TryGetProperty("reason", out var reason) && reason.ValueKind != JsonValueKind.Null + ? ParseConsentStatusReason(reason.GetString() ?? string.Empty) + : null, + Policy = ParsePolicyState(root.GetProperty("policy").GetString() ?? string.Empty), + }; + + private static TelemetryConsentOutcome ParseConsentOutcome(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var status = ParseConsentStatus(root); + return new TelemetryConsentOutcome + { + Result = ParseConsentResult(root.GetProperty("result").GetString() ?? string.Empty), + StoredState = status.StoredState, + EffectiveState = status.EffectiveState, + Reason = status.Reason, + Policy = status.Policy, + }; + } + + private static TelemetryConsentState ParseConsentState(string value) => value switch + { + "granted" => TelemetryConsentState.Granted, + "denied" => TelemetryConsentState.Denied, + "undetermined" => TelemetryConsentState.Undetermined, + "not-applicable" => TelemetryConsentState.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent state '{value}'"), + }; + + private static TelemetryPolicyState ParsePolicyState(string value) => value switch + { + "unrestricted" => TelemetryPolicyState.Unrestricted, + "allowed" => TelemetryPolicyState.Allowed, + "blocked" => TelemetryPolicyState.Blocked, + "not-applicable" => TelemetryPolicyState.NotApplicable, + _ => throw new JsonException($"unknown telemetry policy state '{value}'"), + }; + + private static TelemetryConsentStatusReason ParseConsentStatusReason(string value) => value switch + { + "no-record" => TelemetryConsentStatusReason.NoRecord, + "store-unreadable" => TelemetryConsentStatusReason.StoreUnreadable, + "store-malformed" => TelemetryConsentStatusReason.StoreMalformed, + "consent-schema-unsupported" => TelemetryConsentStatusReason.ConsentSchemaUnsupported, + "prompt-version-missing" => TelemetryConsentStatusReason.PromptVersionMissing, + "prompt-version-unsupported" => TelemetryConsentStatusReason.PromptVersionUnsupported, + "not-applicable" => TelemetryConsentStatusReason.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent status reason '{value}'"), + }; + + private static TelemetryConsentResult ParseConsentResult(string value) => value switch + { + "granted" => TelemetryConsentResult.Granted, + "denied" => TelemetryConsentResult.Denied, + "dismissed" => TelemetryConsentResult.Dismissed, + "withdrawn" => TelemetryConsentResult.Withdrawn, + "alreadyGranted" => TelemetryConsentResult.AlreadyGranted, + "policyBlocked" => TelemetryConsentResult.PolicyBlocked, + "notApplicable" => TelemetryConsentResult.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent result '{value}'"), + }; +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index e99abb605..9870680ae 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; } + + /// + /// Canonical per-invocation telemetry settings serialized 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/Microsoft.Mxc.Sdk/SandboxRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs index 44deeed16..be06c152e 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs @@ -21,39 +21,32 @@ public SandboxRequest(SandboxPolicy policy, string command) } /// The cross-platform restrictions applied to the sandbox. - [JsonPropertyName("policy")] public SandboxPolicy Policy { get; } /// The command line to run. - [JsonPropertyName("command")] public string Command { get; } /// The containment backend and its backend-specific configuration. - [JsonPropertyName("containment")] public SandboxContainment Containment { get; set; } = new ProcessContainment(); /// An optional caller-selected container name. - [JsonPropertyName("containerName")] public string? ContainerName { get; set; } /// An optional initial working directory. - [JsonPropertyName("workingDirectory")] public string? WorkingDirectory { get; set; } /// Environment variables supplied to the sandboxed process. - [JsonPropertyName("environment")] public Dictionary Environment { get; set; } = new(); /// Opt in to experimental containment backends and features. - [JsonPropertyName("experimental")] public bool Experimental { get; set; } } -/// A containment backend selected by a . -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(ProcessContainment), "process")] -[JsonDerivedType(typeof(ProcessContainerContainment), "processContainer")] -[JsonDerivedType(typeof(WslcContainment), "wslc")] +/// +/// A containment backend selected by a . +/// This is an authoring model; performs its canonical +/// wire projection. +/// public abstract class SandboxContainment; /// diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 7849735f6..ecb6ef3bb 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -484,11 +484,15 @@ MXC telemetry is Windows-only and remains off until both of these are true: 1. the user has explicitly granted MXC-owned consent, and 2. the caller opts this invocation in via telemetry settings. -Telemetry remains off by default unless the caller opts in with `SandboxPolicy.TelemetryEnabled = true` (or the equivalent phase-level `TelemetryEnabled` setting for state-aware requests) and applicable Windows consent/policy gates permit collection. - -Any .NET consent surface should stay UI-agnostic, present the canonical -resource verbatim through a host callback, persist only explicit yes/no -decisions, treat dismissal and failures as non-grants, and follow the rules in +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. + +The .NET consent APIs are UI-agnostic: `MxcTelemetry.RequestConsent` supplies +the canonical resource to a host callback, while `GetConsentStatus`, +`NeedsConsentPrompt`, and `WithdrawConsent` provide maintenance operations. +They persist only explicit yes/no decisions and treat dismissal and failures +as non-grants. See [`docs/telemetry/telemetry-consent-design.md`](../../docs/telemetry/telemetry-consent-design.md) and its [SDK presenter requirements](../../docs/telemetry/telemetry-consent-design.md#sdk-presenter-requirements). @@ -498,9 +502,13 @@ and its An IT administrator can still block MXC telemetry device-wide via MXC's own registry policy setting. See [`docs/telemetry/telemetry-administrative-policy.md`](../../docs/telemetry/telemetry-administrative-policy.md) -for the stable registry contract and interaction rules. Policy and consent -queries are not yet exposed by the .NET SDK; any eventual query must fail -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 104f07376..36e5542da 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1567,6 +1567,8 @@ dependencies = [ "mxc-sdk", "serde", "serde_json", + "tempfile", + "wxc_common", ] [[package]] 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..8411cdab0 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -16,6 +16,11 @@ 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"] +# Enables the hidden policy-JSON bridge consumed by co-versioned language +# bindings. +ffi-internals = ["mxc_engine/ffi-internals"] [dependencies] wxc_common.workspace = true diff --git a/src/core/mxc-sdk/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..04fcbbb86 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -144,6 +144,14 @@ mod sandbox; +pub mod telemetry; + +#[cfg(feature = "ffi-internals")] +#[doc(hidden)] +pub mod ffi_internals { + pub use mxc_engine::ffi_internals::build_request_from_json; +} + 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/Cargo.toml b/src/core/mxc_engine/Cargo.toml index 3b76341af..6fb13470d 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -45,6 +45,9 @@ seatbelt_common = { workspace = true } [features] default = [] +# Enables the hidden policy-JSON bridge consumed by co-versioned language +# bindings through mxc-sdk. +ffi-internals = [] # Enables constructing the run-to-completion runner for each experimental # backend. Mirrors the executor binaries' feature set so backend selection can # live in one place. The build-time binary-staging that some backends also need diff --git a/src/core/mxc_engine/src/configs/process_container.rs b/src/core/mxc_engine/src/configs/process_container.rs index 84ca3fdc7..f86b63107 100644 --- a/src/core/mxc_engine/src/configs/process_container.rs +++ b/src/core/mxc_engine/src/configs/process_container.rs @@ -173,19 +173,6 @@ pub(crate) fn apply( config["containment"] = json!(containment); - if wxc_common::directional_network_support(&policy.version) == Some(false) { - if process_container.learning_mode { - return Err(wxc_common::mxc_error::MxcError::malformed_request( - "processContainer.learningMode requires schema version 0.8 or later", - )); - } - if process_container.capture_denials.is_some() { - return Err(wxc_common::mxc_error::MxcError::malformed_request( - "processContainer.captureDenials requires schema version 0.8 or later", - )); - } - } - let mut capabilities = process_container.capabilities.clone(); if let Some(net) = &policy.network { let (allows_internet, allows_local_network) = match network_format { @@ -458,7 +445,7 @@ mod tests { "captureDenials", ), ] { - let error = build_wire_config( + let error = crate::policy::build_request_with_containment( &policy_for_version("0.7.0-alpha", None), &crate::policy::Containment::ProcessContainer(process_container), None, diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index b7d09acb3..154f81537 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -60,6 +60,12 @@ pub use state_aware::{ exec_state_aware_attached, exec_state_aware_json, run_state_aware, run_state_aware_json, }; +#[cfg(feature = "ffi-internals")] +#[doc(hidden)] +pub mod ffi_internals { + pub use crate::policy::build_request_from_json; +} + use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ContainmentBackend, FailurePhase, ScriptResponse}; use wxc_common::sandbox_process::{SandboxProcess, StreamCloser}; diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 208736225..4bd0a1685 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -21,9 +21,13 @@ use crate::configs::process_container; use crate::configs::ProcessContainer; #[cfg(test)] use crate::configs::{CaptureDenials, CaptureDenialsMode}; +#[cfg(feature = "ffi-internals")] +use crate::ErrorCode; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ExecutionRequest, TelemetryConfig}; use wxc_common::mxc_error::MxcError; +#[cfg(feature = "ffi-internals")] +use wxc_common::state_aware_request::MxcRequest; #[cfg(target_os = "linux")] use network::has_host_rules; @@ -588,6 +592,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 +712,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 +779,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 +794,59 @@ 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(()) +} + +/// Build a request from a complete canonical MXC configuration document. +#[cfg(feature = "ffi-internals")] +#[doc(hidden)] +pub fn build_request_from_json(request_json: &str) -> Result { + let mut logger = Logger::new(Mode::Buffer); + let parsed = wxc_common::config_parser::load_mxc_request_from_json_with_options( + request_json, + &mut logger, + wxc_common::config_parser::LoadOptions { + is_base64: false, + allow_missing_command: false, + }, + ) + .map_err(|error| { + use wxc_common::config_parser::ParseError; + + let message = match error { + ParseError::Decode(error) + | ParseError::OneShot(error) + | ParseError::OneShotMalformed(error) => error.to_string(), + ParseError::StateAware(error) => error.to_string(), + }; + crate::Error::new( + ErrorCode::MalformedRequest, + format!("failed to parse request JSON: {message}"), + ) + })?; + let MxcRequest::OneShot(inner) = parsed else { + return Err(crate::Error::new( + ErrorCode::MalformedRequest, + "failed to parse request JSON: expected a one-shot request", + )); + }; + let requested_sandbox_kind = inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.requested_sandbox_kind) + .unwrap_or_else(|| inner.containment.wire_name()); + Ok(SandboxRequest { + inner, + requested_sandbox_kind, + }) +} + /// 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 +854,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 +1018,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 @@ -1070,6 +1145,146 @@ mod tests { ); } + fn complete_one_shot_wire( + mut config: serde_json::Value, + command: &str, + cwd: Option<&str>, + env: &[&str], + ) -> serde_json::Value { + config["process"]["commandLine"] = serde_json::json!(command); + if let Some(cwd) = cwd { + config["process"]["cwd"] = serde_json::json!(cwd); + } + if !env.is_empty() { + config["process"]["env"] = serde_json::json!(env); + } + config + } + + #[test] + fn rust_builder_matches_canonical_cross_language_fixtures() { + use crate::configs::{ + CaptureDenials, ProcessContainer, ProcessContainerNetwork, + ProcessContainerSystemSettings, ProcessContainerUi, ProcessContainerUiIsolation, + }; + + let process_policy: super::SandboxPolicy = serde_json::from_value(serde_json::json!({ + "version": "0.8.0-alpha", + "filesystem": { + "readwritePaths": ["C:\\work"], + "readonlyPaths": ["C:\\input"], + "deniedPaths": ["C:\\secret"], + "clearPolicyOnExit": true + }, + "network": { + "egress": { "default": "deny" }, + "ingress": { "default": "allow", "hostLoopback": "deny" }, + "runtimeConfig": { "networkProxy": "http://127.0.0.1:8080" } + }, + "ui": { "clipboard": "read" }, + "timeoutMs": 30000 + })) + .expect("process policy parses"); + let process_containment = ProcessContainer { + least_privilege: true, + capabilities: vec!["internetClient".to_string()], + capture_denials: Some(CaptureDenials { + output_path: Some("C:\\denials.json".to_string()), + retain_etl: true, + ..Default::default() + }), + ui: Some(ProcessContainerUi { + isolation: ProcessContainerUiIsolation::Handles, + system_settings: ProcessContainerSystemSettings::Parameters, + ime: true, + ..Default::default() + }), + network: Some(ProcessContainerNetwork { + allowed_proxy_peer: Some("Contoso.App_123".to_string()), + }), + ..Default::default() + }; + let process = super::build_wire_config( + &process_policy, + &super::Containment::ProcessContainer(process_containment), + Some("golden-process-container"), + ) + .map(|config| { + complete_one_shot_wire(config, "echo parity", Some("C:\\work"), &["PARITY=true"]) + }) + .expect("process config builds"); + + let directional_policy: super::SandboxPolicy = serde_json::from_value(serde_json::json!({ + "version": "0.8.0-alpha", + "network": { + "egress": { + "default": "deny", + "allow": [{ + "to": [{ + "cidr": "10.20.0.0/16", + "except": ["10.20.30.0/24"] + }], + "ports": [{ + "protocol": "tcp", + "port": 443, + "endPort": 444 + }] + }] + }, + "ingress": { + "default": "deny", + "hostLoopback": "deny" + } + } + })) + .expect("directional policy parses"); + let directional = super::build_wire_config( + &directional_policy, + &super::Containment::ProcessContainer(ProcessContainer::default()), + Some("golden-directional-network"), + ) + .map(|config| complete_one_shot_wire(config, "echo network", None, &[])) + .expect("directional config builds"); + + let wslc_policy: super::SandboxPolicy = + serde_json::from_value(serde_json::json!({ "version": "0.8.0-alpha" })) + .expect("WSLC policy parses"); + let wslc = super::build_wire_config( + &wslc_policy, + &super::Containment::Wslc(super::WslcSection { + image: "alpine:3.20".to_string(), + image_tar_path: Some("C:\\images\\alpine.tar".to_string()), + cpu_count: Some(2), + memory_mb: Some(1024), + gpu: true, + storage_path: Some("C:\\wslc".to_string()), + port_mappings: vec![(8080, 80)], + }), + Some("golden-wslc"), + ) + .map(|config| complete_one_shot_wire(config, "printf parity", None, &[])) + .expect("WSLC config builds"); + + for (actual, fixture) in [ + ( + process, + include_str!("../../../../tests/policy/request-process-container.json"), + ), + ( + directional, + include_str!("../../../../tests/policy/request-directional-network.json"), + ), + ( + wslc, + include_str!("../../../../tests/policy/request-wslc.json"), + ), + ] { + let expected: serde_json::Value = + serde_json::from_str(fixture).expect("canonical fixture parses"); + assert_eq!(actual, expected); + } + } + #[cfg(target_os = "windows")] #[test] fn powershell_policy_grants_system_drive_root() { @@ -1635,15 +1850,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 +1865,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 +1880,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/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index ea07e366e..c2c19cf66 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -918,6 +918,26 @@ fn convert_wire_config( // Validate the schema version up front so an unsupported version fails fast. validate_schema_version(&schema_version)?; + if crate::directional_network_support(&schema_version) == Some(false) { + if cfg + .process_container + .as_ref() + .is_some_and(|section| section.learning_mode.unwrap_or(false)) + { + return Err(WxcError::ConfigParse( + "processContainer.learningMode requires schema version 0.8 or later".to_string(), + )); + } + if cfg + .process_container + .as_ref() + .is_some_and(|section| section.capture_denials.is_some()) + { + return Err(WxcError::ConfigParse( + "processContainer.captureDenials requires schema version 0.8 or later".to_string(), + )); + } + } let container_id = cfg.container_id.unwrap_or_default(); // Process section: required for one-shot and state-aware exec; optional for @@ -2886,6 +2906,32 @@ mod tests { .contains(&"permissiveLearningMode".to_string())); } + #[test] + fn pre_08_rejects_process_container_learning_features() { + for (field, expected) in [ + (r#""learningMode": true"#, "processContainer.learningMode"), + (r#""captureDenials": {}"#, "processContainer.captureDenials"), + ] { + let json = format!( + r#"{{ + "version": "0.7.0-alpha", + "process": {{"commandLine": "echo x"}}, + "containment": "processcontainer", + "processContainer": {{{field}}} + }}"# + ); + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let error = load_request(&encoded, &mut logger, true) + .expect_err("schema 0.7 must reject 0.8 ProcessContainer features"); + assert!( + error.to_string().contains(expected), + "unexpected error: {error}" + ); + } + } + #[test] fn explicit_learning_mode_capabilities_are_rejected_case_insensitively() { for capability in [ diff --git a/src/core/wxc_common/src/telemetry/consent.rs b/src/core/wxc_common/src/telemetry/consent.rs index 4e15ec465..70a729849 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 1d907a170..6a8841187 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`]. @@ -1068,6 +1041,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..e921ddf63 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -13,8 +13,8 @@ name = "mxc_ffi" crate-type = ["cdylib", "staticlib", "lib"] [dependencies] -mxc-sdk = { workspace = true } -serde = { workspace = true } +mxc-sdk = { workspace = true, features = ["ffi-internals"] } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } [features] @@ -32,6 +32,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..7b4de0228 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -3,17 +3,15 @@ //! 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 complete canonical +//! one-shot 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. +//! complete canonical one-shot 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 +35,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 the canonical +//! top-level `telemetry.enabled` field. +//! - **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 +65,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 +183,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 +213,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 +334,13 @@ 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 a canonical MXC configuration document. +/// A nonzero `experimental` is the separate operational opt-in corresponding +/// to the executor's `--experimental` flag. It is intentionally outside the +/// document because the canonical `experimental` object carries backend +/// configuration, not authorization to use experimental features. /// /// # Safety /// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. @@ -301,14 +349,18 @@ pub unsafe extern "C" fn mxc_run( #[no_mangle] pub unsafe extern "C" fn mxc_run_request( request_json_utf8: *const c_char, + experimental: i32, out: *mut MxcRunResult, ) -> i32 { if out.is_null() { 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, experimental != 0)) + .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. @@ -316,7 +368,7 @@ pub unsafe extern "C" fn mxc_run_request( status } -fn run_request_inner(request_json_utf8: *const c_char) -> MxcRunResult { +fn run_request_inner(request_json_utf8: *const c_char, experimental: bool) -> MxcRunResult { // SAFETY: caller contract on `mxc_run_request`; borrowed only within scope. let request_json = match unsafe { cstr_to_str(request_json_utf8) } { Some(value) => value, @@ -325,51 +377,13 @@ fn run_request_inner(request_json_utf8: *const c_char) -> MxcRunResult { } None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "request JSON is not UTF-8"), }; - let request = match request::build_request_from_json(request_json) { + let request = match request::build_request_from_json(request_json, experimental) { Ok(request) => request, Err(error) => return MxcRunResult::from_sdk_error(&error), }; 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 +437,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 +446,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 +470,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 +486,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 +499,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 +529,398 @@ 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(), 0, &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#"{"version":"0.8.0-alpha","process":{"commandLine":"echo hi"}}"#, + None, + ), + ( + r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#, + Some(true), + ), + ( + r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"telemetry":{"enabled":false}}"#, + Some(false), + ), + ] { + let request = request::build_request_from_json(request_json, false) + .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#"{"version":"0.7.0-alpha","process":{"commandLine":"echo hi"}}"#) + .unwrap(); + // SAFETY: valid string, deliberately-null out pointer. + let status = unsafe { mxc_run_request(request.as_ptr(), 0, ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -655,21 +1029,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 document with no command reaches the canonical + /// parser's 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#"{"version":"0.8.0-alpha","process":{}}"#); 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`. + assert!(message.contains("commandLine")); + // 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..acf173e63 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -1,534 +1,60 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Co-versioned JSON request contract used by language bindings. +//! Canonical one-shot request parsing for language bindings. -use std::collections::BTreeMap; +use mxc_sdk::{Error, SandboxRequest}; -use mxc_sdk::configs::{ - CaptureDenials, ProcessContainer, ProcessContainerNetwork, ProcessContainerSystemSettings, - ProcessContainerUi, ProcessContainerUiIsolation, -}; -use mxc_sdk::{ - build_request_with_containment, Containment, Error, ErrorCode, SandboxPolicy, SandboxRequest, - WslcSection, -}; -use serde_json::Value; - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RequestSpec { - policy: SandboxPolicy, - command: String, - #[serde(default)] - containment: RequestContainment, - #[serde(default)] - container_name: Option, - #[serde(default)] - working_directory: Option, - #[serde(default)] - environment: BTreeMap, - #[serde(default)] +/// Parse a complete canonical MXC configuration document. +pub(crate) fn build_request_from_json( + request_json: &str, experimental: bool, -} - -#[derive(Default, serde::Deserialize)] -#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] -enum RequestContainment { - #[default] - Process, - ProcessContainer { - #[serde(default, rename = "leastPrivilege")] - least_privilege: bool, - #[serde(default, rename = "learningMode")] - learning_mode: bool, - #[serde(default)] - capabilities: Vec, - #[serde(default, rename = "captureDenials")] - capture_denials: Option, - #[serde(default = "default_process_container_ui")] - ui: Option, - #[serde(default)] - network: Option, - }, - Wslc { - #[serde(default = "default_wslc_image")] - image: String, - #[serde(default, rename = "imageTarPath")] - image_tar_path: Option, - #[serde(default, rename = "cpuCount")] - cpu_count: Option, - #[serde(default, rename = "memoryMb")] - memory_mb: Option, - #[serde(default)] - gpu: bool, - #[serde(default, rename = "storagePath")] - storage_path: Option, - #[serde(default, rename = "portMappings")] - port_mappings: Vec, - }, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ProcessContainerUiSpec { - #[serde(default)] - isolation: ProcessContainerUiIsolationSpec, - #[serde(default)] - desktop_system_control: bool, - #[serde(default)] - system_settings: ProcessContainerSystemSettingsSpec, - #[serde(default)] - ime: bool, -} - -impl Default for ProcessContainerUiSpec { - fn default() -> Self { - Self { - isolation: ProcessContainerUiIsolationSpec::Container, - desktop_system_control: false, - system_settings: ProcessContainerSystemSettingsSpec::None, - ime: false, - } - } -} - -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -enum ProcessContainerUiIsolationSpec { - Desktop, - Handles, - Atoms, - #[default] - Container, -} - -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -enum ProcessContainerSystemSettingsSpec { - All, - Parameters, - Display, - #[default] - None, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ProcessContainerNetworkSpec { - #[serde(default)] - allowed_proxy_peer: Option, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct WslcPortMappingSpec { - windows_port: u16, - container_port: u16, -} - -fn default_process_container_ui() -> Option { - Some(ProcessContainerUiSpec::default()) -} - -fn default_wslc_image() -> String { - "alpine:latest".to_string() -} - -impl RequestContainment { - // These public SDK configuration types are `#[non_exhaustive]`, so a - // downstream binding crate must start from `Default` and assign fields - // rather than using struct update syntax. - #[allow(clippy::field_reassign_with_default)] - fn into_sdk(self) -> Containment { - match self { - Self::Process => Containment::Process, - Self::ProcessContainer { - least_privilege, - learning_mode, - capabilities, - capture_denials, - ui, - network, - } => { - let mut process_container = ProcessContainer::default(); - process_container.least_privilege = least_privilege; - process_container.learning_mode = learning_mode; - process_container.capabilities = capabilities; - process_container.capture_denials = capture_denials; - process_container.ui = ui.map(ProcessContainerUiSpec::into_sdk); - process_container.network = network.map(ProcessContainerNetworkSpec::into_sdk); - Containment::ProcessContainer(process_container) - } - Self::Wslc { - image, - image_tar_path, - cpu_count, - memory_mb, - gpu, - storage_path, - port_mappings, - } => { - let mut wslc = WslcSection::default(); - wslc.image = image; - wslc.image_tar_path = image_tar_path; - wslc.cpu_count = cpu_count; - wslc.memory_mb = memory_mb; - wslc.gpu = gpu; - wslc.storage_path = storage_path; - wslc.port_mappings = port_mappings - .into_iter() - .map(|mapping| (mapping.windows_port, mapping.container_port)) - .collect(); - Containment::Wslc(wslc) - } - } - } -} - -impl ProcessContainerUiSpec { - fn into_sdk(self) -> ProcessContainerUi { - let mut ui = ProcessContainerUi::default(); - ui.isolation = match self.isolation { - ProcessContainerUiIsolationSpec::Desktop => ProcessContainerUiIsolation::Desktop, - ProcessContainerUiIsolationSpec::Handles => ProcessContainerUiIsolation::Handles, - ProcessContainerUiIsolationSpec::Atoms => ProcessContainerUiIsolation::Atoms, - ProcessContainerUiIsolationSpec::Container => ProcessContainerUiIsolation::Container, - }; - ui.desktop_system_control = self.desktop_system_control; - ui.system_settings = match self.system_settings { - ProcessContainerSystemSettingsSpec::All => ProcessContainerSystemSettings::All, - ProcessContainerSystemSettingsSpec::Parameters => { - ProcessContainerSystemSettings::Parameters - } - ProcessContainerSystemSettingsSpec::Display => ProcessContainerSystemSettings::Display, - ProcessContainerSystemSettingsSpec::None => ProcessContainerSystemSettings::None, - }; - ui.ime = self.ime; - ui - } -} - -impl ProcessContainerNetworkSpec { - fn into_sdk(self) -> ProcessContainerNetwork { - let mut network = ProcessContainerNetwork::default(); - network.allowed_proxy_peer = self.allowed_proxy_peer; - network - } -} - -/// 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() - { - return Err(Error::new( - ErrorCode::MalformedRequest, - "policy.captureDenials is not supported; set containment.type to \ - processContainer and use containment.captureDenials", - )); - } - - let spec: RequestSpec = serde_json::from_value(value).map_err(malformed_request)?; - let containment = spec.containment.into_sdk(); - - let mut request = - build_request_with_containment(&spec.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); +) -> Result { + let mut request = mxc_sdk::ffi_internals::build_request_from_json(request_json)?; + request.set_experimental(experimental); Ok(request) } -fn malformed_request(error: serde_json::Error) -> Error { - Error::new( - ErrorCode::MalformedRequest, - format!("failed to parse request JSON: {error}"), - ) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn managed_full_request_goldens_are_accepted_by_native_contract() { - let process_container = - include_str!("../../../../tests/policy/request-process-container.json"); - let process_spec: RequestSpec = - serde_json::from_str(process_container).expect("process-container golden parses"); - assert_eq!(process_spec.command, "echo parity"); - assert_eq!( - process_spec.environment.get("PARITY").map(String::as_str), - Some("true") - ); - assert_eq!(process_spec.policy.timeout_ms, Some(30_000)); - let filesystem = process_spec - .policy - .filesystem - .as_ref() - .expect("filesystem policy is preserved"); - assert_eq!(filesystem.readwrite_paths, ["C:\\work"]); - assert_eq!(filesystem.readonly_paths, ["C:\\input"]); - assert_eq!(filesystem.denied_paths, ["C:\\secret"]); - assert_eq!(filesystem.clear_policy_on_exit, Some(true)); - let ui = process_spec - .policy - .ui - .as_ref() - .expect("UI policy is preserved"); - assert!(!ui.allow_windows); - assert_eq!(ui.clipboard, mxc_sdk::policy::ClipboardPolicy::Read); - assert!(!ui.allow_input_injection); - let authored_network = process_spec - .policy - .network - .as_ref() - .expect("network policy is preserved"); - assert_eq!( - authored_network - .runtime_config - .as_ref() - .and_then(|runtime| runtime.network_proxy.as_deref()), - Some("http://127.0.0.1:8080") - ); - match process_spec.containment { - RequestContainment::ProcessContainer { - least_privilege, - capabilities, - capture_denials, - network, - .. - } => { - assert!(least_privilege); - assert_eq!(capabilities, ["internetClient"]); - let capture = capture_denials.expect("captureDenials is preserved"); - assert_eq!( - capture.output_path.as_deref(), - Some("C:\\logs\\denials.json") - ); - assert!(capture.retain_etl); - assert_eq!( - network.and_then(|value| value.allowed_proxy_peer), - Some("Contoso.App_123".to_string()) - ); - } - _ => panic!("process-container golden selected the wrong containment"), + fn canonical_request_goldens_are_accepted() { + for request in [ + include_str!("../../../../tests/policy/request-process-container.json"), + include_str!("../../../../tests/policy/request-directional-network.json"), + include_str!("../../../../tests/policy/request-wslc.json"), + ] { + build_request_from_json(request, true) + .unwrap_or_else(|error| panic!("canonical request was rejected: {error}")); } - let mut buildable_process_container: serde_json::Value = - serde_json::from_str(process_container).expect("process-container golden parses"); - buildable_process_container["containment"]["captureDenials"]["outputPath"] = - serde_json::Value::String( - std::env::temp_dir() - .join("denials.json") - .to_string_lossy() - .into_owned(), - ); - build_request_from_json(&buildable_process_container.to_string()) - .expect("process-container golden builds a public SDK request"); - - let directional_network = - include_str!("../../../../tests/policy/request-directional-network.json"); - let network_spec: RequestSpec = - serde_json::from_str(directional_network).expect("directional-network golden parses"); - assert_eq!(network_spec.command, "echo network"); - assert!(matches!( - network_spec.containment, - RequestContainment::Process - )); - let network = network_spec - .policy - .network - .as_ref() - .expect("directional network policy is preserved"); - let allow = network - .egress - .as_ref() - .and_then(|egress| egress.allow.as_ref()) - .expect("egress allow rule is preserved"); - let peers = allow[0].to.as_ref().expect("destination peer is preserved"); - assert_eq!(peers[0].cidr, "10.20.0.0/16"); - assert_eq!( - peers[0].except.as_deref(), - Some(["10.20.30.0/24".to_string()].as_slice()) - ); - let ports = allow[0].ports.as_ref().expect("port rule is preserved"); - assert_eq!(ports[0].protocol, Some(mxc_sdk::NetworkProtocol::Tcp)); - assert_eq!(ports[0].port, Some(443)); - assert_eq!(ports[0].end_port, Some(444)); - build_request_from_json(directional_network) - .expect("directional-network golden builds a public SDK request"); - - let wslc = include_str!("../../../../tests/policy/request-wslc.json"); - let wslc_spec: RequestSpec = serde_json::from_str(wslc).expect("WSLC golden parses"); - assert_eq!(wslc_spec.command, "printf parity"); - assert!(wslc_spec.experimental); - match wslc_spec.containment { - RequestContainment::Wslc { - image, - cpu_count, - memory_mb, - port_mappings, - .. - } => { - assert_eq!(image, "alpine:3.20"); - assert_eq!(cpu_count, Some(2)); - assert_eq!(memory_mb, Some(1024)); - assert_eq!(port_mappings.len(), 1); - assert_eq!(port_mappings[0].windows_port, 8080); - assert_eq!(port_mappings[0].container_port, 80); - } - _ => panic!("WSLC golden selected the wrong containment"), - } - build_request_from_json(wslc).expect("WSLC golden builds a public SDK request"); - } - - #[test] - fn capture_denials_is_mapped_to_process_container_configuration() { - let error = build_request_from_json( - r#"{ - "policy": { "version": "0.7.0-alpha" }, - "command": "echo hi", - "containment": { - "type": "processContainer", - "captureDenials": {} - } - }"#, - ) - .expect_err("captureDenials must reach version validation"); - - assert!( - error - .message - .contains("processContainer.captureDenials requires schema version 0.8"), - "unexpected error: {error}" - ); - } - - #[test] - fn root_policy_capture_denials_is_rejected_instead_of_ignored() { - let error = build_request_from_json( - r#"{ - "policy": { - "version": "0.8.0-alpha", - "captureDenials": {} - }, - "command": "echo hi" - }"#, - ) - .expect_err("a misplaced backend setting must fail closed"); - - assert!(error - .message - .contains("policy.captureDenials is not supported")); } #[test] - fn process_container_options_map_to_the_sdk_type() { - let containment: RequestContainment = serde_json::from_str( - r#"{ - "type": "processContainer", - "leastPrivilege": true, - "learningMode": true, - "capabilities": ["internetClient"], - "captureDenials": { "mode": "allow", "retainEtl": true }, - "ui": { - "isolation": "atoms", - "desktopSystemControl": true, - "systemSettings": "parameters", - "ime": true - }, - "network": { - "allowedProxyPeer": "Contoso.Proxy_123" - } - }"#, - ) - .expect("request containment parses"); - - let Containment::ProcessContainer(config) = containment.into_sdk() else { - panic!("expected ProcessContainer"); - }; - assert!(config.least_privilege); - assert!(config.learning_mode); - assert_eq!(config.capabilities, ["internetClient"]); - assert!(config.capture_denials.expect("capture").retain_etl); - let ui = config.ui.expect("ui"); - assert_eq!(ui.isolation, ProcessContainerUiIsolation::Atoms); - assert!(ui.desktop_system_control); - assert_eq!( - ui.system_settings, - ProcessContainerSystemSettings::Parameters - ); - assert!(ui.ime); - assert_eq!( - config - .network - .expect("network") - .allowed_proxy_peer - .as_deref(), - Some("Contoso.Proxy_123") - ); - } - - #[test] - fn wslc_options_map_to_the_sdk_type() { - let containment: RequestContainment = serde_json::from_str( - r#"{ - "type": "wslc", - "image": "python:3.12", - "imageTarPath": "C:\\images\\python.tar", - "cpuCount": 4, - "memoryMb": 4096, - "gpu": true, - "storagePath": "C:\\wslc", - "portMappings": [ - { "windowsPort": 8080, "containerPort": 80 } - ] - }"#, - ) - .expect("request containment parses"); - - let Containment::Wslc(config) = containment.into_sdk() else { - panic!("expected WSLC"); - }; - assert_eq!(config.image, "python:3.12"); - assert_eq!( - config.image_tar_path.as_deref(), - Some(r"C:\images\python.tar") - ); - assert_eq!(config.cpu_count, Some(4)); - assert_eq!(config.memory_mb, Some(4096)); - assert!(config.gpu); - assert_eq!(config.storage_path.as_deref(), Some(r"C:\wslc")); - assert_eq!(config.port_mappings, [(8080, 80)]); + fn canonical_parser_rejects_unknown_and_duplicate_fields() { + for request in [ + r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"unknown":true}"#, + r#"{"version":"0.9.0-alpha","version":"0.9.0-alpha","process":{"commandLine":"echo hi"}}"#, + r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi","unexpected":true}}"#, + ] { + assert!( + build_request_from_json(request, false).is_err(), + "{request} must be rejected" + ); + } } #[test] - fn directional_networking_reaches_schema_version_validation() { - let error = build_request_from_json( - r#"{ - "policy": { - "version": "0.7.0-alpha", - "network": { - "egress": { "default": "deny" } - } - }, - "command": "echo hi" - }"#, - ) - .expect_err("directional networking must reach version validation"); - - assert!( - error - .message - .contains("network egress/ingress/runtimeConfig"), - "unexpected error: {error}" - ); + fn canonical_parser_requires_a_complete_one_shot_request() { + for request in [ + r#"{"version":"0.9.0-alpha","process":{}}"#, + include_str!("../../../../tests/policy/state-aware-wslc-provision.json"), + ] { + assert!( + build_request_from_json(request, true).is_err(), + "{request} must be rejected by the one-shot binding" + ); + } } } 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..6dec08737 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,84 +107,23 @@ 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 /// [`mxc_run_request`](crate::mxc_run_request). +/// A nonzero `experimental` opts into experimental features separately from +/// the canonical document's backend-configuration `experimental` object. /// /// # 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, + experimental: i32, out_handle: *mut *mut MxcSandbox, out_error: *mut MxcErrorDetail, ) -> i32 { @@ -198,19 +139,25 @@ pub unsafe extern "C" fn mxc_spawn_request( return MXC_STATUS_NULL_ARGUMENT; } - let outcome = catch_unwind(AssertUnwindSafe(|| spawn_request_inner(request_json_utf8))) - .unwrap_or_else(|_| { - Err(( - MXC_STATUS_PANIC, - MxcErrorDetail::from_message("the mxc engine panicked"), - )) - }); + let outcome = catch_unwind(AssertUnwindSafe(|| { + spawn_request_inner(request_json_utf8, experimental != 0) + })) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_spawn_request", &*panic); + Err(( + MXC_STATUS_PANIC, + MxcErrorDetail::from_message("the mxc engine panicked"), + )) + }); // SAFETY: `out_handle` is non-null and `out_error` is null or writable. unsafe { finish_spawn(outcome, out_handle, out_error) } } -fn spawn_request_inner(request_json_utf8: *const c_char) -> Result { +fn spawn_request_inner( + request_json_utf8: *const c_char, + experimental: bool, +) -> Result { // SAFETY: caller contract on `mxc_spawn_request`; borrowed only within scope. let request_json = match unsafe { cstr_to_str(request_json_utf8) } { Some(value) => value, @@ -227,12 +174,14 @@ 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 +235,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 +247,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 +257,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 +270,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 +284,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 +305,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 +325,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 +345,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 +378,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 +396,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 +422,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 +440,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 +463,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 +490,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 +503,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 +514,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 +528,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 +557,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 +570,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 +599,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 +614,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 +627,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 +651,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 +675,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 +686,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 +716,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 +740,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 +781,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 +800,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 +819,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 +849,97 @@ mod tests { } use std::ffi::CString; + use crate::MXC_STATUS_MALFORMED_REQUEST; + + fn request(command: &str) -> CString { + CString::new( + serde_json::json!({ + "version": "0.8.0-alpha", + "process": { "commandLine": 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(), 0, 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(), 0, &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(), 0, &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(), 0, &mut handle, ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_MALFORMED_REQUEST); assert!(handle.is_null()); } @@ -1006,6 +1010,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 +1056,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(), 0, &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_SUCCESS, "spawn failed (status {status})"); assert!(!handle.is_null()); @@ -1070,16 +1112,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(), 0, &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_SUCCESS, "spawn failed (status {status})"); // SAFETY: live handle. @@ -1131,26 +1170,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(), 0, &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..b30122262 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(), 0, &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()); } @@ -103,18 +102,16 @@ fn extern_streaming_warning_and_closer_preconditions_are_safe() { fn extern_run_request_maps_capture_denials_to_process_container() { let request = CString::new( r#"{ - "policy": { "version": "0.7.0-alpha" }, - "command": "echo hi", - "containment": { - "type": "processContainer", - "captureDenials": {} - } + "version": "0.7.0-alpha", + "process": { "commandLine": "echo hi" }, + "containment": "processcontainer", + "processContainer": { "captureDenials": {} } }"#, ) .unwrap(); let mut out = zeroed_result(); // SAFETY: a valid C string and writable result storage. - let status = unsafe { mxc_run_request(request.as_ptr(), &mut out) }; + let status = unsafe { mxc_run_request(request.as_ptr(), 0, &mut out) }; assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); // SAFETY: the message is a valid C string filled by `mxc_run_request`. @@ -136,7 +133,7 @@ fn extern_run_request_rejects_null_result_before_parsing() { // result pointer was checked. let invalid_utf8 = [0xff_u8, 0]; // SAFETY: the byte buffer is NUL-terminated and the result pointer is null. - let status = unsafe { mxc_run_request(invalid_utf8.as_ptr().cast(), ptr::null_mut()) }; + let status = unsafe { mxc_run_request(invalid_utf8.as_ptr().cast(), 0, ptr::null_mut()) }; assert_eq!(status, mxc_ffi::MXC_STATUS_NULL_ARGUMENT); } @@ -145,12 +142,10 @@ fn extern_run_request_rejects_null_result_before_parsing() { fn extern_spawn_request_maps_capture_denials_to_process_container() { let request = CString::new( r#"{ - "policy": { "version": "0.7.0-alpha" }, - "command": "echo hi", - "containment": { - "type": "processContainer", - "captureDenials": {} - } + "version": "0.7.0-alpha", + "process": { "commandLine": "echo hi" }, + "containment": "processcontainer", + "processContainer": { "captureDenials": {} } }"#, ) .unwrap(); @@ -158,7 +153,7 @@ fn extern_spawn_request_maps_capture_denials_to_process_container() { // SAFETY: `MxcErrorDetail` contains integers and nullable pointers. let mut error: MxcErrorDetail = unsafe { std::mem::zeroed() }; // SAFETY: valid request and writable fresh out-parameters. - let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, &mut error) }; + let status = unsafe { mxc_spawn_request(request.as_ptr(), 0, &mut handle, &mut error) }; assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); assert!(handle.is_null()); @@ -181,14 +176,17 @@ 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#"{ + "version":"0.7.0-alpha", + "process":{"commandLine":"cmd /c echo hello-ffi"}, + "filesystem":{"readwritePaths":["C:\\Windows\\Temp"]} + }"#, ) .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(), 0, &mut out) }; assert_eq!(status, mxc_ffi::MXC_STATUS_SUCCESS, "status={status}"); assert_eq!(out.exit_code, 0); @@ -197,6 +195,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) }; } diff --git a/tests/policy/request-directional-network.json b/tests/policy/request-directional-network.json index d3331a90b..afbe4fcff 100644 --- a/tests/policy/request-directional-network.json +++ b/tests/policy/request-directional-network.json @@ -1,41 +1,56 @@ { - "policy": { - "version": "0.8.0-alpha", - "network": { - "allowOutbound": false, - "allowLocalNetwork": false, - "allowedHosts": [], - "blockedHosts": [], - "egress": { - "default": "deny", - "allow": [ - { - "to": [ - { - "cidr": "10.20.0.0/16", - "except": ["10.20.30.0/24"] - } - ], - "ports": [ - { - "protocol": "tcp", - "port": 443, - "endPort": 444 - } - ] - } - ] - }, - "ingress": { - "default": "deny", - "hostLoopback": "deny" - } - } + "version": "0.8.0-alpha", + "containerId": "golden-directional-network", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo network", + "timeout": 0 }, - "command": "echo network", - "containment": { - "type": "process" + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] }, - "environment": {}, - "experimental": false + "network": { + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "10.20.0.0/16", + "except": ["10.20.30.0/24"] + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443, + "endPort": 444 + } + ] + } + ] + }, + "ingress": { + "default": "deny", + "hostLoopback": "deny" + } + }, + "containment": "processcontainer", + "processContainer": { + "leastPrivilege": false, + "capabilities": [ + "internetClient" + ], + "ui": { + "isolation": "container", + "desktopSystemControl": false, + "systemSettings": "none", + "ime": false + } + } } diff --git a/tests/policy/request-process-container.json b/tests/policy/request-process-container.json index 7ddcded7a..09c21b32e 100644 --- a/tests/policy/request-process-container.json +++ b/tests/policy/request-process-container.json @@ -1,46 +1,45 @@ { - "policy": { - "version": "0.8.0-alpha", - "filesystem": { - "readwritePaths": ["C:\\work"], - "readonlyPaths": ["C:\\input"], - "deniedPaths": ["C:\\secret"], - "clearPolicyOnExit": true - }, - "network": { - "allowOutbound": false, - "allowLocalNetwork": false, - "allowedHosts": [], - "blockedHosts": [], - "egress": { - "default": "deny" - }, - "ingress": { - "default": "allow", - "hostLoopback": "deny" - }, - "runtimeConfig": { - "networkProxy": "http://127.0.0.1:8080" - } - }, - "ui": { - "allowWindows": false, - "clipboard": "read", - "allowInputInjection": false + "version": "0.8.0-alpha", + "containerId": "golden-process-container", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": { + "commandLine": "echo parity", + "timeout": 30000, + "cwd": "C:\\work", + "env": ["PARITY=true"] + }, + "filesystem": { + "readwritePaths": ["C:\\work"], + "readonlyPaths": ["C:\\input"], + "deniedPaths": ["C:\\secret"] + }, + "ui": { + "disable": true, + "clipboard": "read", + "injection": false + }, + "network": { + "egress": { + "default": "deny" }, - "timeoutMs": 30000 + "ingress": { + "default": "allow", + "hostLoopback": "deny" + } + }, + "runtimeConfig": { + "networkProxy": "http://127.0.0.1:8080" }, - "command": "echo parity", - "containment": { - "type": "processContainer", + "containment": "processcontainer", + "processContainer": { "leastPrivilege": true, - "learningMode": false, - "capabilities": ["internetClient"], - "captureDenials": { - "mode": "block", - "outputPath": "C:\\logs\\denials.json", - "retainEtl": true - }, + "capabilities": [ + "internetClient", + "privateNetworkClientServer" + ], "ui": { "isolation": "handles", "desktopSystemControl": false, @@ -49,12 +48,11 @@ }, "network": { "allowedProxyPeer": "Contoso.App_123" + }, + "captureDenials": { + "mode": "block", + "outputPath": "C:\\denials.json", + "retainEtl": true } - }, - "containerName": "golden-process-container", - "workingDirectory": "C:\\work", - "environment": { - "PARITY": "true" - }, - "experimental": false + } } diff --git a/tests/policy/request-wslc.json b/tests/policy/request-wslc.json index 35ace35ec..8f7047e9e 100644 --- a/tests/policy/request-wslc.json +++ b/tests/policy/request-wslc.json @@ -1,24 +1,38 @@ { - "policy": { - "version": "0.8.0-alpha" + "version": "0.8.0-alpha", + "containerId": "golden-wslc", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false }, - "command": "printf parity", - "containment": { - "type": "wslc", - "image": "alpine:3.20", - "imageTarPath": "C:\\images\\alpine.tar", - "cpuCount": 2, - "memoryMb": 1024, - "gpu": true, - "storagePath": "C:\\wslc", - "portMappings": [ - { - "windowsPort": 8080, - "containerPort": 80 - } - ] + "process": { + "commandLine": "printf parity", + "timeout": 0 }, - "containerName": "golden-wslc", - "environment": {}, - "experimental": true + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + "deniedPaths": [] + }, + "network": { + "defaultPolicy": "block" + }, + "containment": "wslc", + "experimental": { + "wslc": { + "image": "alpine:3.20", + "imageTarPath": "C:\\images\\alpine.tar", + "cpuCount": 2, + "memoryMb": 1024, + "gpu": true, + "storagePath": "C:\\wslc", + "portMappings": [ + { + "windowsPort": 8080, + "containerPort": 80, + "protocol": "tcp" + } + ] + } + } } From dd633f41b00e3df5b089609b57ac8b8cae1111a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:20:20 +0000 Subject: [PATCH 02/16] Preserve SandboxRequest JSON serialization Co-authored-by: RamonArjona4 <25335379+RamonArjona4@users.noreply.github.com> --- .../MxcSandboxTests.cs | 20 ++++++++++++++++ .../Microsoft.Mxc.Sdk/SandboxRequest.cs | 23 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index c671f4a4c..6eebe08e9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -145,6 +145,26 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() JsonAssert.MatchesGolden(MxcSandbox.SerializeRequest(wslc), "request-wslc.json"); } + [Fact] + public void SandboxRequest_SerializesCanonically() + { + var request = new SandboxRequest( + new SandboxPolicy { Version = "0.8.0-alpha" }, + "printf compatibility") + { + Containment = new WslcContainment + { + Image = "alpine:3.20", + }, + ContainerName = "serialization-compatibility", + Experimental = true, + }; + + var json = JsonSerializer.Serialize(request); + + Assert.Equal(MxcSandbox.SerializeRequest(request), json); + } + [Fact] public void NativeVersion_IsNotEmpty() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs index be06c152e..64c3c314a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Mxc.Sdk; @@ -9,6 +10,7 @@ namespace Microsoft.Mxc.Sdk; /// A complete one-shot sandbox request. This is the managed counterpart of the /// Rust SDK's request built by build_request_with_containment. /// +[JsonConverter(typeof(SandboxRequestJsonConverter))] public sealed class SandboxRequest { /// Create a request for under . @@ -42,6 +44,27 @@ public SandboxRequest(SandboxPolicy policy, string command) public bool Experimental { get; set; } } +/// Serializes requests through MXC's canonical wire projection. +public sealed class SandboxRequestJsonConverter : JsonConverter +{ + /// + public override SandboxRequest Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) => + throw new NotSupportedException("SandboxRequest deserialization is not supported."); + + /// + public override void Write( + Utf8JsonWriter writer, + SandboxRequest value, + JsonSerializerOptions options) + { + using var document = JsonDocument.Parse(MxcSandbox.SerializeRequest(value)); + document.RootElement.WriteTo(writer); + } +} + /// /// A containment backend selected by a . /// This is an authoring model; performs its canonical From 80337aa3561c9142c41e7af892d62ca051c2ce9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:21:40 +0000 Subject: [PATCH 03/16] Restore canonical SandboxRequest serialization Co-authored-by: RamonArjona4 <25335379+RamonArjona4@users.noreply.github.com> --- .../MxcSandboxTests.cs | 16 +++++++++++++++ .../Microsoft.Mxc.Sdk/SandboxRequest.cs | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index c671f4a4c..cbb0df22c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -145,6 +145,22 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() JsonAssert.MatchesGolden(MxcSandbox.SerializeRequest(wslc), "request-wslc.json"); } + [Fact] + public void SandboxRequest_SerializesCanonically() + { + var request = new SandboxRequest( + new SandboxPolicy { Version = "0.8.0-alpha" }, + "echo canonical") + { + Containment = new WslcContainment { Image = "alpine:3.20" }, + ContainerName = "canonical-request", + }; + + Assert.Equal( + MxcSandbox.SerializeRequest(request), + JsonSerializer.Serialize(request)); + } + [Fact] public void NativeVersion_IsNotEmpty() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs index be06c152e..480c0b27d 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Mxc.Sdk; @@ -9,6 +10,7 @@ namespace Microsoft.Mxc.Sdk; /// A complete one-shot sandbox request. This is the managed counterpart of the /// Rust SDK's request built by build_request_with_containment. /// +[JsonConverter(typeof(SandboxRequestJsonConverter))] public sealed class SandboxRequest { /// Create a request for under . @@ -42,6 +44,24 @@ public SandboxRequest(SandboxPolicy policy, string command) public bool Experimental { get; set; } } +internal sealed class SandboxRequestJsonConverter : JsonConverter +{ + public override SandboxRequest Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) => + throw new NotSupportedException("Deserializing SandboxRequest is not supported."); + + public override void Write( + Utf8JsonWriter writer, + SandboxRequest value, + JsonSerializerOptions options) + { + using var document = JsonDocument.Parse(MxcSandbox.SerializeRequest(value)); + document.RootElement.WriteTo(writer); + } +} + /// /// A containment backend selected by a . /// This is an authoring model; performs its canonical From f196cac9c94fb3a2b8e958c18e2bb8385faa82dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:40:39 +0000 Subject: [PATCH 04/16] Promote Linux host rules to firewall and fix golden parser test Co-authored-by: RamonArjona4 <25335379+RamonArjona4@users.noreply.github.com> --- .../MxcSandboxTests.cs | 50 +++++++++++++++++++ .../CanonicalRequestBuilder.cs | 25 ++++++++++ src/ffi/mxc_ffi/src/request.rs | 14 +++++- 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index cb1fe6d0b..50fa81b92 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -165,6 +165,56 @@ public void SandboxRequest_SerializesCanonically() Assert.Equal(MxcSandbox.SerializeRequest(request), json); } + [Fact] + public void ProcessContainment_PromotesHostRulesToFirewallOnLinux() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Firewall promotion is Linux-specific"); + + var request = new SandboxRequest( + new SandboxPolicy + { + Version = "0.8.0-alpha", + Network = new NetworkPolicy + { + AllowOutbound = true, + AllowedHosts = ["example.com"], + }, + }, + "echo network"); + + using var document = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); + + Assert.Equal( + "firewall", + document.RootElement.GetProperty("network").GetProperty("enforcementMode").GetString()); + } + + [Fact] + public void ProcessContainment_LeavesEnforcementModeUnsetForProxyOnLinux() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Firewall promotion is Linux-specific"); + + var request = new SandboxRequest( + new SandboxPolicy + { + Version = "0.8.0-alpha", + Network = new NetworkPolicy + { + AllowOutbound = true, + AllowedHosts = ["example.com"], + Proxy = new UrlNetworkProxyPolicy("http://127.0.0.1:8080"), + }, + }, + "echo network"); + + using var document = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); + + Assert.False( + document.RootElement.GetProperty("network").TryGetProperty( + "enforcementMode", + out _)); + } + [Fact] public void NativeVersion_IsNotEmpty() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index e616ef5b8..929136d2f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -189,6 +189,10 @@ private static void AddContainment( "process", options); } + else if (OperatingSystem.IsLinux()) + { + ApplyLinuxNetworkPolicy(root); + } break; case ProcessContainerContainment processContainer: AddProcessContainer( @@ -213,6 +217,27 @@ private static void AddContainment( } } + /// + /// Promotes network enforcement to firewall when host rules are present and no + /// cooperative proxy is configured — the Linux counterpart of the Rust builder's + /// apply_linux_network_policy. Without it the parser leaves the mode at + /// capabilities and the host lists are not applied. + /// + private static void ApplyLinuxNetworkPolicy(JsonObject root) + { + if (root["network"] is not JsonObject network || network["proxy"] is not null) + { + return; + } + if (HasHosts(network, "allowedHosts") || HasHosts(network, "blockedHosts")) + { + network["enforcementMode"] = "firewall"; + } + } + + private static bool HasHosts(JsonObject network, string key) => + network[key] is JsonArray hosts && hosts.Count != 0; + private static JsonObject BuildWslc(WslcContainment wslc) { var wire = new JsonObject diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index acf173e63..dff668835 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -21,12 +21,24 @@ mod tests { #[test] fn canonical_request_goldens_are_accepted() { + // The goldens pin the cross-SDK wire shape, so they keep their Windows + // paths. `captureDenials.outputPath` is the one field the parser + // resolves against the host filesystem, so point it at a + // platform-native temporary file before parsing. + let temp = tempfile::tempdir().expect("temp dir"); + let output_path = temp.path().join("denials.json"); for request in [ include_str!("../../../../tests/policy/request-process-container.json"), include_str!("../../../../tests/policy/request-directional-network.json"), include_str!("../../../../tests/policy/request-wslc.json"), ] { - build_request_from_json(request, true) + let mut document: serde_json::Value = + serde_json::from_str(request).expect("canonical golden parses"); + if let Some(field) = document.pointer_mut("/processContainer/captureDenials/outputPath") + { + *field = serde_json::Value::String(output_path.to_string_lossy().into_owned()); + } + build_request_from_json(&document.to_string(), true) .unwrap_or_else(|error| panic!("canonical request was rejected: {error}")); } } From dfa085c4dbccaa5e24507d35258e4ddd1e645748 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 11:33:12 -0700 Subject: [PATCH 05/16] Fix PR review findings for consent and request JSON Document consent cancellation as best-effort when native persistence wins, and restore strict canonical SandboxRequest deserialization with round-trip and malformed-input coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../Microsoft.Mxc.Sdk.Tests/JsonAssert.cs | 14 +- .../MxcSandboxTests.cs | 140 ++++- .../MxcTelemetryTests.cs | 2 +- .../CanonicalRequestBuilder.cs | 536 +++++++++++++++++- sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 9 +- .../Microsoft.Mxc.Sdk/SandboxRequest.cs | 9 +- sdk/dotnet/README.md | 6 + 7 files changed, 703 insertions(+), 13 deletions(-) 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/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 50fa81b92..77d2a376d 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Microsoft.Mxc.Sdk; using Xunit; @@ -162,7 +163,144 @@ public void SandboxRequest_SerializesCanonically() var json = JsonSerializer.Serialize(request); Assert.Equal(MxcSandbox.SerializeRequest(request), json); - Assert.Equal(MxcSandbox.SerializeRequest(request), json); + } + + [Theory] + [InlineData("request-process-container.json", typeof(ProcessContainerContainment))] + [InlineData("request-directional-network.json", typeof(ProcessContainerContainment))] + [InlineData("request-wslc.json", typeof(WslcContainment))] + public void SandboxRequest_DeserializesCanonicalGoldens( + string fixtureName, + Type expectedContainment) + { + var request = JsonSerializer.Deserialize( + JsonAssert.ReadGolden(fixtureName)); + + Assert.NotNull(request); + Assert.IsType(expectedContainment, request.Containment); + JsonAssert.MatchesGolden(JsonSerializer.Serialize(request), fixtureName); + } + + [Fact] + public void SandboxRequest_RoundTripsCanonicalProcessRequest() + { + var request = new SandboxRequest( + new SandboxPolicy + { + Version = "0.8.0-alpha", + Filesystem = new FilesystemPolicy + { + ReadwritePaths = [@"C:\work"], + ClearPolicyOnExit = false, + }, + Network = new NetworkPolicy + { + AllowOutbound = true, + AllowLocalNetwork = true, + AllowedHosts = ["example.com"], + Proxy = new LocalhostNetworkProxyPolicy(8080), + }, + Telemetry = new TelemetrySettings { Enabled = true }, + Ui = new UiPolicy + { + AllowWindows = true, + Clipboard = ClipboardPolicy.Write, + AllowInputInjection = true, + }, + TimeoutMs = 1234, + }, + "echo roundtrip") + { + ContainerName = "roundtrip", + WorkingDirectory = @"C:\work", + Environment = + { + ["EMPTY"] = string.Empty, + ["WITH_EQUALS"] = "left=right", + }, + }; + + var firstJson = JsonSerializer.Serialize(request); + var deserialized = JsonSerializer.Deserialize(firstJson); + + Assert.NotNull(deserialized); + Assert.Equal("left=right", deserialized.Environment["WITH_EQUALS"]); + Assert.True(deserialized.Policy.Telemetry!.Enabled); + Assert.Null(deserialized.Policy.Network!.Egress); + Assert.Null(deserialized.Policy.Network.Ingress); + var secondJson = JsonSerializer.Serialize(deserialized); + Assert.True( + JsonNode.DeepEquals(JsonNode.Parse(firstJson), JsonNode.Parse(secondJson)), + $"Canonical request changed after round trip.{Environment.NewLine}{secondJson}"); + } + + [Fact] + public void SandboxRequest_RejectsWindowsProcessSectionOnOtherPlatforms() + { + Assert.SkipWhen( + OperatingSystem.IsWindows(), + "The processContainer section is canonical for process containment on Windows."); + const string json = """ + { + "version":"0.8.0-alpha", + "process":{"commandLine":"echo hi"}, + "containment":"process", + "processContainer":{"leastPrivilege":false,"capabilities":[]} + } + """; + + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(json)); + + Assert.Contains("only on Windows", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData( + """{"policy":{"version":"0.8.0-alpha"},"command":"echo legacy"}""", + "Unknown canonical property")] + [InlineData( + """ + { + "version":"0.8.0-alpha", + "lifecycle":{"destroyOnExit":false}, + "process":{"commandLine":"echo hi"} + } + """, + "destroyOnExit=false")] + [InlineData( + """ + { + "version":"0.8.0-alpha", + "process":{"commandLine":"echo hi","env":["INVALID"]}, + "containment":"process" + } + """, + "NAME=VALUE")] + [InlineData( + """ + { + "version":"0.8.0-alpha", + "process":{"commandLine":"echo hi"}, + "containment":"wslc", + "experimental":{ + "wslc":{ + "portMappings":[ + {"windowsPort":8080,"containerPort":80,"protocol":"udp"} + ] + } + } + } + """, + "only TCP")] + public void SandboxRequest_RejectsUnsupportedDeserialization( + string json, + string expectedMessage) + { + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(json)); + + Assert.Contains(expectedMessage, exception.Message, StringComparison.Ordinal); } [Fact] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index b94d93e09..9ae1484d8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -593,7 +593,7 @@ public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPer } [Fact] - public void FinalizeAsyncOutcome_PersistedDecisionWinsLateCancellation() + public void FinalizeAsyncOutcome_PersistedDecisionWinsConcurrentCancellation() { using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index 929136d2f..fc864e14b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -3,11 +3,23 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace Microsoft.Mxc.Sdk; internal static class CanonicalRequestBuilder { + private static readonly JsonSerializerOptions CanonicalReadOptions = new() + { + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), + new NetworkProxyPolicyJsonConverter(), + }, + }; + internal static string Serialize(SandboxRequest request, JsonSerializerOptions options) { if (string.IsNullOrEmpty(request.Policy.Version)) @@ -79,6 +91,526 @@ internal static string Serialize(SandboxRequest request, JsonSerializerOptions o return root.ToJsonString(options); } + internal static SandboxRequest Deserialize(ref Utf8JsonReader reader) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = RequireObject(document.RootElement, "request"); + EnsureKnownProperties( + root, + "request", + "version", + "containerId", + "lifecycle", + "process", + "filesystem", + "ui", + "network", + "runtimeConfig", + "telemetry", + "containment", + "processContainer", + "experimental"); + + var policy = new SandboxPolicy + { + Version = RequireString(root, "version", "request"), + }; + + var process = RequireObject( + RequireProperty(root, "process", "request"), + "process"); + EnsureKnownProperties(process, "process", "commandLine", "timeout", "cwd", "env"); + var request = new SandboxRequest( + policy, + RequireString(process, "commandLine", "process")); + + if (process.TryGetProperty("timeout", out var timeout)) + { + var timeoutMs = ReadUInt32(timeout, "process.timeout"); + policy.TimeoutMs = timeoutMs == 0 ? null : timeoutMs; + } + if (process.TryGetProperty("cwd", out var cwd)) + { + request.WorkingDirectory = ReadString(cwd, "process.cwd"); + } + if (process.TryGetProperty("env", out var environment)) + { + request.Environment = ReadEnvironment(environment); + } + + if (root.TryGetProperty("containerId", out var containerId)) + { + request.ContainerName = ReadString(containerId, "containerId"); + } + + ReadFilesystemAndLifecycle(root, policy); + if (root.TryGetProperty("ui", out var ui)) + { + policy.Ui = ReadUiPolicy(ui); + } + + if (root.TryGetProperty("network", out var network)) + { + policy.Network = ReadNetworkPolicy( + network, + root.TryGetProperty("runtimeConfig", out var runtimeConfig) + ? runtimeConfig + : null); + } + else if (root.TryGetProperty("runtimeConfig", out var runtimeConfig)) + { + policy.Network = new NetworkPolicy + { + RuntimeConfig = DeserializeElement( + runtimeConfig, + "runtimeConfig"), + }; + } + + if (root.TryGetProperty("telemetry", out var telemetry)) + { + var telemetryObject = RequireObject(telemetry, "telemetry"); + EnsureKnownProperties(telemetryObject, "telemetry", "enabled"); + policy.Telemetry = new TelemetrySettings + { + Enabled = telemetryObject.TryGetProperty("enabled", out var enabled) + && ReadBoolean(enabled, "telemetry.enabled"), + }; + } + + var containmentName = root.TryGetProperty("containment", out var containment) + ? ReadString(containment, "containment") + : "process"; + request.Containment = containmentName switch + { + "process" => ReadProcessContainment(root), + "processcontainer" => ReadExplicitProcessContainer(root), + "wslc" => ReadWslcContainment(root, request), + _ => throw new JsonException( + $"Unsupported canonical containment '{containmentName}'."), + }; + + if (containmentName != "wslc" && root.TryGetProperty("experimental", out var experimental)) + { + var experimentalObject = RequireObject(experimental, "experimental"); + if (experimentalObject.EnumerateObject().Any()) + { + throw new JsonException( + "Only canonical experimental.wslc requests can be deserialized."); + } + } + + return request; + } + + private static void ReadFilesystemAndLifecycle( + JsonElement root, + SandboxPolicy policy) + { + var filesystem = new FilesystemPolicy(); + if (root.TryGetProperty("filesystem", out var filesystemElement)) + { + var filesystemObject = RequireObject(filesystemElement, "filesystem"); + EnsureKnownProperties( + filesystemObject, + "filesystem", + "readwritePaths", + "readonlyPaths", + "deniedPaths"); + filesystem.ReadwritePaths = ReadStringList( + filesystemObject, + "readwritePaths", + "filesystem.readwritePaths"); + filesystem.ReadonlyPaths = ReadStringList( + filesystemObject, + "readonlyPaths", + "filesystem.readonlyPaths"); + filesystem.DeniedPaths = ReadStringList( + filesystemObject, + "deniedPaths", + "filesystem.deniedPaths"); + } + + var preservePolicy = false; + if (root.TryGetProperty("lifecycle", out var lifecycleElement)) + { + var lifecycle = RequireObject(lifecycleElement, "lifecycle"); + EnsureKnownProperties( + lifecycle, + "lifecycle", + "destroyOnExit", + "preservePolicy"); + if (lifecycle.TryGetProperty("destroyOnExit", out var destroyOnExit) + && !ReadBoolean(destroyOnExit, "lifecycle.destroyOnExit")) + { + throw new JsonException( + "SandboxRequest cannot represent lifecycle.destroyOnExit=false."); + } + if (lifecycle.TryGetProperty("preservePolicy", out var preserve)) + { + preservePolicy = ReadBoolean(preserve, "lifecycle.preservePolicy"); + } + } + filesystem.ClearPolicyOnExit = !preservePolicy; + policy.Filesystem = filesystem; + } + + private static UiPolicy ReadUiPolicy(JsonElement element) + { + var ui = RequireObject(element, "ui"); + EnsureKnownProperties(ui, "ui", "disable", "clipboard", "injection"); + return new UiPolicy + { + AllowWindows = ui.TryGetProperty("disable", out var disable) + && !ReadBoolean(disable, "ui.disable"), + Clipboard = ui.TryGetProperty("clipboard", out var clipboard) + ? ReadEnum(clipboard, "ui.clipboard") + : ClipboardPolicy.None, + AllowInputInjection = ui.TryGetProperty("injection", out var injection) + && ReadBoolean(injection, "ui.injection"), + }; + } + + private static NetworkPolicy? ReadNetworkPolicy( + JsonElement element, + JsonElement? runtimeConfig) + { + var network = RequireObject(element, "network"); + var directional = network.TryGetProperty("egress", out _) + || network.TryGetProperty("ingress", out _); + if (directional) + { + EnsureKnownProperties(network, "network", "egress", "ingress"); + return new NetworkPolicy + { + Egress = network.TryGetProperty("egress", out var egress) + ? DeserializeElement(egress, "network.egress") + : null, + Ingress = network.TryGetProperty("ingress", out var ingress) + ? DeserializeElement(ingress, "network.ingress") + : null, + RuntimeConfig = runtimeConfig is { } directionalRuntime + ? DeserializeElement( + directionalRuntime, + "runtimeConfig") + : null, + }; + } + + EnsureKnownProperties( + network, + "network", + "defaultPolicy", + "allowLocalNetwork", + "allowedHosts", + "blockedHosts", + "proxy", + "enforcementMode"); + if (runtimeConfig is null + && network.EnumerateObject().All( + property => property.Name == "defaultPolicy" + && property.Value.ValueKind == JsonValueKind.String + && property.Value.GetString() == "block")) + { + return null; + } + var policy = new NetworkPolicy + { + AllowOutbound = network.TryGetProperty("defaultPolicy", out var defaultPolicy) + && ReadString(defaultPolicy, "network.defaultPolicy") switch + { + "allow" => true, + "block" => false, + var value => throw new JsonException( + $"Unsupported network.defaultPolicy '{value}'."), + }, + AllowLocalNetwork = network.TryGetProperty("allowLocalNetwork", out var local) + && ReadBoolean(local, "network.allowLocalNetwork"), + AllowedHosts = ReadStringList(network, "allowedHosts", "network.allowedHosts"), + BlockedHosts = ReadStringList(network, "blockedHosts", "network.blockedHosts"), + Proxy = network.TryGetProperty("proxy", out var proxy) + ? DeserializeElement(proxy, "network.proxy") + : null, + RuntimeConfig = runtimeConfig is { } legacyRuntime + ? DeserializeElement(legacyRuntime, "runtimeConfig") + : null, + }; + if (network.TryGetProperty("enforcementMode", out var enforcementMode)) + { + _ = ReadString(enforcementMode, "network.enforcementMode") switch + { + "capabilities" or "firewall" or "both" => true, + var value => throw new JsonException( + $"Unsupported network.enforcementMode '{value}'."), + }; + } + return policy; + } + + private static ProcessContainment ReadProcessContainment(JsonElement root) + { + var containment = new ProcessContainment(); + if (root.TryGetProperty("processContainer", out var processContainer)) + { + if (!OperatingSystem.IsWindows()) + { + throw new JsonException( + "A canonical processContainer section is supported only on Windows."); + } + containment.CanonicalProcessContainer = ReadProcessContainer(processContainer); + } + return containment; + } + + private static ProcessContainerContainment ReadExplicitProcessContainer(JsonElement root) => + root.TryGetProperty("processContainer", out var processContainer) + ? ReadProcessContainer(processContainer) + : new ProcessContainerContainment { Ui = null }; + + private static ProcessContainerContainment ReadProcessContainer(JsonElement element) + { + var section = RequireObject(element, "processContainer"); + EnsureKnownProperties( + section, + "processContainer", + "leastPrivilege", + "learningMode", + "capabilities", + "captureDenials", + "ui", + "network"); + return new ProcessContainerContainment + { + LeastPrivilege = section.TryGetProperty("leastPrivilege", out var leastPrivilege) + && ReadBoolean(leastPrivilege, "processContainer.leastPrivilege"), + LearningMode = section.TryGetProperty("learningMode", out var learningMode) + && ReadBoolean(learningMode, "processContainer.learningMode"), + Capabilities = ReadStringList( + section, + "capabilities", + "processContainer.capabilities"), + CaptureDenials = section.TryGetProperty("captureDenials", out var captureDenials) + ? DeserializeElement( + captureDenials, + "processContainer.captureDenials") + : null, + Ui = section.TryGetProperty("ui", out var ui) + ? DeserializeElement(ui, "processContainer.ui") + : null, + Network = section.TryGetProperty("network", out var network) + ? DeserializeElement( + network, + "processContainer.network") + : null, + }; + } + + private static WslcContainment ReadWslcContainment( + JsonElement root, + SandboxRequest request) + { + if (root.TryGetProperty("processContainer", out _)) + { + throw new JsonException( + "A canonical WSLC request cannot contain processContainer."); + } + var experimental = RequireObject( + RequireProperty(root, "experimental", "request"), + "experimental"); + EnsureKnownProperties(experimental, "experimental", "wslc"); + var wslc = RequireObject( + RequireProperty(experimental, "wslc", "experimental"), + "experimental.wslc"); + EnsureKnownProperties( + wslc, + "experimental.wslc", + "image", + "imageTarPath", + "cpuCount", + "memoryMb", + "gpu", + "storagePath", + "portMappings"); + + var containment = new WslcContainment + { + Image = wslc.TryGetProperty("image", out var image) + ? ReadString(image, "experimental.wslc.image") + : "alpine:latest", + ImageTarPath = wslc.TryGetProperty("imageTarPath", out var imageTarPath) + ? ReadString(imageTarPath, "experimental.wslc.imageTarPath") + : null, + CpuCount = wslc.TryGetProperty("cpuCount", out var cpuCount) + ? ReadUInt32(cpuCount, "experimental.wslc.cpuCount") + : null, + MemoryMb = wslc.TryGetProperty("memoryMb", out var memoryMb) + ? ReadUInt64(memoryMb, "experimental.wslc.memoryMb") + : null, + Gpu = wslc.TryGetProperty("gpu", out var gpu) + && ReadBoolean(gpu, "experimental.wslc.gpu"), + StoragePath = wslc.TryGetProperty("storagePath", out var storagePath) + ? ReadString(storagePath, "experimental.wslc.storagePath") + : null, + }; + if (wslc.TryGetProperty("portMappings", out var mappings)) + { + if (mappings.ValueKind != JsonValueKind.Array) + { + throw new JsonException("experimental.wslc.portMappings must be an array."); + } + foreach (var (mapping, index) in mappings.EnumerateArray().Select((value, index) => (value, index))) + { + var mappingObject = RequireObject( + mapping, + $"experimental.wslc.portMappings[{index}]"); + EnsureKnownProperties( + mappingObject, + $"experimental.wslc.portMappings[{index}]", + "windowsPort", + "containerPort", + "protocol"); + if (mappingObject.TryGetProperty("protocol", out var protocol) + && ReadString( + protocol, + $"experimental.wslc.portMappings[{index}].protocol") != "tcp") + { + throw new JsonException( + "SandboxRequest can represent only TCP WSLC port mappings."); + } + containment.PortMappings.Add(new WslcPortMapping( + ReadUInt16( + RequireProperty( + mappingObject, + "windowsPort", + $"experimental.wslc.portMappings[{index}]"), + $"experimental.wslc.portMappings[{index}].windowsPort"), + ReadUInt16( + RequireProperty( + mappingObject, + "containerPort", + $"experimental.wslc.portMappings[{index}]"), + $"experimental.wslc.portMappings[{index}].containerPort"))); + } + } + request.Experimental = true; + return containment; + } + + private static Dictionary ReadEnvironment(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Array) + { + throw new JsonException("process.env must be an array."); + } + var environment = new Dictionary(); + foreach (var (entry, index) in element.EnumerateArray().Select((value, index) => (value, index))) + { + var pair = ReadString(entry, $"process.env[{index}]"); + var separator = pair.IndexOf('='); + if (separator <= 0) + { + throw new JsonException( + $"process.env[{index}] must have the form NAME=VALUE."); + } + if (!environment.TryAdd(pair[..separator], pair[(separator + 1)..])) + { + throw new JsonException( + $"process.env contains duplicate variable '{pair[..separator]}'."); + } + } + return environment; + } + + private static List ReadStringList( + JsonElement parent, + string propertyName, + string location) => + parent.TryGetProperty(propertyName, out var value) + ? DeserializeElement>(value, location) + : []; + + private static T DeserializeElement(JsonElement element, string location) + { + try + { + return element.Deserialize(CanonicalReadOptions) + ?? throw new JsonException($"{location} cannot be null."); + } + catch (JsonException error) + { + throw new JsonException($"Invalid {location}: {error.Message}", error); + } + } + + private static T ReadEnum(JsonElement element, string location) + where T : struct, Enum => + DeserializeElement(element, location); + + private static JsonElement RequireObject(JsonElement element, string location) => + element.ValueKind == JsonValueKind.Object + ? element + : throw new JsonException($"{location} must be an object."); + + private static JsonElement RequireProperty( + JsonElement element, + string propertyName, + string location) => + element.TryGetProperty(propertyName, out var value) + ? value + : throw new JsonException($"{location}.{propertyName} is required."); + + private static string RequireString( + JsonElement element, + string propertyName, + string location) => + ReadString( + RequireProperty(element, propertyName, location), + $"{location}.{propertyName}"); + + private static string ReadString(JsonElement element, string location) => + element.ValueKind == JsonValueKind.String + ? element.GetString()! + : throw new JsonException($"{location} must be a string."); + + private static bool ReadBoolean(JsonElement element, string location) => + element.ValueKind is JsonValueKind.True or JsonValueKind.False + ? element.GetBoolean() + : throw new JsonException($"{location} must be a boolean."); + + private static uint ReadUInt32(JsonElement element, string location) => + element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out var value) + ? value + : throw new JsonException($"{location} must be an unsigned 32-bit integer."); + + private static ulong ReadUInt64(JsonElement element, string location) => + element.ValueKind == JsonValueKind.Number && element.TryGetUInt64(out var value) + ? value + : throw new JsonException($"{location} must be an unsigned 64-bit integer."); + + private static ushort ReadUInt16(JsonElement element, string location) + { + var value = ReadUInt32(element, location); + return value is > 0 and <= ushort.MaxValue + ? (ushort)value + : throw new JsonException($"{location} must be between 1 and {ushort.MaxValue}."); + } + + private static void EnsureKnownProperties( + JsonElement element, + string location, + params string[] propertyNames) + { + var known = new HashSet(propertyNames, StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!known.Contains(property.Name)) + { + throw new JsonException( + $"Unknown canonical property '{location}.{property.Name}'."); + } + } + } + private static JsonObject BuildProcess(SandboxRequest request, SandboxPolicy policy) { var process = new JsonObject @@ -177,13 +709,13 @@ private static void AddContainment( { switch (containment) { - case ProcessContainment: + case ProcessContainment process: root["containment"] = "process"; if (OperatingSystem.IsWindows()) { AddProcessContainer( root, - new ProcessContainerContainment(), + process.CanonicalProcessContainer ?? new ProcessContainerContainment(), network, directional, "process", diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index 78b674aad..fc0ff4a46 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -388,9 +388,14 @@ private static TelemetryConsentOutcome RequestConsentCore( /// Asynchronous wrapper over . /// The native API is synchronous and blocking, so the native work runs on the thread pool while /// the presenter is dispatched to the caller's synchronization context when one is available. - /// Cancellation stops waiting for the presenter and prevents its decision from being accepted - /// when observed before native persistence. It does not cancel the presenter's underlying task. /// + /// + /// 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, diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs index 64c3c314a..978088e4c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs @@ -44,7 +44,7 @@ public SandboxRequest(SandboxPolicy policy, string command) public bool Experimental { get; set; } } -/// Serializes requests through MXC's canonical wire projection. +/// Reads and writes requests through MXC's canonical wire projection. public sealed class SandboxRequestJsonConverter : JsonConverter { /// @@ -52,7 +52,7 @@ public override SandboxRequest Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - throw new NotSupportedException("SandboxRequest deserialization is not supported."); + CanonicalRequestBuilder.Deserialize(ref reader); /// public override void Write( @@ -76,7 +76,10 @@ public abstract class SandboxContainment; /// The host's native process-isolation backend: ProcessContainer on Windows, /// Bubblewrap on Linux, and Seatbelt on macOS. /// -public sealed class ProcessContainment : SandboxContainment; +public sealed class ProcessContainment : SandboxContainment +{ + internal ProcessContainerContainment? CanonicalProcessContainer { get; set; } +} /// Explicit Windows ProcessContainer configuration. public sealed class ProcessContainerContainment : SandboxContainment diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index ecb6ef3bb..5555bcdd5 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -497,6 +497,12 @@ as non-grants. See 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 From 50e04928f04bd1d9ae3d069824d096ba6900efb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:50:37 +0000 Subject: [PATCH 06/16] Fix CanonicalRequestBuilder: no implicit Experimental opt-in, reject duplicate properties Co-authored-by: RamonArjona4 <25335379+RamonArjona4@users.noreply.github.com> --- .../Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index fc864e14b..110b72a4a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -185,7 +185,7 @@ internal static SandboxRequest Deserialize(ref Utf8JsonReader reader) { "process" => ReadProcessContainment(root), "processcontainer" => ReadExplicitProcessContainer(root), - "wslc" => ReadWslcContainment(root, request), + "wslc" => ReadWslcContainment(root), _ => throw new JsonException( $"Unsupported canonical containment '{containmentName}'."), }; @@ -405,9 +405,7 @@ private static ProcessContainerContainment ReadProcessContainer(JsonElement elem }; } - private static WslcContainment ReadWslcContainment( - JsonElement root, - SandboxRequest request) + private static WslcContainment ReadWslcContainment(JsonElement root) { if (root.TryGetProperty("processContainer", out _)) { @@ -492,7 +490,6 @@ private static WslcContainment ReadWslcContainment( $"experimental.wslc.portMappings[{index}].containerPort"))); } } - request.Experimental = true; return containment; } @@ -601,6 +598,7 @@ private static void EnsureKnownProperties( params string[] propertyNames) { var known = new HashSet(propertyNames, StringComparer.Ordinal); + var seen = new HashSet(StringComparer.Ordinal); foreach (var property in element.EnumerateObject()) { if (!known.Contains(property.Name)) @@ -608,6 +606,11 @@ private static void EnsureKnownProperties( throw new JsonException( $"Unknown canonical property '{location}.{property.Name}'."); } + if (!seen.Add(property.Name)) + { + throw new JsonException( + $"Duplicate canonical property '{location}.{property.Name}'."); + } } } From b3ff4db84ed9189de2389763ba3496a251ec8cc5 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 12:26:32 -0700 Subject: [PATCH 07/16] Fix PR review findings for consent and request JSON Document consent cancellation as best-effort when native persistence wins, and restore strict canonical SandboxRequest deserialization with round-trip, malformed-input, duplicate-property, and platform-boundary coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcSandboxTests.cs | 41 +++++++++++++++++++ .../CanonicalRequestBuilder.cs | 17 +++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 77d2a376d..ab9161896 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -178,6 +178,10 @@ public void SandboxRequest_DeserializesCanonicalGoldens( Assert.NotNull(request); Assert.IsType(expectedContainment, request.Containment); + if (expectedContainment == typeof(WslcContainment)) + { + Assert.False(request.Experimental); + } JsonAssert.MatchesGolden(JsonSerializer.Serialize(request), fixtureName); } @@ -255,7 +259,44 @@ public void SandboxRequest_RejectsWindowsProcessSectionOnOtherPlatforms() Assert.Contains("only on Windows", exception.Message, StringComparison.Ordinal); } + [Fact] + public void SandboxRequest_RejectsExplicitProcessContainerOnOtherPlatforms() + { + Assert.SkipWhen( + OperatingSystem.IsWindows(), + "ProcessContainer containment is supported on Windows."); + const string json = """ + { + "version":"0.8.0-alpha", + "process":{"commandLine":"echo hi"}, + "containment":"processcontainer", + "processContainer":{"leastPrivilege":false,"capabilities":[]} + } + """; + + Assert.ThrowsAny( + () => JsonSerializer.Deserialize(json)); + + var request = new SandboxRequest( + new SandboxPolicy { Version = "0.8.0-alpha" }, + "echo hi") + { + Containment = new ProcessContainerContainment(), + }; + Assert.ThrowsAny( + () => JsonSerializer.Serialize(request)); + } + [Theory] + [InlineData( + """ + { + "version":"0.8.0-alpha", + "version":"0.8.0-alpha", + "process":{"commandLine":"echo hi"} + } + """, + "Duplicate canonical property")] [InlineData( """{"policy":{"version":"0.8.0-alpha"},"command":"echo legacy"}""", "Unknown canonical property")] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index 110b72a4a..8470ae453 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -362,10 +362,13 @@ private static ProcessContainment ReadProcessContainment(JsonElement root) return containment; } - private static ProcessContainerContainment ReadExplicitProcessContainer(JsonElement root) => - root.TryGetProperty("processContainer", out var processContainer) + private static ProcessContainerContainment ReadExplicitProcessContainer(JsonElement root) + { + EnsureProcessContainerPlatform(); + return root.TryGetProperty("processContainer", out var processContainer) ? ReadProcessContainer(processContainer) : new ProcessContainerContainment { Ui = null }; + } private static ProcessContainerContainment ReadProcessContainer(JsonElement element) { @@ -614,6 +617,15 @@ private static void EnsureKnownProperties( } } + private static void EnsureProcessContainerPlatform() + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "ProcessContainer containment is supported only on Windows."); + } + } + private static JsonObject BuildProcess(SandboxRequest request, SandboxPolicy policy) { var process = new JsonObject @@ -730,6 +742,7 @@ private static void AddContainment( } break; case ProcessContainerContainment processContainer: + EnsureProcessContainerPlatform(); AddProcessContainer( root, processContainer, From eca6fee9e0da8487186ddefc53193babc6aad8c4 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 12:46:26 -0700 Subject: [PATCH 08/16] Address canonical request review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- scripts/check-dotnet-api-parity.js | 2 +- .../MxcSandboxTests.cs | 37 ++++++++++++++++--- .../CanonicalRequestBuilder.cs | 9 +++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js index 10a3ec2ac..69aa47cba 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -308,7 +308,7 @@ const managedSandboxPolicyFields = managedJsonFields( // `captureDenials` is the one existing managed compatibility alias (MXC0001, // removed in 1.0). MxcSandbox.PrepareRequest migrates it to -// containment.captureDenials and strips it before native serialization. +// processContainer.captureDenials and strips it before native serialization. compare( "sandbox policy compatibility aliases", managedSandboxPolicyFields.filter((field) => field === "captureDenials"), diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index ab9161896..42187207c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -137,12 +137,15 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() Experimental = true, }; - JsonAssert.MatchesGolden( - MxcSandbox.SerializeRequest(processContainer), - "request-process-container.json"); - JsonAssert.MatchesGolden( - MxcSandbox.SerializeRequest(directionalNetwork), - "request-directional-network.json"); + if (OperatingSystem.IsWindows()) + { + JsonAssert.MatchesGolden( + MxcSandbox.SerializeRequest(processContainer), + "request-process-container.json"); + JsonAssert.MatchesGolden( + MxcSandbox.SerializeRequest(directionalNetwork), + "request-directional-network.json"); + } JsonAssert.MatchesGolden(MxcSandbox.SerializeRequest(wslc), "request-wslc.json"); } @@ -173,6 +176,10 @@ public void SandboxRequest_DeserializesCanonicalGoldens( string fixtureName, Type expectedContainment) { + Assert.SkipUnless( + expectedContainment != typeof(ProcessContainerContainment) + || OperatingSystem.IsWindows(), + "ProcessContainer canonical fixtures are Windows-specific."); var request = JsonSerializer.Deserialize( JsonAssert.ReadGolden(fixtureName)); @@ -238,6 +245,24 @@ public void SandboxRequest_RoundTripsCanonicalProcessRequest() $"Canonical request changed after round trip.{Environment.NewLine}{secondJson}"); } + [Theory] + [InlineData("")] + [InlineData("A=B")] + public void SandboxRequest_RejectsInvalidEnvironmentVariableNames(string name) + { + var request = new SandboxRequest( + new SandboxPolicy { Version = "0.8.0-alpha" }, + "echo hi") + { + Environment = { [name] = "value" }, + }; + + var exception = Assert.Throws( + () => JsonSerializer.Serialize(request)); + + Assert.Contains("environment variable name", exception.Message); + } + [Fact] public void SandboxRequest_RejectsWindowsProcessSectionOnOtherPlatforms() { diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index 8470ae453..e0b0f2a0e 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -639,6 +639,15 @@ private static JsonObject BuildProcess(SandboxRequest request, SandboxPolicy pol } if (request.Environment.Count != 0) { + foreach (var name in request.Environment.Keys) + { + if (string.IsNullOrEmpty(name) || name.Contains('=')) + { + throw new ArgumentException( + "An environment variable name cannot be empty or contain '='.", + nameof(request)); + } + } process["env"] = JsonSerializer.SerializeToNode( request.Environment.Select(pair => $"{pair.Key}={pair.Value}").ToArray()); } From e5f695eefb80402f841df3d0ed8aff1b71b8551d Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 13:25:34 -0700 Subject: [PATCH 09/16] Keep canonical request JSON platform-independent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcSandboxTests.cs | 64 +++++-------------- .../CanonicalRequestBuilder.cs | 22 +------ 2 files changed, 18 insertions(+), 68 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 42187207c..dd7a57024 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -137,15 +137,12 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() Experimental = true, }; - if (OperatingSystem.IsWindows()) - { - JsonAssert.MatchesGolden( - MxcSandbox.SerializeRequest(processContainer), - "request-process-container.json"); - JsonAssert.MatchesGolden( - MxcSandbox.SerializeRequest(directionalNetwork), - "request-directional-network.json"); - } + JsonAssert.MatchesGolden( + MxcSandbox.SerializeRequest(processContainer), + "request-process-container.json"); + JsonAssert.MatchesGolden( + MxcSandbox.SerializeRequest(directionalNetwork), + "request-directional-network.json"); JsonAssert.MatchesGolden(MxcSandbox.SerializeRequest(wslc), "request-wslc.json"); } @@ -176,10 +173,6 @@ public void SandboxRequest_DeserializesCanonicalGoldens( string fixtureName, Type expectedContainment) { - Assert.SkipUnless( - expectedContainment != typeof(ProcessContainerContainment) - || OperatingSystem.IsWindows(), - "ProcessContainer canonical fixtures are Windows-specific."); var request = JsonSerializer.Deserialize( JsonAssert.ReadGolden(fixtureName)); @@ -264,11 +257,8 @@ public void SandboxRequest_RejectsInvalidEnvironmentVariableNames(string name) } [Fact] - public void SandboxRequest_RejectsWindowsProcessSectionOnOtherPlatforms() + public void SandboxRequest_PreservesCanonicalProcessContainerForProcessContainment() { - Assert.SkipWhen( - OperatingSystem.IsWindows(), - "The processContainer section is canonical for process containment on Windows."); const string json = """ { "version":"0.8.0-alpha", @@ -278,38 +268,16 @@ public void SandboxRequest_RejectsWindowsProcessSectionOnOtherPlatforms() } """; - var exception = Assert.Throws( - () => JsonSerializer.Deserialize(json)); - - Assert.Contains("only on Windows", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void SandboxRequest_RejectsExplicitProcessContainerOnOtherPlatforms() - { - Assert.SkipWhen( - OperatingSystem.IsWindows(), - "ProcessContainer containment is supported on Windows."); - const string json = """ - { - "version":"0.8.0-alpha", - "process":{"commandLine":"echo hi"}, - "containment":"processcontainer", - "processContainer":{"leastPrivilege":false,"capabilities":[]} - } - """; - - Assert.ThrowsAny( - () => JsonSerializer.Deserialize(json)); + var request = JsonSerializer.Deserialize(json); + var serialized = JsonSerializer.Serialize(request); - var request = new SandboxRequest( - new SandboxPolicy { Version = "0.8.0-alpha" }, - "echo hi") - { - Containment = new ProcessContainerContainment(), - }; - Assert.ThrowsAny( - () => JsonSerializer.Serialize(request)); + Assert.NotNull(request); + Assert.IsType(request.Containment); + using var document = JsonDocument.Parse(serialized); + Assert.Equal( + "process", + document.RootElement.GetProperty("containment").GetString()); + Assert.True(document.RootElement.TryGetProperty("processContainer", out _)); } [Theory] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index e0b0f2a0e..121a8a600 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -352,23 +352,15 @@ private static ProcessContainment ReadProcessContainment(JsonElement root) var containment = new ProcessContainment(); if (root.TryGetProperty("processContainer", out var processContainer)) { - if (!OperatingSystem.IsWindows()) - { - throw new JsonException( - "A canonical processContainer section is supported only on Windows."); - } containment.CanonicalProcessContainer = ReadProcessContainer(processContainer); } return containment; } - private static ProcessContainerContainment ReadExplicitProcessContainer(JsonElement root) - { - EnsureProcessContainerPlatform(); - return root.TryGetProperty("processContainer", out var processContainer) + private static ProcessContainerContainment ReadExplicitProcessContainer(JsonElement root) => + root.TryGetProperty("processContainer", out var processContainer) ? ReadProcessContainer(processContainer) : new ProcessContainerContainment { Ui = null }; - } private static ProcessContainerContainment ReadProcessContainer(JsonElement element) { @@ -617,15 +609,6 @@ private static void EnsureKnownProperties( } } - private static void EnsureProcessContainerPlatform() - { - if (!OperatingSystem.IsWindows()) - { - throw new PlatformNotSupportedException( - "ProcessContainer containment is supported only on Windows."); - } - } - private static JsonObject BuildProcess(SandboxRequest request, SandboxPolicy policy) { var process = new JsonObject @@ -751,7 +734,6 @@ private static void AddContainment( } break; case ProcessContainerContainment processContainer: - EnsureProcessContainerPlatform(); AddProcessContainer( root, processContainer, From 1f33d360f26f5f36b247755bb8147049ba1282b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:11:33 +0000 Subject: [PATCH 10/16] Address canonical request review feedback Co-authored-by: RamonArjona4 <25335379+RamonArjona4@users.noreply.github.com> --- .../MxcSandboxTests.cs | 20 +++++++++++++++++++ .../CanonicalRequestBuilder.cs | 18 ++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index dd7a57024..3eae5f71b 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -280,6 +280,26 @@ public void SandboxRequest_PreservesCanonicalProcessContainerForProcessContainme Assert.True(document.RootElement.TryGetProperty("processContainer", out _)); } + [Fact] + public void SandboxRequest_AcceptsAndIgnoresCanonicalMetadata() + { + const string json = """ + { + "$schema":"https://example.test/mxc.schema.json", + "_comment":{"purpose":"test"}, + "version":"0.8.0-alpha", + "process":{"commandLine":"echo hi"} + } + """; + + var request = JsonSerializer.Deserialize(json); + using var document = JsonDocument.Parse(JsonSerializer.Serialize(request)); + + Assert.NotNull(request); + Assert.False(document.RootElement.TryGetProperty("$schema", out _)); + Assert.False(document.RootElement.TryGetProperty("_comment", out _)); + } + [Theory] [InlineData( """ diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index 121a8a600..3256adfdf 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -98,6 +98,8 @@ internal static SandboxRequest Deserialize(ref Utf8JsonReader reader) EnsureKnownProperties( root, "request", + "$schema", + "_comment", "version", "containerId", "lifecycle", @@ -718,17 +720,27 @@ private static void AddContainment( { case ProcessContainment process: root["containment"] = "process"; - if (OperatingSystem.IsWindows()) + if (process.CanonicalProcessContainer is { } canonicalProcessContainer) { AddProcessContainer( root, - process.CanonicalProcessContainer ?? new ProcessContainerContainment(), + canonicalProcessContainer, network, directional, "process", options); } - else if (OperatingSystem.IsLinux()) + else if (OperatingSystem.IsWindows()) + { + AddProcessContainer( + root, + new ProcessContainerContainment(), + network, + directional, + "process", + options); + } + if (OperatingSystem.IsLinux()) { ApplyLinuxNetworkPolicy(root); } From 2393b2e5332b1cbcbd4c5bd41faab9ca8d287765 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 15:14:15 -0700 Subject: [PATCH 11/16] Explain canonical request adapter boundary Document why the managed authoring model needs an explicit canonical wire projection while native request validation remains authoritative. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index 3256adfdf..dcc50f51c 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -7,6 +7,14 @@ namespace Microsoft.Mxc.Sdk; +// The public .NET request types are an ergonomic authoring model rather than a +// direct representation of MXC's cross-language wire contract. This adapter +// performs the nontrivial projection between those models, including process, +// lifecycle, containment, and version-dependent network shapes. +// +// The adapter does not replace native request validation. It produces and +// reconstructs canonical JSON, while the central Rust parser remains the +// authority for whether a request is valid and executable. internal static class CanonicalRequestBuilder { private static readonly JsonSerializerOptions CanonicalReadOptions = new() From 83e143560d80899501b20147f8f20d395550406c Mon Sep 17 00:00:00 2001 From: RamonArjona4 Date: Wed, 9 Sep 2026 15:45:51 -0700 Subject: [PATCH 12/16] Refactor HasDirectionalNetwork method Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../CanonicalRequestBuilder.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs index dcc50f51c..7460d91e7 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs @@ -649,14 +649,20 @@ private static JsonObject BuildProcess(SandboxRequest request, SandboxPolicy pol private static bool HasDirectionalNetwork( NetworkPolicy? network, - SandboxContainment containment) => - containment is ProcessContainerContainment + SandboxContainment containment) + { + var processContainer = containment switch { - Network.AllowedProxyPeer: { } allowedProxyPeer, - } && !string.IsNullOrWhiteSpace(allowedProxyPeer) - || network?.Egress is not null - || network?.Ingress is not null - || network?.RuntimeConfig?.NetworkProxy is not null; + ProcessContainerContainment explicitContainment => explicitContainment, + ProcessContainment process => process.CanonicalProcessContainer, + _ => null, + }; + return processContainer?.Network?.AllowedProxyPeer is { } allowedProxyPeer + && !string.IsNullOrWhiteSpace(allowedProxyPeer) + || network?.Egress is not null + || network?.Ingress is not null + || network?.RuntimeConfig?.NetworkProxy is not null; + } private static bool HasLegacyNetwork(NetworkPolicy? network) => network is not null From c2da35399f7840b2d36ac17abe95159ef42d3cc5 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 17:54:01 -0700 Subject: [PATCH 13/16] Restore FFI request parsing boundary Return one-shot language bindings to the co-versioned RequestSpec envelope and parse telemetry at the FFI adapter before constructing SandboxRequest. Remove the managed canonical request converter and hidden engine bridge while preserving strict malformed-input validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .github/copilot-instructions.md | 2 +- scripts/check-dotnet-api-parity.js | 17 +- scripts/check-dotnet-bindings-codegen.js | 6 +- .../MxcSandboxTests.cs | 374 +------ .../CanonicalRequestBuilder.cs | 914 ------------------ sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs | 13 +- sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs | 2 +- .../Microsoft.Mxc.Sdk/SandboxRequest.cs | 45 +- src/Cargo.lock | 12 + src/core/mxc-sdk/Cargo.toml | 3 - src/core/mxc-sdk/src/lib.rs | 6 - src/core/mxc_engine/Cargo.toml | 3 - src/core/mxc_engine/src/lib.rs | 6 - src/core/mxc_engine/src/policy.rs | 188 ---- src/ffi/mxc_ffi/Cargo.toml | 4 +- src/ffi/mxc_ffi/src/lib.rs | 52 +- src/ffi/mxc_ffi/src/request.rs | 844 +++++++++++++++- src/ffi/mxc_ffi/src/streaming.rs | 44 +- src/ffi/mxc_ffi/tests/ffi.rs | 38 +- tests/policy/request-directional-network.json | 89 +- tests/policy/request-process-container.json | 90 +- tests/policy/request-wslc.json | 54 +- 22 files changed, 1044 insertions(+), 1762 deletions(-) delete mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5a6d7b404..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. The one-shot entry points accept one complete canonical MXC configuration JSON document and a separate experimental opt-in; they pass the raw JSON directly to `wxc_common::config_parser`, so the central parser remains the only schema/unknown-field boundary. `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_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 69aa47cba..ec7d8d096 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -223,6 +223,7 @@ function compareStructFields(label, rustSource, rustName, managedSource, managed } 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", @@ -322,20 +323,18 @@ compare( rustStructFields(rustPolicy, "SandboxPolicy") ); -// Telemetry is execution metadata, not a containment restriction. Managed -// request serialization emits the canonical wire section directly, so compare -// its public shape with that central parser contract. +// 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 canonical telemetry field", + "sandbox policy binding telemetry field", managedSandboxPolicyFields.filter((field) => field === "telemetry"), - rustStructFields(rustWire, "MxcConfig").filter( - (field) => field === "telemetry" - ) + ["telemetry"] ); compareStructFields( "telemetry settings", - rustWire, - "Telemetry", + rustRequest, + "TelemetrySpec", managedPolicy, "TelemetrySettings" ); diff --git a/scripts/check-dotnet-bindings-codegen.js b/scripts/check-dotnet-bindings-codegen.js index 3fd6be8a7..40cc2ff08 100644 --- a/scripts/check-dotnet-bindings-codegen.js +++ b/scripts/check-dotnet-bindings-codegen.js @@ -94,15 +94,15 @@ if (missing.length > 0) { } const requiredSignatures = [ - "mxc_run_request(byte* request_json_utf8, int experimental, MxcRunResult* @out)", - "mxc_spawn_request(byte* request_json_utf8, int experimental, MxcSandbox** out_handle, MxcErrorDetail* out_error)", + "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 canonical request signature(s):\n " + + "ERROR: generated C# bindings have unexpected request signature(s):\n " + missingSignatures.join("\n ") ); process.exit(1); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs index 3eae5f71b..c3c01cad9 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Microsoft.Mxc.Sdk; using Xunit; @@ -55,7 +54,7 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() Capabilities = ["internetClient"], CaptureDenials = new CaptureDenialsPolicy { - OutputPath = "C:\\denials.json", + OutputPath = "C:\\logs\\denials.json", RetainEtl = true, }, Ui = new ProcessContainerUiPolicy @@ -113,11 +112,7 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() }, }, }, - "echo network") - { - Containment = new ProcessContainerContainment(), - ContainerName = "golden-directional-network", - }; + "echo network"); var wslc = new SandboxRequest( new SandboxPolicy { Version = "0.8.0-alpha" }, @@ -146,267 +141,6 @@ public void FullRequestSerialization_MatchesCrossLanguageGoldens() JsonAssert.MatchesGolden(MxcSandbox.SerializeRequest(wslc), "request-wslc.json"); } - [Fact] - public void SandboxRequest_SerializesCanonically() - { - var request = new SandboxRequest( - new SandboxPolicy { Version = "0.8.0-alpha" }, - "printf compatibility") - { - Containment = new WslcContainment - { - Image = "alpine:3.20", - }, - ContainerName = "serialization-compatibility", - Experimental = true, - }; - - var json = JsonSerializer.Serialize(request); - Assert.Equal(MxcSandbox.SerializeRequest(request), json); - } - - [Theory] - [InlineData("request-process-container.json", typeof(ProcessContainerContainment))] - [InlineData("request-directional-network.json", typeof(ProcessContainerContainment))] - [InlineData("request-wslc.json", typeof(WslcContainment))] - public void SandboxRequest_DeserializesCanonicalGoldens( - string fixtureName, - Type expectedContainment) - { - var request = JsonSerializer.Deserialize( - JsonAssert.ReadGolden(fixtureName)); - - Assert.NotNull(request); - Assert.IsType(expectedContainment, request.Containment); - if (expectedContainment == typeof(WslcContainment)) - { - Assert.False(request.Experimental); - } - JsonAssert.MatchesGolden(JsonSerializer.Serialize(request), fixtureName); - } - - [Fact] - public void SandboxRequest_RoundTripsCanonicalProcessRequest() - { - var request = new SandboxRequest( - new SandboxPolicy - { - Version = "0.8.0-alpha", - Filesystem = new FilesystemPolicy - { - ReadwritePaths = [@"C:\work"], - ClearPolicyOnExit = false, - }, - Network = new NetworkPolicy - { - AllowOutbound = true, - AllowLocalNetwork = true, - AllowedHosts = ["example.com"], - Proxy = new LocalhostNetworkProxyPolicy(8080), - }, - Telemetry = new TelemetrySettings { Enabled = true }, - Ui = new UiPolicy - { - AllowWindows = true, - Clipboard = ClipboardPolicy.Write, - AllowInputInjection = true, - }, - TimeoutMs = 1234, - }, - "echo roundtrip") - { - ContainerName = "roundtrip", - WorkingDirectory = @"C:\work", - Environment = - { - ["EMPTY"] = string.Empty, - ["WITH_EQUALS"] = "left=right", - }, - }; - - var firstJson = JsonSerializer.Serialize(request); - var deserialized = JsonSerializer.Deserialize(firstJson); - - Assert.NotNull(deserialized); - Assert.Equal("left=right", deserialized.Environment["WITH_EQUALS"]); - Assert.True(deserialized.Policy.Telemetry!.Enabled); - Assert.Null(deserialized.Policy.Network!.Egress); - Assert.Null(deserialized.Policy.Network.Ingress); - var secondJson = JsonSerializer.Serialize(deserialized); - Assert.True( - JsonNode.DeepEquals(JsonNode.Parse(firstJson), JsonNode.Parse(secondJson)), - $"Canonical request changed after round trip.{Environment.NewLine}{secondJson}"); - } - - [Theory] - [InlineData("")] - [InlineData("A=B")] - public void SandboxRequest_RejectsInvalidEnvironmentVariableNames(string name) - { - var request = new SandboxRequest( - new SandboxPolicy { Version = "0.8.0-alpha" }, - "echo hi") - { - Environment = { [name] = "value" }, - }; - - var exception = Assert.Throws( - () => JsonSerializer.Serialize(request)); - - Assert.Contains("environment variable name", exception.Message); - } - - [Fact] - public void SandboxRequest_PreservesCanonicalProcessContainerForProcessContainment() - { - const string json = """ - { - "version":"0.8.0-alpha", - "process":{"commandLine":"echo hi"}, - "containment":"process", - "processContainer":{"leastPrivilege":false,"capabilities":[]} - } - """; - - var request = JsonSerializer.Deserialize(json); - var serialized = JsonSerializer.Serialize(request); - - Assert.NotNull(request); - Assert.IsType(request.Containment); - using var document = JsonDocument.Parse(serialized); - Assert.Equal( - "process", - document.RootElement.GetProperty("containment").GetString()); - Assert.True(document.RootElement.TryGetProperty("processContainer", out _)); - } - - [Fact] - public void SandboxRequest_AcceptsAndIgnoresCanonicalMetadata() - { - const string json = """ - { - "$schema":"https://example.test/mxc.schema.json", - "_comment":{"purpose":"test"}, - "version":"0.8.0-alpha", - "process":{"commandLine":"echo hi"} - } - """; - - var request = JsonSerializer.Deserialize(json); - using var document = JsonDocument.Parse(JsonSerializer.Serialize(request)); - - Assert.NotNull(request); - Assert.False(document.RootElement.TryGetProperty("$schema", out _)); - Assert.False(document.RootElement.TryGetProperty("_comment", out _)); - } - - [Theory] - [InlineData( - """ - { - "version":"0.8.0-alpha", - "version":"0.8.0-alpha", - "process":{"commandLine":"echo hi"} - } - """, - "Duplicate canonical property")] - [InlineData( - """{"policy":{"version":"0.8.0-alpha"},"command":"echo legacy"}""", - "Unknown canonical property")] - [InlineData( - """ - { - "version":"0.8.0-alpha", - "lifecycle":{"destroyOnExit":false}, - "process":{"commandLine":"echo hi"} - } - """, - "destroyOnExit=false")] - [InlineData( - """ - { - "version":"0.8.0-alpha", - "process":{"commandLine":"echo hi","env":["INVALID"]}, - "containment":"process" - } - """, - "NAME=VALUE")] - [InlineData( - """ - { - "version":"0.8.0-alpha", - "process":{"commandLine":"echo hi"}, - "containment":"wslc", - "experimental":{ - "wslc":{ - "portMappings":[ - {"windowsPort":8080,"containerPort":80,"protocol":"udp"} - ] - } - } - } - """, - "only TCP")] - public void SandboxRequest_RejectsUnsupportedDeserialization( - string json, - string expectedMessage) - { - var exception = Assert.Throws( - () => JsonSerializer.Deserialize(json)); - - Assert.Contains(expectedMessage, exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void ProcessContainment_PromotesHostRulesToFirewallOnLinux() - { - Assert.SkipUnless(OperatingSystem.IsLinux(), "Firewall promotion is Linux-specific"); - - var request = new SandboxRequest( - new SandboxPolicy - { - Version = "0.8.0-alpha", - Network = new NetworkPolicy - { - AllowOutbound = true, - AllowedHosts = ["example.com"], - }, - }, - "echo network"); - - using var document = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - - Assert.Equal( - "firewall", - document.RootElement.GetProperty("network").GetProperty("enforcementMode").GetString()); - } - - [Fact] - public void ProcessContainment_LeavesEnforcementModeUnsetForProxyOnLinux() - { - Assert.SkipUnless(OperatingSystem.IsLinux(), "Firewall promotion is Linux-specific"); - - var request = new SandboxRequest( - new SandboxPolicy - { - Version = "0.8.0-alpha", - Network = new NetworkPolicy - { - AllowOutbound = true, - AllowedHosts = ["example.com"], - Proxy = new UrlNetworkProxyPolicy("http://127.0.0.1:8080"), - }, - }, - "echo network"); - - using var document = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - - Assert.False( - document.RootElement.GetProperty("network").TryGetProperty( - "enforcementMode", - out _)); - } - [Fact] public void NativeVersion_IsNotEmpty() { @@ -607,13 +341,14 @@ public void SandboxRequest_NestsCaptureDenialsUnderProcessContainer() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - var capture = root.GetProperty("processContainer").GetProperty("captureDenials"); + var containment = root.GetProperty("containment"); + var capture = containment.GetProperty("captureDenials"); - Assert.Equal("processcontainer", root.GetProperty("containment").GetString()); + Assert.Equal("processContainer", containment.GetProperty("type").GetString()); Assert.Equal("block", capture.GetProperty("mode").GetString()); Assert.False(capture.GetProperty("retainEtl").GetBoolean()); Assert.False(capture.TryGetProperty("outputPath", out _)); - Assert.False(root.TryGetProperty("captureDenials", out _)); + Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); } [Fact] @@ -631,15 +366,15 @@ public void SandboxRequest_MigratesLegacyCaptureDenialsWithoutMutation() using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - var processContainer = root.GetProperty("processContainer"); + var containment = root.GetProperty("containment"); - Assert.Equal("processcontainer", root.GetProperty("containment").GetString()); + Assert.Equal("processContainer", containment.GetProperty("type").GetString()); Assert.Equal( "allow", - processContainer.GetProperty("captureDenials").GetProperty("mode").GetString()); + containment.GetProperty("captureDenials").GetProperty("mode").GetString()); Assert.True( - processContainer.GetProperty("captureDenials").GetProperty("retainEtl").GetBoolean()); - Assert.False(root.TryGetProperty("captureDenials", out _)); + containment.GetProperty("captureDenials").GetProperty("retainEtl").GetBoolean()); + Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); Assert.Same(originalContainment, request.Containment); } @@ -690,12 +425,12 @@ public void SandboxRequest_AllowsEquivalentLegacyCaptureDenials() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - var processContainer = doc.RootElement.GetProperty("processContainer"); + var containment = doc.RootElement.GetProperty("containment"); - Assert.True(processContainer.GetProperty("leastPrivilege").GetBoolean()); + Assert.True(containment.GetProperty("leastPrivilege").GetBoolean()); Assert.Equal( captureDenials.OutputPath, - processContainer.GetProperty("captureDenials").GetProperty("outputPath").GetString()); + containment.GetProperty("captureDenials").GetProperty("outputPath").GetString()); } [Fact] @@ -736,14 +471,12 @@ public void SandboxRequest_SerializesExecutionSettings() using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - Assert.Equal( - "echo hi", - root.GetProperty("process").GetProperty("commandLine").GetString()); - Assert.Equal("process", root.GetProperty("containment").GetString()); - Assert.Equal("test-container", root.GetProperty("containerId").GetString()); - Assert.Equal(@"C:\work", root.GetProperty("process").GetProperty("cwd").GetString()); - Assert.Equal("GREETING=hello", root.GetProperty("process").GetProperty("env")[0].GetString()); - Assert.False(root.TryGetProperty("experimentalOptIn", out _)); + Assert.Equal("echo hi", root.GetProperty("command").GetString()); + Assert.Equal("process", root.GetProperty("containment").GetProperty("type").GetString()); + Assert.Equal("test-container", root.GetProperty("containerName").GetString()); + Assert.Equal(@"C:\work", root.GetProperty("workingDirectory").GetString()); + Assert.Equal("hello", root.GetProperty("environment").GetProperty("GREETING").GetString()); + Assert.True(root.GetProperty("experimental").GetBoolean()); } [Fact] @@ -778,7 +511,7 @@ public void SandboxRequest_SerializesCompleteProcessContainerOptions() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - var containment = doc.RootElement.GetProperty("processContainer"); + var containment = doc.RootElement.GetProperty("containment"); Assert.True(containment.GetProperty("leastPrivilege").GetBoolean()); Assert.True(containment.GetProperty("learningMode").GetBoolean()); @@ -813,10 +546,9 @@ public void SandboxRequest_SerializesWslcOptions() }; using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); - var root = doc.RootElement; - var containment = root.GetProperty("experimental").GetProperty("wslc"); + var containment = doc.RootElement.GetProperty("containment"); - Assert.Equal("wslc", root.GetProperty("containment").GetString()); + Assert.Equal("wslc", containment.GetProperty("type").GetString()); Assert.Equal("python:3.12", containment.GetProperty("image").GetString()); Assert.Equal(4, containment.GetProperty("cpuCount").GetInt32()); Assert.Equal(4096, containment.GetProperty("memoryMb").GetInt64()); @@ -825,8 +557,6 @@ public void SandboxRequest_SerializesWslcOptions() containment.GetProperty("portMappings")[0].GetProperty("windowsPort").GetInt32()); Assert.Equal(80, containment.GetProperty("portMappings")[0].GetProperty("containerPort").GetInt32()); - Assert.Equal("tcp", - containment.GetProperty("portMappings")[0].GetProperty("protocol").GetString()); } [Fact] @@ -888,49 +618,6 @@ public void SandboxPolicy_SerializesDirectionalNetworking() network.GetProperty("runtimeConfig").GetProperty("networkProxy").GetString()); } - [Fact] - public void SandboxRequest_RejectsMixedLegacyAndDirectionalNetworking() - { - var request = new SandboxRequest( - new SandboxPolicy - { - Version = "0.8.0-alpha", - Network = new NetworkPolicy - { - AllowOutbound = true, - Egress = new NetworkEgressPolicy { Default = NetworkAction.Deny }, - }, - }, - "echo hi"); - - var exception = Assert.Throws( - () => MxcSandbox.SerializeRequest(request)); - - Assert.Equal(ErrorCode.MalformedRequest, exception.Code); - Assert.Contains("cannot be combined", exception.Message); - } - - [Fact] - public void SandboxRequest_RejectsProcessContainerHostRulesWithoutOutbound() - { - var request = new SandboxRequest( - new SandboxPolicy - { - Version = "0.7.0-alpha", - Network = new NetworkPolicy { AllowedHosts = ["example.com"] }, - }, - "echo hi") - { - Containment = new ProcessContainerContainment(), - }; - - var exception = Assert.Throws( - () => MxcSandbox.SerializeRequest(request)); - - Assert.Equal(ErrorCode.MalformedRequest, exception.Code); - Assert.Contains("require allowOutbound", exception.Message); - } - [Fact] public void SandboxPolicy_OmitsCaptureDenialsWhenNotConfigured() { @@ -1022,7 +709,7 @@ public void RunRequest_DirectionalNetworkingReachesNativeVersionValidation() var ex = Assert.Throws(() => MxcSandbox.Run(request)); Assert.Equal(ErrorCode.MalformedRequest, ex.Code); - Assert.Contains("require schema version 0.8", ex.Message); + Assert.Contains("network egress/ingress/runtimeConfig", ex.Message); } [Theory] @@ -1131,17 +818,17 @@ public void SerializeRequest_StripsLegacyCaptureDenialsFromThePolicy() using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); var root = doc.RootElement; - Assert.False(root.TryGetProperty("captureDenials", out _)); + Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); Assert.Equal( "block", - root.GetProperty("processContainer") + root.GetProperty("containment") .GetProperty("captureDenials") .GetProperty("mode") .GetString()); } [Fact] - public void SerializeRequest_PreservesTelemetryWhenMigratingLegacyCaptureDenials() + public void SerializeRequest_PreservesTelemetryInTheBindingPolicy() { var policy = CreateLegacyCaptureDenialsPolicy( new CaptureDenialsPolicy(), @@ -1153,8 +840,11 @@ public void SerializeRequest_PreservesTelemetryWhenMigratingLegacyCaptureDenials var root = doc.RootElement; Assert.True( - root.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); - Assert.False(root.TryGetProperty("captureDenials", out _)); + root.GetProperty("policy") + .GetProperty("telemetry") + .GetProperty("enabled") + .GetBoolean()); + Assert.False(root.GetProperty("policy").TryGetProperty("captureDenials", out _)); } private static SandboxPolicy CreateLegacyCaptureDenialsPolicy( diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs deleted file mode 100644 index 7460d91e7..000000000 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/CanonicalRequestBuilder.cs +++ /dev/null @@ -1,914 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace Microsoft.Mxc.Sdk; - -// The public .NET request types are an ergonomic authoring model rather than a -// direct representation of MXC's cross-language wire contract. This adapter -// performs the nontrivial projection between those models, including process, -// lifecycle, containment, and version-dependent network shapes. -// -// The adapter does not replace native request validation. It produces and -// reconstructs canonical JSON, while the central Rust parser remains the -// authority for whether a request is valid and executable. -internal static class CanonicalRequestBuilder -{ - private static readonly JsonSerializerOptions CanonicalReadOptions = new() - { - PropertyNameCaseInsensitive = false, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, - Converters = - { - new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), - new NetworkProxyPolicyJsonConverter(), - }, - }; - - internal static string Serialize(SandboxRequest request, JsonSerializerOptions options) - { - if (string.IsNullOrEmpty(request.Policy.Version)) - { - throw new MxcException(ErrorCode.MalformedRequest, "Policy version is required."); - } - - var policy = request.Policy; - var filesystem = policy.Filesystem ?? new FilesystemPolicy(); - var clearPolicy = filesystem.ClearPolicyOnExit ?? true; - var root = new JsonObject - { - ["version"] = policy.Version, - ["containerId"] = request.ContainerName ?? Guid.NewGuid().ToString("N"), - ["lifecycle"] = new JsonObject - { - ["destroyOnExit"] = true, - ["preservePolicy"] = !clearPolicy, - }, - ["process"] = BuildProcess(request, policy), - ["filesystem"] = new JsonObject - { - ["readwritePaths"] = JsonSerializer.SerializeToNode( - filesystem.ReadwritePaths, - options), - ["readonlyPaths"] = JsonSerializer.SerializeToNode( - filesystem.ReadonlyPaths, - options), - ["deniedPaths"] = JsonSerializer.SerializeToNode( - filesystem.DeniedPaths, - options), - }, - }; - - if (policy.Ui is not null) - { - root["ui"] = new JsonObject - { - ["disable"] = !policy.Ui.AllowWindows, - ["clipboard"] = Wire(policy.Ui.Clipboard), - ["injection"] = policy.Ui.AllowInputInjection, - }; - } - - var directional = HasDirectionalNetwork(policy.Network, request.Containment); - if (directional && HasLegacyNetwork(policy.Network)) - { - throw new MxcException( - ErrorCode.MalformedRequest, - "Legacy network fields cannot be combined with directional network fields."); - } - if (RequiresOutboundForHostRules(request.Containment) - && policy.Network is { AllowOutbound: false } networkWithRules - && (networkWithRules.AllowedHosts.Count != 0 - || networkWithRules.BlockedHosts.Count != 0)) - { - throw new MxcException( - ErrorCode.MalformedRequest, - "allowedHosts/blockedHosts require allowOutbound to be true."); - } - AddNetwork(root, policy.Network, directional, options); - AddContainment(root, request.Containment, policy.Network, directional, options); - - if (policy.Telemetry is not null) - { - root["telemetry"] = new JsonObject { ["enabled"] = policy.Telemetry.Enabled }; - } - - return root.ToJsonString(options); - } - - internal static SandboxRequest Deserialize(ref Utf8JsonReader reader) - { - using var document = JsonDocument.ParseValue(ref reader); - var root = RequireObject(document.RootElement, "request"); - EnsureKnownProperties( - root, - "request", - "$schema", - "_comment", - "version", - "containerId", - "lifecycle", - "process", - "filesystem", - "ui", - "network", - "runtimeConfig", - "telemetry", - "containment", - "processContainer", - "experimental"); - - var policy = new SandboxPolicy - { - Version = RequireString(root, "version", "request"), - }; - - var process = RequireObject( - RequireProperty(root, "process", "request"), - "process"); - EnsureKnownProperties(process, "process", "commandLine", "timeout", "cwd", "env"); - var request = new SandboxRequest( - policy, - RequireString(process, "commandLine", "process")); - - if (process.TryGetProperty("timeout", out var timeout)) - { - var timeoutMs = ReadUInt32(timeout, "process.timeout"); - policy.TimeoutMs = timeoutMs == 0 ? null : timeoutMs; - } - if (process.TryGetProperty("cwd", out var cwd)) - { - request.WorkingDirectory = ReadString(cwd, "process.cwd"); - } - if (process.TryGetProperty("env", out var environment)) - { - request.Environment = ReadEnvironment(environment); - } - - if (root.TryGetProperty("containerId", out var containerId)) - { - request.ContainerName = ReadString(containerId, "containerId"); - } - - ReadFilesystemAndLifecycle(root, policy); - if (root.TryGetProperty("ui", out var ui)) - { - policy.Ui = ReadUiPolicy(ui); - } - - if (root.TryGetProperty("network", out var network)) - { - policy.Network = ReadNetworkPolicy( - network, - root.TryGetProperty("runtimeConfig", out var runtimeConfig) - ? runtimeConfig - : null); - } - else if (root.TryGetProperty("runtimeConfig", out var runtimeConfig)) - { - policy.Network = new NetworkPolicy - { - RuntimeConfig = DeserializeElement( - runtimeConfig, - "runtimeConfig"), - }; - } - - if (root.TryGetProperty("telemetry", out var telemetry)) - { - var telemetryObject = RequireObject(telemetry, "telemetry"); - EnsureKnownProperties(telemetryObject, "telemetry", "enabled"); - policy.Telemetry = new TelemetrySettings - { - Enabled = telemetryObject.TryGetProperty("enabled", out var enabled) - && ReadBoolean(enabled, "telemetry.enabled"), - }; - } - - var containmentName = root.TryGetProperty("containment", out var containment) - ? ReadString(containment, "containment") - : "process"; - request.Containment = containmentName switch - { - "process" => ReadProcessContainment(root), - "processcontainer" => ReadExplicitProcessContainer(root), - "wslc" => ReadWslcContainment(root), - _ => throw new JsonException( - $"Unsupported canonical containment '{containmentName}'."), - }; - - if (containmentName != "wslc" && root.TryGetProperty("experimental", out var experimental)) - { - var experimentalObject = RequireObject(experimental, "experimental"); - if (experimentalObject.EnumerateObject().Any()) - { - throw new JsonException( - "Only canonical experimental.wslc requests can be deserialized."); - } - } - - return request; - } - - private static void ReadFilesystemAndLifecycle( - JsonElement root, - SandboxPolicy policy) - { - var filesystem = new FilesystemPolicy(); - if (root.TryGetProperty("filesystem", out var filesystemElement)) - { - var filesystemObject = RequireObject(filesystemElement, "filesystem"); - EnsureKnownProperties( - filesystemObject, - "filesystem", - "readwritePaths", - "readonlyPaths", - "deniedPaths"); - filesystem.ReadwritePaths = ReadStringList( - filesystemObject, - "readwritePaths", - "filesystem.readwritePaths"); - filesystem.ReadonlyPaths = ReadStringList( - filesystemObject, - "readonlyPaths", - "filesystem.readonlyPaths"); - filesystem.DeniedPaths = ReadStringList( - filesystemObject, - "deniedPaths", - "filesystem.deniedPaths"); - } - - var preservePolicy = false; - if (root.TryGetProperty("lifecycle", out var lifecycleElement)) - { - var lifecycle = RequireObject(lifecycleElement, "lifecycle"); - EnsureKnownProperties( - lifecycle, - "lifecycle", - "destroyOnExit", - "preservePolicy"); - if (lifecycle.TryGetProperty("destroyOnExit", out var destroyOnExit) - && !ReadBoolean(destroyOnExit, "lifecycle.destroyOnExit")) - { - throw new JsonException( - "SandboxRequest cannot represent lifecycle.destroyOnExit=false."); - } - if (lifecycle.TryGetProperty("preservePolicy", out var preserve)) - { - preservePolicy = ReadBoolean(preserve, "lifecycle.preservePolicy"); - } - } - filesystem.ClearPolicyOnExit = !preservePolicy; - policy.Filesystem = filesystem; - } - - private static UiPolicy ReadUiPolicy(JsonElement element) - { - var ui = RequireObject(element, "ui"); - EnsureKnownProperties(ui, "ui", "disable", "clipboard", "injection"); - return new UiPolicy - { - AllowWindows = ui.TryGetProperty("disable", out var disable) - && !ReadBoolean(disable, "ui.disable"), - Clipboard = ui.TryGetProperty("clipboard", out var clipboard) - ? ReadEnum(clipboard, "ui.clipboard") - : ClipboardPolicy.None, - AllowInputInjection = ui.TryGetProperty("injection", out var injection) - && ReadBoolean(injection, "ui.injection"), - }; - } - - private static NetworkPolicy? ReadNetworkPolicy( - JsonElement element, - JsonElement? runtimeConfig) - { - var network = RequireObject(element, "network"); - var directional = network.TryGetProperty("egress", out _) - || network.TryGetProperty("ingress", out _); - if (directional) - { - EnsureKnownProperties(network, "network", "egress", "ingress"); - return new NetworkPolicy - { - Egress = network.TryGetProperty("egress", out var egress) - ? DeserializeElement(egress, "network.egress") - : null, - Ingress = network.TryGetProperty("ingress", out var ingress) - ? DeserializeElement(ingress, "network.ingress") - : null, - RuntimeConfig = runtimeConfig is { } directionalRuntime - ? DeserializeElement( - directionalRuntime, - "runtimeConfig") - : null, - }; - } - - EnsureKnownProperties( - network, - "network", - "defaultPolicy", - "allowLocalNetwork", - "allowedHosts", - "blockedHosts", - "proxy", - "enforcementMode"); - if (runtimeConfig is null - && network.EnumerateObject().All( - property => property.Name == "defaultPolicy" - && property.Value.ValueKind == JsonValueKind.String - && property.Value.GetString() == "block")) - { - return null; - } - var policy = new NetworkPolicy - { - AllowOutbound = network.TryGetProperty("defaultPolicy", out var defaultPolicy) - && ReadString(defaultPolicy, "network.defaultPolicy") switch - { - "allow" => true, - "block" => false, - var value => throw new JsonException( - $"Unsupported network.defaultPolicy '{value}'."), - }, - AllowLocalNetwork = network.TryGetProperty("allowLocalNetwork", out var local) - && ReadBoolean(local, "network.allowLocalNetwork"), - AllowedHosts = ReadStringList(network, "allowedHosts", "network.allowedHosts"), - BlockedHosts = ReadStringList(network, "blockedHosts", "network.blockedHosts"), - Proxy = network.TryGetProperty("proxy", out var proxy) - ? DeserializeElement(proxy, "network.proxy") - : null, - RuntimeConfig = runtimeConfig is { } legacyRuntime - ? DeserializeElement(legacyRuntime, "runtimeConfig") - : null, - }; - if (network.TryGetProperty("enforcementMode", out var enforcementMode)) - { - _ = ReadString(enforcementMode, "network.enforcementMode") switch - { - "capabilities" or "firewall" or "both" => true, - var value => throw new JsonException( - $"Unsupported network.enforcementMode '{value}'."), - }; - } - return policy; - } - - private static ProcessContainment ReadProcessContainment(JsonElement root) - { - var containment = new ProcessContainment(); - if (root.TryGetProperty("processContainer", out var processContainer)) - { - containment.CanonicalProcessContainer = ReadProcessContainer(processContainer); - } - return containment; - } - - private static ProcessContainerContainment ReadExplicitProcessContainer(JsonElement root) => - root.TryGetProperty("processContainer", out var processContainer) - ? ReadProcessContainer(processContainer) - : new ProcessContainerContainment { Ui = null }; - - private static ProcessContainerContainment ReadProcessContainer(JsonElement element) - { - var section = RequireObject(element, "processContainer"); - EnsureKnownProperties( - section, - "processContainer", - "leastPrivilege", - "learningMode", - "capabilities", - "captureDenials", - "ui", - "network"); - return new ProcessContainerContainment - { - LeastPrivilege = section.TryGetProperty("leastPrivilege", out var leastPrivilege) - && ReadBoolean(leastPrivilege, "processContainer.leastPrivilege"), - LearningMode = section.TryGetProperty("learningMode", out var learningMode) - && ReadBoolean(learningMode, "processContainer.learningMode"), - Capabilities = ReadStringList( - section, - "capabilities", - "processContainer.capabilities"), - CaptureDenials = section.TryGetProperty("captureDenials", out var captureDenials) - ? DeserializeElement( - captureDenials, - "processContainer.captureDenials") - : null, - Ui = section.TryGetProperty("ui", out var ui) - ? DeserializeElement(ui, "processContainer.ui") - : null, - Network = section.TryGetProperty("network", out var network) - ? DeserializeElement( - network, - "processContainer.network") - : null, - }; - } - - private static WslcContainment ReadWslcContainment(JsonElement root) - { - if (root.TryGetProperty("processContainer", out _)) - { - throw new JsonException( - "A canonical WSLC request cannot contain processContainer."); - } - var experimental = RequireObject( - RequireProperty(root, "experimental", "request"), - "experimental"); - EnsureKnownProperties(experimental, "experimental", "wslc"); - var wslc = RequireObject( - RequireProperty(experimental, "wslc", "experimental"), - "experimental.wslc"); - EnsureKnownProperties( - wslc, - "experimental.wslc", - "image", - "imageTarPath", - "cpuCount", - "memoryMb", - "gpu", - "storagePath", - "portMappings"); - - var containment = new WslcContainment - { - Image = wslc.TryGetProperty("image", out var image) - ? ReadString(image, "experimental.wslc.image") - : "alpine:latest", - ImageTarPath = wslc.TryGetProperty("imageTarPath", out var imageTarPath) - ? ReadString(imageTarPath, "experimental.wslc.imageTarPath") - : null, - CpuCount = wslc.TryGetProperty("cpuCount", out var cpuCount) - ? ReadUInt32(cpuCount, "experimental.wslc.cpuCount") - : null, - MemoryMb = wslc.TryGetProperty("memoryMb", out var memoryMb) - ? ReadUInt64(memoryMb, "experimental.wslc.memoryMb") - : null, - Gpu = wslc.TryGetProperty("gpu", out var gpu) - && ReadBoolean(gpu, "experimental.wslc.gpu"), - StoragePath = wslc.TryGetProperty("storagePath", out var storagePath) - ? ReadString(storagePath, "experimental.wslc.storagePath") - : null, - }; - if (wslc.TryGetProperty("portMappings", out var mappings)) - { - if (mappings.ValueKind != JsonValueKind.Array) - { - throw new JsonException("experimental.wslc.portMappings must be an array."); - } - foreach (var (mapping, index) in mappings.EnumerateArray().Select((value, index) => (value, index))) - { - var mappingObject = RequireObject( - mapping, - $"experimental.wslc.portMappings[{index}]"); - EnsureKnownProperties( - mappingObject, - $"experimental.wslc.portMappings[{index}]", - "windowsPort", - "containerPort", - "protocol"); - if (mappingObject.TryGetProperty("protocol", out var protocol) - && ReadString( - protocol, - $"experimental.wslc.portMappings[{index}].protocol") != "tcp") - { - throw new JsonException( - "SandboxRequest can represent only TCP WSLC port mappings."); - } - containment.PortMappings.Add(new WslcPortMapping( - ReadUInt16( - RequireProperty( - mappingObject, - "windowsPort", - $"experimental.wslc.portMappings[{index}]"), - $"experimental.wslc.portMappings[{index}].windowsPort"), - ReadUInt16( - RequireProperty( - mappingObject, - "containerPort", - $"experimental.wslc.portMappings[{index}]"), - $"experimental.wslc.portMappings[{index}].containerPort"))); - } - } - return containment; - } - - private static Dictionary ReadEnvironment(JsonElement element) - { - if (element.ValueKind != JsonValueKind.Array) - { - throw new JsonException("process.env must be an array."); - } - var environment = new Dictionary(); - foreach (var (entry, index) in element.EnumerateArray().Select((value, index) => (value, index))) - { - var pair = ReadString(entry, $"process.env[{index}]"); - var separator = pair.IndexOf('='); - if (separator <= 0) - { - throw new JsonException( - $"process.env[{index}] must have the form NAME=VALUE."); - } - if (!environment.TryAdd(pair[..separator], pair[(separator + 1)..])) - { - throw new JsonException( - $"process.env contains duplicate variable '{pair[..separator]}'."); - } - } - return environment; - } - - private static List ReadStringList( - JsonElement parent, - string propertyName, - string location) => - parent.TryGetProperty(propertyName, out var value) - ? DeserializeElement>(value, location) - : []; - - private static T DeserializeElement(JsonElement element, string location) - { - try - { - return element.Deserialize(CanonicalReadOptions) - ?? throw new JsonException($"{location} cannot be null."); - } - catch (JsonException error) - { - throw new JsonException($"Invalid {location}: {error.Message}", error); - } - } - - private static T ReadEnum(JsonElement element, string location) - where T : struct, Enum => - DeserializeElement(element, location); - - private static JsonElement RequireObject(JsonElement element, string location) => - element.ValueKind == JsonValueKind.Object - ? element - : throw new JsonException($"{location} must be an object."); - - private static JsonElement RequireProperty( - JsonElement element, - string propertyName, - string location) => - element.TryGetProperty(propertyName, out var value) - ? value - : throw new JsonException($"{location}.{propertyName} is required."); - - private static string RequireString( - JsonElement element, - string propertyName, - string location) => - ReadString( - RequireProperty(element, propertyName, location), - $"{location}.{propertyName}"); - - private static string ReadString(JsonElement element, string location) => - element.ValueKind == JsonValueKind.String - ? element.GetString()! - : throw new JsonException($"{location} must be a string."); - - private static bool ReadBoolean(JsonElement element, string location) => - element.ValueKind is JsonValueKind.True or JsonValueKind.False - ? element.GetBoolean() - : throw new JsonException($"{location} must be a boolean."); - - private static uint ReadUInt32(JsonElement element, string location) => - element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out var value) - ? value - : throw new JsonException($"{location} must be an unsigned 32-bit integer."); - - private static ulong ReadUInt64(JsonElement element, string location) => - element.ValueKind == JsonValueKind.Number && element.TryGetUInt64(out var value) - ? value - : throw new JsonException($"{location} must be an unsigned 64-bit integer."); - - private static ushort ReadUInt16(JsonElement element, string location) - { - var value = ReadUInt32(element, location); - return value is > 0 and <= ushort.MaxValue - ? (ushort)value - : throw new JsonException($"{location} must be between 1 and {ushort.MaxValue}."); - } - - private static void EnsureKnownProperties( - JsonElement element, - string location, - params string[] propertyNames) - { - var known = new HashSet(propertyNames, StringComparer.Ordinal); - var seen = new HashSet(StringComparer.Ordinal); - foreach (var property in element.EnumerateObject()) - { - if (!known.Contains(property.Name)) - { - throw new JsonException( - $"Unknown canonical property '{location}.{property.Name}'."); - } - if (!seen.Add(property.Name)) - { - throw new JsonException( - $"Duplicate canonical property '{location}.{property.Name}'."); - } - } - } - - private static JsonObject BuildProcess(SandboxRequest request, SandboxPolicy policy) - { - var process = new JsonObject - { - ["commandLine"] = request.Command, - ["timeout"] = policy.TimeoutMs ?? 0, - }; - if (request.WorkingDirectory is not null) - { - process["cwd"] = request.WorkingDirectory; - } - if (request.Environment.Count != 0) - { - foreach (var name in request.Environment.Keys) - { - if (string.IsNullOrEmpty(name) || name.Contains('=')) - { - throw new ArgumentException( - "An environment variable name cannot be empty or contain '='.", - nameof(request)); - } - } - process["env"] = JsonSerializer.SerializeToNode( - request.Environment.Select(pair => $"{pair.Key}={pair.Value}").ToArray()); - } - return process; - } - - private static bool HasDirectionalNetwork( - NetworkPolicy? network, - SandboxContainment containment) - { - var processContainer = containment switch - { - ProcessContainerContainment explicitContainment => explicitContainment, - ProcessContainment process => process.CanonicalProcessContainer, - _ => null, - }; - return processContainer?.Network?.AllowedProxyPeer is { } allowedProxyPeer - && !string.IsNullOrWhiteSpace(allowedProxyPeer) - || network?.Egress is not null - || network?.Ingress is not null - || network?.RuntimeConfig?.NetworkProxy is not null; - } - - private static bool HasLegacyNetwork(NetworkPolicy? network) => - network is not null - && (network.AllowOutbound - || network.AllowLocalNetwork - || network.AllowedHosts.Count != 0 - || network.BlockedHosts.Count != 0 - || network.Proxy is not null); - - private static bool RequiresOutboundForHostRules(SandboxContainment containment) => - containment is ProcessContainerContainment - || containment is ProcessContainment && OperatingSystem.IsWindows(); - - private static void AddNetwork( - JsonObject root, - NetworkPolicy? network, - bool directional, - JsonSerializerOptions options) - { - if (directional) - { - if (network?.Egress is not null || network?.Ingress is not null) - { - var wire = new JsonObject(); - if (network.Egress is not null) - { - wire["egress"] = JsonSerializer.SerializeToNode(network.Egress, options); - } - if (network.Ingress is not null) - { - wire["ingress"] = JsonSerializer.SerializeToNode(network.Ingress, options); - } - root["network"] = wire; - } - if (network?.RuntimeConfig is not null) - { - root["runtimeConfig"] = JsonSerializer.SerializeToNode( - network.RuntimeConfig, - options); - } - return; - } - - var legacy = new JsonObject - { - ["defaultPolicy"] = network?.AllowOutbound == true ? "allow" : "block", - }; - if (network is not null) - { - legacy["allowLocalNetwork"] = network.AllowLocalNetwork; - legacy["allowedHosts"] = JsonSerializer.SerializeToNode(network.AllowedHosts, options); - legacy["blockedHosts"] = JsonSerializer.SerializeToNode(network.BlockedHosts, options); - if (network.Proxy is not null) - { - legacy["proxy"] = JsonSerializer.SerializeToNode(network.Proxy, options); - } - } - root["network"] = legacy; - } - - private static void AddContainment( - JsonObject root, - SandboxContainment containment, - NetworkPolicy? network, - bool directional, - JsonSerializerOptions options) - { - switch (containment) - { - case ProcessContainment process: - root["containment"] = "process"; - if (process.CanonicalProcessContainer is { } canonicalProcessContainer) - { - AddProcessContainer( - root, - canonicalProcessContainer, - network, - directional, - "process", - options); - } - else if (OperatingSystem.IsWindows()) - { - AddProcessContainer( - root, - new ProcessContainerContainment(), - network, - directional, - "process", - options); - } - if (OperatingSystem.IsLinux()) - { - ApplyLinuxNetworkPolicy(root); - } - break; - case ProcessContainerContainment processContainer: - AddProcessContainer( - root, - processContainer, - network, - directional, - "processcontainer", - options); - break; - case WslcContainment wslc: - root["containment"] = "wslc"; - root["experimental"] = new JsonObject - { - ["wslc"] = BuildWslc(wslc), - }; - break; - default: - throw new ArgumentException( - $"Unsupported containment type {containment.GetType().Name}.", - nameof(containment)); - } - } - - /// - /// Promotes network enforcement to firewall when host rules are present and no - /// cooperative proxy is configured — the Linux counterpart of the Rust builder's - /// apply_linux_network_policy. Without it the parser leaves the mode at - /// capabilities and the host lists are not applied. - /// - private static void ApplyLinuxNetworkPolicy(JsonObject root) - { - if (root["network"] is not JsonObject network || network["proxy"] is not null) - { - return; - } - if (HasHosts(network, "allowedHosts") || HasHosts(network, "blockedHosts")) - { - network["enforcementMode"] = "firewall"; - } - } - - private static bool HasHosts(JsonObject network, string key) => - network[key] is JsonArray hosts && hosts.Count != 0; - - private static JsonObject BuildWslc(WslcContainment wslc) - { - var wire = new JsonObject - { - ["image"] = wslc.Image, - ["gpu"] = wslc.Gpu, - }; - if (wslc.ImageTarPath is not null) - { - wire["imageTarPath"] = wslc.ImageTarPath; - } - if (wslc.CpuCount is not null) - { - wire["cpuCount"] = wslc.CpuCount; - } - if (wslc.MemoryMb is not null) - { - wire["memoryMb"] = wslc.MemoryMb; - } - if (wslc.StoragePath is not null) - { - wire["storagePath"] = wslc.StoragePath; - } - if (wslc.PortMappings.Count != 0) - { - wire["portMappings"] = new JsonArray( - wslc.PortMappings - .Select(mapping => (JsonNode)new JsonObject - { - ["windowsPort"] = mapping.WindowsPort, - ["containerPort"] = mapping.ContainerPort, - ["protocol"] = "tcp", - }) - .ToArray()); - } - return wire; - } - - private static void AddProcessContainer( - JsonObject root, - ProcessContainerContainment containment, - NetworkPolicy? network, - bool directional, - string wireContainment, - JsonSerializerOptions options) - { - root["containment"] = wireContainment; - var capabilities = new List(containment.Capabilities); - var allowsInternet = directional - ? network?.Egress is { } egress - && (egress.Default == NetworkAction.Allow || egress.Allow?.Count > 0) - : network?.AllowOutbound == true; - var allowsLocalNetwork = directional - ? network?.Ingress?.Default == NetworkAction.Allow - : network?.AllowLocalNetwork == true; - AddCapability(capabilities, "internetClient", allowsInternet); - AddCapability(capabilities, "privateNetworkClientServer", allowsLocalNetwork); - - var section = new JsonObject - { - ["leastPrivilege"] = containment.LeastPrivilege, - ["capabilities"] = JsonSerializer.SerializeToNode(capabilities, options), - }; - if (containment.LearningMode) - { - section["learningMode"] = true; - } - if (containment.Ui is not null) - { - section["ui"] = JsonSerializer.SerializeToNode(containment.Ui, options); - } - if (containment.Network?.AllowedProxyPeer is not null) - { - section["network"] = JsonSerializer.SerializeToNode(containment.Network, options); - } - if (containment.CaptureDenials is not null) - { - section["captureDenials"] = JsonSerializer.SerializeToNode( - containment.CaptureDenials, - options); - } - root["processContainer"] = section; - - if (!directional && root["network"] is JsonObject legacy) - { - legacy["enforcementMode"] = - network is { AllowedHosts.Count: > 0 } or { BlockedHosts.Count: > 0 } - ? "both" - : "capabilities"; - } - } - - private static void AddCapability(List capabilities, string value, bool add) - { - if (add && !capabilities.Contains(value, StringComparer.OrdinalIgnoreCase)) - { - capabilities.Add(value); - } - } - - private static string Wire(ClipboardPolicy value) => value switch - { - ClipboardPolicy.None => "none", - ClipboardPolicy.Read => "read", - ClipboardPolicy.Write => "write", - ClipboardPolicy.All => "all", - _ => throw new ArgumentOutOfRangeException(nameof(value)), - }; -} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs index 5292d0905..8e90e3459 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs @@ -121,10 +121,7 @@ public static RunResult Run(SandboxRequest request) fixed (byte* requestPtr = requestBuf) { MxcRunResult result = default; - var status = NativeMethods.mxc_run_request( - requestPtr, - request.Experimental ? 1 : 0, - &result); + var status = NativeMethods.mxc_run_request(requestPtr, &result); try { if (status != (int)ErrorCode.Success) @@ -202,11 +199,7 @@ public static MxcSandboxProcess Spawn(SandboxRequest request) { NativeSandbox* handle = null; MxcErrorDetail error = default; - var status = NativeMethods.mxc_spawn_request( - requestPtr, - request.Experimental ? 1 : 0, - &handle, - &error); + var status = NativeMethods.mxc_spawn_request(requestPtr, &handle, &error); if (status != (int)ErrorCode.Success) { // `finally`, not a straight-line free: marshalling the strings or @@ -252,7 +245,7 @@ internal static string SerializePolicy(SandboxPolicy policy) internal static string SerializeRequest(SandboxRequest request) { ArgumentNullException.ThrowIfNull(request); - return CanonicalRequestBuilder.Serialize(PrepareRequest(request), JsonOptions); + return JsonSerializer.Serialize(PrepareRequest(request), JsonOptions); } private static SandboxRequest PrepareRequest(SandboxRequest request) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index 9870680ae..d37df55cf 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -58,7 +58,7 @@ public sealed class SandboxPolicy public uint? TimeoutMs { get; set; } /// - /// Canonical per-invocation telemetry settings serialized as + /// Per-invocation telemetry settings serialized in the binding policy as /// {"telemetry":{"enabled":...}}. /// [JsonPropertyName("telemetry")] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs index 978088e4c..44deeed16 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxRequest.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Mxc.Sdk; @@ -10,7 +9,6 @@ namespace Microsoft.Mxc.Sdk; /// A complete one-shot sandbox request. This is the managed counterpart of the /// Rust SDK's request built by build_request_with_containment. /// -[JsonConverter(typeof(SandboxRequestJsonConverter))] public sealed class SandboxRequest { /// Create a request for under . @@ -23,63 +21,46 @@ public SandboxRequest(SandboxPolicy policy, string command) } /// The cross-platform restrictions applied to the sandbox. + [JsonPropertyName("policy")] public SandboxPolicy Policy { get; } /// The command line to run. + [JsonPropertyName("command")] public string Command { get; } /// The containment backend and its backend-specific configuration. + [JsonPropertyName("containment")] public SandboxContainment Containment { get; set; } = new ProcessContainment(); /// An optional caller-selected container name. + [JsonPropertyName("containerName")] public string? ContainerName { get; set; } /// An optional initial working directory. + [JsonPropertyName("workingDirectory")] public string? WorkingDirectory { get; set; } /// Environment variables supplied to the sandboxed process. + [JsonPropertyName("environment")] public Dictionary Environment { get; set; } = new(); /// Opt in to experimental containment backends and features. + [JsonPropertyName("experimental")] public bool Experimental { get; set; } } -/// Reads and writes requests through MXC's canonical wire projection. -public sealed class SandboxRequestJsonConverter : JsonConverter -{ - /// - public override SandboxRequest Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options) => - CanonicalRequestBuilder.Deserialize(ref reader); - - /// - public override void Write( - Utf8JsonWriter writer, - SandboxRequest value, - JsonSerializerOptions options) - { - using var document = JsonDocument.Parse(MxcSandbox.SerializeRequest(value)); - document.RootElement.WriteTo(writer); - } -} - -/// -/// A containment backend selected by a . -/// This is an authoring model; performs its canonical -/// wire projection. -/// +/// A containment backend selected by a . +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ProcessContainment), "process")] +[JsonDerivedType(typeof(ProcessContainerContainment), "processContainer")] +[JsonDerivedType(typeof(WslcContainment), "wslc")] public abstract class SandboxContainment; /// /// The host's native process-isolation backend: ProcessContainer on Windows, /// Bubblewrap on Linux, and Seatbelt on macOS. /// -public sealed class ProcessContainment : SandboxContainment -{ - internal ProcessContainerContainment? CanonicalProcessContainer { get; set; } -} +public sealed class ProcessContainment : SandboxContainment; /// Explicit Windows ProcessContainer configuration. public sealed class ProcessContainerContainment : SandboxContainment diff --git a/src/Cargo.lock b/src/Cargo.lock index 36e5542da..2490883fa 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1565,7 +1565,9 @@ version = "0.8.0" dependencies = [ "csbindgen", "mxc-sdk", + "semver", "serde", + "serde_ignored", "serde_json", "tempfile", "wxc_common", @@ -2174,6 +2176,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/core/mxc-sdk/Cargo.toml b/src/core/mxc-sdk/Cargo.toml index 8411cdab0..af870cde6 100644 --- a/src/core/mxc-sdk/Cargo.toml +++ b/src/core/mxc-sdk/Cargo.toml @@ -18,9 +18,6 @@ wslc = ["mxc_engine/wslc"] isolation_session = ["mxc_engine/isolation_session"] # Exposes the debug-only telemetry test seams to downstream binding tests. test-support = ["wxc_common/test-support"] -# Enables the hidden policy-JSON bridge consumed by co-versioned language -# bindings. -ffi-internals = ["mxc_engine/ffi-internals"] [dependencies] wxc_common.workspace = true diff --git a/src/core/mxc-sdk/src/lib.rs b/src/core/mxc-sdk/src/lib.rs index 04fcbbb86..d3960ee21 100644 --- a/src/core/mxc-sdk/src/lib.rs +++ b/src/core/mxc-sdk/src/lib.rs @@ -146,12 +146,6 @@ mod sandbox; pub mod telemetry; -#[cfg(feature = "ffi-internals")] -#[doc(hidden)] -pub mod ffi_internals { - pub use mxc_engine::ffi_internals::build_request_from_json; -} - pub use mxc_engine::configs; pub use mxc_engine::policy; pub use mxc_engine::{ diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index 6fb13470d..3b76341af 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -45,9 +45,6 @@ seatbelt_common = { workspace = true } [features] default = [] -# Enables the hidden policy-JSON bridge consumed by co-versioned language -# bindings through mxc-sdk. -ffi-internals = [] # Enables constructing the run-to-completion runner for each experimental # backend. Mirrors the executor binaries' feature set so backend selection can # live in one place. The build-time binary-staging that some backends also need diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 154f81537..b7d09acb3 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -60,12 +60,6 @@ pub use state_aware::{ exec_state_aware_attached, exec_state_aware_json, run_state_aware, run_state_aware_json, }; -#[cfg(feature = "ffi-internals")] -#[doc(hidden)] -pub mod ffi_internals { - pub use crate::policy::build_request_from_json; -} - use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ContainmentBackend, FailurePhase, ScriptResponse}; use wxc_common::sandbox_process::{SandboxProcess, StreamCloser}; diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 4bd0a1685..f13a4b5ed 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -21,13 +21,9 @@ use crate::configs::process_container; use crate::configs::ProcessContainer; #[cfg(test)] use crate::configs::{CaptureDenials, CaptureDenialsMode}; -#[cfg(feature = "ffi-internals")] -use crate::ErrorCode; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{ExecutionRequest, TelemetryConfig}; use wxc_common::mxc_error::MxcError; -#[cfg(feature = "ffi-internals")] -use wxc_common::state_aware_request::MxcRequest; #[cfg(target_os = "linux")] use network::has_host_rules; @@ -803,50 +799,6 @@ fn require_sdk_policy_version(policy: &SandboxPolicy) -> Result<(), crate::Error Ok(()) } -/// Build a request from a complete canonical MXC configuration document. -#[cfg(feature = "ffi-internals")] -#[doc(hidden)] -pub fn build_request_from_json(request_json: &str) -> Result { - let mut logger = Logger::new(Mode::Buffer); - let parsed = wxc_common::config_parser::load_mxc_request_from_json_with_options( - request_json, - &mut logger, - wxc_common::config_parser::LoadOptions { - is_base64: false, - allow_missing_command: false, - }, - ) - .map_err(|error| { - use wxc_common::config_parser::ParseError; - - let message = match error { - ParseError::Decode(error) - | ParseError::OneShot(error) - | ParseError::OneShotMalformed(error) => error.to_string(), - ParseError::StateAware(error) => error.to_string(), - }; - crate::Error::new( - ErrorCode::MalformedRequest, - format!("failed to parse request JSON: {message}"), - ) - })?; - let MxcRequest::OneShot(inner) = parsed else { - return Err(crate::Error::new( - ErrorCode::MalformedRequest, - "failed to parse request JSON: expected a one-shot request", - )); - }; - let requested_sandbox_kind = inner - .telemetry - .as_ref() - .and_then(|telemetry| telemetry.requested_sandbox_kind) - .unwrap_or_else(|| inner.containment.wire_name()); - Ok(SandboxRequest { - inner, - requested_sandbox_kind, - }) -} - /// Construct the wire-format `ContainerConfig` JSON value for the supported /// backends, mirroring `createConfigFromPolicy` + the per-backend builders. pub(crate) fn build_wire_config( @@ -1145,146 +1097,6 @@ mod tests { ); } - fn complete_one_shot_wire( - mut config: serde_json::Value, - command: &str, - cwd: Option<&str>, - env: &[&str], - ) -> serde_json::Value { - config["process"]["commandLine"] = serde_json::json!(command); - if let Some(cwd) = cwd { - config["process"]["cwd"] = serde_json::json!(cwd); - } - if !env.is_empty() { - config["process"]["env"] = serde_json::json!(env); - } - config - } - - #[test] - fn rust_builder_matches_canonical_cross_language_fixtures() { - use crate::configs::{ - CaptureDenials, ProcessContainer, ProcessContainerNetwork, - ProcessContainerSystemSettings, ProcessContainerUi, ProcessContainerUiIsolation, - }; - - let process_policy: super::SandboxPolicy = serde_json::from_value(serde_json::json!({ - "version": "0.8.0-alpha", - "filesystem": { - "readwritePaths": ["C:\\work"], - "readonlyPaths": ["C:\\input"], - "deniedPaths": ["C:\\secret"], - "clearPolicyOnExit": true - }, - "network": { - "egress": { "default": "deny" }, - "ingress": { "default": "allow", "hostLoopback": "deny" }, - "runtimeConfig": { "networkProxy": "http://127.0.0.1:8080" } - }, - "ui": { "clipboard": "read" }, - "timeoutMs": 30000 - })) - .expect("process policy parses"); - let process_containment = ProcessContainer { - least_privilege: true, - capabilities: vec!["internetClient".to_string()], - capture_denials: Some(CaptureDenials { - output_path: Some("C:\\denials.json".to_string()), - retain_etl: true, - ..Default::default() - }), - ui: Some(ProcessContainerUi { - isolation: ProcessContainerUiIsolation::Handles, - system_settings: ProcessContainerSystemSettings::Parameters, - ime: true, - ..Default::default() - }), - network: Some(ProcessContainerNetwork { - allowed_proxy_peer: Some("Contoso.App_123".to_string()), - }), - ..Default::default() - }; - let process = super::build_wire_config( - &process_policy, - &super::Containment::ProcessContainer(process_containment), - Some("golden-process-container"), - ) - .map(|config| { - complete_one_shot_wire(config, "echo parity", Some("C:\\work"), &["PARITY=true"]) - }) - .expect("process config builds"); - - let directional_policy: super::SandboxPolicy = serde_json::from_value(serde_json::json!({ - "version": "0.8.0-alpha", - "network": { - "egress": { - "default": "deny", - "allow": [{ - "to": [{ - "cidr": "10.20.0.0/16", - "except": ["10.20.30.0/24"] - }], - "ports": [{ - "protocol": "tcp", - "port": 443, - "endPort": 444 - }] - }] - }, - "ingress": { - "default": "deny", - "hostLoopback": "deny" - } - } - })) - .expect("directional policy parses"); - let directional = super::build_wire_config( - &directional_policy, - &super::Containment::ProcessContainer(ProcessContainer::default()), - Some("golden-directional-network"), - ) - .map(|config| complete_one_shot_wire(config, "echo network", None, &[])) - .expect("directional config builds"); - - let wslc_policy: super::SandboxPolicy = - serde_json::from_value(serde_json::json!({ "version": "0.8.0-alpha" })) - .expect("WSLC policy parses"); - let wslc = super::build_wire_config( - &wslc_policy, - &super::Containment::Wslc(super::WslcSection { - image: "alpine:3.20".to_string(), - image_tar_path: Some("C:\\images\\alpine.tar".to_string()), - cpu_count: Some(2), - memory_mb: Some(1024), - gpu: true, - storage_path: Some("C:\\wslc".to_string()), - port_mappings: vec![(8080, 80)], - }), - Some("golden-wslc"), - ) - .map(|config| complete_one_shot_wire(config, "printf parity", None, &[])) - .expect("WSLC config builds"); - - for (actual, fixture) in [ - ( - process, - include_str!("../../../../tests/policy/request-process-container.json"), - ), - ( - directional, - include_str!("../../../../tests/policy/request-directional-network.json"), - ), - ( - wslc, - include_str!("../../../../tests/policy/request-wslc.json"), - ), - ] { - let expected: serde_json::Value = - serde_json::from_str(fixture).expect("canonical fixture parses"); - assert_eq!(actual, expected); - } - } - #[cfg(target_os = "windows")] #[test] fn powershell_policy_grants_system_drive_root() { diff --git a/src/ffi/mxc_ffi/Cargo.toml b/src/ffi/mxc_ffi/Cargo.toml index e921ddf63..0e37150ea 100644 --- a/src/ffi/mxc_ffi/Cargo.toml +++ b/src/ffi/mxc_ffi/Cargo.toml @@ -13,9 +13,11 @@ name = "mxc_ffi" crate-type = ["cdylib", "staticlib", "lib"] [dependencies] -mxc-sdk = { workspace = true, features = ["ffi-internals"] } +mxc-sdk = { 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 diff --git a/src/ffi/mxc_ffi/src/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 7b4de0228..d4c376375 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -5,13 +5,12 @@ //! //! This is the flat, panic-safe C surface loaded by language bindings. //! -//! - **Run to completion** — [`mxc_run_request`] accepts a complete canonical -//! one-shot request. +//! - **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 canonical one-shot request and returns an opaque live handle. +//! 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 @@ -35,8 +34,8 @@ //! ([`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 the canonical -//! top-level `telemetry.enabled` field. +//! - **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`], @@ -336,11 +335,7 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { /// Run a complete one-shot request to completion and capture its output. /// -/// `request_json_utf8` is a canonical MXC configuration document. -/// A nonzero `experimental` is the separate operational opt-in corresponding -/// to the executor's `--experimental` flag. It is intentionally outside the -/// document because the canonical `experimental` object carries backend -/// configuration, not authorization to use experimental features. +/// `request_json_utf8` is the co-versioned binding request document. /// /// # Safety /// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. @@ -349,18 +344,16 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { #[no_mangle] pub unsafe extern "C" fn mxc_run_request( request_json_utf8: *const c_char, - experimental: i32, out: *mut MxcRunResult, ) -> i32 { if out.is_null() { return MXC_STATUS_NULL_ARGUMENT; } - let result = catch_unwind(|| run_request_inner(request_json_utf8, experimental != 0)) - .unwrap_or_else(|panic| { - report_panic("mxc_run_request", &*panic); - 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. @@ -368,7 +361,7 @@ pub unsafe extern "C" fn mxc_run_request( status } -fn run_request_inner(request_json_utf8: *const c_char, experimental: bool) -> MxcRunResult { +fn run_request_inner(request_json_utf8: *const c_char) -> MxcRunResult { // SAFETY: caller contract on `mxc_run_request`; borrowed only within scope. let request_json = match unsafe { cstr_to_str(request_json_utf8) } { Some(value) => value, @@ -377,7 +370,7 @@ fn run_request_inner(request_json_utf8: *const c_char, experimental: bool) -> Mx } None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "request JSON is not UTF-8"), }; - let request = match request::build_request_from_json(request_json, experimental) { + let request = match request::build_request_from_json(request_json) { Ok(request) => request, Err(error) => return MxcRunResult::from_sdk_error(&error), }; @@ -872,7 +865,7 @@ mod tests { let request = CString::new(request_json).unwrap(); let mut out = MxcRunResult::empty(); // SAFETY: a valid CString and out pointer. - let status = unsafe { mxc_run_request(request.as_ptr(), 0, &mut out) }; + let status = unsafe { mxc_run_request(request.as_ptr(), &mut out) }; assert_eq!(status, out.status); out } @@ -881,19 +874,19 @@ mod tests { fn shared_request_builder_propagates_telemetry_enablement() { for (request_json, expected) in [ ( - r#"{"version":"0.8.0-alpha","process":{"commandLine":"echo hi"}}"#, + r#"{"policy":{"version":"0.8.0-alpha"},"command":"echo hi"}"#, None, ), ( - r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"telemetry":{"enabled":true}}"#, + r#"{"policy":{"version":"0.9.0-alpha","telemetry":{"enabled":true}},"command":"echo hi"}"#, Some(true), ), ( - r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"telemetry":{"enabled":false}}"#, + r#"{"policy":{"version":"0.9.0-alpha","telemetry":{"enabled":false}},"command":"echo hi"}"#, Some(false), ), ] { - let request = request::build_request_from_json(request_json, 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(), @@ -917,10 +910,9 @@ mod tests { #[test] fn null_out_pointer_reports_null_argument_without_leaking() { let request = - CString::new(r#"{"version":"0.7.0-alpha","process":{"commandLine":"echo hi"}}"#) - .unwrap(); + 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(), 0, ptr::null_mut()) }; + let status = unsafe { mxc_run_request(request.as_ptr(), ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -1029,18 +1021,18 @@ mod tests { result.free_strings(); } - /// A structurally valid document with no command reaches the canonical - /// parser's SDK error path rather than failing in the FFI string boundary. + /// 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":"0.8.0-alpha","process":{}}"#); + 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_request`. let message = unsafe { CStr::from_ptr(out.error.message_utf8) } .to_str() .unwrap() .to_string(); - assert!(message.contains("commandLine")); + assert_eq!(message, "Policy version is required"); // SAFETY: `out` was filled by `mxc_run_request`. unsafe { mxc_run_result_free(&mut out) }; } diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index dff668835..5c32aa5b4 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -1,72 +1,836 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Canonical one-shot request parsing for language bindings. +//! Co-versioned JSON request contract used by language bindings. -use mxc_sdk::{Error, SandboxRequest}; +use std::collections::BTreeMap; -/// Parse a complete canonical MXC configuration document. -pub(crate) fn build_request_from_json( - request_json: &str, +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::de::{Error as _, IgnoredAny}; +use serde::{Deserialize, Deserializer}; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RequestSpec { + policy: RequestPolicy, + command: String, + #[serde(default)] + containment: RequestContainment, + #[serde(default)] + container_name: Option, + #[serde(default)] + working_directory: Option, + #[serde(default)] + environment: BTreeMap, + #[serde(default)] experimental: bool, -) -> Result { - let mut request = mxc_sdk::ffi_internals::build_request_from_json(request_json)?; - request.set_experimental(experimental); +} + +#[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 { + #[default] + Process, + ProcessContainer { + #[serde(default, rename = "leastPrivilege")] + least_privilege: bool, + #[serde(default, rename = "learningMode")] + learning_mode: bool, + #[serde(default)] + capabilities: Vec, + #[serde(default, rename = "captureDenials")] + capture_denials: Option, + #[serde(default = "default_process_container_ui")] + ui: Option, + #[serde(default)] + network: Option, + }, + Wslc { + #[serde(default = "default_wslc_image")] + image: String, + #[serde(default, rename = "imageTarPath")] + image_tar_path: Option, + #[serde(default, rename = "cpuCount")] + cpu_count: Option, + #[serde(default, rename = "memoryMb")] + memory_mb: Option, + #[serde(default)] + gpu: bool, + #[serde(default, rename = "storagePath")] + storage_path: Option, + #[serde(default, rename = "portMappings")] + port_mappings: Vec, + }, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProcessContainerUiSpec { + #[serde(default)] + isolation: ProcessContainerUiIsolationSpec, + #[serde(default)] + desktop_system_control: bool, + #[serde(default)] + system_settings: ProcessContainerSystemSettingsSpec, + #[serde(default)] + ime: bool, +} + +impl Default for ProcessContainerUiSpec { + fn default() -> Self { + Self { + isolation: ProcessContainerUiIsolationSpec::Container, + desktop_system_control: false, + system_settings: ProcessContainerSystemSettingsSpec::None, + ime: false, + } + } +} + +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +enum ProcessContainerUiIsolationSpec { + Desktop, + Handles, + Atoms, + #[default] + Container, +} + +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +enum ProcessContainerSystemSettingsSpec { + All, + Parameters, + Display, + #[default] + None, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProcessContainerNetworkSpec { + #[serde(default)] + allowed_proxy_peer: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct WslcPortMappingSpec { + windows_port: u16, + container_port: u16, +} + +fn default_process_container_ui() -> Option { + Some(ProcessContainerUiSpec::default()) +} + +fn default_wslc_image() -> String { + "alpine:latest".to_string() +} + +impl RequestContainment { + // These public SDK configuration types are `#[non_exhaustive]`, so a + // downstream binding crate must start from `Default` and assign fields + // rather than using struct update syntax. + #[allow(clippy::field_reassign_with_default)] + fn into_sdk(self) -> Containment { + match self { + Self::Process => Containment::Process, + Self::ProcessContainer { + least_privilege, + learning_mode, + capabilities, + capture_denials, + ui, + network, + } => { + let mut process_container = ProcessContainer::default(); + process_container.least_privilege = least_privilege; + process_container.learning_mode = learning_mode; + process_container.capabilities = capabilities; + process_container.capture_denials = capture_denials; + process_container.ui = ui.map(ProcessContainerUiSpec::into_sdk); + process_container.network = network.map(ProcessContainerNetworkSpec::into_sdk); + Containment::ProcessContainer(process_container) + } + Self::Wslc { + image, + image_tar_path, + cpu_count, + memory_mb, + gpu, + storage_path, + port_mappings, + } => { + let mut wslc = WslcSection::default(); + wslc.image = image; + wslc.image_tar_path = image_tar_path; + wslc.cpu_count = cpu_count; + wslc.memory_mb = memory_mb; + wslc.gpu = gpu; + wslc.storage_path = storage_path; + wslc.port_mappings = port_mappings + .into_iter() + .map(|mapping| (mapping.windows_port, mapping.container_port)) + .collect(); + Containment::Wslc(wslc) + } + } + } +} + +impl ProcessContainerUiSpec { + fn into_sdk(self) -> ProcessContainerUi { + let mut ui = ProcessContainerUi::default(); + ui.isolation = match self.isolation { + ProcessContainerUiIsolationSpec::Desktop => ProcessContainerUiIsolation::Desktop, + ProcessContainerUiIsolationSpec::Handles => ProcessContainerUiIsolation::Handles, + ProcessContainerUiIsolationSpec::Atoms => ProcessContainerUiIsolation::Atoms, + ProcessContainerUiIsolationSpec::Container => ProcessContainerUiIsolation::Container, + }; + ui.desktop_system_control = self.desktop_system_control; + ui.system_settings = match self.system_settings { + ProcessContainerSystemSettingsSpec::All => ProcessContainerSystemSettings::All, + ProcessContainerSystemSettingsSpec::Parameters => { + ProcessContainerSystemSettings::Parameters + } + ProcessContainerSystemSettingsSpec::Display => ProcessContainerSystemSettings::Display, + ProcessContainerSystemSettingsSpec::None => ProcessContainerSystemSettings::None, + }; + ui.ime = self.ime; + ui + } +} + +impl ProcessContainerNetworkSpec { + fn into_sdk(self) -> ProcessContainerNetwork { + let mut network = ProcessContainerNetwork::default(); + network.allowed_proxy_peer = self.allowed_proxy_peer; + network + } +} + +/// 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 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, + format!("invalid environment variable name `{name}`"), + )); + } + let (policy, telemetry) = spec.policy.into_sdk()?; + let containment = spec.containment.into_sdk(); + + let mut request = + 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) } +fn malformed_request(error: serde_json::Error) -> Error { + Error::new( + ErrorCode::MalformedRequest, + format!("failed to parse request JSON: {error}"), + ) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn canonical_request_goldens_are_accepted() { - // The goldens pin the cross-SDK wire shape, so they keep their Windows - // paths. `captureDenials.outputPath` is the one field the parser - // resolves against the host filesystem, so point it at a - // platform-native temporary file before parsing. - let temp = tempfile::tempdir().expect("temp dir"); - let output_path = temp.path().join("denials.json"); - for request in [ - include_str!("../../../../tests/policy/request-process-container.json"), - include_str!("../../../../tests/policy/request-directional-network.json"), - include_str!("../../../../tests/policy/request-wslc.json"), - ] { - let mut document: serde_json::Value = - serde_json::from_str(request).expect("canonical golden parses"); - if let Some(field) = document.pointer_mut("/processContainer/captureDenials/outputPath") - { - *field = serde_json::Value::String(output_path.to_string_lossy().into_owned()); + fn managed_full_request_goldens_are_accepted_by_native_contract() { + let process_container = + include_str!("../../../../tests/policy/request-process-container.json"); + let process_spec: RequestSpec = + serde_json::from_str(process_container).expect("process-container golden parses"); + assert_eq!(process_spec.command, "echo parity"); + assert_eq!( + process_spec.environment.get("PARITY").map(String::as_str), + Some("true") + ); + assert_eq!(process_spec.policy.timeout_ms, Some(30_000)); + let filesystem = process_spec + .policy + .filesystem + .as_ref() + .expect("filesystem policy is preserved"); + assert_eq!(filesystem.readwrite_paths, ["C:\\work"]); + assert_eq!(filesystem.readonly_paths, ["C:\\input"]); + assert_eq!(filesystem.denied_paths, ["C:\\secret"]); + assert_eq!(filesystem.clear_policy_on_exit, Some(true)); + let ui = process_spec + .policy + .ui + .as_ref() + .expect("UI policy is preserved"); + assert!(!ui.allow_windows); + assert_eq!(ui.clipboard, mxc_sdk::policy::ClipboardPolicy::Read); + assert!(!ui.allow_input_injection); + let authored_network = process_spec + .policy + .network + .as_ref() + .expect("network policy is preserved"); + assert_eq!( + authored_network + .runtime_config + .as_ref() + .and_then(|runtime| runtime.network_proxy.as_deref()), + Some("http://127.0.0.1:8080") + ); + match process_spec.containment { + RequestContainment::ProcessContainer { + least_privilege, + capabilities, + capture_denials, + network, + .. + } => { + assert!(least_privilege); + assert_eq!(capabilities, ["internetClient"]); + let capture = capture_denials.expect("captureDenials is preserved"); + assert_eq!( + capture.output_path.as_deref(), + Some("C:\\logs\\denials.json") + ); + assert!(capture.retain_etl); + assert_eq!( + network.and_then(|value| value.allowed_proxy_peer), + Some("Contoso.App_123".to_string()) + ); + } + _ => panic!("process-container golden selected the wrong containment"), + } + let mut buildable_process_container: serde_json::Value = + serde_json::from_str(process_container).expect("process-container golden parses"); + buildable_process_container["containment"]["captureDenials"]["outputPath"] = + serde_json::Value::String( + std::env::temp_dir() + .join("denials.json") + .to_string_lossy() + .into_owned(), + ); + build_request_from_json(&buildable_process_container.to_string()) + .expect("process-container golden builds a public SDK request"); + + let directional_network = + include_str!("../../../../tests/policy/request-directional-network.json"); + let network_spec: RequestSpec = + serde_json::from_str(directional_network).expect("directional-network golden parses"); + assert_eq!(network_spec.command, "echo network"); + assert!(matches!( + network_spec.containment, + RequestContainment::Process + )); + let network = network_spec + .policy + .network + .as_ref() + .expect("directional network policy is preserved"); + let allow = network + .egress + .as_ref() + .and_then(|egress| egress.allow.as_ref()) + .expect("egress allow rule is preserved"); + let peers = allow[0].to.as_ref().expect("destination peer is preserved"); + assert_eq!(peers[0].cidr, "10.20.0.0/16"); + assert_eq!( + peers[0].except.as_deref(), + Some(["10.20.30.0/24".to_string()].as_slice()) + ); + let ports = allow[0].ports.as_ref().expect("port rule is preserved"); + assert_eq!(ports[0].protocol, Some(mxc_sdk::NetworkProtocol::Tcp)); + assert_eq!(ports[0].port, Some(443)); + assert_eq!(ports[0].end_port, Some(444)); + build_request_from_json(directional_network) + .expect("directional-network golden builds a public SDK request"); + + let wslc = include_str!("../../../../tests/policy/request-wslc.json"); + let wslc_spec: RequestSpec = serde_json::from_str(wslc).expect("WSLC golden parses"); + assert_eq!(wslc_spec.command, "printf parity"); + assert!(wslc_spec.experimental); + match wslc_spec.containment { + RequestContainment::Wslc { + image, + cpu_count, + memory_mb, + port_mappings, + .. + } => { + assert_eq!(image, "alpine:3.20"); + assert_eq!(cpu_count, Some(2)); + assert_eq!(memory_mb, Some(1024)); + assert_eq!(port_mappings.len(), 1); + assert_eq!(port_mappings[0].windows_port, 8080); + assert_eq!(port_mappings[0].container_port, 80); } - build_request_from_json(&document.to_string(), true) - .unwrap_or_else(|error| panic!("canonical request was rejected: {error}")); + _ => panic!("WSLC golden selected the wrong containment"), + } + 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 canonical_parser_rejects_unknown_and_duplicate_fields() { - for request in [ - r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"unknown":true}"#, - r#"{"version":"0.9.0-alpha","version":"0.9.0-alpha","process":{"commandLine":"echo hi"}}"#, - r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi","unexpected":true}}"#, + 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!( - build_request_from_json(request, false).is_err(), - "{request} must be rejected" + error.message.contains("duplicate field"), + "unexpected error: {error}" ); } } #[test] - fn canonical_parser_requires_a_complete_one_shot_request() { - for request in [ - r#"{"version":"0.9.0-alpha","process":{}}"#, - include_str!("../../../../tests/policy/state-aware-wslc-provision.json"), + 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!( - build_request_from_json(request, true).is_err(), - "{request} must be rejected by the one-shot binding" + 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( + r#"{ + "policy": { "version": "0.7.0-alpha" }, + "command": "echo hi", + "containment": { + "type": "processContainer", + "captureDenials": {} + } + }"#, + ) + .expect_err("captureDenials must reach version validation"); + + assert!( + error + .message + .contains("processContainer.captureDenials requires schema version 0.8"), + "unexpected error: {error}" + ); + } + + #[test] + fn root_policy_capture_denials_is_rejected_instead_of_ignored() { + let error = build_request_from_json( + r#"{ + "policy": { + "version": "0.8.0-alpha", + "captureDenials": {} + }, + "command": "echo hi" + }"#, + ) + .expect_err("a misplaced backend setting must fail closed"); + + assert!(error + .message + .contains("policy.captureDenials is not supported")); + } + + #[test] + fn process_container_options_map_to_the_sdk_type() { + let containment: RequestContainment = serde_json::from_str( + r#"{ + "type": "processContainer", + "leastPrivilege": true, + "learningMode": true, + "capabilities": ["internetClient"], + "captureDenials": { "mode": "allow", "retainEtl": true }, + "ui": { + "isolation": "atoms", + "desktopSystemControl": true, + "systemSettings": "parameters", + "ime": true + }, + "network": { + "allowedProxyPeer": "Contoso.Proxy_123" + } + }"#, + ) + .expect("request containment parses"); + + let Containment::ProcessContainer(config) = containment.into_sdk() else { + panic!("expected ProcessContainer"); + }; + assert!(config.least_privilege); + assert!(config.learning_mode); + assert_eq!(config.capabilities, ["internetClient"]); + assert!(config.capture_denials.expect("capture").retain_etl); + let ui = config.ui.expect("ui"); + assert_eq!(ui.isolation, ProcessContainerUiIsolation::Atoms); + assert!(ui.desktop_system_control); + assert_eq!( + ui.system_settings, + ProcessContainerSystemSettings::Parameters + ); + assert!(ui.ime); + assert_eq!( + config + .network + .expect("network") + .allowed_proxy_peer + .as_deref(), + Some("Contoso.Proxy_123") + ); + } + + #[test] + fn wslc_options_map_to_the_sdk_type() { + let containment: RequestContainment = serde_json::from_str( + r#"{ + "type": "wslc", + "image": "python:3.12", + "imageTarPath": "C:\\images\\python.tar", + "cpuCount": 4, + "memoryMb": 4096, + "gpu": true, + "storagePath": "C:\\wslc", + "portMappings": [ + { "windowsPort": 8080, "containerPort": 80 } + ] + }"#, + ) + .expect("request containment parses"); + + let Containment::Wslc(config) = containment.into_sdk() else { + panic!("expected WSLC"); + }; + assert_eq!(config.image, "python:3.12"); + assert_eq!( + config.image_tar_path.as_deref(), + Some(r"C:\images\python.tar") + ); + assert_eq!(config.cpu_count, Some(4)); + assert_eq!(config.memory_mb, Some(4096)); + assert!(config.gpu); + assert_eq!(config.storage_path.as_deref(), Some(r"C:\wslc")); + assert_eq!(config.port_mappings, [(8080, 80)]); + } + + #[test] + fn directional_networking_reaches_schema_version_validation() { + let error = build_request_from_json( + r#"{ + "policy": { + "version": "0.7.0-alpha", + "network": { + "egress": { "default": "deny" } + } + }, + "command": "echo hi" + }"#, + ) + .expect_err("directional networking must reach version validation"); + + assert!( + error + .message + .contains("network egress/ingress/runtimeConfig"), + "unexpected error: {error}" + ); + } } diff --git a/src/ffi/mxc_ffi/src/streaming.rs b/src/ffi/mxc_ffi/src/streaming.rs index 6dec08737..78d547c58 100644 --- a/src/ffi/mxc_ffi/src/streaming.rs +++ b/src/ffi/mxc_ffi/src/streaming.rs @@ -111,8 +111,6 @@ pub struct MxcStreamCloser { /// /// Uses the same co-versioned request JSON contract as /// [`mxc_run_request`](crate::mxc_run_request). -/// A nonzero `experimental` opts into experimental features separately from -/// the canonical document's backend-configuration `experimental` object. /// /// # Safety /// - `request_json_utf8` must be null or valid NUL-terminated UTF-8. @@ -123,7 +121,6 @@ pub struct MxcStreamCloser { #[no_mangle] pub unsafe extern "C" fn mxc_spawn_request( request_json_utf8: *const c_char, - experimental: i32, out_handle: *mut *mut MxcSandbox, out_error: *mut MxcErrorDetail, ) -> i32 { @@ -139,25 +136,20 @@ pub unsafe extern "C" fn mxc_spawn_request( return MXC_STATUS_NULL_ARGUMENT; } - let outcome = catch_unwind(AssertUnwindSafe(|| { - spawn_request_inner(request_json_utf8, experimental != 0) - })) - .unwrap_or_else(|panic| { - crate::report_panic("mxc_spawn_request", &*panic); - Err(( - MXC_STATUS_PANIC, - MxcErrorDetail::from_message("the mxc engine panicked"), - )) - }); + let outcome = catch_unwind(AssertUnwindSafe(|| spawn_request_inner(request_json_utf8))) + .unwrap_or_else(|panic| { + crate::report_panic("mxc_spawn_request", &*panic); + Err(( + MXC_STATUS_PANIC, + MxcErrorDetail::from_message("the mxc engine panicked"), + )) + }); // SAFETY: `out_handle` is non-null and `out_error` is null or writable. unsafe { finish_spawn(outcome, out_handle, out_error) } } -fn spawn_request_inner( - request_json_utf8: *const c_char, - experimental: bool, -) -> Result { +fn spawn_request_inner(request_json_utf8: *const c_char) -> Result { // SAFETY: caller contract on `mxc_spawn_request`; borrowed only within scope. let request_json = match unsafe { cstr_to_str(request_json_utf8) } { Some(value) => value, @@ -174,8 +166,7 @@ fn spawn_request_inner( )) } }; - let request = - request::build_request_from_json(request_json, experimental).map_err(sdk_error_detail)?; + let request = request::build_request_from_json(request_json).map_err(sdk_error_detail)?; spawn_sandbox(request).map_err(sdk_error_detail) } @@ -899,7 +890,7 @@ mod tests { let request = request("echo hi"); // SAFETY: valid string, deliberately-null out_handle. let status = - unsafe { mxc_spawn_request(request.as_ptr(), 0, ptr::null_mut(), ptr::null_mut()) }; + unsafe { mxc_spawn_request(request.as_ptr(), ptr::null_mut(), ptr::null_mut()) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } @@ -908,7 +899,7 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: null request pointer is explicitly handled. - let status = unsafe { mxc_spawn_request(ptr::null(), 0, &mut handle, &mut err) }; + let status = unsafe { mxc_spawn_request(ptr::null(), &mut handle, &mut err) }; assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); assert!(handle.is_null()); assert!( @@ -925,7 +916,7 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: valid string and valid out pointers. - let status = unsafe { mxc_spawn_request(request.as_ptr(), 0, &mut handle, &mut err) }; + 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()); @@ -938,8 +929,7 @@ mod tests { let request = CString::new("{ not json").unwrap(); let mut handle: *mut MxcSandbox = ptr::null_mut(); // SAFETY: valid string; null out_error must be tolerated. - let status = - unsafe { mxc_spawn_request(request.as_ptr(), 0, &mut handle, ptr::null_mut()) }; + 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()); } @@ -1065,7 +1055,7 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: valid string and out pointers. - let status = unsafe { mxc_spawn_request(request.as_ptr(), 0, &mut handle, &mut err) }; + 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()); @@ -1118,7 +1108,7 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: valid string and out pointers. - let status = unsafe { mxc_spawn_request(request.as_ptr(), 0, &mut handle, &mut err) }; + 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. @@ -1177,7 +1167,7 @@ mod tests { let mut handle: *mut MxcSandbox = ptr::null_mut(); let mut err = MxcErrorDetail::none(); // SAFETY: valid string and out pointers. - let status = unsafe { mxc_spawn_request(request.as_ptr(), 0, &mut handle, &mut err) }; + 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. diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index b30122262..0ac9e0a37 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -27,7 +27,7 @@ fn extern_run_rejects_malformed_request() { let request = CString::new("not json").unwrap(); let mut out = zeroed_result(); // SAFETY: valid C string and a valid out pointer. - let status = unsafe { mxc_run_request(request.as_ptr(), 0, &mut out) }; + 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); @@ -102,16 +102,18 @@ fn extern_streaming_warning_and_closer_preconditions_are_safe() { fn extern_run_request_maps_capture_denials_to_process_container() { let request = CString::new( r#"{ - "version": "0.7.0-alpha", - "process": { "commandLine": "echo hi" }, - "containment": "processcontainer", - "processContainer": { "captureDenials": {} } + "policy": { "version": "0.7.0-alpha" }, + "command": "echo hi", + "containment": { + "type": "processContainer", + "captureDenials": {} + } }"#, ) .unwrap(); let mut out = zeroed_result(); // SAFETY: a valid C string and writable result storage. - let status = unsafe { mxc_run_request(request.as_ptr(), 0, &mut out) }; + let status = unsafe { mxc_run_request(request.as_ptr(), &mut out) }; assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); // SAFETY: the message is a valid C string filled by `mxc_run_request`. @@ -133,7 +135,7 @@ fn extern_run_request_rejects_null_result_before_parsing() { // result pointer was checked. let invalid_utf8 = [0xff_u8, 0]; // SAFETY: the byte buffer is NUL-terminated and the result pointer is null. - let status = unsafe { mxc_run_request(invalid_utf8.as_ptr().cast(), 0, ptr::null_mut()) }; + let status = unsafe { mxc_run_request(invalid_utf8.as_ptr().cast(), ptr::null_mut()) }; assert_eq!(status, mxc_ffi::MXC_STATUS_NULL_ARGUMENT); } @@ -142,10 +144,12 @@ fn extern_run_request_rejects_null_result_before_parsing() { fn extern_spawn_request_maps_capture_denials_to_process_container() { let request = CString::new( r#"{ - "version": "0.7.0-alpha", - "process": { "commandLine": "echo hi" }, - "containment": "processcontainer", - "processContainer": { "captureDenials": {} } + "policy": { "version": "0.7.0-alpha" }, + "command": "echo hi", + "containment": { + "type": "processContainer", + "captureDenials": {} + } }"#, ) .unwrap(); @@ -153,7 +157,7 @@ fn extern_spawn_request_maps_capture_denials_to_process_container() { // SAFETY: `MxcErrorDetail` contains integers and nullable pointers. let mut error: MxcErrorDetail = unsafe { std::mem::zeroed() }; // SAFETY: valid request and writable fresh out-parameters. - let status = unsafe { mxc_spawn_request(request.as_ptr(), 0, &mut handle, &mut error) }; + let status = unsafe { mxc_spawn_request(request.as_ptr(), &mut handle, &mut error) }; assert_eq!(status, mxc_ffi::MXC_STATUS_MALFORMED_REQUEST); assert!(handle.is_null()); @@ -178,15 +182,17 @@ fn extern_spawn_request_maps_capture_denials_to_process_container() { fn extern_run_executes_command() { let request = CString::new( r#"{ - "version":"0.7.0-alpha", - "process":{"commandLine":"cmd /c echo hello-ffi"}, - "filesystem":{"readwritePaths":["C:\\Windows\\Temp"]} + "policy":{ + "version":"0.7.0-alpha", + "filesystem":{"readwritePaths":["C:\\Windows\\Temp"]} + }, + "command":"cmd /c echo hello-ffi" }"#, ) .unwrap(); let mut out = zeroed_result(); // SAFETY: valid C string and a valid out pointer. - let status = unsafe { mxc_run_request(request.as_ptr(), 0, &mut out) }; + 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); diff --git a/tests/policy/request-directional-network.json b/tests/policy/request-directional-network.json index afbe4fcff..d3331a90b 100644 --- a/tests/policy/request-directional-network.json +++ b/tests/policy/request-directional-network.json @@ -1,56 +1,41 @@ { - "version": "0.8.0-alpha", - "containerId": "golden-directional-network", - "lifecycle": { - "destroyOnExit": true, - "preservePolicy": false - }, - "process": { - "commandLine": "echo network", - "timeout": 0 - }, - "filesystem": { - "readwritePaths": [], - "readonlyPaths": [], - "deniedPaths": [] - }, - "network": { - "egress": { - "default": "deny", - "allow": [ - { - "to": [ - { - "cidr": "10.20.0.0/16", - "except": ["10.20.30.0/24"] - } - ], - "ports": [ - { - "protocol": "tcp", - "port": 443, - "endPort": 444 - } - ] - } - ] - }, - "ingress": { - "default": "deny", - "hostLoopback": "deny" + "policy": { + "version": "0.8.0-alpha", + "network": { + "allowOutbound": false, + "allowLocalNetwork": false, + "allowedHosts": [], + "blockedHosts": [], + "egress": { + "default": "deny", + "allow": [ + { + "to": [ + { + "cidr": "10.20.0.0/16", + "except": ["10.20.30.0/24"] + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443, + "endPort": 444 + } + ] + } + ] + }, + "ingress": { + "default": "deny", + "hostLoopback": "deny" + } } }, - "containment": "processcontainer", - "processContainer": { - "leastPrivilege": false, - "capabilities": [ - "internetClient" - ], - "ui": { - "isolation": "container", - "desktopSystemControl": false, - "systemSettings": "none", - "ime": false - } - } + "command": "echo network", + "containment": { + "type": "process" + }, + "environment": {}, + "experimental": false } diff --git a/tests/policy/request-process-container.json b/tests/policy/request-process-container.json index 09c21b32e..7ddcded7a 100644 --- a/tests/policy/request-process-container.json +++ b/tests/policy/request-process-container.json @@ -1,45 +1,46 @@ { - "version": "0.8.0-alpha", - "containerId": "golden-process-container", - "lifecycle": { - "destroyOnExit": true, - "preservePolicy": false - }, - "process": { - "commandLine": "echo parity", - "timeout": 30000, - "cwd": "C:\\work", - "env": ["PARITY=true"] - }, - "filesystem": { - "readwritePaths": ["C:\\work"], - "readonlyPaths": ["C:\\input"], - "deniedPaths": ["C:\\secret"] - }, - "ui": { - "disable": true, - "clipboard": "read", - "injection": false - }, - "network": { - "egress": { - "default": "deny" + "policy": { + "version": "0.8.0-alpha", + "filesystem": { + "readwritePaths": ["C:\\work"], + "readonlyPaths": ["C:\\input"], + "deniedPaths": ["C:\\secret"], + "clearPolicyOnExit": true }, - "ingress": { - "default": "allow", - "hostLoopback": "deny" - } - }, - "runtimeConfig": { - "networkProxy": "http://127.0.0.1:8080" + "network": { + "allowOutbound": false, + "allowLocalNetwork": false, + "allowedHosts": [], + "blockedHosts": [], + "egress": { + "default": "deny" + }, + "ingress": { + "default": "allow", + "hostLoopback": "deny" + }, + "runtimeConfig": { + "networkProxy": "http://127.0.0.1:8080" + } + }, + "ui": { + "allowWindows": false, + "clipboard": "read", + "allowInputInjection": false + }, + "timeoutMs": 30000 }, - "containment": "processcontainer", - "processContainer": { + "command": "echo parity", + "containment": { + "type": "processContainer", "leastPrivilege": true, - "capabilities": [ - "internetClient", - "privateNetworkClientServer" - ], + "learningMode": false, + "capabilities": ["internetClient"], + "captureDenials": { + "mode": "block", + "outputPath": "C:\\logs\\denials.json", + "retainEtl": true + }, "ui": { "isolation": "handles", "desktopSystemControl": false, @@ -48,11 +49,12 @@ }, "network": { "allowedProxyPeer": "Contoso.App_123" - }, - "captureDenials": { - "mode": "block", - "outputPath": "C:\\denials.json", - "retainEtl": true } - } + }, + "containerName": "golden-process-container", + "workingDirectory": "C:\\work", + "environment": { + "PARITY": "true" + }, + "experimental": false } diff --git a/tests/policy/request-wslc.json b/tests/policy/request-wslc.json index 8f7047e9e..35ace35ec 100644 --- a/tests/policy/request-wslc.json +++ b/tests/policy/request-wslc.json @@ -1,38 +1,24 @@ { - "version": "0.8.0-alpha", - "containerId": "golden-wslc", - "lifecycle": { - "destroyOnExit": true, - "preservePolicy": false + "policy": { + "version": "0.8.0-alpha" }, - "process": { - "commandLine": "printf parity", - "timeout": 0 + "command": "printf parity", + "containment": { + "type": "wslc", + "image": "alpine:3.20", + "imageTarPath": "C:\\images\\alpine.tar", + "cpuCount": 2, + "memoryMb": 1024, + "gpu": true, + "storagePath": "C:\\wslc", + "portMappings": [ + { + "windowsPort": 8080, + "containerPort": 80 + } + ] }, - "filesystem": { - "readwritePaths": [], - "readonlyPaths": [], - "deniedPaths": [] - }, - "network": { - "defaultPolicy": "block" - }, - "containment": "wslc", - "experimental": { - "wslc": { - "image": "alpine:3.20", - "imageTarPath": "C:\\images\\alpine.tar", - "cpuCount": 2, - "memoryMb": 1024, - "gpu": true, - "storagePath": "C:\\wslc", - "portMappings": [ - { - "windowsPort": 8080, - "containerPort": 80, - "protocol": "tcp" - } - ] - } - } + "containerName": "golden-wslc", + "environment": {}, + "experimental": true } From 6e2b278fa0907716b2983327b5df05f584900985 Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 19:27:40 -0700 Subject: [PATCH 14/16] Restore ProcessContainer version validation Keep schema-version validation with the ProcessContainer authoring code and remove the duplicate shared-parser check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../src/configs/process_container.rs | 15 +++++++++++++- src/core/wxc_common/src/config_parser.rs | 20 ------------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/core/mxc_engine/src/configs/process_container.rs b/src/core/mxc_engine/src/configs/process_container.rs index f86b63107..84ca3fdc7 100644 --- a/src/core/mxc_engine/src/configs/process_container.rs +++ b/src/core/mxc_engine/src/configs/process_container.rs @@ -173,6 +173,19 @@ pub(crate) fn apply( config["containment"] = json!(containment); + if wxc_common::directional_network_support(&policy.version) == Some(false) { + if process_container.learning_mode { + return Err(wxc_common::mxc_error::MxcError::malformed_request( + "processContainer.learningMode requires schema version 0.8 or later", + )); + } + if process_container.capture_denials.is_some() { + return Err(wxc_common::mxc_error::MxcError::malformed_request( + "processContainer.captureDenials requires schema version 0.8 or later", + )); + } + } + let mut capabilities = process_container.capabilities.clone(); if let Some(net) = &policy.network { let (allows_internet, allows_local_network) = match network_format { @@ -445,7 +458,7 @@ mod tests { "captureDenials", ), ] { - let error = crate::policy::build_request_with_containment( + let error = build_wire_config( &policy_for_version("0.7.0-alpha", None), &crate::policy::Containment::ProcessContainer(process_container), None, diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index c2c19cf66..45f526424 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -918,26 +918,6 @@ fn convert_wire_config( // Validate the schema version up front so an unsupported version fails fast. validate_schema_version(&schema_version)?; - if crate::directional_network_support(&schema_version) == Some(false) { - if cfg - .process_container - .as_ref() - .is_some_and(|section| section.learning_mode.unwrap_or(false)) - { - return Err(WxcError::ConfigParse( - "processContainer.learningMode requires schema version 0.8 or later".to_string(), - )); - } - if cfg - .process_container - .as_ref() - .is_some_and(|section| section.capture_denials.is_some()) - { - return Err(WxcError::ConfigParse( - "processContainer.captureDenials requires schema version 0.8 or later".to_string(), - )); - } - } let container_id = cfg.container_id.unwrap_or_default(); // Process section: required for one-shot and state-aware exec; optional for From 7a5aa623c8f6606164e0640802a96c8ac7b791aa Mon Sep 17 00:00:00 2001 From: Ramon Arjona Date: Wed, 9 Sep 2026 20:23:58 -0700 Subject: [PATCH 15/16] Fix validation CI and consent prompt parsing Remove the stale parser-level validation test after restoring ProcessContainer ownership, and reject null required consent prompt strings before invoking a presenter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e20d046-910e-4ee7-b8e8-acb0fe007208 --- .../MxcTelemetryTests.cs | 43 +++++++++++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs | 43 ++++++++++++++----- src/core/wxc_common/src/config_parser.rs | 26 ----------- 3 files changed, 75 insertions(+), 37 deletions(-) diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs index 9ae1484d8..cfdb5aa48 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -5,6 +5,7 @@ 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; @@ -413,6 +414,48 @@ public void RequestConsentAsync_RejectsLocaleWithEmbeddedNulSynchronously() 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() diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs index fc0ff4a46..79c5524b4 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -742,29 +742,50 @@ private static void ValidateLocale(string? locale) } } - private static TelemetryConsentPrompt ParseConsentPrompt(string json) + 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 = root.GetProperty("locale").GetString() ?? string.Empty, - Title = ParseConsentMessage(root.GetProperty("title")), - Body = ParseConsentMessage(root.GetProperty("body")), - AffirmativeLabel = ParseConsentMessage(root.GetProperty("affirmativeLabel")), - NegativeLabel = ParseConsentMessage(root.GetProperty("negativeLabel")), - LearnMoreLabel = ParseConsentMessage(root.GetProperty("learnMoreLabel")), - LearnMoreUrl = root.GetProperty("learnMoreUrl").GetString() ?? string.Empty, + 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) => new() + private static TelemetryConsentMessage ParseConsentMessage(JsonElement value, string path) => new() { - Id = value.GetProperty("id").GetString() ?? string.Empty, - Text = value.GetProperty("text").GetString() ?? string.Empty, + 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); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 45f526424..ea07e366e 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -2886,32 +2886,6 @@ mod tests { .contains(&"permissiveLearningMode".to_string())); } - #[test] - fn pre_08_rejects_process_container_learning_features() { - for (field, expected) in [ - (r#""learningMode": true"#, "processContainer.learningMode"), - (r#""captureDenials": {}"#, "processContainer.captureDenials"), - ] { - let json = format!( - r#"{{ - "version": "0.7.0-alpha", - "process": {{"commandLine": "echo x"}}, - "containment": "processcontainer", - "processContainer": {{{field}}} - }}"# - ); - let encoded = base64_encode(json.as_bytes()); - let mut logger = test_logger(); - - let error = load_request(&encoded, &mut logger, true) - .expect_err("schema 0.7 must reject 0.8 ProcessContainer features"); - assert!( - error.to_string().contains(expected), - "unexpected error: {error}" - ); - } - } - #[test] fn explicit_learning_mode_capabilities_are_rejected_case_insensitively() { for capability in [ From da5f5987b8c22ff03d103a75781f0742f3990d97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:10:30 +0000 Subject: [PATCH 16/16] Fix streaming test request envelope Co-authored-by: RamonArjona4 <25335379+RamonArjona4@users.noreply.github.com> --- src/ffi/mxc_ffi/src/streaming.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffi/mxc_ffi/src/streaming.rs b/src/ffi/mxc_ffi/src/streaming.rs index 78d547c58..6492617ce 100644 --- a/src/ffi/mxc_ffi/src/streaming.rs +++ b/src/ffi/mxc_ffi/src/streaming.rs @@ -845,8 +845,8 @@ mod tests { fn request(command: &str) -> CString { CString::new( serde_json::json!({ - "version": "0.8.0-alpha", - "process": { "commandLine": command } + "policy": { "version": "0.8.0-alpha" }, + "command": command }) .to_string(), )