Repository: memorysafety/sudo-rs
Pull request #117 (2023-03-20 16:43:10)

Richer error messages
*sales pitch voice*:

Before:
```
$ target/debug/sudo ls
Parse error: can't assign to boolean setting `env_reset'
Parse error: use quotes around filenames or escape whitespace
```
After:
```
$ target/debug/sudo ls
/etc/sudoers.test:1:22: can't assign to boolean setting `env_reset'
Defaults env_reset = "FOO BAR BAZ"
                     ^
/etc/sudoers.test:2:10: use quotes around filenames or escape whitespace
@include hello world
         ^~~~~~
```
Much nicer, no?

Note that the squigglies are not based on some kind of "skip until the next bit of whitespace" heuristic but the end of them actually indicates the position where the parser rejected the input. The caret generally indicates where the offending element started (but what is considered a "element" depends on context).

And all of this can be yours today! Just click on the *merge* button and GitHub should do the rest! 


Patch:
diff --git a/.github/codecov.yml b/.github/codecov.yml
index 9faaf230..5dc07327 100644
--- a/.github/codecov.yml
+++ b/.github/codecov.yml
@@ -1,10 +1,2 @@
 ignore:
  - "test-binaries"
-coverage:
-  status:
-    project:
-      default:
-        informational: true
-    patch:
-      default:
-        informational: true
\ No newline at end of file
diff --git a/README.md b/README.md
index 46be7e52..b41e7ab3 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # sudo-rs
 
-A safety oriented and memory safe implementation of sudo and su written in Rust.
+A safety oriented and memory safe implementation in Rust of sudo and su.
 
 ## !!WARNING!!
 
@@ -16,8 +16,7 @@ unix-based operating systems may work, they are currently not actively
 supported or maintained.
 
 Sudo-rs is written in Rust. We currently only support the latest stable compiler
-toolchain. To get a recent compiler toolchain we recommend using [rustup]. To
-build sudo-rs run
+toolchain. To build sudo-rs run
 
 ```
 cargo build --release
@@ -27,44 +26,19 @@ This produces a binary `target/release/sudo`. However this binary must have the
 setuid flag set and must be owned by the root user in order to provide any
 useful functionality. Because we are in such an early stage we also require
 an environment variable `SUDO_RS_IS_UNSTABLE` to be set, and it must have the
-value `I accept that my system may break unexpectedly`. If you are unsure about
-how to set this up, then the current version of sudo is not intended for you.
+value `I accept that my system may break unexpectedly`. If you are unsure how
+to set this value then this software is not suited for you at this time.
 
-Sudo-rs needs the sudoers configuration file, but it currently reads it at
-`/etc/sudoers.test`, instead of the default location. You must make sure that
-a valid sudoers configuration exists at that location. For an explanation of
-the sudoers syntax you can look at the
+Sudo-rs needs the sudoers configuration file, but it currently reads it at the
+`/etc/sudoers.test` location, so make sure that your sudoers configuration is at
+that location. For an explanation of the sudoers syntax you can look at the
 [original sudo man page](https://www.sudo.ws/docs/man/sudoers.man/). While most
 syntax should be supported as is, most functionality will currently not be
 implemented. Sudo-rs currently may not always warn about this, so your sudoers
 file may have a different meaning compared to the original sudo implementation.
 
 Sudo-rs always uses PAM for authentication at this time, your system must be
-setup for PAM. Sudo-rs will use the `sudo` service configuration.
-
-[rustup]: https://rustup.rs/
-
-## Current work
-
-Our current target is to build a drop-in replacement for most basic use cases of
-sudo. For the sudoers config syntax this means that we aim to at least support
-the default configuration files of some common Linux distributions (we currently
-aim to suppor both the Fedora and Debian default sudoers configs). Our
-implementation should eventually at least support all commonly used CLI flags
-from the original sudo implementation (e.g. flags like `-u`, `-g` and `-s`).
-
-Some parts of the original sudo are explicitly not in scope. Sudo has a large
-and rich history and some of the features available in the original sudo
-implementation are largely unused or only available for legacy platforms. In
-order to determine which features make it we both consider whether the feature
-is relevant for modern systems, and whether it will receive at very least
-decent usage. Finally of course a feature should not compromise the safety of
-the whole program.
-
-The `su` program is a much simpler program and will only include basic
-functionality. However, we think that the building blocks that make up our sudo
-implementation will be suited to be used for creating a simple su
-implementation.
+setup for PAM. Sudo-rs will use the sudo service configuration.
 
 ## Contributions
 
@@ -73,10 +47,3 @@ of sudo-rs up and running, accepting arbitrary contributions will be difficult.
 If you have any small changes or suggestions please do leave those, but if you
 want to work on larger parts of sudo-rs please ask first, or we may risk doing
 work twice or not being able to include your work.
-
-## Future work
-
-While our initial target is a drop-in replacement for most basic use cases of
-sudo, our work may evolve beyond that target. We are also looking into
-alternative ways to configure sudo without the sudoers config file syntax and to
-extract parts of our work in usable crates for other people.
diff --git a/lib/sudo-common/src/context.rs b/lib/sudo-common/src/context.rs
index a74a930d..123d8325 100644
--- a/lib/sudo-common/src/context.rs
+++ b/lib/sudo-common/src/context.rs
@@ -68,7 +68,6 @@ pub struct Context<'a> {
     // system
     pub hostname: String,
     pub current_user: User,
-    pub pid: i32,
 }
 
 pub trait Configuration {
@@ -143,7 +142,6 @@ 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 7f9e8487..9f47b8cb 100644
--- a/lib/sudo-cutils/src/lib.rs
+++ b/lib/sudo-cutils/src/lib.rs
@@ -53,45 +53,18 @@ pub unsafe fn string_from_ptr(ptr: *const libc::c_char) -> String {
     }
 }
 
-/// 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;
+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 };
     if mem.is_null() {
         panic!("libc malloc failed");
     } else {
-        std::ptr::copy_nonoverlapping(s.as_ptr(), mem, alloc_len as usize);
-        *mem.offset(alloc_len) = 0;
+        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 };
     }
 
     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 3363774b..a3a6add6 100644
--- a/lib/sudo-exec/src/lib.rs
+++ b/lib/sudo-exec/src/lib.rs
@@ -2,125 +2,112 @@
 use std::{
     ffi::c_int,
     io,
-    os::unix::process::ExitStatusExt,
+    os::unix::process::CommandExt,
     process::{Command, ExitStatus},
-    time::Duration,
 };
 
 use signal_hook::{
     consts::*,
     iterator::{exfiltrator::WithOrigin, SignalsInfo},
-    low_level::{
-        emulate_default_handler,
-        siginfo::{Cause, Process, Sent},
-    },
+    low_level::siginfo::{Cause, Origin, Sent},
 };
 use sudo_common::context::{Context, Environment};
-use sudo_system::{getpgid, kill, set_target_user};
+use sudo_system::{getpgid, kill};
 
-/// We only handle the signals that ogsudo handles.
+/// 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`.
 const SIGNALS: &[c_int] = &[
-    SIGINT, SIGQUIT, SIGTSTP, SIGTERM, SIGHUP, SIGALRM, SIGPIPE, SIGUSR1, SIGUSR2, SIGCHLD,
-    SIGCONT, SIGWINCH,
+    SIGABRT, SIGALRM, SIGBUS, SIGCHLD, SIGCONT, SIGHUP, SIGINT, SIGPIPE, SIGPROF, SIGQUIT, SIGSYS,
+    SIGTERM, SIGTRAP, SIGTSTP, SIGTTIN, SIGTTOU, SIGURG, SIGUSR1, SIGUSR2, SIGVTALRM, SIGWINCH,
+    SIGXCPU, SIGXFSZ, SIGIO,
 ];
 
 /// Based on `ogsudo`s `exec_nopty` function.
 pub fn run_command(ctx: Context<'_>, env: Environment) -> io::Result<ExitStatus> {
     // FIXME: should we pipe the stdio streams?
-    let mut command = Command::new(ctx.command.command);
-    // reset env and set filtered environment
-    command.args(ctx.command.arguments).env_clear().envs(env);
-    // set target user and groups
-    set_target_user(&mut command, ctx.target_user);
-    // spawn and exec to command
-    let mut child = command.spawn()?;
+    let mut cmd = Command::new(ctx.command.command)
+        .args(ctx.command.arguments)
+        .uid(ctx.target_user.uid)
+        .gid(ctx.target_user.gid)
+        .env_clear()
+        .envs(env)
+        .spawn()?;
 
-    let child_pid = child.id() as i32;
+    let cmd_pid = cmd.id() as i32;
 
     let mut signals = SignalsInfo::<WithOrigin>::new(SIGNALS)?;
 
     loop {
-        // First we check if the child is finished
-        if let Some(status) = child.try_wait()? {
-            if let Some(signal) = status.signal() {
-                // If the child 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 {
+        for Origin {
+            signal,
+            process,
+            cause,
+            ..
+        } in signals.pending()
+        {
+            match signal {
                 SIGCHLD => {
                     // FIXME: check `handle_sigchld_nopty`
-                    // We just wait until all the children are done.
-                    continue;
+                    cmd.kill()?;
+                }
+                SIGWINCH => {
+                    // FIXME: check `handle_sigwinch`
+                    cmd.kill()?;
                 }
-                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, child_pid, ctx.pid) {
+                SIGINT | SIGQUIT | SIGTSTP => {
+                    if cause != Cause::Sent(Sent::User) {
                         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;
+                                }
+                            }
+                        }
+                    }
                 }
                 _ => {
-                    // Skip the signal if it was sent by the user and it is self-terminating.
-                    if user_signaled && is_self_terminating(info.process, child_pid, ctx.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;
+                                    }
+                                }
+                            }
+                        }
                     }
                 }
             }
 
-            let status = if info.signal == SIGALRM {
-                // Kill the child with increasing urgency.
-                // Based on `terminate_command`.
-                kill(child_pid, SIGHUP);
-                kill(child_pid, SIGTERM);
-                std::thread::sleep(Duration::from_secs(2));
-                kill(child_pid, SIGKILL)
-            } else {
-                kill(child_pid, info.signal)
-            };
-
-            if status != 0 {
+            if signal == SIGALRM {
+                // FIXME: check `terminate_command` to match behavior.
+                cmd.kill()?;
+            } else if kill(cmd_pid, signal) != 0 {
                 eprintln!("kill failed");
             }
         }
-    }
-}
 
-/// 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 child, or
-/// - is in the process group of the child and either sudo or the child is the leader.
-fn is_self_terminating(process: Option<Process>, child_pid: i32, sudo_pid: i32) -> bool {
-    if let Some(process) = process {
-        if process.pid != 0 {
-            if process.pid == child_pid {
-                return true;
-            }
-            let grp_leader = getpgid(process.pid);
-
-            if grp_leader != -1 {
-                if grp_leader == child_pid || grp_leader == sudo_pid {
-                    return true;
-                }
-            } else {
-                eprintln!("Could not fetch process group ID");
-            }
+        if let Some(code) = cmd.try_wait()? {
+            return Ok(code);
         }
     }
-
-    false
 }
diff --git a/lib/sudo-pam/src/converse.rs b/lib/sudo-pam/src/converse.rs
index 9a3e380f..18f0b24f 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 = unsafe { sudo_cutils::into_leaky_cstring(r) };
+                let cstr = 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
deleted file mode 100644
index e4520a5f..00000000
--- a/lib/sudo-system/src/audit.rs
+++ /dev/null
@@ -1,43 +0,0 @@
-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 7db28dc0..862f278f 100644
--- a/lib/sudo-system/src/lib.rs
+++ b/lib/sudo-system/src/lib.rs
@@ -10,9 +10,6 @@ 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];
@@ -25,28 +22,6 @@ pub fn hostname() -> String {
     }
 }
 
-/// set target user and groups (uid, gid, additional groups) for a command
-pub fn set_target_user(cmd: &mut std::process::Command, target_user: User) {
-    use std::os::unix::process::CommandExt;
-
-    let uid = target_user.uid;
-    let gid = target_user.gid;
-    let groups = target_user.groups.unwrap_or_default();
-
-    // we need to do this in a `pre_exec` call since the `groups` method in `process::Command` is unstable
-    // see https://github.com/rust-lang/rust/blob/a01b4cc9f375f1b95fa8195daeea938d3d9c4c34/library/std/src/sys/unix/process/process_unix.rs#L329-L352
-    // for the std implementation of the libc calls to `setgroups`, `setgid` and `setuid`
-    unsafe {
-        cmd.pre_exec(move || {
-            cerr(libc::setgroups(groups.len(), groups.as_ptr()))?;
-            cerr(libc::setgid(gid))?;
-            cerr(libc::setuid(uid))?;
-
-            Ok(())
-        });
-    }
-}
-
 /// Send a signal to a process.
 pub fn kill(pid: pid_t, signal: c_int) -> c_int {
     // SAFETY: This function cannot cause UB even if `pid` is not a valid process ID or if
diff --git a/lib/sudoers/src/ast.rs b/lib/sudoers/src/ast.rs
index 1f56b4a4..ba9e47f2 100644
--- a/lib/sudoers/src/ast.rs
+++ b/lib/sudoers/src/ast.rs
@@ -305,6 +305,7 @@ impl Parse for ProtoCommandSpec {
             }
         }
 
+        let start_pos = stream.get_pos();
         let digest = if let Some(Username(keyword)) = try_nonterminal(stream)? {
             let hash_type = match keyword.as_str() {
                 "sha224" => 224,
@@ -312,7 +313,11 @@ impl Parse for ProtoCommandSpec {
                 "sha384" => 384,
                 "sha512" => 512,
                 "sudoedit" => todo!(), // note: special behaviour of forward slashes in wildcards, tread carefully
-                _ => unrecoverable!(stream, "expected command but found {keyword}"),
+                _ => unrecoverable!(
+                    pos = start_pos,
+                    stream,
+                    "expected command but found {keyword}"
+                ),
             };
             expect_syntax(':', stream)?;
             let hex = expect_nonterminal::<Sha2>(stream)?;
@@ -464,18 +469,24 @@ fn parse_include(stream: &mut impl CharStream) -> Parsed<Sudo> {
             expect_syntax('"', stream)?;
             make(path)
         } else {
+            let value_pos = stream.get_pos();
             let IncludePath(path) = expect_nonterminal(stream)?;
             if stream.peek() != Some('\n') {
-                unrecoverable!(stream, "use quotes around filenames or escape whitespace")
+                unrecoverable!(
+                    pos = value_pos,
+                    stream,
+                    "use quotes around filenames or escape whitespace"
+                )
             }
             make(path)
         }
     }
 
+    let key_pos = stream.get_pos();
     let result = match try_nonterminal(stream)? {
         Some(Username(key)) if key == "include" => Sudo::Include(get_path(stream)?),
         Some(Username(key)) if key == "includedir" => Sudo::IncludeDir(get_path(stream)?),
-        _ => unrecoverable!(stream, "unknown directive"),
+        _ => unrecoverable!(pos = key_pos, stream, "unknown directive"),
     };
 
     make(result)
@@ -537,7 +548,7 @@ fn get_directive(
     fn parse_default<T: CharStream>(stream: &mut T) -> Parsed<Directive> {
         let id_pos = stream.get_pos();
 
-        let list_items = |mode: Mode, name: String, cfg: Setting, stream: &mut _| {
+        let list_items = |mode: Mode, name: String, cfg: Setting, stream: &mut T| {
             expect_syntax('=', stream)?;
             if !matches!(cfg, Setting::List(_)) {
                 unrecoverable!(pos = id_pos, stream, "{name} is not a list parameter");
@@ -561,6 +572,7 @@ fn get_directive(
         use sudo_defaults::OptTuple;
 
         if is_syntax('!', stream)? {
+            let value_pos = stream.get_pos();
             let EnvVar(name) = expect_nonterminal(stream)?;
             let value = match sudo_default(&name) {
                 Some(Setting::Flag(_)) => ConfigValue::Flag(false),
@@ -574,13 +586,17 @@ fn get_directive(
                 Some(Setting::Integer(OptTuple {
                     negated: Some(val), ..
                 })) => ConfigValue::Num(val),
-                _ => unrecoverable!(stream, "`{name}' cannot be used in a boolean context"),
+                _ => unrecoverable!(
+                    pos = value_pos,
+                    stream,
+                    "`{name}' cannot be used in a boolean context"
+                ),
             };
             make(Defaults(name, value))
         } else {
             let EnvVar(name) = try_nonterminal(stream)?;
             let Some(cfg) = sudo_default(&name) else {
-                unrecoverable!(pos=id_pos, stream, "unknown setting: `{name}'");
+                unrecoverable!(pos = id_pos, stream, "unknown setting: `{name}'");
             };
 
             if is_syntax('+', stream)? {
diff --git a/lib/sudoers/src/basic_parser.rs b/lib/sudoers/src/basic_parser.rs
index eb9715c8..5c24a42d 100644
--- a/lib/sudoers/src/basic_parser.rs
+++ b/lib/sudoers/src/basic_parser.rs
@@ -28,7 +28,7 @@
 /// Type holding a parsed object (or error information if parsing failed)
 pub type Parsed<T> = Result<T, Status>;
 
-pub type Position = (usize, usize);
+pub type Position = std::ops::Range<(usize, usize)>;
 
 #[derive(Debug, Clone)]
 #[cfg_attr(test, derive(PartialEq))]
@@ -46,15 +46,17 @@ pub fn reject<T>() -> Parsed<T> {
 }
 
 macro_rules! unrecoverable {
-    // TODO: transmit begin-and-end position information to give an accurate "underlining" of the full error
     (pos=$pos:expr, $stream:ident, $($str:expr),*) => {
-        return Err(crate::basic_parser::Status::Fatal($pos,format![$($str),*]))
+        return Err(crate::basic_parser::Status::Fatal($pos .. $stream.get_pos(), format![$($str),*]))
     };
     ($stream:ident, $($str:expr),*) => {
-        return Err(crate::basic_parser::Status::Fatal($stream.get_pos(),format![$($str),*]))
+        {
+            let pos = $stream.get_pos();
+            return Err(crate::basic_parser::Status::Fatal(pos .. pos, format![$($str),*]))
+        }
     };
     ($($str:expr),*) => {
-        return Err(crate::basic_parser::Status::Fatal(None,format![$($str),*]))
+        return Err(crate::basic_parser::Status::Fatal(Default::default(), format![$($str),*]))
     };
 }
 
@@ -202,9 +204,10 @@ pub fn try_nonterminal<T: Parse>(stream: &mut impl CharStream) -> Parsed<T> {
 use crate::ast_names::UserFriendly;
 
 pub fn expect_nonterminal<T: Parse + UserFriendly>(stream: &mut impl CharStream) -> Parsed<T> {
+    let begin_pos = stream.get_pos();
     match try_nonterminal(stream) {
         Err(Status::Reject) => {
-            unrecoverable!(stream, "expected {}", T::DESCRIPTION)
+            unrecoverable!(pos = begin_pos, stream, "expected {}", T::DESCRIPTION)
         }
         result => result,
     }
diff --git a/lib/sudoers/src/char_stream.rs b/lib/sudoers/src/char_stream.rs
index 22a4783f..fb7091df 100644
--- a/lib/sudoers/src/char_stream.rs
+++ b/lib/sudoers/src/char_stream.rs
@@ -55,21 +55,3 @@ impl<Iter: Iterator<Item = char>> CharStream for std::iter::Peekable<Iter> {
         (0, 0)
     }
 }
-
-#[cfg(test)]
-mod test {
-    use super::*;
-
-    #[test]
-    fn test_iter() {
-        let mut stream = PeekableWithPos::<std::str::Chars>::new("12\n3\n".chars());
-        assert_eq!(stream.peek(), Some('1'));
-        stream.advance();
-        assert_eq!(stream.peek(), Some('2'));
-        stream.advance();
-        stream.advance();
-        assert_eq!(stream.peek(), Some('3'));
-        stream.advance();
-        assert_eq!(stream.get_pos(), (2, 2));
-    }
-}
diff --git a/lib/sudoers/src/lib.rs b/lib/sudoers/src/lib.rs
index cf92454c..5d4c5cad 100644
--- a/lib/sudoers/src/lib.rs
+++ b/lib/sudoers/src/lib.rs
@@ -78,27 +78,17 @@ 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,
+            flags: check_permission(self, am_user, on_host, request),
             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 = sudo_system::secure_open(path)?;
+    let mut source = File::open(path)?;
 
     // it's a bit frustrating that BufReader.chars() does not exist
     let mut buffer = String::new();
diff --git a/sudo/src/diagnostic.rs b/sudo/src/diagnostic.rs
new file mode 100644
index 00000000..adb99cc6
--- /dev/null
+++ b/sudo/src/diagnostic.rs
@@ -0,0 +1,50 @@
+use std::fs::File;
+use std::io::{BufRead, BufReader};
+use std::ops::Range;
+use std::path::Path;
+
+pub(crate) fn cited_error(message: &str, range: Range<(usize, usize)>, path: impl AsRef<Path>) {
+    let path_str = path.as_ref().display();
+    let Range {
+        start: (line, col),
+        end: (end_line, mut end_col),
+    } = range;
+    eprintln!("{path_str}:{line}:{col}: {message}");
+
+    // we won't try to "span" errors across multiple lines
+    if line != end_line {
+        end_col = col;
+    }
+
+    let citation = || {
+        let inp = BufReader::new(File::open(path).ok()?);
+        let line = inp.lines().nth(line - 1)?.ok()?;
+        let padding = line
+            .chars()
+            .take(col - 1)
+            .map(|c| if c.is_whitespace() { c } else { ' ' })
+            .collect::<String>();
+        let lineunder = std::iter::repeat('~')
+            .take(end_col - col)
+            .skip(1)
+            .collect::<String>();
+        eprintln!("{line}");
+        eprintln!("{padding}^{lineunder}");
+        Some(())
+    };
+
+    // we ignore any errors in displaying an error
+    let _ = citation();
+}
+
+macro_rules! diagnostic {
+    ($str:expr, $path:tt @ $pos:ident) => {
+        if let Some(range) = $pos {
+            $crate::diagnostic::cited_error(&format!($str), range, $path);
+        } else {
+            eprintln!($str);
+        }
+    };
+}
+
+pub(crate) use diagnostic;
diff --git a/sudo/src/main.rs b/sudo/src/main.rs
index c6c2a054..d4517b84 100644
--- a/sudo/src/main.rs
+++ b/sudo/src/main.rs
@@ -9,6 +9,7 @@ use sudo_common::{
 use sudo_env::environment;
 use sudoers::{Authorization, Sudoers};
 
+mod diagnostic;
 mod pam;
 
 fn parse_sudoers() -> Result<Sudoers, Error> {
@@ -16,10 +17,10 @@ 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 valid sudoers file: {e}")))?;
+        .map_err(|e| Error::Configuration(format!("no sudoers file {e}")))?;
 
-    for sudoers::Error(_pos, error) in syntax_errors {
-        eprintln!("Parse error: {error}");
+    for sudoers::Error(pos, error) in syntax_errors {
+        diagnostic::diagnostic!("{error}", sudoers_path @ pos);
     }
 
     Ok(sudoers)


Test patch:
diff --git a/lib/sudo-env/tests/env_tests.rs b/lib/sudo-env/tests/env_tests.rs
index a0b57d51..a8b2237f 100644
--- a/lib/sudo-env/tests/env_tests.rs
+++ b/lib/sudo-env/tests/env_tests.rs
@@ -132,7 +132,6 @@ 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 75aaaa10..1da26646 100644
--- a/lib/sudoers/src/test/mod.rs
+++ b/lib/sudoers/src/test/mod.rs
@@ -29,10 +29,6 @@ impl UnixUser for Named {
         self.0 == name
     }
 
-    fn has_uid(&self, uid: u32) -> bool {
-        dummy_cksum(self.0) == uid
-    }
-
     fn in_group_by_name(&self, name: &str) -> bool {
         self.has_name(name)
     }
@@ -62,7 +58,7 @@ macro_rules! request {
 }
 
 macro_rules! sudoer {
-    ($h:expr $(,$e:expr)* $(,)?) => {
+    ($h:expr $(,$e:expr)*) => {
 	    parse_lines(&mut
 		(
 		    iter::once($h)
@@ -140,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!(Sudoers { rules, aliases, settings }.check(&Named($user), $server, req).flags, None);
+            assert_eq!(check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $server, req), None);
         }
     }
 
@@ -149,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 = Sudoers { rules, aliases, settings }.check(&Named($user), $server, req).flags;
+            let result = check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $server, req);
             assert!(!result.is_none());
             $(
                 let result = result.unwrap();
@@ -215,43 +211,12 @@ 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" => request! { user, user }, "server"; "/bin/foo" => [passwd: false]);
-    pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => request! { user, root }, "server"; "/bin/foo" => [passwd: true]);
-
-    assert_eq!(Named("user").as_gid(), 1466);
-    pass!(["#1466 server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello");
-    pass!(["%#1466 server=(ALL:ALL) ALL"], "user" => root(), "server"; "/bin/hello");
-    FAIL!(["#1466 server=(ALL:ALL) ALL"], "root" => root(), "server"; "/bin/hello");
-    FAIL!(["%#1466 server=(ALL:ALL) ALL"], "root" => root(), "server"; "/bin/hello");
-    pass!(["user ALL=(ALL:#1466) /bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo");
-    FAIL!(["user ALL=(ALL:#1466) /bin/foo"], "user" => request! { root, other }, "server"; "/bin/foo");
-    pass!(["user ALL=(ALL:#1466) /bin/foo"], "user" => request! { root, user }, "server"; "/bin/foo");
-    pass!(["user ALL=(root,user:ALL) /bin/foo"], "user" => request! { root, wheel }, "server"; "/bin/foo");
-    pass!(["user ALL=(root,user:ALL) /bin/foo"], "user" => request! { user, wheel }, "server"; "/bin/foo");
-    FAIL!(["user ALL=(root,user:ALL) /bin/foo"], "user" => request! { sudo, wheel }, "server"; "/bin/foo");
-    FAIL!(["user ALL=(#0:wheel) /bin/foo"], "user" => request! { sudo, wheel }, "server"; "/bin/foo");
-    pass!(["user ALL=(#0:wheel) /bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo");
-    FAIL!(["user ALL=(%#1466:wheel) /bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo");
-    pass!(["user ALL=(%#1466:wheel) /bin/foo"], "user" => request! { user, user }, "server"; "/bin/foo");
-
-    // tests with a 'singular' runas spec
-    FAIL!(["user ALL=(ALL) /bin/foo"], "user" => request! { sudo, wheel }, "server"; "/bin/foo");
-    pass!(["user ALL=(ALL) /bin/foo"], "user" => request! { sudo, sudo }, "server"; "/bin/foo");
-
-    // tests without a runas spec
-    FAIL!(["user ALL=/bin/foo"], "user" => request! { sudo, sudo }, "server"; "/bin/foo");
-    FAIL!(["user ALL=/bin/foo"], "user" => request! { sudo, root }, "server"; "/bin/foo");
-    FAIL!(["user ALL=/bin/foo"], "user" => request! { root, sudo }, "server"; "/bin/foo");
-    pass!(["user ALL=/bin/foo"], "user" => request! { root, root }, "server"; "/bin/foo");
-
     SYNTAX!(["User_Alias, marc ALL = ALL"]);
 
     pass!(["User_Alias FULLTIME=ALL,!marc","FULLTIME ALL=ALL"], "user" => root(), "server"; "/bin/bash");
     FAIL!(["User_Alias FULLTIME=ALL,!marc","FULLTIME ALL=ALL"], "marc" => root(), "server"; "/bin/bash");
     FAIL!(["User_Alias FULLTIME=ALL,!marc","ALL,!FULLTIME ALL=ALL"], "user" => root(), "server"; "/bin/bash");
-    pass!(["User_Alias FULLTIME=ALL,!!!marc","ALL,!FULLTIME ALL=ALL"], "marc" => root(), "server"; "/bin/bash");
+    pass!(["User_Alias FULLTIME=ALL,!marc","ALL,!FULLTIME ALL=ALL"], "marc" => root(), "server"; "/bin/bash");
     pass!(["Host_Alias MACHINE=laptop,server","user MACHINE=ALL"], "user" => root(), "server"; "/bin/bash");
     pass!(["Host_Alias MACHINE=laptop,server","user MACHINE=ALL"], "user" => root(), "laptop"; "/bin/bash");
     FAIL!(["Host_Alias MACHINE=laptop,server","user MACHINE=ALL"], "user" => root(), "desktop"; "/bin/bash");
@@ -263,61 +228,15 @@ fn permission_test() {
     pass!(["Host_Alias A=B","Host_Alias B=vm","ALL A=ALL"], "user" => root(), "vm"; "/bin/ls");
     pass!(["Cmnd_Alias A=B","Cmnd_Alias B=/bin/ls","ALL ALL=A"], "user" => root(), "vm"; "/bin/ls");
 
-    FAIL!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=() ALL"], "user" => request!{ sudo, sudo }, "vm"; "/bin/ls");
-    pass!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(TIME) ALL"], "user" => request! { sudo, sudo }, "vm"; "/bin/ls");
-    FAIL!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(:TIME) ALL"], "user" => request! { sudo, sudo }, "vm"; "/bin/ls");
-    pass!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(:TIME) ALL"], "user" => request! { user, sudo }, "vm"; "/bin/ls");
-    pass!(["Runas_Alias TIME=%wheel,!!sudo","user ALL=(TIME) ALL"], "user" => request! { wheel, wheel }, "vm"; "/bin/ls");
+    FAIL!(["Runas_Alias TIME=%wheel,sudo","user ALL=() ALL"], "user" => request!{ sudo, sudo }, "vm"; "/bin/ls");
+    pass!(["Runas_Alias TIME=%wheel,sudo","user ALL=(TIME) ALL"], "user" => request! { sudo, sudo }, "vm"; "/bin/ls");
+    FAIL!(["Runas_Alias TIME=%wheel,sudo","user ALL=(:TIME) ALL"], "user" => request! { sudo, sudo }, "vm"; "/bin/ls");
+    pass!(["Runas_Alias TIME=%wheel,sudo","user ALL=(:TIME) ALL"], "user" => request! { user, sudo }, "vm"; "/bin/ls");
+    pass!(["Runas_Alias TIME=%wheel,sudo","user ALL=(TIME) ALL"], "user" => request! { wheel, wheel }, "vm"; "/bin/ls");
 
     pass!(["Runas_Alias \\"," TIME=%wheel\\",",sudo # hallo","user ALL\\","=(TIME) ALL"], "user" => request! { wheel, wheel }, "vm"; "/bin/ls");
 }
 
-#[test]
-fn default_bool_test() {
-    let (Sudoers { settings, .. }, _) = analyze(sudoer![
-        "Defaults env_reset",
-        "Defaults !use_pty",
-        "Defaults !env_keep",
-        "Defaults !umask",
-        "Defaults !secure_path"
-    ]);
-    assert!(settings.flags.contains("env_reset"));
-    assert!(!settings.flags.contains("use_pty"));
-    assert!(settings.list["env_keep"].is_empty());
-    assert_eq!(settings.str_value["secure_path"], "");
-    assert_eq!(settings.int_value["umask"], 0o777);
-}
-
-#[test]
-fn default_set_test() {
-    let (Sudoers { settings, .. }, _) = analyze(sudoer![
-        "Defaults env_keep = \"FOO HUK BAR\"",
-        "Defaults env_keep -= HUK",
-        "Defaults !env_check",
-        "Defaults env_check += \"FOO\"",
-        "Defaults env_check += \"XYZZY\"",
-        //"Defaults umask = 0123",
-        //"Defaults passwd_tries = 5",
-        "Defaults lecture_file = \"/etc/sudoers\"",
-        "Defaults secure_path = /etc"
-    ]);
-    assert_eq!(
-        settings.list["env_keep"],
-        ["FOO", "BAR"].into_iter().map(|x| x.to_string()).collect()
-    );
-    assert_eq!(
-        settings.list["env_check"],
-        ["FOO", "XYZZY"]
-            .into_iter()
-            .map(|x| x.to_string())
-            .collect()
-    );
-    assert_eq!(settings.str_value["lecture_file"], "/etc/sudoers");
-    assert_eq!(settings.str_value["secure_path"], "/etc");
-    //assert_eq!(settings.int_value["umask"], 0o123);
-    //assert_eq!(settings.int_value["passwd_tries"], 5);
-}
-
 #[test]
 #[should_panic]
 fn invalid_directive() {
diff --git a/test-binaries/checkpermit.rs b/test-binaries/checkpermit.rs
index 5f350787..5f54f197 100644
--- a/test-binaries/checkpermit.rs
+++ b/test-binaries/checkpermit.rs
@@ -128,7 +128,7 @@ fn main() {
     let args: Vec<String> = env::args().collect();
     if let Ok((cfg, warn)) = Sudoers::new("./sudoers") {
         for sudoers::Error(pos, msg) in warn {
-            if let Some((x, y)) = pos {
+            if let Some(std::ops::Range { start: (x, y), .. }) = pos {
                 fancy_error(x, y, "./sudoers");
             }
             eprintln!("{msg}");
diff --git a/test-framework/sudo-compliance-tests/src/child_process.rs b/test-framework/sudo-compliance-tests/src/child_process.rs
index db67dc5d..de72caad 100644
--- a/test-framework/sudo-compliance-tests/src/child_process.rs
+++ b/test-framework/sudo-compliance-tests/src/child_process.rs
@@ -1,47 +1,53 @@
 use pretty_assertions::assert_eq;
-use sudo_test::{Command, Env};
+use sudo_test::{As, EnvBuilder};
 
 use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
 
-mod signal_handling;
-
 #[test]
 fn sudo_forwards_childs_exit_code() -> Result<()> {
-    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
+    let env = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
 
     let expected = 42;
-    let output = Command::new("sudo")
-        .args(["sh", "-c"])
-        .arg(format!("exit {expected}"))
-        .exec(&env)?;
-    assert_eq!(Some(expected), output.status().code());
+    let output = env.exec(
+        &["sudo", "sh", "-c", &format!("exit {expected}")],
+        As::Root,
+        None,
+    )?;
+    assert_eq!(Some(expected), output.status.code());
 
     Ok(())
 }
 
 #[test]
 fn sudo_forwards_childs_stdout() -> Result<()> {
-    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
+    let env = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
 
     let expected = "hello";
-    let output = Command::new("sudo").args(["echo", expected]).exec(&env)?;
-    assert!(output.stderr().is_empty());
-    assert_eq!(expected, output.stdout()?);
+    let output = env.exec(&["sudo", "echo", expected], As::Root, None)?;
+    assert_eq!(expected, output.stdout);
+    assert!(output.stderr.is_empty());
 
     Ok(())
 }
 
 #[test]
 fn sudo_forwards_childs_stderr() -> Result<()> {
-    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
+    let env = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
 
     let expected = "hello";
-    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());
+    let output = env.exec(
+        &["sudo", "sh", "-c", &format!(">&2 echo {expected}")],
+        As::Root,
+        None,
+    )?;
+    assert_eq!(expected, output.stderr);
+    assert!(output.stdout.is_empty());
 
     Ok(())
 }
diff --git a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/expects-signal.sh b/test-framework/sudo-compliance-tests/src/child_process/signal_handling/expects-signal.sh
deleted file mode 100644
index 134f4ac4..00000000
--- a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/expects-signal.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-trap 'echo got signal && exit 0' TERM
-for _ in $(seq 1 20); do
-	sleep 0.1
-done
-echo >&2 received no signal
-exit 1
diff --git a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/kill-sudo-parent.sh b/test-framework/sudo-compliance-tests/src/child_process/signal_handling/kill-sudo-parent.sh
deleted file mode 100644
index f16d2133..00000000
--- a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/kill-sudo-parent.sh
+++ /dev/null
@@ -1,9 +0,0 @@
-trap 'echo received SIGTERM; exit 1' TERM
-sudo_pid="$(pidof sudo)"
-[ -n "$sudo_pid" ] || (echo sudo process not found && exit 2)
-kill "$sudo_pid"
-# as insurance, wait a bit for signal delivery to happen
-# the signal handler won't run while `sleep` runs hence the multiple `sleep` invocations
-for _ in $(seq 1 10); do
-	sleep 0.1
-done
diff --git a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/kill-sudo.sh b/test-framework/sudo-compliance-tests/src/child_process/signal_handling/kill-sudo.sh
deleted file mode 100644
index be15ac74..00000000
--- a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/kill-sudo.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-# because the sudo process is `spawn`-ed it may not be immediately visible so
-# retry `pidof` until it becomes visible
-for _ in $(seq 1 20); do
-	sudopid="$(pidof sudo)"
-	if [ -n "$sudopid" ]; then
-		# give `expects-signal.sh ` some time to execute the `trap` command otherwise
-		# it'll be terminated before the signal handler is installed
-		sleep 0.1
-		kill "$sudopid"
-		exit 0
-	fi
-	sleep 0.1
-done
-
-echo >&2 timeout
-exit 1
diff --git a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/mod.rs b/test-framework/sudo-compliance-tests/src/child_process/signal_handling/mod.rs
deleted file mode 100644
index 51d007d6..00000000
--- a/test-framework/sudo-compliance-tests/src/child_process/signal_handling/mod.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-use pretty_assertions::assert_eq;
-use sudo_test::{Command, Env};
-
-use crate::{Result, SUDOERS_USER_ALL_NOPASSWD, USERNAME};
-
-// man sudo > Signal handling
-// "As a special case, sudo will not relay signals that were sent by the command it is running."
-#[test]
-fn signal_sent_by_child_process_is_ignored() -> Result<()> {
-    let script = include_str!("kill-sudo-parent.sh");
-
-    let kill_sudo_parent = "/root/kill-sudo-parent.sh";
-    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
-        .user(USERNAME)
-        .file(kill_sudo_parent, script)
-        .build()?;
-
-    Command::new("sudo")
-        .args(["sh", kill_sudo_parent])
-        .as_user(USERNAME)
-        .exec(&env)?
-        .assert_success()
-}
-
-#[test]
-fn signal_is_forwarded_to_child() -> Result<()> {
-    let expected = "got signal";
-    let expects_signal = "/root/expects-signal.sh";
-    let kill_sudo = "/root/kill-sudo.sh";
-    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
-        .user(USERNAME)
-        .file(expects_signal, include_str!("expects-signal.sh"))
-        .file(kill_sudo, include_str!("kill-sudo.sh"))
-        .build()?;
-
-    let child = Command::new("sudo")
-        .args(["sh", expects_signal])
-        .as_user(USERNAME)
-        .spawn(&env)?;
-
-    Command::new("sh")
-        .arg(kill_sudo)
-        .exec(&env)?
-        .assert_success()?;
-
-    let actual = child.wait()?.stdout()?;
-
-    assert_eq!(expected, actual);
-
-    Ok(())
-}
-
-// man sudo > Exit value
-// "If the command terminated due to receipt of a signal, sudo will send itself the same signal that terminated the command."
-#[test]
-fn child_terminated_by_signal() -> Result<()> {
-    let env = Env(SUDOERS_USER_ALL_NOPASSWD).user(USERNAME).build()?;
-
-    // child process sends SIGTERM to itself
-    let output = Command::new("sudo")
-        .args(["sh", "-c", "kill $$"])
-        .as_user(USERNAME)
-        .exec(&env)?;
-
-    assert_eq!(Some(143), output.status().code());
-    assert!(output.stderr().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 8b0e36be..843083a2 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::{Command, Env};
+use sudo_test::{As, EnvBuilder};
 
 use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
 
@@ -11,24 +11,22 @@ 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 = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
+    let env = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
 
-    let stdout = Command::new("env").exec(&env)?.stdout()?;
+    let stdout = env.stdout(&["env"], As::Root, None)?;
     let normal_env = parse_env_output(&stdout)?;
 
-    let sudo_abs_path = Command::new("which").arg("sudo").exec(&env)?.stdout()?;
-    let env_abs_path = Command::new("which").arg("env").exec(&env)?.stdout()?;
+    let sudo_abs_path = env.stdout(&["which", "sudo"], As::Root, None)?;
+    let env_abs_path = env.stdout(&["which", "env"], As::Root, None)?;
 
     // run sudo in an empty environment
-    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 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 mut sudo_env = parse_env_output(&stdout)?;
 
     // # man sudo
@@ -80,25 +78,27 @@ fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
 
 #[test]
 fn env_reset_mode_clears_env_vars() -> Result<()> {
-    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).build()?;
+    let env = EnvBuilder::default()
+        .sudoers(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 = Command::new("sh")
-        .arg("-c")
-        .arg(format!("{set_env_var}; env"))
-        .exec(&env)?
-        .stdout()?;
+    let stdout = env.stdout(
+        &["sh", "-c", &format!("{set_env_var}; env")],
+        As::Root,
+        None,
+    )?;
     let env_vars = parse_env_output(&stdout)?;
     assert!(env_vars.contains_key(varname));
 
-    let stdout = Command::new("sh")
-        .arg("-c")
-        .arg(format!("{set_env_var}; sudo env"))
-        .exec(&env)?
-        .stdout()?;
+    let stdout = env.stdout(
+        &["sh", "-c", &format!("{set_env_var}; sudo env")],
+        As::Root,
+        None,
+    )?;
     let env_vars = parse_env_output(&stdout)?;
     assert!(!env_vars.contains_key(varname));
 
@@ -110,8 +110,6 @@ 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 7cb5eb50..01686c59 100644
--- a/test-framework/sudo-compliance-tests/src/flag_user.rs
+++ b/test-framework/sudo-compliance-tests/src/flag_user.rs
@@ -1,17 +1,18 @@
 use pretty_assertions::assert_eq;
-use sudo_test::{Command, Env, User};
+use sudo_test::{As, EnvBuilder};
 
-use crate::{Result, GROUPNAME, SUDOERS_ROOT_ALL_NOPASSWD, SUDOERS_USER_ALL_NOPASSWD, USERNAME};
+use crate::{Result, SUDOERS_FERRIS_ALL_NOPASSWD, SUDOERS_ROOT_ALL_NOPASSWD};
 
 #[test]
 fn root_can_become_another_user_by_name() -> Result<()> {
-    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).user(USERNAME).build()?;
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
 
-    let expected = Command::new("id").as_user(USERNAME).exec(&env)?.stdout()?;
-    let actual = Command::new("sudo")
-        .args(["-u", USERNAME, "id"])
-        .exec(&env)?
-        .stdout()?;
+    let expected = env.stdout(&["id"], As::User { name: username }, None)?;
+    let actual = env.stdout(&["sudo", "-u", username, "id"], As::Root, None)?;
 
     assert_eq!(expected, actual);
 
@@ -20,71 +21,44 @@ fn root_can_become_another_user_by_name() -> Result<()> {
 
 #[test]
 fn root_can_become_another_user_by_uid() -> Result<()> {
-    let env = Env(SUDOERS_ROOT_ALL_NOPASSWD).user(USERNAME).build()?;
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
 
-    let uid = Command::new("id")
-        .arg("-u")
-        .as_user(USERNAME)
-        .exec(&env)?
-        .stdout()?
+    let uid = env
+        .stdout(&["id", "-u"], As::User { name: username }, None)?
         .parse::<u32>()?;
-    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()?;
+    let expected = env.stdout(&["id"], As::User { name: username }, None)?;
+    let actual = env.stdout(&["sudo", "-u", &format!("#{uid}"), "id"], As::Root, None)?;
 
     assert_eq!(expected, actual);
 
     Ok(())
 }
 
+#[ignore]
 #[test]
 fn user_can_become_another_user() -> Result<()> {
-    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]
-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)
+    let env = EnvBuilder::default()
+        .user("ferris", &[])
+        .user("someone_else", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
         .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(invoking_user)
-        .exec(&env)?
-        .stdout()?;
+    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,
+    )?;
 
     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 1f9e7e6d..908a14ef 100644
--- a/test-framework/sudo-compliance-tests/src/lib.rs
+++ b/test-framework/sudo-compliance-tests/src/lib.rs
@@ -7,7 +7,6 @@ mod child_process;
 mod env_reset;
 mod flag_user;
 mod nopasswd;
-mod pam;
 mod pass_auth;
 mod perms;
 mod sudoers;
@@ -15,10 +14,6 @@ mod sudoers;
 type Error = Box<dyn std::error::Error>;
 type Result<T> = core::result::Result<T, Error>;
 
-const USERNAME: &str = "ferris";
-const GROUPNAME: &str = "rustaceans";
-const PASSWORD: &str = "strong-password";
-
+const SUDOERS_FERRIS_ALL_NOPASSWD: &str = "ferris    ALL=(ALL:ALL) NOPASSWD: ALL";
 const SUDOERS_ROOT_ALL: &str = "root    ALL=(ALL:ALL) 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";
+const SUDOERS_ROOT_ALL_NOPASSWD: &str = "root    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 9b6fe7d4..9c94c81d 100644
--- a/test-framework/sudo-compliance-tests/src/nopasswd.rs
+++ b/test-framework/sudo-compliance-tests/src/nopasswd.rs
@@ -1,47 +1,56 @@
 //! Scenarios where a password does not need to be provided
 
-use sudo_test::{Command, Env};
+use sudo_test::{As, EnvBuilder};
 
-use crate::{Result, SUDOERS_ROOT_ALL, USERNAME};
+use crate::{Result, SUDOERS_ROOT_ALL};
 
 // 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 = Env(SUDOERS_ROOT_ALL).build()?;
+    let env = EnvBuilder::default().sudoers(SUDOERS_ROOT_ALL).build()?;
 
-    Command::new("sudo")
-        .arg("true")
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
 
 // 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 env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
-        .user(USERNAME)
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
         .build()?;
 
-    Command::new("sudo")
-        .args(["-u", USERNAME, "true"])
-        .as_user(USERNAME)
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(
+        &["sudo", "-u", username, "true"],
+        As::User { name: username },
+        None,
+    )?;
+
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
 
 #[test]
 fn nopasswd_tag() -> Result<()> {
-    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) NOPASSWD: ALL"))
-        .user(USERNAME)
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) NOPASSWD: ALL"))
+        .user(username, &[])
         .build()?;
 
-    Command::new("sudo")
-        .arg("true")
-        .as_user(USERNAME)
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(&["sudo", "true"], As::User { name: username }, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
diff --git a/test-framework/sudo-compliance-tests/src/pam.rs b/test-framework/sudo-compliance-tests/src/pam.rs
deleted file mode 100644
index 73a8e7cb..00000000
--- a/test-framework/sudo-compliance-tests/src/pam.rs
+++ /dev/null
@@ -1,68 +0,0 @@
-//! 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]
-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 723d9dca..94960c64 100644
--- a/test-framework/sudo-compliance-tests/src/pass_auth.rs
+++ b/test-framework/sudo-compliance-tests/src/pass_auth.rs
@@ -3,41 +3,51 @@
 // 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 crate::{Result, PASSWORD, USERNAME};
+use sudo_test::{As, EnvBuilder};
+
+use crate::Result;
 
 #[ignore]
 #[test]
 fn correct_password() -> Result<()> {
-    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
-        .user(User(USERNAME).password(PASSWORD))
+    let username = "ferris";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+        .user(username, &[])
+        .user_password(username, password)
         .build()?;
 
-    Command::new("sudo")
-        .args(["-S", "true"])
-        .as_user(USERNAME)
-        .stdin(PASSWORD)
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(
+        &["sudo", "-S", "true"],
+        As::User { name: username },
+        Some(password),
+    )?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
 
 #[test]
 fn incorrect_password() -> Result<()> {
-    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
-        .user(User(USERNAME).password("strong-password"))
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+        .user(username, &[])
+        .user_password(username, "strong-password")
         .build()?;
 
-    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());
+    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());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr(), "incorrect password attempt");
+        assert_contains!(output.stderr, "incorrect password attempt");
     }
 
     Ok(())
@@ -45,18 +55,19 @@ fn incorrect_password() -> Result<()> {
 
 #[test]
 fn no_password() -> Result<()> {
-    let env = Env(format!("{USERNAME}    ALL=(ALL:ALL) ALL"))
-        .user(User(USERNAME).password(PASSWORD))
+    let username = "ferris";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+        .user(username, &[])
+        .user_password(username, password)
         .build()?;
 
-    let output = Command::new("sudo")
-        .args(["-S", "true"])
-        .as_user(USERNAME)
-        .exec(&env)?;
-    assert_eq!(Some(1), output.status().code());
+    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
+    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 f8a1d663..84b9a4db 100644
--- a/test-framework/sudo-compliance-tests/src/perms.rs
+++ b/test-framework/sudo-compliance-tests/src/perms.rs
@@ -1,23 +1,20 @@
 use pretty_assertions::assert_eq;
-use sudo_test::{Command, Env, TextFile};
+use sudo_test::{As, EnvBuilder};
 
-use crate::{Result, SUDOERS_USER_ALL_NOPASSWD, USERNAME};
+use crate::{Result, SUDOERS_FERRIS_ALL_NOPASSWD};
 
 #[test]
 fn user_can_read_file_owned_by_root() -> Result<()> {
     let expected = "hello";
     let path = "/root/file";
-    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
-        .user(USERNAME)
-        .file(path, expected)
+    let env = EnvBuilder::default()
+        .user("ferris", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+        .text_file(path, "root:root", "000", expected)
         .build()?;
 
-    let actual = Command::new("sudo")
-        .args(["cat", path])
-        .as_user(USERNAME)
-        .exec(&env)?
-        .stdout()?;
-    assert_eq!(expected, actual);
+    let stdout = env.stdout(&["sudo", "cat", path], As::User { name: "ferris" }, None)?;
+    assert_eq!(expected, stdout);
 
     Ok(())
 }
@@ -25,37 +22,36 @@ 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 = Env(SUDOERS_USER_ALL_NOPASSWD)
-        .user(USERNAME)
-        .file(path, "")
+    let env = EnvBuilder::default()
+        .user("ferris", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+        .text_file(path, "root:root", "000", "")
         .build()?;
 
-    Command::new("sudo")
-        .args(["rm", path])
-        .as_user(USERNAME)
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(&["sudo", "rm", path], As::User { name: "ferris" }, None)?;
+    assert!(output.status.success());
+
+    Ok(())
 }
 
 #[test]
 #[ignore]
 fn user_can_execute_file_owned_by_root() -> Result<()> {
     let path = "/root/file";
-    let env = Env(SUDOERS_USER_ALL_NOPASSWD)
-        .user(USERNAME)
-        .file(
+    let env = EnvBuilder::default()
+        .user("ferris", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+        .text_file(
             path,
-            TextFile(
-                r#"#!/bin/sh
+            "root:root",
+            "100",
+            r#"#!/bin/sh
 exit 0"#,
-            )
-            .chmod("100"),
         )
         .build()?;
 
-    Command::new("sudo")
-        .arg(path)
-        .as_user(USERNAME)
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(&["sudo", path], As::User { name: "ferris" }, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
diff --git a/test-framework/sudo-compliance-tests/src/sudoers.rs b/test-framework/sudo-compliance-tests/src/sudoers.rs
index c5200fe8..d0afcd50 100644
--- a/test-framework/sudo-compliance-tests/src/sudoers.rs
+++ b/test-framework/sudo-compliance-tests/src/sudoers.rs
@@ -1,68 +1,18 @@
-use sudo_test::{Command, Env, TextFile, User};
+use sudo_test::{As, EnvBuilder};
 
-use crate::{Result, PASSWORD, SUDOERS_ROOT_ALL_NOPASSWD, USERNAME};
+use crate::Result;
 
 mod user_list;
 
 #[test]
 fn cannot_sudo_if_sudoers_file_is_world_writable() -> Result<()> {
-    let env = Env(TextFile(SUDOERS_ROOT_ALL_NOPASSWD).chmod("446")).build()?;
+    let env = EnvBuilder::default().sudoers_chmod("446").build()?;
 
-    let output = Command::new("sudo").arg("true").exec(&env)?;
-    assert_eq!(Some(1), output.status().code());
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert_eq!(Some(1), output.status.code());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr(), "/etc/sudoers is world writable");
-    }
-
-    Ok(())
-}
-
-#[test]
-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 = 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 gid 1234, should be 0"
-        );
-    }
-
-    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"
-        );
+        assert_contains!(output.stderr, "/etc/sudoers is world writable");
     }
 
     Ok(())
@@ -71,28 +21,29 @@ fn cannot_sudo_if_sudoers_file_is_not_owned_by_root() -> Result<()> {
 #[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 username = "ferris";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .user_password(username, password)
+        .sudoers("ferris ALL=(ALL:ALL) NOPASSWD: ALL")
+        // overrides the preceding NOPASSWD
+        .sudoers("ferris ALL=(ALL:ALL) ALL")
+        .build()?;
 
-    let output = Command::new("sudo")
-        .args(["-S", "true"])
-        .as_user(USERNAME)
-        .exec(&env)?;
-    assert!(!output.status().success());
-    assert_eq!(Some(1), output.status().code());
+    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
+    assert!(!output.status.success());
 
     if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr(), "no password was provided");
+        assert_contains!(output.stderr, "no password was provided");
     }
 
-    Command::new("sudo")
-        .args(["-S", "true"])
-        .as_user(USERNAME)
-        .stdin(PASSWORD)
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(
+        &["sudo", "-S", "true"],
+        As::User { name: username },
+        Some(password),
+    )?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
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 759be8d5..9b703588 100644
--- a/test-framework/sudo-compliance-tests/src/sudoers/user_list.rs
+++ b/test-framework/sudo-compliance-tests/src/sudoers/user_list.rs
@@ -1,19 +1,18 @@
 //! Test the first component of the user specification: `<user_list> ALL=(ALL:ALL) ALL`
 
-use pretty_assertions::assert_eq;
-use sudo_test::{Command, Env};
+use sudo_test::{As, EnvBuilder};
 
-use crate::{Result, USERNAME};
+use crate::Result;
 
 #[test]
 fn no_match() -> Result<()> {
-    let env = Env("").build()?;
+    let env = EnvBuilder::default().build()?;
 
-    let output = Command::new("sudo").arg("true").exec(&env)?;
-    assert_eq!(Some(1), output.status().code());
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    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(())
@@ -21,58 +20,65 @@ fn no_match() -> Result<()> {
 
 #[test]
 fn all() -> Result<()> {
-    let env = Env("ALL ALL=(ALL:ALL) NOPASSWD: ALL")
-        .user(USERNAME)
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .sudoers("ALL ALL=(ALL:ALL) NOPASSWD: ALL")
+        .user(username, &[])
         .build()?;
 
-    Command::new("sudo")
-        .arg("true")
-        .exec(&env)?
-        .assert_success()?;
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
 
-    Command::new("sudo")
-        .arg("true")
-        .as_user(USERNAME)
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(&["sudo", "true"], As::User { name: username }, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
 
 #[test]
 fn user_name() -> Result<()> {
-    let env = Env("root ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
+    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);
 
-    Command::new("sudo")
-        .arg("true")
-        .exec(&env)?
-        .assert_success()
+    Ok(())
 }
 
 #[test]
 fn user_id() -> Result<()> {
-    let env = Env("#0 ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
+    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);
 
-    Command::new("sudo")
-        .arg("true")
-        .exec(&env)?
-        .assert_success()
+    Ok(())
 }
 
 #[test]
 fn group_name() -> Result<()> {
-    let env = Env("%root ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
+    let env = EnvBuilder::default()
+        .sudoers("%root ALL=(ALL:ALL) NOPASSWD: ALL")
+        .build()?;
 
-    Command::new("sudo")
-        .arg("true")
-        .exec(&env)?
-        .assert_success()
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
 }
 
 #[test]
 fn group_id() -> Result<()> {
-    let env = Env("%#0 ALL=(ALL:ALL) NOPASSWD: ALL").build()?;
+    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);
 
-    Command::new("sudo")
-        .arg("true")
-        .exec(&env)?
-        .assert_success()
+    Ok(())
 }
diff --git a/test-framework/sudo-test/Cargo.toml b/test-framework/sudo-test/Cargo.toml
index e11d75c1..990660ea 100644
--- a/test-framework/sudo-test/Cargo.toml
+++ b/test-framework/sudo-test/Cargo.toml
@@ -7,4 +7,5 @@ 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 c0cccbf4..92d3e6cf 100644
--- a/test-framework/sudo-test/src/docker.rs
+++ b/test-framework/sudo-test/src/docker.rs
@@ -1,20 +1,14 @@
 use core::str;
 use std::{
-    fs::{self, File},
-    io::{Seek, SeekFrom, Write},
-    path::PathBuf,
-    process::{self, Command as StdCommand, Stdio},
+    fs,
+    process::{Command, ExitStatus, Stdio},
 };
 
 use tempfile::NamedTempFile;
 
-use crate::{Result, SudoUnderTest, BASE_IMAGE};
+use crate::{helpers, Result};
 
-pub use self::command::{Child, Command, Output};
-
-mod command;
-
-const DOCKER_RUN_COMMAND: &[&str] = &["sleep", "infinity"];
+const DEFAULT_COMMAND: &[&str] = &["sleep", "infinity"];
 
 pub struct Container {
     id: String,
@@ -22,48 +16,43 @@ pub struct Container {
 
 impl Container {
     pub fn new(image: &str) -> Result<Self> {
-        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)?;
+        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)?;
 
         Ok(Container { id })
     }
 
-    pub fn exec(&self, cmd: &Command) -> Result<Output> {
-        run(&mut self.docker_exec(cmd), cmd.get_stdin())
+    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)
     }
 
-    pub fn spawn(&self, cmd: &Command) -> Result<Child> {
-        let mut docker_exec = self.docker_exec(cmd);
-
-        docker_exec.stdout(Stdio::piped()).stderr(Stdio::piped());
-
-        if let Some(stdin) = cmd.get_stdin() {
-            let mut temp_file = tempfile::tempfile()?;
-            temp_file.write_all(stdin.as_bytes())?;
-            temp_file.seek(SeekFrom::Start(0))?;
-            docker_exec.stdin(Stdio::from(temp_file));
-        }
-
-        Ok(Child::new(docker_exec.spawn()?))
+    /// 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_exec(&self, cmd: &Command) -> process::Command {
-        let mut docker_exec = StdCommand::new("docker");
-        docker_exec.arg("exec");
-        if cmd.get_stdin().is_some() {
-            docker_exec.arg("-i");
+    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) = cmd.get_user() {
-            docker_exec.arg("--user");
-            docker_exec.arg(user);
+        if let Some(user) = user.as_string() {
+            docker_cmd.arg("--user");
+            docker_cmd.arg(user);
         }
-        docker_exec.arg(&self.id);
-        docker_exec.args(cmd.get_args());
-        docker_exec
+        docker_cmd.arg(&self.id);
+        for arg in cmd {
+            docker_cmd.arg(arg.as_ref());
+        }
+        docker_cmd
     }
 
     pub fn cp(&self, path_in_container: &str, file_contents: &str) -> Result<()> {
@@ -73,21 +62,37 @@ impl Container {
         let src_path = temp_file.path().display().to_string();
         let dest_path = format!("{}:{path_in_container}", self.id);
 
-        run(
-            StdCommand::new("docker").args(["cp", &src_path, &dest_path]),
+        helpers::stdout(
+            Command::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 _ = StdCommand::new("docker")
+        let _ = Command::new("docker")
             .args(["stop", &self.id])
             .stdout(Stdio::null())
             .stderr(Stdio::null())
@@ -95,52 +100,13 @@ impl Drop for Container {
     }
 }
 
-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(())
+pub struct ExecOutput {
+    pub status: ExitStatus,
+    pub stderr: String,
+    pub stdout: String,
 }
 
-fn repo_root() -> PathBuf {
-    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
-    path.pop();
-    path.pop();
-    path
-}
-
-fn run(cmd: &mut StdCommand, stdin: Option<&str>) -> Result<Output> {
-    if let Some(stdin) = stdin {
-        let mut temp_file = tempfile::tempfile()?;
-        temp_file.write_all(stdin.as_bytes())?;
-        temp_file.seek(SeekFrom::Start(0))?;
-        cmd.stdin(Stdio::from(temp_file));
-    }
-
-    cmd.output()?.try_into()
-}
-
-fn validate_docker_id(id: &str, cmd: &StdCommand) -> Result<()> {
+fn validate_docker_id(id: &str, cmd: &Command) -> 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(),
@@ -161,19 +127,19 @@ mod tests {
     #[test]
     #[ignore = "slow"]
     fn eventually_removes_container_on_drop() -> Result<()> {
-        let mut check_cmd = StdCommand::new("docker");
+        let mut check_cmd = Command::new("docker");
         let docker = Container::new(IMAGE)?;
         check_cmd.args(["ps", "--all", "--quiet", "--filter"]);
         check_cmd.arg(format!("id={}", docker.id));
 
-        let matches = run(&mut check_cmd, None)?.stdout()?;
+        let matches = helpers::stdout(&mut check_cmd, None)?;
         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 = run(&mut check_cmd, None)?.stdout()?;
+        let matches = helpers::stdout(&mut check_cmd, None)?;
         assert_eq!(0, matches.lines().count());
 
         Ok(())
@@ -182,90 +148,53 @@ mod tests {
     #[test]
     fn exec_as_root_works() -> Result<()> {
         let docker = Container::new(IMAGE)?;
-
-        docker.exec(&Command::new("true"))?.assert_success()?;
-
-        let output = docker.exec(&Command::new("false"))?;
+        let output = docker.exec(&["true"], As::Root, None)?;
+        assert!(output.status.success());
+        let output = docker.exec(&["false"], As::Root, None)?;
         assert_eq!(Some(1), output.status.code());
-
         Ok(())
     }
 
     #[test]
     fn exec_as_user_named_root_works() -> Result<()> {
         let docker = Container::new(IMAGE)?;
-
-        docker
-            .exec(Command::new("true").as_user("root"))?
-            .assert_success()
+        let output = docker.exec(&["true"], As::User { name: "root" }, None)?;
+        assert!(output.status.success());
+        Ok(())
     }
 
     #[test]
     fn exec_as_non_root_user_works() -> Result<()> {
-        let username = "ferris";
-
         let docker = Container::new(IMAGE)?;
-
-        docker
-            .exec(Command::new("useradd").arg(username))?
-            .assert_success()?;
-
-        docker
-            .exec(Command::new("true").as_user(username))?
-            .assert_success()
+        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(())
     }
 
     #[test]
     fn cp_works() -> Result<()> {
-        let path = "/tmp/file";
-        let expected = "Hello, world!";
-
         let docker = Container::new(IMAGE)?;
-
-        docker.cp(path, expected)?;
-
-        let actual = docker.exec(Command::new("cat").arg(path))?.stdout()?;
+        let expected = "Hello, world!";
+        docker.cp("/tmp/file", expected)?;
+        let actual = docker.stdout(&["cat", "/tmp/file"], As::Root, None)?;
         assert_eq!(expected, actual);
-
         Ok(())
     }
 
     #[test]
     fn stdin_works() -> Result<()> {
         let expected = "Hello, root!";
-        let filename = "greeting";
-
-        let docker = Container::new(IMAGE)?;
-
-        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(())
-    }
 
-    #[test]
-    fn spawn_works() -> Result<()> {
         let docker = Container::new(IMAGE)?;
 
-        let child = docker.spawn(Command::new("sh").args(["-c", "sleep 2"]))?;
-
-        // `sh` process may not be immediately visible to `pidof` since it was spawned so wait a bit
-        thread::sleep(Duration::from_millis(500));
+        docker.stdout(&["tee", "greeting"], As::Root, Some(expected))?;
 
-        docker
-            .exec(Command::new("pidof").arg("sh"))?
-            .assert_success()?;
+        let actual = docker.stdout(&["cat", "greeting"], As::Root, None)?;
 
-        child.wait()?.assert_success()?;
-
-        let output = docker.exec(Command::new("pidof").arg("sh"))?;
-
-        assert!(!output.status().success());
-        assert_eq!(Some(1), output.status().code());
+        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
deleted file mode 100644
index 1ab72589..00000000
--- a/test-framework/sudo-test/src/docker/command.rs
+++ /dev/null
@@ -1,149 +0,0 @@
-use std::process::{self, ExitStatus};
-
-use crate::{Error, 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()
-    }
-}
-
-/// A process spawned in the test environment
-pub struct Child {
-    inner: process::Child,
-}
-
-impl Child {
-    pub(super) fn new(inner: process::Child) -> Self {
-        Self { inner }
-    }
-
-    /// waits for the child to exit and collects its stdout and stderr
-    pub fn wait(self) -> Result<Output> {
-        let output = self.inner.wait_with_output()?;
-        output.try_into()
-    }
-}
-
-/// 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)
-    }
-}
-
-impl TryFrom<process::Output> for Output {
-    type Error = Error;
-
-    fn try_from(output: process::Output) -> std::result::Result<Self, Self::Error> {
-        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,
-        })
-    }
-}
diff --git a/test-framework/sudo-test/src/helpers.rs b/test-framework/sudo-test/src/helpers.rs
new file mode 100644
index 00000000..3d40033d
--- /dev/null
+++ b/test-framework/sudo-test/src/helpers.rs
@@ -0,0 +1,52 @@
+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 4787f96f..894347ee 100644
--- a/test-framework/sudo-test/src/lib.rs
+++ b/test-framework/sudo-test/src/lib.rs
@@ -1,691 +1,700 @@
-//! sudo-rs test framework
-
-#![deny(missing_docs)]
-#![deny(unsafe_code)]
-
 use std::{
     collections::{HashMap, HashSet},
     env,
-    path::Path,
+    fs::File,
+    path::{Path, PathBuf},
+    process::{Command, Stdio},
     sync::Once,
 };
 
+use bimap::BiMap;
 use docker::Container;
 
-pub use docker::{Child, Command, Output};
+pub use docker::{As, ExecOutput};
 
-type Error = Box<dyn std::error::Error>;
-type Result<T> = core::result::Result<T, Error>;
+pub type Error = Box<dyn std::error::Error>;
+pub 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))
 }
 
-enum SudoUnderTest {
-    Ours,
-    Theirs,
-}
-
-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)
-        }
-    }
+/// 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>,
 }
 
-type AbsolutePath = String;
-type Groupname = String;
-type Username = String;
-
-/// test environment        
-pub struct Env {
-    container: Container,
-    users: HashSet<Username>,
+struct TextFile {
+    contents: String,
+    chmod: String,
+    chown: String,
 }
 
-/// 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 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"
+        );
 
-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() {
+        let mut set = HashSet::new();
+        for group in groups {
             assert!(
-                env.users.contains(username),
-                "tried to exec as non-existent user: {username}"
+                !set.contains(*group),
+                "group `{group}` declared more than once"
             );
+
+            set.insert(group.to_string());
         }
 
-        env.container.exec(self)
-    }
+        assert!(!set.contains(username), "do not list $username in $groups");
 
-    /// spawns the command in the specified test environment
-    pub fn spawn(&self, env: &Env) -> Result<Child> {
-        if let Some(username) = self.get_user() {
-            assert!(
-                env.users.contains(username),
-                "tried to exec as non-existent user: {username}"
-            );
-        }
+        self.username_to_groups.insert(username.to_string(), set);
 
-        env.container.spawn(self)
+        self
     }
-}
 
-/// test environment builder
-#[derive(Default)]
-pub struct EnvBuilder {
-    files: HashMap<AbsolutePath, TextFile>,
-    groups: HashMap<Groupname, Group>,
-    users: HashMap<Username, User>,
-}
+    /// 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"
+        );
 
-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.files.contains_key(path),
-            "file at {path} has already been declared"
+            !self.user_id_to_username.contains_left(&user_id),
+            "user ID {user_id} has already been assigned"
         );
 
-        self.files.insert(path.to_string(), file.into());
+        self.user_id_to_username
+            .insert(user_id, username.to_string());
 
         self
     }
 
-    /// 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;
+    /// 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"
+        );
+
         assert!(
-            !self.groups.contains_key(groupname),
-            "group {} has already been declared",
-            groupname
+            !self.group_id_to_groupname.contains_left(&group_id),
+            "group ID {group_id} has already been assigned"
         );
-        self.groups.insert(groupname.to_string(), group);
 
+        self.group_id_to_groupname
+            .insert(group_id, groupname.to_string());
+
+        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
     }
 
-    /// adds the specified `user` to the test environment
+    /// appends content to the `/etc/sudoers` file
     ///
-    /// # Panics
+    /// 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 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;
+    /// 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
+    ///
+    /// 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");
+
         assert!(
-            !self.users.contains_key(username),
-            "user {} has already been declared",
-            username
+            !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.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(|| {
-            docker::build_base_image().expect("fatal error: could not build the base Docker image")
+            build_base_image().expect("fatal error: could not build the base Docker image")
         });
 
         let container = Container::new(BASE_IMAGE)?;
 
-        let (mut usernames, user_ids) = getent_passwd(&container)?;
+        let mut users = get_users(&container)?;
 
-        for new_user in self.users.values() {
+        for username in &users {
             assert!(
-                !usernames.contains(&new_user.name),
-                "user {} already exists in base image",
-                new_user.name
+                !self.user_id_to_username.contains_right(username),
+                "cannot override the user ID of system 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"
-                );
-            }
+        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}"
+            );
         }
 
-        let (groupnames, group_ids) = getent_group(&container)?;
+        let mut groups = get_groups(&container)?;
 
-        for new_group in self.groups.values() {
+        for groupname in &groups {
             assert!(
-                !groupnames.contains(&new_group.name),
-                "group {} already exists in base image",
-                new_group.name
+                !self.group_id_to_groupname.contains_right(groupname),
+                "cannot override the group ID of system 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"
-                );
-            }
+        for groupname in self.group_id_to_groupname.right_values() {
+            assert!(
+                !self.username_to_groups.contains_key(groupname),
+                "cannot assign group ID to the implicit user group {groupname}"
+            );
+
+            assert!(
+                self.username_to_groups
+                    .values()
+                    .any(|groups| groups.contains(groupname)),
+                "cannot assign group ID to non-existent group {groupname}"
+            );
         }
 
+        // 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 in self.groups.values().filter(|group| group.id.is_some()) {
-            group.create(&container)?;
+        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());
         }
 
         // .. with groups that get assigned IDs dynamically
-        for group in self.groups.values().filter(|group| group.id.is_none()) {
-            group.create(&container)?;
+        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());
+                }
+            }
         }
 
         // create users with known IDs first to avoid collisions ..
-        for user in self.users.values().filter(|user| user.id.is_some()) {
-            user.create(&container)?;
-            usernames.insert(user.name.to_string());
-        }
+        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(),
+            };
 
-        // .. with users that get assigned IDs dynamically
-        for user in self.users.values().filter(|user| user.id.is_none()) {
             user.create(&container)?;
-            usernames.insert(user.name.to_string());
-        }
 
-        for (path, file) in &self.files {
-            file.create(path, &container)?;
+            users.insert(username.to_string());
+            groups.insert(username.to_string());
         }
 
-        Ok(Env {
-            container,
-            users: usernames,
-        })
-    }
-}
+        // .. with users that get assigned IDs dynamically
+        for (username, user_groups) in &self.username_to_groups {
+            if users.contains(username) {
+                continue;
+            }
 
-/// a user
-pub struct User {
-    name: Username,
+            let user = User {
+                name: username,
+                id: None,
+                groups: user_groups.clone(),
+            };
 
-    groups: HashSet<Groupname>,
-    id: Option<u32>,
-    password: Option<String>,
-}
+            user.create(&container)?;
 
-/// creates a new user with the specified `name`
-#[allow(non_snake_case)]
-pub fn User(name: impl AsRef<str>) -> User {
-    name.as_ref().into()
-}
+            users.insert(username.to_string());
+            groups.insert(username.to_string());
+        }
 
-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
-        );
+        for (username, password) in &self.username_to_passwords {
+            assert!(
+                users.contains(username),
+                "cannot assign password to non-existing user: {username}"
+            );
 
-        self.groups.insert(groupname.to_string());
+            let stdin = format!("{username}:{password}");
+            container.stdout(&["chpasswd"], As::Root, Some(&stdin))?;
+        }
 
-        self
-    }
+        for (path, text_file) in &self.text_files {
+            container.cp(path, &text_file.contents)?;
 
-    /// 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);
+            container.stdout(&["chown", &text_file.chown, path], As::Root, None)?;
+            container.stdout(&["chmod", &text_file.chmod, path], As::Root, None)?;
         }
-        self
-    }
 
-    /// 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
-    }
-
-    /// 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
+        Ok(Env { container, users })
     }
+}
 
-    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()?;
+enum SudoUnderTest {
+    Ours,
+    Theirs,
+}
 
-        if let Some(password) = &self.password {
-            container
-                .exec(Command::new("chpasswd").stdin(format!("{}:{password}", self.name)))?
-                .assert_success()?;
+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)
         }
-
-        Ok(())
     }
 }
 
-impl From<String> for User {
-    fn from(name: String) -> Self {
-        assert!(!name.is_empty(), "user name cannot be an empty string");
+fn build_base_image() -> Result<()> {
+    let repo_root = repo_root();
+    let mut cmd = Command::new("docker");
+
+    cmd.args(["build", "-t", BASE_IMAGE]);
 
-        Self {
-            name,
-            groups: HashSet::new(),
-            id: None,
-            password: None,
+    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", "."]);
         }
-    }
-}
 
-impl From<&'_ str> for User {
-    fn from(name: &'_ str) -> Self {
-        name.to_string().into()
+        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));
+        }
     }
-}
 
-/// a group
-pub struct Group {
-    name: Groupname,
+    helpers::stdout(&mut cmd, None)?;
 
-    id: Option<u32>,
+    Ok(())
 }
 
-/// creates a group with the specified `name`
-#[allow(non_snake_case)]
-pub fn Group(name: impl AsRef<str>) -> Group {
-    name.as_ref().into()
+fn repo_root() -> PathBuf {
+    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+    path.pop();
+    path.pop();
+    path
 }
 
-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());
+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());
         }
-        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");
 
-        Self { name, id: None }
-    }
+    Ok(groups)
 }
 
-impl From<&'_ str> for Group {
-    fn from(name: &'_ str) -> Self {
-        name.to_string().into()
+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());
+        }
     }
-}
 
-/// a text file
-pub struct TextFile {
-    contents: String,
-
-    chmod: String,
-    chown: String,
+    Ok(users)
 }
 
-/// creates a text file with the specified `contents`
-#[allow(non_snake_case)]
-pub fn TextFile(contents: impl AsRef<str>) -> TextFile {
-    contents.as_ref().into()
+/// test environment
+pub struct Env {
+    container: Container,
+    users: HashSet<String>,
 }
 
-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
-    }
+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"
+            );
+        }
 
-    /// 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
+        self.container.exec(cmd, user, stdin)
     }
 
-    fn create(&self, path: &str, container: &Container) -> Result<()> {
-        container.cp(path, &self.contents)?;
+    /// 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"
+            );
+        }
 
-        container
-            .exec(Command::new("chown").args([&self.chown, path]))?
-            .assert_success()?;
-        container
-            .exec(Command::new("chmod").args([&self.chmod, path]))?
-            .assert_success()
+        self.container.stdout(cmd, user, stdin)
     }
 }
 
-impl From<String> for TextFile {
-    fn from(contents: String) -> Self {
-        Self {
-            contents,
-            chmod: Self::DEFAULT_CHMOD.to_string(),
-            chown: Self::DEFAULT_CHOWN.to_string(),
-        }
-    }
+struct User<'a> {
+    name: &'a str,
+    id: Option<u32>,
+    groups: HashSet<String>,
 }
 
-impl From<&'_ str> for TextFile {
-    fn from(contents: &'_ str) -> Self {
-        contents.to_string().into()
-    }
-}
+impl User<'_> {
+    fn create(&self, container: &Container) -> Result<()> {
+        let mut cmd = vec!["useradd", "-m"];
 
-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());
-            }
+        let id_as_string;
+        if let Some(id) = self.id {
+            id_as_string = id.to_string();
+            cmd.extend_from_slice(&["-u", &id_as_string]);
         }
-    }
-
-    Ok((groupnames, group_ids))
-}
 
-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());
-            }
+        let group_list;
+        if !self.groups.is_empty() {
+            group_list = self.groups.iter().cloned().collect::<Vec<_>>().join(",");
+            cmd.extend_from_slice(&["-G", &group_list]);
         }
-    }
 
-    Ok((usernames, user_ids))
+        cmd.push(self.name);
+
+        container.stdout(&cmd, As::Root, None)?;
+
+        Ok(())
+    }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
-    const USERNAME: &str = "ferris";
-    const GROUPNAME: &str = "rustaceans";
-
     #[test]
     fn group_creation_works() -> Result<()> {
-        let env = EnvBuilder::default().group(GROUPNAME).build()?;
+        let username = "ferris";
+        let groupname = "rustaceans";
+        let env = EnvBuilder::default().user(username, &[groupname]).build()?;
 
-        let groupnames = getent_group(&env.container)?.0;
-        assert!(groupnames.contains(GROUPNAME));
+        let groups = get_groups(&env.container)?;
+        assert!(groups.contains(groupname));
 
         Ok(())
     }
 
     #[test]
     fn user_creation_works() -> Result<()> {
-        let env = EnvBuilder::default().user(USERNAME).build()?;
+        let new_user = "ferris";
+        let env = EnvBuilder::default().user(new_user, &[]).build()?;
 
-        let usernames = getent_passwd(&env.container)?.0;
-        assert!(usernames.contains(USERNAME));
+        let users = get_users(&env.container)?;
+        assert!(users.contains(new_user));
 
         Ok(())
     }
 
     #[test]
-    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());
+    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());
+
         Ok(())
     }
 
     #[test]
-    fn no_implicit_user_group_creation() -> Result<()> {
-        let env = EnvBuilder::default().user(USERNAME).build()?;
+    fn created_user_belongs_to_group_named_after_themselves() -> Result<()> {
+        let new_user = "ferris";
+        let env = EnvBuilder::default().user(new_user, &[]).build()?;
 
-        let stdout = Command::new("groups")
-            .as_user(USERNAME)
-            .exec(&env)?
-            .stdout()?;
-        let groups = stdout.split(' ').collect::<HashSet<_>>();
-        assert!(!groups.contains(USERNAME));
+        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));
 
         Ok(())
     }
 
     #[test]
-    fn no_password_by_default() -> Result<()> {
-        let env = EnvBuilder::default().user(USERNAME).build()?;
+    fn creating_user_part_of_existing_group_works() -> Result<()> {
+        let user = "ferris";
+        let group = "users";
+        let env = EnvBuilder::default().user(user, &[group]).build()?;
 
-        let stdout = Command::new("passwd")
-            .args(["--status", USERNAME])
-            .exec(&env)?
-            .stdout()?;
+        let output = env.exec(&["groups"], As::User { name: user }, None)?;
+        assert!(output.status.success());
 
-        assert!(stdout.starts_with(&format!("{USERNAME} L")));
+        let user_groups = output.stdout.split(' ').collect::<HashSet<_>>();
+        dbg!(&user_groups);
+        assert!(user_groups.contains(group));
 
         Ok(())
     }
 
     #[test]
-    fn password_assignment_works() -> Result<()> {
-        let password = "strong-password";
-        let env = Env("ALL ALL=(ALL:ALL) ALL")
-            .user(User(USERNAME).password(password))
-            .build()?;
+    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());
 
-        Command::new("sudo")
-            .args(["-S", "true"])
-            .as_user(USERNAME)
-            .stdin(password)
-            .exec(&env)?
-            .assert_success()
+        let actual = output.stdout;
+        assert_eq!(expected, actual);
+
+        Ok(())
     }
 
     #[test]
-    fn creating_user_part_of_existing_group_works() -> Result<()> {
-        let groupname = "users";
-        let env = EnvBuilder::default()
-            .user(User(USERNAME).group(groupname))
-            .build()?;
+    fn default_pam_d_sudo() -> Result<()> {
+        let env = EnvBuilder::default().build()?;
 
-        let stdout = Command::new("groups")
-            .as_user(USERNAME)
-            .exec(&env)?
-            .stdout()?;
-        let user_groups = stdout.split(' ').collect::<HashSet<_>>();
-        assert!(user_groups.contains(groupname));
+        let actual = env.stdout(&["cat", "/etc/pam.d/sudo"], As::Root, None)?;
+        let expected = EnvBuilder::DEFAULT_PAM_D_SUDO;
 
+        assert_eq!(expected, actual);
         Ok(())
     }
 
     #[test]
-    fn sudoers_file_get_created_with_expected_contents() -> Result<()> {
-        let expected = "Hello, root!";
-        let env = Env(expected).build()?;
+    fn overwrite_pam_d_sudo() -> Result<()> {
+        let expected = "invalid pam.d file";
+        let env = EnvBuilder::default().pam_d_sudo(expected).build()?;
 
-        let actual = Command::new("cat")
-            .arg("/etc/sudoers")
-            .exec(&env)?
-            .stdout()?;
-        assert_eq!(expected, actual);
+        let actual = env.stdout(&["cat", "/etc/pam.d/sudo"], As::Root, None)?;
 
+        assert_eq!(expected, actual);
         Ok(())
     }
 
     #[test]
     fn text_file_gets_created_with_right_perms() -> Result<()> {
-        let chown = format!("{USERNAME}:{GROUPNAME}");
+        let chown = "ferris:ferris";
         let chmod = "600";
         let expected_contents = "hello";
         let path = "/root/file";
         let env = EnvBuilder::default()
-            .user(USERNAME)
-            .group(GROUPNAME)
-            .file(path, TextFile(expected_contents).chown(chown).chmod(chmod))
+            .user("ferris", &[])
+            .text_file(path, chown, chmod, expected_contents)
             .build()?;
 
-        let actual_contents = Command::new("cat").arg(path).exec(&env)?.stdout()?;
+        let actual_contents = env.stdout(&["cat", path], As::Root, None)?;
         assert_eq!(expected_contents, &actual_contents);
 
-        let ls_l = Command::new("ls").args(["-l", path]).exec(&env)?.stdout()?;
+        let ls_l = env.stdout(&["ls", "-l", path], As::Root, None)?;
         assert!(ls_l.starts_with("-rw-------"));
-        assert!(ls_l.contains(&format!("{USERNAME} {GROUPNAME}")));
+        assert!(ls_l.contains("ferris ferris"));
 
         Ok(())
     }
 
     #[test]
-    #[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();
+    #[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();
     }
 
     #[test]
-    #[should_panic = "user ID 0 already exists in base image"]
-    fn cannot_assign_user_id_that_already_exists_in_base_image() {
+    #[should_panic = "cannot override the group ID of system group root"]
+    fn override_root_group_id_fails() {
         EnvBuilder::default()
-            .user(User(USERNAME).id(0))
+            .group_id("root", 1000)
             .build()
             .unwrap();
     }
 
     #[test]
-    #[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();
+    #[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();
     }
 
     #[test]
-    #[should_panic = "group ID 0 already exists in base image"]
-    fn cannot_assign_group_id_that_already_exists_in_base_image() {
+    #[should_panic = "cannot assign group ID to the implicit user group ferris"]
+    fn set_group_id_of_implicit_user_group_fails() {
         EnvBuilder::default()
-            .group(Group(GROUPNAME).id(0))
+            .user("ferris", &[])
+            .group_id("ferris", 1000)
             .build()
             .unwrap();
     }
 
     #[test]
-    fn setting_user_id_works() -> Result<()> {
+    #[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<()> {
         let expected = 1023;
+        let username = "ferris";
         let env = EnvBuilder::default()
-            .user(User(USERNAME).id(expected))
+            .user(username, &[])
+            .user_id(username, expected)
             .build()?;
 
-        let actual = Command::new("id")
-            .args(["-u", USERNAME])
-            .exec(&env)?
-            .stdout()?
+        let actual = env
+            .stdout(&["id", "-u", username], As::Root, None)?
             .parse()?;
         assert_eq!(expected, actual);
-
         Ok(())
     }
 
     #[test]
-    fn setting_group_id_works() -> Result<()> {
+    fn group_id_override_works() -> Result<()> {
         let expected = 1023;
+        let username = "ferris";
+        let groupname = "rustaceans";
         let env = EnvBuilder::default()
-            .group(Group(GROUPNAME).id(expected))
+            .user(username, &[groupname])
+            .group_id(groupname, expected)
             .build()?;
 
-        let stdout = Command::new("getent")
-            .args(["group", GROUPNAME])
-            .exec(&env)?
-            .stdout()?;
+        let stdout = env.stdout(&["getent", "group", groupname], As::Root, None)?;
         let actual = stdout.split(':').nth(2);
         assert_eq!(Some(expected.to_string().as_str()), actual);
-
         Ok(())
     }
 }