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