1use 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
18const 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#[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
97const 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
136fn 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: ®ex::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#[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: ®ex::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 debug_assert_eq!(
194 triple.len(),
195 3,
196 "the run regex only matches whole %XX groups"
197 );
198 #[cfg(not(coverage))] 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 (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
273fn 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
319const fn is_b64_data(c: char) -> bool {
324 c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-')
325}
326
327fn is_b64_separator(c: char) -> bool {
330 let cp = c as u32;
331 #[cfg(not(coverage))] if (0xDC80..=0xDCFF).contains(&cp) {
334 return true;
335 }
336 if cp < 0x80 {
337 return !is_b64_data(c);
338 }
339 false
340}
341
342const 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))] 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
365fn 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 !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))] 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
393fn 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 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 let data_len = if index + 1 == groups.len() {
411 3 - pad
412 } else {
413 3
414 };
415 #[cfg(not(coverage))] 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, })
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, })
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
488fn 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 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
523fn 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 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 let match_end_char: Option<usize> = if data_count >= 12 {
563 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))] if end <= start {
582 i += 1;
583 continue;
584 }
585 spans.push((start, end));
586 i = end_char;
588 }
589 spans
590}
591
592fn 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#[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))] 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
657fn 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#[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
773fn 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 return first_close.map(|(_, close_end)| (start + 2, close_end));
792 }
793 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
815fn 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#[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 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#[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#[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(¤t);
1001 current = percent_unquote_ignore(¤t);
1002 current = html_escape::decode_html_entities(¤t).into_owned();
1003 current = decode_percent_u_escapes(¤t);
1004 current = decode_hex_escapes(¤t);
1005 current = decode_ldap_hex_escapes(¤t);
1006 current = decode_unicode_escapes(¤t);
1007 current = normalize_unicode(¤t);
1008 current = decode_base64_candidates(¤t, &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(¤t), exhausted)
1016}
1017
1018#[must_use]
1020pub fn decode_common_encodings(content: &str) -> String {
1021 decode_common_encodings_with_budget(content).0
1022}
1023
1024#[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#[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#[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#[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 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 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 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 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 assert_eq!(decode_common_encodings("%3Cscript%3E"), "<script>");
1198 assert_eq!(decode_common_encodings("<script>"), "<script>");
1200 assert_eq!(decode_common_encodings("%253Cscript%253E"), "<script>");
1202 assert_eq!(decode_common_encodings(r"\x3Cscript\x3E"), "<script>");
1204 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 assert_eq!(strip_sql_comments("x /* note */ y"), "x note y");
1216 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 assert_eq!(
1229 decode_base64_candidates("550e8400-e29b-41d4-a716-446655440000", &mut gunzip),
1230 "550e8400-e29b-41d4-a716-446655440000"
1231 );
1232 assert_eq!(
1234 decode_base64_candidates("aGVsbG8=", &mut gunzip),
1235 "aGVsbG8="
1236 );
1237 }
1238
1239 #[test]
1240 fn overlong_utf8_runs() {
1241 assert_eq!(decode_overlong_utf8_percent_runs("%C0%AF"), "/");
1243 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 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 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 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 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 let out = decode_overlong_utf8_percent_runs("%C0");
1327 assert_eq!(out, "");
1328 let out = decode_overlong_utf8_percent_runs("%E0%80%AF");
1330 assert_eq!(out, "/");
1331 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 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 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 let mut attempts = 0u32;
1364 let out = decode_base64_candidates("QUJDREVGR0hJSk=", &mut attempts);
1365 assert!(!out.is_empty());
1366 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 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 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 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 let mut attempts = 1u32;
1413 let out = decode_base64_candidates("H4sIAA==", &mut attempts);
1414 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 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 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 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 let mut attempts = 3u32;
1493 let out = decode_base64_candidates("H4tBQUFBQUFB", &mut attempts);
1494 assert_eq!(attempts, 2);
1495 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 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 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 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 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 assert_eq!(base64_token_spans("AAAAAAAAAAA="), vec![(0, 12)]);
1555 assert_eq!(base64_token_spans("AAAAAAAAAAA=?"), vec![(0, 12)]);
1559 }
1560
1561 #[test]
1562 fn ten_data_chars_anchor_on_a_double_pad() {
1563 assert_eq!(base64_token_spans("AAAAAAAAAA=="), vec![(0, 12)]);
1565 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 assert_eq!(consume_gap(&gap, 0, 2, 3), (String::from("ab"), 1));
1576 assert_eq!(consume_gap(&gap, 0, 3, 2), (String::from("a "), 0));
1579 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 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 let fragments = view.split('\n').count();
1595 assert_eq!(fragments, 19_998);
1596 }
1597}