diff --git a/scripts/check-dotnet-api-parity.js b/scripts/check-dotnet-api-parity.js index 68201386f..0dcf07950 100644 --- a/scripts/check-dotnet-api-parity.js +++ b/scripts/check-dotnet-api-parity.js @@ -206,22 +206,21 @@ function managedJsonFields(source, className) { }); } -// `legacyManagedOnly` lists managed JSON fields that intentionally have no Rust -// counterpart because they are deprecated compatibility aliases. They must stay -// serializable so legacy JSON round-trips, but the managed request path strips -// them before the native layer sees them. +// `managedOnly` lists managed JSON fields intentionally represented outside the +// Rust policy builder. Compatibility aliases are stripped before native parsing; +// telemetry is extracted by the FFI and applied through SandboxRequest. function compareStructFields( label, rustSource, rustName, managedSource, managedName, - legacyManagedOnly = [] + managedOnly = [] ) { compare( `${label} fields`, managedJsonFields(managedSource, managedName).filter( - (field) => !legacyManagedOnly.includes(field) + (field) => !managedOnly.includes(field) ), rustStructFields(rustSource, rustName) ); @@ -312,12 +311,18 @@ for (const [ rustSource, rustName, managedName, - legacyManagedOnly, + managedOnly, ] of [ // `captureDenials` is an obsolete managed-only alias (MXC0001, removed in // 1.0). Rust only accepts it under `containment.captureDenials`, and // MxcSandbox.PrepareRequest strips it from the policy before serialization. - ["sandbox policy", rustPolicy, "SandboxPolicy", "SandboxPolicy", ["captureDenials"]], + [ + "sandbox policy", + rustPolicy, + "SandboxPolicy", + "SandboxPolicy", + ["captureDenials", "telemetry"], + ], ["filesystem policy", rustPolicy, "FilesystemSection", "FilesystemPolicy"], ["UI policy", rustPolicy, "UiSection", "UiPolicy"], ["network policy", rustNetworkPolicy, "NetworkSection", "NetworkPolicy"], @@ -334,7 +339,7 @@ for (const [ rustName, managedPolicy, managedName, - legacyManagedOnly + managedOnly ); } const managedOneShot = [ diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/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..662f2ba8f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcSandboxTests.cs @@ -827,6 +827,23 @@ public void SerializeRequest_StripsLegacyCaptureDenialsFromThePolicy() .GetString()); } + [Fact] + public void SerializeRequest_PreservesTelemetryWhenMigratingLegacyCaptureDenials() + { + var policy = CreateLegacyCaptureDenialsPolicy( + new CaptureDenialsPolicy(), + "0.9.0-alpha"); + policy.Telemetry = new TelemetrySettings { Enabled = true }; + var request = new SandboxRequest(policy, "echo hi"); + + using var doc = JsonDocument.Parse(MxcSandbox.SerializeRequest(request)); + var serializedPolicy = doc.RootElement.GetProperty("policy"); + + Assert.True( + serializedPolicy.GetProperty("telemetry").GetProperty("enabled").GetBoolean()); + Assert.False(serializedPolicy.TryGetProperty("captureDenials", out _)); + } + private static SandboxPolicy CreateLegacyCaptureDenialsPolicy( CaptureDenialsPolicy captureDenials, string version = "0.8.0-alpha") diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs new file mode 100644 index 000000000..399c0e5bc --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcTelemetryTests.cs @@ -0,0 +1,788 @@ +// 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 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 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); + } + +#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 listener = new CapturingTraceListener( + "MXC telemetry consent presenter faulted after cancellation"); + Trace.Listeners.Add(listener); + try + { + var presenterStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var presenter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var request = MxcTelemetry.RequestConsentAsync(_ => + { + presenterStarted.SetResult(); + return presenter.Task; + }, cancellationToken: cancellation.Token); + + await presenterStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => request); + + presenter.SetException(new InvalidOperationException("late presenter failure")); + var diagnostic = await listener.Message.WaitAsync(TestContext.Current.CancellationToken); + Assert.Contains("late presenter failure", diagnostic); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + finally + { + Trace.Listeners.Remove(listener); + } + } + + [Fact] + public async Task RequestConsentAsync_ExplicitDismissalCompletesWithoutCancellation_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + var outcome = await MxcTelemetry.RequestConsentAsync( + _ => Task.FromResult(TelemetryConsentDecision.Dismissed), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(TelemetryConsentResult.Dismissed, outcome.Result); + Assert.Equal(TelemetryConsentState.Undetermined, outcome.StoredState); + } + + [Fact] + public async Task RequestConsentAsync_SynchronousPresenterCancellationDoesNotPersistDecision_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + using var cancellation = new CancellationTokenSource(); + var previousContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + var request = MxcTelemetry.RequestConsentAsync(_ => + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + return Task.FromResult(TelemetryConsentDecision.Yes); + }, cancellationToken: cancellation.Token); + + await Assert.ThrowsAnyAsync(() => request); + Assert.Equal( + TelemetryConsentState.Undetermined, + MxcTelemetry.GetConsentStatus().StoredState); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + } + + [Fact] + public void FinalizeAsyncOutcome_PersistedDecisionWinsLateCancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Granted, + StoredState = TelemetryConsentState.Granted, + EffectiveState = TelemetryConsentState.Granted, + }; + + Assert.Same(outcome, MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + + [Fact] + public void FinalizeAsyncOutcome_CanceledDismissalThrows() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var outcome = new TelemetryConsentOutcome + { + Result = TelemetryConsentResult.Dismissed, + StoredState = TelemetryConsentState.Undetermined, + EffectiveState = TelemetryConsentState.Undetermined, + }; + + Assert.ThrowsAny( + () => MxcTelemetry.FinalizeAsyncOutcome(outcome, cancellation.Token)); + } + + [Fact] + public void ConsentLifecycle_RoundTripsThroughNativeApi_OnWindows() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var _ = new TelemetryTestEnv(); + Assert.Equal(TelemetryConsentState.Undetermined, MxcTelemetry.GetConsent()); + Assert.True(MxcTelemetry.NeedsConsentPrompt()); + + var dismissed = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Dismissed); + Assert.Equal(TelemetryConsentResult.Dismissed, dismissed.Result); + Assert.Equal(TelemetryConsentState.Undetermined, dismissed.StoredState); + + var granted = MxcTelemetry.RequestConsent(_ => TelemetryConsentDecision.Yes); + Assert.Equal(TelemetryConsentResult.Granted, granted.Result); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsent()); + Assert.Equal(TelemetryConsentState.Granted, MxcTelemetry.GetConsentStatus().EffectiveState); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + + var withdrawn = MxcTelemetry.WithdrawConsent(); + Assert.Equal(TelemetryConsentResult.Withdrawn, withdrawn.Result); + Assert.Equal(TelemetryConsentState.Denied, MxcTelemetry.GetConsent()); + Assert.False(MxcTelemetry.NeedsConsentPrompt()); + } + + [SupportedOSPlatform("windows")] + private sealed class TelemetryTestEnv : IDisposable + { + private static readonly SemaphoreSlim Gate = new(1, 1); + + private readonly string? _originalLocalAppData; + private readonly string? _originalLocalAppDataOwnerPid; + private readonly string? _originalPolicyKey; + private readonly string? _originalPolicyOwnerPid; + private readonly string _storeDir; + private readonly string _policySubkey; + + public TelemetryTestEnv() + { + Gate.Wait(); + try + { + _originalLocalAppData = Environment.GetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE"); + _originalLocalAppDataOwnerPid = Environment.GetEnvironmentVariable( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID"); + _originalPolicyKey = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE"); + _originalPolicyOwnerPid = Environment.GetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID"); + + _storeDir = Directory.CreateTempSubdirectory("mxc-dotnet-telemetry-").FullName; + _policySubkey = $@"Software\MxcTelemetryDotNetTests\{Guid.NewGuid():N}"; + + Registry.CurrentUser.CreateSubKey(_policySubkey)?.Dispose(); + Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _storeDir); + Environment.SetEnvironmentVariable( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + GetParentProcessId().ToString()); + Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _policySubkey); + Environment.SetEnvironmentVariable( + "MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", + Environment.ProcessId.ToString()); + } + catch + { + Gate.Release(); + throw; + } + } + + public void Dispose() + { + Environment.SetEnvironmentVariable("MXC_TEST_LOCALAPPDATA_OVERRIDE", _originalLocalAppData); + Environment.SetEnvironmentVariable( + "MXC_TEST_LOCALAPPDATA_OVERRIDE_OWNER_PID", + _originalLocalAppDataOwnerPid); + Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE", _originalPolicyKey); + Environment.SetEnvironmentVariable("MXC_TEST_POLICY_KEY_OVERRIDE_OWNER_PID", _originalPolicyOwnerPid); + + try + { + if (Directory.Exists(_storeDir)) + { + Directory.Delete(_storeDir, recursive: true); + } + } + catch + { + } + + try + { + Registry.CurrentUser.DeleteSubKeyTree(_policySubkey, throwOnMissingSubKey: false); + } + catch + { + } + + Gate.Release(); + } + + private static int GetParentProcessId() + { + using var process = Process.GetCurrentProcess(); + var status = NtQueryInformationProcess( + process.Handle, + processInformationClass: 0, + out var processInformation, + Marshal.SizeOf(), + out _); + if (status != 0) + { + throw new InvalidOperationException( + $"NtQueryInformationProcess failed with NTSTATUS 0x{status:X8}"); + } + + return checked((int)processInformation.InheritedFromUniqueProcessId); + } + + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + out ProcessBasicInformation processInformation, + int processInformationLength, + out int returnLength); + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessBasicInformation + { + internal IntPtr Reserved1; + internal IntPtr PebBaseAddress; + internal IntPtr Reserved2_0; + internal IntPtr Reserved2_1; + internal IntPtr UniqueProcessId; + internal IntPtr InheritedFromUniqueProcessId; + } + } + + private sealed class PumpSynchronizationContext : SynchronizationContext, IDisposable + { + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _work = new(); + private readonly AutoResetEvent _workAvailable = new(initialState: false); + + public override void Post(SendOrPostCallback callback, object? state) + { + _work.Enqueue((callback, state)); + _workAvailable.Set(); + } + + public void RunUntilCompleted(Task task) + { + while (!task.IsCompleted) + { + if (_work.TryDequeue(out var work)) + { + work.Callback(work.State); + } + else + { + _workAvailable.WaitOne(TimeSpan.FromSeconds(5)); + } + } + } + + public void Dispose() => _workAvailable.Dispose(); + } + + private sealed class CapturingTraceListener(string messagePrefix) : TraceListener + { + private readonly TaskCompletionSource _message = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Message => _message.Task; + + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? message) + { + Capture(eventType, message); + } + + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? format, + params object?[]? args) + { + Capture( + eventType, + args is null || args.Length == 0 + ? format + : string.Format(format ?? string.Empty, args)); + } + + public override void Write(string? message) + { + } + + public override void WriteLine(string? message) + { + } + + private void Capture(TraceEventType eventType, string? message) + { + if (eventType == TraceEventType.Error && + message?.StartsWith(messagePrefix, StringComparison.Ordinal) == true) + { + _message.TrySetResult(message); + } + } + } +#endif + + private sealed class FakeTelemetryQueryApi : MxcTelemetry.ITelemetryReadApi + { + internal required Func GetConsentImpl { get; init; } + internal required Func NeedsConsentPromptImpl { get; init; } + internal required Func GetPolicyImpl { get; init; } + + public MxcTelemetry.NativePayloadResult GetConsent() => GetConsentImpl(); + + public MxcTelemetry.NativeBooleanResult NeedsConsentPrompt() => + NeedsConsentPromptImpl(); + + public MxcTelemetry.NativePayloadResult GetPolicy() => GetPolicyImpl(); + } + + private sealed class ThrowingTraceListener : TraceListener + { + public override void TraceEvent( + TraceEventCache? eventCache, + string? source, + TraceEventType eventType, + int id, + string? format, + params object?[]? args) => + throw new InvalidOperationException("listener failure"); + + public override void Write(string? message) => + throw new InvalidOperationException("listener failure"); + + public override void WriteLine(string? message) => + throw new InvalidOperationException("listener failure"); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs index 6bbde7798..488abed94 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/ErrorCode.cs @@ -58,4 +58,7 @@ public enum ErrorCode /// The native side panicked and was caught at the boundary (FFI-local). Panic = 102, + + /// Telemetry consent could not be persisted (FFI-local). + ConsentWriteFailed = 103, } diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs index 12810c042..146a94f38 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcSandbox.cs @@ -320,6 +320,7 @@ private static SandboxPolicy ClonePolicyWithoutCaptureDenials(SandboxPolicy poli Network = policy.Network, Ui = policy.Ui, TimeoutMs = policy.TimeoutMs, + Telemetry = policy.Telemetry, }; private static bool CaptureDenialsEqual( diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs new file mode 100644 index 000000000..e90fa8ffa --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcTelemetry.cs @@ -0,0 +1,825 @@ +// 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 +{ + Granted, + Denied, + Undetermined, + NotApplicable, +} + +/// The administrative telemetry policy for this machine. +public enum TelemetryPolicyState +{ + Unrestricted, + Allowed, + Blocked, + NotApplicable, +} + +/// The presenter decision returned to MXC. +public enum TelemetryConsentDecision +{ + No, + Yes, + Dismissed, +} + +/// Why stored consent is not currently effective. +public enum TelemetryConsentStatusReason +{ + NoRecord, + StoreUnreadable, + StoreMalformed, + ConsentSchemaUnsupported, + PromptVersionMissing, + PromptVersionUnsupported, + NotApplicable, +} + +/// Result of a consent request or withdrawal. +public enum TelemetryConsentResult +{ + Granted, + Denied, + Dismissed, + Withdrawn, + AlreadyGranted, + PolicyBlocked, + NotApplicable, +} + +/// One canonical consent message. +public sealed class TelemetryConsentMessage +{ + public string Id { get; init; } = string.Empty; + public string Text { get; init; } = string.Empty; +} + +/// The canonical consent prompt resource passed to a host presenter. +public sealed class TelemetryConsentPrompt +{ + public uint ResourceVersion { get; init; } + public string Locale { get; init; } = string.Empty; + public TelemetryConsentMessage Title { get; init; } = new(); + public TelemetryConsentMessage Body { get; init; } = new(); + public TelemetryConsentMessage AffirmativeLabel { get; init; } = new(); + public TelemetryConsentMessage NegativeLabel { get; init; } = new(); + public TelemetryConsentMessage LearnMoreLabel { get; init; } = new(); + public string LearnMoreUrl { get; init; } = string.Empty; +} + +/// Stored and effective consent plus the current administrative policy. +public sealed class TelemetryConsentStatus +{ + public TelemetryConsentState StoredState { get; init; } + public TelemetryConsentState EffectiveState { get; init; } + public TelemetryConsentStatusReason? Reason { get; init; } + public TelemetryPolicyState Policy { get; init; } +} + +/// Telemetry consent action result with the resulting status snapshot. +public sealed class TelemetryConsentOutcome +{ + public TelemetryConsentResult Result { get; init; } + public TelemetryConsentState StoredState { get; init; } + public TelemetryConsentState EffectiveState { get; init; } + public TelemetryConsentStatusReason? Reason { get; init; } + public TelemetryPolicyState Policy { get; init; } +} + +/// Telemetry consent helpers over the native mxc_ffi surface. +public static class MxcTelemetry +{ + private const int NativeConsentDecisionNo = 0; + private const int NativeConsentDecisionYes = 1; + private const int NativeConsentDecisionDismissed = 2; + private const int NativeConsentPresenterError = -1; + private 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(result.Payload ?? "undetermined"); + } + 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); + 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); + 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(result.Payload ?? "blocked"); + } + 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; + } + + try + { + Trace.TraceError( + "MXC telemetry consent presenter faulted after cancellation: {0}", + exception.GetBaseException()); + } + catch + { + // Diagnostic listeners must not fault an unobserved continuation. + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + private static Task InvokePresenterAsync( + Func> presenter, + TelemetryConsentPrompt prompt, + 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 unsafe string? ReadNativeUtf8(byte* value) => + value is null ? null : Marshal.PtrToStringUTF8((IntPtr)value); + + private static unsafe void FreeNativeString(byte* value) + { + if (value is not null) + { + NativeMethods.mxc_string_free(value); + } + } + + private static byte[] ToNullTerminatedUtf8(string value) + { + var byteCount = Encoding.UTF8.GetByteCount(value); + var buffer = new byte[byteCount + 1]; + Encoding.UTF8.GetBytes(value, 0, value.Length, buffer, 0); + buffer[byteCount] = 0; + return buffer; + } + + private static TelemetryConsentPrompt ParseConsentPrompt(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + return new TelemetryConsentPrompt + { + ResourceVersion = root.GetProperty("resourceVersion").GetUInt32(), + Locale = root.GetProperty("locale").GetString() ?? string.Empty, + Title = ParseConsentMessage(root.GetProperty("title")), + Body = ParseConsentMessage(root.GetProperty("body")), + AffirmativeLabel = ParseConsentMessage(root.GetProperty("affirmativeLabel")), + NegativeLabel = ParseConsentMessage(root.GetProperty("negativeLabel")), + LearnMoreLabel = ParseConsentMessage(root.GetProperty("learnMoreLabel")), + LearnMoreUrl = root.GetProperty("learnMoreUrl").GetString() ?? string.Empty, + }; + } + + private static TelemetryConsentMessage ParseConsentMessage(JsonElement value) => new() + { + Id = value.GetProperty("id").GetString() ?? string.Empty, + Text = value.GetProperty("text").GetString() ?? string.Empty, + }; + + private static TelemetryConsentStatus ParseConsentStatus(string json) + { + using var doc = JsonDocument.Parse(json); + return ParseConsentStatus(doc.RootElement); + } + + private static TelemetryConsentStatus ParseConsentStatus(JsonElement root) => new() + { + StoredState = ParseConsentState(root.GetProperty("storedState").GetString() ?? string.Empty), + EffectiveState = ParseConsentState(root.GetProperty("effectiveState").GetString() ?? string.Empty), + Reason = root.TryGetProperty("reason", out var reason) && reason.ValueKind != JsonValueKind.Null + ? ParseConsentStatusReason(reason.GetString() ?? string.Empty) + : null, + Policy = ParsePolicyState(root.GetProperty("policy").GetString() ?? string.Empty), + }; + + private static TelemetryConsentOutcome ParseConsentOutcome(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var status = ParseConsentStatus(root); + return new TelemetryConsentOutcome + { + Result = ParseConsentResult(root.GetProperty("result").GetString() ?? string.Empty), + StoredState = status.StoredState, + EffectiveState = status.EffectiveState, + Reason = status.Reason, + Policy = status.Policy, + }; + } + + private static TelemetryConsentState ParseConsentState(string value) => value switch + { + "granted" => TelemetryConsentState.Granted, + "denied" => TelemetryConsentState.Denied, + "undetermined" => TelemetryConsentState.Undetermined, + "not-applicable" => TelemetryConsentState.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent state '{value}'"), + }; + + private static TelemetryPolicyState ParsePolicyState(string value) => value switch + { + "unrestricted" => TelemetryPolicyState.Unrestricted, + "allowed" => TelemetryPolicyState.Allowed, + "blocked" => TelemetryPolicyState.Blocked, + "not-applicable" => TelemetryPolicyState.NotApplicable, + _ => throw new JsonException($"unknown telemetry policy state '{value}'"), + }; + + private static TelemetryConsentStatusReason ParseConsentStatusReason(string value) => value switch + { + "no-record" => TelemetryConsentStatusReason.NoRecord, + "store-unreadable" => TelemetryConsentStatusReason.StoreUnreadable, + "store-malformed" => TelemetryConsentStatusReason.StoreMalformed, + "consent-schema-unsupported" => TelemetryConsentStatusReason.ConsentSchemaUnsupported, + "prompt-version-missing" => TelemetryConsentStatusReason.PromptVersionMissing, + "prompt-version-unsupported" => TelemetryConsentStatusReason.PromptVersionUnsupported, + "not-applicable" => TelemetryConsentStatusReason.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent status reason '{value}'"), + }; + + private static TelemetryConsentResult ParseConsentResult(string value) => value switch + { + "granted" => TelemetryConsentResult.Granted, + "denied" => TelemetryConsentResult.Denied, + "dismissed" => TelemetryConsentResult.Dismissed, + "withdrawn" => TelemetryConsentResult.Withdrawn, + "alreadyGranted" => TelemetryConsentResult.AlreadyGranted, + "policyBlocked" => TelemetryConsentResult.PolicyBlocked, + "notApplicable" => TelemetryConsentResult.NotApplicable, + _ => throw new JsonException($"unknown telemetry consent result '{value}'"), + }; +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs index e99abb605..691d9899d 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/SandboxPolicy.cs @@ -56,6 +56,21 @@ 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; } +} + +/// Telemetry section of a . +public sealed class TelemetrySettings +{ + /// Whether this invocation opts telemetry on, subject to consent and policy. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } } /// diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index 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..c641801d8 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_enabled(true); -// Run to completion and capture the output. let output = run(request)?; assert_eq!(output.outcome, WaitOutcome::Exited(0)); assert_eq!(String::from_utf8_lossy(&output.stdout), "hello\n"); -# Ok::<(), Box>(()) +Ok(()) +} ``` [`run`] is the run-to-completion convenience (spawn + `wait_with_output`); use @@ -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_enabled(true)` is +called. Enabling that per-invocation switch still requires persisted user +consent and a permitting administrative policy. + To target a specific backend instead of the host default, use [`build_request_with_containment`] with a [`Containment`]. @@ -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,80 @@ 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. + +Telemetry is also off per invocation unless the request explicitly enables it +with `SandboxRequest::set_telemetry_enabled(true)`. This stable switch does not +require `set_experimental(true)` and cannot bypass consent or administrative +policy. The same configured `SandboxRequest` can be passed to either +run-to-completion (`run`) or streaming (`spawn_sandbox`). + +```rust,no_run +use std::error::Error; +use mxc_sdk::telemetry; + +fn main() -> Result<(), Box> { +let outcome = telemetry::request_consent(Some("en-US"), |prompt| { + assert_eq!(prompt.locale, "en-US"); + Ok(telemetry::ConsentDecision::Yes) +})?; + +let status = telemetry::get_consent_status(); +let withdrawal = telemetry::withdraw_consent()?; +let _ = (outcome, status, withdrawal); +Ok(()) +} +``` + +Off Windows `get_consent()` always returns `ConsentState::NotApplicable`, +`needs_consent_prompt()` is always `false`, and consent requests return +`ConsentActionResult::NotApplicable` — MXC neither collects nor offers consent +for telemetry there, so a host can call these unconditionally without +special-casing the platform. + +### 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..82125e1b1 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::binding::{parse_policy_json, ParsedPolicy}; +} + pub use mxc_engine::configs; pub use mxc_engine::policy; pub use mxc_engine::{ diff --git a/src/core/mxc-sdk/src/telemetry.rs b/src/core/mxc-sdk/src/telemetry.rs 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/binding.rs b/src/core/mxc_engine/src/binding.rs new file mode 100644 index 000000000..f7f7f031e --- /dev/null +++ b/src/core/mxc_engine/src/binding.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Internal policy parsing shared by language-binding adapters. + +use crate::policy::SandboxPolicy; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TelemetrySection { + enabled: bool, +} + +#[derive(Default)] +struct OptionalTelemetrySection(Option); + +impl<'de> serde::Deserialize<'de> for OptionalTelemetrySection { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ::deserialize(deserializer) + .map(|section| Self(Some(section))) + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PolicyEnvelope { + #[serde(flatten)] + policy: SandboxPolicy, + #[serde(default)] + telemetry: OptionalTelemetrySection, + #[serde( + default, + rename = "telemetryEnabled", + deserialize_with = "reject_legacy_telemetry_enabled" + )] + _telemetry_enabled: (), +} + +fn reject_legacy_telemetry_enabled<'de, D>(deserializer: D) -> Result<(), D::Error> +where + D: serde::Deserializer<'de>, +{ + let _ = ::deserialize(deserializer)?; + Err(serde::de::Error::custom( + "telemetryEnabled is not supported; use telemetry.enabled", + )) +} + +fn validate_canonical_telemetry_version(envelope: &PolicyEnvelope) -> Result<(), String> { + if envelope.telemetry.0.is_none() { + return Ok(()); + } + let Some(core) = envelope.policy.version.split(['-', '+']).next() else { + return Ok(()); + }; + let mut components = core.split('.'); + let (Some(major), Some(minor), Some(patch), None) = ( + components.next(), + components.next(), + components.next(), + components.next(), + ) else { + return Ok(()); + }; + let (Ok(major), Ok(minor), Ok(_patch)) = ( + major.parse::(), + minor.parse::(), + patch.parse::(), + ) else { + return Ok(()); + }; + if major == 0 && minor < 9 { + return Err( + "failed to parse policy JSON: top-level 'telemetry' requires config schema version \ + 0.9.0-alpha or later" + .to_string(), + ); + } + Ok(()) +} + +/// A policy decoded for a language-binding adapter. +#[derive(Debug)] +pub struct ParsedPolicy { + pub policy: SandboxPolicy, + pub telemetry_enabled: Option, +} + +/// Parse SDK policy JSON through the native policy contract. +pub fn parse_policy_json(policy_json: &str) -> Result { + let envelope: PolicyEnvelope = serde_json::from_str(policy_json) + .map_err(|error| format!("failed to parse policy JSON: {error}"))?; + validate_canonical_telemetry_version(&envelope)?; + let telemetry_enabled = envelope + .telemetry + .0 + .as_ref() + .map(|telemetry| telemetry.enabled); + Ok(ParsedPolicy { + policy: envelope.policy, + telemetry_enabled, + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn accepts_canonical_telemetry() { + let parsed = + super::parse_policy_json(r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true}}"#) + .expect("policy should parse"); + + assert_eq!(parsed.policy.version, "0.9.0-alpha"); + assert_eq!(parsed.telemetry_enabled, Some(true)); + } + + #[test] + fn rejects_canonical_telemetry_before_schema_09() { + let error = + super::parse_policy_json(r#"{"version":"0.8.0-alpha","telemetry":{"enabled":true}}"#) + .expect_err("canonical telemetry must honor the native schema boundary"); + + assert!(error.contains("requires config schema version 0.9.0-alpha")); + } + + #[test] + fn rejects_legacy_telemetry_alias() { + let error = + super::parse_policy_json(r#"{"version":"0.9.0-alpha","telemetryEnabled":true}"#) + .expect_err("the legacy telemetry alias must be rejected"); + + assert!(error.contains("telemetryEnabled")); + } + + #[test] + fn rejects_null_and_missing_telemetry_enabled() { + for policy_json in [ + r#"{"version":"0.9.0-alpha","telemetry":null}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":null}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{}}"#, + ] { + assert!( + super::parse_policy_json(policy_json).is_err(), + "{policy_json} must be rejected" + ); + } + } + + #[test] + fn rejects_duplicate_fields() { + for policy_json in [ + r#"{"version":"0.9.0-alpha","version":"0.9.0-alpha"}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true},"telemetry":{"enabled":false}}"#, + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true,"enabled":false}}"#, + ] { + assert!( + super::parse_policy_json(policy_json).is_err(), + "{policy_json} must be rejected" + ); + } + } +} diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index b7d09acb3..6a11c8883 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -29,6 +29,9 @@ //! - [`Error`] / [`ErrorCode`] — the crate-owned error facade over //! `wxc_common`'s internal error type. +#[cfg(feature = "ffi-internals")] +#[doc(hidden)] +pub mod binding; pub mod configs; mod dispatch; mod error; diff --git a/src/core/mxc_engine/src/policy.rs b/src/core/mxc_engine/src/policy.rs index 208736225..450fe91e5 100644 --- a/src/core/mxc_engine/src/policy.rs +++ b/src/core/mxc_engine/src/policy.rs @@ -710,6 +710,14 @@ impl SandboxRequest { }); self } + + /// Return the explicit per-request telemetry switch for this invocation. + pub fn telemetry_enabled(&self) -> Option { + self.inner + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.enabled) + } } /// Build a [`SandboxRequest`] from a [`SandboxPolicy`], resolving the host's @@ -1636,14 +1644,7 @@ mod tests { assert!(!request.inner.experimental_enabled); request.set_telemetry_enabled(true); - assert_eq!( - request - .inner - .telemetry - .as_ref() - .and_then(|telemetry| telemetry.enabled), - Some(true) - ); + assert_eq!(request.telemetry_enabled(), Some(true)); assert_eq!( request .inner @@ -1658,14 +1659,7 @@ mod tests { ); request.set_telemetry_enabled(false); - assert_eq!( - request - .inner - .telemetry - .as_ref() - .and_then(|telemetry| telemetry.enabled), - Some(false) - ); + assert_eq!(request.telemetry_enabled(), Some(false)); assert!(!request.inner.experimental_enabled); } diff --git a/src/core/wxc_common/src/telemetry/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/lib.rs b/src/ffi/mxc_ffi/src/lib.rs index 0feced050..c2966ca10 100644 --- a/src/ffi/mxc_ffi/src/lib.rs +++ b/src/ffi/mxc_ffi/src/lib.rs @@ -3,8 +3,7 @@ //! C ABI over the MXC public Rust SDK ([`mxc_sdk`]). //! -//! This is the flat, panic-safe C surface that language bindings (currently the -//! C# SDK) load. It spans three surfaces: +//! This is the flat, panic-safe C surface loaded by language bindings. //! //! - **Run to completion** — [`mxc_run_request`] accepts the complete binding //! request; [`mxc_run`] is the policy + command compatibility entry point. @@ -37,29 +36,30 @@ //! ([`MXC_STATUS_PANIC`]), never an unwind across the boundary. //! - **Data contract**: JSON in, captured bytes + status out. The status codes //! mirror `mxc_sdk::ErrorCode` one-for-one (plus a few FFI-local codes). +//! - **Per-invocation telemetry opt-in**: [`mxc_run`] / [`mxc_spawn`] accept +//! the canonical top-level execution field `telemetry.enabled` inside the +//! policy JSON they already take. +//! - **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, + available_backends, build_request, platform_support, run, ErrorCode, SandboxRequest, + WaitOutcome, }; mod error_detail; @@ -70,13 +70,97 @@ 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)) { + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + report_to_stderr(format_args!( + "mxc: internal error: panic caught at the FFI boundary in {operation}: {message}. \ + Failing closed; the calling process is unaffected." + )); +} + +fn report_diagnostic_once(flag: &OnceLock<()>, args: std::fmt::Arguments<'_>) { + if flag.set(()).is_ok() { + report_to_stderr(args); + } +} + +fn report_consent_persist_failure_once(flag: &OnceLock<()>, operation: &str) { + report_diagnostic_once( + flag, + format_args!( + "mxc: {operation} failed because telemetry consent could not be persisted. \ + Returning a fail-closed status without host-specific details." + ), + ); +} + +fn report_request_consent_persist_failure_once() { + static REQUEST_REPORTED: OnceLock<()> = OnceLock::new(); + report_consent_persist_failure_once(&REQUEST_REPORTED, "mxc_telemetry_request_consent"); +} + +fn report_withdraw_consent_persist_failure_once() { + static WITHDRAW_REPORTED: OnceLock<()> = OnceLock::new(); + report_consent_persist_failure_once(&WITHDRAW_REPORTED, "mxc_telemetry_withdraw_consent"); +} + +fn report_consent_presenter_failure_once() { + static REPORTED: OnceLock<()> = OnceLock::new(); + report_diagnostic_once( + &REPORTED, + format_args!( + "mxc: telemetry consent presenter failed. Returning MXC_STATUS_BACKEND_ERROR \ + without host-supplied details." + ), + ); +} + // --------------------------------------------------------------------------- // Status codes // --------------------------------------------------------------------------- /// Success. pub const MXC_STATUS_SUCCESS: i32 = 0; -// 1..=12 mirror `mxc_sdk::ErrorCode` (kept in lockstep with a CI drift gate). +// 1..=12 mirror `mxc_sdk::ErrorCode`. /// The request/policy was malformed. pub const MXC_STATUS_MALFORMED_REQUEST: i32 = 1; /// The requested containment backend is not supported by this library. @@ -109,6 +193,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 { @@ -200,6 +291,14 @@ impl MxcRunResult { } } + fn from_error_detail(status: i32, error: MxcErrorDetail) -> Self { + Self { + status, + error, + ..Self::empty() + } + } + /// Free any owned out-strings, resetting them to null. Idempotent. fn free_strings(&mut self) { free_cstr(&mut self.stdout_utf8); @@ -249,6 +348,43 @@ pub(crate) unsafe fn cstr_to_str<'a>(p: *const c_char) -> Option<&'a str> { CStr::from_ptr(p).to_str().ok() } +pub(crate) fn build_run_request( + policy_json: &str, + command: &str, +) -> Result { + build_ffi_request(policy_json, command) +} + +pub(crate) fn build_spawn_request( + policy_json: &str, + command: &str, +) -> Result { + build_ffi_request(policy_json, command) +} + +fn build_ffi_request( + policy_json: &str, + command: &str, +) -> Result { + let parsed = mxc_sdk::ffi_internals::parse_policy_json(policy_json).map_err(|error| { + ( + MXC_STATUS_MALFORMED_REQUEST, + MxcErrorDetail::from_message(error), + ) + })?; + let mut request = build_request(&parsed.policy, None).map_err(|error| { + ( + status_from_error_code(error.code), + MxcErrorDetail::from_error(&error), + ) + })?; + request.set_script(command); + if let Some(enabled) = parsed.telemetry_enabled { + request.set_telemetry_enabled(enabled); + } + Ok(request) +} + // --------------------------------------------------------------------------- // Entry points // --------------------------------------------------------------------------- @@ -277,8 +413,10 @@ pub unsafe extern "C" fn mxc_run( 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 result = catch_unwind(|| run_inner(policy_json_utf8, command_utf8)).unwrap_or_else(|p| { + report_panic("mxc_run", &*p); + MxcRunResult::error(MXC_STATUS_PANIC, "the mxc engine panicked") + }); let status = result.status; // SAFETY: `out` is non-null and caller-guaranteed writable; ownership of the @@ -351,21 +489,10 @@ fn run_inner(policy_json_utf8: *const c_char, command_utf8: *const c_char) -> Mx None => return MxcRunResult::error(MXC_STATUS_INVALID_UTF8, "command is not UTF-8"), }; - let policy: SandboxPolicy = match serde_json::from_str(policy_json) { - Ok(p) => p, - Err(e) => { - return MxcRunResult::error( - MXC_STATUS_MALFORMED_REQUEST, - format!("failed to parse policy JSON: {e}"), - ) - } - }; - - let mut request = match build_request(&policy, None) { - Ok(r) => r, - Err(e) => return MxcRunResult::from_sdk_error(&e), + let request = match build_run_request(policy_json, command) { + Ok(request) => request, + Err((status, error)) => return MxcRunResult::from_error_detail(status, error), }; - request.set_script(command); execute_request(request) } @@ -437,10 +564,12 @@ pub unsafe extern "C" fn mxc_run_result_free(r: *mut MxcRunResult) { if r.is_null() { return; } - let _ = catch_unwind(|| { + if let Err(p) = catch_unwind(|| { // SAFETY: caller guarantees `r` points to a valid, not-yet-freed result. unsafe { (*r).free_strings() }; - }); + }) { + report_panic("mxc_run_result_free", &*p); + } } /// Free a single heap C string returned by this library. @@ -453,10 +582,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. @@ -507,6 +638,335 @@ pub extern "C" fn mxc_version() -> *const c_char { .unwrap_or(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::*; @@ -522,6 +982,29 @@ mod tests { out } + #[test] + fn build_run_request_propagates_telemetry_enablement() { + for (policy_json, expected) in [ + (r#"{"version":"0.8.0-alpha"}"#, None), + ( + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true}}"#, + Some(true), + ), + ( + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":false}}"#, + Some(false), + ), + ] { + let request = build_run_request(policy_json, "echo hi") + .unwrap_or_else(|_| panic!("build_run_request failed for {policy_json}")); + assert_eq!( + request.telemetry_enabled(), + expected, + "policy: {policy_json}" + ); + } + } + #[test] fn malformed_policy_json_reports_malformed_request() { let mut out = run_with("{ not json", Some("echo hi")); @@ -672,4 +1155,535 @@ mod tests { // SAFETY: `out` was filled by `mxc_run`. unsafe { mxc_run_result_free(&mut out) }; } + + // ----------------------------------------------------------------- + // Telemetry consent + // ----------------------------------------------------------------- + // + // Telemetry overrides are debug-only, so these tests are excluded from + // release builds rather than reading the host's real consent and policy + // state. `CONSENT_ENV_LOCK` and `TelemetryTestEnv`'s policy lock serialize + // the process-global overrides; unrelated `mxc_run` tests remain parallel. + #[cfg(debug_assertions)] + struct TelemetryTestEnv { + _dir: tempfile::TempDir, + _env: wxc_common::telemetry::test_support::TelemetryTestEnv, + } + + #[cfg(debug_assertions)] + impl TelemetryTestEnv { + fn new(_label: &str) -> Self { + let dir = tempfile::tempdir().expect("create temp dir"); + let env = wxc_common::telemetry::test_support::TelemetryTestEnv::new(dir.path()); + Self { + _dir: dir, + _env: env, + } + } + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_reports_string_and_never_errors() { + let _guard = TelemetryTestEnv::new("get_default"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + #[cfg(target_os = "windows")] + assert_eq!(s, "undetermined"); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + // SAFETY: `out` was allocated via `alloc_cstring`/`CString::into_raw`. + unsafe { mxc_string_free(out) }; + } + + #[cfg(debug_assertions)] + #[test] + fn get_consent_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("get_null_out"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_consent(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[cfg(debug_assertions)] + #[test] + fn presenter_request_then_get_consent_round_trips() { + let _guard = TelemetryTestEnv::new("round_trip"); + unsafe extern "C" fn presenter( + prompt_json_utf8: *const c_char, + context: *mut c_void, + ) -> i32 { + // SAFETY: the FFI request provides valid pointers for this call. + let prompt = unsafe { CStr::from_ptr(prompt_json_utf8) } + .to_str() + .unwrap(); + let prompt: serde_json::Value = serde_json::from_str(prompt).unwrap(); + let canonical = wxc_common::telemetry::consent_prompt::prompt_for_locale(Some("en-US")); + assert_eq!(prompt["resourceVersion"], canonical.resource_version); + assert_eq!(prompt["locale"], canonical.locale); + assert_eq!(prompt["title"]["id"], canonical.title.id); + assert_eq!(prompt["title"]["text"], canonical.title.text); + assert_eq!(prompt["body"]["id"], canonical.body.id); + assert_eq!(prompt["body"]["text"], canonical.body.text); + assert_eq!( + prompt["affirmativeLabel"]["text"], + canonical.affirmative_label.text + ); + assert_eq!( + prompt["negativeLabel"]["text"], + canonical.negative_label.text + ); + assert_eq!( + prompt["learnMoreLabel"]["text"], + canonical.learn_more_label.text + ); + assert_eq!(prompt["learnMoreUrl"], canonical.learn_more_url); + // SAFETY: the test passes a pointer to this bool as context. + unsafe { *(context as *mut bool) = true }; + MXC_TELEMETRY_CONSENT_DECISION_YES + } + + let mut called = false; + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback, context, and out pointer remain valid for the call. + let request_status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(presenter), + (&mut called as *mut bool).cast(), + &mut outcome, + ) + }; + assert_eq!(request_status, MXC_STATUS_SUCCESS); + assert!(!outcome.is_null()); + // SAFETY: allocated by the request export. + let outcome_json = unsafe { CStr::from_ptr(outcome) }.to_str().unwrap(); + let outcome_json: serde_json::Value = serde_json::from_str(outcome_json).unwrap(); + unsafe { mxc_string_free(outcome) }; + + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let get_status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(get_status, MXC_STATUS_SUCCESS); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert!(called); + assert_eq!(outcome_json["result"], "granted"); + assert_eq!(s, "granted"); + } + #[cfg(not(target_os = "windows"))] + { + assert!(!called); + assert_eq!(outcome_json["result"], "notApplicable"); + assert_eq!(s, "not-applicable"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn request_consent_requires_a_presenter() { + let _guard = TelemetryTestEnv::new("null_presenter"); + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: null presenter is explicitly rejected. + let status = + unsafe { mxc_telemetry_request_consent(ptr::null(), None, ptr::null_mut(), &mut out) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + assert!(out.is_null()); + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_tracks_the_store() { + let _guard = TelemetryTestEnv::new("needs_prompt"); + let mut needs: i32 = -1; + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + // Fresh store on Windows must prompt; off Windows nothing is collected + // so nothing may be asked. + #[cfg(target_os = "windows")] + assert_eq!(needs, 1); + #[cfg(not(target_os = "windows"))] + assert_eq!(needs, 0); + + #[cfg(target_os = "windows")] + { + unsafe extern "C" fn deny( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + MXC_TELEMETRY_CONSENT_DECISION_NO + } + let mut outcome: *mut c_char = ptr::null_mut(); + // SAFETY: callback and out pointer remain valid for the call. + assert_eq!( + unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(deny), + ptr::null_mut(), + &mut outcome, + ) + }, + MXC_STATUS_SUCCESS + ); + // SAFETY: allocated by the request export. + unsafe { mxc_string_free(outcome) }; + // SAFETY: `needs` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_needs_consent_prompt(&mut needs) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert_eq!(needs, 0, "a recorded denial must not re-prompt"); + } + } + + #[cfg(debug_assertions)] + #[test] + fn needs_consent_prompt_null_out_reports_null_argument() { + let _guard = TelemetryTestEnv::new("needs_prompt_null"); + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_needs_consent_prompt(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[cfg(debug_assertions)] + #[test] + fn withdraw_consent_reports_success_and_is_idempotent() { + let _guard = TelemetryTestEnv::new("withdraw_idempotent"); + + for _ in 0..2 { + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_withdraw_consent`. + let outcome_json = unsafe { CStr::from_ptr(out) }.to_str().unwrap(); + let outcome_json: serde_json::Value = serde_json::from_str(outcome_json).unwrap(); + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert_eq!(outcome_json["result"], "withdrawn"); + assert_eq!(outcome_json["effectiveState"], "denied"); + assert_eq!(outcome_json["storedState"], "denied"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(outcome_json["result"], "notApplicable"); + assert_eq!(outcome_json["effectiveState"], "not-applicable"); + assert_eq!(outcome_json["storedState"], "not-applicable"); + } + } + } + + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn withdraw_consent_write_failure_maps_to_status_103_with_null_output() { + let dir = tempfile::tempdir().unwrap(); + let bogus_localappdata = dir.path().join("localappdata-file"); + std::fs::write(&bogus_localappdata, b"not a directory").unwrap(); + let _guard = + wxc_common::telemetry::test_support::TelemetryTestEnv::new(&bogus_localappdata); + + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_CONSENT_WRITE_FAILED); + assert!(out.is_null()); + } + + /// Verifies the export marshals a valid state string that the caller can + /// free. Policy semantics are tested in `wxc_common::telemetry::policy`. + #[test] + fn get_policy_returns_a_valid_state_string() { + let s = read_policy_string(); + #[cfg(target_os = "windows")] + assert!( + ["unrestricted", "allowed", "blocked"].contains(&s.as_str()), + "unexpected policy state {s:?}" + ); + #[cfg(not(target_os = "windows"))] + assert_eq!(s, "not-applicable"); + } + + /// Calls the export and returns the marshalled string, freeing the + /// allocation. Shared by the policy tests below. + fn read_policy_string() -> String { + let mut out: *mut c_char = ptr::null_mut(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_policy(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_policy`. + let s = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + unsafe { mxc_string_free(out) }; + s + } + + /// Drives every administrative policy state through a redirected registry + /// key and asserts the marshaled string. The redirect is debug-only. + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn get_policy_marshals_the_exact_state_for_each_registry_value() { + use wxc_common::telemetry::policy::test_support::PolicyKeyGuard; + + let guard = PolicyKeyGuard::new(); + // No value set: an unmanaged machine. + assert_eq!(read_policy_string(), "unrestricted"); + + guard.set_value(3); + assert_eq!(read_policy_string(), "allowed"); + + for blocked in [0u32, 1, 2, 99, u32::MAX] { + guard.set_value(blocked); + assert_eq!( + read_policy_string(), + "blocked", + "value {blocked} must marshal as blocked" + ); + } + + // A wrong-typed value is a policy we cannot evaluate: it must fail + // closed all the way out through the ABI, not read as unmanaged. + guard.set_string_value("0"); + assert_eq!(read_policy_string(), "blocked"); + } + + #[test] + fn get_policy_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_policy(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + // Failure paths must clear caller-owned output pointers before validation + // or other fallible work. + + /// Return an obvious sentinel `*mut c_char` value that isn't null and + /// isn't a legal allocation from this library, so an accidental free + /// would be caught. We only ever *read* the pointer's value; the exports + /// under test must overwrite it with null before any failure path. + fn stale_out_sentinel() -> *mut c_char { + // Use an aligned, obviously-invalid address. Never dereferenced. + 0xDEAD_BEEF_usize as *mut c_char + } + + #[test] + fn request_consent_null_presenter_clears_stale_out_pointer() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable; + // null presenter is explicitly rejected. + let status = + unsafe { mxc_telemetry_request_consent(ptr::null(), None, ptr::null_mut(), &mut out) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + assert!( + out.is_null(), + "failure path must leave *out_utf8 null, not the caller's sentinel" + ); + } + + #[test] + fn request_consent_invalid_utf8_locale_clears_stale_out_pointer() { + // A presenter that would panic if invoked. The invalid-UTF-8 locale + // must be rejected before we ever reach the presenter, so this + // proves the failure path both returns the right status *and* null + // the out-pointer. + unsafe extern "C" fn never_called_presenter( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + panic!("presenter must not be invoked when locale rejection precedes it"); + } + + // A raw non-UTF-8 byte sequence with a valid NUL terminator. + let bad_locale = [0xffu8, 0xfe, 0xfd, 0x00]; + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `bad_locale` is a NUL-terminated byte string; `out` is a + // valid writable pointer to a local variable. + let status = unsafe { + mxc_telemetry_request_consent( + bad_locale.as_ptr() as *const c_char, + Some(never_called_presenter), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_INVALID_UTF8); + assert!( + out.is_null(), + "invalid-UTF-8 failure path must leave *out_utf8 null" + ); + } + + #[test] + fn withdraw_consent_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_withdraw_consent(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + #[test] + fn get_consent_status_null_out_reports_null_argument() { + // SAFETY: null out-pointer is explicitly handled. + let status = unsafe { mxc_telemetry_get_consent_status(ptr::null_mut()) }; + assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); + } + + // The read-only exports also overwrite stale output pointers on success. + + #[test] + fn get_consent_overwrites_stale_out_pointer_on_success() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null(), "success path must produce a real string"); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent`. + unsafe { mxc_string_free(out) }; + } + + #[test] + fn get_policy_overwrites_stale_out_pointer_on_success() { + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_policy(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null(), "success path must produce a real string"); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_policy`. + unsafe { mxc_string_free(out) }; + } + + // Use a synthetic store so the snapshot is deterministic on Windows. + #[cfg(debug_assertions)] + #[test] + fn get_consent_status_returns_a_valid_json_snapshot() { + let _guard = TelemetryTestEnv::new("get_status"); + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_get_consent_status(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_get_consent_status`. + let json = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + // SAFETY: allocated by the export above. + unsafe { mxc_string_free(out) }; + + // The shape is fixed; the values depend on the platform. + assert!(json.get("storedState").is_some()); + assert!(json.get("effectiveState").is_some()); + assert!(json.get("policy").is_some()); + // `reason` is `Option<..>` and may be JSON null. + assert!(json.get("reason").is_some()); + + #[cfg(target_os = "windows")] + { + // A fresh synthetic env has no persisted record. + assert_eq!(json["storedState"], "undetermined"); + assert_eq!(json["effectiveState"], "undetermined"); + assert_eq!(json["reason"], "no-record"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(json["storedState"], "not-applicable"); + assert_eq!(json["effectiveState"], "not-applicable"); + assert_eq!(json["reason"], "not-applicable"); + assert_eq!(json["policy"], "not-applicable"); + } + } + + // Presenter failures are mapped to a backend error without persisting. + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn request_consent_presenter_error_maps_to_backend_error() { + let _guard = TelemetryTestEnv::new("presenter_error"); + unsafe extern "C" fn broken_presenter( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + MXC_TELEMETRY_CONSENT_PRESENTER_ERROR + } + + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: callback and out pointer remain valid for the call. + let status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(broken_presenter), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_BACKEND_ERROR); + assert!( + out.is_null(), + "presenter-error failure path must leave *out_utf8 null" + ); + } + + #[cfg(all(target_os = "windows", debug_assertions))] + #[test] + fn request_consent_invalid_decision_maps_to_backend_error() { + let _guard = TelemetryTestEnv::new("invalid_decision"); + unsafe extern "C" fn bad_decision( + _prompt_json_utf8: *const c_char, + _context: *mut c_void, + ) -> i32 { + // Neither of the four defined return values. + 42 + } + + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: callback and out pointer remain valid for the call. + let status = unsafe { + mxc_telemetry_request_consent( + ptr::null(), + Some(bad_decision), + ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(status, MXC_STATUS_BACKEND_ERROR); + assert!( + out.is_null(), + "invalid-decision failure path must leave *out_utf8 null" + ); + } + + /// Verify the withdrawal export returns its typed JSON outcome. + #[cfg(debug_assertions)] + #[test] + fn withdraw_consent_success_returns_typed_outcome() { + let _guard = TelemetryTestEnv::new("withdraw_success"); + let mut out: *mut c_char = stale_out_sentinel(); + // SAFETY: `out` is a valid writable pointer to a local variable. + let status = unsafe { mxc_telemetry_withdraw_consent(&mut out) }; + assert_eq!(status, MXC_STATUS_SUCCESS); + assert!(!out.is_null()); + // SAFETY: `out` was just allocated by `mxc_telemetry_withdraw_consent`. + let json = unsafe { CStr::from_ptr(out) }.to_str().unwrap().to_string(); + let json: serde_json::Value = serde_json::from_str(&json).unwrap(); + // SAFETY: allocated by the export above. + unsafe { mxc_string_free(out) }; + + #[cfg(target_os = "windows")] + { + assert_eq!(json["result"], "withdrawn"); + assert_eq!(json["effectiveState"], "denied"); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(json["result"], "notApplicable"); + assert_eq!(json["effectiveState"], "not-applicable"); + } + } } diff --git a/src/ffi/mxc_ffi/src/request.rs b/src/ffi/mxc_ffi/src/request.rs index ca9ab5f77..bd3f1cbff 100644 --- a/src/ffi/mxc_ffi/src/request.rs +++ b/src/ffi/mxc_ffi/src/request.rs @@ -10,15 +10,15 @@ use mxc_sdk::configs::{ ProcessContainerUi, ProcessContainerUiIsolation, }; use mxc_sdk::{ - build_request_with_containment, Containment, Error, ErrorCode, SandboxPolicy, SandboxRequest, - WslcSection, + build_request_with_containment, Containment, Error, ErrorCode, SandboxRequest, WslcSection, }; -use serde_json::Value; +use serde_json::{value::RawValue, Value}; #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RequestSpec { - policy: SandboxPolicy, +struct RequestSpec<'a> { + #[serde(borrow)] + policy: &'a RawValue, command: String, #[serde(default)] containment: RequestContainment, @@ -219,12 +219,11 @@ impl ProcessContainerNetworkSpec { /// Parse a binding request and build the public Rust SDK request it describes. pub(crate) fn build_request_from_json(request_json: &str) -> Result { - let value: Value = serde_json::from_str(request_json).map_err(malformed_request)?; - if value - .get("policy") - .and_then(|policy| policy.get("captureDenials")) - .is_some() - { + let spec: RequestSpec<'_> = serde_json::from_str(request_json).map_err(malformed_request)?; + let parsed = mxc_sdk::ffi_internals::parse_policy_json(spec.policy.get()) + .map_err(|error| Error::new(ErrorCode::MalformedRequest, error))?; + let policy_value: Value = serde_json::from_str(spec.policy.get()).map_err(malformed_request)?; + if policy_value.get("captureDenials").is_some() { return Err(Error::new( ErrorCode::MalformedRequest, "policy.captureDenials is not supported; set containment.type to \ @@ -232,17 +231,22 @@ pub(crate) fn build_request_from_json(request_json: &str) -> Result 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 +516,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 +534,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 +557,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 +584,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 + }) } // --------------------------------------------------------------------------- @@ -580,7 +608,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 @@ -620,7 +651,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. @@ -659,7 +693,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 @@ -684,6 +721,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 +745,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> { @@ -732,6 +780,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,7 +810,10 @@ pub unsafe extern "C" fn mxc_sandbox_wait( Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_wait", &*panic); + MXC_STATUS_PANIC + }) } /// Kill the child and its whole process tree. Reaping happens in a subsequent @@ -776,7 +834,10 @@ pub unsafe extern "C" fn mxc_sandbox_kill(handle: *mut MxcSandbox) -> i32 { Err(_) => MXC_STATUS_BACKEND_ERROR, } })); - status.unwrap_or(MXC_STATUS_PANIC) + status.unwrap_or_else(|panic| { + crate::report_panic("mxc_sandbox_kill", &*panic); + MXC_STATUS_PANIC + }) } // --------------------------------------------------------------------------- @@ -794,11 +855,13 @@ pub unsafe extern "C" fn mxc_sandbox_free(handle: *mut MxcSandbox) { if handle.is_null() { return; } - let _ = catch_unwind(AssertUnwindSafe(|| { + if let Err(panic) = catch_unwind(AssertUnwindSafe(|| { // SAFETY: non-null handle produced by `Box::into_raw` in `mxc_spawn`, // not yet freed; reconstructing the Box drops it (and its child). drop(unsafe { Box::from_raw(handle) }); - })); + })) { + crate::report_panic("mxc_sandbox_free", &*panic); + } } /// Free a readable stream handle. Safe to call with null (no-op). Must be @@ -812,10 +875,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 +894,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 +913,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,6 +943,40 @@ mod tests { } use std::ffi::CString; + use crate::MXC_STATUS_MALFORMED_REQUEST; + + struct PanicReader; + + impl Read for PanicReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + panic!("forced panic in test reader"); + } + } + + struct PanicWriter; + + impl Write for PanicWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + panic!("forced panic in test writer"); + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + struct PanicFlushWriter; + + impl Write for PanicFlushWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Ok(0) + } + + fn flush(&mut self) -> std::io::Result<()> { + panic!("forced panic in test flush"); + } + } + #[test] fn spawn_null_out_handle_is_null_argument() { let policy = CString::new(r#"{"version":"0.7.0-alpha"}"#).unwrap(); @@ -890,6 +993,29 @@ mod tests { assert_eq!(status, MXC_STATUS_NULL_ARGUMENT); } + #[test] + fn build_spawn_request_propagates_telemetry_enablement() { + for (policy_json, expected) in [ + (r#"{"version":"0.8.0-alpha"}"#, None), + ( + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":true}}"#, + Some(true), + ), + ( + r#"{"version":"0.9.0-alpha","telemetry":{"enabled":false}}"#, + Some(false), + ), + ] { + let request = build_spawn_request(policy_json, "echo hi") + .unwrap_or_else(|_| panic!("build_spawn_request failed for {policy_json}")); + assert_eq!( + request.telemetry_enabled(), + expected, + "policy: {policy_json}" + ); + } + } + #[test] fn spawn_null_policy_reports_null_argument() { let command = CString::new("echo hi").unwrap(); @@ -1006,6 +1132,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 @@ -1144,13 +1308,14 @@ mod tests { assert_eq!(status, MXC_STATUS_SUCCESS, "spawn failed (status {status})"); // Child should still be running. - let mut exit = 0; - let mut running = 0; + let mut exit = i32::MIN; + let mut running = -1; // SAFETY: live handle + out pointers. let 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.