Add opt-in Unicode canonical matching to exec policy rules (#59535)

## What changed

- Support `canonical_matching=True` in `prefix_rule` and `canonical_matching = true` in managed TOML prefix rules. Apply Unicode canonical caseless matching to every prefix token, including alternatives, while keeping exact matching as the default.
- Match case and canonically equivalent Unicode spellings, such as `bash` and `bAſh`, without resolving executables on the filesystem.
- Preserve managed restrictions when checking absolute-path rule amendments, and enforce applicable `host_executable` path allowlists during basename fallback.

## Testing

Add coverage for case folding, Unicode normalization, token alternatives, exact-match defaults, and host executable allowlists. Extend managed-policy and shell-execution tests to cover canonical matching.

GitOrigin-RevId: c2f79069dc38d55c77028c457b22beb468d5c571
This commit is contained in:
Dylan Hurd
2026-11-05 00:55:46 +00:00
committed by copyberry
parent f3074e35c0
commit de6e72addd
11 changed files with 234 additions and 41 deletions
+74 -1
View File
@@ -256,6 +256,7 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("ls"),
rest: vec![PatternToken::Single(String::from("-l"))].into(),
},
@@ -317,6 +318,7 @@ prefix_rule(
vec![
RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("git"),
rest: Vec::<PatternToken>::new().into(),
},
@@ -325,6 +327,7 @@ prefix_rule(
}),
RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("git"),
rest: vec![PatternToken::Single("commit".to_string())].into(),
},
@@ -394,6 +397,7 @@ prefix_rule(
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("bash"),
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
@@ -405,6 +409,7 @@ prefix_rule(
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("sh"),
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
},
@@ -459,6 +464,7 @@ prefix_rule(
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
canonical_matching: false,
first: Arc::from("npm"),
rest: vec![
PatternToken::Alts(vec!["i".to_string(), "install".to_string()]),
@@ -810,7 +816,7 @@ fn inherited_absolute_amendments_preserve_managed_basename_restrictions() -> Res
let mut parser = PolicyParser::new();
parser.parse(
"managed.rules",
r#"prefix_rule(pattern = ["git", ["push", "send-pack"]], decision = "forbidden")"#,
r#"prefix_rule(pattern = ["git", ["push", "send-pack"]], canonical_matching=True, decision = "forbidden")"#,
)?;
let managed = parser.build();
let requirements = RequirementsExecPolicy::new(managed.clone());
@@ -819,6 +825,7 @@ fn inherited_absolute_amendments_preserve_managed_basename_restrictions() -> Res
(&["status"][..], Decision::Allow, true),
(&[], Decision::Allow, false),
(&["send-pack", "origin"], Decision::Allow, false),
(&["PUſH"], Decision::Allow, false),
(&["push"], Decision::Forbidden, true),
] {
let mut snapshot = managed.clone();
@@ -830,6 +837,20 @@ fn inherited_absolute_amendments_preserve_managed_basename_restrictions() -> Res
accepted
);
}
let path = starlark_string(&git);
for (insensitive, accepted) in [("True", true), ("False", false)] {
let mut parser = PolicyParser::new();
parser.parse("path.rules", &format!(
r#"prefix_rule(pattern=["{path}", ["push", "send-pack"]], canonical_matching={insensitive}, decision="forbidden")"#
))?;
let managed = managed.merge_overlay(&parser.build());
let mut snapshot = managed.clone();
snapshot.add_prefix_rule(std::slice::from_ref(&git), Decision::Allow)?;
assert_eq!(
snapshot.preserves_managed_requirements(&RequirementsExecPolicy::new(managed)),
accepted
);
}
Ok(())
}
@@ -877,6 +898,31 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"])
Ok(())
}
#[test]
fn canonical_host_executable_resolution_uses_only_relevant_declarations() -> Result<()> {
let bash_path = starlark_string(&host_absolute_path(&[
"usr",
"bin",
&host_executable_name("bash"),
]));
// The long s keeps the executable lookup keys distinct on Windows too.
let long_s_bash_path = starlark_string(&host_absolute_path(&[
"usr",
"bin",
&host_executable_name("baſh"),
]));
let policy_src = format!(
r#"
prefix_rule(pattern = ["bash"], canonical_matching = True, decision = "forbidden",
match = [["{bash_path}"]], not_match = [["{long_s_bash_path}"]])
host_executable(name = "bash", paths = ["{bash_path}"])
host_executable(name = "baſh", paths = ["{long_s_bash_path}"])
"#
);
PolicyParser::new().parse("test.rules", &policy_src)?;
Ok(())
}
#[test]
fn host_executable_resolution_respects_explicit_empty_allowlist() -> Result<()> {
let policy_src = r#"
@@ -1011,3 +1057,30 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
);
Ok(())
}
#[test]
fn canonical_matching_prefix_matches_each_token() -> Result<()> {
let mut parser = PolicyParser::new();
parser.parse(
"case.rules",
r#"
prefix_rule(pattern=[["Tool", "bash"], "Run", ["ONE", "two", "set"]], canonical_matching=True,
match=["TOOL rUn Two extra", "bAſh RUN one", "bash run ſet"],
not_match=["TOOL run three", "tool runner two"])
prefix_rule(pattern=["café", ["résumé", "run"]], canonical_matching=True,
match=["CAFÉ RÉSUMÉ"], not_match=["cafe resume", "café résumé"])
# Full case folding can expand one character into two, within a single token.
prefix_rule(pattern=["straße"], canonical_matching=True,
match=["STRASSE"], not_match=["STRAS SE"])
# U+0345 folds to a spacing iota. Reorder the acute accent before folding
# so it stays attached to the alpha.
prefix_rule(pattern=["α\u0345\u0301"], canonical_matching=True,
match=["άι"], not_match=["αί"])
prefix_rule(pattern=["café"], not_match=["café"])
prefix_rule(pattern=["set-content"], canonical_matching=True, not_match=["set-cøntent"])
prefix_rule(pattern=["bash"], not_match=["baſh"])
prefix_rule(pattern=["Tool", "Run"], not_match=["tool Run", "Tool run"])
"#,
)?;
Ok(())
}