Repository: memorysafety/sudo-rs
Pull request #103 (2023-03-15 19:42:07)

refactor test framework
this PR revamps the sudo-test API to better follow the "builder pattern". the new API removes the need for option-like values (e.g. `None`, `&[]`). the new API is also less error prone (e.g. there's no way to assign a user ID to a non-existent user); this let me remove some unit tests from sudo-test. the new API also let me simplify the `EnvBuilder::build` method which was getting pretty long.

there are two notable changes in the sudo-test semantics:
- a home directory is not automatically created for new users. no compliance test was relying on this. we can add an API for this later if needed
- a user group named after the user is not automatically created. this caused the compliance test that uncovered issue #81 to pass so I added a new compliance test that acts as a regression test for that issue

there is a lot of "churn" in this PR so I recommend reviewing commit by commit and mainly looking at how the unit tests have changed.

this PR is built on top of PR #98


Patch:
diff --git a/lib/sudo-common/src/context.rs b/lib/sudo-common/src/context.rs
index 123d8325..a74a930d 100644
--- a/lib/sudo-common/src/context.rs
+++ b/lib/sudo-common/src/context.rs
@@ -68,6 +68,7 @@ pub struct Context<'a> {
     // system
     pub hostname: String,
     pub current_user: User,
+    pub pid: i32,
 }
 
 pub trait Configuration {
@@ -142,6 +143,7 @@ impl<'a> Context<'a> {
             login: sudo_options.login,
             shell: sudo_options.shell,
             chdir: sudo_options.directory.clone(),
+            pid: sudo_system::Process::process_id(),
         })
     }
 }
diff --git a/lib/sudo-exec/src/lib.rs b/lib/sudo-exec/src/lib.rs
index a3a6add6..b4105ed5 100644
--- a/lib/sudo-exec/src/lib.rs
+++ b/lib/sudo-exec/src/lib.rs
@@ -2,26 +2,26 @@
 use std::{
     ffi::c_int,
     io,
-    os::unix::process::CommandExt,
+    os::unix::process::{CommandExt, ExitStatusExt},
     process::{Command, ExitStatus},
+    time::Duration,
 };
 
 use signal_hook::{
     consts::*,
     iterator::{exfiltrator::WithOrigin, SignalsInfo},
-    low_level::siginfo::{Cause, Origin, Sent},
+    low_level::{
+        emulate_default_handler,
+        siginfo::{Cause, Process, Sent},
+    },
 };
 use sudo_common::context::{Context, Environment};
 use sudo_system::{getpgid, kill};
 
-/// We do not handle `SIGKILL`, `SIGSTOP`, `SIGILL`, `SIGFPE` nor `SIGSEGV` because those should
-/// not be intercepted and replaced. according to `POSIX`.
-// FIXME: are we missing any signals? `SIGEMT`, `SIGLOST` and `SIGPWR` are not exposed by
-// `signal-hook`.
+/// We only handle the signals that ogsudo handles.
 const SIGNALS: &[c_int] = &[
-    SIGABRT, SIGALRM, SIGBUS, SIGCHLD, SIGCONT, SIGHUP, SIGINT, SIGPIPE, SIGPROF, SIGQUIT, SIGSYS,
-    SIGTERM, SIGTRAP, SIGTSTP, SIGTTIN, SIGTTOU, SIGURG, SIGUSR1, SIGUSR2, SIGVTALRM, SIGWINCH,
-    SIGXCPU, SIGXFSZ, SIGIO,
+    SIGINT, SIGQUIT, SIGTSTP, SIGTERM, SIGHUP, SIGALRM, SIGPIPE, SIGUSR1, SIGUSR2, SIGCHLD,
+    SIGCONT, SIGWINCH,
 ];
 
 /// Based on `ogsudo`s `exec_nopty` function.
@@ -40,74 +40,87 @@ pub fn run_command(ctx: Context<'_>, env: Environment) -> io::Result<ExitStatus>
     let mut signals = SignalsInfo::<WithOrigin>::new(SIGNALS)?;
 
     loop {
-        for Origin {
-            signal,
-            process,
-            cause,
-            ..
-        } in signals.pending()
-        {
-            match signal {
+        // First we check if the command is done.
+        if let Some(status) = cmd.try_wait()? {
+            if let Some(signal) = status.signal() {
+                // If the command terminated because of a signal, we send this signal to sudo
+                // itself to match the original sudo behavior. If we fail we just return the status
+                // code.
+                if kill(ctx.pid, signal) != -1 {
+                    // Given that we overwrote the default handlers for all the signals, we must
+                    // emulate them to handle the signal we just sent correctly.
+                    for info in signals.pending() {
+                        emulate_default_handler(info.signal)?;
+                    }
+                }
+            }
+
+            return Ok(status);
+        }
+
+        // Then we check any pending signals that we received.
+        for info in signals.pending() {
+            let user_signaled = info.cause == Cause::Sent(Sent::User);
+            match info.signal {
                 SIGCHLD => {
                     // FIXME: check `handle_sigchld_nopty`
-                    cmd.kill()?;
-                }
-                SIGWINCH => {
-                    // FIXME: check `handle_sigwinch`
-                    cmd.kill()?;
+                    // We just wait until all the children are done.
+                    continue;
                 }
-                SIGINT | SIGQUIT | SIGTSTP => {
-                    if cause != Cause::Sent(Sent::User) {
+                SIGWINCH | SIGINT | SIGQUIT | SIGTSTP => {
+                    // Skip the signal if it was not sent by the user or if it is self-terminating.
+                    if !user_signaled || is_self_terminating(info.process, cmd_pid, ctx.pid) {
                         continue;
                     }
-
-                    if let Some(process) = process {
-                        if process.pid != 0 {
-                            if process.pid == cmd_pid {
-                                continue;
-                            }
-                            let process_grp = getpgid(process.pid);
-                            if process_grp != -1 {
-                                // FIXME: we should also check that the process group is not the
-                                // sudo PID.
-                                if process_grp == cmd_pid {
-                                    continue;
-                                }
-                            }
-                        }
-                    }
                 }
                 _ => {
-                    if cause == Cause::Sent(Sent::User) {
-                        if let Some(process) = process {
-                            if process.pid != 0 {
-                                if process.pid == cmd_pid {
-                                    continue;
-                                }
-                                let process_grp = getpgid(process.pid);
-                                if process_grp != -1 {
-                                    // FIXME: we should also check that the process group is not the
-                                    // sudo PID.
-                                    if process_grp == cmd_pid {
-                                        continue;
-                                    }
-                                }
-                            }
-                        }
+                    // Skip the signal if it was sent by the user and it is self-terminating.
+                    if user_signaled && is_self_terminating(info.process, cmd_pid, ctx.pid) {
+                        continue;
                     }
                 }
             }
 
-            if signal == SIGALRM {
-                // FIXME: check `terminate_command` to match behavior.
-                cmd.kill()?;
-            } else if kill(cmd_pid, signal) != 0 {
+            let status = if info.signal == SIGALRM {
+                // Kill the command with increasing urgency.
+                // Based on `terminate_command`.
+                kill(cmd_pid, SIGHUP);
+                kill(cmd_pid, SIGTERM);
+                std::thread::sleep(Duration::from_secs(2));
+                kill(cmd_pid, SIGKILL)
+            } else {
+                kill(cmd_pid, info.signal)
+            };
+
+            if status != 0 {
                 eprintln!("kill failed");
             }
         }
+    }
+}
 
-        if let Some(code) = cmd.try_wait()? {
-            return Ok(code);
+/// Decides if the signal sent by `process` is self-terminating.
+///
+/// A signal is self-terminating if the PID of the `process`:
+/// - is the same PID of the command, or
+/// - is in the process group of the command and either sudo of the command are the leader.
+fn is_self_terminating(process: Option<Process>, cmd_pid: i32, sudo_pid: i32) -> bool {
+    if let Some(process) = process {
+        if process.pid != 0 {
+            if process.pid == cmd_pid {
+                return true;
+            }
+            let grp_leader = getpgid(process.pid);
+
+            if grp_leader != -1 {
+                if grp_leader == cmd_pid || grp_leader == sudo_pid {
+                    return true;
+                }
+            } else {
+                eprintln!("Could not fetch process group ID");
+            }
         }
     }
+
+    false
 }


Test patch:
diff --git a/lib/sudo-env/tests/env_tests.rs b/lib/sudo-env/tests/env_tests.rs
index a8b2237f..a0b57d51 100644
--- a/lib/sudo-env/tests/env_tests.rs
+++ b/lib/sudo-env/tests/env_tests.rs
@@ -132,6 +132,7 @@ fn create_test_context<'a>(sudo_options: &'a SudoOptions) -> Context<'a> {
         login: sudo_options.login,
         shell: sudo_options.shell,
         chdir: sudo_options.directory.clone(),
+        pid: std::process::id() as i32,
     }
 }
 
diff --git a/test-framework/sudo-compliance-tests/src/child_process.rs b/test-framework/sudo-compliance-tests/src/child_process.rs
index de72caad..9f9e97de 100644
--- a/test-framework/sudo-compliance-tests/src/child_process.rs
+++ b/test-framework/sudo-compliance-tests/src/child_process.rs
@@ -1,53 +1,45 @@
 use pretty_assertions::assert_eq;
-use sudo_test::{As, EnvBuilder};
+use sudo_test::{Command, Env};
 
 use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
 
 #[test]
 fn sudo_forwards_childs_exit_code() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
-        .build()?;
+    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
 
     let expected = 42;
-    let output = env.exec(
-        &["sudo", "sh", "-c", &format!("exit {expected}")],
-        As::Root,
-        None,
-    )?;
-    assert_eq!(Some(expected), output.status.code());
+    let output = Command::new("sudo")
+        .args(["sh", "-c"])
+        .arg(format!("exit {expected}"))
+        .exec(&env)?;
+    assert_eq!(Some(expected), output.status().code());
 
     Ok(())
 }
 
 #[test]
 fn sudo_forwards_childs_stdout() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
-        .build()?;
+    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
 
     let expected = "hello";
-    let output = env.exec(&["sudo", "echo", expected], As::Root, None)?;
-    assert_eq!(expected, output.stdout);
-    assert!(output.stderr.is_empty());
+    let output = Command::new("sudo").args(["echo", expected]).exec(&env)?;
+    assert!(output.stderr().is_empty());
+    assert_eq!(expected, output.stdout()?);
 
     Ok(())
 }
 
 #[test]
 fn sudo_forwards_childs_stderr() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
-        .build()?;
+    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
 
     let expected = "hello";
-    let output = env.exec(
-        &["sudo", "sh", "-c", &format!(">&2 echo {expected}")],
-        As::Root,
-        None,
-    )?;
-    assert_eq!(expected, output.stderr);
-    assert!(output.stdout.is_empty());
+    let output = Command::new("sudo")
+        .args(["sh", "-c"])
+        .arg(format!(">&2 echo {expected}"))
+        .exec(&env)?;
+    assert_eq!(expected, output.stderr());
+    assert!(output.stdout()?.is_empty());
 
     Ok(())
 }
diff --git a/test-framework/sudo-compliance-tests/src/env_reset.rs b/test-framework/sudo-compliance-tests/src/env_reset.rs
index 843083a2..8b0e36be 100644
--- a/test-framework/sudo-compliance-tests/src/env_reset.rs
+++ b/test-framework/sudo-compliance-tests/src/env_reset.rs
@@ -1,7 +1,7 @@
 use std::collections::HashMap;
 
 use pretty_assertions::assert_eq;
-use sudo_test::{As, EnvBuilder};
+use sudo_test::{Command, Env};
 
 use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
 
@@ -11,22 +11,24 @@ use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
 // see 'command environment' section in`man sudoers`
 #[test]
 fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
-        .build()?;
+    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
 
-    let stdout = env.stdout(&["env"], As::Root, None)?;
+    let stdout = Command::new("env").exec(&env)?.stdout()?;
     let normal_env = parse_env_output(&stdout)?;
 
-    let sudo_abs_path = env.stdout(&["which", "sudo"], As::Root, None)?;
-    let env_abs_path = env.stdout(&["which", "env"], As::Root, None)?;
+    let sudo_abs_path = Command::new("which").arg("sudo").exec(&env)?.stdout()?;
+    let env_abs_path = Command::new("which").arg("env").exec(&env)?.stdout()?;
 
     // run sudo in an empty environment
-    let stdout = env.stdout(
-        &["env", "-i", "SUDO_RS_IS_UNSTABLE=I accept that my system may break unexpectedly", &sudo_abs_path, &env_abs_path],
-        As::Root,
-        None,
-    )?;
+    let stdout = Command::new("env")
+        .args([
+            "-i",
+            "SUDO_RS_IS_UNSTABLE=I accept that my system may break unexpectedly",
+            &sudo_abs_path,
+            &env_abs_path,
+        ])
+        .exec(&env)?
+        .stdout()?;
     let mut sudo_env = parse_env_output(&stdout)?;
 
     // # man sudo
@@ -78,27 +80,25 @@ fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
 
 #[test]
 fn env_reset_mode_clears_env_vars() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
-        .build()?;
+    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
 
     let varname = "SHOULD_BE_REMOVED";
     let set_env_var = format!("export {varname}=1");
 
     // sanity check that `set_env_var` makes `varname` visible to `env` program
-    let stdout = env.stdout(
-        &["sh", "-c", &format!("{set_env_var}; env")],
-        As::Root,
-        None,
-    )?;
+    let stdout = Command::new("sh")
+        .arg("-c")
+        .arg(format!("{set_env_var}; env"))
+        .exec(&env)?
+        .stdout()?;
     let env_vars = parse_env_output(&stdout)?;
     assert!(env_vars.contains_key(varname));
 
-    let stdout = env.stdout(
-        &["sh", "-c", &format!("{set_env_var}; sudo env")],
-        As::Root,
-        None,
-    )?;
+    let stdout = Command::new("sh")
+        .arg("-c")
+        .arg(format!("{set_env_var}; sudo env"))
+        .exec(&env)?
+        .stdout()?;
     let env_vars = parse_env_output(&stdout)?;
     assert!(!env_vars.contains_key(varname));
 
@@ -110,6 +110,8 @@ fn parse_env_output(env_output: &str) -> Result<HashMap<&str, &str>> {
     for line in env_output.lines() {
         if let Some((key, value)) = line.split_once('=') {
             env.insert(key, value);
+        } else {
+            return Err(format!("invalid env syntax: {line}").into());
         }
     }
 
diff --git a/test-framework/sudo-compliance-tests/src/flag_user.rs b/test-framework/sudo-compliance-tests/src/flag_user.rs
index 01686c59..f8e0db3d 100644
--- a/test-framework/sudo-compliance-tests/src/flag_user.rs
+++ b/test-framework/sudo-compliance-tests/src/flag_user.rs
@@ -1,18 +1,17 @@
 use pretty_assertions::assert_eq;
-use sudo_test::{As, EnvBuilder};
+use sudo_test::{Command, Env, User};
 
-use crate::{Result, SUDOERS_FERRIS_ALL_NOPASSWD, SUDOERS_ROOT_ALL_NOPASSWD};
+use crate::{Result, GROUPNAME, SUDOERS_ROOT_ALL_NOPASSWD, SUDOERS_USER_ALL_NOPASSWD, USERNAME};
 
 #[test]
 fn root_can_become_another_user_by_name() -> Result<()> {
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .user(username, &[])
-        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
-        .build()?;
+    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).user(USERNAME).build()?;
 
-    let expected = env.stdout(&["id"], As::User { name: username }, None)?;
-    let actual = env.stdout(&["sudo", "-u", username, "id"], As::Root, None)?;
+    let expected = Command::new("id").as_user(USERNAME).exec(&env)?.stdout()?;
+    let actual = Command::new("sudo")
+        .args(["-u", USERNAME, "id"])
+        .exec(&env)?
+        .stdout()?;
 
     assert_eq!(expected, actual);
 
@@ -21,44 +20,72 @@ fn root_can_become_another_user_by_name() -> Result<()> {
 
 #[test]
 fn root_can_become_another_user_by_uid() -> Result<()> {
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .user(username, &[])
-        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
-        .build()?;
+    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).user(USERNAME).build()?;
 
-    let uid = env
-        .stdout(&["id", "-u"], As::User { name: username }, None)?
+    let uid = Command::new("id")
+        .arg("-u")
+        .as_user(USERNAME)
+        .exec(&env)?
+        .stdout()?
         .parse::<u32>()?;
-    let expected = env.stdout(&["id"], As::User { name: username }, None)?;
-    let actual = env.stdout(&["sudo", "-u", &format!("#{uid}"), "id"], As::Root, None)?;
+    let expected = Command::new("id").as_user(USERNAME).exec(&env)?.stdout()?;
+    let actual = Command::new("sudo")
+        .arg("-u")
+        .arg(format!("#{uid}"))
+        .arg("id")
+        .exec(&env)?
+        .stdout()?;
 
     assert_eq!(expected, actual);
 
     Ok(())
 }
 
-#[ignore]
 #[test]
 fn user_can_become_another_user() -> Result<()> {
-    let env = EnvBuilder::default()
-        .user("ferris", &[])
-        .user("someone_else", &[])
-        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+    let invoking_user = USERNAME;
+    let another_user = "another_user";
+    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
+        .user(invoking_user)
+        .user(another_user)
+        .build()?;
+
+    let expected = Command::new("id")
+        .as_user(another_user)
+        .exec(&env)?
+        .stdout()?;
+    let actual = Command::new("sudo")
+        .args(["-u", another_user, "id"])
+        .as_user(USERNAME)
+        .exec(&env)?
+        .stdout()?;
+
+    assert_eq!(expected, actual);
+
+    Ok(())
+}
+
+// regression test for memorysafety/sudo-rs#81
+#[test]
+#[ignore]
+fn invoking_user_groups_are_lost_when_becoming_another_user() -> Result<()> {
+    let invoking_user = USERNAME;
+    let another_user = "another_user";
+    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
+        .group(GROUPNAME)
+        .user(User(invoking_user).group(GROUPNAME))
+        .user(another_user)
         .build()?;
 
-    let expected = env.stdout(
-        &["id"],
-        As::User {
-            name: "someone_else",
-        },
-        None,
-    )?;
-    let actual = env.stdout(
-        &["sudo", "-u", "someone_else", "id"],
-        As::User { name: "ferris" },
-        None,
-    )?;
+    let expected = Command::new("id")
+        .as_user(another_user)
+        .exec(&env)?
+        .stdout()?;
+    let actual = Command::new("sudo")
+        .args(["-u", another_user, "id"])
+        .as_user(invoking_user)
+        .exec(&env)?
+        .stdout()?;
 
     assert_eq!(expected, actual);
 
diff --git a/test-framework/sudo-compliance-tests/src/lib.rs b/test-framework/sudo-compliance-tests/src/lib.rs
index 908a14ef..06853f96 100644
--- a/test-framework/sudo-compliance-tests/src/lib.rs
+++ b/test-framework/sudo-compliance-tests/src/lib.rs
@@ -14,6 +14,10 @@ mod sudoers;
 type Error = Box<dyn std::error::Error>;
 type Result<T> = core::result::Result<T, Error>;
 
-const SUDOERS_FERRIS_ALL_NOPASSWD: &str = "ferris    ALL=(ALL:ALL) NOPASSWD: ALL";
+const USERNAME: &str = "ferris";
+const GROUPNAME: &str = "rustaceans";
+const PASSWORD: &str = "strong-password";
+
 const SUDOERS_ROOT_ALL: &str = "root    ALL=(ALL:ALL) ALL";
-const SUDOERS_ROOT_ALL_NOPASSWD: &str = "root    ALL=(ALL:ALL) NOPASSWD: ALL";
+const SUDOERS_ROOT_ALL_NOPASSWD: &str = "root ALL=(ALL:ALL) NOPASSWD: ALL";
+const SUDOERS_USER_ALL_NOPASSWD: &str = "ferris ALL=(ALL:ALL) NOPASSWD: ALL";
diff --git a/test-framework/sudo-compliance-tests/src/nopasswd.rs b/test-framework/sudo-compliance-tests/src/nopasswd.rs
index 9c94c81d..f284eb4a 100644
--- a/test-framework/sudo-compliance-tests/src/nopasswd.rs
+++ b/test-framework/sudo-compliance-tests/src/nopasswd.rs
@@ -1,8 +1,8 @@
 //! Scenarios where a password does not need to be provided
 
-use sudo_test::{As, EnvBuilder};
+use sudo_test::{Command, Env};
 
-use crate::{Result, SUDOERS_ROOT_ALL};
+use crate::{Result, SUDOERS_ROOT_ALL, USERNAME};
 
 // NOTE all these tests assume that the invoking user passes the sudoers file 'User_List' criteria
 
@@ -11,12 +11,12 @@ use crate::{Result, SUDOERS_ROOT_ALL};
 #[ignore]
 #[test]
 fn user_is_root() -> Result<()> {
-    let env = EnvBuilder::default().sudoers(SUDOERS_ROOT_ALL).build()?;
+    let env = Env(SUDOERS_ROOT_ALL).build()?;
 
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .arg("true")
+        .exec(&env)?
+        .assert_success()
 }
 
 // man sudoers > User Authentication:
@@ -24,33 +24,26 @@ fn user_is_root() -> Result<()> {
 #[ignore]
 #[test]
 fn user_as_themselves() -> Result<()> {
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .user(username, &[])
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
+        .user(USERNAME)
         .build()?;
 
-    let output = env.exec(
-        &["sudo", "-u", username, "true"],
-        As::User { name: username },
-        None,
-    )?;
-
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .args(["-u", USERNAME, "true"])
+        .as_user(USERNAME)
+        .exec(&env)?
+        .assert_success()
 }
 
 #[test]
 fn nopasswd_tag() -> Result<()> {
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) NOPASSWD: ALL"))
-        .user(username, &[])
+    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) NOPASSWD: ALL"))
+        .user(USERNAME)
         .build()?;
 
-    let output = env.exec(&["sudo", "true"], As::User { name: username }, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .arg("true")
+        .as_user(USERNAME)
+        .exec(&env)?
+        .assert_success()
 }
diff --git a/test-framework/sudo-compliance-tests/src/pass_auth.rs b/test-framework/sudo-compliance-tests/src/pass_auth.rs
index 94960c64..723d9dca 100644
--- a/test-framework/sudo-compliance-tests/src/pass_auth.rs
+++ b/test-framework/sudo-compliance-tests/src/pass_auth.rs
@@ -3,51 +3,41 @@
 // NOTE all these tests assume that the invoking user passes the sudoers file 'User_List' criteria
 
 use pretty_assertions::assert_eq;
+use sudo_test::{Command, Env, User};
 
-use sudo_test::{As, EnvBuilder};
-
-use crate::Result;
+use crate::{Result, PASSWORD, USERNAME};
 
 #[ignore]
 #[test]
 fn correct_password() -> Result<()> {
-    let username = "ferris";
-    let password = "strong-password";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
-        .user(username, &[])
-        .user_password(username, password)
+    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
+        .user(User(USERNAME).password(PASSWORD))
         .build()?;
 
-    let output = env.exec(
-        &["sudo", "-S", "true"],
-        As::User { name: username },
-        Some(password),
-    )?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .args(["-S", "true"])
+        .as_user(USERNAME)
+        .stdin(PASSWORD)
+        .exec(&env)?
+        .assert_success()
 }
 
 #[test]
 fn incorrect_password() -> Result<()> {
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
-        .user(username, &[])
-        .user_password(username, "strong-password")
+    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
+        .user(User(USERNAME).password("strong-password"))
         .build()?;
 
-    let output = env.exec(
-        &["sudo", "-S", "true"],
-        As::User { name: username },
-        Some("incorrect-password"),
-    )?;
-    assert!(!output.status.success());
-    assert_eq!(Some(1), output.status.code());
+    let output = Command::new("sudo")
+        .args(["-S", "true"])
+        .as_user(USERNAME)
+        .stdin("incorrect-password")
+        .exec(&env)?;
+    assert!(!output.status().success());
+    assert_eq!(Some(1), output.status().code());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "incorrect password attempt");
+        assert_contains!(output.stderr(), "incorrect password attempt");
     }
 
     Ok(())
@@ -55,19 +45,18 @@ fn incorrect_password() -> Result<()> {
 
 #[test]
 fn no_password() -> Result<()> {
-    let username = "ferris";
-    let password = "strong-password";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
-        .user(username, &[])
-        .user_password(username, password)
+    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
+        .user(User(USERNAME).password(PASSWORD))
         .build()?;
 
-    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
-    assert_eq!(Some(1), output.status.code());
+    let output = Command::new("sudo")
+        .args(["-S", "true"])
+        .as_user(USERNAME)
+        .exec(&env)?;
+    assert_eq!(Some(1), output.status().code());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "no password was provided");
+        assert_contains!(output.stderr(), "no password was provided");
     }
 
     Ok(())
diff --git a/test-framework/sudo-compliance-tests/src/perms.rs b/test-framework/sudo-compliance-tests/src/perms.rs
index 84b9a4db..f8a1d663 100644
--- a/test-framework/sudo-compliance-tests/src/perms.rs
+++ b/test-framework/sudo-compliance-tests/src/perms.rs
@@ -1,20 +1,23 @@
 use pretty_assertions::assert_eq;
-use sudo_test::{As, EnvBuilder};
+use sudo_test::{Command, Env, TextFile};
 
-use crate::{Result, SUDOERS_FERRIS_ALL_NOPASSWD};
+use crate::{Result, SUDOERS_USER_ALL_NOPASSWD, USERNAME};
 
 #[test]
 fn user_can_read_file_owned_by_root() -> Result<()> {
     let expected = "hello";
     let path = "/root/file";
-    let env = EnvBuilder::default()
-        .user("ferris", &[])
-        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
-        .text_file(path, "root:root", "000", expected)
+    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
+        .user(USERNAME)
+        .file(path, expected)
         .build()?;
 
-    let stdout = env.stdout(&["sudo", "cat", path], As::User { name: "ferris" }, None)?;
-    assert_eq!(expected, stdout);
+    let actual = Command::new("sudo")
+        .args(["cat", path])
+        .as_user(USERNAME)
+        .exec(&env)?
+        .stdout()?;
+    assert_eq!(expected, actual);
 
     Ok(())
 }
@@ -22,36 +25,37 @@ fn user_can_read_file_owned_by_root() -> Result<()> {
 #[test]
 fn user_can_write_file_owned_by_root() -> Result<()> {
     let path = "/root/file";
-    let env = EnvBuilder::default()
-        .user("ferris", &[])
-        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
-        .text_file(path, "root:root", "000", "")
+    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
+        .user(USERNAME)
+        .file(path, "")
         .build()?;
 
-    let output = env.exec(&["sudo", "rm", path], As::User { name: "ferris" }, None)?;
-    assert!(output.status.success());
-
-    Ok(())
+    Command::new("sudo")
+        .args(["rm", path])
+        .as_user(USERNAME)
+        .exec(&env)?
+        .assert_success()
 }
 
 #[test]
 #[ignore]
 fn user_can_execute_file_owned_by_root() -> Result<()> {
     let path = "/root/file";
-    let env = EnvBuilder::default()
-        .user("ferris", &[])
-        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
-        .text_file(
+    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
+        .user(USERNAME)
+        .file(
             path,
-            "root:root",
-            "100",
-            r#"#!/bin/sh
+            TextFile(
+                r#"#!/bin/sh
 exit 0"#,
+            )
+            .chmod("100"),
         )
         .build()?;
 
-    let output = env.exec(&["sudo", path], As::User { name: "ferris" }, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .arg(path)
+        .as_user(USERNAME)
+        .exec(&env)?
+        .assert_success()
 }
diff --git a/test-framework/sudo-compliance-tests/src/sudoers.rs b/test-framework/sudo-compliance-tests/src/sudoers.rs
index d0afcd50..2d97fa47 100644
--- a/test-framework/sudo-compliance-tests/src/sudoers.rs
+++ b/test-framework/sudo-compliance-tests/src/sudoers.rs
@@ -1,18 +1,18 @@
-use sudo_test::{As, EnvBuilder};
+use sudo_test::{Command, Env, TextFile, User};
 
-use crate::Result;
+use crate::{Result, PASSWORD, USERNAME};
 
 mod user_list;
 
 #[test]
 fn cannot_sudo_if_sudoers_file_is_world_writable() -> Result<()> {
-    let env = EnvBuilder::default().sudoers_chmod("446").build()?;
+    let env = Env(TextFile("").chmod("446")).build()?;
 
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert_eq!(Some(1), output.status.code());
+    let output = Command::new("sudo").arg("true").exec(&env)?;
+    assert_eq!(Some(1), output.status().code());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "/etc/sudoers is world writable");
+        assert_contains!(output.stderr(), "/etc/sudoers is world writable");
     }
 
     Ok(())
@@ -21,29 +21,28 @@ fn cannot_sudo_if_sudoers_file_is_world_writable() -> Result<()> {
 #[ignore]
 #[test]
 fn user_specifications_evaluated_bottom_to_top() -> Result<()> {
-    let username = "ferris";
-    let password = "strong-password";
-    let env = EnvBuilder::default()
-        .user(username, &[])
-        .user_password(username, password)
-        .sudoers("ferris ALL=(ALL:ALL) NOPASSWD: ALL")
-        // overrides the preceding NOPASSWD
-        .sudoers("ferris ALL=(ALL:ALL) ALL")
-        .build()?;
-
-    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
-    assert!(!output.status.success());
+    let env = Env(format!(
+        r#"{USERNAME} ALL=(ALL:ALL) NOPASSWD: ALL
+{USERNAME} ALL=(ALL:ALL) ALL"#
+    ))
+    .user(User(USERNAME).password(PASSWORD))
+    .build()?;
+
+    let output = Command::new("sudo")
+        .args(["-S", "true"])
+        .as_user(USERNAME)
+        .exec(&env)?;
+    assert!(!output.status().success());
+    assert_eq!(Some(1), output.status().code());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "no password was provided");
+        assert_contains!(output.stderr(), "no password was provided");
     }
 
-    let output = env.exec(
-        &["sudo", "-S", "true"],
-        As::User { name: username },
-        Some(password),
-    )?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .args(["-S", "true"])
+        .as_user(USERNAME)
+        .stdin(PASSWORD)
+        .exec(&env)?
+        .assert_success()
 }
diff --git a/test-framework/sudo-compliance-tests/src/sudoers/user_list.rs b/test-framework/sudo-compliance-tests/src/sudoers/user_list.rs
index 9b703588..759be8d5 100644
--- a/test-framework/sudo-compliance-tests/src/sudoers/user_list.rs
+++ b/test-framework/sudo-compliance-tests/src/sudoers/user_list.rs
@@ -1,18 +1,19 @@
 //! Test the first component of the user specification: `<user_list> ALL=(ALL:ALL) ALL`
 
-use sudo_test::{As, EnvBuilder};
+use pretty_assertions::assert_eq;
+use sudo_test::{Command, Env};
 
-use crate::Result;
+use crate::{Result, USERNAME};
 
 #[test]
 fn no_match() -> Result<()> {
-    let env = EnvBuilder::default().build()?;
+    let env = Env("").build()?;
 
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert_eq!(Some(1), output.status.code());
+    let output = Command::new("sudo").arg("true").exec(&env)?;
+    assert_eq!(Some(1), output.status().code());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "root is not in the sudoers file");
+        assert_contains!(output.stderr(), "root is not in the sudoers file");
     }
 
     Ok(())
@@ -20,65 +21,58 @@ fn no_match() -> Result<()> {
 
 #[test]
 fn all() -> Result<()> {
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .sudoers("ALL ALL=(ALL:ALL) NOPASSWD: ALL")
-        .user(username, &[])
+    let env = Env("ALL ALL=(ALL:ALL) NOPASSWD: ALL")
+        .user(USERNAME)
         .build()?;
 
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
+    Command::new("sudo")
+        .arg("true")
+        .exec(&env)?
+        .assert_success()?;
 
-    let output = env.exec(&["sudo", "true"], As::User { name: username }, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .arg("true")
+        .as_user(USERNAME)
+        .exec(&env)?
+        .assert_success()
 }
 
 #[test]
 fn user_name() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers("root ALL=(ALL:ALL) NOPASSWD: ALL")
-        .build()?;
-
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
+    let env = Env("root ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
 
-    Ok(())
+    Command::new("sudo")
+        .arg("true")
+        .exec(&env)?
+        .assert_success()
 }
 
 #[test]
 fn user_id() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers("#0 ALL=(ALL:ALL) NOPASSWD: ALL")
-        .build()?;
-
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
+    let env = Env("#0 ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
 
-    Ok(())
+    Command::new("sudo")
+        .arg("true")
+        .exec(&env)?
+        .assert_success()
 }
 
 #[test]
 fn group_name() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers("%root ALL=(ALL:ALL) NOPASSWD: ALL")
-        .build()?;
+    let env = Env("%root ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
 
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
+    Command::new("sudo")
+        .arg("true")
+        .exec(&env)?
+        .assert_success()
 }
 
 #[test]
 fn group_id() -> Result<()> {
-    let env = EnvBuilder::default()
-        .sudoers("%#0 ALL=(ALL:ALL) NOPASSWD: ALL")
-        .build()?;
-
-    let output = env.exec(&["sudo", "true"], As::Root, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
+    let env = Env("%#0 ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
 
-    Ok(())
+    Command::new("sudo")
+        .arg("true")
+        .exec(&env)?
+        .assert_success()
 }
diff --git a/test-framework/sudo-test/Cargo.toml b/test-framework/sudo-test/Cargo.toml
index 990660ea..e11d75c1 100644
--- a/test-framework/sudo-test/Cargo.toml
+++ b/test-framework/sudo-test/Cargo.toml
@@ -7,5 +7,4 @@ edition = "2021"
 doctest = false
 
 [dependencies]
-bimap = "0.6.2"
 tempfile = "3.4.0"
diff --git a/test-framework/sudo-test/src/docker.rs b/test-framework/sudo-test/src/docker.rs
index 92d3e6cf..49206025 100644
--- a/test-framework/sudo-test/src/docker.rs
+++ b/test-framework/sudo-test/src/docker.rs
@@ -1,14 +1,20 @@
 use core::str;
 use std::{
-    fs,
-    process::{Command, ExitStatus, Stdio},
+    fs::{self, File},
+    io::{Seek, SeekFrom, Write},
+    path::PathBuf,
+    process::{Command as StdCommand, Stdio},
 };
 
 use tempfile::NamedTempFile;
 
-use crate::{helpers, Result};
+use crate::{Result, SudoUnderTest, BASE_IMAGE};
 
-const DEFAULT_COMMAND: &[&str] = &["sleep", "infinity"];
+pub use self::command::{Command, Output};
+
+mod command;
+
+const DOCKER_RUN_COMMAND: &[&str] = &["sleep", "infinity"];
 
 pub struct Container {
     id: String,
@@ -16,43 +22,30 @@ pub struct Container {
 
 impl Container {
     pub fn new(image: &str) -> Result<Self> {
-        let mut cmd = Command::new("docker");
-        cmd.args(["run", "-d", "--rm", image]).args(DEFAULT_COMMAND);
-        let id = helpers::stdout(&mut cmd, None)?;
-        validate_docker_id(&id, &cmd)?;
+        let mut docker_run = StdCommand::new("docker");
+        docker_run
+            .args(["run", "-d", "--rm", image])
+            .args(DOCKER_RUN_COMMAND);
+        let id = run(&mut docker_run, None)?.stdout()?;
+        validate_docker_id(&id, &docker_run)?;
 
         Ok(Container { id })
     }
 
-    pub fn exec(
-        &self,
-        cmd: &[impl AsRef<str>],
-        user: As,
-        stdin: Option<&str>,
-    ) -> Result<ExecOutput> {
-        helpers::run(&mut self.docker_cmd(cmd, user, stdin.is_some()), stdin)
-    }
-
-    /// Returns `$cmd`'s stdout if it successfully exists
-    pub fn stdout(&self, cmd: &[impl AsRef<str>], user: As, stdin: Option<&str>) -> Result<String> {
-        helpers::stdout(&mut self.docker_cmd(cmd, user, stdin.is_some()), stdin)
-    }
-
-    fn docker_cmd(&self, cmd: &[impl AsRef<str>], user: As, with_stdin: bool) -> Command {
-        let mut docker_cmd = Command::new("docker");
-        docker_cmd.arg("exec");
-        if with_stdin {
-            docker_cmd.arg("-i");
-        }
-        if let Some(user) = user.as_string() {
-            docker_cmd.arg("--user");
-            docker_cmd.arg(user);
+    pub fn exec(&self, cmd: &Command) -> Result<Output> {
+        let mut docker_exec = StdCommand::new("docker");
+        docker_exec.arg("exec");
+        if cmd.get_stdin().is_some() {
+            docker_exec.arg("-i");
         }
-        docker_cmd.arg(&self.id);
-        for arg in cmd {
-            docker_cmd.arg(arg.as_ref());
+        if let Some(user) = cmd.get_user() {
+            docker_exec.arg("--user");
+            docker_exec.arg(user);
         }
-        docker_cmd
+        docker_exec.arg(&self.id);
+        docker_exec.args(cmd.get_args());
+
+        run(&mut docker_exec, cmd.get_stdin())
     }
 
     pub fn cp(&self, path_in_container: &str, file_contents: &str) -> Result<()> {
@@ -62,37 +55,21 @@ impl Container {
         let src_path = temp_file.path().display().to_string();
         let dest_path = format!("{}:{path_in_container}", self.id);
 
-        helpers::stdout(
-            Command::new("docker").args(["cp", &src_path, &dest_path]),
+        run(
+            StdCommand::new("docker").args(["cp", &src_path, &dest_path]),
             None,
-        )?;
+        )?
+        .assert_success()?;
 
         Ok(())
     }
 }
 
-#[derive(Clone, Copy)]
-pub enum As<'a> {
-    Root,
-    User { name: &'a str },
-    // UserGroup { user: &'a str, group: &'a str },
-}
-
-impl<'a> As<'a> {
-    fn as_string(&self) -> Option<String> {
-        let s = match self {
-            As::Root => return None,
-            As::User { name } => name.to_string(),
-        };
-        Some(s)
-    }
-}
-
 impl Drop for Container {
     fn drop(&mut self) {
         // running this to completion would block the current thread for several seconds so just
         // fire and forget
-        let _ = Command::new("docker")
+        let _ = StdCommand::new("docker")
             .args(["stop", &self.id])
             .stdout(Stdio::null())
             .stderr(Stdio::null())
@@ -100,13 +77,71 @@ impl Drop for Container {
     }
 }
 
-pub struct ExecOutput {
-    pub status: ExitStatus,
-    pub stderr: String,
-    pub stdout: String,
+pub fn build_base_image() -> Result<()> {
+    let repo_root = repo_root();
+    let mut cmd = StdCommand::new("docker");
+
+    cmd.args(["build", "-t", BASE_IMAGE]);
+
+    match SudoUnderTest::from_env()? {
+        SudoUnderTest::Ours => {
+            // needed for dockerfile-specific dockerignore (e.g. `Dockerfile.dockerignore`) support
+            cmd.env("DOCKER_BUILDKIT", "1");
+
+            cmd.current_dir(repo_root);
+            cmd.args(["-f", "test-framework/sudo-test/src/ours.Dockerfile", "."]);
+        }
+
+        SudoUnderTest::Theirs => {
+            // pass Dockerfile via stdin to not provide the repository as a build context
+            let f = File::open(repo_root.join("test-framework/sudo-test/src/theirs.Dockerfile"))?;
+            cmd.arg("-").stdin(Stdio::from(f));
+        }
+    }
+
+    run(&mut cmd, None)?.assert_success()?;
+
+    Ok(())
+}
+
+fn repo_root() -> PathBuf {
+    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+    path.pop();
+    path.pop();
+    path
 }
 
-fn validate_docker_id(id: &str, cmd: &Command) -> Result<()> {
+fn run(cmd: &mut StdCommand, stdin: Option<&str>) -> Result<Output> {
+    let mut temp_file;
+    if let Some(stdin) = stdin {
+        temp_file = tempfile::tempfile()?;
+        temp_file.write_all(stdin.as_bytes())?;
+        temp_file.seek(SeekFrom::Start(0))?;
+        cmd.stdin(Stdio::from(temp_file));
+    }
+
+    let output = cmd.output()?;
+
+    let mut stderr = String::from_utf8(output.stderr)?;
+    let mut stdout = String::from_utf8(output.stdout)?;
+
+    // it's a common pitfall to forget to remove the trailing '\n' so remove it here
+    if stderr.ends_with('\n') {
+        stderr.pop();
+    }
+
+    if stdout.ends_with('\n') {
+        stdout.pop();
+    }
+
+    Ok(Output {
+        status: output.status,
+        stderr,
+        stdout,
+    })
+}
+
+fn validate_docker_id(id: &str, cmd: &StdCommand) -> Result<()> {
     if id.chars().any(|c| !c.is_ascii_hexdigit()) {
         return Err(
             format!("`{cmd:?}` return what appears to be an invalid docker id: {id}").into(),
@@ -127,19 +162,19 @@ mod tests {
     #[test]
     #[ignore = "slow"]
     fn eventually_removes_container_on_drop() -> Result<()> {
-        let mut check_cmd = Command::new("docker");
+        let mut check_cmd = StdCommand::new("docker");
         let docker = Container::new(IMAGE)?;
         check_cmd.args(["ps", "--all", "--quiet", "--filter"]);
         check_cmd.arg(format!("id={}", docker.id));
 
-        let matches = helpers::stdout(&mut check_cmd, None)?;
+        let matches = run(&mut check_cmd, None)?.stdout()?;
         assert_eq!(1, matches.lines().count());
         drop(docker);
 
         // wait for a bit until `stop` and `--rm` have done their work
         thread::sleep(Duration::from_secs(15));
 
-        let matches = helpers::stdout(&mut check_cmd, None)?;
+        let matches = run(&mut check_cmd, None)?.stdout()?;
         assert_eq!(0, matches.lines().count());
 
         Ok(())
@@ -148,52 +183,66 @@ mod tests {
     #[test]
     fn exec_as_root_works() -> Result<()> {
         let docker = Container::new(IMAGE)?;
-        let output = docker.exec(&["true"], As::Root, None)?;
-        assert!(output.status.success());
-        let output = docker.exec(&["false"], As::Root, None)?;
+
+        docker.exec(&Command::new("true"))?.assert_success()?;
+
+        let output = docker.exec(&Command::new("false"))?;
         assert_eq!(Some(1), output.status.code());
+
         Ok(())
     }
 
     #[test]
     fn exec_as_user_named_root_works() -> Result<()> {
         let docker = Container::new(IMAGE)?;
-        let output = docker.exec(&["true"], As::User { name: "root" }, None)?;
-        assert!(output.status.success());
-        Ok(())
+
+        docker
+            .exec(Command::new("true").as_user("root"))?
+            .assert_success()
     }
 
     #[test]
     fn exec_as_non_root_user_works() -> Result<()> {
-        let docker = Container::new(IMAGE)?;
         let username = "ferris";
-        let output = docker.exec(&["useradd", username], As::Root, None)?;
-        assert!(output.status.success());
-        let output = docker.exec(&["true"], As::User { name: username }, None)?;
-        assert!(output.status.success());
-        Ok(())
+
+        let docker = Container::new(IMAGE)?;
+
+        docker
+            .exec(Command::new("useradd").arg(username))?
+            .assert_success()?;
+
+        docker
+            .exec(Command::new("true").as_user(username))?
+            .assert_success()
     }
 
     #[test]
     fn cp_works() -> Result<()> {
-        let docker = Container::new(IMAGE)?;
+        let path = "/tmp/file";
         let expected = "Hello, world!";
-        docker.cp("/tmp/file", expected)?;
-        let actual = docker.stdout(&["cat", "/tmp/file"], As::Root, None)?;
+
+        let docker = Container::new(IMAGE)?;
+
+        docker.cp(path, expected)?;
+
+        let actual = docker.exec(Command::new("cat").arg(path))?.stdout()?;
         assert_eq!(expected, actual);
+
         Ok(())
     }
 
     #[test]
     fn stdin_works() -> Result<()> {
         let expected = "Hello, root!";
+        let filename = "greeting";
 
         let docker = Container::new(IMAGE)?;
 
-        docker.stdout(&["tee", "greeting"], As::Root, Some(expected))?;
-
-        let actual = docker.stdout(&["cat", "greeting"], As::Root, None)?;
+        docker
+            .exec(Command::new("tee").arg(filename).stdin(expected))?
+            .assert_success()?;
 
+        let actual = docker.exec(Command::new("cat").arg(filename))?.stdout()?;
         assert_eq!(expected, actual);
 
         Ok(())
diff --git a/test-framework/sudo-test/src/docker/command.rs b/test-framework/sudo-test/src/docker/command.rs
new file mode 100644
index 00000000..5f85dbea
--- /dev/null
+++ b/test-framework/sudo-test/src/docker/command.rs
@@ -0,0 +1,108 @@
+use std::process::ExitStatus;
+
+use crate::Result;
+
+/// command builder
+pub struct Command {
+    args: Vec<String>,
+    user: Option<String>,
+    stdin: Option<String>,
+}
+
+impl Command {
+    /// constructs a new `Command` for launching a program at path `program`
+    pub fn new(program: impl AsRef<str>) -> Self {
+        Self {
+            args: vec![program.as_ref().to_string()],
+            user: None,
+            stdin: None,
+        }
+    }
+
+    /// adds an argument to pass to the program
+    pub fn arg(&mut self, arg: impl AsRef<str>) -> &mut Self {
+        self.args.push(arg.as_ref().to_string());
+        self
+    }
+
+    /// adds multiple arguments to pass to the program
+    pub fn args(&mut self, args: impl IntoIterator<Item = impl AsRef<str>>) -> &mut Self {
+        args.into_iter().for_each(|arg| {
+            self.arg(arg);
+        });
+        self
+    }
+
+    /// the user to run the program as
+    ///
+    /// NOTE if this method is not used the default is to run the program as `root`
+    pub fn as_user(&mut self, username: impl AsRef<str>) -> &mut Self {
+        self.user = Some(username.as_ref().to_string());
+        self
+    }
+
+    /// input to feed into the program via stdin
+    ///
+    /// NOTE this overrides the last `stdin` call
+    pub fn stdin(&mut self, input: impl AsRef<str>) -> &mut Self {
+        self.stdin = Some(input.as_ref().to_string());
+        self
+    }
+
+    pub(super) fn get_args(&self) -> &[String] {
+        &self.args
+    }
+
+    pub(super) fn get_stdin(&self) -> Option<&str> {
+        self.stdin.as_deref()
+    }
+
+    pub(crate) fn get_user(&self) -> Option<&str> {
+        self.user.as_deref()
+    }
+}
+
+/// the output of a finished `Command`
+#[must_use]
+pub struct Output {
+    pub(super) status: ExitStatus,
+    pub(super) stderr: String,
+    pub(super) stdout: String,
+}
+
+impl Output {
+    /// the status (exit code) of the finished `Command`
+    pub fn status(&self) -> ExitStatus {
+        self.status
+    }
+
+    /// the collected standard error of the finished `Command`
+    pub fn stderr(&self) -> &str {
+        &self.stderr
+    }
+
+    /// helper method that asserts that the program exited successfully
+    ///
+    /// if it didn't the error value will include the exit code and the program's stderr
+    pub fn assert_success(&self) -> Result<()> {
+        if self.status.success() {
+            Ok(())
+        } else {
+            let exit_code = if let Some(code) = self.status.code() {
+                format!("exit code {}", code)
+            } else {
+                "a non-zero exit code".to_string()
+            };
+
+            Err(format!("program failed with {exit_code}. stderr:\n{}", self.stderr).into())
+        }
+    }
+
+    /// the collected standard output of the finished `Command`
+    ///
+    /// NOTE this method implicitly runs `assert_success` before granting access to `stdout`
+    pub fn stdout(self) -> Result<String> {
+        self.assert_success()?;
+        Ok(self.stdout)
+    }
+}
diff --git a/test-framework/sudo-test/src/helpers.rs b/test-framework/sudo-test/src/helpers.rs
deleted file mode 100644
index 3d40033d..00000000
--- a/test-framework/sudo-test/src/helpers.rs
+++ /dev/null
@@ -1,52 +0,0 @@
-use std::{
-    io::{Seek, Write},
-    process::{Command, Stdio},
-};
-
-use crate::{docker::ExecOutput, Result};
-
-pub fn run(cmd: &mut Command, stdin: Option<&str>) -> Result<ExecOutput> {
-    let mut temp_file;
-    if let Some(stdin) = stdin {
-        temp_file = tempfile::tempfile()?;
-        temp_file.write_all(stdin.as_bytes())?;
-        temp_file.seek(std::io::SeekFrom::Start(0))?;
-        cmd.stdin(Stdio::from(temp_file));
-    }
-
-    let output = cmd.output()?;
-
-    let mut stderr = String::from_utf8(output.stderr)?;
-    let mut stdout = String::from_utf8(output.stdout)?;
-
-    // it's a common pitfall to forget to remove the trailing '\n' so remove it here
-    if stderr.ends_with('\n') {
-        stderr.pop();
-    }
-
-    if stdout.ends_with('\n') {
-        stdout.pop();
-    }
-
-    Ok(ExecOutput {
-        status: output.status,
-        stderr,
-        stdout,
-    })
-}
-
-pub fn stdout(cmd: &mut Command, stdin: Option<&str>) -> Result<String> {
-    let output = run(cmd, stdin)?;
-
-    if !output.status.success() {
-        let reason = if let Some(code) = output.status.code() {
-            format!("exit code {code}")
-        } else {
-            "a non-zero exit code".to_string()
-        };
-
-        return Err(format!("`{cmd:?}` exited with {reason}. stderr:\n{}", output.stderr).into());
-    }
-
-    Ok(output.stdout)
-}
diff --git a/test-framework/sudo-test/src/lib.rs b/test-framework/sudo-test/src/lib.rs
index 894347ee..7844f018 100644
--- a/test-framework/sudo-test/src/lib.rs
+++ b/test-framework/sudo-test/src/lib.rs
@@ -1,700 +1,679 @@
+//! sudo-rs test framework
+
+#![deny(missing_docs)]
+#![deny(unsafe_code)]
+
 use std::{
     collections::{HashMap, HashSet},
     env,
-    fs::File,
-    path::{Path, PathBuf},
-    process::{Command, Stdio},
+    path::Path,
     sync::Once,
 };
 
-use bimap::BiMap;
 use docker::Container;
 
-pub use docker::{As, ExecOutput};
+pub use docker::{Command, Output};
 
-pub type Error = Box<dyn std::error::Error>;
-pub type Result<T> = core::result::Result<T, Error>;
+type Error = Box<dyn std::error::Error>;
+type Result<T> = core::result::Result<T, Error>;
 
 mod docker;
-mod helpers;
 
 const BASE_IMAGE: &str = env!("CARGO_CRATE_NAME");
 
+/// are we testing the original sudo?
 pub fn is_original_sudo() -> bool {
     matches!(SudoUnderTest::from_env(), Ok(SudoUnderTest::Theirs))
 }
 
-/// test environment builder
-#[derive(Default)]
-pub struct EnvBuilder {
-    pam_d_sudo: Option<String>,
-    sudoers: String,
-    sudoers_chmod: Option<String>,
-    sudoers_chown: Option<String>,
-    text_files: HashMap<String, TextFile>,
-    username_to_groups: HashMap<String, HashSet<String>>,
-    username_to_passwords: HashMap<String, String>,
-    user_id_to_username: BiMap<u32, String>,
-    group_id_to_groupname: BiMap<u32, String>,
+enum SudoUnderTest {
+    Ours,
+    Theirs,
 }
 
-struct TextFile {
-    contents: String,
-    chmod: String,
-    chown: String,
+impl SudoUnderTest {
+    fn from_env() -> Result<Self> {
+        if let Ok(under_test) = env::var("SUDO_UNDER_TEST") {
+            if under_test == "ours" {
+                Ok(Self::Ours)
+            } else if under_test == "theirs" {
+                Ok(Self::Theirs)
+            } else {
+                Err("variable SUDO_UNDER_TEST must be set to one of: ours, theirs".into())
+            }
+        } else {
+            Ok(Self::Theirs)
+        }
+    }
 }
 
-impl EnvBuilder {
-    /// add an user to the environment
-    ///
-    /// NOTE users will have a home directory at `/home/$username` and will be part of the group
-    /// `$username`
-    pub fn user(&mut self, username: &str, groups: &[&str]) -> &mut Self {
-        assert!(
-            !self.username_to_groups.contains_key(username),
-            "user `{username}` declared more than once"
-        );
+type AbsolutePath = String;
+type Groupname = String;
+type Username = String;
 
-        let mut set = HashSet::new();
-        for group in groups {
+/// test environment        
+pub struct Env {
+    container: Container,
+    users: HashSet<Username>,
+}
+
+/// creates a new test environment builder that contains the specified `/etc/sudoers` file
+#[allow(non_snake_case)]
+pub fn Env(sudoers: impl Into<TextFile>) -> EnvBuilder {
+    let mut builder = EnvBuilder::default();
+    let mut sudoers = sudoers.into();
+    // HACK append newline to work around memorysafety/sudo-rs#102
+    sudoers.contents.push('\n');
+    builder.file("/etc/sudoers", sudoers);
+    builder
+}
+
+impl Command {
+    /// executes the command in the specified test environment
+    pub fn exec(&self, env: &Env) -> Result<Output> {
+        if let Some(username) = self.get_user() {
             assert!(
-                !set.contains(*group),
-                "group `{group}` declared more than once"
+                env.users.contains(username),
+                "tried to exec as non-existent user: {username}"
             );
-
-            set.insert(group.to_string());
         }
 
-        assert!(!set.contains(username), "do not list $username in $groups");
-
-        self.username_to_groups.insert(username.to_string(), set);
-
-        self
+        env.container.exec(self)
     }
+}
 
-    /// assign a known user ID to a user instead of relying on runtime allocation
-    pub fn user_id(&mut self, username: &str, user_id: u32) -> &mut Self {
-        // > 1000 because we want to avoid collisions with system user ids
-        // 100 should be enough for testing purposes
-        assert!(
-            (1000..=1100).contains(&user_id),
-            "user ID range is limited to 1000-1100"
-        );
+/// test environment builder
+#[derive(Default)]
+pub struct EnvBuilder {
+    files: HashMap<AbsolutePath, TextFile>,
+    groups: HashMap<Groupname, Group>,
+    users: HashMap<Username, User>,
+}
 
+impl EnvBuilder {
+    /// adds a `file` to the test environment at the specified `path`
+    ///
+    /// # Panics
+    ///
+    /// - if `path` is not an absolute path
+    /// - if `path` has previously been declared
+    pub fn file(&mut self, path: impl AsRef<str>, file: impl Into<TextFile>) -> &mut Self {
+        let path = path.as_ref();
+        assert!(Path::new(path).is_absolute(), "path must be absolute");
         assert!(
-            !self.user_id_to_username.contains_left(&user_id),
-            "user ID {user_id} has already been assigned"
+            !self.files.contains_key(path),
+            "file at {path} has already been declared"
         );
 
-        self.user_id_to_username
-            .insert(user_id, username.to_string());
+        self.files.insert(path.to_string(), file.into());
 
         self
     }
 
-    /// assign a known group ID to a group instead of relying on runtime allocation
-    pub fn group_id(&mut self, groupname: &str, group_id: u32) -> &mut Self {
-        // > 1000 because we want to avoid collisions with system group ids
-        // 100 should be enough for testing purposes
-        assert!(
-            (1000..=1100).contains(&group_id),
-            "group ID range is limited to 1000-1100"
-        );
-
+    /// adds the specified `group` to the test environment
+    ///
+    /// # Panics
+    ///
+    /// - if the `group` has previously been declared
+    pub fn group(&mut self, group: impl Into<Group>) -> &mut Self {
+        let group = group.into();
+        let groupname = &group.name;
         assert!(
-            !self.group_id_to_groupname.contains_left(&group_id),
-            "group ID {group_id} has already been assigned"
+            !self.groups.contains_key(groupname),
+            "group {} has already been declared",
+            groupname
         );
-
-        self.group_id_to_groupname
-            .insert(group_id, groupname.to_string());
+        self.groups.insert(groupname.to_string(), group);
 
         self
     }
 
-    pub fn user_password(&mut self, username: &str, password: &str) -> &mut Self {
-        self.username_to_passwords
-            .insert(username.to_string(), password.to_string());
-        self
-    }
-
-    /// appends content to the `/etc/sudoers` file
+    /// adds the specified `user` to the test environment
     ///
-    /// NOTE that if this method is not called `/etc/sudoers` will be empty
-    pub fn sudoers(&mut self, sudoers: &str) -> &mut Self {
-        self.sudoers.push_str(sudoers);
-        self.sudoers.push('\n');
-        self
-    }
-
-    const DEFAULT_PAM_D_SUDO: &str = r#"#%PAM-1.0
-
-@include common-auth
-@include common-account
-@include common-session-noninteractive"#;
-
-    /// overwrites the contents of `/etc/pam.d/sudo`
-    ///
-    /// if this method is not called the contents of `/etc/pam.d/sudo` will match the contents of
-    /// the file provided by the `sudo` package
-    pub fn pam_d_sudo(&mut self, pam_conf: &str) -> &mut Self {
-        let pam_d_sudo = self.pam_d_sudo.get_or_insert_with(String::new);
-        pam_d_sudo.push_str(pam_conf);
-        pam_d_sudo.push('\n');
-        self
-    }
-
-    const DEFAULT_SUDOERS_CHOWN: &str = "root:root";
-
-    /// NOTE if unset, defaults to "root:root"
-    pub fn sudoers_chown(&mut self, chown: &str) -> &mut Self {
-        assert!(self.sudoers_chown.is_none(), "sudoers_chown already set");
-        self.sudoers_chown = Some(chown.to_string());
-        self
-    }
-
-    const DEFAULT_SUDOERS_CHMOD: &str = "440";
-
-    /// NOTE if unset, defaults to "440"
-    pub fn sudoers_chmod(&mut self, chmod: &str) -> &mut Self {
-        assert!(self.sudoers_chown.is_none(), "sudoers_chmod already set");
-        self.sudoers_chmod = Some(chmod.to_string());
-        self
-    }
-
-    /// Creates a file at `path` with specified `contents` and permissions
+    /// # Panics
     ///
-    /// NOTE `path` must be absolute
-    pub fn text_file(&mut self, path: &str, chown: &str, chmod: &str, contents: &str) -> &mut Self {
-        assert!(Path::new(path).is_absolute(), "path must be absolute");
-
+    /// - if the `user` has previously been declared
+    pub fn user(&mut self, user: impl Into<User>) -> &mut Self {
+        let user = user.into();
+        let username = &user.name;
         assert!(
-            !self.text_files.contains_key(path),
-            "text file has already been declared"
-        );
-
-        self.text_files.insert(
-            path.to_string(),
-            TextFile {
-                contents: contents.to_string(),
-                chmod: chmod.to_string(),
-                chown: chown.to_string(),
-            },
+            !self.users.contains_key(username),
+            "user {} has already been declared",
+            username
         );
+        self.users.insert(username.to_string(), user);
 
         self
     }
 
+    /// builds the test environment
+    ///
+    /// # Panics
+    ///
+    /// - if any specified `user` already exists in the base image
+    /// - if any specified `group` already exists in the base image
+    /// - if any specified `user` tries to use a user ID that already exists in the base image
+    /// - if any specified `group` tries to use a group ID that already exists in the base image
     pub fn build(&self) -> Result<Env> {
         static ONCE: Once = Once::new();
         ONCE.call_once(|| {
-            build_base_image().expect("fatal error: could not build the base Docker image")
+            docker::build_base_image().expect("fatal error: could not build the base Docker image")
         });
 
         let container = Container::new(BASE_IMAGE)?;
 
-        let mut users = get_users(&container)?;
+        let (mut usernames, user_ids) = getent_passwd(&container)?;
 
-        for username in &users {
+        for new_user in self.users.values() {
             assert!(
-                !self.user_id_to_username.contains_right(username),
-                "cannot override the user ID of system user {username}"
+                !usernames.contains(&new_user.name),
+                "user {} already exists in base image",
+                new_user.name
             );
-        }
 
-        for username in self.user_id_to_username.right_values() {
-            assert!(
-                self.username_to_groups.contains_key(username),
-                "cannot assign user ID to non-existent user {username}"
-            );
+            if let Some(user_id) = new_user.id {
+                assert!(
+                    !user_ids.contains(&user_id),
+                    "user ID {user_id} already exists in base image"
+                );
+            }
         }
 
-        let mut groups = get_groups(&container)?;
-
-        for groupname in &groups {
-            assert!(
-                !self.group_id_to_groupname.contains_right(groupname),
-                "cannot override the group ID of system group {groupname}"
-            );
-        }
+        let (groupnames, group_ids) = getent_group(&container)?;
 
-        for groupname in self.group_id_to_groupname.right_values() {
+        for new_group in self.groups.values() {
             assert!(
-                !self.username_to_groups.contains_key(groupname),
-                "cannot assign group ID to the implicit user group {groupname}"
+                !groupnames.contains(&new_group.name),
+                "group {} already exists in base image",
+                new_group.name
             );
 
-            assert!(
-                self.username_to_groups
-                    .values()
-                    .any(|groups| groups.contains(groupname)),
-                "cannot assign group ID to non-existent group {groupname}"
-            );
+            if let Some(group_id) = new_group.id {
+                assert!(
+                    !group_ids.contains(&group_id),
+                    "group ID {group_id} already exists in base image"
+                );
+            }
         }
 
-        // normally this would be done with `visudo` as that uses a file lock but as it's guaranteed
-        // that no user is active in the container at this point doing it like this is fine
-        let path = "/etc/sudoers";
-        container.cp(path, &self.sudoers)?;
-
-        container.stdout(
-            &[
-                "chown",
-                self.sudoers_chown
-                    .as_deref()
-                    .unwrap_or(Self::DEFAULT_SUDOERS_CHOWN),
-                path,
-            ],
-            As::Root,
-            None,
-        )?;
-
-        container.stdout(
-            &[
-                "chmod",
-                self.sudoers_chmod
-                    .as_deref()
-                    .unwrap_or(Self::DEFAULT_SUDOERS_CHMOD),
-                path,
-            ],
-            As::Root,
-            None,
-        )?;
-
-        let path = "/etc/pam.d/sudo";
-        container.cp(
-            path,
-            self.pam_d_sudo
-                .as_deref()
-                .unwrap_or(Self::DEFAULT_PAM_D_SUDO),
-        )?;
-
-        container.stdout(&["chown", "root:root", path], As::Root, None)?;
-        container.stdout(&["chmod", "644", path], As::Root, None)?;
-
         // create groups with known IDs first to avoid collisions ..
-        for (group_id, groupname) in &self.group_id_to_groupname {
-            container.stdout(
-                &["groupadd", "-g", &group_id.to_string(), groupname],
-                As::Root,
-                None,
-            )?;
-
-            groups.insert(groupname.to_string());
+        for group in self.groups.values().filter(|group| group.id.is_some()) {
+            group.create(&container)?;
         }
 
         // .. with groups that get assigned IDs dynamically
-        for user_groups in self.username_to_groups.values() {
-            for user_group in user_groups {
-                if !groups.contains(user_group) {
-                    container.stdout(&["groupadd", user_group], As::Root, None)?;
-
-                    groups.insert(user_group.to_string());
-                }
-            }
+        for group in self.groups.values().filter(|group| group.id.is_none()) {
+            group.create(&container)?;
         }
 
         // create users with known IDs first to avoid collisions ..
-        for (user_id, username) in &self.user_id_to_username {
-            //
-            let user = User {
-                name: username,
-                id: Some(*user_id),
-                groups: self
-                    .username_to_groups
-                    .get(username)
-                    .cloned()
-                    .unwrap_or_default(),
-            };
-
+        for user in self.users.values().filter(|user| user.id.is_some()) {
             user.create(&container)?;
-
-            users.insert(username.to_string());
-            groups.insert(username.to_string());
+            usernames.insert(user.name.to_string());
         }
 
         // .. with users that get assigned IDs dynamically
-        for (username, user_groups) in &self.username_to_groups {
-            if users.contains(username) {
-                continue;
-            }
-
-            let user = User {
-                name: username,
-                id: None,
-                groups: user_groups.clone(),
-            };
-
+        for user in self.users.values().filter(|user| user.id.is_none()) {
             user.create(&container)?;
-
-            users.insert(username.to_string());
-            groups.insert(username.to_string());
+            usernames.insert(user.name.to_string());
         }
 
-        for (username, password) in &self.username_to_passwords {
-            assert!(
-                users.contains(username),
-                "cannot assign password to non-existing user: {username}"
-            );
-
-            let stdin = format!("{username}:{password}");
-            container.stdout(&["chpasswd"], As::Root, Some(&stdin))?;
+        for (path, file) in &self.files {
+            file.create(path, &container)?;
         }
 
-        for (path, text_file) in &self.text_files {
-            container.cp(path, &text_file.contents)?;
+        Ok(Env {
+            container,
+            users: usernames,
+        })
+    }
+}
 
-            container.stdout(&["chown", &text_file.chown, path], As::Root, None)?;
-            container.stdout(&["chmod", &text_file.chmod, path], As::Root, None)?;
-        }
+/// a user
+pub struct User {
+    name: Username,
 
-        Ok(Env { container, users })
-    }
+    groups: HashSet<Groupname>,
+    id: Option<u32>,
+    password: Option<String>,
 }
 
-enum SudoUnderTest {
-    Ours,
-    Theirs,
+/// creates a new user with the specified `name`
+#[allow(non_snake_case)]
+pub fn User(name: impl AsRef<str>) -> User {
+    name.as_ref().into()
 }
 
-impl SudoUnderTest {
-    fn from_env() -> Result<Self> {
-        if let Ok(under_test) = env::var("SUDO_UNDER_TEST") {
-            if under_test == "ours" {
-                Ok(Self::Ours)
-            } else if under_test == "theirs" {
-                Ok(Self::Theirs)
-            } else {
-                Err("variable SUDO_UNDER_TEST must be set to one of: ours, theirs".into())
-            }
-        } else {
-            Ok(Self::Theirs)
+impl User {
+    /// assigns this user to the specified `group`
+    pub fn group(mut self, group: impl AsRef<str>) -> Self {
+        let groupname = group.as_ref();
+        assert!(
+            !self.groups.contains(groupname),
+            "user {} has already been assigned to {groupname}",
+            self.name
+        );
+
+        self.groups.insert(groupname.to_string());
+
+        self
+    }
+
+    /// assigns this user to all the specified `groups`
+    pub fn groups(mut self, groups: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
+        for group in groups {
+            self = self.group(group);
         }
+        self
     }
-}
 
-fn build_base_image() -> Result<()> {
-    let repo_root = repo_root();
-    let mut cmd = Command::new("docker");
+    /// assigns the specified user `id` to this user
+    ///
+    /// if not specified, the user will get an automatically allocated ID
+    pub fn id(mut self, id: u32) -> Self {
+        self.id = Some(id);
+        self
+    }
 
-    cmd.args(["build", "-t", BASE_IMAGE]);
+    /// assigns the specified `password` to this user
+    ///
+    /// if not specified, the user will have no password
+    pub fn password(mut self, password: impl AsRef<str>) -> Self {
+        self.password = Some(password.as_ref().to_string());
+        self
+    }
 
-    match SudoUnderTest::from_env()? {
-        SudoUnderTest::Ours => {
-            // needed for dockerfile-specific dockerignore (e.g. `Dockerfile.dockerignore`) support
-            cmd.env("DOCKER_BUILDKIT", "1");
+    fn create(&self, container: &Container) -> Result<()> {
+        let mut useradd = Command::new("useradd");
+        useradd.arg("--no-user-group");
+        if let Some(id) = self.id {
+            useradd.arg("--uid").arg(id.to_string());
+        }
+        if !self.groups.is_empty() {
+            let group_list = self.groups.iter().cloned().collect::<Vec<_>>().join(",");
+            useradd.arg("--groups").arg(group_list);
+        }
+        useradd.arg(&self.name);
+        container.exec(&useradd)?.assert_success()?;
 
-            cmd.current_dir(repo_root);
-            cmd.args(["-f", "test-framework/sudo-test/src/ours.Dockerfile", "."]);
+        if let Some(password) = &self.password {
+            container
+                .exec(Command::new("chpasswd").stdin(format!("{}:{password}", self.name)))?
+                .assert_success()?;
         }
 
-        SudoUnderTest::Theirs => {
-            // pass Dockerfile via stdin to not provide the repository as a build context
-            let f = File::open(repo_root.join("test-framework/sudo-test/src/theirs.Dockerfile"))?;
-            cmd.arg("-").stdin(Stdio::from(f));
+        Ok(())
+    }
+}
+
+impl From<String> for User {
+    fn from(name: String) -> Self {
+        assert!(!name.is_empty(), "user name cannot be an empty string");
+
+        Self {
+            name,
+            groups: HashSet::new(),
+            id: None,
+            password: None,
         }
     }
+}
+
+impl From<&'_ str> for User {
+    fn from(name: &'_ str) -> Self {
+        name.to_string().into()
+    }
+}
 
-    helpers::stdout(&mut cmd, None)?;
+/// a group
+pub struct Group {
+    name: Groupname,
 
-    Ok(())
+    id: Option<u32>,
 }
 
-fn repo_root() -> PathBuf {
-    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
-    path.pop();
-    path.pop();
-    path
+/// creates a group with the specified `name`
+#[allow(non_snake_case)]
+pub fn Group(name: impl AsRef<str>) -> Group {
+    name.as_ref().into()
 }
 
-fn get_groups(container: &Container) -> Result<HashSet<String>> {
-    let stdout = container.stdout(&["getent", "group"], As::Root, None)?;
-    let mut groups = HashSet::new();
-    for line in stdout.lines() {
-        if let Some((name, _rest)) = line.split_once(':') {
-            groups.insert(name.to_string());
+impl Group {
+    /// assigns the specified group `id` to this group
+    ///
+    /// if not specified, the group will get an automatically allocated ID
+    pub fn id(mut self, id: u32) -> Self {
+        self.id = Some(id);
+        self
+    }
+
+    fn create(&self, container: &Container) -> Result<()> {
+        let mut groupadd = Command::new("groupadd");
+        if let Some(id) = self.id {
+            groupadd.arg("--gid");
+            groupadd.arg(id.to_string());
         }
+        groupadd.arg(&self.name);
+        container.exec(&groupadd)?.assert_success()
     }
+}
+
+impl From<String> for Group {
+    fn from(name: String) -> Self {
+        assert!(!name.is_empty(), "group name cannot be an empty string");
 
-    Ok(groups)
+        Self { name, id: None }
+    }
 }
 
-fn get_users(container: &Container) -> Result<HashSet<String>> {
-    let stdout = container.stdout(&["getent", "passwd"], As::Root, None)?;
-    let mut users = HashSet::new();
-    for line in stdout.lines() {
-        if let Some((name, _rest)) = line.split_once(':') {
-            users.insert(name.to_string());
-        }
+impl From<&'_ str> for Group {
+    fn from(name: &'_ str) -> Self {
+        name.to_string().into()
     }
+}
 
-    Ok(users)
+/// a text file
+pub struct TextFile {
+    contents: String,
+
+    chmod: String,
+    chown: String,
 }
 
-/// test environment
-pub struct Env {
-    container: Container,
-    users: HashSet<String>,
+/// creates a text file with the specified `contents`
+#[allow(non_snake_case)]
+pub fn TextFile(contents: impl AsRef<str>) -> TextFile {
+    contents.as_ref().into()
 }
 
-impl Env {
-    pub fn exec(
-        &self,
-        cmd: &[impl AsRef<str>],
-        user: As,
-        stdin: Option<&str>,
-    ) -> Result<ExecOutput> {
-        if let As::User { name } = user {
-            assert!(
-                self.users.contains(name),
-                "tried to exec as non-existing user"
-            );
-        }
+impl TextFile {
+    const DEFAULT_CHMOD: &str = "000";
+    const DEFAULT_CHOWN: &str = "root:root";
+
+    /// chmod string to apply to the file
+    ///
+    /// if not specified, the default is "000"
+    pub fn chmod(mut self, chmod: impl AsRef<str>) -> Self {
+        self.chmod = chmod.as_ref().to_string();
+        self
+    }
 
-        self.container.exec(cmd, user, stdin)
+    /// chown string to apply to the file
+    ///
+    /// if not specified, the default is "root:root"
+    pub fn chown(mut self, chown: impl AsRef<str>) -> Self {
+        self.chown = chown.as_ref().to_string();
+        self
     }
 
-    /// utility function on top of `exec` that checks that `cmd` executed successfully and returns
-    /// its stdout
-    pub fn stdout(&self, cmd: &[impl AsRef<str>], user: As, stdin: Option<&str>) -> Result<String> {
-        if let As::User { name } = user {
-            assert!(
-                self.users.contains(name),
-                "tried to exec as non-existing user"
-            );
-        }
+    fn create(&self, path: &str, container: &Container) -> Result<()> {
+        container.cp(path, &self.contents)?;
 
-        self.container.stdout(cmd, user, stdin)
+        container
+            .exec(Command::new("chown").args([&self.chown, path]))?
+            .assert_success()?;
+        container
+            .exec(Command::new("chmod").args([&self.chmod, path]))?
+            .assert_success()
     }
 }
 
-struct User<'a> {
-    name: &'a str,
-    id: Option<u32>,
-    groups: HashSet<String>,
+impl From<String> for TextFile {
+    fn from(contents: String) -> Self {
+        Self {
+            contents,
+            chmod: Self::DEFAULT_CHMOD.to_string(),
+            chown: Self::DEFAULT_CHOWN.to_string(),
+        }
+    }
 }
 
-impl User<'_> {
-    fn create(&self, container: &Container) -> Result<()> {
-        let mut cmd = vec!["useradd", "-m"];
-
-        let id_as_string;
-        if let Some(id) = self.id {
-            id_as_string = id.to_string();
-            cmd.extend_from_slice(&["-u", &id_as_string]);
-        }
+impl From<&'_ str> for TextFile {
+    fn from(contents: &'_ str) -> Self {
+        contents.to_string().into()
+    }
+}
 
-        let group_list;
-        if !self.groups.is_empty() {
-            group_list = self.groups.iter().cloned().collect::<Vec<_>>().join(",");
-            cmd.extend_from_slice(&["-G", &group_list]);
+fn getent_group(container: &Container) -> Result<(HashSet<Groupname>, HashSet<u32>)> {
+    let stdout = container
+        .exec(Command::new("getent").arg("group"))?
+        .stdout()?;
+    let mut groupnames = HashSet::new();
+    let mut group_ids = HashSet::new();
+    for line in stdout.lines() {
+        let mut parts = line.split(':');
+        match (parts.next(), parts.next(), parts.next()) {
+            (Some(name), Some(_), Some(id)) => {
+                groupnames.insert(name.to_string());
+                group_ids.insert(id.parse()?);
+            }
+            _ => {
+                return Err(format!("invalid `getent group` syntax: {line}").into());
+            }
         }
+    }
 
-        cmd.push(self.name);
-
-        container.stdout(&cmd, As::Root, None)?;
+    Ok((groupnames, group_ids))
+}
 
-        Ok(())
+fn getent_passwd(container: &Container) -> Result<(HashSet<Username>, HashSet<u32>)> {
+    let stdout = container
+        .exec(Command::new("getent").arg("passwd"))?
+        .stdout()?;
+    let mut usernames = HashSet::new();
+    let mut user_ids = HashSet::new();
+    for line in stdout.lines() {
+        let mut parts = line.split(':');
+        match (parts.next(), parts.next(), parts.next()) {
+            (Some(name), Some(_), Some(id)) => {
+                usernames.insert(name.to_string());
+                user_ids.insert(id.parse()?);
+            }
+            _ => {
+                return Err(format!("invalid `getent passwd` syntax: {line}").into());
+            }
+        }
     }
+
+    Ok((usernames, user_ids))
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    const USERNAME: &str = "ferris";
+    const GROUPNAME: &str = "rustaceans";
+
     #[test]
     fn group_creation_works() -> Result<()> {
-        let username = "ferris";
-        let groupname = "rustaceans";
-        let env = EnvBuilder::default().user(username, &[groupname]).build()?;
+        let env = EnvBuilder::default().group(GROUPNAME).build()?;
 
-        let groups = get_groups(&env.container)?;
-        assert!(groups.contains(groupname));
+        let groupnames = getent_group(&env.container)?.0;
+        assert!(groupnames.contains(GROUPNAME));
 
         Ok(())
     }
 
     #[test]
     fn user_creation_works() -> Result<()> {
-        let new_user = "ferris";
-        let env = EnvBuilder::default().user(new_user, &[]).build()?;
+        let env = EnvBuilder::default().user(USERNAME).build()?;
 
-        let users = get_users(&env.container)?;
-        assert!(users.contains(new_user));
+        let usernames = getent_passwd(&env.container)?.0;
+        assert!(usernames.contains(USERNAME));
 
         Ok(())
     }
 
     #[test]
-    fn created_user_has_a_home() -> Result<()> {
-        let new_user = "ferris";
-        let env = EnvBuilder::default().user(new_user, &[]).build()?;
-
-        let output = env.exec(&["sh", "-c", "[ -d /home/ferris ]"], As::Root, None)?;
-        assert!(output.status.success());
-
+    fn no_implicit_home_creation() -> Result<()> {
+        let env = EnvBuilder::default().user(USERNAME).build()?;
+
+        let output = Command::new("sh")
+            .arg("-c")
+            .arg(format!("[ -d /home/{USERNAME} ]"))
+            .exec(&env)?;
+        assert!(!output.status().success());
         Ok(())
     }
 
     #[test]
-    fn created_user_belongs_to_group_named_after_themselves() -> Result<()> {
-        let new_user = "ferris";
-        let env = EnvBuilder::default().user(new_user, &[]).build()?;
+    fn no_implicit_user_group_creation() -> Result<()> {
+        let env = EnvBuilder::default().user(USERNAME).build()?;
 
-        let output = env.exec(&["groups"], As::User { name: new_user }, None)?;
-        assert!(output.status.success());
-
-        let groups = output.stdout.split(' ').collect::<HashSet<_>>();
-        assert!(groups.contains(new_user));
+        let stdout = Command::new("groups")
+            .as_user(USERNAME)
+            .exec(&env)?
+            .stdout()?;
+        let groups = stdout.split(' ').collect::<HashSet<_>>();
+        assert!(!groups.contains(USERNAME));
 
         Ok(())
     }
 
     #[test]
-    fn creating_user_part_of_existing_group_works() -> Result<()> {
-        let user = "ferris";
-        let group = "users";
-        let env = EnvBuilder::default().user(user, &[group]).build()?;
+    fn no_password_by_default() -> Result<()> {
+        let env = EnvBuilder::default().user(USERNAME).build()?;
 
-        let output = env.exec(&["groups"], As::User { name: user }, None)?;
-        assert!(output.status.success());
+        let stdout = Command::new("passwd")
+            .args(["--status", USERNAME])
+            .exec(&env)?
+            .stdout()?;
 
-        let user_groups = output.stdout.split(' ').collect::<HashSet<_>>();
-        dbg!(&user_groups);
-        assert!(user_groups.contains(group));
+        assert!(stdout.starts_with(&format!("{USERNAME} L")));
 
         Ok(())
     }
 
     #[test]
-    fn sudoers_file_get_created_with_expected_contents() -> Result<()> {
-        let expected = "Hello, root!";
-        let env = EnvBuilder::default().sudoers(expected).build()?;
-
-        let output = env.exec(&["cat", "/etc/sudoers"], As::Root, None)?;
-        assert!(output.status.success());
-
-        let actual = output.stdout;
-        assert_eq!(expected, actual);
+    fn password_assignment_works() -> Result<()> {
+        let password = "strong-password";
+        let env = Env("ALL ALL=(ALL:ALL) ALL")
+            .user(User(USERNAME).password(password))
+            .build()?;
 
-        Ok(())
+        Command::new("sudo")
+            .args(["-S", "true"])
+            .as_user(USERNAME)
+            .stdin(password)
+            .exec(&env)?
+            .assert_success()
     }
 
     #[test]
-    fn default_pam_d_sudo() -> Result<()> {
-        let env = EnvBuilder::default().build()?;
+    fn creating_user_part_of_existing_group_works() -> Result<()> {
+        let groupname = "users";
+        let env = EnvBuilder::default()
+            .user(User(USERNAME).group(groupname))
+            .build()?;
 
-        let actual = env.stdout(&["cat", "/etc/pam.d/sudo"], As::Root, None)?;
-        let expected = EnvBuilder::DEFAULT_PAM_D_SUDO;
+        let stdout = Command::new("groups")
+            .as_user(USERNAME)
+            .exec(&env)?
+            .stdout()?;
+        let user_groups = stdout.split(' ').collect::<HashSet<_>>();
+        assert!(user_groups.contains(groupname));
 
-        assert_eq!(expected, actual);
         Ok(())
     }
 
     #[test]
-    fn overwrite_pam_d_sudo() -> Result<()> {
-        let expected = "invalid pam.d file";
-        let env = EnvBuilder::default().pam_d_sudo(expected).build()?;
-
-        let actual = env.stdout(&["cat", "/etc/pam.d/sudo"], As::Root, None)?;
+    fn sudoers_file_get_created_with_expected_contents() -> Result<()> {
+        let expected = "Hello, root!";
+        let env = Env(expected).build()?;
 
+        let actual = Command::new("cat")
+            .arg("/etc/sudoers")
+            .exec(&env)?
+            .stdout()?;
         assert_eq!(expected, actual);
+
         Ok(())
     }
 
     #[test]
     fn text_file_gets_created_with_right_perms() -> Result<()> {
-        let chown = "ferris:ferris";
+        let chown = format!("{USERNAME}:{GROUPNAME}");
         let chmod = "600";
         let expected_contents = "hello";
         let path = "/root/file";
         let env = EnvBuilder::default()
-            .user("ferris", &[])
-            .text_file(path, chown, chmod, expected_contents)
+            .user(USERNAME)
+            .group(GROUPNAME)
+            .file(path, TextFile(expected_contents).chown(chown).chmod(chmod))
             .build()?;
 
-        let actual_contents = env.stdout(&["cat", path], As::Root, None)?;
+        let actual_contents = Command::new("cat").arg(path).exec(&env)?.stdout()?;
         assert_eq!(expected_contents, &actual_contents);
 
-        let ls_l = env.stdout(&["ls", "-l", path], As::Root, None)?;
+        let ls_l = Command::new("ls").args(["-l", path]).exec(&env)?.stdout()?;
         assert!(ls_l.starts_with("-rw-------"));
-        assert!(ls_l.contains("ferris ferris"));
+        assert!(ls_l.contains(&format!("{USERNAME} {GROUPNAME}")));
 
         Ok(())
     }
 
     #[test]
-    #[should_panic = "cannot override the user ID of system user root"]
-    fn override_root_user_id_fails() {
-        EnvBuilder::default().user_id("root", 1000).build().unwrap();
+    #[should_panic = "user root already exists in base image"]
+    fn cannot_create_user_that_already_exists_in_base_image() {
+        EnvBuilder::default().user("root").build().unwrap();
     }
 
     #[test]
-    #[should_panic = "cannot override the group ID of system group root"]
-    fn override_root_group_id_fails() {
+    #[should_panic = "user ID 0 already exists in base image"]
+    fn cannot_assign_user_id_that_already_exists_in_base_image() {
         EnvBuilder::default()
-            .group_id("root", 1000)
+            .user(User(USERNAME).id(0))
             .build()
             .unwrap();
     }
 
     #[test]
-    #[should_panic = "cannot assign user ID to non-existent user ferris"]
-    fn set_user_id_of_nonexistent_user_fails() {
-        EnvBuilder::default()
-            .user_id("ferris", 1000)
-            .build()
-            .unwrap();
+    #[should_panic = "group root already exists in base image"]
+    fn cannot_create_group_that_already_exists_in_base_image() {
+        EnvBuilder::default().group("root").build().unwrap();
     }
 
     #[test]
-    #[should_panic = "cannot assign group ID to the implicit user group ferris"]
-    fn set_group_id_of_implicit_user_group_fails() {
+    #[should_panic = "group ID 0 already exists in base image"]
+    fn cannot_assign_group_id_that_already_exists_in_base_image() {
         EnvBuilder::default()
-            .user("ferris", &[])
-            .group_id("ferris", 1000)
+            .group(Group(GROUPNAME).id(0))
             .build()
             .unwrap();
     }
 
     #[test]
-    #[should_panic = "cannot assign group ID to non-existent group rustaceans"]
-    fn set_group_id_of_nonexistent_group_fails() {
-        EnvBuilder::default()
-            .group_id("rustaceans", 1000)
-            .build()
-            .unwrap();
-    }
-
-    #[test]
-    fn user_id_override_works() -> Result<()> {
+    fn setting_user_id_works() -> Result<()> {
         let expected = 1023;
-        let username = "ferris";
         let env = EnvBuilder::default()
-            .user(username, &[])
-            .user_id(username, expected)
+            .user(User(USERNAME).id(expected))
             .build()?;
 
-        let actual = env
-            .stdout(&["id", "-u", username], As::Root, None)?
+        let actual = Command::new("id")
+            .args(["-u", USERNAME])
+            .exec(&env)?
+            .stdout()?
             .parse()?;
         assert_eq!(expected, actual);
+
         Ok(())
     }
 
     #[test]
-    fn group_id_override_works() -> Result<()> {
+    fn setting_group_id_works() -> Result<()> {
         let expected = 1023;
-        let username = "ferris";
-        let groupname = "rustaceans";
         let env = EnvBuilder::default()
-            .user(username, &[groupname])
-            .group_id(groupname, expected)
+            .group(Group(GROUPNAME).id(expected))
             .build()?;
 
-        let stdout = env.stdout(&["getent", "group", groupname], As::Root, None)?;
+        let stdout = Command::new("getent")
+            .args(["group", GROUPNAME])
+            .exec(&env)?
+            .stdout()?;
         let actual = stdout.split(':').nth(2);
         assert_eq!(Some(expected.to_string().as_str()), actual);
+
         Ok(())
     }
 }