mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -352,6 +352,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
r#match: Option<UnpackList<Value<'v>>>,
|
||||
not_match: Option<UnpackList<Value<'v>>>,
|
||||
justification: Option<&'v str>,
|
||||
canonical_matching: Option<bool>,
|
||||
eval: &mut Evaluator<'v, '_, '_>,
|
||||
) -> anyhow::Result<NoneType> {
|
||||
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(),
|
||||
},
|
||||
|
||||
@@ -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<String, RuleRef>,
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
network_rules: Vec<NetworkRule>,
|
||||
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
|
||||
@@ -78,7 +81,21 @@ impl Policy {
|
||||
network_rules: Vec<NetworkRule>,
|
||||
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
|
||||
) -> Self {
|
||||
let insensitive_rules = rules_by_program
|
||||
.iter_all()
|
||||
.flat_map(|(program, rules)| {
|
||||
rules
|
||||
.iter()
|
||||
.filter(|rule| {
|
||||
rule.as_any()
|
||||
.downcast_ref::<PrefixRule>()
|
||||
.is_some_and(|rule| rule.pattern.canonical_matching)
|
||||
})
|
||||
.map(|rule| (canonical_key(program).collect::<String>(), 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::<PrefixRule>()
|
||||
.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::<PrefixRule>()
|
||||
.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<Vec<RuleMatch>> {
|
||||
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::<String>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|rule| rule.program() != program),
|
||||
);
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn match_host_executable_rules(&self, cmd: &[String]) -> Vec<RuleMatch> {
|
||||
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::<PrefixRule>() 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Item = char> + '_ {
|
||||
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<str>,
|
||||
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<Vec<String>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user