diff --git a/Cargo.lock b/Cargo.lock index 63dd289..06edc48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,8 +320,10 @@ dependencies = [ name = "test-utils" version = "0.1.0" dependencies = [ + "pretty_assertions 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)", "tempdir 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)", "trim-margin 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "yaml-rust 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] diff --git a/Justfile b/Justfile index 135f054..252162d 100755 --- a/Justfile +++ b/Justfile @@ -21,7 +21,12 @@ scripts: dev: clear ; printf "\e[3J" - cargo test --all --color=always --features 'dev test' -- --test-threads=1 --quiet + cargo test --all --color=always --features 'dev test' -- --test-threads=1 + +unit_test: + clear ; printf "\e[3J" + cargo build --color=always --features=dev + cargo test --lib --all --color=always --features 'dev test' -- --test-threads=1 --quiet run_bigger: cargo run -- tests/examples/bigger/script diff --git a/src/executable_mock.rs b/src/executable_mock.rs new file mode 100644 index 0000000..680731e --- /dev/null +++ b/src/executable_mock.rs @@ -0,0 +1,191 @@ +use crate::context::Context; +use crate::tracer::tracee_memory; +use crate::utils::short_temp_files::ShortTempFile; +use crate::{ExitCode, R}; +use bincode::{deserialize, serialize}; +use libc::user_regs_struct; +use nix::unistd::Pid; +use std::fs; +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Serialize, Deserialize)] +pub enum Config { + Config { stdout: Vec, exitcode: i32 }, + Wrapper { executable: PathBuf }, +} + +#[derive(Debug)] +pub struct ExecutableMock { + temp_file: ShortTempFile, +} + +impl ExecutableMock { + pub fn new(context: &Context, mock_config: Config) -> R { + let mut contents = b"#!".to_vec(); + contents.append( + &mut context + .scriptkeeper_executable() + .as_os_str() + .as_bytes() + .to_vec(), + ); + contents.append(&mut b" --executable-mock\n".to_vec()); + contents.append(&mut serialize(&mock_config)?); + let temp_file = ShortTempFile::new(&contents)?; + Ok(ExecutableMock { temp_file }) + } + + pub fn wrapper(context: &Context, executable: &Path) -> R { + ExecutableMock::new( + context, + Config::Wrapper { + executable: executable.to_owned(), + }, + ) + } + + pub fn path(&self) -> PathBuf { + self.temp_file.path() + } + + pub fn poke_for_execve_syscall( + pid: Pid, + registers: &user_regs_struct, + executable_mock_path: PathBuf, + ) -> R<()> { + tracee_memory::poke_single_word_string( + pid, + registers.rdi, + &executable_mock_path.as_os_str().as_bytes(), + ) + } + + pub fn run(context: &Context, executable_mock_path: &Path) -> R { + let config: Config = deserialize(&ExecutableMock::skip_hashbang_line(fs::read( + executable_mock_path, + )?))?; + match config { + Config::Config { stdout, exitcode } => { + context.stdout().write_all(&stdout)?; + Ok(ExitCode(exitcode)) + } + Config::Wrapper { executable } => { + Command::new(&executable).status()?; + Ok(ExitCode(0)) + } + } + } + + fn skip_hashbang_line(input: Vec) -> Vec { + input + .clone() + .into_iter() + .skip_while(|char: &u8| *char != b'\n') + .skip(1) + .collect() + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::process::Command; + use test_utils::{trim_margin, TempFile}; + + mod new { + use super::*; + + #[test] + fn creates_an_executable_that_outputs_the_given_stdout() -> R<()> { + let executable_mock = ExecutableMock::new( + &Context::new_mock(), + Config::Config { + stdout: b"foo".to_vec(), + exitcode: 0, + }, + )?; + let output = Command::new(&executable_mock.path()).output(); + assert_eq!(output?.stdout, b"foo"); + Ok(()) + } + + #[test] + fn creates_an_executable_that_exits_with_the_given_exitcode() -> R<()> { + let executable_mock = ExecutableMock::new( + &Context::new_mock(), + Config::Config { + stdout: b"foo".to_vec(), + exitcode: 42, + }, + )?; + let output = Command::new(executable_mock.path()).output()?; + assert_eq!(output.status.code(), Some(42)); + Ok(()) + } + } + + mod wrapper { + use super::*; + use crate::utils::path_to_string; + use tempdir::TempDir; + + #[test] + fn executes_the_given_command() -> R<()> { + let temp_dir = TempDir::new("test")?; + let path = temp_dir.path().join("foo"); + let script = TempFile::write_temp_script( + trim_margin(&format!( + " + |#!/usr/bin/env bash + |touch {} + ", + path_to_string(&path)? + ))? + .as_bytes(), + )?; + let executable_mock = ExecutableMock::wrapper(&Context::new_mock(), &script.path())?; + Command::new(executable_mock.path()).status()?; + assert!(path.exists()); + Ok(()) + } + + #[test] + fn relays_stdout() -> R<()> { + let script = TempFile::write_temp_script( + trim_margin( + " + |#!/usr/bin/env bash + |echo foo + ", + )? + .as_bytes(), + )?; + let executable_mock = ExecutableMock::wrapper(&Context::new_mock(), &script.path())?; + let output = Command::new(executable_mock.path()).output()?; + assert_eq!(String::from_utf8(output.stdout)?, "foo\n"); + Ok(()) + } + + #[test] + fn relays_the_process_environment() -> R<()> { + let script = TempFile::write_temp_script( + trim_margin( + " + |#!/usr/bin/env bash + |echo $FOO + ", + )? + .as_bytes(), + )?; + let executable_mock = ExecutableMock::wrapper(&Context::new_mock(), &script.path())?; + let output = Command::new(executable_mock.path()) + .env("FOO", "bar") + .output()?; + assert_eq!(String::from_utf8(output.stdout)?, "bar\n"); + Ok(()) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e7adb5b..ae14158 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ extern crate memoffset; pub mod cli; pub mod context; +mod executable_mock; mod recorder; mod test_checker; mod test_spec; @@ -21,8 +22,8 @@ mod tracer; pub mod utils; use crate::context::Context; +use crate::executable_mock::ExecutableMock; use crate::recorder::{hole_recorder::run_against_tests, Recorder}; -use crate::test_checker::executable_mock; use crate::test_spec::yaml::write_yaml; use crate::test_spec::Tests; use crate::tracer::stdio_redirecting::CaptureStderr; @@ -80,7 +81,7 @@ pub fn run_main(context: &Context, args: &cli::Args) -> R { Ok(match args { cli::Args::ExecutableMock { executable_mock_path, - } => executable_mock::run(context, &executable_mock_path)?, + } => ExecutableMock::run(context, &executable_mock_path)?, cli::Args::Scriptkeeper { script_path, record, @@ -97,20 +98,19 @@ pub fn run_main(context: &Context, args: &cli::Args) -> R { #[cfg(test)] mod run_main { use super::*; - use executable_mock::create_mock_executable; + use crate::executable_mock; use test_utils::TempFile; #[test] fn when_passed_executable_mock_flag_behaves_like_executable_mock() -> R<()> { let context = Context::new_mock(); - let executable_contents = create_mock_executable( + let executable_mock = ExecutableMock::new( &context, - executable_mock::Config { + executable_mock::Config::Config { stdout: b"foo".to_vec(), exitcode: 0, }, )?; - let executable_mock = TempFile::write_temp_script(&executable_contents)?; run_main( &context, &cli::Args::ExecutableMock { @@ -141,7 +141,7 @@ fn print_recorded_test(context: &Context, program: &Path) -> R { vec![], HashMap::new(), CaptureStderr::NoCapture, - Recorder::empty(), + Recorder::empty(context), )?; write_yaml(&mut *context.stdout(), &Tests::new(vec![test]).serialize()?)?; Ok(ExitCode(0)) diff --git a/src/recorder/hole_recorder.rs b/src/recorder/hole_recorder.rs index 5a21cbe..f58a2fb 100644 --- a/src/recorder/hole_recorder.rs +++ b/src/recorder/hole_recorder.rs @@ -56,6 +56,7 @@ impl SyscallMock for HoleRecorder { CheckerResult::Pass => { *self = HoleRecorder::Recorder { recorder: Recorder::new( + &checker.context, original_test.clone(), &checker.unmocked_commands, ), @@ -86,7 +87,8 @@ impl SyscallMock for HoleRecorder { } => match checker.result { CheckerResult::Pass => { original_test.ends_with_hole = false; - let recorder = Recorder::new(original_test, &checker.unmocked_commands); + let recorder = + Recorder::new(&checker.context, original_test, &checker.unmocked_commands); RecorderResult::Recorded(recorder.handle_end(exitcode, redirector)?) } failure @ CheckerResult::Failure(_) => { diff --git a/src/recorder/mod.rs b/src/recorder/mod.rs index 602c397..53b3614 100644 --- a/src/recorder/mod.rs +++ b/src/recorder/mod.rs @@ -1,6 +1,8 @@ pub mod hole_recorder; mod result; +use crate::context::Context; +use crate::executable_mock::ExecutableMock; use crate::test_spec::command::Command; use crate::test_spec::command_matcher::CommandMatcher; use crate::test_spec::{compare_executables, Step, Test}; @@ -13,25 +15,32 @@ use std::ffi::OsString; use std::path::PathBuf; pub struct Recorder { + context: Context, test: Test, command: Option, unmocked_commands: Vec, + temporary_executables: Vec, } impl Recorder { - pub fn empty() -> Recorder { + pub fn empty(context: &Context) -> Recorder { + // fixme: use new Recorder { + context: context.clone(), test: Test::new(vec![]), command: None, unmocked_commands: vec![], + temporary_executables: vec![], } } - pub fn new(test: Test, unmocked_commands: &[PathBuf]) -> Recorder { + pub fn new(context: &Context, test: Test, unmocked_commands: &[PathBuf]) -> Recorder { Recorder { + context: context.clone(), test, command: None, unmocked_commands: unmocked_commands.to_vec(), + temporary_executables: vec![], } } } @@ -41,8 +50,8 @@ impl SyscallMock for Recorder { fn handle_execve_enter( &mut self, - _pid: Pid, - _registers: &user_regs_struct, + pid: Pid, + registers: &user_regs_struct, executable: PathBuf, arguments: Vec, ) -> R<()> { @@ -51,6 +60,11 @@ impl SyscallMock for Recorder { .iter() .any(|unmocked_command| compare_executables(unmocked_command, &executable)); if !is_unmocked_command { + let executable_mock_path = ExecutableMock::wrapper(&self.context, &executable)?; + let path = executable_mock_path.path(); + self.temporary_executables.push(executable_mock_path); + ExecutableMock::poke_for_execve_syscall(pid, registers, path)?; + self.command = Some(Command { executable, arguments, diff --git a/src/test_checker/executable_mock.rs b/src/test_checker/executable_mock.rs deleted file mode 100644 index caef9dd..0000000 --- a/src/test_checker/executable_mock.rs +++ /dev/null @@ -1,77 +0,0 @@ -use crate::context::Context; -use crate::{ExitCode, R}; -use bincode::{deserialize, serialize}; -use std::fs; -use std::io::Write; -use std::os::unix::ffi::OsStrExt; -use std::path::Path; - -#[derive(Debug, Serialize, Deserialize)] -pub struct Config { - pub stdout: Vec, - pub exitcode: i32, -} - -pub fn create_mock_executable(context: &Context, config: Config) -> R> { - let mut result = b"#!".to_vec(); - result.append( - &mut context - .scriptkeeper_executable() - .as_os_str() - .as_bytes() - .to_vec(), - ); - result.append(&mut b" --executable-mock\n".to_vec()); - result.append(&mut serialize(&config)?); - Ok(result) -} - -pub fn run(context: &Context, executable_mock_path: &Path) -> R { - let config: Config = deserialize(&skip_hashbang_line(fs::read(executable_mock_path)?))?; - context.stdout().write_all(&config.stdout)?; - Ok(ExitCode(config.exitcode)) -} - -fn skip_hashbang_line(input: Vec) -> Vec { - input - .clone() - .into_iter() - .skip_while(|char: &u8| *char != b'\n') - .skip(1) - .collect() -} - -#[cfg(test)] -mod test { - use super::*; - use std::process::Command; - use test_utils::TempFile; - - #[test] - fn renders_an_executable_that_outputs_the_given_stdout() -> R<()> { - let mock_executable = TempFile::write_temp_script(&create_mock_executable( - &Context::new_mock(), - Config { - stdout: b"foo".to_vec(), - exitcode: 0, - }, - )?)?; - let output = Command::new(mock_executable.path()).output()?; - assert_eq!(output.stdout, b"foo"); - Ok(()) - } - - #[test] - fn renders_an_executable_that_exits_with_the_given_exitcode() -> R<()> { - let mock_executable = TempFile::write_temp_script(&create_mock_executable( - &Context::new_mock(), - Config { - stdout: b"foo".to_vec(), - exitcode: 42, - }, - )?)?; - let output = Command::new(mock_executable.path()).output()?; - assert_eq!(output.status.code(), Some(42)); - Ok(()) - } -} diff --git a/src/test_checker/mod.rs b/src/test_checker/mod.rs index 08ed203..f5722d0 100644 --- a/src/test_checker/mod.rs +++ b/src/test_checker/mod.rs @@ -1,12 +1,12 @@ pub mod checker_result; -pub mod executable_mock; use crate::context::Context; +use crate::executable_mock; +use crate::executable_mock::ExecutableMock; use crate::test_spec; use crate::test_spec::Test; use crate::tracer::stdio_redirecting::Redirector; use crate::tracer::{tracee_memory, SyscallMock}; -use crate::utils::short_temp_files::ShortTempFile; use crate::R; use checker_result::CheckerResult; use libc::{c_ulonglong, user_regs_struct}; @@ -18,11 +18,11 @@ use std::path::PathBuf; #[derive(Debug)] pub struct TestChecker { - context: Context, + pub context: Context, pub test: Test, pub unmocked_commands: Vec, pub result: CheckerResult, - temporary_executables: Vec, + temporary_executables: Vec, } impl TestChecker { @@ -37,7 +37,7 @@ impl TestChecker { } fn allow_failing_scripts_to_continue() -> executable_mock::Config { - executable_mock::Config { + executable_mock::Config::Config { stdout: vec![], exitcode: 0, } @@ -52,7 +52,7 @@ impl TestChecker { &received.format(), ); } - executable_mock::Config { + executable_mock::Config::Config { stdout: next_test_step.stdout, exitcode: next_test_step.exitcode, } @@ -62,11 +62,9 @@ impl TestChecker { TestChecker::allow_failing_scripts_to_continue() } }; - let mock_executable_contents = - executable_mock::create_mock_executable(&self.context, mock_config)?; - let temp_executable = ShortTempFile::new(&mock_executable_contents)?; - let path = temp_executable.path(); - self.temporary_executables.push(temp_executable); + let executable_mock = ExecutableMock::new(&self.context, mock_config)?; + let path = executable_mock.path(); + self.temporary_executables.push(executable_mock); Ok(path) } @@ -102,15 +100,11 @@ impl SyscallMock for TestChecker { .iter() .any(|unmocked_command| test_spec::compare_executables(unmocked_command, &executable)); if !is_unmocked_command { - let mock_executable_path = self.handle_step(test_spec::Command { + let executable_mock_path = self.handle_step(test_spec::Command { executable, arguments, })?; - tracee_memory::poke_single_word_string( - pid, - registers.rdi, - &mock_executable_path.as_os_str().as_bytes(), - )?; + ExecutableMock::poke_for_execve_syscall(pid, registers, executable_mock_path)?; } Ok(()) } diff --git a/src/test_spec/mod.rs b/src/test_spec/mod.rs index 9f9e13f..fdf29e8 100644 --- a/src/test_spec/mod.rs +++ b/src/test_spec/mod.rs @@ -77,15 +77,23 @@ impl Step { fn serialize(&self) -> Yaml { let command = Yaml::String(self.command_matcher.format()); - if self.exitcode == 0 { + if self.exitcode == 0 && self.stdout.is_empty() { command } else { let mut step = LinkedHashMap::new(); step.insert(Yaml::from_str("command"), command); - step.insert( - Yaml::from_str("exitcode"), - Yaml::Integer(i64::from(self.exitcode)), - ); + if !self.stdout.is_empty() { + step.insert( + Yaml::from_str("stdout"), + Yaml::String(String::from_utf8_lossy(&self.stdout).into_owned()), + ); + } + if self.exitcode != 0 { + step.insert( + Yaml::from_str("exitcode"), + Yaml::Integer(i64::from(self.exitcode)), + ); + } Yaml::Hash(step) } } @@ -1160,6 +1168,7 @@ mod load { mod serialize { use super::*; use pretty_assertions::assert_eq; + use test_utils::trim_margin; fn roundtrip(tests: Tests) -> R<()> { let yaml = tests.serialize()?; @@ -1208,14 +1217,87 @@ mod serialize { roundtrip(Tests::new(vec![test])) } - #[test] - fn includes_the_step_exitcodes() -> R<()> { - let test = Test::new(vec![Step { - command_matcher: CommandMatcher::ExactMatch(Command::new("cp")?), - stdout: vec![], - exitcode: 42, - }]); - roundtrip(Tests::new(vec![test])) + mod steps { + use super::*; + + #[test] + fn includes_the_step_exitcodes() -> R<()> { + let test = Test::new(vec![Step { + command_matcher: CommandMatcher::ExactMatch(Command::new("cp")?), + stdout: vec![], + exitcode: 42, + }]); + roundtrip(Tests::new(vec![test])) + } + + #[test] + fn includes_stdout_of_steps() -> R<()> { + let protocol = Test::new(vec![Step { + command_matcher: CommandMatcher::ExactMatch(Command::new("cp")?), + stdout: b"foo".to_vec(), + exitcode: 0, + }]); + roundtrip(Tests::new(vec![protocol])) + } + + mod when_serialized_as_object { + use super::*; + use pretty_assertions::assert_eq; + use std::io::Cursor; + use test_utils::assert_eq_yaml; + + #[test] + fn does_not_include_exitcode_when_zero() -> R<()> { + let mut buffer = Cursor::new(vec![]); + write_yaml( + &mut buffer, + &Tests::new(vec![Test::new(vec![Step { + command_matcher: CommandMatcher::ExactMatch(Command::new("cp")?), + stdout: b"foo".to_vec(), + exitcode: 0, + }])]) + .serialize()?, + )?; + assert_eq_yaml( + &String::from_utf8(buffer.into_inner())?, + &trim_margin( + " + |tests: + | - steps: + | - command: cp + | stdout: foo + ", + )?, + )?; + Ok(()) + } + + #[test] + fn does_not_include_stdout_when_empty() -> R<()> { + let mut buffer = Cursor::new(vec![]); + write_yaml( + &mut buffer, + &Tests::new(vec![Test::new(vec![Step { + command_matcher: CommandMatcher::ExactMatch(Command::new("cp")?), + stdout: vec![], + exitcode: 1, + }])]) + .serialize()?, + )?; + assert_eq_yaml( + &String::from_utf8(buffer.into_inner())?, + &trim_margin( + " + |tests: + | - steps: + | - command: cp + | exitcode: 1 + ", + )?, + )?; + Ok(()) + } + } } #[test] diff --git a/test-utils/Cargo.toml b/test-utils/Cargo.toml index 7fea99f..3692896 100644 --- a/test-utils/Cargo.toml +++ b/test-utils/Cargo.toml @@ -7,3 +7,5 @@ edition = "2018" [dependencies] tempdir = "*" trim-margin = "*" +yaml-rust = "*" +pretty_assertions = "*" diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 7ca2620..cb97728 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -1,11 +1,13 @@ #![deny(clippy::all)] +use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::fs; use std::path::PathBuf; use std::process::Command; use tempdir::TempDir; use trim_margin::MarginTrimmable; +use yaml_rust::YamlLoader; type R = Result>; @@ -75,3 +77,12 @@ macro_rules! assert_error { assert_eq!(format!("{}", $result.unwrap_err()), $expected); }; } + +pub fn assert_eq_yaml(result: &str, expected: &str) -> R<()> { + let result = + YamlLoader::load_from_str(result).map_err(|error| format!("{}\n({})", error, result))?; + let expected = YamlLoader::load_from_str(expected) + .map_err(|error| format!("{}\n({})", error, expected))?; + assert_eq!(result, expected); + Ok(()) +} diff --git a/tests/holes.rs b/tests/holes.rs index 0ddcc50..78150d9 100644 --- a/tests/holes.rs +++ b/tests/holes.rs @@ -13,8 +13,8 @@ use scriptkeeper::context::Context; use scriptkeeper::utils::path_to_string; use scriptkeeper::{cli, run_main, R}; use std::fs; -use test_utils::trim_margin; -use utils::{assert_eq_yaml, prepare_script}; +use test_utils::{assert_eq_yaml, trim_margin}; +use utils::prepare_script; fn test_holes(script_code: &str, existing: &str, expected: &str) -> R<()> { let (script, test_file) = prepare_script(script_code, existing)?; diff --git a/tests/recording.rs b/tests/recording.rs index f841f68..a795506 100644 --- a/tests/recording.rs +++ b/tests/recording.rs @@ -8,10 +8,11 @@ #[path = "./utils.rs"] mod utils; +use quale::which; use scriptkeeper::context::Context; +use scriptkeeper::utils::path_to_string; use scriptkeeper::{cli, run_main, R}; -use test_utils::{trim_margin, TempFile}; -use utils::assert_eq_yaml; +use test_utils::{assert_eq_yaml, trim_margin, TempFile}; mod yaml_formatting { use super::*; @@ -151,3 +152,24 @@ fn records_command_exitcodes() -> R<()> { "#, ) } + +#[test] +#[ignore] +fn records_stdout_of_commands() -> R<()> { + let echo = which("echo").ok_or("echo not found in $PATH")?; + test_recording( + &format!( + " + |#!/usr/bin/env bash + |{} foo > /dev/null + ", + path_to_string(&echo)? + ), + " + |protocols: + | - protocol: + | - command: echo foo + | stdout: foo + ", + ) +} diff --git a/tests/utils.rs b/tests/utils.rs index e6602ae..2c2c509 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -12,7 +12,6 @@ use scriptkeeper::{context::Context, run_scriptkeeper, ExitCode, R}; use std::fs; use std::path::PathBuf; use test_utils::{trim_margin, TempFile}; -use yaml_rust::YamlLoader; fn compare_results(result: (ExitCode, String), expected: Result<(), &str>) { let expected_output = match expected { @@ -57,12 +56,3 @@ pub fn test_run_with_context( pub fn test_run(script_code: &str, tests: &str, expected: Result<(), &str>) -> R<()> { test_run_with_context(&Context::new_mock(), script_code, tests, expected) } - -pub fn assert_eq_yaml(result: &str, expected: &str) -> R<()> { - let result = - YamlLoader::load_from_str(result).map_err(|error| format!("{}\n({})", error, result))?; - let expected = YamlLoader::load_from_str(expected) - .map_err(|error| format!("{}\n({})", error, expected))?; - assert_eq!(result, expected); - Ok(()) -}