Implement command and environment substituion in shell subsystem
This change also implements foundations for stream redirection as well as
pipes.
Diff
Cargo.lock | 114 +++++-
pisshoff-server/Cargo.toml | 3 +-
pisshoff-server/src/audit.rs | 2 +-
pisshoff-server/src/command.rs | 65 ++-
pisshoff-server/src/command/echo.rs | 9 +-
pisshoff-server/src/command/exit.rs | 2 +-
pisshoff-server/src/command/ls.rs | 2 +-
pisshoff-server/src/server.rs | 61 ++-
pisshoff-server/src/subsystem/sftp.rs | 5 +-
pisshoff-server/src/subsystem/shell.rs | 152 ++++++-
pisshoff-server/src/subsystem/shell/parser.rs | 574 +++++++++++++++++++++++++++-
pisshoff-types/src/audit.rs | 1 +-
12 files changed, 952 insertions(+), 38 deletions(-)
@@ -86,6 +86,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "arrayvec"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]]
name = "async-trait"
version = "0.1.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -97,6 +103,15 @@ dependencies = [
]
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -188,6 +203,15 @@ dependencies = [
]
[[package]]
name = "brownstone"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5839ee4f953e811bfdcf223f509cb2c6a3e1447959b0bff459405575bc17f22"
dependencies = [
"arrayvec",
]
[[package]]
name = "byteorder"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -700,6 +724,12 @@ dependencies = [
]
[[package]]
name = "indent_write"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cfe9645a18782869361d9c8732246be7b410ad4e919d3609ebabdac00ba12c3"
[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -780,6 +810,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
[[package]]
name = "joinery"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72167d68f5fce3b8655487b8038691a3c9984ee769590f93f2a631f4ad64e4f5"
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -948,6 +984,19 @@ dependencies = [
]
[[package]]
name = "nom-supreme"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd3ae6c901f1959588759ff51c95d24b491ecb9ff91aa9c2ef4acc5b1dcab27"
dependencies = [
"brownstone",
"indent_write",
"joinery",
"memchr",
"nom",
]
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1119,6 +1168,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"atoi",
"bitflags 2.3.3",
"bytes",
"clap",
@@ -1129,6 +1179,7 @@ dependencies = [
"mockall",
"nix",
"nom",
"nom-supreme",
"parking_lot",
"pisshoff-types",
"serde",
@@ -1143,6 +1194,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"yoke",
]
[[package]]
@@ -1612,6 +1664,12 @@ dependencies = [
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1684,6 +1742,17 @@ dependencies = [
]
[[package]]
name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.20",
]
[[package]]
name = "termtree"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2314,3 +2383,48 @@ dependencies = [
"bit-vec",
"num-bigint",
]
[[package]]
name = "yoke"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65e71b2e4f287f467794c671e2b8f8a5f3716b3c829079a1c44740148eff07e4"
dependencies = [
"serde",
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e6936f0cce458098a201c245a11bef556c6a0181129c7034d10d76d1ec3a2b8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.20",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655b0814c5c0b19ade497851070c640773304939a6c0fd5f5fb43da0696d05b7"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6a647510471d372f2e6c2e6b7219e44d8c574d24fdc11c610a61455782f18c3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.20",
"synstructure",
]
@@ -10,6 +10,7 @@ pisshoff-types = { path = "../pisshoff-types" }
anyhow = "1.0"
async-trait = "0.1"
atoi = "2.0"
bitflags = "2.3"
bytes = "1.4"
clap = { version = "4.3", features = ["derive", "env", "cargo"] }
@@ -18,6 +19,7 @@ parking_lot = "0.12"
fastrand = "1.9"
itertools = "0.10"
nom = "7.1"
nom-supreme = "0.8"
nix = { version = "0.26", features = ["hostname"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -30,6 +32,7 @@ toml = "0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.3", features = ["v4", "serde"] }
yoke = { version = "0.7", features = ["derive"] }
[dev-dependencies]
mockall = "0.11"
@@ -50,7 +50,7 @@ pub fn start_audit_writer(
_ = &mut shutdown_recv => {
shutdown = true;
}
_ = tokio::time::sleep(Duration::from_secs(5)), if !writer.buffer().is_empty() => {
() = tokio::time::sleep(Duration::from_secs(5)), if !writer.buffer().is_empty() => {
debug!("Flushing audits to disk");
writer.flush().await?;
}
@@ -9,13 +9,17 @@ mod whoami;
use crate::server::{ConnectionState, ThrusshSession};
use async_trait::async_trait;
use itertools::Either;
use std::borrow::Cow;
use std::fmt::Debug;
use thrussh::{server::Session, ChannelId};
use thrussh::ChannelId;
#[derive(Debug)]
pub enum CommandResult<T> {
ReadStdin(T),
Exit(u32),
Close(u32),
}
@@ -55,6 +59,34 @@ pub trait Command: Sized {
) -> CommandResult<Self>;
}
#[derive(PartialEq, Eq, Debug)]
pub struct PartialCommand<'a> {
exec: Option<Cow<'a, [u8]>>,
params: Vec<Cow<'a, [u8]>>,
}
impl<'a> PartialCommand<'a> {
pub fn new(exec: Option<Cow<'a, [u8]>>, params: Vec<Cow<'a, [u8]>>) -> Self {
Self { exec, params }
}
pub async fn into_concrete_command<S: ThrusshSession + Send>(
self,
connection: &mut ConnectionState,
channel: ChannelId,
session: &mut S,
) -> CommandResult<ConcreteCommand> {
let args = self
.params
.iter()
.map(|v| String::from_utf8_lossy(v).to_string())
.collect::<Vec<_>>();
ConcreteCommand::new(connection, self.exec.as_deref(), &args, channel, session).await
}
}
macro_rules! define_commands {
($($name:ident($ty:ty) = $command:expr),*) => {
#[derive(Debug, Clone)]
@@ -63,35 +95,36 @@ macro_rules! define_commands {
}
impl ConcreteCommand {
pub async fn new(
pub async fn new<S: ThrusshSession + Send>(
connection: &mut ConnectionState,
exec: Option<&[u8]>,
params: &[String],
channel: ChannelId,
session: &mut Session,
session: &mut S,
) -> CommandResult<Self> {
let Some(command) = params.get(0) else {
let Some(command) = exec else {
return CommandResult::Exit(0);
};
match command.as_str() {
$($command => <$ty as Command>::new(connection, ¶ms[1..], channel, session).await.map(Self::$name),)*
match command {
$($command => <$ty as Command>::new(connection, ¶ms, channel, session).await.map(Self::$name),)*
other => {
session.data(
channel,
format!("bash: {other}: command not found\n").into(),
format!("bash: {}: command not found\n", String::from_utf8_lossy(other)).into(),
);
CommandResult::Exit(1)
}
}
}
pub async fn stdin(
pub async fn stdin<S: ThrusshSession + Send>(
self,
connection: &mut ConnectionState,
channel: ChannelId,
data: &[u8],
session: &mut Session,
session: &mut S,
) -> CommandResult<Self> {
match self {
$(Self::$name(cmd) => {
@@ -107,13 +140,13 @@ macro_rules! define_commands {
}
define_commands! {
Echo(echo::Echo) = "echo",
Exit(exit::Exit) = "exit",
Ls(ls::Ls) = "ls",
Pwd(pwd::Pwd) = "pwd",
Scp(scp::Scp) = "scp",
Uname(uname::Uname) = "uname",
Whoami(whoami::Whoami) = "whoami"
Echo(echo::Echo) = b"echo",
Exit(exit::Exit) = b"exit",
Ls(ls::Ls) = b"ls",
Pwd(pwd::Pwd) = b"pwd",
Scp(scp::Scp) = b"scp",
Uname(uname::Uname) = b"uname",
Whoami(whoami::Whoami) = b"whoami"
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
@@ -17,7 +17,12 @@ impl Command for Echo {
channel: ChannelId,
session: &mut S,
) -> CommandResult<Self> {
session.data(channel, format!("{}\n", params.iter().join(" ")).into());
let suffix = if session.redirected() { "" } else { "\n" };
session.data(
channel,
format!("{}{suffix}", params.iter().join(" ")).into(),
);
CommandResult::Exit(0)
}
@@ -58,6 +63,8 @@ mod test {
.with(always(), eq_string(output))
.returning(|_, _| ());
session.expect_redirected().returning(|| false);
let out = Echo::new(
&mut ConnectionState::mock(),
params
@@ -18,7 +18,7 @@ impl Command for Exit {
_session: &mut S,
) -> CommandResult<Self> {
let exit_status = params
.get(0)
.first()
.map(String::as_str)
.map_or(Ok(0), u32::from_str)
.unwrap_or(2);
@@ -22,7 +22,7 @@ impl Command for Ls {
} else if params.len() == 1 {
connection
.file_system()
.ls(Some(params.get(0).unwrap()))
.ls(Some(params.first().unwrap()))
.join(" ")
} else {
let mut out = String::new();
@@ -78,6 +78,7 @@ impl thrussh::server::Server for Server {
},
username: None,
file_system: None,
environment: HashMap::new(),
},
subsystem: HashMap::new(),
}
@@ -88,6 +89,7 @@ pub struct ConnectionState {
audit_log: AuditLog,
username: Option<String>,
file_system: Option<FileSystem>,
environment: HashMap<Cow<'static, [u8]>, Cow<'static, [u8]>>,
}
impl ConnectionState {
@@ -109,6 +111,7 @@ impl ConnectionState {
},
username: None,
file_system: None,
environment: HashMap::new(),
}
}
}
@@ -129,6 +132,10 @@ impl ConnectionState {
pub fn audit_log(&mut self) -> &mut AuditLog {
&mut self.audit_log
}
pub fn environment(&self) -> &HashMap<Cow<'static, [u8]>, Cow<'static, [u8]>> {
&self.environment
}
}
pub struct Connection {
@@ -673,7 +680,7 @@ impl Drop for Connection {
}
}
#[derive(Debug, Clone)]
#[derive(Debug)]
pub enum Subsystem {
Shell(subsystem::shell::Shell),
Sftp(subsystem::sftp::Sftp),
@@ -682,6 +689,10 @@ pub enum Subsystem {
#[cfg_attr(test, mockall::automock)]
pub trait ThrusshSession {
fn data(&mut self, channel: ChannelId, data: CryptoVec);
fn redirected(&self) -> bool {
false
}
}
impl ThrusshSession for Session {
@@ -690,6 +701,54 @@ impl ThrusshSession for Session {
}
}
impl ThrusshSession for &mut Session {
fn data(&mut self, channel: ChannelId, data: CryptoVec) {
Session::data(self, channel, data);
}
}
pub enum EitherSession<A, B> {
L(A),
R(B),
}
impl<A: ThrusshSession, B: ThrusshSession> ThrusshSession for EitherSession<A, B> {
fn data(&mut self, channel: ChannelId, data: CryptoVec) {
match self {
Self::L(a) => a.data(channel, data),
Self::R(b) => b.data(channel, data),
}
}
fn redirected(&self) -> bool {
match self {
Self::L(a) => a.redirected(),
Self::R(b) => b.redirected(),
}
}
}
pub struct StdoutCaptureSession<'a> {
out: &'a mut Vec<u8>,
}
impl<'a> StdoutCaptureSession<'a> {
pub fn new(out: &'a mut Vec<u8>) -> Self {
Self { out }
}
}
impl ThrusshSession for StdoutCaptureSession<'_> {
fn data(&mut self, _channel: ChannelId, data: CryptoVec) {
self.out.extend_from_slice(data.as_ref());
}
fn redirected(&self) -> bool {
true
}
}
type HandlerResult<T> = Result<T, <Connection as thrussh::server::Handler>::Error>;
type HandlerFuture<T> = ServerFuture<
<Connection as thrussh::server::Handler>::Error,
@@ -406,7 +406,10 @@ impl<'a> WirePacket<'a> {
)(rest)?;
let Some(typ) = PacketType::from_repr(typ) else {
return Err(nom::Err::Failure(nom::error::Error::new(rest, nom::error::ErrorKind::Verify)));
return Err(nom::Err::Failure(nom::error::Error::new(
rest,
nom::error::ErrorKind::Verify,
)));
};
Ok((
@@ -1,15 +1,23 @@
mod parser;
use crate::{
command::{CommandResult, ConcreteCommand},
server::ConnectionState,
subsystem::Subsystem,
server::{ConnectionState, EitherSession, StdoutCaptureSession},
subsystem::{
shell::parser::{tokenize, IterState, ParsedPart},
Subsystem,
},
};
use async_trait::async_trait;
use pisshoff_types::audit::{AuditLogAction, ExecCommandEvent};
use thrussh::{server::Session, ChannelId};
use tracing::info;
pub const SHELL_PROMPT: &str = "bash-5.1$ ";
#[derive(Clone, Debug)]
type IResult<I, O> = nom::IResult<I, O, nom_supreme::error::ErrorTree<I>>;
#[derive(Debug)]
pub struct Shell {
interactive: bool,
state: State,
@@ -29,7 +37,7 @@ impl Shell {
fn handle_command_result(
&self,
command_result: CommandResult<ConcreteCommand>,
command_result: CommandResult<ExecutingCommand>,
) -> (State, bool) {
match (command_result, self.interactive) {
(CommandResult::ReadStdin(cmd), _) => (State::Running(cmd), true),
@@ -53,21 +61,30 @@ impl Subsystem for Shell {
session: &mut Session,
) {
loop {
let (next, terminal) = match std::mem::take(&mut self.state) {
let (next, end) = match std::mem::take(&mut self.state) {
State::Prompt => {
let Some(args) = shlex::split(String::from_utf8_lossy(data).as_ref()) else {
return;
};
connection
.audit_log()
.push_action(AuditLogAction::ExecCommand(ExecCommandEvent {
args: Box::from(args.clone()),
args: Box::from(vec![String::from_utf8_lossy(data).to_string()]),
}));
self.handle_command_result(
ConcreteCommand::new(connection, &args, channel, session).await,
)
match tokenize(data) {
Ok((_unparsed, args)) => {
let cmd = parser::Iter::new(
args.into_iter().map(ParsedPart::into_owned).collect(),
);
self.handle_command_result(
ExecutingCommand::new(cmd, connection, channel, session).await,
)
}
Err(e) => {
info!("Invalid syntax: {e}");
session.data(channel, "bash: syntax error\n".to_string().into());
(State::Prompt, true)
}
}
}
State::Running(command) => self
.handle_command_result(command.stdin(connection, channel, data, session).await),
@@ -84,7 +101,7 @@ impl Subsystem for Shell {
self.state = next;
if terminal {
if end {
break;
}
}
@@ -95,11 +112,114 @@ impl Subsystem for Shell {
}
}
#[derive(Debug, Clone, Default)]
#[derive(Debug)]
pub struct ExecutingCommand {
iter: parser::Iter<'static>,
current: ConcreteCommand,
buf: Option<Vec<u8>>,
}
impl ExecutingCommand {
async fn new(
iter: parser::Iter<'static>,
connection: &mut ConnectionState,
channel: ChannelId,
session: &mut Session,
) -> CommandResult<Self> {
Self::new_inner(Vec::new(), iter, connection, channel, session).await
}
async fn new_inner(
mut buf: Vec<u8>,
mut iter: parser::Iter<'static>,
connection: &mut ConnectionState,
channel: ChannelId,
session: &mut Session,
) -> CommandResult<Self> {
loop {
let (has_next, current) = match iter.step(
connection.environment(),
Some(std::mem::take(&mut buf)).filter(|v| !v.is_empty()),
) {
IterState::Expand(cmd) => (true, cmd),
IterState::Ready(cmd) => (false, cmd),
};
let mut session = if has_next {
EitherSession::L(StdoutCaptureSession::new(&mut buf))
} else {
EitherSession::R(&mut *session)
};
match (
current
.into_concrete_command(connection, channel, &mut session)
.await,
has_next,
) {
(CommandResult::ReadStdin(cmd), has_next) => {
break CommandResult::ReadStdin(Self {
iter,
current: cmd,
buf: has_next.then_some(buf),
})
}
(CommandResult::Exit(_status), true) => {
continue;
}
(CommandResult::Exit(status), false) => {
break CommandResult::Exit(status);
}
(CommandResult::Close(status), _) => {
break CommandResult::Close(status);
}
}
}
}
async fn stdin(
mut self,
connection: &mut ConnectionState,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> CommandResult<Self> {
let mut sess = if let Some(buf) = &mut self.buf {
EitherSession::L(StdoutCaptureSession::new(buf))
} else {
EitherSession::R(&mut *session)
};
match self
.current
.stdin(connection, channel, data, &mut sess)
.await
{
CommandResult::ReadStdin(cmd) => CommandResult::ReadStdin(Self {
iter: self.iter,
current: cmd,
buf: self.buf,
}),
CommandResult::Exit(_) => {
Self::new_inner(
self.buf.unwrap_or_default(),
self.iter,
connection,
channel,
session,
)
.await
}
CommandResult::Close(status) => CommandResult::Close(status),
}
}
}
#[derive(Debug, Default)]
enum State {
#[default]
Prompt,
Running(ConcreteCommand),
Running(ExecutingCommand),
Exit(u32),
Quit(u32),
}
@@ -0,0 +1,574 @@
use crate::{command::PartialCommand, subsystem::shell::IResult};
use nom::{
branch::alt,
bytes::complete::{escaped_transform, is_not, tag, take, take_until, take_while1},
character::complete::{alphanumeric1, char, digit0, digit1, multispace1},
combinator::{cut, fail, map, map_opt, peek, value},
error::context,
multi::{fold_many0, many_till},
sequence::{delimited, preceded},
AsChar,
};
use std::{borrow::Cow, collections::HashMap};
#[derive(Debug, PartialEq, Eq)]
pub enum IterState<'a> {
Expand(PartialCommand<'a>),
Ready(PartialCommand<'a>),
}
#[derive(Debug)]
pub struct Iter<'a> {
command: std::vec::IntoIter<ParsedPart<'a>>,
expanding: Option<Box<Iter<'a>>>,
stdio_out: [RedirectionTo<'a>; 2],
exec: Option<Cow<'a, [u8]>>,
params: Vec<Cow<'a, [u8]>>,
}
impl<'a> Iter<'a> {
pub fn new(command: Vec<ParsedPart<'a>>) -> Self {
Self {
command: command.into_iter(),
expanding: None,
stdio_out: [
RedirectionTo::Stdio(0), RedirectionTo::Stdio(1), ],
exec: None,
params: Vec::new(),
}
}
}
impl<'a> Iter<'a> {
pub fn step(
&mut self,
env: &HashMap<Cow<'static, [u8]>, Cow<'static, [u8]>>,
mut previous_out: Option<Vec<u8>>,
) -> IterState<'a> {
loop {
let out = if let Some(expanding) = &mut self.expanding {
return match expanding.step(env, previous_out) {
IterState::Expand(cmd) => {
IterState::Expand(cmd)
}
IterState::Ready(cmd) => {
self.expanding = None;
IterState::Expand(cmd)
}
};
} else if let Some(arg) = previous_out.take() {
Cow::Owned(arg)
} else if let Some(arg) = self.command.next() {
match arg {
ParsedPart::Break => {
if self.params.last().map_or(true, |v| !v.is_empty()) {
self.params.push(Cow::Borrowed(b""));
}
continue;
}
ParsedPart::String(data) => {
data
}
ParsedPart::Expansion(Expansion::Command(command)) => {
self.expanding = Some(Box::new(Iter::new(command)));
continue;
}
ParsedPart::Expansion(Expansion::Variable(variable)) => {
env.get(&variable).cloned().unwrap_or(Cow::Borrowed(b""))
}
ParsedPart::Redirection(idx, target) => {
if let Some(out) = self.stdio_out.get_mut(usize::from(idx)) {
*out = target;
}
continue;
}
}
} else {
return IterState::Ready(PartialCommand::new(
self.exec.clone(),
self.params.clone(),
));
};
if self.exec.is_none() {
self.exec = Some(out);
} else if let Some(lst) = self.params.last_mut() {
lst.to_mut().extend_from_slice(&out);
} else {
self.params.push(out);
}
}
}
}
#[derive(PartialEq, Eq, Debug)]
pub enum ParsedPart<'a> {
Break,
String(Cow<'a, [u8]>),
Expansion(Expansion<'a>),
Redirection(u8, RedirectionTo<'a>),
}
impl ParsedPart<'_> {
pub fn into_owned(self) -> ParsedPart<'static> {
match self {
ParsedPart::Break => ParsedPart::Break,
ParsedPart::String(s) => ParsedPart::String(Cow::Owned(s.into_owned())),
ParsedPart::Expansion(e) => ParsedPart::Expansion(e.into_owned()),
ParsedPart::Redirection(s, e) => ParsedPart::Redirection(s, e.into_owned()),
}
}
}
#[derive(PartialEq, Eq, Debug)]
pub enum RedirectionTo<'a> {
Stdio(u8),
File(Cow<'a, [u8]>),
}
impl RedirectionTo<'_> {
pub fn into_owned(self) -> RedirectionTo<'static> {
match self {
RedirectionTo::Stdio(v) => RedirectionTo::Stdio(v),
RedirectionTo::File(f) => RedirectionTo::File(Cow::Owned(f.into_owned())),
}
}
}
#[derive(PartialEq, Eq, Debug)]
pub enum Expansion<'a> {
Variable(Cow<'a, [u8]>),
Command(Vec<ParsedPart<'a>>),
}
impl Expansion<'_> {
pub fn into_owned(self) -> Expansion<'static> {
match self {
Expansion::Variable(v) => Expansion::Variable(Cow::Owned(v.into_owned())),
Expansion::Command(c) => {
Expansion::Command(c.into_iter().map(ParsedPart::into_owned).collect())
}
}
}
}
pub fn tokenize(s: &[u8]) -> IResult<&[u8], Vec<ParsedPart<'_>>> {
fold_many0(parse_string_part, Vec::new, |mut acc, res| {
acc.extend(res);
acc
})(s)
}
fn parse_string_part(s: &[u8]) -> IResult<&[u8], Vec<ParsedPart<'_>>> {
if s.is_empty() {
return context("empty input", fail)(s);
}
alt((
parse_double_quoted,
map(
alt((
parse_redirection,
map(multispace1, |_| ParsedPart::Break),
map(parse_single_quoted, |r| {
ParsedPart::String(Cow::Borrowed(r))
}),
map(parse_expansion, ParsedPart::Expansion),
map(parse_unquoted, |r| ParsedPart::String(Cow::Owned(r))),
)),
|r| vec![r],
),
))(s)
}
fn parse_redirection(s: &[u8]) -> IResult<&[u8], ParsedPart<'_>> {
let (s, from) = map_opt(digit0, atoi)(s)?;
let (s, _) = char('>')(s)?;
let (s, to) = alt((
map(
preceded(char('&'), map_opt(digit1, atoi)),
RedirectionTo::Stdio,
),
map(alphanumeric1, |f| RedirectionTo::File(Cow::Borrowed(f))),
))(s)?;
Ok((s, ParsedPart::Redirection(from, to)))
}
fn parse_unquoted(s: &[u8]) -> IResult<&[u8], Vec<u8>> {
escaped_transform(
is_not("\\\n \"'$`|>&();"),
'\\',
alt((value(b"".as_slice(), char('\n')), take(1_u8))),
)(s)
}
fn parse_single_quoted(s: &[u8]) -> IResult<&[u8], &[u8]> {
delimited(char('\''), take_until("'"), char('\''))(s)
}
fn parse_double_quoted(s: &[u8]) -> IResult<&[u8], Vec<ParsedPart<'_>>> {
let escaped = escaped_transform(
is_not("\\\"$`"),
'\\',
alt((
value(b"\"".as_slice(), char('"')),
value(b"\n".as_slice(), char('n')),
value(b"\t".as_slice(), char('t')),
value(b"$".as_slice(), char('$')),
value(b"`".as_slice(), char('`')),
value(b"\\".as_slice(), char('\\')),
)),
);
let take_part = alt((
map(escaped, |r| ParsedPart::String(Cow::Owned(r))),
map(parse_expansion, ParsedPart::Expansion),
));
delimited(
char('"'),
map(many_till(take_part, peek(char('"'))), |(r, _)| r),
char('"'),
)(s)
}
fn parse_expansion(s: &[u8]) -> IResult<&[u8], Expansion<'_>> {
let dollar_expansion = alt((
map(tag("$"), |f| Expansion::Variable(Cow::Borrowed(f))),
map(
delimited(
char('('),
cut(context("tokenize", tokenize)),
cut(context("end brace", char(')'))),
),
Expansion::Command,
),
map(take_while1(|c: u8| c.is_alphanum() || c == b'_'), |f| {
Expansion::Variable(Cow::Borrowed(f))
}),
map(
delimited(
char('{'),
take_until("}"),
cut(context("end brace", char('}'))),
),
|f| Expansion::Variable(Cow::Borrowed(f)),
),
));
alt((
preceded(char('$'), dollar_expansion),
map(
delimited(char('`'), context("tokenize", tokenize), char('`')),
Expansion::Command,
),
))(s)
}
fn atoi(v: &[u8]) -> Option<u8> {
if v.is_empty() {
Some(0)
} else {
atoi::atoi(v)
}
}
#[cfg(test)]
mod test {
mod iter {
use crate::command::PartialCommand;
use crate::server::ConnectionState;
use crate::subsystem::shell::parser::{tokenize, Iter, IterState};
use std::borrow::Cow;
#[test]
fn single_nested() {
let (rest, s) = tokenize(b"echo $(echo hello) world!").unwrap();
assert!(rest.is_empty());
let state = ConnectionState::mock();
let mut command = Iter::new(s.into());
let step = command.step(state.environment(), None);
assert_eq!(
step,
IterState::Expand(PartialCommand::new(
Some(Cow::Borrowed(b"echo")),
vec![Cow::Borrowed(b"hello")]
))
);
let step = command.step(state.environment(), Some(b"hello".to_vec()));
assert_eq!(
step,
IterState::Ready(PartialCommand::new(
Some(Cow::Borrowed(b"echo")),
vec![Cow::Borrowed(b"hello"), Cow::Borrowed(b"world!")]
))
);
}
#[test]
fn multi_nested() {
let (rest, s) = tokenize(b"echo $(echo hello `echo the whole`) world!").unwrap();
assert!(rest.is_empty());
let state = ConnectionState::mock();
let mut command = Iter::new(s.into());
let step = command.step(state.environment(), None);
assert_eq!(
step,
IterState::Expand(PartialCommand::new(
Some(Cow::Borrowed(b"echo")),
vec![Cow::Borrowed(b"the"), Cow::Borrowed(b"whole")]
))
);
let step = command.step(state.environment(), Some(b"the whole".to_vec()));
assert_eq!(
step,
IterState::Expand(PartialCommand::new(
Some(Cow::Borrowed(b"echo")),
vec![Cow::Borrowed(b"hello"), Cow::Borrowed(b"the whole")]
))
);
let step = command.step(state.environment(), Some(b"hello the whole".to_vec()));
assert_eq!(
step,
IterState::Ready(PartialCommand::new(
Some(Cow::Borrowed(b"echo")),
vec![Cow::Borrowed(b"hello the whole"), Cow::Borrowed(b"world!")]
))
);
}
}
mod parse_command {
use crate::subsystem::shell::parser::{tokenize, Expansion, ParsedPart, RedirectionTo};
use std::borrow::Cow;
#[test]
fn messed_up() {
let (rest, s) = tokenize(b"echo ${HI}'this' \"is a \\t${TEST}\"using'$(complex string)>|' $(echo parsing) for the hell of it;fin").unwrap();
assert_eq!(rest, b";fin");
assert_eq!(
s,
vec![
ParsedPart::String(Cow::Borrowed(b"echo")),
ParsedPart::Break,
ParsedPart::Expansion(Expansion::Variable(Cow::Borrowed(b"HI"))),
ParsedPart::String(Cow::Borrowed(b"this")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"is a \t")),
ParsedPart::Expansion(Expansion::Variable(Cow::Borrowed(b"TEST"))),
ParsedPart::String(Cow::Borrowed(b"using")),
ParsedPart::String(Cow::Borrowed(b"$(complex string)>|")),
ParsedPart::Break,
ParsedPart::Expansion(Expansion::Command(vec![
ParsedPart::String(Cow::Borrowed(b"echo")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"parsing")),
])),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"for")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"the")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"hell")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"of")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"it")),
]
);
}
#[test]
fn parses_named_redirects() {
let (rest, s) = tokenize(b"hello test 2>&1").unwrap();
assert!(rest.is_empty(), "{}", String::from_utf8_lossy(rest));
assert_eq!(
s,
vec![
ParsedPart::String(Cow::Borrowed(b"hello")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"test")),
ParsedPart::Break,
ParsedPart::Redirection(2, RedirectionTo::Stdio(1)),
]
);
}
#[test]
fn parses_unnamed_redirects() {
let (rest, s) = tokenize(b"hello test >&1").unwrap();
assert!(rest.is_empty(), "{}", String::from_utf8_lossy(rest));
assert_eq!(
s,
vec![
ParsedPart::String(Cow::Borrowed(b"hello")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"test")),
ParsedPart::Break,
ParsedPart::Redirection(0, RedirectionTo::Stdio(1)),
]
);
}
}
mod parse_expansion {
use crate::subsystem::shell::parser::{parse_expansion, Expansion, ParsedPart};
use std::borrow::Cow;
#[test]
fn double_dollar() {
let (rest, s) = parse_expansion(b"$$a").unwrap();
assert_eq!(rest, b"a");
assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"$")));
}
#[test]
fn variable() {
let (rest, s) = parse_expansion(b"$HELLO_WORLD").unwrap();
assert!(rest.is_empty());
assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"HELLO_WORLD")));
}
#[test]
fn variable_split() {
let (rest, s) = parse_expansion(b"$HELLO-WORLD").unwrap();
assert_eq!(rest, b"-WORLD");
assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"HELLO")));
}
#[test]
fn braced_variable() {
let (rest, s) = parse_expansion(b"${helloworld}").unwrap();
assert!(rest.is_empty());
assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"helloworld")));
}
#[test]
fn not_expansion() {
parse_expansion(b"NOT_VARIABLE").expect_err("not variable");
}
#[test]
fn nested() {
let (rest, s) = parse_expansion(b"$(\'echo\' \'hello\')").unwrap();
assert!(rest.is_empty(), "{rest:?}");
assert_eq!(
s,
Expansion::Command(vec![
ParsedPart::String(Cow::Borrowed(b"echo")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"hello")),
])
)
}
}
mod parse_unquoted {
use crate::subsystem::shell::parser::parse_unquoted;
#[test]
fn escape() {
let (rest, s) =
parse_unquoted(b"hello\\ \\world\\ \\thi\\ns\\ is\\ a\\ \\$test\\\n! dontparse")
.unwrap();
assert_eq!(rest, b" dontparse", "{}", String::from_utf8_lossy(rest));
assert_eq!(
s,
b"hello world thins is a $test!".to_vec(),
"{}",
String::from_utf8_lossy(&s)
);
}
}
mod parse_single_quoted {
use crate::subsystem::shell::parser::parse_single_quoted;
#[test]
fn multi_quote() {
let (rest, s) = parse_single_quoted(b"'hello''world'").unwrap();
assert_eq!(rest, b"'world'");
assert_eq!(s, b"hello");
}
}
mod parse_double_quoted {
use crate::subsystem::shell::parser::{parse_double_quoted, Expansion, ParsedPart};
use std::borrow::Cow;
#[test]
fn with_expansion() {
let (rest, s) = parse_double_quoted(b"\"hello world $('cat' 'test') test\"").unwrap();
assert!(rest.is_empty());
assert_eq!(
s,
vec![
ParsedPart::String(Cow::Borrowed(b"hello world ")),
ParsedPart::Expansion(Expansion::Command(vec![
ParsedPart::String(Cow::Borrowed(b"cat")),
ParsedPart::Break,
ParsedPart::String(Cow::Borrowed(b"test")),
])),
ParsedPart::String(Cow::Borrowed(b" test")),
]
)
}
#[test]
fn with_expansion_escape() {
let (rest, s) = parse_double_quoted(b"\"hello world \\$('cat' 'test') test\"").unwrap();
assert!(rest.is_empty());
assert_eq!(
s,
vec![ParsedPart::String(Cow::Borrowed(
b"hello world $('cat' 'test') test"
))]
);
}
#[test]
fn with_escape_code() {
let (rest, s) = parse_double_quoted(b"\"hi\\nworld\"").unwrap();
assert!(rest.is_empty());
assert_eq!(s, vec![ParsedPart::String(Cow::Borrowed(b"hi\nworld"))]);
}
}
}
@@ -38,6 +38,7 @@ impl Default for AuditLog {
}
}
#[allow(clippy::missing_fields_in_debug)]
impl Debug for AuditLog {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditLog")