diff --git a/Cargo.lock b/Cargo.lock index 1a3f9a2..c1c9c7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + [[package]] name = "console" version = "0.15.0" @@ -29,9 +41,9 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "hashbrown" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "607c8a29735385251a339424dd462993c0fed8fa09d378f259377df08c126022" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "indexmap" @@ -65,9 +77,9 @@ checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" [[package]] name = "libc" -version = "0.2.126" +version = "0.2.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" [[package]] name = "linked-hash-map" @@ -76,10 +88,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] -name = "once_cell" -version = "1.13.0" +name = "nix" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" +checksum = "e322c04a9e3440c327fca7b6c8a63e6890a32fa2ad689db972425f07e0d22abb" +dependencies = [ + "autocfg", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0" [[package]] name = "oyster" @@ -87,6 +111,7 @@ version = "0.1.0" dependencies = [ "oyster_lineedit", "oyster_parser", + "oyster_runtime", ] [[package]] @@ -101,6 +126,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "oyster_runtime" +version = "0.0.0" +dependencies = [ + "insta", + "nix", + "oyster_parser", + "thiserror", +] + [[package]] name = "proc-macro2" version = "1.0.40" diff --git a/crates/oyster/Cargo.toml b/crates/oyster/Cargo.toml index 27099dc..89a78bf 100644 --- a/crates/oyster/Cargo.toml +++ b/crates/oyster/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" [dependencies] oyster_lineedit = { path = "../oyster_lineedit" } oyster_parser = { path = "../oyster_parser" } +oyster_runtime = { path = "../oyster_runtime" } diff --git a/crates/oyster/src/main.rs b/crates/oyster/src/main.rs index 5e6b11c..cf7b2c6 100644 --- a/crates/oyster/src/main.rs +++ b/crates/oyster/src/main.rs @@ -1,7 +1,13 @@ +use std::process; + use oyster_lineedit::readline; use oyster_parser::ast::Code; +use oyster_runtime::{Shell, Status}; fn main() { + let mut shell = Shell::default(); + let mut exit_code = Status::SUCCESS; + loop { let prog = readline("> ").unwrap(); @@ -9,6 +15,9 @@ fn main() { break; } - Code::try_from(prog.as_ref()).unwrap(); + let ast = Code::try_from(prog.as_ref()).unwrap(); + exit_code = shell.run(&ast).unwrap(); } + + process::exit(exit_code.0); } diff --git a/crates/oyster_runtime/Cargo.toml b/crates/oyster_runtime/Cargo.toml new file mode 100644 index 0000000..482542f --- /dev/null +++ b/crates/oyster_runtime/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "oyster_runtime" +version = "0.0.0" +edition = "2021" + +[lib] +doctest = false + +[dependencies] +oyster_parser = { path = "../oyster_parser" } +thiserror = "1.0.35" + +[dependencies.nix] +version = "~0.25.0" +default-features = false +features = [ "fs", "ioctl", "process", "term" ] + +[dev-dependencies] +insta = "^1.15.0" diff --git a/crates/oyster_runtime/src/lib.rs b/crates/oyster_runtime/src/lib.rs new file mode 100644 index 0000000..dada854 --- /dev/null +++ b/crates/oyster_runtime/src/lib.rs @@ -0,0 +1,53 @@ +//! The runtime for executing oyster programs. +//! Panics when an invalid ast gets passed. + +mod pipeline; + +use std::io; + +use oyster_parser::ast; +use thiserror::Error; + +/// Exit status of a finished command. +#[derive(Debug)] +pub struct Status(pub i32); + +impl Status { + pub const SUCCESS: Status = Status(0); + pub const COULD_NOT_EXEC: Status = Status(126); + pub const COMMAND_NOT_FOUND: Status = Status(127); + pub const SIG_BASE: Status = Status(128); +} + +/// Errors that occur during runtime. +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("failed to create pipe: {0}")] + PipeCreationFailed(#[source] nix::Error), + + #[error("command not found: {0}")] + CommandNotFound(String), + + #[error("permission denied: {0}")] + PermissionDenied(String), + + #[error("failed to spawn process: {0}")] + SpawnFailed(#[source] io::Error), +} + +#[derive(Default)] +pub struct Shell; + +impl Shell { + pub fn run<'a>(&mut self, code: &'a ast::Code) -> Result { + let mut last_status = Status::SUCCESS; + + for stmt in code.0.iter() { + last_status = match stmt { + ast::Statement::Pipeline(pipeline) => self.run_pipeline(pipeline)?, + }; + } + + Ok(last_status) + } +} diff --git a/crates/oyster_runtime/src/pipeline.rs b/crates/oyster_runtime/src/pipeline.rs new file mode 100644 index 0000000..07bad7f --- /dev/null +++ b/crates/oyster_runtime/src/pipeline.rs @@ -0,0 +1,165 @@ +use std::{ + borrow::Cow, + ffi::{OsStr, OsString}, + fs::File, + io, + os::unix::{ + io::FromRawFd, + process::{CommandExt, ExitStatusExt}, + }, + process::{Child, Command, Stdio}, + slice::Iter, +}; + +use nix::{ + fcntl::OFlag, + unistd::{self, Pid}, +}; +use oyster_parser::ast::{self, Redirect}; + +use crate::{RuntimeError, Shell, Status}; + +/// A command that's ready to be run. +struct PreparedCommand<'a> { + cmd: Cow<'a, str>, + args: WordBuilder<'a>, + redirect: Redirect, + stdin: Option, + stdout: Option, + stderr: Option, +} + +impl<'a> PreparedCommand<'a> { + /// Run this command with the given context. + fn spawn(self, pgid: &mut Pid) -> Result { + let args = self.args.map(|w| match w { + Cow::Borrowed(s) => Cow::Borrowed(OsStr::new(s)), + Cow::Owned(s) => Cow::Owned(OsString::from(s)), + }); + + let mut cmd = Command::new(self.cmd.as_ref()); + cmd.args(args); + cmd.stdin(self.stdin.map_or(Stdio::inherit(), Stdio::from)); + cmd.stdout(self.stdout.map_or(Stdio::inherit(), Stdio::from)); + cmd.stderr(self.stderr.map_or(Stdio::inherit(), Stdio::from)); + cmd.process_group(pgid.as_raw()); + + let child = match cmd.spawn() { + Ok(child) => child, + Err(err) => { + return Err(match err.kind() { + io::ErrorKind::NotFound => RuntimeError::CommandNotFound(self.cmd.into_owned()), + io::ErrorKind::PermissionDenied => { + RuntimeError::PermissionDenied(self.cmd.into_owned()) + } + _ => RuntimeError::SpawnFailed(err), + }) + } + }; + + let child_id = Pid::from_raw(child.id() as i32); + if *pgid == Pid::from_raw(0) { + *pgid = child_id; + }; + + // prevent race conditions + let _ = unistd::setpgid(child_id, *pgid); + + Ok(child) + } +} + +impl<'a> From<&'a ast::Command<'a>> for PreparedCommand<'a> { + fn from(command: &'a ast::Command<'a>) -> Self { + let mut words = WordBuilder(command.0.iter()); + let cmd = words.next().expect("words need to have >1 parts"); + let args = words; + let redirect = command.1; + + PreparedCommand { + cmd, + args, + redirect, + stdin: None, + stdout: None, + stderr: None, + } + } +} + +impl Shell { + pub(crate) fn run_pipeline( + &mut self, + pipeline: &ast::Pipeline, + ) -> Result { + let mut children = Vec::with_capacity(pipeline.0.len()); + let mut cmds = pipeline.0.iter().map(PreparedCommand::from); + let mut pgid = Pid::from_raw(0); + + let mut last_cmd = cmds.next().expect("pipelines need to have >1 commands"); + for mut cmd in cmds { + let (output, input) = create_pipe()?; + cmd.stdin = Some(output); + + match last_cmd.redirect { + Redirect::None => (), + Redirect::Stdout => last_cmd.stdout = Some(input), + } + + children.push(last_cmd.spawn(&mut pgid)?); + + last_cmd = cmd; + } + + children.push(last_cmd.spawn(&mut pgid)?); + + // TODO: kill children if error occured + // TODO: set foreground group then wait for foreground group + + let mut last_status = Status::SUCCESS; + for mut c in children { + // TODO: handle error + let status = c.wait().unwrap(); + last_status = Status( + status + .code() + .unwrap_or_else(|| Status::SIG_BASE.0 + status.signal().unwrap()), + ); + } + + Ok(last_status) + } +} + +/// Create a new unix pipe. +fn create_pipe() -> Result<(File, File), RuntimeError> { + let (output, input) = + unistd::pipe2(OFlag::O_CLOEXEC).map_err(RuntimeError::PipeCreationFailed)?; + + Ok(unsafe { (File::from_raw_fd(output), File::from_raw_fd(input)) }) +} + +/// Build words from WordParts. +struct WordBuilder<'a>(Iter<'a, ast::Word<'a>>); + +impl<'a> Iterator for WordBuilder<'a> { + type Item = Cow<'a, str>; + + fn next(&mut self) -> Option { + self.0.next().map(|word| { + let mut words = word.0.iter(); + + let mut s = match words.next().expect("words need to have >1 parts") { + ast::WordPart::Text(text) => Cow::from(*text), + }; + + for part in words { + match part { + ast::WordPart::Text(text) => s.to_mut().push_str(text), + } + } + + s + }) + } +} diff --git a/crates/oyster_runtime/tests/it/main.rs b/crates/oyster_runtime/tests/it/main.rs new file mode 100644 index 0000000..eab2e1f --- /dev/null +++ b/crates/oyster_runtime/tests/it/main.rs @@ -0,0 +1 @@ +mod pipeline; diff --git a/crates/oyster_runtime/tests/it/pipeline.rs b/crates/oyster_runtime/tests/it/pipeline.rs new file mode 100644 index 0000000..9627b20 --- /dev/null +++ b/crates/oyster_runtime/tests/it/pipeline.rs @@ -0,0 +1,172 @@ +use std::{ + env, + fs::File, + io::{BufRead, BufReader, Write}, + os::unix::io::FromRawFd, + process, +}; + +use insta::assert_debug_snapshot as assert_snapshot; +use nix::{ + ioctl_write_int_bad, libc, + pty::{self, OpenptyResult}, + sys, + unistd::{self, ForkResult}, +}; +use oyster_runtime::Shell; + +// TODO: test signal return codes + +ioctl_write_int_bad!(tiocsctty, libc::TIOCSCTTY); + +/// Forks to redirect stdin, stderr, stdout, then run the commands. +/// Relies on inserting a NUL byte in the end, so there shouldn't be NUL in the output. +fn collect_output(mut f: F) -> String +where + F: FnMut(), +{ + let OpenptyResult { master, slave } = pty::openpty(None, None).unwrap(); + + match unsafe { unistd::fork() }.unwrap() { + ForkResult::Parent { child } => { + sys::wait::waitpid(child, None).unwrap(); + } + ForkResult::Child => { + unistd::setsid().unwrap(); + unsafe { tiocsctty(slave, 0) }.unwrap(); + + unistd::dup2(slave, libc::STDIN_FILENO).unwrap(); + unistd::dup2(slave, libc::STDOUT_FILENO).unwrap(); + unistd::dup2(slave, libc::STDERR_FILENO).unwrap(); + + let _ = unistd::close(master); + let _ = unistd::close(slave); + + f(); + + process::exit(0); + } + } + + let master = unsafe { File::from_raw_fd(master) }; + let mut slave = unsafe { File::from_raw_fd(slave) }; + + slave.write(&[0]).unwrap(); + let mut r = BufReader::new(master); + let mut buf = vec![]; + r.read_until(0, &mut buf).unwrap(); + + std::str::from_utf8(&buf[..buf.len() - 1]) + .unwrap() + .to_owned() +} + +#[test] +fn simple_command() { + let ast = { + use oyster_parser::ast::*; + + Code(vec![Statement::Pipeline(Pipeline(vec![Command( + vec![ + Word(vec![WordPart::Text("echo")]), + Word(vec![WordPart::Text("hi")]), + ], + Redirect::None, + )]))]) + }; + + let actual = collect_output(|| { + let mut shell = Shell::default(); + shell.run(&ast).unwrap(); + }); + + assert_snapshot!(actual); +} + +#[test] +fn pipeline() { + let ast = { + use oyster_parser::ast::*; + + Code(vec![Statement::Pipeline(Pipeline(vec![ + Command( + vec![ + Word(vec![WordPart::Text("echo")]), + Word(vec![WordPart::Text("hi")]), + ], + Redirect::Stdout, + ), + Command( + vec![ + Word(vec![WordPart::Text("wc")]), + Word(vec![WordPart::Text("-c")]), + ], + Redirect::None, + ), + ]))]) + }; + + let actual = collect_output(|| { + let mut shell = Shell::default(); + shell.run(&ast).unwrap(); + }); + + assert_snapshot!(actual); +} + +#[test] +fn command_not_found() { + let ast = { + use oyster_parser::ast::*; + + Code(vec![Statement::Pipeline(Pipeline(vec![Command( + vec![Word(vec![WordPart::Text("this_command_doesnt_exist")])], + Redirect::None, + )]))]) + }; + + // XXX: this relies on the command actually not existing, as unsetting PATH is rather complex + let mut shell = Shell::default(); + let actual = shell.run(&ast); + + assert_snapshot!(actual); +} + +#[test] +fn permission_denied() { + let ast = { + use oyster_parser::ast::*; + + Code(vec![Statement::Pipeline(Pipeline(vec![Command( + vec![Word(vec![WordPart::Text("/")])], + Redirect::None, + )]))]) + }; + + let mut shell = Shell::default(); + let actual = shell.run(&ast); + + assert_snapshot!(actual); +} + +#[test] +fn multipart_word() { + let ast = { + use oyster_parser::ast::*; + + Code(vec![Statement::Pipeline(Pipeline(vec![Command( + vec![ + Word(vec![WordPart::Text("ec"), WordPart::Text("ho")]), + Word(vec![WordPart::Text("hel"), WordPart::Text("lo")]), + ], + Redirect::None, + )]))]) + }; + + let actual = collect_output(|| { + let mut shell = Shell::default(); + shell.run(&ast).unwrap(); + }); + + assert_snapshot!(actual); +} diff --git a/crates/oyster_runtime/tests/it/snapshots/it__pipeline__command_not_found.snap b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__command_not_found.snap new file mode 100644 index 0000000..e86a398 --- /dev/null +++ b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__command_not_found.snap @@ -0,0 +1,9 @@ +--- +source: crates/oyster_runtime/tests/it/pipeline.rs +expression: actual +--- +Err( + CommandNotFound( + "this_command_doesnt_exist", + ), +) diff --git a/crates/oyster_runtime/tests/it/snapshots/it__pipeline__multipart_word.snap b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__multipart_word.snap new file mode 100644 index 0000000..17df64b --- /dev/null +++ b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__multipart_word.snap @@ -0,0 +1,5 @@ +--- +source: crates/oyster_runtime/tests/it/pipeline.rs +expression: actual +--- +"hello\r\n" diff --git a/crates/oyster_runtime/tests/it/snapshots/it__pipeline__permission_denied.snap b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__permission_denied.snap new file mode 100644 index 0000000..ea33113 --- /dev/null +++ b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__permission_denied.snap @@ -0,0 +1,9 @@ +--- +source: crates/oyster_runtime/tests/it/pipeline.rs +expression: actual +--- +Err( + PermissionDenied( + "/", + ), +) diff --git a/crates/oyster_runtime/tests/it/snapshots/it__pipeline__pipeline.snap b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__pipeline.snap new file mode 100644 index 0000000..24bbfba --- /dev/null +++ b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__pipeline.snap @@ -0,0 +1,5 @@ +--- +source: crates/oyster_runtime/tests/it/pipeline.rs +expression: actual +--- +"3\r\n" diff --git a/crates/oyster_runtime/tests/it/snapshots/it__pipeline__simple_command.snap b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__simple_command.snap new file mode 100644 index 0000000..b3c9645 --- /dev/null +++ b/crates/oyster_runtime/tests/it/snapshots/it__pipeline__simple_command.snap @@ -0,0 +1,5 @@ +--- +source: crates/oyster_runtime/tests/it/pipeline.rs +expression: actual +--- +"hi\r\n"