Skip to main content

safe_chains/
parse.rs

1use std::ops::Deref;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct CommandLine(String);
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Segment(String);
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Token(String);
11
12impl Deref for Token {
13    type Target = str;
14    fn deref(&self) -> &str {
15        &self.0
16    }
17}
18
19pub struct WordSet(&'static [&'static str]);
20
21impl WordSet {
22    pub const fn new(words: &'static [&'static str]) -> Self {
23        let mut i = 1;
24        while i < words.len() {
25            assert!(
26                const_less(words[i - 1].as_bytes(), words[i].as_bytes()),
27                "WordSet: entries must be sorted, no duplicates"
28            );
29            i += 1;
30        }
31        Self(words)
32    }
33
34    pub fn contains(&self, s: &str) -> bool {
35        self.0.binary_search(&s).is_ok()
36    }
37
38    pub fn iter(&self) -> impl Iterator<Item = &'static str> + '_ {
39        self.0.iter().copied()
40    }
41}
42
43const fn const_less(a: &[u8], b: &[u8]) -> bool {
44    let min = if a.len() < b.len() { a.len() } else { b.len() };
45    let mut i = 0;
46    while i < min {
47        if a[i] < b[i] {
48            return true;
49        }
50        if a[i] > b[i] {
51            return false;
52        }
53        i += 1;
54    }
55    a.len() < b.len()
56}
57
58pub struct FlagCheck {
59    required: WordSet,
60    denied: WordSet,
61}
62
63impl FlagCheck {
64    pub const fn new(required: &'static [&'static str], denied: &'static [&'static str]) -> Self {
65        Self {
66            required: WordSet::new(required),
67            denied: WordSet::new(denied),
68        }
69    }
70
71    pub fn required(&self) -> &WordSet {
72        &self.required
73    }
74
75    pub fn denied(&self) -> &WordSet {
76        &self.denied
77    }
78
79    pub fn is_safe(&self, tokens: &[Token]) -> bool {
80        tokens.iter().any(|t| self.required.contains(t))
81            && !tokens.iter().any(|t| self.denied.contains(t))
82    }
83}
84
85impl CommandLine {
86    pub fn new(s: impl Into<String>) -> Self {
87        Self(s.into())
88    }
89
90    pub fn as_str(&self) -> &str {
91        &self.0
92    }
93
94    pub fn segments(&self) -> Vec<Segment> {
95        split_outside_quotes(&self.0)
96            .into_iter()
97            .map(Segment)
98            .collect()
99    }
100}
101
102impl Segment {
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106
107    pub fn is_empty(&self) -> bool {
108        self.0.is_empty()
109    }
110
111    pub fn from_words<S: AsRef<str>>(words: &[S]) -> Self {
112        Segment(shell_words::join(words))
113    }
114
115    pub fn tokenize(&self) -> Option<Vec<Token>> {
116        shell_words::split(&self.0)
117            .ok()
118            .map(|v| v.into_iter().map(Token).collect())
119    }
120
121    pub fn has_unsafe_shell_syntax(&self) -> bool {
122        check_unsafe_shell_syntax(&self.0)
123    }
124
125    pub fn strip_env_prefix(&self) -> Segment {
126        Segment(strip_env_prefix_str(self.as_str()).trim().to_string())
127    }
128
129    pub fn from_tokens_replacing(tokens: &[Token], find: &str, replace: &str) -> Self {
130        let words: Vec<&str> = tokens
131            .iter()
132            .map(|t| if t.as_str() == find { replace } else { t.as_str() })
133            .collect();
134        Self::from_words(&words)
135    }
136
137    pub fn strip_fd_redirects(&self) -> Segment {
138        match self.tokenize() {
139            Some(tokens) => {
140                let filtered: Vec<_> = tokens
141                    .into_iter()
142                    .filter(|t| !t.is_fd_redirect())
143                    .collect();
144                Token::join(&filtered)
145            }
146            None => Segment(self.0.clone()),
147        }
148    }
149}
150
151impl Token {
152    pub fn as_str(&self) -> &str {
153        &self.0
154    }
155
156    pub fn join(tokens: &[Token]) -> Segment {
157        Segment(shell_words::join(tokens.iter().map(|t| t.as_str())))
158    }
159
160    pub fn as_command_line(&self) -> CommandLine {
161        CommandLine(self.0.clone())
162    }
163
164    pub fn command_name(&self) -> &str {
165        self.as_str().rsplit('/').next().unwrap_or(self.as_str())
166    }
167
168    pub fn is_one_of(&self, options: &[&str]) -> bool {
169        options.contains(&self.as_str())
170    }
171
172    pub fn split_value(&self, sep: &str) -> Option<&str> {
173        self.as_str().split_once(sep).map(|(_, v)| v)
174    }
175
176    pub fn content_outside_double_quotes(&self) -> String {
177        let bytes = self.as_str().as_bytes();
178        let mut result = Vec::with_capacity(bytes.len());
179        let mut i = 0;
180        while i < bytes.len() {
181            if bytes[i] == b'"' {
182                result.push(b' ');
183                i += 1;
184                while i < bytes.len() {
185                    if bytes[i] == b'\\' && i + 1 < bytes.len() {
186                        i += 2;
187                        continue;
188                    }
189                    if bytes[i] == b'"' {
190                        i += 1;
191                        break;
192                    }
193                    i += 1;
194                }
195            } else {
196                result.push(bytes[i]);
197                i += 1;
198            }
199        }
200        String::from_utf8(result).unwrap_or_default()
201    }
202
203    pub fn is_fd_redirect(&self) -> bool {
204        let s = self.as_str();
205        let rest = s.trim_start_matches(|c: char| c.is_ascii_digit());
206        if rest.len() < 2 || !rest.starts_with(">&") {
207            return false;
208        }
209        let after = &rest[2..];
210        !after.is_empty() && after.bytes().all(|b| b.is_ascii_digit() || b == b'-')
211    }
212
213    pub fn is_dev_null_redirect(&self) -> bool {
214        let s = self.as_str();
215        let rest = s.trim_start_matches(|c: char| c.is_ascii_digit());
216        rest.strip_prefix(">>")
217            .or_else(|| rest.strip_prefix('>'))
218            .or_else(|| rest.strip_prefix('<'))
219            .is_some_and(|after| after == "/dev/null")
220    }
221
222    pub fn is_redirect_operator(&self) -> bool {
223        let s = self.as_str();
224        let rest = s.trim_start_matches(|c: char| c.is_ascii_digit());
225        matches!(rest, ">" | ">>" | "<")
226    }
227}
228
229impl PartialEq<str> for Token {
230    fn eq(&self, other: &str) -> bool {
231        self.0 == other
232    }
233}
234
235impl PartialEq<&str> for Token {
236    fn eq(&self, other: &&str) -> bool {
237        self.0 == *other
238    }
239}
240
241impl PartialEq<Token> for str {
242    fn eq(&self, other: &Token) -> bool {
243        self == other.as_str()
244    }
245}
246
247impl PartialEq<Token> for &str {
248    fn eq(&self, other: &Token) -> bool {
249        *self == other.as_str()
250    }
251}
252
253impl std::fmt::Display for Token {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.write_str(&self.0)
256    }
257}
258
259pub fn has_flag(tokens: &[Token], short: Option<&str>, long: Option<&str>) -> bool {
260    for token in &tokens[1..] {
261        if token == "--" {
262            return false;
263        }
264        if let Some(long_flag) = long
265            && (token == long_flag || token.starts_with(&format!("{long_flag}=")))
266        {
267            return true;
268        }
269        if let Some(short_flag) = short {
270            let short_char = short_flag.trim_start_matches('-');
271            if token.starts_with('-')
272                && !token.starts_with("--")
273                && token[1..].contains(short_char)
274            {
275                return true;
276            }
277        }
278    }
279    false
280}
281
282fn split_outside_quotes(cmd: &str) -> Vec<String> {
283    let mut segments = Vec::new();
284    let mut current = String::new();
285    let mut in_single = false;
286    let mut in_double = false;
287    let mut escaped = false;
288    let mut chars = cmd.chars().peekable();
289
290    while let Some(c) = chars.next() {
291        if escaped {
292            current.push(c);
293            escaped = false;
294            continue;
295        }
296        if c == '\\' && !in_single {
297            escaped = true;
298            current.push(c);
299            continue;
300        }
301        if c == '\'' && !in_double {
302            in_single = !in_single;
303            current.push(c);
304            continue;
305        }
306        if c == '"' && !in_single {
307            in_double = !in_double;
308            current.push(c);
309            continue;
310        }
311        if !in_single && !in_double {
312            if c == '|' {
313                segments.push(std::mem::take(&mut current));
314                continue;
315            }
316            if c == '&' && !current.ends_with('>') {
317                segments.push(std::mem::take(&mut current));
318                if chars.peek() == Some(&'&') {
319                    chars.next();
320                }
321                continue;
322            }
323            if c == ';' || c == '\n' {
324                segments.push(std::mem::take(&mut current));
325                continue;
326            }
327        }
328        current.push(c);
329    }
330    segments.push(current);
331    segments
332        .into_iter()
333        .map(|s| s.trim().to_string())
334        .filter(|s| !s.is_empty())
335        .collect()
336}
337
338fn check_unsafe_shell_syntax(segment: &str) -> bool {
339    let mut in_single = false;
340    let mut in_double = false;
341    let mut escaped = false;
342    let chars: Vec<char> = segment.chars().collect();
343
344    for (i, &c) in chars.iter().enumerate() {
345        if escaped {
346            escaped = false;
347            continue;
348        }
349        if c == '\\' && !in_single {
350            escaped = true;
351            continue;
352        }
353        if c == '\'' && !in_double {
354            in_single = !in_single;
355            continue;
356        }
357        if c == '"' && !in_single {
358            in_double = !in_double;
359            continue;
360        }
361        if !in_single && !in_double {
362            if c == '>' || c == '<' {
363                let next = chars.get(i + 1);
364                if next == Some(&'&')
365                    && chars
366                        .get(i + 2)
367                        .is_some_and(|ch| ch.is_ascii_digit() || *ch == '-')
368                {
369                    continue;
370                }
371                if is_dev_null_target(&chars, i + 1, c) {
372                    continue;
373                }
374                return true;
375            }
376            if c == '`' {
377                return true;
378            }
379            if c == '$' && chars.get(i + 1) == Some(&'(') {
380                return true;
381            }
382        }
383    }
384    false
385}
386
387const DEV_NULL: [char; 9] = ['/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l'];
388
389fn is_dev_null_target(chars: &[char], start: usize, redirect_char: char) -> bool {
390    let mut j = start;
391    if redirect_char == '>' && j < chars.len() && chars[j] == '>' {
392        j += 1;
393    }
394    while j < chars.len() && chars[j] == ' ' {
395        j += 1;
396    }
397    if j + DEV_NULL.len() > chars.len() {
398        return false;
399    }
400    if chars[j..j + DEV_NULL.len()] != DEV_NULL {
401        return false;
402    }
403    let end = j + DEV_NULL.len();
404    end >= chars.len() || chars[end].is_whitespace() || ";|&)".contains(chars[end])
405}
406
407fn find_unquoted_space(s: &str) -> Option<usize> {
408    let mut in_single = false;
409    let mut in_double = false;
410    let mut escaped = false;
411    for (i, b) in s.bytes().enumerate() {
412        if escaped {
413            escaped = false;
414            continue;
415        }
416        if b == b'\\' && !in_single {
417            escaped = true;
418            continue;
419        }
420        if b == b'\'' && !in_double {
421            in_single = !in_single;
422            continue;
423        }
424        if b == b'"' && !in_single {
425            in_double = !in_double;
426            continue;
427        }
428        if b == b' ' && !in_single && !in_double {
429            return Some(i);
430        }
431    }
432    None
433}
434
435fn strip_env_prefix_str(segment: &str) -> &str {
436    let mut rest = segment;
437    loop {
438        let trimmed = rest.trim_start();
439        if trimmed.is_empty() {
440            return trimmed;
441        }
442        let bytes = trimmed.as_bytes();
443        if !bytes[0].is_ascii_uppercase() && bytes[0] != b'_' {
444            return trimmed;
445        }
446        if let Some(eq_pos) = trimmed.find('=') {
447            let key = &trimmed[..eq_pos];
448            let valid_key = key
449                .bytes()
450                .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_');
451            if !valid_key {
452                return trimmed;
453            }
454            if let Some(space_pos) = find_unquoted_space(&trimmed[eq_pos..]) {
455                rest = &trimmed[eq_pos + space_pos..];
456                continue;
457            }
458            return trimmed;
459        }
460        return trimmed;
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    fn seg(s: &str) -> Segment {
469        Segment(s.to_string())
470    }
471
472    fn tok(s: &str) -> Token {
473        Token(s.to_string())
474    }
475
476    fn toks(words: &[&str]) -> Vec<Token> {
477        words.iter().map(|s| tok(s)).collect()
478    }
479
480    #[test]
481    fn split_pipe() {
482        let segs = CommandLine::new("grep foo | head -5").segments();
483        assert_eq!(segs, vec![seg("grep foo"), seg("head -5")]);
484    }
485
486    #[test]
487    fn split_and() {
488        let segs = CommandLine::new("ls && echo done").segments();
489        assert_eq!(segs, vec![seg("ls"), seg("echo done")]);
490    }
491
492    #[test]
493    fn split_semicolon() {
494        let segs = CommandLine::new("ls; echo done").segments();
495        assert_eq!(segs, vec![seg("ls"), seg("echo done")]);
496    }
497
498    #[test]
499    fn split_preserves_quoted_pipes() {
500        let segs = CommandLine::new("echo 'a | b' foo").segments();
501        assert_eq!(segs, vec![seg("echo 'a | b' foo")]);
502    }
503
504    #[test]
505    fn split_background_operator() {
506        let segs = CommandLine::new("cat file & rm -rf /").segments();
507        assert_eq!(segs, vec![seg("cat file"), seg("rm -rf /")]);
508    }
509
510    #[test]
511    fn split_newline() {
512        let segs = CommandLine::new("echo foo\necho bar").segments();
513        assert_eq!(segs, vec![seg("echo foo"), seg("echo bar")]);
514    }
515
516    #[test]
517    fn unsafe_redirect() {
518        assert!(seg("echo hello > file.txt").has_unsafe_shell_syntax());
519    }
520
521    #[test]
522    fn safe_fd_redirect_stderr_to_stdout() {
523        assert!(!seg("cargo clippy 2>&1").has_unsafe_shell_syntax());
524    }
525
526    #[test]
527    fn safe_fd_redirect_close() {
528        assert!(!seg("cmd 2>&-").has_unsafe_shell_syntax());
529    }
530
531    #[test]
532    fn unsafe_redirect_ampersand_no_digit() {
533        assert!(seg("echo hello >& file.txt").has_unsafe_shell_syntax());
534    }
535
536    #[test]
537    fn unsafe_backtick() {
538        assert!(seg("echo `rm -rf /`").has_unsafe_shell_syntax());
539    }
540
541    #[test]
542    fn unsafe_command_substitution() {
543        assert!(seg("echo $(rm -rf /)").has_unsafe_shell_syntax());
544    }
545
546    #[test]
547    fn safe_quoted_dollar_paren() {
548        assert!(!seg("echo '$(safe)' arg").has_unsafe_shell_syntax());
549    }
550
551    #[test]
552    fn safe_quoted_redirect() {
553        assert!(!seg("echo 'greater > than' test").has_unsafe_shell_syntax());
554    }
555
556    #[test]
557    fn safe_no_special_chars() {
558        assert!(!seg("grep pattern file").has_unsafe_shell_syntax());
559    }
560
561    #[test]
562    fn safe_redirect_to_dev_null() {
563        assert!(!seg("cmd >/dev/null").has_unsafe_shell_syntax());
564    }
565
566    #[test]
567    fn safe_redirect_stderr_to_dev_null() {
568        assert!(!seg("cmd 2>/dev/null").has_unsafe_shell_syntax());
569    }
570
571    #[test]
572    fn safe_redirect_append_to_dev_null() {
573        assert!(!seg("cmd >>/dev/null").has_unsafe_shell_syntax());
574    }
575
576    #[test]
577    fn safe_redirect_space_dev_null() {
578        assert!(!seg("cmd > /dev/null").has_unsafe_shell_syntax());
579    }
580
581    #[test]
582    fn safe_redirect_input_dev_null() {
583        assert!(!seg("cmd < /dev/null").has_unsafe_shell_syntax());
584    }
585
586    #[test]
587    fn safe_redirect_both_dev_null() {
588        assert!(!seg("cmd 2>/dev/null").has_unsafe_shell_syntax());
589    }
590
591    #[test]
592    fn unsafe_redirect_dev_null_prefix() {
593        assert!(seg("cmd > /dev/nullicious").has_unsafe_shell_syntax());
594    }
595
596    #[test]
597    fn unsafe_redirect_dev_null_path_traversal() {
598        assert!(seg("cmd > /dev/null/../etc/passwd").has_unsafe_shell_syntax());
599    }
600
601    #[test]
602    fn unsafe_redirect_dev_null_subpath() {
603        assert!(seg("cmd > /dev/null/foo").has_unsafe_shell_syntax());
604    }
605
606    #[test]
607    fn unsafe_redirect_to_file() {
608        assert!(seg("cmd > output.txt").has_unsafe_shell_syntax());
609    }
610
611    #[test]
612    fn has_flag_short() {
613        let tokens = toks(&["sed", "-i", "s/foo/bar/"]);
614        assert!(has_flag(&tokens, Some("-i"), Some("--in-place")));
615    }
616
617    #[test]
618    fn has_flag_long_with_eq() {
619        let tokens = toks(&["sed", "--in-place=.bak", "s/foo/bar/"]);
620        assert!(has_flag(&tokens, Some("-i"), Some("--in-place")));
621    }
622
623    #[test]
624    fn has_flag_combined_short() {
625        let tokens = toks(&["sed", "-ni", "s/foo/bar/p"]);
626        assert!(has_flag(&tokens, Some("-i"), Some("--in-place")));
627    }
628
629    #[test]
630    fn has_flag_stops_at_double_dash() {
631        let tokens = toks(&["cmd", "--", "-i"]);
632        assert!(!has_flag(&tokens, Some("-i"), Some("--in-place")));
633    }
634
635    #[test]
636    fn has_flag_long_only() {
637        let tokens = toks(&["sort", "--compress-program", "gzip", "file.txt"]);
638        assert!(has_flag(&tokens, None, Some("--compress-program")));
639    }
640
641    #[test]
642    fn has_flag_long_only_eq() {
643        let tokens = toks(&["sort", "--compress-program=gzip", "file.txt"]);
644        assert!(has_flag(&tokens, None, Some("--compress-program")));
645    }
646
647    #[test]
648    fn has_flag_long_only_absent() {
649        let tokens = toks(&["sort", "-r", "file.txt"]);
650        assert!(!has_flag(&tokens, None, Some("--compress-program")));
651    }
652
653    #[test]
654    fn strip_single_env_var() {
655        assert_eq!(
656            seg("RACK_ENV=test bundle exec rspec").strip_env_prefix(),
657            seg("bundle exec rspec")
658        );
659    }
660
661    #[test]
662    fn strip_multiple_env_vars() {
663        assert_eq!(
664            seg("RACK_ENV=test RAILS_ENV=test bundle exec rspec").strip_env_prefix(),
665            seg("bundle exec rspec")
666        );
667    }
668
669    #[test]
670    fn strip_no_env_var() {
671        assert_eq!(
672            seg("bundle exec rspec").strip_env_prefix(),
673            seg("bundle exec rspec")
674        );
675    }
676
677    #[test]
678    fn tokenize_simple() {
679        assert_eq!(
680            seg("grep foo file.txt").tokenize(),
681            Some(vec![tok("grep"), tok("foo"), tok("file.txt")])
682        );
683    }
684
685    #[test]
686    fn tokenize_quoted() {
687        assert_eq!(
688            seg("echo 'hello world'").tokenize(),
689            Some(vec![tok("echo"), tok("hello world")])
690        );
691    }
692
693    #[test]
694    fn strip_env_quoted_single() {
695        assert_eq!(
696            seg("FOO='bar baz' ls").strip_env_prefix(),
697            seg("ls")
698        );
699    }
700
701    #[test]
702    fn strip_env_quoted_double() {
703        assert_eq!(
704            seg("FOO=\"bar baz\" ls").strip_env_prefix(),
705            seg("ls")
706        );
707    }
708
709    #[test]
710    fn strip_env_quoted_with_equals() {
711        assert_eq!(
712            seg("FOO='a=b' ls").strip_env_prefix(),
713            seg("ls")
714        );
715    }
716
717    #[test]
718    fn strip_env_quoted_multiple() {
719        assert_eq!(
720            seg("FOO='x y' BAR=\"a b\" cmd").strip_env_prefix(),
721            seg("cmd")
722        );
723    }
724
725    #[test]
726    fn command_name_simple() {
727        assert_eq!(tok("ls").command_name(), "ls");
728    }
729
730    #[test]
731    fn command_name_with_path() {
732        assert_eq!(tok("/usr/bin/ls").command_name(), "ls");
733    }
734
735    #[test]
736    fn command_name_relative_path() {
737        assert_eq!(tok("./scripts/test.sh").command_name(), "test.sh");
738    }
739
740    #[test]
741    fn fd_redirect_detection() {
742        assert!(tok("2>&1").is_fd_redirect());
743        assert!(tok(">&2").is_fd_redirect());
744        assert!(tok("10>&1").is_fd_redirect());
745        assert!(tok("255>&2").is_fd_redirect());
746        assert!(tok("2>&-").is_fd_redirect());
747        assert!(tok("2>&10").is_fd_redirect());
748        assert!(!tok(">").is_fd_redirect());
749        assert!(!tok("/dev/null").is_fd_redirect());
750        assert!(!tok(">&").is_fd_redirect());
751        assert!(!tok("").is_fd_redirect());
752        assert!(!tok("42").is_fd_redirect());
753        assert!(!tok("123abc").is_fd_redirect());
754    }
755
756    #[test]
757    fn dev_null_redirect_single_token() {
758        assert!(tok(">/dev/null").is_dev_null_redirect());
759        assert!(tok(">>/dev/null").is_dev_null_redirect());
760        assert!(tok("2>/dev/null").is_dev_null_redirect());
761        assert!(tok("2>>/dev/null").is_dev_null_redirect());
762        assert!(tok("</dev/null").is_dev_null_redirect());
763        assert!(tok("10>/dev/null").is_dev_null_redirect());
764        assert!(tok("255>/dev/null").is_dev_null_redirect());
765        assert!(!tok(">/tmp/file").is_dev_null_redirect());
766        assert!(!tok(">/dev/nullicious").is_dev_null_redirect());
767        assert!(!tok("ls").is_dev_null_redirect());
768        assert!(!tok("").is_dev_null_redirect());
769        assert!(!tok("42").is_dev_null_redirect());
770        assert!(!tok("<</dev/null").is_dev_null_redirect());
771    }
772
773    #[test]
774    fn redirect_operator_detection() {
775        assert!(tok(">").is_redirect_operator());
776        assert!(tok(">>").is_redirect_operator());
777        assert!(tok("<").is_redirect_operator());
778        assert!(tok("2>").is_redirect_operator());
779        assert!(tok("2>>").is_redirect_operator());
780        assert!(tok("10>").is_redirect_operator());
781        assert!(tok("255>>").is_redirect_operator());
782        assert!(!tok("ls").is_redirect_operator());
783        assert!(!tok(">&1").is_redirect_operator());
784        assert!(!tok("/dev/null").is_redirect_operator());
785        assert!(!tok("").is_redirect_operator());
786        assert!(!tok("42").is_redirect_operator());
787        assert!(!tok("<<").is_redirect_operator());
788    }
789
790    #[test]
791    fn reverse_partial_eq() {
792        let t = tok("hello");
793        assert!("hello" == t);
794        assert!("world" != t);
795        let s: &str = "hello";
796        assert!(s == t);
797    }
798
799    #[test]
800    fn token_deref() {
801        let t = tok("--flag");
802        assert!(t.starts_with("--"));
803        assert!(t.contains("fl"));
804        assert_eq!(t.len(), 6);
805        assert!(!t.is_empty());
806        assert_eq!(t.as_bytes()[0], b'-');
807        assert!(t.eq_ignore_ascii_case("--FLAG"));
808        assert_eq!(t.get(2..), Some("flag"));
809    }
810
811    #[test]
812    fn token_is_one_of() {
813        assert!(tok("-v").is_one_of(&["-v", "--verbose"]));
814        assert!(!tok("-q").is_one_of(&["-v", "--verbose"]));
815    }
816
817    #[test]
818    fn token_split_value() {
819        assert_eq!(tok("--method=GET").split_value("="), Some("GET"));
820        assert_eq!(tok("--flag").split_value("="), None);
821    }
822
823    #[test]
824    fn word_set_contains() {
825        let set = WordSet::new(&["list", "show", "view"]);
826        assert!(set.contains(&tok("list")));
827        assert!(set.contains(&tok("view")));
828        assert!(!set.contains(&tok("delete")));
829        assert!(set.contains("list"));
830        assert!(!set.contains("delete"));
831    }
832
833    #[test]
834    fn word_set_iter() {
835        let set = WordSet::new(&["a", "b", "c"]);
836        let items: Vec<&str> = set.iter().collect();
837        assert_eq!(items, vec!["a", "b", "c"]);
838    }
839
840    #[test]
841    fn token_as_command_line() {
842        let cl = tok("ls -la | grep foo").as_command_line();
843        let segs = cl.segments();
844        assert_eq!(segs, vec![seg("ls -la"), seg("grep foo")]);
845    }
846
847    #[test]
848    fn segment_from_tokens_replacing() {
849        let tokens = toks(&["find", ".", "-name", "{}", "-print"]);
850        let result = Segment::from_tokens_replacing(&tokens, "{}", "file");
851        assert_eq!(result.tokenize().unwrap(), toks(&["find", ".", "-name", "file", "-print"]));
852    }
853
854    #[test]
855    fn segment_strip_fd_redirects() {
856        assert_eq!(
857            seg("cargo test 2>&1").strip_fd_redirects(),
858            seg("cargo test")
859        );
860        assert_eq!(
861            seg("cmd 2>&1 >&2").strip_fd_redirects(),
862            seg("cmd")
863        );
864        assert_eq!(
865            seg("ls -la").strip_fd_redirects(),
866            seg("ls -la")
867        );
868    }
869
870    #[test]
871    fn flag_check_required_present_no_denied() {
872        let fc = FlagCheck::new(&["--show"], &["--set"]);
873        assert!(fc.is_safe(&toks(&["--show"])));
874    }
875
876    #[test]
877    fn flag_check_required_absent() {
878        let fc = FlagCheck::new(&["--show"], &["--set"]);
879        assert!(!fc.is_safe(&toks(&["--verbose"])));
880    }
881
882    #[test]
883    fn flag_check_denied_present() {
884        let fc = FlagCheck::new(&["--show"], &["--set"]);
885        assert!(!fc.is_safe(&toks(&["--show", "--set", "key", "val"])));
886    }
887
888    #[test]
889    fn flag_check_empty_denied() {
890        let fc = FlagCheck::new(&["--check"], &[]);
891        assert!(fc.is_safe(&toks(&["--check", "--all"])));
892    }
893
894    #[test]
895    fn flag_check_empty_tokens() {
896        let fc = FlagCheck::new(&["--show"], &[]);
897        assert!(!fc.is_safe(&[]));
898    }
899
900    #[test]
901    fn content_outside_double_quotes_strips_string() {
902        assert_eq!(tok(r#""system""#).content_outside_double_quotes(), " ");
903    }
904
905    #[test]
906    fn content_outside_double_quotes_preserves_code() {
907        let result = tok(r#"{print "hello"} END{print NR}"#).content_outside_double_quotes();
908        assert_eq!(result, r#"{print  } END{print NR}"#);
909    }
910
911    #[test]
912    fn content_outside_double_quotes_escaped() {
913        let result = tok(r#"{print "he said \"hi\""}"#).content_outside_double_quotes();
914        assert_eq!(result, "{print  }");
915    }
916
917    #[test]
918    fn content_outside_double_quotes_no_quotes() {
919        assert_eq!(tok("{print $1}").content_outside_double_quotes(), "{print $1}");
920    }
921
922    #[test]
923    fn content_outside_double_quotes_empty() {
924        assert_eq!(tok("").content_outside_double_quotes(), "");
925    }
926}