diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c2ce088a6f..dfae092ab7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3439,6 +3439,7 @@ name = "codex-execpolicy" version = "0.0.0" dependencies = [ "anyhow", + "caseless", "clap", "codex-utils-absolute-path", "multimap", @@ -3450,6 +3451,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "unicode-normalization", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ee36eb6573..cbd41d41d2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -557,6 +557,7 @@ tree-sitter-powershell = "=0.26.4" ts-rs = "11" tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } uds_windows = "1.1.0" +unicode-normalization = "0.1.25" unicode-segmentation = "1.12.0" unicode-width = "0.2" url = "2" diff --git a/codex-rs/config/src/requirements_exec_policy.rs b/codex-rs/config/src/requirements_exec_policy.rs index 7576d27c18..8b228af331 100644 --- a/codex-rs/config/src/requirements_exec_policy.rs +++ b/codex-rs/config/src/requirements_exec_policy.rs @@ -21,6 +21,8 @@ pub struct RequirementsExecPolicyToml { /// This shares the builtin's matching semantics and additionally supports managed `prompt-user` decisions. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct RequirementsExecPolicyPrefixRuleToml { + #[serde(default)] + pub canonical_matching: bool, pub pattern: Vec, pub decision: Option, pub justification: Option, @@ -136,6 +138,7 @@ impl RequirementsExecPolicyToml { for head in first_token.alternatives() { let rule: RuleRef = Arc::new(PrefixRule { pattern: PrefixPattern { + canonical_matching: rule.canonical_matching, first: Arc::from(head.as_str()), rest: rest.clone(), }, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index e0e85a4d79..16811b4c11 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -4623,6 +4623,7 @@ prefix_rules = [ parsed, RequirementsExecPolicyToml { prefix_rules: vec![RequirementsExecPolicyPrefixRuleToml { + canonical_matching: false, pattern: vec![RequirementsExecPolicyPatternTokenToml { token: Some("rm".to_string()), any_of: None, @@ -4652,6 +4653,7 @@ prefix_rules = [ RequirementsExecPolicyToml { prefix_rules: vec![ RequirementsExecPolicyPrefixRuleToml { + canonical_matching: false, pattern: vec![RequirementsExecPolicyPatternTokenToml { token: Some("rm".to_string()), any_of: None, @@ -4660,6 +4662,7 @@ prefix_rules = [ justification: None, }, RequirementsExecPolicyPrefixRuleToml { + canonical_matching: false, pattern: vec![ RequirementsExecPolicyPatternTokenToml { token: Some("git".to_string()), diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 51bded91db..65895a99a5 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -638,7 +638,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { .expect("create policy directory"); fs::write( &policy_path, - r#"prefix_rule(pattern=["echo"], decision="forbidden")"#, + r#"prefix_rule(pattern=["bash"], canonical_matching=True, decision="forbidden")"#, ) .expect("write policy file"); }); @@ -647,7 +647,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { let call_id = "shell-forbidden"; let args = json!({ - "cmd": "echo blocked", + "cmd": "bAſh --version", "yield_time_ms": 1_000, }); @@ -706,7 +706,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { .function_call_output_text(call_id) .expect("forbidden command should produce a tool response"); assert!( - output.contains("policy forbids commands starting with `echo`"), + output.contains("policy forbids commands starting with `'bAſh'`"), "unexpected output: {output}" ); @@ -720,7 +720,7 @@ async fn inherited_policy_allows_remembered_commands_and_enforces_managed_denial let test = test_codex() .with_cloud_config_bundle(CloudConfigBundleFixture::loader_with_enterprise_requirement( r#"[rules] -prefix_rules = [{ pattern = [{ token = "touch" }, { token = "denied-marker" }], decision = "forbidden" }]"#, +prefix_rules = [{ pattern = [{ token = "touch" }, { token = "denied-ma\u0301rker" }], canonical_matching = true, decision = "forbidden" }]"#, )) .build_with_auto_env(&server) .await?; @@ -752,7 +752,7 @@ prefix_rules = [{ pattern = [{ token = "touch" }, { token = "denied-marker" }], sse(vec![ ev_response_created("commands"), ev_exec_command_call("allowed", "touch allowed-marker"), - ev_exec_command_call("denied", "touch denied-marker"), + ev_exec_command_call("denied", "touch Denied-MárKer"), ev_completed("commands"), ]), sse_completed("done"), @@ -784,12 +784,12 @@ prefix_rules = [{ pattern = [{ token = "touch" }, { token = "denied-marker" }], ) .await?; assert!(files.iter().any(|file| file.file_name == "allowed-marker")); - assert!(!files.iter().any(|file| file.file_name == "denied-marker")); + assert!(!files.iter().any(|file| file.file_name == "Denied-MárKer")); let denial = requests .function_call_output_text("denied") .expect("denied command output"); assert!( - denial.contains("policy forbids commands starting with `touch denied-marker`"), + denial.contains("policy forbids commands starting with `touch 'Denied-MárKer'`"), "{denial}" ); Ok(()) diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index 4204022233..4516b99cf4 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" workspace = true [dependencies] +caseless = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } @@ -29,6 +30,7 @@ starlark = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt"] } +unicode-normalization = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md index 557e46fbed..5db42fb0d6 100644 --- a/codex-rs/execpolicy/README.md +++ b/codex-rs/execpolicy/README.md @@ -40,8 +40,8 @@ host_executable( - execpolicy always tries exact first-token matches first. - With host-executable resolution disabled, `/usr/bin/git status` only matches a rule whose first token is `/usr/bin/git`. - With host-executable resolution enabled, if no exact rule matches, execpolicy may fall back from `/usr/bin/git` to basename rules for `git`. - - If `host_executable(name="git", ...)` exists, basename fallback is only allowed for listed absolute paths. - - If no `host_executable()` entry exists for a basename, basename fallback is allowed. + - A `host_executable()` declaration restricts basename fallback to its listed absolute paths. Declarations apply to the command's basename and the matching rule's program name; if both have declarations, the path must appear in both lists. + - Without an applicable declaration, basename fallback is allowed. ## CLI @@ -95,3 +95,5 @@ cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status - The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`). Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future. + +Set `canonical_matching=True` on a `prefix_rule` to use Unicode canonical caseless matching for every token in the prefix, including alternatives. For example, `["bash", "run"]` matches `bAſh RUN`, and a rule containing `café` also matches `CAFE` followed by a combining acute accent. This compares the supplied tokens without resolving the executable on the filesystem. Prefix rules match case exactly by default. diff --git a/codex-rs/execpolicy/src/parser.rs b/codex-rs/execpolicy/src/parser.rs index 33262db1b3..cdd7935d96 100644 --- a/codex-rs/execpolicy/src/parser.rs +++ b/codex-rs/execpolicy/src/parser.rs @@ -352,6 +352,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) { r#match: Option>>, not_match: Option>>, justification: Option<&'v str>, + canonical_matching: Option, eval: &mut Evaluator<'v, '_, '_>, ) -> anyhow::Result { let decision = match decision { @@ -393,6 +394,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) { .map(|head| { Arc::new(PrefixRule { pattern: PrefixPattern { + canonical_matching: canonical_matching.unwrap_or(false), first: Arc::from(head.as_str()), rest: rest.clone(), }, diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 052b30f731..206094f376 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -1,6 +1,7 @@ use crate::decision::Decision; use crate::error::Error; use crate::error::Result; +use crate::executable_name::executable_lookup_key; use crate::executable_name::executable_path_lookup_key; use crate::rule::NetworkRule; use crate::rule::NetworkRuleProtocol; @@ -9,6 +10,7 @@ use crate::rule::PrefixPattern; use crate::rule::PrefixRule; use crate::rule::RuleMatch; use crate::rule::RuleRef; +use crate::rule::canonical_key; use crate::rule::normalize_network_rule_host; use codex_utils_absolute_path::AbsolutePathBuf; use multimap::MultiMap; @@ -26,6 +28,7 @@ pub struct MatchOptions { #[derive(Clone, Debug)] pub struct Policy { + insensitive_rules: MultiMap, rules_by_program: MultiMap, network_rules: Vec, host_executables_by_name: HashMap>, @@ -78,7 +81,21 @@ impl Policy { network_rules: Vec, host_executables_by_name: HashMap>, ) -> Self { + let insensitive_rules = rules_by_program + .iter_all() + .flat_map(|(program, rules)| { + rules + .iter() + .filter(|rule| { + rule.as_any() + .downcast_ref::() + .is_some_and(|rule| rule.pattern.canonical_matching) + }) + .map(|rule| (canonical_key(program).collect::(), rule.clone())) + }) + .collect(); Self { + insensitive_rules, rules_by_program, network_rules, host_executables_by_name, @@ -132,6 +149,7 @@ impl Policy { let rule: RuleRef = Arc::new(PrefixRule { pattern: PrefixPattern { + canonical_matching: false, first: Arc::from(first_token.as_str()), rest: rest .iter() @@ -195,6 +213,31 @@ impl Policy { let Ok(path) = AbsolutePathBuf::try_from(program.as_str()) else { continue; }; + // A differently cased path can shadow a managed basename at that spelling. + if rules.iter().any(|rule| { + rule.as_any() + .downcast_ref::() + .is_some_and(|rule| rule.pattern.canonical_matching) + }) && let Some(name) = executable_path_lookup_key(path.as_path()) + && managed.rules().iter_all().any(|(program, fallbacks)| { + canonical_key(program).eq(canonical_key(&name)) + && rules + .iter() + .filter(|rule| { + rule.as_any() + .downcast_ref::() + .is_some_and(|prefix| prefix.pattern.canonical_matching) + && !managed.contains_prefix_rule(rule.program(), rule) + }) + .any(|rule| { + fallbacks + .iter() + .any(|fallback| can_weaken_fallback(rule, fallback, rules)) + }) + }) + { + return false; + } if let Some((_, fallback_rules)) = managed.host_executable_rules(&path) && rules.iter().any(|rule| { !managed.contains_prefix_rule(program, rule) @@ -383,13 +426,32 @@ impl Policy { fn match_exact_rules(&self, cmd: &[String]) -> Option> { let first = cmd.first()?; Some( - self.rules_by_program - .get_vec(first) - .map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect()) - .unwrap_or_default(), + self.candidates(first) + .into_iter() + .filter_map(|rule| rule.matches(cmd)) + .collect(), ) } + fn candidates(&self, program: &str) -> Vec<&RuleRef> { + let mut candidates: Vec<_> = self + .rules_by_program + .get_vec(program) + .into_iter() + .flatten() + .collect(); + if !self.insensitive_rules.is_empty() { + candidates.extend( + self.insensitive_rules + .get_vec(&canonical_key(program).collect::()) + .into_iter() + .flatten() + .filter(|rule| rule.program() != program), + ); + } + candidates + } + fn match_host_executable_rules(&self, cmd: &[String]) -> Vec { let Some(first) = cmd.first() else { return Vec::new(); @@ -411,14 +473,22 @@ impl Policy { .collect() } - fn host_executable_rules(&self, path: &AbsolutePathBuf) -> Option<(String, &[RuleRef])> { + fn host_executable_rules(&self, path: &AbsolutePathBuf) -> Option<(String, Vec<&RuleRef>)> { let basename = executable_path_lookup_key(path.as_path())?; if let Some(paths) = self.host_executables_by_name.get(&basename) && !paths.contains(path) { return None; } - let rules = self.rules_by_program.get_vec(&basename)?; + let mut rules = self.candidates(&basename); + // Canonical rules can use a different name, whose declaration also restricts the path. + rules.retain(|rule| { + rule.program() == basename.as_str() + || self + .host_executables_by_name + .get(&executable_lookup_key(rule.program())) + .is_none_or(|paths| paths.contains(path)) + }); Some((basename, rules)) } } @@ -433,31 +503,53 @@ fn can_weaken_fallback(exact: &RuleRef, fallback: &RuleRef, path_rules: &[RuleRe if exact.decision >= fallback.decision { return false; } - let (mut shorter, mut longer) = (&exact.pattern.rest, &fallback.pattern.rest); - if shorter.len() > longer.len() { - std::mem::swap(&mut shorter, &mut longer); - } - // The overlapping commands use arguments allowed by both prefixes where both apply. - let shared_arguments_at = |i: usize| { - longer[i].alternatives().iter().filter(move |arg| { - shorter - .get(i) - .is_none_or(|token| token.alternatives().contains(arg)) + let fallback_decision = fallback.decision; + let exact = &exact.pattern; + let fallback = &fallback.pattern; + let length = exact.rest.len().max(fallback.rest.len()); + // Where both rules constrain an argument, use the case-sensitive spellings + // if available. A missing token leaves the other rule's alternatives unrestricted. + let overlap_at = |i: usize| { + let (source, filter) = if i >= exact.rest.len() + || (i < fallback.rest.len() && exact.canonical_matching && !fallback.canonical_matching) + { + (fallback, exact) + } else { + (exact, fallback) + }; + source.rest[i].alternatives().iter().filter_map(move |arg| { + let matches = filter.rest.get(i).is_none_or(|token| { + token + .alternatives() + .iter() + .any(|alt| filter.tokens_equal(alt, arg)) + }); + matches.then_some((arg, source.canonical_matching)) }) }; - if (0..shorter.len()).any(|i| shared_arguments_at(i).next().is_none()) { + if (0..length).any(|i| overlap_at(i).next().is_none()) { return false; } - // Exact matches take the strongest decision. A shorter or equal exact rule - // can therefore preserve the fallback for every command in the overlap. + // A protecting exact rule must cover every spelling in the overlap. !path_rules.iter().any(|rule| { let Some(rule) = rule.as_any().downcast_ref::() else { return false; }; - rule.decision >= fallback.decision - && rule.pattern.rest.len() <= longer.len() + rule.decision >= fallback_decision + && (!exact.canonical_matching || rule.pattern.canonical_matching) + && rule.pattern.rest.len() <= length && rule.pattern.rest.iter().enumerate().all(|(i, token)| { - shared_arguments_at(i).all(|arg| token.alternatives().contains(arg)) + overlap_at(i).all(|(arg, insensitive)| { + // For a folded overlap, an exact spelling alone proves coverage + // only for ASCII tokens without letters. + let needs_fold = insensitive + && (!arg.is_ascii() || arg.bytes().any(|b| b.is_ascii_alphabetic())); + (!needs_fold || rule.pattern.canonical_matching) + && token + .alternatives() + .iter() + .any(|alt| rule.pattern.tokens_equal(alt, arg)) + }) }) }) } diff --git a/codex-rs/execpolicy/src/rule.rs b/codex-rs/execpolicy/src/rule.rs index 6642cb1d9c..92d94baab1 100644 --- a/codex-rs/execpolicy/src/rule.rs +++ b/codex-rs/execpolicy/src/rule.rs @@ -3,6 +3,7 @@ use crate::error::Error; use crate::error::Result; use crate::policy::MatchOptions; use crate::policy::Policy; +use caseless::Caseless; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -10,6 +11,12 @@ use shlex::try_join; use std::any::Any; use std::fmt::Debug; use std::sync::Arc; +use unicode_normalization::UnicodeNormalization; + +/// Unicode canonical caseless comparison key: NFD, full case folding, then NFD. +pub(crate) fn canonical_key(token: &str) -> impl Iterator + '_ { + token.nfd().default_case_fold().nfd() +} /// Matches a single command token, either a fixed string or one of several allowed alternatives. #[derive(Clone, Debug, Eq, PartialEq)] @@ -19,13 +26,6 @@ pub enum PatternToken { } impl PatternToken { - fn matches(&self, token: &str) -> bool { - match self { - Self::Single(expected) => expected == token, - Self::Alts(alternatives) => alternatives.iter().any(|alt| alt == token), - } - } - pub fn alternatives(&self) -> &[String] { match self { Self::Single(expected) => std::slice::from_ref(expected), @@ -38,19 +38,32 @@ impl PatternToken { /// First token is fixed since we key by the first token in policy. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PrefixPattern { + pub canonical_matching: bool, pub first: Arc, pub rest: Arc<[PatternToken]>, } impl PrefixPattern { + pub(crate) fn tokens_equal(&self, a: &str, b: &str) -> bool { + if self.canonical_matching { + canonical_key(a).eq(canonical_key(b)) + } else { + a == b + } + } + pub fn matches_prefix(&self, cmd: &[String]) -> Option> { let pattern_length = self.rest.len() + 1; - if cmd.len() < pattern_length || cmd[0] != self.first.as_ref() { + if cmd.len() < pattern_length || !self.tokens_equal(&cmd[0], &self.first) { return None; } for (pattern_token, cmd_token) in self.rest.iter().zip(&cmd[1..pattern_length]) { - if !pattern_token.matches(cmd_token) { + if !pattern_token + .alternatives() + .iter() + .any(|alt| self.tokens_equal(alt, cmd_token)) + { return None; } } diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs index 07d0d1fb54..9750895843 100644 --- a/codex-rs/execpolicy/tests/basic.rs +++ b/codex-rs/execpolicy/tests/basic.rs @@ -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::::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(()) +}