diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 4aec54ae62..a239e788d5 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -219,6 +219,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 @@ -250,6 +257,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 5c217fa8c8..de812a5de1 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -461,6 +461,112 @@ "This option WILL cause issues. Any operation incapable of elevating itself WILL FAIL. Install/update/uninstall as administrator will NOT WORK.": "This option WILL cause issues. Any operation incapable of elevating itself WILL FAIL. Install/update/uninstall as administrator will NOT WORK.", "Delegate package operations to the Devolutions Agent broker": "Delegate package operations to the Devolutions Agent broker", "When enabled, install, update and uninstall operations for supported package managers will be performed by the Devolutions Agent service instead of requesting UAC elevation. Operations will fail unless the agent is installed and running.": "When enabled, install, update and uninstall operations for supported package managers will be performed by the Devolutions Agent service instead of requesting UAC elevation. Operations will fail unless the agent is installed and running.", + "Inspect active package broker policy": "Inspect active package broker policy", + "Check Devolutions Agent reachability and review the policy currently enforced for package operations.": "Check Devolutions Agent reachability and review the policy currently enforced for package operations.", + "Active package broker policy": "Active package broker policy", + "Loading active package broker policy": "Loading active package broker policy", + "Contacting the Devolutions Agent service.": "Contacting the Devolutions Agent service.", + "Devolutions Agent is unavailable": "Devolutions Agent is unavailable", + "Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, then refresh.": "Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, then refresh.", + "Policy inspection is unsupported": "Policy inspection is unsupported", + "The installed Devolutions Agent is reachable but does not support active policy inspection. Update the Agent and try again.": "The installed Devolutions Agent is reachable but does not support active policy inspection. Update the Agent and try again.", + "Access to the active policy was denied": "Access to the active policy was denied", + "Devolutions Agent did not authorize UniGetUI to inspect the active package policy.": "Devolutions Agent did not authorize UniGetUI to inspect the active package policy.", + "The active policy is unavailable": "The active policy is unavailable", + "Devolutions Agent supports policy inspection but could not provide the active policy. Review the Agent configuration and try again.": "Devolutions Agent supports policy inspection but could not provide the active policy. Review the Agent configuration and try again.", + "The policy response is invalid": "The policy response is invalid", + "Devolutions Agent returned a malformed or incompatible policy response.": "Devolutions Agent returned a malformed or incompatible policy response.", + "Policy inspection is available on Windows only": "Policy inspection is available on Windows only", + "This page cannot contact the Windows Devolutions Agent service on the current platform.": "This page cannot contact the Windows Devolutions Agent service on the current platform.", + "Connected to Devolutions Agent": "Connected to Devolutions Agent", + "The active package broker policy was loaded successfully.": "The active package broker policy was loaded successfully.", + "Refresh": "Refresh", + "Policy metadata": "Policy metadata", + "Enforcement": "Enforcement", + "Rules": "Rules", + "The active policy contains no rules.": "The active policy contains no rules.", + "Canonical policy JSON": "Canonical policy JSON", + "Copy canonical policy JSON to clipboard": "Copy canonical policy JSON to clipboard", + "Could not copy policy JSON": "Could not copy policy JSON", + "The canonical policy JSON could not be copied to the clipboard. Try again.": "The canonical policy JSON could not be copied to the clipboard. Try again.", + "Server version": "Server version", + "Policy ID": "Policy ID", + "Revision": "Revision", + "Policy format version": "Policy format version", + "Published": "Published", + "Valid from": "Valid from", + "Valid until": "Valid until", + "Description": "Description", + "Support URL": "Support URL", + "Default decision": "Default decision", + "Audit mode": "Audit mode", + "Rule {0}: {1}": "Rule {0}: {1}", + "Rule ID": "Rule ID", + "Priority": "Priority", + "Decision": "Decision", + "Reason": "Reason", + "Match criteria": "Match criteria", + "Constraints": "Constraints", + "Package managers": "Package managers", + "Exact package identifiers": "Exact package identifiers", + "Package identifier patterns": "Package identifier patterns", + "Exact versions": "Exact versions", + "Version range": "Version range", + "Scopes": "Scopes", + "Architectures": "Architectures", + "Execution elevation": "Execution elevation", + "Interactive": "Interactive", + "Prerelease": "Prerelease", + "Has custom parameters": "Has custom parameters", + "Has custom install location": "Has custom install location", + "Has pre/post commands": "Has pre/post commands", + "Has kill-before-operation": "Has kill-before-operation", + "Has uninstall previous": "Has uninstall previous", + "Allow interactive": "Allow interactive", + "Allow skip hash check": "Allow skip hash check", + "Allow prerelease": "Allow prerelease", + "Allow custom install location": "Allow custom install location", + "Allowed install location patterns": "Allowed install location patterns", + "Allow custom parameters": "Allow custom parameters", + "Allowed custom parameters": "Allowed custom parameters", + "Allowed custom parameter patterns": "Allowed custom parameter patterns", + "Denied custom parameters": "Denied custom parameters", + "Allow pre/post commands": "Allow pre/post commands", + "Allow kill-before-operation": "Allow kill-before-operation", + "Allow uninstall previous": "Allow uninstall previous", + "Allow upgrade": "Allow upgrade", + "Any": "Any", + "Does not matter": "Does not matter", + "None": "None", + "{0} to {1}; include prerelease: {2}": "{0} to {1}; include prerelease: {2}", + "Allow": "Allow", + "Deny": "Deny", + "PriorityThenDeny": "Priority, then deny", + "Winget": "WinGet", + "PowerShell": "PowerShell", + "PowerShell7": "PowerShell 7", + "Apt": "APT", + "Bun": "Bun", + "Cargo": "Cargo", + "Chocolatey": "Chocolatey", + "Dnf": "DNF", + "Dotnet": ".NET Tool", + "Flatpak": "Flatpak", + "Homebrew": "Homebrew", + "Npm": "npm", + "Pacman": "Pacman", + "Pip": "Pip", + "Scoop": "Scoop", + "Snap": "Snap", + "Vcpkg": "Vcpkg", + "User": "User", + "Machine": "Machine", + "X86": "x86", + "X64": "x64", + "Arm64": "ARM64", + "Neutral": "Neutral", + "Standard": "Standard", + "Elevated": "Elevated", "Allow custom command-line arguments": "Allow custom command-line arguments", "Custom command-line arguments can change the way in which programs are installed, upgraded or uninstalled, in a way UniGetUI cannot control. Using custom command-lines can break packages. Proceed with caution.": "Custom command-line arguments can change the way in which programs are installed, upgraded or uninstalled, in a way UniGetUI cannot control. Using custom command-lines can break packages. Proceed with caution.", "Ignore custom pre-install and post-install commands when importing packages from a bundle": "Ignore custom pre-install and post-install commands when importing packages from a bundle", @@ -1054,7 +1160,6 @@ "Stop using a folder for this package": "Stop using a folder for this package", "Pick a folder from the list above, otherwise nothing will be moved.": "Pick a folder from the list above, otherwise nothing will be moved.", "Move": "Move", - "Delete": "Delete", "Shortcut": "Shortcut", "Currently in": "Currently in", "Rename to": "Rename to", @@ -1089,5 +1194,495 @@ "Reset the default installer download location": "Reset the default installer download location", "Open the default installer download location": "Open the default installer download location", "{pm} could not be loaded": "{pm} could not be loaded", - "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.": "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details." + "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.": "{pm} was found on your system, but it could not be started. Check the UniGetUI log for more details.", + "UniGetUI has detected the following desktop shortcuts which can be removed automatically on future upgrades": "UniGetUI has detected the following desktop shortcuts which can be removed automatically on future upgrades", + "Please wait while {0} is being installed. A black window may show up. Please wait until it closes.": "Please wait while {0} is being installed. A black (or blue) window may show up. Please wait until it closes.", + "Automatic desktop shortcut remover": "Automatic desktop shortcut remover", + "Update checking": "Update checking", + "Automatic updates": "Automatic updates", + "Here you can change UniGetUI's behaviour regarding the following shortcuts. Checking a shortcut will make UniGetUI delete it if if gets created on a future upgrade. Unchecking it will keep the shortcut intact": "Here you can change UniGetUI's behaviour regarding the following shortcuts. Checking a shortcut will make UniGetUI delete it if gets created on a future upgrade. Unchecking it will keep the shortcut intact", + "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 exists": "No policy file exists", + "No active package policy": "No active package policy", + "Devolutions Agent reports that no policy file exists at the configured path.": "Devolutions Agent reports that no policy file exists at the configured path.", + "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. An administrator must correct or replace the protected policy file outside UniGetUI.": "Review the diagnostics below. An administrator must correct or replace the protected policy file outside UniGetUI.", + "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", + "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", + "Correct the selected error before saving.": "Correct the selected error before saving.", + "1 error": "1 error", + "{0} error(s)": "{0} error(s)", + "{0} error(s), {1} warning(s)": "{0} error(s), {1} warning(s)", + "Previous": "Previous", + "Next": "Next", + "Previous validation finding": "Previous validation finding", + "Next validation finding": "Next validation finding", + "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 draft as malformed. Refresh policy management state, then review the policy before retrying.": "Devolutions Agent rejected the policy draft as malformed. Refresh policy management state, then review the policy before retrying.", + "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", + "Package broker policy": "Package broker policy", + "Loading policy information": "Loading policy information", + "Loading package broker policy": "Loading package broker policy", + "Refresh policy information": "Refresh policy information", + "State": "State", + "Configured path": "Configured path", + "Path source": "Path source", + "Agent write capability": "Agent write capability", + "Policy changes from this app": "Policy changes from this app", + "Policy change availability reason": "Policy change availability reason", + "Elevation required": "Elevation required", + "Writable": "Writable", + "Read-only": "Read-only", + "Unsupported": "Unsupported", + "Available": "Available", + "Unavailable": "Unavailable", + "Devolutions Agent does not allow policy changes.": "Devolutions Agent does not allow policy changes.", + "Policy management is disabled in Devolutions Agent.": "Policy management is disabled in Devolutions Agent.", + "No policy path is configured in Devolutions Agent.": "No policy path is configured in Devolutions Agent.", + "Devolutions Agent does not support the configured policy format.": "Devolutions Agent does not support the configured policy format.", + "Devolutions Agent considers the configured policy path unsafe.": "Devolutions Agent considers the configured policy path unsafe.", + "Devolutions Agent does not have permission to change the policy file.": "Devolutions Agent does not have permission to change the policy file.", + "Devolutions Agent does not support the policy file system.": "Devolutions Agent does not support the policy file system.", + "The signed policy write helper is missing. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.": "The signed policy write helper is missing. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.", + "Policy changes are disabled because this UniGetUI installation is not administrator-protected. Reinstall UniGetUI for all users in an administrator-protected location to enable them.": "Policy changes are disabled because this UniGetUI installation is not administrator-protected. Reinstall UniGetUI for all users in an administrator-protected location to enable them.", + "This UniGetUI installation cannot securely launch the policy write helper. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.": "This UniGetUI installation cannot securely launch the policy write helper. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes.", + "Edit": "Edit", + "Edit the active policy": "Edit the active policy", + "Create": "Create", + "Create a new policy": "Create a new policy", + "Replace identity": "Replace identity", + "Replace the active policy identity": "Replace the active policy identity", + "Diagnostics": "Diagnostics", + "{0}. Location: {1}. JSON pointer: {2}": "{0}. Location: {1}. JSON pointer: {2}", + "The policy format version is unsupported.": "The policy format version is unsupported.", + "Policy document": "Policy document", + "Item {0}": "Item {0}", + "{0} Detail: {1}": "{0} Detail: {1}", + "Schema": "Schema", + "Policy type": "Policy type", + "Metadata": "Metadata", + "ID": "ID", + "Go to field": "Go to field", + "Go to affected policy field": "Go to affected policy field", + "Go to JSON error": "Go to JSON error", + "Go to raw JSON error": "Go to raw JSON error", + "The $schema field is obsolete. Remove it.": "The $schema field is obsolete. Remove it.", + "PolicyVersion is obsolete. Rename it to PolicyFormatVersion.": "PolicyVersion is obsolete. Rename it to PolicyFormatVersion.", + "The policy draft is missing PolicyFormatVersion.": "The policy draft is missing PolicyFormatVersion.", + "PolicyFormatVersion must be a canonical three-part numeric version such as 1.0.0.": "PolicyFormatVersion must be a canonical three-part numeric version such as 1.0.0.", + "The policy draft uses an unsupported policy format version. This version supports major version 1.": "The policy draft uses an unsupported policy format version. This version supports major version 1.", + "Use guided fields to edit the policy. Values managed by UniGetUI or Devolutions Agent are shown read-only.": "Use guided fields to edit the policy. Values managed by UniGetUI or Devolutions Agent are shown read-only.", + "Edit the complete policy as JSON. Use this view for review or fields not shown in the guided editor; invalid JSON must be corrected before returning.": "Edit the complete policy as JSON. Use this view for review or fields not shown in the guided editor; invalid JSON must be corrected before returning.", + "Permanent identifier used in policy history and diagnostics, not a display name. Use 1 to 128 characters starting with a letter or number; then use letters, numbers, '.', '_', ':' or '-', for example contoso-policy.": "Permanent identifier used in policy history and diagnostics, not a display name. Use 1 to 128 characters starting with a letter or number; then use letters, numbers, '.', '_', ':' or '-', for example contoso-policy.", + "Organization or administrator responsible for this policy. Use a name that users can recognize when reviewing policy details.": "Organization or administrator responsible for this policy. Use a name that users can recognize when reviewing policy details.", + "Policy document version supported by UniGetUI and Devolutions Agent. It is read-only and is different from the policy revision.": "Policy document version supported by UniGetUI and Devolutions Agent. It is read-only and is different from the policy revision.", + "Version of Devolutions Agent that supplied this policy information. Use it when comparing behavior or troubleshooting compatibility.": "Version of Devolutions Agent that supplied this policy information. Use it when comparing behavior or troubleshooting compatibility.", + "Change number assigned by Devolutions Agent when the policy is saved. It increases independently of the policy document version.": "Change number assigned by Devolutions Agent when the policy is saved. It increases independently of the policy document version.", + "Date and time when Devolutions Agent last committed this policy revision.": "Date and time when Devolutions Agent last committed this policy revision.", + "Optional summary of the policy's purpose. Turn this field off to leave the description out.": "Optional summary of the policy's purpose. Turn this field off to leave the description out.", + "Optional HTTP or HTTPS page where users can learn about this policy or request an exception.": "Optional HTTP or HTTPS page where users can learn about this policy or request an exception.", + "Optional local date and time when the policy begins. Before this instant, Devolutions Agent rejects package operations; leave empty for immediate validity.": "Optional local date and time when the policy begins. Before this instant, Devolutions Agent rejects package operations; leave empty for immediate validity.", + "Optional local date and time when the policy ends. After this instant, Devolutions Agent rejects package operations; leave empty for no expiry.": "Optional local date and time when the policy ends. After this instant, Devolutions Agent rejects package operations; leave empty for no expiry.", + "{0} (UTC{1})": "{0} (UTC{1})", + "This local time does not exist because of a daylight-saving time change. Choose another time.": "This local time does not exist because of a daylight-saving time change. Choose another time.", + "This local time occurs twice because of a daylight-saving time change. Choose a time outside the repeated hour.": "This local time occurs twice because of a daylight-saving time change. Choose a time outside the repeated hour.", + "Valid until must be later than Valid from.": "Valid until must be later than Valid from.", + "This policy is outside its configured validity window. If saved now, package operations will be rejected until the policy becomes valid again.": "This policy is outside its configured validity window. If saved now, package operations will be rejected until the policy becomes valid again.", + "Valid from date": "Valid from date", + "Valid from time": "Valid from time", + "Valid until date": "Valid until date", + "Valid until time": "Valid until time", + "Clear Valid from": "Clear Valid from", + "Clear Valid until": "Clear Valid until", + "Clear": "Clear", + "Validity time zone": "Validity time zone", + "Action taken when no enabled rule matches. Choose Deny for a least-privilege policy; choose Allow only when unmatched package requests should proceed.": "Action taken when no enabled rule matches. Choose Deny for a least-privilege policy; choose Allow only when unmatched package requests should proceed.", + "Default Allow permits every package request that does not match an enabled rule.": "Default Allow permits every package request that does not match an enabled rule.", + "Machine scope affects all users and commonly requires administrator privileges.": "Machine scope affects all users and commonly requires administrator privileges.", + "Elevated execution runs the package operation with administrator privileges.": "Elevated execution runs the package operation with administrator privileges.", + "This enabled Allow rule applies to every package request. Add match conditions to limit its scope.": "This enabled Allow rule applies to every package request. Add match conditions to limit its scope.", + "This enabled Allow rule uses a universal package identifier pattern and may authorize requests far beyond the intended scope.": "This enabled Allow rule uses a universal package identifier pattern and may authorize requests far beyond the intended scope.", + "This rule permits bypassing package integrity checks.": "This rule permits bypassing package integrity checks.", + "This Allow rule can permit arbitrary commands before or after package operations because it does not limit package identifiers or sources.": "This Allow rule can permit arbitrary commands before or after package operations because it does not limit package identifiers or sources.", + "This Allow rule can permit arbitrary extra package-manager options because it does not limit package identifiers or sources.": "This Allow rule can permit arbitrary extra package-manager options because it does not limit package identifiers or sources.", + "This Allow rule can permit any custom install folder because it does not limit package identifiers or sources.": "This Allow rule can permit any custom install folder because it does not limit package identifiers or sources.", + "Skip hash check is set to Does not matter, so this Allow rule can match requests that bypass integrity verification. Set it to No to allow only normal verification, or place an earlier Deny rule that covers this rule's scope.": "Skip hash check is set to Does not matter, so this Allow rule can match requests that bypass integrity verification. Set it to No to allow only normal verification, or place an earlier Deny rule that covers this rule's scope.", + "Custom parameters is set to Does not matter, so this Allow rule can match requests with arbitrary extra options. Set it to No to allow only requests without extra options, or place an earlier Deny rule that covers this rule's scope.": "Custom parameters is set to Does not matter, so this Allow rule can match requests with arbitrary extra options. Set it to No to allow only requests without extra options, or place an earlier Deny rule that covers this rule's scope.", + "Custom install location is set to Does not matter, so this Allow rule can match requests for any custom folder. Set it to No to allow only the default location, or place an earlier Deny rule that covers this rule's scope.": "Custom install location is set to Does not matter, so this Allow rule can match requests for any custom folder. Set it to No to allow only the default location, or place an earlier Deny rule that covers this rule's scope.", + "Pre/post commands is set to Does not matter, so this Allow rule can match requests that run arbitrary commands. Set it to No to allow only requests without pre/post commands, or place an earlier Deny rule that covers this rule's scope.": "Pre/post commands is set to Does not matter, so this Allow rule can match requests that run arbitrary commands. Set it to No to allow only requests without pre/post commands, or place an earlier Deny rule that covers this rule's scope.", + "Skip hash check is set to Does not matter, so {0} can match requests that bypass integrity verification. Set it to No to allow only normal verification, or place an earlier Deny rule that covers this rule's scope.": "Skip hash check is set to Does not matter, so {0} can match requests that bypass integrity verification. Set it to No to allow only normal verification, or place an earlier Deny rule that covers this rule's scope.", + "Custom parameters is set to Does not matter, so {0} can match requests with arbitrary extra options. Set it to No to allow only requests without extra options, or place an earlier Deny rule that covers this rule's scope.": "Custom parameters is set to Does not matter, so {0} can match requests with arbitrary extra options. Set it to No to allow only requests without extra options, or place an earlier Deny rule that covers this rule's scope.", + "Custom install location is set to Does not matter, so {0} can match requests for any custom folder. Set it to No to allow only the default location, or place an earlier Deny rule that covers this rule's scope.": "Custom install location is set to Does not matter, so {0} can match requests for any custom folder. Set it to No to allow only the default location, or place an earlier Deny rule that covers this rule's scope.", + "Pre/post commands is set to Does not matter, so {0} can match requests that run arbitrary commands. Set it to No to allow only requests without pre/post commands, or place an earlier Deny rule that covers this rule's scope.": "Pre/post commands is set to Does not matter, so {0} can match requests that run arbitrary commands. Set it to No to allow only requests without pre/post commands, or place an earlier Deny rule that covers this rule's scope.", + "This Allow rule explicitly permits bypassing package integrity checks.": "This Allow rule explicitly permits bypassing package integrity checks.", + "Rules with lower priority numbers are considered first, and Deny wins when priorities tie. This order is fixed.": "Rules with lower priority numbers are considered first, and Deny wins when priorities tie. This order is fixed.", + "When Yes, the broker still evaluates and logs policy decisions but permits requests the policy would deny. Use Yes only temporarily to evaluate rollout; set No to enforce policy.": "When Yes, the broker still evaluates and logs policy decisions but permits requests the policy would deny. Use Yes only temporarily to evaluate rollout; set No to enforce policy.", + "Audit mode is on. Policy decisions are evaluated and logged, but requests the policy would deny are still permitted. Set Audit mode to No to enforce policy.": "Audit mode is on. Policy decisions are evaluated and logged, but requests the policy would deny are still permitted. Set Audit mode to No to enforce policy.", + "Advanced": "Advanced", + "Advanced policy settings": "Advanced policy settings", + "Audit mode warning": "Audit mode warning", + "Add a disabled Deny rule with no match restrictions. Configure when it should apply, then enable it.": "Add a disabled Deny rule with no match restrictions. Configure when it should apply, then enable it.", + "A disabled rule has no effect. Enable it only after defining the requests it should match and confirming its Allow or Deny decision.": "A disabled rule has no effect. Enable it only after defining the requests it should match and confirming its Allow or Deny decision.", + "This rule is disabled and has no effect. Configure its request characteristics, then enable it when ready.": "This rule is disabled and has no effect. Configure its request characteristics, then enable it when ready.", + "Choose at least one condition before this rule can be saved, or delete the rule.": "Choose at least one condition before this rule can be saved, or delete the rule.", + "This enabled rule has no narrowing match conditions, so it applies to every package request. Add at least one match condition or disable the rule.": "This enabled rule has no narrowing match conditions, so it applies to every package request. Add at least one match condition or disable the rule.", + "Disabled rule notice": "Disabled rule notice", + "Unrestricted rule warning": "Unrestricted rule warning", + "Copy this rule as a starting point for a similar exception or restriction. Give the copy a unique rule ID.": "Copy this rule as a starting point for a similar exception or restriction. Give the copy a unique rule ID.", + "Move the rule to change its evaluation order. Moving a rule can change which Allow or Deny decision wins.": "Move the rule to change its evaluation order. Moving a rule can change which Allow or Deny decision wins.", + "Remove this rule from the policy. The removal takes effect when the policy is saved.": "Remove this rule from the policy. The removal takes effect when the policy is saved.", + "Permanent identifier used in decision logs, not a display name. Use 1 to 128 characters starting with a letter or number; then use letters, numbers, '.', '_', ':' or '-', for example allow-winget-updates.": "Permanent identifier used in decision logs, not a display name. Use 1 to 128 characters starting with a letter or number; then use letters, numbers, '.', '_', ':' or '-', for example allow-winget-updates.", + "Policy ID is required.": "Policy ID is required.", + "Policy ID cannot exceed 128 characters.": "Policy ID cannot exceed 128 characters.", + "Policy ID must start with an ASCII letter or number.": "Policy ID must start with an ASCII letter or number.", + "Policy ID can contain only ASCII letters, numbers, '.', '_', ':' and '-'; spaces are not allowed.": "Policy ID can contain only ASCII letters, numbers, '.', '_', ':' and '-'; spaces are not allowed.", + "Rule {0} ID is required.": "Rule {0} ID is required.", + "Rule {0} ID cannot exceed 128 characters.": "Rule {0} ID cannot exceed 128 characters.", + "Rule {0} ID must start with an ASCII letter or number.": "Rule {0} ID must start with an ASCII letter or number.", + "Rule {0} ID can contain only ASCII letters, numbers, '.', '_', ':' and '-'; spaces are not allowed.": "Rule {0} ID can contain only ASCII letters, numbers, '.', '_', ':' and '-'; spaces are not allowed.", + "Rule {0} needs at least one request condition. Configure Request characteristics or another match field, or delete the rule.": "Rule {0} needs at least one request condition. Configure Request characteristics or another match field, or delete the rule.", + "Shows this rule's evaluation position. Use Move up or Move down to change it; UniGetUI assigns the underlying priority automatically.": "Shows this rule's evaluation position. Use Move up or Move down to change it; UniGetUI assigns the underlying priority automatically.", + "Choose Allow to permit a matching request or Deny to block it. Changing a Deny rule to Allow starts high-impact request characteristics at No, so the rule matches normal verified requests without extra capabilities. Changing those characteristics back to Does not matter broadens what the Allow rule can match. Disabled rules have no effect.": "Choose Allow to permit a matching request or Deny to block it. Changing a Deny rule to Allow starts high-impact request characteristics at No, so the rule matches normal verified requests without extra capabilities. Changing those characteristics back to Does not matter broadens what the Allow rule can match. Disabled rules have no effect.", + "Optional administrator-facing explanation recorded with the rule's decision. Describe why the request is allowed or denied.": "Optional administrator-facing explanation recorded with the rule's decision. Describe why the request is allowed or denied.", + "Limit this rule to package installs, updates, or removals. Leave all choices clear to include every operation.": "Limit this rule to package installs, updates, or removals. Leave all choices clear to include every operation.", + "Limit this rule to selected package managers. Leave all choices clear to include requests from every manager.": "Limit this rule to selected package managers. Leave all choices clear to include requests from every manager.", + "Restrict a package identifier to approved source names configured in one selected package manager. Enter exact configured names—not display names or URLs—and use a separate rule for each manager.": "Restrict a package identifier to approved source names configured in one selected package manager. Enter exact configured names—not display names or URLs—and use a separate rule for each manager.", + "Source names (one per line; optional)": "Source names (one per line; optional)", + "Source names": "Source names", + "Rule {0} uses Source names and must select exactly one Package manager. Create separate rules for different managers.": "Rule {0} uses Source names and must select exactly one Package manager. Create separate rules for different managers.", + "Limit this rule to package identifiers, one per line. Wildcards such as Contoso.* are supported; leave empty to include every package identifier.": "Limit this rule to package identifiers, one per line. Wildcards such as Contoso.* are supported; leave empty to include every package identifier.", + "Package display-name matching is not currently available. Leave this field empty and use package identifiers instead.": "Package display-name matching is not currently available. Leave this field empty and use package identifiers instead.", + "Limit this rule to exact package version values, one per line. Leave empty to include any version, including requests that do not specify one.": "Limit this rule to exact package version values, one per line. Leave empty to include any version, including requests that do not specify one.", + "Limit this rule to a semantic-version range. A request without a valid semantic version will not match; leave the range off to accept other version formats.": "Limit this rule to a semantic-version range. A request without a valid semantic version will not match; leave the range off to accept other version formats.", + "Lowest semantic version included by this rule. Leave empty for no lower limit.": "Lowest semantic version included by this rule. Leave empty for no lower limit.", + "Highest semantic version included by this rule. Leave empty for no upper limit.": "Highest semantic version included by this rule. Leave empty for no upper limit.", + "Include prerelease versions such as 2.0.0-beta within this range. Leave off to match stable versions only.": "Include prerelease versions such as 2.0.0-beta within this range. Leave off to match stable versions only.", + "Limit this rule to current-user or all-users package requests. Leave both clear to include either scope and requests that do not specify one.": "Limit this rule to current-user or all-users package requests. Leave both clear to include either scope and requests that do not specify one.", + "Limit this rule to selected target architectures. Leave all choices clear to include any architecture and requests that do not specify one.": "Limit this rule to selected target architectures. Leave all choices clear to include any architecture and requests that do not specify one.", + "Limit this rule by requested administrator privileges. Leave both clear to include standard and elevated requests.": "Limit this rule by requested administrator privileges. Leave both clear to include standard and elevated requests.", + "Select a value to restrict this rule to that value. Leaving every value in the group clear means the group does not restrict matching.": "Select a value to restrict this rule to that value. Leaving every value in the group clear means the group does not restrict matching.", + "Request characteristics": "Request characteristics", + "Use these characteristics only to decide whether the rule applies. Does not matter ignores a characteristic; Yes matches when it is present or enabled; No matches when it is absent or disabled.": "Use these characteristics only to decide whether the rule applies. Does not matter ignores a characteristic; Yes matches when it is present or enabled; No matches when it is absent or disabled.", + "Custom parameters": "Custom parameters", + "Custom install location": "Custom install location", + "Pre/post commands": "Pre/post commands", + "Stop running apps before operation": "Stop running apps before operation", + "Uninstall previous version": "Uninstall previous version", + "Decides whether this rule applies based on user interaction. Does not matter ignores this characteristic; Yes matches requests that may show prompts; No matches unattended requests.": "Decides whether this rule applies based on user interaction. Does not matter ignores this characteristic; Yes matches requests that may show prompts; No matches unattended requests.", + "Decides whether this rule applies based on integrity-check bypass. Does not matter ignores this characteristic; Yes matches requests that bypass checks; No matches requests using normal verification.": "Decides whether this rule applies based on integrity-check bypass. Does not matter ignores this characteristic; Yes matches requests that bypass checks; No matches requests using normal verification.", + "Decides whether this rule applies based on prerelease selection. Does not matter ignores this characteristic; Yes matches requests that allow prerelease packages; No matches stable-only requests.": "Decides whether this rule applies based on prerelease selection. Does not matter ignores this characteristic; Yes matches requests that allow prerelease packages; No matches stable-only requests.", + "Decides whether this rule applies based on extra package-manager options. Does not matter ignores this characteristic; Yes matches requests with extra options; No matches requests without them.": "Decides whether this rule applies based on extra package-manager options. Does not matter ignores this characteristic; Yes matches requests with extra options; No matches requests without them.", + "Decides whether this rule applies based on install location. Does not matter ignores this characteristic; Yes matches requests with a custom folder; No matches requests using the default location.": "Decides whether this rule applies based on install location. Does not matter ignores this characteristic; Yes matches requests with a custom folder; No matches requests using the default location.", + "Decides whether this rule applies based on commands run before or after the package operation. Does not matter ignores this characteristic; Yes matches requests with commands; No matches requests without them.": "Decides whether this rule applies based on commands run before or after the package operation. Does not matter ignores this characteristic; Yes matches requests with commands; No matches requests without them.", + "Decides whether this rule applies based on stopping running apps before the package operation. Does not matter ignores this characteristic; Yes matches requests that stop apps; No matches requests that do not.": "Decides whether this rule applies based on stopping running apps before the package operation. Does not matter ignores this characteristic; Yes matches requests that stop apps; No matches requests that do not.", + "Decides whether this rule applies based on removing an existing version before an update. Does not matter ignores this characteristic; Yes matches removal-first requests; No matches requests that do not remove first.": "Decides whether this rule applies based on removing an existing version before an update. Does not matter ignores this characteristic; Yes matches removal-first requests; No matches requests that do not remove first.", + "Additional safety limits": "Additional safety limits", + "Add safety limits to this Allow rule": "Add safety limits to this Allow rule", + "Additional safety limits are available only for Allow rules and restrict what an otherwise allowed request may do. Deny rules do not keep these limits. Dependency installation, agreement acceptance, and restart behavior remain controlled by the package manager.": "Additional safety limits are available only for Allow rules and restrict what an otherwise allowed request may do. Deny rules do not keep these limits. Dependency installation, agreement acceptance, and restart behavior remain controlled by the package manager.", + "Permit a matching request to show installer or package-manager prompts. Turn off to require unattended operation.": "Permit a matching request to show installer or package-manager prompts. Turn off to require unattended operation.", + "Permit a matching request to bypass package integrity checks. Turn off unless a narrowly reviewed exception requires it.": "Permit a matching request to bypass package integrity checks. Turn off unless a narrowly reviewed exception requires it.", + "Permit a matching request to use prerelease package versions. Turn off to require stable releases.": "Permit a matching request to use prerelease package versions. Turn off to require stable releases.", + "Permit a matching request to choose a non-default install folder. Use the location patterns below to limit approved folders.": "Permit a matching request to choose a non-default install folder. Use the location patterns below to limit approved folders.", + "Approved custom install folders, one wildcard pattern per line. If custom locations are allowed and this list is empty, any folder is accepted.": "Approved custom install folders, one wildcard pattern per line. If custom locations are allowed and this list is empty, any folder is accepted.", + "Permit extra package-manager command options. Use the lists below to allow known options and block dangerous ones.": "Permit extra package-manager command options. Use the lists below to allow known options and block dangerous ones.", + "Extra command options allowed exactly as written, one per line. If both allowed lists are empty, any option is accepted unless denied below.": "Extra command options allowed exactly as written, one per line. If both allowed lists are empty, any option is accepted unless denied below.", + "Wildcard patterns for allowed extra command options, one per line. Use these for options whose values vary.": "Wildcard patterns for allowed extra command options, one per line. Use these for options whose values vary.", + "Extra command options that must be rejected, one wildcard pattern per line. A denied option always wins over an allowed option.": "Extra command options that must be rejected, one wildcard pattern per line. A denied option always wins over an allowed option.", + "Permit commands to run before or after the package operation. Turn off unless a narrowly reviewed workflow requires this high-risk capability.": "Permit commands to run before or after the package operation. Turn off unless a narrowly reviewed workflow requires this high-risk capability.", + "Permit named processes to be closed before the package operation. Turn off to prevent policy-approved requests from terminating applications.": "Permit named processes to be closed before the package operation. Turn off to prevent policy-approved requests from terminating applications.", + "Permit removing an installed version before applying an update. Turn off to require updates that do not uninstall first.": "Permit removing an installed version before applying an update. Turn off to require updates that do not uninstall first.", + "Permit an install request to leave an already installed package unchanged instead of upgrading it. Turn off to reject requests that use this option.": "Permit an install request to leave an already installed package unchanged instead of upgrading it. Turn off to reject requests that use this option.", + "Check and save the policy. Errors must be corrected first, and Windows may ask for administrator approval.": "Check and save the policy. Errors must be corrected first, and Windows may ask for administrator approval.", + "Replace a policy that changed after editing began. Review the newer policy first because overwriting discards those external changes.": "Replace a policy that changed after editing began. Review the newer policy first because overwriting discards those external changes.", + "Errors must be corrected before saving; warnings require review and confirmation. Use Go to field to open the affected setting.": "Errors must be corrected before saving; warnings require review and confirmation. Use Go to field to open the affected setting.", + "Open and focus the setting associated with this finding.": "Open and focus the setting associated with this finding.", + "Focus the policy JSON so you can correct the reported formatting or structure problem.": "Focus the policy JSON so you can correct the reported formatting or structure problem.", + "Read-only JSON for the active policy exactly as Devolutions Agent recognizes it. Use it for review, diagnostics, or comparison.": "Read-only JSON for the active policy exactly as Devolutions Agent recognizes it. Use it for review, diagnostics, or comparison.", + "Copy the complete active-policy JSON for review, diagnostics, or comparison.": "Copy the complete active-policy JSON for review, diagnostics, or comparison.", + "Reload the policy state and active policy details from Devolutions Agent.": "Reload the policy state and active policy details from Devolutions Agent.", + "Whether Devolutions Agent allows policy files to be changed. Read-only means the Agent configuration or policy location prevents changes.": "Whether Devolutions Agent allows policy files to be changed. Read-only means the Agent configuration or policy location prevents changes.", + "Whether this UniGetUI installation can safely request policy changes. Available requires both Agent permission and a trusted administrator helper.": "Whether this UniGetUI installation can safely request policy changes. Available requires both Agent permission and a trusted administrator helper.", + "Explains the Agent restriction or local installation condition that prevents policy changes.": "Explains the Agent restriction or local installation condition that prevents policy changes.", + "Whether saving policy changes requires Windows administrator approval.": "Whether saving policy changes requires Windows administrator approval.", + "Open the active policy for editing. The policy ID remains locked so this updates the same policy.": "Open the active policy for editing. The policy ID remains locked so this updates the same policy.", + "Create the first policy for the configured location. New policies start with Deny as the default and no rules.": "Create the first policy for the configured location. New policies start with Deny as the default and no rules.", + "Replace the active policy with a different policy ID. Use only when intentionally creating a new policy identity.": "Replace the active policy with a different policy ID. Use only when intentionally creating a new policy identity.", + "Current policy state: Active is usable, Missing means no policy file exists, and Invalid means an administrator must correct or replace the protected policy file outside UniGetUI.": "Current policy state: Active is usable, Missing means no policy file exists, and Invalid means an administrator must correct or replace the protected policy file outside UniGetUI.", + "Location where Devolutions Agent reads and writes the policy file. Change this location in Agent configuration, not here.": "Location where Devolutions Agent reads and writes the policy file. Change this location in Agent configuration, not here.", + "Shows whether Devolutions Agent is using its default policy location or an explicitly configured location.": "Shows whether Devolutions Agent is using its default policy location or an explicitly configured location.", + "Install applies this rule to requests that add a package. Leave all operations clear to include installs, updates, and removals.": "Install applies this rule to requests that add a package. Leave all operations clear to include installs, updates, and removals.", + "Update applies this rule to requests that change an installed package to another version. Leave all operations clear to include every operation.": "Update applies this rule to requests that change an installed package to another version. Leave all operations clear to include every operation.", + "Uninstall applies this rule to requests that remove a package. Leave all operations clear to include every operation.": "Uninstall applies this rule to requests that remove a package. Leave all operations clear to include every operation.", + "User applies this rule to packages installed for the current user. Leave both scope choices clear to include all scopes and requests that do not state one.": "User applies this rule to packages installed for the current user. Leave both scope choices clear to include all scopes and requests that do not state one.", + "Machine applies this rule to packages installed for all users and commonly requires administrator approval. Leave both scope choices clear to include all scopes and requests that do not state one.": "Machine applies this rule to packages installed for all users and commonly requires administrator approval. Leave both scope choices clear to include all scopes and requests that do not state one.", + "X86 applies this rule to 32-bit Intel or AMD packages. Leave all architectures clear to include any architecture and requests that do not specify one.": "X86 applies this rule to 32-bit Intel or AMD packages. Leave all architectures clear to include any architecture and requests that do not specify one.", + "X64 applies this rule to 64-bit Intel or AMD packages. Leave all architectures clear to include any architecture and requests that do not specify one.": "X64 applies this rule to 64-bit Intel or AMD packages. Leave all architectures clear to include any architecture and requests that do not specify one.", + "Arm64 applies this rule to 64-bit ARM packages. Leave all architectures clear to include any architecture and requests that do not specify one.": "Arm64 applies this rule to 64-bit ARM packages. Leave all architectures clear to include any architecture and requests that do not specify one.", + "Neutral applies this rule to packages that are not tied to one processor architecture. Leave all architectures clear to include any architecture.": "Neutral applies this rule to packages that are not tied to one processor architecture. Leave all architectures clear to include any architecture.", + "Standard applies this rule when the request runs without administrator privileges. It excludes elevated requests and machine-wide work that requests elevation; leave both choices clear to include either.": "Standard applies this rule when the request runs without administrator privileges. It excludes elevated requests and machine-wide work that requests elevation; leave both choices clear to include either.", + "Elevated applies this rule when the request requires administrator privileges. Leave both elevation choices clear to include standard and elevated requests.": "Elevated applies this rule when the request requires administrator privileges. Leave both elevation choices clear to include standard and elevated requests.", + "{0} applies this rule to requests handled by that package manager. Leave all managers clear to include every package manager.": "{0} applies this rule to requests handled by that package manager. Leave all managers clear to include every package manager.", + "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%.", + "Enable Audit mode?": "Enable Audit mode?", + "Enable Audit mode": "Enable Audit mode", + "Allow unmatched package requests?": "Allow unmatched package requests?", + "Use Allow as default": "Use Allow as default", + "Audit mode still evaluates and logs policy decisions, but requests the policy would deny will be permitted. Enable Audit mode and save this policy?": "Audit mode still evaluates and logs policy decisions, but requests the policy would deny will be permitted. Enable Audit mode and save this policy?", + "The default decision will permit every package request that does not match an enabled rule. Use Allow as the default and save this policy?": "The default decision will permit every package request that does not match an enabled rule. Use Allow as the default and save this policy?", + "Change this rule to Deny?": "Change this rule to Deny?", + "Remove limits and change": "Remove limits and change", + "Rule {0} has Additional safety limits that apply only to Allow rules. Changing it to Deny will remove those limits. Continue?": "Rule {0} has Additional safety limits that apply only to Allow rules. Changing it to Deny will remove those limits. Continue?", + "Replace the active policy?": "Replace the active policy?", + "Replace": "Replace", + "Create a new policy?": "Create a new policy?", + "Overwrite": "Overwrite", + "Discard unsaved changes?": "Discard unsaved changes?", + "Confirm": "Confirm", + "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}.", + "You have unsaved changes to policy {0}. Discard them?": "You have unsaved changes to policy {0}. Discard them?", + "You have unsaved policy changes. Discard them?": "You have unsaved policy changes. 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", + "Evaluation order": "Evaluation order", + "Rule {0} is now position {1} of {2}.": "Rule {0} is now position {1} of {2}.", + "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 complete policy as JSON. Valid structure is required to return to guided fields; policy errors remain available there for correction.": "Edit the complete policy as JSON. Valid structure is required to return to guided fields; policy errors remain available there for correction.", + "Raw policy JSON": "Raw policy JSON", + "Validation findings": "Validation findings", + "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.", + "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 type is unsupported.": "The policy type is unsupported.", + "The policy validity interval is invalid.": "The policy validity interval is invalid.", + "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 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}", + "Policy saved, then replaced again": "Policy saved, then replaced again", + "The policy was saved, but another writer replaced it before management state was refreshed.": "The policy was saved, but another writer replaced it before management state was refreshed.", + "The policy was saved, but refreshing the current policy state timed out.": "The policy was saved, but refreshing the current policy state timed out.", + "The policy was saved, but the current policy state could not be refreshed.": "The policy was saved, but the current policy state could not be refreshed.", + "The policy write result could not be confirmed.": "The policy write result could not be confirmed.", + "The policy write result is unknown. Refresh policy management state before retrying.": "The policy write result is unknown. Refresh policy management state before retrying.", + "The document is not a valid policy draft": "The document is not a valid policy draft", + "The document is empty.": "The document is empty.", + "The JSON syntax is invalid.": "The JSON syntax is invalid.", + "The policy draft uses an unsupported schema.": "The policy draft uses an unsupported schema.", + "The policy draft uses an unsupported policy type.": "The policy draft uses an unsupported policy type.", + "The policy draft is missing the Enforcement object.": "The policy draft is missing the Enforcement object.", + "The policy draft uses an unsupported rule precedence.": "The policy draft uses an unsupported rule precedence.", + "The policy draft is missing the Metadata object.": "The policy draft is missing the Metadata object.", + "The document does not match the policy draft format.": "The document does not match the policy draft format.", + "Enter a whole number from 0 through 2147483647.": "Enter a whole number from 0 through 2147483647.", + "Allowed custom locations": "Allowed custom locations", + "Allowed hash-check skipping": "Allowed hash-check skipping", + "Allowed pre/post commands": "Allowed pre/post commands", + "AdministratorRequired": "Administrator required", + "BadRequest": "Bad request", + "BrokerPaused": "Broker paused", + "Conflict": "Conflict", + "Forbidden": "Forbidden", + "Info": "Information", + "InternalError": "Internal error", + "InvalidPolicy": "Invalid policy", + "MalformedDraft": "Malformed draft", + "NotFound": "Not found", + "PayloadTooLarge": "Payload too large", + "PolicyActivationFailed": "Policy activation failed", + "PolicyPersistenceFailed": "Policy persistence failed", + "StalePolicyStoreToken": "The policy changed since editing began", + "Timeout": "Timed out", + "Unauthenticated": "Authentication required", + "Unauthorized": "Unauthorized", + "UnsafePolicyPath": "Unsafe policy path", + "UnsupportedEndpoint": "Unsupported endpoint", + "UnsupportedMediaType": "Unsupported media type", + "UnsupportedPolicyFilesystem": "Unsupported policy filesystem", + "UnsupportedPolicyFormat": "Unsupported policy format", + "ValidationFailed": "Validation failed", + "WarningConfirmationRequired": "Warning confirmation required", + "Exact identifiers": "Exact identifiers", + "Identifier patterns": "Identifier patterns", + "Semantic version range": "Semantic version range", + "Package identifiers": "Package identifiers", + "Exact values": "Exact values", + "Patterns": "Patterns", + "Package versions": "Package versions", + "Execution privilege": "Execution privilege", + "Execution privilege (optional)": "Execution privilege (optional)", + "Source names become available after selecting exactly one package manager that supports configured sources.": "Source names become available after selecting exactly one package manager that supports configured sources.", + "Package identifier match mode": "Package identifier match mode", + "Exact identifiers match complete package identifiers literally; wildcard characters have no special meaning.": "Exact identifiers match complete package identifiers literally; wildcard characters have no special meaning.", + "Patterns may match multiple packages. Use * for any characters and ? for one character; for example, Contoso.*.": "Patterns may match multiple packages. Use * for any characters and ? for one character; for example, Contoso.*.", + "Package versions match mode": "Package versions match mode", + "Exact package versions": "Exact package versions", + "Exact versions match any package version value literally, including versions that are not semantic versions.": "Exact versions match any package version value literally, including versions that are not semantic versions.", + "Choose whether package identifiers do not affect matching, must match exact literal identifiers, or match wildcard patterns.": "Choose whether package identifiers do not affect matching, must match exact literal identifiers, or match wildcard patterns.", + "Choose whether package versions do not affect matching, must match exact version text, or fall within a semantic-version range.": "Choose whether package versions do not affect matching, must match exact version text, or fall within a semantic-version range.", + "Limit this rule by requested execution privilege. Leave both clear to include standard and elevated requests.": "Limit this rule by requested execution privilege. Leave both clear to include standard and elevated requests.", + "Match complete package identifiers exactly as written. This works for any package identifier and does not interpret wildcard characters.": "Match complete package identifiers exactly as written. This works for any package identifier and does not interpret wildcard characters.", + "Match complete package version values exactly as written. Exact matching works for semantic and non-semantic package versions.": "Match complete package version values exactly as written. Exact matching works for semantic and non-semantic package versions.", + "Match package identifiers with wildcard patterns. Patterns may match multiple packages; use * for any characters and ? for one character.": "Match package identifiers with wildcard patterns. Patterns may match multiple packages; use * for any characters and ? for one character.", + "Restrict package requests to approved source names configured in exactly one selected source-capable package manager. Names are matched exactly; use a separate rule for each manager.": "Restrict package requests to approved source names configured in exactly one selected source-capable package manager. Names are matched exactly; use a separate rule for each manager.", + "Rule {0} must include at least one exact version or a semantic-version range for the selected match mode.": "Rule {0} must include at least one exact version or a semantic-version range for the selected match mode.", + "Rule {0} must include at least one package identifier for the selected match mode.": "Rule {0} must include at least one package identifier for the selected match mode.", + "Rule {0} uses Source names, but the selected package manager does not support configured sources. Choose a source-capable manager or remove Source names.": "Rule {0} uses Source names, but the selected package manager does not support configured sources. Choose a source-capable manager or remove Source names.", + "Start Menu shortcuts list": "Start Menu shortcuts list", + "Choose both a date and time, or clear this validity limit.": "Choose both a date and time, or clear this validity limit.", + "Allow custom installation location": "Allow custom installation location", + "Allow stopping running applications": "Allow stopping running applications", + "Allow uninstalling the previous version": "Allow uninstalling the previous version", + "An enabled Allow rule": "An enabled Allow rule", + "Communication with Devolutions Agent timed out.": "Communication with Devolutions Agent timed out.", + "Devolutions Agent closed the connection without a response.": "Devolutions Agent closed the connection without a response.", + "Devolutions Agent returned an invalid policy response.": "Devolutions Agent returned an invalid policy response.", + "Devolutions Agent was unavailable or closed the connection before responding.": "Devolutions Agent was unavailable or closed the connection before responding.", + "Prerelease packages": "Prerelease packages", + "Rule {0}": "Rule {0}", + "Rule “{0}”": "Rule “{0}”", + "Skip hash verification": "Skip hash verification", + "{0} The policy write result is unknown. Refresh policy management state before retrying.": "{0} The policy write result is unknown. Refresh policy management state before retrying.", + "{0} allows a custom installation location without approved paths.": "{0} allows a custom installation location without approved paths.", + "{0} allows a sensitive option.": "{0} allows a sensitive option.", + "{0} allows custom installation locations within configured approved paths.": "{0} allows custom installation locations within configured approved paths.", + "{0} allows custom parameters subject to configured restrictions.": "{0} allows custom parameters subject to configured restrictions.", + "{0} allows custom parameters without an allowlist.": "{0} allows custom parameters without an allowlist.", + "{0} allows pre/post commands.": "{0} allows pre/post commands.", + "{0} allows prerelease packages.": "{0} allows prerelease packages.", + "{0} allows skipping hash verification.": "{0} allows skipping hash verification.", + "{0} allows stopping running applications.": "{0} allows stopping running applications.", + "{0} allows uninstalling the previous version.": "{0} allows uninstalling the previous version.", + "{0} warning(s)": "{0} warning(s)", + "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.": "Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app." } diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/AuthenticatedBrokerTransport.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/AuthenticatedBrokerTransport.cs new file mode 100644 index 0000000000..31b21c0b0b --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/AuthenticatedBrokerTransport.cs @@ -0,0 +1,349 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Client; +using Microsoft.Win32.SafeHandles; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation.Interop; + +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +internal sealed class AuthenticatedBrokerTransport : IBrokerTransport +{ + private const string DefaultPipeName = "Devolutions.Now.PackageBroker.v1"; + private const int ConnectTimeoutMilliseconds = 5000; + private const int ReadTimeoutMilliseconds = 30000; + private const int MaxHeaderBytes = 65536; + internal const int MaxPolicyManagementResponseBytes = + BrokerApi.MaxPolicyManagementBodyBytes * 3 + MaxHeaderBytes; + private readonly string _pipeName; + private readonly Func _authenticate; + + public AuthenticatedBrokerTransport(string? pipeName = null) + : this(pipeName, AuthenticatedBrokerServer.Authenticate) + { + } + + internal AuthenticatedBrokerTransport( + string? pipeName, + Func authenticate) + { + _pipeName = string.IsNullOrWhiteSpace(pipeName) ? DefaultPipeName : pipeName; + _authenticate = authenticate; + } + + public Transport Kind => Transport.HttpNamedPipe; + + public async Task Send( + BrokerTransportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + using var pipe = new NamedPipeClientStream( + ".", + _pipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous, + TokenImpersonationLevel.Identification); + using (CancellationTokenSource connectCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + connectCancellation.CancelAfter(ConnectTimeoutMilliseconds); + await pipe.ConnectAsync(connectCancellation.Token).ConfigureAwait(false); + } + + using IDisposable server = _authenticate(pipe, request.Path); + await WriteRequestAsync(pipe, request, cancellationToken).ConfigureAwait(false); + + using CancellationTokenSource readCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + readCancellation.CancelAfter(ReadTimeoutMilliseconds); + return await ReadResponseAsync( + pipe, + request.Path, + readCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw BrokerFailure( + BrokerClientErrorKind.Timeout, + $"Timed out communicating with the package broker at {request.Path}.", + request.Path, + ex); + } + catch (IOException ex) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"Unable to communicate with the package broker at {request.Path}.", + request.Path, + ex); + } + catch (UnauthorizedAccessException ex) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"Access to the package broker was denied while calling {request.Path}.", + request.Path, + ex); + } + } + + public void Dispose() + { + } + + private static async Task WriteRequestAsync( + Stream pipe, + BrokerTransportRequest request, + CancellationToken cancellationToken) + { + var headers = new StringBuilder() + .Append(request.Method) + .Append(' ') + .Append(request.Path) + .Append(" HTTP/1.1\r\n") + .Append("Host: now-package-broker\r\n") + .Append("Connection: close\r\n"); + foreach ((string name, string value) in request.Headers) + { + if (!name.Equals("Host", StringComparison.OrdinalIgnoreCase)) + { + headers.Append(name).Append(": ").Append(value).Append("\r\n"); + } + } + + byte[]? body = request.Body is null ? null : Encoding.UTF8.GetBytes(request.Body); + headers.Append("Content-Length: ").Append(body?.Length ?? 0).Append("\r\n\r\n"); + await pipe.WriteAsync( + Encoding.ASCII.GetBytes(headers.ToString()), + cancellationToken).ConfigureAwait(false); + if (body is not null) + { + await pipe.WriteAsync(body, cancellationToken).ConfigureAwait(false); + } + + await pipe.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadResponseAsync( + Stream pipe, + string path, + CancellationToken cancellationToken) + { + byte[] buffer = new byte[MaxHeaderBytes]; + int totalRead = 0; + while (totalRead < MaxHeaderBytes) + { + int read = await pipe.ReadAsync( + buffer.AsMemory(totalRead, MaxHeaderBytes - totalRead), + cancellationToken).ConfigureAwait(false); + if (read == 0) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker disconnected before sending a complete response for {path}.", + path); + } + + totalRead += read; + string received = Encoding.ASCII.GetString(buffer, 0, totalRead); + int headerEnd = received.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (headerEnd < 0) + { + continue; + } + + string[] lines = received[..headerEnd].Split("\r\n"); + string[] status = lines[0].Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (status.Length < 2 || !int.TryParse(status[1], out int statusCode)) + { + throw BrokerFailure( + BrokerClientErrorKind.InvalidResponse, + $"The package broker returned an invalid HTTP status line for {path}.", + path); + } + + int? contentLength = null; + for (int index = 1; index < lines.Length; index++) + { + int separator = lines[index].IndexOf(':'); + if (separator <= 0) + { + continue; + } + + string name = lines[index][..separator].Trim(); + if (!name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (contentLength is not null + || !int.TryParse(lines[index][(separator + 1)..].Trim(), out int parsed) + || parsed < 0 + || parsed > MaxPolicyManagementResponseBytes) + { + throw BrokerFailure( + BrokerClientErrorKind.InvalidResponse, + $"The package broker returned an invalid Content-Length for {path}.", + path); + } + + contentLength = parsed; + } + + int bodyLength = contentLength ?? 0; + int bodyStart = headerEnd + 4; + if (bodyStart + bodyLength > buffer.Length) + { + Array.Resize(ref buffer, bodyStart + bodyLength); + } + + int bodyRead = totalRead - bodyStart; + while (bodyRead < bodyLength) + { + read = await pipe.ReadAsync( + buffer.AsMemory(bodyStart + bodyRead, bodyLength - bodyRead), + cancellationToken).ConfigureAwait(false); + if (read == 0) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker disconnected before sending the complete response body for {path}.", + path); + } + + bodyRead += read; + } + + return new BrokerTransportResponse + { + StatusCode = statusCode, + Body = Encoding.UTF8.GetString(buffer, bodyStart, bodyLength), + }; + } + + throw BrokerFailure( + BrokerClientErrorKind.InvalidResponse, + $"The package broker returned response headers that are too large for {path}.", + path); + } + + private static BrokerClientException BrokerFailure( + BrokerClientErrorKind kind, + string message, + string path, + Exception? innerException = null) => + new(kind, message, path, null, null, innerException); + + private sealed class AuthenticatedBrokerServer : IDisposable + { + private readonly SafeProcessHandle _process; + private readonly PolicyElevationLocationVerification _location; + + private AuthenticatedBrokerServer( + SafeProcessHandle process, + PolicyElevationLocationVerification location) + { + _process = process; + _location = location; + } + + public static AuthenticatedBrokerServer Authenticate( + NamedPipeClientStream pipe, + string path) + { + if (!PolicyElevationNative.GetNamedPipeServerProcessId( + pipe.SafePipeHandle, + out uint serverProcessId) + || serverProcessId == 0) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker identity could not be verified for {path}.", + path); + } + + SafeProcessHandle process = PolicyElevationNative.OpenProcess( + PolicyElevationNative.ProcessQueryLimitedInformation, + false, + serverProcessId); + if (process.IsInvalid) + { + process.Dispose(); + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker identity could not be verified for {path}.", + path); + } + + PolicyElevationLocationVerification? location = null; + try + { + if (!WindowsProcessInspector.TryGetProcessId( + process.DangerousGetHandle(), + out uint heldProcessId) + || heldProcessId != serverProcessId + || !WindowsProcessInspector.TryGetImagePath( + process.DangerousGetHandle(), + out string? imagePath) + || (imagePath = WindowsProcessInspector.TryGetCanonicalPath(imagePath)) is null + || !WindowsProcessInspector.TryGetTokenElevation( + process.DangerousGetHandle(), + out bool elevated, + out bool administrator) + || !elevated + || !administrator) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker identity could not be verified for {path}.", + path); + } + + location = new WindowsProtectedLocationVerifier().VerifyExecutable(imagePath); + if (!location.IsProtected + || !WindowsProcessInspector.PathsAreEqual( + location.CanonicalHelperPath, + imagePath)) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker executable is not in a protected location for {path}.", + path); + } + + PolicyElevationTrustResult trust = + new WindowsAuthenticodeTrustVerifier().VerifyExecutable(imagePath); + if (!trust.IsTrusted) + { + throw BrokerFailure( + BrokerClientErrorKind.BrokerUnavailable, + $"The package broker publisher could not be verified for {path}.", + path); + } + + var authenticated = new AuthenticatedBrokerServer(process, location); + process = null!; + location = null; + return authenticated; + } + finally + { + process?.Dispose(); + location?.Dispose(); + } + } + + public void Dispose() + { + _location.Dispose(); + _process.Dispose(); + } + } +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperStageTimeouts.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperStageTimeouts.cs new file mode 100644 index 0000000000..ec936506c9 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperStageTimeouts.cs @@ -0,0 +1,32 @@ +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +internal sealed class PolicyElevationHelperStageTimeouts : IDisposable +{ + private readonly TimeSpan _exchangeTimeout; + private CancellationTokenSource? _stage; + private bool _exchangeStarted; + + public PolicyElevationHelperStageTimeouts(TimeSpan connectTimeout, TimeSpan exchangeTimeout) + { + _exchangeTimeout = exchangeTimeout; + _stage = new CancellationTokenSource(connectTimeout); + } + + public CancellationToken Token => + _stage?.Token ?? throw new ObjectDisposedException(nameof(PolicyElevationHelperStageTimeouts)); + + public void BeginExchange() + { + ObjectDisposedException.ThrowIf(_stage is null, this); + if (_exchangeStarted) + throw new InvalidOperationException("The helper exchange timeout has already started."); + + _exchangeStarted = true; + CancellationTokenSource connectStage = _stage; + _stage = new CancellationTokenSource(_exchangeTimeout); + connectStage.Dispose(); + } + + public void Dispose() => + Interlocked.Exchange(ref _stage, null)?.Dispose(); +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperSynchronousStageRunner.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperSynchronousStageRunner.cs new file mode 100644 index 0000000000..12c4c805f6 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyElevationHelperSynchronousStageRunner.cs @@ -0,0 +1,61 @@ +namespace UniGetUI.AgentPolicy.ElevatedHelper; + +internal readonly record struct PolicyElevationHelperSynchronousStageResult( + bool Completed, + T Value) +{ + public static PolicyElevationHelperSynchronousStageResult TimedOut => + new(false, default!); +} + +internal static class PolicyElevationHelperSynchronousStageRunner +{ + public static async Task> RunAsync( + Func operation, + CancellationToken cancellationToken, + Action? disposeAbandonedResult = null, + Action? cleanupAfterAbandonedWork = null) + { + cancellationToken.ThrowIfCancellationRequested(); + Task worker = Task.Run( + () => + { + cancellationToken.ThrowIfCancellationRequested(); + return operation(); + }, + CancellationToken.None); + + try + { + T result = await worker.WaitAsync(cancellationToken).ConfigureAwait(false); + return new(true, result); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Task cleanup = worker.ContinueWith( + completed => + { + try + { + if (completed.Status == TaskStatus.RanToCompletion) + disposeAbandonedResult?.Invoke(completed.Result); + else + _ = completed.Exception; + } + finally + { + cleanupAfterAbandonedWork?.Invoke(); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + _ = cleanup.ContinueWith( + static faulted => _ = faulted.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return PolicyElevationHelperSynchronousStageResult.TimedOut; + } + } +} diff --git a/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs new file mode 100644 index 0000000000..64e7b83aa5 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Client; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; +using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation.Interop; + +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, + string effectiveUser, + CancellationToken cancellationToken) + { + var response = new PolicyElevationResponseMessage + { + ProtocolVersion = PolicyElevationProtocol.Version, + RequestId = request.RequestId, + }; + + try + { + using var transport = new AuthenticatedBrokerTransport(); + BrokerClientOptions options = CreateClientOptions(effectiveUser, transport); + using var client = new BrokerClient(options); + + PolicyReplacementResponse replacement = + await PolicyElevationReplacementDispatcher.DispatchAsync( + request, + client.ReplacePolicy, + cancellationToken).ConfigureAwait(false); + + response.Disposition = PolicyElevationDisposition.Committed; + response.CommittedStoreToken = replacement.Management.StoreToken; + PolicyElevationFrame.ValidateResponse(response); + return response; + } + catch (BrokerClientException ex) + { + response.Disposition = GetFailureDisposition(ex); + response.BrokerStatusCode = ex.StatusCode; + response.BrokerErrorCode = Truncate( + ex.BrokerError?.Code.ToString() ?? ex.Kind.ToString(), + PolicyElevationProtocol.MaxBrokerErrorCodeCharacters); + if (response.Disposition == PolicyElevationDisposition.Rejected + && ex.BrokerError is + { + Code: ErrorCode.StalePolicyStoreToken, + Management: not null, + } stale) + { + response.ConflictStoreToken = stale.Management.StoreToken; + response.ConflictState = stale.Management.State switch + { + PolicyManagementState.Active => PolicyElevationManagementState.Active, + PolicyManagementState.Missing => PolicyElevationManagementState.Missing, + PolicyManagementState.Invalid => PolicyElevationManagementState.Invalid, + _ => throw new InvalidDataException("The stale response carried an invalid management state."), + }; + response.ConflictPolicyId = stale.Management.Policy?.Metadata.Id; + } + return response; + } + catch (OperationCanceledException) + { + response.Disposition = PolicyElevationDisposition.Unknown; + response.BrokerErrorCode = BrokerClientErrorKind.Timeout.ToString(); + return response; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException) + { + response.Disposition = PolicyElevationDisposition.Unknown; + response.BrokerErrorCode = BrokerClientErrorKind.InvalidResponse.ToString(); + return response; + } + } + + internal static PolicyElevationDisposition GetFailureDisposition(BrokerClientException exception) + { + ArgumentNullException.ThrowIfNull(exception); + return exception.Kind is + BrokerClientErrorKind.BrokerUnavailable + or BrokerClientErrorKind.Timeout + or BrokerClientErrorKind.EmptyResponse + or BrokerClientErrorKind.InvalidResponse + || (exception.Kind == BrokerClientErrorKind.BrokerError + && exception.BrokerError is null) + ? PolicyElevationDisposition.Unknown + : PolicyElevationDisposition.Rejected; + } + + internal static BrokerClientOptions CreateClientOptions( + string effectiveUser, + IBrokerTransport? transport = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(effectiveUser); + if (!WindowsProcessInspector.IsValidEffectiveUser(effectiveUser)) + { + throw new ArgumentException("The effective user is not a bounded Windows account name.", nameof(effectiveUser)); + } + + return new BrokerClientOptions + { + Transport = transport!, + RequestedElevation = Elevation.Elevated, + EffectiveUser = effectiveUser, + ClientExecutablePath = Environment.ProcessPath, + ClientVersion = typeof(PolicyReplacementExecutor).Assembly.GetName().Version?.ToString(), + }; + } + + 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..095a56745a --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/Program.cs @@ -0,0 +1,405 @@ +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 stageTimeouts = new PolicyElevationHelperStageTimeouts( + PolicyElevationProtocol.ConnectTimeout, + PolicyElevationProtocol.ExchangeTimeout); + + try + { + return await RunAsync(launch, stageTimeouts).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, + PolicyElevationHelperStageTimeouts stageTimeouts) + { + if (!WindowsProcessInspector.TryGetTokenElevation( + PolicyElevationNative.GetCurrentProcess(), + out bool isElevated, + out bool isAdministrator) + || !isElevated + || !isAdministrator) + { + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + AuthenticatedHostContext? authenticatedHost = null; + NamedPipeClientStream? pipe = null; + try + { + PolicyElevationHelperSynchronousStageResult preparation = + await PolicyElevationHelperSynchronousStageRunner.RunAsync( + () => TryPrepareAuthenticatedHost(launch), + stageTimeouts.Token, + static abandoned => abandoned?.Dispose()).ConfigureAwait(false); + if (!preparation.Completed) + return PolicyElevationProtocol.ExitConnectFailed; + + authenticatedHost = preparation.Value; + if (authenticatedHost is null) + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + + pipe = new NamedPipeClientStream( + ".", + launch.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + TokenImpersonationLevel.Anonymous); + + await pipe.ConnectAsync( + (int)PolicyElevationProtocol.ConnectTimeout.TotalMilliseconds, + stageTimeouts.Token) + .ConfigureAwait(false); + + AuthenticatedHostContext pipeAuthenticationHost = authenticatedHost; + NamedPipeClientStream authenticatedPipe = pipe; + PolicyElevationHelperSynchronousStageResult pipeAuthentication = + await PolicyElevationHelperSynchronousStageRunner.RunAsync( + () => AuthenticateConnectedPipe(pipeAuthenticationHost, authenticatedPipe), + stageTimeouts.Token, + cleanupAfterAbandonedWork: () => + { + authenticatedPipe.Dispose(); + pipeAuthenticationHost.Dispose(); + }).ConfigureAwait(false); + if (!pipeAuthentication.Completed) + { + pipe = null; + authenticatedHost = null; + return PolicyElevationProtocol.ExitConnectFailed; + } + + if (pipeAuthentication.Value != PolicyElevationProtocol.ExitSuccess) + return pipeAuthentication.Value; + + AuthenticatedHostContext identityHost = authenticatedHost; + NamedPipeClientStream identityPipe = pipe; + PolicyElevationHelperSynchronousStageResult identityResolution = + await PolicyElevationHelperSynchronousStageRunner.RunAsync( + () => ResolveInitiatingUser(identityHost), + stageTimeouts.Token, + cleanupAfterAbandonedWork: () => + { + identityPipe.Dispose(); + identityHost.Dispose(); + }).ConfigureAwait(false); + if (!identityResolution.Completed) + { + pipe = null; + authenticatedHost = null; + return PolicyElevationProtocol.ExitConnectFailed; + } + + InitiatingUserResult identity = identityResolution.Value; + if (identity.ExitCode != PolicyElevationProtocol.ExitSuccess + || identity.EffectiveUser is null) + { + return identity.ExitCode; + } + + PolicyElevationRequestMessage request = + await PolicyElevationFrame.ReadRequestAsync(pipe, stageTimeouts.Token).ConfigureAwait(false); + stageTimeouts.Token.ThrowIfCancellationRequested(); + stageTimeouts.BeginExchange(); + + using var brokerCancellation = + CancellationTokenSource.CreateLinkedTokenSource(stageTimeouts.Token); + using var disconnectMonitorCancellation = + CancellationTokenSource.CreateLinkedTokenSource(stageTimeouts.Token); + Task disconnectMonitor = MonitorHostDisconnectAsync( + pipe, + brokerCancellation, + disconnectMonitorCancellation.Token); + + PolicyElevationResponseMessage response; + try + { + response = await PolicyReplacementExecutor + .ExecuteAsync(request, identity.EffectiveUser, 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. + using var responseWrite = + new CancellationTokenSource(PolicyElevationProtocol.ResponseWriteTimeout); + await PolicyElevationFrame.WriteResponseAsync(pipe, response, responseWrite.Token).ConfigureAwait(false); + + return PolicyElevationProtocol.ExitSuccess; + } + catch (Exception ex) when (ex is TimeoutException or IOException or UnauthorizedAccessException) + { + return PolicyElevationProtocol.ExitConnectFailed; + } + finally + { + pipe?.Dispose(); + authenticatedHost?.Dispose(); + } + } + + private static AuthenticatedHostContext? TryPrepareAuthenticatedHost( + PolicyElevationLaunchArguments launch) + { + 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 null; + } + + SafeProcessHandle host = PolicyElevationNative.OpenProcess( + PolicyElevationNative.ProcessQueryLimitedInformation | PolicyElevationNative.Synchronize, + false, + unchecked((uint)launch.ParentProcessId)); + if (host.IsInvalid) + { + host.Dispose(); + verification.Dispose(); + return null; + } + + var expectation = new PolicyElevationPeerExpectation( + hostPath, + installRoot, + unchecked((uint)launch.ParentProcessId), + launch.ParentCreationTimeUtcTicks, + launch.SessionId) + { + RequireElevatedAdministrator = false, + Verification = verification, + }; + + if (!WindowsPeerAuthenticator + .Authenticate( + host.DangerousGetHandle(), + expectation.ExpectedProcessId, + expectation, + trustVerifier, + selfPath) + .IsAuthenticated) + { + host.Dispose(); + verification.Dispose(); + return null; + } + + return new AuthenticatedHostContext( + verification, + host, + expectation, + trustVerifier, + selfPath); + } + + private static int AuthenticateConnectedPipe( + AuthenticatedHostContext authenticatedHost, + NamedPipeClientStream pipe) + { + if (!PolicyElevationNative.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out uint serverProcessId)) + return PolicyElevationProtocol.ExitPeerAuthenticationFailed; + + return WindowsPeerAuthenticator + .Authenticate( + authenticatedHost.Host.DangerousGetHandle(), + serverProcessId, + authenticatedHost.Expectation, + authenticatedHost.TrustVerifier, + authenticatedHost.SelfPath) + .IsAuthenticated + ? PolicyElevationProtocol.ExitSuccess + : PolicyElevationProtocol.ExitPeerAuthenticationFailed; + } + + private static InitiatingUserResult ResolveInitiatingUser( + AuthenticatedHostContext authenticatedHost) + { + int exitCode = PolicyElevationInitiatingUserResolver.Resolve( + authenticatedHost.Host.DangerousGetHandle(), + out string? effectiveUser); + return new InitiatingUserResult(exitCode, effectiveUser); + } + + 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; + } + + private readonly record struct InitiatingUserResult(int ExitCode, string? EffectiveUser); + + private sealed class AuthenticatedHostContext : IDisposable + { + public AuthenticatedHostContext( + PolicyElevationLocationVerification layout, + SafeProcessHandle host, + PolicyElevationPeerExpectation expectation, + IPolicyElevationTrustVerifier trustVerifier, + string selfPath) + { + Layout = layout; + Host = host; + Expectation = expectation; + TrustVerifier = trustVerifier; + SelfPath = selfPath; + } + + public PolicyElevationLocationVerification Layout { get; } + public SafeProcessHandle Host { get; } + public PolicyElevationPeerExpectation Expectation { get; } + public IPolicyElevationTrustVerifier TrustVerifier { get; } + public string SelfPath { get; } + + public void Dispose() + { + Host.Dispose(); + Layout.Dispose(); + } + } +} 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..a900ce6581 --- /dev/null +++ b/src/UniGetUI.AgentPolicy.ElevatedHelper/UniGetUI.AgentPolicy.ElevatedHelper.csproj @@ -0,0 +1,52 @@ + + + + + + $(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/AppRestartHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs index def6de74cd..f541199b4c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs @@ -8,17 +8,20 @@ internal static class AppRestartHelper { private const string LauncherExecutableName = "UniGetUI.exe"; - public static void Restart() + public static void Restart() => _ = RestartAsync(); + + private static async Task RestartAsync() { string executablePath = ResolveRestartExecutablePath(AppContext.BaseDirectory); - CoreTools.ScheduleRelaunchAfterExit(executablePath); if (MainWindow.Instance is { } mainWindow) { - mainWindow.QuitApplication(); + await mainWindow.RequestQuitApplicationAsync( + () => CoreTools.ScheduleRelaunchAfterExit(executablePath)); return; } + CoreTools.ScheduleRelaunchAfterExit(executablePath); (global::Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); } diff --git a/src/UniGetUI.Avalonia/Infrastructure/ApplicationShutdownCoordinator.cs b/src/UniGetUI.Avalonia/Infrastructure/ApplicationShutdownCoordinator.cs new file mode 100644 index 0000000000..8bd4fb3037 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/ApplicationShutdownCoordinator.cs @@ -0,0 +1,47 @@ +namespace UniGetUI.Avalonia.Infrastructure; + +internal sealed class ApplicationShutdownCoordinator +{ + private int _isQuitting; + private int _requestPending; + + public bool IsQuitting => Volatile.Read(ref _isQuitting) != 0; + + public async Task RequestAsync( + Func> authorizeShutdown, + Func shutdown, + Action? onAuthorized = null) + { + ArgumentNullException.ThrowIfNull(authorizeShutdown); + ArgumentNullException.ThrowIfNull(shutdown); + + if (IsQuitting || Interlocked.Exchange(ref _requestPending, 1) != 0) + return false; + + try + { + if (!await authorizeShutdown()) + return false; + + if (Interlocked.Exchange(ref _isQuitting, 1) != 0) + return false; + + try + { + onAuthorized?.Invoke(); + } + catch + { + Interlocked.Exchange(ref _isQuitting, 0); + throw; + } + + await shutdown(); + return true; + } + finally + { + Interlocked.Exchange(ref _requestPending, 0); + } + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index a23cf7b298..5aa27b78f2 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -27,7 +27,12 @@ public sealed class SettingsSearchResult /// public static class SettingsSearchIndex { - private sealed record Entry(string Title, string[] Keywords, Type PageType, string? Anchor); + private sealed record Entry( + string Title, + string[] Keywords, + Type PageType, + string? Anchor, + bool WindowsOnly = false); // Order matters only as a tie-break for equally-ranked matches. private static readonly Entry[] Entries = @@ -123,6 +128,11 @@ private sealed record Entry(string Title, string[] Keywords, Type PageType, stri new("Ask for administrator privileges once for each batch of operations", ["administrator", "admin rights", "elevation", "uac", "batch"], typeof(Administrator), "AdminElevationCard"), 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("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"), @@ -146,6 +156,11 @@ private sealed record Entry(string Title, string[] Keywords, Type PageType, stri private const int NoMatch = int.MaxValue; public static IReadOnlyList Search(string query, int limit = 8) + { + return Search(query, limit, OperatingSystem.IsWindows()); + } + + internal static IReadOnlyList Search(string query, int limit, bool isWindows) { var queryWords = Tokenize(query); if (queryWords.Count == 0) return []; @@ -154,6 +169,8 @@ public static IReadOnlyList Search(string query, int limit foreach (var e in Entries) { + if (e.WindowsOnly && !isWindows) continue; + int score = Rank(queryWords, CoreTools.Translate(e.Title), e.Title, e.Keywords); if (score < NoMatch) scored.Add((score, new SettingsSearchResult @@ -278,6 +295,7 @@ void Flush() nameof(Internet) => "Internet connection settings", nameof(Backup) => "Package backup", nameof(Administrator) => "Administrator rights and other dangerous settings", + nameof(AgentPolicyInspector) => "Active package broker policy", nameof(Experimental) => "Experimental settings and developer options", _ => "UniGetUI Settings", }); diff --git a/src/UniGetUI.Avalonia/InternalsVisibleTo.cs b/src/UniGetUI.Avalonia/InternalsVisibleTo.cs new file mode 100644 index 0000000000..f5236ec50a --- /dev/null +++ b/src/UniGetUI.Avalonia/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.Tests")] diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 3663cb6dee..783664590e 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\ + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index d6dbe26ee3..4fcbe429d6 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -48,6 +48,7 @@ public partial class MainWindowViewModel : ViewModelBase private PageType _currentPage = PageType.Null; public PageType CurrentPage_t => _currentPage; private readonly List NavigationHistory = new(); + private readonly SemaphoreSlim _navigationSemaphore = new(1, 1); [ObservableProperty] private object? _currentPageContent; @@ -683,66 +684,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 @@ -761,56 +787,115 @@ 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); - await BundlesPage.OpenFromString(content, BundleFormatType.UBUNDLE, "GitHub Gist"); + await NavigateThenLoadBundleAsync( + () => NavigateToAsync(PageType.Bundles), + () => BundlesPage.OpenFromString( + content, + BundleFormatType.UBUNDLE, + "GitHub Gist")); } public async Task LoadBundleFromFileAsync(string path) { - NavigateTo(PageType.Bundles); - await BundlesPage.OpenFromFile(path); + await NavigateThenLoadBundleAsync( + () => NavigateToAsync(PageType.Bundles), + () => BundlesPage.OpenFromFile(path)); + } + + internal static async Task NavigateThenLoadBundleAsync( + Func> navigate, + Func load) + { + if (!await navigate()) + return false; + + await load(); + return true; + } + + 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() @@ -823,7 +908,7 @@ private async Task ShowAboutDialog() } // ─── Notification activation ───────────────────────────────────────────── - private void HandleNotificationActivation(string action) + private async void HandleNotificationActivation(string action) { if (string.Equals(action, NotificationArguments.UpdateAllPackages, StringComparison.OrdinalIgnoreCase)) { @@ -831,8 +916,8 @@ private void HandleNotificationActivation(string action) } else if (string.Equals(action, NotificationArguments.ShowOnUpdatesTab, StringComparison.OrdinalIgnoreCase)) { - NavigateTo(PageType.Updates); - MainWindow.Instance?.ShowFromTray(); + if (await NavigateToAsync(PageType.Updates)) + MainWindow.Instance?.ShowFromTray(); } else if (string.Equals(action, NotificationArguments.Show, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs index 3abd7ca894..ebbef0e01a 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Views.Pages.SettingsPages; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.SettingsEngine.SecureSettings; using UniGetUI.Core.Tools; @@ -10,6 +11,7 @@ namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; public partial class AdministratorViewModel : ViewModelBase { public event EventHandler? RestartRequired; + public event EventHandler? NavigationRequested; // ── Warning banner strings ──────────────────────────────────────────── public string WarningTitle { get; } = CoreTools.Translate("Warning") + "!"; @@ -67,4 +69,8 @@ private void RefreshPrePostState() { IsPrePostCommandsEnabled = SecureSettings.Get(SecureSettings.K.AllowPrePostOpCommand); } + + [RelayCommand] + private void NavigateToAgentPolicyInspector() => + NavigationRequested?.Invoke(this, typeof(AgentPolicyInspector)); } diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs new file mode 100644 index 0000000000..d80395108a --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AgentPolicyInspectorViewModel.cs @@ -0,0 +1,854 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using Avalonia.Automation; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Model; +using UniGetUI.Avalonia.Infrastructure; +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 UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation; +using PolicyArchitecture = Devolutions.Now.Policy.Model.Architecture; +using PolicyDecision = Devolutions.Now.Policy.Model.Decision; +using PolicyElevation = Devolutions.Now.Policy.Model.Elevation; +using PolicyManagerName = Devolutions.Now.Policy.Model.ManagerName; +using PolicyOperation = Devolutions.Now.Policy.Model.Operation; +using PolicyScope = Devolutions.Now.Policy.Model.Scope; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public sealed record PolicyDetailRow(string Label, string Value, string HelpText = "") +{ + public string AutomationName => $"{Label}: {Value}"; +} + +/// +/// Raised by when the user chooses Edit/Create/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/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 record PolicyCopyRequest(string Text, long PageGeneration); + +public sealed class PolicyRuleViewModel +{ + public required string AutomationName { get; init; } + public required string Id { get; init; } + public required string Enabled { get; init; } + public required string Priority { get; init; } + public required string Decision { get; init; } + public required string Reason { get; init; } + public required bool HasConstraints { get; init; } + public required IReadOnlyList MatchRows { get; init; } + public required IReadOnlyList ConstraintRows { get; init; } +} + +public partial class AgentPolicyInspectorViewModel : ViewModelBase, IDisposable +{ + private readonly Action _announce; + private readonly IBrokerPolicyManagementService _managementService; + private readonly IPolicyWriteElevationEligibility _writeElevationEligibility; + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private CancellationTokenSource? _managementRefreshCancellation; + private CancellationTokenSource? _pageRefreshCancellation; + private long _pageRefreshGeneration; + private long _managementRefreshGeneration; + private long _appliedManagementGeneration; + private int _isDisposed; + private PolicyManagementSnapshot? _managementSnapshot; + + /// Single page-level status for policy management and rendering. + 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 _isPageRefreshActive; + [ObservableProperty] private bool _hasActivePolicyDetails; + [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 _agentWriteCapabilityText = ""; + [ObservableProperty] private string _policyChangesFromThisAppText = ""; + [ObservableProperty] private string _policyChangesReasonText = ""; + [ObservableProperty] private bool _hasPolicyChangesReason; + [ObservableProperty] private bool _managementElevationRequired; + [ObservableProperty] private string _managementElevationRequiredText = ""; + [ObservableProperty] private bool _canEdit; + [ObservableProperty] private bool _canCreate; + [ObservableProperty] private bool _canReplaceIdentity; + [ObservableProperty] private bool _hasManagementDiagnostics; + + public event EventHandler? CopyTextRequested; + public event EventHandler? OpenPolicyEditorRequested; + + public AgentPolicyInspectorViewModel() + : this( + new BrokerPolicyManagementService(), + new PackagedPolicyWriteElevationEligibility(), + AccessibilityAnnouncementService.Announce) + { + } + + public AgentPolicyInspectorViewModel( + IBrokerPolicyManagementService managementService) + : this( + managementService, + new PackagedPolicyWriteElevationEligibility(), + AccessibilityAnnouncementService.Announce) + { + } + + internal AgentPolicyInspectorViewModel( + IBrokerPolicyManagementService managementService, + Action announce) + : this( + managementService, + new PackagedPolicyWriteElevationEligibility(), + announce) + { + } + + internal AgentPolicyInspectorViewModel( + IBrokerPolicyManagementService managementService, + IPolicyWriteElevationEligibility writeElevationEligibility, + Action announce) + { + _announce = announce; + _managementService = managementService; + _writeElevationEligibility = writeElevationEligibility; + SetManagementStatus( + CoreTools.Translate("Loading package broker policy"), + CoreTools.Translate("Contacting the Devolutions Agent service."), + InfoBarSeverity.Informational); + } + + /// Loads the authoritative management state without consulting any other endpoint. + public Task LoadManagementAsync() => LoadPageAsync(); + + [RelayCommand(CanExecute = nameof(CanRefreshPage))] + private Task RefreshPageAsync() => + CanRefreshPage() ? RefreshPageCoreAsync() : Task.CompletedTask; + + internal Task LoadPageAsync() => RefreshPageCoreAsync(); + + private async Task RefreshPageCoreAsync() + { + if (Volatile.Read(ref _isDisposed) != 0) return; + + long generation = Interlocked.Increment(ref _pageRefreshGeneration); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); + CancellationTokenSource? previous = + Interlocked.Exchange(ref _pageRefreshCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + + IsPageRefreshActive = true; + RefreshPageCommand.NotifyCanExecuteChanged(); + ClearManagement(); + ClearPolicy(); + HasActivePolicyDetails = false; + SetManagementStatus( + CoreTools.Translate("Loading package broker policy"), + CoreTools.Translate("Contacting the Devolutions Agent service."), + InfoBarSeverity.Informational); + try + { + BrokerPolicyManagementResult? management = + await RefreshManagementCoreAsync( + announce: false, + cancellation.Token); + if (!CanApplyPage(generation, cancellation) || management is null) return; + + AnnounceManagementStatus(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + finally + { + if (CanApplyPage(generation, cancellation)) + { + IsPageRefreshActive = false; + RefreshPageCommand.NotifyCanExecuteChanged(); + } + } + } + + private bool CanRefreshPage() => + Volatile.Read(ref _isDisposed) == 0 + && !IsPageRefreshActive + && !IsManagementLoading; + + partial void OnIsManagementLoadingChanged(bool value) => + RefreshPageCommand.NotifyCanExecuteChanged(); + + private bool CanApplyPage(long generation, CancellationTokenSource cancellation) => + Volatile.Read(ref _isDisposed) == 0 + && !cancellation.IsCancellationRequested + && generation == Volatile.Read(ref _pageRefreshGeneration); + + [RelayCommand] + private void CopyRawJson() + { + if (!string.IsNullOrEmpty(RawJson)) + { + CopyTextRequested?.Invoke( + this, + new PolicyCopyRequest(RawJson, Volatile.Read(ref _pageRefreshGeneration))); + } + } + + internal void ReportCopyFailure(long pageGeneration) + { + if (Volatile.Read(ref _isDisposed) != 0 + || pageGeneration != Volatile.Read(ref _pageRefreshGeneration)) + { + return; + } + + SetManagementStatus( + CoreTools.Translate("Could not copy policy JSON"), + CoreTools.Translate("The canonical policy JSON could not be copied to the clipboard. Try again."), + InfoBarSeverity.Error); + AnnounceManagementStatus(); + } + + private async Task RefreshManagementCoreAsync( + bool announce = true, + CancellationToken externalCancellation = default) + { + if (Volatile.Read(ref _isDisposed) != 0) return null; + + long generation = Interlocked.Increment(ref _managementRefreshGeneration); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token, + externalCancellation); + 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); + ClearPolicy(); + HasActivePolicyDetails = false; + + try + { + BrokerPolicyManagementResult result = + await _managementService.GetManagementAsync(cancellation.Token); + if (!CanApplyManagement(generation, cancellation)) return null; + + PolicyWriteElevationEligibility writeEligibility = + PolicyWriteElevationEligibility.Eligible; + if (result is + { + Status: BrokerPolicyManagementStatus.Retrieved, + Snapshot.WriteCapability: PolicyWriteCapability.Writable, + Snapshot.State: PolicyManagementState.Active or PolicyManagementState.Missing, + }) + { + writeEligibility = await _writeElevationEligibility + .EvaluateAsync(cancellation.Token); + if (!CanApplyManagement(generation, cancellation)) return null; + } + + _appliedManagementGeneration = generation; + ApplyManagementResult(result, writeEligibility); + ApplyUnifiedPagePresentation(result); + if (announce) + { + AnnounceManagementStatus(); + } + return result; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + return null; + } + 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( + PolicyEditorTemplates.CreateReplacementId(snapshot.Policy.Metadata.Id), + 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)); + } + + private bool CanApplyManagement(long generation, CancellationTokenSource cancellation) + { + return Volatile.Read(ref _isDisposed) == 0 + && !cancellation.IsCancellationRequested + && generation == Volatile.Read(ref _managementRefreshGeneration); + } + + private void ApplyUnifiedPagePresentation(BrokerPolicyManagementResult result) + { + ClearPolicy(); + + switch (result) + { + case + { + Status: BrokerPolicyManagementStatus.Retrieved, + Snapshot.State: PolicyManagementState.Active, + Snapshot.Policy: not null, + }: + HasActivePolicyDetails = true; + ApplyPolicy( + result.Snapshot.Policy, + PolicySerializer.Serialize(result.Snapshot.Policy), + result.Server?.ServerVersion); + break; + case + { + Status: BrokerPolicyManagementStatus.Retrieved, + Snapshot.State: PolicyManagementState.Missing or PolicyManagementState.Invalid, + }: + HasActivePolicyDetails = false; + break; + default: + HasActivePolicyDetails = false; + break; + } + } + + private void ApplyPolicy( + PolicyDocument policy, + string canonicalJson, + string? serverVersion) + { + PolicyMetadata metadata = policy.Metadata; + + if (serverVersion is not null) + { + MetadataRows.Add(Row("Server version", Value(serverVersion))); + } + MetadataRows.Add(Row("Policy ID", Value(metadata.Id))); + MetadataRows.Add(Row("Publisher", Value(metadata.Publisher))); + MetadataRows.Add(Row("Revision", metadata.Revision.ToString(CultureInfo.CurrentCulture))); + MetadataRows.Add(Row("Policy format version", policy.PolicyFormatVersion.Value)); + MetadataRows.Add(Row("Published", FormatDate(metadata.PublishedAt))); + MetadataRows.Add(Row("Valid from", FormatDate(metadata.ValidFrom))); + MetadataRows.Add(Row("Valid until", FormatDate(metadata.ValidUntil))); + MetadataRows.Add(Row("Description", Value(metadata.Description))); + MetadataRows.Add(Row("Support URL", Value(metadata.SupportUrl))); + + EnforcementRows.Add(Row("Default decision", TranslateEnum(policy.Enforcement.DefaultDecision))); + EnforcementRows.Add(Row("Audit mode", FormatNullableBoolean(policy.Enforcement.AuditMode))); + + PolicyRule[] orderedRules = policy.Rules + .Select((rule, sourceIndex) => (Rule: rule, SourceIndex: sourceIndex)) + .OrderBy(item => item.Rule.Priority) + .ThenBy(item => item.Rule.Decision == PolicyDecision.Deny ? 0 : 1) + .ThenBy(item => item.SourceIndex) + .Select(item => item.Rule) + .ToArray(); + for (int index = 0; index < orderedRules.Length; index++) + { + Rules.Add(BuildRule(orderedRules[index], index)); + } + + RawJson = canonicalJson; + HasNoRules = Rules.Count == 0; + HasPolicy = true; + } + + private static PolicyRuleViewModel BuildRule(PolicyRule rule, int index) + { + PolicyMatch match = rule.Match; + bool hasConstraints = rule.Decision == PolicyDecision.Allow; + PolicyConstraints? constraints = rule.Constraints; + + return new PolicyRuleViewModel + { + AutomationName = CoreTools.Translate("Rule {0}: {1}", index + 1, Value(rule.Id)), + Id = Value(rule.Id), + Enabled = FormatBoolean(rule.Enabled), + Priority = (index + 1).ToString(CultureInfo.CurrentCulture), + Decision = TranslateEnum(rule.Decision), + Reason = Value(rule.Reason), + HasConstraints = hasConstraints, + MatchRows = + [ + Row("Operations", FormatEnumList(match.Operations)), + Row("Package managers", FormatEnumList(match.Managers)), + Row("Source names", FormatList(match.SourceNames, anyWhenEmpty: true)), + Row( + "Exact package identifiers", + FormatList(match.PackageIdentifiers?.Exact ?? [], anyWhenEmpty: true)), + Row( + "Package identifier patterns", + FormatList(match.PackageIdentifiers?.Patterns ?? [], anyWhenEmpty: true)), + Row( + "Exact versions", + FormatList(match.Version?.Exact ?? [], anyWhenEmpty: true)), + Row("Version range", FormatVersionRange(match.Version?.Range)), + Row("Scopes", FormatEnumList(match.Scopes)), + Row("Architectures", FormatEnumList(match.Architectures)), + Row("Execution privilege", FormatEnumList(match.ExecutionElevation)), + Row("Interactive", FormatMatchBoolean(match.Interactive)), + Row("Skip hash check", FormatMatchBoolean(match.SkipHashCheck)), + Row("Prerelease", FormatMatchBoolean(match.PreRelease)), + Row("Custom parameters", FormatMatchBoolean(match.HasCustomParameters)), + Row("Custom install location", FormatMatchBoolean(match.HasCustomInstallLocation)), + Row("Pre/post commands", FormatMatchBoolean(match.HasPrePostCommands)), + Row("Stop running apps before operation", FormatMatchBoolean(match.HasKillBeforeOperation)), + Row("Uninstall previous version", FormatMatchBoolean(match.HasUninstallPrevious)), + ], + ConstraintRows = !hasConstraints + ? [] + : constraints is null + ? [Row("Constraints", CoreTools.Translate("Not set"))] + : + [ + Row("Allow interactive", FormatBoolean(constraints.AllowInteractive)), + Row("Allow skip hash check", FormatBoolean(constraints.AllowSkipHashCheck)), + Row("Allow prerelease", FormatBoolean(constraints.AllowPreRelease)), + Row("Allow custom install location", FormatBoolean(constraints.AllowCustomInstallLocation)), + Row("Allowed install location patterns", FormatList(constraints.AllowedInstallLocationPatterns)), + Row("Allow custom parameters", FormatBoolean(constraints.AllowCustomParameters)), + Row("Allowed custom parameters", FormatList(constraints.AllowedCustomParameters)), + Row("Allowed custom parameter patterns", FormatList(constraints.AllowedCustomParameterPatterns)), + Row("Denied custom parameters", FormatList(constraints.DeniedCustomParameters)), + Row("Allow pre/post commands", FormatBoolean(constraints.AllowPrePostCommands)), + Row("Allow kill-before-operation", FormatBoolean(constraints.AllowKillBeforeOperation)), + Row("Allow uninstall previous", FormatBoolean(constraints.AllowUninstallPrevious)), + Row("Allow upgrade", FormatBoolean(constraints.AllowUpgrade)), + ], + }; + } + + private static PolicyDetailRow Row(string label, string value) => + new(CoreTools.Translate(label), value, HelpForRow(label)); + + private static string HelpForRow(string label) => label switch + { + "Server version" => PolicyEditorHelp.ServerVersion, + "Policy ID" => PolicyEditorHelp.PolicyId, + "Publisher" => PolicyEditorHelp.Publisher, + "Revision" => PolicyEditorHelp.Revision, + "Policy format version" => PolicyEditorHelp.PolicyFormatVersion, + "Published" => PolicyEditorHelp.Published, + "Valid from" => PolicyEditorHelp.ValidFrom, + "Valid until" => PolicyEditorHelp.ValidUntil, + "Description" => PolicyEditorHelp.Description, + "Support URL" => PolicyEditorHelp.SupportUrl, + "Default decision" => PolicyEditorHelp.DefaultDecision, + "Audit mode" => PolicyEditorHelp.AuditMode, + "Operations" => PolicyEditorHelp.Operations, + "Package managers" => PolicyEditorHelp.Managers, + "Source names" => PolicyEditorHelp.SourceNames, + "Exact package identifiers" => PolicyEditorHelp.ExactPackageIdentifiers, + "Package identifier patterns" => PolicyEditorHelp.PackageIdentifierPatterns, + "Exact versions" => PolicyEditorHelp.ExactVersions, + "Version range" => PolicyEditorHelp.VersionRange, + "Scopes" => PolicyEditorHelp.Scopes, + "Architectures" => PolicyEditorHelp.Architectures, + "Execution privilege" => PolicyEditorHelp.ExecutionPrivilege, + "Interactive" => PolicyEditorHelp.InteractiveMatch, + "Skip hash check" => PolicyEditorHelp.SkipHashMatch, + "Prerelease" => PolicyEditorHelp.PrereleaseMatch, + "Custom parameters" => PolicyEditorHelp.CustomParametersMatch, + "Custom install location" => PolicyEditorHelp.CustomLocationMatch, + "Pre/post commands" => PolicyEditorHelp.PrePostCommandsMatch, + "Stop running apps before operation" => PolicyEditorHelp.KillBeforeMatch, + "Uninstall previous version" => PolicyEditorHelp.UninstallPreviousMatch, + "Constraints" => PolicyEditorHelp.Constraints, + "Allow interactive" => PolicyEditorHelp.AllowInteractive, + "Allow skip hash check" => PolicyEditorHelp.AllowSkipHashCheck, + "Allow prerelease" => PolicyEditorHelp.AllowPrerelease, + "Allow custom install location" => PolicyEditorHelp.AllowCustomLocation, + "Allowed install location patterns" => PolicyEditorHelp.LocationPatterns, + "Allow custom parameters" => PolicyEditorHelp.AllowCustomParameters, + "Allowed custom parameters" => PolicyEditorHelp.AllowedParameters, + "Allowed custom parameter patterns" => PolicyEditorHelp.AllowedParameterPatterns, + "Denied custom parameters" => PolicyEditorHelp.DeniedParameters, + "Allow pre/post commands" => PolicyEditorHelp.AllowPrePostCommands, + "Allow kill-before-operation" => PolicyEditorHelp.AllowKillBefore, + "Allow uninstall previous" => PolicyEditorHelp.AllowUninstallPrevious, + "Allow upgrade" => PolicyEditorHelp.AllowUpgrade, + _ => "", + }; + + private static string FormatDate(DateTimeOffset? value) => + value?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) + ?? CoreTools.Translate("Not set"); + + private static string FormatBoolean(bool value) => + CoreTools.Translate(value ? "Yes" : "No"); + + private static string FormatNullableBoolean(bool? value) => + value.HasValue ? FormatBoolean(value.Value) : CoreTools.Translate("Not set"); + + private static string FormatMatchBoolean(bool? value) => + value.HasValue ? FormatBoolean(value.Value) : CoreTools.Translate("Any"); + + private static string FormatEnumList(IEnumerable values) where T : struct, Enum => + FormatList(values.Select(TranslateEnum), anyWhenEmpty: true); + + private static string FormatList(IEnumerable values, bool anyWhenEmpty = false) + { + string[] items = values.Where(value => !string.IsNullOrEmpty(value)).ToArray(); + return items.Length == 0 + ? CoreTools.Translate(anyWhenEmpty ? "Any" : "None") + : string.Join(", ", items); + } + + private static string FormatVersionRange(VersionRange? range) + { + if (range is null) return CoreTools.Translate("Any"); + + return CoreTools.Translate( + "{0} to {1}; include prerelease: {2}", + Value(range.MinVersion, "Any"), + Value(range.MaxVersion, "Any"), + FormatBoolean(range.IncludePrerelease)); + } + + private static string TranslateEnum(T value) where T : struct, Enum => + CoreTools.Translate(value.ToString()); + + private static string Value(string? value, string fallback = "Not set") => + string.IsNullOrEmpty(value) ? CoreTools.Translate(fallback) : value; + + private void ClearPolicy() + { + MetadataRows.Clear(); + EnforcementRows.Clear(); + Rules.Clear(); + RawJson = ""; + HasPolicy = false; + HasNoRules = false; + } + + private void ApplyManagementResult( + BrokerPolicyManagementResult result, + PolicyWriteElevationEligibility writeEligibility) + { + ClearManagement(); + + switch (result.Status) + { + case BrokerPolicyManagementStatus.Retrieved when result.Snapshot is not null: + ApplyManagementSnapshot( + result.Snapshot, + result.Diagnostics, + writeEligibility); + break; + case BrokerPolicyManagementStatus.AgentUnavailable: + SetManagementStatus( + CoreTools.Translate("Devolutions Agent is unavailable"), + CoreTools.Translate("Communication with the package broker could not be completed. Verify that Devolutions Agent is installed and running. If the problem persists, check the Agent logs, 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, + PolicyWriteElevationEligibility writeEligibility) + { + _managementSnapshot = snapshot; + HasManagementSnapshot = true; + + ManagementStateText = TranslateEnum(snapshot.State); + ManagementConfiguredPath = Value(PolicyFindingPresentation.SanitizeAgentText( + snapshot.ConfiguredPath, + BrokerPolicyManagementLimits.MaxSanitizedPathLength)); + ManagementSourceText = TranslateEnum(snapshot.Source); + AgentWriteCapabilityText = snapshot.WriteCapability switch + { + PolicyWriteCapability.Writable => CoreTools.Translate("Writable"), + PolicyWriteCapability.ReadOnly => CoreTools.Translate("Read-only"), + PolicyWriteCapability.Unsupported => CoreTools.Translate("Unsupported"), + _ => CoreTools.Translate("Unknown"), + }; + ManagementElevationRequired = snapshot.ElevationRequired; + ManagementElevationRequiredText = FormatBoolean(snapshot.ElevationRequired); + + bool agentWritable = snapshot.WriteCapability == PolicyWriteCapability.Writable; + bool stateSupportsChanges = + snapshot.State is PolicyManagementState.Active or PolicyManagementState.Missing; + bool writable = agentWritable && stateSupportsChanges && writeEligibility.IsEligible; + PolicyChangesFromThisAppText = writable + ? CoreTools.Translate("Available") + : CoreTools.Translate("Unavailable"); + HasPolicyChangesReason = !writable; + PolicyChangesReasonText = writable + ? CoreTools.Translate("Not applicable") + : !agentWritable && snapshot.ReadOnlyReason.HasValue + ? GetAgentReadOnlyReason(snapshot.ReadOnlyReason.Value) + : !agentWritable + ? CoreTools.Translate("Devolutions Agent does not allow policy changes.") + : snapshot.State == PolicyManagementState.Invalid + ? CoreTools.Translate("Invalid policy files cannot be changed from UniGetUI. An administrator must correct or replace the protected policy file outside this app.") + : GetElevationEligibilityReason(writeEligibility.Status); + + CanEdit = writable && snapshot.State == PolicyManagementState.Active; + CanCreate = writable && snapshot.State == PolicyManagementState.Missing; + CanReplaceIdentity = writable + && snapshot.State == PolicyManagementState.Active + && PolicyEditorTemplates.IsValidResourceId(snapshot.Policy?.Metadata.Id); + + 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 exists"), + 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. An administrator must correct or replace the protected policy file outside UniGetUI."), + 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 string GetElevationEligibilityReason( + PolicyWriteElevationEligibilityStatus status) => + status switch + { + PolicyWriteElevationEligibilityStatus.HelperMissing => CoreTools.Translate( + "The signed policy write helper is missing. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes."), + PolicyWriteElevationEligibilityStatus.ProtectedInstallRequired => CoreTools.Translate( + "Policy changes are disabled because this UniGetUI installation is not administrator-protected. Reinstall UniGetUI for all users in an administrator-protected location to enable them."), + _ => CoreTools.Translate( + "This UniGetUI installation cannot securely launch the policy write helper. Reinstall UniGetUI for all users in an administrator-protected location to enable policy changes."), + }; + + private static string GetAgentReadOnlyReason(PolicyReadOnlyReason reason) => + reason switch + { + PolicyReadOnlyReason.ManagementDisabled => + CoreTools.Translate("Policy management is disabled in Devolutions Agent."), + PolicyReadOnlyReason.PathNotConfigured => + CoreTools.Translate("No policy path is configured in Devolutions Agent."), + PolicyReadOnlyReason.UnsupportedFormat => + CoreTools.Translate("Devolutions Agent does not support the configured policy format."), + PolicyReadOnlyReason.UnsafePath => + CoreTools.Translate("Devolutions Agent considers the configured policy path unsafe."), + PolicyReadOnlyReason.InsufficientPermissions => + CoreTools.Translate("Devolutions Agent does not have permission to change the policy file."), + PolicyReadOnlyReason.UnsupportedFileSystem => + CoreTools.Translate("Devolutions Agent does not support the policy file system."), + _ => CoreTools.Translate("Devolutions Agent does not allow policy changes."), + }; + + 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 AnnounceManagementStatus() + { + string message = string.IsNullOrEmpty(ManagementStatus.Message) + ? ManagementStatus.Title + : $"{ManagementStatus.Title}. {ManagementStatus.Message}"; + _announce( + message, + ManagementStatus.Severity == InfoBarSeverity.Error + ? AutomationLiveSetting.Assertive + : AutomationLiveSetting.Polite); + } + + 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 = ""; + AgentWriteCapabilityText = ""; + PolicyChangesFromThisAppText = ""; + PolicyChangesReasonText = ""; + HasPolicyChangesReason = false; + ManagementElevationRequired = false; + ManagementElevationRequiredText = ""; + HasManagementDiagnostics = false; + CanEdit = false; + CanCreate = false; + CanReplaceIdentity = false; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _isDisposed, 1) != 0) return; + + _lifetimeCancellation.Cancel(); + Interlocked.Exchange(ref _managementRefreshCancellation, null)?.Cancel(); + Interlocked.Exchange(ref _pageRefreshCancellation, 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..7118d7a4be --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftFingerprint.cs @@ -0,0 +1,34 @@ +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// A content-based fingerprint of a , used to detect whether the +/// draft has actually changed (dirty tracking). +/// 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..5a9de67a41 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyDraftModels.cs @@ -0,0 +1,242 @@ +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). +/// 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 required PolicyFormatVersion PolicyFormatVersion { get; set; } + + public required PolicyEditorDraftMetadata Metadata { get; set; } + + public required PolicyEditorDraftEnforcement Enforcement { get; set; } + + public List Rules { get; set; } = []; + + public PolicyEditorDraftDocument Clone() => new() + { + PolicyFormatVersion = PolicyFormatVersion, + 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 . +public sealed class PolicyEditorDraftEnforcement +{ + public required Decision DefaultDecision { get; set; } + + 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 . Exclusive package identifier and version +/// conditions carry an explicit mode so incompatible final-contract shapes cannot coexist. +/// +public sealed class PolicyEditorDraftMatch +{ + public List Operations { get; set; } = []; + + public List Managers { get; set; } = []; + + public List SourceNames { get; set; } = []; + + public PackageIdentifierMode PackageIdentifierMode { get; set; } + + public List ExactPackageIdentifiers { get; set; } = []; + + public List PackageIdentifierPatterns { get; set; } = []; + + public PackageVersionMode VersionMode { get; set; } + + public List ExactVersions { get; set; } = []; + + public PolicyEditorDraftVersionRange? VersionRange { get; set; } + + public List Scopes { get; set; } = []; + + public List Architectures { get; set; } = []; + + public List ExecutionElevation { 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], + SourceNames = [.. SourceNames], + PackageIdentifierMode = PackageIdentifierMode, + ExactPackageIdentifiers = [.. ExactPackageIdentifiers], + PackageIdentifierPatterns = [.. PackageIdentifierPatterns], + VersionMode = VersionMode, + ExactVersions = [.. ExactVersions], + VersionRange = VersionRange?.Clone(), + Scopes = [.. Scopes], + Architectures = [.. Architectures], + ExecutionElevation = [.. ExecutionElevation], + 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/PolicyEditorAdvisories.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorAdvisories.cs new file mode 100644 index 0000000000..f13d173ee0 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorAdvisories.cs @@ -0,0 +1,310 @@ +using Devolutions.Now.Policy.Model; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +internal static class PolicyEditorAdvisories +{ + public static IReadOnlyList ForRule(PolicyEditorDraftRule rule) + { + var messages = new HashSet(StringComparer.Ordinal); + if (rule.Enabled + && rule.Decision == Decision.Allow + && PolicyEditorRuleSemantics.IsCatchAll(rule.Match)) + { + messages.Add(CoreTools.Translate( + "This enabled Allow rule applies to every package request. Add match conditions to limit its scope.")); + } + + if (rule.Enabled + && rule.Decision == Decision.Allow + && rule.Match.PackageIdentifierMode == PackageIdentifierMode.Patterns + && rule.Match.PackageIdentifierPatterns.Any(IsUniversalPattern)) + { + messages.Add(CoreTools.Translate( + "This enabled Allow rule uses a universal package identifier pattern and may authorize requests far beyond the intended scope.")); + } + + return [.. messages]; + } + + public static string SkipHashCheck( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowSkipHashCheck + && IsExplicitRisk(rule.Match.SkipHashCheck) + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.SkipHashCheck) + ? CoreTools.Translate("This Allow rule explicitly permits bypassing package integrity checks.") + : ""; + + public static string SkipHashCheckMatch( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowSkipHashCheck + && rule.Match.SkipHashCheck == TriState.Omitted + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.SkipHashCheck) + ? CoreTools.Translate("Skip hash check is set to Does not matter, so this Allow rule can match requests that bypass integrity verification. Set it to No to allow only normal verification, or place an earlier Deny rule that covers this rule's scope.") + : ""; + + public static string CustomParameters( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsBroadlyScoped(rule) + && IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowCustomParameters + && IsExplicitRisk(rule.Match.HasCustomParameters) + && limits.AllowedCustomParameters.Count == 0 + && limits.AllowedCustomParameterPatterns.Count == 0 + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.CustomParameters) + ? CoreTools.Translate("This Allow rule can permit arbitrary extra package-manager options because it does not limit package identifiers or sources.") + : ""; + + public static string CustomParametersMatch( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsBroadlyScoped(rule) + && IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowCustomParameters + && rule.Match.HasCustomParameters == TriState.Omitted + && limits.AllowedCustomParameters.Count == 0 + && limits.AllowedCustomParameterPatterns.Count == 0 + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.CustomParameters) + ? CoreTools.Translate("Custom parameters is set to Does not matter, so this Allow rule can match requests with arbitrary extra options. Set it to No to allow only requests without extra options, or place an earlier Deny rule that covers this rule's scope.") + : ""; + + public static string CustomInstallLocation( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsBroadlyScoped(rule) + && IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowCustomInstallLocation + && IsExplicitRisk(rule.Match.HasCustomInstallLocation) + && limits.AllowedInstallLocationPatterns.Count == 0 + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.CustomInstallLocation) + ? CoreTools.Translate("This Allow rule can permit any custom install folder because it does not limit package identifiers or sources.") + : ""; + + public static string CustomInstallLocationMatch( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsBroadlyScoped(rule) + && IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowCustomInstallLocation + && rule.Match.HasCustomInstallLocation == TriState.Omitted + && limits.AllowedInstallLocationPatterns.Count == 0 + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.CustomInstallLocation) + ? CoreTools.Translate("Custom install location is set to Does not matter, so this Allow rule can match requests for any custom folder. Set it to No to allow only the default location, or place an earlier Deny rule that covers this rule's scope.") + : ""; + + public static string PrePostCommands( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsBroadlyScoped(rule) + && IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowPrePostCommands + && IsExplicitRisk(rule.Match.HasPrePostCommands) + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.PrePostCommands) + ? CoreTools.Translate("This Allow rule can permit arbitrary commands before or after package operations because it does not limit package identifiers or sources.") + : ""; + + public static string PrePostCommandsMatch( + PolicyEditorDraftRule rule, + IReadOnlyList? rules = null, + int ruleIndex = -1) => + IsBroadlyScoped(rule) + && IsAllowWithConstraints(rule, out PolicyEditorDraftConstraints? limits) + && limits.AllowPrePostCommands + && rule.Match.HasPrePostCommands == TriState.Omitted + && !PolicyEditorRiskCoverage.IsCovered(rules, ruleIndex, PolicyEditorRisk.PrePostCommands) + ? CoreTools.Translate("Pre/post commands is set to Does not matter, so this Allow rule can match requests that run arbitrary commands. Set it to No to allow only requests without pre/post commands, or place an earlier Deny rule that covers this rule's scope.") + : ""; + + public static IReadOnlyList FieldSpecific(PolicyEditorDraftRule rule) => + [ + .. new[] + { + SkipHashCheck(rule), + CustomParameters(rule), + CustomInstallLocation(rule), + PrePostCommands(rule), + }.Where(message => !string.IsNullOrEmpty(message)), + ]; + + private static bool IsAllowWithConstraints( + PolicyEditorDraftRule rule, + out PolicyEditorDraftConstraints limits) + { + limits = rule.Constraints!; + return rule.Enabled + && rule.Decision == Decision.Allow + && limits is not null; + } + + private static bool IsExplicitRisk(TriState match) => match == TriState.True; + + internal enum PolicyEditorRisk + { + SkipHashCheck, + CustomParameters, + CustomInstallLocation, + PrePostCommands, + } + + internal static class PolicyEditorRiskCoverage + { + public static bool IsCovered( + IReadOnlyList? rules, + int allowIndex, + PolicyEditorRisk risk) + { + if (rules is null || allowIndex <= 0 || allowIndex >= rules.Count) + return false; + + PolicyEditorDraftMatch allow = rules[allowIndex].Match; + for (int index = 0; index < allowIndex; index++) + { + PolicyEditorDraftRule deny = rules[index]; + if (deny.Enabled + && deny.Decision == Decision.Deny + && MatchesRisk(deny.Match, risk) + && ContainsScope(deny.Match, allow, risk)) + { + return true; + } + } + + return false; + } + + public static bool ShouldSuppressFinding( + PolicyValidationFinding finding, + IReadOnlyList rules) + { + if (!finding.IsWarning + || finding.Code != Devolutions.Now.Policy.Api.PolicyFindingCode.SensitiveOptionAllowed + || !TryGetRisk(finding, out PolicyEditorRisk risk) + || !TryGetRuleIndex(finding, rules, out int index)) + { + return false; + } + + return IsCovered(rules, index, risk); + } + + private static bool MatchesRisk(PolicyEditorDraftMatch match, PolicyEditorRisk risk) => + risk switch + { + PolicyEditorRisk.SkipHashCheck => match.SkipHashCheck == TriState.True, + PolicyEditorRisk.CustomParameters => match.HasCustomParameters == TriState.True, + PolicyEditorRisk.CustomInstallLocation => match.HasCustomInstallLocation == TriState.True, + PolicyEditorRisk.PrePostCommands => match.HasPrePostCommands == TriState.True, + _ => false, + }; + + private static bool ContainsScope( + PolicyEditorDraftMatch deny, + PolicyEditorDraftMatch allow, + PolicyEditorRisk risk) => + ContainsSet(deny.Operations, allow.Operations) + && ContainsSet(deny.Managers, allow.Managers) + && ContainsSet(deny.SourceNames, allow.SourceNames) + && ContainsPackageIdentifiers(deny, allow) + && ContainsVersions(deny, allow) + && ContainsSet(deny.Scopes, allow.Scopes) + && ContainsSet(deny.Architectures, allow.Architectures) + && ContainsSet(deny.ExecutionElevation, allow.ExecutionElevation) + && ContainsBoolean(deny.Interactive, allow.Interactive) + && ContainsBooleanExceptRisk(deny.SkipHashCheck, allow.SkipHashCheck, risk, PolicyEditorRisk.SkipHashCheck) + && ContainsBoolean(deny.PreRelease, allow.PreRelease) + && ContainsBooleanExceptRisk(deny.HasCustomParameters, allow.HasCustomParameters, risk, PolicyEditorRisk.CustomParameters) + && ContainsBooleanExceptRisk(deny.HasCustomInstallLocation, allow.HasCustomInstallLocation, risk, PolicyEditorRisk.CustomInstallLocation) + && ContainsBooleanExceptRisk(deny.HasPrePostCommands, allow.HasPrePostCommands, risk, PolicyEditorRisk.PrePostCommands) + && ContainsBoolean(deny.HasKillBeforeOperation, allow.HasKillBeforeOperation) + && ContainsBoolean(deny.HasUninstallPrevious, allow.HasUninstallPrevious); + + private static bool ContainsSet(IReadOnlyCollection deny, IReadOnlyCollection allow) + where T : notnull => + deny.Count == 0 || (allow.Count > 0 && allow.All(deny.Contains)); + + private static bool ContainsPackageIdentifiers( + PolicyEditorDraftMatch deny, + PolicyEditorDraftMatch allow) => + deny.PackageIdentifierMode == PackageIdentifierMode.Omitted + || (deny.PackageIdentifierMode == PackageIdentifierMode.Exact + && allow.PackageIdentifierMode == PackageIdentifierMode.Exact + && ContainsSet(deny.ExactPackageIdentifiers, allow.ExactPackageIdentifiers)); + + private static bool ContainsVersions( + PolicyEditorDraftMatch deny, + PolicyEditorDraftMatch allow) => + deny.VersionMode == PackageVersionMode.Omitted + || (deny.VersionMode == PackageVersionMode.Exact + && allow.VersionMode == PackageVersionMode.Exact + && ContainsSet(deny.ExactVersions, allow.ExactVersions)); + + private static bool ContainsBoolean(TriState deny, TriState allow) => + deny == TriState.Omitted || deny == allow; + + private static bool ContainsBooleanExceptRisk( + TriState deny, + TriState allow, + PolicyEditorRisk actualRisk, + PolicyEditorRisk testedRisk) => + actualRisk == testedRisk || ContainsBoolean(deny, allow); + + private static bool TryGetRisk( + PolicyValidationFinding finding, + out PolicyEditorRisk risk) + { + risk = default; + if (finding.Arguments is null + || !finding.Arguments.TryGetValue("option", out string? option)) + return false; + + return option.Trim().Trim('"') switch + { + "SkipHashCheck" => SetRisk(PolicyEditorRisk.SkipHashCheck, out risk), + "AllowCustomParameters" => SetRisk(PolicyEditorRisk.CustomParameters, out risk), + "AllowCustomInstallLocation" => SetRisk(PolicyEditorRisk.CustomInstallLocation, out risk), + "AllowPrePostCommands" => SetRisk(PolicyEditorRisk.PrePostCommands, out risk), + _ => false, + }; + } + + private static bool TryGetRuleIndex( + PolicyValidationFinding finding, + IReadOnlyList rules, + out int index) + { + index = rules + .Select((rule, candidate) => (rule, candidate)) + .Where(item => string.Equals(item.rule.Id, finding.RuleId, StringComparison.Ordinal)) + .Select(item => item.candidate) + .DefaultIfEmpty(-1) + .First(); + return index >= 0; + } + + private static bool SetRisk(PolicyEditorRisk value, out PolicyEditorRisk risk) + { + risk = value; + return true; + } + } + + private static bool IsBroadlyScoped(PolicyEditorDraftRule rule) => + rule.Match.SourceNames.Count == 0 + && rule.Match.PackageIdentifierMode == PackageIdentifierMode.Omitted; + + private static bool IsUniversalPattern(string value) => + value.Trim() is "*" or "**"; +} 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..fff92e42f3 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDependencies.cs @@ -0,0 +1,107 @@ +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) +{ + public PolicyReplacementRequest ToSharedRequest() => new() + { + ExpectedStoreToken = ExpectedStoreToken, + Operation = Operation, + ConflictHandling = ConflictHandling, + Draft = Draft.Clone(), + ValidationReceipt = ValidationReceipt, + }; +} + +public enum PolicyWriteFailureKind +{ + None, + UacCanceled, + LaunchFailed, + AuthenticationFailed, + ProtocolFailed, + HelperFailed, + BrokerRejected, + WriteResultUnknown, +} + +internal static class PolicyWriteDiagnosticCodes +{ + internal const string PostCommitRefreshTimeout = nameof(PostCommitRefreshTimeout); + internal const string PostCommitRefreshUnavailable = nameof(PostCommitRefreshUnavailable); +} + +public sealed record PolicyWriteOutcome( + PolicyReplacementResponse? Response, + ErrorResponse? Error, + PolicyWriteFailureKind FailureKind = PolicyWriteFailureKind.None, + PolicyEditorRetryDecision? ConflictDecision = null, + bool SavedThenSuperseded = false, + string? DiagnosticCode = null) +{ + public bool Succeeded => Response is not null; + + public static PolicyWriteOutcome Success( + PolicyReplacementResponse response, + bool savedThenSuperseded = false) => + new(response, null, SavedThenSuperseded: savedThenSuperseded); + + public static PolicyWriteOutcome Failure( + PolicyWriteFailureKind kind, + ErrorResponse? error = null, + PolicyEditorRetryDecision? conflictDecision = null, + string? diagnosticCode = null) => + new( + null, + error, + kind, + conflictDecision, + DiagnosticCode: diagnosticCode); +} + +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, + string? RuleId = null); + +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..5fdc767ec0 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorDialogViewModel.cs @@ -0,0 +1,520 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia.Automation; +using CommunityToolkit.Mvvm.ComponentModel; +using Devolutions.Now.Policy.Api; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.ViewModels; +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 +{ + private readonly Action _announce; + private long _announcedWriteCompletionGeneration; + private long _handledFindingNavigationGeneration; + private int _selectedFindingIndex = -1; + + public PolicyEditorSessionViewModel Session { get; } + + public PolicyEditorDocumentUi Document { get; } + + public ObservableCollection Rules { get; } = []; + + public InfoBarViewModel Status { get; } = new() { IsClosable = false, IsOpen = false }; + public event EventHandler? FindingNavigationRequested; + + public PolicyValidationFinding? SelectedFinding => + _selectedFindingIndex >= 0 && _selectedFindingIndex < ErrorFindings.Count + ? ErrorFindings[_selectedFindingIndex] + : null; + public bool HasFindingSummary => Session.SyntaxError is not null || SelectedFinding is not null; + public bool HasMultipleFindings => Session.SyntaxError is null && ErrorFindings.Count > 1; + public bool CanNavigateFinding => + !(Session.IsRawMode && Session.IsRawSyntaxPending); + public string FindingCountText + { + get + { + if (Session.SyntaxError is not null) + return CoreTools.Translate("1 error"); + int errors = Session.Findings.Count(finding => + finding.Severity == PolicyValidationSeverity.Error); + return CoreTools.Translate("{0} error(s)", errors); + } + } + public string SelectedFindingMessage => + Session.SyntaxError is not null + ? Session.SyntaxErrorMessage + : SelectedFinding?.Message ?? ""; + + public PolicyEditorDialogViewModel(PolicyEditorSessionViewModel session) + : this(session, AccessibilityAnnouncementService.Announce) + { + } + + internal PolicyEditorDialogViewModel( + PolicyEditorSessionViewModel session, + Action announce) + { + Session = session; + _announce = announce; + _announcedWriteCompletionGeneration = session.LastWriteCompletion?.Generation ?? 0; + _handledFindingNavigationGeneration = session.FindingNavigationGeneration; + Document = new PolicyEditorDocumentUi(session); + Session.PropertyChanged += OnSessionPropertyChanged; + RebuildRules(); + SelectFirstFinding(navigate: false); + 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"), + _ => 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, + Session.LastWriteDiagnosticCode); + + /// + /// 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(); + for (int index = 0; index < Session.Rules.Count; index++) + { + Rules.Add(new PolicyEditorRuleUi(Session.Rules[index], index, Session)); + } + } + + public void RefreshStructuredProjection() + { + Document.RefreshFromDraft(); + RebuildRules(); + } + + public void AnnounceRulePosition(PolicyEditorDraftRule rule) + { + int? index = Session.Rules + .Select((candidate, candidateIndex) => (candidate, candidateIndex)) + .Where(item => ReferenceEquals(item.candidate, rule)) + .Select(item => (int?)item.candidateIndex) + .FirstOrDefault(); + if (index is null) return; + _announce( + CoreTools.Translate( + "Rule {0} is now position {1} of {2}.", + rule.Id, + index.Value + 1, + Session.Rules.Count), + AutomationLiveSetting.Polite); + } + + private void OnSessionPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PolicyEditorSessionViewModel.LastWriteCompletion) + && Session.LastWriteCompletion is { } completion + && completion.Generation > _announcedWriteCompletionGeneration) + { + _announcedWriteCompletionGeneration = completion.Generation; + AnnounceWriteCompletion(completion); + } + + if (e.PropertyName == nameof(PolicyEditorSessionViewModel.Findings)) + { + Document.RefreshFindings(); + foreach (PolicyEditorRuleUi rule in Rules) + { + rule.RefreshFindings(); + } + + PolicyValidationFinding? firstError = Session.Findings.FirstOrDefault( + finding => finding.Severity == PolicyValidationSeverity.Error); + if (firstError is not null) + { + _announce( + firstError.AutomationName, + AutomationLiveSetting.Assertive); + } + SelectFirstFinding(navigate: false); + } + else if (e.PropertyName == nameof(PolicyEditorSessionViewModel.FindingNavigationGeneration) + && Session.FindingNavigationGeneration > _handledFindingNavigationGeneration) + { + _handledFindingNavigationGeneration = Session.FindingNavigationGeneration; + SelectFirstFinding(navigate: true); + } + else if (e.PropertyName == nameof(PolicyEditorSessionViewModel.SyntaxError)) + { + RefreshFindingSummary(); + if (Session.SyntaxError is not null) + FindingNavigationRequested?.Invoke(this, null); + } + + if (e.PropertyName is nameof(PolicyEditorSessionViewModel.LastWriteFailureKind) + or nameof(PolicyEditorSessionViewModel.LastErrorCode) + or nameof(PolicyEditorSessionViewModel.LastWriteDiagnosticCode)) + { + 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(); + } + + if (e.PropertyName is nameof(PolicyEditorSessionViewModel.IsRawMode) + or nameof(PolicyEditorSessionViewModel.IsRawSyntaxPending)) + { + OnPropertyChanged(nameof(CanNavigateFinding)); + } + + RefreshStatus(); + } + + public void SelectPreviousFinding() + { + if (ErrorFindings.Count == 0) return; + _selectedFindingIndex = + (_selectedFindingIndex - 1 + ErrorFindings.Count) % ErrorFindings.Count; + RefreshFindingSummary(); + RequestFindingNavigation(SelectedFinding); + } + + public void SelectNextFinding() + { + if (ErrorFindings.Count == 0) return; + _selectedFindingIndex = + (_selectedFindingIndex + 1) % ErrorFindings.Count; + RefreshFindingSummary(); + RequestFindingNavigation(SelectedFinding); + } + + public void NavigateToSelectedFinding() => + RequestFindingNavigation(SelectedFinding); + + private void SelectFirstFinding(bool navigate) + { + PolicyValidationFinding? selected = ErrorFindings.FirstOrDefault(); + _selectedFindingIndex = selected is null + ? -1 + : ErrorFindings + .Select((finding, index) => (finding, index)) + .Where(item => ReferenceEquals(item.finding, selected)) + .Select(item => item.index) + .FirstOrDefault(); + RefreshFindingSummary(); + if (navigate && selected is not null) + RequestFindingNavigation(selected); + } + + private void RequestFindingNavigation(PolicyValidationFinding? finding) + { + if (CanNavigateFinding) + FindingNavigationRequested?.Invoke(this, finding); + } + + private IReadOnlyList ErrorFindings => + Session.Findings + .Where(finding => finding.Severity == PolicyValidationSeverity.Error) + .ToArray(); + + private void RefreshFindingSummary() + { + OnPropertyChanged(nameof(SelectedFinding)); + OnPropertyChanged(nameof(HasFindingSummary)); + OnPropertyChanged(nameof(HasMultipleFindings)); + OnPropertyChanged(nameof(FindingCountText)); + OnPropertyChanged(nameof(SelectedFindingMessage)); + } + + 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, + announce: false); + return; + } + + if (Session.SyntaxError is { } syntaxError) + { + SetStatus( + Session.SyntaxErrorTitle, + Session.SyntaxErrorMessage, + InfoBarSeverity.Error, + announce: false); + 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, + announce: !HasAnnouncedWriteCompletion); + return; + } + + if (Session.SavedThenSuperseded) + { + SetStatus( + CoreTools.Translate("Policy saved, then replaced again"), + CoreTools.Translate("The policy was saved, but another writer replaced it before management state was refreshed."), + InfoBarSeverity.Warning, + announce: !HasAnnouncedWriteCompletion); + return; + } + + if (Session.LastSaveSucceeded) + { + SetStatus( + CoreTools.Translate("Policy saved"), + CoreTools.Translate("The package broker policy was saved successfully."), + InfoBarSeverity.Success, + announce: !HasAnnouncedWriteCompletion); + 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, + announce: !HasAnnouncedWriteCompletion); + return; + } + + if (HasWriteFailure) + { + SetStatus( + CoreTools.Translate("The policy could not be saved"), + WriteFailureMessage, + InfoBarSeverity.Error, + announce: Session.LastWriteFailureKind == PolicyWriteFailureKind.None + || !HasAnnouncedWriteCompletion); + return; + } + + if (Session.HasFindings) + { + int errorCount = Session.Findings.Count(finding => finding.Severity == PolicyValidationSeverity.Error); + if (errorCount > 0) + { + SetStatus( + CoreTools.Translate("Validation found errors"), + CoreTools.Translate("Correct the selected error before saving."), + InfoBarSeverity.Error, + announce: false); + return; + } + } + + Status.IsOpen = false; + } + + private void SetStatus( + string title, + string message, + InfoBarSeverity severity, + bool announce = true) + { + bool changed = !Status.IsOpen + || Status.Title != title + || Status.Message != message + || Status.Severity != severity; + Status.Title = title; + Status.Message = message; + Status.Severity = severity; + Status.IsOpen = true; + if (changed && announce) + { + AnnounceStatus(); + } + } + + private void AnnounceStatus() + { + string message = string.IsNullOrEmpty(Status.Message) + ? Status.Title + : $"{Status.Title}. {Status.Message}"; + _announce( + message, + Status.Severity == InfoBarSeverity.Error + ? AutomationLiveSetting.Assertive + : AutomationLiveSetting.Polite); + } + + private bool HasAnnouncedWriteCompletion => + Session.LastWriteCompletion is { } completion + && completion.Generation <= _announcedWriteCompletionGeneration; + + private void AnnounceWriteCompletion(PolicyEditorWriteCompletion completion) + { + (string title, string message, InfoBarSeverity severity) = completion.Kind switch + { + PolicyEditorWriteCompletionKind.SavedWithNewerChanges => ( + CoreTools.Translate("Policy saved; newer changes remain"), + CoreTools.Translate("The policy was saved, but newer draft changes remain unsaved."), + InfoBarSeverity.Warning), + PolicyEditorWriteCompletionKind.SavedThenSuperseded => ( + CoreTools.Translate("Policy saved, then replaced again"), + CoreTools.Translate("The policy was saved, but another writer replaced it before management state was refreshed."), + InfoBarSeverity.Warning), + PolicyEditorWriteCompletionKind.Saved => ( + CoreTools.Translate("Policy saved"), + CoreTools.Translate("The package broker policy was saved successfully."), + InfoBarSeverity.Success), + PolicyEditorWriteCompletionKind.Conflict => ( + CoreTools.Translate("The policy changed since you started editing"), + CoreTools.Translate("Review your changes, then choose Overwrite to save anyway."), + InfoBarSeverity.Warning), + _ => ( + CoreTools.Translate("The policy could not be saved"), + DescribeWriteFailure( + completion.FailureKind, + completion.ErrorCode, + completion.DiagnosticCode), + InfoBarSeverity.Error), + }; + + _announce( + $"{title}. {message}", + severity == InfoBarSeverity.Error + ? AutomationLiveSetting.Assertive + : AutomationLiveSetting.Polite); + } + + internal static string DescribeWriteFailure( + PolicyWriteFailureKind kind, + ErrorCode? errorCode, + string? diagnosticCode = null) + { + 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 + when errorCode == ErrorCode.MalformedDraft => + CoreTools.Translate("Devolutions Agent rejected the policy draft as malformed. Refresh policy management state, then review the policy before retrying."), + PolicyWriteFailureKind.BrokerRejected => + CoreTools.Translate("Devolutions Agent rejected the policy replacement."), + PolicyWriteFailureKind.WriteResultUnknown => + DescribeUnknownWriteResult(diagnosticCode), + _ => 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."); + } + + private static string DescribeUnknownWriteResult(string? diagnosticCode) + { + string cause = diagnosticCode switch + { + nameof(Devolutions.Now.Policy.Client.BrokerClientErrorKind.BrokerUnavailable) => + CoreTools.Translate("Devolutions Agent was unavailable or closed the connection before responding."), + nameof(Devolutions.Now.Policy.Client.BrokerClientErrorKind.Timeout) => + CoreTools.Translate("Communication with Devolutions Agent timed out."), + nameof(Devolutions.Now.Policy.Client.BrokerClientErrorKind.EmptyResponse) => + CoreTools.Translate("Devolutions Agent closed the connection without a response."), + nameof(Devolutions.Now.Policy.Client.BrokerClientErrorKind.InvalidResponse) => + CoreTools.Translate("Devolutions Agent returned an invalid policy response."), + PolicyWriteDiagnosticCodes.PostCommitRefreshTimeout => + CoreTools.Translate("The policy was saved, but refreshing the current policy state timed out."), + PolicyWriteDiagnosticCodes.PostCommitRefreshUnavailable => + CoreTools.Translate("The policy was saved, but the current policy state could not be refreshed."), + _ => CoreTools.Translate("The policy write result could not be confirmed."), + }; + return CoreTools.Translate( + "{0} The policy write result is unknown. Refresh policy management state before retrying.", + cause); + } + + 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..a77a5ce197 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorEnums.cs @@ -0,0 +1,71 @@ +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Tri-state representation of a nullable boolean policy match criterion. +/// +public enum TriState +{ + Omitted, + False, + True, +} + +public enum PackageIdentifierMode +{ + Omitted, + Exact, + Patterns, +} + +public enum PackageVersionMode +{ + Omitted, + Exact, + Range, +} + +/// +/// 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, +} + +/// +/// Severity of a , as reported by the external (Agent-side) +/// semantic validator. +/// +public enum PolicyValidationSeverity +{ + Info, + Warning, + Error, +} + +public enum PolicyEditorConfirmationKind +{ + EnableAuditMode, + EnableDefaultAllow, + RemoveAllowSafetyLimits, + ReplaceIdentity, + Create, + ConfirmOverwrite, + DiscardChanges, +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorHelp.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorHelp.cs new file mode 100644 index 0000000000..6624971ca5 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorHelp.cs @@ -0,0 +1,128 @@ +using Devolutions.Now.Policy.Model; +using UniGetUI.Core.Tools; +using PolicyElevation = Devolutions.Now.Policy.Model.Elevation; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// Shared localized help used by tooltips and accessibility descriptions. +public static class PolicyEditorHelp +{ + public static string StructuredMode => T("Use guided fields to edit the policy. Values managed by UniGetUI or Devolutions Agent are shown read-only."); + public static string RawMode => T("Edit the complete policy as JSON. Use this view for review or fields not shown in the guided editor; invalid JSON must be corrected before returning."); + public static string PolicyId => T("Permanent identifier used in policy history and diagnostics, not a display name. Use 1 to 128 characters starting with a letter or number; then use letters, numbers, '.', '_', ':' or '-', for example contoso-policy."); + public static string Publisher => T("Organization or administrator responsible for this policy. Use a name that users can recognize when reviewing policy details."); + public static string PolicyFormatVersion => T("Policy document version supported by UniGetUI and Devolutions Agent. It is read-only and is different from the policy revision."); + public static string ServerVersion => T("Version of Devolutions Agent that supplied this policy information. Use it when comparing behavior or troubleshooting compatibility."); + public static string Revision => T("Change number assigned by Devolutions Agent when the policy is saved. It increases independently of the policy document version."); + public static string Published => T("Date and time when Devolutions Agent last committed this policy revision."); + public static string Description => T("Optional summary of the policy's purpose. Turn this field off to leave the description out."); + public static string SupportUrl => T("Optional HTTP or HTTPS page where users can learn about this policy or request an exception."); + public static string ValidFrom => T("Optional local date and time when the policy begins. Before this instant, Devolutions Agent rejects package operations; leave empty for immediate validity."); + public static string ValidUntil => T("Optional local date and time when the policy ends. After this instant, Devolutions Agent rejects package operations; leave empty for no expiry."); + public static string DefaultDecision => T("Action taken when no enabled rule matches. Choose Deny for a least-privilege policy; choose Allow only when unmatched package requests should proceed."); + public static string DefaultAllowAdvisory => T("Default Allow permits every package request that does not match an enabled rule."); + public static string AuditMode => T("When Yes, the broker still evaluates and logs policy decisions but permits requests the policy would deny. Use Yes only temporarily to evaluate rollout; set No to enforce policy."); + public static string AuditModeWarning => T("Audit mode is on. Policy decisions are evaluated and logged, but requests the policy would deny are still permitted. Set Audit mode to No to enforce policy."); + public static string AddRule => T("Add a disabled Deny rule with no match restrictions. Configure when it should apply, then enable it."); + public static string RuleEnabled => T("A disabled rule has no effect. Enable it only after defining the requests it should match and confirming its Allow or Deny decision."); + public static string DisabledRuleHint => T("This rule is disabled and has no effect. Configure its request characteristics, then enable it when ready."); + public static string IncompleteRuleHint => T("Choose at least one condition before this rule can be saved, or delete the rule."); + public static string UnrestrictedRuleWarning => T("This enabled rule has no narrowing match conditions, so it applies to every package request. Add at least one match condition or disable the rule."); + public static string DuplicateRule => T("Copy this rule as a starting point for a similar exception or restriction. Give the copy a unique rule ID."); + public static string MoveRule => T("Move the rule to change its evaluation order. Moving a rule can change which Allow or Deny decision wins."); + public static string DeleteRule => T("Remove this rule from the policy. The removal takes effect when the policy is saved."); + public static string RuleId => T("Permanent identifier used in decision logs, not a display name. Use 1 to 128 characters starting with a letter or number; then use letters, numbers, '.', '_', ':' or '-', for example allow-winget-updates."); + public static string Priority => T("Shows this rule's evaluation position. Use Move up or Move down to change it; UniGetUI assigns the underlying priority automatically."); + public static string Decision => T("Choose Allow to permit a matching request or Deny to block it. Changing a Deny rule to Allow starts high-impact request characteristics at No, so the rule matches normal verified requests without extra capabilities. Changing those characteristics back to Does not matter broadens what the Allow rule can match. Disabled rules have no effect."); + public static string RuleReason => T("Optional administrator-facing explanation recorded with the rule's decision. Describe why the request is allowed or denied."); + public static string Operations => T("Limit this rule to package installs, updates, or removals. Leave all choices clear to include every operation."); + public static string Managers => T("Limit this rule to selected package managers. Leave all choices clear to include requests from every manager."); + public static string SourceNames => T("Restrict package requests to approved source names configured in exactly one selected source-capable package manager. Names are matched exactly; use a separate rule for each manager."); + public static string PackageIdentifiers => T("Choose whether package identifiers do not affect matching, must match exact literal identifiers, or match wildcard patterns."); + public static string ExactPackageIdentifiers => T("Match complete package identifiers exactly as written. This works for any package identifier and does not interpret wildcard characters."); + public static string PackageIdentifierPatterns => T("Match package identifiers with wildcard patterns. Patterns may match multiple packages; use * for any characters and ? for one character."); + public static string PackageVersion => T("Choose whether package versions do not affect matching, must match exact version text, or fall within a semantic-version range."); + public static string ExactVersions => T("Match complete package version values exactly as written. Exact matching works for semantic and non-semantic package versions."); + public static string VersionRange => T("Limit this rule to a semantic-version range. A request without a valid semantic version will not match; leave the range off to accept other version formats."); + public static string MinimumVersion => T("Lowest semantic version included by this rule. Leave empty for no lower limit."); + public static string MaximumVersion => T("Highest semantic version included by this rule. Leave empty for no upper limit."); + public static string IncludePrerelease => T("Include prerelease versions such as 2.0.0-beta within this range. Leave off to match stable versions only."); + public static string Scopes => T("Limit this rule to current-user or all-users package requests. Leave both clear to include either scope and requests that do not specify one."); + public static string Architectures => T("Limit this rule to selected target architectures. Leave all choices clear to include any architecture and requests that do not specify one."); + public static string ExecutionPrivilege => T("Limit this rule by requested execution privilege. Leave both clear to include standard and elevated requests."); + private static string MatchOption => T("Select a value to restrict this rule to that value. Leaving every value in the group clear means the group does not restrict matching."); + public static string RequestCharacteristics => T("Use these characteristics only to decide whether the rule applies. Does not matter ignores a characteristic; Yes matches when it is present or enabled; No matches when it is absent or disabled."); + public static string InteractiveMatch => T("Decides whether this rule applies based on user interaction. Does not matter ignores this characteristic; Yes matches requests that may show prompts; No matches unattended requests."); + public static string SkipHashMatch => T("Decides whether this rule applies based on integrity-check bypass. Does not matter ignores this characteristic; Yes matches requests that bypass checks; No matches requests using normal verification."); + public static string PrereleaseMatch => T("Decides whether this rule applies based on prerelease selection. Does not matter ignores this characteristic; Yes matches requests that allow prerelease packages; No matches stable-only requests."); + public static string CustomParametersMatch => T("Decides whether this rule applies based on extra package-manager options. Does not matter ignores this characteristic; Yes matches requests with extra options; No matches requests without them."); + public static string CustomLocationMatch => T("Decides whether this rule applies based on install location. Does not matter ignores this characteristic; Yes matches requests with a custom folder; No matches requests using the default location."); + public static string PrePostCommandsMatch => T("Decides whether this rule applies based on commands run before or after the package operation. Does not matter ignores this characteristic; Yes matches requests with commands; No matches requests without them."); + public static string KillBeforeMatch => T("Decides whether this rule applies based on stopping running apps before the package operation. Does not matter ignores this characteristic; Yes matches requests that stop apps; No matches requests that do not."); + public static string UninstallPreviousMatch => T("Decides whether this rule applies based on removing an existing version before an update. Does not matter ignores this characteristic; Yes matches removal-first requests; No matches requests that do not remove first."); + public static string Constraints => T("Additional safety limits are available only for Allow rules and restrict what an otherwise allowed request may do. Deny rules do not keep these limits. Dependency installation, agreement acceptance, and restart behavior remain controlled by the package manager."); + public static string AllowInteractive => T("Permit a matching request to show installer or package-manager prompts. Turn off to require unattended operation."); + public static string AllowSkipHashCheck => T("Permit a matching request to bypass package integrity checks. Turn off unless a narrowly reviewed exception requires it."); + public static string AllowPrerelease => T("Permit a matching request to use prerelease package versions. Turn off to require stable releases."); + public static string AllowCustomLocation => T("Permit a matching request to choose a non-default install folder. Use the location patterns below to limit approved folders."); + public static string LocationPatterns => T("Approved custom install folders, one wildcard pattern per line. If custom locations are allowed and this list is empty, any folder is accepted."); + public static string AllowCustomParameters => T("Permit extra package-manager command options. Use the lists below to allow known options and block dangerous ones."); + public static string AllowedParameters => T("Extra command options allowed exactly as written, one per line. If both allowed lists are empty, any option is accepted unless denied below."); + public static string AllowedParameterPatterns => T("Wildcard patterns for allowed extra command options, one per line. Use these for options whose values vary."); + public static string DeniedParameters => T("Extra command options that must be rejected, one wildcard pattern per line. A denied option always wins over an allowed option."); + public static string AllowPrePostCommands => T("Permit commands to run before or after the package operation. Turn off unless a narrowly reviewed workflow requires this high-risk capability."); + public static string AllowKillBefore => T("Permit named processes to be closed before the package operation. Turn off to prevent policy-approved requests from terminating applications."); + public static string AllowUninstallPrevious => T("Permit removing an installed version before applying an update. Turn off to require updates that do not uninstall first."); + public static string AllowUpgrade => T("Permit an install request to leave an already installed package unchanged instead of upgrading it. Turn off to reject requests that use this option."); + public static string Save => T("Check and save the policy. Errors must be corrected first, and Windows may ask for administrator approval."); + public static string Overwrite => T("Replace a policy that changed after editing began. Review the newer policy first because overwriting discards those external changes."); + public static string GoToFinding => T("Open and focus the setting associated with this finding."); + public static string GoToRawError => T("Focus the policy JSON so you can correct the reported formatting or structure problem."); + public static string CanonicalJson => T("Read-only JSON for the active policy exactly as Devolutions Agent recognizes it. Use it for review, diagnostics, or comparison."); + public static string CopyCanonicalJson => T("Copy the complete active-policy JSON for review, diagnostics, or comparison."); + public static string RefreshPolicy => T("Reload the policy state and active policy details from Devolutions Agent."); + public static string AgentWriteCapability => T("Whether Devolutions Agent allows policy files to be changed. Read-only means the Agent configuration or policy location prevents changes."); + public static string AppWriteAvailability => T("Whether this UniGetUI installation can safely request policy changes. Available requires both Agent permission and a trusted administrator helper."); + public static string AppWriteReason => T("Explains the Agent restriction or local installation condition that prevents policy changes."); + public static string ElevationRequired => T("Whether saving policy changes requires Windows administrator approval."); + public static string EditPolicy => T("Open the active policy for editing. The policy ID remains locked so this updates the same policy."); + public static string CreatePolicy => T("Create the first policy for the configured location. New policies start with Deny as the default and no rules."); + public static string ReplaceIdentity => T("Replace the active policy with a different policy ID. Use only when intentionally creating a new policy identity."); + public static string ManagementState => T("Current policy state: Active is usable, Missing means no policy file exists, and Invalid means an administrator must correct or replace the protected policy file outside UniGetUI."); + public static string ConfiguredPath => T("Location where Devolutions Agent reads and writes the policy file. Change this location in Agent configuration, not here."); + public static string PathSource => T("Shows whether Devolutions Agent is using its default policy location or an explicitly configured location."); + + internal static string EnumOption(TEnum value) where TEnum : struct, Enum => + value switch + { + Operation.Install => + T("Install applies this rule to requests that add a package. Leave all operations clear to include installs, updates, and removals."), + Operation.Update => + T("Update applies this rule to requests that change an installed package to another version. Leave all operations clear to include every operation."), + Operation.Uninstall => + T("Uninstall applies this rule to requests that remove a package. Leave all operations clear to include every operation."), + Scope.User => + T("User applies this rule to packages installed for the current user. Leave both scope choices clear to include all scopes and requests that do not state one."), + Scope.Machine => + T("Machine applies this rule to packages installed for all users and commonly requires administrator approval. Leave both scope choices clear to include all scopes and requests that do not state one."), + Architecture.X86 => + T("X86 applies this rule to 32-bit Intel or AMD packages. Leave all architectures clear to include any architecture and requests that do not specify one."), + Architecture.X64 => + T("X64 applies this rule to 64-bit Intel or AMD packages. Leave all architectures clear to include any architecture and requests that do not specify one."), + Architecture.Arm64 => + T("Arm64 applies this rule to 64-bit ARM packages. Leave all architectures clear to include any architecture and requests that do not specify one."), + Architecture.Neutral => + T("Neutral applies this rule to packages that are not tied to one processor architecture. Leave all architectures clear to include any architecture."), + PolicyElevation.Standard => + T("Standard applies this rule when the request runs without administrator privileges. It excludes elevated requests and machine-wide work that requests elevation; leave both choices clear to include either."), + PolicyElevation.Elevated => + T("Elevated applies this rule when the request requires administrator privileges. Leave both elevation choices clear to include standard and elevated requests."), + ManagerName manager => + CoreTools.Translate( + "{0} applies this rule to requests handled by that package manager. Leave all managers clear to include every package manager.", + CoreTools.Translate(manager.ToString())), + _ => MatchOption, + }; + + private static string T(string value) => CoreTools.Translate(value); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorLocalValidation.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorLocalValidation.cs new file mode 100644 index 0000000000..2e1543a229 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorLocalValidation.cs @@ -0,0 +1,168 @@ +using Devolutions.Now.Policy.Api; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +internal static class PolicyEditorLocalValidation +{ + public static IReadOnlyList ValidateDraft( + PolicyEditorDraftDocument draft) + { + var findings = new List(); + if (!PolicyEditorTemplates.IsValidResourceId(draft.Metadata.Id)) + { + findings.Add(new( + "/Metadata/Id", + null, + PolicyValidationSeverity.Error, + DescribePolicyIdError(draft.Metadata.Id), + PolicyFindingCode.InvalidFieldValue)); + } + + for (int index = 0; index < draft.Rules.Count; index++) + { + PolicyEditorDraftRule rule = draft.Rules[index]; + if (!PolicyEditorTemplates.IsValidResourceId(rule.Id)) + { + findings.Add(new( + $"/Rules/{index}/Id", + rule.Id, + PolicyValidationSeverity.Error, + DescribeRuleIdError(rule.Id, index), + PolicyFindingCode.InvalidFieldValue)); + } + + if (PolicyEditorRuleSemantics.IsCatchAll(rule.Match)) + { + findings.Add(new( + $"/Rules/{index}/Match", + rule.Id, + PolicyValidationSeverity.Error, + CoreTools.Translate( + "Rule {0} needs at least one request condition. Configure Request characteristics or another match field, or delete the rule.", + index + 1), + PolicyFindingCode.InvalidFieldValue)); + } + + if (rule.Match.SourceNames.Count > 0 && rule.Match.Managers.Count != 1) + { + findings.Add(new( + $"/Rules/{index}/Match/Managers", + rule.Id, + PolicyValidationSeverity.Error, + CoreTools.Translate( + "Rule {0} uses Source names and must select exactly one Package manager. Create separate rules for different managers.", + index + 1), + PolicyFindingCode.InvalidFieldValue)); + } + else if (rule.Match.SourceNames.Count > 0 + && !PolicyEditorRuleSemantics.SupportsSourceNames(rule.Match.Managers[0])) + { + findings.Add(new( + $"/Rules/{index}/Match/Managers", + rule.Id, + PolicyValidationSeverity.Error, + CoreTools.Translate( + "Rule {0} uses Source names, but the selected package manager does not support configured sources. Choose a source-capable manager or remove Source names.", + index + 1), + PolicyFindingCode.InvalidFieldValue)); + } + + AddExclusiveConditionFindings(findings, rule, index); + } + + return findings; + } + + private static void AddExclusiveConditionFindings( + List findings, + PolicyEditorDraftRule rule, + int index) + { + if (rule.Match.PackageIdentifierMode != PackageIdentifierMode.Omitted + && !PolicyEditorRuleSemantics.HasPackageIdentifierCriterion(rule.Match)) + { + string member = rule.Match.PackageIdentifierMode == PackageIdentifierMode.Exact + ? "Exact" + : "Patterns"; + findings.Add(new( + $"/Rules/{index}/Match/PackageIdentifiers/{member}", + rule.Id, + PolicyValidationSeverity.Error, + CoreTools.Translate( + "Rule {0} must include at least one package identifier for the selected match mode.", + index + 1), + PolicyFindingCode.InvalidFieldValue)); + } + + if (rule.Match.VersionMode != PackageVersionMode.Omitted + && !PolicyEditorRuleSemantics.HasVersionCriterion(rule.Match)) + { + string member = rule.Match.VersionMode == PackageVersionMode.Exact + ? "Exact" + : "Range"; + findings.Add(new( + $"/Rules/{index}/Match/Version/{member}", + rule.Id, + PolicyValidationSeverity.Error, + CoreTools.Translate( + "Rule {0} must include at least one exact version or a semantic-version range for the selected match mode.", + index + 1), + PolicyFindingCode.InvalidFieldValue)); + } + } + + private static string DescribePolicyIdError(string value) => + GetErrorKind(value) switch + { + ResourceIdErrorKind.Empty => + CoreTools.Translate("Policy ID is required."), + ResourceIdErrorKind.TooLong => + CoreTools.Translate("Policy ID cannot exceed 128 characters."), + ResourceIdErrorKind.InvalidFirstCharacter => + CoreTools.Translate("Policy ID must start with an ASCII letter or number."), + _ => + CoreTools.Translate("Policy ID can contain only ASCII letters, numbers, '.', '_', ':' and '-'; spaces are not allowed."), + }; + + private static string DescribeRuleIdError(string value, int index) => + GetErrorKind(value) switch + { + ResourceIdErrorKind.Empty => + CoreTools.Translate("Rule {0} ID is required.", index + 1), + ResourceIdErrorKind.TooLong => + CoreTools.Translate("Rule {0} ID cannot exceed 128 characters.", index + 1), + ResourceIdErrorKind.InvalidFirstCharacter => + CoreTools.Translate( + "Rule {0} ID must start with an ASCII letter or number.", + index + 1), + _ => + CoreTools.Translate( + "Rule {0} ID can contain only ASCII letters, numbers, '.', '_', ':' and '-'; spaces are not allowed.", + index + 1), + }; + + private static ResourceIdErrorKind GetErrorKind(string value) + { + if (string.IsNullOrEmpty(value)) + return ResourceIdErrorKind.Empty; + if (value.Length > PolicyEditorTemplates.ResourceIdMaxLength) + return ResourceIdErrorKind.TooLong; + if (!IsAsciiLetterOrDigit(value[0])) + return ResourceIdErrorKind.InvalidFirstCharacter; + return ResourceIdErrorKind.InvalidCharacter; + } + + private static bool IsAsciiLetterOrDigit(char value) => + value is >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or >= '0' and <= '9'; + + private enum ResourceIdErrorKind + { + Empty, + TooLong, + InvalidFirstCharacter, + InvalidCharacter, + } +} 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..d04bbea77b --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorMapper.cs @@ -0,0 +1,548 @@ +using Devolutions.Now.Policy.Api; +using Devolutions.Now.Policy.Model; + +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 + { + PolicyFormatVersion = document.PolicyFormatVersion, + Metadata = ToDraft(document.Metadata), + Enforcement = ToDraft(document.Enforcement), + Rules = ToOrderedDraftRules(document.Rules), + }; + } + + public static PolicyEditorDraftDocument ToDraft(PolicyDraftDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return new PolicyEditorDraftDocument + { + PolicyFormatVersion = document.PolicyFormatVersion, + 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 = ToOrderedDraftRules(document.Rules), + }; + } + + internal static PolicyEditorDraftDocument ToDraftPreservingRuleOrder( + PolicyDraftDocument document) + { + ArgumentNullException.ThrowIfNull(document); + return new PolicyEditorDraftDocument + { + PolicyFormatVersion = document.PolicyFormatVersion, + 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 + { + PolicyFormatVersion = draft.PolicyFormatVersion, + 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((rule, index) => ToDocument(rule, checked((uint)index))) + .ToList(), + }; + } + + internal static PolicyDraftDocument ToSharedDraftPreservingPriorities( + PolicyEditorDraftDocument draft) + { + ArgumentNullException.ThrowIfNull(draft); + return new PolicyDraftDocument + { + PolicyFormatVersion = draft.PolicyFormatVersion, + 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(rule => ToDocument(rule, rule.Priority)).ToList(), + }; + } + + internal static void NormalizeStructuredRuleOrder( + List rules) + { + PolicyEditorDraftRule[] ordered = rules + .Select((rule, sourceIndex) => (Rule: rule, SourceIndex: sourceIndex)) + .OrderBy(item => item.Rule.Priority) + .ThenBy(item => + item.Rule.Decision == Devolutions.Now.Policy.Model.Decision.Deny ? 0 : 1) + .ThenBy(item => item.SourceIndex) + .Select(item => item.Rule) + .ToArray(); + rules.Clear(); + rules.AddRange(ordered); + PolicyRuleListOperations.NormalizePriorities(rules); + } + + /// 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 + { + PolicyFormatVersion = draft.PolicyFormatVersion, + Metadata = ToDocument(draft.Metadata, revision, publishedAt), + Enforcement = ToDocument(draft.Enforcement), + Rules = draft.Rules + .Select((rule, index) => ToDocument(rule, checked((uint)index))) + .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 + { + PolicyFormatVersion = document.PolicyFormatVersion, + Metadata = CloneMetadata(document.Metadata), + Enforcement = CloneEnforcement(document.Enforcement), + Rules = document.Rules.Select(CloneRule).ToList(), + }; + } + + public static PolicyDraftDocument CloneDraftDocument(PolicyDraftDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return PolicyDraftDocument.ParseJson(PolicySerializer.Serialize(document)); + } + + 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, + AuditMode = draft.AuditMode, + }; + + private static PolicyEnforcement CloneEnforcement(PolicyEnforcement enforcement) => new() + { + DefaultDecision = enforcement.DefaultDecision, + 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.Decision != Devolutions.Now.Policy.Model.Decision.Allow + || rule.Constraints is null + ? null + : ToDraft(rule.Constraints), + }; + + private static PolicyRule ToDocument(PolicyEditorDraftRule draft, uint priority) => new() + { + Id = draft.Id, + Enabled = draft.Enabled, + Priority = priority, + Decision = draft.Decision, + Reason = draft.Reason, + Match = ToDocument(draft.Match), + Constraints = draft.Decision != Devolutions.Now.Policy.Model.Decision.Allow + || draft.Constraints is null + ? null + : ToDocument(draft.Constraints), + }; + + private static List ToOrderedDraftRules( + IEnumerable rules) + { + List ordered = rules + .Select((rule, index) => (Rule: rule, SourceIndex: index)) + .OrderBy(item => item.Rule.Priority) + .ThenBy(item => + item.Rule.Decision == Devolutions.Now.Policy.Model.Decision.Deny ? 0 : 1) + .ThenBy(item => item.SourceIndex) + .Select(item => ToDraft(item.Rule)) + .ToList(); + PolicyRuleListOperations.NormalizePriorities(ordered); + return ordered; + } + + 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], + SourceNames = [.. match.SourceNames], + PackageIdentifierMode = GetPackageIdentifierMode(match.PackageIdentifiers), + ExactPackageIdentifiers = [.. match.PackageIdentifiers?.Exact ?? []], + PackageIdentifierPatterns = [.. match.PackageIdentifiers?.Patterns ?? []], + VersionMode = GetVersionMode(match.Version), + ExactVersions = [.. match.Version?.Exact ?? []], + VersionRange = match.Version?.Range is null ? null : ToDraft(match.Version.Range), + Scopes = [.. match.Scopes], + Architectures = [.. match.Architectures], + ExecutionElevation = [.. match.ExecutionElevation], + 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], + SourceNames = [.. draft.SourceNames], + PackageIdentifiers = ToDocumentPackageIdentifiers(draft), + Version = ToDocumentVersion(draft), + Scopes = [.. draft.Scopes], + Architectures = [.. draft.Architectures], + ExecutionElevation = [.. draft.ExecutionElevation], + 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], + SourceNames = [.. match.SourceNames], + PackageIdentifiers = ClonePackageIdentifiers(match.PackageIdentifiers), + Version = CloneVersion(match.Version), + Scopes = [.. match.Scopes], + Architectures = [.. match.Architectures], + ExecutionElevation = [.. match.ExecutionElevation], + 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 nullable boolean match into a tri-state. + /// + internal static TriState ToTriState(bool? value) => value switch + { + null => TriState.Omitted, + true => TriState.True, + false => TriState.False, + }; + + internal static bool? FromTriState(TriState state) => state switch + { + TriState.Omitted => null, + TriState.True => true, + TriState.False => false, + _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown tri-state value."), + }; + + private static PackageIdentifierMode GetPackageIdentifierMode( + PackageIdentifierCondition? condition) => + condition?.Patterns?.Count > 0 + ? PackageIdentifierMode.Patterns + : condition?.Exact?.Count > 0 + ? PackageIdentifierMode.Exact + : PackageIdentifierMode.Omitted; + + private static PackageVersionMode GetVersionMode(VersionCondition? condition) => + condition?.Range is not null + ? PackageVersionMode.Range + : condition?.Exact?.Count > 0 + ? PackageVersionMode.Exact + : PackageVersionMode.Omitted; + + private static PackageIdentifierCondition? ToDocumentPackageIdentifiers( + PolicyEditorDraftMatch draft) + { + var condition = new PackageIdentifierCondition(); + switch (draft.PackageIdentifierMode) + { + case PackageIdentifierMode.Omitted: + return null; + case PackageIdentifierMode.Exact: + condition.UseExact([.. draft.ExactPackageIdentifiers]); + return condition; + case PackageIdentifierMode.Patterns: + condition.UsePatterns([.. draft.PackageIdentifierPatterns]); + return condition; + default: + throw new ArgumentOutOfRangeException( + nameof(draft), + draft.PackageIdentifierMode, + "Unknown package identifier mode."); + } + } + + private static VersionCondition? ToDocumentVersion(PolicyEditorDraftMatch draft) + { + var condition = new VersionCondition(); + switch (draft.VersionMode) + { + case PackageVersionMode.Omitted: + return null; + case PackageVersionMode.Exact: + condition.UseExact([.. draft.ExactVersions]); + return condition; + case PackageVersionMode.Range: + condition.UseRange(ToDocument( + draft.VersionRange ?? new PolicyEditorDraftVersionRange())); + return condition; + default: + throw new ArgumentOutOfRangeException( + nameof(draft), + draft.VersionMode, + "Unknown package version mode."); + } + } + + private static PackageIdentifierCondition? ClonePackageIdentifiers( + PackageIdentifierCondition? condition) + { + if (condition is null) + return null; + + var clone = new PackageIdentifierCondition(); + if (condition.Patterns?.Count > 0) + clone.UsePatterns([.. condition.Patterns]); + else + clone.UseExact([.. condition.Exact ?? []]); + return clone; + } + + private static VersionCondition? CloneVersion(VersionCondition? condition) + { + if (condition is null) + return null; + + var clone = new VersionCondition(); + if (condition.Range is not null) + clone.UseRange(CloneVersionRange(condition.Range)); + else + clone.UseExact([.. condition.Exact ?? []]); + return clone; + } +} 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..f1630d0d4b --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorPolicyContract.cs @@ -0,0 +1,16 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Single source of truth for policy defaults chosen by the editor. +/// +public static class PolicyEditorPolicyContract +{ + /// + /// 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; + +} 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..9e74a3b5fb --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorProductionAdapters.cs @@ -0,0 +1,291 @@ +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 static readonly TimeSpan DefaultCommittedRefreshTimeout = TimeSpan.FromSeconds(3); + + private readonly IPolicyWriteElevator _elevator; + private readonly IBrokerPolicyManagementService _managementService; + private readonly TimeSpan _committedRefreshTimeout; + + public WindowsPolicyEditorWriteClient() + : this(CreateDefaultElevator(), new BrokerPolicyManagementService()) + { + } + + public WindowsPolicyEditorWriteClient( + IPolicyWriteElevator elevator, + IBrokerPolicyManagementService? managementService = null, + TimeSpan? committedRefreshTimeout = null) + { + _elevator = elevator; + _managementService = managementService ?? new BrokerPolicyManagementService(); + _committedRefreshTimeout = committedRefreshTimeout ?? DefaultCommittedRefreshTimeout; + } + + 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, + request.ExpectedStoreToken, + request.ValidationReceipt) + { + Operation = MapOperation(request.Operation), + ConflictHandling = MapConflictHandling(request.ConflictHandling), + }; + + PolicyElevationResult result; + try + { + result = await _elevator.ReplacePolicyAsync(elevationRequest, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + + if (result.Outcome is PolicyElevationOutcome.Cancelled) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + Logger.Warn( + "[PolicyEditor] The elevation path reported cancellation without caller cancellation."); + return PolicyWriteOutcome.Failure(PolicyWriteFailureKind.ProtocolFailed); + } + + if (result.Succeeded && result.CommittedStoreToken is not null) + { + BrokerPolicyManagementResult refreshed; + using (var refreshCancellation = new CancellationTokenSource()) + { + try + { + refreshed = await _managementService + .GetManagementAsync(refreshCancellation.Token) + .WaitAsync(_committedRefreshTimeout, CancellationToken.None) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + refreshCancellation.Cancel(); + Logger.Warn( + "[PolicyEditor] The Agent committed the policy, but the authoritative management refresh timed out."); + return PolicyWriteOutcome.Failure( + PolicyWriteFailureKind.WriteResultUnknown, + diagnosticCode: PolicyWriteDiagnosticCodes.PostCommitRefreshTimeout); + } + } + + if (refreshed is + { + Status: BrokerPolicyManagementStatus.Retrieved, + Snapshot: + { + State: PolicyManagementState.Active, + Policy: not null, + } snapshot, + }) + { + var response = new PolicyReplacementResponse + { + Policy = snapshot.Policy, + Management = snapshot, + }; + return PolicyWriteOutcome.Success( + response, + savedThenSuperseded: !string.Equals( + result.CommittedStoreToken, + snapshot.StoreToken, + StringComparison.Ordinal)); + } + + Logger.Warn( + "[PolicyEditor] The Agent committed the policy, but management state could not be refreshed."); + return PolicyWriteOutcome.Failure( + PolicyWriteFailureKind.WriteResultUnknown, + diagnosticCode: PolicyWriteDiagnosticCodes.PostCommitRefreshUnavailable); + } + + Logger.Warn( + "[PolicyEditor] Elevated policy write did not succeed: " + + $"outcome={result.Outcome}; operation={request.Operation}; stage=helper-response; " + + $"helperExit={result.HelperExitCode?.ToString() ?? "none"}; " + + $"brokerStatus={result.BrokerStatusCode?.ToString() ?? "none"}; " + + $"brokerError={result.BrokerErrorCode ?? "none"}"); + + ErrorCode? errorCode = TryParseErrorCode(result.BrokerErrorCode); + ErrorResponse? error = errorCode is null + ? null + : new ErrorResponse { Code = errorCode.Value }; + PolicyEditorRetryDecision? conflict = BuildConflictDecision(request, result, errorCode); + return PolicyWriteOutcome.Failure( + MapFailureKind(result.Outcome), + error, + conflict, + result.BrokerErrorCode); + } + + 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.WriteResultUnknown => PolicyWriteFailureKind.WriteResultUnknown, + PolicyElevationOutcome.Cancelled => PolicyWriteFailureKind.ProtocolFailed, + _ => PolicyWriteFailureKind.HelperFailed, + }; + + private static ErrorCode? TryParseErrorCode(string? value) => + Enum.TryParse(value, ignoreCase: false, out ErrorCode parsed) + && Enum.IsDefined(parsed) + ? parsed + : null; + + private static PolicyEditorRetryDecision? BuildConflictDecision( + PolicyEditorWriteRequest request, + PolicyElevationResult result, + ErrorCode? errorCode) + { + if (errorCode != ErrorCode.StalePolicyStoreToken + || result.ConflictStoreToken is null + || result.ConflictState is null) + { + return null; + } + + PolicyManagementState state = result.ConflictState.Value switch + { + PolicyElevationManagementState.Active => PolicyManagementState.Active, + PolicyElevationManagementState.Missing => PolicyManagementState.Missing, + PolicyElevationManagementState.Invalid => PolicyManagementState.Invalid, + _ => throw new InvalidDataException("The helper returned an invalid conflict state."), + }; + string draftId = request.Draft.GetProperty("Metadata").GetProperty("Id").GetString() + ?? throw new InvalidDataException("The validated draft did not carry an identity."); + if (state == PolicyManagementState.Invalid) + return null; + + return PolicyEditorRetryResolver.Resolve( + draftId, + state, + result.ConflictStoreToken, + result.ConflictPolicyId); + } + + private static PolicyElevationOperation MapOperation(PolicyReplacementOperation operation) => operation switch + { + PolicyReplacementOperation.Update => PolicyElevationOperation.Update, + PolicyReplacementOperation.ReplaceIdentity => PolicyElevationOperation.ReplaceIdentity, + PolicyReplacementOperation.Create => PolicyElevationOperation.Create, + _ => 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..c3363ce026 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRawSyntax.cs @@ -0,0 +1,343 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// Stable, localizable classification of a raw policy-draft parsing failure. +public enum PolicyEditorSyntaxErrorKind +{ + EmptyDocument, + InvalidJson, + InvalidPolicyDraft, + LegacySchemaField, + LegacyPolicyVersionField, + MissingPolicyFormatVersion, + InvalidPolicyFormatVersion, + UnsupportedPolicyFormatVersion, + MissingEnforcement, + MissingMetadata, +} + +/// A structural failure that prevented raw JSON text from becoming a structured draft. +public sealed record PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind Kind, 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 or JSON that doesn't match the final shared policy shape 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) + { + return TryParseStrictWithElement(rawJson, out draft, out _, out error); + } + + public static bool TryParseStrictWithElement( + string? rawJson, + out PolicyEditorDraftDocument? draft, + out JsonElement element, + out PolicyEditorSyntaxError? error) + { + draft = null; + element = default; + error = null; + + if (string.IsNullOrWhiteSpace(rawJson)) + { + error = new PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind.EmptyDocument, ""); + return false; + } + + try + { + using JsonDocument json = JsonDocument.Parse(rawJson); + element = json.RootElement.Clone(); + if (!TryCheckDraftContractFields(json.RootElement, out error)) + { + return false; + } + } + catch (JsonException) + { + error = new PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind.InvalidJson, ""); + return false; + } + + PolicyDraftDocument? document; + try + { + document = PolicyDraftDocument.ParseJson(MakeResourceIdsProjectable(rawJson)); + } + catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException or NotSupportedException) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.InvalidPolicyDraft, + PointerFromException(ex)); + return false; + } + + if (document is null) + { + error = new PolicyEditorSyntaxError(PolicyEditorSyntaxErrorKind.InvalidPolicyDraft, ""); + return false; + } + + if (!TryCheckFixedContract(document, out error)) + { + return false; + } + + draft = PolicyEditorMapper.ToDraftPreservingRuleOrder(document); + RestoreAuthoredResourceIds(element, draft); + return true; + } + + private static string MakeResourceIdsProjectable(string rawJson) + { + // The package deserializer validates ResourceId values while materializing the document. + // Substitute only for shape projection, then restore the authored strings for inline editing. + JsonNode? root = JsonNode.Parse(rawJson); + if (root is not JsonObject policy) + { + return rawJson; + } + + if (policy["Metadata"] is JsonObject metadata) + { + ReplaceInvalidResourceId(metadata, "Id", "policy"); + } + + if (policy["Rules"] is JsonArray rules) + { + for (int index = 0; index < rules.Count; index++) + { + if (rules[index] is JsonObject rule) + { + ReplaceInvalidResourceId(rule, "Id", $"rule-{index + 1}"); + } + } + } + + return root.ToJsonString(); + } + + private static void ReplaceInvalidResourceId( + JsonObject owner, + string propertyName, + string replacement) + { + if (owner[propertyName] is JsonValue value + && value.TryGetValue(out string? authored) + && !PolicyEditorTemplates.IsValidResourceId(authored)) + { + owner[propertyName] = replacement; + } + } + + private static void RestoreAuthoredResourceIds( + JsonElement root, + PolicyEditorDraftDocument draft) + { + if (root.TryGetProperty("Metadata", out JsonElement metadata) + && metadata.ValueKind == JsonValueKind.Object + && metadata.TryGetProperty("Id", out JsonElement policyId) + && policyId.ValueKind == JsonValueKind.String) + { + draft.Metadata.Id = policyId.GetString() ?? ""; + } + + if (!root.TryGetProperty("Rules", out JsonElement rules) + || rules.ValueKind != JsonValueKind.Array) + { + return; + } + + int index = 0; + foreach (JsonElement rule in rules.EnumerateArray()) + { + if (index >= draft.Rules.Count) + { + break; + } + + if (rule.ValueKind == JsonValueKind.Object + && rule.TryGetProperty("Id", out JsonElement ruleId) + && ruleId.ValueKind == JsonValueKind.String) + { + draft.Rules[index].Id = ruleId.GetString() ?? ""; + } + + index++; + } + } + + private static bool TryCheckDraftContractFields( + JsonElement root, + out PolicyEditorSyntaxError? error) + { + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("$schema", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.LegacySchemaField, + "/$schema"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("PolicyVersion", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.LegacyPolicyVersionField, + "/PolicyVersion"); + return false; + } + + JsonElement policyFormatVersion = default; + if (root.ValueKind == JsonValueKind.Object + && !root.TryGetProperty("PolicyFormatVersion", out policyFormatVersion)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingPolicyFormatVersion, + "/PolicyFormatVersion"); + return false; + } + + if (policyFormatVersion.ValueKind == JsonValueKind.String + && !TryParsePolicyFormatVersion( + policyFormatVersion.GetString(), + out PolicyEditorSyntaxErrorKind? formatError)) + { + error = new PolicyEditorSyntaxError( + formatError!.Value, + "/PolicyFormatVersion"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && !root.TryGetProperty("Enforcement", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingEnforcement, + "/Enforcement"); + return false; + } + + if (root.ValueKind == JsonValueKind.Object + && !root.TryGetProperty("Metadata", out _)) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingMetadata, + "/Metadata"); + return false; + } + + error = null; + return true; + } + + /// + /// Serializes exactly the editable draft shape. Server-managed metadata is never emitted. + /// + public static string ToCanonicalRaw(PolicyEditorDraftDocument draft) => + PolicySerializer.Serialize(PolicyEditorMapper.ToSharedDraft(draft)); + + internal static string ToCanonicalRawPreservingPriorities( + PolicyEditorDraftDocument draft) => + PolicySerializer.Serialize( + PolicyEditorMapper.ToSharedDraftPreservingPriorities(draft)); + + private static bool TryCheckFixedContract(PolicyDraftDocument document, out PolicyEditorSyntaxError? error) + { + if (document.Enforcement is null) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingEnforcement, + "/Enforcement"); + return false; + } + + if (document.Metadata is null) + { + error = new PolicyEditorSyntaxError( + PolicyEditorSyntaxErrorKind.MissingMetadata, + "/Metadata"); + return false; + } + + error = null; + return true; + } + + private static bool TryParsePolicyFormatVersion( + string? value, + out PolicyEditorSyntaxErrorKind? error) + { + try + { + PolicyFormatVersion.Parse(value!); + error = null; + return true; + } + catch (FormatException) + { + error = PolicyEditorSyntaxErrorKind.InvalidPolicyFormatVersion; + return false; + } + catch (NotSupportedException) + { + error = PolicyEditorSyntaxErrorKind.UnsupportedPolicyFormatVersion; + return false; + } + catch (ArgumentNullException) + { + error = PolicyEditorSyntaxErrorKind.InvalidPolicyFormatVersion; + return false; + } + } + + private static string PointerFromException(Exception ex) + { + if (ex is JsonException { Path: { Length: > 0 } path }) + { + return ConvertJsonPathToPointer(path); + } + + Match pathInMessage = JsonPathInMessage().Match(ex.Message); + return pathInMessage.Success + ? ConvertJsonPathToPointer(pathInMessage.Groups["path"].Value) + : ""; + } + + /// 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(); + + [GeneratedRegex(@"\bat (?\$(?:\.[A-Za-z_][A-Za-z0-9_]*|\[\d+\])+)\b", RegexOptions.CultureInvariant)] + private static partial Regex JsonPathInMessage(); +} 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..3d5f673c16 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorRetry.cs @@ -0,0 +1,82 @@ +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) + { + ArgumentNullException.ThrowIfNull(management); + return Resolve( + draftId, + management.State, + management.StoreToken, + management.Policy?.Metadata.Id); + } + + public static PolicyEditorRetryDecision Resolve( + string draftId, + PolicyManagementState state, + string token, + string? activePolicyId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + ArgumentException.ThrowIfNullOrWhiteSpace(token); + return state switch + { + PolicyManagementState.Active when activePolicyId is not null + && string.Equals( + activePolicyId, + draftId, + StringComparison.Ordinal) => + new( + PolicyReplacementOperation.Update, + token, + state, + activePolicyId), + PolicyManagementState.Active when activePolicyId is not null => + new( + PolicyReplacementOperation.ReplaceIdentity, + token, + state, + activePolicyId), + PolicyManagementState.Missing => + new( + PolicyReplacementOperation.Create, + token, + state, + null), + PolicyManagementState.Invalid => throw new InvalidOperationException( + "Invalid policy files cannot be retried from UniGetUI."), + _ => 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..86240fc539 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSession.cs @@ -0,0 +1,683 @@ +using System.Text.Json; +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 sealed record PolicyEditorConflictSnapshot( + string SubmittedCanonicalRawJson, + string ValidationReceipt, + string DraftId, + long MutationGeneration, + PolicyEditorRetryDecision RetryDecision, + DateTimeOffset DetectedAt); + +internal readonly record struct PolicyEditorDirtyComparisonSnapshot( + long MutationGeneration, + long BaselineVersion, + string BaselineRawJson); + +public sealed class PolicyEditorSession +{ + private string _baselineRawJson; + private string _lastAnalyzedRawJson; + private string _lastAnalyzedCanonicalRawJson; + private string _lastAnalyzedDraftId; + private JsonElement _lastAnalyzedRawElement; + private bool _hasLastAnalyzedRawElement; + private long _lastAnalyzedMutationGeneration; + private long _mutationGeneration; + private long _cleanMutationGeneration; + private long _baselineVersion; + private bool _isDirty; + + 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 PolicyEditorConflictSnapshot? Conflict { get; private set; } + + public long MutationGeneration => _mutationGeneration; + + public bool IsRawAnalysisPending { get; private set; } + internal bool LastRawAnalysisWasFormattingOnly { get; private set; } + + public bool IsIdentityLocked => Operation == PolicyEditorOperationKind.Update; + + public bool IsDirty => _isDirty; + + public bool IsValidationCurrent => + Validation is not null + && !IsRawAnalysisPending + && string.Equals( + Validation.SubmittedRawJson, + GetEffectiveRawJson(), + StringComparison.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; + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + _lastAnalyzedMutationGeneration = _mutationGeneration; + _cleanMutationGeneration = _mutationGeneration; + } + + 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 void SwitchToRaw() + { + RawBuffer = PolicyEditorRawSyntax.ToCanonicalRaw(Draft); + Mode = PolicyEditorMode.Raw; + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + IsRawAnalysisPending = false; + InvalidateContentState(markDirty: false); + _isDirty = !string.Equals(RawBuffer, _baselineRawJson, StringComparison.Ordinal); + if (!_isDirty) + _cleanMutationGeneration = _mutationGeneration; + _lastAnalyzedMutationGeneration = _mutationGeneration; + } + + public void SetRawBuffer(string rawText) + { + if (Mode != PolicyEditorMode.Raw) + throw new InvalidOperationException("The session is not in raw mode."); + + RawBuffer = rawText ?? ""; + _mutationGeneration++; + _isDirty = true; + IsRawAnalysisPending = true; + } + + public bool CompleteRawAnalysis( + string analyzedRawJson, + long analyzedMutationGeneration, + string? canonicalRawJson, + string? draftId, + JsonElement? rawElement) + { + if (Mode != PolicyEditorMode.Raw + || analyzedMutationGeneration != _mutationGeneration + || !string.Equals(RawBuffer, analyzedRawJson, StringComparison.Ordinal)) + { + LastRawAnalysisWasFormattingOnly = false; + return false; + } + + IsRawAnalysisPending = false; + _hasLastAnalyzedRawElement = rawElement.HasValue; + _lastAnalyzedRawElement = rawElement?.Clone() ?? default; + _lastAnalyzedMutationGeneration = analyzedMutationGeneration; + bool formattingOnly = + canonicalRawJson is not null + && string.Equals( + _lastAnalyzedCanonicalRawJson, + canonicalRawJson, + StringComparison.Ordinal); + LastRawAnalysisWasFormattingOnly = formattingOnly; + if (formattingOnly) + { + if (Validation is not null + && string.Equals( + Validation.SubmittedRawJson, + _lastAnalyzedRawJson, + StringComparison.Ordinal)) + { + Validation = Validation with { SubmittedRawJson = RawBuffer }; + } + if (Conflict is not null) + { + Conflict = Conflict with { MutationGeneration = _mutationGeneration }; + } + _lastAnalyzedRawJson = RawBuffer; + return true; + } + + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = canonicalRawJson ?? ""; + _lastAnalyzedDraftId = draftId ?? ""; + ClearContentState(); + return true; + } + + public bool TryGetAnalyzedRawElement(out JsonElement element) + { + if (Mode == PolicyEditorMode.Raw + && !IsRawAnalysisPending + && _hasLastAnalyzedRawElement + && _lastAnalyzedMutationGeneration == _mutationGeneration + && string.Equals( + _lastAnalyzedRawJson, + RawBuffer, + StringComparison.Ordinal)) + { + element = _lastAnalyzedRawElement.Clone(); + return true; + } + + element = default; + return false; + } + + 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 = PolicySerializer.Serialize(Validation.CanonicalDraft); + Mode = PolicyEditorMode.Structured; + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + _lastAnalyzedMutationGeneration = _mutationGeneration; + _hasLastAnalyzedRawElement = false; + IsRawAnalysisPending = false; + _isDirty = !string.Equals(RawBuffer, _baselineRawJson, StringComparison.Ordinal); + if (!_isDirty) + _cleanMutationGeneration = _mutationGeneration; + } + + public void ProjectRawToStructured( + string submittedRawJson, + PolicyEditorDraftDocument draft) + { + ArgumentNullException.ThrowIfNull(submittedRawJson); + ArgumentNullException.ThrowIfNull(draft); + + PolicyEditorMapper.NormalizeStructuredRuleOrder(draft.Rules); + Draft = draft; + RawBuffer = submittedRawJson; + Mode = PolicyEditorMode.Structured; + _lastAnalyzedRawJson = submittedRawJson; + _lastAnalyzedCanonicalRawJson = PolicyEditorRawSyntax.ToCanonicalRaw(draft); + _lastAnalyzedDraftId = draft.Metadata.Id; + _lastAnalyzedMutationGeneration = _mutationGeneration; + _hasLastAnalyzedRawElement = false; + IsRawAnalysisPending = false; + ClearContentState(); + _isDirty = !string.Equals( + _lastAnalyzedCanonicalRawJson, + _baselineRawJson, + StringComparison.Ordinal); + if (!_isDirty) + _cleanMutationGeneration = _mutationGeneration; + } + + internal void SetLocalFindings(IReadOnlyList findings) + { + Validation = null; + Findings = PolicyEditorFindingIndex.Build(findings); + Conflict = null; + } + + 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 string DuplicateRule(PolicyEditorDraftRule rule, string? newId = null) + { + EnsureStructuredMode(); + string result = PolicyRuleListOperations.Duplicate(Draft.Rules, rule, newId); + InvalidateContentState(); + return result; + } + + public void SetRuleEnabled(string id, bool enabled) + { + EnsureStructuredMode(); + PolicyRuleListOperations.SetEnabled(Draft.Rules, id, enabled); + InvalidateContentState(); + } + + public void SetRuleEnabled(PolicyEditorDraftRule rule, bool enabled) + { + EnsureStructuredMode(); + PolicyRuleListOperations.SetEnabled(Draft.Rules, rule, enabled); + InvalidateContentState(); + } + + public void DeleteRule(string id) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Delete(Draft.Rules, id); + InvalidateContentState(); + } + + public void DeleteRule(PolicyEditorDraftRule rule) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Delete(Draft.Rules, rule); + InvalidateContentState(); + } + + public void MoveRule(string id, int newIndex) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Move(Draft.Rules, id, newIndex); + InvalidateContentState(); + } + + public void MoveRule(PolicyEditorDraftRule rule, int newIndex) + { + EnsureStructuredMode(); + PolicyRuleListOperations.Move(Draft.Rules, rule, newIndex); + InvalidateContentState(); + } + + public void ApplyValidationResult( + string submittedRawJson, + PolicyValidationResult validation, + IReadOnlyList? boundedFindings = null, + int omittedFindingCount = 0) + { + ArgumentNullException.ThrowIfNull(submittedRawJson); + ArgumentNullException.ThrowIfNull(validation); + + IReadOnlyList findings; + if (boundedFindings is not null) + { + findings = boundedFindings; + } + 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 = sanitized; + omittedFindingCount += validation.Findings.Count - take; + } + if (validation.CanonicalDraft is not null) + { + IReadOnlyList rules = + PolicyEditorMapper.ToDraft(validation.CanonicalDraft).Rules; + findings = findings + .Where(finding => !PolicyEditorAdvisories.PolicyEditorRiskCoverage + .ShouldSuppressFinding(finding, rules)) + .ToArray(); + } + Findings = PolicyEditorFindingIndex.Build(findings, omittedFindingCount); + 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 CaptureConflict( + PolicyManagementSnapshot management, + PolicyDraftDocument submittedCanonicalDraft, + string validationReceipt, + string draftId) + { + ArgumentNullException.ThrowIfNull(submittedCanonicalDraft); + ArgumentException.ThrowIfNullOrWhiteSpace(validationReceipt); + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + string submittedCanonicalRawJson = PolicySerializer.Serialize(submittedCanonicalDraft); + PolicyEditorRetryDecision decision = + PolicyEditorRetryResolver.Resolve(draftId, management); + Conflict = new PolicyEditorConflictSnapshot( + submittedCanonicalRawJson, + validationReceipt, + draftId, + _mutationGeneration, + decision, + DateTimeOffset.UtcNow); + } + + public void CaptureConflict( + PolicyEditorRetryDecision decision, + PolicyDraftDocument submittedCanonicalDraft, + string validationReceipt, + string draftId) + { + ArgumentNullException.ThrowIfNull(decision); + ArgumentNullException.ThrowIfNull(submittedCanonicalDraft); + ArgumentException.ThrowIfNullOrWhiteSpace(validationReceipt); + ArgumentException.ThrowIfNullOrWhiteSpace(draftId); + Conflict = new PolicyEditorConflictSnapshot( + PolicySerializer.Serialize(submittedCanonicalDraft), + validationReceipt, + draftId, + _mutationGeneration, + 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; + SetCleanBaseline(RawBuffer, _mutationGeneration); + _lastAnalyzedRawJson = RawBuffer; + _lastAnalyzedCanonicalRawJson = RawBuffer; + _lastAnalyzedDraftId = Draft.Metadata.Id; + _lastAnalyzedMutationGeneration = _mutationGeneration; + _hasLastAnalyzedRawElement = false; + IsRawAnalysisPending = false; + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + Conflict = null; + } + + public void MarkSavedPreservingCurrentDraft( + PolicyReplacementResponse response, + long savedMutationGeneration) + { + 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; + SetBaseline( + PolicyEditorRawSyntax.ToCanonicalRaw(PolicyEditorMapper.ToDraft(response.Policy)), + savedMutationGeneration); + _isDirty = _mutationGeneration != _cleanMutationGeneration; + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + 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 => throw new InvalidOperationException( + "Invalid policy files cannot be changed from UniGetUI."), + _ => throw new InvalidDataException("The policy management state is not supported."), + }; + } + + internal PolicyEditorDirtyComparisonSnapshot CaptureDirtyComparison() => + new(_mutationGeneration, _baselineVersion, _baselineRawJson); + + internal bool TryApplyDirtyComparison( + PolicyEditorDirtyComparisonSnapshot snapshot, + bool isDirty) + { + if (snapshot.MutationGeneration != _mutationGeneration + || snapshot.BaselineVersion != _baselineVersion) + { + return false; + } + + _isDirty = isDirty; + if (!isDirty) + _cleanMutationGeneration = _mutationGeneration; + return true; + } + + private void InvalidateContentState(bool markDirty = true) + { + _mutationGeneration++; + if (markDirty) + _isDirty = true; + ClearContentState(); + } + + private void SetCleanBaseline(string baselineRawJson, long mutationGeneration) + { + SetBaseline(baselineRawJson, mutationGeneration); + _isDirty = false; + } + + private void SetBaseline(string baselineRawJson, long mutationGeneration) + { + _baselineRawJson = baselineRawJson; + _cleanMutationGeneration = mutationGeneration; + _baselineVersion++; + } + + private void ClearContentState() + { + Validation = null; + Findings = PolicyEditorFindingIndex.Build([]); + Conflict = null; + } + + private bool TryGetCanonicalEffectiveRaw( + out string? canonicalRawJson, + out string? draftId) + { + if (Mode == PolicyEditorMode.Raw) + { + if (IsRawAnalysisPending + || _lastAnalyzedMutationGeneration != _mutationGeneration + || !string.Equals( + _lastAnalyzedRawJson, + RawBuffer, + StringComparison.Ordinal) + || string.IsNullOrEmpty(_lastAnalyzedCanonicalRawJson)) + { + canonicalRawJson = null; + draftId = null; + return false; + } + + canonicalRawJson = _lastAnalyzedCanonicalRawJson; + draftId = _lastAnalyzedDraftId; + return true; + } + + string effectiveRawJson = GetEffectiveRawJson(); + if (Validation is not null + && string.Equals( + Validation.SubmittedRawJson, + effectiveRawJson, + StringComparison.Ordinal)) + { + canonicalRawJson = PolicySerializer.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.ToSharedDraftPreservingPriorities(parsed); + canonicalRawJson = PolicySerializer.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 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..23db838026 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionCloseGuard.cs @@ -0,0 +1,94 @@ +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) + { + cancellationToken.ThrowIfCancellationRequested(); + 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.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..2802b62900 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorSessionViewModel.cs @@ -0,0 +1,1398 @@ +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 TimeSpan _rawSyntaxDebounce; + private readonly TimeSpan _structuredDirtyDebounce; + private readonly Func _structuredDraftSerializer; + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly object _discardConfirmationLock = new(); + private readonly Dictionary _localInputErrors = []; + private readonly HashSet _deferredBlankRules = + new(ReferenceEqualityComparer.Instance); + private readonly Dictionary> + _explicitMatchCharacteristics = new(ReferenceEqualityComparer.Instance); + private CancellationTokenSource? _rawSyntaxCancellation; + private Task _rawSyntaxAnalysis = Task.CompletedTask; + private CancellationTokenSource? _structuredDirtyCancellation; + private Task _structuredDirtyAnalysis = Task.CompletedTask; + private CancellationTokenSource? _authoritativeValidationCancellation; + private Task _authoritativeValidation = Task.CompletedTask; + private Task? _discardConfirmationTask; + private long _saveGeneration; + private long _findingNavigationGeneration; + private bool _hasLocalSemanticErrors; + 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 IsRawSyntaxPending => Session.IsRawAnalysisPending; + public string SyntaxErrorTitle => SyntaxError?.Kind switch + { + PolicyEditorSyntaxErrorKind.EmptyDocument or PolicyEditorSyntaxErrorKind.InvalidJson => + CoreTools.Translate("The document is not valid JSON"), + _ => CoreTools.Translate("The document is not a valid policy draft"), + }; + public string SyntaxErrorMessage => SyntaxError?.Kind switch + { + PolicyEditorSyntaxErrorKind.EmptyDocument => + CoreTools.Translate("The document is empty."), + PolicyEditorSyntaxErrorKind.InvalidJson => + CoreTools.Translate("The JSON syntax is invalid."), + PolicyEditorSyntaxErrorKind.LegacySchemaField => + CoreTools.Translate("The $schema field is obsolete. Remove it."), + PolicyEditorSyntaxErrorKind.LegacyPolicyVersionField => + CoreTools.Translate("PolicyVersion is obsolete. Rename it to PolicyFormatVersion."), + PolicyEditorSyntaxErrorKind.MissingPolicyFormatVersion => + CoreTools.Translate("The policy draft is missing PolicyFormatVersion."), + PolicyEditorSyntaxErrorKind.InvalidPolicyFormatVersion => + CoreTools.Translate("PolicyFormatVersion must be a canonical three-part numeric version such as 1.0.0."), + PolicyEditorSyntaxErrorKind.UnsupportedPolicyFormatVersion => + CoreTools.Translate("The policy draft uses an unsupported policy format version. This version supports major version 1."), + PolicyEditorSyntaxErrorKind.MissingEnforcement => + CoreTools.Translate("The policy draft is missing the Enforcement object."), + PolicyEditorSyntaxErrorKind.MissingMetadata => + CoreTools.Translate("The policy draft is missing the Metadata object."), + _ => CoreTools.Translate("The document does not match the policy draft format."), + }; + public bool HasLocalInputErrors => _localInputErrors.Count > 0; + public bool HasLocalSemanticErrors => _hasLocalSemanticErrors; + public string LocalInputErrorSummary => string.Join(Environment.NewLine, _localInputErrors.Values); + public bool CanValidateOrSave => CanStartRemoteOperation(); + public bool CanSwitchToRaw => CanSwitchStructuredToRaw(); + public bool CanSwitchToStructured => CanProjectRawToStructured(); + public long FindingNavigationGeneration => _findingNavigationGeneration; + + public string RawBuffer + { + get => Session.RawBuffer; + set + { + if (Session.Mode != PolicyEditorMode.Raw + || string.Equals(Session.RawBuffer, value, StringComparison.Ordinal)) + return; + + Session.SetRawBuffer(value); + SyntaxError = null; + ScheduleRawSyntaxAnalysis(value); + OnEditorStateChanged(); + } + } + + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private PolicyEditorSyntaxError? _syntaxError; + [ObservableProperty] private bool _lastSaveSucceeded; + [ObservableProperty] private bool _savedWithNewerChanges; + [ObservableProperty] private bool _savedThenSuperseded; + [ObservableProperty] private bool _requiresManagementRefresh; + [ObservableProperty] private ErrorCode? _lastErrorCode; + [ObservableProperty] private PolicyWriteFailureKind _lastWriteFailureKind; + [ObservableProperty] private string? _lastWriteDiagnosticCode; + internal PolicyEditorWriteCompletion? LastWriteCompletion { get; private set; } + + public PolicyEditorSessionViewModel( + PolicyEditorSession session, + IPolicyValidationClient validationClient, + IPolicyEditorConfirmationPrompt confirmationPrompt, + IPolicyWriteClient writeClient, + TimeSpan? rawSyntaxDebounce = null, + TimeSpan? structuredDirtyDebounce = null, + Func? structuredDraftSerializer = null) + { + Session = session; + _validationClient = validationClient; + _confirmationPrompt = confirmationPrompt; + _writeClient = writeClient; + _rawSyntaxDebounce = rawSyntaxDebounce ?? TimeSpan.FromMilliseconds(300); + _structuredDirtyDebounce = structuredDirtyDebounce ?? TimeSpan.FromMilliseconds(300); + _structuredDraftSerializer = + structuredDraftSerializer ?? PolicyEditorRawSyntax.ToCanonicalRaw; + if (Session.Findings.All.Count == 0) + { + RefreshLocalSemanticValidation(); + } + } + + [RelayCommand(CanExecute = nameof(CanSwitchStructuredToRaw))] + private void SwitchToRaw() + { + if (Session.Mode != PolicyEditorMode.Structured) + return; + + Session.SwitchToRaw(); + RefreshLocalSemanticValidation(); + CancelStructuredDirtyAnalysis(); + CancelRawSyntaxAnalysis(); + SyntaxError = null; + OnEditorStateChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanProjectRawToStructured))] + private Task SwitchToStructuredAsync(CancellationToken cancellationToken) + { + if (Session.Mode != PolicyEditorMode.Raw || !CanProjectRawToStructured()) + return Task.CompletedTask; + string submitted = Session.RawBuffer; + if (!PolicyEditorRawSyntax.TryParseStrict( + submitted, + out PolicyEditorDraftDocument? draft, + out PolicyEditorSyntaxError? syntaxError)) + { + SyntaxError = syntaxError; + return Task.CompletedTask; + } + + CancelRawSyntaxAnalysis(); + Session.ProjectRawToStructured(submitted, draft!); + RefreshLocalSemanticValidation(); + SyntaxError = null; + LastErrorCode = null; + OnEditorStateChanged(); + return Task.CompletedTask; + } + + [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(); + OnStructuredDraftChanged(); + } + + internal async Task ChangeRuleDecisionAsync( + PolicyEditorRuleUi ruleUi, + int selectedIndex, + CancellationToken cancellationToken = default) + { + if (selectedIndex < 0 + || selectedIndex >= PolicyEditorEnumDisplay.Decisions.Length + || Volatile.Read(ref _isDisposed) != 0 + || IsBusy) + { + ruleUi.RefreshDecisionPresentation(); + return; + } + + Devolutions.Now.Policy.Model.Decision selected = + PolicyEditorEnumDisplay.Decisions[selectedIndex]; + if (selected == ruleUi.Rule.Decision) + { + ruleUi.RefreshDecisionPresentation(); + return; + } + + if (selected == Devolutions.Now.Policy.Model.Decision.Deny + && PolicyEditorRuleSemantics.HasConfiguredSafetyLimits(ruleUi.Rule.Constraints)) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + IsBusy = true; + bool confirmed; + try + { + confirmed = await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.RemoveAllowSafetyLimits, + GetInitialOperation(), + Session.Draft.Metadata.Id, + Session.OriginManagement.StoreToken, + Session.OriginManagement.State, + Session.OriginManagement.Policy?.Metadata.Id, + Findings, + RuleId: ruleUi.Rule.Id), + linked.Token); + } + finally + { + IsBusy = false; + } + + if (!confirmed || !CanApply(linked.Token)) + { + ruleUi.RefreshDecisionPresentation(); + return; + } + } + + if (ruleUi.Rule.Decision == Devolutions.Now.Policy.Model.Decision.Deny + && selected == Devolutions.Now.Policy.Model.Decision.Allow) + { + ApplySafeAllowMatchDefaults(ruleUi.Rule); + } + + ruleUi.ApplyDecision(selected); + OnEditorStateChanged(); + } + + public void NotifyLocalInputChanged() + { + Session.NotifyDraftChanged(); + OnStructuredDraftChanged(); + } + + internal void MarkMatchCharacteristicConfigured( + PolicyEditorDraftRule rule, + PolicyEditorAdvisories.PolicyEditorRisk risk) + { + if (!_explicitMatchCharacteristics.TryGetValue(rule, out HashSet? configured)) + { + configured = []; + _explicitMatchCharacteristics.Add(rule, configured); + } + + configured.Add(risk); + } + + 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)); + OnPropertyChanged(nameof(CanSwitchToStructured)); + NotifyCommandStates(); + } + + [RelayCommand] + private void AddRule() + { + PolicyEditorDraftRule rule = Session.AddRule(); + _deferredBlankRules.Add(rule); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void DuplicateRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.DuplicateRule(rule); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void ToggleRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + Session.SetRuleEnabled(rule, !rule.Enabled); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void DeleteRule(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + _deferredBlankRules.Remove(rule); + Session.DeleteRule(rule); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void MoveRuleUp(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + int index = Session.Draft.Rules.IndexOf(rule); + Session.MoveRule(rule, index - 1); + OnStructuredDraftChanged(); + } + + [RelayCommand] + private void MoveRuleDown(PolicyEditorDraftRule? rule) + { + if (rule is null) return; + int index = Session.Draft.Rules.IndexOf(rule); + Session.MoveRule(rule, index + 1); + OnStructuredDraftChanged(); + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanStartRemoteOperation))] + private async Task SaveAsync(CancellationToken cancellationToken) + { + using CancellationTokenSource linked = CreateLinkedCancellation(cancellationToken); + cancellationToken = linked.Token; + PromoteDeferredBlankRules(); + if (!CanStartRemoteOperation()) + { + if (HasLocalSemanticErrors) + RequestFindingNavigation(); + return; + } + + try + { + await SaveCoreAsync( + conflict: null, + PolicyConflictHandling.Reject, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + [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; + } + + try + { + await SaveCoreAsync( + conflict, + PolicyConflictHandling.ConfirmOverwrite, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + public async Task ConfirmDiscardAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + Task confirmation; + lock (_discardConfirmationLock) + { + if (_discardConfirmationTask is null) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + confirmation = completion.Task; + _discardConfirmationTask = confirmation; + _ = CompleteDiscardConfirmationAsync(completion); + } + else + { + confirmation = _discardConfirmationTask; + } + } + + return await confirmation.WaitAsync(cancellationToken); + } + + private async Task CompleteDiscardConfirmationAsync(TaskCompletionSource completion) + { + try + { + bool result = await ConfirmDiscardCoreAsync(_lifetimeCancellation.Token); + lock (_discardConfirmationLock) + { + completion.TrySetResult(result); + ClearDiscardConfirmation(completion.Task); + } + } + catch (OperationCanceledException ex) + { + lock (_discardConfirmationLock) + { + completion.TrySetCanceled(ex.CancellationToken); + ClearDiscardConfirmation(completion.Task); + } + } + catch (Exception ex) + { + lock (_discardConfirmationLock) + { + completion.TrySetException(ex); + ClearDiscardConfirmation(completion.Task); + } + } + } + + private void ClearDiscardConfirmation(Task confirmation) + { + if (ReferenceEquals(_discardConfirmationTask, confirmation)) + _discardConfirmationTask = null; + } + + private async Task ConfirmDiscardCoreAsync(CancellationToken cancellationToken) + { + if (IsBusy) + { + StatusMessage = CoreTools.Translate( + "Please wait for the current policy operation to finish before closing."); + return false; + } + + if (Session.IsDirty) + ReconcileDirtyAtBoundary(Session.GetEffectiveRawJson()); + 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; + + long saveGeneration = Interlocked.Increment(ref _saveGeneration); + IsBusy = true; + LastSaveSucceeded = false; + SavedWithNewerChanges = false; + SavedThenSuperseded = false; + LastErrorCode = null; + LastWriteFailureKind = PolicyWriteFailureKind.None; + LastWriteDiagnosticCode = null; + try + { + string submitted = Session.GetEffectiveRawJson(); + long attemptGeneration = Session.MutationGeneration; + ReconcileDirtyAtBoundary(submitted); + + // 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) + || saveGeneration != Volatile.Read(ref _saveGeneration) + || 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) + { + if (Session.Findings.All.Any(finding => finding.IsError)) + RequestFindingNavigation(); + return; + } + } + + string canonicalRaw = PolicySerializer.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 = conflict.RetryDecision; + 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; + } + + PolicyEditorDraftDocument canonicalDraft = + PolicyEditorMapper.ToDraft(validation.CanonicalDraft); + bool loadedAuditMode = + Session.OriginManagement.Policy?.Enforcement.AuditMode is true; + if (!loadedAuditMode && canonicalDraft.Enforcement.AuditMode is true) + { + bool acknowledged = await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.EnableAuditMode, + operation, + validation.CanonicalDraft.Metadata.Id, + token, + state, + activePolicyId, + validation.Findings.All), + cancellationToken); + if (!CanApply(cancellationToken) + || saveGeneration != Volatile.Read(ref _saveGeneration) + || Session.MutationGeneration != attemptGeneration) + { + return; + } + if (!acknowledged) + return; + } + + bool loadedDefaultAllow = + Session.OriginManagement.Policy?.Enforcement.DefaultDecision + == Devolutions.Now.Policy.Model.Decision.Allow; + if (!loadedDefaultAllow + && canonicalDraft.Enforcement.DefaultDecision + == Devolutions.Now.Policy.Model.Decision.Allow) + { + bool acknowledged = await _confirmationPrompt.ConfirmAsync( + new PolicyEditorConfirmationRequest( + PolicyEditorConfirmationKind.EnableDefaultAllow, + operation, + validation.CanonicalDraft.Metadata.Id, + token, + state, + activePolicyId, + validation.Findings.All), + cancellationToken); + if (!CanApply(cancellationToken) + || saveGeneration != Volatile.Read(ref _saveGeneration) + || Session.MutationGeneration != attemptGeneration) + { + return; + } + if (!acknowledged) + return; + } + + PolicyEditorConfirmationKind? operationConfirmation = + conflictHandling == PolicyConflictHandling.ConfirmOverwrite + ? null + : operation switch + { + PolicyReplacementOperation.ReplaceIdentity => + PolicyEditorConfirmationKind.ReplaceIdentity, + PolicyReplacementOperation.Create => + PolicyEditorConfirmationKind.Create, + _ => 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) + || saveGeneration != Volatile.Read(ref _saveGeneration)) + return; + if (Session.MutationGeneration != attemptGeneration) return; + + using JsonDocument canonicalDocument = JsonDocument.Parse(canonicalRaw); + var request = new PolicyEditorWriteRequest( + operation, + conflictHandling, + token, + canonicalDocument.RootElement.Clone(), + validation.Receipt); + PolicyWriteOutcome write = + await _writeClient.WriteAsync(request, cancellationToken); + if (!CanApplyDispatchedWrite(saveGeneration)) return; + PublishWriteCompletion( + saveGeneration, + write, + Session.MutationGeneration != attemptGeneration); + + if (write.FailureKind == PolicyWriteFailureKind.WriteResultUnknown) + { + LastWriteFailureKind = write.FailureKind; + LastErrorCode = write.Error?.Code; + LastWriteDiagnosticCode = write.DiagnosticCode; + RequiresManagementRefresh = true; + OnEditorStateChanged(); + } + + if (Session.MutationGeneration != attemptGeneration) + { + if (write.Response is not null) + { + Session.MarkSavedPreservingCurrentDraft(write.Response, attemptGeneration); + SavedWithNewerChanges = true; + LastSaveSucceeded = true; + ScheduleCurrentModeDirtyAnalysis(); + 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; + SavedThenSuperseded = write.SavedThenSuperseded; + LastSaveSucceeded = true; + StatusMessage = ""; + OnEditorStateChanged(); + return; + } + + LastWriteFailureKind = write.FailureKind; + LastErrorCode = write.Error?.Code; + LastWriteDiagnosticCode = write.DiagnosticCode; + RequiresManagementRefresh = + write.FailureKind == PolicyWriteFailureKind.WriteResultUnknown; + if (write.ConflictDecision is { } conflictDecision) + { + Session.CaptureConflict( + conflictDecision, + validation.CanonicalDraft, + validation.Receipt, + validation.CanonicalDraft.Metadata.Id); + } + else 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 + { + if (Volatile.Read(ref _isDisposed) != 0 + || saveGeneration == Volatile.Read(ref _saveGeneration)) + { + StatusMessage = ""; + IsBusy = false; + } + } + } + + private bool CanStartRemoteOperation() => + Volatile.Read(ref _isDisposed) == 0 + && !IsBusy + && !HasLocalInputErrors + && !HasLocalSemanticErrors + && !IsRawSyntaxPending + && !RequiresManagementRefresh + && SyntaxError is null; + + private bool CanStartStructuredOperation() => + Volatile.Read(ref _isDisposed) == 0 + && !IsBusy + && !HasLocalInputErrors; + + private bool CanSwitchStructuredToRaw() => + CanStartStructuredOperation() + && Session.Mode == PolicyEditorMode.Structured + && !Session.Draft.Rules.Any(rule => + PolicyEditorRuleSemantics.IsCatchAll(rule.Match)); + + private bool CanProjectRawToStructured() => + CanStartStructuredOperation() + && Session.Mode == PolicyEditorMode.Raw + && !IsRawSyntaxPending; + + private bool CanApply(CancellationToken cancellationToken) => + Volatile.Read(ref _isDisposed) == 0 && !cancellationToken.IsCancellationRequested; + + private bool CanApplyDispatchedWrite(long saveGeneration) => + Volatile.Read(ref _isDisposed) == 0 + && saveGeneration == Volatile.Read(ref _saveGeneration); + + private void PublishWriteCompletion( + long generation, + PolicyWriteOutcome write, + bool hasNewerChanges) + { + PolicyEditorWriteCompletionKind kind; + if (write.Response is not null) + { + kind = hasNewerChanges + ? PolicyEditorWriteCompletionKind.SavedWithNewerChanges + : write.SavedThenSuperseded + ? PolicyEditorWriteCompletionKind.SavedThenSuperseded + : PolicyEditorWriteCompletionKind.Saved; + } + else if (write.FailureKind != PolicyWriteFailureKind.WriteResultUnknown + && (write.ConflictDecision is not null + || write.Error is + { + Code: ErrorCode.StalePolicyStoreToken, + Management: not null, + })) + { + kind = PolicyEditorWriteCompletionKind.Conflict; + } + else + { + kind = PolicyEditorWriteCompletionKind.Failed; + } + + LastWriteCompletion = new( + generation, + kind, + write.FailureKind, + write.Error?.Code, + write.DiagnosticCode); + OnPropertyChanged(nameof(LastWriteCompletion)); + } + + private CancellationTokenSource CreateLinkedCancellation(CancellationToken cancellationToken) => + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetimeCancellation.Token); + + partial void OnIsBusyChanged(bool value) => NotifyCommandStates(); + + partial void OnSyntaxErrorChanged(PolicyEditorSyntaxError? value) + { + OnPropertyChanged(nameof(SyntaxErrorTitle)); + OnPropertyChanged(nameof(SyntaxErrorMessage)); + NotifyCommandStates(); + } + + partial void OnRequiresManagementRefreshChanged(bool value) => NotifyCommandStates(); + + private void NotifyCommandStates() + { + OnPropertyChanged(nameof(CanValidateOrSave)); + OnPropertyChanged(nameof(CanSwitchToRaw)); + OnPropertyChanged(nameof(CanSwitchToStructured)); + SwitchToRawCommand.NotifyCanExecuteChanged(); + SwitchToStructuredCommand.NotifyCanExecuteChanged(); + SaveCommand.NotifyCanExecuteChanged(); + ConfirmOverwriteCommand.NotifyCanExecuteChanged(); + } + + private void RequestFindingNavigation() + { + _findingNavigationGeneration++; + OnPropertyChanged(nameof(FindingNavigationGeneration)); + } + + private bool TryGetDraftElement( + string raw, + out JsonElement element, + out PolicyEditorSyntaxError? error) + { + element = default; + if (Session.Mode == PolicyEditorMode.Raw + && string.Equals(raw, Session.RawBuffer, StringComparison.Ordinal) + && Session.TryGetAnalyzedRawElement(out element)) + { + error = null; + return true; + } + + return PolicyEditorRawSyntax.TryParseStrictWithElement( + raw, + out _, + out element, + out error); + } + + 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, + _ => 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(HasLocalSemanticErrors)); + OnPropertyChanged(nameof(HasConflict)); + OnPropertyChanged(nameof(Findings)); + OnPropertyChanged(nameof(IsRawSyntaxPending)); + NotifyCommandStates(); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _isDisposed, 1) != 0) return; + Interlocked.Increment(ref _saveGeneration); + CancelRawSyntaxAnalysis(); + CancelStructuredDirtyAnalysis(); + CancelAuthoritativeValidation(); + _lifetimeCancellation.Cancel(); + _lifetimeCancellation.Dispose(); + NotifyCommandStates(); + } + + internal Task WaitForRawSyntaxAnalysisAsync() => _rawSyntaxAnalysis; + + internal Task WaitForStructuredDirtyAnalysisAsync() => _structuredDirtyAnalysis; + + internal Task WaitForAuthoritativeValidationAsync() => _authoritativeValidation; + + private void OnStructuredDraftChanged() + { + RefreshLocalSemanticValidation(); + ScheduleAuthoritativeValidation(); + ScheduleStructuredDirtyAnalysis(); + OnEditorStateChanged(); + } + + private void ApplySafeAllowMatchDefaults(PolicyEditorDraftRule rule) + { + if (rule.Match.SkipHashCheck == TriState.Omitted + && !IsExplicitlyConfigured(rule, PolicyEditorAdvisories.PolicyEditorRisk.SkipHashCheck)) + rule.Match.SkipHashCheck = TriState.False; + if (rule.Match.HasCustomParameters == TriState.Omitted + && !IsExplicitlyConfigured(rule, PolicyEditorAdvisories.PolicyEditorRisk.CustomParameters)) + rule.Match.HasCustomParameters = TriState.False; + if (rule.Match.HasCustomInstallLocation == TriState.Omitted + && !IsExplicitlyConfigured(rule, PolicyEditorAdvisories.PolicyEditorRisk.CustomInstallLocation)) + rule.Match.HasCustomInstallLocation = TriState.False; + if (rule.Match.HasPrePostCommands == TriState.Omitted + && !IsExplicitlyConfigured(rule, PolicyEditorAdvisories.PolicyEditorRisk.PrePostCommands)) + rule.Match.HasPrePostCommands = TriState.False; + } + + private bool IsExplicitlyConfigured( + PolicyEditorDraftRule rule, + PolicyEditorAdvisories.PolicyEditorRisk risk) => + _explicitMatchCharacteristics.TryGetValue(rule, out HashSet? configured) + && configured.Contains(risk); + + private void RefreshLocalSemanticValidation() + { + IReadOnlyList findings = + PolicyEditorLocalValidation.ValidateDraft(Session.Draft) + .Where(finding => !IsDeferredBlankFinding(finding)) + .ToArray(); + _hasLocalSemanticErrors = findings.Any( + finding => finding.Severity == PolicyValidationSeverity.Error); + Session.SetLocalFindings(findings); + } + + internal bool IsDeferredBlankRule(PolicyEditorDraftRule rule) => + _deferredBlankRules.Contains(rule) + && PolicyEditorRuleSemantics.IsCatchAll(rule.Match); + + private bool IsDeferredBlankFinding(PolicyValidationFinding finding) + { + if (!finding.Pointer.EndsWith("/Match", StringComparison.Ordinal) + || !TryGetRuleIndex(finding.Pointer, out int index) + || index < 0 + || index >= Session.Draft.Rules.Count) + { + return false; + } + + return IsDeferredBlankRule(Session.Draft.Rules[index]); + } + + private static bool TryGetRuleIndex(string pointer, out int index) + { + index = -1; + string[] segments = pointer.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments.Length >= 2 + && segments[0].Equals("Rules", StringComparison.OrdinalIgnoreCase) + && int.TryParse(segments[1], out index); + } + + private void PromoteDeferredBlankRules() + { + if (_deferredBlankRules.Count == 0) return; + _deferredBlankRules.Clear(); + RefreshLocalSemanticValidation(); + OnEditorStateChanged(); + } + + private void ReconcileDirtyAtBoundary(string effectiveRawJson) + { + PolicyEditorDirtyComparisonSnapshot snapshot = Session.CaptureDirtyComparison(); + ApplyDirtyComparison( + snapshot, + !string.Equals( + effectiveRawJson, + snapshot.BaselineRawJson, + StringComparison.Ordinal)); + } + + private void ScheduleStructuredDirtyAnalysis() + { + if (Session.Mode != PolicyEditorMode.Structured) + return; + + PolicyEditorDirtyComparisonSnapshot snapshot = Session.CaptureDirtyComparison(); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + CancellationTokenSource? previous = + Interlocked.Exchange(ref _structuredDirtyCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + _structuredDirtyAnalysis = AnalyzeStructuredDirtyAsync(snapshot, cancellation); + } + + private void ScheduleCurrentModeDirtyAnalysis() + { + if (Session.Mode == PolicyEditorMode.Raw) + ScheduleRawSyntaxAnalysis(Session.RawBuffer); + else + ScheduleStructuredDirtyAnalysis(); + } + + private bool ApplyDirtyComparison( + PolicyEditorDirtyComparisonSnapshot snapshot, + bool isDirty) + { + if (!Session.TryApplyDirtyComparison(snapshot, isDirty)) + return false; + + if (!isDirty && !HasLocalInputErrors) + SavedWithNewerChanges = false; + return true; + } + + private async Task AnalyzeStructuredDirtyAsync( + PolicyEditorDirtyComparisonSnapshot snapshot, + CancellationTokenSource cancellation) + { + try + { + await Task.Delay(_structuredDirtyDebounce, cancellation.Token); + if (cancellation.IsCancellationRequested + || Volatile.Read(ref _isDisposed) != 0) + { + return; + } + + PolicyEditorDraftDocument draftSnapshot = Session.Draft.Clone(); + bool isDirty = await Task.Run( + () => + { + try + { + return !string.Equals( + _structuredDraftSerializer(draftSnapshot), + snapshot.BaselineRawJson, + StringComparison.Ordinal); + } + catch (JsonException) + { + return true; + } + }, + cancellation.Token); + if (cancellation.IsCancellationRequested + || Volatile.Read(ref _isDisposed) != 0) + { + return; + } + + if (!ApplyDirtyComparison(snapshot, isDirty)) + { + return; + } + + // This continuation intentionally resumes on the UI context captured by the edit. + OnPropertyChanged(nameof(IsDirty)); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + catch (InvalidOperationException) when ( + cancellation.IsCancellationRequested + || snapshot.MutationGeneration != Session.MutationGeneration) + { + } + finally + { + if (ReferenceEquals( + Interlocked.CompareExchange( + ref _structuredDirtyCancellation, + null, + cancellation), + cancellation)) + { + cancellation.Dispose(); + } + } + + } + + private void CancelStructuredDirtyAnalysis() + { + CancellationTokenSource? cancellation = + Interlocked.Exchange(ref _structuredDirtyCancellation, null); + cancellation?.Cancel(); + cancellation?.Dispose(); + _structuredDirtyAnalysis = Task.CompletedTask; + } + + private void ScheduleRawSyntaxAnalysis(string raw) + { + long mutationGeneration = Session.MutationGeneration; + PolicyEditorDirtyComparisonSnapshot dirtySnapshot = Session.CaptureDirtyComparison(); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + CancellationTokenSource? previous = + Interlocked.Exchange(ref _rawSyntaxCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + _rawSyntaxAnalysis = AnalyzeRawSyntaxAsync( + raw, + mutationGeneration, + dirtySnapshot, + cancellation); + } + + private async Task AnalyzeRawSyntaxAsync( + string raw, + long mutationGeneration, + PolicyEditorDirtyComparisonSnapshot dirtySnapshot, + CancellationTokenSource cancellation) + { + try + { + await Task.Delay(_rawSyntaxDebounce, cancellation.Token); + ( + PolicyEditorSyntaxError? Error, + string? CanonicalRaw, + string? DraftId, + JsonElement? RawElement, + IReadOnlyList LocalFindings, + bool IsDirty) result = + await Task.Run<( + PolicyEditorSyntaxError? Error, + string? CanonicalRaw, + string? DraftId, + JsonElement? RawElement, + IReadOnlyList LocalFindings, + bool IsDirty)>( + () => + { + bool parsed = PolicyEditorRawSyntax.TryParseStrictWithElement( + raw, + out PolicyEditorDraftDocument? draft, + out JsonElement element, + out PolicyEditorSyntaxError? error); + return ( + error, + parsed && draft is not null + ? PolicyEditorRawSyntax.ToCanonicalRawPreservingPriorities(draft) + : null, + parsed ? draft?.Metadata.Id : null, + parsed ? (JsonElement?)element : null, + parsed && draft is not null + ? PolicyEditorLocalValidation.ValidateDraft(draft) + : [], + !string.Equals( + raw, + dirtySnapshot.BaselineRawJson, + StringComparison.Ordinal)); + }, + cancellation.Token); + if (cancellation.IsCancellationRequested + || Volatile.Read(ref _isDisposed) != 0 + || !Session.CompleteRawAnalysis( + raw, + mutationGeneration, + result.CanonicalRaw, + result.DraftId, + result.RawElement)) + { + return; + } + + ApplyDirtyComparison(dirtySnapshot, result.IsDirty); + if (!Session.LastRawAnalysisWasFormattingOnly) + { + _hasLocalSemanticErrors = result.LocalFindings.Any( + finding => finding.Severity == PolicyValidationSeverity.Error); + Session.SetLocalFindings(result.LocalFindings); + } + else + { + _hasLocalSemanticErrors = Session.Findings.All.Any( + finding => finding.Severity == PolicyValidationSeverity.Error); + } + SyntaxError = result.Error; + if (result.Error is null && !_hasLocalSemanticErrors && result.RawElement is { } element) + { + ScheduleAuthoritativeValidation( + raw, + element, + mutationGeneration); + } + OnEditorStateChanged(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + finally + { + if (ReferenceEquals( + Interlocked.CompareExchange( + ref _rawSyntaxCancellation, + null, + cancellation), + cancellation)) + { + cancellation.Dispose(); + } + } + } + + private void CancelRawSyntaxAnalysis() + { + CancellationTokenSource? cancellation = + Interlocked.Exchange(ref _rawSyntaxCancellation, null); + cancellation?.Cancel(); + cancellation?.Dispose(); + } + + private void ScheduleAuthoritativeValidation() + { + if (Session.Mode != PolicyEditorMode.Structured + || HasLocalInputErrors + || _hasLocalSemanticErrors) + { + CancelAuthoritativeValidation(); + return; + } + + string raw; + try + { + raw = Session.GetEffectiveRawJson(); + } + catch (JsonException) + { + return; + } + if (!TryGetDraftElement( + raw, + out JsonElement element, + out _)) + { + return; + } + + ScheduleAuthoritativeValidation(raw, element, Session.MutationGeneration); + } + + private void ScheduleAuthoritativeValidation( + string raw, + JsonElement draft, + long mutationGeneration) + { + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + CancellationTokenSource? previous = + Interlocked.Exchange(ref _authoritativeValidationCancellation, cancellation); + previous?.Cancel(); + previous?.Dispose(); + _authoritativeValidation = ValidateAuthoritativeAsync( + raw, + draft.Clone(), + mutationGeneration, + cancellation); + } + + private async Task ValidateAuthoritativeAsync( + string raw, + JsonElement draft, + long mutationGeneration, + CancellationTokenSource cancellation) + { + try + { + await Task.Delay(_structuredDirtyDebounce, cancellation.Token); + PolicyEditorValidationOutcome outcome = + await _validationClient.ValidateAsync(draft, cancellation.Token); + if (cancellation.IsCancellationRequested + || Volatile.Read(ref _isDisposed) != 0 + || mutationGeneration != Session.MutationGeneration + || !string.Equals(raw, Session.GetEffectiveRawJson(), StringComparison.Ordinal) + || outcome.Validation is null) + { + return; + } + + Session.ApplyValidationResult( + raw, + outcome.Validation, + outcome.BoundedFindings, + outcome.OmittedFindingCount); + _hasLocalSemanticErrors = Session.Findings.All.Any( + finding => finding.Severity == PolicyValidationSeverity.Error); + SyntaxError = null; + OnEditorStateChanged(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + finally + { + if (ReferenceEquals( + Interlocked.CompareExchange( + ref _authoritativeValidationCancellation, + null, + cancellation), + cancellation)) + { + cancellation.Dispose(); + } + } + } + + private void CancelAuthoritativeValidation() + { + CancellationTokenSource? cancellation = + Interlocked.Exchange(ref _authoritativeValidationCancellation, null); + cancellation?.Cancel(); + cancellation?.Dispose(); + _authoritativeValidation = Task.CompletedTask; + } +} + +internal enum PolicyEditorWriteCompletionKind +{ + Saved, + SavedWithNewerChanges, + SavedThenSuperseded, + Conflict, + Failed, +} + +internal sealed record PolicyEditorWriteCompletion( + long Generation, + PolicyEditorWriteCompletionKind Kind, + PolicyWriteFailureKind FailureKind, + ErrorCode? ErrorCode, + string? DiagnosticCode); 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..4e93b78f51 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorStructuredUi.cs @@ -0,0 +1,1293 @@ +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Devolutions.Now.Policy.Model; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// A single checkbox-style option for a multi-select enum match field (e.g. Operations, Managers, +/// Scopes, Architectures, Execution 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; } + public string HelpText { get; } + public string AdvisoryText { get; } + public bool IsAdvisoryVisible => IsSelected && !string.IsNullOrEmpty(AdvisoryText); + + [ObservableProperty] + private bool _isSelected; + + public PolicyEditorEnumOption( + string display, + string helpText, + string advisoryText, + bool isSelected, + Action onToggled) + { + Display = display; + HelpText = helpText; + AdvisoryText = advisoryText; + _isSelected = isSelected; + _onToggled = onToggled; + } + + partial void OnIsSelectedChanged(bool value) + { + OnPropertyChanged(nameof(IsAdvisoryVisible)); + _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()), + PolicyEditorHelp.EnumOption(value), + "", + 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("Does not matter"), + 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 readonly IReadOnlyList AuditModeDisplayItems = + [ + CoreTools.Translate("No"), + CoreTools.Translate("Yes"), + ]; + + public static readonly IReadOnlyList PackageIdentifierModeDisplayItems = + [ + CoreTools.Translate("Does not matter"), + CoreTools.Translate("Exact identifiers"), + CoreTools.Translate("Identifier patterns"), + ]; + + public static readonly IReadOnlyList PackageVersionModeDisplayItems = + [ + CoreTools.Translate("Does not matter"), + CoreTools.Translate("Exact versions"), + CoreTools.Translate("Semantic version range"), + ]; + + 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; + private DateTimeOffset? _pendingValidFromDate; + private DateTimeOffset? _pendingValidUntilDate; + private TimeSpan? _pendingValidFromTime; + private TimeSpan? _pendingValidUntilTime; + private bool _hasValidityOrderError; + + public PolicyEditorDocumentUi(PolicyEditorSessionViewModel sessionViewModel) + { + _sessionViewModel = sessionViewModel; + _validFromText = Draft.Metadata.ValidFrom?.ToString("O", CultureInfo.InvariantCulture) ?? ""; + _validUntilText = Draft.Metadata.ValidUntil?.ToString("O", CultureInfo.InvariantCulture) ?? ""; + ClearValidFromCommand = new RelayCommand(ClearValidFrom); + ClearValidUntilCommand = new RelayCommand(ClearValidUntil); + } + + private PolicyEditorDraftDocument Draft => _sessionViewModel.Draft; + + public bool IsIdentityLocked => _sessionViewModel.IsIdentityLocked; + + public void NotifyIdentityLockChanged() => + OnPropertyChanged(nameof(IsIdentityLocked)); + + public string PolicyFormatVersion => Draft.PolicyFormatVersion.Value; + public IReadOnlyList PolicyFormatVersionFindings => + FindingsFor("/PolicyFormatVersion"); + public bool HasPolicyFormatVersionErrors => HasErrors(PolicyFormatVersionFindings); + + public string Id + { + get => Draft.Metadata.Id; + set { Draft.Metadata.Id = value ?? ""; MarkDirty(); } + } + public IReadOnlyList IdFindings => FindingsFor("/Metadata/Id"); + public bool HasIdErrors => HasErrors(IdFindings); + + public string Publisher + { + get => Draft.Metadata.Publisher; + set { Draft.Metadata.Publisher = value ?? ""; MarkDirty(); } + } + public IReadOnlyList PublisherFindings => FindingsFor("/Metadata/Publisher"); + public bool HasPublisherErrors => HasErrors(PublisherFindings); + + public string? Description + { + get => Draft.Metadata.Description; + set + { + if (!HasDescription && string.IsNullOrEmpty(value)) return; + if (string.Equals(Draft.Metadata.Description, value, StringComparison.Ordinal)) return; + bool hadDescription = HasDescription; + Draft.Metadata.Description = value; + if (hadDescription != HasDescription) + OnPropertyChanged(nameof(HasDescription)); + MarkDirty(); + } + } + public IReadOnlyList DescriptionFindings => FindingsFor("/Metadata/Description"); + public bool HasDescriptionErrors => HasErrors(DescriptionFindings); + + public bool HasDescription + { + get => Draft.Metadata.Description is not null; + set + { + if (value == HasDescription) return; + Draft.Metadata.Description = value ? "" : null; + OnPropertyChanged(); + OnPropertyChanged(nameof(Description)); + MarkDirty(); + } + } + + public string? SupportUrl + { + get => Draft.Metadata.SupportUrl; + set + { + string? normalized = string.IsNullOrEmpty(value) ? null : value; + if (string.Equals(Draft.Metadata.SupportUrl, normalized, StringComparison.Ordinal)) + return; + Draft.Metadata.SupportUrl = normalized; + MarkDirty(); + } + } + public IReadOnlyList SupportUrlFindings => FindingsFor("/Metadata/SupportUrl"); + public bool HasSupportUrlErrors => HasErrors(SupportUrlFindings); + + public DateTimeOffset? ValidFromDate + { + get => Draft.Metadata.ValidFrom is { } value + ? LocalDate(value) + : _pendingValidFromDate; + set => SetLocalValidityDate(isStart: true, value); + } + internal string ValidFromText + { + get => _validFromText; + set => SetAbsoluteValidity(isStart: true, value); + } + public TimeSpan? ValidFromTime + { + get => Draft.Metadata.ValidFrom is { } value + ? LocalTime(value) + : _pendingValidFromTime; + set => SetLocalValidityTime(isStart: true, value); + } + public DateTimeOffset? ValidUntilDate + { + get => Draft.Metadata.ValidUntil is { } value + ? LocalDate(value) + : _pendingValidUntilDate; + set => SetLocalValidityDate(isStart: false, value); + } + internal string ValidUntilText + { + get => _validUntilText; + set => SetAbsoluteValidity(isStart: false, value); + } + public TimeSpan? ValidUntilTime + { + get => Draft.Metadata.ValidUntil is { } value + ? LocalTime(value) + : _pendingValidUntilTime; + set => SetLocalValidityTime(isStart: false, value); + } + public string LocalTimeZoneText + { + get + { + TimeZoneInfo zone = TimeZoneInfo.Local; + TimeSpan offset = zone.GetUtcOffset(DateTimeOffset.Now); + return CoreTools.Translate( + "{0} (UTC{1})", + zone.StandardName, + $"{(offset < TimeSpan.Zero ? "-" : "+")}{offset.Duration():hh\\:mm}"); + } + } + public bool IsOutsideValidityWindow + { + get + { + DateTimeOffset now = DateTimeOffset.UtcNow; + return Draft.Metadata.ValidFrom is { } from && now < from.ToUniversalTime() + || Draft.Metadata.ValidUntil is { } until && now > until.ToUniversalTime(); + } + } + public string ValidityWindowAdvisory => CoreTools.Translate( + "This policy is outside its configured validity window. If saved now, package operations will be rejected until the policy becomes valid again."); + public IRelayCommand ClearValidFromCommand { get; } + public IRelayCommand ClearValidUntilCommand { get; } + + public void ClearValidFrom() + { + bool changed = Draft.Metadata.ValidFrom is not null + || _pendingValidFromDate is not null + || _pendingValidFromTime is not null + || _validFromError is not null; + _validFromText = ""; + Draft.Metadata.ValidFrom = null; + _pendingValidFromDate = null; + _pendingValidFromTime = null; + SetValidFromError(null); + NotifyValidityChanged(); + ValidateValidityOrder(); + if (changed) MarkDirty(); + } + + public void ClearValidUntil() + { + bool changed = Draft.Metadata.ValidUntil is not null + || _pendingValidUntilDate is not null + || _pendingValidUntilTime is not null + || _validUntilError is not null; + _validUntilText = ""; + Draft.Metadata.ValidUntil = null; + _pendingValidUntilDate = null; + _pendingValidUntilTime = null; + SetValidUntilError(null); + NotifyValidityChanged(); + ValidateValidityOrder(); + if (changed) MarkDirty(); + } + + public string? ValidFromError => _validFromError; + public string? ValidUntilError => _validUntilError; + public IReadOnlyList ValidFromFindings => + [.. FindingsFor("/Metadata/ValidFrom"), .. FindingsForExact("/Metadata")]; + public IReadOnlyList ValidUntilFindings => + [.. FindingsFor("/Metadata/ValidUntil"), .. FindingsForExact("/Metadata")]; + public bool HasValidFromErrors => HasErrors(ValidFromFindings); + public bool HasValidUntilErrors => HasErrors(ValidUntilFindings); + + public int DecisionIndex + { + get => PolicyEditorEnumDisplay.IndexOfDecision(Draft.Enforcement.DefaultDecision); + set + { + if (value >= 0 && value < PolicyEditorEnumDisplay.Decisions.Length) + { + Draft.Enforcement.DefaultDecision = PolicyEditorEnumDisplay.Decisions[value]; + OnPropertyChanged(nameof(IsDefaultAllow)); + OnPropertyChanged(nameof(HasEnforcementAdvisory)); + OnPropertyChanged(nameof(EnforcementAdvisoryCountText)); + MarkDirty(); + } + } + } + public bool IsDefaultAllow => + Draft.Enforcement.DefaultDecision == Decision.Allow; + public bool HasEnforcementAdvisory => + IsDefaultAllow || IsAuditModeEnabled; + public string EnforcementAdvisoryCountText => CoreTools.Translate( + "{0} warning(s)", + (IsDefaultAllow ? 1 : 0) + (IsAuditModeEnabled ? 1 : 0)); + public IReadOnlyList DefaultDecisionFindings => + FindingsFor("/Enforcement/DefaultDecision"); + public bool HasDefaultDecisionErrors => HasErrors(DefaultDecisionFindings); + + public int AuditModeIndex + { + get => Draft.Enforcement.AuditMode is true ? 1 : 0; + set + { + if (value is 0 or 1) + { + Draft.Enforcement.AuditMode = value == 1; + OnPropertyChanged(nameof(IsAuditModeEnabled)); + OnPropertyChanged(nameof(HasEnforcementAdvisory)); + OnPropertyChanged(nameof(EnforcementAdvisoryCountText)); + MarkDirty(); + } + } + } + public bool IsAuditModeEnabled => Draft.Enforcement.AuditMode is true; + public IReadOnlyList AuditModeFindings => + FindingsFor("/Enforcement/AuditMode"); + public bool HasAuditModeErrors => HasErrors(AuditModeFindings); + + private void MarkDirty() => _sessionViewModel.NotifyDraftChangedCommand.Execute(null); + + public void RefreshFromDraft() + { + ClearPendingValiditySelections(); + SetValidFromError(null); + SetValidUntilError(null); + OnPropertyChanged(nameof(PolicyFormatVersion)); + OnPropertyChanged(nameof(Id)); + OnPropertyChanged(nameof(Publisher)); + OnPropertyChanged(nameof(Description)); + OnPropertyChanged(nameof(HasDescription)); + OnPropertyChanged(nameof(SupportUrl)); + NotifyValidityChanged(); + OnPropertyChanged(nameof(ValidFromError)); + OnPropertyChanged(nameof(ValidUntilError)); + OnPropertyChanged(nameof(DecisionIndex)); + OnPropertyChanged(nameof(IsDefaultAllow)); + OnPropertyChanged(nameof(HasEnforcementAdvisory)); + OnPropertyChanged(nameof(EnforcementAdvisoryCountText)); + OnPropertyChanged(nameof(AuditModeIndex)); + OnPropertyChanged(nameof(IsAuditModeEnabled)); + OnPropertyChanged(nameof(IsIdentityLocked)); + RefreshFindings(); + } + + public void RefreshFindings() + { + foreach (string property in new[] + { + nameof(PolicyFormatVersionFindings), nameof(HasPolicyFormatVersionErrors), + nameof(IdFindings), nameof(HasIdErrors), + nameof(PublisherFindings), nameof(HasPublisherErrors), + nameof(DescriptionFindings), nameof(HasDescriptionErrors), + nameof(SupportUrlFindings), nameof(HasSupportUrlErrors), + nameof(ValidFromFindings), nameof(HasValidFromErrors), + nameof(ValidUntilFindings), nameof(HasValidUntilErrors), + nameof(DefaultDecisionFindings), nameof(HasDefaultDecisionErrors), + nameof(AuditModeFindings), nameof(HasAuditModeErrors), + }) + { + OnPropertyChanged(property); + } + } + + private IReadOnlyList FindingsFor(params string[] pointers) => + _sessionViewModel.Session.Findings.All + .Where(finding => pointers.Any(finding.TargetsPointer)) + .ToArray(); + + private IReadOnlyList FindingsForExact(string pointer) => + _sessionViewModel.Session.Findings.All + .Where(finding => finding.Pointer.Equals(pointer, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + private static bool HasErrors(IEnumerable findings) => + findings.Any(finding => finding.IsError); + + 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 void SetLocalValidityDate(bool isStart, DateTimeOffset? date) + { + DateTimeOffset? current = isStart ? ValidFromDate : ValidUntilDate; + if (current?.Date == date?.Date) + return; + + TimeSpan? time = isStart ? ValidFromTime : ValidUntilTime; + if (isStart) + { + Draft.Metadata.ValidFrom = null; + _pendingValidFromDate = date; + _pendingValidFromTime = time; + } + else + { + Draft.Metadata.ValidUntil = null; + _pendingValidUntilDate = date; + _pendingValidUntilTime = time; + } + + CommitPendingLocalValidity(isStart); + } + + private void SetLocalValidityTime(bool isStart, TimeSpan? time) + { + TimeSpan? current = isStart ? ValidFromTime : ValidUntilTime; + if (current == time) + return; + + DateTimeOffset? date = isStart ? ValidFromDate : ValidUntilDate; + if (isStart) + { + Draft.Metadata.ValidFrom = null; + _pendingValidFromDate = date; + _pendingValidFromTime = time; + } + else + { + Draft.Metadata.ValidUntil = null; + _pendingValidUntilDate = date; + _pendingValidUntilTime = time; + } + + CommitPendingLocalValidity(isStart); + } + + private void CommitPendingLocalValidity(bool isStart) + { + DateTimeOffset? date = isStart ? _pendingValidFromDate : _pendingValidUntilDate; + TimeSpan? time = isStart ? _pendingValidFromTime : _pendingValidUntilTime; + if (date is null || time is null) + { + ValidateValidityOrder(); + string incompleteError = CoreTools.Translate( + "Choose both a date and time, or clear this validity limit."); + if (isStart) SetValidFromError(incompleteError); else SetValidUntilError(incompleteError); + NotifyValidityChanged(); + MarkDirty(); + return; + } + + DateTime local = date.Value.Date + time.Value; + TimeZoneInfo zone = TimeZoneInfo.Local; + string? error = zone.IsInvalidTime(local) + ? CoreTools.Translate("This local time does not exist because of a daylight-saving time change. Choose another time.") + : zone.IsAmbiguousTime(local) + ? CoreTools.Translate("This local time occurs twice because of a daylight-saving time change. Choose a time outside the repeated hour.") + : null; + if (error is not null) + { + if (isStart) Draft.Metadata.ValidFrom = null; else Draft.Metadata.ValidUntil = null; + ValidateValidityOrder(); + if (isStart) SetValidFromError(error); else SetValidUntilError(error); + NotifyValidityChanged(); + MarkDirty(); + return; + } + if (isStart) SetValidFromError(null); else SetValidUntilError(null); + + var absolute = new DateTimeOffset(local, zone.GetUtcOffset(local)); + if (isStart) + { + Draft.Metadata.ValidFrom = absolute; + _pendingValidFromDate = null; + _pendingValidFromTime = null; + _validFromText = absolute.ToString("O", CultureInfo.InvariantCulture); + } + else + { + Draft.Metadata.ValidUntil = absolute; + _pendingValidUntilDate = null; + _pendingValidUntilTime = null; + _validUntilText = absolute.ToString("O", CultureInfo.InvariantCulture); + } + ValidateValidityOrder(); + NotifyValidityChanged(); + MarkDirty(); + } + + private void SetAbsoluteValidity(bool isStart, string? text) + { + text ??= ""; + if (isStart) _validFromText = text; else _validUntilText = text; + if (string.IsNullOrEmpty(text)) + { + if (isStart) ClearValidFrom(); else ClearValidUntil(); + return; + } + + string normalized = text.EndsWith('Z') ? text[..^1] + "+00:00" : text; + if (!DateTimeOffset.TryParseExact( + normalized, + ["yyyy-MM-dd'T'HH:mm:sszzz", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz"], + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTimeOffset value)) + { + string error = CoreTools.Translate("Enter a valid ISO 8601 date and time."); + if (isStart) SetValidFromError(error); else SetValidUntilError(error); + _sessionViewModel.NotifyLocalInputChanged(); + return; + } + + if (isStart) + { + Draft.Metadata.ValidFrom = value; + _pendingValidFromDate = null; + _pendingValidFromTime = null; + SetValidFromError(null); + } + else + { + Draft.Metadata.ValidUntil = value; + _pendingValidUntilDate = null; + _pendingValidUntilTime = null; + SetValidUntilError(null); + } + ValidateValidityOrder(); + NotifyValidityChanged(); + MarkDirty(); + } + + private void ValidateValidityOrder() + { + if (Draft.Metadata.ValidFrom is { } from + && Draft.Metadata.ValidUntil is { } until + && from >= until) + { + _hasValidityOrderError = true; + SetValidUntilError(CoreTools.Translate( + "Valid until must be later than Valid from.")); + } + else if (_hasValidityOrderError) + { + _hasValidityOrderError = false; + SetValidUntilError(null); + } + } + + private void NotifyValidityChanged() + { + OnPropertyChanged(nameof(ValidFromDate)); + OnPropertyChanged(nameof(ValidFromText)); + OnPropertyChanged(nameof(ValidFromTime)); + OnPropertyChanged(nameof(ValidUntilDate)); + OnPropertyChanged(nameof(ValidUntilText)); + OnPropertyChanged(nameof(ValidUntilTime)); + OnPropertyChanged(nameof(LocalTimeZoneText)); + OnPropertyChanged(nameof(IsOutsideValidityWindow)); + OnPropertyChanged(nameof(ValidityWindowAdvisory)); + } + + private void ClearPendingValiditySelections() + { + _pendingValidFromDate = null; + _pendingValidFromTime = null; + _pendingValidUntilDate = null; + _pendingValidUntilTime = null; + } + + private static DateTimeOffset? LocalDate(DateTimeOffset? value) + { + if (value is null) return null; + DateTimeOffset local = TimeZoneInfo.ConvertTime(value.Value, TimeZoneInfo.Local); + return new DateTimeOffset(local.Date, local.Offset); + } + + private static TimeSpan? LocalTime(DateTimeOffset? value) + { + if (value is null) return null; + return TimeZoneInfo.ConvertTime(value.Value, TimeZoneInfo.Local).TimeOfDay; + } +} + +/// +/// 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 package conditions and 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 int _ruleIndex; + + public PolicyEditorDraftRule Rule { get; } + + public PolicyEditorRuleUi( + PolicyEditorDraftRule rule, + int ruleIndex, + PolicyEditorSessionViewModel sessionViewModel) + { + Rule = rule; + _ruleIndex = ruleIndex; + _sessionViewModel = sessionViewModel; + + 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); + ExecutionElevationOptions = + PolicyEditorEnumOptionFactory.Build(Rule.Match.ExecutionElevation, MarkDirty); + } + + public PolicyEditorRuleUi( + PolicyEditorDraftRule rule, + PolicyEditorSessionViewModel sessionViewModel) + : this(rule, sessionViewModel.Draft.Rules.IndexOf(rule), sessionViewModel) + { + } + + public string Id + { + get => Rule.Id; + set + { + value ??= ""; + if (string.Equals(Rule.Id, value, StringComparison.Ordinal)) return; + Rule.Id = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(AutomationName)); + MarkDirty(); + } + } + public IReadOnlyList IdFindings => FindingsFor("/Id"); + public bool HasIdErrors => HasErrors(IdFindings); + + public bool Enabled + { + get => Rule.Enabled; + set { Rule.Enabled = value; MarkDirty(); } + } + + public int EvaluationOrder => _ruleIndex + 1; + public bool CanMoveUp => _ruleIndex > 0; + public bool CanMoveDown => _ruleIndex < _sessionViewModel.Rules.Count - 1; + + public int DecisionIndex => PolicyEditorEnumDisplay.IndexOfDecision(Rule.Decision); + public bool IsAllowDecision => Rule.Decision == Decision.Allow; + public IReadOnlyList DecisionFindings => FindingsFor("/Decision"); + public bool HasDecisionErrors => HasErrors(DecisionFindings); + + public string? Reason + { + get => Rule.Reason; + set + { + if (!HasReason && string.IsNullOrEmpty(value)) return; + if (string.Equals(Rule.Reason, value, StringComparison.Ordinal)) return; + bool hadReason = HasReason; + Rule.Reason = value; + if (hadReason != HasReason) + OnPropertyChanged(nameof(HasReason)); + MarkDirty(); + } + } + public IReadOnlyList ReasonFindings => FindingsFor("/Reason"); + public bool HasReasonErrors => HasErrors(ReasonFindings); + + public bool HasReason + { + get => Rule.Reason is not null; + set + { + if (value == HasReason) return; + Rule.Reason = value ? "" : null; + OnPropertyChanged(); + OnPropertyChanged(nameof(Reason)); + 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 ExecutionElevationOptions { get; } + + public string SourceNames + { + get => Join(Rule.Match.SourceNames); + set => SetListField(Rule.Match.SourceNames, value); + } + public bool CanUseSourceNames => + Rule.Match.Managers.Count == 1 + && PolicyEditorRuleSemantics.SupportsSourceNames(Rule.Match.Managers[0]); + public bool IsSourceNamesVisible => + CanUseSourceNames || Rule.Match.SourceNames.Count > 0; + + public IReadOnlyList PackageIdentifierModeItems => + PolicyEditorEnumDisplay.PackageIdentifierModeDisplayItems; + + public int PackageIdentifierModeIndex + { + get => (int)Rule.Match.PackageIdentifierMode; + set + { + if (!Enum.IsDefined((PackageIdentifierMode)value) + || value == PackageIdentifierModeIndex) + return; + Rule.Match.PackageIdentifierMode = (PackageIdentifierMode)value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsExactPackageIdentifierMode)); + OnPropertyChanged(nameof(IsPackageIdentifierPatternMode)); + MarkDirty(); + } + } + + public bool IsExactPackageIdentifierMode => + Rule.Match.PackageIdentifierMode == PackageIdentifierMode.Exact; + public bool IsPackageIdentifierPatternMode => + Rule.Match.PackageIdentifierMode == PackageIdentifierMode.Patterns; + + public string ExactPackageIdentifiers + { + get => Join(Rule.Match.ExactPackageIdentifiers); + set => SetListField(Rule.Match.ExactPackageIdentifiers, value); + } + public IReadOnlyList ExactPackageIdentifierFindings => + FindingsFor("/Match/PackageIdentifiers/Exact"); + public bool HasExactPackageIdentifierErrors => HasErrors(ExactPackageIdentifierFindings); + + public string PackageIdentifierPatterns + { + get => Join(Rule.Match.PackageIdentifierPatterns); + set => SetListField(Rule.Match.PackageIdentifierPatterns, value); + } + public IReadOnlyList PackageIdentifierPatternFindings => + FindingsFor("/Match/PackageIdentifiers/Patterns"); + public bool HasPackageIdentifierPatternErrors => + HasErrors(PackageIdentifierPatternFindings); + + public IReadOnlyList PackageVersionModeItems => + PolicyEditorEnumDisplay.PackageVersionModeDisplayItems; + + public int PackageVersionModeIndex + { + get => (int)Rule.Match.VersionMode; + set + { + if (!Enum.IsDefined((PackageVersionMode)value) + || value == PackageVersionModeIndex) + return; + Rule.Match.VersionMode = (PackageVersionMode)value; + if (Rule.Match.VersionMode == PackageVersionMode.Range) + Rule.Match.VersionRange ??= new PolicyEditorDraftVersionRange(); + MarkDirty(); + OnPropertyChanged(nameof(PackageVersionModeIndex)); + OnPropertyChanged(nameof(IsExactVersionMode)); + OnPropertyChanged(nameof(IsVersionRangeMode)); + NotifyVersionRangePropertiesChanged(); + } + } + + public bool IsExactVersionMode => Rule.Match.VersionMode == PackageVersionMode.Exact; + public bool IsVersionRangeMode => Rule.Match.VersionMode == PackageVersionMode.Range; + + public string ExactVersions + { + get => Join(Rule.Match.ExactVersions); + set => SetListField(Rule.Match.ExactVersions, value); + } + public IReadOnlyList ExactVersionFindings => + FindingsFor("/Match/Version/Exact"); + public bool HasExactVersionErrors => HasErrors(ExactVersionFindings); + + public string? MinVersion + { + get => Rule.Match.VersionRange?.MinVersion; + set { EnsureVersionRange().MinVersion = string.IsNullOrEmpty(value) ? null : value; MarkDirty(); } + } + public IReadOnlyList MinVersionFindings => + [.. FindingsFor("/Match/Version/Range/MinVersion"), .. FindingsEndingAt("/Match/Version/Range")]; + public bool HasMinVersionErrors => HasErrors(MinVersionFindings); + + public string? MaxVersion + { + get => Rule.Match.VersionRange?.MaxVersion; + set { EnsureVersionRange().MaxVersion = string.IsNullOrEmpty(value) ? null : value; MarkDirty(); } + } + public IReadOnlyList MaxVersionFindings => + [.. FindingsFor("/Match/Version/Range/MaxVersion"), .. FindingsEndingAt("/Match/Version/Range")]; + public bool HasMaxVersionErrors => HasErrors(MaxVersionFindings); + + 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, + PolicyEditorAdvisories.PolicyEditorRisk.SkipHashCheck); + } + + 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, + PolicyEditorAdvisories.PolicyEditorRisk.CustomParameters); + } + + public int HasCustomInstallLocationIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasCustomInstallLocation); + set => SetTriState( + v => Rule.Match.HasCustomInstallLocation = v, + value, + PolicyEditorAdvisories.PolicyEditorRisk.CustomInstallLocation); + } + + public int HasPrePostCommandsIndex + { + get => PolicyEditorEnumDisplay.IndexOfTriState(Rule.Match.HasPrePostCommands); + set => SetTriState( + v => Rule.Match.HasPrePostCommands = v, + value, + PolicyEditorAdvisories.PolicyEditorRisk.PrePostCommands); + } + + 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 IsDisabled => !Rule.Enabled; + public bool IsIncompleteNewRule => + _sessionViewModel.IsDeferredBlankRule(Rule); + public bool IsEnabledWithoutMatchConditions => + Rule.Enabled && PolicyEditorRuleSemantics.IsCatchAll(Rule.Match); + public IReadOnlyList RuleSafetyAdvisories => + PolicyEditorAdvisories.ForRule(Rule); + public bool HasRuleSafetyAdvisories => RuleSafetyAdvisories.Count > 0; + public string SkipHashCheckAdvisory => + PolicyEditorAdvisories.SkipHashCheck( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public string SkipHashCheckMatchAdvisory => + PolicyEditorAdvisories.SkipHashCheckMatch( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public bool HasSkipHashCheckMatchAdvisory => + !string.IsNullOrEmpty(SkipHashCheckMatchAdvisory); + public bool HasSkipHashCheckAdvisory => + !string.IsNullOrEmpty(SkipHashCheckAdvisory); + public string CustomParametersAdvisory => + PolicyEditorAdvisories.CustomParameters( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public string CustomParametersMatchAdvisory => + PolicyEditorAdvisories.CustomParametersMatch( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public bool HasCustomParametersMatchAdvisory => + !string.IsNullOrEmpty(CustomParametersMatchAdvisory); + public bool HasCustomParametersAdvisory => + !string.IsNullOrEmpty(CustomParametersAdvisory); + public string CustomInstallLocationAdvisory => + PolicyEditorAdvisories.CustomInstallLocation( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public string CustomInstallLocationMatchAdvisory => + PolicyEditorAdvisories.CustomInstallLocationMatch( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public bool HasCustomInstallLocationMatchAdvisory => + !string.IsNullOrEmpty(CustomInstallLocationMatchAdvisory); + public bool HasCustomInstallLocationAdvisory => + !string.IsNullOrEmpty(CustomInstallLocationAdvisory); + public string PrePostCommandsAdvisory => + PolicyEditorAdvisories.PrePostCommands( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public string PrePostCommandsMatchAdvisory => + PolicyEditorAdvisories.PrePostCommandsMatch( + Rule, + _sessionViewModel.Rules, + _ruleIndex); + public bool HasPrePostCommandsMatchAdvisory => + !string.IsNullOrEmpty(PrePostCommandsMatchAdvisory); + public bool HasPrePostCommandsAdvisory => + !string.IsNullOrEmpty(PrePostCommandsAdvisory); + public int FieldSafetyAdvisoryCount => + new[] + { + SkipHashCheckAdvisory, + SkipHashCheckMatchAdvisory, + CustomParametersAdvisory, + CustomParametersMatchAdvisory, + CustomInstallLocationAdvisory, + CustomInstallLocationMatchAdvisory, + PrePostCommandsAdvisory, + PrePostCommandsMatchAdvisory, + }.Count(message => !string.IsNullOrEmpty(message)); + public bool HasFieldSafetyAdvisories => FieldSafetyAdvisoryCount > 0; + public string FieldSafetyAdvisoryCountText => CoreTools.Translate( + "{0} warning(s)", + FieldSafetyAdvisoryCount); + public IReadOnlyList MatchFindings => + FindingsEndingAt("/Match"); + public bool HasMatchErrors => HasErrors(MatchFindings); + + public bool HasConstraints + { + get => Rule.Constraints is not null; + set + { + if (!IsAllowDecision) return; + if (value == (Rule.Constraints is not null)) return; + Rule.Constraints = value ? new PolicyEditorDraftConstraints() : null; + MarkDirty(); + OnPropertyChanged(); + NotifyConstraintPropertiesChanged(); + } + } + + internal void ApplyDecision(Decision decision) + { + Rule.Decision = decision; + if (!IsAllowDecision) + { + Rule.Constraints = null; + NotifyConstraintPropertiesChanged(); + } + OnPropertyChanged(nameof(DecisionIndex)); + OnPropertyChanged(nameof(IsAllowDecision)); + OnPropertyChanged(nameof(HasConstraints)); + OnPropertyChanged(nameof(SkipHashCheckIndex)); + OnPropertyChanged(nameof(HasCustomParametersIndex)); + OnPropertyChanged(nameof(HasCustomInstallLocationIndex)); + OnPropertyChanged(nameof(HasPrePostCommandsIndex)); + MarkDirty(); + } + + internal void RefreshDecisionPresentation() + { + OnPropertyChanged(nameof(DecisionIndex)); + OnPropertyChanged(nameof(IsAllowDecision)); + OnPropertyChanged(nameof(HasConstraints)); + } + + 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 or document index. + public IReadOnlyList Findings => + _sessionViewModel.Session.Findings.All.Where(finding => + string.Equals(finding.RuleId, Rule.Id, StringComparison.Ordinal) + || finding.TargetsPointer($"/Rules/{_ruleIndex}")).ToArray(); + + 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)); + OnPropertyChanged(nameof(IsIncompleteNewRule)); + foreach (string property in new[] + { + nameof(IdFindings), nameof(HasIdErrors), + nameof(DecisionFindings), nameof(HasDecisionErrors), + nameof(ReasonFindings), nameof(HasReasonErrors), + nameof(MatchFindings), nameof(HasMatchErrors), + nameof(ExactPackageIdentifierFindings), nameof(HasExactPackageIdentifierErrors), + nameof(PackageIdentifierPatternFindings), nameof(HasPackageIdentifierPatternErrors), + nameof(ExactVersionFindings), nameof(HasExactVersionErrors), + nameof(MinVersionFindings), nameof(HasMinVersionErrors), + nameof(MaxVersionFindings), nameof(HasMaxVersionErrors), + }) + { + OnPropertyChanged(property); + } + } + + private IReadOnlyList FindingsFor(params string[] suffixes) => + Findings.Where(finding => suffixes.Any(suffix => + finding.Pointer.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) + || finding.Pointer.Contains( + suffix + "/", + StringComparison.OrdinalIgnoreCase))).ToArray(); + + private IReadOnlyList FindingsEndingAt(string suffix) => + Findings.Where(finding => + finding.Pointer.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)).ToArray(); + + private static bool HasErrors(IEnumerable findings) => + findings.Any(finding => finding.IsError); + + private void SetTriState( + Action assign, + int index, + PolicyEditorAdvisories.PolicyEditorRisk? risk = null) + { + if (index < 0 || index >= PolicyEditorEnumDisplay.TriStates.Length) return; + assign(PolicyEditorEnumDisplay.TriStates[index]); + if (risk is { } configuredRisk) + _sessionViewModel.MarkMatchCharacteristicConfigured(Rule, configuredRisk); + MarkDirty(); + } + + private void NotifyVersionRangePropertiesChanged() + { + OnPropertyChanged(nameof(MinVersion)); + OnPropertyChanged(nameof(MaxVersion)); + OnPropertyChanged(nameof(IncludePrerelease)); + } + + private void NotifyConstraintPropertiesChanged() + { + OnPropertyChanged(nameof(AllowInteractive)); + OnPropertyChanged(nameof(AllowSkipHashCheck)); + OnPropertyChanged(nameof(AllowPreRelease)); + OnPropertyChanged(nameof(AllowCustomInstallLocation)); + OnPropertyChanged(nameof(AllowedInstallLocationPatterns)); + OnPropertyChanged(nameof(AllowCustomParameters)); + OnPropertyChanged(nameof(AllowedCustomParameters)); + OnPropertyChanged(nameof(AllowedCustomParameterPatterns)); + OnPropertyChanged(nameof(DeniedCustomParameters)); + OnPropertyChanged(nameof(AllowPrePostCommands)); + OnPropertyChanged(nameof(AllowKillBeforeOperation)); + OnPropertyChanged(nameof(AllowUninstallPrevious)); + OnPropertyChanged(nameof(AllowUpgrade)); + } + + private PolicyEditorDraftVersionRange EnsureVersionRange() => + Rule.Match.VersionRange ??= new PolicyEditorDraftVersionRange(); + + private PolicyEditorDraftConstraints EnsureConstraints() => + Rule.Constraints ??= new PolicyEditorDraftConstraints(); + + private void MarkDirty() + { + OnPropertyChanged(nameof(IsDisabled)); + OnPropertyChanged(nameof(IsIncompleteNewRule)); + OnPropertyChanged(nameof(IsEnabledWithoutMatchConditions)); + OnPropertyChanged(nameof(CanUseSourceNames)); + OnPropertyChanged(nameof(IsSourceNamesVisible)); + OnPropertyChanged(nameof(RuleSafetyAdvisories)); + OnPropertyChanged(nameof(HasRuleSafetyAdvisories)); + OnPropertyChanged(nameof(SkipHashCheckAdvisory)); + OnPropertyChanged(nameof(HasSkipHashCheckAdvisory)); + OnPropertyChanged(nameof(SkipHashCheckMatchAdvisory)); + OnPropertyChanged(nameof(HasSkipHashCheckMatchAdvisory)); + OnPropertyChanged(nameof(CustomParametersAdvisory)); + OnPropertyChanged(nameof(HasCustomParametersAdvisory)); + OnPropertyChanged(nameof(CustomParametersMatchAdvisory)); + OnPropertyChanged(nameof(HasCustomParametersMatchAdvisory)); + OnPropertyChanged(nameof(CustomInstallLocationAdvisory)); + OnPropertyChanged(nameof(HasCustomInstallLocationAdvisory)); + OnPropertyChanged(nameof(CustomInstallLocationMatchAdvisory)); + OnPropertyChanged(nameof(HasCustomInstallLocationMatchAdvisory)); + OnPropertyChanged(nameof(PrePostCommandsAdvisory)); + OnPropertyChanged(nameof(HasPrePostCommandsAdvisory)); + OnPropertyChanged(nameof(PrePostCommandsMatchAdvisory)); + OnPropertyChanged(nameof(HasPrePostCommandsMatchAdvisory)); + OnPropertyChanged(nameof(FieldSafetyAdvisoryCount)); + OnPropertyChanged(nameof(HasFieldSafetyAdvisories)); + OnPropertyChanged(nameof(FieldSafetyAdvisoryCountText)); + _sessionViewModel.NotifyDraftChangedCommand.Execute(null); + } + + public void Dispose() + { + } + + 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", "\n", "\r"], + StringSplitOptions.RemoveEmptyEntries)); + } + + 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..e1a3d617ee --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyEditorTemplates.cs @@ -0,0 +1,87 @@ +using Devolutions.Now.Policy.Model; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages.PolicyEditor; + +/// +/// Produces the fail-closed starting point for a brand-new policy document. Everything the template +/// fixes (policy type, policy format 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 +{ + public const int ResourceIdMaxLength = 128; + + /// + /// Creates a brand-new draft document: fixed 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.IsNullOrEmpty(publisher)) + { + throw new ArgumentException("A new policy requires a non-empty publisher.", nameof(publisher)); + } + + return new PolicyEditorDraftDocument + { + PolicyFormatVersion = PolicyFormatVersion.Current, + Metadata = new PolicyEditorDraftMetadata + { + Id = id, + Publisher = publisher, + }, + Enforcement = new PolicyEditorDraftEnforcement + { + DefaultDecision = PolicyEditorPolicyContract.DefaultTemplateDecision, + }, + Rules = [], + }; + } + + public static string CreateReplacementId(string activeId) + { + if (!IsValidResourceId(activeId)) + { + throw new ArgumentException( + "The active policy identifier is not a valid resource identifier.", + nameof(activeId)); + } + + const string suffix = "-new"; + int prefixLength = Math.Min(activeId.Length, ResourceIdMaxLength - suffix.Length); + string candidate = activeId[..prefixLength] + suffix; + if (!string.Equals(candidate, activeId, StringComparison.Ordinal)) + { + return candidate; + } + + char replacement = activeId[^1] == '0' ? '1' : '0'; + return activeId[..^1] + replacement; + } + + public static bool IsValidResourceId(string? value) + { + if (string.IsNullOrEmpty(value) + || value.Length > ResourceIdMaxLength + || !IsAsciiLetterOrDigit(value[0])) + { + return false; + } + + return value.AsSpan(1).IndexOfAnyExcept( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-") < 0; + } + + private static bool IsAsciiLetterOrDigit(char value) => + value is >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or >= '0' and <= '9'; +} 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..568d336d8d --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyRuleOperations.cs @@ -0,0 +1,215 @@ +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 disabled deny rule. Every match criterion is initially unrestricted, so the rule + /// has no effect until an administrator narrows and enables it. + /// + public static PolicyEditorDraftRule CreateBlank(string? id = null) => new() + { + Id = id ?? CreateRuleId(), + Enabled = false, + Priority = 0, + Decision = Decision.Deny, + Reason = null, + Match = new PolicyEditorDraftMatch(), + Constraints = null, + }; +} + +internal static class PolicyEditorRuleSemantics +{ + private static readonly HashSet SourceCapableManagers = + [ + ManagerName.Winget, + ManagerName.PowerShell, + ManagerName.PowerShell7, + ManagerName.Chocolatey, + ManagerName.Flatpak, + ManagerName.Homebrew, + ManagerName.Scoop, + ManagerName.Vcpkg, + ]; + + public static bool HasConfiguredSafetyLimits(PolicyEditorDraftConstraints? constraints) => + constraints is not null + && (!constraints.AllowInteractive + || !constraints.AllowSkipHashCheck + || !constraints.AllowPreRelease + || !constraints.AllowCustomInstallLocation + || constraints.AllowedInstallLocationPatterns.Count > 0 + || !constraints.AllowCustomParameters + || constraints.AllowedCustomParameters.Count > 0 + || constraints.AllowedCustomParameterPatterns.Count > 0 + || constraints.DeniedCustomParameters.Count > 0 + || !constraints.AllowPrePostCommands + || !constraints.AllowKillBeforeOperation + || !constraints.AllowUninstallPrevious + || !constraints.AllowUpgrade); + + public static bool IsCatchAll(PolicyEditorDraftMatch match) => + !HasVersionCriterion(match) + && !HasPackageIdentifierCriterion(match) + && match.Operations.Count == 0 + && match.Managers.Count == 0 + && match.SourceNames.Count == 0 + && match.Scopes.Count == 0 + && match.Architectures.Count == 0 + && match.ExecutionElevation.Count == 0 + && match.Interactive == TriState.Omitted + && match.SkipHashCheck == TriState.Omitted + && match.PreRelease == TriState.Omitted + && match.HasCustomParameters == TriState.Omitted + && match.HasCustomInstallLocation == TriState.Omitted + && match.HasPrePostCommands == TriState.Omitted + && match.HasKillBeforeOperation == TriState.Omitted + && match.HasUninstallPrevious == TriState.Omitted; + + public static bool SupportsSourceNames(ManagerName manager) => + SourceCapableManagers.Contains(manager); + + public static bool HasPackageIdentifierCriterion(PolicyEditorDraftMatch match) => + match.PackageIdentifierMode switch + { + PackageIdentifierMode.Exact => match.ExactPackageIdentifiers.Count > 0, + PackageIdentifierMode.Patterns => match.PackageIdentifierPatterns.Count > 0, + _ => false, + }; + + public static bool HasVersionCriterion(PolicyEditorDraftMatch match) => + match.VersionMode switch + { + PackageVersionMode.Exact => match.ExactVersions.Count > 0, + PackageVersionMode.Range => + !string.IsNullOrEmpty(match.VersionRange?.MinVersion) + || !string.IsNullOrEmpty(match.VersionRange?.MaxVersion), + _ => false, + }; + +} + +/// +/// Pure, UI-independent mutation operations over a rule list, covering add/edit/duplicate(new +/// ID)/enable/disable/delete/reorder/priority. UI actions can target the selected rule instance so +/// temporary duplicate IDs cannot redirect an action to a different row; ID-based overloads remain +/// available for validated programmatic callers. +/// +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); + NormalizePriorities(rules); + } + + 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) + => Duplicate(rules, Find(rules, id), newId); + + public static string Duplicate( + List rules, + PolicyEditorDraftRule rule, + string? newId = null) + { + PolicyEditorDraftRule source = Find(rules, rule); + string generatedId = newId ?? PolicyRuleFactory.CreateRuleId(); + EnsureIdIsUnique(rules, generatedId); + + PolicyEditorDraftRule copy = source.CloneWithNewId(generatedId); + int index = rules.IndexOf(source); + rules.Insert(index + 1, copy); + NormalizePriorities(rules); + return generatedId; + } + + public static void SetEnabled(List rules, string id, bool enabled) => + Find(rules, id).Enabled = enabled; + + public static void SetEnabled( + List rules, + PolicyEditorDraftRule rule, + bool enabled) => + Find(rules, rule).Enabled = enabled; + + public static void Delete(List rules, string id) + { + rules.Remove(Find(rules, id)); + NormalizePriorities(rules); + } + + public static void Delete(List rules, PolicyEditorDraftRule rule) + { + rules.Remove(Find(rules, rule)); + NormalizePriorities(rules); + } + + /// 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) + => Move(rules, Find(rules, id), newIndex); + + public static void Move( + List rules, + PolicyEditorDraftRule rule, + int newIndex) + { + rule = Find(rules, rule); + int clamped = Math.Clamp(newIndex, 0, rules.Count - 1); + rules.Remove(rule); + rules.Insert(clamped, rule); + NormalizePriorities(rules); + } + + internal static void NormalizePriorities(List rules) + { + for (int index = 0; index < rules.Count; index++) + { + rules[index].Priority = checked((uint)index); + } + } + + 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."); + } + + private static PolicyEditorDraftRule Find( + List rules, + PolicyEditorDraftRule rule) + { + ArgumentNullException.ThrowIfNull(rules); + ArgumentNullException.ThrowIfNull(rule); + return rules.FirstOrDefault(candidate => ReferenceEquals(candidate, rule)) + ?? throw new KeyNotFoundException("The selected rule no longer 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..74ab4ecc5f --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/PolicyEditor/PolicyValidationFinding.cs @@ -0,0 +1,766 @@ +using System.Text; +using System.Text.Json; +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) + { + IReadOnlyDictionary arguments = + PolicyFindingPresentation.CopyArguments(finding.Arguments); + return CreateBounded(new( + finding.Path ?? "", + finding.RuleId, + MapSeverity(finding.Severity), + PolicyFindingPresentation.Describe( + finding.Code, + arguments, + finding.Message, + finding.Path, + finding.RuleId), + finding.Code, + arguments)); + } + + public static PolicyValidationFinding FromSanitized(BrokerPolicySanitizedFinding finding) + { + IReadOnlyDictionary arguments = + PolicyFindingPresentation.CopyArguments(finding.Arguments); + return CreateBounded(new( + finding.Path ?? "", + finding.RuleId, + MapSeverity(finding.Severity), + PolicyFindingPresentation.Describe( + finding.Code, + arguments, + finding.Message, + finding.Path, + finding.RuleId), + finding.Code, + 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 bool IsError => Severity == PolicyValidationSeverity.Error; + + public bool IsWarning => Severity == PolicyValidationSeverity.Warning; + + public string NavigationPointer => + PolicyFindingPresentation.GetStructuredNavigationPointer( + Code, + Arguments, + Pointer); + + public string RawNavigationPointer => + PolicyFindingPresentation.GetRawNavigationPointer( + Code, + Arguments, + Pointer); + + public string FriendlyLocation => + PolicyFindingPresentation.DescribeLocation(NavigationPointer, RuleId); + + public string ConfirmationMessage => + Code == PolicyFindingCode.SensitiveOptionAllowed + && PolicyFindingPresentation.HasKnownSensitiveOption(Arguments) + ? Message + : CoreTools.Translate("{0}: {1}", FriendlyLocation, Message); + + public bool HasRawPointer => !string.IsNullOrWhiteSpace(Pointer); + + public string AutomationName => HasRawPointer + ? CoreTools.Translate( + "{0}. Location: {1}. JSON pointer: {2}", + Message, + FriendlyLocation, + Pointer) + : CoreTools.Translate("{0}. Location: {1}", Message, FriendlyLocation); + + public bool TargetsPointer(string pointer) + { + if (string.IsNullOrEmpty(pointer) || string.IsNullOrEmpty(Pointer)) + return false; + + return NavigationPointer.Equals(pointer, StringComparison.OrdinalIgnoreCase) + || (NavigationPointer.StartsWith(pointer, StringComparison.OrdinalIgnoreCase) + && NavigationPointer.Length > pointer.Length + && NavigationPointer[pointer.Length] == '/'); + } + + private static PolicyValidationSeverity MapSeverity(PolicyFindingSeverity severity) => + severity switch + { + PolicyFindingSeverity.Warning => PolicyValidationSeverity.Warning, + PolicyFindingSeverity.Error => PolicyValidationSeverity.Error, + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null), + }; +} + +/// +/// Converts stable Agent finding codes and structured arguments into localized UI text. Generic codes +/// retain a bounded, sanitized Agent detail because that is where value/constraint specifics live. +/// +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, + string? pointer = null, + string? ruleId = null) => code switch + { + PolicyFindingCode.SchemaViolation => + CoreTools.Translate("The policy draft does not match the required JSON schema."), + PolicyFindingCode.UnknownField => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy draft contains an unknown field."), + fallbackMessage), + PolicyFindingCode.MissingRequiredField => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy draft is missing a required field."), + fallbackMessage), + PolicyFindingCode.InvalidFieldType => + DescribeWithSpecificDetail( + CoreTools.Translate("A policy field has the wrong value type."), + fallbackMessage), + PolicyFindingCode.InvalidFieldValue => + DescribeWithSpecificDetail( + CoreTools.Translate("A policy field has an invalid value."), + fallbackMessage), + PolicyFindingCode.DuplicateRuleId => + CoreTools.Translate("Rule IDs must be unique."), + PolicyFindingCode.InvalidVersionRange => + DescribeWithSpecificDetail( + CoreTools.Translate("The version range is invalid."), + fallbackMessage), + 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 => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy validity interval is invalid."), + fallbackMessage), + PolicyFindingCode.UnsupportedPolicyFormatVersion => + DescribeWithSpecificDetail( + CoreTools.Translate("The policy format version is unsupported."), + fallbackMessage), + 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, pointer, ruleId, fallbackMessage), + _ => SanitizeFallback(fallbackMessage), + }; + + public static string DescribeLocation(string? pointer, string? ruleId) + { + string sanitizedPointer = Sanitize(pointer ?? "", MaxFallbackLength); + string sanitizedRuleId = Sanitize(ruleId ?? "", MaxArgumentLength); + if (string.IsNullOrWhiteSpace(sanitizedPointer)) + return CoreTools.Translate("Policy document"); + + string[] segments = sanitizedPointer + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(DecodePointerSegment) + .ToArray(); + if (segments.Length == 2 + && segments[0].Equals("Metadata", StringComparison.OrdinalIgnoreCase) + && segments[1].Equals("Id", StringComparison.OrdinalIgnoreCase)) + { + return CoreTools.Translate("Policy ID"); + } + + var parts = new List(3); + int index = 0; + bool isRule = false; + if (segments.Length >= 2 + && segments[0].Equals("Rules", StringComparison.OrdinalIgnoreCase) + && int.TryParse(segments[1], out int ruleIndex)) + { + isRule = true; + parts.Add(string.IsNullOrWhiteSpace(sanitizedRuleId) + ? CoreTools.Translate("Rule: {0}", ruleIndex + 1) + : CoreTools.Translate("Rule: {0}", $"'{sanitizedRuleId}'")); + index = 2; + } + + for (; index < segments.Length; index++) + { + string segment = segments[index]; + if (int.TryParse(segment, out int itemIndex)) + { + parts.Add(CoreTools.Translate("Item {0}", itemIndex + 1)); + continue; + } + + string? label = isRule + && index == 2 + && segment.Equals("Id", StringComparison.OrdinalIgnoreCase) + ? CoreTools.Translate("Rule ID") + : FieldLabel(segment); + if (label is not null + && (parts.Count == 0 || !parts[^1].Equals(label, StringComparison.Ordinal))) + { + parts.Add(label); + } + } + + return parts.Count == 0 + ? CoreTools.Translate("Policy document") + : string.Join(" \u00b7 ", parts); + } + + 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; + } + + public static string GetStructuredNavigationPointer( + PolicyFindingCode? code, + IReadOnlyDictionary? arguments, + string pointer) + { + if (code != PolicyFindingCode.SensitiveOptionAllowed) + return pointer; + + string? option = ReadJsonString(arguments, "option"); + string? rulePointer = GetRulePointer(pointer); + if (rulePointer is null) + return pointer; + if (pointer.StartsWith( + $"{rulePointer}/Match/", + StringComparison.Ordinal)) + { + return pointer; + } + + return option switch + { + "SkipHashCheck" => $"{rulePointer}/Constraints/AllowSkipHashCheck", + "PreRelease" => $"{rulePointer}/Constraints/AllowPreRelease", + "AllowCustomParameters" when HasRestriction( + arguments, + "allowedCustomParameters") => + $"{rulePointer}/Constraints/AllowedCustomParameters", + "AllowCustomParameters" when HasRestriction( + arguments, + "allowedCustomParameterPatterns") => + $"{rulePointer}/Constraints/AllowedCustomParameterPatterns", + "AllowCustomParameters" => $"{rulePointer}/Constraints/AllowCustomParameters", + "AllowCustomInstallLocation" when HasRestriction( + arguments, + "allowedInstallLocationPatterns") => + $"{rulePointer}/Constraints/AllowedInstallLocationPatterns", + "AllowCustomInstallLocation" => + $"{rulePointer}/Constraints/AllowCustomInstallLocation", + "AllowPrePostCommands" => $"{rulePointer}/Constraints/AllowPrePostCommands", + "AllowKillBeforeOperation" => $"{rulePointer}/Constraints/AllowKillBeforeOperation", + "AllowUninstallPrevious" => $"{rulePointer}/Constraints/AllowUninstallPrevious", + _ => pointer, + }; + } + + public static string GetRawNavigationPointer( + PolicyFindingCode? code, + IReadOnlyDictionary? arguments, + string pointer) + { + if (code != PolicyFindingCode.SensitiveOptionAllowed + || pointer.Split('/', StringSplitOptions.RemoveEmptyEntries).Length > 2) + { + return pointer; + } + + return GetStructuredNavigationPointer(code, arguments, pointer); + } + + public static bool HasKnownSensitiveOption( + IReadOnlyDictionary? arguments) => + ReadJsonString(arguments, "option") is + "SkipHashCheck" + or "PreRelease" + or "AllowCustomParameters" + or "AllowCustomInstallLocation" + or "AllowPrePostCommands" + or "AllowKillBeforeOperation" + or "AllowUninstallPrevious"; + + private static string DescribeSensitiveOption( + IReadOnlyDictionary? arguments, + string? pointer, + string? ruleId, + string? fallbackMessage) + { + string? option = ReadJsonString(arguments, "option"); + string rule = DescribeRule(pointer, ruleId); + if (pointer?.Contains("/Match/", StringComparison.Ordinal) is true) + { + return option switch + { + "SkipHashCheck" => CoreTools.Translate( + "Skip hash check is set to Does not matter, so {0} can match requests that bypass integrity verification. Set it to No to allow only normal verification, or place an earlier Deny rule that covers this rule's scope.", + rule), + "AllowCustomParameters" => CoreTools.Translate( + "Custom parameters is set to Does not matter, so {0} can match requests with arbitrary extra options. Set it to No to allow only requests without extra options, or place an earlier Deny rule that covers this rule's scope.", + rule), + "AllowCustomInstallLocation" => CoreTools.Translate( + "Custom install location is set to Does not matter, so {0} can match requests for any custom folder. Set it to No to allow only the default location, or place an earlier Deny rule that covers this rule's scope.", + rule), + "AllowPrePostCommands" => CoreTools.Translate( + "Pre/post commands is set to Does not matter, so {0} can match requests that run arbitrary commands. Set it to No to allow only requests without pre/post commands, or place an earlier Deny rule that covers this rule's scope.", + rule), + _ => null, + } ?? SanitizeFallback(fallbackMessage); + } + string description = option switch + { + "SkipHashCheck" => CoreTools.Translate( + "{0} allows skipping hash verification.", + rule), + "PreRelease" => CoreTools.Translate( + "{0} allows prerelease packages.", + rule), + "AllowCustomInstallLocation" when HasRestriction( + arguments, + "allowedInstallLocationPatterns") => CoreTools.Translate( + "{0} allows custom installation locations within configured approved paths.", + rule), + "AllowCustomInstallLocation" => CoreTools.Translate( + "{0} allows a custom installation location without approved paths.", + rule), + "AllowCustomParameters" when HasRestriction( + arguments, + "allowedCustomParameters") + || HasRestriction(arguments, "allowedCustomParameterPatterns") => + CoreTools.Translate( + "{0} allows custom parameters subject to configured restrictions.", + rule), + "AllowCustomParameters" => CoreTools.Translate( + "{0} allows custom parameters without an allowlist.", + rule), + "AllowPrePostCommands" => CoreTools.Translate( + "{0} allows pre/post commands.", + rule), + "AllowKillBeforeOperation" => CoreTools.Translate( + "{0} allows stopping running applications.", + rule), + "AllowUninstallPrevious" => CoreTools.Translate( + "{0} allows uninstalling the previous version.", + rule), + _ => DescribeWithSpecificDetail( + CoreTools.Translate("{0} allows a sensitive option.", rule), + fallbackMessage), + }; + + 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 DescribeRule(string? pointer, string? ruleId) + { + string sanitizedRuleId = Sanitize(ruleId ?? "", MaxArgumentLength); + if (!string.IsNullOrWhiteSpace(sanitizedRuleId)) + return CoreTools.Translate("Rule “{0}”", sanitizedRuleId); + + string? rulePointer = GetRulePointer(pointer); + string[] segments = rulePointer?.Split( + '/', + StringSplitOptions.RemoveEmptyEntries) ?? []; + return segments.Length == 2 + && int.TryParse(segments[1], out int index) + ? CoreTools.Translate("Rule {0}", index + 1) + : CoreTools.Translate("An enabled Allow rule"); + } + + private static string? GetRulePointer(string? pointer) + { + string[] segments = (pointer ?? "").Split( + '/', + StringSplitOptions.RemoveEmptyEntries); + return segments.Length >= 2 + && segments[0].Equals("Rules", StringComparison.Ordinal) + && int.TryParse(segments[1], out _) + ? $"/Rules/{segments[1]}" + : null; + } + + private static bool HasRestriction( + IReadOnlyDictionary? arguments, + string key) + { + if (arguments is null + || !arguments.TryGetValue(key, out string? raw) + || string.IsNullOrWhiteSpace(raw)) + { + return false; + } + + try + { + using JsonDocument document = JsonDocument.Parse(raw); + return document.RootElement.ValueKind switch + { + JsonValueKind.Array => document.RootElement.GetArrayLength() > 0, + JsonValueKind.String => !string.IsNullOrWhiteSpace( + document.RootElement.GetString()), + _ => false, + }; + } + catch (JsonException) + { + return false; + } + } + + private static string DescribeWithSpecificDetail(string summary, string? fallbackMessage) + { + string detail = Sanitize(fallbackMessage ?? "", MaxArgumentLength); + if (string.IsNullOrWhiteSpace(detail) + || detail.Equals(summary, StringComparison.OrdinalIgnoreCase)) + { + return summary; + } + + return CoreTools.Translate("{0} Detail: {1}", summary, detail); + } + + private static string DecodePointerSegment(string segment) => + segment.Replace("~1", "/", StringComparison.Ordinal) + .Replace("~0", "~", StringComparison.Ordinal); + + private static string? FieldLabel(string segment) => + segment.ToUpperInvariant() switch + { + "$SCHEMA" => CoreTools.Translate("Schema"), + "POLICYFORMATVERSION" => CoreTools.Translate("Policy format version"), + "METADATA" => CoreTools.Translate("Metadata"), + "ID" => CoreTools.Translate("ID"), + "PUBLISHER" => CoreTools.Translate("Publisher"), + "DESCRIPTION" => CoreTools.Translate("Description"), + "SUPPORTURL" => CoreTools.Translate("Support URL"), + "VALIDFROM" => CoreTools.Translate("Valid from"), + "VALIDUNTIL" => CoreTools.Translate("Valid until"), + "ENFORCEMENT" => CoreTools.Translate("Enforcement"), + "DEFAULTDECISION" => CoreTools.Translate("Default decision"), + "AUDITMODE" => CoreTools.Translate("Audit mode"), + "RULES" => null, + "ENABLED" => CoreTools.Translate("Enabled"), + "PRIORITY" => CoreTools.Translate("Priority"), + "DECISION" => CoreTools.Translate("Decision"), + "REASON" => CoreTools.Translate("Reason"), + "MATCH" => CoreTools.Translate("Match criteria"), + "OPERATIONS" => CoreTools.Translate("Operations"), + "MANAGERS" => CoreTools.Translate("Package managers"), + "SOURCENAMES" => CoreTools.Translate("Source names"), + "PACKAGEIDENTIFIERS" => CoreTools.Translate("Package identifiers"), + "EXACT" => CoreTools.Translate("Exact values"), + "PATTERNS" => CoreTools.Translate("Patterns"), + "VERSION" => CoreTools.Translate("Package versions"), + "RANGE" => CoreTools.Translate("Semantic version range"), + "MINVERSION" => CoreTools.Translate("Minimum version"), + "MAXVERSION" => CoreTools.Translate("Maximum version"), + "INCLUDEPRERELEASE" => CoreTools.Translate("Include prerelease versions"), + "SCOPES" => CoreTools.Translate("Scopes"), + "ARCHITECTURES" => CoreTools.Translate("Architectures"), + "EXECUTIONELEVATION" => CoreTools.Translate("Execution privilege"), + "INTERACTIVE" => CoreTools.Translate("Interactive"), + "SKIPHASHCHECK" => CoreTools.Translate("Skip hash check"), + "PRERELEASE" => CoreTools.Translate("Prerelease"), + "HASCUSTOMPARAMETERS" => CoreTools.Translate("Custom parameters"), + "HASCUSTOMINSTALLLOCATION" => CoreTools.Translate("Custom install location"), + "HASPREPOSTCOMMANDS" => CoreTools.Translate("Pre/post commands"), + "HASKILLBEFOREOPERATION" => CoreTools.Translate("Stop running apps before operation"), + "HASUNINSTALLPREVIOUS" => CoreTools.Translate("Uninstall previous version"), + "CONSTRAINTS" => CoreTools.Translate("Additional safety limits"), + "ALLOWSKIPHASHCHECK" => CoreTools.Translate("Skip hash verification"), + "ALLOWPRERELEASE" => CoreTools.Translate("Prerelease packages"), + "ALLOWCUSTOMPARAMETERS" => CoreTools.Translate("Allow custom parameters"), + "ALLOWCUSTOMINSTALLLOCATION" => CoreTools.Translate("Allow custom installation location"), + "ALLOWPREPOSTCOMMANDS" => CoreTools.Translate("Allow pre/post commands"), + "ALLOWKILLBEFOREOPERATION" => CoreTools.Translate("Allow stopping running applications"), + "ALLOWUNINSTALLPREVIOUS" => CoreTools.Translate("Allow uninstalling the previous version"), + "ALLOWUPGRADE" => CoreTools.Translate("Allow upgrade"), + _ => Humanize(segment), + }; + + private static string Humanize(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return ""; + + var result = new StringBuilder(value.Length + 4); + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + if (index > 0 && char.IsUpper(character) && char.IsLower(value[index - 1])) + result.Append(' '); + result.Append(character); + } + + return Sanitize(result.ToString(), MaxArgumentLength); + } + + 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/PolicyHelp.cs b/src/UniGetUI.Avalonia/Views/Controls/PolicyHelp.cs new file mode 100644 index 0000000000..1147f0e1ca --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/PolicyHelp.cs @@ -0,0 +1,38 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Media; + +namespace UniGetUI.Avalonia.Views.Controls; + +/// Applies the same localized policy help to a wrapped tooltip and accessibility help text. +public static class PolicyHelp +{ + public static readonly AttachedProperty TextProperty = + AvaloniaProperty.RegisterAttached("Text", typeof(PolicyHelp)); + + public static void SetText(Control control, string? value) => + control.SetValue(TextProperty, value); + + public static string? GetText(Control control) => + control.GetValue(TextProperty); + + static PolicyHelp() + { + TextProperty.Changed.AddClassHandler((control, change) => + { + string? text = change.GetNewValue(); + AutomationProperties.SetHelpText(control, text); + ToolTip.SetTip( + control, + string.IsNullOrWhiteSpace(text) + ? null + : new TextBlock + { + Text = text, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 420, + }); + }); + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs b/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs new file mode 100644 index 0000000000..98738310c6 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/PolicyJsonEditor.cs @@ -0,0 +1,212 @@ +using System.Text; +using System.Text.Json; +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; + } + + public bool TryNavigateToJsonPointer(string? pointer) + { + if (!TryFindJsonPointerSelection( + Text, + pointer, + out int offset, + out int length)) + return false; + + Select(offset, length); + CaretOffset = offset; + ScrollToLine(Document.GetLineByOffset(offset).LineNumber); + return true; + } + + internal static bool TryFindJsonPointerSelection( + string json, + string? pointer, + out int characterOffset, + out int characterLength) + { + characterOffset = 0; + characterLength = 0; + string[] segments = (pointer ?? "").Split( + '/', + StringSplitOptions.RemoveEmptyEntries) + .Select(segment => segment + .Replace("~1", "/", StringComparison.Ordinal) + .Replace("~0", "~", StringComparison.Ordinal)) + .ToArray(); + if (segments.Length == 0) + return false; + + byte[] utf8 = Encoding.UTF8.GetBytes(json); + long byteOffset; + int byteLength; + try + { + var reader = new Utf8JsonReader(utf8); + if (!reader.Read() + || !TryFindProperty( + ref reader, + utf8, + segments, + 0, + out byteOffset, + out byteLength)) + { + return false; + } + } + catch (JsonException) + { + return false; + } + + characterOffset = Encoding.UTF8.GetCharCount( + utf8.AsSpan(0, checked((int)byteOffset))); + characterLength = Encoding.UTF8.GetCharCount( + utf8.AsSpan(checked((int)byteOffset), byteLength)); + return true; + } + + internal static string LastPropertySegment(string? pointer) + { + string[] segments = (pointer ?? "").Split( + '/', + StringSplitOptions.RemoveEmptyEntries); + for (int index = segments.Length - 1; index >= 0; index--) + { + if (!int.TryParse(segments[index], out _)) + { + return segments[index] + .Replace("~1", "/", StringComparison.Ordinal) + .Replace("~0", "~", StringComparison.Ordinal); + } + } + + return ""; + } + + private static bool TryFindProperty( + ref Utf8JsonReader reader, + ReadOnlySpan json, + IReadOnlyList segments, + int depth, + out long byteOffset, + out int byteLength) + { + byteOffset = 0; + byteLength = 0; + if (reader.TokenType == JsonTokenType.StartObject) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + return false; + + string property = reader.GetString() ?? ""; + long propertyOffset = reader.TokenStartIndex; + int propertyLength = GetPropertyTokenLength( + json, + checked((int)propertyOffset)); + if (!reader.Read()) + return false; + + if (depth < segments.Count + && property.Equals(segments[depth], StringComparison.Ordinal)) + { + if (depth == segments.Count - 1) + { + byteOffset = propertyOffset; + byteLength = propertyLength; + return true; + } + + if (TryFindProperty( + ref reader, + json, + segments, + depth + 1, + out byteOffset, + out byteLength)) + { + return true; + } + } + else + { + reader.Skip(); + } + } + } + else if (reader.TokenType == JsonTokenType.StartArray + && depth < segments.Count + && int.TryParse(segments[depth], out int targetIndex)) + { + int index = 0; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (index == targetIndex) + { + if (depth == segments.Count - 1) + { + byteOffset = reader.TokenStartIndex; + byteLength = Math.Max( + 1, + checked((int)( + reader.BytesConsumed + - reader.TokenStartIndex))); + return true; + } + + return TryFindProperty( + ref reader, + json, + segments, + depth + 1, + out byteOffset, + out byteLength); + } + + reader.Skip(); + index++; + } + } + + return false; + } + + private static int GetPropertyTokenLength( + ReadOnlySpan json, + int propertyStart) + { + bool escaped = false; + for (int index = propertyStart + 1; index < json.Length; index++) + { + if (!escaped && json[index] == (byte)'"') + { + return index - propertyStart + 1; + } + + escaped = !escaped && json[index] == (byte)'\\'; + } + + return 1; + } +} diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs index 5e3cd0f0dc..d49290982a 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/ImmersiveConfirmationDialog.axaml.cs @@ -66,4 +66,10 @@ private void Complete(bool result) Result = result; Close(); } + + internal void CancelPendingChoice() + { + RequireChoice = false; + Close(); + } } diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index b8060789c8..74f52d8335 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -126,7 +126,7 @@ public partial class MainWindow : Window private bool _maxButtonPressed; private TrayService? _trayService; private bool _allowClose; - private int _isQuitting; + private readonly ApplicationShutdownCoordinator _shutdownCoordinator = new(); // Saved outer size (DIPs) awaiting a native, exact restore in OnOpened on Windows. private double _pendingRestoreWidth; @@ -1939,23 +1939,28 @@ public void ShowFromTray() AvaloniaOperationRegistry.PromptPendingShortcutsIfAny(); } - public bool IsQuitting => Interlocked.CompareExchange(ref _isQuitting, 0, 0) == 1; + public bool IsQuitting => _shutdownCoordinator.IsQuitting; - public void QuitApplication() - { - if (Interlocked.Exchange(ref _isQuitting, 1) == 1) - return; + public void QuitApplication() => _ = RequestQuitApplicationAsync(); + internal Task RequestQuitApplicationAsync(Action? onAuthorized = null) => + _shutdownCoordinator.RequestAsync( + () => ViewModel.CanShutdownAsync(), + ShutdownApplicationAsync, + onAuthorized); + + private async Task ShutdownApplicationAsync() + { _allowClose = true; ReleaseWindowResources(); if (IsVisible) Hide(); - _ = QuitApplicationAsync(); + await StopAndExitApplicationAsync(); } - private static async Task QuitApplicationAsync() + private static async Task StopAndExitApplicationAsync() { Logger.Warn("Quitting UniGetUI"); try diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml index 9a52f5002c..abd9afb56b 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml @@ -90,6 +90,15 @@ Text="{t:Translate Delegate package operations to the Devolutions Agent broker}" WarningText="{t:Translate Text='When enabled, install, update and uninstall operations for supported package managers will be performed by the Devolutions Agent service instead of requesting UAC elevation. Operations will fail unless the agent is installed and running.'}" CornerRadius="8"/> + + + + CoreTools.Translate("Administrator rights and other dangerous settings"); public event EventHandler? RestartRequired; - public event EventHandler? NavigationRequested { add { } remove { } } + public event EventHandler? NavigationRequested; public Administrator() { DataContext = new AdministratorViewModel(); InitializeComponent(); VM.RestartRequired += (s, e) => RestartRequired?.Invoke(s, e); + VM.NavigationRequested += (s, e) => NavigationRequested?.Invoke(s, e); } } diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml new file mode 100644 index 0000000000..4ec08ed014 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + +