Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions crates/agent-policy-tester/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@ async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Resu
let validation = validation_response.json()?["Validation"].clone();
ensure!(validation["IsValid"] == true, "policy validation failed");
ensure!(
validation["ValidatorVersion"] == "now-package-broker-policy-validator/10",
validation["ValidatorVersion"] == "now-package-broker-policy-validator/11",
"unexpected validator contract"
);
ensure!(
Expand Down Expand Up @@ -1018,7 +1018,7 @@ async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str
ensure!(
invalid_validation.get("CanonicalDraft").is_none()
&& invalid_validation.get("ValidationReceipt").is_none()
&& invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/10",
&& invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/11",
"invalid draft returned a canonical draft, receipt, or wrong validator version"
);
ensure!(
Expand Down
1 change: 1 addition & 0 deletions crates/now-package-broker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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" }
Expand Down
28 changes: 21 additions & 7 deletions crates/now-package-broker/src/command_builder/powershell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@
//!
//! The Windows executor materializes these scripts into temporary `.ps1` files before launch.
//!
//! Both builders bind the policy-matched request source as the PowerShell repository.
//! Both builders bind the policy-matched request source as the PowerShell repository
//! and pin the invariant culture so repository lookup does not depend on the host locale.
//! Options that don't apply to PowerShell modules (interactive, custom install
//! location, winget's no-upgrade / uninstall-previous) are intentionally omitted.

use anyhow::bail;
use now_policy_api::{Operation, PackageRequest, Scope};

/// Pins culture-sensitive lookups, including `-Repository` name matching, to the
/// invariant culture instead of the host locale.
const INVARIANT_CULTURE_PREFIX: &str =
"[System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture;";

/// Build a Windows PowerShell 5.x (PowerShellGet) command from a validated request.
pub fn build_powershell5_command(request: &PackageRequest) -> anyhow::Result<Vec<String>> {
validate_powershell_request(request)?;

let mut script = String::new();
let mut script = INVARIANT_CULTURE_PREFIX.to_owned();

let verb = match request.operation {
Operation::Install => "Install-Module",
Expand Down Expand Up @@ -67,7 +73,7 @@ pub fn build_powershell5_command(request: &PackageRequest) -> anyhow::Result<Vec
pub fn build_powershell7_command(request: &PackageRequest) -> anyhow::Result<Vec<String>> {
validate_powershell_request(request)?;

let mut script = String::new();
let mut script = INVARIANT_CULTURE_PREFIX.to_owned();

let verb = match request.operation {
Operation::Install => "Install-PSResource",
Expand Down Expand Up @@ -223,7 +229,9 @@ mod tests {
let script = script_of(&cmd);
assert_eq!(cmd[0], "powershell.exe");
// verb, then -Name <id>, -Confirm:$false, -Force.
assert!(script.starts_with("Install-Module -Name 'Pester' -Repository 'PSGallery' -Confirm:$false -Force"));
assert!(script.starts_with(&format!(
"{INVARIANT_CULTURE_PREFIX} Install-Module -Name 'Pester' -Repository 'PSGallery' -Confirm:$false -Force"
)));
assert!(script.contains("-Scope CurrentUser"));
assert!(script.contains("-RequiredVersion '5.6.0'"));
}
Expand Down Expand Up @@ -254,7 +262,9 @@ mod tests {
request.operation = Operation::Uninstall;
let cmd = build_powershell5_command(&request).expect("build command");
let script = script_of(&cmd);
assert!(script.starts_with("Uninstall-Module -Name 'Pester' -Repository 'PSGallery'"));
assert!(script.starts_with(&format!(
"{INVARIANT_CULTURE_PREFIX} Uninstall-Module -Name 'Pester' -Repository 'PSGallery'"
)));
assert!(!script.contains("-Scope"));
assert!(!script.contains("-RequiredVersion"));
assert!(!script.contains("-SkipPublisherCheck"));
Expand All @@ -266,7 +276,9 @@ mod tests {
let cmd = build_powershell7_command(&request).expect("build command");
let script = script_of(&cmd);
assert_eq!(cmd[0], "pwsh.exe");
assert!(script.starts_with("Install-PSResource -Name 'Pester' -Repository 'PSGallery' -Confirm:$false"));
assert!(script.starts_with(&format!(
"{INVARIANT_CULTURE_PREFIX} Install-PSResource -Name 'Pester' -Repository 'PSGallery' -Confirm:$false"
)));
assert!(script.contains("-Version '5.6.0'"));
assert!(script.contains("-TrustRepository"));
assert!(script.contains("-AcceptLicense"));
Expand All @@ -291,7 +303,9 @@ mod tests {
request.operation = Operation::Uninstall;
let cmd = build_powershell7_command(&request).expect("build command");
let script = script_of(&cmd);
assert!(script.starts_with("Uninstall-PSResource -Name 'Pester' -Repository 'PSGallery' -Confirm:$false"));
assert!(script.starts_with(&format!(
"{INVARIANT_CULTURE_PREFIX} Uninstall-PSResource -Name 'Pester' -Repository 'PSGallery' -Confirm:$false"
)));
// Uninstall must not pin a version (it would remove only the matching version,
// or fail if it does not match what is installed).
assert!(!script.contains("-Version"));
Expand Down
33 changes: 29 additions & 4 deletions crates/now-package-broker/src/evaluator/matching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -129,8 +129,19 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet<Elev
allowed.is_empty() || allowed.contains(&policy_elevation(elevation))
}

fn source_names_match(value: &str, allowed: &BTreeSet<now_policy::SourceName>) -> 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<now_policy::SourceName>,
) -> bool {
allowed.is_empty()
|| allowed.iter().any(|source| {
if super::is_powershell_manager(manager) {
literal_case_insensitive_match(value, source.as_ref())
} else {
source.as_ref().eq_ignore_ascii_case(value)
}
})
}

fn package_identifiers_match(
Expand Down Expand Up @@ -262,6 +273,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();
Expand Down
25 changes: 25 additions & 0 deletions crates/now-package-broker/src/evaluator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ mod matching;
mod version;
mod wildcard;

pub(crate) use wildcard::has_powershell_wildcard_syntax;

#[cfg(test)]
mod tests;

Expand Down Expand Up @@ -113,6 +115,29 @@ 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)
Comment thread
CBenoit marked this conversation as resolved.
}

/// Whether `manager` resolves a source spelling to exactly the literal repository name
/// that policy evaluation matched.
///
/// PowerShell resolves `-Repository` through `WildcardPattern`, so a spelling with
/// wildcard syntax could select repositories that policy evaluation never matched.
pub(crate) fn source_name_is_unambiguous_for_manager(manager: now_policy_api::ManagerName, source_name: &str) -> bool {
source_name_is_unambiguous(source_name)
&& !(is_powershell_manager(manager) && has_powershell_wildcard_syntax(source_name))
}

pub(crate) fn is_powershell_manager(manager: now_policy_api::ManagerName) -> bool {
matches!(
manager,
now_policy_api::ManagerName::PowerShell | now_policy_api::ManagerName::PowerShell7
)
}

pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation {
if request.options.scope == Some(Scope::Machine) || request.client.requested_elevation == Elevation::Elevated {
Elevation::Elevated
Expand Down
72 changes: 70 additions & 2 deletions crates/now-package-broker/src/evaluator/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, source_name_is_unambiguous_for_manager};

fn make_policy(default_decision: Decision, rules: Vec<PolicyRule>) -> PolicyDocument {
PolicyDocument {
Expand Down Expand Up @@ -128,6 +128,74 @@ fn deny_unmatched_package() {
assert_eq!(result.rule_id, "<default>");
}

#[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 powershell_wildcard_source_spelling_is_rejected_before_evaluation() {
for source_name in ["Corp*", "Corp?", "Corp[1]", "Corp`*"] {
assert!(!source_name_is_unambiguous_for_manager(
api::ManagerName::PowerShell,
source_name
));
assert!(!source_name_is_unambiguous_for_manager(
api::ManagerName::PowerShell7,
source_name
));
assert!(source_name_is_unambiguous_for_manager(
api::ManagerName::Winget,
source_name
));
}
assert!(source_name_is_unambiguous_for_manager(
api::ManagerName::PowerShell7,
"PSGallery"
));
}

#[test]
fn disabled_rules_are_ignored() {
let policy = make_policy(
Expand Down
50 changes: 50 additions & 0 deletions crates/now-package-broker/src/evaluator/wildcard.rs
Original file line number Diff line number Diff line change
@@ -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<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\p{Default_Ignorable_Code_Point}").expect("valid Unicode property regex"));

pub(super) fn wildcard_any<S: AsRef<str>>(value: &str, patterns: &BTreeSet<S>) -> bool {
patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref()))
Expand All @@ -10,6 +17,36 @@ pub(super) fn wildcard_any_vec<S: AsRef<str>>(value: &str, patterns: &[S]) -> bo
patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref()))
}

/// Match an exact source name using PowerShell repository identity semantics.
///
/// PowerShell compares literal repository names case-insensitively with the current
/// culture, which the broker pins to the invariant culture.
/// This approximates that comparison with canonical (NFC) normalization and ordinal
/// case-insensitive comparison.
/// Residual collation differences only involve repositories registered in the
/// requesting user's own repository store, which that user can already repoint.
/// 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<u16> = value.nfc().collect::<String>().encode_utf16().collect();
let expected: Vec<u16> = expected.nfc().collect::<String>().encode_utf16().collect();

// SAFETY: Both slices contain valid, initialized UTF-16 code units.
unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL }
Comment thread
CBenoit marked this conversation as resolved.
}

/// 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)
}

/// Characters that PowerShell `WildcardPattern` interprets as wildcard or escape syntax.
pub(crate) fn has_powershell_wildcard_syntax(value: &str) -> bool {
value.contains(['*', '?', '[', ']', '`'])
}

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"\*", ".*"));
Expand Down Expand Up @@ -47,4 +84,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"));
}
}
3 changes: 2 additions & 1 deletion crates/now-package-broker/src/pipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ async fn accept_connections(
match result {
Ok(()) => {
let state = Arc::clone(state);
let connection_deadline = tokio::time::Instant::now() + CONNECTION_DEADLINE;
connections.spawn(async move {
// Serving this connection can commit a policy and record its terminal
// event, which is blocking work the shutdown cannot interrupt, so the
Expand Down Expand Up @@ -139,7 +140,7 @@ async fn accept_connections(

// Enforce a deadline so idle or slow clients cannot pin
// a connection slot indefinitely.
if tokio::time::timeout(CONNECTION_DEADLINE, serve).await.is_err() {
if tokio::time::timeout_at(connection_deadline, serve).await.is_err() {
warn!("Closed named pipe connection: deadline exceeded");
}
});
Expand Down
2 changes: 1 addition & 1 deletion crates/now-package-broker/src/policy_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1145,7 +1145,7 @@ mod storage_tests {
assert_eq!(error.code, ErrorCode::ValidationFailed);

let validation = store.validate_draft(&request.draft);
assert_eq!(validation.validator_version, "now-package-broker-policy-validator/10");
assert_eq!(validation.validator_version, "now-package-broker-policy-validator/11");
let canonical = validation.canonical_draft.as_ref().expect("compatible draft");
let old_receipt =
store
Expand Down
Loading
Loading