use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::{Child as TokioChild, Command as TokioCommand};
use crate::error::{Error, Result};
pub(crate) const MAX_LINE_CHARS: usize = 8192;
pub(crate) const MAX_COMMANDS: usize = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Refusal {
pub(crate) construct: &'static str,
pub(crate) reason: String,
pub(crate) at: usize,
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"refused at character {}: {} is not supported by the `shell` tool. {}",
self.at, self.construct, self.reason
)
}
}
impl Refusal {
fn new(construct: &'static str, at: usize, reason: impl Into<String>) -> Self {
Self {
construct,
reason: reason.into(),
at,
}
}
}
pub(crate) type ParseResult<T> = std::result::Result<T, Refusal>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RedirectKind {
Stdout,
StdoutAppend,
Stdin,
Stderr,
StderrAppend,
StderrToStdout,
}
impl RedirectKind {
pub(crate) fn is_write(self) -> bool {
matches!(
self,
Self::Stdout | Self::StdoutAppend | Self::Stderr | Self::StderrAppend
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Redirect {
pub(crate) kind: RedirectKind,
pub(crate) target: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Command {
pub(crate) argv: Vec<String>,
pub(crate) redirects: Vec<Redirect>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Pipeline {
pub(crate) stages: Vec<Command>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AndOr {
And,
Or,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AndOrList {
pub(crate) first: Pipeline,
pub(crate) rest: Vec<(AndOr, Pipeline)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Line {
pub(crate) seq: Vec<AndOrList>,
}
impl Line {
pub(crate) fn commands(&self) -> impl Iterator<Item = &Command> {
self.seq.iter().flat_map(|ao| {
ao.first
.stages
.iter()
.chain(ao.rest.iter().flat_map(|(_, p)| p.stages.iter()))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Word {
text: String,
quoted: bool,
at: usize,
},
Pipe(usize),
AndIf(usize),
OrIf(usize),
Semi(usize),
Redirect { kind: RedirectKind, at: usize },
}
impl Token {
fn at(&self) -> usize {
match self {
Token::Word { at, .. }
| Token::Pipe(at)
| Token::AndIf(at)
| Token::OrIf(at)
| Token::Semi(at)
| Token::Redirect { at, .. } => *at,
}
}
}
fn is_bare_word_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '-' | '.' | '/' | '=' | '+' | ':' | ',' | '@' | '%')
|| (c as u32) > 0x7f
}
fn is_permitted_in_quotes(c: char) -> bool {
(c as u32) >= 0x20 || c == '\t' || c == '\n'
}
const RESERVED_WORDS: &[&str] = &[
"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
"function", "select",
];
fn lex(src: &str) -> ParseResult<Vec<Token>> {
if src.chars().count() > MAX_LINE_CHARS {
return Err(Refusal::new(
"an over-long command line",
0,
format!("this tool parses at most {MAX_LINE_CHARS} characters; split the work across several calls."),
));
}
let chars: Vec<char> = src.chars().collect();
let mut out: Vec<Token> = Vec::new();
let mut i = 0usize;
let mut word: Option<(String, bool, usize)> = None;
macro_rules! flush {
($out:expr, $word:expr) => {
if let Some((text, quoted, at)) = $word.take() {
$out.push(Token::Word { text, quoted, at });
}
};
}
while i < chars.len() {
let c = chars[i];
match c {
' ' | '\t' => {
flush!(out, word);
i += 1;
}
'\n' => {
flush!(out, word);
out.push(Token::Semi(i));
i += 1;
}
'\'' => {
let start = i;
i += 1;
let (text, _, _) = word.get_or_insert((String::new(), false, start));
loop {
let Some(&ch) = chars.get(i) else {
return Err(Refusal::new(
"an unterminated single quote",
start,
"the line ended inside a quoted string; close the quote.",
));
};
i += 1;
if ch == '\'' {
break;
}
if !is_permitted_in_quotes(ch) {
return Err(Refusal::new(
"a control character",
i - 1,
"control characters are refused even inside quotes.",
));
}
text.push(ch);
}
word.as_mut().expect("just inserted").1 = true;
}
'"' => {
let start = i;
i += 1;
let (text, _, _) = word.get_or_insert((String::new(), false, start));
loop {
let Some(&ch) = chars.get(i) else {
return Err(Refusal::new(
"an unterminated double quote",
start,
"the line ended inside a quoted string; close the quote.",
));
};
match ch {
'"' => {
i += 1;
break;
}
'$' => {
return Err(Refusal::new(
"parameter or command expansion",
i,
"`$` expands inside double quotes too. Use single quotes for a literal `$`, and pass values as arguments rather than through the environment.",
))
}
'`' => {
return Err(Refusal::new(
"command substitution",
i,
"backticks expand inside double quotes too. Run the inner command as its own step and use its output.",
))
}
'\\' => {
match chars.get(i + 1) {
Some(&n @ ('"' | '\\' | '$' | '`')) => {
text.push(n);
i += 2;
}
Some(&'\n') => i += 2,
_ => {
text.push('\\');
i += 1;
}
}
}
_ => {
if !is_permitted_in_quotes(ch) {
return Err(Refusal::new(
"a control character",
i,
"control characters are refused even inside quotes.",
));
}
text.push(ch);
i += 1;
}
}
}
word.as_mut().expect("just inserted").1 = true;
}
'\\' => {
match chars.get(i + 1) {
None => {
return Err(Refusal::new(
"a trailing backslash",
i,
"the line ended with an escape that has nothing to escape.",
))
}
Some(&'\n') => i += 2,
Some(&n) => {
if (n as u32) < 0x20 && n != '\t' {
return Err(Refusal::new(
"a control character",
i + 1,
"control characters are refused even when escaped.",
));
}
word.get_or_insert((String::new(), false, i)).0.push(n);
i += 2;
}
}
}
'|' => {
flush!(out, word);
if chars.get(i + 1) == Some(&'|') {
out.push(Token::OrIf(i));
i += 2;
} else {
out.push(Token::Pipe(i));
i += 1;
}
}
'&' => {
if chars.get(i + 1) == Some(&'&') {
flush!(out, word);
out.push(Token::AndIf(i));
i += 2;
} else {
return Err(Refusal::new(
"background execution",
i,
"a single `&` detaches a process this tool would then not be able to bound, so the run could end leaving it alive. Run the command in the foreground instead; if it is long, raise the contract's exec timeout rather than backgrounding it.",
));
}
}
';' => {
flush!(out, word);
if chars.get(i + 1) == Some(&';') {
return Err(Refusal::new(
"a `case` clause terminator",
i,
"`case` and the other compound commands are not supported; express the branch as separate calls.",
));
}
out.push(Token::Semi(i));
i += 1;
}
'>' | '<' => {
let fd: Option<u32> = match &word {
Some((text, false, _)) if !text.is_empty() && text.chars().all(|c| c.is_ascii_digit()) => {
text.parse().ok()
}
_ => None,
};
if fd.is_some() {
word = None;
} else {
flush!(out, word);
}
let at = i;
let kind = if c == '<' {
match chars.get(i + 1) {
Some(&'<') => {
return Err(Refusal::new(
"a here-document",
i,
"`<<` reads inline input this tool cannot bound. Write the text to a file with `write_file` and redirect from it.",
))
}
Some(&'(') => {
return Err(Refusal::new(
"process substitution",
i,
"`<( )` starts a hidden sub-command that would never be checked. Run it as its own stage and redirect from a file.",
))
}
Some(&'>') => {
return Err(Refusal::new(
"a read-write redirect",
i,
"`<>` opens a file for both reading and writing, which this tool cannot express as a single permission check.",
))
}
Some(&'&') => {
return Err(Refusal::new(
"an input descriptor duplication",
i,
"only `2>&1` is supported among the descriptor duplications.",
))
}
_ => {
if fd.is_some_and(|n| n != 0) {
return Err(Refusal::new(
"a redirect of an unsupported file descriptor",
at,
"input may be redirected only for descriptor 0.",
));
}
i += 1;
RedirectKind::Stdin
}
}
} else {
match chars.get(i + 1) {
Some(&'>') => {
i += 2;
match fd {
None | Some(1) => RedirectKind::StdoutAppend,
Some(2) => RedirectKind::StderrAppend,
Some(_) => {
return Err(Refusal::new(
"a redirect of an unsupported file descriptor",
at,
"only descriptors 1 and 2 may be redirected.",
))
}
}
}
Some(&'|') => {
return Err(Refusal::new(
"a clobbering redirect",
i,
"`>|` overrides a shell option this tool does not have; use `>`.",
))
}
Some(&'&') => {
if fd != Some(2) || chars.get(i + 2) != Some(&'1') {
return Err(Refusal::new(
"a descriptor duplication",
at,
"`2>&1` is the only descriptor duplication supported.",
));
}
if chars.get(i + 3).is_some_and(|&n| {
is_bare_word_char(n) || n == '\'' || n == '"' || n == '\\'
}) {
return Err(Refusal::new(
"a descriptor duplication",
at,
"`2>&1` is the only descriptor duplication supported.",
));
}
i += 3;
RedirectKind::StderrToStdout
}
_ => {
i += 1;
match fd {
None | Some(1) => RedirectKind::Stdout,
Some(2) => RedirectKind::Stderr,
Some(_) => {
return Err(Refusal::new(
"a redirect of an unsupported file descriptor",
at,
"only descriptors 1 and 2 may be redirected.",
))
}
}
}
}
};
out.push(Token::Redirect { kind, at });
}
'$' => {
let what = if chars.get(i + 1) == Some(&'(') {
if chars.get(i + 2) == Some(&'(') {
("arithmetic expansion", "`$(( ))` is not evaluated by this tool.")
} else {
("command substitution", "`$( )` would run a command this tool never sees and therefore never checks. Run it as its own step and use the output.")
}
} else {
("parameter expansion", "this tool does not expand variables: the argv it checks must be the argv it runs, and a value read from the environment is neither. Pass the value literally.")
};
return Err(Refusal::new(what.0, i, what.1));
}
'`' => {
return Err(Refusal::new(
"command substitution",
i,
"backticks would run a command this tool never sees and therefore never checks. Run it as its own step and use the output.",
))
}
'(' | ')' => {
return Err(Refusal::new(
"a subshell",
i,
"`( )` groups commands in a child shell this tool does not start. Write the commands as a sequence with `;` or `&&`.",
))
}
'{' | '}' => {
return Err(Refusal::new(
"a brace group or brace expansion",
i,
"`{ }` is either a command group or an expansion, and this tool performs neither. Quote it if you meant the literal character.",
))
}
'*' | '?' | '[' | ']' => {
return Err(Refusal::new(
"a glob pattern",
i,
"expanding a glob would let the argv that runs differ from the argv that was checked, since the filesystem can change in between. Use `find` or `list_dir` to select paths, then name them; or quote the character if you meant it literally.",
))
}
'~' => {
return Err(Refusal::new(
"tilde expansion",
i,
"this tool does not expand `~`. Write the path out, or quote the character if you meant it literally.",
))
}
'#' => {
return Err(Refusal::new(
"a comment",
i,
"`#` begins a comment, which has no meaning in a single command line. Quote it if you meant the literal character.",
))
}
'!' => {
return Err(Refusal::new(
"pipeline negation or history expansion",
i,
"`!` is not supported. Check the exit status through `&&` and `||` instead.",
))
}
_ if (c as u32) < 0x20 => {
return Err(Refusal::new(
"a control character",
i,
"control characters are not permitted in a command line. Quote a tab or a newline if a literal one is genuinely needed.",
));
}
_ if is_bare_word_char(c) => {
word.get_or_insert((String::new(), false, i)).0.push(c);
i += 1;
}
_ => {
return Err(Refusal::new(
"an unsupported character",
i,
format!(
"`{}` (U+{:04X}) is not permitted outside quotes. Quote it if you meant it literally.",
c.escape_debug(),
c as u32
),
));
}
}
}
flush!(out, word);
Ok(out)
}
pub(crate) fn parse(src: &str) -> ParseResult<Line> {
let tokens = lex(src)?;
let mut p = Parser {
tokens,
pos: 0,
commands: 0,
};
let line = p.line()?;
if line.seq.is_empty() {
return Err(Refusal::new(
"an empty command line",
0,
"there is nothing here to run.",
));
}
Ok(line)
}
struct Parser {
tokens: Vec<Token>,
pos: usize,
commands: usize,
}
impl Parser {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn line(&mut self) -> ParseResult<Line> {
let mut seq = Vec::new();
loop {
while matches!(self.peek(), Some(Token::Semi(_))) {
self.pos += 1;
}
if self.peek().is_none() {
break;
}
seq.push(self.and_or()?);
}
Ok(Line { seq })
}
fn and_or(&mut self) -> ParseResult<AndOrList> {
let first = self.pipeline()?;
let mut rest = Vec::new();
loop {
let op = match self.peek() {
Some(Token::AndIf(_)) => AndOr::And,
Some(Token::OrIf(_)) => AndOr::Or,
_ => break,
};
let at = self.peek().expect("matched above").at();
self.pos += 1;
if matches!(self.peek(), None | Some(Token::Semi(_))) {
return Err(Refusal::new(
"a dangling operator",
at,
"`&&` and `||` must be followed by a command.",
));
}
rest.push((op, self.pipeline()?));
}
Ok(AndOrList { first, rest })
}
fn pipeline(&mut self) -> ParseResult<Pipeline> {
let mut stages = vec![self.command()?];
while let Some(Token::Pipe(at)) = self.peek() {
let at = *at;
self.pos += 1;
if matches!(self.peek(), None | Some(Token::Semi(_))) {
return Err(Refusal::new(
"a dangling pipe",
at,
"`|` must be followed by a command.",
));
}
stages.push(self.command()?);
}
Ok(Pipeline { stages })
}
fn command(&mut self) -> ParseResult<Command> {
self.commands += 1;
if self.commands > MAX_COMMANDS {
let at = self.peek().map_or(0, Token::at);
return Err(Refusal::new(
"an over-long command line",
at,
format!("this tool runs at most {MAX_COMMANDS} sub-commands in one line; split the work across several calls."),
));
}
let start = self.peek().map_or(0, Token::at);
let mut argv: Vec<String> = Vec::new();
let mut redirects: Vec<Redirect> = Vec::new();
let mut program_quoted = false;
loop {
match self.peek() {
Some(Token::Word { .. }) => {
let Some(Token::Word { text, quoted, .. }) = self.tokens.get(self.pos) else {
unreachable!("matched Word above")
};
if argv.is_empty() {
program_quoted = *quoted;
}
argv.push(text.clone());
self.pos += 1;
}
Some(&Token::Redirect { kind, at }) => {
self.pos += 1;
let target = if kind == RedirectKind::StderrToStdout {
None
} else {
match self.tokens.get(self.pos) {
Some(Token::Word { text, .. }) => {
let t = text.clone();
self.pos += 1;
Some(t)
}
_ => {
return Err(Refusal::new(
"a redirect with no target",
at,
"a redirect must name the file it reads from or writes to.",
))
}
}
};
redirects.push(Redirect { kind, target });
}
_ => break,
}
}
if argv.is_empty() {
return Err(Refusal::new(
"a redirect with no command",
start,
"there is no program here to run, so there is nothing to check against the execute policy. Name the command that produces or consumes the file.",
));
}
if !program_quoted && RESERVED_WORDS.contains(&argv[0].as_str()) {
return Err(Refusal::new(
"a shell keyword",
start,
format!(
"`{}` introduces a compound command, and this tool has no control flow: it runs a sequence of checked commands and nothing else. Express the branch with `&&` and `||`, or as separate steps that read each other's results.",
argv[0]
),
));
}
Ok(Command { argv, redirects })
}
}
pub(crate) const CD: &str = "cd";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Planned {
pub(crate) cwd: PathBuf,
pub(crate) redirects: Vec<(RedirectKind, Option<PathBuf>)>,
pub(crate) cd_target: Option<PathBuf>,
}
pub(crate) fn plan(line: &Line, root: &Path) -> Result<Vec<Planned>> {
let mut cwd = root.to_path_buf();
let mut out = Vec::new();
for cmd in line.commands() {
let here = cwd.clone();
let mut redirects = Vec::with_capacity(cmd.redirects.len());
for r in &cmd.redirects {
let target = match r.target.as_deref() {
None => None,
Some(t) => Some(resolve(&here, t, root)?),
};
redirects.push((r.kind, target));
}
let cd_target = if cmd.argv[0] == CD {
Some(match cmd.argv.get(1) {
None => root.to_path_buf(),
Some(t) => resolve(&here, t, root)?,
})
} else {
None
};
out.push(Planned {
cwd: here.clone(),
redirects,
cd_target: cd_target.clone(),
});
if let Some(t) = cd_target {
cwd = t;
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ShellOutcome {
Ran {
code: Option<i32>,
stdout: String,
stderr: String,
elided: usize,
ran: usize,
},
TimedOut {
after: Duration,
},
Unavailable {
reason: String,
},
}
pub(crate) struct Shell {
timeout: Option<Duration>,
cap: usize,
capture: Option<Capture>,
}
pub(crate) struct Capture {
pub(crate) path: std::path::PathBuf,
#[allow(clippy::type_complexity)]
pub(crate) on_spawn: std::sync::Arc<dyn Fn(&TokioChild) -> Result<()> + Send + Sync>,
}
impl Shell {
pub(crate) fn new(timeout: Duration, cap: usize) -> Self {
Self {
timeout: Some(timeout),
cap,
capture: None,
}
}
pub(crate) fn detached(cap: usize, capture: Capture) -> Self {
Self {
timeout: None,
cap,
capture: Some(capture),
}
}
pub(crate) async fn run(&self, line: &Line, plan: &[Planned]) -> Result<ShellOutcome> {
let Some(limit) = self.timeout else {
return self.run_line(line, plan).await;
};
match tokio::time::timeout(limit, self.run_line(line, plan)).await {
Err(_elapsed) => Ok(ShellOutcome::TimedOut { after: limit }),
Ok(r) => r,
}
}
async fn run_line(&self, line: &Line, plan: &[Planned]) -> Result<ShellOutcome> {
let mut out = String::new();
let mut err = String::new();
let mut code = Some(0);
let mut ran = 0usize;
let mut at = 0usize;
for list in &line.seq {
let mut status = match self
.run_pipeline(&list.first, plan, &mut at, &mut out, &mut err)
.await?
{
Stage::Ran(c) => {
ran += list.first.stages.len();
c
}
Stage::Unavailable(reason) => return Ok(ShellOutcome::Unavailable { reason }),
};
for (op, pipeline) in &list.rest {
if (*op == AndOr::And) != (status == Some(0)) {
at += pipeline.stages.len();
continue;
}
status = match self
.run_pipeline(pipeline, plan, &mut at, &mut out, &mut err)
.await?
{
Stage::Ran(c) => {
ran += pipeline.stages.len();
c
}
Stage::Unavailable(reason) => return Ok(ShellOutcome::Unavailable { reason }),
};
}
code = status;
}
let (stdout, cut_o) = super::exec::head_and_tail(&out, self.cap);
let (stderr, cut_e) = super::exec::head_and_tail(&err, self.cap);
Ok(ShellOutcome::Ran {
code,
stdout,
stderr,
elided: cut_o + cut_e,
ran,
})
}
async fn run_pipeline(
&self,
pipeline: &Pipeline,
plan: &[Planned],
at: &mut usize,
out: &mut String,
err: &mut String,
) -> Result<Stage> {
let base = *at;
*at += pipeline.stages.len();
if pipeline.stages.len() == 1 && pipeline.stages[0].argv[0] == CD {
let next = plan.get(base + 1).map(|p| p.cwd.clone());
return Ok(match next {
Some(d) if !d.is_dir() => {
err.push_str(&format!("[shell] cd: no such directory: {}\n", d.display()));
Stage::Ran(Some(1))
}
_ => Stage::Ran(Some(0)),
});
}
let mut children = Vec::with_capacity(pipeline.stages.len());
let last = pipeline.stages.len() - 1;
let mut prev_stdout: Option<Stdio> = None;
for (i, stage) in pipeline.stages.iter().enumerate() {
let planned = &plan[base + i];
if stage.argv[0] == CD {
err.push_str(
"[shell] `cd` inside a pipeline changes nothing and was skipped; run it as \
its own stage.\n",
);
continue;
}
let mut cmd = TokioCommand::new(&stage.argv[0]);
cmd.args(&stage.argv[1..])
.current_dir(&planned.cwd)
.kill_on_drop(true);
#[cfg(unix)]
if self.capture.is_some() {
crate::sandbox::own_process_group(&mut cmd);
}
#[cfg(windows)]
if self.capture.is_some() {
cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
}
match prev_stdout.take() {
Some(s) => cmd.stdin(s),
None => cmd.stdin(Stdio::null()),
};
match (&self.capture, i == last) {
(Some(c), true) => {
let out = append_to(&c.path)?;
let err = out.try_clone().map_err(Error::Io)?;
cmd.stdout(out).stderr(err);
}
_ => {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
}
}
apply_redirects(&mut cmd, planned, i != last)?;
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Stage::Unavailable(format!(
"no `{}` on PATH",
stage.argv[0]
)))
}
Err(e) => return Err(Error::Io(e)),
};
if let Some(c) = &self.capture {
(c.on_spawn)(&child)?;
}
if i != last {
if let Some(so) = child.stdout.take() {
let piped: Stdio = so.try_into().map_err(Error::Io)?;
prev_stdout = Some(piped);
}
}
children.push(child);
}
let mut code = Some(0);
let merge_stderr = pipeline.stages.last().is_some_and(|s| {
s.redirects
.iter()
.any(|r| r.kind == RedirectKind::StderrToStdout)
});
let n = children.len();
for (i, child) in children.into_iter().enumerate() {
let output = child.wait_with_output().await.map_err(Error::Io)?;
let so = String::from_utf8_lossy(&output.stdout);
let se = String::from_utf8_lossy(&output.stderr);
if i + 1 == n {
code = output.status.code();
out.push_str(&so);
if merge_stderr {
out.push_str(&se);
} else {
err.push_str(&se);
}
} else {
err.push_str(&se);
}
}
Ok(Stage::Ran(code))
}
}
enum Stage {
Ran(Option<i32>),
Unavailable(String),
}
fn apply_redirects(cmd: &mut TokioCommand, planned: &Planned, piped_out: bool) -> Result<()> {
for (kind, target) in &planned.redirects {
let Some(path) = target else {
if piped_out {
return Err(Error::Config(
"`2>&1` on a stage whose stdout is piped is not supported by this tool: \
merging the two streams into a pipe needs a descriptor duplication this \
crate does not perform. Put the redirect on the last stage of the pipeline."
.into(),
));
}
continue;
};
match kind {
RedirectKind::Stdin => {
cmd.stdin(Stdio::from(std::fs::File::open(path).map_err(Error::Io)?));
}
RedirectKind::Stdout | RedirectKind::StdoutAppend => {
cmd.stdout(Stdio::from(open_for_write(
path,
*kind == RedirectKind::StdoutAppend,
)?));
}
RedirectKind::Stderr | RedirectKind::StderrAppend => {
cmd.stderr(Stdio::from(open_for_write(
path,
*kind == RedirectKind::StderrAppend,
)?));
}
RedirectKind::StderrToStdout => unreachable!("handled above: it has no target"),
}
}
Ok(())
}
fn append_to(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(Error::Io)
}
fn open_for_write(path: &Path, append: bool) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create(true)
.append(append)
.truncate(!append)
.open(path)
.map_err(Error::Io)
}
fn resolve(base: &Path, rel: &str, root: &Path) -> Result<PathBuf> {
let joined = if Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
base.join(rel)
};
let mut out = PathBuf::new();
for c in joined.components() {
match c {
Component::ParentDir => {
if !out.pop() {
return Err(Error::Config(format!("`{rel}` leaves the workspace root")));
}
}
Component::CurDir => {}
other => out.push(other),
}
}
if !out.starts_with(root) {
return Err(Error::Config(format!("`{rel}` leaves the workspace root")));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(src: &str) -> Line {
parse(src).unwrap_or_else(|e| panic!("expected `{src}` to parse, got: {e}"))
}
fn refused(src: &str) -> Refusal {
parse(src).expect_err(&format!("expected `{src}` to be refused, but it parsed"))
}
fn argvs(line: &Line) -> Vec<Vec<String>> {
line.commands().map(|c| c.argv.clone()).collect()
}
#[test]
fn a_bare_command_is_one_stage() {
assert_eq!(argvs(&ok("ls")), vec![vec!["ls".to_string()]]);
}
#[test]
fn words_split_on_whitespace_and_join_across_quotes() {
assert_eq!(
argvs(&ok("grep -n 'two words' file.txt")),
vec![vec!["grep", "-n", "two words", "file.txt"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()]
);
assert_eq!(
argvs(&ok("echo a'b'c")),
vec![vec!["echo".to_string(), "abc".to_string()]]
);
}
#[test]
fn the_motivating_line_parses_into_its_three_stages() {
let line = ok("cd infra && kubectl get pods | grep CrashLoop");
assert_eq!(
argvs(&line),
vec![
vec!["cd".to_string(), "infra".to_string()],
vec!["kubectl".to_string(), "get".to_string(), "pods".to_string()],
vec!["grep".to_string(), "CrashLoop".to_string()],
]
);
assert_eq!(line.seq.len(), 1);
assert_eq!(line.seq[0].rest.len(), 1);
assert_eq!(line.seq[0].rest[0].0, AndOr::And);
assert_eq!(line.seq[0].rest[0].1.stages.len(), 2);
}
#[test]
fn sequences_and_newlines_are_the_same_separator() {
assert_eq!(argvs(&ok("a; b")), argvs(&ok("a\nb")));
assert_eq!(ok("a; b").seq.len(), 2);
assert_eq!(ok("a; ").seq.len(), 1);
assert_eq!(ok("a; ; b").seq.len(), 2);
assert_eq!(refused("a;; b").construct, "a `case` clause terminator");
}
#[test]
fn every_supported_redirect_is_recognised() {
let cases: &[(&str, RedirectKind, Option<&str>)] = &[
("c > o", RedirectKind::Stdout, Some("o")),
("c 1> o", RedirectKind::Stdout, Some("o")),
("c >> o", RedirectKind::StdoutAppend, Some("o")),
("c < i", RedirectKind::Stdin, Some("i")),
("c 0< i", RedirectKind::Stdin, Some("i")),
("c 2> e", RedirectKind::Stderr, Some("e")),
("c 2>> e", RedirectKind::StderrAppend, Some("e")),
("c 2>&1", RedirectKind::StderrToStdout, None),
];
for (src, kind, target) in cases {
let line = ok(src);
let cmd = line.commands().next().expect("one command");
assert_eq!(cmd.argv, vec!["c".to_string()], "argv for `{src}`");
assert_eq!(cmd.redirects.len(), 1, "redirect count for `{src}`");
assert_eq!(cmd.redirects[0].kind, *kind, "kind for `{src}`");
assert_eq!(
cmd.redirects[0].target.as_deref(),
*target,
"target for `{src}`"
);
}
}
#[test]
fn write_redirects_are_distinguished_from_read_ones() {
assert!(RedirectKind::Stdout.is_write());
assert!(RedirectKind::StdoutAppend.is_write());
assert!(RedirectKind::Stderr.is_write());
assert!(RedirectKind::StderrAppend.is_write());
assert!(!RedirectKind::Stdin.is_write());
assert!(!RedirectKind::StderrToStdout.is_write());
}
#[test]
fn a_quoted_digit_is_an_argument_not_a_descriptor() {
let line = ok("echo \"2\"> out");
let cmd = line.commands().next().expect("one command");
assert_eq!(cmd.argv, vec!["echo".to_string(), "2".to_string()]);
assert_eq!(cmd.redirects[0].kind, RedirectKind::Stdout);
}
#[test]
fn an_unquoted_digit_immediately_before_an_operator_is_a_descriptor() {
let line = ok("cmd 2> err");
let cmd = line.commands().next().expect("one command");
assert_eq!(cmd.argv, vec!["cmd".to_string()]);
assert_eq!(cmd.redirects[0].kind, RedirectKind::Stderr);
let line = ok("cmd 2 > err");
let cmd = line.commands().next().expect("one command");
assert_eq!(cmd.argv, vec!["cmd".to_string(), "2".to_string()]);
assert_eq!(cmd.redirects[0].kind, RedirectKind::Stdout);
}
#[test]
fn every_excluded_construct_is_refused_by_name() {
let cases: &[(&str, &str)] = &[
("echo $(date)", "command substitution"),
("echo `date`", "command substitution"),
("echo \"`date`\"", "command substitution"),
("echo $HOME", "parameter expansion"),
("echo ${HOME}", "parameter expansion"),
("echo \"$HOME\"", "parameter or command expansion"),
("echo $((1+1))", "arithmetic expansion"),
("diff <(a) <(b)", "process substitution"),
("(cd x && ls)", "a subshell"),
("{ ls; }", "a brace group or brace expansion"),
("cat <<EOF", "a here-document"),
("sleep 10 &", "background execution"),
("ls *.rs", "a glob pattern"),
("ls file?.txt", "a glob pattern"),
("ls [ab].txt", "a glob pattern"),
("cd ~/src", "tilde expansion"),
("ls # comment", "a comment"),
("! ls", "pipeline negation or history expansion"),
("x;; y", "a `case` clause terminator"),
("if test -f x", "a shell keyword"),
("for f in a b", "a shell keyword"),
("while true", "a shell keyword"),
("case x in", "a shell keyword"),
("ls && then ls", "a shell keyword"),
("function f", "a shell keyword"),
("cmd >| out", "a clobbering redirect"),
("cmd <> f", "a read-write redirect"),
("cmd 3> f", "a redirect of an unsupported file descriptor"),
("cmd 1>&2", "a descriptor duplication"),
("cmd 2>&12", "a descriptor duplication"),
("cmd <&0", "an input descriptor duplication"),
("echo 'unterminated", "an unterminated single quote"),
("echo \"unterminated", "an unterminated double quote"),
("echo trailing\\", "a trailing backslash"),
("> out", "a redirect with no command"),
("ls |", "a dangling pipe"),
("ls &&", "a dangling operator"),
("ls > ", "a redirect with no target"),
("", "an empty command line"),
];
for (src, construct) in cases {
let r = refused(src);
assert_eq!(&r.construct, construct, "wrong construct for `{src}`: {r}");
}
}
#[test]
fn a_refused_line_names_a_character_offset_inside_it() {
let r = refused("ls && echo $HOME");
assert_eq!(r.construct, "parameter expansion");
assert_eq!(r.at, 11, "offset should point at the `$`");
}
#[test]
fn quoting_makes_a_refused_character_an_ordinary_argument() {
assert_eq!(
argvs(&ok("grep '$HOME' f")),
vec![vec![
"grep".to_string(),
"$HOME".to_string(),
"f".to_string()
]]
);
assert_eq!(
argvs(&ok("grep \\$HOME f")),
vec![vec![
"grep".to_string(),
"$HOME".to_string(),
"f".to_string()
]]
);
assert_eq!(
argvs(&ok("grep '*.rs' f")),
vec![vec![
"grep".to_string(),
"*.rs".to_string(),
"f".to_string()
]]
);
}
#[test]
fn double_quotes_follow_the_posix_escape_rule_exactly() {
assert_eq!(
argvs(&ok("echo \"a\\\"b\"")),
vec![vec!["echo".to_string(), "a\"b".to_string()]]
);
assert_eq!(
argvs(&ok("echo \"a\\\\b\"")),
vec![vec!["echo".to_string(), "a\\b".to_string()]]
);
assert_eq!(
argvs(&ok("echo \"a\\nb\"")),
vec![vec!["echo".to_string(), "a\\nb".to_string()]]
);
}
#[test]
fn non_ascii_is_a_literal_and_control_characters_are_not() {
assert_eq!(
argvs(&ok("grep café ./notes")),
vec![vec![
"grep".to_string(),
"café".to_string(),
"./notes".to_string()
]]
);
for src in [
"echo a\u{0}b",
"echo 'a\u{0}b'",
"echo \"a\u{0}b\"",
"echo a\rb",
] {
assert_eq!(refused(src).construct, "a control character", "for {src:?}");
}
}
#[test]
fn the_length_and_stage_caps_refuse_rather_than_allocate() {
let long = "a".repeat(MAX_LINE_CHARS + 1);
assert_eq!(refused(&long).construct, "an over-long command line");
let many = vec!["true"; MAX_COMMANDS + 1].join(" | ");
assert_eq!(refused(&many).construct, "an over-long command line");
assert_eq!(ok(&"a".repeat(MAX_LINE_CHARS)).seq.len(), 1);
let many = vec!["true"; MAX_COMMANDS].join(" | ");
assert_eq!(ok(&many).commands().count(), MAX_COMMANDS);
}
#[test]
fn no_input_containing_an_unpermitted_character_is_ever_accepted() {
const ALPHABET: &[char] = &[
'a', 'b', '1', '2', ' ', '\t', '\n', '|', '&', ';', '<', '>', '(', ')', '{', '}', '$',
'`', '*', '?', '[', ']', '~', '#', '!', '\'', '"', '\\', '=', '-', '.', '/', '\u{0}',
'\u{7}', '\u{1b}', 'é',
];
let mut seed: u64 = 0x5eed_1234_dead_beef;
let mut next = move || {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(seed >> 33) as usize
};
let mut accepted = 0usize;
for _ in 0..20_000 {
let len = 1 + next() % 12;
let src: String = (0..len)
.map(|_| ALPHABET[next() % ALPHABET.len()])
.collect();
let Ok(line) = parse(&src) else { continue };
accepted += 1;
for cmd in line.commands() {
for word in cmd
.argv
.iter()
.chain(cmd.redirects.iter().filter_map(|r| r.target.as_ref()))
{
for c in word.chars() {
assert!(
is_permitted_in_quotes(c),
"control character U+{:04X} survived from {src:?}",
c as u32
);
}
}
}
const NEVER_UNQUOTED: &[char] = &[
'$', '`', '(', ')', '{', '}', '*', '?', '[', ']', '~', '#', '!', '|', '&', ';',
'<', '>', '\'', '"', '\\', ' ', '\t', '\n',
];
if !src.contains(['\'', '"', '\\']) {
for cmd in line.commands() {
for word in cmd
.argv
.iter()
.chain(cmd.redirects.iter().filter_map(|r| r.target.as_ref()))
{
for c in word.chars() {
assert!(
!NEVER_UNQUOTED.contains(&c),
"shell metacharacter {c:?} reached an argv from unquoted {src:?}"
);
}
}
}
}
}
assert!(
accepted > 200,
"only {accepted} inputs parsed; the property was barely exercised"
);
}
const PERMITTED_PUNCTUATION: &[char] = &['_', '-', '.', '/', '=', '+', ':', ',', '@', '%'];
const ADMITTED_SYNTAX: &[char] = &[' ', '\t', '\n', ';', '|', '<', '>', '&', '\'', '"', '\\'];
#[test]
fn every_ascii_character_outside_the_permitted_set_is_refused() {
for byte in 0u8..=127 {
let c = byte as char;
if c.is_ascii_alphanumeric() || ADMITTED_SYNTAX.contains(&c) {
continue;
}
let src = format!("echo A{c}B");
let parsed = parse(&src);
if PERMITTED_PUNCTUATION.contains(&c) {
let line = parsed.unwrap_or_else(|e| {
panic!("U+{byte:04X} ({c:?}) is permitted but {src:?} was refused: {e}")
});
let cmds: Vec<_> = line.commands().collect();
assert_eq!(cmds.len(), 1, "U+{byte:04X} ({c:?}) split {src:?}");
assert_eq!(
cmds[0].argv,
vec!["echo".to_string(), format!("A{c}B")],
"U+{byte:04X} ({c:?}) changed the argv of {src:?}"
);
} else {
assert!(
parsed.is_err(),
"U+{byte:04X} ({c:?}) is outside the permitted set but {src:?} parsed as {:?}",
parsed.map(|l| l.commands().map(|c| c.argv.clone()).collect::<Vec<_>>())
);
}
}
}
#[test]
fn every_ascii_character_is_either_refused_or_structurally_inert() {
for byte in 0u8..=127 {
let c = byte as char;
if c.is_ascii_alphanumeric() {
continue;
}
if ADMITTED_SYNTAX.contains(&c) {
continue;
}
let src = format!("echo A{c}B");
let Ok(line) = parse(&src) else { continue };
let cmds: Vec<_> = line.commands().collect();
assert_eq!(
cmds.len(),
1,
"U+{byte:04X} ({c:?}) created {} sub-commands from {src:?}",
cmds.len()
);
assert!(
cmds[0].redirects.is_empty(),
"U+{byte:04X} ({c:?}) created a redirect from {src:?}"
);
assert_eq!(
cmds[0].argv,
vec!["echo".to_string(), format!("A{c}B")],
"U+{byte:04X} ({c:?}) changed the argv of {src:?}"
);
}
}
#[test]
fn every_ascii_character_in_command_position_is_refused_or_inert() {
for byte in 0u8..=127 {
let c = byte as char;
if c.is_ascii_alphanumeric() {
continue;
}
if ADMITTED_SYNTAX.contains(&c) {
continue;
}
let src = format!("A{c}B arg");
let Ok(line) = parse(&src) else { continue };
let cmds: Vec<_> = line.commands().collect();
assert_eq!(
cmds.len(),
1,
"U+{byte:04X} ({c:?}) created {} sub-commands from {src:?}",
cmds.len()
);
assert_eq!(
cmds[0].argv,
vec![format!("A{c}B"), "arg".to_string()],
"U+{byte:04X} ({c:?}) changed the argv of {src:?}"
);
}
}
#[test]
fn the_reported_stage_count_matches_the_operators_in_the_line() {
let cases = [
("a", 1),
("a | b", 2),
("a && b", 2),
("a || b", 2),
("a; b", 2),
("a | b | c", 3),
("a && b | c; d", 4),
("a > f && b < g | c", 3),
];
for (src, want) in cases {
assert_eq!(ok(src).commands().count(), want, "for `{src}`");
}
}
}