use std::fmt;
use std::iter::Peekable;
use std::mem::take;
use std::str::Chars;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Dynamic(&'static str),
Unsupported(&'static str),
Malformed(String),
}
impl Refusal {
#[must_use]
pub fn construct(&self) -> String {
match self {
Self::Dynamic(c) | Self::Unsupported(c) => (*c).to_string(),
Self::Malformed(_) => "malformed input".to_string(),
}
}
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dynamic(c) => write!(
f,
"refused by design: {c} is a dynamic construct the confined shell does not \
interpret (use the embedder's unbridled/--yolo path for a full shell)"
),
Self::Unsupported(c) => write!(
f,
"not yet supported by the confined shell engine: {c} (tracked on agent-bridle#34)"
),
Self::Malformed(why) => write!(f, "malformed command: {why}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Seg {
Lit(String),
Var(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Arg {
Lit(String),
Glob(String),
Var(Vec<Seg>),
VarGlob(Vec<Seg>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Redirect {
Stdout { path: Vec<Seg>, append: bool },
Stdin { path: Vec<Seg> },
Stderr { path: Vec<Seg>, append: bool },
StderrToStdout,
}
#[must_use]
pub(crate) fn seg_literal(segs: &[Seg]) -> Option<&str> {
match segs {
[Seg::Lit(s)] => Some(s.as_str()),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StderrTo {
Capture,
File { path: String, append: bool },
Stdout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub argv: Vec<Arg>,
pub redirects: Vec<Redirect>,
}
impl Command {
#[must_use]
pub fn stdin_path(&self) -> Option<&str> {
self.redirects.iter().rev().find_map(|r| match r {
Redirect::Stdin { path } => seg_literal(path),
_ => None,
})
}
#[must_use]
pub fn stdout_redirect(&self) -> Option<(&str, bool)> {
self.redirects.iter().rev().find_map(|r| match r {
Redirect::Stdout { path, append } => seg_literal(path).map(|p| (p, *append)),
_ => None,
})
}
#[must_use]
pub fn stderr_disposition(&self) -> StderrTo {
self.redirects
.iter()
.rev()
.find_map(|r| match r {
Redirect::Stderr { path, append } => seg_literal(path).map(|p| StderrTo::File {
path: p.to_string(),
append: *append,
}),
Redirect::StderrToStdout => Some(StderrTo::Stdout),
_ => None,
})
.unwrap_or(StderrTo::Capture)
}
}
pub type Pipeline = Vec<Command>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sep {
Seq,
And,
Or,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptItem {
pub sep: Sep,
pub pipeline: Pipeline,
}
pub type Script = Vec<ScriptItem>;
pub fn classify(input: &str) -> Result<Script, Refusal> {
let mut chars = input.chars().peekable();
let mut script: Script = Vec::new();
let mut pending_sep = Sep::Seq; let mut stages: Vec<Command> = Vec::new(); let mut argv: Vec<Arg> = Vec::new(); let mut redirects: Vec<Redirect> = Vec::new();
loop {
skip_whitespace(&mut chars);
let Some(&c) = chars.peek() else { break };
match c {
'|' => {
chars.next();
if chars.peek() == Some(&'|') {
chars.next();
push_pipeline(
&mut script,
&mut stages,
&mut argv,
&mut redirects,
&mut pending_sep,
Sep::Or,
)?;
} else {
if argv.is_empty() {
return Err(Refusal::Malformed(
"empty pipeline stage (nothing before `|`)".into(),
));
}
stages.push(Command {
argv: take(&mut argv),
redirects: take(&mut redirects),
});
}
}
'&' => {
chars.next();
if chars.peek() == Some(&'&') {
chars.next();
push_pipeline(
&mut script,
&mut stages,
&mut argv,
&mut redirects,
&mut pending_sep,
Sep::And,
)?;
} else {
return Err(Refusal::Unsupported("background `&`"));
}
}
';' => {
chars.next();
push_pipeline(
&mut script,
&mut stages,
&mut argv,
&mut redirects,
&mut pending_sep,
Sep::Seq,
)?;
}
'(' | ')' => return Err(Refusal::Dynamic("subshell `( )`")),
'`' => return Err(Refusal::Dynamic("command substitution (backticks)")),
'>' => {
chars.next();
let append = chars.peek() == Some(&'>');
if append {
chars.next();
}
if chars.peek() == Some(&'&') {
return Err(Refusal::Unsupported("`>&` (fd duplication)"));
}
let path = read_redirect_target(&mut chars)?;
redirects.push(Redirect::Stdout { path, append });
}
'<' => {
chars.next();
if chars.peek() == Some(&'<') {
return Err(Refusal::Unsupported("heredoc/herestring `<<`"));
}
if chars.peek() == Some(&'&') {
return Err(Refusal::Unsupported("`<&` (fd duplication)"));
}
let path = read_redirect_target(&mut chars)?;
redirects.push(Redirect::Stdin { path });
}
_ => {
let arg = read_word(&mut chars)?;
if let Arg::Lit(s) = &arg {
if !s.is_empty()
&& s.bytes().all(|b| b.is_ascii_digit())
&& matches!(chars.peek(), Some('>') | Some('<'))
{
read_fd_redirect(s, &mut chars, &mut redirects)?;
continue;
}
}
argv.push(arg);
}
}
}
match finalize_pipeline(&mut stages, &mut argv, &mut redirects)? {
Some(pipeline) => script.push(ScriptItem {
sep: pending_sep,
pipeline,
}),
None => {
if script.is_empty() {
return Err(Refusal::Malformed("empty command".into()));
}
if !matches!(pending_sep, Sep::Seq) {
return Err(Refusal::Malformed(
"expected a command after `&&` or `||`".into(),
));
}
}
}
Ok(script)
}
fn push_pipeline(
script: &mut Script,
stages: &mut Vec<Command>,
argv: &mut Vec<Arg>,
redirects: &mut Vec<Redirect>,
pending_sep: &mut Sep,
next_sep: Sep,
) -> Result<(), Refusal> {
match finalize_pipeline(stages, argv, redirects)? {
Some(pipeline) => {
script.push(ScriptItem {
sep: *pending_sep,
pipeline,
});
*pending_sep = next_sep;
Ok(())
}
None => Err(Refusal::Malformed(
"empty command around a separator (`&&`/`||`/`;`)".into(),
)),
}
}
fn finalize_pipeline(
stages: &mut Vec<Command>,
argv: &mut Vec<Arg>,
redirects: &mut Vec<Redirect>,
) -> Result<Option<Pipeline>, Refusal> {
if argv.is_empty() {
if !stages.is_empty() {
return Err(Refusal::Malformed(
"empty pipeline stage (nothing after `|`)".into(),
));
}
if !redirects.is_empty() {
return Err(Refusal::Malformed("redirection without a command".into()));
}
return Ok(None);
}
stages.push(Command {
argv: take(argv),
redirects: take(redirects),
});
Ok(Some(take(stages)))
}
fn skip_whitespace(chars: &mut Peekable<Chars>) {
while matches!(chars.peek(), Some(' ' | '\t' | '\n' | '\r')) {
chars.next();
}
}
fn read_word(chars: &mut Peekable<Chars>) -> Result<Arg, Refusal> {
let mut cur = String::new(); let mut segs: Vec<Seg> = Vec::new(); let mut is_glob = false;
while let Some(&c) = chars.peek() {
match c {
' ' | '\t' | '\n' | '\r' => break,
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => break,
'$' => {
chars.next(); let name = read_var_name(chars)?;
if !cur.is_empty() {
segs.push(Seg::Lit(std::mem::take(&mut cur)));
}
segs.push(Seg::Var(name));
}
'*' | '?' | '[' => {
cur.push(c);
chars.next();
is_glob = true;
}
'\'' => {
chars.next();
loop {
match chars.next() {
Some('\'') => break,
Some(ch) => cur.push(ch),
None => return Err(Refusal::Malformed("unterminated single quote".into())),
}
}
}
'"' => {
chars.next();
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.peek() {
Some(&n) if matches!(n, '"' | '\\' | '$' | '`') => {
cur.push(n);
chars.next();
}
_ => cur.push('\\'),
},
Some('$') => {
let name = read_var_name(chars)?;
if !cur.is_empty() {
segs.push(Seg::Lit(std::mem::take(&mut cur)));
}
segs.push(Seg::Var(name));
}
Some('`') => {
return Err(Refusal::Dynamic("command substitution (backticks)"))
}
Some(ch) => cur.push(ch),
None => return Err(Refusal::Malformed("unterminated double quote".into())),
}
}
}
'\\' => {
chars.next();
match chars.next() {
Some(n) => cur.push(n),
None => return Err(Refusal::Malformed("trailing backslash".into())),
}
}
_ => {
cur.push(c);
chars.next();
}
}
}
if segs.is_empty() {
return Ok(if is_glob {
Arg::Glob(cur)
} else {
Arg::Lit(cur)
});
}
if !cur.is_empty() {
segs.push(Seg::Lit(cur));
}
if is_glob {
Ok(Arg::VarGlob(segs))
} else {
Ok(Arg::Var(segs))
}
}
fn read_var_name(chars: &mut Peekable<Chars>) -> Result<String, Refusal> {
let name = match chars.peek() {
Some('(') => return Err(Refusal::Dynamic("command/arithmetic substitution `$(`")),
Some('{') => {
chars.next(); let mut name = String::new();
loop {
match chars.next() {
Some('}') => break,
Some(ch) => name.push(ch),
None => return Err(Refusal::Malformed("unterminated `${`".into())),
}
}
name
}
Some(&c) if c == '_' || c.is_ascii_alphabetic() => {
let mut name = String::new();
while let Some(&ch) = chars.peek() {
if ch == '_' || ch.is_ascii_alphanumeric() {
name.push(ch);
chars.next();
} else {
break;
}
}
name
}
_ => {
return Err(Refusal::Unsupported(
"bare `$` (escape as `\\$` for a literal dollar)",
))
}
};
if !is_valid_var_name(&name) {
return Err(Refusal::Unsupported("unsupported variable name"));
}
Ok(name)
}
fn is_valid_var_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
fn read_redirect_target(chars: &mut Peekable<Chars>) -> Result<Vec<Seg>, Refusal> {
skip_whitespace(chars);
match chars.peek().copied() {
None | Some('|' | '&' | ';' | '<' | '>' | '(' | ')') => {
return Err(Refusal::Malformed("missing redirection target".into()))
}
_ => {}
}
match read_word(chars)? {
Arg::Lit(s) if !s.is_empty() => Ok(vec![Seg::Lit(s)]),
Arg::Lit(_) => Err(Refusal::Malformed("empty redirection target".into())),
Arg::Glob(_) | Arg::VarGlob(_) => Err(Refusal::Unsupported("glob in a redirection target")),
Arg::Var(segs) => Ok(segs),
}
}
fn read_fd_redirect(
fd: &str,
chars: &mut Peekable<Chars>,
redirects: &mut Vec<Redirect>,
) -> Result<(), Refusal> {
if chars.peek() == Some(&'<') {
chars.next();
if fd != "0" {
return Err(Refusal::Unsupported("fd-number input redirection"));
}
if chars.peek() == Some(&'<') {
return Err(Refusal::Unsupported("heredoc/herestring `<<`"));
}
if chars.peek() == Some(&'&') {
return Err(Refusal::Unsupported("`<&` (fd duplication)"));
}
redirects.push(Redirect::Stdin {
path: read_redirect_target(chars)?,
});
return Ok(());
}
chars.next(); let append = chars.peek() == Some(&'>');
if append {
chars.next();
}
if chars.peek() == Some(&'&') {
chars.next(); let mut target = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
target.push(d);
chars.next();
} else {
break;
}
}
if fd == "2" && target == "1" {
redirects.push(Redirect::StderrToStdout);
return Ok(());
}
return Err(Refusal::Unsupported(
"fd duplication (only `2>&1` is supported)",
));
}
let path = read_redirect_target(chars)?;
match fd {
"1" => redirects.push(Redirect::Stdout { path, append }),
"2" => redirects.push(Redirect::Stderr { path, append }),
_ => {
return Err(Refusal::Unsupported(
"fd-number redirection (only 1>, 2>, 0<, 2>&1)",
))
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn lit(s: &str) -> Arg {
Arg::Lit(s.to_string())
}
fn glob(s: &str) -> Arg {
Arg::Glob(s.to_string())
}
fn var(name: &str) -> Arg {
Arg::Var(vec![Seg::Var(name.to_string())])
}
fn slit(s: &str) -> Seg {
Seg::Lit(s.to_string())
}
fn svar(s: &str) -> Seg {
Seg::Var(s.to_string())
}
fn stage(args: Vec<Arg>) -> Command {
Command {
argv: args,
redirects: vec![],
}
}
fn one_stage(args: Vec<Arg>) -> Script {
vec![ScriptItem {
sep: Sep::Seq,
pipeline: vec![stage(args)],
}]
}
#[test]
fn literals_globs_pipelines_redirects_sequencing() {
assert_eq!(
classify("echo hi").unwrap(),
one_stage(vec![lit("echo"), lit("hi")])
);
assert_eq!(
classify("ls *.rs").unwrap(),
one_stage(vec![lit("ls"), glob("*.rs")])
);
assert_eq!(
classify("echo \"a*b\"").unwrap(),
one_stage(vec![lit("echo"), lit("a*b")])
);
assert!(classify("a && b").is_ok());
let p = classify("echo hi > out").unwrap();
assert_eq!(p[0].pipeline[0].stdout_redirect(), Some(("out", false)));
}
#[test]
fn standalone_variables_are_marked() {
assert_eq!(
classify("echo $HOME").unwrap(),
one_stage(vec![lit("echo"), var("HOME")])
);
assert_eq!(
classify("echo ${HOME}").unwrap(),
one_stage(vec![lit("echo"), var("HOME")])
);
assert_eq!(
classify("ls $TMPDIR").unwrap(),
one_stage(vec![lit("ls"), var("TMPDIR")])
);
assert_eq!(
classify("cat $FILE | wc").unwrap(),
vec![ScriptItem {
sep: Sep::Seq,
pipeline: vec![stage(vec![lit("cat"), var("FILE")]), stage(vec![lit("wc")])],
}]
);
}
#[test]
fn mixed_and_quoted_variables_are_segments() {
assert_eq!(
classify("echo $HOME/x").unwrap(),
one_stage(vec![lit("echo"), Arg::Var(vec![svar("HOME"), slit("/x")])])
);
assert_eq!(
classify("echo foo$HOME").unwrap(),
one_stage(vec![lit("echo"), Arg::Var(vec![slit("foo"), svar("HOME")])])
);
assert_eq!(
classify("echo \"$HOME/x\"").unwrap(),
one_stage(vec![lit("echo"), Arg::Var(vec![svar("HOME"), slit("/x")])])
);
assert_eq!(
classify("echo pre${X}.log").unwrap(),
one_stage(vec![
lit("echo"),
Arg::Var(vec![slit("pre"), svar("X"), slit(".log")]),
])
);
}
#[test]
fn glob_and_variable_in_one_word_parses_as_varglob() {
let p = classify("ls $DIR/*.rs").unwrap();
assert_eq!(
p[0].pipeline[0].argv,
vec![lit("ls"), Arg::VarGlob(vec![svar("DIR"), slit("/*.rs")])]
);
}
#[test]
fn invalid_or_dynamic_variable_forms_are_refused() {
assert!(matches!(classify("echo $1"), Err(Refusal::Unsupported(_)))); assert!(matches!(classify("echo $"), Err(Refusal::Unsupported(_)))); assert!(matches!(classify("echo $(id)"), Err(Refusal::Dynamic(_)))); assert_eq!(
classify("echo \\$HOME").unwrap(),
one_stage(vec![lit("echo"), lit("$HOME")])
);
}
#[test]
fn variables_parse_in_redirect_targets() {
let p = classify("echo hi > $HOME/out").unwrap();
assert_eq!(
p[0].pipeline[0].redirects,
vec![Redirect::Stdout {
path: vec![svar("HOME"), slit("/out")],
append: false,
}]
);
assert!(matches!(
classify("echo > *.log"),
Err(Refusal::Unsupported(_))
));
}
#[test]
fn stderr_to_file_and_append() {
let p = classify("cmd 2> err").unwrap();
assert_eq!(
p[0].pipeline[0].stderr_disposition(),
StderrTo::File {
path: "err".into(),
append: false
}
);
let p = classify("cmd 2>> err").unwrap();
assert_eq!(
p[0].pipeline[0].stderr_disposition(),
StderrTo::File {
path: "err".into(),
append: true
}
);
}
#[test]
fn merge_and_fd_aliases() {
assert_eq!(
classify("cmd 2>&1").unwrap()[0].pipeline[0].stderr_disposition(),
StderrTo::Stdout
);
let p = classify("cmd 1> out").unwrap();
assert_eq!(p[0].pipeline[0].stdout_redirect(), Some(("out", false)));
let p = classify("cmd 0< in").unwrap();
assert_eq!(p[0].pipeline[0].stdin_path(), Some("in"));
let p = classify("cmd > out 2>&1").unwrap();
assert_eq!(p[0].pipeline[0].stdout_redirect(), Some(("out", false)));
assert_eq!(p[0].pipeline[0].stderr_disposition(), StderrTo::Stdout);
assert_eq!(
classify("head -2").unwrap(),
one_stage(vec![lit("head"), lit("-2")])
);
}
#[test]
fn unsupported_fd_forms_refused() {
assert!(matches!(classify("cmd 3> f"), Err(Refusal::Unsupported(_)))); assert!(matches!(classify("cmd 1>&2"), Err(Refusal::Unsupported(_)))); assert!(matches!(classify("cmd 2<&1"), Err(Refusal::Unsupported(_)))); }
#[test]
fn dynamic_and_malformed_still_refused() {
assert!(matches!(classify("echo `id`"), Err(Refusal::Dynamic(_))));
assert!(matches!(classify("(echo hi)"), Err(Refusal::Dynamic(_))));
assert!(matches!(classify(""), Err(Refusal::Malformed(_))));
assert!(matches!(classify("a &&"), Err(Refusal::Malformed(_))));
}
}