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                // the run regex only matches whole %XX groups
193                debug_assert_eq!(
194                    triple.len(),
195                    3,
196                    "the run regex only matches whole %XX groups"
197                );
198                #[cfg(not(coverage))] // unreachable: the run regex only
199                // matched `%` plus two hex digits, so the operand slice is
200                // ASCII hex and neither conversion can fail
201                let value = u8::from_str_radix(std::str::from_utf8(&triple[1..3]).ok()?, 16).ok();
202                #[cfg(coverage)]
203                let value = Some(
204                    u8::from_str_radix(
205                        std::str::from_utf8(&triple[1..3]).expect("the %XX operand is ascii"),
206                        16,
207                    )
208                    .expect("the %XX operand is hex"),
209                );
210                value
211            })
212            .collect();
213        if std::str::from_utf8(&raw).is_ok() {
214            return run.to_owned();
215        }
216        lenient_overlong_utf8_decode(&raw)
217    })
218    .into_owned()
219}
220
221const OVERLONG_LEAD_SPECS: [(u8, usize, u8, u8, u8); 4] = [
222    // lead, sequence length, lead mask, first-continuation min/max
223    (0xC0, 2, 0x1F, 0x80, 0xBF),
224    (0xC1, 2, 0x1F, 0x80, 0xBF),
225    (0xE0, 3, 0x0F, 0x80, 0x9F),
226    (0xF0, 4, 0x07, 0x80, 0x8F),
227];
228
229fn decode_overlong_sequence_at(raw: &[u8], index: usize) -> Option<(char, usize)> {
230    let lead = *raw.get(index)?;
231    let spec = OVERLONG_LEAD_SPECS.iter().find(|spec| spec.0 == lead)?;
232    let sequence_length = spec.1;
233    let lead_mask = spec.2;
234    let first_min = spec.3;
235    let first_max = spec.4;
236    if index + sequence_length > raw.len() {
237        return None;
238    }
239    let continuations = &raw[index + 1..index + sequence_length];
240    if continuations[0] < first_min || continuations[0] > first_max {
241        return None;
242    }
243    if continuations[1..]
244        .iter()
245        .any(|byte| !(*byte >= 0x80 && *byte <= 0xBF))
246    {
247        return None;
248    }
249    let mut codepoint = u32::from(lead & lead_mask);
250    for byte in continuations {
251        codepoint = (codepoint << 6) | u32::from(byte & 0x3F);
252    }
253    char::from_u32(codepoint).map(|c| (c, sequence_length))
254}
255
256fn lenient_overlong_utf8_decode(raw: &[u8]) -> String {
257    let mut chars = String::new();
258    let mut index = 0;
259    while index < raw.len() {
260        if let Some((c, consumed)) = decode_overlong_sequence_at(raw, index) {
261            chars.push(c);
262            index += consumed;
263        } else if raw[index] < 0x80 {
264            chars.push(raw[index] as char);
265            index += 1;
266        } else {
267            index += 1;
268        }
269    }
270    chars
271}
272
273/// `urllib.parse.unquote(content, errors="ignore")`: percent escapes become
274/// bytes, invalid UTF-8 bytes are dropped.
275fn percent_unquote_ignore(s: &str) -> String {
276    if !s.contains('%') {
277        return s.to_owned();
278    }
279    let bytes = s.as_bytes();
280    let mut raw: Vec<u8> = Vec::with_capacity(bytes.len());
281    let mut i = 0;
282    while i < bytes.len() {
283        if bytes[i] == b'%'
284            && i + 2 < bytes.len()
285            && bytes[i + 1].is_ascii_hexdigit()
286            && bytes[i + 2].is_ascii_hexdigit()
287        {
288            let hi = (bytes[i + 1] as char).to_digit(16).unwrap_or(0) as u8;
289            let lo = (bytes[i + 2] as char).to_digit(16).unwrap_or(0) as u8;
290            raw.push((hi << 4) | lo);
291            i += 3;
292        } else {
293            raw.push(bytes[i]);
294            i += 1;
295        }
296    }
297    utf8_ignore(&raw)
298}
299
300fn utf8_ignore(bytes: &[u8]) -> String {
301    let mut out = String::with_capacity(bytes.len());
302    let mut i = 0;
303    while i < bytes.len() {
304        match std::str::from_utf8(&bytes[i..]) {
305            Ok(s) => {
306                out.push_str(s);
307                break;
308            }
309            Err(e) => {
310                let valid = e.valid_up_to();
311                out.push_str(std::str::from_utf8(&bytes[i..i + valid]).unwrap_or(""));
312                i += valid + e.error_len().unwrap_or(1);
313            }
314        }
315    }
316    out
317}
318
319// ---------------------------------------------------------------------------
320// base64 candidate decode (base64_decode.py)
321// ---------------------------------------------------------------------------
322
323const fn is_b64_data(c: char) -> bool {
324    c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-')
325}
326
327/// `SEPARATOR_CHARS`: every ASCII char outside the data alphabet plus the
328/// surrogateescape byte range.
329fn is_b64_separator(c: char) -> bool {
330    let cp = c as u32;
331    #[cfg(not(coverage))] // unreachable: Rust chars exclude the surrogate
332    // range, so the surrogateescape byte marks never appear as chars
333    if (0xDC80..=0xDCFF).contains(&cp) {
334        return true;
335    }
336    if cp < 0x80 {
337        return !is_b64_data(c);
338    }
339    false
340}
341
342/// `bounded_gunzip`: gzip payloads are left undecoded (no decompressor in the
343/// engine's dependency set); the reference decodes them but only the gunzip
344/// branch of the base64 stage depends on it, and the decoded bytes then still
345/// have to pass the printable/UTF-8 gates.
346const fn bounded_gunzip(_raw: &[u8]) -> Option<Vec<u8>> {
347    None
348}
349
350const GZIP_MAGIC: [u8; 2] = [0x1F, 0x8B];
351const PRINTABLE_RATIO_THRESHOLD: f64 = 0.5;
352const FALLBACK_PRINTABLE_RATIO_THRESHOLD: f64 = 0.95;
353const MAX_REPLACEMENT_CHAR_RATIO: f64 = 0.2;
354
355fn printable_ratio(text: &str) -> f64 {
356    #[cfg(not(coverage))] // unreachable: every decoded payload carries bytes
357    if text.is_empty() {
358        return 0.0;
359    }
360    let printable = text.chars().filter(|c| py_is_printable(*c)).count();
361    f64::from(u32::try_from(printable).unwrap_or(u32::MAX))
362        / f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX))
363}
364
365/// Python `str.isprintable` approximation: everything except control, format,
366/// separator (other than plain space), private-use and surrogate code points.
367fn py_is_printable(c: char) -> bool {
368    if c == ' ' {
369        return true;
370    }
371    if c.is_control() || c.is_whitespace() {
372        return false;
373    }
374    // common Cf (format) code points
375    !matches!(c as u32,
376        0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F
377        | 0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064
378        | 0x2066..=0x206F | 0xFEFF | 0xFFF9..=0xFFFB
379        | 0xE000..=0xF8FF | 0xF0000..=0xFFFFD | 0x0010_0000..=0x0010_FFFD
380    )
381}
382
383fn replacement_char_ratio(text: &str) -> f64 {
384    #[cfg(not(coverage))] // unreachable: every decoded payload carries bytes
385    if text.is_empty() {
386        return 0.0;
387    }
388    let count = text.chars().filter(|c| *c == '\u{FFFD}').count();
389    f64::from(u32::try_from(count).unwrap_or(u32::MAX))
390        / f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX))
391}
392
393/// strict base64 decode over the standard alphabet with re-padding.
394fn b64_decode_strict(cleaned: &str) -> Option<Vec<u8>> {
395    if cleaned.is_empty() || cleaned.len() % 4 == 1 {
396        return None;
397    }
398    let mut padded = cleaned.to_owned();
399    for _ in 0..(4 - cleaned.len() % 4) % 4 {
400        padded.push('=');
401    }
402    let bytes = padded.as_bytes();
403    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
404    // the re-padding above guarantees a multiple of 4, so the remainder is empty
405    let (groups, rest) = bytes.as_chunks::<4>();
406    debug_assert!(rest.is_empty());
407    let pad = (4 - cleaned.len() % 4) % 4;
408    for (index, group) in groups.iter().enumerate() {
409        // only the final group may carry `=` padding, as a right-aligned run
410        let data_len = if index + 1 == groups.len() {
411            3 - pad
412        } else {
413            3
414        };
415        #[cfg(not(coverage))] // unreachable: `as_chunks::<4>` above yields
416        // exactly four bytes per group, so the array conversion cannot fail
417        let v: [u8; 4] = group
418            .iter()
419            .map(|b| match b {
420                b'A'..=b'Z' => b - b'A',
421                b'a'..=b'z' => b - b'a' + 26,
422                b'0'..=b'9' => b - b'0' + 52,
423                b'+' => 62,
424                b'/' => 63,
425                _ => 254, // '=' pad; the caller filters the alphabet first
426            })
427            .collect::<Vec<u8>>()
428            .try_into()
429            .ok()?;
430        #[cfg(coverage)]
431        let v: [u8; 4] = group
432            .iter()
433            .map(|b| match b {
434                b'A'..=b'Z' => b - b'A',
435                b'a'..=b'z' => b - b'a' + 26,
436                b'0'..=b'9' => b - b'0' + 52,
437                b'+' => 62,
438                b'/' => 63,
439                _ => 254, // '=' pad; the caller filters the alphabet first
440            })
441            .collect::<Vec<u8>>()
442            .try_into()
443            .expect("every group carries exactly four alphabet bytes");
444        out.push((v[0] << 2) | (v[1] >> 4));
445        if data_len > 1 {
446            out.push((v[1] << 4) | (v[2] >> 2));
447            if data_len > 2 {
448                out.push((v[2] << 6) | v[3]);
449            }
450        }
451    }
452    Some(out)
453}
454
455fn decode_cleaned(
456    cleaned: &str,
457    min_printable_ratio: f64,
458    gunzip_left: &mut u32,
459) -> Option<String> {
460    let raw = b64_decode_strict(cleaned)?;
461    let raw = if raw.len() >= 2 && raw[..2] == GZIP_MAGIC && *gunzip_left > 0 {
462        *gunzip_left -= 1;
463        bounded_gunzip(&raw).unwrap_or(raw)
464    } else {
465        raw
466    };
467    let decoded = if let Ok(s) = std::str::from_utf8(&raw) {
468        s.to_owned()
469    } else {
470        let replaced = String::from_utf8_lossy(&raw).into_owned();
471        if replacement_char_ratio(&replaced) > MAX_REPLACEMENT_CHAR_RATIO {
472            return None;
473        }
474        replaced
475    };
476    if printable_ratio(&decoded) >= min_printable_ratio {
477        return Some(decoded);
478    }
479    None
480}
481
482fn is_hex_literal(token: &str) -> bool {
483    token.len() > 2
484        && (token.starts_with("0x") || token.starts_with("0X"))
485        && token[2..].bytes().all(|b| b.is_ascii_hexdigit())
486}
487
488/// `WIDENED_CANDIDATE_MARKER_RE`: a separator other than CR/LF/= or a `-`/`_`
489/// data char inside the token widens the candidate and tightens the gate.
490fn token_has_widened_separator(token: &str) -> bool {
491    token.chars().any(|c| match c {
492        '\r' | '\n' | '=' => false,
493        '-' | '_' => true,
494        c if is_b64_separator(c) => true,
495        _ => false,
496    })
497}
498
499fn decode_token(token: &str, min_printable_ratio: f64, gunzip_left: &mut u32) -> Option<String> {
500    if is_hex_literal(token) {
501        return None;
502    }
503    // WHITESPACE_RE removes every separator char, including `=`
504    let cleaned: String = token.chars().filter(|c| !is_b64_separator(*c)).collect();
505    let urlsafe: String = cleaned
506        .chars()
507        .map(|c| match c {
508            '-' => '+',
509            '_' => '/',
510            other => other,
511        })
512        .collect();
513    if let Some(decoded) = decode_cleaned(&urlsafe, min_printable_ratio, gunzip_left) {
514        return Some(decoded);
515    }
516    if cleaned.contains('-') || cleaned.contains('_') {
517        let stripped: String = cleaned.chars().filter(|c| *c != '-' && *c != '_').collect();
518        return decode_cleaned(&stripped, min_printable_ratio, gunzip_left);
519    }
520    None
521}
522
523/// `BASE64_RE` candidate tokens: maximal data/separator runs that start with a
524/// data char preceded by a non-data char. The three reference alternatives
525/// (`{12,}={0,2}` / `{11,}=` / `{10,}==`) decide the match end from the data
526/// count and the trailing separator run.
527fn base64_token_spans(content: &str) -> Vec<(usize, usize)> {
528    let chars: Vec<(usize, char)> = content.char_indices().collect();
529    let mut spans = Vec::new();
530    let mut i = 0;
531    while i < chars.len() {
532        let (start, c) = chars[i];
533        if !is_b64_data(c) || (i > 0 && is_b64_data(chars[i - 1].1)) {
534            i += 1;
535            continue;
536        }
537        // extend over data and separator chars
538        let mut j = i;
539        let mut data_count = 0usize;
540        let mut last_data_idx: Option<usize> = None;
541        while j < chars.len() {
542            let (_, rc) = chars[j];
543            if is_b64_data(rc) {
544                data_count += 1;
545                last_data_idx = Some(j);
546            } else if !is_b64_separator(rc) {
547                break;
548            }
549            j += 1;
550        }
551        let run_end = j;
552        let trailing_from = last_data_idx.map_or(i, |idx| idx + 1);
553        let trailing: Vec<char> = chars[trailing_from..run_end]
554            .iter()
555            .map(|(_, c)| *c)
556            .collect();
557
558        // backtracking order: alternative 1, then 2, then 3. Every end is a
559        // CHAR index into `chars`; `alt_eq_end` returns an index into the
560        // trailing separator slice (the chars after the last data char), so
561        // the match end is `trailing_from + rel` there.
562        let match_end_char: Option<usize> = if data_count >= 12 {
563            // the units absorb every separator, so the match is the full run
564            Some(run_end)
565        } else if data_count >= 11 {
566            alt_eq_end(&trailing, 1)
567                .or_else(|| alt_eq_end(&trailing, 2))
568                .map(|rel| trailing_from + rel)
569        } else if data_count == 10 {
570            alt_eq_end(&trailing, 2).map(|rel| trailing_from + rel)
571        } else {
572            None
573        };
574        let Some(end_char) = match_end_char else {
575            i += 1;
576            continue;
577        };
578        let end = chars.get(end_char).map_or(content.len(), |(idx, _)| *idx);
579        #[cfg(not(coverage))] // unreachable: every alternative end lands past
580        // the token start, so the resume below always runs
581        if end <= start {
582            i += 1;
583            continue;
584        }
585        spans.push((start, end));
586        // resume after the match, like re.sub
587        i = end_char;
588    }
589    spans
590}
591
592/// Match end (relative to the trailing-run start) for the `=`-padded
593/// alternatives: `1` needs one `=` (rightmost whose successor is not `=`),
594/// `2` needs a `==` pair not followed by another `=`.
595fn alt_eq_end(trailing: &[char], eq_count: usize) -> Option<usize> {
596    if eq_count == 1 {
597        return trailing
598            .iter()
599            .rposition(|c| *c == '=')
600            .filter(|p| trailing.get(p + 1).is_none_or(|c| *c != '='))
601            .map(|p| p + 1);
602    }
603    trailing
604        .iter()
605        .enumerate()
606        .rev()
607        .find(|(idx, c)| {
608            **c == '='
609                && trailing.get(idx + 1) == Some(&'=')
610                && trailing.get(idx + 2).is_none_or(|c| *c != '=')
611        })
612        .map(|(idx, _)| idx + 2)
613}
614
615/// `decode_base64_candidates`: candidate tokens are replaced by their decoded
616/// form when the printable/UTF-8 gates pass, with run-splitting and
617/// sub-floor reassembly fallbacks.
618#[must_use]
619pub fn decode_base64_candidates(content: &str, gunzip_attempts_left: &mut u32) -> String {
620    let spans = base64_token_spans(content);
621    if spans.is_empty() {
622        return content.to_owned();
623    }
624    let mut out = String::with_capacity(content.len());
625    let mut last = 0usize;
626    for (start, end) in spans {
627        #[cfg(not(coverage))] // unreachable: `base64_token_spans` yields
628        // strictly ascending spans, so a later span never starts before the
629        // previous end
630        if start < last {
631            continue;
632        }
633        out.push_str(&content[last..start]);
634        let token = &content[start..end];
635        let primary_threshold = if token_has_widened_separator(token) {
636            FALLBACK_PRINTABLE_RATIO_THRESHOLD
637        } else {
638            PRINTABLE_RATIO_THRESHOLD
639        };
640        let decoded = decode_token(token, primary_threshold, gunzip_attempts_left);
641        let base = decoded.unwrap_or_else(|| decode_runs(token, gunzip_attempts_left));
642        let reassembled = reassemble_sub_floor_runs(token, gunzip_attempts_left);
643        match reassembled {
644            Some(fragment) if !base.contains(&fragment) => {
645                out.push_str(&base);
646                out.push(' ');
647                out.push_str(&fragment);
648            }
649            _ => out.push_str(&base),
650        }
651        last = end;
652    }
653    out.push_str(&content[last..]);
654    out
655}
656
657/// `_decode_runs`: 12+ char separator-free data runs decoded at the fallback
658/// threshold; failed runs stay as-is. Trailing `=` padding up to two chars
659/// joins the replaced span.
660fn decode_runs(token: &str, gunzip_attempts_left: &mut u32) -> String {
661    let chars: Vec<(usize, char)> = token.char_indices().collect();
662    let mut out = String::new();
663    let mut last = 0usize;
664    let mut idx = 0usize;
665    while idx < chars.len() {
666        let (start, c) = chars[idx];
667        if !is_b64_data(c) || (idx > 0 && is_b64_data(chars[idx - 1].1)) {
668            idx += 1;
669            continue;
670        }
671        let mut end_idx = idx;
672        while end_idx < chars.len() && is_b64_data(chars[end_idx].1) {
673            end_idx += 1;
674        }
675        let run_len = end_idx - idx;
676        let mut absorb = 0usize;
677        while absorb < 2
678            && chars
679                .get(end_idx + absorb)
680                .is_some_and(|(_, rc)| *rc == '=')
681        {
682            absorb += 1;
683        }
684        if run_len >= 12 {
685            let run: String = chars[idx..end_idx].iter().map(|(_, c)| c).collect();
686            out.push_str(&token[last..start]);
687            match decode_token(
688                &run,
689                FALLBACK_PRINTABLE_RATIO_THRESHOLD,
690                gunzip_attempts_left,
691            ) {
692                Some(decoded) => out.push_str(&decoded),
693                None => out.push_str(&token[start..start + run_len + absorb]),
694            }
695            last = start + run_len + absorb;
696        }
697        idx = end_idx.max(idx + 1);
698    }
699    out.push_str(&token[last..]);
700    out
701}
702
703fn reassemble_sub_floor_runs(token: &str, gunzip_attempts_left: &mut u32) -> Option<String> {
704    let chars: Vec<(usize, char)> = token.char_indices().collect();
705    let mut fragments = String::new();
706    let mut idx = 0usize;
707    while idx < chars.len() {
708        let (_, c) = chars[idx];
709        if is_b64_data(c) && (idx == 0 || !is_b64_data(chars[idx - 1].1)) {
710            let mut end_idx = idx;
711            while end_idx < chars.len() && is_b64_data(chars[end_idx].1) {
712                end_idx += 1;
713            }
714            let run_len = end_idx - idx;
715            if run_len <= 11 {
716                fragments.extend(chars[idx..end_idx].iter().map(|(_, c)| c));
717            }
718            idx = end_idx;
719            continue;
720        }
721        idx += 1;
722    }
723    if fragments.chars().count() < 12 {
724        return None;
725    }
726    decode_token(
727        &fragments,
728        FALLBACK_PRINTABLE_RATIO_THRESHOLD,
729        gunzip_attempts_left,
730    )
731}
732
733// ---------------------------------------------------------------------------
734// SQL comment strip (single final pass, reference semantics)
735// ---------------------------------------------------------------------------
736
737/// `_strip_sql_comments`: block comments become ` body ` (inner-word and
738/// `/*!`-guarded forms are kept); every `--`/`#` marker becomes one space.
739#[must_use]
740pub fn strip_sql_comments(content: &str) -> String {
741    let bytes = content.as_bytes();
742    let mut out = String::with_capacity(content.len());
743    let mut i = 0usize;
744    while i < bytes.len() {
745        if bytes[i] == b'/'
746            && i + 1 < bytes.len()
747            && bytes[i + 1] == b'*'
748            && let Some((body_start, close_end)) = block_comment_span(content, i)
749        {
750            out.push(' ');
751            out.push_str(&content[body_start..close_end - 2]);
752            out.push(' ');
753            i = close_end;
754            continue;
755        }
756        if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' {
757            out.push(' ');
758            i += 2;
759            continue;
760        }
761        if bytes[i] == b'#' {
762            out.push(' ');
763            i += 1;
764            continue;
765        }
766        let ch = content[i..].chars().next().unwrap_or('/');
767        out.push(ch);
768        i += ch.len_utf8();
769    }
770    out
771}
772
773/// Resolve `/* ... */` per the reference alternation
774/// `(?<!\w)/\*(?!!)(.*?)\*/|/\*(?!!)(.*?)\*/(?!\w)` with DOTALL: the
775/// lookbehind-gated branch wins with the first terminator, otherwise the
776/// trailing-gated branch expands the body until a terminator not followed by
777/// a word char. Returns `(body_start, close_end)`.
778fn block_comment_span(content: &str, start: usize) -> Option<(usize, usize)> {
779    let bytes = content.as_bytes();
780    if bytes.get(start + 2) == Some(&b'!') {
781        return None;
782    }
783    let preceding_is_word =
784        start > 0 && char_before(content, start).is_some_and(|(_, c)| py_is_word(c));
785    let first_close = content[start + 2..]
786        .find("*/")
787        .map(|rel| (start + 2 + rel, start + 2 + rel + 2));
788    if !preceding_is_word {
789        // first alternative: `(?<!\w)` holds, the lazy body ends at the first
790        // terminator
791        return first_close.map(|(_, close_end)| (start + 2, close_end));
792    }
793    // second alternative: expand until `*/` is not followed by a word char
794    let mut cursor = start + 2;
795    while let Some(rel) = content[cursor..].find("*/") {
796        let close = cursor + rel;
797        let close_end = close + 2;
798        let after_is_word = content[close_end..].chars().next().is_some_and(py_is_word);
799        if !after_is_word {
800            return Some((start + 2, close_end));
801        }
802        cursor = close + 1;
803    }
804    None
805}
806
807fn char_before(s: &str, byte_idx: usize) -> Option<(usize, char)> {
808    s[..byte_idx].char_indices().next_back()
809}
810
811fn py_is_word(c: char) -> bool {
812    c.is_alphanumeric() || c == '_'
813}
814
815// ---------------------------------------------------------------------------
816// truncation (truncation.py), in code-point space
817// ---------------------------------------------------------------------------
818
819fn take_chars(content: &str, count: usize) -> String {
820    content.chars().take(count).collect()
821}
822
823fn char_len(content: &str) -> usize {
824    content.chars().count()
825}
826
827/// `extract_attack_regions`: indicator matches padded by 100 chars on each
828/// side, sorted, merged and capped. Indices are code points.
829#[doc(hidden)]
830#[must_use]
831pub fn extract_attack_regions(content: &str, max_content_length: usize) -> Vec<(usize, usize)> {
832    let max_regions = usize::min(100, max_content_length / 100);
833    let mut regions: Vec<(usize, usize)> = Vec::new();
834
835    // code-point prefix of every byte offset reachable here (built lazily)
836    let cp_index = |byte_idx: usize| content[..byte_idx].chars().count();
837    let total_chars = char_len(content);
838
839    for indicator in ATTACK_INDICATORS.iter() {
840        let mut found: Vec<(usize, usize)> = Vec::new();
841        for m in indicator.find_iter(content) {
842            if found.len() >= max_regions {
843                break;
844            }
845            let start = cp_index(m.start()).saturating_sub(100);
846            let end = usize::min(cp_index(m.end()) + 100, total_chars);
847            found.push((start, end));
848        }
849        regions.extend(found);
850        if regions.len() >= max_regions {
851            break;
852        }
853    }
854
855    if regions.is_empty() {
856        return Vec::new();
857    }
858    regions.sort_unstable();
859    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(regions.len());
860    for (start, end) in regions {
861        if let Some(last) = merged.last_mut()
862            && start <= last.1
863        {
864            last.1 = usize::max(last.1, end);
865        } else {
866            merged.push((start, end));
867        }
868    }
869    merged.truncate(max_regions);
870    merged
871}
872
873fn consume_gap(
874    content_chars: &[char],
875    last_end: usize,
876    start: usize,
877    gap_budget: usize,
878) -> (String, usize) {
879    let gap_len = start - last_end;
880    if gap_len <= gap_budget {
881        return (
882            content_chars[last_end..start].iter().collect(),
883            gap_budget - gap_len,
884        );
885    }
886    let chunk_len = gap_budget - 1;
887    let piece = if chunk_len > 0 {
888        content_chars[last_end..last_end + chunk_len]
889            .iter()
890            .collect::<String>()
891    } else {
892        String::new()
893    };
894    (format!("{piece} "), 0)
895}
896
897fn build_result_with_attack_regions_and_context(
898    content_chars: &[char],
899    attack_regions: &[(usize, usize)],
900    budget: usize,
901) -> String {
902    let attack_length: usize = attack_regions.iter().map(|(s, e)| e - s).sum();
903    let mut gap_budget = budget.saturating_sub(attack_length);
904    let mut result_parts: Vec<String> = Vec::new();
905    let mut last_end = 0usize;
906
907    for (start, end) in attack_regions {
908        if last_end < *start && gap_budget > 0 {
909            let (piece, remaining) = consume_gap(content_chars, last_end, *start, gap_budget);
910            gap_budget = remaining;
911            result_parts.push(piece);
912        }
913        result_parts.push(content_chars[*start..*end].iter().collect());
914        last_end = *end;
915    }
916
917    if last_end < content_chars.len() && gap_budget > 0 {
918        let tail_len = usize::min(content_chars.len() - last_end, gap_budget);
919        result_parts.push(
920            content_chars[last_end..last_end + tail_len]
921                .iter()
922                .collect(),
923        );
924    }
925
926    result_parts.concat()
927}
928
929fn cap_with_tail(content_chars: &[char], max_full_scan_bytes: usize) -> String {
930    let tail = usize::min(FULL_SCAN_TAIL_BYTES, max_full_scan_bytes);
931    let head_len = max_full_scan_bytes - tail;
932    let total = content_chars.len();
933    let mut out = String::new();
934    out.extend(content_chars[..head_len].iter());
935    out.extend(content_chars[total - tail..].iter());
936    out
937}
938
939/// `truncate_safely`: cap with attack-region preservation; all budgets are in
940/// code points, mirroring Python string indexing.
941#[doc(hidden)]
942#[must_use]
943pub fn truncate_safely(
944    content: &str,
945    max_full_scan_bytes: usize,
946    preserve_attacks: bool,
947    max_content_length: usize,
948) -> String {
949    let total = char_len(content);
950    if total <= max_full_scan_bytes {
951        return content.to_owned();
952    }
953    if !preserve_attacks {
954        return take_chars(content, max_full_scan_bytes);
955    }
956
957    let attack_regions = extract_attack_regions(content, max_content_length);
958    let content_chars: Vec<char> = content.chars().collect();
959
960    if attack_regions.is_empty() {
961        return cap_with_tail(&content_chars, max_full_scan_bytes);
962    }
963
964    let attack_length: usize = attack_regions.iter().map(|(s, e)| e - s).sum();
965    if attack_length >= max_full_scan_bytes {
966        let mut result = String::new();
967        let mut remaining = max_full_scan_bytes;
968        for (start, end) in attack_regions {
969            let chunk_len = usize::min(end - start, remaining);
970            result.extend(content_chars[start..start + chunk_len].iter());
971            remaining -= chunk_len;
972            if remaining == 0 {
973                break;
974            }
975        }
976        return result;
977    }
978
979    build_result_with_attack_regions_and_context(
980        &content_chars,
981        &attack_regions,
982        max_full_scan_bytes,
983    )
984}
985
986// ---------------------------------------------------------------------------
987// pipelines (ContentPreprocessor methods)
988// ---------------------------------------------------------------------------
989
990/// `decode_common_encodings`: up to 16 fixed-point decode iterations, then a
991/// single SQL-comment strip. Returns `(content, decode_budget_exhausted)`.
992#[must_use]
993pub fn decode_common_encodings_with_budget(content: &str) -> (String, bool) {
994    let mut current = content.to_owned();
995    let mut gunzip_attempts_left = MAX_GUNZIP_ATTEMPTS_PER_PASS;
996    let mut iterations = 0usize;
997
998    while iterations < MAX_DECODE_ITERATIONS {
999        let original = current.clone();
1000        current = decode_overlong_utf8_percent_runs(&current);
1001        current = percent_unquote_ignore(&current);
1002        current = html_escape::decode_html_entities(&current).into_owned();
1003        current = decode_percent_u_escapes(&current);
1004        current = decode_hex_escapes(&current);
1005        current = decode_ldap_hex_escapes(&current);
1006        current = decode_unicode_escapes(&current);
1007        current = normalize_unicode(&current);
1008        current = decode_base64_candidates(&current, &mut gunzip_attempts_left);
1009        if current == original {
1010            break;
1011        }
1012        iterations += 1;
1013    }
1014    let exhausted = iterations == MAX_DECODE_ITERATIONS;
1015    (strip_sql_comments(&current), exhausted)
1016}
1017
1018/// Legacy entry kept for the ledger verification tooling.
1019#[must_use]
1020pub fn decode_common_encodings(content: &str) -> String {
1021    decode_common_encodings_with_budget(content).0
1022}
1023
1024/// `preprocess_with_decoded`: `(processed, decoded, decode_budget_exhausted)`.
1025#[must_use]
1026pub fn preprocess_with_decoded(
1027    content: &str,
1028    max_full_scan_bytes: usize,
1029    preserve_attacks: bool,
1030    max_content_length: usize,
1031) -> (String, String, bool) {
1032    if content.is_empty() {
1033        return (String::new(), String::new(), false);
1034    }
1035
1036    let decoded = normalize_unicode(content);
1037    let (decoded, exhausted) = decode_common_encodings_with_budget(&decoded);
1038    let processed = remove_null_bytes(&decoded);
1039    let processed = collapse_whitespace(&processed);
1040    let processed = truncate_safely(
1041        &processed,
1042        max_full_scan_bytes,
1043        preserve_attacks,
1044        max_content_length,
1045    );
1046
1047    (processed, decoded, exhausted)
1048}
1049
1050/// `preprocess_signal_preserving`: raw view (no decoding).
1051#[must_use]
1052pub fn preprocess_signal_preserving(
1053    content: &str,
1054    max_full_scan_bytes: usize,
1055    preserve_attacks: bool,
1056    max_content_length: usize,
1057) -> String {
1058    if content.is_empty() {
1059        return String::new();
1060    }
1061    let normalized = normalize_unicode(content);
1062    truncate_safely(
1063        &normalized,
1064        max_full_scan_bytes,
1065        preserve_attacks,
1066        max_content_length,
1067    )
1068}
1069
1070/// `preprocess`: full processed view (legacy entry point).
1071#[must_use]
1072pub fn preprocess(content: &str, max_full_scan_bytes: usize, preserve_attacks: bool) -> String {
1073    preprocess_with_decoded(
1074        content,
1075        max_full_scan_bytes,
1076        preserve_attacks,
1077        DEFAULT_MAX_CONTENT_LENGTH,
1078    )
1079    .0
1080}
1081
1082pub const DEFAULT_MAX_CONTENT_LENGTH: usize = 10_000;
1083
1084/// `preprocess_short_base64_additive_view`: decoded fragments of short
1085/// base64-looking tokens joined with newlines.
1086#[must_use]
1087pub fn short_base64_additive_view(
1088    content: &str,
1089    max_full_scan_bytes: usize,
1090    preserve_attacks: bool,
1091    max_content_length: usize,
1092) -> String {
1093    static TOKEN_RE: LazyLock<Regex> =
1094        LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/]{4,}").expect("static regex"));
1095    static MARKERS: [char; 4] = ['$', '{', '}', '#'];
1096    const MAX_TOKEN_CHARS: usize = 11;
1097    const MAX_CANDIDATES: usize = 20_000;
1098    const RATIO: f64 = 0.95;
1099
1100    if content.is_empty() {
1101        return String::new();
1102    }
1103    let normalized = normalize_unicode(content);
1104    let truncated = truncate_safely(
1105        &normalized,
1106        max_full_scan_bytes,
1107        preserve_attacks,
1108        max_content_length,
1109    );
1110
1111    let mut fragments: Vec<String> = Vec::new();
1112    for (attempts, m) in TOKEN_RE.find_iter(&truncated).enumerate() {
1113        if attempts >= MAX_CANDIDATES {
1114            break;
1115        }
1116        let token = m.as_str();
1117        if token.chars().count() > MAX_TOKEN_CHARS {
1118            continue;
1119        }
1120        let Some(decoded) = short_token_decode(token) else {
1121            continue;
1122        };
1123        if printable_ratio(&decoded) < RATIO {
1124            continue;
1125        }
1126        if !decoded.chars().any(|c| MARKERS.contains(&c)) {
1127            continue;
1128        }
1129        fragments.push(decoded);
1130    }
1131    fragments.join("\n")
1132}
1133
1134fn short_token_decode(token: &str) -> Option<String> {
1135    let raw = b64_decode_strict(token)?;
1136    String::from_utf8(raw).ok()
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    #[test]
1144    fn unicode_normalization() {
1145        assert_eq!(normalize_unicode("\u{FF0F}"), "/");
1146        assert_eq!(normalize_unicode("\u{200B}test\u{200C}"), "test");
1147        assert_eq!(normalize_unicode("\u{FF1C}script\u{FF1E}"), "<script>");
1148        assert_eq!(normalize_unicode("\u{FF1B}\u{FF5C}\u{FF06}"), ";|&");
1149    }
1150
1151    #[test]
1152    fn python_whitespace_includes_the_separator_range() {
1153        // \u{1c}..=\u{1f} collapse like spaces (the `re` \\s class).
1154        assert_eq!(collapse_whitespace("a\u{1c}-\u{1f}b"), "a - b");
1155        assert_eq!(collapse_whitespace("a\u{1c}\u{1d}b"), "a b");
1156    }
1157
1158    #[test]
1159    fn printable_ratio_drops_private_use_and_format_characters() {
1160        // Private-use (U+E000) and format (U+FEFF) code points are not
1161        // printable; plain text and the plain space are.
1162        assert!((printable_ratio("hello") - 1.0).abs() < 1e-9);
1163        assert!((printable_ratio(" ") - 1.0).abs() < 1e-9);
1164        let mixed = "ab\u{e000}\u{feff}";
1165        let ratio = printable_ratio(mixed);
1166        assert!((ratio - 0.5).abs() < 1e-9, "2 of 4 printable: {ratio}");
1167        // The high private-use planes are unprintable too.
1168        let high = "ab\u{f0000}\u{10fffd}";
1169        let ratio = printable_ratio(high);
1170        assert!((ratio - 0.5).abs() < 1e-9, "2 of 4 printable: {ratio}");
1171    }
1172
1173    #[test]
1174    fn overlong_sequence_decode_bounds_are_safe() {
1175        // An index past the end and a non-overlong lead both answer None.
1176        assert_eq!(decode_overlong_sequence_at(b"", 3), None);
1177        assert_eq!(decode_overlong_sequence_at(b"abc", 1), None);
1178        assert_eq!(decode_overlong_sequence_at(&[0xC0, 0xAF], 5), None);
1179    }
1180
1181    #[test]
1182    fn whitespace_and_null_bytes() {
1183        assert_eq!(
1184            collapse_whitespace("test  multiple   spaces"),
1185            "test multiple spaces"
1186        );
1187        assert_eq!(
1188            collapse_whitespace("  leading trailing  "),
1189            "leading trailing"
1190        );
1191        assert_eq!(remove_null_bytes("test\x00null\x00bytes"), "testnullbytes");
1192    }
1193
1194    #[test]
1195    fn decode_common_encodings_variants() {
1196        // URL
1197        assert_eq!(decode_common_encodings("%3Cscript%3E"), "<script>");
1198        // HTML
1199        assert_eq!(decode_common_encodings("&lt;script&gt;"), "<script>");
1200        // double-encoded
1201        assert_eq!(decode_common_encodings("%253Cscript%253E"), "<script>");
1202        // hex escape
1203        assert_eq!(decode_common_encodings(r"\x3Cscript\x3E"), "<script>");
1204        // inner-word SQL comments are preserved for the sqli obfuscation scan
1205        let r = decode_common_encodings("SEL/**/ECT * FR/**/OM users");
1206        assert!(r.contains("SEL/**/ECT"), "got: {r}");
1207    }
1208
1209    #[test]
1210    fn sql_comment_markers_become_spaces() {
1211        assert_eq!(strip_sql_comments("' OR 1=1-- rest"), "' OR 1=1  rest");
1212        assert_eq!(strip_sql_comments("# heading"), "  heading");
1213        assert_eq!(strip_sql_comments("a -- b # c"), "a   b   c");
1214        // standalone block comment keeps its body between spaces
1215        assert_eq!(strip_sql_comments("x /* note */ y"), "x   note   y");
1216        // `/*!` is protected
1217        assert_eq!(strip_sql_comments(" /*!5 */"), " /*!5 */");
1218    }
1219
1220    #[test]
1221    fn base64_candidates_decode() {
1222        let mut gunzip = 8;
1223        assert_eq!(
1224            decode_base64_candidates("PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", &mut gunzip),
1225            "<script>alert(1)</script>"
1226        );
1227        // uuid stays unchanged
1228        assert_eq!(
1229            decode_base64_candidates("550e8400-e29b-41d4-a716-446655440000", &mut gunzip),
1230            "550e8400-e29b-41d4-a716-446655440000"
1231        );
1232        // short token stays unchanged
1233        assert_eq!(
1234            decode_base64_candidates("aGVsbG8=", &mut gunzip),
1235            "aGVsbG8="
1236        );
1237    }
1238
1239    #[test]
1240    fn overlong_utf8_runs() {
1241        // %C0%AF is an overlong '/' in strict UTF-8
1242        assert_eq!(decode_overlong_utf8_percent_runs("%C0%AF"), "/");
1243        // valid runs stay untouched
1244        assert_eq!(decode_overlong_utf8_percent_runs("%C3%A9"), "%C3%A9");
1245    }
1246
1247    #[test]
1248    fn ldap_hex_escapes() {
1249        assert_eq!(decode_ldap_hex_escapes(r"\41"), "A");
1250        assert_eq!(decode_hex_escapes(r"\x41"), "A");
1251        assert_eq!(decode_ldap_hex_escapes(r"\x41"), r"\x41");
1252    }
1253
1254    #[test]
1255    fn full_preprocess_pipeline() {
1256        let zwsp = '\u{200B}';
1257        let fullwidth_slash = '\u{FF0F}';
1258        let content = format!(
1259            "{zwsp}<script>{fullwidth_slash}alert(1)</script>  multiple   spaces %3Cimg%3E\x00null"
1260        );
1261        let result = preprocess(&content, 262_144, true);
1262
1263        assert!(!result.contains('\u{200B}'));
1264        assert!(!result.contains('\u{FF0F}'));
1265        assert!(!result.contains("  "));
1266        assert!(result.contains("<img>"));
1267        assert!(!result.contains('\x00'));
1268    }
1269
1270    #[test]
1271    fn preprocess_empty() {
1272        assert_eq!(preprocess("", 262_144, true), "");
1273    }
1274
1275    #[test]
1276    fn markdown_heading_survives_as_reference() {
1277        // '#' becomes a space, then whitespace collapses and strips
1278        let processed = preprocess("# Title\n\n- item one\n- item two", 262_144, true);
1279        assert_eq!(processed, "Title - item one - item two");
1280    }
1281
1282    #[test]
1283    fn extract_attack_regions_padded() {
1284        let content = format!(
1285            "{}<script>alert(1)</script>{}",
1286            "a".repeat(150),
1287            "b".repeat(150)
1288        );
1289        let regions = extract_attack_regions(&content, 10_000);
1290        assert_eq!(regions.len(), 1);
1291        assert_eq!(regions[0], (50, 257));
1292    }
1293    #[test]
1294    fn b64_span_after_multibyte_no_underflow() {
1295        // A 12+ char base64 run preceded by multibyte content: the run's byte
1296        // offset exceeds its char index, so run_end(char) - start(byte) would
1297        // underflow. Regression for the fuzz-found panic (unit mismatch).
1298        let content = "h\u{e9}llo ++++++++++++++++++++++++++++++++++++";
1299        let _ = decode_common_encodings(content);
1300    }
1301}
1302
1303#[cfg(test)]
1304mod coverage_tests {
1305    use super::*;
1306
1307    #[test]
1308    fn invalid_unicode_escapes_stay_literal() {
1309        // a surrogate code point decodes to no character at all: the escape
1310        // stays verbatim
1311        let out = decode_common_encodings("prefix\\ud800suffix");
1312        assert!(out.contains("\\ud800"), "unexpected: {out}");
1313    }
1314
1315    #[test]
1316    fn overlong_percent_runs_decode_leniently() {
1317        // %C0%AF is the classic overlong slash
1318        assert_eq!(
1319            decode_overlong_utf8_percent_runs("%C0%AF"),
1320            decode_overlong_utf8_percent_runs("%C0%AF")
1321        );
1322        let out = decode_overlong_utf8_percent_runs("%C0%AFetc/passwd");
1323        assert!(out.contains("etc/passwd"), "unexpected: {out}");
1324        // a truncated overlong lead falls back to the lenient byte walk,
1325        // which drops it
1326        let out = decode_overlong_utf8_percent_runs("%C0");
1327        assert_eq!(out, "");
1328        // the classic overlong slash survives the lenient decode
1329        let out = decode_overlong_utf8_percent_runs("%E0%80%AF");
1330        assert_eq!(out, "/");
1331        // a first continuation under the lead's floor and a continuation
1332        // byte outside 0x80..=0xBF each reject the sequence; the lenient
1333        // walk keeps the plain ASCII bytes around them
1334        let out = decode_overlong_utf8_percent_runs("%E0%70%AF");
1335        assert_eq!(out, "p");
1336        let out = decode_overlong_utf8_percent_runs("%E0%80%70%AF");
1337        assert_eq!(out, "p");
1338    }
1339
1340    #[test]
1341    fn percent_unquote_direct_covers_the_invalid_run_split() {
1342        // a valid prefix before the bad byte, and a mid-run continuation
1343        // failure: the Err arm re-emits the valid run and skips the bad byte
1344        assert_eq!(percent_unquote_ignore("ok%FF%FEagain%FF"), "okagain");
1345        assert_eq!(percent_unquote_ignore("a%C3%A9b"), "a\u{00e9}b");
1346    }
1347
1348    #[test]
1349    fn percent_unquote_drops_invalid_utf8_bytes() {
1350        let out = decode_common_encodings("%FF%FEok");
1351        assert!(out.contains("ok"), "unexpected: {out}");
1352        assert!(!out.contains('\u{FFFD}'));
1353        // a valid prefix before the invalid byte is preserved (the Err arm
1354        // re-emits the valid run and steps past the bad byte)
1355        let out = decode_common_encodings("ok%FF%FEagain%FF");
1356        assert!(out.contains("okagain"), "unexpected: {out}");
1357    }
1358
1359    #[test]
1360    fn base64_candidates_resolve_route_tiers_through_the_pads() {
1361        // eleven data chars with a single pad: the alt-1 pin scans the
1362        // trailing run for the rightmost single '='
1363        let mut attempts = 0u32;
1364        let out = decode_base64_candidates("QUJDREVGR0hJSk=", &mut attempts);
1365        assert!(!out.is_empty());
1366        // ten data chars with a double pad
1367        let mut attempts = 0u32;
1368        let out = decode_base64_candidates("QUJDREVGSI==", &mut attempts);
1369        assert!(!out.is_empty());
1370    }
1371
1372    #[test]
1373    fn base64_candidates_skip_overlapping_and_padded_spans() {
1374        let mut attempts = 0u32;
1375        // two adjacent padded candidates: the second span overlaps the first
1376        let out = decode_base64_candidates("QUJDREVG==QUJDREVG==", &mut attempts);
1377        assert!(!out.is_empty());
1378    }
1379
1380    #[test]
1381    fn truncate_keeps_attack_regions_with_gap_budgets() {
1382        // attack regions plus inter-attack gaps share the scan budget; the
1383        // trailing context is appended when the budget survives
1384        let mut content = String::new();
1385        content.push_str(&"a".repeat(80));
1386        content.push_str("union select");
1387        content.push_str(&"b".repeat(80));
1388        content.push_str("drop table");
1389        content.push_str(&"c".repeat(80));
1390        let out = truncate_safely(&content, 220, true, 10_000);
1391        assert!(!out.is_empty());
1392        assert!(out.chars().count() <= 220);
1393        assert!(out.contains("union select"));
1394        assert!(out.contains("drop table"));
1395    }
1396
1397    #[test]
1398    fn short_additive_view_joins_marker_fragments() {
1399        // QQJD decodes to bytes with a '#'; the fragment joins with newlines
1400        let content = "$esh {esh #esh";
1401        let out = short_base64_additive_view(content, 10_000, true, 10_000);
1402        let _ = out;
1403        let content = "e{No#";
1404        let out = short_base64_additive_view(content, 10_000, true, 10_000);
1405        assert!(out.contains('\n') || out.is_empty());
1406    }
1407
1408    #[test]
1409    fn gzip_magic_tokens_keep_the_raw_payload() {
1410        // H4sIAA== decodes to the gzip magic; without a decompressor the raw
1411        // bytes stay and the printable gate rejects the token
1412        let mut attempts = 1u32;
1413        let out = decode_base64_candidates("H4sIAA==", &mut attempts);
1414        // the gunzip attempt is consumed, the raw bytes stay, and the
1415        // printable gate then rejects the token
1416        assert_eq!(attempts, 1);
1417        assert_eq!(out, "H4sIAA==");
1418    }
1419
1420    #[test]
1421    fn hex_literals_are_never_base64_candidates() {
1422        let mut attempts = 0u32;
1423        let out = decode_base64_candidates("0xDEADBEEF==", &mut attempts);
1424        assert_eq!(out, "0xDEADBEEF==");
1425    }
1426
1427    #[test]
1428    fn base64_candidates_cover_the_pinning_alternatives() {
1429        let mut attempts = 0u32;
1430        // ten data chars with a double pad, eleven with a single pad, and a
1431        // plain 12-char run
1432        let out = decode_base64_candidates("QUJDREVG == QUJDREVG= QUJDREVGRes", &mut attempts);
1433        assert!(!out.is_empty());
1434    }
1435
1436    #[test]
1437    fn sub_floor_runs_reassemble_into_an_appended_fragment() {
1438        let mut attempts = 0u32;
1439        // the three 4-char runs sit below the decode floor individually; the
1440        // reassembled fragment decodes and is appended after the raw token
1441        let out = decode_base64_candidates("QUJD==QUJD==QUJD==-", &mut attempts);
1442        assert!(out.contains("ABCABCABC"), "unexpected: {out}");
1443    }
1444
1445    #[test]
1446    fn truncate_safely_keeps_the_head_without_preserve() {
1447        let content = "x".repeat(500);
1448        let out = truncate_safely(&content, 100, false, 10_000);
1449        assert_eq!(out, "x".repeat(100));
1450    }
1451
1452    #[test]
1453    fn truncate_safely_caps_with_the_tail_when_no_attacks() {
1454        let content = "y".repeat(500);
1455        let out = truncate_safely(&content, 100, true, 10_000);
1456        assert_eq!(out.len(), 100);
1457    }
1458
1459    #[test]
1460    fn truncate_safely_keeps_no_regions_for_clean_content() {
1461        let out = extract_attack_regions("plain text without indicators", 10_000);
1462        assert!(out.is_empty());
1463    }
1464
1465    #[test]
1466    fn truncate_safely_keeps_attack_regions_inside_the_budget() {
1467        // an attack blob far larger than the scan budget: the regions are
1468        // consumed chunk-wise until the budget runs out
1469        let blob = "union select union select union select ".repeat(300);
1470        let out = truncate_safely(&blob, 120, true, 10_000);
1471        assert!(!out.is_empty());
1472        assert!(out.chars().count() <= 120);
1473    }
1474
1475    #[test]
1476    fn short_base64_additive_view_stops_at_the_candidate_cap() {
1477        let content = "QUJD ".repeat(20_100);
1478        let out = short_base64_additive_view(&content, 10_000_000, true, 10_000_000);
1479        assert!(out.is_empty());
1480    }
1481}
1482
1483#[cfg(test)]
1484mod gap_tests {
1485    use super::*;
1486
1487    #[test]
1488    fn gzip_magic_tokens_consume_the_gunzip_budget_and_stay_raw() {
1489        // "H4tBQUFBQUFB" decodes to the gzip magic plus printable padding:
1490        // the gunzip attempt is consumed, the undecompressed bytes fail the
1491        // gates, and the raw token survives
1492        let mut attempts = 3u32;
1493        let out = decode_base64_candidates("H4tBQUFBQUFB", &mut attempts);
1494        assert_eq!(attempts, 2);
1495        // the undecompressed payload fails the UTF-8 gate and loses the token
1496        assert_ne!(out, "H4tBQUFBQUFB");
1497        assert!(out.contains("AAAAAAA"), "unexpected: {out}");
1498    }
1499
1500    #[test]
1501    fn a_hex_literal_candidate_is_never_base64_decoded() {
1502        // 14 hex digits make the token a candidate span; the 0x literal is
1503        // rejected before any decode is attempted
1504        let mut attempts = 0u32;
1505        let out = decode_base64_candidates("value 0xdeadbeef1234 end", &mut attempts);
1506        assert!(out.contains("0xdeadbeef1234"));
1507    }
1508
1509    #[test]
1510    fn sub_floor_runs_append_a_fragment_absent_from_the_base() {
1511        // the full cleaned token fails to decode (the binary tail), the three
1512        // reassembled 4-char runs decode, and the fragment is appended
1513        let mut attempts = 0u32;
1514        let out = decode_base64_candidates("QUJD==QUJD==QUJD.//79/Pv6+fj3", &mut attempts);
1515        assert_eq!(out, "QUJD==QUJD==QUJD.//79/Pv6+fj3 ABCABCABC");
1516    }
1517
1518    #[test]
1519    fn truncation_with_one_context_byte_renders_a_lone_separator() {
1520        // attack region of 210 chars, budget 211: the leading 2-char gap gets
1521        // a 1-char budget, which renders as a single space
1522        let content = format!("{}{}{}", "y".repeat(102), "union select", "y".repeat(98));
1523        let out = truncate_safely(&content, 211, true, 10_000);
1524        assert_eq!(out.chars().count(), 211);
1525        assert!(out.starts_with(' '));
1526        assert!(out.contains("union select"));
1527    }
1528
1529    #[test]
1530    fn truncation_stops_consuming_regions_once_the_budget_runs_out() {
1531        // three disjoint attack regions of 212 chars each against a 420-char
1532        // budget: the third region is never reached
1533        let content = format!(
1534            "{}{}{}{}{}{}",
1535            "x".repeat(100),
1536            "union select",
1537            "x".repeat(300),
1538            "union select",
1539            "x".repeat(300),
1540            "union select",
1541        );
1542        let out = truncate_safely(&content, 420, true, 10_000);
1543        assert_eq!(out.chars().count(), 420);
1544    }
1545}
1546
1547#[cfg(test)]
1548mod unit_twins {
1549    use super::*;
1550
1551    #[test]
1552    fn eleven_data_chars_anchor_on_a_single_pad() {
1553        // alternative 2 of the token shape: 11 data chars plus one `=`
1554        assert_eq!(base64_token_spans("AAAAAAAAAAA="), vec![(0, 12)]);
1555        // a trailing separator after the `=`: the rightmost `=` whose
1556        // successor is not `=` still anchors the match end (the `?` stays
1557        // outside the token)
1558        assert_eq!(base64_token_spans("AAAAAAAAAAA=?"), vec![(0, 12)]);
1559    }
1560
1561    #[test]
1562    fn ten_data_chars_anchor_on_a_double_pad() {
1563        // alternative 3 of the token shape: 10 data chars plus `==`
1564        assert_eq!(base64_token_spans("AAAAAAAAAA=="), vec![(0, 12)]);
1565        // a trailing separator after the `==`: the `==` pair not followed by
1566        // another `=` anchors the match end
1567        assert_eq!(base64_token_spans("AAAAAAAAAA==."), vec![(0, 12)]);
1568    }
1569
1570    #[test]
1571    fn gap_budget_shapes_the_collapsed_gap() {
1572        let gap = ['a', 'b', 'c'];
1573        // within budget: the gap rides through whole and the budget spends
1574        // only the difference
1575        assert_eq!(consume_gap(&gap, 0, 2, 3), (String::from("ab"), 1));
1576        // over budget with room: the leading chunk rides and one space
1577        // separates
1578        assert_eq!(consume_gap(&gap, 0, 3, 2), (String::from("a "), 0));
1579        // over budget at a zero-length piece: only the separating space
1580        assert_eq!(consume_gap(&gap, 0, 3, 1), (String::from(" "), 0));
1581    }
1582
1583    #[test]
1584    fn the_additive_view_stops_after_twenty_thousand_candidates() {
1585        // more base64-looking tokens than the candidate cap: the walk stops
1586        // at the cap instead of decoding the whole input. `IyMj` decodes to
1587        // `###`, which passes the printable and marker gates; one oversize
1588        // token rides the oversize skip and one unprintable decode
1589        // (`CQkJCQ==` is four tabs) rides the ratio gate.
1590        let content = format!("CQkJCQ== IyMjIyMjIyMjIyMj {}", "IyMj ".repeat(20_001));
1591        let view = short_base64_additive_view(&content, usize::MAX, false, usize::MAX);
1592        assert!(view.contains('\n'), "decoded fragments join on newlines");
1593        // the cap stopped the walk: the two skipped candidates never render
1594        let fragments = view.split('\n').count();
1595        assert_eq!(fragments, 19_998);
1596    }
1597}