mod awk;
mod commands;
mod copy;
mod cwd;
mod database;
mod decoders;
mod embedded;
mod error;
pub(crate) mod git;
mod in_place;
mod invocation;
mod make;
mod operands;
mod options;
mod path_options;
pub(crate) mod push;
mod sed;
pub(crate) mod tables;
#[cfg(test)]
mod tests;
mod text;
pub(crate) mod tokens;
mod upload;
pub(crate) use error::ClassifyError;
use crate::action::AtomicAction;
use crate::lexer::Token;
use crate::paths;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseOutcome {
Parsed(Vec<AtomicAction>),
Unparseable {
reason: String,
atoms: Vec<AtomicAction>,
asks: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unparsed {
pub reason: String,
pub asks: bool,
}
impl ParseOutcome {
#[must_use]
pub fn into_parts(self) -> (Vec<AtomicAction>, Option<Unparsed>) {
match self {
Self::Parsed(atoms) => (atoms, None),
Self::Unparseable {
reason,
atoms,
asks,
} => (atoms, Some(Unparsed { reason, asks })),
}
}
}
pub(crate) fn program_name(argv: &[String]) -> &str {
argv.first().map_or("", |p| tokens::basename(p))
}
pub(crate) fn names_hosts(argv: &[String]) -> bool {
let program = program_name(argv);
tables::HOST_PROGRAMS.contains(&program) || (program == "git" && git::names_hosts(argv))
}
const WORD_PROJECT: Option<&str> = None;
#[derive(Debug, Clone, Copy)]
pub struct ShellContext<'a> {
pub home: &'a str,
pub cwd: &'a [Option<String>],
}
impl ShellContext<'_> {
pub(crate) fn paths(&self, word: &str) -> Result<Vec<String>, ClassifyError> {
if let Some(path) = paths::resolve(word, self.home, WORD_PROJECT, None) {
return Ok(vec![path]);
}
self.cwd
.iter()
.map(|cwd| {
paths::resolve(word, self.home, WORD_PROJECT, cwd.as_deref())
.ok_or_else(|| ClassifyError::UnknownDirectory(word.to_owned()))
})
.collect()
}
pub(crate) fn traversals(&self, word: &str) -> Vec<String> {
if let Some(path) = paths::traversal(word, self.home, WORD_PROJECT, None) {
return vec![path];
}
self.cwd
.iter()
.filter_map(|cwd| paths::traversal(word, self.home, WORD_PROJECT, cwd.as_deref()))
.collect()
}
}
pub const MAX_DEPTH: u8 = 4;
pub const MAX_ATOMS: usize = 2048;
pub const MAX_DIRS: usize = 16;
#[cfg(test)]
#[must_use]
pub fn classify(command: &str, ctx: &ShellContext<'_>) -> ParseOutcome {
classify_globs(command, ctx).0
}
#[must_use]
pub fn classify_globs(
command: &str,
ctx: &ShellContext<'_>,
) -> (ParseOutcome, Vec<(AtomicAction, bool)>) {
let mut sink = Sink::default();
let mut stopped = commands::classify_into(command, ctx, &mut sink, 0).err();
let split = command.replace("${IFS}", " ").replace("$IFS", " ");
if split != command {
let again = commands::classify_into(&split, ctx, &mut sink, 0).err();
stopped = stopped.or(again);
}
let asks = sink.asks || stopped.as_ref().is_some_and(ClassifyError::asks);
let outcome = match sink.skipped.or(stopped) {
Some(e) => ParseOutcome::Unparseable {
reason: e.to_string(),
atoms: sink.atoms,
asks,
},
None if sink.atoms.is_empty() => ParseOutcome::Unparseable {
reason: "no command found".to_owned(),
atoms: Vec::new(),
asks: false,
},
None => ParseOutcome::Parsed(sink.atoms),
};
(outcome, sink.globs)
}
pub(crate) fn is_lowercase(name: &str) -> bool {
name.bytes().any(|b| b.is_ascii_lowercase())
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
#[derive(Debug, Default)]
pub(crate) struct Sink {
atoms: Vec<AtomicAction>,
skipped: Option<ClassifyError>,
asks: bool,
glob_words: Vec<String>,
globs: Vec<(AtomicAction, bool)>,
no_dot_dirs: bool,
dollar_words: Vec<String>,
locals: Vec<(String, u8, Option<Vec<String>>)>,
depth: u8,
pushes: Vec<push::Push>,
}
impl Sink {
pub(crate) fn enter(&mut self, depth: u8) {
self.depth = depth;
self.no_dot_dirs = false;
}
pub(crate) fn refuse_dot_dirs(&mut self, program: &str) {
self.no_dot_dirs = matches!(program, "rm" | "rmdir");
}
pub(crate) fn note_local(&mut self, name: &str, values: Option<Vec<String>>) {
self.locals
.retain(|(n, d, _)| !(n == name && *d == self.depth));
self.locals.push((name.to_owned(), self.depth, values));
}
pub(crate) fn known_locals(&self, depth: u8) -> Vec<(String, Vec<String>)> {
self.locals
.iter()
.filter(|(_, d, _)| *d == depth)
.filter_map(|(n, _, v)| Some((n.clone(), v.clone()?)))
.collect()
}
pub(crate) fn push(&mut self, atom: AtomicAction) -> Result<(), ClassifyError> {
if let AtomicAction::EnvRead { name } = &atom
&& is_lowercase(name)
&& self
.locals
.iter()
.any(|(n, d, v)| n == name && *d == self.depth && v.is_some())
{
return Ok(());
}
if self.atoms.contains(&atom) {
return Ok(());
}
if self.atoms.len() >= MAX_ATOMS {
return Err(ClassifyError::TooManyActions);
}
self.atoms.push(atom);
Ok(())
}
pub(crate) fn skip(&mut self, error: ClassifyError) {
self.asks |= error.asks();
self.skipped.get_or_insert(error);
}
fn overrides_a_known_name(&self, word: &str) -> bool {
["HOME", "PWD", "project"].iter().any(|name| {
let refers = word.contains(&format!("${{{name}}}"))
|| word.match_indices(&format!("${name}")).any(|(at, _)| {
!word[at + 1 + name.len()..]
.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')
});
refers
&& self
.locals
.iter()
.any(|(n, d, v)| n == name && *d == self.depth && v.is_none())
})
}
fn paths(&mut self, ctx: &ShellContext<'_>, word: &str) -> Vec<String> {
if (paths::has_unexpanded_variable(word) || self.overrides_a_known_name(word))
&& self.dollar_words.iter().any(|w| w.contains(word))
{
let atom = AtomicAction::Shell {
argv: vec!["@unknown-path".to_owned(), word.to_owned()],
};
if let Err(full) = self.push(atom) {
self.skip(full);
}
self.skip(ClassifyError::UnknownValue(word.to_owned()));
}
ctx.paths(word).unwrap_or_else(|e| {
if paths::other_user_home(word, ctx.home) {
let atom = AtomicAction::Shell {
argv: vec!["@other-home".to_owned(), word.to_owned()],
};
if let Err(full) = self.push(atom) {
self.skip(full);
}
}
self.skip(e);
Vec::new()
})
}
pub(crate) fn note_globs(&mut self, tokens: &[Token]) {
for token in tokens {
if let Token::Word(w) = token {
if w.glob && !self.glob_words.contains(&w.text) {
self.glob_words.push(w.text.clone());
}
self.note_dollar_word(w);
}
}
}
pub(crate) fn note_dollar_word(&mut self, word: &crate::lexer::Word) {
let expands = !word.substitutions.is_empty()
|| word
.text
.match_indices('$')
.any(|(pos, _)| !word.literal.iter().any(|span| span.contains(&pos)));
if expands && !self.dollar_words.contains(&word.text) {
self.dollar_words.push(word.text.clone());
}
}
pub(crate) fn read(&mut self, ctx: &ShellContext<'_>, word: &str) -> Result<(), ClassifyError> {
self.fs(ctx, word, |path| AtomicAction::FsRead { path })
}
pub(crate) fn write(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
) -> Result<(), ClassifyError> {
self.fs(ctx, word, |path| AtomicAction::FsWrite { path })
}
pub(crate) fn redirect_read(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
) -> Result<(), ClassifyError> {
self.redirect(ctx, word, tables::STREAM_SOURCES, |path| {
AtomicAction::FsRead { path }
})
}
pub(crate) fn redirect_write(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
) -> Result<(), ClassifyError> {
self.redirect(ctx, word, tables::STREAM_SINKS, |path| {
AtomicAction::FsWrite { path }
})
}
fn redirect(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
streams: &[&str],
atom: fn(String) -> AtomicAction,
) -> Result<(), ClassifyError> {
let stream = !word.contains(['*', '?', '['])
&& ctx
.paths(word)
.is_ok_and(|paths| paths.iter().all(|p| streams.contains(&p.as_str())));
if stream {
return Ok(());
}
self.fs(ctx, word, atom)
}
fn fs(
&mut self,
ctx: &ShellContext<'_>,
word: &str,
atom: fn(String) -> AtomicAction,
) -> Result<(), ClassifyError> {
let glob =
word.contains(['*', '?', '[']) && self.glob_words.iter().any(|w| w.ends_with(word));
let traversals = ctx.traversals(word);
for path in self.paths(ctx, word).into_iter().chain(traversals) {
let atom = atom(path);
let entry = (atom.clone(), !self.no_dot_dirs);
if glob && !self.globs.contains(&entry) {
self.globs.push(entry);
}
self.push(atom)?;
}
Ok(())
}
}