Skip to main content

hypermail/
string_utils.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4/// Maximum length for URL detection to prevent ReDoS attacks.
5///
6/// RFC 3986 doesn't specify a maximum URL length, but browsers typically
7/// support 2048 characters. We allow up to 4096 for compatibility with
8/// data URIs and long query strings.
9const MAX_URL_LENGTH: usize = 4096;
10
11/// Maximum subject length for thread detection processing.
12///
13/// RFC 2822 recommends lines < 998 characters, but some clients generate
14/// longer subjects. We limit to 2048 for performance in O(n²) threading loop.
15const MAX_SUBJECT_THREAD_LENGTH: usize = 2048;
16
17static URL_RE: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r#"(?i)((https?|ftp)://[^\s<>"']+|www\.[^\s<>"']+)"#).unwrap());
19
20static EMAIL_RE: LazyLock<Regex> =
21    LazyLock::new(|| Regex::new(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})").unwrap());
22
23static UNRE_RE: LazyLock<Regex> = LazyLock::new(|| {
24    Regex::new(r"(?i)^(\s*(re|fwd?|aw|ang|sv|vs|odp|antw)\s*[\[:\]>#]*\s*)+")
25        .expect("UNRE_RE compile")
26});
27
28static ONEUNRE_RE: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(r"(?i)^\s*(re|fwd?|aw|ang|sv|vs|odp|antw)\s*[\[:\]>#]*\s*")
30        .expect("ONEUNRE_RE compile")
31});
32
33static STRIPZONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+\([^)]*\)\s*$").unwrap());
34
35static NUM_REF_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"&#(\d+);").unwrap());
36
37/// Strips reply prefixes from email subjects.
38///
39/// Removes internationalized reply/forward prefixes: Re:, Fwd:, AW: (German),
40/// SV: (Swedish), Odp: (Polish), Antw: (Dutch), etc.
41///
42/// # Security
43///
44/// For performance in O(n²) threading loops, extremely long subjects are
45/// truncated to MAX_SUBJECT_THREAD_LENGTH before regex processing.
46///
47/// # Examples
48///
49/// ```
50/// use hypermail::string_utils::unre;
51/// assert_eq!(unre("Re: Hello"), "Hello");
52/// assert_eq!(unre("RE: Re: Fwd: Hello"), "Hello");
53/// assert_eq!(unre("AW: Diskussion"), "Diskussion");
54/// ```
55pub fn unre(subject: &str) -> String {
56    // Security: Limit subject length to prevent ReDoS on pathological inputs
57    // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 sequences
58    let truncated = if subject.len() > MAX_SUBJECT_THREAD_LENGTH {
59        &subject[..subject.floor_char_boundary(MAX_SUBJECT_THREAD_LENGTH)]
60    } else {
61        subject
62    };
63
64    UNRE_RE.replace(truncated, "").trim().to_string()
65}
66
67/// Strips a single reply/forward prefix from a subject line.
68pub fn oneunre(subject: &str) -> String {
69    // Security: Same truncation as unre()
70    // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 sequences
71    let truncated = if subject.len() > MAX_SUBJECT_THREAD_LENGTH {
72        &subject[..subject.floor_char_boundary(MAX_SUBJECT_THREAD_LENGTH)]
73    } else {
74        subject
75    };
76
77    ONEUNRE_RE.replace(truncated, "").trim().to_string()
78}
79
80/// Attempts to parse a URL from the start of a string, writing it into `url`.
81pub fn parse_url(s: &str, url: &mut String) -> Option<usize> {
82    if let Some(m) = URL_RE.find(s) {
83        url.push_str(m.as_str());
84        Some(m.len())
85    } else {
86        None
87    }
88}
89
90/// Converts URLs in text to clickable HTML links.
91///
92/// Detects http://, https://, ftp:// URLs and www. patterns, converting
93/// them to `<a>` tags with rel="noopener noreferrer" for security.
94///
95/// # Security
96///
97/// To prevent ReDoS attacks, this function skips processing if the input
98/// exceeds reasonable length or contains extremely long potential URLs.
99///
100/// # Arguments
101///
102/// * `line` - Text that may contain URLs
103///
104/// # Returns
105///
106/// Text with URLs replaced by HTML `<a>` tags
107pub fn conv_urls(line: &str) -> String {
108    // Security: Skip URL processing on unreasonably large inputs
109    // This prevents ReDoS on malicious inputs with pathological patterns
110    if line.len() > MAX_URL_LENGTH * 10 {
111        return line.to_string();
112    }
113
114    URL_RE
115        .replace_all(line, |caps: &regex::Captures| {
116            let url = &caps[1];
117
118            // Security: Skip extremely long URLs to prevent memory issues
119            if url.len() > MAX_URL_LENGTH {
120                return url.to_string();
121            }
122
123            let href = if url.starts_with("www.") {
124                format!("https://{}", url)
125            } else {
126                url.to_string()
127            };
128            // SEC: Escape both href attribute and anchor text
129            format!(
130                "<a href=\"{}\" rel=\"noopener noreferrer\">{}</a>",
131                escape_html_attr(&href),
132                escape_html_attr(url)
133            )
134        })
135        .to_string()
136}
137
138/// Escape a string for safe use inside an HTML attribute value (double-quote delimited).
139fn escape_html_attr(s: &str) -> String {
140    let mut result = String::with_capacity(s.len());
141    for c in s.chars() {
142        match c {
143            '&' => result.push_str("&amp;"),
144            '"' => result.push_str("&quot;"),
145            '<' => result.push_str("&lt;"),
146            '>' => result.push_str("&gt;"),
147            '\'' => result.push_str("&#39;"),
148            c => result.push(c),
149        }
150    }
151    result
152}
153
154/// Obfuscates an email address using HTML numeric character references.
155pub fn obfuscate_email_address(s: &str) -> String {
156    let mut result = String::with_capacity(s.len());
157    for c in s.chars() {
158        match c {
159            '@' => result.push_str("&#64;"),
160            '.' => result.push('.'),
161            '-' => result.push('-'),
162            '_' => result.push('_'),
163            c if c.is_ascii_alphanumeric() => {
164                let code = c as u32;
165                result.push_str(&format!("&#{};", code));
166            },
167            c => result.push(c),
168        }
169    }
170    result
171}
172
173/// Reverses HTML numeric character reference obfuscation back to plain text.
174pub fn unobfuscate_email_address(s: &str) -> String {
175    NUM_REF_RE
176        .replace_all(s, |caps: &regex::Captures| {
177            let code: u32 = caps[1].parse().unwrap_or(0);
178            char::from_u32(code).map_or(String::new(), |c| c.to_string())
179        })
180        .to_string()
181}
182
183/// Applies spam protection to email addresses in a string.
184///
185/// Replaces `@` with the configured anti-spam string, or substitutes the domain.
186pub fn spamify(
187    s: &str,
188    antispam_at: &str,
189    antispamdomain: Option<&str>,
190    spamprotect: bool,
191    spamprotect_id: bool,
192) -> String {
193    if !spamprotect && !spamprotect_id {
194        return s.to_string();
195    }
196
197    if !EMAIL_RE.is_match(s) {
198        return s.to_string();
199    }
200
201    let result = EMAIL_RE.replace_all(s, |caps: &regex::Captures| {
202        let email = &caps[1];
203        if let Some(domain) = antispamdomain {
204            if let Some(at_pos) = email.find('@') {
205                let local = &email[..at_pos];
206                return format!("{}@{}", local, domain);
207            }
208        }
209        if spamprotect {
210            email.replace('@', antispam_at)
211        } else {
212            email.to_string()
213        }
214    });
215
216    result.to_string()
217}
218
219/// Replaces characters found in `chars` with underscores.
220pub fn convchars(s: &str, chars: &str) -> String {
221    let mut result = String::with_capacity(s.len());
222    for c in s.chars() {
223        if chars.contains(c) {
224            result.push('_');
225        } else {
226            result.push(c);
227        }
228    }
229    result
230}
231
232/// Strips trailing parenthetical timezone info from a date string.
233pub fn stripzone(s: &str) -> String {
234    STRIPZONE_RE.replace(s.trim(), "").to_string()
235}
236
237/// Returns `None` if the string is empty or "NONE", otherwise returns `Some`.
238pub fn getvalue(s: &str) -> Option<&str> {
239    let s = s.trim();
240    if s.is_empty() || s.eq_ignore_ascii_case("NONE") {
241        None
242    } else {
243        Some(s)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_unre() {
253        assert_eq!(unre("Re: Hello"), "Hello");
254        assert_eq!(unre("Re: Re: Hello"), "Hello");
255        assert_eq!(unre("Fwd: Hello"), "Hello");
256        assert_eq!(unre("Hello"), "Hello");
257    }
258
259    #[test]
260    fn test_conv_urls() {
261        let result = conv_urls("Visit https://example.com today");
262        assert!(result.contains("<a href=\"https://example.com\""));
263        assert!(result.contains("rel=\"noopener noreferrer\""));
264    }
265
266    #[test]
267    fn test_obfuscate_email() {
268        let ob = obfuscate_email_address("a@b.com");
269        assert!(ob.contains("&#97;"));
270        assert!(ob.contains("&#64;"));
271    }
272
273    #[test]
274    fn test_spamify() {
275        let result = spamify("a@b.com", " at ", None, true, false);
276        assert_eq!(result, "a at b.com");
277    }
278
279    #[test]
280    fn test_spamify_with_domain() {
281        let result = spamify("a@b.com", "@", Some("example.com"), true, false);
282        assert_eq!(result, "a@example.com");
283    }
284
285    #[test]
286    fn test_stripzone() {
287        let result = stripzone("Mon, 15 Mar 2021 12:00:00 +0000 (UTC)");
288        assert!(!result.contains("(UTC)"));
289    }
290
291    #[test]
292    fn test_getvalue() {
293        assert_eq!(getvalue("test"), Some("test"));
294        assert_eq!(getvalue("NONE"), None);
295        assert_eq!(getvalue(""), None);
296    }
297
298    #[test]
299    fn test_spamify_antispamdomain_replaces_domain() {
300        let result = spamify("user@real-domain.com", "_at_", Some("nospam.invalid"), true, false);
301        assert!(result.contains("nospam.invalid"), "domain should be replaced");
302        assert!(!result.contains("real-domain.com"), "original domain should be gone");
303    }
304
305    #[test]
306    fn test_spamify_antispamdomain_none_falls_back_to_at_replacement() {
307        let result = spamify("user@real-domain.com", "_at_", None, true, false);
308        assert!(result.contains("_at_"), "should use antispam_at when no antispamdomain");
309        assert!(!result.contains('@'), "@ should be replaced");
310    }
311
312    #[test]
313    fn test_convchars() {
314        assert_eq!(convchars("hello world", " "), "hello_world");
315    }
316
317    #[test]
318    fn test_oneunre_strips_single_prefix() {
319        assert_eq!(oneunre("Re: Hello"), "Hello");
320        assert_eq!(oneunre("Re: Re: Hello"), "Re: Hello");
321        assert_eq!(oneunre("Hello"), "Hello");
322    }
323
324    #[test]
325    fn test_parse_url_found() {
326        let mut url = String::new();
327        let len = parse_url("https://example.com/path?q=1", &mut url);
328        assert!(len.is_some());
329        assert_eq!(url, "https://example.com/path?q=1");
330    }
331
332    #[test]
333    fn test_parse_url_not_found() {
334        let mut url = String::new();
335        let len = parse_url("plain text no url", &mut url);
336        assert!(len.is_none());
337        assert!(url.is_empty());
338    }
339
340    #[test]
341    fn test_unobfuscate_roundtrip() {
342        let original = "user@example.com";
343        let obfuscated = obfuscate_email_address(original);
344        let restored = unobfuscate_email_address(&obfuscated);
345        assert_eq!(restored, original);
346    }
347
348    #[test]
349    fn test_spamify_no_email_unchanged() {
350        let result = spamify("no email here", " at ", None, true, false);
351        assert_eq!(result, "no email here");
352    }
353
354    #[test]
355    fn test_spamify_disabled_unchanged() {
356        let result = spamify("user@example.com", " at ", None, false, false);
357        assert_eq!(result, "user@example.com");
358    }
359
360    #[test]
361    fn test_conv_urls_escapes_href_with_quotes() {
362        // In practice, conv_urls() receives pre-escaped input from escape_html(),
363        // so raw " never appears. The URL regex [^\s<>"']+ also stops at " by design.
364        // Test that the escape_html + conv_urls pipeline is safe:
365        let escaped_input =
366            crate::txt2html::escape_html(r#"Visit https://evil.com/a"onmouseover="alert(1) today"#);
367        let result = conv_urls(&escaped_input);
368        // The " was escaped to &quot; before conv_urls, so it's part of the URL match
369        // but rendered safely in the href via escape_html_attr.
370        assert!(
371            !result.contains(r#""onmouseover"#),
372            "raw double-quote injection must not appear: {}",
373            result
374        );
375    }
376
377    #[test]
378    fn test_conv_urls_escapes_special_chars_in_href() {
379        // URLs with & should be properly escaped in the href attribute
380        let result = conv_urls("https://example.com/search?a=1&amp;b=2");
381        assert!(
382            result.contains("&amp;amp;") || result.contains("&amp;b=2"),
383            "& in URL should be preserved or double-escaped in href attribute: {}",
384            result
385        );
386    }
387
388    #[test]
389    fn test_conv_urls_escapes_angle_brackets() {
390        // The URL regex stops at < so no <script> can appear in generated href
391        let escaped_input = crate::txt2html::escape_html("https://evil.com/<script>");
392        let result = conv_urls(&escaped_input);
393        assert!(
394            !result.contains("<script>"),
395            "angle brackets should not appear raw in link output: {}",
396            result
397        );
398    }
399
400    #[test]
401    fn test_unre_utf8_at_truncation_boundary() {
402        // Build a subject with multi-byte UTF-8 chars near the 2048-byte boundary.
403        // Each 'ä' is 2 bytes in UTF-8. Place them so the 2048 boundary falls mid-char.
404        let prefix = "Re: ";
405        let filler = "ä".repeat(1024); // 2048 bytes of 'ä'
406        let subject = format!("{}{}", prefix, filler);
407        // This should NOT panic even though byte 2048 might be mid-char
408        let result = unre(&subject);
409        assert!(!result.is_empty(), "should handle UTF-8 at truncation boundary");
410    }
411
412    #[test]
413    fn test_oneunre_utf8_at_truncation_boundary() {
414        let filler = "ö".repeat(1025); // 2050 bytes, exceeds MAX_SUBJECT_THREAD_LENGTH
415        let subject = format!("Re: {}", filler);
416        // Should not panic
417        let result = oneunre(&subject);
418        assert!(!result.is_empty());
419    }
420}