Skip to main content

nodedb_types/
ascii.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Byte-stable ASCII matching for untrusted text.
4//!
5//! These helpers never allocate or case-transform the input. They compare only
6//! ASCII needles, so every returned offset and suffix addresses the unchanged
7//! original UTF-8 string safely. Non-ASCII needles are deliberately rejected:
8//! Unicode case folding can expand one character into multiple bytes and must
9//! not be used to calculate byte offsets into the original input.
10
11/// Return whether `text` starts with the ASCII `prefix`, ignoring ASCII case.
12///
13/// An empty prefix matches. Non-ASCII prefixes never match.
14pub fn starts_with_ascii_case_insensitive(text: &str, prefix: &str) -> bool {
15    prefix.is_ascii()
16        && text.len() >= prefix.len()
17        && text.as_bytes()[..prefix.len()]
18            .iter()
19            .zip(prefix.as_bytes())
20            .all(|(left, right)| left.eq_ignore_ascii_case(right))
21}
22
23/// Strip an ASCII `prefix` from `text`, ignoring ASCII case.
24///
25/// The returned suffix is always a slice of the unchanged original input. An
26/// empty prefix returns the original string. Non-ASCII prefixes return `None`.
27pub fn strip_prefix_ascii_case_insensitive<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
28    starts_with_ascii_case_insensitive(text, prefix).then(|| &text[prefix.len()..])
29}
30
31/// Find an ASCII `needle` in `text` without allocating a case-transformed
32/// copy. The returned byte offset always belongs to `text`.
33pub fn find_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
34    find_ascii_case_insensitive_from(text, needle, 0)
35}
36
37/// Find the last ASCII `needle` in `text` without allocating a case-transformed
38/// copy. The returned byte offset always belongs to `text`.
39pub fn rfind_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
40    if needle.is_empty() {
41        return Some(text.len());
42    }
43    if !needle.is_ascii() || needle.len() > text.len() {
44        return None;
45    }
46
47    let haystack = text.as_bytes();
48    let needle = needle.as_bytes();
49    (0..=haystack.len() - needle.len()).rev().find(|&position| {
50        haystack[position..position + needle.len()]
51            .iter()
52            .zip(needle)
53            .all(|(left, right)| left.eq_ignore_ascii_case(right))
54    })
55}
56
57/// Find an ASCII `needle` at or after byte offset `start` in `text`.
58///
59/// `start` need not be a UTF-8 character boundary because matching is done on
60/// bytes. A successful offset always starts at an ASCII byte and is therefore
61/// a UTF-8 boundary in the unchanged original input.
62pub fn find_ascii_case_insensitive_from(text: &str, needle: &str, start: usize) -> Option<usize> {
63    if needle.is_empty() {
64        return (start <= text.len()).then_some(start);
65    }
66    if !needle.is_ascii() || start > text.len() || needle.len() > text.len() - start {
67        return None;
68    }
69
70    let haystack = text.as_bytes();
71    let needle = needle.as_bytes();
72    (start..=haystack.len() - needle.len()).find(|&position| {
73        haystack[position..position + needle.len()]
74            .iter()
75            .zip(needle)
76            .all(|(left, right)| left.eq_ignore_ascii_case(right))
77    })
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn matches_ascii_without_mutating_unicode_input() {
86        let text = "ßSELECT ff from İstanbul";
87        let select = find_ascii_case_insensitive(text, "select").expect("ASCII match");
88        let from = rfind_ascii_case_insensitive(text, "FROM").expect("ASCII match");
89        assert_eq!(select, 2);
90        assert_eq!(from, 13);
91        assert_eq!(&text[select..select + 6], "SELECT");
92        assert_eq!(&text[from..from + 4], "from");
93        assert_eq!(find_ascii_case_insensitive(text, "istanbul"), None);
94        assert_eq!(find_ascii_case_insensitive(text, "ssselect"), None);
95        assert_eq!(find_ascii_case_insensitive(text, "ff"), None);
96    }
97
98    #[test]
99    fn rejects_non_ascii_needles() {
100        let text = "SELECT Straße";
101        assert!(!starts_with_ascii_case_insensitive(text, "ŚELECT"));
102        assert_eq!(strip_prefix_ascii_case_insensitive(text, "ŚELECT"), None);
103        assert_eq!(find_ascii_case_insensitive(text, "ß"), None);
104        assert_eq!(find_ascii_case_insensitive_from(text, "İ", 0), None);
105        assert_eq!(rfind_ascii_case_insensitive(text, "ff"), None);
106    }
107
108    #[test]
109    fn prefix_helpers_return_original_suffix() {
110        let text = "SeLeCt ß";
111        assert!(starts_with_ascii_case_insensitive(text, "select"));
112        assert_eq!(
113            strip_prefix_ascii_case_insensitive(text, "SELECT"),
114            Some(" ß")
115        );
116        assert_eq!(strip_prefix_ascii_case_insensitive(text, ""), Some(text));
117        assert!(starts_with_ascii_case_insensitive(text, ""));
118        assert!(!starts_with_ascii_case_insensitive(text, "select x"));
119    }
120
121    #[test]
122    fn empty_and_start_bounds_are_conventional() {
123        let text = "éX";
124        assert_eq!(find_ascii_case_insensitive(text, ""), Some(0));
125        assert_eq!(rfind_ascii_case_insensitive(text, ""), Some(text.len()));
126        assert_eq!(find_ascii_case_insensitive_from(text, "", 1), Some(1));
127        assert_eq!(
128            find_ascii_case_insensitive_from(text, "", text.len()),
129            Some(text.len())
130        );
131        assert_eq!(
132            find_ascii_case_insensitive_from(text, "", text.len() + 1),
133            None
134        );
135        assert_eq!(find_ascii_case_insensitive_from(text, "x", 1), Some(2));
136        assert_eq!(
137            find_ascii_case_insensitive_from(text, "x", text.len()),
138            None
139        );
140    }
141}