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}