Skip to main content

guard_core_engine/patterns/
shell_validators.rs

1//! Candidate-rejection validators ported from
2//! `guard_core/handlers/_suspatterns_shell_validators.py` (spec 4.0.2).
3//!
4//! A validator receives a candidate match plus the detection context and
5//! decides whether the match is a true injection; returning false rejects the
6//! candidate and lets the scan continue with later matches.
7
8use super::chars_util::char_before;
9use super::pyregex::PyRegex;
10
11pub const AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS: &[&str] = &["query_param", "url_path"];
12
13const STRONG_SQL_KEYWORD_GLUED_PREFIX_RE: &str =
14    r"\b(?:SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|VALUES|ORDER\s+BY|GROUP\s+BY)\z";
15const STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE: &str =
16    r"\A(?:SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|VALUES|ORDER\s+BY|GROUP\s+BY)\b";
17const BARE_SHELL_PARAMETER_NAME_RE: &str =
18    r"\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z";
19
20const BACKTICK_WINDOW_DELIMITERS: &[char] = &['`', '\'', '"', '\n', '\r'];
21
22const fn is_ascii_word(c: char) -> bool {
23    c.is_ascii_alphanumeric() || c == '_'
24}
25
26fn shell_text_is_printable_ascii(token: &str) -> bool {
27    token
28        .chars()
29        .all(|c| c == '\t' || ('\u{20}'..='\u{7e}').contains(&c))
30}
31
32#[allow(clippy::missing_const_for_fn)] // clippy version drift: newer lints flag these
33fn backtick_token_has_chained_shell_operators(token: &str) -> bool {
34    let mut count = 0usize;
35    let bytes = token.as_bytes();
36    let mut i = 0usize;
37    while i < bytes.len() {
38        match bytes[i] {
39            b';' => {
40                count += 1;
41                i += 1;
42            }
43            b'|' | b'&' => {
44                count += 1;
45                if i + 1 < bytes.len() && bytes[i + 1] == bytes[i] {
46                    i += 2;
47                } else {
48                    i += 1;
49                }
50            }
51            _ => i += 1,
52        }
53    }
54    count >= 2
55}
56
57fn backtick_pair_glued(content: &str, start: usize, end: usize) -> bool {
58    let prefix_glued =
59        start > 0 && char_before(content, start).is_some_and(|(_, c)| is_ascii_word(c));
60    let suffix_glued = content[end..].chars().next().is_some_and(is_ascii_word);
61    prefix_glued || suffix_glued
62}
63
64const BACKTICK_CLAUSE_BOUNDARY_CHARS: &[char] = &['.', '!', '?', ';', '&', '|'];
65
66fn backtick_pair_tail_anchored(content: &str, end: usize) -> bool {
67    content[end..].trim().is_empty()
68}
69
70fn backtick_pair_clause_initial(content: &str, start: usize) -> bool {
71    if start == 0 {
72        return false;
73    }
74    if !char_before(content, start).is_some_and(|(_, c)| matches!(c, ' ' | '\t' | '\r' | '\n')) {
75        return false;
76    }
77    let prefix = content[..start].trim_end();
78    if prefix.is_empty() {
79        return false;
80    }
81    prefix
82        .chars()
83        .next_back()
84        .is_some_and(|c| BACKTICK_CLAUSE_BOUNDARY_CHARS.contains(&c))
85}
86
87fn backtick_pair_appended_clause(content: &str, start: usize, end: usize) -> bool {
88    backtick_pair_tail_anchored(content, end) && backtick_pair_clause_initial(content, start)
89}
90
91fn backtick_window_start(content: &str, position: usize) -> usize {
92    let mut index = position;
93    while index > 0
94        && char_before(content, index)
95            .is_some_and(|(_, c)| !BACKTICK_WINDOW_DELIMITERS.contains(&c))
96    {
97        index = char_before(content, index).map_or(0, |(i, _)| i);
98    }
99    index
100}
101
102fn backtick_window_end(content: &str, position: usize) -> usize {
103    content[position..]
104        .char_indices()
105        .find(|(_, c)| BACKTICK_WINDOW_DELIMITERS.contains(c))
106        .map_or(content.len(), |(i, _)| position + i)
107}
108
109fn backtick_pair_context_window(content: &str, start: usize, end: usize) -> &str {
110    let window_start = backtick_window_start(content, start);
111    let window_end = backtick_window_end(content, end);
112    &content[window_start..window_end]
113}
114
115fn token_is_implausible_sql_identifier(token: &str) -> bool {
116    // [\s/.;|&$()]
117    token.chars().any(|c| {
118        matches!(
119            c,
120            ' ' | '\t'
121                | '\n'
122                | '\r'
123                | '\x0b'
124                | '\x0c'
125                | '/'
126                | '.'
127                | ';'
128                | '|'
129                | '&'
130                | '$'
131                | '('
132                | ')'
133        )
134    })
135}
136
137fn strong_sql_keyword_glued_to_pair(content: &str, start: usize, end: usize) -> bool {
138    let window_start = backtick_window_start(content, start);
139    let window_end = backtick_window_end(content, end);
140    let prefix = &content[window_start..start];
141    let suffix = &content[end..window_end];
142    #[cfg(not(coverage))] // unreachable: statically valid literal
143    let Ok(prefix_re) = PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_PREFIX_RE, true) else {
144        return false;
145    };
146    #[cfg(coverage)]
147    let prefix_re =
148        PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_PREFIX_RE, true).expect("statically valid");
149    if prefix_re.re().is_match(prefix) {
150        return true;
151    }
152    #[cfg(not(coverage))] // unreachable: statically valid literal
153    let Ok(suffix_re) = PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE, true) else {
154        return false;
155    };
156    #[cfg(coverage)]
157    let suffix_re =
158        PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE, true).expect("statically valid");
159    suffix_re.re().is_match(suffix)
160}
161
162/// `_glued_backtick_pair_is_injection`.
163#[must_use]
164pub fn glued_backtick_pair_is_injection(
165    content: &str,
166    candidate: super::pyregex::Candidate,
167    request_context: &str,
168) -> bool {
169    let start = candidate.start;
170    let end = candidate.end;
171    let token = &content[start + 1..end - 1];
172    if !shell_text_is_printable_ascii(token) {
173        return false;
174    }
175    if backtick_token_has_chained_shell_operators(token) {
176        return true;
177    }
178    let appended_clause = backtick_pair_appended_clause(content, start, end);
179    if !backtick_pair_glued(content, start, end) && !appended_clause {
180        return false;
181    }
182    if token_is_implausible_sql_identifier(token) {
183        return true;
184    }
185    let window = backtick_pair_context_window(content, start, end);
186    if shell_metacharacter_window(window) {
187        return true;
188    }
189    if strong_sql_keyword_glued_to_pair(content, start, end) {
190        return false;
191    }
192    let normalized = request_context.split(':').next().unwrap_or(request_context);
193    AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS.contains(&normalized) || appended_clause
194}
195
196/// `(?:;|\|\||\||&&)\s*(?:\x60|[A-Za-z_][\w-]*|[~./][\w./-]*|-[\w-]*)|\$\(|\$\{`
197#[allow(clippy::missing_const_for_fn)] // clippy version drift: newer lints flag these
198fn shell_metacharacter_window(window: &str) -> bool {
199    let bytes = window.as_bytes();
200    let mut i = 0usize;
201    while i < bytes.len() {
202        // operator
203        let op_len = if bytes[i] == b';' {
204            1
205        } else if (bytes[i] == b'|' || bytes[i] == b'&')
206            && i + 1 < bytes.len()
207            && bytes[i + 1] == bytes[i]
208        {
209            2
210        } else if bytes[i] == b'|' || bytes[i] == b'&' {
211            1
212        } else {
213            i += 1;
214            continue;
215        };
216        let mut j = i + op_len;
217        while j < bytes.len() && (bytes[j] as char).is_whitespace() {
218            j += 1;
219        }
220        if j >= bytes.len() {
221            i += op_len;
222            continue;
223        }
224        // `\x60` (backtick) or `$(`/`${`
225        if bytes[j] == b'`' {
226            return true;
227        }
228        if bytes[j] == b'$' && j + 1 < bytes.len() && (bytes[j + 1] == b'(' || bytes[j + 1] == b'{')
229        {
230            return true;
231        }
232        // [A-Za-z_][\w-]*
233        if bytes[j] == b'_' || bytes[j].is_ascii_alphabetic() {
234            let mut k = j + 1;
235            while k < bytes.len()
236                && (bytes[k] == b'_' || bytes[k] == b'-' || bytes[k].is_ascii_alphanumeric())
237            {
238                k += 1;
239            }
240            return true;
241        }
242        // [~./][\w./-]*
243        if matches!(bytes[j], b'~' | b'.' | b'/') {
244            let mut k = j + 1;
245            while k < bytes.len()
246                && (bytes[k] == b'_'
247                    || bytes[k] == b'.'
248                    || bytes[k] == b'/'
249                    || bytes[k] == b'-'
250                    || bytes[k].is_ascii_alphanumeric())
251            {
252                k += 1;
253            }
254            return true;
255        }
256        // -[\w-]*
257        if bytes[j] == b'-' {
258            return true;
259        }
260        i = j;
261    }
262    false
263}
264
265fn dollar_substitution_token_is_implausible(token: &str, delimiter: char) -> bool {
266    let stripped = token.trim().to_lowercase();
267    if stripped == "ifs" {
268        return true;
269    }
270    if delimiter == '{' {
271        #[cfg(not(coverage))] // unreachable: statically valid literal
272        let Ok(re) = PyRegex::compile(BARE_SHELL_PARAMETER_NAME_RE, false) else {
273            return true;
274        };
275        #[cfg(coverage)]
276        let re = PyRegex::compile(BARE_SHELL_PARAMETER_NAME_RE, false).expect("statically valid");
277        return re
278            .re()
279            .find(token.trim())
280            .is_none_or(|m| m.start() != 0 || m.end() != token.trim().len());
281    }
282    // [/.;|&$()]
283    token
284        .chars()
285        .any(|c| matches!(c, '/' | '.' | ';' | '|' | '&' | '$' | '(' | ')'))
286}
287
288fn dollar_substitution_pair_backtick_quoted(content: &str, start: usize, end: usize) -> bool {
289    let prefix_quoted = start > 0 && char_before(content, start).is_some_and(|(_, c)| c == '`');
290    let suffix_quoted = content[end..].chars().next().is_some_and(|c| c == '`');
291    prefix_quoted || suffix_quoted
292}
293
294/// `_dollar_substitution_pair_is_injection`.
295#[must_use]
296pub fn dollar_substitution_pair_is_injection(
297    content: &str,
298    candidate: super::pyregex::Candidate,
299    request_context: &str,
300) -> bool {
301    let start = candidate.start;
302    let end = candidate.end;
303    if dollar_substitution_pair_backtick_quoted(content, start, end) {
304        return false;
305    }
306    let Some(delimiter) = content[start + 1..].chars().next() else {
307        return false;
308    };
309    let token = &content[start + 2..end - 1];
310    if dollar_substitution_token_is_implausible(token, delimiter) {
311        return true;
312    }
313    if strong_sql_keyword_glued_to_pair(content, start, end) {
314        return false;
315    }
316    let normalized = request_context.split(':').next().unwrap_or(request_context);
317    AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS.contains(&normalized)
318}
319
320/// `_quote_splice_token_is_dangerous_command`: >= 3 consecutive 1-char
321/// fragments when splitting the token on quote runs.
322#[must_use]
323pub fn quote_splice_token_is_dangerous_command(token: &str) -> bool {
324    let mut run = 0usize;
325    for fragment in token.split(['\'', '"']) {
326        let chars = fragment.chars().count();
327        run = if chars == 1 { run + 1 } else { 0 };
328        if run >= 3 {
329            return true;
330        }
331    }
332    false
333}
334
335const GLOB_WILDCARD_COMMAND_SUFFIX_CHARS: &[char] = &[' ', '\t', '\r', '\n', ';', '|', '&'];
336const GLOB_WILDCARD_VALUE_START_CONTEXTS: &[&str] = &["request_body"];
337
338fn glob_wildcard_token_is_word_shaped(token: &str) -> bool {
339    let chars: Vec<char> = token.chars().collect();
340    for (index, c) in chars.iter().enumerate() {
341        if *c != '?' && *c != '*' {
342            continue;
343        }
344        let mut left = 0usize;
345        let mut position = index;
346        while position > 0 && chars[position - 1].is_ascii_alphabetic() {
347            left += 1;
348            position -= 1;
349        }
350        let mut right = 0usize;
351        position = index + 1;
352        while position < chars.len() && chars[position].is_ascii_alphabetic() {
353            right += 1;
354            position += 1;
355        }
356        if left + right >= 2 {
357            return true;
358        }
359    }
360    false
361}
362
363/// `_glob_wildcard_token_is_dangerous_command`.
364#[must_use]
365pub fn glob_wildcard_token_is_dangerous_command(
366    content: &str,
367    candidate: super::pyregex::Candidate,
368    request_context: &str,
369) -> bool {
370    if !glob_wildcard_token_is_word_shaped(candidate.text(content)) {
371        return false;
372    }
373    let suffix = content[candidate.end..].chars().next();
374    if let Some(c) = suffix
375        && !GLOB_WILDCARD_COMMAND_SUFFIX_CHARS.contains(&c)
376    {
377        return false;
378    }
379    let prefix = &content[..candidate.start];
380    if glob_command_boundary_prefix(prefix) {
381        return true;
382    }
383    if GLOB_WILDCARD_VALUE_START_CONTEXTS.contains(&request_context) {
384        return prefix.trim().is_empty();
385    }
386    false
387}
388
389/// `(?:;|\|\||\||&&|\$\(|\x60)\s*$`
390fn glob_command_boundary_prefix(prefix: &str) -> bool {
391    let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
392    let Some(last) = trimmed.chars().next_back() else {
393        return false;
394    };
395    match last {
396        // `||`, `&&`, `|`, `&` all end with the same char, as do `;` and backtick
397        ';' | '`' | '|' | '&' => true,
398        '$' => trimmed.as_bytes().get(trimmed.len() - 2) == Some(&b'('),
399        _ => false,
400    }
401}
402
403/// `_brace_expansion_is_dangerous_command`: any comma-separated item is a
404/// word-shaped token containing a letter.
405#[must_use]
406pub fn brace_expansion_is_dangerous_command(candidate_text: &str) -> bool {
407    let Some(start) = candidate_text.find('{') else {
408        return false;
409    };
410    let Some(end) = candidate_text.rfind('}') else {
411        return false;
412    };
413    if end <= start {
414        return false;
415    }
416    candidate_text[start + 1..end].split(',').any(|item| {
417        !item.is_empty()
418            && item
419                .chars()
420                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '~' | '-'))
421            && item.chars().any(|c| c.is_ascii_alphabetic())
422    })
423}
424
425#[cfg(test)]
426mod tests {
427    use super::super::pyregex::Candidate;
428    use super::*;
429
430    #[test]
431    fn quote_splice_three_single_char_fragments() {
432        assert!(quote_splice_token_is_dangerous_command("a'b'c"));
433        assert!(!quote_splice_token_is_dangerous_command("ab'cd"));
434        assert!(!quote_splice_token_is_dangerous_command("it's"));
435    }
436
437    #[test]
438    fn brace_expansion_word_items() {
439        assert!(brace_expansion_is_dangerous_command(";{a,b,c}"));
440        assert!(brace_expansion_is_dangerous_command(";{echo,id}"));
441        assert!(!brace_expansion_is_dangerous_command(";{1,2,3}"));
442        // word-shaped items with dashes are still dangerous
443        assert!(brace_expansion_is_dangerous_command(";{a-b,c}"));
444    }
445
446    #[test]
447    fn glob_word_shaped_after_boundary() {
448        // `*.txt` has no adjacent letters: not word-shaped, never dangerous
449        assert!(!glob_wildcard_token_is_dangerous_command(
450            "ls *.txt",
451            Candidate::new(3, 8),
452            "unknown"
453        ));
454        // word-shaped token after a command boundary
455        assert!(glob_wildcard_token_is_dangerous_command(
456            "; file* ",
457            Candidate::new(2, 7),
458            "unknown"
459        ));
460        // word-shaped but no command boundary before it
461        assert!(!glob_wildcard_token_is_dangerous_command(
462            "file* ",
463            Candidate::new(0, 5),
464            "unknown"
465        ));
466    }
467
468    #[test]
469    fn glob_value_start_only_in_request_body() {
470        assert!(glob_wildcard_token_is_dangerous_command(
471            "file* ",
472            Candidate::new(0, 5),
473            "request_body"
474        ));
475        assert!(!glob_wildcard_token_is_dangerous_command(
476            "file* ",
477            Candidate::new(0, 5),
478            "query_param"
479        ));
480    }
481
482    #[test]
483    fn backtick_glued_injection() {
484        // glued backtick pairs only fire in the ambiguous contexts
485        let content = "x`id`y";
486        assert!(glued_backtick_pair_is_injection(
487            content,
488            Candidate::new(1, 5),
489            "query_param"
490        ));
491        assert!(!glued_backtick_pair_is_injection(
492            content,
493            Candidate::new(1, 5),
494            "request_body"
495        ));
496    }
497
498    #[test]
499    fn backtick_sql_glue_negated() {
500        let content = "SELECT`a`FROM";
501        assert!(!glued_backtick_pair_is_injection(
502            content,
503            Candidate::new(6, 9),
504            "request_body"
505        ));
506    }
507
508    #[test]
509    fn dollar_substitution_implausible() {
510        // implausible $() token (path separator inside)
511        let content = ";$(rm -rf /)";
512        assert!(dollar_substitution_pair_is_injection(
513            content,
514            Candidate::new(1, 12),
515            "request_body"
516        ));
517        // plausible $() token: only ambiguous contexts accept it
518        let content = ";$(rm -rf)";
519        assert!(dollar_substitution_pair_is_injection(
520            content,
521            Candidate::new(1, 9),
522            "query_param"
523        ));
524        let content = ";${IFS}";
525        assert!(dollar_substitution_pair_is_injection(
526            content,
527            Candidate::new(1, 7),
528            "request_body"
529        ));
530        let content = ";${PATH}";
531        assert!(!dollar_substitution_pair_is_injection(
532            content,
533            Candidate::new(1, 8),
534            "request_body"
535        ));
536    }
537}
538
539#[cfg(test)]
540mod coverage_tests {
541    use super::*;
542    use crate::patterns::pyregex::Candidate;
543
544    fn pair(content: &str) -> Candidate {
545        // the candidate spans the first `...` pair including both backticks
546        let start = content.find('`').expect("open backtick");
547        let end = content[start + 1..].find('`').expect("close backtick") + start + 2;
548        Candidate::new(start, end)
549    }
550
551    #[test]
552    fn chained_shell_operators_count_single_and_doubled() {
553        assert!(backtick_token_has_chained_shell_operators("a;b|c"));
554        assert!(backtick_token_has_chained_shell_operators("a&b&&c"));
555        assert!(!backtick_token_has_chained_shell_operators("a&b"));
556    }
557
558    #[test]
559    fn glued_backtick_pairs_reject_unprintable_tokens_and_accept_chains() {
560        // a non-printable token inside the pair is never injection
561        let content = "x`\u{7f}`y";
562        assert!(!glued_backtick_pair_is_injection(
563            content,
564            pair(content),
565            "arg"
566        ));
567        // a single-char operator chain inside a glued pair is injection
568        let content = "x`a&b&&c;d`y";
569        assert!(glued_backtick_pair_is_injection(
570            content,
571            pair(content),
572            "arg"
573        ));
574    }
575
576    #[test]
577    fn glued_backtick_pairs_classify_windows_and_contexts() {
578        // an sql identifier shape (dots/slashes) inside a glued pair
579        let content = "x`a/b.c`y";
580        assert!(glued_backtick_pair_is_injection(
581            content,
582            pair(content),
583            "arg"
584        ));
585        // a metacharacter window (operator then a word) marks injection
586        let content = "a; `id`b";
587        assert!(glued_backtick_pair_is_injection(
588            content,
589            pair(content),
590            "arg"
591        ));
592        // a strong sql keyword glued to the pair vetoes the metacharacter hit
593        let content = "select * from `id` where";
594        assert!(!glued_backtick_pair_is_injection(
595            content,
596            pair(content),
597            "arg"
598        ));
599        // the ambiguous request contexts carry an otherwise-clean pair
600        let content = "x`id`y";
601        assert!(glued_backtick_pair_is_injection(
602            content,
603            pair(content),
604            "query_param"
605        ));
606        assert!(!glued_backtick_pair_is_injection(
607            content,
608            pair(content),
609            "arg"
610        ));
611        // an appended clause marks injection regardless of context
612        let content = "end. `id`";
613        assert!(glued_backtick_pair_is_injection(
614            content,
615            pair(content),
616            "arg"
617        ));
618    }
619
620    fn dollar_pair(content: &str) -> Candidate {
621        // the candidate spans the first `$...}` substitution
622        let start = content.find('$').expect("dollar");
623        let end = content[start..].find('}').expect("brace") + start + 1;
624        Candidate::new(start, end)
625    }
626
627    #[test]
628    fn dollar_pairs_reject_backtick_quoted_and_classify_tokens() {
629        // backtick-quoted on either side never counts
630        let content = "`${IFS}`";
631        assert!(!dollar_substitution_pair_is_injection(
632            content,
633            dollar_pair(content),
634            "arg"
635        ));
636        // the IFS token is implausible in a plain dollar-paren pair
637        let content = "x ${IFS} y";
638        assert!(dollar_substitution_pair_is_injection(
639            content,
640            dollar_pair(content),
641            "arg"
642        ));
643        // a brace substitution that is not a bare parameter name
644        let content = "x ${A-B} y";
645        assert!(dollar_substitution_pair_is_injection(
646            content,
647            dollar_pair(content),
648            "arg"
649        ));
650        // a paren substitution with path separators
651        let content = "x ${/bin/sh} y";
652        assert!(dollar_substitution_pair_is_injection(
653            content,
654            dollar_pair(content),
655            "arg"
656        ));
657        // a strong sql keyword glued to the pair vetoes it
658        let content = "select ${id} from";
659        assert!(!dollar_substitution_pair_is_injection(
660            content,
661            dollar_pair(content),
662            "arg"
663        ));
664        // the ambiguous request contexts carry a clean pair
665        let content = "x ${id} y";
666        assert!(dollar_substitution_pair_is_injection(
667            content,
668            dollar_pair(content),
669            "url_path"
670        ));
671        assert!(!dollar_substitution_pair_is_injection(
672            content,
673            dollar_pair(content),
674            "arg"
675        ));
676    }
677
678    #[test]
679    fn appended_clauses_and_context_windows_cover_the_clause_edges() {
680        // an appended clause: tail anchored and clause-initial prefix
681        let content = "end. `id`";
682        assert!(glued_backtick_pair_is_injection(
683            content,
684            pair(content),
685            "arg"
686        ));
687        // start==0 is not clause-initial
688        let content = "`id` end.";
689        assert!(!glued_backtick_pair_is_injection(
690            content,
691            pair(content),
692            "arg"
693        ));
694        // a prefix ending in whitespace without a boundary char is not
695        // clause-initial either
696        let content = "word `id` end.";
697        assert!(!glued_backtick_pair_is_injection(
698            content,
699            pair(content),
700            "arg"
701        ));
702    }
703
704    #[test]
705    fn glob_wildcards_reject_unshaped_tokens_and_value_start_contexts() {
706        // the wildcard is not word-shaped (no letters around it)
707        let content = "cmd; * ";
708        assert!(!glob_wildcard_token_is_dangerous_command(
709            content,
710            Candidate::new(5, 6),
711            "arg"
712        ));
713        // a request_body value start: a clean (empty) prefix right at the
714        // value start marks the token
715        let content = "xy* ";
716        assert!(glob_wildcard_token_is_dangerous_command(
717            content,
718            Candidate::new(0, 3),
719            "request_body"
720        ));
721        // a non-boundary prefix with a plain context is clean
722        assert!(!glob_wildcard_token_is_dangerous_command(
723            content,
724            Candidate::new(0, 3),
725            "arg"
726        ));
727        // a prefix whose trim ends in the `$` of a `($` command form
728        let content = "eval ($ xy* ;";
729        let start = content.find("xy*").expect("token");
730        assert!(glob_wildcard_token_is_dangerous_command(
731            content,
732            Candidate::new(start, start + 3),
733            "arg"
734        ));
735    }
736
737    #[test]
738    fn brace_expansion_guards_reject_malformed_candidates() {
739        assert!(!brace_expansion_is_dangerous_command("no braces"));
740        assert!(!brace_expansion_is_dangerous_command("{unclosed"));
741        assert!(!brace_expansion_is_dangerous_command("}reversed{"));
742        assert!(!brace_expansion_is_dangerous_command("{}"));
743        assert!(brace_expansion_is_dangerous_command("{a,b}"));
744        assert!(!brace_expansion_is_dangerous_command("{1,2}"));
745    }
746
747    #[test]
748    fn glued_backtick_windows_cover_every_metacharacter_shape() {
749        // a doubled pipe then a word: the || arm and the word arm
750        let content = "x`a`b||cmd";
751        assert!(glued_backtick_pair_is_injection(
752            content,
753            pair(content),
754            "arg"
755        ));
756        // an operator at end-of-window: the scan advances past it and the
757        // candidate stays clean
758        assert!(!glued_backtick_pair_is_injection(
759            "x`a`b;",
760            pair("x`a`b;"),
761            "arg"
762        ));
763        // operator followed by a backtick
764        let content = "x;`a`b";
765        assert!(glued_backtick_pair_is_injection(
766            content,
767            pair(content),
768            "arg"
769        ));
770        // operator followed by a command substitution
771        let content = "x;$(a `a`b";
772        assert!(glued_backtick_pair_is_injection(
773            content,
774            pair(content),
775            "arg"
776        ));
777        // operator followed by a tilde path
778        let content = "x;~/x `a`b";
779        assert!(glued_backtick_pair_is_injection(
780            content,
781            pair(content),
782            "arg"
783        ));
784        // operator followed by a dash-led flag
785        let content = "x;-v `a`b";
786        assert!(glued_backtick_pair_is_injection(
787            content,
788            pair(content),
789            "arg"
790        ));
791    }
792
793    #[test]
794    fn appended_clause_edges_reject_boundaryless_and_empty_prefixes() {
795        // clause-initial requires a boundary char before the run: a bare
796        // word prefix is clean
797        let content = "word. `id` tail";
798        assert!(!glued_backtick_pair_is_injection(
799            content,
800            pair(content),
801            "arg"
802        ));
803        // a backtick at the very start is not clause-initial either
804        let content = "`id` end.";
805        assert!(!glued_backtick_pair_is_injection(
806            content,
807            pair(content),
808            "arg"
809        ));
810        // no whitespace between the boundary and the backtick: the char
811        // before the run is a word char, not a separator
812        let content = "end.`id` tail";
813        assert!(!glued_backtick_pair_is_injection(
814            content,
815            pair(content),
816            "arg"
817        ));
818        // the boundary-adjacent run is only whitespace before the clause
819        // start: the trimmed prefix collapses to empty
820        let content = "  `id` tail";
821        assert!(!glued_backtick_pair_is_injection(
822            content,
823            pair(content),
824            "arg"
825        ));
826    }
827
828    #[test]
829    fn glob_wildcards_after_a_command_boundary_are_dangerous() {
830        // the prefix ends in a command separator and the wildcard is
831        // word-shaped (`ls` + `txt` around the star)
832        let content = "eval; ls*.txt ";
833        let candidate = Candidate::new(6, 14);
834        assert!(glob_wildcard_token_is_dangerous_command(
835            content, candidate, "arg"
836        ));
837    }
838}
839
840#[cfg(test)]
841mod gap_tests {
842    use super::*;
843    use crate::patterns::pyregex::Candidate;
844
845    #[test]
846    fn appended_clause_requires_whitespace_before_the_pair() {
847        // tail-anchored pair whose preceding char is a boundary char rather
848        // than whitespace: not clause-initial
849        let content = "end.`id`";
850        assert!(!glued_backtick_pair_is_injection(
851            content,
852            Candidate::new(4, 8),
853            "arg"
854        ));
855    }
856
857    #[test]
858    fn appended_clause_rejects_a_whitespace_only_prefix() {
859        // tail-anchored pair preceded only by whitespace: the trimmed prefix
860        // collapses to empty, so no clause boundary exists either
861        let content = " \t`id`";
862        assert!(!glued_backtick_pair_is_injection(
863            content,
864            Candidate::new(2, 6),
865            "arg"
866        ));
867    }
868
869    #[test]
870    fn a_strong_sql_keyword_glued_to_a_dollar_pair_vetoes_it() {
871        // the prefix before the pair ends with SELECT
872        let content = "SELECT${id}";
873        assert!(!dollar_substitution_pair_is_injection(
874            content,
875            Candidate::new(6, 11),
876            "url_path"
877        ));
878        // the suffix after the pair starts with FROM
879        let content = "${id}FROM";
880        assert!(!dollar_substitution_pair_is_injection(
881            content,
882            Candidate::new(0, 5),
883            "url_path"
884        ));
885    }
886}
887
888#[cfg(test)]
889mod unit_twins {
890    use super::super::pyregex::Candidate;
891    use super::*;
892
893    #[test]
894    fn a_single_metacharacter_opens_the_window() {
895        // one `|` (not a `||` pair) followed by a word token still reads as
896        // an operator opening a command
897        assert!(shell_metacharacter_window("; |x"));
898        assert!(shell_metacharacter_window("&& &y"));
899    }
900
901    #[test]
902    fn a_dollar_substitution_after_an_operator_opens_the_window() {
903        // `$(cmd)` and `${var}` after an operator both read as a command
904        // opening, not just the backtick and word-token forms.
905        assert!(shell_metacharacter_window("; $(cmd)"));
906        assert!(shell_metacharacter_window("|| ${VAR}"));
907    }
908
909    #[test]
910    fn an_unrecognized_post_operator_token_keeps_scanning() {
911        // after the `;` the window holds `@x`: no token alternative matches,
912        // so the scan advances past the operator and reports no window
913        assert!(!shell_metacharacter_window("; @x"));
914    }
915
916    #[test]
917    fn a_dollar_pair_at_the_string_end_has_no_delimiter() {
918        // the candidate's `$` is the last character: there is no delimiter
919        // byte after it and the pair is rejected before any token check
920        let content = "x$";
921        assert!(!dollar_substitution_pair_is_injection(
922            content,
923            Candidate::new(1, 2),
924            "query_param"
925        ));
926    }
927
928    #[test]
929    fn a_dollar_paren_boundary_reads_both_command_forms() {
930        // the `($` command form ends its prefix on the `$` with the `(` just
931        // before it
932        assert!(glob_command_boundary_prefix("eval ($"));
933        // a bare variable reference (`$HOME`) does not
934        assert!(!glob_command_boundary_prefix("echo $HOME"));
935        // the `$(` alternative ends on its `(`, which is not one of the
936        // single-character boundary markers this structural check reads
937        assert!(!glob_command_boundary_prefix("echo $("));
938        // the operator alternatives end on their own marker characters
939        assert!(glob_command_boundary_prefix("echo ;"));
940        assert!(glob_command_boundary_prefix("echo |"));
941        assert!(glob_command_boundary_prefix("echo &"));
942        assert!(glob_command_boundary_prefix("echo `"));
943        assert!(!glob_command_boundary_prefix(""));
944    }
945}