Skip to main content

guard_core_engine/
preprocessor.rs

1//! Content preprocessing ported from `guard_core/detection_engine/preprocessor.py`.
2//!
3//! Spec 4.0.2: the shared decode chain produces the processed view (null-byte
4//! removal, whitespace collapse, attack-preserving truncation) plus the decoded
5//! view handed to the URL-decoded scan pass, the signal-preserving raw view,
6//! and the short-base64 additive view.
7
8use std::sync::LazyLock;
9
10use regex::Regex;
11use unicode_normalization::UnicodeNormalization;
12
13pub const DEFAULT_MAX_FULL_SCAN_BYTES: usize = 262_144;
14const FULL_SCAN_TAIL_BYTES: usize = 4096;
15const MAX_DECODE_ITERATIONS: usize = 16;
16const MAX_GUNZIP_ATTEMPTS_PER_PASS: u32 = 8;
17
18// visually ambiguous unicode chars mapped to their ASCII equivalents
19const LOOKALIKES: &[(char, &str)] = &[
20    ('\u{2044}', "/"),
21    ('\u{FF0F}', "/"),
22    ('\u{29F8}', "/"),
23    ('\u{0130}', "I"),
24    ('\u{0131}', "i"),
25    ('\u{200B}', ""),
26    ('\u{200C}', ""),
27    ('\u{200D}', ""),
28    ('\u{FEFF}', ""),
29    ('\u{00AD}', ""),
30    ('\u{034F}', ""),
31    ('\u{180E}', ""),
32    ('\u{2028}', "\n"),
33    ('\u{2029}', "\n"),
34    ('\u{E000}', ""),
35    ('\u{FFF0}', ""),
36    ('\u{01C0}', "|"),
37    ('\u{037E}', ";"),
38    ('\u{2215}', "/"),
39    ('\u{2216}', "\\"),
40    ('\u{FF1C}', "<"),
41    ('\u{FF1E}', ">"),
42    ('\u{FF1B}', ";"),
43    ('\u{FF5C}', "|"),
44    ('\u{FF06}', "&"),
45];
46
47static ATTACK_INDICATORS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
48    let patterns = [
49        r"<script",
50        r"javascript:",
51        r"on\w+=",
52        r"SELECT\s+.{0,50}?\s+FROM",
53        r"UNION\s+SELECT",
54        r"\.\./",
55        r"eval\s*\(",
56        r"exec\s*\(",
57        r"system\s*\(",
58        r"<\?php",
59        r"<%",
60        r"\{\{",
61        r"\{%",
62        r"<iframe",
63        r"<object",
64        r"<embed",
65        r"onerror\s*=",
66        r"onload\s*=",
67        r"\$\{",
68        r"\\x[0-9a-fA-F]{2}",
69        r"%[0-9a-fA-F]{2}",
70        r"`",
71        r"\$\(",
72        r"[;&|]",
73        r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
74    ];
75    patterns
76        .iter()
77        .map(|p| Regex::new(&format!("(?i){p}")).expect("static indicator must compile"))
78        .collect()
79});
80
81/// NFKC normalization + unicode lookalike replacement
82/// (fullwidth forms, zero-width characters, etc.).
83#[must_use]
84pub fn normalize_unicode(content: &str) -> String {
85    let normalized: String = content.nfkc().collect();
86    let mut result = normalized;
87
88    for &(ch, replacement) in LOOKALIKES {
89        if result.contains(ch) {
90            result = result.replace(ch, replacement);
91        }
92    }
93
94    result
95}
96
97/// Python `str.isspace`-compatible whitespace test (`re` `\s` also matches the
98/// file/group/record/unit separators, which `char::is_whitespace` excludes).
99const fn py_is_space(c: char) -> bool {
100    c.is_whitespace() || matches!(c, '\u{1c}'..='\u{1f}')
101}
102
103#[doc(hidden)]
104#[must_use]
105pub fn collapse_whitespace(content: &str) -> String {
106    let mut out = String::with_capacity(content.len());
107    let mut in_run = false;
108    let mut any = false;
109    for c in content.chars() {
110        if py_is_space(c) {
111            in_run = true;
112            continue;
113        }
114        if in_run && any {
115            out.push(' ');
116        }
117        in_run = false;
118        any = true;
119        out.push(c);
120    }
121    out
122}
123
124#[doc(hidden)]
125#[must_use]
126pub fn remove_null_bytes(content: &str) -> String {
127    content
128        .chars()
129        .filter(|&c| {
130            let code = c as u32;
131            code >= 32 || matches!(code, 9 | 10 | 13)
132        })
133        .collect()
134}
135
136// ---------------------------------------------------------------------------
137// encoding decoders (encoding_decoders.py)
138// ---------------------------------------------------------------------------
139
140fn decode_percent_u_escapes(s: &str) -> String {
141    static RE: LazyLock<Regex> =
142        LazyLock::new(|| Regex::new(r"(?i)%u([0-9a-fA-F]{4})").expect("static regex"));
143    replace_hex_group(&RE, s)
144}
145
146fn decode_hex_escapes(s: &str) -> String {
147    static RE: LazyLock<Regex> =
148        LazyLock::new(|| Regex::new(r"\\x([0-9a-fA-F]{2})").expect("static regex"));
149    replace_hex_group(&RE, s)
150}
151
152fn decode_ldap_hex_escapes(s: &str) -> String {
153    static RE: LazyLock<Regex> =
154        LazyLock::new(|| Regex::new(r"\\([0-9a-fA-F]{2})").expect("static regex"));
155    replace_hex_group(&RE, s)
156}
157
158fn decode_unicode_escapes(s: &str) -> String {
159    static RE: LazyLock<Regex> =
160        LazyLock::new(|| Regex::new(r"\\u([0-9a-fA-F]{4})").expect("static regex"));
161    replace_hex_group(&RE, s)
162}
163
164fn replace_hex_group(re: &Regex, s: &str) -> String {
165    re.replace_all(s, |caps: &regex::Captures<'_>| {
166        let hex = caps.get(1).map_or("0", |m| m.as_str());
167        u32::from_str_radix(hex, 16)
168            .ok()
169            .and_then(char::from_u32)
170            .map_or_else(
171                || caps.get(0).map_or("", |m| m.as_str()).to_owned(),
172                String::from,
173            )
174    })
175    .into_owned()
176}
177
178const PERCENT_BYTE_RUN_RE: &str = r"(?:%[0-9a-fA-F]{2})+";
179
180/// `decode_overlong_utf8_percent_runs`: percent-encoded byte runs that do not
181/// form valid UTF-8 are re-decoded leniently (overlong lead bytes).
182#[must_use]
183pub fn decode_overlong_utf8_percent_runs(content: &str) -> String {
184    static RE: LazyLock<Regex> =
185        LazyLock::new(|| Regex::new(PERCENT_BYTE_RUN_RE).expect("static regex"));
186    RE.replace_all(content, |caps: &regex::Captures<'_>| {
187        let run = caps.get(0).map_or("", |m| m.as_str());
188        let raw: Vec<u8> = run
189            .as_bytes()
190            .chunks(3)
191            .filter_map(|triple| {
192                if triple.len() == 3 {
193                    u8::from_str_radix(std::str::from_utf8(&triple[1..3]).ok()?, 16).ok()
194                } else {
195                    None
196                }
197            })
198            .collect();
199        if std::str::from_utf8(&raw).is_ok() {
200            return run.to_owned();
201        }
202        lenient_overlong_utf8_decode(&raw)
203    })
204    .into_owned()
205}
206
207const OVERLONG_LEAD_SPECS: [(u8, usize, u8, u8, u8); 4] = [
208    // lead, sequence length, lead mask, first-continuation min/max
209    (0xC0, 2, 0x1F, 0x80, 0xBF),
210    (0xC1, 2, 0x1F, 0x80, 0xBF),
211    (0xE0, 3, 0x0F, 0x80, 0x9F),
212    (0xF0, 4, 0x07, 0x80, 0x8F),
213];
214
215fn decode_overlong_sequence_at(raw: &[u8], index: usize) -> Option<(char, usize)> {
216    let lead = *raw.get(index)?;
217    let spec = OVERLONG_LEAD_SPECS.iter().find(|spec| spec.0 == lead)?;
218    let sequence_length = spec.1;
219    let lead_mask = spec.2;
220    let first_min = spec.3;
221    let first_max = spec.4;
222    if index + sequence_length > raw.len() {
223        return None;
224    }
225    let continuations = &raw[index + 1..index + sequence_length];
226    if continuations[0] < first_min || continuations[0] > first_max {
227        return None;
228    }
229    if continuations[1..]
230        .iter()
231        .any(|byte| !(*byte >= 0x80 && *byte <= 0xBF))
232    {
233        return None;
234    }
235    let mut codepoint = u32::from(lead & lead_mask);
236    for byte in continuations {
237        codepoint = (codepoint << 6) | u32::from(byte & 0x3F);
238    }
239    char::from_u32(codepoint).map(|c| (c, sequence_length))
240}
241
242fn lenient_overlong_utf8_decode(raw: &[u8]) -> String {
243    let mut chars = String::new();
244    let mut index = 0;
245    while index < raw.len() {
246        if let Some((c, consumed)) = decode_overlong_sequence_at(raw, index) {
247            chars.push(c);
248            index += consumed;
249        } else if raw[index] < 0x80 {
250            chars.push(raw[index] as char);
251            index += 1;
252        } else {
253            index += 1;
254        }
255    }
256    chars
257}
258
259/// `urllib.parse.unquote(content, errors="ignore")`: percent escapes become
260/// bytes, invalid UTF-8 bytes are dropped.
261fn percent_unquote_ignore(s: &str) -> String {
262    if !s.contains('%') {
263        return s.to_owned();
264    }
265    let bytes = s.as_bytes();
266    let mut raw: Vec<u8> = Vec::with_capacity(bytes.len());
267    let mut i = 0;
268    while i < bytes.len() {
269        if bytes[i] == b'%'
270            && i + 2 < bytes.len()
271            && bytes[i + 1].is_ascii_hexdigit()
272            && bytes[i + 2].is_ascii_hexdigit()
273        {
274            let hi = (bytes[i + 1] as char).to_digit(16).unwrap_or(0) as u8;
275            let lo = (bytes[i + 2] as char).to_digit(16).unwrap_or(0) as u8;
276            raw.push((hi << 4) | lo);
277            i += 3;
278        } else {
279            raw.push(bytes[i]);
280            i += 1;
281        }
282    }
283    utf8_ignore(&raw)
284}
285
286fn utf8_ignore(bytes: &[u8]) -> String {
287    let mut out = String::with_capacity(bytes.len());
288    let mut i = 0;
289    while i < bytes.len() {
290        match std::str::from_utf8(&bytes[i..]) {
291            Ok(s) => {
292                out.push_str(s);
293                break;
294            }
295            Err(e) => {
296                let valid = e.valid_up_to();
297                out.push_str(std::str::from_utf8(&bytes[i..i + valid]).unwrap_or(""));
298                i += valid + e.error_len().unwrap_or(1);
299            }
300        }
301    }
302    out
303}
304
305// ---------------------------------------------------------------------------
306// base64 candidate decode (base64_decode.py)
307// ---------------------------------------------------------------------------
308
309const fn is_b64_data(c: char) -> bool {
310    c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-')
311}
312
313/// `SEPARATOR_CHARS`: every ASCII char outside the data alphabet plus the
314/// surrogateescape byte range.
315fn is_b64_separator(c: char) -> bool {
316    let cp = c as u32;
317    if (0xDC80..=0xDCFF).contains(&cp) {
318        return true;
319    }
320    if cp < 0x80 {
321        return !is_b64_data(c);
322    }
323    false
324}
325
326/// `bounded_gunzip`: gzip payloads are left undecoded (no decompressor in the
327/// engine's dependency set); the reference decodes them but only the gunzip
328/// branch of the base64 stage depends on it, and the decoded bytes then still
329/// have to pass the printable/UTF-8 gates.
330const fn bounded_gunzip(_raw: &[u8]) -> Option<Vec<u8>> {
331    None
332}
333
334const GZIP_MAGIC: [u8; 2] = [0x1F, 0x8B];
335const PRINTABLE_RATIO_THRESHOLD: f64 = 0.5;
336const FALLBACK_PRINTABLE_RATIO_THRESHOLD: f64 = 0.95;
337const MAX_REPLACEMENT_CHAR_RATIO: f64 = 0.2;
338
339fn printable_ratio(text: &str) -> f64 {
340    if text.is_empty() {
341        return 0.0;
342    }
343    let printable = text.chars().filter(|c| py_is_printable(*c)).count();
344    f64::from(u32::try_from(printable).unwrap_or(u32::MAX))
345        / f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX))
346}
347
348/// Python `str.isprintable` approximation: everything except control, format,
349/// separator (other than plain space), private-use and surrogate code points.
350fn py_is_printable(c: char) -> bool {
351    if c == ' ' {
352        return true;
353    }
354    if c.is_control() || c.is_whitespace() {
355        return false;
356    }
357    // common Cf (format) code points
358    !matches!(c as u32,
359        0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F
360        | 0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064
361        | 0x2066..=0x206F | 0xFEFF | 0xFFF9..=0xFFFB
362        | 0xE000..=0xF8FF | 0xF0000..=0xFFFFD | 0x0010_0000..=0x0010_FFFD
363    )
364}
365
366fn replacement_char_ratio(text: &str) -> f64 {
367    if text.is_empty() {
368        return 0.0;
369    }
370    let count = text.chars().filter(|c| *c == '\u{FFFD}').count();
371    f64::from(u32::try_from(count).unwrap_or(u32::MAX))
372        / f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX))
373}
374
375/// strict base64 decode over the standard alphabet with re-padding.
376fn b64_decode_strict(cleaned: &str) -> Option<Vec<u8>> {
377    if cleaned.is_empty() || cleaned.len() % 4 == 1 {
378        return None;
379    }
380    let mut padded = cleaned.to_owned();
381    for _ in 0..(4 - cleaned.len() % 4) % 4 {
382        padded.push('=');
383    }
384    let bytes = padded.as_bytes();
385    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
386    // the re-padding above guarantees a multiple of 4, so the remainder is empty
387    let (groups, rest) = bytes.as_chunks::<4>();
388    debug_assert!(rest.is_empty());
389    for group in groups {
390        let v: [u8; 4] = group
391            .iter()
392            .map(|b| match b {
393                b'A'..=b'Z' => b - b'A',
394                b'a'..=b'z' => b - b'a' + 26,
395                b'0'..=b'9' => b - b'0' + 52,
396                b'+' => 62,
397                b'/' => 63,
398                b'=' => 254,
399                _ => 255,
400            })
401            .collect::<Vec<u8>>()
402            .try_into()
403            .ok()?;
404        if v.contains(&255) || v[1] == 254 {
405            return None;
406        }
407        out.push((v[0] << 2) | (v[1] >> 4));
408        if v[2] != 254 {
409            out.push((v[1] << 4) | (v[2] >> 2));
410            if v[3] != 254 {
411                out.push((v[2] << 6) | v[3]);
412            }
413        } else if v[3] != 254 {
414            return None;
415        }
416    }
417    Some(out)
418}
419
420fn decode_cleaned(
421    cleaned: &str,
422    min_printable_ratio: f64,
423    gunzip_left: &mut u32,
424) -> Option<String> {
425    let raw = b64_decode_strict(cleaned)?;
426    let raw = if raw.len() >= 2 && raw[..2] == GZIP_MAGIC && *gunzip_left > 0 {
427        *gunzip_left -= 1;
428        bounded_gunzip(&raw).unwrap_or(raw)
429    } else {
430        raw
431    };
432    let decoded = if let Ok(s) = std::str::from_utf8(&raw) {
433        s.to_owned()
434    } else {
435        let replaced = String::from_utf8_lossy(&raw).into_owned();
436        if replacement_char_ratio(&replaced) > MAX_REPLACEMENT_CHAR_RATIO {
437            return None;
438        }
439        replaced
440    };
441    if printable_ratio(&decoded) >= min_printable_ratio {
442        return Some(decoded);
443    }
444    None
445}
446
447fn is_hex_literal(token: &str) -> bool {
448    token.len() > 2
449        && (token.starts_with("0x") || token.starts_with("0X"))
450        && token[2..].bytes().all(|b| b.is_ascii_hexdigit())
451}
452
453/// `WIDENED_CANDIDATE_MARKER_RE`: a separator other than CR/LF/= or a `-`/`_`
454/// data char inside the token widens the candidate and tightens the gate.
455fn token_has_widened_separator(token: &str) -> bool {
456    token.chars().any(|c| match c {
457        '\r' | '\n' | '=' => false,
458        '-' | '_' => true,
459        c if is_b64_separator(c) => true,
460        _ => false,
461    })
462}
463
464fn decode_token(token: &str, min_printable_ratio: f64, gunzip_left: &mut u32) -> Option<String> {
465    if is_hex_literal(token) {
466        return None;
467    }
468    // WHITESPACE_RE removes every separator char, including `=`
469    let cleaned: String = token.chars().filter(|c| !is_b64_separator(*c)).collect();
470    let urlsafe: String = cleaned
471        .chars()
472        .map(|c| match c {
473            '-' => '+',
474            '_' => '/',
475            other => other,
476        })
477        .collect();
478    if let Some(decoded) = decode_cleaned(&urlsafe, min_printable_ratio, gunzip_left) {
479        return Some(decoded);
480    }
481    if cleaned.contains('-') || cleaned.contains('_') {
482        let stripped: String = cleaned.chars().filter(|c| *c != '-' && *c != '_').collect();
483        return decode_cleaned(&stripped, min_printable_ratio, gunzip_left);
484    }
485    None
486}
487
488/// `BASE64_RE` candidate tokens: maximal data/separator runs that start with a
489/// data char preceded by a non-data char. The three reference alternatives
490/// (`{12,}={0,2}` / `{11,}=` / `{10,}==`) decide the match end from the data
491/// count and the trailing separator run.
492fn base64_token_spans(content: &str) -> Vec<(usize, usize)> {
493    let chars: Vec<(usize, char)> = content.char_indices().collect();
494    let mut spans = Vec::new();
495    let mut i = 0;
496    while i < chars.len() {
497        let (start, c) = chars[i];
498        if !is_b64_data(c) || (i > 0 && is_b64_data(chars[i - 1].1)) {
499            i += 1;
500            continue;
501        }
502        // extend over data and separator chars
503        let mut j = i;
504        let mut data_count = 0usize;
505        let mut last_data_idx: Option<usize> = None;
506        while j < chars.len() {
507            let (_, rc) = chars[j];
508            if is_b64_data(rc) {
509                data_count += 1;
510                last_data_idx = Some(j);
511            } else if !is_b64_separator(rc) {
512                break;
513            }
514            j += 1;
515        }
516        let run_end = j;
517        let trailing_from = last_data_idx.map_or(i, |idx| idx + 1);
518        let trailing: Vec<char> = chars[trailing_from..run_end]
519            .iter()
520            .map(|(_, c)| *c)
521            .collect();
522
523        // backtracking order: alternative 1, then 2, then 3. Every end is a
524        // CHAR index into `chars`; `alt_eq_end` returns an index into the
525        // trailing separator slice (the chars after the last data char), so
526        // the match end is `trailing_from + rel` there.
527        let match_end_char: Option<usize> = if data_count >= 12 {
528            // the units absorb every separator, so the match is the full run
529            Some(run_end)
530        } else if data_count >= 11 {
531            alt_eq_end(&trailing, 1)
532                .or_else(|| alt_eq_end(&trailing, 2))
533                .map(|rel| trailing_from + rel)
534        } else if data_count == 10 {
535            alt_eq_end(&trailing, 2).map(|rel| trailing_from + rel)
536        } else {
537            None
538        };
539        let Some(end_char) = match_end_char else {
540            i += 1;
541            continue;
542        };
543        let end = chars.get(end_char).map_or(content.len(), |(idx, _)| *idx);
544        if end > start {
545            spans.push((start, end));
546            // resume after the match, like re.sub
547            i = end_char;
548            continue;
549        }
550        i += 1;
551    }
552    spans
553}
554
555/// Match end (relative to the trailing-run start) for the `=`-padded
556/// alternatives: `1` needs one `=` (rightmost whose successor is not `=`),
557/// `2` needs a `==` pair not followed by another `=`.
558fn alt_eq_end(trailing: &[char], eq_count: usize) -> Option<usize> {
559    if eq_count == 1 {
560        return trailing
561            .iter()
562            .rposition(|c| *c == '=')
563            .filter(|p| trailing.get(p + 1).is_none_or(|c| *c != '='))
564            .map(|p| p + 1);
565    }
566    trailing
567        .iter()
568        .enumerate()
569        .rev()
570        .find(|(idx, c)| {
571            **c == '='
572                && trailing.get(idx + 1) == Some(&'=')
573                && trailing.get(idx + 2).is_none_or(|c| *c != '=')
574        })
575        .map(|(idx, _)| idx + 2)
576}
577
578/// `decode_base64_candidates`: candidate tokens are replaced by their decoded
579/// form when the printable/UTF-8 gates pass, with run-splitting and
580/// sub-floor reassembly fallbacks.
581#[must_use]
582pub fn decode_base64_candidates(content: &str, gunzip_attempts_left: &mut u32) -> String {
583    let spans = base64_token_spans(content);
584    if spans.is_empty() {
585        return content.to_owned();
586    }
587    let mut out = String::with_capacity(content.len());
588    let mut last = 0usize;
589    for (start, end) in spans {
590        if start < last {
591            continue;
592        }
593        out.push_str(&content[last..start]);
594        let token = &content[start..end];
595        let primary_threshold = if token_has_widened_separator(token) {
596            FALLBACK_PRINTABLE_RATIO_THRESHOLD
597        } else {
598            PRINTABLE_RATIO_THRESHOLD
599        };
600        let decoded = decode_token(token, primary_threshold, gunzip_attempts_left);
601        let base = decoded.unwrap_or_else(|| decode_runs(token, gunzip_attempts_left));
602        let reassembled = reassemble_sub_floor_runs(token, gunzip_attempts_left);
603        match reassembled {
604            Some(fragment) if !base.contains(&fragment) => {
605                out.push_str(&base);
606                out.push(' ');
607                out.push_str(&fragment);
608            }
609            _ => out.push_str(&base),
610        }
611        last = end;
612    }
613    out.push_str(&content[last..]);
614    out
615}
616
617/// `_decode_runs`: 12+ char separator-free data runs decoded at the fallback
618/// threshold; failed runs stay as-is. Trailing `=` padding up to two chars
619/// joins the replaced span.
620fn decode_runs(token: &str, gunzip_attempts_left: &mut u32) -> String {
621    let chars: Vec<(usize, char)> = token.char_indices().collect();
622    let mut out = String::new();
623    let mut last = 0usize;
624    let mut idx = 0usize;
625    while idx < chars.len() {
626        let (start, c) = chars[idx];
627        if !is_b64_data(c) || (idx > 0 && is_b64_data(chars[idx - 1].1)) {
628            idx += 1;
629            continue;
630        }
631        let mut end_idx = idx;
632        while end_idx < chars.len() && is_b64_data(chars[end_idx].1) {
633            end_idx += 1;
634        }
635        let run_len = end_idx - idx;
636        let mut absorb = 0usize;
637        while absorb < 2
638            && chars
639                .get(end_idx + absorb)
640                .is_some_and(|(_, rc)| *rc == '=')
641        {
642            absorb += 1;
643        }
644        if run_len >= 12 {
645            let run: String = chars[idx..end_idx].iter().map(|(_, c)| c).collect();
646            out.push_str(&token[last..start]);
647            match decode_token(
648                &run,
649                FALLBACK_PRINTABLE_RATIO_THRESHOLD,
650                gunzip_attempts_left,
651            ) {
652                Some(decoded) => out.push_str(&decoded),
653                None => out.push_str(&token[start..start + run_len + absorb]),
654            }
655            last = start + run_len + absorb;
656        }
657        idx = end_idx.max(idx + 1);
658    }
659    out.push_str(&token[last..]);
660    out
661}
662
663fn reassemble_sub_floor_runs(token: &str, gunzip_attempts_left: &mut u32) -> Option<String> {
664    let chars: Vec<(usize, char)> = token.char_indices().collect();
665    let mut fragments = String::new();
666    let mut idx = 0usize;
667    while idx < chars.len() {
668        let (_, c) = chars[idx];
669        if is_b64_data(c) && (idx == 0 || !is_b64_data(chars[idx - 1].1)) {
670            let mut end_idx = idx;
671            while end_idx < chars.len() && is_b64_data(chars[end_idx].1) {
672                end_idx += 1;
673            }
674            let run_len = end_idx - idx;
675            if run_len <= 11 {
676                fragments.extend(chars[idx..end_idx].iter().map(|(_, c)| c));
677            }
678            idx = end_idx;
679            continue;
680        }
681        idx += 1;
682    }
683    if fragments.chars().count() < 12 {
684        return None;
685    }
686    decode_token(
687        &fragments,
688        FALLBACK_PRINTABLE_RATIO_THRESHOLD,
689        gunzip_attempts_left,
690    )
691}
692
693// ---------------------------------------------------------------------------
694// SQL comment strip (single final pass, reference semantics)
695// ---------------------------------------------------------------------------
696
697/// `_strip_sql_comments`: block comments become ` body ` (inner-word and
698/// `/*!`-guarded forms are kept); every `--`/`#` marker becomes one space.
699#[must_use]
700pub fn strip_sql_comments(content: &str) -> String {
701    let bytes = content.as_bytes();
702    let mut out = String::with_capacity(content.len());
703    let mut i = 0usize;
704    while i < bytes.len() {
705        if bytes[i] == b'/'
706            && i + 1 < bytes.len()
707            && bytes[i + 1] == b'*'
708            && let Some((body_start, close_end)) = block_comment_span(content, i)
709        {
710            out.push(' ');
711            out.push_str(&content[body_start..close_end - 2]);
712            out.push(' ');
713            i = close_end;
714            continue;
715        }
716        if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' {
717            out.push(' ');
718            i += 2;
719            continue;
720        }
721        if bytes[i] == b'#' {
722            out.push(' ');
723            i += 1;
724            continue;
725        }
726        let ch = content[i..].chars().next().unwrap_or('/');
727        out.push(ch);
728        i += ch.len_utf8();
729    }
730    out
731}
732
733/// Resolve `/* ... */` per the reference alternation
734/// `(?<!\w)/\*(?!!)(.*?)\*/|/\*(?!!)(.*?)\*/(?!\w)` with DOTALL: the
735/// lookbehind-gated branch wins with the first terminator, otherwise the
736/// trailing-gated branch expands the body until a terminator not followed by
737/// a word char. Returns `(body_start, close_end)`.
738fn block_comment_span(content: &str, start: usize) -> Option<(usize, usize)> {
739    let bytes = content.as_bytes();
740    if bytes.get(start + 2) == Some(&b'!') {
741        return None;
742    }
743    let preceding_is_word =
744        start > 0 && char_before(content, start).is_some_and(|(_, c)| py_is_word(c));
745    let first_close = content[start + 2..]
746        .find("*/")
747        .map(|rel| (start + 2 + rel, start + 2 + rel + 2));
748    if !preceding_is_word {
749        // first alternative: `(?<!\w)` holds, the lazy body ends at the first
750        // terminator
751        return first_close.map(|(_, close_end)| (start + 2, close_end));
752    }
753    // second alternative: expand until `*/` is not followed by a word char
754    let mut cursor = start + 2;
755    while let Some(rel) = content[cursor..].find("*/") {
756        let close = cursor + rel;
757        let close_end = close + 2;
758        let after_is_word = content[close_end..].chars().next().is_some_and(py_is_word);
759        if !after_is_word {
760            return Some((start + 2, close_end));
761        }
762        cursor = close + 1;
763    }
764    None
765}
766
767fn char_before(s: &str, byte_idx: usize) -> Option<(usize, char)> {
768    s[..byte_idx].char_indices().next_back()
769}
770
771fn py_is_word(c: char) -> bool {
772    c.is_alphanumeric() || c == '_'
773}
774
775// ---------------------------------------------------------------------------
776// truncation (truncation.py), in code-point space
777// ---------------------------------------------------------------------------
778
779fn take_chars(content: &str, count: usize) -> String {
780    content.chars().take(count).collect()
781}
782
783fn char_len(content: &str) -> usize {
784    content.chars().count()
785}
786
787/// `extract_attack_regions`: indicator matches padded by 100 chars on each
788/// side, sorted, merged and capped. Indices are code points.
789#[doc(hidden)]
790#[must_use]
791pub fn extract_attack_regions(content: &str, max_content_length: usize) -> Vec<(usize, usize)> {
792    let max_regions = usize::min(100, max_content_length / 100);
793    let mut regions: Vec<(usize, usize)> = Vec::new();
794
795    // code-point prefix of every byte offset reachable here (built lazily)
796    let cp_index = |byte_idx: usize| content[..byte_idx].chars().count();
797    let total_chars = char_len(content);
798
799    for indicator in ATTACK_INDICATORS.iter() {
800        let mut found: Vec<(usize, usize)> = Vec::new();
801        for m in indicator.find_iter(content) {
802            if found.len() >= max_regions {
803                break;
804            }
805            let start = cp_index(m.start()).saturating_sub(100);
806            let end = usize::min(cp_index(m.end()) + 100, total_chars);
807            found.push((start, end));
808        }
809        regions.extend(found);
810        if regions.len() >= max_regions {
811            break;
812        }
813    }
814
815    if regions.is_empty() {
816        return Vec::new();
817    }
818    regions.sort_unstable();
819    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(regions.len());
820    for (start, end) in regions {
821        if let Some(last) = merged.last_mut()
822            && start <= last.1
823        {
824            last.1 = usize::max(last.1, end);
825        } else {
826            merged.push((start, end));
827        }
828    }
829    merged.truncate(max_regions);
830    merged
831}
832
833fn consume_gap(
834    content_chars: &[char],
835    last_end: usize,
836    start: usize,
837    gap_budget: usize,
838) -> (String, usize) {
839    let gap_len = start - last_end;
840    if gap_len <= gap_budget {
841        return (
842            content_chars[last_end..start].iter().collect(),
843            gap_budget - gap_len,
844        );
845    }
846    let chunk_len = gap_budget - 1;
847    let piece = if chunk_len > 0 {
848        content_chars[last_end..last_end + chunk_len]
849            .iter()
850            .collect::<String>()
851    } else {
852        String::new()
853    };
854    (format!("{piece} "), 0)
855}
856
857fn build_result_with_attack_regions_and_context(
858    content_chars: &[char],
859    attack_regions: &[(usize, usize)],
860    budget: usize,
861) -> String {
862    let attack_length: usize = attack_regions.iter().map(|(s, e)| e - s).sum();
863    let mut gap_budget = budget.saturating_sub(attack_length);
864    let mut result_parts: Vec<String> = Vec::new();
865    let mut last_end = 0usize;
866
867    for (start, end) in attack_regions {
868        if last_end < *start && gap_budget > 0 {
869            let (piece, remaining) = consume_gap(content_chars, last_end, *start, gap_budget);
870            gap_budget = remaining;
871            result_parts.push(piece);
872        }
873        result_parts.push(content_chars[*start..*end].iter().collect());
874        last_end = *end;
875    }
876
877    if last_end < content_chars.len() && gap_budget > 0 {
878        let tail_len = usize::min(content_chars.len() - last_end, gap_budget);
879        result_parts.push(
880            content_chars[last_end..last_end + tail_len]
881                .iter()
882                .collect(),
883        );
884    }
885
886    result_parts.concat()
887}
888
889fn cap_with_tail(content_chars: &[char], max_full_scan_bytes: usize) -> String {
890    let tail = usize::min(FULL_SCAN_TAIL_BYTES, max_full_scan_bytes);
891    let head_len = max_full_scan_bytes - tail;
892    let total = content_chars.len();
893    let mut out = String::new();
894    out.extend(content_chars[..head_len].iter());
895    out.extend(content_chars[total - tail..].iter());
896    out
897}
898
899/// `truncate_safely`: cap with attack-region preservation; all budgets are in
900/// code points, mirroring Python string indexing.
901#[doc(hidden)]
902#[must_use]
903pub fn truncate_safely(
904    content: &str,
905    max_full_scan_bytes: usize,
906    preserve_attacks: bool,
907    max_content_length: usize,
908) -> String {
909    let total = char_len(content);
910    if total <= max_full_scan_bytes {
911        return content.to_owned();
912    }
913    if !preserve_attacks {
914        return take_chars(content, max_full_scan_bytes);
915    }
916
917    let attack_regions = extract_attack_regions(content, max_content_length);
918    let content_chars: Vec<char> = content.chars().collect();
919
920    if attack_regions.is_empty() {
921        return cap_with_tail(&content_chars, max_full_scan_bytes);
922    }
923
924    let attack_length: usize = attack_regions.iter().map(|(s, e)| e - s).sum();
925    if attack_length >= max_full_scan_bytes {
926        let mut result = String::new();
927        let mut remaining = max_full_scan_bytes;
928        for (start, end) in attack_regions {
929            let chunk_len = usize::min(end - start, remaining);
930            result.extend(content_chars[start..start + chunk_len].iter());
931            remaining -= chunk_len;
932            if remaining == 0 {
933                break;
934            }
935        }
936        return result;
937    }
938
939    build_result_with_attack_regions_and_context(
940        &content_chars,
941        &attack_regions,
942        max_full_scan_bytes,
943    )
944}
945
946// ---------------------------------------------------------------------------
947// pipelines (ContentPreprocessor methods)
948// ---------------------------------------------------------------------------
949
950/// `decode_common_encodings`: up to 16 fixed-point decode iterations, then a
951/// single SQL-comment strip. Returns `(content, decode_budget_exhausted)`.
952#[must_use]
953pub fn decode_common_encodings_with_budget(content: &str) -> (String, bool) {
954    let mut current = content.to_owned();
955    let mut gunzip_attempts_left = MAX_GUNZIP_ATTEMPTS_PER_PASS;
956    let mut iterations = 0usize;
957
958    while iterations < MAX_DECODE_ITERATIONS {
959        let original = current.clone();
960        current = decode_overlong_utf8_percent_runs(&current);
961        current = percent_unquote_ignore(&current);
962        current = html_escape::decode_html_entities(&current).into_owned();
963        current = decode_percent_u_escapes(&current);
964        current = decode_hex_escapes(&current);
965        current = decode_ldap_hex_escapes(&current);
966        current = decode_unicode_escapes(&current);
967        current = normalize_unicode(&current);
968        current = decode_base64_candidates(&current, &mut gunzip_attempts_left);
969        if current == original {
970            break;
971        }
972        iterations += 1;
973    }
974    let exhausted = iterations == MAX_DECODE_ITERATIONS;
975    (strip_sql_comments(&current), exhausted)
976}
977
978/// Legacy entry kept for the ledger verification tooling.
979#[must_use]
980pub fn decode_common_encodings(content: &str) -> String {
981    decode_common_encodings_with_budget(content).0
982}
983
984/// `preprocess_with_decoded`: `(processed, decoded, decode_budget_exhausted)`.
985#[must_use]
986pub fn preprocess_with_decoded(
987    content: &str,
988    max_full_scan_bytes: usize,
989    preserve_attacks: bool,
990    max_content_length: usize,
991) -> (String, String, bool) {
992    if content.is_empty() {
993        return (String::new(), String::new(), false);
994    }
995
996    let decoded = normalize_unicode(content);
997    let (decoded, exhausted) = decode_common_encodings_with_budget(&decoded);
998    let processed = remove_null_bytes(&decoded);
999    let processed = collapse_whitespace(&processed);
1000    let processed = truncate_safely(
1001        &processed,
1002        max_full_scan_bytes,
1003        preserve_attacks,
1004        max_content_length,
1005    );
1006
1007    (processed, decoded, exhausted)
1008}
1009
1010/// `preprocess_signal_preserving`: raw view (no decoding).
1011#[must_use]
1012pub fn preprocess_signal_preserving(
1013    content: &str,
1014    max_full_scan_bytes: usize,
1015    preserve_attacks: bool,
1016    max_content_length: usize,
1017) -> String {
1018    if content.is_empty() {
1019        return String::new();
1020    }
1021    let normalized = normalize_unicode(content);
1022    truncate_safely(
1023        &normalized,
1024        max_full_scan_bytes,
1025        preserve_attacks,
1026        max_content_length,
1027    )
1028}
1029
1030/// `preprocess`: full processed view (legacy entry point).
1031#[must_use]
1032pub fn preprocess(content: &str, max_full_scan_bytes: usize, preserve_attacks: bool) -> String {
1033    preprocess_with_decoded(
1034        content,
1035        max_full_scan_bytes,
1036        preserve_attacks,
1037        DEFAULT_MAX_CONTENT_LENGTH,
1038    )
1039    .0
1040}
1041
1042pub const DEFAULT_MAX_CONTENT_LENGTH: usize = 10_000;
1043
1044/// `preprocess_short_base64_additive_view`: decoded fragments of short
1045/// base64-looking tokens joined with newlines.
1046#[must_use]
1047pub fn short_base64_additive_view(
1048    content: &str,
1049    max_full_scan_bytes: usize,
1050    preserve_attacks: bool,
1051    max_content_length: usize,
1052) -> String {
1053    static TOKEN_RE: LazyLock<Regex> =
1054        LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/]{4,}").expect("static regex"));
1055    static MARKERS: [char; 4] = ['$', '{', '}', '#'];
1056    const MAX_TOKEN_CHARS: usize = 11;
1057    const MAX_CANDIDATES: usize = 20_000;
1058    const RATIO: f64 = 0.95;
1059
1060    if content.is_empty() {
1061        return String::new();
1062    }
1063    let normalized = normalize_unicode(content);
1064    let truncated = truncate_safely(
1065        &normalized,
1066        max_full_scan_bytes,
1067        preserve_attacks,
1068        max_content_length,
1069    );
1070
1071    let mut fragments: Vec<String> = Vec::new();
1072    for (attempts, m) in TOKEN_RE.find_iter(&truncated).enumerate() {
1073        if attempts >= MAX_CANDIDATES {
1074            break;
1075        }
1076        let token = m.as_str();
1077        if token.chars().count() > MAX_TOKEN_CHARS {
1078            continue;
1079        }
1080        let Some(decoded) = short_token_decode(token) else {
1081            continue;
1082        };
1083        if printable_ratio(&decoded) < RATIO {
1084            continue;
1085        }
1086        if !decoded.chars().any(|c| MARKERS.contains(&c)) {
1087            continue;
1088        }
1089        fragments.push(decoded);
1090    }
1091    fragments.join("\n")
1092}
1093
1094fn short_token_decode(token: &str) -> Option<String> {
1095    let raw = b64_decode_strict(token)?;
1096    String::from_utf8(raw).ok()
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102
1103    #[test]
1104    fn unicode_normalization() {
1105        assert_eq!(normalize_unicode("\u{FF0F}"), "/");
1106        assert_eq!(normalize_unicode("\u{200B}test\u{200C}"), "test");
1107        assert_eq!(normalize_unicode("\u{FF1C}script\u{FF1E}"), "<script>");
1108        assert_eq!(normalize_unicode("\u{FF1B}\u{FF5C}\u{FF06}"), ";|&");
1109    }
1110
1111    #[test]
1112    fn whitespace_and_null_bytes() {
1113        assert_eq!(
1114            collapse_whitespace("test  multiple   spaces"),
1115            "test multiple spaces"
1116        );
1117        assert_eq!(
1118            collapse_whitespace("  leading trailing  "),
1119            "leading trailing"
1120        );
1121        assert_eq!(remove_null_bytes("test\x00null\x00bytes"), "testnullbytes");
1122    }
1123
1124    #[test]
1125    fn decode_common_encodings_variants() {
1126        // URL
1127        assert_eq!(decode_common_encodings("%3Cscript%3E"), "<script>");
1128        // HTML
1129        assert_eq!(decode_common_encodings("&lt;script&gt;"), "<script>");
1130        // double-encoded
1131        assert_eq!(decode_common_encodings("%253Cscript%253E"), "<script>");
1132        // hex escape
1133        assert_eq!(decode_common_encodings(r"\x3Cscript\x3E"), "<script>");
1134        // inner-word SQL comments are preserved for the sqli obfuscation scan
1135        let r = decode_common_encodings("SEL/**/ECT * FR/**/OM users");
1136        assert!(r.contains("SEL/**/ECT"), "got: {r}");
1137    }
1138
1139    #[test]
1140    fn sql_comment_markers_become_spaces() {
1141        assert_eq!(strip_sql_comments("' OR 1=1-- rest"), "' OR 1=1  rest");
1142        assert_eq!(strip_sql_comments("# heading"), "  heading");
1143        assert_eq!(strip_sql_comments("a -- b # c"), "a   b   c");
1144        // standalone block comment keeps its body between spaces
1145        assert_eq!(strip_sql_comments("x /* note */ y"), "x   note   y");
1146        // `/*!` is protected
1147        assert_eq!(strip_sql_comments(" /*!5 */"), " /*!5 */");
1148    }
1149
1150    #[test]
1151    fn base64_candidates_decode() {
1152        let mut gunzip = 8;
1153        assert_eq!(
1154            decode_base64_candidates("PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", &mut gunzip),
1155            "<script>alert(1)</script>"
1156        );
1157        // uuid stays unchanged
1158        assert_eq!(
1159            decode_base64_candidates("550e8400-e29b-41d4-a716-446655440000", &mut gunzip),
1160            "550e8400-e29b-41d4-a716-446655440000"
1161        );
1162        // short token stays unchanged
1163        assert_eq!(
1164            decode_base64_candidates("aGVsbG8=", &mut gunzip),
1165            "aGVsbG8="
1166        );
1167    }
1168
1169    #[test]
1170    fn overlong_utf8_runs() {
1171        // %C0%AF is an overlong '/' in strict UTF-8
1172        assert_eq!(decode_overlong_utf8_percent_runs("%C0%AF"), "/");
1173        // valid runs stay untouched
1174        assert_eq!(decode_overlong_utf8_percent_runs("%C3%A9"), "%C3%A9");
1175    }
1176
1177    #[test]
1178    fn ldap_hex_escapes() {
1179        assert_eq!(decode_ldap_hex_escapes(r"\41"), "A");
1180        assert_eq!(decode_hex_escapes(r"\x41"), "A");
1181        assert_eq!(decode_ldap_hex_escapes(r"\x41"), r"\x41");
1182    }
1183
1184    #[test]
1185    fn full_preprocess_pipeline() {
1186        let zwsp = '\u{200B}';
1187        let fullwidth_slash = '\u{FF0F}';
1188        let content = format!(
1189            "{zwsp}<script>{fullwidth_slash}alert(1)</script>  multiple   spaces %3Cimg%3E\x00null"
1190        );
1191        let result = preprocess(&content, 262_144, true);
1192
1193        assert!(!result.contains('\u{200B}'));
1194        assert!(!result.contains('\u{FF0F}'));
1195        assert!(!result.contains("  "));
1196        assert!(result.contains("<img>"));
1197        assert!(!result.contains('\x00'));
1198    }
1199
1200    #[test]
1201    fn preprocess_empty() {
1202        assert_eq!(preprocess("", 262_144, true), "");
1203    }
1204
1205    #[test]
1206    fn markdown_heading_survives_as_reference() {
1207        // '#' becomes a space, then whitespace collapses and strips
1208        let processed = preprocess("# Title\n\n- item one\n- item two", 262_144, true);
1209        assert_eq!(processed, "Title - item one - item two");
1210    }
1211
1212    #[test]
1213    fn extract_attack_regions_padded() {
1214        let content = format!(
1215            "{}<script>alert(1)</script>{}",
1216            "a".repeat(150),
1217            "b".repeat(150)
1218        );
1219        let regions = extract_attack_regions(&content, 10_000);
1220        assert_eq!(regions.len(), 1);
1221        assert_eq!(regions[0], (50, 257));
1222    }
1223    #[test]
1224    fn b64_span_after_multibyte_no_underflow() {
1225        // A 12+ char base64 run preceded by multibyte content: the run's byte
1226        // offset exceeds its char index, so run_end(char) - start(byte) would
1227        // underflow. Regression for the fuzz-found panic (unit mismatch).
1228        let content = "h\u{e9}llo ++++++++++++++++++++++++++++++++++++";
1229        let _ = decode_common_encodings(content);
1230    }
1231}