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}