Repository: memorysafety/sudo-rs
Pull request #116 (2023-03-20 12:53:13)

Dont' require authentication if user is root or requested user themselves.
Most changes are to test cases and code intended to be readable so this is an easy one to review.

Check this set of changes for sudo compliancy: as far as my probing has revealed (and what seems logical) is that sudo does not require a password if:

- you are already root (since you can do whatever you want anyway)
- with *the combination* of "-u" and "-g" you are not trying to become anything that you are not already

Should be squashed.


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-cutils/src/lib.rs b/lib/sudo-cutils/src/lib.rs
index 9f47b8cb..7f9e8487 100644
--- a/lib/sudo-cutils/src/lib.rs
+++ b/lib/sudo-cutils/src/lib.rs
@@ -53,18 +53,45 @@ pub unsafe fn string_from_ptr(ptr: *const libc::c_char) -> String {
     }
 }
 
-pub fn into_leaky_cstring(s: &str) -> *const libc::c_char {
-    let alloc_len = s.len() as isize;
-    let mem = unsafe { libc::malloc(alloc_len as usize + 1) as *mut u8 };
+/// Create a C string copy of a Rust string copy, allocated by libc::malloc()
+///
+/// # Safety
+/// This function assumes that the caller will clean up the returned pointer
+/// via a call to libc::free.
+pub unsafe fn into_leaky_cstring(s: &str) -> *const libc::c_char {
+    let alloc_len: isize = s.len().try_into().expect("absurd string size");
+    let mem = libc::malloc(alloc_len as usize + 1) as *mut u8;
     if mem.is_null() {
         panic!("libc malloc failed");
     } else {
-        for (i, e) in s.bytes().enumerate() {
-            let signed_i = i as isize;
-            unsafe { *mem.offset(signed_i) = e };
-        }
-        unsafe { *mem.offset(alloc_len) = 0 };
+        std::ptr::copy_nonoverlapping(s.as_ptr(), mem, alloc_len as usize);
+        *mem.offset(alloc_len) = 0;
     }
 
     mem as *mut libc::c_char
 }
+
+#[cfg(test)]
+mod test {
+    use super::{into_leaky_cstring, string_from_ptr};
+
+    #[test]
+    fn test_str_to_ptr() {
+        let strp = |ptr| unsafe { string_from_ptr(ptr) };
+        assert_eq!(strp(std::ptr::null()), "");
+        assert_eq!(strp("\0".as_ptr() as *const libc::c_char), "");
+        assert_eq!(strp("hello\0".as_ptr() as *const libc::c_char), "hello");
+    }
+
+    #[test]
+    fn test_leaky_cstring() {
+        let test = |text| unsafe {
+            let ptr = into_leaky_cstring(text);
+            let result = string_from_ptr(ptr);
+            libc::free(ptr as *mut libc::c_void);
+            result
+        };
+        assert_eq!(test(""), "");
+        assert_eq!(test("hello"), "hello");
+    }
+}
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
 }
diff --git a/lib/sudo-pam/src/converse.rs b/lib/sudo-pam/src/converse.rs
index 18f0b24f..9a3e380f 100644
--- a/lib/sudo-pam/src/converse.rs
+++ b/lib/sudo-pam/src/converse.rs
@@ -249,7 +249,7 @@ pub(crate) extern "C" fn converse<C: Converser>(
             // Unwrap here should be ok because we previously allocated an array of the same size
             let our_resp = &conversation.messages.get(i as usize).unwrap().response;
             if let Some(r) = our_resp {
-                let cstr = sudo_cutils::into_leaky_cstring(r);
+                let cstr = unsafe { sudo_cutils::into_leaky_cstring(r) };
                 response.resp = cstr as *mut _;
             }
         }
diff --git a/lib/sudo-system/src/audit.rs b/lib/sudo-system/src/audit.rs
new file mode 100644
index 00000000..e4520a5f
--- /dev/null
+++ b/lib/sudo-system/src/audit.rs
@@ -0,0 +1,43 @@
+use std::fs::File;
+use std::io::{self, Error, ErrorKind};
+use std::os::unix::fs::{MetadataExt, PermissionsExt};
+use std::path::Path;
+
+// of course we can also write "file & 0o040 != 0", but this makes the intent explicit
+enum Op {
+    _Read = 4,
+    Write = 2,
+    _Exec = 1,
+}
+enum Category {
+    _Owner = 2,
+    Group = 1,
+    World = 0,
+}
+
+fn mode(who: Category, what: Op) -> u32 {
+    (what as u32) << (3 * who as u32)
+}
+
+pub fn secure_open(path: &Path) -> io::Result<File> {
+    let file = File::open(path)?;
+    let meta = file.metadata()?;
+    let permbits = meta.permissions().mode();
+    let error = |msg| Error::new(ErrorKind::PermissionDenied, msg);
+
+    if meta.uid() != 0 {
+        Err(error(format!("{} must be owned by root", path.display())))
+    } else if meta.gid() != 0 && (permbits & mode(Category::Group, Op::Write) != 0) {
+        Err(error(format!(
+            "{} cannot be group-writable",
+            path.display()
+        )))
+    } else if permbits & mode(Category::World, Op::Write) != 0 {
+        Err(error(format!(
+            "{} cannot be world-writable",
+            path.display()
+        )))
+    } else {
+        Ok(file)
+    }
+}
diff --git a/lib/sudo-system/src/lib.rs b/lib/sudo-system/src/lib.rs
index 862f278f..c735184a 100644
--- a/lib/sudo-system/src/lib.rs
+++ b/lib/sudo-system/src/lib.rs
@@ -10,6 +10,9 @@ use libc::pid_t;
 pub use libc::PATH_MAX;
 use sudo_cutils::*;
 
+mod audit;
+pub use audit::secure_open;
+
 pub fn hostname() -> String {
     let max_hostname_size = sysconf(libc::_SC_HOST_NAME_MAX).unwrap_or(256);
     let mut buf = vec![0; max_hostname_size as usize];
diff --git a/lib/sudoers/src/lib.rs b/lib/sudoers/src/lib.rs
index 5d4c5cad..cf92454c 100644
--- a/lib/sudoers/src/lib.rs
+++ b/lib/sudoers/src/lib.rs
@@ -78,17 +78,27 @@ impl Sudoers {
         on_host: &str,
         request: Request<User, Group>,
     ) -> Policy {
+        // exception: if user is root or does not switch users, NOPASSWD is implied
+        let skip_passwd =
+            am_user.is_root() || (request.user == am_user && in_group(am_user, request.group));
+
+        let mut flags = check_permission(self, am_user, on_host, request);
+        if let Some(Tag { passwd, .. }) = flags.as_mut() {
+            if skip_passwd {
+                *passwd = false
+            }
+        }
+
         Policy {
-            flags: check_permission(self, am_user, on_host, request),
+            flags,
             settings: self.settings.clone(), // this is wasteful, but in the future this will not be a simple clone and it avoids a lifetime
         }
     }
 }
 
 fn read_sudoers(path: &Path) -> Result<Vec<basic_parser::Parsed<Sudo>>, std::io::Error> {
-    use std::fs::File;
     use std::io::Read;
-    let mut source = File::open(path)?;
+    let mut source = sudo_system::secure_open(path)?;
 
     // it's a bit frustrating that BufReader.chars() does not exist
     let mut buffer = String::new();
diff --git a/sudo/src/main.rs b/sudo/src/main.rs
index fc739288..c6c2a054 100644
--- a/sudo/src/main.rs
+++ b/sudo/src/main.rs
@@ -16,7 +16,7 @@ fn parse_sudoers() -> Result<Sudoers, Error> {
     let sudoers_path = "/etc/sudoers.test";
 
     let (sudoers, syntax_errors) = Sudoers::new(sudoers_path)
-        .map_err(|e| Error::Configuration(format!("no sudoers file {e}")))?;
+        .map_err(|e| Error::Configuration(format!("no valid sudoers file: {e}")))?;
 
     for sudoers::Error(_pos, error) in syntax_errors {
         eprintln!("Parse error: {error}");


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/lib/sudoers/src/test/mod.rs b/lib/sudoers/src/test/mod.rs
index 1da26646..30b84569 100644
--- a/lib/sudoers/src/test/mod.rs
+++ b/lib/sudoers/src/test/mod.rs
@@ -136,7 +136,7 @@ fn permission_test() {
             let (Sudoers { rules,aliases,settings }, _) = analyze(sudoer![$($sudo),*]);
             let cmdvec = $command.split_whitespace().collect::<Vec<_>>();
             let req = Request { user: $req.0, group: $req.1, command: cmdvec[0].as_ref(), arguments: &cmdvec[1..].join(" ") };
-            assert_eq!(check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $server, req), None);
+            assert_eq!(Sudoers { rules, aliases, settings }.check(&Named($user), $server, req).flags, None);
         }
     }
 
@@ -145,7 +145,7 @@ fn permission_test() {
             let (Sudoers { rules,aliases,settings }, _) = analyze(sudoer![$($sudo),*]);
             let cmdvec = $command.split_whitespace().collect::<Vec<_>>();
             let req = Request { user: $req.0, group: $req.1, command: &cmdvec[0].as_ref(), arguments: &cmdvec[1..].join(" ") };
-            let result = check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $server, req);
+            let result = Sudoers { rules, aliases, settings }.check(&Named($user), $server, req).flags;
             assert!(!result.is_none());
             $(
                 let result = result.unwrap();
@@ -211,6 +211,11 @@ fn permission_test() {
     pass!(["user ALL=/bin/hel* me"], "user" => root(), "server"; "/bin/help me");
     FAIL!(["user ALL=/bin/hel* me"], "user" => root(), "server"; "/bin/help me please");
 
+    pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => root(), "server"; "/bin/foo" => [passwd: true]);
+    pass!(["root ALL=(ALL:ALL) /bin/foo"], "root" => root(), "server"; "/bin/foo" => [passwd: false]);
+    pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => (&Named("user"), &Named("user")), "server"; "/bin/foo" => [passwd: false]);
+    pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => (&Named("user"), &Named("root")), "server"; "/bin/foo" => [passwd: true]);
+
     SYNTAX!(["User_Alias, marc ALL = ALL"]);
 
     pass!(["User_Alias FULLTIME=ALL,!marc","FULLTIME ALL=ALL"], "user" => root(), "server"; "/bin/bash");
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..1f9e7e6d 100644
--- a/test-framework/sudo-compliance-tests/src/lib.rs
+++ b/test-framework/sudo-compliance-tests/src/lib.rs
@@ -7,6 +7,7 @@ mod child_process;
 mod env_reset;
 mod flag_user;
 mod nopasswd;
+mod pam;
 mod pass_auth;
 mod perms;
 mod sudoers;
@@ -14,6 +15,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..9b6fe7d4 100644
--- a/test-framework/sudo-compliance-tests/src/nopasswd.rs
+++ b/test-framework/sudo-compliance-tests/src/nopasswd.rs
@@ -1,56 +1,47 @@
 //! 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
 
 // man sudoers > User Authentication:
 // "A password is not required if the invoking user is root"
-#[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:
 // "A password is not required if (..) the target user is the same as the invoking user"
-#[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/pam.rs b/test-framework/sudo-compliance-tests/src/pam.rs
new file mode 100644
index 00000000..c44734fa
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/pam.rs
@@ -0,0 +1,69 @@
+//! PAM integration tests
+
+use sudo_test::{Command, Env, User};
+
+use crate::{Result, PASSWORD, USERNAME};
+
+#[test]
+fn given_pam_permit_then_no_password_auth_required() -> Result<()> {
+    let env = Env("ALL ALL=(ALL:ALL) ALL")
+        .user(USERNAME)
+        .file("/etc/pam.d/sudo", "auth sufficient pam_permit.so")
+        .build()?;
+
+    Command::new("sudo")
+        .arg("true")
+        .as_user(USERNAME)
+        .exec(&env)?
+        .assert_success()
+}
+
+#[test]
+fn given_pam_deny_then_password_auth_always_fails() -> Result<()> {
+    let env = Env("ALL ALL=(ALL:ALL) ALL")
+        .user(User(USERNAME).password(PASSWORD))
+        .file("/etc/pam.d/sudo", "auth requisite pam_deny.so")
+        .build()?;
+
+    let output = Command::new("sudo")
+        .args(["-S", "true"])
+        .as_user(USERNAME)
+        .stdin(PASSWORD)
+        .exec(&env)?;
+
+    assert!(!output.status().success());
+    assert_eq!(Some(1), output.status().code());
+
+    if sudo_test::is_original_sudo() {
+        assert_contains!(output.stderr(), "3 incorrect password attempts");
+    }
+
+    Ok(())
+}
+
+#[test]
+#[ignore]
+fn being_root_has_precedence_over_pam() -> Result<()> {
+    let env = Env("ALL ALL=(ALL:ALL) ALL")
+        .file("/etc/pam.d/sudo", "auth requisite pam_deny.so")
+        .build()?;
+
+    Command::new("sudo")
+        .args(["true"])
+        .exec(&env)?
+        .assert_success()
+}
+
+#[test]
+fn nopasswd_in_sudoers_has_precedence_over_pam() -> Result<()> {
+    let env = Env("ALL ALL=(ALL:ALL) NOPASSWD: ALL")
+        .file("/etc/pam.d/sudo", "auth requisite pam_deny.so")
+        .user(USERNAME)
+        .build()?;
+
+    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..c5200fe8 100644
--- a/test-framework/sudo-compliance-tests/src/sudoers.rs
+++ b/test-framework/sudo-compliance-tests/src/sudoers.rs
@@ -1,49 +1,98 @@
-use sudo_test::{As, EnvBuilder};
+use sudo_test::{Command, Env, TextFile, User};
 
-use crate::Result;
+use crate::{Result, PASSWORD, SUDOERS_ROOT_ALL_NOPASSWD, 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(SUDOERS_ROOT_ALL_NOPASSWD).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(())
 }
 
-#[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()?;
+fn cannot_sudo_if_sudoers_file_is_group_writable() -> Result<()> {
+    let env = Env(TextFile(SUDOERS_ROOT_ALL_NOPASSWD)
+        .chmod("464")
+        .chown("root:1234"))
+    .user(User(USERNAME).password(PASSWORD))
+    .build()?;
 
-    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
-    assert!(!output.status.success());
+    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, "no password was provided");
+        assert_contains!(
+            output.stderr(),
+            "/etc/sudoers is owned by gid 1234, should be 0"
+        );
     }
 
-    let output = env.exec(
-        &["sudo", "-S", "true"],
-        As::User { name: username },
-        Some(password),
-    )?;
-    assert!(output.status.success(), "{}", output.stderr);
+    Ok(())
+}
+
+#[test]
+fn can_sudo_if_sudoers_file_is_owner_writable() -> Result<()> {
+    let env = Env(TextFile(SUDOERS_ROOT_ALL_NOPASSWD).chmod("644")).build()?;
+
+    let output = Command::new("sudo").arg("true").exec(&env)?;
+    assert_eq!(Some(0), output.status().code());
+
+    Ok(())
+}
+
+#[test]
+fn cannot_sudo_if_sudoers_file_is_not_owned_by_root() -> Result<()> {
+    let env = Env(TextFile(SUDOERS_ROOT_ALL_NOPASSWD).chown("1234:root"))
+        .user(User(USERNAME).password(PASSWORD))
+        .build()?;
+
+    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 owned by uid 1234, should be 0"
+        );
+    }
 
     Ok(())
 }
+
+#[ignore]
+#[test]
+fn user_specifications_evaluated_bottom_to_top() -> Result<()> {
+    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");
+    }
+
+    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(())
     }
 }