pub fn matches(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star, mut mark) = (usize::MAX, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = pi; mark = ti; pi += 1; } else if star != usize::MAX {
pi = star + 1; mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literal_match_is_exact_and_anchored() {
assert!(matches("rm -rf /tmp", "rm -rf /tmp"));
assert!(!matches("rm -rf /tmp", "rm -rf /tmp "));
assert!(!matches("rm -rf", "rm -rf /tmp"));
assert!(!matches("rf /tmp", "rm -rf /tmp"));
}
#[test]
fn anchoring_prd_example() {
assert!(matches("rm -rf /*", "rm -rf /tmp"));
assert!(!matches("rm -rf /*", "sudo rm -rf /tmp"));
assert!(matches("*rm -rf*", "rm -rf /tmp"));
assert!(matches("*rm -rf*", "sudo rm -rf /tmp"));
}
#[test]
fn star_matches_empty_sequence() {
assert!(matches("*", ""));
assert!(matches("a*", "a"));
assert!(matches("*a", "a"));
assert!(matches("a*b", "ab"));
assert!(matches("**", ""));
assert!(matches("a**b", "ab"));
assert!(matches("a**", "a"));
}
#[test]
fn star_crosses_slashes_and_spaces() {
assert!(matches("curl*|*sh", "curl https://x.example/a/b | sh"));
assert!(matches("*/etc/*", "cat /etc/shadow"));
}
#[test]
fn question_mark_matches_exactly_one_char() {
assert!(matches("a?c", "abc"));
assert!(!matches("a?c", "ac")); assert!(!matches("a?c", "abbc")); assert!(matches("???", "abc"));
assert!(!matches("???", "ab"));
}
#[test]
fn brackets_dots_and_backslashes_are_literals() {
assert!(matches("[a-z]", "[a-z]"));
assert!(!matches("[a-z]", "b"));
assert!(matches("a.c", "a.c"));
assert!(!matches("a.c", "abc")); assert!(matches("a\\*b", "a\\zzb")); assert!(!matches("a\\*b", "a*b")); }
#[test]
fn matching_is_case_sensitive() {
assert!(!matches("rm -rf /*", "RM -RF /tmp"));
assert!(!matches("*BASH*", "bash"));
}
#[test]
fn question_mark_matches_one_multibyte_char_not_one_byte() {
assert!(matches("caf?", "café")); assert!(!matches("caf??", "café"));
assert!(matches("?", "🦀")); assert!(!matches("??", "🦀"));
assert!(matches("rm ?", "rm 日"));
}
#[test]
fn empty_pattern_matches_only_empty_text() {
assert!(matches("", ""));
assert!(!matches("", "x"));
assert!(!matches("x", ""));
assert!(!matches("?", ""));
}
#[test]
fn adversarial_pattern_does_not_blow_up() {
let pattern = "*a*a*a*a*a*a*b";
let text = "a".repeat(40);
let start = std::time::Instant::now();
assert!(!matches(pattern, &text));
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"matcher took {:?} — the backtracking guard is gone",
start.elapsed()
);
let long = "a".repeat(4_000);
let start = std::time::Instant::now();
assert!(!matches(pattern, &long));
assert!(start.elapsed() < std::time::Duration::from_secs(1));
}
#[test]
fn backtracking_finds_a_late_match() {
assert!(matches("*ab", "aaab"));
assert!(matches("*a*b", "aaaxb"));
assert!(matches("a*b*c", "axxbyyc"));
assert!(!matches("a*b*c", "axxbyy"));
}
#[test]
fn mixed_metacharacters() {
assert!(matches("*rm -rf /?", "sudo rm -rf /x"));
assert!(!matches("*rm -rf /?", "sudo rm -rf /"));
assert!(matches(
"git push*--force*",
"git push origin main --force-with-lease"
));
}
}