diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 87550f9d6..a381ea780 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -887,6 +887,9 @@ jobs:
$DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe"
echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT
+ $DAgentPolicyConsentHelper = Join-Path $TargetOutputPath "DevolutionsAgentPolicyConsent.exe"
+ echo "dagent-policy-consent-helper=$DAgentPolicyConsentHelper" >> $Env:GITHUB_OUTPUT
+
$DAgentUpdaterExecutable = Join-Path $TargetOutputPath "DevolutionsAgentUpdater.exe"
echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT
}
@@ -1052,6 +1055,28 @@ jobs:
DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }}
TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }}
+ - name: Build NativeAOT policy consent helper
+ if: ${{ matrix.os == 'windows' }}
+ run: |
+ $Rid = "win-${{ matrix.arch }}"
+ $Output = Split-Path -Parent '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}'
+ dotnet publish package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj `
+ --configuration Release `
+ --runtime $Rid `
+ --output $Output `
+ -p:Version=${{ needs.preflight.outputs.version }}
+ if ($LASTEXITCODE -ne 0) {
+ exit $LASTEXITCODE
+ }
+ $Helper = '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}'
+ if (-Not (Test-Path -LiteralPath $Helper -PathType Leaf)) {
+ throw "NativeAOT policy consent helper was not produced"
+ }
+ if ((Get-Item -LiteralPath $Helper).Length -gt 8MB) {
+ throw "NativeAOT policy consent helper exceeds 8 MiB"
+ }
+ shell: pwsh
+
- name: Package
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
run: |
@@ -1065,6 +1090,7 @@ jobs:
$Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}"
$Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}"
$Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}"
+ $Env:DAGENT_POLICY_CONSENT_HELPER = "${{ steps.load-variables.outputs.dagent-policy-consent-helper }}"
$Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}"
$Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}"
$Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}"
@@ -1186,6 +1212,11 @@ jobs:
run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj
shell: pwsh
+ - name: Policy consent helper tests
+ run: dotnet test package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj -c Release
+ shell: pwsh
+
+
winapi-sanitizer-tests:
name: Windows API sanitizer tests
runs-on: windows-2022
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index 27ee014f9..af56e0b9f 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -322,7 +322,7 @@ jobs:
run: |
$IncludePattern = @(switch ('${{ matrix.project }}') {
'devolutions-gateway' { @('DevolutionsGateway.exe') }
- 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') }
+ 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsAgentPolicyConsent.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') }
'jetsocat' { @('jetsocat.exe', 'jetsocat') }
})
$ExcludePattern = "*.pdb"
@@ -495,6 +495,7 @@ jobs:
$Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' -File | Select-Object -First 1
$Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' -File | Select-Object -First 1
$Env:DAGENT_SESSION_EXECUTABLE = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' -File | Select-Object -First 1
+ $Env:DAGENT_POLICY_CONSENT_HELPER = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' -File | Select-Object -First 1
$Env:DAGENT_TUN2SOCKS_EXE = Join-Path $ArchRoot 'tun2socks.exe'
$Env:DAGENT_WINTUN_DLL = Join-Path $ArchRoot 'wintun.dll'
$MultiPwshDirectory = Join-Path $Env:RUNNER_TEMP 'multi-pwsh' 'windows' $Arch
@@ -508,6 +509,7 @@ jobs:
Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}"
Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}"
Write-Host "DAGENT_SESSION_EXECUTABLE = ${Env:DAGENT_SESSION_EXECUTABLE}"
+ Write-Host "DAGENT_POLICY_CONSENT_HELPER = ${Env:DAGENT_POLICY_CONSENT_HELPER}"
Write-Host "DAGENT_TUN2SOCKS_EXE = ${Env:DAGENT_TUN2SOCKS_EXE}"
Write-Host "DAGENT_WINTUN_DLL = ${Env:DAGENT_WINTUN_DLL}"
Write-Host "DAGENT_MULTI_PWSH_EXECUTABLE = ${Env:DAGENT_MULTI_PWSH_EXECUTABLE}"
@@ -534,7 +536,8 @@ jobs:
@((Join-Path $ArchRoot DesktopAgent),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' | Select-Object -First 1),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' | Select-Object -First 1),
- (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1)) | ForEach-Object {
+ (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1),
+ (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' | Select-Object -First 1)) | ForEach-Object {
Remove-Item $_ -Recurse -ErrorAction SilentlyContinue | Out-Null
}
}
diff --git a/Cargo.lock b/Cargo.lock
index 0cc021554..74e389f1e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1791,6 +1791,7 @@ dependencies = [
"uuid",
"win-api-wrappers",
"windows 0.61.3",
+ "windows-registry 0.5.3",
"x509-parser",
]
@@ -4843,6 +4844,7 @@ dependencies = [
"tokio-util",
"tower-service",
"tracing",
+ "unicode-normalization",
"uuid",
"widestring 1.2.1",
"win-api-wrappers",
diff --git a/ci/README.md b/ci/README.md
index 264a9500d..bac41e802 100644
--- a/ci/README.md
+++ b/ci/README.md
@@ -14,14 +14,32 @@ This folder contains PowerShell scripts for CI, building, and packaging.
| Gateway | Windows (regular) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1` |
| Gateway | Windows (assembled) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1 -Generate`
`package-assembled.ps1 gateway` |
| Gateway | Linux | `build.ps1 gateway`
`package-gateway-linux.ps1` (not available yet) |
-| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1` |
-| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1 -Generate`
`package-assembled.ps1 agent` |
+| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1` with the arguments below |
+| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate` with the arguments below
`package-assembled.ps1 agent` |
| Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`
Jetsocat is not packaged. |
| Session | Windows/macOS/Linux | `build.ps1 session`
Session is not packaged. |
| PEDM module | Windows | `build.ps1 pedm` |
| PowerShell module | Windows | `copy-ps-module.ps1` |
| Desktop Agent | Windows | `..\dotnet\DesktopAgent\build.ps1` |
+## Agent Windows package arguments
+
+Pass every staged artifact to `package-agent-windows.ps1`.
+
+```powershell
+.\package-agent-windows.ps1 `
+ -Exe `
+ -UpdaterExe `
+ -PedmDll `
+ -PedmMsix `
+ -SessionExe `
+ -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe `
+ -Architecture x64 `
+ -Outfile
+```
+
+For an assembled package, replace `-Outfile ` with `-Generate`.
+
## What is the difference between _Windows (regular)_ and _Windows (assembled)_?
_Windows (regular)_ is the "normal" build process where the MSI is built by WiX but not signed. This is used in _ci.yaml_. _Windows (assembled)_ is a two-step process where the `-Generate` flag is used to build supporting files for the MSI, including DLLs, language transforms, and _cmd_ scripts. The MSI is assembled in second step using _package-assembled.ps1_. The two-step approach is described [here](https://github.com/oleg-shilo/wixsharp/wiki/Developer's-Guide#compiling-wix-project).
diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1
index 4a37cc512..bb97553a5 100644
--- a/ci/package-agent-windows.ps1
+++ b/ci/package-agent-windows.ps1
@@ -11,6 +11,8 @@ param(
[parameter(Mandatory = $true)]
[string] $SessionExe,
[parameter(Mandatory = $true)]
+ [string] $PolicyConsentHelper,
+ [parameter(Mandatory = $true)]
[ValidateSet('x64', 'arm64')]
[string] $Architecture,
[string] $Outfile
@@ -43,8 +45,9 @@ function Set-FileNameAndCopy {
# If the name is already correct, return the original path without copying
if ($currName -ieq $NewName) {
- Write-Host "Using $Path without copying"
- return $Path
+ $resolvedPath = (Resolve-Path -LiteralPath $Path).Path
+ Write-Host "Using $resolvedPath without copying"
+ return $resolvedPath
}
# Copy to a temporary directory.
@@ -98,6 +101,9 @@ function New-AgentMsi() {
# The path to the devolutions-session.exe file.
[string] $SessionExe,
[parameter(Mandatory = $true)]
+ # The path to the signed DevolutionsAgentPolicyConsent.exe file.
+ [string] $PolicyConsentHelper,
+ [parameter(Mandatory = $true)]
[ValidateSet('x64', 'arm64')]
# Architecture: x64 or arm64
[string] $Architecture,
@@ -120,6 +126,7 @@ function New-AgentMsi() {
$PedmDll = Convert-Path -Path $PedmDll
$PedmMsix = Convert-Path -Path $PedmMsix
$SessionExe = Convert-Path -Path $SessionExe
+ $PolicyConsentHelper = Convert-Path -Path $PolicyConsentHelper
if ($Outfile) {
$Outfile = Convert-Path -Path $Outfile
}
@@ -137,6 +144,7 @@ function New-AgentMsi() {
$myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'DevolutionsAgentUpdater.exe'
# The session is a service that gets launched on demand.
$mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe'
+ $myPolicyConsentHelper = Set-FileNameAndCopy -Path $PolicyConsentHelper -NewName 'DevolutionsAgentPolicyConsent.exe'
Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe"
@@ -145,6 +153,7 @@ function New-AgentMsi() {
Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll
Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix
Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe
+ Set-EnvVarPath 'DAGENT_POLICY_CONSENT_HELPER' $myPolicyConsentHelper
# The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`.
# After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`.
@@ -184,4 +193,4 @@ function New-AgentMsi() {
Pop-Location
}
-New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile
+New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -PolicyConsentHelper $PolicyConsentHelper -Architecture $Architecture -Outfile $Outfile
diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml
index fca797a41..5b407647b 100644
--- a/crates/now-package-broker/Cargo.toml
+++ b/crates/now-package-broker/Cargo.toml
@@ -49,6 +49,7 @@ tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parki
tokio-util = "0.7"
tower-service = "0.3"
tracing = "0.1"
+unicode-normalization = "0.1"
uuid = { version = "1.23", features = ["v4"] }
widestring = "1.2"
win-api-wrappers = { path = "../win-api-wrappers" }
diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs
index d647e750c..f293a0306 100644
--- a/crates/now-package-broker/src/auth.rs
+++ b/crates/now-package-broker/src/auth.rs
@@ -32,6 +32,7 @@ use windows::Win32::System::Threading::{
use crate::policy_security::RetainedExecutableSecurity;
const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000);
+const POLICY_CONSENT_HELPER_NAME: &str = "DevolutionsAgentPolicyConsent.exe";
const PROCESS_IDENTITY_ACCESS: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(
PROCESS_QUERY_INFORMATION.0 | PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_VM_READ.0 | PROCESS_SYNCHRONIZE.0,
);
@@ -289,6 +290,20 @@ impl PipeClient {
Ok(())
}
+ pub(crate) fn validate_policy_write(&self, skip_signature_validation: bool) -> anyhow::Result<()> {
+ self.validate_connection(skip_signature_validation)?;
+ // Dev builds cannot enforce helper identity when signature validation is explicitly disabled.
+ if signature_validation_skipped(skip_signature_validation) {
+ return Ok(());
+ }
+ let agent = std::env::current_exe().context("failed to query Agent executable path")?;
+ let executable_file = self
+ .executable_file
+ .as_deref()
+ .context("policy consent helper executable handle is not retained")?;
+ Self::validate_policy_consent_helper_path(&self.executable_path, executable_file, &agent)
+ }
+
fn validate_process_instance(&self) -> anyhow::Result<()> {
let Some(process) = &self.process else {
return Ok(());
@@ -304,6 +319,29 @@ impl PipeClient {
)
}
+ fn validate_policy_consent_helper_path(client: &Path, client_file: &File, agent: &Path) -> anyhow::Result<()> {
+ if !client
+ .file_name()
+ .is_some_and(|name| name.eq_ignore_ascii_case(POLICY_CONSENT_HELPER_NAME))
+ {
+ bail!("policy replacement requires the Agent policy consent helper");
+ }
+ let expected = agent
+ .parent()
+ .context("Agent executable has no installation directory")?
+ .join(POLICY_CONSENT_HELPER_NAME);
+ if !crate::policy_security::windows_paths_equal(client, &expected) {
+ bail!("policy consent helper is not the installed Agent helper path");
+ }
+ let expected_id = file_id(&expected).context("failed to query installed policy consent helper identity")?;
+ let retained_id =
+ file_id_from_handle(client_file).context("failed to query retained policy consent helper identity")?;
+ if !same_file(&expected_id, &retained_id) {
+ bail!("policy consent helper does not match the installed helper");
+ }
+ Ok(())
+ }
+
/// Validate that the request's `effective_user` denotes the authenticated pipe client user.
///
/// The name is resolved to a SID and compared against the SID captured at connect,
@@ -539,6 +577,51 @@ mod tests {
.expect_err("a recycled PID with a different creation time must be rejected");
}
+ #[test]
+ fn policy_consent_helper_requires_exact_agent_sibling_path() {
+ let current_executable = std::env::current_exe().expect("current executable");
+ let current_file = open_executable_file(¤t_executable).expect("open current executable");
+ let agent = Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe");
+ assert!(
+ PipeClient::validate_policy_consent_helper_path(
+ Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgentPolicyConsent.exe"),
+ ¤t_file,
+ agent,
+ )
+ .is_err(),
+ "path text alone must not authorize a different retained image"
+ );
+ assert!(
+ PipeClient::validate_policy_consent_helper_path(
+ Path::new(r"C:\Users\Alice\DevolutionsAgentPolicyConsent.exe"),
+ ¤t_file,
+ agent,
+ )
+ .is_err()
+ );
+ assert!(
+ PipeClient::validate_policy_consent_helper_path(
+ Path::new(r"C:\Users\Alice\UniGetUI.exe"),
+ ¤t_file,
+ agent,
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn policy_consent_helper_accepts_exact_retained_sibling() {
+ let temp = tempfile::tempdir().expect("temp directory");
+ let agent = temp.path().join("DevolutionsAgent.exe");
+ let helper = temp.path().join(POLICY_CONSENT_HELPER_NAME);
+ std::fs::write(&agent, b"agent path anchor").expect("write Agent path anchor");
+ std::fs::copy(std::env::current_exe().expect("current executable"), &helper).expect("copy helper fixture");
+ let retained = open_executable_file(&helper).expect("retain helper fixture");
+
+ PipeClient::validate_policy_consent_helper_path(&helper, &retained, &agent)
+ .expect("exact retained sibling must be accepted");
+ }
+
#[test]
fn exited_process_cannot_supply_executable_identity() {
let mut child = std::process::Command::new("powershell.exe")
diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs
index 4ee5301a9..3bd0dbf80 100644
--- a/crates/now-package-broker/src/evaluator/matching.rs
+++ b/crates/now-package-broker/src/evaluator/matching.rs
@@ -9,7 +9,7 @@ use now_policy_api::PackageRequest;
use super::RequestFlags;
use super::constraints::constraints_pass;
-use super::wildcard::wildcard_any;
+use super::wildcard::{literal_case_insensitive_match, wildcard_any};
pub(super) fn rule_matches(
rule: &PolicyRule,
@@ -21,7 +21,7 @@ pub(super) fn rule_matches(
operations_match(request.operation, &m.operations)
&& managers_match(request.manager, &m.managers)
- && source_names_match(&request.source.name, &m.source_names)
+ && source_names_match(request.manager, &request.source.name, &m.source_names)
&& package_identifiers_match(&request.package.id, &m.package_identifiers)
&& versions_match(effective_version, &m.version)
&& scopes_match(request.options.scope, &m.scopes)
@@ -129,8 +129,18 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool {
- allowed.is_empty() || allowed.iter().any(|source| source.as_ref().eq_ignore_ascii_case(value))
+fn source_names_match(
+ manager: now_policy_api::ManagerName,
+ value: &str,
+ allowed: &BTreeSet,
+) -> bool {
+ allowed.is_empty()
+ || allowed.iter().any(|source| match manager {
+ now_policy_api::ManagerName::PowerShell | now_policy_api::ManagerName::PowerShell7 => {
+ literal_case_insensitive_match(value, source.as_ref())
+ }
+ _ => source.as_ref().eq_ignore_ascii_case(value),
+ })
}
fn package_identifiers_match(
@@ -262,6 +272,20 @@ mod tests {
}));
}
+ #[test]
+ fn non_powershell_source_names_remain_canonically_distinct() {
+ let mut request = request();
+ request.manager = api::ManagerName::Scoop;
+ request.source.name = "CO\u{0308}RP".to_owned();
+ let flags = RequestFlags::from_request(&request);
+ let rule = rule(PolicyMatch {
+ source_names: BTreeSet::from([now_policy::SourceName::parse("CÖRP").expect("valid source")]),
+ ..Default::default()
+ });
+
+ assert!(!rule_matches(&rule, &request, &flags, "1.2.3"));
+ }
+
#[test]
fn absent_scope_or_architecture_in_request_fails_when_rule_restricts_them() {
let mut request = request();
diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs
index d0376caf8..729c8abc6 100644
--- a/crates/now-package-broker/src/evaluator/mod.rs
+++ b/crates/now-package-broker/src/evaluator/mod.rs
@@ -113,6 +113,12 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci
}
}
+/// Whether a source spelling has a stable identity across package-manager lookup and
+/// policy evaluation.
+pub(crate) fn source_name_is_unambiguous(source_name: &str) -> bool {
+ source_name == source_name.trim() && !wildcard::has_default_ignorable_code_point(source_name)
+}
+
pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation {
if request.options.scope == Some(Scope::Machine) || request.client.requested_elevation == Elevation::Elevated {
Elevation::Elevated
diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs
index 8b6776a08..3e845a5e3 100644
--- a/crates/now-package-broker/src/evaluator/tests.rs
+++ b/crates/now-package-broker/src/evaluator/tests.rs
@@ -5,11 +5,11 @@ use std::collections::BTreeSet;
use chrono::Utc;
use now_policy::{
Decision, PackageIdentifier, PackageIdentifierCondition, PolicyDocument, PolicyEnforcement, PolicyFormatVersion,
- PolicyMatch, PolicyMetadata, PolicyRule, ResourceId,
+ PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, SourceName,
};
use now_policy_api::{self as api, PackageRequest};
-use super::evaluate;
+use super::{evaluate, source_name_is_unambiguous};
fn make_policy(default_decision: Decision, rules: Vec) -> PolicyDocument {
PolicyDocument {
@@ -128,6 +128,52 @@ fn deny_unmatched_package() {
assert_eq!(result.rule_id, "");
}
+#[test]
+fn unicode_case_equivalent_source_deny_outranks_allow() {
+ let policy = make_policy(
+ Decision::Deny,
+ vec![
+ PolicyRule {
+ id: ResourceId::from("allow-any"),
+ enabled: true,
+ priority: 100,
+ decision: Decision::Allow,
+ reason: None,
+ match_criteria: PolicyMatch::default(),
+ constraints: None,
+ },
+ PolicyRule {
+ id: ResourceId::from("deny-corp"),
+ enabled: true,
+ priority: 100,
+ decision: Decision::Deny,
+ reason: None,
+ match_criteria: PolicyMatch {
+ source_names: BTreeSet::from([SourceName::parse("CÖRP").expect("valid source")]),
+ ..Default::default()
+ },
+ constraints: None,
+ },
+ ],
+ );
+ let mut request = make_request(api::Operation::Install, "Example.Package");
+ request.manager = api::ManagerName::PowerShell;
+ request.source.name = "cörp".to_owned();
+
+ let result = evaluate(&policy, &request);
+
+ assert_eq!(result.decision, Decision::Deny);
+ assert_eq!(result.rule_id, "deny-corp");
+}
+
+#[test]
+fn default_ignorable_source_spelling_is_rejected_before_evaluation() {
+ assert!(!source_name_is_unambiguous("PS\u{00AD}Gallery"));
+ assert!(!source_name_is_unambiguous("PSGallery "));
+ assert!(!source_name_is_unambiguous(" PSGallery"));
+ assert!(source_name_is_unambiguous("PSGallery"));
+}
+
#[test]
fn disabled_rules_are_ignored() {
let policy = make_policy(
diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs
index 68e99df85..fc586e9a8 100644
--- a/crates/now-package-broker/src/evaluator/wildcard.rs
+++ b/crates/now-package-broker/src/evaluator/wildcard.rs
@@ -1,6 +1,13 @@
//! Case-insensitive wildcard matching helpers.
use std::collections::BTreeSet;
+use std::sync::LazyLock;
+
+use unicode_normalization::UnicodeNormalization as _;
+use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal};
+
+static DEFAULT_IGNORABLE_CODE_POINT: LazyLock =
+ LazyLock::new(|| regex::Regex::new(r"\p{Default_Ignorable_Code_Point}").expect("valid Unicode property regex"));
pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet) -> bool {
patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref()))
@@ -10,6 +17,27 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo
patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref()))
}
+/// Match an exact source name using the PowerShell repository identity semantics.
+///
+/// PowerShell resolves repository names after canonical Unicode normalization with
+/// ordinal case-insensitive comparison.
+/// Source names are literals, so this deliberately does not apply wildcard semantics.
+pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> bool {
+ let value: Vec = value.nfc().collect::().encode_utf16().collect();
+ let expected: Vec = expected.nfc().collect::().encode_utf16().collect();
+
+ // SAFETY: The binding marshals both valid UTF-8 strings as bounded UTF-16.
+ unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL }
+}
+
+/// Default-ignorable characters are rejected before matching and command building.
+///
+/// PowerShell repository lookup ignores them, while an opaque package request would
+/// otherwise preserve them for `-Repository` and make policy identity ambiguous.
+pub(super) fn has_default_ignorable_code_point(value: &str) -> bool {
+ DEFAULT_IGNORABLE_CODE_POINT.is_match(value)
+}
+
fn wildcard_match(value: &str, pattern: &str) -> bool {
// Convert glob pattern to regex: escape everything except *, which becomes .*
let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*"));
@@ -47,4 +75,17 @@ mod tests {
assert!(wildcard_any("Contoso.Tools+", &patterns));
assert!(!wildcard_any("Contoso.Toolss", &patterns));
}
+
+ #[test]
+ fn literal_match_uses_unicode_case_insensitive_semantics() {
+ assert!(literal_case_insensitive_match("CO\u{0308}RP", "CÖRP"));
+ assert!(!literal_case_insensitive_match("P\u{017F}Gallery", "PSGallery"));
+ assert!(!literal_case_insensitive_match("cörp", "CÖRP*"));
+ }
+
+ #[test]
+ fn default_ignorable_source_characters_are_detected() {
+ assert!(has_default_ignorable_code_point("PS\u{00AD}Gallery"));
+ assert!(!has_default_ignorable_code_point("CÖRP"));
+ }
}
diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs
index 6a4c51b00..3ebe2c346 100644
--- a/crates/now-package-broker/src/pipe.rs
+++ b/crates/now-package-broker/src/pipe.rs
@@ -20,7 +20,7 @@ use windows::Win32::Security::Authorization::SET_ACCESS;
use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_GENERIC_WRITE};
use crate::auth::PipeClient;
-use crate::server::{BrokerState, build_router_for_client, serve_connection};
+use crate::server::{BrokerState, build_router_for_client_with_policy_write_deadline, serve_connection};
/// Default pipe name for the package broker.
pub const DEFAULT_PIPE_NAME: &str = r"\\.\pipe\Devolutions.Now.PackageBroker.v1";
@@ -36,12 +36,16 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16;
/// Deadline for serving a single pipe connection, from accept to response completion.
///
/// Each connection serves exactly one HTTP request (`keep_alive` is disabled) and all
-/// endpoints respond without blocking on package operations (execution is asynchronous,
-/// tracked via the operation tracker), so a healthy exchange completes well within this
-/// deadline. Without it, idle clients holding their connection open without sending a
-/// request would each pin a connection slot indefinitely and could exhaust the pool.
+/// ordinary endpoints respond without blocking on package operations (execution is
+/// asynchronous and tracked via the operation tracker). Without this deadline, idle
+/// clients holding their connection open without sending a request would each pin a
+/// connection slot indefinitely and could exhaust the pool.
const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
+/// The bounded policy-replacement exchange lifetime used only after the exact installed
+/// consent helper has passed write authorization.
+const POLICY_CONSENT_CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120);
+
/// Start the named pipe server and accept connections until shutdown.
pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> {
let pipe_name = state.pipe_name.clone();
@@ -72,40 +76,65 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke
match result {
Ok(()) => {
let state = Arc::clone(&state);
+ let connection_deadline = tokio::time::Instant::now() + CONNECTION_DEADLINE;
tokio::spawn(async move {
- let serve = async move {
- // Keep blocking unauthenticated capture off the accept loop and
- // retain the connection slot until the work actually completes.
- let capture = spawn_bounded_capture(permit, move || {
- let client = PipeClient::from_connected_pipe(&server);
- (server, client)
- });
- let (_permit, server, client) = match capture.await {
- Ok((permit, (server, Ok(client)))) => (permit, server, client),
- Ok((_permit, (_server, Err(error)))) => {
- warn!(error = format!("{error:#}"), "Rejected named pipe client");
- return;
- }
- Err(error) => {
- error!(
- error = format!("{error:#}"),
- "Named pipe client identity capture task failed"
- );
- return;
- }
- };
-
- info!("Client connected to named pipe");
- let router = build_router_for_client(state, client);
- serve_connection(server, router).await;
- info!("Client disconnected from named pipe");
+ // Keep blocking unauthenticated capture off the accept loop and
+ // retain the connection slot until the work actually completes.
+ let capture = spawn_bounded_capture(permit, move || {
+ let client = PipeClient::from_connected_pipe(&server);
+ (server, client)
+ });
+ let (_permit, server, client) =
+ match tokio::time::timeout_at(connection_deadline, capture).await {
+ Ok(Ok((permit, (server, Ok(client))))) => (permit, server, client),
+ Ok(Ok((_permit, (_server, Err(error))))) => {
+ warn!(error = format!("{error:#}"), "Rejected named pipe client");
+ return;
+ }
+ Ok(Err(error)) => {
+ error!(
+ error = format!("{error:#}"),
+ "Named pipe client identity capture task failed"
+ );
+ return;
+ }
+ Err(_) => {
+ warn!("Closed named pipe connection: client identity capture deadline exceeded");
+ return;
+ }
};
- // Enforce a deadline so idle or slow clients cannot pin
- // a connection slot indefinitely.
- if tokio::time::timeout(CONNECTION_DEADLINE, serve).await.is_err() {
- warn!("Closed named pipe connection: deadline exceeded");
+ info!("Client connected to named pipe");
+ let (policy_write_deadline, mut policy_write_authorized) = tokio::sync::watch::channel(false);
+ let router = build_router_for_client_with_policy_write_deadline(
+ state,
+ client,
+ policy_write_deadline,
+ );
+ let serve = serve_connection(server, router);
+ tokio::pin!(serve);
+ let mut policy_write_is_authorized = false;
+ let mut deadline = connection_deadline;
+ loop {
+ tokio::select! {
+ () = &mut serve => break,
+ () = tokio::time::sleep_until(deadline) => {
+ if policy_write_is_authorized {
+ warn!("Closed named pipe policy replacement: deadline exceeded");
+ } else {
+ warn!("Closed named pipe connection: deadline exceeded");
+ }
+ break;
+ }
+ result = policy_write_authorized.changed(), if !policy_write_is_authorized => {
+ if result.is_ok() && *policy_write_authorized.borrow_and_update() {
+ policy_write_is_authorized = true;
+ deadline = tokio::time::Instant::now() + POLICY_CONSENT_CONNECTION_DEADLINE;
+ }
+ }
+ }
}
+ info!("Client disconnected from named pipe");
});
}
Err(error) => {
@@ -200,6 +229,12 @@ mod tests {
use super::*;
+ #[test]
+ fn connection_deadlines_preserve_short_untrusted_and_long_authorized_bounds() {
+ assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(30));
+ assert_eq!(POLICY_CONSENT_CONNECTION_DEADLINE, Duration::from_secs(120));
+ }
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn timed_out_capture_keeps_its_permit_until_blocking_work_finishes() {
let permits = Arc::new(Semaphore::new(1));
diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs
index 2f0771679..c24cf8da7 100644
--- a/crates/now-package-broker/src/policy_store/validation.rs
+++ b/crates/now-package-broker/src/policy_store/validation.rs
@@ -10,6 +10,8 @@ use now_policy_api::{
API_VERSION_STR, PolicyFinding, PolicyFindingCode, PolicyFindingSeverity, PolicyValidationResult,
};
+use crate::evaluator;
+
pub(super) const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/10";
const MAX_RULES: usize = 1024;
const MAX_RULE_PRIORITY: u32 = i32::MAX as u32;
@@ -419,6 +421,17 @@ fn check_rule(index: usize, rule: &PolicyRule, findings: &mut Findings) {
if let Some(reason) = &rule.reason {
check_string_len(reason, 0, 512, &format!("{base}/Reason"), findings);
}
+ for (source_index, source_name) in rule.match_criteria.source_names.iter().enumerate() {
+ if !evaluator::source_name_is_unambiguous(source_name.as_ref()) {
+ findings.push(rule_finding(
+ rule,
+ PolicyFindingSeverity::Error,
+ PolicyFindingCode::InvalidFieldValue,
+ format!("{base}/Match/SourceNames/{source_index}"),
+ "SourceNames must not contain leading, trailing, or default-ignorable characters",
+ ));
+ }
+ }
if let Some(PackageIdentifierCondition::Patterns(patterns)) = &rule.match_criteria.package_identifiers {
check_patterns(
index,
@@ -709,6 +722,34 @@ mod tests {
assert_eq!(canonical.pointer("/Rules/0/Match/Interactive"), Some(&json!(false)));
}
+ #[test]
+ fn source_names_reject_ambiguous_spellings() {
+ for source_name in ["PSGallery ", " PSGallery", "PS\u{00AD}Gallery"] {
+ let mut raw = draft();
+ raw["Rules"] = json!([rule(
+ "deny",
+ json!({ "Managers": ["PowerShell"], "SourceNames": [source_name] })
+ )]);
+
+ let result = validate_draft(&raw);
+
+ assert!(!result.is_valid, "{source_name:?} must be rejected");
+ assert!(
+ result
+ .findings
+ .iter()
+ .any(|finding| finding.path == "/Rules/0/Match/SourceNames/0")
+ );
+ }
+
+ let mut valid = draft();
+ valid["Rules"] = json!([rule(
+ "deny",
+ json!({ "Managers": ["PowerShell"], "SourceNames": ["PSGallery"] })
+ )]);
+ assert!(validate_draft(&valid).is_valid);
+ }
+
#[test]
fn shared_contract_rejects_invalid_rule_shapes() {
let cases = [
diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs
index 972029cf2..52b802742 100644
--- a/crates/now-package-broker/src/policy_store/windows.rs
+++ b/crates/now-package-broker/src/policy_store/windows.rs
@@ -34,10 +34,11 @@ use windows::Win32::Storage::FileSystem::{
CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT,
FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE,
FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_DISPOSITION_INFO_EX_FLAGS,
- FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_GENERIC_READ,
- FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE,
- FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo,
- FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle,
+ FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_DELETE_ON_CLOSE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH,
+ FILE_GENERIC_READ, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0,
+ FILE_SHARE_DELETE, FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx,
+ FileRenameInfo, FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL,
+ SetFileInformationByHandle,
};
#[cfg(test)]
use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
@@ -544,6 +545,11 @@ impl std::error::Error for UnsupportedAtomicSemantics {}
/// Filesystem names known to support atomic same-directory handle renames.
/// Conservative by design: an unrecognized filesystem is treated as unsupported.
const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"];
+const PROBE_SOURCE_NAME: &str = ".package-broker-write-probe-a.tmp";
+const PROBE_TARGET_NAME: &str = ".package-broker-write-probe-b.tmp";
+const PROBE_TOMBSTONE_NAME: &str = ".package-broker-write-probe-old.tmp";
+const PROBE_SOURCE_CONTENT: &[u8] = b"probe-source";
+const PROBE_TARGET_CONTENT: &[u8] = b"probe-target";
/// Verifies that `dir` supports the handle-based tombstone and create-new publication semantics required by [`atomic_replace`].
///
@@ -559,11 +565,12 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> {
}
let dir_handle = open_directory_no_reparse(dir)?;
- let source_path = dir.join(".package-broker-write-probe-a.tmp");
- let target_path = dir.join(".package-broker-write-probe-b.tmp");
- let tombstone_path = dir.join(".package-broker-write-probe-old.tmp");
- let source = create_probe_file(&source_path, b"probe-source", false)?;
- let target = match create_probe_file(&target_path, b"probe-target", true) {
+ recover_interrupted_write_capability_probe(&dir_handle, dir)?;
+ let source_path = dir.join(PROBE_SOURCE_NAME);
+ let target_path = dir.join(PROBE_TARGET_NAME);
+ let tombstone_path = dir.join(PROBE_TOMBSTONE_NAME);
+ let source = create_probe_file(&source_path, PROBE_SOURCE_CONTENT, false)?;
+ let target = match create_probe_file(&target_path, PROBE_TARGET_CONTENT, true) {
Ok(target) => target,
Err(error) => {
return match cleanup_probe_file(source, &source_path, "write-capability probe source") {
@@ -595,7 +602,7 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> {
source_published = true;
let replaced = std::fs::read(&target_path).context("read write-capability probe result")?;
ensure!(
- replaced == b"probe-source",
+ replaced == PROBE_SOURCE_CONTENT,
"atomic replacement did not take effect on this filesystem"
);
delete_file_handle(&target).context("probe POSIX tombstone unlink")?;
@@ -620,6 +627,59 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> {
probe_result.and(source_cleanup).and(target_cleanup)
}
+/// Retire a trusted remnant from a capability probe interrupted before its
+/// delete-on-close handles were released.
+///
+/// The fixed names are only reclaimable when the entry is a non-reparse,
+/// single-link, managed-policy file containing one of the probe's exact payloads.
+/// This preserves fail-closed collision handling for untrusted lookalikes.
+fn recover_interrupted_write_capability_probe(dir: &File, dir_path: &Path) -> anyhow::Result<()> {
+ verify_directory_handle_type(dir, "write-capability probe directory")?;
+
+ for (name, expected_contents) in [
+ (PROBE_SOURCE_NAME, &[PROBE_SOURCE_CONTENT][..]),
+ (PROBE_TARGET_NAME, &[PROBE_TARGET_CONTENT, PROBE_SOURCE_CONTENT][..]),
+ (PROBE_TOMBSTONE_NAME, &[PROBE_TARGET_CONTENT][..]),
+ ] {
+ let path = dir_path.join(name);
+ let file = match OpenOptions::new()
+ .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0)
+ .share_mode(FILE_SHARE_READ.0)
+ .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0)
+ .open(&path)
+ {
+ Ok(file) => file,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(error) => {
+ return Err(error)
+ .with_context(|| format!("failed to open interrupted write probe {}", path.display()));
+ }
+ };
+
+ policy_security::verify_policy_file_path(&file, &path)
+ .with_context(|| format!("interrupted write probe {} is unsafe", path.display()))?;
+ policy_security::verify_managed_policy_file_security(&file)
+ .with_context(|| format!("interrupted write probe {} has unsafe security", path.display()))?;
+ ensure!(
+ policy_security::file_link_count(&file)? == 1,
+ "interrupted write probe {} has multiple hard links",
+ path.display()
+ );
+ let content = read_file_from_start(&file)
+ .with_context(|| format!("failed to read interrupted write probe {}", path.display()))?;
+ ensure!(
+ expected_contents.contains(&content.as_slice()),
+ "interrupted write probe {} has unexpected content",
+ path.display()
+ );
+ delete_file_handle(&file)
+ .with_context(|| format!("failed to retire interrupted write probe {}", path.display()))?;
+ drop(file);
+ ensure_path_absent(&path, "interrupted write probe")?;
+ }
+ Ok(())
+}
+
fn verify_no_replace_collision(
source: &File,
target: &File,
@@ -775,7 +835,7 @@ fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> any
}
.0,
)
- .custom_flags((FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0)
+ .custom_flags((FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0)
.open(path)
.with_context(|| format!("failed to create write-capability probe {}", path.display()))?;
if let Err(error) = file
@@ -4148,7 +4208,7 @@ mod tests {
// ─── probe_write_capability / volume_filesystem_name ──────────────────────
//
- // No elevation required: these never touch `admin_only_security_attributes`.
+ // No elevation required: ordinary probes use inherited directory security.
#[test]
fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() {
@@ -4171,6 +4231,54 @@ mod tests {
assert!(leftover.is_empty(), "probe left files behind: {leftover:?}");
}
+ #[test]
+ fn probe_file_is_removed_when_its_handle_closes() {
+ let dir = temp_dir();
+ let path = dir.path().join(PROBE_SOURCE_NAME);
+ let file = create_probe_file(&path, PROBE_SOURCE_CONTENT, false).unwrap();
+
+ drop(file);
+
+ assert!(!path.exists(), "delete-on-close probe file survived its handle");
+ }
+
+ #[test]
+ fn interrupted_trusted_probe_remnant_is_recovered() {
+ use std::io::Write as _;
+
+ let dir = temp_dir();
+ let dir_file = open_directory_no_reparse(dir.path()).unwrap();
+ let path = dir.path().join(PROBE_SOURCE_NAME);
+ let mut file = match create_secure_transaction_file(&path) {
+ Ok(file) => file,
+ Err(error) => {
+ tracing::warn!(
+ error = %format!("{error:#}"),
+ "Skipping administrator-owned probe recovery fixture"
+ );
+ return;
+ }
+ };
+ file.write_all(PROBE_SOURCE_CONTENT).unwrap();
+ file.sync_all().unwrap();
+ drop(file);
+
+ recover_interrupted_write_capability_probe(&dir_file, dir.path()).unwrap();
+
+ assert!(!path.exists(), "trusted interrupted probe remnant was not retired");
+ }
+
+ #[test]
+ fn interrupted_untrusted_probe_remnant_is_not_removed() {
+ let dir = temp_dir();
+ let dir_file = open_directory_no_reparse(dir.path()).unwrap();
+ let path = dir.path().join(PROBE_SOURCE_NAME);
+ std::fs::write(&path, PROBE_SOURCE_CONTENT).unwrap();
+
+ assert!(recover_interrupted_write_capability_probe(&dir_file, dir.path()).is_err());
+ assert!(path.exists(), "untrusted probe collision must remain fail-closed");
+ }
+
#[test]
fn occupied_no_replace_probe_preserves_both_retained_files() {
let dir = temp_dir();
diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs
index 6ace82de8..9d5fcef8c 100644
--- a/crates/now-package-broker/src/server/mod.rs
+++ b/crates/now-package-broker/src/server/mod.rs
@@ -26,6 +26,7 @@ use now_policy_api::{
use now_policy_server_template::{
MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer,
};
+use tokio::sync::watch;
use tracing::{info, trace, warn};
use win_api_wrappers::identity::sid::Sid;
@@ -112,7 +113,25 @@ struct EvaluatedRequest {
}
/// Build the axum router for a single authenticated pipe client.
+#[cfg(test)]
pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router {
+ build_router_for_client_with_optional_policy_write_deadline(state, client, None)
+}
+
+/// Build the router and signal when the exact helper policy-write authorization completes.
+pub(crate) fn build_router_for_client_with_policy_write_deadline(
+ state: Arc,
+ client: PipeClient,
+ policy_write_deadline: watch::Sender,
+) -> axum::Router {
+ build_router_for_client_with_optional_policy_write_deadline(state, client, Some(policy_write_deadline))
+}
+
+fn build_router_for_client_with_optional_policy_write_deadline(
+ state: Arc,
+ client: PipeClient,
+ policy_write_deadline: Option>,
+) -> axum::Router {
let server: SharedPackageBrokerServer = Arc::new(BrokerConnection {
state: Arc::clone(&state),
client: client.clone(),
@@ -120,6 +139,7 @@ pub(crate) fn build_router_for_client(state: Arc, client: PipeClien
axum::Router::from(now_policy_server_template::api_router_from_shared(server))
.layer(middleware::from_fn(reject_duplicate_policy_json_members))
.layer(middleware::from_fn_with_state(state, authenticate_policy_management))
+ .layer(Extension(policy_write_deadline))
.layer(Extension(client))
}
@@ -292,6 +312,7 @@ fn reject_duplicate_json_members(bytes: &[u8]) -> Result<(), serde_json::Error>
async fn authenticate_policy_management(
State(state): State>,
Extension(client): Extension,
+ Extension(policy_write_deadline): Extension