diff --git a/Cargo.lock b/Cargo.lock index c7cc857..4c9a6ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ version = "1.0.71" 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" @@ -96,6 +102,15 @@ dependencies = [ "syn 2.0.20", ] +[[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" @@ -187,6 +202,15 @@ dependencies = [ "cipher 0.4.4", ] +[[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" @@ -699,6 +723,12 @@ dependencies = [ "unicode-normalization", ] +[[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" @@ -779,6 +809,12 @@ version = "1.0.6" 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" @@ -947,6 +983,19 @@ dependencies = [ "minimal-lexical", ] +[[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" @@ -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]] @@ -1611,6 +1663,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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" @@ -1683,6 +1741,17 @@ dependencies = [ "unicode-ident", ] +[[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" @@ -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", +] diff --git a/pisshoff-server/Cargo.toml b/pisshoff-server/Cargo.toml index 78ba335..22471da 100644 --- a/pisshoff-server/Cargo.toml +++ b/pisshoff-server/Cargo.toml @@ -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" diff --git a/pisshoff-server/src/audit.rs b/pisshoff-server/src/audit.rs index 7ce2055..7db53ac 100644 --- a/pisshoff-server/src/audit.rs +++ b/pisshoff-server/src/audit.rs @@ -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?; } diff --git a/pisshoff-server/src/command.rs b/pisshoff-server/src/command.rs index b02d8c0..2ef082b 100644 --- a/pisshoff-server/src/command.rs +++ b/pisshoff-server/src/command.rs @@ -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 { + /// Wait for stdin ReadStdin(T), + /// Exit process Exit(u32), + /// Close session Close(u32), } @@ -55,6 +59,34 @@ pub trait Command: Sized { ) -> CommandResult; } +#[derive(PartialEq, Eq, Debug)] +pub struct PartialCommand<'a> { + exec: Option>, + params: Vec>, +} + +impl<'a> PartialCommand<'a> { + pub fn new(exec: Option>, params: Vec>) -> Self { + Self { exec, params } + } + + pub async fn into_concrete_command( + self, + connection: &mut ConnectionState, + channel: ChannelId, + session: &mut S, + ) -> CommandResult { + // TODO: make commands take byte slices + let args = self + .params + .iter() + .map(|v| String::from_utf8_lossy(v).to_string()) + .collect::>(); + + 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( connection: &mut ConnectionState, + exec: Option<&[u8]>, params: &[String], channel: ChannelId, - session: &mut Session, + session: &mut S, ) -> CommandResult { - 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 => { // TODO: fix stderr displaying out of order 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( self, connection: &mut ConnectionState, channel: ChannelId, data: &[u8], - session: &mut Session, + session: &mut S, ) -> CommandResult { 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)] diff --git a/pisshoff-server/src/command/echo.rs b/pisshoff-server/src/command/echo.rs index 4986a6c..33e39f4 100644 --- a/pisshoff-server/src/command/echo.rs +++ b/pisshoff-server/src/command/echo.rs @@ -17,7 +17,12 @@ impl Command for Echo { channel: ChannelId, session: &mut S, ) -> CommandResult { - 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 diff --git a/pisshoff-server/src/command/exit.rs b/pisshoff-server/src/command/exit.rs index 620dd6a..aa01ea0 100644 --- a/pisshoff-server/src/command/exit.rs +++ b/pisshoff-server/src/command/exit.rs @@ -18,7 +18,7 @@ impl Command for Exit { _session: &mut S, ) -> CommandResult { let exit_status = params - .get(0) + .first() .map(String::as_str) .map_or(Ok(0), u32::from_str) .unwrap_or(2); diff --git a/pisshoff-server/src/command/ls.rs b/pisshoff-server/src/command/ls.rs index e51cbcf..81278bd 100644 --- a/pisshoff-server/src/command/ls.rs +++ b/pisshoff-server/src/command/ls.rs @@ -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(); diff --git a/pisshoff-server/src/server.rs b/pisshoff-server/src/server.rs index 1663a0d..a3b7d61 100644 --- a/pisshoff-server/src/server.rs +++ b/pisshoff-server/src/server.rs @@ -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, file_system: Option, + environment: HashMap, 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]>> { + &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 { + L(A), + R(B), +} + +impl ThrusshSession for EitherSession { + 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> { + /// Captured stdout + out: &'a mut Vec, +} + +impl<'a> StdoutCaptureSession<'a> { + pub fn new(out: &'a mut Vec) -> 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 = Result::Error>; type HandlerFuture = ServerFuture< ::Error, diff --git a/pisshoff-server/src/subsystem/sftp.rs b/pisshoff-server/src/subsystem/sftp.rs index aaa6c7d..ff8208e 100644 --- a/pisshoff-server/src/subsystem/sftp.rs +++ b/pisshoff-server/src/subsystem/sftp.rs @@ -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(( diff --git a/pisshoff-server/src/subsystem/shell.rs b/pisshoff-server/src/subsystem/shell.rs index 86b0059..a9d7047 100644 --- a/pisshoff-server/src/subsystem/shell.rs +++ b/pisshoff-server/src/subsystem/shell.rs @@ -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 = nom::IResult>; + +#[derive(Debug)] pub struct Shell { interactive: bool, state: State, @@ -29,7 +37,7 @@ impl Shell { fn handle_command_result( &self, - command_result: CommandResult, + command_result: CommandResult, ) -> (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) => { + // TODO + 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>, +} + +impl ExecutingCommand { + async fn new( + iter: parser::Iter<'static>, + connection: &mut ConnectionState, + channel: ChannelId, + session: &mut Session, + ) -> CommandResult { + Self::new_inner(Vec::new(), iter, connection, channel, session).await + } + + async fn new_inner( + mut buf: Vec, + mut iter: parser::Iter<'static>, + connection: &mut ConnectionState, + channel: ChannelId, + session: &mut Session, + ) -> CommandResult { + 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 { + 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), } diff --git a/pisshoff-server/src/subsystem/shell/parser.rs b/pisshoff-server/src/subsystem/shell/parser.rs new file mode 100644 index 0000000..22b0202 --- /dev/null +++ b/pisshoff-server/src/subsystem/shell/parser.rs @@ -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>, + expanding: Option>>, + stdio_out: [RedirectionTo<'a>; 2], + exec: Option>, + params: Vec>, +} + +impl<'a> Iter<'a> { + pub fn new(command: Vec>) -> Self { + Self { + command: command.into_iter(), + expanding: None, + stdio_out: [ + RedirectionTo::Stdio(0), // stdout + RedirectionTo::Stdio(1), // stderr + ], + exec: None, + params: Vec::new(), + } + } +} + +impl<'a> Iter<'a> { + pub fn step( + &mut self, + env: &HashMap, Cow<'static, [u8]>>, + mut previous_out: Option>, + ) -> IterState<'a> { + loop { + let out = if let Some(expanding) = &mut self.expanding { + return match expanding.step(env, previous_out) { + IterState::Expand(cmd) => { + // inner command has to expand some parameters, yield back to + // the shell to execute it, and return `expanding` back to the + // state, so we feed the input back to it + IterState::Expand(cmd) + } + IterState::Ready(cmd) => { + // inner command is ready to be executed after expanding its, + // params, however it's _our_ expansion, so we'll rewrite its + // 'ready to an expand', but we won't replace it back into the + // state so the `previous_out` is written to our params + self.expanding = None; + IterState::Expand(cmd) + } + }; + } else if let Some(arg) = previous_out.take() { + // our `expanding` has completed, and we've received its output so lets + // store it in our params + Cow::Owned(arg) + } else if let Some(arg) = self.command.next() { + // traverse the command AST until we hit the next actionable part + match arg { + ParsedPart::Break => { + // if we hit a break insert a new parameter to start writing into + if self.params.last().map_or(true, |v| !v.is_empty()) { + self.params.push(Cow::Borrowed(b"")); + } + continue; + } + ParsedPart::String(data) => { + // push the string into our params + data + } + ParsedPart::Expansion(Expansion::Command(command)) => { + // command needs to be substituted so lets yield to it + self.expanding = Some(Box::new(Iter::new(command))); + continue; + } + ParsedPart::Expansion(Expansion::Variable(variable)) => { + // substitute environment variable in + env.get(&variable).cloned().unwrap_or(Cow::Borrowed(b"")) + } + ParsedPart::Redirection(idx, target) => { + // store a stdio redirection + if let Some(out) = self.stdio_out.get_mut(usize::from(idx)) { + *out = target; + } + continue; + } + } + } else { + // fully evaluated and ready to be executed + 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>), +} + +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()) + } + } + } +} + +/// Parses a single command (including substitutions), a command is delimited by a `;`, `|` or `>` +pub fn tokenize(s: &[u8]) -> IResult<&[u8], Vec>> { + fold_many0(parse_string_part, Vec::new, |mut acc, res| { + acc.extend(res); + acc + })(s) +} + +fn parse_string_part(s: &[u8]) -> IResult<&[u8], Vec>> { + 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> { + 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]> { + // no special chars in single quoted, so we just need to read ahead + // until the end quote + delimited(char('\''), take_until("'"), char('\''))(s) +} + +fn parse_double_quoted(s: &[u8]) -> IResult<&[u8], Vec>> { + 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( + // TODO: this should deal with bash variable expansion operators + // like `-` which allows for a rhs default is a var is unset + 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 { + 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()); + + // once we step we should be requested to execute `echo hello` for subbing + let step = command.step(state.environment(), None); + assert_eq!( + step, + IterState::Expand(PartialCommand::new( + Some(Cow::Borrowed(b"echo")), + vec![Cow::Borrowed(b"hello")] + )) + ); + + // step again with the supposed output of the command we were requested to execute + // and we should receive the final command to execute + 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()); + + // once we step we should be requested to execute `echo the whole` for subbing + 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")] + )) + ); + + // once we step we should be requested to execute `echo hello` for subbing + 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")] + )) + ); + + // step again with the supposed output of the command we were requested to execute + // and we should receive the final command to execute + 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"))]); + } + } +} diff --git a/pisshoff-types/src/audit.rs b/pisshoff-types/src/audit.rs index 99dcad0..27f3f59 100644 --- a/pisshoff-types/src/audit.rs +++ b/pisshoff-types/src/audit.rs @@ -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")