diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 2e68a19a8f..80e5c8332b 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -216,6 +216,13 @@ jobs: throw "Windows app host was not produced at unigetui_bin/UniGetUI.exe" } + # The elevated policy-write helper is authenticated by exact path at runtime, and it must + # be present here so the code-signing step below signs it and the integrity tree that is + # generated afterwards covers it. + if (-not (Test-Path "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe")) { + throw "Elevated policy helper was not staged at unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe" + } + $MaxShippedPdbSizeBytes = 1MB $PdbsToRemove = Get-ChildItem "unigetui_bin" -Filter "*.pdb" -File -Recurse | Where-Object { $_.Length -gt $MaxShippedPdbSizeBytes @@ -247,6 +254,14 @@ jobs: -CertificateName '${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}' ` -TimestampServer '${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}' + # The helper is the one binary whose signature is checked at runtime by the host before + # it is elevated, so an unsigned helper must fail the release rather than ship. + $HelperPath = Join-Path $PWD "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe" + $HelperSignature = Get-AuthenticodeSignature $HelperPath + if ($HelperSignature.Status -ne "Valid") { + throw "Elevated policy helper is not validly signed (status: $($HelperSignature.Status))." + } + - name: Build installer shell: pwsh run: | diff --git a/UniGetUI.iss b/UniGetUI.iss index 4f83449097..9b2b1edfe8 100644 --- a/UniGetUI.iss +++ b/UniGetUI.iss @@ -130,6 +130,8 @@ begin // Elevator (gsudo cache) and pinget live in {app} and lock their own files. TaskKillWait('UniGetUI Elevator.exe'); TaskKillWait('pinget.exe'); + // The elevated policy helper is short-lived, but it lives in {app} and can hold a file lock. + TaskKillWait('UniGetUI.PolicyElevator.exe'); Sleep(1000); // let the OS release file handles before copying end; @@ -346,3 +348,9 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--migrate-wingetui-to-unigetui"; Filename: {sys}\taskkill.exe; Parameters: "/f /im WingetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillWingetUI" Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUI" Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.Avalonia.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIAvalonia" +Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.PolicyElevator.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIPolicyElevator" + +[UninstallDelete] +; The elevated policy helper is authenticated by exact path, so a leftover copy must never +; survive an uninstall. +Type: files; Name: "{app}\Assets\Utilities\UniGetUI.PolicyElevator.exe" diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 3d348921cd..e1164b4ba8 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -96,6 +96,13 @@ if (-not (Test-Path $WindowsAppHostPath)) { throw "Windows app host was not produced at $WindowsAppHostPath" } +# The elevated policy-write helper is authenticated by exact path at runtime, so a missing or +# misplaced helper must fail the build rather than silently ship an install that cannot elevate. +$PolicyElevatorPath = Join-Path $BinDir "Assets\Utilities\UniGetUI.PolicyElevator.exe" +if (-not (Test-Path $PolicyElevatorPath)) { + throw "Elevated policy helper was not staged at $PolicyElevatorPath" +} + # Keep smaller symbols for useful local crash source information, and prune oversized ones. $MaxShippedPdbSizeBytes = 1MB diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 7a21f3e8be..94a0d1b0d4 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1052,5 +1052,199 @@ "The custom operation {0} failed to run": "The custom operation {0} failed to run", "Environment variables use %VARIABLE% syntax.": "Environment variables use %VARIABLE% syntax.", "Environment variables use syntax.": "Environment variables use syntax.", - "Change environment variable syntax": "Change environment variable syntax" + "Change environment variable syntax": "Change environment variable syntax", + "Agent broker unavailable": "Agent broker unavailable", + "Loading policy management state": "Loading policy management state", + "Your organization": "Your organization", + "Policy management is unsupported": "Policy management is unsupported", + "The installed Devolutions Agent is reachable but does not support policy management. Update the Agent and try again.": "The installed Devolutions Agent is reachable but does not support policy management. Update the Agent and try again.", + "Access to policy management was denied": "Access to policy management was denied", + "Devolutions Agent did not authorize UniGetUI to manage the package policy.": "Devolutions Agent did not authorize UniGetUI to manage the package policy.", + "The policy management response is invalid": "The policy management response is invalid", + "Devolutions Agent returned a malformed or incompatible policy management response.": "Devolutions Agent returned a malformed or incompatible policy management response.", + "Policy management is available on Windows only": "Policy management is available on Windows only", + "This page cannot manage the policy file through the Windows Devolutions Agent service on the current platform.": "This page cannot manage the policy file through the Windows Devolutions Agent service on the current platform.", + "The configured policy path is unsafe": "The configured policy path is unsafe", + "Devolutions Agent refused to manage the configured policy path because it is considered unsafe (for example, a path traversal or reparse point).": "Devolutions Agent refused to manage the configured policy path because it is considered unsafe (for example, a path traversal or reparse point).", + "The policy file format is unsupported": "The policy file format is unsupported", + "Devolutions Agent reported that the configured policy file format is not supported for management.": "Devolutions Agent reported that the configured policy file format is not supported for management.", + "The policy file system is unsupported": "The policy file system is unsupported", + "Devolutions Agent reported that the file system hosting the configured policy path is not supported for management.": "Devolutions Agent reported that the file system hosting the configured policy path is not supported for management.", + "The policy management state is unavailable": "The policy management state is unavailable", + "Devolutions Agent supports policy management but could not provide the current state. Review the Agent configuration and try again.": "Devolutions Agent supports policy management but could not provide the current state. Review the Agent configuration and try again.", + "Not applicable": "Not applicable", + "Note": "Note", + "Additional findings were omitted.": "Additional findings were omitted.", + "Policy management is active": "Policy management is active", + "A valid policy file is configured and in effect.": "A valid policy file is configured and in effect.", + "No policy file is configured": "No policy file is configured", + "Create a new policy file to start enforcing package broker rules.": "Create a new policy file to start enforcing package broker rules.", + "The configured policy file is invalid": "The configured policy file is invalid", + "Review the diagnostics below and repair the policy file.": "Review the diagnostics below and repair the policy file.", + "The policy management state is invalid": "The policy management state is invalid", + "Devolutions Agent returned an unrecognized policy management state.": "Devolutions Agent returned an unrecognized policy management state.", + "{0} ({1})": "{0} ({1})", + "Edit policy '{0}'": "Edit policy '{0}'", + "Replace active policy identity": "Replace active policy identity", + "Create a new package broker policy": "Create a new package broker policy", + "Repair the stored package broker policy": "Repair the stored package broker policy", + "Package broker policy editor": "Package broker policy editor", + "Working…": "Working…", + "Contacting Devolutions Agent.": "Contacting Devolutions Agent.", + "Policy saved": "Policy saved", + "The package broker policy was saved successfully.": "The package broker policy was saved successfully.", + "The document is not valid JSON": "The document is not valid JSON", + "The policy could not be saved": "The policy could not be saved", + "The policy changed since you started editing": "The policy changed since you started editing", + "Review your changes, then choose Overwrite to save anyway.": "Review your changes, then choose Overwrite to save anyway.", + "Validation found errors": "Validation found errors", + "Validation found warnings": "Validation found warnings", + "Review the findings below before saving.": "Review the findings below before saving.", + "The elevation prompt was dismissed. No changes were saved.": "The elevation prompt was dismissed. No changes were saved.", + "The elevated helper could not be started.": "The elevated helper could not be started.", + "The elevated helper could not be authenticated.": "The elevated helper could not be authenticated.", + "Communication with the elevated helper failed.": "Communication with the elevated helper failed.", + "The elevated helper stopped unexpectedly.": "The elevated helper stopped unexpectedly.", + "Devolutions Agent rejected the policy replacement.": "Devolutions Agent rejected the policy replacement.", + "The save failed ({0}).": "The save failed ({0}).", + "The save failed.": "The save failed.", + "Rule: {0}": "Rule: {0}", + "(untitled)": "(untitled)", + "Policy management": "Policy management", + "Refresh policy management state": "Refresh policy management state", + "State": "State", + "Configured path": "Configured path", + "Path source": "Path source", + "Write capability": "Write capability", + "Read-only reason": "Read-only reason", + "Elevation required": "Elevation required", + "Edit": "Edit", + "Edit the active policy": "Edit the active policy", + "Create": "Create", + "Create a new policy": "Create a new policy", + "Repair": "Repair", + "Repair the stored policy": "Repair the stored policy", + "Replace identity": "Replace identity", + "Replace the active policy identity": "Replace the active policy identity", + "Diagnostics": "Diagnostics", + "Environment variables in custom install options": "Environment variables in custom install options", + "Use %VARIABLE% syntax for environment variables in custom install arguments and locations": "Use %VARIABLE% syntax for environment variables in custom install arguments and locations", + "By default UniGetUI expands tokens, which cannot appear in a real path. Enabling this uses the Windows %VARIABLE% syntax instead, which may unexpectedly expand folder names that contain a variable name such as %TEMP%.": "By default UniGetUI expands tokens, which cannot appear in a real path. Enabling this uses the Windows %VARIABLE% syntax instead, which may unexpectedly expand folder names that contain a variable name such as %TEMP%.", + "Save policy with warnings?": "Save policy with warnings?", + "Save anyway": "Save anyway", + "Replace the active policy?": "Replace the active policy?", + "Replace": "Replace", + "Create a new policy?": "Create a new policy?", + "Repair the stored policy?": "Repair the stored policy?", + "Overwrite": "Overwrite", + "Discard unsaved changes?": "Discard unsaved changes?", + "Confirm": "Confirm", + "Validation reported {0} warning(s) for policy '{1}'. Do you want to save it anyway?": "Validation reported {0} warning(s) for policy '{1}'. Do you want to save it anyway?", + "This will replace the active policy '{0}' with a new policy '{1}'. This cannot be undone.": "This will replace the active policy '{0}' with a new policy '{1}'. This cannot be undone.", + "This will create a new package broker policy '{0}'.": "This will create a new package broker policy '{0}'.", + "The stored policy file is invalid and will be replaced with '{0}'.": "The stored policy file is invalid and will be replaced with '{0}'.", + "The policy '{0}' was modified elsewhere since you started editing. Overwrite it with your changes?": "The policy '{0}' was modified elsewhere since you started editing. Overwrite it with your changes?", + "You have unsaved changes to policy '{0}'. Discard them?": "You have unsaved changes to policy '{0}'. Discard them?", + "Do you want to continue?": "Do you want to continue?", + "Structured": "Structured", + "Switch to the structured editor": "Switch to the structured editor", + "Raw JSON": "Raw JSON", + "Switch to the raw JSON editor": "Switch to the raw JSON editor", + "Valid from (optional ISO-8601)": "Valid from (optional ISO-8601)", + "Valid until (optional ISO-8601)": "Valid until (optional ISO-8601)", + "Rules are always evaluated by priority; ties are broken toward Deny. This precedence is fixed and cannot be changed.": "Rules are always evaluated by priority; ties are broken toward Deny. This precedence is fixed and cannot be changed.", + "Add rule": "Add rule", + "This policy has no rules yet.": "This policy has no rules yet.", + "Rule enabled": "Rule enabled", + "Duplicate": "Duplicate", + "Duplicate rule": "Duplicate rule", + "Move up": "Move up", + "Move rule up": "Move rule up", + "Move down": "Move down", + "Move rule down": "Move rule down", + "Delete": "Delete", + "Delete rule": "Delete rule", + "Operations (optional)": "Operations (optional)", + "Package managers (optional)": "Package managers (optional)", + "Scopes (optional)": "Scopes (optional)", + "Architectures (optional)": "Architectures (optional)", + "Elevation (optional)": "Elevation (optional)", + "Sources (one per line; optional)": "Sources (one per line; optional)", + "Package identifiers (one per line; optional)": "Package identifiers (one per line; optional)", + "Package names (one per line; optional)": "Package names (one per line; optional)", + "Versions (one per line; optional)": "Versions (one per line; optional)", + "Restrict to a version range": "Restrict to a version range", + "Minimum version": "Minimum version", + "Maximum version": "Maximum version", + "Include prerelease versions": "Include prerelease versions", + "This rule defines constraints": "This rule defines constraints", + "Allow interactive installs": "Allow interactive installs", + "Allow skipping the hash check": "Allow skipping the hash check", + "Allow prerelease packages": "Allow prerelease packages", + "Allow a custom install location": "Allow a custom install location", + "Allow uninstalling a previous version": "Allow uninstalling a previous version", + "Allow upgrades": "Allow upgrades", + "Edit the canonical policy document as JSON. Switching back to the structured editor requires the document to parse and pass validation.": "Edit the canonical policy document as JSON. Switching back to the structured editor requires the document to parse and pass validation.", + "Raw policy JSON": "Raw policy JSON", + "Validation findings": "Validation findings", + "Validate": "Validate", + "Validate policy": "Validate policy", + "Overwrite the policy that changed elsewhere": "Overwrite the policy that changed elsewhere", + "Save policy": "Save policy", + "Close policy editor": "Close policy editor", + "No reason provided": "No reason provided", + "Operation denied by policy": "Operation denied by policy", + "Operation failed via broker": "Operation failed via broker", + "The broker accepted the request but did not report an operation to track.": "The broker accepted the request but did not report an operation to track.", + "The operation did not finish within the allotted time. It may still be running on the agent.": "The operation did not finish within the allotted time. It may still be running on the agent.", + "Operation denied or failed via broker": "Operation denied or failed via broker", + "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running.": "The Devolutions Agent broker is not available. The operation cannot be performed. Please ensure the Devolutions Agent is installed and running.", + "A boolean match must be omitted, true, or false; mixed arrays are invalid.": "A boolean match must be omitted, true, or false; mixed arrays are invalid.", + "A policy field has an invalid value.": "A policy field has an invalid value.", + "A policy field has the wrong value type.": "A policy field has the wrong value type.", + "A wildcard pattern is invalid.": "A wildcard pattern is invalid.", + "An enabled Allow rule permits a sensitive option.": "An enabled Allow rule permits a sensitive option.", + "An enabled Allow rule permits custom command-line parameters.": "An enabled Allow rule permits custom command-line parameters.", + "An enabled Allow rule permits custom install locations.": "An enabled Allow rule permits custom install locations.", + "An enabled Allow rule permits killing processes before an operation.": "An enabled Allow rule permits killing processes before an operation.", + "An enabled Allow rule permits pre-operation or post-operation commands.": "An enabled Allow rule permits pre-operation or post-operation commands.", + "An enabled Allow rule permits prerelease package versions.": "An enabled Allow rule permits prerelease package versions.", + "An enabled Allow rule permits skipping package hash verification.": "An enabled Allow rule permits skipping package hash verification.", + "An enabled Allow rule permits uninstalling a previous version.": "An enabled Allow rule permits uninstalling a previous version.", + "Audit mode is enabled; decisions are logged but not enforced.": "Audit mode is enabled; decisions are logged but not enforced.", + "Configured policy path": "Configured policy path", + "Correct the highlighted fields": "Correct the highlighted fields", + "Devolutions Agent reported an unrecognized policy finding.": "Devolutions Agent reported an unrecognized policy finding.", + "Enter a valid ISO 8601 date and time.": "Enter a valid ISO 8601 date and time.", + "Enter a whole number from 0 through 4294967295.": "Enter a whole number from 0 through 4294967295.", + "Policy management state": "Policy management state", + "Policy operation in progress": "Policy operation in progress", + "Policy path source": "Policy path source", + "Policy read-only reason": "Policy read-only reason", + "Policy write capability": "Policy write capability", + "Policy write elevation requirement": "Policy write elevation requirement", + "Raw JSON syntax error": "Raw JSON syntax error", + "Restrictions: {0}": "Restrictions: {0}", + "Rule IDs must be unique.": "Rule IDs must be unique.", + "The default decision is Allow; requests matching no rule are permitted.": "The default decision is Allow; requests matching no rule are permitted.", + "The policy draft contains an unknown field.": "The policy draft contains an unknown field.", + "The policy draft does not match the required JSON schema.": "The policy draft does not match the required JSON schema.", + "The policy draft is missing a required field.": "The policy draft is missing a required field.", + "The policy schema is unsupported.": "The policy schema is unsupported.", + "The policy type is unsupported.": "The policy type is unsupported.", + "The policy validity interval is invalid.": "The policy validity interval is invalid.", + "The policy version is unsupported.": "The policy version is unsupported.", + "The rule contains contradictory constraints.": "The rule contains contradictory constraints.", + "The version range does not restrict any versions.": "The version range does not restrict any versions.", + "The version range is invalid.": "The version range is invalid.", + "Please wait for the current policy operation to finish before closing.": "Please wait for the current policy operation to finish before closing.", + "The current policy operation could not be canceled in time. Please wait, then try closing again.": "The current policy operation could not be canceled in time. Please wait, then try closing again.", + "The active policy '{0}' changed since editing began. Overwrite that exact current version with your changes?": "The active policy '{0}' changed since editing began. Overwrite that exact current version with your changes?", + "The policy store now contains active policy '{0}'. Replace it with the different policy identity '{1}'?": "The policy store now contains active policy '{0}'. Replace it with the different policy identity '{1}'?", + "The policy store is now missing. Create policy '{0}' against that exact current state?": "The policy store is now missing. Create policy '{0}' against that exact current state?", + "The policy store is now invalid. Replace it with repaired policy '{0}' against that exact current state?": "The policy store is now invalid. Replace it with repaired policy '{0}' against that exact current state?", + "The policy was saved, but newer draft changes remain unsaved.": "The policy was saved, but newer draft changes remain unsaved.", + "Policy saved; newer changes remain": "Policy saved; newer changes remain", + "{0} additional validation finding(s) were omitted.": "{0} additional validation finding(s) were omitted.", + "{0}. Location: {1}": "{0}. Location: {1}" } diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs new file mode 100644 index 0000000000..07824183e8 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Client; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; + +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +/// +/// Turns the single broker replacement call into the bounded response frame contract. +/// +internal static class PolicyReplacementExecutor +{ + public static async Task ExecuteAsync( + PolicyElevationRequestMessage request, + CancellationToken cancellationToken) + { + var response = new PolicyElevationResponseMessage + { + ProtocolVersion = PolicyElevationProtocol.Version, + RequestId = request.RequestId, + }; + + try + { + using var client = CreateClient(); + + PolicyReplacementResponse replacement = await client.ReplacePolicy( + new PolicyReplacementRequest + { + Draft = request.Draft, + Operation = (PolicyReplacementOperation)request.Operation, + ConflictHandling = (PolicyConflictHandling)request.ConflictHandling, + ExpectedStoreToken = request.ExpectedStoreToken, + ValidationReceipt = request.ValidationReceipt, + WarningsAcknowledged = request.WarningsAcknowledged, + }, + cancellationToken).ConfigureAwait(false); + + response.Outcome = PolicyElevationResponseStatus.Replaced; + response.Payload = SerializePayload(replacement); + return response; + } + catch (BrokerClientException ex) + { + response.Outcome = ex.Kind switch + { + BrokerClientErrorKind.BrokerUnavailable => PolicyElevationResponseStatus.BrokerUnavailable, + BrokerClientErrorKind.Timeout => PolicyElevationResponseStatus.BrokerUnavailable, + BrokerClientErrorKind.EmptyResponse => PolicyElevationResponseStatus.BrokerInvalidResponse, + BrokerClientErrorKind.InvalidResponse => PolicyElevationResponseStatus.BrokerInvalidResponse, + _ => PolicyElevationResponseStatus.BrokerRejected, + }; + + response.BrokerStatusCode = ex.StatusCode; + response.BrokerErrorCode = Truncate( + ex.BrokerError?.Code.ToString() ?? ex.Kind.ToString(), + PolicyElevationProtocol.MaxBrokerErrorCodeCharacters); + response.Message = "The Agent rejected the policy write."; + response.Payload = ex.BrokerError is null + ? null + : SerializePayload(ex.BrokerError); + return response; + } + catch (OperationCanceledException) + { + response.Outcome = PolicyElevationResponseStatus.BrokerUnavailable; + response.Message = "The broker did not answer before the elevated helper timed out."; + return response; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException) + { + response.Outcome = PolicyElevationResponseStatus.BrokerInvalidResponse; + response.Message = "The Agent returned an invalid policy response."; + return response; + } + } + + public static PolicyElevationResponseMessage Rejected(string requestId, string reason) + => new() + { + ProtocolVersion = PolicyElevationProtocol.Version, + RequestId = requestId, + Outcome = PolicyElevationResponseStatus.HelperRejected, + Message = Truncate(reason, PolicyElevationProtocol.MaxMessageCharacters), + }; + + private static BrokerClient CreateClient() + => new(new BrokerClientOptions + { + RequestedElevation = Elevation.Elevated, + EffectiveUser = GetEffectiveUser(), + ClientExecutablePath = Environment.ProcessPath, + ClientVersion = typeof(PolicyReplacementExecutor).Assembly.GetName().Version?.ToString(), + }); + + private static string GetEffectiveUser() + => string.IsNullOrWhiteSpace(Environment.UserDomainName) + ? Environment.UserName + : $"{Environment.UserDomainName}\\{Environment.UserName}"; + + private static JsonElement SerializePayload(T payload) + { + using JsonDocument document = JsonDocument.Parse(BrokerJson.Serialize(payload)); + return document.RootElement.Clone(); + } + + private static string? Truncate(string? value, int maxCharacters) + { + if (value is null) + { + return null; + } + + return value.Length <= maxCharacters ? value : value[..maxCharacters]; + } +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/Program.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/Program.cs new file mode 100644 index 0000000000..6c4f1b67fb --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/Program.cs @@ -0,0 +1,268 @@ +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation.Interop; + +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +/// +/// The elevated policy-write helper. +/// +/// +/// +/// This process is started by a non-elevated UniGetUI through ShellExecuteEx with the +/// runas verb, so it runs with a full administrator token. Its command line carries routing +/// information only — a pipe name, the caller's process id, the caller's process creation time and +/// the logon session. The policy draft, the store token, the validation receipt and every other +/// piece of request state travel exclusively over the authenticated pipe, and no temporary file is +/// ever used. +/// +/// +/// The helper handles exactly one connection, reads exactly one request, writes exactly one +/// response and exits. It performs its half of the mutual authentication before reading a single +/// byte of payload, and it connects with an anonymous impersonation level so a rogue pipe cannot +/// borrow its elevated token. +/// +/// +internal static class Program +{ + private static async Task Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + return PolicyElevationProtocol.ExitInvalidArguments; + } + + if (!PolicyElevationLaunchArguments.TryParse(args, out PolicyElevationLaunchArguments? launch, out _)) + { + return PolicyElevationProtocol.ExitInvalidArguments; + } + + using var lifetime = new CancellationTokenSource(PolicyElevationProtocol.ExchangeTimeout); + + try + { + return await RunAsync(launch, lifetime.Token).ConfigureAwait(false); + } + catch (PolicyElevationFrameException) + { + return PolicyElevationProtocol.ExitProtocolError; + } + catch (OperationCanceledException) + { + return PolicyElevationProtocol.ExitConnectFailed; + } + catch (IOException) + { + return PolicyElevationProtocol.ExitConnectFailed; + } + catch (Exception) + { + return PolicyElevationProtocol.ExitUnexpectedFailure; + } + } + + private static async Task RunAsync( + PolicyElevationLaunchArguments launch, + CancellationToken cancellationToken) + { + IPolicyElevationTrustVerifier trustVerifier = new WindowsAuthenticodeTrustVerifier(); + + if (!TryDescribePackagedLayout( + out string? installRoot, + out string? hostPath, + out string? selfPath, + out PolicyElevationLocationVerification? verification) + || installRoot is null || hostPath is null || selfPath is null || verification is null) + { + verification?.Dispose(); + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + // Held for the whole exchange: while these handles are open neither the install tree nor + // either packaged binary can be deleted, renamed or redirected. + using PolicyElevationLocationVerification layout = verification; + + using SafeProcessHandle host = PolicyElevationNative.OpenProcess( + PolicyElevationNative.ProcessQueryLimitedInformation | PolicyElevationNative.Synchronize, + false, + unchecked((uint)launch.ParentProcessId)); + + if (host.IsInvalid) + { + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + var expectation = new PolicyElevationPeerExpectation( + hostPath, + installRoot, + unchecked((uint)launch.ParentProcessId), + launch.ParentCreationTimeUtcTicks, + launch.SessionId) + { + // The caller is deliberately the non-elevated side of the channel. + RequireElevatedAdministrator = false, + Verification = layout, + }; + + // Verified once from the launch arguments, before the pipe is touched at all. + if (!WindowsPeerAuthenticator + .Authenticate( + host.DangerousGetHandle(), + expectation.ExpectedProcessId, + expectation, + trustVerifier, + selfPath) + .IsAuthenticated) + { + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + using var pipe = new NamedPipeClientStream( + ".", + launch.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + TokenImpersonationLevel.Anonymous); + + try + { + await pipe.ConnectAsync((int)PolicyElevationProtocol.ConnectTimeout.TotalMilliseconds, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is TimeoutException or IOException or UnauthorizedAccessException) + { + return PolicyElevationProtocol.ExitConnectFailed; + } + + // Verified again from the kernel's view of the connected pipe, before any payload moves. + if (!PolicyElevationNative.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId)) + { + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + if (!WindowsPeerAuthenticator + .Authenticate(host.DangerousGetHandle(), serverProcessId, expectation, trustVerifier, selfPath) + .IsAuthenticated) + { + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + PolicyElevationRequestMessage request = + await PolicyElevationFrame.ReadRequestAsync(pipe, cancellationToken).ConfigureAwait(false); + + using var brokerCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var disconnectMonitorCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task disconnectMonitor = MonitorHostDisconnectAsync( + pipe, + brokerCancellation, + disconnectMonitorCancellation.Token); + + PolicyElevationResponseMessage response; + try + { + response = await PolicyReplacementExecutor + .ExecuteAsync(request, brokerCancellation.Token) + .ConfigureAwait(false); + } + finally + { + await disconnectMonitorCancellation.CancelAsync().ConfigureAwait(false); + try + { + await disconnectMonitor.ConfigureAwait(false); + } + catch (OperationCanceledException) when (disconnectMonitorCancellation.IsCancellationRequested) + { + } + } + + // WriteResponseAsync completes only once the whole frame has been handed to the pipe and + // flushed, under the same bounded, cancellable token as every other stage. Closing the + // handle afterwards is enough: a synchronous drain would block on the reader with no + // timeout and no cancellation, which is exactly the unbounded hang this design forbids. + await PolicyElevationFrame.WriteResponseAsync(pipe, response, cancellationToken).ConfigureAwait(false); + + return PolicyElevationProtocol.ExitSuccess; + } + + private static async Task MonitorHostDisconnectAsync( + NamedPipeClientStream pipe, + CancellationTokenSource brokerCancellation, + CancellationToken cancellationToken) + { + byte[] unexpectedData = new byte[1]; + try + { + int read = await pipe.ReadAsync(unexpectedData, cancellationToken).ConfigureAwait(false); + if (!cancellationToken.IsCancellationRequested) + { + await brokerCancellation.CancelAsync().ConfigureAwait(false); + } + } + catch (IOException) + { + await brokerCancellation.CancelAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + /// + /// Confirms this process really is the packaged helper, and derives both the install root the + /// host must also live in and this process' own canonical image, which the mutual signer + /// binding needs. The returned verification holds kernel handles to every verified object and + /// must stay alive for the whole exchange. + /// + private static bool TryDescribePackagedLayout( + out string? installRoot, + out string? hostPath, + out string? selfImagePath, + out PolicyElevationLocationVerification? verification) + { + installRoot = null; + hostPath = null; + selfImagePath = null; + verification = null; + + string? selfPath = WindowsProcessInspector.TryGetCurrentProcessCanonicalPath(); + if (selfPath is null + || !PolicyElevationPaths.TryGetInstallRootFromHelperPath(selfPath, out string? root) + || root is null) + { + return false; + } + + string? canonicalHostPath = WindowsProcessInspector.TryGetCanonicalPath( + PolicyElevationPaths.GetHostPath(root)); + + if (canonicalHostPath is null) + { + return false; + } + + // Always handle-verified: this process is about to perform a machine-wide policy write, so + // the packaged layout it was launched from has to be provably administrator-protected. + PolicyElevationLocationVerification verified = + new WindowsProtectedLocationVerifier().Verify(root, selfPath, canonicalHostPath); + + if (!verified.IsProtected + || !WindowsProcessInspector.PathsAreEqual(verified.CanonicalHelperPath, selfPath) + || !WindowsProcessInspector.PathsAreEqual(verified.CanonicalHostPath, canonicalHostPath) + || !WindowsProcessInspector.PathsAreEqual(verified.CanonicalInstallRoot, root)) + { + verified.Dispose(); + return false; + } + + verification = verified; + installRoot = root; + hostPath = canonicalHostPath; + selfImagePath = selfPath; + return true; + } +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/UniGetUI.AgentPolicy.ElevatedHelper.csproj b/src/UniGetUI.AgentPolicy.ElevatedHelper/UniGetUI.AgentPolicy.ElevatedHelper.csproj new file mode 100644 index 0000000000..562244247b --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/UniGetUI.AgentPolicy.ElevatedHelper.csproj @@ -0,0 +1,51 @@ + + + + + + $(WindowsTargetFramework) + Exe + UniGetUI.PolicyElevator + UniGetUI.AgentPolicy.ElevatedHelper + app.manifest + win-x64;win-arm64 + true + true + true + en + true + true + false + true + full + false + true + true + false + false + false + false + + + + + + + + + + + + + + diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/app.manifest b/src/UniGetUI.AgentPolicy.ElevatedHelper/app.manifest new file mode 100644 index 0000000000..8a0c06b8f9 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/app.manifest @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + true + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 9beb29fed0..d7d9ce4d2f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -115,6 +115,11 @@ private sealed record Entry( new("Ask only once for administrator privileges", ["admin once", "cache admin rights"], typeof(Administrator), "CacheAdminOnceCard"), new("Prohibit any kind of Elevation via UniGetUI Elevator or GSudo", ["prohibit elevation", "no elevation"], typeof(Administrator), "ProhibitElevationCard"), new("Inspect active package broker policy", ["policy", "package broker", "devolutions agent", "rules", "enforcement"], typeof(AgentPolicyInspector), null, WindowsOnly: true), + new("Policy management", ["policy management", "policy state", "active", "missing", "invalid", "configured path"], typeof(AgentPolicyInspector), "PolicyManagementHeading", WindowsOnly: true), + new("Edit the active policy", ["edit policy", "policy editor"], typeof(AgentPolicyInspector), "EditPolicyButton", WindowsOnly: true), + new("Create a new policy", ["create policy", "new policy"], typeof(AgentPolicyInspector), "CreatePolicyButton", WindowsOnly: true), + new("Repair the stored policy", ["repair policy", "invalid policy", "fix policy"], typeof(AgentPolicyInspector), "RepairPolicyButton", WindowsOnly: true), + new("Replace the active policy identity", ["replace identity", "replace policy"], typeof(AgentPolicyInspector), "ReplaceIdentityButton", WindowsOnly: true), new("Allow custom command-line arguments", ["command line arguments", "cli arguments"], typeof(Administrator), "AdminRestrictionsOpsCard"), new("Ignore custom pre-install and post-install commands when importing packages from a bundle", ["pre-install commands", "post-install commands"], typeof(Administrator), "PrePostCommandCard"), new("Allow changing the paths for package manager executables", ["manager paths", "executable path"], typeof(Administrator), "AdminManagerPathsCard"), diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 471725a2f1..86abd152a9 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -157,9 +157,62 @@ /> + + + <_PolicyHelperProject>$(MSBuildThisFileDirectory)..\UniGetUI.AgentPolicy.ElevatedHelper\UniGetUI.AgentPolicy.ElevatedHelper.csproj + <_PolicyHelperPlatform Condition="'$(RuntimeIdentifier)' == 'win-x64'">x64 + <_PolicyHelperPlatform Condition="'$(RuntimeIdentifier)' == 'win-arm64'">arm64 + + <_PolicyHelperIntermediateDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)$(BaseIntermediateOutputPath)policy-elevator\$(Configuration)\$(RuntimeIdentifier)')) + <_PolicyHelperExecutable>$(_PolicyHelperIntermediateDir)\UniGetUI.PolicyElevator.exe + <_PolicyHelperDestination>$(PublishDir)Assets\Utilities\ + + + + + + + + + + + _currentPage; private readonly List NavigationHistory = new(); + private readonly SemaphoreSlim _navigationSemaphore = new(1, 1); [ObservableProperty] private object? _currentPageContent; @@ -632,66 +633,91 @@ public static PageType GetPreviousPage(PageType type) => _ => PageType.Discover, }; - public void NavigateTo(PageType newPage_t, bool toHistory = true) + public void NavigateTo(PageType newPage_t, bool toHistory = true) => + _ = NavigateToAsync(newPage_t, toHistory); + + public async Task NavigateToAsync( + PageType newPage_t, + bool toHistory = true, + CancellationToken cancellationToken = default) { - if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return; } - if (newPage_t is PageType.Quit) { MainWindow.Instance?.QuitApplication(); return; } + if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return true; } + if (newPage_t is PageType.Quit) { MainWindow.Instance?.QuitApplication(); return true; } if (_currentPage == newPage_t) { // Re-focus the primary control even when we're already on the page (CurrentPageContent as AbstractPackagesPage)?.FocusPackageList(); - return; + return true; } - Sidebar.SelectNavButtonForPage(newPage_t); + await _navigationSemaphore.WaitAsync(cancellationToken); + try + { + if (_currentPage == newPage_t) + return true; - var newPage = GetPageForType(newPage_t); - var oldPage = CurrentPageContent as Control; + if (CurrentPageContent is IAsyncLeaveGuard guard + && !await guard.CanLeaveAsync(PageLeaveReason.TopLevelNavigation, cancellationToken)) + { + Sidebar.SelectNavButtonForPage(_currentPage); + return false; + } - if (oldPage is ISearchBoxPage oldSPage) - oldSPage.QueryBackup = GlobalSearchText; - (oldPage as IEnterLeaveListener)?.OnLeave(); + Sidebar.SelectNavButtonForPage(newPage_t); - CurrentPageContent = newPage; - _oldPage = _currentPage; - _currentPage = newPage_t; + var newPage = GetPageForType(newPage_t); + var oldPage = CurrentPageContent as Control; - // #5129: Help/ReleaseNotes each host a WebView2 that the control never releases on - // detach. Drop the page when leaving so its WebView2 process cluster gets freed. - ReleaseWebViewPage(oldPage); + if (oldPage is ISearchBoxPage oldSPage) + oldSPage.QueryBackup = GlobalSearchText; + (oldPage as IEnterLeaveListener)?.OnLeave(); - if (toHistory && _oldPage is not PageType.Null) - { - NavigationHistory.Add(_oldPage); - CanGoBackChanged?.Invoke(this, true); - } + CurrentPageContent = newPage; + _oldPage = _currentPage; + _currentPage = newPage_t; - (newPage as AbstractPackagesPage)?.FilterPackages(); - (newPage as IEnterLeaveListener)?.OnEnter(); + // #5129: Help/ReleaseNotes each host a WebView2 that the control never releases on + // detach. Drop the page when leaving so its WebView2 process cluster gets freed. + ReleaseWebViewPage(oldPage); - CloseSuggestions(); + if (toHistory && _oldPage is not PageType.Null) + { + NavigationHistory.Add(_oldPage); + CanGoBackChanged?.Invoke(this, true); + } - if (newPage is ISearchBoxPage newSPage) - { - SubscribeToPageViewModel(newPage as AbstractPackagesPage); - GlobalSearchText = newSPage.QueryBackup; - GlobalSearchPlaceholder = newSPage.SearchBoxPlaceholder; - GlobalSearchEnabled = true; + (newPage as AbstractPackagesPage)?.FilterPackages(); + (newPage as IEnterLeaveListener)?.OnEnter(); + + CloseSuggestions(); + + if (newPage is ISearchBoxPage newSPage) + { + SubscribeToPageViewModel(newPage as AbstractPackagesPage); + GlobalSearchText = newSPage.QueryBackup; + GlobalSearchPlaceholder = newSPage.SearchBoxPlaceholder; + GlobalSearchEnabled = true; + } + else + { + SubscribeToPageViewModel(null); + GlobalSearchText = ""; + GlobalSearchPlaceholder = ""; + GlobalSearchEnabled = false; + } + + // Focus after search state is restored so MegaQueryVisible is already correct + (newPage as AbstractPackagesPage)?.FocusPackageList(); + + AccessibilityAnnouncementService.Announce(GetPageAnnouncement(newPage_t)); + CurrentPageChanged?.Invoke(this, newPage_t); + return true; } - else + finally { - SubscribeToPageViewModel(null); - GlobalSearchText = ""; - GlobalSearchPlaceholder = ""; - GlobalSearchEnabled = false; + _navigationSemaphore.Release(); } - - // Focus after search state is restored so MegaQueryVisible is already correct - (newPage as AbstractPackagesPage)?.FocusPackageList(); - - AccessibilityAnnouncementService.Announce(GetPageAnnouncement(newPage_t)); - CurrentPageChanged?.Invoke(this, newPage_t); } private static string GetPageAnnouncement(PageType pageType) => pageType switch @@ -710,52 +736,96 @@ public void NavigateTo(PageType newPage_t, bool toHistory = true) _ => CoreTools.Translate("UniGetUI"), }; - public void NavigateBack() + public void NavigateBack() => _ = NavigateBackAsync(); + + public async Task NavigateBackAsync(CancellationToken cancellationToken = default) { if (CurrentPageContent is IInnerNavigationPage navPage && navPage.CanGoBack()) { - navPage.GoBack(); + return await navPage.GoBackAsync(cancellationToken); } else if (NavigationHistory.Count > 0) { - NavigateTo(NavigationHistory.Last(), toHistory: false); + if (!await NavigateToAsync( + NavigationHistory.Last(), + toHistory: false, + cancellationToken)) + return false; + NavigationHistory.RemoveAt(NavigationHistory.Count - 1); CanGoBackChanged?.Invoke(this, NavigationHistory.Count > 0 || ((CurrentPageContent as IInnerNavigationPage)?.CanGoBack() ?? false)); + return true; } + + return false; } public void OpenManagerLogs(IPackageManager? manager = null) + => _ = OpenManagerLogsAsync(manager); + + private async Task OpenManagerLogsAsync(IPackageManager? manager) { - NavigateTo(PageType.ManagerLog); - if (manager is not null) ManagerLogPage?.LoadForManager(manager); + if (!await NavigateToAsync(PageType.ManagerLog)) + return; + if (manager is not null) + ManagerLogPage?.LoadForManager(manager); } public void OpenManagerSettings(IPackageManager? manager = null) + => _ = OpenManagerSettingsAsync(manager); + + private async Task OpenManagerSettingsAsync(IPackageManager? manager) { - NavigateTo(PageType.Managers); - if (manager is not null) ManagersPage?.NavigateTo(manager); + if (!await NavigateToAsync(PageType.Managers)) + return; + if (manager is not null && ManagersPage is not null) + await ManagersPage.NavigateToAsync(manager); } public void OpenSettingsPage(Type page, string? anchor = null) + => _ = OpenSettingsPageAsync(page, anchor); + + private async Task OpenSettingsPageAsync(Type page, string? anchor) { - NavigateTo(PageType.Settings); - SettingsPage?.NavigateTo(page, anchor); + if (!await NavigateToAsync(PageType.Settings)) + return; + if (SettingsPage is not null) + await SettingsPage.NavigateToAsync(page, anchor); } public void ShowHelp(string uriAttachment = "") + => _ = ShowHelpAsync(uriAttachment); + + private async Task ShowHelpAsync(string uriAttachment) { - NavigateTo(PageType.Help); + if (!await NavigateToAsync(PageType.Help)) + return; HelpPage?.NavigateTo(uriAttachment); } public async Task LoadCloudBundleAsync(string content) { - NavigateTo(PageType.Bundles); + if (!await NavigateToAsync(PageType.Bundles)) + return; await BundlesPage.OpenFromString(content, BundleFormatType.UBUNDLE, "GitHub Gist"); } + public async Task CanShutdownAsync(CancellationToken cancellationToken = default) + { + await _navigationSemaphore.WaitAsync(cancellationToken); + try + { + return CurrentPageContent is not IAsyncLeaveGuard guard + || await guard.CanLeaveAsync(PageLeaveReason.Shutdown, cancellationToken); + } + finally + { + _navigationSemaphore.Release(); + } + } + private async Task ShowAboutDialog() { Sidebar.SelectNavButtonForPage(PageType.Null); @@ -766,7 +836,7 @@ private async Task ShowAboutDialog() } // ─── Notification activation ───────────────────────────────────────────── - private void HandleNotificationActivation(string action) + private async void HandleNotificationActivation(string action) { if (action == NotificationArguments.UpdateAllPackages) { @@ -774,8 +844,8 @@ private void HandleNotificationActivation(string action) } else if (action == NotificationArguments.ShowOnUpdatesTab) { - NavigateTo(PageType.Updates); - MainWindow.Instance?.ShowFromTray(); + if (await NavigateToAsync(PageType.Updates)) + MainWindow.Instance?.ShowFromTray(); } else if (action == NotificationArguments.Show) { diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs index 65c4db60dd..324151a684 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs @@ -5,8 +5,10 @@ using Devolutions.Now.Policy.Api; using Devolutions.Now.Policy.Model; using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.AgentBroker; +using UniGetUI.PackageEngine.AgentBroker.PolicyManagement; using PolicyArchitecture = Devolutions.Now.Policy.Model.Architecture; using PolicyDecision = Devolutions.Now.Policy.Model.Decision; using PolicyElevation = Devolutions.Now.Policy.Model.Elevation; @@ -21,6 +23,19 @@ public sealed record PolicyDetailRow(string Label, string Value) public string AutomationName => $"{Label}: {Value}"; } +/// +/// Raised by when the user chooses Edit/Create/Repair/Replace +/// identity. Carries everything the (view-owned) dialog launcher needs to construct a +/// PolicyEditorSession without the view model itself depending on any Avalonia window/dialog type. +/// is populated for Create/Repair/ReplaceIdentity (there is no existing valid +/// draft to derive from); Update leaves it null since PolicyEditorSession.StartUpdate derives the +/// draft from itself. +/// +public sealed record PolicyEditorLaunchRequest( + PolicyEditorOperationKind Operation, + PolicyManagementSnapshot Management, + PolicyEditorDraftDocument? SeedDraft = null); + public sealed class PolicyRuleViewModel { public required string AutomationName { get; init; } @@ -36,10 +51,14 @@ public sealed class PolicyRuleViewModel public partial class AgentPolicyInspectorViewModel : ViewModelBase, IDisposable { private readonly IBrokerPolicyInspector _inspector; + private readonly IBrokerPolicyManagementService _managementService; private readonly CancellationTokenSource _lifetimeCancellation = new(); private CancellationTokenSource? _refreshCancellation; + private CancellationTokenSource? _managementRefreshCancellation; private long _refreshGeneration; + private long _managementRefreshGeneration; private int _isDisposed; + private PolicyManagementSnapshot? _managementSnapshot; public InfoBarViewModel Status { get; } = new() { @@ -47,33 +66,78 @@ public partial class AgentPolicyInspectorViewModel : ViewModelBase, IDisposable IsOpen = true, }; + /// Status for the independent Phase 2 management-state section (Active/Missing/Invalid). + public InfoBarViewModel ManagementStatus { get; } = new() + { + IsClosable = false, + IsOpen = true, + }; + public ObservableCollection MetadataRows { get; } = []; public ObservableCollection EnforcementRows { get; } = []; public ObservableCollection Rules { get; } = []; + /// Sanitized Invalid-state findings, or empty when the snapshot is not Invalid. + public ObservableCollection ManagementDiagnosticsRows { get; } = []; + [ObservableProperty] private bool _isLoading; [ObservableProperty] private bool _hasPolicy; [ObservableProperty] private bool _hasNoRules; [ObservableProperty] private string _rawJson = ""; + [ObservableProperty] private bool _isManagementLoading; + [ObservableProperty] private bool _hasManagementSnapshot; + [ObservableProperty] private string _managementStateText = ""; + [ObservableProperty] private string _managementConfiguredPath = ""; + [ObservableProperty] private string _managementSourceText = ""; + [ObservableProperty] private string _managementCapabilityText = ""; + [ObservableProperty] private string _managementReadOnlyReasonText = ""; + [ObservableProperty] private bool _managementElevationRequired; + [ObservableProperty] private string _managementElevationRequiredText = ""; + [ObservableProperty] private bool _canEdit; + [ObservableProperty] private bool _canCreate; + [ObservableProperty] private bool _canRepair; + [ObservableProperty] private bool _canReplaceIdentity; + [ObservableProperty] private bool _hasManagementDiagnostics; + public event EventHandler? CopyTextRequested; + public event EventHandler? OpenPolicyEditorRequested; public AgentPolicyInspectorViewModel() - : this(new BrokerPolicyInspector()) + : this(new BrokerPolicyInspector(), new BrokerPolicyManagementService()) { } public AgentPolicyInspectorViewModel(IBrokerPolicyInspector inspector) + : this(inspector, new BrokerPolicyManagementService()) + { + } + + public AgentPolicyInspectorViewModel( + IBrokerPolicyInspector inspector, + IBrokerPolicyManagementService managementService) { _inspector = inspector; + _managementService = managementService; SetStatus( CoreTools.Translate("Loading active package broker policy"), CoreTools.Translate("Contacting the Devolutions Agent service."), InfoBarSeverity.Informational); + SetManagementStatus( + CoreTools.Translate("Loading policy management state"), + CoreTools.Translate("Contacting the Devolutions Agent service."), + InfoBarSeverity.Informational); } public Task LoadAsync() => RefreshAsync(); + /// + /// Kept independent from (and its own + /// dependency default) so Phase 1's inspector behavior and tests - which construct this view model with + /// only a stub - are unaffected by the Phase 2 management surface. + /// + public Task LoadManagementAsync() => RefreshManagementAsync(); + [RelayCommand(AllowConcurrentExecutions = true)] private async Task RefreshAsync() { @@ -121,6 +185,95 @@ private void CopyRawJson() } } + [RelayCommand(AllowConcurrentExecutions = true)] + private async Task RefreshManagementAsync() + { + if (Volatile.Read(ref _isDisposed) != 0) return; + + long generation = Interlocked.Increment(ref _managementRefreshGeneration); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); + CancellationTokenSource? previous = Interlocked.Exchange(ref _managementRefreshCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + + IsManagementLoading = true; + SetManagementStatus( + CoreTools.Translate("Loading policy management state"), + CoreTools.Translate("Contacting the Devolutions Agent service."), + InfoBarSeverity.Informational); + + try + { + BrokerPolicyManagementResult result = + await _managementService.GetManagementAsync(cancellation.Token); + if (!CanApplyManagement(generation, cancellation)) return; + + ApplyManagementResult(result); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + finally + { + if (CanApplyManagement(generation, cancellation)) + { + IsManagementLoading = false; + } + } + } + + [RelayCommand] + private void EditPolicy() + { + if (!CanEdit || _managementSnapshot is not { State: PolicyManagementState.Active } snapshot) return; + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.Update, snapshot)); + } + + [RelayCommand] + private void ReplaceIdentity() + { + if (!CanReplaceIdentity + || _managementSnapshot is not { State: PolicyManagementState.Active, Policy: not null } snapshot) + { + return; + } + + PolicyEditorDraftDocument seed = PolicyEditorTemplates.CreateNew( + $"{snapshot.Policy.Metadata.Id}-new", + snapshot.Policy.Metadata.Publisher); + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.ReplaceIdentity, snapshot, seed)); + } + + [RelayCommand] + private void CreatePolicy() + { + if (!CanCreate || _managementSnapshot is not { State: PolicyManagementState.Missing } snapshot) return; + + PolicyEditorDraftDocument seed = PolicyEditorTemplates.CreateNew( + "new-policy", + CoreTools.Translate("Your organization")); + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.Create, snapshot, seed)); + } + + [RelayCommand] + private void RepairPolicy() + { + if (!CanRepair || _managementSnapshot is not { State: PolicyManagementState.Invalid } snapshot) return; + + PolicyEditorDraftDocument seed = PolicyEditorTemplates.CreateNew( + "repaired-policy", + CoreTools.Translate("Your organization")); + OpenPolicyEditorRequested?.Invoke( + this, + new PolicyEditorLaunchRequest(PolicyEditorOperationKind.Repair, snapshot, seed)); + } + private bool CanApply(long generation, CancellationTokenSource cancellation) { return Volatile.Read(ref _isDisposed) == 0 @@ -128,6 +281,13 @@ private bool CanApply(long generation, CancellationTokenSource cancellation) && generation == Volatile.Read(ref _refreshGeneration); } + private bool CanApplyManagement(long generation, CancellationTokenSource cancellation) + { + return Volatile.Read(ref _isDisposed) == 0 + && !cancellation.IsCancellationRequested + && generation == Volatile.Read(ref _managementRefreshGeneration); + } + private void ApplyResult(BrokerPolicyInspectionResult result) { ClearPolicy(); @@ -325,6 +485,161 @@ private void ClearPolicy() HasNoRules = false; } + private void ApplyManagementResult(BrokerPolicyManagementResult result) + { + ClearManagement(); + + switch (result.Status) + { + case BrokerPolicyManagementStatus.Retrieved when result.Snapshot is not null: + ApplyManagementSnapshot(result.Snapshot, result.Diagnostics); + break; + case BrokerPolicyManagementStatus.AgentUnavailable: + SetManagementStatus( + CoreTools.Translate("Devolutions Agent is unavailable"), + CoreTools.Translate("The package broker could not be reached. Verify that Devolutions Agent is installed and running, then refresh."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.Unsupported: + SetManagementStatus( + CoreTools.Translate("Policy management is unsupported"), + CoreTools.Translate("The installed Devolutions Agent is reachable but does not support policy management. Update the Agent and try again."), + InfoBarSeverity.Warning); + break; + case BrokerPolicyManagementStatus.AccessDenied: + SetManagementStatus( + CoreTools.Translate("Access to policy management was denied"), + CoreTools.Translate("Devolutions Agent did not authorize UniGetUI to manage the package policy."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.InvalidResponse: + SetManagementStatus( + CoreTools.Translate("The policy management response is invalid"), + CoreTools.Translate("Devolutions Agent returned a malformed or incompatible policy management response."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.UnsupportedPlatform: + SetManagementStatus( + CoreTools.Translate("Policy management is available on Windows only"), + CoreTools.Translate("This page cannot manage the policy file through the Windows Devolutions Agent service on the current platform."), + InfoBarSeverity.Warning); + break; + case BrokerPolicyManagementStatus.UnsafePolicyPath: + SetManagementStatus( + CoreTools.Translate("The configured policy path is unsafe"), + CoreTools.Translate("Devolutions Agent refused to manage the configured policy path because it is considered unsafe (for example, a path traversal or reparse point)."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.UnsupportedPolicyFormat: + SetManagementStatus( + CoreTools.Translate("The policy file format is unsupported"), + CoreTools.Translate("Devolutions Agent reported that the configured policy file format is not supported for management."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.UnsupportedPolicyFilesystem: + SetManagementStatus( + CoreTools.Translate("The policy file system is unsupported"), + CoreTools.Translate("Devolutions Agent reported that the file system hosting the configured policy path is not supported for management."), + InfoBarSeverity.Error); + break; + case BrokerPolicyManagementStatus.PolicyUnavailable: + SetManagementStatus( + CoreTools.Translate("The policy management state is unavailable"), + CoreTools.Translate("Devolutions Agent supports policy management but could not provide the current state. Review the Agent configuration and try again."), + InfoBarSeverity.Error); + break; + default: + SetManagementStatus( + CoreTools.Translate("The policy management response is invalid"), + CoreTools.Translate("Devolutions Agent returned a malformed or incompatible policy management response."), + InfoBarSeverity.Error); + break; + } + } + + private void ApplyManagementSnapshot(PolicyManagementSnapshot snapshot, BrokerPolicyDiagnosticsView? diagnostics) + { + _managementSnapshot = snapshot; + HasManagementSnapshot = true; + + ManagementStateText = TranslateEnum(snapshot.State); + ManagementConfiguredPath = Value(PolicyFindingPresentation.SanitizeAgentText( + snapshot.ConfiguredPath, + BrokerPolicyManagementLimits.MaxSanitizedPathLength)); + ManagementSourceText = TranslateEnum(snapshot.Source); + ManagementCapabilityText = TranslateEnum(snapshot.WriteCapability); + ManagementReadOnlyReasonText = snapshot.ReadOnlyReason.HasValue + ? TranslateEnum(snapshot.ReadOnlyReason.Value) + : CoreTools.Translate("Not applicable"); + ManagementElevationRequired = snapshot.ElevationRequired; + ManagementElevationRequiredText = FormatBoolean(snapshot.ElevationRequired); + + bool writable = snapshot.WriteCapability == PolicyWriteCapability.Writable; + CanEdit = writable && snapshot.State == PolicyManagementState.Active; + CanCreate = writable && snapshot.State == PolicyManagementState.Missing; + CanRepair = writable && snapshot.State == PolicyManagementState.Invalid; + CanReplaceIdentity = writable && snapshot.State == PolicyManagementState.Active; + + if (diagnostics is not null) + { + foreach (BrokerPolicySanitizedFinding finding in diagnostics.Findings) + { + ManagementDiagnosticsRows.Add(BuildDiagnosticRow(finding)); + } + + if (diagnostics.FindingsTruncated) + { + ManagementDiagnosticsRows.Add(new PolicyDetailRow( + CoreTools.Translate("Note"), + CoreTools.Translate("Additional findings were omitted."))); + } + } + + HasManagementDiagnostics = ManagementDiagnosticsRows.Count > 0; + + switch (snapshot.State) + { + case PolicyManagementState.Active: + SetManagementStatus( + CoreTools.Translate("Policy management is active"), + CoreTools.Translate("A valid policy file is configured and in effect."), + InfoBarSeverity.Success); + break; + case PolicyManagementState.Missing: + SetManagementStatus( + CoreTools.Translate("No policy file is configured"), + CoreTools.Translate("Create a new policy file to start enforcing package broker rules."), + InfoBarSeverity.Informational); + break; + case PolicyManagementState.Invalid: + SetManagementStatus( + CoreTools.Translate("The configured policy file is invalid"), + CoreTools.Translate("Review the diagnostics below and repair the policy file."), + InfoBarSeverity.Warning); + break; + default: + SetManagementStatus( + CoreTools.Translate("The policy management state is invalid"), + CoreTools.Translate("Devolutions Agent returned an unrecognized policy management state."), + InfoBarSeverity.Error); + break; + } + } + + private static PolicyDetailRow BuildDiagnosticRow(BrokerPolicySanitizedFinding finding) + { + string label = CoreTools.Translate("{0} ({1})", TranslateEnum(finding.Severity), TranslateEnum(finding.Code)); + string location = finding.Path is { Length: > 0 } path + ? (finding.RuleId is { Length: > 0 } ruleId ? $"{path} \u00b7 {ruleId}" : path) + : finding.RuleId is { Length: > 0 } ruleIdOnly ? ruleIdOnly : ""; + string message = PolicyFindingPresentation.Describe( + finding.Code, + finding.Arguments, + finding.Message); + string value = string.IsNullOrEmpty(location) ? message : $"{location}: {message}"; + return new PolicyDetailRow(label, value); + } + private void SetStatus(string title, string message, InfoBarSeverity severity) { Status.Title = title; @@ -333,11 +648,39 @@ private void SetStatus(string title, string message, InfoBarSeverity severity) Status.IsOpen = true; } + private void SetManagementStatus(string title, string message, InfoBarSeverity severity) + { + ManagementStatus.Title = title; + ManagementStatus.Message = message; + ManagementStatus.Severity = severity; + ManagementStatus.IsOpen = true; + } + + private void ClearManagement() + { + ManagementDiagnosticsRows.Clear(); + _managementSnapshot = null; + HasManagementSnapshot = false; + ManagementStateText = ""; + ManagementConfiguredPath = ""; + ManagementSourceText = ""; + ManagementCapabilityText = ""; + ManagementReadOnlyReasonText = ""; + ManagementElevationRequired = false; + ManagementElevationRequiredText = ""; + HasManagementDiagnostics = false; + CanEdit = false; + CanCreate = false; + CanRepair = false; + CanReplaceIdentity = false; + } + public void Dispose() { if (Interlocked.Exchange(ref _isDisposed, 1) != 0) return; _lifetimeCancellation.Cancel(); Interlocked.Exchange(ref _refreshCancellation, null)?.Cancel(); + Interlocked.Exchange(ref _managementRefreshCancellation, null)?.Cancel(); } } diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftFingerprint.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftFingerprint.cs new file mode 100644 index 0000000000..1bf3906f06 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftFingerprint.cs @@ -0,0 +1,35 @@ +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// A content-based fingerprint of a , used to detect whether the +/// draft has actually changed (dirty tracking) and to tie a warning acknowledgement to the exact +/// draft state it was granted against (see ). +/// Computed from the canonical draft JSON, which omits server-managed metadata. +/// +public readonly struct PolicyEditorDraftFingerprint : IEquatable +{ + private readonly string _canonicalJson; + + private PolicyEditorDraftFingerprint(string canonicalJson) + { + _canonicalJson = canonicalJson; + } + + public static PolicyEditorDraftFingerprint Compute(PolicyEditorDraftDocument draft) + { + ArgumentNullException.ThrowIfNull(draft); + return new PolicyEditorDraftFingerprint(PolicyEditorRawSyntax.ToCanonicalRaw(draft)); + } + + public bool Equals(PolicyEditorDraftFingerprint other) => + string.Equals(_canonicalJson, other._canonicalJson, StringComparison.Ordinal); + + public override bool Equals(object? obj) => obj is PolicyEditorDraftFingerprint other && Equals(other); + + public override int GetHashCode() => + _canonicalJson is null ? 0 : StringComparer.Ordinal.GetHashCode(_canonicalJson); + + public static bool operator ==(PolicyEditorDraftFingerprint left, PolicyEditorDraftFingerprint right) => left.Equals(right); + + public static bool operator !=(PolicyEditorDraftFingerprint left, PolicyEditorDraftFingerprint right) => !left.Equals(right); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftModels.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftModels.cs new file mode 100644 index 0000000000..03c0bf48b9 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftModels.cs @@ -0,0 +1,248 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Editable projection of . Deliberately excludes +/// and (server/write-path +/// assigned bookkeeping, never user-edited) and exposes / as +/// fixed, read-only values instead of editable fields: see . +/// Use to convert to/from the wire model, and for a +/// full, independent deep copy (used for snapshots, undo points, and conflict capture). +/// +public sealed class PolicyEditorDraftDocument +{ + public string Schema => PolicyEditorPolicyContract.Schema; + + public string PolicyType => PolicyEditorPolicyContract.PolicyType; + + public required string PolicyVersion { get; set; } + + public required PolicyEditorDraftMetadata Metadata { get; set; } + + public required PolicyEditorDraftEnforcement Enforcement { get; set; } + + public List Rules { get; set; } = []; + + public PolicyEditorDraftDocument Clone() => new() + { + PolicyVersion = PolicyVersion, + Metadata = Metadata.Clone(), + Enforcement = Enforcement.Clone(), + Rules = Rules.Select(rule => rule.Clone()).ToList(), + }; +} + +/// Editable projection of , minus Revision/PublishedAt. +public sealed class PolicyEditorDraftMetadata +{ + public required string Id { get; set; } + + public required string Publisher { get; set; } + + public DateTimeOffset? ValidFrom { get; set; } + + public DateTimeOffset? ValidUntil { get; set; } + + public string? Description { get; set; } + + public string? SupportUrl { get; set; } + + public PolicyEditorDraftMetadata Clone() => new() + { + Id = Id, + Publisher = Publisher, + ValidFrom = ValidFrom, + ValidUntil = ValidUntil, + Description = Description, + SupportUrl = SupportUrl, + }; +} + +/// +/// Editable projection of . is fixed +/// (see ); only and +/// are user-editable. +/// +public sealed class PolicyEditorDraftEnforcement +{ + public required Decision DefaultDecision { get; set; } + + public RulePrecedence RulePrecedence => PolicyEditorPolicyContract.FixedRulePrecedence; + + public bool? AuditMode { get; set; } + + public PolicyEditorDraftEnforcement Clone() => new() + { + DefaultDecision = DefaultDecision, + AuditMode = AuditMode, + }; +} + +/// Editable projection of a single . +public sealed class PolicyEditorDraftRule +{ + public required string Id { get; set; } + + public bool Enabled { get; set; } = true; + + public uint Priority { get; set; } + + public required Decision Decision { get; set; } + + public string? Reason { get; set; } + + public required PolicyEditorDraftMatch Match { get; set; } + + public PolicyEditorDraftConstraints? Constraints { get; set; } + + /// Deep copy preserving the same rule identity ( included). + public PolicyEditorDraftRule Clone() => new() + { + Id = Id, + Enabled = Enabled, + Priority = Priority, + Decision = Decision, + Reason = Reason, + Match = Match.Clone(), + Constraints = Constraints?.Clone(), + }; + + /// Deep copy under a new rule identity, for use by the "duplicate rule" operation. + public PolicyEditorDraftRule CloneWithNewId(string newId) + { + PolicyEditorDraftRule clone = Clone(); + clone.Id = newId; + return clone; + } +} + +/// +/// Editable projection of . The eight boolean criteria are exposed as +/// instead of List<bool>; see and +/// for the conversion rules. +/// +public sealed class PolicyEditorDraftMatch +{ + public List Operations { get; set; } = []; + + public List Managers { get; set; } = []; + + public List Sources { get; set; } = []; + + public List PackageIdentifiers { get; set; } = []; + + public List PackageNames { get; set; } = []; + + public List Versions { get; set; } = []; + + public PolicyEditorDraftVersionRange? VersionRange { get; set; } + + public List Scopes { get; set; } = []; + + public List Architectures { get; set; } = []; + + public List Elevation { get; set; } = []; + + public TriState Interactive { get; set; } + + public TriState SkipHashCheck { get; set; } + + public TriState PreRelease { get; set; } + + public TriState HasCustomParameters { get; set; } + + public TriState HasCustomInstallLocation { get; set; } + + public TriState HasPrePostCommands { get; set; } + + public TriState HasKillBeforeOperation { get; set; } + + public TriState HasUninstallPrevious { get; set; } + + public PolicyEditorDraftMatch Clone() => new() + { + Operations = [.. Operations], + Managers = [.. Managers], + Sources = [.. Sources], + PackageIdentifiers = [.. PackageIdentifiers], + PackageNames = [.. PackageNames], + Versions = [.. Versions], + VersionRange = VersionRange?.Clone(), + Scopes = [.. Scopes], + Architectures = [.. Architectures], + Elevation = [.. Elevation], + Interactive = Interactive, + SkipHashCheck = SkipHashCheck, + PreRelease = PreRelease, + HasCustomParameters = HasCustomParameters, + HasCustomInstallLocation = HasCustomInstallLocation, + HasPrePostCommands = HasPrePostCommands, + HasKillBeforeOperation = HasKillBeforeOperation, + HasUninstallPrevious = HasUninstallPrevious, + }; +} + +/// Editable projection of . +public sealed class PolicyEditorDraftVersionRange +{ + public string? MinVersion { get; set; } + + public string? MaxVersion { get; set; } + + public bool IncludePrerelease { get; set; } + + public PolicyEditorDraftVersionRange Clone() => new() + { + MinVersion = MinVersion, + MaxVersion = MaxVersion, + IncludePrerelease = IncludePrerelease, + }; +} + +/// Editable projection of (plain booleans, no tri-state). +public sealed class PolicyEditorDraftConstraints +{ + public bool AllowInteractive { get; set; } + + public bool AllowSkipHashCheck { get; set; } + + public bool AllowPreRelease { get; set; } + + public bool AllowCustomInstallLocation { get; set; } + + public List AllowedInstallLocationPatterns { get; set; } = []; + + public bool AllowCustomParameters { get; set; } + + public List AllowedCustomParameters { get; set; } = []; + + public List AllowedCustomParameterPatterns { get; set; } = []; + + public List DeniedCustomParameters { get; set; } = []; + + public bool AllowPrePostCommands { get; set; } + + public bool AllowKillBeforeOperation { get; set; } + + public bool AllowUninstallPrevious { get; set; } + + public bool AllowUpgrade { get; set; } + + public PolicyEditorDraftConstraints Clone() => new() + { + AllowInteractive = AllowInteractive, + AllowSkipHashCheck = AllowSkipHashCheck, + AllowPreRelease = AllowPreRelease, + AllowCustomInstallLocation = AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. AllowedInstallLocationPatterns], + AllowCustomParameters = AllowCustomParameters, + AllowedCustomParameters = [.. AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. DeniedCustomParameters], + AllowPrePostCommands = AllowPrePostCommands, + AllowKillBeforeOperation = AllowKillBeforeOperation, + AllowUninstallPrevious = AllowUninstallPrevious, + AllowUpgrade = AllowUpgrade, + }; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDependencies.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDependencies.cs new file mode 100644 index 0000000000..ddd2d1157c --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDependencies.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public sealed record PolicyEditorValidationOutcome( + PolicyValidationResult? Validation, + ErrorCode? ErrorCode = null, + IReadOnlyList? BoundedFindings = null, + int OmittedFindingCount = 0) +{ + public bool Completed => Validation is not null; +} + +public interface IPolicyValidationClient +{ + Task ValidateAsync( + JsonElement draft, + CancellationToken cancellationToken); +} + +public sealed record PolicyEditorWriteRequest( + PolicyReplacementOperation Operation, + PolicyConflictHandling ConflictHandling, + string ExpectedStoreToken, + JsonElement Draft, + string ValidationReceipt, + bool WarningsAcknowledged) +{ + public PolicyReplacementRequest ToSharedRequest() => new() + { + ExpectedStoreToken = ExpectedStoreToken, + Operation = Operation, + ConflictHandling = ConflictHandling, + WarningsAcknowledged = WarningsAcknowledged, + Draft = Draft.Clone(), + ValidationReceipt = ValidationReceipt, + }; +} + +public enum PolicyWriteFailureKind +{ + None, + UacCanceled, + LaunchFailed, + AuthenticationFailed, + ProtocolFailed, + HelperFailed, + BrokerRejected, +} + +public sealed record PolicyWriteOutcome( + PolicyReplacementResponse? Response, + ErrorResponse? Error, + PolicyWriteFailureKind FailureKind = PolicyWriteFailureKind.None) +{ + public bool Succeeded => Response is not null; + + public static PolicyWriteOutcome Success(PolicyReplacementResponse response) => + new(response, null); + + public static PolicyWriteOutcome Failure( + PolicyWriteFailureKind kind, + ErrorResponse? error = null) => + new(null, error, kind); +} + +public interface IPolicyWriteClient +{ + Task WriteAsync( + PolicyEditorWriteRequest request, + CancellationToken cancellationToken); +} + +public sealed record PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind Kind, + PolicyReplacementOperation Operation, + string DraftId, + string ExpectedStoreToken, + PolicyManagementState State, + string? ActivePolicyId, + IReadOnlyList Findings); + +public interface IPolicyEditorConfirmationPrompt +{ + Task ConfirmAsync( + PolicyEditorConfirmationRequest request, + CancellationToken cancellationToken); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDialogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDialogViewModel.cs new file mode 100644 index 0000000000..fa4a4080c0 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDialogViewModel.cs @@ -0,0 +1,267 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia.Automation; +using CommunityToolkit.Mvvm.ComponentModel; +using Devolutions.Now.Policy.Api; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Composite root DataContext for PolicyEditorDialog: bundles the domain +/// together with the UI-only +/// wrapper and a live wrapper collection, so the whole dialog AXAML tree can bind +/// through a single compiled x:DataType instead of juggling several sibling data contexts. +/// The collection is only rebuilt after a structural rule-list operation +/// (add/duplicate/delete/move) or a raw→structured mode switch; ordinary field edits mutate the +/// existing instances in place so bound controls never lose focus. +/// +public sealed class PolicyEditorDialogViewModel : ObservableObject, IDisposable +{ + public PolicyEditorSessionViewModel Session { get; } + + public PolicyEditorDocumentUi Document { get; } + + public ObservableCollection Rules { get; } = []; + + public InfoBarViewModel Status { get; } = new() { IsClosable = false, IsOpen = false }; + + public PolicyEditorDialogViewModel(PolicyEditorSessionViewModel session) + { + Session = session; + Document = new PolicyEditorDocumentUi(session); + Session.PropertyChanged += OnSessionPropertyChanged; + RebuildRules(); + RefreshStatus(); + } + + public string Title => Session.Session.Operation switch + { + PolicyEditorOperationKind.Update => CoreTools.Translate("Edit policy '{0}'", Session.Draft.Metadata.Id), + PolicyEditorOperationKind.ReplaceIdentity => CoreTools.Translate("Replace active policy identity"), + PolicyEditorOperationKind.Create => CoreTools.Translate("Create a new package broker policy"), + PolicyEditorOperationKind.Repair => CoreTools.Translate("Repair the stored package broker policy"), + _ => CoreTools.Translate("Package broker policy editor"), + }; + + public bool HasWriteFailure => Session.LastWriteFailureKind != PolicyWriteFailureKind.None + || Session.LastErrorCode is not null; + + public string WriteFailureMessage => DescribeWriteFailure(Session.LastWriteFailureKind, Session.LastErrorCode); + + /// + /// Rebuilds every wrapper from the current + /// . Call after any operation that changes the rule + /// list's identity/order (add/duplicate/delete/move, or a raw→structured switch); never on ordinary + /// field edits, which mutate existing wrappers in place instead. + /// + public void RebuildRules() + { + foreach (PolicyEditorRuleUi rule in Rules) + { + rule.Dispose(); + } + Rules.Clear(); + foreach (PolicyEditorDraftRule rule in Session.Rules) + { + Rules.Add(new PolicyEditorRuleUi(rule, Session)); + } + } + + public void RefreshStructuredProjection() + { + Document.RefreshFromDraft(); + RebuildRules(); + } + + private void OnSessionPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PolicyEditorSessionViewModel.Findings)) + { + foreach (PolicyEditorRuleUi rule in Rules) + { + rule.RefreshFindings(); + } + + PolicyValidationFinding? firstError = Session.Findings.FirstOrDefault( + finding => finding.Severity == PolicyValidationSeverity.Error); + if (firstError is not null) + { + AccessibilityAnnouncementService.Announce( + firstError.AutomationName, + AutomationLiveSetting.Assertive); + } + else if (Session.Findings.FirstOrDefault() is { } firstWarning) + { + AccessibilityAnnouncementService.Announce( + firstWarning.AutomationName, + AutomationLiveSetting.Polite); + } + } + + if (e.PropertyName is nameof(PolicyEditorSessionViewModel.LastWriteFailureKind) + or nameof(PolicyEditorSessionViewModel.LastErrorCode)) + { + OnPropertyChanged(nameof(HasWriteFailure)); + OnPropertyChanged(nameof(WriteFailureMessage)); + } + + if (e.PropertyName is nameof(PolicyEditorSessionViewModel.Draft) + or nameof(PolicyEditorSessionViewModel.Operation)) + { + OnPropertyChanged(nameof(Title)); + } + else if (e.PropertyName == nameof(PolicyEditorSessionViewModel.LastSaveSucceeded) + && Session.LastSaveSucceeded + && !Session.SavedWithNewerChanges) + { + RefreshStructuredProjection(); + } + + if (e.PropertyName == nameof(PolicyEditorSessionViewModel.IsIdentityLocked)) + { + Document.NotifyIdentityLockChanged(); + } + + RefreshStatus(); + } + + private void RefreshStatus() + { + if (Session.IsBusy) + { + SetStatus( + CoreTools.Translate("Working…"), + CoreTools.Translate("Contacting Devolutions Agent."), + InfoBarSeverity.Informational); + return; + } + + if (!string.IsNullOrWhiteSpace(Session.StatusMessage)) + { + SetStatus( + CoreTools.Translate("Policy operation in progress"), + Session.StatusMessage, + InfoBarSeverity.Informational); + return; + } + + if (Session.HasLocalInputErrors) + { + SetStatus( + CoreTools.Translate("Correct the highlighted fields"), + Session.LocalInputErrorSummary, + InfoBarSeverity.Error); + return; + } + + if (Session.SavedWithNewerChanges) + { + SetStatus( + CoreTools.Translate("Policy saved; newer changes remain"), + CoreTools.Translate("The policy was saved, but newer draft changes remain unsaved."), + InfoBarSeverity.Warning); + return; + } + + if (Session.LastSaveSucceeded) + { + SetStatus( + CoreTools.Translate("Policy saved"), + CoreTools.Translate("The package broker policy was saved successfully."), + InfoBarSeverity.Success); + return; + } + + if (Session.SyntaxError is { } syntaxError) + { + SetStatus( + CoreTools.Translate("The document is not valid JSON"), + syntaxError.Message, + InfoBarSeverity.Error); + return; + } + + if (HasWriteFailure) + { + SetStatus( + CoreTools.Translate("The policy could not be saved"), + WriteFailureMessage, + InfoBarSeverity.Error); + return; + } + + if (Session.HasConflict) + { + SetStatus( + CoreTools.Translate("The policy changed since you started editing"), + CoreTools.Translate("Review your changes, then choose Overwrite to save anyway."), + InfoBarSeverity.Warning); + return; + } + + if (Session.HasFindings) + { + int errorCount = Session.Findings.Count(finding => finding.Severity == PolicyValidationSeverity.Error); + SetStatus( + errorCount > 0 + ? CoreTools.Translate("Validation found errors") + : CoreTools.Translate("Validation found warnings"), + CoreTools.Translate("Review the findings below before saving."), + errorCount > 0 ? InfoBarSeverity.Error : InfoBarSeverity.Warning); + return; + } + + Status.IsOpen = false; + } + + private void SetStatus(string title, string message, InfoBarSeverity severity) + { + Status.Title = title; + Status.Message = message; + Status.Severity = severity; + Status.IsOpen = true; + } + + private static string DescribeWriteFailure(PolicyWriteFailureKind kind, ErrorCode? errorCode) + { + string? reason = kind switch + { + PolicyWriteFailureKind.UacCanceled => + CoreTools.Translate("The elevation prompt was dismissed. No changes were saved."), + PolicyWriteFailureKind.LaunchFailed => + CoreTools.Translate("The elevated helper could not be started."), + PolicyWriteFailureKind.AuthenticationFailed => + CoreTools.Translate("The elevated helper could not be authenticated."), + PolicyWriteFailureKind.ProtocolFailed => + CoreTools.Translate("Communication with the elevated helper failed."), + PolicyWriteFailureKind.HelperFailed => + CoreTools.Translate("The elevated helper stopped unexpectedly."), + PolicyWriteFailureKind.BrokerRejected => + CoreTools.Translate("Devolutions Agent rejected the policy replacement."), + _ => null, + }; + + if (errorCode is { } code) + { + string codeText = CoreTools.Translate(code.ToString()); + return reason is null + ? CoreTools.Translate("The save failed ({0}).", codeText) + : CoreTools.Translate("{0} ({1})", reason, codeText); + } + + return reason ?? CoreTools.Translate("The save failed."); + } + + public void Dispose() + { + Session.PropertyChanged -= OnSessionPropertyChanged; + foreach (PolicyEditorRuleUi rule in Rules) + { + rule.Dispose(); + } + Session.Dispose(); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorEnums.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorEnums.cs new file mode 100644 index 0000000000..92be16624d --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorEnums.cs @@ -0,0 +1,60 @@ +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Tri-state representation of a boolean policy match criterion. The wire format represents these +/// as a List<bool> (see PolicyMatch.Interactive and its siblings), where an empty +/// list means "don't care" and a single-element list pins the criterion to that value. The editor +/// only ever produces these three states. The shared contract rejects lists with more than one item. +/// +public enum TriState +{ + Omitted, + False, + True, +} + +/// +/// Which editing surface currently owns the source of truth for a . +/// +public enum PolicyEditorMode +{ + /// The structured is authoritative. + Structured, + + /// The free-form text is authoritative. + Raw, +} + +/// +/// The operation a was opened to perform. This reflects user intent +/// at session-open time; it is distinct from the state-derived retry operation computed by +/// when a save is attempted against a possibly-stale origin. +/// +public enum PolicyEditorOperationKind +{ + Update, + ReplaceIdentity, + Create, + Repair, +} + +/// +/// Severity of a , as reported by the external (Agent-side) +/// semantic validator. +/// +public enum PolicyValidationSeverity +{ + Info, + Warning, + Error, +} + +public enum PolicyEditorConfirmationKind +{ + Warnings, + ReplaceIdentity, + Create, + Repair, + ConfirmOverwrite, + DiscardChanges, +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorMapper.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorMapper.cs new file mode 100644 index 0000000000..9ab4c89975 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorMapper.cs @@ -0,0 +1,390 @@ +using Devolutions.Now.Policy.Model; +using Devolutions.Now.Policy.Api; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Explicit, reflection-free, NativeAOT-safe field-by-field mapping between the wire model +/// ( and friends, from Devolutions.Now.Policy.Model) and the editor's +/// draft model ( and friends). Every mapping here also produces a +/// deep copy: no list or nested object is shared between the source and the result, so mutating one +/// side after mapping never affects the other. +/// +public static class PolicyEditorMapper +{ + // ---- PolicyDocument <-> PolicyEditorDraftDocument ------------------------------------------------- + + public static PolicyEditorDraftDocument ToDraft(PolicyDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return new PolicyEditorDraftDocument + { + PolicyVersion = document.PolicyVersion, + Metadata = ToDraft(document.Metadata), + Enforcement = ToDraft(document.Enforcement), + Rules = document.Rules.Select(ToDraft).ToList(), + }; + } + + public static PolicyEditorDraftDocument ToDraft(PolicyDraftDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return new PolicyEditorDraftDocument + { + PolicyVersion = document.PolicyVersion, + Metadata = new PolicyEditorDraftMetadata + { + Id = document.Metadata.Id, + Publisher = document.Metadata.Publisher, + ValidFrom = document.Metadata.ValidFrom, + ValidUntil = document.Metadata.ValidUntil, + Description = document.Metadata.Description, + SupportUrl = document.Metadata.SupportUrl, + }, + Enforcement = ToDraft(document.Enforcement), + Rules = document.Rules.Select(ToDraft).ToList(), + }; + } + + public static PolicyDraftDocument ToSharedDraft(PolicyEditorDraftDocument draft) + { + ArgumentNullException.ThrowIfNull(draft); + + return new PolicyDraftDocument + { + Schema = PolicyEditorPolicyContract.Schema, + PolicyVersion = draft.PolicyVersion, + PolicyType = PolicyEditorPolicyContract.PolicyType, + Metadata = new PolicyDraftMetadata + { + Id = draft.Metadata.Id, + Publisher = draft.Metadata.Publisher, + ValidFrom = draft.Metadata.ValidFrom, + ValidUntil = draft.Metadata.ValidUntil, + Description = draft.Metadata.Description, + SupportUrl = draft.Metadata.SupportUrl, + }, + Enforcement = ToDocument(draft.Enforcement), + Rules = draft.Rules.Select(ToDocument).ToList(), + }; + } + + /// Builds a committed document only from authoritative server metadata. + public static PolicyDocument ToDocument( + PolicyEditorDraftDocument draft, + uint revision, + DateTimeOffset publishedAt) + { + ArgumentNullException.ThrowIfNull(draft); + + return new PolicyDocument + { + Schema = PolicyEditorPolicyContract.Schema, + PolicyType = PolicyEditorPolicyContract.PolicyType, + PolicyVersion = draft.PolicyVersion, + Metadata = ToDocument(draft.Metadata, revision, publishedAt), + Enforcement = ToDocument(draft.Enforcement), + Rules = draft.Rules.Select(ToDocument).ToList(), + }; + } + + /// Deep-clones a wire-model without going through the draft + /// (so / survive intact). + /// Used for origin snapshots and conflict captures. + public static PolicyDocument CloneDocument(PolicyDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return new PolicyDocument + { + Schema = document.Schema, + PolicyType = document.PolicyType, + PolicyVersion = document.PolicyVersion, + Metadata = CloneMetadata(document.Metadata), + Enforcement = CloneEnforcement(document.Enforcement), + Rules = document.Rules.Select(CloneRule).ToList(), + }; + } + + public static PolicyDraftDocument CloneDraftDocument(PolicyDraftDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return PolicyJson.DeserializePolicyDraftDocumentStrict(PolicyJson.Serialize(document)) + ?? throw new InvalidOperationException("Serialized policy draft unexpectedly deserialized as null."); + } + + public static PolicyManagementSnapshot CloneManagementSnapshot( + PolicyManagementSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return new PolicyManagementSnapshot + { + State = snapshot.State, + ConfiguredPath = snapshot.ConfiguredPath, + StoreToken = snapshot.StoreToken, + Source = snapshot.Source, + WriteCapability = snapshot.WriteCapability, + ReadOnlyReason = snapshot.ReadOnlyReason, + ElevationRequired = snapshot.ElevationRequired, + Policy = snapshot.Policy is null ? null : CloneDocument(snapshot.Policy), + // Editor concurrency only needs state, token, capability, and active identity. + // Invalid diagnostics are presented through the separately bounded management view. + InvalidDiagnostics = null, + }; + } + + // ---- Metadata ---------------------------------------------------------------------------- + + private static PolicyEditorDraftMetadata ToDraft(PolicyMetadata metadata) => new() + { + Id = metadata.Id, + Publisher = metadata.Publisher, + ValidFrom = metadata.ValidFrom, + ValidUntil = metadata.ValidUntil, + Description = metadata.Description, + SupportUrl = metadata.SupportUrl, + }; + + private static PolicyMetadata ToDocument(PolicyEditorDraftMetadata draft, uint revision, DateTimeOffset publishedAt) => new() + { + Id = draft.Id, + Publisher = draft.Publisher, + Revision = revision, + PublishedAt = publishedAt, + ValidFrom = draft.ValidFrom, + ValidUntil = draft.ValidUntil, + Description = draft.Description, + SupportUrl = draft.SupportUrl, + }; + + private static PolicyMetadata CloneMetadata(PolicyMetadata metadata) => new() + { + Id = metadata.Id, + Publisher = metadata.Publisher, + Revision = metadata.Revision, + PublishedAt = metadata.PublishedAt, + ValidFrom = metadata.ValidFrom, + ValidUntil = metadata.ValidUntil, + Description = metadata.Description, + SupportUrl = metadata.SupportUrl, + }; + + // ---- Enforcement ------------------------------------------------------------------------- + + private static PolicyEditorDraftEnforcement ToDraft(PolicyEnforcement enforcement) => new() + { + DefaultDecision = enforcement.DefaultDecision, + AuditMode = enforcement.AuditMode, + }; + + private static PolicyEnforcement ToDocument(PolicyEditorDraftEnforcement draft) => new() + { + DefaultDecision = draft.DefaultDecision, + RulePrecedence = PolicyEditorPolicyContract.FixedRulePrecedence, + AuditMode = draft.AuditMode, + }; + + private static PolicyEnforcement CloneEnforcement(PolicyEnforcement enforcement) => new() + { + DefaultDecision = enforcement.DefaultDecision, + RulePrecedence = enforcement.RulePrecedence, + AuditMode = enforcement.AuditMode, + }; + + // ---- Rule / Match / Constraints ---------------------------------------------------------- + + private static PolicyEditorDraftRule ToDraft(PolicyRule rule) => new() + { + Id = rule.Id, + Enabled = rule.Enabled, + Priority = rule.Priority, + Decision = rule.Decision, + Reason = rule.Reason, + Match = ToDraft(rule.Match), + Constraints = rule.Constraints is null ? null : ToDraft(rule.Constraints), + }; + + private static PolicyRule ToDocument(PolicyEditorDraftRule draft) => new() + { + Id = draft.Id, + Enabled = draft.Enabled, + Priority = draft.Priority, + Decision = draft.Decision, + Reason = draft.Reason, + Match = ToDocument(draft.Match), + Constraints = draft.Constraints is null ? null : ToDocument(draft.Constraints), + }; + + private static PolicyRule CloneRule(PolicyRule rule) => new() + { + Id = rule.Id, + Enabled = rule.Enabled, + Priority = rule.Priority, + Decision = rule.Decision, + Reason = rule.Reason, + Match = CloneMatch(rule.Match), + Constraints = rule.Constraints is null ? null : CloneConstraints(rule.Constraints), + }; + + private static PolicyEditorDraftMatch ToDraft(PolicyMatch match) => new() + { + Operations = [.. match.Operations], + Managers = [.. match.Managers], + Sources = [.. match.Sources], + PackageIdentifiers = [.. match.PackageIdentifiers], + PackageNames = [.. match.PackageNames], + Versions = [.. match.Versions], + VersionRange = match.VersionRange is null ? null : ToDraft(match.VersionRange), + Scopes = [.. match.Scopes], + Architectures = [.. match.Architectures], + Elevation = [.. match.Elevation], + Interactive = ToTriState(match.Interactive), + SkipHashCheck = ToTriState(match.SkipHashCheck), + PreRelease = ToTriState(match.PreRelease), + HasCustomParameters = ToTriState(match.HasCustomParameters), + HasCustomInstallLocation = ToTriState(match.HasCustomInstallLocation), + HasPrePostCommands = ToTriState(match.HasPrePostCommands), + HasKillBeforeOperation = ToTriState(match.HasKillBeforeOperation), + HasUninstallPrevious = ToTriState(match.HasUninstallPrevious), + }; + + private static PolicyMatch ToDocument(PolicyEditorDraftMatch draft) => new() + { + Operations = [.. draft.Operations], + Managers = [.. draft.Managers], + Sources = [.. draft.Sources], + PackageIdentifiers = [.. draft.PackageIdentifiers], + PackageNames = [.. draft.PackageNames], + Versions = [.. draft.Versions], + VersionRange = draft.VersionRange is null ? null : ToDocument(draft.VersionRange), + Scopes = [.. draft.Scopes], + Architectures = [.. draft.Architectures], + Elevation = [.. draft.Elevation], + Interactive = FromTriState(draft.Interactive), + SkipHashCheck = FromTriState(draft.SkipHashCheck), + PreRelease = FromTriState(draft.PreRelease), + HasCustomParameters = FromTriState(draft.HasCustomParameters), + HasCustomInstallLocation = FromTriState(draft.HasCustomInstallLocation), + HasPrePostCommands = FromTriState(draft.HasPrePostCommands), + HasKillBeforeOperation = FromTriState(draft.HasKillBeforeOperation), + HasUninstallPrevious = FromTriState(draft.HasUninstallPrevious), + }; + + private static PolicyMatch CloneMatch(PolicyMatch match) => new() + { + Operations = [.. match.Operations], + Managers = [.. match.Managers], + Sources = [.. match.Sources], + PackageIdentifiers = [.. match.PackageIdentifiers], + PackageNames = [.. match.PackageNames], + Versions = [.. match.Versions], + VersionRange = match.VersionRange is null ? null : CloneVersionRange(match.VersionRange), + Scopes = [.. match.Scopes], + Architectures = [.. match.Architectures], + Elevation = [.. match.Elevation], + Interactive = [.. match.Interactive], + SkipHashCheck = [.. match.SkipHashCheck], + PreRelease = [.. match.PreRelease], + HasCustomParameters = [.. match.HasCustomParameters], + HasCustomInstallLocation = [.. match.HasCustomInstallLocation], + HasPrePostCommands = [.. match.HasPrePostCommands], + HasKillBeforeOperation = [.. match.HasKillBeforeOperation], + HasUninstallPrevious = [.. match.HasUninstallPrevious], + }; + + private static PolicyEditorDraftVersionRange ToDraft(VersionRange range) => new() + { + MinVersion = range.MinVersion, + MaxVersion = range.MaxVersion, + IncludePrerelease = range.IncludePrerelease, + }; + + private static VersionRange ToDocument(PolicyEditorDraftVersionRange draft) => new() + { + MinVersion = draft.MinVersion, + MaxVersion = draft.MaxVersion, + IncludePrerelease = draft.IncludePrerelease, + }; + + private static VersionRange CloneVersionRange(VersionRange range) => new() + { + MinVersion = range.MinVersion, + MaxVersion = range.MaxVersion, + IncludePrerelease = range.IncludePrerelease, + }; + + private static PolicyEditorDraftConstraints ToDraft(PolicyConstraints constraints) => new() + { + AllowInteractive = constraints.AllowInteractive, + AllowSkipHashCheck = constraints.AllowSkipHashCheck, + AllowPreRelease = constraints.AllowPreRelease, + AllowCustomInstallLocation = constraints.AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. constraints.AllowedInstallLocationPatterns], + AllowCustomParameters = constraints.AllowCustomParameters, + AllowedCustomParameters = [.. constraints.AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. constraints.AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. constraints.DeniedCustomParameters], + AllowPrePostCommands = constraints.AllowPrePostCommands, + AllowKillBeforeOperation = constraints.AllowKillBeforeOperation, + AllowUninstallPrevious = constraints.AllowUninstallPrevious, + AllowUpgrade = constraints.AllowUpgrade, + }; + + private static PolicyConstraints ToDocument(PolicyEditorDraftConstraints draft) => new() + { + AllowInteractive = draft.AllowInteractive, + AllowSkipHashCheck = draft.AllowSkipHashCheck, + AllowPreRelease = draft.AllowPreRelease, + AllowCustomInstallLocation = draft.AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. draft.AllowedInstallLocationPatterns], + AllowCustomParameters = draft.AllowCustomParameters, + AllowedCustomParameters = [.. draft.AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. draft.AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. draft.DeniedCustomParameters], + AllowPrePostCommands = draft.AllowPrePostCommands, + AllowKillBeforeOperation = draft.AllowKillBeforeOperation, + AllowUninstallPrevious = draft.AllowUninstallPrevious, + AllowUpgrade = draft.AllowUpgrade, + }; + + private static PolicyConstraints CloneConstraints(PolicyConstraints constraints) => new() + { + AllowInteractive = constraints.AllowInteractive, + AllowSkipHashCheck = constraints.AllowSkipHashCheck, + AllowPreRelease = constraints.AllowPreRelease, + AllowCustomInstallLocation = constraints.AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. constraints.AllowedInstallLocationPatterns], + AllowCustomParameters = constraints.AllowCustomParameters, + AllowedCustomParameters = [.. constraints.AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. constraints.AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. constraints.DeniedCustomParameters], + AllowPrePostCommands = constraints.AllowPrePostCommands, + AllowKillBeforeOperation = constraints.AllowKillBeforeOperation, + AllowUninstallPrevious = constraints.AllowUninstallPrevious, + AllowUpgrade = constraints.AllowUpgrade, + }; + + // ---- Tri-state boolean-match conversion -------------------------------------------------- + + /// + /// Converts the contract's empty-or-single-value boolean match into a tri-state. + /// + internal static TriState ToTriState(IReadOnlyCollection values) => values.Count switch + { + 0 => TriState.Omitted, + 1 => values.First() ? TriState.True : TriState.False, + _ => throw new InvalidDataException("Boolean policy match arrays may contain at most one value."), + }; + + internal static List FromTriState(TriState state) => state switch + { + TriState.Omitted => [], + TriState.True => [true], + TriState.False => [false], + _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown tri-state value."), + }; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorPolicyContract.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorPolicyContract.cs new file mode 100644 index 0000000000..adee87e014 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorPolicyContract.cs @@ -0,0 +1,41 @@ +using Devolutions.Now.Policy.Model; +using PolicySchemaUris = Devolutions.Now.Policy.Model.SchemaUris; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Single source of truth for the parts of the package broker policy document contract that the +/// editor treats as fixed (non-negotiable) rather than user-editable. These values are intentionally +/// duplicated here (as opposed to being read back from an arbitrary document) so that both the "new +/// policy" template and the strict raw-JSON acceptance path can fail closed: a document that disagrees +/// with any of these constants is rejected outright instead of being silently coerced. +/// +public static class PolicyEditorPolicyContract +{ + /// + /// The only value the editor understands. + /// + public const string PolicyType = "PackageBrokerPolicy"; + + /// + /// The semantic version stamped onto a brand-new policy document created by the editor. + /// Existing documents keep whatever their publisher chose. + /// + public const string InitialPolicyVersion = "1.0.0"; + + /// + /// The only value the editor understands. + /// + public const RulePrecedence FixedRulePrecedence = RulePrecedence.PriorityThenDeny; + + /// + /// The fail-closed default decision applied to brand-new policy documents: deny unless a rule + /// explicitly allows the operation. + /// + public const Decision DefaultTemplateDecision = Decision.Deny; + + /// + /// The only value the editor understands. + /// + public static string Schema => PolicySchemaUris.Policy; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorProductionAdapters.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorProductionAdapters.cs new file mode 100644 index 0000000000..715b23dc36 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorProductionAdapters.cs @@ -0,0 +1,176 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.AgentBroker.PolicyManagement; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Production bridge from the editor-facing seam to +/// . Every +/// outcome that is not "the Agent produced a validation result" is mapped onto the narrower +/// contract via an , so +/// always has something to report instead of a silently +/// empty findings list. +/// +public sealed class BrokerPolicyEditorValidationClient : IPolicyValidationClient +{ + private readonly IBrokerPolicyManagementService _service; + + public BrokerPolicyEditorValidationClient() + : this(new BrokerPolicyManagementService()) + { + } + + public BrokerPolicyEditorValidationClient(IBrokerPolicyManagementService service) + { + _service = service; + } + + public async Task ValidateAsync(JsonElement draft, CancellationToken cancellationToken) + { + BrokerPolicyValidationOutcome outcome = await _service.ValidateAsync(draft, cancellationToken).ConfigureAwait(false); + return outcome.Status switch + { + BrokerPolicyValidationStatus.Completed when outcome.Validation is not null => + BuildCompletedOutcome(outcome), + BrokerPolicyValidationStatus.MalformedDraft => + new PolicyEditorValidationOutcome(null, ErrorCode.MalformedDraft), + BrokerPolicyValidationStatus.RequestTooLarge => + new PolicyEditorValidationOutcome(null, ErrorCode.PayloadTooLarge), + BrokerPolicyValidationStatus.AccessDenied => + new PolicyEditorValidationOutcome(null, ErrorCode.Forbidden), + BrokerPolicyValidationStatus.Unsupported => + new PolicyEditorValidationOutcome(null, ErrorCode.UnsupportedEndpoint), + _ => new PolicyEditorValidationOutcome(null, ErrorCode.InternalError), + }; + } + + private static PolicyEditorValidationOutcome BuildCompletedOutcome( + BrokerPolicyValidationOutcome outcome) + { + if (outcome.Validation is null || outcome.Diagnostics is null) + return new PolicyEditorValidationOutcome(outcome.Validation); + + IReadOnlyList findings = + [ + .. outcome.Diagnostics.Findings.Select(PolicyValidationFinding.FromSanitized), + ]; + int omitted = Math.Max( + 0, + outcome.Validation.Findings.Count - outcome.Diagnostics.Findings.Count); + if (outcome.Diagnostics.FindingsTruncated && omitted == 0) + { + omitted = 1; + } + + return new PolicyEditorValidationOutcome( + outcome.Validation, + BoundedFindings: findings, + OmittedFindingCount: omitted); + } +} + +/// +/// Production bridge from the editor-facing seam to +/// (the Windows elevated-helper write path). +/// Maps the editor's shared / +/// onto the AgentBroker package's own (structurally identical, but distinct) elevation enums, and maps +/// every onto a so the session +/// view model can present a specific, translated failure reason instead of a generic error. +/// +public sealed class WindowsPolicyEditorWriteClient : IPolicyWriteClient +{ + private readonly IPolicyWriteElevator _elevator; + + public WindowsPolicyEditorWriteClient() + : this(CreateDefaultElevator()) + { + } + + public WindowsPolicyEditorWriteClient(IPolicyWriteElevator elevator) + { + _elevator = elevator; + } + + private static IPolicyWriteElevator CreateDefaultElevator() + { +#if WINDOWS + return new WindowsPolicyWriteElevator(); +#else + return new UnsupportedPolicyWriteElevator(); +#endif + } + + public async Task WriteAsync(PolicyEditorWriteRequest request, CancellationToken cancellationToken) + { + var elevationRequest = new PolicyElevationWriteRequest(request.Draft) + { + Operation = MapOperation(request.Operation), + ConflictHandling = MapConflictHandling(request.ConflictHandling), + ExpectedStoreToken = request.ExpectedStoreToken, + ValidationReceipt = request.ValidationReceipt, + WarningsAcknowledged = request.WarningsAcknowledged, + }; + + PolicyElevationResult result; + try + { + result = await _elevator.ReplacePolicyAsync(elevationRequest, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + + if (result.Succeeded && result.Response is not null) + { + return PolicyWriteOutcome.Success(result.Response); + } + + if (result.ErrorMessage is not null) + { + Logger.Warn($"[PolicyEditor] Elevated policy write did not succeed ({result.Outcome}): {result.ErrorMessage}"); + } + + return PolicyWriteOutcome.Failure(MapFailureKind(result.Outcome), result.Error); + } + + private static PolicyWriteFailureKind MapFailureKind(PolicyElevationOutcome outcome) => outcome switch + { + PolicyElevationOutcome.Replaced => PolicyWriteFailureKind.None, + PolicyElevationOutcome.UserDeclinedElevation => PolicyWriteFailureKind.UacCanceled, + PolicyElevationOutcome.UnsupportedPlatform + or PolicyElevationOutcome.HelperUnavailable + or PolicyElevationOutcome.LaunchFailed => PolicyWriteFailureKind.LaunchFailed, + PolicyElevationOutcome.HelperUntrusted + or PolicyElevationOutcome.PeerAuthenticationFailed => PolicyWriteFailureKind.AuthenticationFailed, + PolicyElevationOutcome.PayloadTooLarge + or PolicyElevationOutcome.MalformedResponse + or PolicyElevationOutcome.TimedOut + or PolicyElevationOutcome.ConnectionClosed => PolicyWriteFailureKind.ProtocolFailed, + PolicyElevationOutcome.HelperCrashed => PolicyWriteFailureKind.HelperFailed, + PolicyElevationOutcome.BrokerRejected + or PolicyElevationOutcome.BrokerUnavailable + or PolicyElevationOutcome.BrokerInvalidResponse => PolicyWriteFailureKind.BrokerRejected, + PolicyElevationOutcome.Cancelled => PolicyWriteFailureKind.LaunchFailed, + _ => PolicyWriteFailureKind.HelperFailed, + }; + + private static PolicyElevationOperation MapOperation(PolicyReplacementOperation operation) => operation switch + { + PolicyReplacementOperation.Update => PolicyElevationOperation.Update, + PolicyReplacementOperation.ReplaceIdentity => PolicyElevationOperation.ReplaceIdentity, + PolicyReplacementOperation.Create => PolicyElevationOperation.Create, + PolicyReplacementOperation.Repair => PolicyElevationOperation.Repair, + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null), + }; + + private static PolicyElevationConflictHandling MapConflictHandling(PolicyConflictHandling handling) => handling switch + { + PolicyConflictHandling.Reject => PolicyElevationConflictHandling.Reject, + PolicyConflictHandling.ConfirmOverwrite => PolicyElevationConflictHandling.ConfirmOverwrite, + _ => throw new ArgumentOutOfRangeException(nameof(handling), handling, null), + }; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRawSyntax.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRawSyntax.cs new file mode 100644 index 0000000000..40cace8abc --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRawSyntax.cs @@ -0,0 +1,131 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// A structural failure that prevented raw JSON text from becoming a structured draft. +public sealed record PolicyEditorSyntaxError(string Message, string Pointer); + +/// +/// The two seams between the editor's raw-text surface and its structured surface: +/// (raw -> structured, only for syntactically and structurally valid +/// text) and (structured -> raw, always succeeds). Parsing is strict and +/// fails closed: invalid JSON, JSON that doesn't match the wire shape, or JSON that disagrees with the +/// fixed schema/policy-type/rule-precedence contract (see ) is +/// rejected outright with a and the original raw text is left +/// completely untouched by the caller (this class never mutates or truncates input). Agent-side +/// semantic validation (e.g. whether specific values make operational sense) is intentionally out of +/// scope here — it is external, see . +/// +public static partial class PolicyEditorRawSyntax +{ + public static bool TryParseStrict( + string? rawJson, + out PolicyEditorDraftDocument? draft, + out PolicyEditorSyntaxError? error) + { + draft = null; + error = null; + + if (string.IsNullOrWhiteSpace(rawJson)) + { + error = new PolicyEditorSyntaxError("The document is empty.", ""); + return false; + } + + PolicyDraftDocument? document; + try + { + document = PolicyJson.DeserializePolicyDraftDocumentStrict(rawJson); + } + catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException or NotSupportedException) + { + error = new PolicyEditorSyntaxError(ex.Message, PointerFromException(ex)); + return false; + } + + if (document is null) + { + error = new PolicyEditorSyntaxError("The document could not be parsed.", ""); + return false; + } + + if (!TryCheckFixedContract(document, out error)) + { + return false; + } + + draft = PolicyEditorMapper.ToDraft(document); + return true; + } + + /// + /// Serializes exactly the editable draft shape. Server-managed metadata is never emitted. + /// + public static string ToCanonicalRaw(PolicyEditorDraftDocument draft) => + PolicyJson.Serialize(PolicyEditorMapper.ToSharedDraft(draft)); + + private static bool TryCheckFixedContract(PolicyDraftDocument document, out PolicyEditorSyntaxError? error) + { + if (!string.Equals(document.Schema, PolicyEditorPolicyContract.Schema, StringComparison.Ordinal)) + { + error = new PolicyEditorSyntaxError( + $"Unsupported schema '{document.Schema}'. Expected '{PolicyEditorPolicyContract.Schema}'.", + "/schema"); + return false; + } + + if (!string.Equals(document.PolicyType, PolicyEditorPolicyContract.PolicyType, StringComparison.Ordinal)) + { + error = new PolicyEditorSyntaxError( + $"Unsupported policyType '{document.PolicyType}'. Expected '{PolicyEditorPolicyContract.PolicyType}'.", + "/policyType"); + return false; + } + + if (document.Enforcement is null) + { + error = new PolicyEditorSyntaxError("Missing enforcement block.", "/enforcement"); + return false; + } + + if (document.Enforcement.RulePrecedence != PolicyEditorPolicyContract.FixedRulePrecedence) + { + error = new PolicyEditorSyntaxError( + $"Unsupported rulePrecedence '{document.Enforcement.RulePrecedence}'. Expected '{PolicyEditorPolicyContract.FixedRulePrecedence}'.", + "/enforcement/rulePrecedence"); + return false; + } + + if (document.Metadata is null) + { + error = new PolicyEditorSyntaxError("Missing metadata block.", "/metadata"); + return false; + } + + error = null; + return true; + } + + private static string PointerFromException(Exception ex) => + ex is JsonException { Path: { Length: > 0 } path } ? ConvertJsonPathToPointer(path) : ""; + + /// Converts a System.Text.Json exception path (e.g. $.rules[0].match.versions[1]) + /// into an RFC 6901 JSON Pointer (e.g. /rules/0/match/versions/1). + private static string ConvertJsonPathToPointer(string path) + { + StringBuilder builder = new(); + foreach (Match match in JsonPathSegment().Matches(path)) + { + string segment = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + builder.Append('/').Append(segment.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal)); + } + + return builder.ToString(); + } + + [GeneratedRegex(@"\.([A-Za-z_][A-Za-z0-9_]*)|\[(\d+)\]", RegexOptions.CultureInvariant)] + private static partial Regex JsonPathSegment(); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRetry.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRetry.cs new file mode 100644 index 0000000000..64c42b127b --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRetry.cs @@ -0,0 +1,74 @@ +using Devolutions.Now.Policy.Api; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public sealed record PolicyEditorRetryDecision( + PolicyReplacementOperation Operation, + string Token, + PolicyManagementState State, + string? ActivePolicyId); + +public sealed record PolicyEditorConfirmationContext( + PolicyReplacementOperation Operation, + PolicyManagementState State, + string? ActivePolicyId, + string Token, + string DraftId) +{ + public static PolicyEditorConfirmationContext For( + PolicyEditorRetryDecision decision, + string draftId) => + new(decision.Operation, decision.State, decision.ActivePolicyId, decision.Token, draftId); +} + +public static class PolicyEditorRetryResolver +{ + public static PolicyEditorRetryDecision Resolve( + string draftId, + PolicyManagementSnapshot management) + { + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + ArgumentNullException.ThrowIfNull(management); + ArgumentException.ThrowIfNullOrWhiteSpace(management.StoreToken); + + return management.State switch + { + PolicyManagementState.Active when management.Policy is not null + && string.Equals( + management.Policy.Metadata.Id, + draftId, + StringComparison.Ordinal) => + new( + PolicyReplacementOperation.Update, + management.StoreToken, + management.State, + management.Policy.Metadata.Id), + PolicyManagementState.Active when management.Policy is not null => + new( + PolicyReplacementOperation.ReplaceIdentity, + management.StoreToken, + management.State, + management.Policy.Metadata.Id), + PolicyManagementState.Missing => + new( + PolicyReplacementOperation.Create, + management.StoreToken, + management.State, + null), + PolicyManagementState.Invalid => + new( + PolicyReplacementOperation.Repair, + management.StoreToken, + management.State, + null), + _ => throw new InvalidDataException( + "The management snapshot is inconsistent with its policy state."), + }; + } + + public static bool RequiresFreshConfirmation( + PolicyEditorConfirmationContext? existing, + PolicyEditorRetryDecision decision, + string draftId) => + existing != PolicyEditorConfirmationContext.For(decision, draftId); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSession.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSession.cs new file mode 100644 index 0000000000..545d9a222c --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSession.cs @@ -0,0 +1,502 @@ +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public sealed record PolicyEditorValidationState( + string SubmittedRawJson, + PolicyDraftDocument CanonicalDraft, + string Receipt, + PolicyEditorFindingIndex Findings) +{ + public bool HasWarnings => + Findings.All.Any(finding => finding.Severity == PolicyValidationSeverity.Warning); +} + +public sealed record PolicyEditorWarningAcknowledgement( + string CanonicalRawJson, + string Receipt, + IReadOnlyList WarningKeys); + +public sealed record PolicyEditorConflictSnapshot( + string SubmittedCanonicalRawJson, + string ValidationReceipt, + string DraftId, + long MutationGeneration, + PolicyManagementSnapshot Management, + PolicyEditorRetryDecision RetryDecision, + DateTimeOffset DetectedAt); + +public sealed class PolicyEditorSession +{ + private string _baselineRawJson; + private long _mutationGeneration; + + public PolicyEditorOperationKind Operation { get; private set; } + + public PolicyManagementSnapshot OriginManagement { get; private set; } + + public PolicyEditorDraftDocument Draft { get; private set; } + + public string RawBuffer { get; private set; } + + public PolicyEditorMode Mode { get; private set; } + + public PolicyEditorValidationState? Validation { get; private set; } + + public PolicyEditorFindingIndex Findings { get; private set; } = + PolicyEditorFindingIndex.Build([]); + + public PolicyEditorWarningAcknowledgement? WarningAcknowledgement { get; private set; } + + public PolicyEditorConflictSnapshot? Conflict { get; private set; } + + public long MutationGeneration => _mutationGeneration; + + public bool IsIdentityLocked => Operation == PolicyEditorOperationKind.Update; + + public bool IsDirty => + !string.Equals(GetEffectiveRawJson(), _baselineRawJson, StringComparison.Ordinal); + + public bool IsValidationCurrent => + Validation is not null + && string.Equals( + Validation.SubmittedRawJson, + GetEffectiveRawJson(), + StringComparison.Ordinal); + + public bool HasCurrentWarningAcknowledgement + { + get + { + if (Validation is null || WarningAcknowledgement is null) + return false; + + string canonical = PolicyJson.Serialize(Validation.CanonicalDraft); + return string.Equals( + WarningAcknowledgement.CanonicalRawJson, + canonical, + StringComparison.Ordinal) + && string.Equals( + WarningAcknowledgement.Receipt, + Validation.Receipt, + StringComparison.Ordinal) + && WarningAcknowledgement.WarningKeys.SequenceEqual( + GetWarningKeys(Validation.Findings), + StringComparer.Ordinal); + } + } + + private PolicyEditorSession( + PolicyEditorOperationKind operation, + PolicyManagementSnapshot originManagement, + PolicyEditorDraftDocument draft) + { + ArgumentException.ThrowIfNullOrWhiteSpace(originManagement.StoreToken); + Operation = operation; + OriginManagement = PolicyEditorMapper.CloneManagementSnapshot(originManagement); + Draft = draft.Clone(); + Mode = PolicyEditorMode.Structured; + RawBuffer = PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + _baselineRawJson = RawBuffer; + } + + public static PolicyEditorSession StartUpdate(PolicyManagementSnapshot management) + { + RequireState(management, PolicyManagementState.Active); + return new( + PolicyEditorOperationKind.Update, + management, + PolicyEditorMapper.ToDraft(management.Policy!)); + } + + public static PolicyEditorSession StartReplaceIdentity( + PolicyManagementSnapshot management, + PolicyEditorDraftDocument draft) + { + RequireState(management, PolicyManagementState.Active); + if (string.Equals( + management.Policy!.Metadata.Id, + draft.Metadata.Id, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "A replacement policy must use a different identity.", + nameof(draft)); + } + + return new(PolicyEditorOperationKind.ReplaceIdentity, management, draft); + } + + public static PolicyEditorSession StartCreate( + PolicyManagementSnapshot management, + PolicyEditorDraftDocument draft) + { + RequireState(management, PolicyManagementState.Missing); + return new(PolicyEditorOperationKind.Create, management, draft); + } + + public static PolicyEditorSession StartRepair( + PolicyManagementSnapshot management, + PolicyEditorDraftDocument draft) + { + RequireState(management, PolicyManagementState.Invalid); + return new(PolicyEditorOperationKind.Repair, management, draft); + } + + public void SwitchToRaw() + { + RawBuffer = PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + Mode = PolicyEditorMode.Raw; + InvalidateContentState(); + } + + public void SetRawBuffer(string rawText) + { + if (Mode != PolicyEditorMode.Raw) + throw new InvalidOperationException("The session is not in raw mode."); + + string previousRaw = RawBuffer; + bool formattingOnly = + TryCanonicalizeRaw(previousRaw, out string? previousCanonical, out _) + && TryCanonicalizeRaw(rawText ?? "", out string? nextCanonical, out _) + && string.Equals(previousCanonical, nextCanonical, StringComparison.Ordinal); + RawBuffer = rawText ?? ""; + if (formattingOnly) + { + if (Validation is not null + && string.Equals( + Validation.SubmittedRawJson, + previousRaw, + StringComparison.Ordinal)) + { + Validation = Validation with { SubmittedRawJson = RawBuffer }; + } + return; + } + + InvalidateContentState(); + } + + public bool TryParseRaw( + out PolicyEditorDraftDocument? parsed, + out PolicyEditorSyntaxError? error) => + PolicyEditorRawSyntax.TryParseStrict(RawBuffer, out parsed, out error); + + public void AcceptValidatedRaw( + string submittedRawJson, + PolicyValidationResult validation) + { + ApplyValidationResult(submittedRawJson, validation); + if (Validation is null) + throw new InvalidOperationException( + "Only an authoritative valid result can enter structured mode."); + + Draft = PolicyEditorMapper.ToDraft(Validation.CanonicalDraft); + RawBuffer = PolicyJson.Serialize(Validation.CanonicalDraft); + Mode = PolicyEditorMode.Structured; + } + + public string GetEffectiveRawJson() => + Mode == PolicyEditorMode.Raw + ? RawBuffer + : PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + + public void NotifyDraftChanged() => InvalidateContentState(); + + public PolicyEditorDraftRule AddRule(PolicyEditorDraftRule? rule = null) + { + EnsureStructuredMode(); + PolicyEditorDraftRule newRule = rule ?? PolicyRuleFactory.CreateBlank(); + PolicyRuleListOperations.Add(Draft.Rules, newRule); + InvalidateContentState(); + return newRule; + } + + public void EditRule(string id, Action mutate) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Edit(Draft.Rules, id, mutate); + InvalidateContentState(); + } + + public string DuplicateRule(string id, string? newId = null) + { + EnsureStructuredMode(); + string result = PolicyRuleListOperations.Duplicate(Draft.Rules, id, newId); + InvalidateContentState(); + return result; + } + + public void SetRuleEnabled(string id, bool enabled) + { + EnsureStructuredMode(); + PolicyRuleListOperations.SetEnabled(Draft.Rules, id, enabled); + InvalidateContentState(); + } + + public void DeleteRule(string id) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Delete(Draft.Rules, id); + InvalidateContentState(); + } + + public void MoveRule(string id, int newIndex) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Move(Draft.Rules, id, newIndex); + InvalidateContentState(); + } + + public void SetRulePriority(string id, uint priority) + { + EnsureStructuredMode(); + PolicyRuleListOperations.SetPriority(Draft.Rules, id, priority); + InvalidateContentState(); + } + + public void ApplyValidationResult( + string submittedRawJson, + PolicyValidationResult validation, + IReadOnlyList? boundedFindings = null, + int omittedFindingCount = 0) + { + ArgumentNullException.ThrowIfNull(submittedRawJson); + ArgumentNullException.ThrowIfNull(validation); + + if (boundedFindings is not null) + { + Findings = PolicyEditorFindingIndex.Build(boundedFindings, omittedFindingCount); + } + else + { + int take = Math.Min( + validation.Findings.Count, + PolicyEditorFindingIndex.MaxDisplayedFindings); + var sanitized = new List(take); + for (int index = 0; index < take; index++) + { + sanitized.Add(PolicyValidationFinding.FromShared(validation.Findings[index])); + } + + Findings = PolicyEditorFindingIndex.Build( + sanitized, + validation.Findings.Count - take); + } + WarningAcknowledgement = null; + + if (!validation.IsValid + || validation.CanonicalDraft is null + || string.IsNullOrWhiteSpace(validation.ValidationReceipt)) + { + Validation = null; + return; + } + + Validation = new PolicyEditorValidationState( + submittedRawJson, + PolicyEditorMapper.CloneDraftDocument(validation.CanonicalDraft), + validation.ValidationReceipt, + Findings); + Operation = ResolveOperationForDraftId(validation.CanonicalDraft.Metadata.Id); + } + + public void AcknowledgeWarnings() + { + if (Validation is null || !Validation.HasWarnings) + throw new InvalidOperationException("There are no current validated warnings."); + + WarningAcknowledgement = new PolicyEditorWarningAcknowledgement( + PolicyJson.Serialize(Validation.CanonicalDraft), + Validation.Receipt, + GetWarningKeys(Validation.Findings)); + } + + public void CaptureConflict( + PolicyManagementSnapshot management, + PolicyDraftDocument submittedCanonicalDraft, + string validationReceipt, + string draftId) + { + ArgumentNullException.ThrowIfNull(submittedCanonicalDraft); + ArgumentException.ThrowIfNullOrWhiteSpace(validationReceipt); + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + string submittedCanonicalRawJson = PolicyJson.Serialize(submittedCanonicalDraft); + PolicyEditorRetryDecision decision = + PolicyEditorRetryResolver.Resolve(draftId, management); + Conflict = new PolicyEditorConflictSnapshot( + submittedCanonicalRawJson, + validationReceipt, + draftId, + _mutationGeneration, + PolicyEditorMapper.CloneManagementSnapshot(management), + decision, + DateTimeOffset.UtcNow); + } + + public void ClearConflict() => Conflict = null; + + public bool IsConflictCurrent(PolicyEditorConflictSnapshot conflict) + { + ArgumentNullException.ThrowIfNull(conflict); + if (!ReferenceEquals(Conflict, conflict) + || conflict.MutationGeneration != _mutationGeneration) + { + return false; + } + + return TryGetCanonicalEffectiveRaw(out string? canonical, out string? draftId) + && string.Equals( + canonical, + conflict.SubmittedCanonicalRawJson, + StringComparison.Ordinal) + && string.Equals(draftId, conflict.DraftId, StringComparison.Ordinal); + } + + public void MarkSaved(PolicyReplacementResponse response) + { + ArgumentNullException.ThrowIfNull(response); + if (response.Management.State != PolicyManagementState.Active + || response.Management.Policy is null + || string.IsNullOrWhiteSpace(response.Management.StoreToken)) + { + throw new InvalidDataException( + "A successful replacement did not return an active management snapshot."); + } + + PolicyDocument authoritative = PolicyEditorMapper.CloneDocument(response.Policy); + OriginManagement = PolicyEditorMapper.CloneManagementSnapshot(response.Management); + Operation = PolicyEditorOperationKind.Update; + Draft = PolicyEditorMapper.ToDraft(authoritative); + RawBuffer = PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + Mode = PolicyEditorMode.Structured; + _baselineRawJson = RawBuffer; + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + WarningAcknowledgement = null; + Conflict = null; + } + + public void MarkSavedPreservingCurrentDraft(PolicyReplacementResponse response) + { + ArgumentNullException.ThrowIfNull(response); + if (response.Management.State != PolicyManagementState.Active + || response.Management.Policy is null + || string.IsNullOrWhiteSpace(response.Management.StoreToken)) + { + throw new InvalidDataException( + "A successful replacement did not return an active management snapshot."); + } + + OriginManagement = PolicyEditorMapper.CloneManagementSnapshot(response.Management); + Operation = TryGetCanonicalEffectiveRaw(out _, out string? currentDraftId) + ? ResolveOperationForDraftId(currentDraftId!) + : PolicyEditorOperationKind.Update; + _baselineRawJson = PolicyEditorRawSyntax.ToCanonicalRaw( + PolicyEditorMapper.ToDraft(response.Policy)); + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + WarningAcknowledgement = null; + Conflict = null; + } + + public PolicyEditorOperationKind ResolveOperationForDraftId(string draftId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + return OriginManagement.State switch + { + PolicyManagementState.Active + when string.Equals( + OriginManagement.Policy!.Metadata.Id, + draftId, + StringComparison.Ordinal) => + PolicyEditorOperationKind.Update, + PolicyManagementState.Active => PolicyEditorOperationKind.ReplaceIdentity, + PolicyManagementState.Missing => PolicyEditorOperationKind.Create, + PolicyManagementState.Invalid => PolicyEditorOperationKind.Repair, + _ => throw new InvalidDataException("The policy management state is not supported."), + }; + } + + private void InvalidateContentState() + { + _mutationGeneration++; + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + WarningAcknowledgement = null; + Conflict = null; + } + + private bool TryGetCanonicalEffectiveRaw( + out string? canonicalRawJson, + out string? draftId) + { + string effectiveRawJson = GetEffectiveRawJson(); + if (Validation is not null + && string.Equals( + Validation.SubmittedRawJson, + effectiveRawJson, + StringComparison.Ordinal)) + { + canonicalRawJson = PolicyJson.Serialize(Validation.CanonicalDraft); + draftId = Validation.CanonicalDraft.Metadata.Id; + return true; + } + + return TryCanonicalizeRaw(effectiveRawJson, out canonicalRawJson, out draftId); + } + + private static bool TryCanonicalizeRaw( + string rawJson, + out string? canonicalRawJson, + out string? draftId) + { + canonicalRawJson = null; + draftId = null; + if (!PolicyEditorRawSyntax.TryParseStrict( + rawJson, + out PolicyEditorDraftDocument? parsed, + out _) + || parsed is null) + { + return false; + } + + PolicyDraftDocument shared = PolicyEditorMapper.ToSharedDraft(parsed); + canonicalRawJson = PolicyJson.Serialize(shared); + draftId = shared.Metadata.Id; + return true; + } + + private void EnsureStructuredMode() + { + if (Mode != PolicyEditorMode.Structured) + throw new InvalidOperationException("Rule edits require structured mode."); + } + + private static IReadOnlyList GetWarningKeys( + PolicyEditorFindingIndex findings) => + findings.All + .Where(finding => finding.Severity == PolicyValidationSeverity.Warning) + .Select(finding => + $"{finding.Code}\u001f{finding.Pointer}\u001f{finding.RuleId}") + .Order(StringComparer.Ordinal) + .ToArray(); + + private static void RequireState( + PolicyManagementSnapshot management, + PolicyManagementState expected) + { + ArgumentNullException.ThrowIfNull(management); + ArgumentException.ThrowIfNullOrWhiteSpace(management.StoreToken); + if (management.State != expected) + throw new ArgumentException( + $"Expected a {expected} management snapshot.", + nameof(management)); + if (expected == PolicyManagementState.Active && management.Policy is null) + throw new ArgumentException( + "An active management snapshot requires a policy.", + nameof(management)); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionCloseGuard.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionCloseGuard.cs new file mode 100644 index 0000000000..f5a8ba39ca --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionCloseGuard.cs @@ -0,0 +1,92 @@ +using Avalonia.Automation; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Shared "can we close/navigate away now" guard for every surface that hosts a +/// (the modal PolicyEditorDialog window's own +/// Closing event, and AgentPolicyInspector's IAsyncLeaveGuard.CanLeaveAsync for +/// page navigation/app shutdown while the dialog is open). +/// +/// +/// Blocker 31: closing/navigating/quitting while a remote policy operation +/// (validate/save/overwrite/raw-validation, and transitively the elevated-helper write exchange) is +/// in flight must first try to cancel that operation and wait a bounded amount of time for +/// it to actually settle, instead of either abruptly tearing down the session mid-flight or leaving +/// the caller no better off than an unconditional refusal. If the in-flight operation does not settle +/// within (e.g. the elevated helper is unresponsive), the guard +/// reports failure so the caller can refuse the close/leave with an accessible busy status instead of +/// silently discarding a session a command could still be mutating. +/// +public static class PolicyEditorSessionCloseGuard +{ + /// + /// How long to wait for a canceled in-flight operation to actually observe the cancellation and + /// unwind (release ) before giving up and treating + /// the session as still busy. + /// + public static readonly TimeSpan DefaultCancelWaitTimeout = TimeSpan.FromSeconds(5); + + /// + /// If currently has a remote operation in flight, requests its + /// cancellation and waits up to for it to settle. + /// + /// + /// if no operation was running, or the running operation settled within + /// ; if it is still running once the bound + /// elapses (the caller must refuse to close/leave and must not dispose or clear the session). + /// + public static async Task TryCancelActiveOperationAsync( + PolicyEditorSessionViewModel session, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + IAsyncRelayCommand? running = FindRunningCommand(session); + if (running is null) + return true; + + if (running.CanBeCanceled) + { + running.Cancel(); + } + + Task? executionTask = running.ExecutionTask; + if (executionTask is null || executionTask.IsCompleted) + return true; + + Task delay = Task.Delay(timeout, cancellationToken); + Task completed = await Task.WhenAny(executionTask, delay).ConfigureAwait(false); + if (completed != executionTask) + return false; + + // Accessing Exception observes a fault without turning close handling into an error sink. + _ = executionTask.Exception; + + return true; + } + + private static IAsyncRelayCommand? FindRunningCommand(PolicyEditorSessionViewModel session) + { + if (session.ValidateCommand.IsRunning) return session.ValidateCommand; + if (session.SaveCommand.IsRunning) return session.SaveCommand; + if (session.ConfirmOverwriteCommand.IsRunning) return session.ConfirmOverwriteCommand; + if (session.SwitchToStructuredCommand.IsRunning) return session.SwitchToStructuredCommand; + return null; + } + + /// + /// Announces (accessibly, via ) that a close or + /// navigate-away request was refused because the in-flight policy operation could not be + /// canceled within . Call this whenever + /// returns . + /// + public static void AnnounceCloseBlockedByBusyOperation() + { + AccessibilityAnnouncementService.Announce( + CoreTools.Translate("The current policy operation could not be canceled in time. Please wait, then try closing again."), + AutomationLiveSetting.Assertive); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionViewModel.cs new file mode 100644 index 0000000000..04c916fc8c --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionViewModel.cs @@ -0,0 +1,656 @@ +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Model; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +public partial class PolicyEditorSessionViewModel : ViewModelBase, IDisposable +{ + private readonly IPolicyValidationClient _validationClient; + private readonly IPolicyEditorConfirmationPrompt _confirmationPrompt; + private readonly IPolicyWriteClient _writeClient; + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly Dictionary _localInputErrors = []; + private long _validationGeneration; + private int _isDisposed; + + public PolicyEditorSession Session { get; } + + public PolicyEditorDraftDocument Draft => Session.Draft; + public IReadOnlyList Rules => Session.Draft.Rules; + public PolicyEditorOperationKind Operation => Session.Operation; + public bool IsStructuredMode => Session.Mode == PolicyEditorMode.Structured; + public bool IsRawMode => Session.Mode == PolicyEditorMode.Raw; + public bool IsDirty => Session.IsDirty || HasLocalInputErrors; + public bool IsIdentityLocked => Session.IsIdentityLocked; + public bool HasFindings => Session.Findings.All.Count > 0; + public bool HasConflict => Session.Conflict is not null; + public IReadOnlyList Findings => Session.Findings.All; + public bool HasLocalInputErrors => _localInputErrors.Count > 0; + public string LocalInputErrorSummary => string.Join(Environment.NewLine, _localInputErrors.Values); + public bool CanValidateOrSave => CanStartRemoteOperation(); + public bool CanSwitchToRaw => CanStartStructuredOperation(); + + public string RawBuffer + { + get => Session.RawBuffer; + set + { + if (Session.Mode != PolicyEditorMode.Raw + || string.Equals(Session.RawBuffer, value, StringComparison.Ordinal)) + return; + + Session.SetRawBuffer(value); + PolicyEditorRawSyntax.TryParseStrict(value, out _, out PolicyEditorSyntaxError? error); + SyntaxError = error; + OnEditorStateChanged(); + } + } + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private PolicyEditorSyntaxError? _syntaxError; + [ObservableProperty] private bool _lastSaveSucceeded; + [ObservableProperty] private bool _savedWithNewerChanges; + [ObservableProperty] private ErrorCode? _lastErrorCode; + [ObservableProperty] private PolicyWriteFailureKind _lastWriteFailureKind; + + public PolicyEditorSessionViewModel( + PolicyEditorSession session, + IPolicyValidationClient validationClient, + IPolicyEditorConfirmationPrompt confirmationPrompt, + IPolicyWriteClient writeClient) + { + Session = session; + _validationClient = validationClient; + _confirmationPrompt = confirmationPrompt; + _writeClient = writeClient; + } + + [RelayCommand(CanExecute = nameof(CanStartStructuredOperation))] + private void SwitchToRaw() + { + Session.SwitchToRaw(); + SyntaxError = null; + OnEditorStateChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task SwitchToStructuredAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + if (!Session.TryParseRaw(out _, out PolicyEditorSyntaxError? syntaxError)) + { + SyntaxError = syntaxError; + return; + } + + string submitted = Session.RawBuffer; + long generation = Interlocked.Increment(ref _validationGeneration); + PolicyEditorValidationOutcome outcome = + await ValidateCoreAsync(submitted, cancellationToken); + if (!CanApply(cancellationToken) + || generation != Volatile.Read(ref _validationGeneration) + || !string.Equals(Session.RawBuffer, submitted, StringComparison.Ordinal)) + return; + + if (outcome.Validation is not { IsValid: true, CanonicalDraft: not null }) + { + if (outcome.Validation is not null) + Session.ApplyValidationResult( + submitted, + outcome.Validation, + outcome.BoundedFindings, + outcome.OmittedFindingCount); + LastErrorCode = outcome.ErrorCode; + OnEditorStateChanged(); + return; + } + + Session.AcceptValidatedRaw(submitted, outcome.Validation); + SyntaxError = null; + LastErrorCode = null; + OnEditorStateChanged(); + } + + [RelayCommand] + private void NotifyDraftChanged() + { + if (Session.IsIdentityLocked + && Session.OriginManagement.Policy is { } origin + && !string.Equals( + Session.Draft.Metadata.Id, + origin.Metadata.Id, + StringComparison.Ordinal)) + { + Session.Draft.Metadata.Id = origin.Metadata.Id; + } + + Session.NotifyDraftChanged(); + OnEditorStateChanged(); + } + + public void NotifyLocalInputChanged() + { + Session.NotifyDraftChanged(); + OnEditorStateChanged(); + } + + public void SetLocalInputError(object key, string? message) + { + ArgumentNullException.ThrowIfNull(key); + if (string.IsNullOrEmpty(message)) + _localInputErrors.Remove(key); + else + _localInputErrors[key] = message; + + OnPropertyChanged(nameof(HasLocalInputErrors)); + OnPropertyChanged(nameof(LocalInputErrorSummary)); + OnPropertyChanged(nameof(IsDirty)); + OnPropertyChanged(nameof(CanValidateOrSave)); + OnPropertyChanged(nameof(CanSwitchToRaw)); + NotifyCommandStates(); + } + + [RelayCommand] + private void AddRule() + { + Session.AddRule(); + OnEditorStateChanged(); + } + + [RelayCommand] + private void DuplicateRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.DuplicateRule(rule.Id); + OnEditorStateChanged(); + } + + [RelayCommand] + private void ToggleRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.SetRuleEnabled(rule.Id, !rule.Enabled); + OnEditorStateChanged(); + } + + [RelayCommand] + private void DeleteRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.DeleteRule(rule.Id); + OnEditorStateChanged(); + } + + [RelayCommand] + private void MoveRuleUp(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + int index = Session.Draft.Rules.IndexOf(rule); + Session.MoveRule(rule.Id, index - 1); + OnEditorStateChanged(); + } + + [RelayCommand] + private void MoveRuleDown(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + int index = Session.Draft.Rules.IndexOf(rule); + Session.MoveRule(rule.Id, index + 1); + OnEditorStateChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task ValidateAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + string raw = Session.GetEffectiveRawJson(); + if (!TryGetDraftElement(raw, out JsonElement draft, out PolicyEditorSyntaxError? error)) + { + SyntaxError = error; + return; + } + + long generation = Interlocked.Increment(ref _validationGeneration); + PolicyEditorValidationOutcome outcome = + await ValidateCoreAsync(draft, cancellationToken); + if (!CanApply(cancellationToken) + || generation != Volatile.Read(ref _validationGeneration) + || !string.Equals(Session.GetEffectiveRawJson(), raw, StringComparison.Ordinal)) + return; + + if (outcome.Validation is not null) + Session.ApplyValidationResult( + raw, + outcome.Validation, + outcome.BoundedFindings, + outcome.OmittedFindingCount); + LastErrorCode = outcome.ErrorCode; + SyntaxError = null; + OnEditorStateChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task SaveAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + await SaveCoreAsync( + conflict: null, + PolicyConflictHandling.Reject, + cancellationToken); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task ConfirmOverwriteAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + if (!CanStartRemoteOperation()) return; + + PolicyEditorConflictSnapshot? conflict = Session.Conflict; + if (conflict is null || !Session.IsConflictCurrent(conflict)) + { + Session.ClearConflict(); + OnEditorStateChanged(); + return; + } + + var confirmation = new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.ConfirmOverwrite, + conflict.RetryDecision.Operation, + conflict.DraftId, + conflict.RetryDecision.Token, + conflict.RetryDecision.State, + conflict.RetryDecision.ActivePolicyId, + Findings); + bool confirmed; + IsBusy = true; + try + { + confirmed = await _confirmationPrompt.ConfirmAsync(confirmation, cancellationToken); + } + finally + { + IsBusy = false; + } + if (!confirmed || !CanApply(cancellationToken)) return; + + if (!CanApply(cancellationToken) || !Session.IsConflictCurrent(conflict)) + { + Session.ClearConflict(); + OnEditorStateChanged(); + return; + } + + await SaveCoreAsync( + conflict, + PolicyConflictHandling.ConfirmOverwrite, + cancellationToken); + } + + public async Task ConfirmDiscardAsync( + CancellationToken cancellationToken = default) + { + if (IsBusy) + { + StatusMessage = CoreTools.Translate( + "Please wait for the current policy operation to finish before closing."); + return false; + } + + if (!IsDirty) + return true; + + PolicyReplacementOperation operation = GetInitialOperation(); + return await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.DiscardChanges, + operation, + Session.Draft.Metadata.Id, + Session.OriginManagement.StoreToken, + Session.OriginManagement.State, + Session.OriginManagement.Policy?.Metadata.Id, + Findings), + cancellationToken); + } + + private async Task SaveCoreAsync( + PolicyEditorConflictSnapshot? conflict, + PolicyConflictHandling conflictHandling, + CancellationToken cancellationToken) + { + if (!CanStartRemoteOperation()) return; + + IsBusy = true; + LastSaveSucceeded = false; + SavedWithNewerChanges = false; + LastErrorCode = null; + LastWriteFailureKind = PolicyWriteFailureKind.None; + try + { + string submitted = Session.GetEffectiveRawJson(); + long attemptGeneration = Session.MutationGeneration; + + // Correction #14: reuse the exact current validation (same receipt/CanonicalDraft) when + // it still matches the unchanged draft/raw, instead of re-validating on every Save. A + // stale-token retry (ConfirmOverwrite) always revalidates to obtain a current receipt + // per correction #16, since the previously submitted receipt was already rejected by the + // write that produced the conflict. + PolicyEditorValidationState? validation = + conflictHandling != PolicyConflictHandling.ConfirmOverwrite && Session.IsValidationCurrent + ? Session.Validation + : null; + + if (validation is null) + { + if (!TryGetDraftElement( + submitted, + out JsonElement submittedElement, + out PolicyEditorSyntaxError? error)) + { + SyntaxError = error; + return; + } + + PolicyEditorValidationOutcome validationOutcome = + await _validationClient.ValidateAsync(submittedElement, cancellationToken); + if (!CanApply(cancellationToken) + || Session.MutationGeneration != attemptGeneration) + return; + if (validationOutcome.Validation is null) + { + LastErrorCode = validationOutcome.ErrorCode; + return; + } + + Session.ApplyValidationResult( + submitted, + validationOutcome.Validation, + validationOutcome.BoundedFindings, + validationOutcome.OmittedFindingCount); + OnEditorStateChanged(); + validation = Session.Validation; + if (validation is null) + return; + } + + string canonicalRaw = PolicyJson.Serialize(validation.CanonicalDraft); + + PolicyReplacementOperation operation; + string token; + PolicyManagementState state; + string? activePolicyId; + if (conflictHandling == PolicyConflictHandling.ConfirmOverwrite) + { + if (conflict is null + || !Session.IsConflictCurrent(conflict) + || !string.Equals( + canonicalRaw, + conflict.SubmittedCanonicalRawJson, + StringComparison.Ordinal) + || !string.Equals( + validation.CanonicalDraft.Metadata.Id, + conflict.DraftId, + StringComparison.Ordinal)) + { + Session.ClearConflict(); + return; + } + PolicyEditorRetryDecision decision = + PolicyEditorRetryResolver.Resolve( + validation.CanonicalDraft.Metadata.Id, + conflict.Management); + operation = decision.Operation; + token = decision.Token; + state = decision.State; + activePolicyId = decision.ActivePolicyId; + } + else + { + operation = ToReplacementOperation( + Session.ResolveOperationForDraftId(validation.CanonicalDraft.Metadata.Id)); + token = Session.OriginManagement.StoreToken; + state = Session.OriginManagement.State; + activePolicyId = Session.OriginManagement.Policy?.Metadata.Id; + } + + if (validation.HasWarnings && !Session.HasCurrentWarningAcknowledgement) + { + bool acknowledged = await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.Warnings, + operation, + validation.CanonicalDraft.Metadata.Id, + token, + state, + activePolicyId, + validation.Findings.All), + cancellationToken); + if (!CanApply(cancellationToken)) return; + if (Session.MutationGeneration != attemptGeneration) return; + if (!acknowledged) + return; + Session.AcknowledgeWarnings(); + } + + PolicyEditorConfirmationKind? operationConfirmation = + conflictHandling == PolicyConflictHandling.ConfirmOverwrite + ? null + : operation switch + { + PolicyReplacementOperation.ReplaceIdentity => + PolicyEditorConfirmationKind.ReplaceIdentity, + PolicyReplacementOperation.Create => + PolicyEditorConfirmationKind.Create, + PolicyReplacementOperation.Repair => + PolicyEditorConfirmationKind.Repair, + _ => null, + }; + if (operationConfirmation is { } kind + && !await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + kind, + operation, + validation.CanonicalDraft.Metadata.Id, + token, + state, + activePolicyId, + validation.Findings.All), + cancellationToken)) + return; + if (!CanApply(cancellationToken)) return; + if (Session.MutationGeneration != attemptGeneration) return; + + using JsonDocument canonicalDocument = JsonDocument.Parse(canonicalRaw); + var request = new PolicyEditorWriteRequest( + operation, + conflictHandling, + token, + canonicalDocument.RootElement.Clone(), + validation.Receipt, + validation.HasWarnings && Session.HasCurrentWarningAcknowledgement); + PolicyWriteOutcome write = + await _writeClient.WriteAsync(request, cancellationToken); + if (!CanApply(cancellationToken)) return; + + if (Session.MutationGeneration != attemptGeneration) + { + if (write.Response is not null) + { + Session.MarkSavedPreservingCurrentDraft(write.Response); + SavedWithNewerChanges = true; + LastSaveSucceeded = true; + OnEditorStateChanged(); + } + + return; + } + + if (conflictHandling == PolicyConflictHandling.ConfirmOverwrite + && (conflict is null || !Session.IsConflictCurrent(conflict))) + { + Session.ClearConflict(); + return; + } + + if (write.Response is not null) + { + Session.MarkSaved(write.Response); + SavedWithNewerChanges = false; + LastSaveSucceeded = true; + StatusMessage = ""; + OnEditorStateChanged(); + return; + } + + LastWriteFailureKind = write.FailureKind; + LastErrorCode = write.Error?.Code; + if (write.Error is + { + Code: ErrorCode.StalePolicyStoreToken, + Management: not null, + }) + { + Session.CaptureConflict( + write.Error.Management, + validation.CanonicalDraft, + validation.Receipt, + validation.CanonicalDraft.Metadata.Id); + } + OnEditorStateChanged(); + } + finally + { + StatusMessage = ""; + IsBusy = false; + } + } + + private async Task ValidateCoreAsync( + string rawJson, + CancellationToken cancellationToken) + { + if (!TryGetDraftElement( + rawJson, + out JsonElement draft, + out PolicyEditorSyntaxError? error)) + { + SyntaxError = error; + return new PolicyEditorValidationOutcome(null); + } + + return await ValidateCoreAsync(draft, cancellationToken); + } + + private async Task ValidateCoreAsync( + JsonElement draft, + CancellationToken cancellationToken) + { + IsBusy = true; + try + { + return await _validationClient.ValidateAsync(draft, cancellationToken); + } + finally + { + StatusMessage = ""; + IsBusy = false; + } + } + + private bool CanStartRemoteOperation() => + Volatile.Read(ref _isDisposed) == 0 + && !IsBusy + && !HasLocalInputErrors + && SyntaxError is null; + + private bool CanStartStructuredOperation() => + Volatile.Read(ref _isDisposed) == 0 + && !IsBusy + && !HasLocalInputErrors; + + private bool CanApply(CancellationToken cancellationToken) => + Volatile.Read(ref _isDisposed) == 0 && !cancellationToken.IsCancellationRequested; + + private CancellationTokenSource CreateLinkedCancellation(CancellationToken cancellationToken) => + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetimeCancellation.Token); + + partial void OnIsBusyChanged(bool value) => NotifyCommandStates(); + + partial void OnSyntaxErrorChanged(PolicyEditorSyntaxError? value) => NotifyCommandStates(); + + private void NotifyCommandStates() + { + OnPropertyChanged(nameof(CanValidateOrSave)); + OnPropertyChanged(nameof(CanSwitchToRaw)); + SwitchToRawCommand.NotifyCanExecuteChanged(); + SwitchToStructuredCommand.NotifyCanExecuteChanged(); + ValidateCommand.NotifyCanExecuteChanged(); + SaveCommand.NotifyCanExecuteChanged(); + ConfirmOverwriteCommand.NotifyCanExecuteChanged(); + } + + private static bool TryGetDraftElement( + string raw, + out JsonElement element, + out PolicyEditorSyntaxError? error) + { + element = default; + if (!PolicyEditorRawSyntax.TryParseStrict(raw, out _, out error)) + return false; + + using JsonDocument document = JsonDocument.Parse(raw); + element = document.RootElement.Clone(); + return true; + } + + private PolicyReplacementOperation GetInitialOperation() => + ToReplacementOperation(Session.Operation); + + private static PolicyReplacementOperation ToReplacementOperation( + PolicyEditorOperationKind operation) => operation switch + { + PolicyEditorOperationKind.Update => PolicyReplacementOperation.Update, + PolicyEditorOperationKind.ReplaceIdentity => PolicyReplacementOperation.ReplaceIdentity, + PolicyEditorOperationKind.Create => PolicyReplacementOperation.Create, + PolicyEditorOperationKind.Repair => PolicyReplacementOperation.Repair, + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null), + }; + + private void OnEditorStateChanged() + { + OnPropertyChanged(nameof(Draft)); + OnPropertyChanged(nameof(Rules)); + OnPropertyChanged(nameof(Operation)); + OnPropertyChanged(nameof(RawBuffer)); + OnPropertyChanged(nameof(IsStructuredMode)); + OnPropertyChanged(nameof(IsRawMode)); + OnPropertyChanged(nameof(IsDirty)); + OnPropertyChanged(nameof(IsIdentityLocked)); + OnPropertyChanged(nameof(HasFindings)); + OnPropertyChanged(nameof(HasConflict)); + OnPropertyChanged(nameof(Findings)); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _isDisposed, 1) != 0) return; + Interlocked.Increment(ref _validationGeneration); + _lifetimeCancellation.Cancel(); + _lifetimeCancellation.Dispose(); + NotifyCommandStates(); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorStructuredUi.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorStructuredUi.cs new file mode 100644 index 0000000000..7ed031be1a --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorStructuredUi.cs @@ -0,0 +1,677 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using Devolutions.Now.Policy.Model; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// Bindable brush lookup for , used by the findings-list template. +internal static class PolicyEditorSeverityConverters +{ + public static readonly IValueConverter ToBrush = new FuncValueConverter( + severity => severity switch + { + PolicyValidationSeverity.Error => Brushes.Firebrick, + PolicyValidationSeverity.Warning => Brushes.DarkOrange, + _ => null, + }); +} + +/// +/// A single checkbox-style option for a multi-select enum match field (e.g. Operations, Managers, +/// Scopes, Architectures, Elevation). Deliberately non-generic (one concrete type serves every enum +/// list) so a single compiled AXAML DataTemplate can render all of them. +/// +public sealed partial class PolicyEditorEnumOption : ObservableObject +{ + private readonly Action _onToggled; + + public string Display { get; } + + [ObservableProperty] + private bool _isSelected; + + public PolicyEditorEnumOption(string display, bool isSelected, Action onToggled) + { + Display = display; + _isSelected = isSelected; + _onToggled = onToggled; + } + + partial void OnIsSelectedChanged(bool value) => _onToggled(value); +} + +/// Builds lists for every value of a match enum. +internal static class PolicyEditorEnumOptionFactory +{ + public static List Build(List backing, Action markDirty) + where TEnum : struct, Enum + { + return Enum.GetValues() + .Select(value => new PolicyEditorEnumOption( + CoreTools.Translate(value.ToString()), + backing.Contains(value), + selected => + { + if (selected) + { + if (!backing.Contains(value)) backing.Add(value); + } + else + { + backing.Remove(value); + } + + markDirty(); + })) + .ToList(); + } +} + +/// +/// Shared, index-based single-select enum lists (Decision, tri-state). Mirrors the codebase's +/// established "translated display strings + SelectedIndex" ComboBox pattern (see +/// BaseLogPage.axaml) instead of a ComboBox.ItemTemplate, so no compiled-binding +/// x:DataType is needed for a raw enum value. +/// +internal static class PolicyEditorEnumDisplay +{ + public static readonly Decision[] Decisions = [Decision.Allow, Decision.Deny]; + + public static readonly IReadOnlyList DecisionDisplayItems = + Decisions.Select(value => CoreTools.Translate(value.ToString())).ToList(); + + public static readonly TriState[] TriStates = [TriState.Omitted, TriState.False, TriState.True]; + + public static readonly IReadOnlyList TriStateDisplayItems = + [ + CoreTools.Translate("Any"), + CoreTools.Translate("No"), + CoreTools.Translate("Yes"), + ]; + + /// Not set / No / Yes, for the nullable-boolean audit-mode field. + public static readonly IReadOnlyList NullableBooleanDisplayItems = + [ + CoreTools.Translate("Not set"), + CoreTools.Translate("No"), + CoreTools.Translate("Yes"), + ]; + + public static int IndexOfDecision(Decision value) => Array.IndexOf(Decisions, value); + + public static int IndexOfTriState(TriState value) => Array.IndexOf(TriStates, value); + + public static int IndexOfNullableBoolean(bool? value) => value switch + { + null => 0, + false => 1, + true => 2, + }; + + public static bool? NullableBooleanFromIndex(int index) => index switch + { + 1 => false, + 2 => true, + _ => null, + }; +} + +/// +/// UI-facing wrapper over the document-level and +/// , exposing convenience index/text properties the +/// structured editor's AXAML can bind directly (compiled bindings require a concrete get/set surface; +/// the draft POCOs are plain mutable objects with no change notification of their own). Every setter +/// routes through so validation, +/// findings and dirty state stay in sync without rebuilding this wrapper on every keystroke. +/// +public sealed class PolicyEditorDocumentUi : ObservableObject +{ + private readonly PolicyEditorSessionViewModel _sessionViewModel; + private readonly object _validFromErrorKey = new(); + private readonly object _validUntilErrorKey = new(); + private string _validFromText; + private string _validUntilText; + private string? _validFromError; + private string? _validUntilError; + + public PolicyEditorDocumentUi(PolicyEditorSessionViewModel sessionViewModel) + { + _sessionViewModel = sessionViewModel; + _validFromText = Format(Draft.Metadata.ValidFrom); + _validUntilText = Format(Draft.Metadata.ValidUntil); + } + + private PolicyEditorDraftDocument Draft => _sessionViewModel.Draft; + + public bool IsIdentityLocked => _sessionViewModel.IsIdentityLocked; + + public void NotifyIdentityLockChanged() => + OnPropertyChanged(nameof(IsIdentityLocked)); + + public string PolicyVersion + { + get => Draft.PolicyVersion; + set { Draft.PolicyVersion = value ?? ""; MarkDirty(); } + } + + public string Id + { + get => Draft.Metadata.Id; + set { Draft.Metadata.Id = value ?? ""; MarkDirty(); } + } + + public string Publisher + { + get => Draft.Metadata.Publisher; + set { Draft.Metadata.Publisher = value ?? ""; MarkDirty(); } + } + + public string? Description + { + get => Draft.Metadata.Description; + set { Draft.Metadata.Description = string.IsNullOrWhiteSpace(value) ? null : value; MarkDirty(); } + } + + public string? SupportUrl + { + get => Draft.Metadata.SupportUrl; + set { Draft.Metadata.SupportUrl = string.IsNullOrWhiteSpace(value) ? null : value; MarkDirty(); } + } + + /// Round-trip ISO-8601 text. Invalid input is retained and blocks validation/save. + public string ValidFromText + { + get => _validFromText; + set + { + value ??= ""; + if (string.Equals(_validFromText, value, StringComparison.Ordinal)) return; + _validFromText = value; + OnPropertyChanged(); + if (TryParse(value, out DateTimeOffset? parsed)) + { + Draft.Metadata.ValidFrom = parsed; + SetValidFromError(null); + MarkDirty(); + } + else + { + SetValidFromError(CoreTools.Translate("Enter a valid ISO 8601 date and time.")); + _sessionViewModel.NotifyLocalInputChanged(); + } + } + } + + public string ValidUntilText + { + get => _validUntilText; + set + { + value ??= ""; + if (string.Equals(_validUntilText, value, StringComparison.Ordinal)) return; + _validUntilText = value; + OnPropertyChanged(); + if (TryParse(value, out DateTimeOffset? parsed)) + { + Draft.Metadata.ValidUntil = parsed; + SetValidUntilError(null); + MarkDirty(); + } + else + { + SetValidUntilError(CoreTools.Translate("Enter a valid ISO 8601 date and time.")); + _sessionViewModel.NotifyLocalInputChanged(); + } + } + } + + public string? ValidFromError => _validFromError; + public string? ValidUntilError => _validUntilError; + + public int DecisionIndex + { + get => PolicyEditorEnumDisplay.IndexOfDecision(Draft.Enforcement.DefaultDecision); + set + { + if (value >= 0 && value < PolicyEditorEnumDisplay.Decisions.Length) + { + Draft.Enforcement.DefaultDecision = PolicyEditorEnumDisplay.Decisions[value]; + MarkDirty(); + } + } + } + + public string RulePrecedenceDisplay => CoreTools.Translate(Draft.Enforcement.RulePrecedence.ToString()); + + public int AuditModeIndex + { + get => PolicyEditorEnumDisplay.IndexOfNullableBoolean(Draft.Enforcement.AuditMode); + set + { + Draft.Enforcement.AuditMode = PolicyEditorEnumDisplay.NullableBooleanFromIndex(value); + MarkDirty(); + } + } + + private void MarkDirty() => _sessionViewModel.NotifyDraftChangedCommand.Execute(null); + + public void RefreshFromDraft() + { + _validFromText = Format(Draft.Metadata.ValidFrom); + _validUntilText = Format(Draft.Metadata.ValidUntil); + SetValidFromError(null); + SetValidUntilError(null); + OnPropertyChanged(nameof(PolicyVersion)); + OnPropertyChanged(nameof(Id)); + OnPropertyChanged(nameof(Publisher)); + OnPropertyChanged(nameof(Description)); + OnPropertyChanged(nameof(SupportUrl)); + OnPropertyChanged(nameof(ValidFromText)); + OnPropertyChanged(nameof(ValidUntilText)); + OnPropertyChanged(nameof(ValidFromError)); + OnPropertyChanged(nameof(ValidUntilError)); + OnPropertyChanged(nameof(DecisionIndex)); + OnPropertyChanged(nameof(AuditModeIndex)); + OnPropertyChanged(nameof(RulePrecedenceDisplay)); + OnPropertyChanged(nameof(IsIdentityLocked)); + } + + private void SetValidFromError(string? error) + { + if (string.Equals(_validFromError, error, StringComparison.Ordinal)) return; + _validFromError = error; + _sessionViewModel.SetLocalInputError(_validFromErrorKey, error); + OnPropertyChanged(nameof(ValidFromError)); + } + + private void SetValidUntilError(string? error) + { + if (string.Equals(_validUntilError, error, StringComparison.Ordinal)) return; + _validUntilError = error; + _sessionViewModel.SetLocalInputError(_validUntilErrorKey, error); + OnPropertyChanged(nameof(ValidUntilError)); + } + + private static string Format(DateTimeOffset? value) => + value?.ToString("O", CultureInfo.InvariantCulture) ?? ""; + + private static bool TryParse(string? text, out DateTimeOffset? parsed) + { + if (string.IsNullOrWhiteSpace(text)) + { + parsed = null; + return true; + } + + string normalized = text.EndsWith('Z') + ? text[..^1] + "+00:00" + : text; + string[] formats = + [ + "yyyy-MM-dd'T'HH:mm:sszzz", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz", + ]; + if (DateTimeOffset.TryParseExact( + normalized, + formats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTimeOffset value)) + { + parsed = value; + return true; + } + + parsed = null; + return false; + } +} + +/// +/// UI-facing wrapper over a single : every field of +/// and , projected as +/// bindable properties (string-joined lists, index-based enum pickers, on-demand nullable +/// sub-object creation for VersionRange/Constraints). See +/// for why every setter routes through NotifyDraftChangedCommand instead of raising its own +/// change notification. +/// +public sealed class PolicyEditorRuleUi : ObservableObject, IDisposable +{ + private readonly PolicyEditorSessionViewModel _sessionViewModel; + private readonly object _priorityErrorKey = new(); + private string _priorityText; + private string? _priorityError; + + public PolicyEditorDraftRule Rule { get; } + + public PolicyEditorRuleUi(PolicyEditorDraftRule rule, PolicyEditorSessionViewModel sessionViewModel) + { + Rule = rule; + _sessionViewModel = sessionViewModel; + _priorityText = Rule.Priority.ToString(CultureInfo.InvariantCulture); + + OperationOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Operations, MarkDirty); + ManagerOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Managers, MarkDirty); + ScopeOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Scopes, MarkDirty); + ArchitectureOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Architectures, MarkDirty); + ElevationOptions = PolicyEditorEnumOptionFactory.Build(Rule.Match.Elevation, MarkDirty); + } + + public string Id + { + get => Rule.Id; + set { Rule.Id = value ?? ""; MarkDirty(); } + } + + public bool Enabled + { + get => Rule.Enabled; + set { Rule.Enabled = value; MarkDirty(); } + } + + public string PriorityText + { + get => _priorityText; + set + { + value ??= ""; + if (string.Equals(_priorityText, value, StringComparison.Ordinal)) return; + _priorityText = value; + OnPropertyChanged(); + if (uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)) + { + Rule.Priority = parsed; + SetPriorityError(null); + MarkDirty(); + } + else + { + SetPriorityError(CoreTools.Translate("Enter a whole number from 0 through 4294967295.")); + _sessionViewModel.NotifyLocalInputChanged(); + } + } + } + + public string? PriorityError => _priorityError; + + public int DecisionIndex + { + get => PolicyEditorEnumDisplay.IndexOfDecision(Rule.Decision); + set + { + if (value >= 0 && value < PolicyEditorEnumDisplay.Decisions.Length) + { + Rule.Decision = PolicyEditorEnumDisplay.Decisions[value]; + MarkDirty(); + } + } + } + + public string? Reason + { + get => Rule.Reason; + set { Rule.Reason = string.IsNullOrWhiteSpace(value) ? null : value; MarkDirty(); } + } + + public string AutomationName => CoreTools.Translate( + "Rule: {0}", + string.IsNullOrWhiteSpace(Rule.Id) ? CoreTools.Translate("(untitled)") : Rule.Id); + + public IReadOnlyList OperationOptions { get; } + public IReadOnlyList ManagerOptions { get; } + public IReadOnlyList ScopeOptions { get; } + public IReadOnlyList ArchitectureOptions { get; } + public IReadOnlyList ElevationOptions { get; } + + public string Sources + { + get => Join(Rule.Match.Sources); + set => SetListField(Rule.Match.Sources, value); + } + + public string PackageIdentifiers + { + get => Join(Rule.Match.PackageIdentifiers); + set => SetListField(Rule.Match.PackageIdentifiers, value); + } + + public string PackageNames + { + get => Join(Rule.Match.PackageNames); + set => SetListField(Rule.Match.PackageNames, value); + } + + public string Versions + { + get => Join(Rule.Match.Versions); + set => SetListField(Rule.Match.Versions, value); + } + + public bool HasVersionRange + { + get => Rule.Match.VersionRange is not null; + set + { + if (value == (Rule.Match.VersionRange is not null)) return; + Rule.Match.VersionRange = value ? new PolicyEditorDraftVersionRange() : null; + MarkDirty(); + OnPropertyChanged(); + } + } + + public string? MinVersion + { + get => Rule.Match.VersionRange?.MinVersion; + set { EnsureVersionRange().MinVersion = string.IsNullOrWhiteSpace(value) ? null : value; MarkDirty(); } + } + + public string? MaxVersion + { + get => Rule.Match.VersionRange?.MaxVersion; + set { EnsureVersionRange().MaxVersion = string.IsNullOrWhiteSpace(value) ? null : value; MarkDirty(); } + } + + public bool IncludePrerelease + { + get => Rule.Match.VersionRange?.IncludePrerelease ?? false; + set { EnsureVersionRange().IncludePrerelease = value; MarkDirty(); } + } + + public int InteractiveIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.Interactive); + set => SetTriState(v => Rule.Match.Interactive = v, value); + } + + public int SkipHashCheckIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.SkipHashCheck); + set => SetTriState(v => Rule.Match.SkipHashCheck = v, value); + } + + public int PreReleaseIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.PreRelease); + set => SetTriState(v => Rule.Match.PreRelease = v, value); + } + + public int HasCustomParametersIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasCustomParameters); + set => SetTriState(v => Rule.Match.HasCustomParameters = v, value); + } + + public int HasCustomInstallLocationIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasCustomInstallLocation); + set => SetTriState(v => Rule.Match.HasCustomInstallLocation = v, value); + } + + public int HasPrePostCommandsIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasPrePostCommands); + set => SetTriState(v => Rule.Match.HasPrePostCommands = v, value); + } + + public int HasKillBeforeOperationIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasKillBeforeOperation); + set => SetTriState(v => Rule.Match.HasKillBeforeOperation = v, value); + } + + public int HasUninstallPreviousIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasUninstallPrevious); + set => SetTriState(v => Rule.Match.HasUninstallPrevious = v, value); + } + + public bool HasConstraints + { + get => Rule.Constraints is not null; + set + { + if (value == (Rule.Constraints is not null)) return; + Rule.Constraints = value ? new PolicyEditorDraftConstraints() : null; + MarkDirty(); + OnPropertyChanged(); + } + } + + public bool AllowInteractive + { + get => Rule.Constraints?.AllowInteractive ?? false; + set { EnsureConstraints().AllowInteractive = value; MarkDirty(); } + } + + public bool AllowSkipHashCheck + { + get => Rule.Constraints?.AllowSkipHashCheck ?? false; + set { EnsureConstraints().AllowSkipHashCheck = value; MarkDirty(); } + } + + public bool AllowPreRelease + { + get => Rule.Constraints?.AllowPreRelease ?? false; + set { EnsureConstraints().AllowPreRelease = value; MarkDirty(); } + } + + public bool AllowCustomInstallLocation + { + get => Rule.Constraints?.AllowCustomInstallLocation ?? false; + set { EnsureConstraints().AllowCustomInstallLocation = value; MarkDirty(); } + } + + public string AllowedInstallLocationPatterns + { + get => Join(Rule.Constraints?.AllowedInstallLocationPatterns); + set => SetListField(EnsureConstraints().AllowedInstallLocationPatterns, value); + } + + public bool AllowCustomParameters + { + get => Rule.Constraints?.AllowCustomParameters ?? false; + set { EnsureConstraints().AllowCustomParameters = value; MarkDirty(); } + } + + public string AllowedCustomParameters + { + get => Join(Rule.Constraints?.AllowedCustomParameters); + set => SetListField(EnsureConstraints().AllowedCustomParameters, value); + } + + public string AllowedCustomParameterPatterns + { + get => Join(Rule.Constraints?.AllowedCustomParameterPatterns); + set => SetListField(EnsureConstraints().AllowedCustomParameterPatterns, value); + } + + public string DeniedCustomParameters + { + get => Join(Rule.Constraints?.DeniedCustomParameters); + set => SetListField(EnsureConstraints().DeniedCustomParameters, value); + } + + public bool AllowPrePostCommands + { + get => Rule.Constraints?.AllowPrePostCommands ?? false; + set { EnsureConstraints().AllowPrePostCommands = value; MarkDirty(); } + } + + public bool AllowKillBeforeOperation + { + get => Rule.Constraints?.AllowKillBeforeOperation ?? false; + set { EnsureConstraints().AllowKillBeforeOperation = value; MarkDirty(); } + } + + public bool AllowUninstallPrevious + { + get => Rule.Constraints?.AllowUninstallPrevious ?? false; + set { EnsureConstraints().AllowUninstallPrevious = value; MarkDirty(); } + } + + public bool AllowUpgrade + { + get => Rule.Constraints?.AllowUpgrade ?? false; + set { EnsureConstraints().AllowUpgrade = value; MarkDirty(); } + } + + /// Findings attributed to this rule's identifier (see ). + public IReadOnlyList Findings => _sessionViewModel.Session.Findings.ForRule(Rule.Id); + + public bool HasFindings => Findings.Count > 0; + + /// + /// Re-raises change notification for the findings-derived properties without rebuilding this + /// wrapper or its parent collection, so a Validate/Save click never steals focus from whichever + /// field the user was editing. + /// + public void RefreshFindings() + { + OnPropertyChanged(nameof(Findings)); + OnPropertyChanged(nameof(HasFindings)); + } + + private void SetTriState(Action assign, int index) + { + if (index < 0 || index >= PolicyEditorEnumDisplay.TriStates.Length) return; + assign(PolicyEditorEnumDisplay.TriStates[index]); + MarkDirty(); + } + + private PolicyEditorDraftVersionRange EnsureVersionRange() => + Rule.Match.VersionRange ??= new PolicyEditorDraftVersionRange(); + + private PolicyEditorDraftConstraints EnsureConstraints() => + Rule.Constraints ??= new PolicyEditorDraftConstraints(); + + private void MarkDirty() => _sessionViewModel.NotifyDraftChangedCommand.Execute(null); + + private void SetPriorityError(string? error) + { + if (string.Equals(_priorityError, error, StringComparison.Ordinal)) return; + _priorityError = error; + _sessionViewModel.SetLocalInputError(_priorityErrorKey, error); + OnPropertyChanged(nameof(PriorityError)); + } + + public void Dispose() => _sessionViewModel.SetLocalInputError(_priorityErrorKey, null); + + private static string Join(IEnumerable? values) => + values is null ? "" : string.Join(Environment.NewLine, values); + + private void SetListField(List backing, string? value) + { + backing.Clear(); + if (!string.IsNullOrEmpty(value)) + { + backing.AddRange(value.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + MarkDirty(); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorTemplates.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorTemplates.cs new file mode 100644 index 0000000000..f9cf627011 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorTemplates.cs @@ -0,0 +1,44 @@ +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Produces the fail-closed starting point for a brand-new policy document. Everything the template +/// fixes (schema, policy type, policy version, rule precedence, default decision, empty rule set) is +/// non-negotiable at creation time; only the caller-supplied identity ( in +/// ) and publisher are free-form, because the editor cannot know them in advance. +/// +public static class PolicyEditorTemplates +{ + /// + /// Creates a brand-new draft document: fixed schema/type/version, PriorityThenDeny + /// precedence, a default decision of Deny (fail closed), and no rules. The caller must + /// supply the new policy's and ; both are + /// validated to be non-empty since the write path (external to this domain) requires them. + /// + public static PolicyEditorDraftDocument CreateNew(string id, string publisher) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("A new policy requires a non-empty identifier.", nameof(id)); + } + + if (string.IsNullOrWhiteSpace(publisher)) + { + throw new ArgumentException("A new policy requires a non-empty publisher.", nameof(publisher)); + } + + return new PolicyEditorDraftDocument + { + PolicyVersion = PolicyEditorPolicyContract.InitialPolicyVersion, + Metadata = new PolicyEditorDraftMetadata + { + Id = id, + Publisher = publisher, + }, + Enforcement = new PolicyEditorDraftEnforcement + { + DefaultDecision = PolicyEditorPolicyContract.DefaultTemplateDecision, + }, + Rules = [], + }; + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyRuleOperations.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyRuleOperations.cs new file mode 100644 index 0000000000..ad2952ef3b --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyRuleOperations.cs @@ -0,0 +1,96 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// Creates blank rules with fresh, contract-valid identifiers. +public static class PolicyRuleFactory +{ + /// + /// Generates a new rule identifier. The format (lowercase hex GUID with a readable prefix) only + /// uses ASCII letters, digits, and hyphens, satisfying the broker's resource-id contract. + /// + public static string CreateRuleId() => $"rule-{Guid.NewGuid():N}"; + + /// Creates a new, empty, enabled rule that matches nothing until edited. + public static PolicyEditorDraftRule CreateBlank(string? id = null) => new() + { + Id = id ?? CreateRuleId(), + Enabled = true, + Priority = 0, + Decision = Decision.Deny, + Reason = null, + Match = new PolicyEditorDraftMatch(), + Constraints = null, + }; +} + +/// +/// Pure, UI-independent mutation operations over a rule list, covering add/edit/duplicate(new +/// ID)/enable/disable/delete/reorder/priority. Every operation validates rule-identity uniqueness and +/// existence up front and throws rather than silently no-op, so callers (namely +/// ) never end up with a list in an inconsistent state. +/// +public static class PolicyRuleListOperations +{ + public static void Add(List rules, PolicyEditorDraftRule rule) + { + ArgumentNullException.ThrowIfNull(rules); + ArgumentNullException.ThrowIfNull(rule); + EnsureIdIsUnique(rules, rule.Id); + rules.Add(rule); + } + + public static void Edit(List rules, string id, Action mutate) + { + ArgumentNullException.ThrowIfNull(mutate); + mutate(Find(rules, id)); + } + + /// Duplicates a rule, always assigning the copy a new identifier distinct from every + /// existing rule. Returns the new rule's id. + public static string Duplicate(List rules, string id, string? newId = null) + { + PolicyEditorDraftRule source = Find(rules, id); + string generatedId = newId ?? PolicyRuleFactory.CreateRuleId(); + EnsureIdIsUnique(rules, generatedId); + + PolicyEditorDraftRule copy = source.CloneWithNewId(generatedId); + int index = rules.IndexOf(source); + rules.Insert(index + 1, copy); + return generatedId; + } + + public static void SetEnabled(List rules, string id, bool enabled) => + Find(rules, id).Enabled = enabled; + + public static void Delete(List rules, string id) => + rules.Remove(Find(rules, id)); + + /// Moves a rule to a new position in document order. is + /// clamped to the valid range. + public static void Move(List rules, string id, int newIndex) + { + PolicyEditorDraftRule rule = Find(rules, id); + int clamped = Math.Clamp(newIndex, 0, rules.Count - 1); + rules.Remove(rule); + rules.Insert(clamped, rule); + } + + public static void SetPriority(List rules, string id, uint priority) => + Find(rules, id).Priority = priority; + + private static void EnsureIdIsUnique(List rules, string id) + { + if (rules.Any(rule => string.Equals(rule.Id, id, StringComparison.Ordinal))) + { + throw new InvalidOperationException($"A rule with id '{id}' already exists."); + } + } + + private static PolicyEditorDraftRule Find(List rules, string id) + { + ArgumentNullException.ThrowIfNull(rules); + return rules.FirstOrDefault(rule => string.Equals(rule.Id, id, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException($"No rule with id '{id}' exists."); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyValidationFinding.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyValidationFinding.cs new file mode 100644 index 0000000000..e92cbf673a --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyValidationFinding.cs @@ -0,0 +1,368 @@ +using System.Text.Json; +using System.Text; +using Devolutions.Now.Policy.Api; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.AgentBroker.PolicyManagement; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// A single finding reported by the external (Agent-side) semantic validator, or synthesized locally +/// by for structural/contract failures. is a +/// JSON Pointer (RFC 6901, e.g. /rules/0/match/versions/1) into the raw JSON that was +/// validated; is populated when the finding can be attributed to a specific rule, +/// even if its exact position in the document has since changed. +/// +public sealed record PolicyValidationFinding( + string Pointer, + string? RuleId, + PolicyValidationSeverity Severity, + string Message, + PolicyFindingCode? Code = null, + IReadOnlyDictionary? Arguments = null) +{ + public static PolicyValidationFinding FromShared(PolicyFinding finding) => + CreateBounded(new( + finding.Path ?? "", + finding.RuleId, + finding.Severity == PolicyFindingSeverity.Error + ? PolicyValidationSeverity.Error + : PolicyValidationSeverity.Warning, + PolicyFindingPresentation.Describe(finding.Code, finding.Arguments, finding.Message), + finding.Code, + PolicyFindingPresentation.CopyArguments(finding.Arguments))); + + public static PolicyValidationFinding FromSanitized(BrokerPolicySanitizedFinding finding) => + CreateBounded(new( + finding.Path ?? "", + finding.RuleId, + finding.Severity == PolicyFindingSeverity.Error + ? PolicyValidationSeverity.Error + : PolicyValidationSeverity.Warning, + PolicyFindingPresentation.Describe(finding.Code, finding.Arguments, finding.Message), + finding.Code, + PolicyFindingPresentation.CopyArguments(finding.Arguments))); + + public static PolicyValidationFinding CreateBounded(PolicyValidationFinding finding) => finding with + { + Pointer = PolicyFindingPresentation.SanitizeAgentText( + finding.Pointer, + BrokerPolicyManagementLimits.MaxSanitizedTextLength), + RuleId = string.IsNullOrEmpty(finding.RuleId) + ? null + : PolicyFindingPresentation.SanitizeAgentText( + finding.RuleId, + BrokerPolicyManagementLimits.MaxSanitizedTextLength), + Message = PolicyFindingPresentation.SanitizeAgentText( + finding.Message, + BrokerPolicyManagementLimits.MaxSanitizedTextLength), + Arguments = finding.Arguments is null + ? null + : PolicyFindingPresentation.CopyArguments(finding.Arguments), + }; + + public string SeverityText => CoreTools.Translate(Severity.ToString()); + + public string AutomationName => string.IsNullOrWhiteSpace(Pointer) + ? Message + : CoreTools.Translate("{0}. Location: {1}", Message, Pointer); +} + +/// +/// Converts stable Agent finding codes and structured arguments into localized UI text. +/// Recognized codes never render the Agent's English fallback message. +/// +public static class PolicyFindingPresentation +{ + private const int MaxArgumentEntries = + BrokerPolicyManagementLimits.MaxSanitizedArgumentEntries; + private const int MaxArgumentLength = + BrokerPolicyManagementLimits.MaxSanitizedArgumentValueLength; + private const int MaxFallbackLength = + BrokerPolicyManagementLimits.MaxSanitizedTextLength; + + public static string Describe( + PolicyFindingCode code, + IReadOnlyDictionary? arguments, + string? fallbackMessage) + { + IReadOnlyDictionary copied = CopyArguments(arguments); + return Describe(code, copied, fallbackMessage); + } + + public static string Describe( + PolicyFindingCode code, + IReadOnlyDictionary? arguments, + string? fallbackMessage) => code switch + { + PolicyFindingCode.SchemaViolation => + CoreTools.Translate("The policy draft does not match the required JSON schema."), + PolicyFindingCode.UnknownField => + CoreTools.Translate("The policy draft contains an unknown field."), + PolicyFindingCode.MissingRequiredField => + CoreTools.Translate("The policy draft is missing a required field."), + PolicyFindingCode.InvalidFieldType => + CoreTools.Translate("A policy field has the wrong value type."), + PolicyFindingCode.InvalidFieldValue => + CoreTools.Translate("A policy field has an invalid value."), + PolicyFindingCode.DuplicateRuleId => + CoreTools.Translate("Rule IDs must be unique."), + PolicyFindingCode.IneffectiveBooleanMatch => + CoreTools.Translate("A boolean match must be omitted, true, or false; mixed arrays are invalid."), + PolicyFindingCode.InvalidVersionRange => + CoreTools.Translate("The version range is invalid."), + PolicyFindingCode.EmptyVersionRange => + CoreTools.Translate("The version range does not restrict any versions."), + PolicyFindingCode.InvalidWildcardPattern => + CoreTools.Translate("A wildcard pattern is invalid."), + PolicyFindingCode.ContradictoryConstraints => + CoreTools.Translate("The rule contains contradictory constraints."), + PolicyFindingCode.InvalidValidityInterval => + CoreTools.Translate("The policy validity interval is invalid."), + PolicyFindingCode.UnsupportedSchema => + CoreTools.Translate("The policy schema is unsupported."), + PolicyFindingCode.UnsupportedPolicyType => + CoreTools.Translate("The policy type is unsupported."), + PolicyFindingCode.UnsupportedPolicyVersion => + CoreTools.Translate("The policy version is unsupported."), + PolicyFindingCode.AuditModeEnabled => + CoreTools.Translate("Audit mode is enabled; decisions are logged but not enforced."), + PolicyFindingCode.DefaultAllow => + CoreTools.Translate("The default decision is Allow; requests matching no rule are permitted."), + PolicyFindingCode.SensitiveOptionAllowed => + DescribeSensitiveOption(arguments), + _ => SanitizeFallback(fallbackMessage), + }; + + public static IReadOnlyDictionary CopyArguments( + IReadOnlyDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + return new Dictionary(); + + var copied = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair argument in arguments + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Take(MaxArgumentEntries)) + { + string key = Sanitize(argument.Key, MaxArgumentLength); + string value; + try + { + value = argument.Value.GetRawText(); + } + catch (InvalidOperationException) + { + value = ""; + } + + copied[key] = Sanitize(value, MaxArgumentLength); + } + + return copied; + } + + public static IReadOnlyDictionary CopyArguments( + IReadOnlyDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + return new Dictionary(); + + var copied = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair argument in arguments + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Take(MaxArgumentEntries)) + { + copied[Sanitize(argument.Key, MaxArgumentLength)] = + Sanitize(argument.Value, MaxArgumentLength); + } + + return copied; + } + + private static string DescribeSensitiveOption(IReadOnlyDictionary? arguments) + { + string? option = ReadJsonString(arguments, "Option"); + string description = option switch + { + "SkipHashCheck" => CoreTools.Translate("An enabled Allow rule permits skipping package hash verification."), + "PreRelease" => CoreTools.Translate("An enabled Allow rule permits prerelease package versions."), + "AllowCustomInstallLocation" => CoreTools.Translate("An enabled Allow rule permits custom install locations."), + "AllowCustomParameters" => CoreTools.Translate("An enabled Allow rule permits custom command-line parameters."), + "AllowPrePostCommands" => CoreTools.Translate("An enabled Allow rule permits pre-operation or post-operation commands."), + "AllowKillBeforeOperation" => CoreTools.Translate("An enabled Allow rule permits killing processes before an operation."), + "AllowUninstallPrevious" => CoreTools.Translate("An enabled Allow rule permits uninstalling a previous version."), + _ => CoreTools.Translate("An enabled Allow rule permits a sensitive option."), + }; + + string[] restrictions = + [ + FormatRestriction(arguments, "AllowedInstallLocationPatterns", "Allowed install location patterns"), + FormatRestriction(arguments, "AllowedCustomParameters", "Allowed custom parameters"), + FormatRestriction(arguments, "AllowedCustomParameterPatterns", "Allowed custom parameter patterns"), + FormatRestriction(arguments, "DeniedCustomParameters", "Denied custom parameters"), + ]; + string restrictionText = string.Join( + "; ", + restrictions.Where(value => !string.IsNullOrEmpty(value))); + return restrictionText.Length == 0 + ? description + : $"{description} {CoreTools.Translate("Restrictions: {0}", restrictionText)}"; + } + + private static string FormatRestriction( + IReadOnlyDictionary? arguments, + string key, + string label) + { + if (arguments is null + || !arguments.TryGetValue(key, out string? value) + || string.IsNullOrWhiteSpace(value)) + { + return ""; + } + + return $"{CoreTools.Translate(label)}: {Sanitize(value, MaxArgumentLength)}"; + } + + private static string? ReadJsonString( + IReadOnlyDictionary? arguments, + string key) + { + if (arguments is null || !arguments.TryGetValue(key, out string? raw)) + return null; + + try + { + using JsonDocument document = JsonDocument.Parse(raw); + return document.RootElement.ValueKind == JsonValueKind.String + ? Sanitize(document.RootElement.GetString() ?? "", MaxArgumentLength) + : null; + } + catch (JsonException) + { + return null; + } + } + + private static string SanitizeFallback(string? message) + { + string sanitized = Sanitize(message ?? "", MaxFallbackLength); + return string.IsNullOrWhiteSpace(sanitized) + ? CoreTools.Translate("Devolutions Agent reported an unrecognized policy finding.") + : sanitized; + } + + public static string SanitizeAgentText(string? value, int maxLength) => + Sanitize(value ?? "", maxLength); + + private static string Sanitize(string value, int maxLength) + { + ArgumentOutOfRangeException.ThrowIfNegative(maxLength); + + var result = new StringBuilder(Math.Min(value.Length, maxLength)); + int scalarCount = 0; + foreach (Rune rune in value.EnumerateRunes()) + { + if (Rune.IsControl(rune)) + continue; + if (scalarCount == maxLength) + break; + + result.Append(rune); + scalarCount++; + } + + return result.ToString(); + } +} + +/// +/// Indexes a flat list of for quick lookup by JSON Pointer or by +/// rule ID, so the UI can highlight the right field/rule without re-scanning the whole finding list on +/// every render. +/// +public sealed class PolicyEditorFindingIndex +{ + public const int MaxDisplayedFindings = + BrokerPolicyManagementLimits.MaxSanitizedFindings; + + private static readonly IReadOnlyList Empty = []; + + public IReadOnlyList All { get; } + public bool FindingsTruncated { get; } + public int OmittedFindingCount { get; } + + private readonly IReadOnlyDictionary> _byPointer; + private readonly IReadOnlyDictionary> _byRuleId; + + private PolicyEditorFindingIndex( + IReadOnlyList all, + IReadOnlyDictionary> byPointer, + IReadOnlyDictionary> byRuleId, + int omittedFindingCount) + { + All = all; + _byPointer = byPointer; + _byRuleId = byRuleId; + OmittedFindingCount = omittedFindingCount; + FindingsTruncated = omittedFindingCount > 0; + } + + public static PolicyEditorFindingIndex Build( + IReadOnlyList findings, + int omittedFindingCount = 0) + { + ArgumentNullException.ThrowIfNull(findings); + ArgumentOutOfRangeException.ThrowIfNegative(omittedFindingCount); + + int totalOmitted = omittedFindingCount; + int retainedLimit = findings.Count + totalOmitted > MaxDisplayedFindings + ? MaxDisplayedFindings - 1 + : MaxDisplayedFindings; + if (findings.Count > retainedLimit) + { + totalOmitted += findings.Count - retainedLimit; + } + + var all = new List(MaxDisplayedFindings); + for (int index = 0; index < Math.Min(findings.Count, retainedLimit); index++) + { + all.Add(PolicyValidationFinding.CreateBounded(findings[index])); + } + + if (totalOmitted > 0) + { + all.Add(new PolicyValidationFinding( + "", + null, + PolicyValidationSeverity.Warning, + CoreTools.Translate( + "{0} additional validation finding(s) were omitted.", + totalOmitted))); + } + + Dictionary> byPointer = all + .GroupBy(finding => finding.Pointer, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + IReadOnlyList (group) => [.. group], + StringComparer.Ordinal); + + Dictionary> byRuleId = all + .Where(finding => finding.RuleId is not null) + .GroupBy(finding => finding.RuleId!, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + IReadOnlyList (group) => [.. group], + StringComparer.Ordinal); + + return new PolicyEditorFindingIndex(all, byPointer, byRuleId, totalOmitted); + } + + public IReadOnlyList ForPointer(string pointer) => + _byPointer.TryGetValue(pointer, out IReadOnlyList? findings) ? findings : Empty; + + public IReadOnlyList ForRule(string ruleId) => + _byRuleId.TryGetValue(ruleId, out IReadOnlyList? findings) ? findings : Empty; +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs b/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs new file mode 100644 index 0000000000..e2be64885d --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using AvaloniaEdit; + +namespace UniGetUI.Avalonia.Views.Controls; + +public sealed class PolicyJsonEditor : TextEditor +{ + protected override Type StyleKeyOverride => typeof(TextEditor); + + public PolicyJsonEditor() + { + ShowLineNumbers = true; + WordWrap = false; + FontFamily = new FontFamily("Cascadia Mono,Consolas,Menlo,monospace"); + FontSize = 12; + Padding = new Thickness(8); + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + } +} diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 67d1a6ca61..16a5303fd6 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -122,6 +122,7 @@ public partial class MainWindow : Window private TrayService? _trayService; private bool _allowClose; private int _isQuitting; + private int _quitRequestPending; // Saved outer size (DIPs) awaiting a native, exact restore in OnOpened on Windows. private double _pendingRestoreWidth; @@ -1859,21 +1860,36 @@ public void ShowFromTray() public bool IsQuitting => Interlocked.CompareExchange(ref _isQuitting, 0, 0) == 1; - public void QuitApplication() + public void QuitApplication() => _ = RequestQuitApplicationAsync(); + + private async Task RequestQuitApplicationAsync() { - if (Interlocked.Exchange(ref _isQuitting, 1) == 1) + if (IsQuitting || Interlocked.Exchange(ref _quitRequestPending, 1) == 1) return; - _allowClose = true; - ReleaseWindowResources(); + try + { + if (!await ViewModel.CanShutdownAsync()) + return; - if (IsVisible) - Hide(); + if (Interlocked.Exchange(ref _isQuitting, 1) == 1) + return; - _ = QuitApplicationAsync(); + _allowClose = true; + ReleaseWindowResources(); + + if (IsVisible) + Hide(); + + await StopAndExitApplicationAsync(); + } + finally + { + Interlocked.Exchange(ref _quitRequestPending, 0); + } } - private static async Task QuitApplicationAsync() + private static async Task StopAndExitApplicationAsync() { Logger.Warn("Quitting UniGetUI"); try diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml index 00524aa01e..3665fc24c7 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml @@ -37,7 +37,125 @@ + + +