Repository: memorysafety/sudo-rs
Pull request #73 (2023-03-09 17:22:13)

fix bug in checkpermit program
This is a minor fix for something introduced in 7ff66fb3ac42f9587a5870a5f308ce9cce3eeba3


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index a944924b..bbfb9633 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -9,6 +9,31 @@ on:
       - main
 
 jobs:
+  compliance-tests:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Install rust
+        uses: actions-rs/toolchain@v1
+        with:
+          toolchain: stable
+          override: true
+
+      - name: Test sudo-test itself
+        working-directory: test-framework
+        run: cargo test -p sudo-test
+
+      - name: Test sudo-rs
+        working-directory: test-framework
+        env:
+          SUDO_UNDER_TEST: ours
+        run: cargo test -p sudo-compliance-tests || true
+
+      - name: Test original sudo
+        working-directory: test-framework
+        run: cargo test -p sudo-compliance-tests
+
   build-and-test:
     runs-on: ubuntu-latest
 
diff --git a/Cargo.lock b/Cargo.lock
index f58bc85c..3b19a06b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -482,6 +482,7 @@ dependencies = [
  "sudo-cli",
  "sudo-pam",
  "sudo-system",
+ "sudoers",
  "thiserror",
  "which",
 ]
diff --git a/lib/sudo-common/Cargo.toml b/lib/sudo-common/Cargo.toml
index 4772ef86..da22a675 100644
--- a/lib/sudo-common/Cargo.toml
+++ b/lib/sudo-common/Cargo.toml
@@ -12,6 +12,7 @@ build = "build.rs"
 sudo-cli.workspace = true
 sudo-pam.workspace = true
 sudo-system.workspace = true
+sudoers.workspace = true
 libc.workspace = true
 thiserror.workspace = true
 which.workspace = true
diff --git a/lib/sudo-common/src/context.rs b/lib/sudo-common/src/context.rs
index 2e079ca3..e2a79c6e 100644
--- a/lib/sudo-common/src/context.rs
+++ b/lib/sudo-common/src/context.rs
@@ -1,6 +1,7 @@
-use std::{path::PathBuf, str::FromStr};
+use std::{collections::HashSet, path::PathBuf, str::FromStr};
 use sudo_cli::SudoOptions;
 use sudo_system::{hostname, Group, User};
+use sudoers::Settings;
 
 use crate::{env::Environment, error::Error};
 
@@ -10,6 +11,16 @@ pub struct CommandAndArguments<'a> {
     pub arguments: Vec<&'a str>,
 }
 
+impl<'a> ToString for CommandAndArguments<'a> {
+    fn to_string(&self) -> String {
+        format!(
+            "{} {}",
+            self.command.to_string_lossy(),
+            self.arguments.join(" ")
+        )
+    }
+}
+
 impl<'a> TryFrom<&'a [String]> for CommandAndArguments<'a> {
     type Error = Error;
 
@@ -47,45 +58,108 @@ impl<'a, T: FromStr> NameOrId<'a, T> {
 
 #[derive(Debug)]
 pub struct Context<'a> {
-    pub preserve_env: bool,
+    // cli options
     pub preserve_env_list: Vec<String>,
     pub set_home: bool,
+    pub login: bool,
+    pub shell: bool,
+    pub chdir: Option<PathBuf>,
     pub command: CommandAndArguments<'a>,
-    pub hostname: String,
-    pub current_user: User,
     pub target_user: User,
     pub target_group: Group,
+    // configuration
+    pub env_delete: &'a HashSet<String>,
+    pub env_keep: &'a HashSet<String>,
+    pub env_check: &'a HashSet<String>,
+    pub always_set_home: bool,
+    pub use_pty: bool,
+    // system
+    pub hostname: String,
+    pub current_user: User,
+    // computed
     pub target_environment: Environment,
 }
 
+pub trait Configuration {
+    fn env_delete(&self) -> &HashSet<String>;
+    fn env_keep(&self) -> &HashSet<String>;
+    fn env_check(&self) -> &HashSet<String>;
+    fn always_set_home(&self) -> bool;
+    fn use_pty(&self) -> bool;
+}
+
+impl Configuration for Settings {
+    fn env_delete(&self) -> &HashSet<String> {
+        self.list
+            .get("env_delete")
+            .expect("env_delete missing from settings")
+    }
+
+    fn env_keep(&self) -> &HashSet<String> {
+        self.list
+            .get("env_keep")
+            .expect("env_keep missing from settings")
+    }
+
+    fn env_check(&self) -> &HashSet<String> {
+        self.list
+            .get("env_check")
+            .expect("env_check missing from settings")
+    }
+
+    fn always_set_home(&self) -> bool {
+        self.flags.contains("always_set_home")
+    }
+
+    fn use_pty(&self) -> bool {
+        self.flags.contains("use_pty")
+    }
+}
+
+fn resolve_current_user() -> Result<User, Error> {
+    User::real()?.ok_or(Error::UserNotFound("current user".to_string()))
+}
+
+fn resolve_target_user(target_name_or_id: &Option<String>) -> Result<User, Error> {
+    let target_name_or_id = target_name_or_id.as_deref().unwrap_or("root");
+
+    match NameOrId::parse(target_name_or_id) {
+        Some(NameOrId::Name(name)) => User::from_name(name)?,
+        Some(NameOrId::Id(uid)) => User::from_uid(uid)?,
+        _ => None,
+    }
+    .ok_or_else(|| Error::UserNotFound(target_name_or_id.to_string()))
+}
+
+fn resolve_target_group(
+    target_name_or_id: &Option<String>,
+    target_user: &User,
+) -> Result<Group, Error> {
+    match target_name_or_id.as_deref() {
+        Some(name_or_id) => match NameOrId::parse(name_or_id) {
+            Some(NameOrId::Name(name)) => Group::from_name(name)?,
+            Some(NameOrId::Id(gid)) => Group::from_gid(gid)?,
+            _ => None,
+        },
+        None => Group::from_gid(target_user.gid)?,
+    }
+    .ok_or(Error::GroupNotFound(
+        target_name_or_id
+            .clone()
+            .unwrap_or_else(|| target_user.gid.to_string()),
+    ))
+}
+
 impl<'a> Context<'a> {
-    pub fn build_from_options(sudo_options: &SudoOptions) -> Result<Context, Error> {
+    pub fn build_from_options(
+        sudo_options: &'a SudoOptions,
+        settings: &'a Settings,
+    ) -> Result<Context<'a>, Error> {
         let command = CommandAndArguments::try_from(sudo_options.external_args.as_slice())?;
         let hostname = hostname();
-        let current_user = User::real()?.ok_or(Error::UserNotFound("current user".to_string()))?;
-
-        let target_name_or_id = sudo_options.user.as_deref().unwrap_or("root");
-        let target_user = match NameOrId::parse(target_name_or_id) {
-            Some(NameOrId::Name(name)) => User::from_name(name)?,
-            Some(NameOrId::Id(uid)) => User::from_uid(uid)?,
-            _ => None,
-        }
-        .ok_or_else(|| Error::UserNotFound(target_name_or_id.to_string()))?;
-
-        let target_group = match sudo_options.group.as_deref() {
-            Some(name_or_id) => match NameOrId::parse(name_or_id) {
-                Some(NameOrId::Name(name)) => Group::from_name(name)?,
-                Some(NameOrId::Id(gid)) => Group::from_gid(gid)?,
-                _ => None,
-            },
-            None => Group::from_gid(target_user.gid)?,
-        }
-        .ok_or(Error::GroupNotFound(
-            sudo_options
-                .group
-                .clone()
-                .unwrap_or_else(|| target_user.gid.to_string()),
-        ))?;
+        let current_user = resolve_current_user()?;
+        let target_user = resolve_target_user(&sudo_options.user)?;
+        let target_group = resolve_target_group(&sudo_options.group, &target_user)?;
 
         Ok(Context {
             hostname,
@@ -94,9 +168,16 @@ impl<'a> Context<'a> {
             target_user,
             target_group,
             target_environment: Default::default(),
-            preserve_env: sudo_options.preserve_env,
             set_home: sudo_options.set_home,
             preserve_env_list: sudo_options.preserve_env_list.clone(),
+            login: sudo_options.login,
+            shell: sudo_options.shell,
+            chdir: sudo_options.directory.clone(),
+            env_delete: settings.env_delete(),
+            env_keep: settings.env_keep(),
+            env_check: settings.env_check(),
+            always_set_home: settings.always_set_home(),
+            use_pty: settings.use_pty(),
         })
     }
 
@@ -113,6 +194,7 @@ mod tests {
 
     use super::Context;
     use sudo_cli::SudoOptions;
+    use sudoers::Settings;
 
     #[test]
     fn test_build_context() {
@@ -121,7 +203,8 @@ mod tests {
         let mut current_env = HashMap::new();
         current_env.insert("FOO".to_string(), "BAR".to_string());
 
-        let context = Context::build_from_options(&options)
+        let settings = Settings::default();
+        let context = Context::build_from_options(&options, &settings)
             .unwrap()
             .with_filtered_env(current_env);
 
diff --git a/lib/sudo-common/src/env.rs b/lib/sudo-common/src/env.rs
index d542266e..76bf0978 100644
--- a/lib/sudo-common/src/env.rs
+++ b/lib/sudo-common/src/env.rs
@@ -2,7 +2,7 @@ use crate::{
     context::{CommandAndArguments, Context},
     wildcard_match::wildcard_match,
 };
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use sudo_system::PATH_MAX;
 
 pub type Environment = HashMap<String, String>;
@@ -10,32 +10,6 @@ pub type Environment = HashMap<String, String>;
 const PATH_MAILDIR: &str = env!("PATH_MAILDIR");
 const PATH_ZONEINFO: &str = env!("PATH_ZONEINFO");
 
-/// Remove if these environment variables if the value contains '/' or '%'
-const CHECK_ENV_TABLE: &[&str] = &[
-    "COLORTERM",
-    "LANG",
-    "LANGUAGE",
-    "LC_*",
-    "LINGUAS",
-    "TERM",
-    "TZ",
-];
-
-/// Keep these environment variables by default
-const KEEP_ENV_TABLE: &[&str] = &[
-    "COLORS",
-    "DISPLAY",
-    "HOSTNAME",
-    "KRB5CCNAME",
-    "LS_COLORS",
-    "PATH",
-    "PS1",
-    "PS2",
-    "XAUTHORITY",
-    "XAUTHORIZATION",
-    "XDG_CURRENT_DESKTOP",
-];
-
 /// Convert a list of `Into<String>` key value pars to an Environment
 pub fn environment_from_list<K: Into<String>, V: Into<String>>(list: Vec<(K, V)>) -> Environment {
     list.into_iter()
@@ -66,25 +40,35 @@ fn format_command(command_and_arguments: &CommandAndArguments) -> String {
 
 /// Construct sudo-specific environment variables
 fn get_extra_env(context: &Context) -> Environment {
-    environment_from_list(vec![
+    let mut extra = vec![
         ("SUDO_COMMAND", format_command(&context.command)),
         ("SUDO_UID", context.current_user.uid.to_string()),
         ("SUDO_GID", context.current_user.gid.to_string()),
         ("SUDO_USER", context.current_user.name.clone()),
-        // TODO: preserve exsisting when sudo -s
-        ("SHELL", context.target_user.shell.clone()),
-        // TODO: Set to the login name of the target user when the -i option is specified,
-        // when the set_logname option is enabled in sudoers, or when the env_reset option
-        // is enabled in sudoers (unless LOGNAME is present in the env_keep list).
-        ("LOGNAME", context.target_user.name.clone()),
-        ("USER", context.target_user.name.clone()),
-        // TODO: check home dir config + options
-        ("HOME", context.target_user.home.clone()),
         (
             "MAIL",
             format!("{PATH_MAILDIR}/{}", context.target_user.name),
         ),
-    ])
+    ];
+
+    // TODO: preserve exsisting when sudo -s
+    if !context.shell {
+        extra.push(("SHELL", context.target_user.shell.clone()));
+    }
+
+    // TODO: Set to the login name of the target user when the -i option is specified,
+    // when the set_logname option is enabled in sudoers, or when the env_reset option
+    // is enabled in sudoers (unless LOGNAME is present in the env_keep list).
+    if context.login {
+        extra.push(("LOGNAME", context.target_user.name.clone()));
+        extra.push(("USER", context.target_user.name.clone()));
+    }
+
+    if context.always_set_home || context.set_home {
+        extra.push(("HOME", context.target_user.home.clone()));
+    }
+
+    environment_from_list(extra)
 }
 
 /// Check a string only contains printable (non-space) characters
@@ -120,14 +104,14 @@ fn is_safe_tz(value: &str) -> bool {
 }
 
 /// Check whether the needle exists in a haystack, in which the haystack is a list of patterns, possibly containing wildcards
-fn in_table(needle: &str, haystack: &[&str]) -> bool {
+fn in_table(needle: &str, haystack: &HashSet<String>) -> bool {
     haystack
         .iter()
         .any(|pattern| wildcard_match(needle, pattern))
 }
 
 /// Determine whether a specific environment variable should be kept
-fn should_keep(key: &str, value: &str, check_env: &[&str], keep_env: &[&str]) -> bool {
+fn should_keep(key: &str, value: &str, context: &Context) -> bool {
     if value.starts_with("()") {
         return false;
     }
@@ -136,11 +120,11 @@ fn should_keep(key: &str, value: &str, check_env: &[&str], keep_env: &[&str]) ->
         return false;
     }
 
-    if in_table(key, check_env) && !value.contains(|c| c == '%' || c == '/') {
+    if in_table(key, context.env_check) && !value.contains(|c| c == '%' || c == '/') {
         return true;
     }
 
-    in_table(key, keep_env)
+    in_table(key, context.env_keep)
 }
 
 /// Construct the final environment from the current one and a sudo context
@@ -160,7 +144,7 @@ pub fn get_target_environment(current_env: Environment, context: &Context) -> En
     let mut result = Environment::new();
 
     for (key, value) in current_env.into_iter() {
-        if should_keep(&key, &value, CHECK_ENV_TABLE, KEEP_ENV_TABLE) {
+        if should_keep(&key, &value, context) {
             result.insert(key, value);
         }
     }
diff --git a/sudo/src/main.rs b/sudo/src/main.rs
index 54db2602..3ce70087 100644
--- a/sudo/src/main.rs
+++ b/sudo/src/main.rs
@@ -2,10 +2,9 @@
 
 use sudo_cli::SudoOptions;
 use sudo_common::{context::Context, env::Environment, error::Error, pam::authenticate};
-use sudoers::Tag;
+use sudoers::{Sudoers, Tag};
 
-/// parse suoers file and check permission to run the provided command given the context
-fn check_sudoers(context: &Context, sudo_options: &SudoOptions) -> Result<Option<Vec<Tag>>, Error> {
+fn parse_sudoers() -> Result<Sudoers, Error> {
     // TODO: move to global configuration
     let sudoers_path = "/etc/sudoers.test";
 
@@ -16,15 +15,20 @@ fn check_sudoers(context: &Context, sudo_options: &SudoOptions) -> Result<Option
         eprintln!("Parse error: {error}");
     }
 
+    Ok(sudoers)
+}
+
+/// parse suoers file and check permission to run the provided command given the context
+fn check_sudoers(sudoers: &Sudoers, context: &Context) -> Result<Option<Vec<Tag>>, Error> {
     Ok(sudoers::check_permission(
-        &sudoers,
+        sudoers,
         &context.current_user,
         sudoers::Request {
             user: &context.target_user,
             group: &context.target_group,
         },
         &context.hostname,
-        &sudo_options.external_args.join(" "),
+        context.command.to_string().as_str(),
     ))
 }
 
@@ -32,12 +36,16 @@ fn main() -> Result<(), Error> {
     // parse cli options
     let sudo_options = SudoOptions::parse();
 
+    // parse sudoers file
+    let sudoers = parse_sudoers()?;
+
     // build context and environment
     let current_env = std::env::vars().collect::<Environment>();
-    let context = Context::build_from_options(&sudo_options)?.with_filtered_env(current_env);
+    let context = Context::build_from_options(&sudo_options, &sudoers.settings)?
+        .with_filtered_env(current_env);
 
     // check sudoers file for permission
-    match check_sudoers(&context, &sudo_options)? {
+    match check_sudoers(&sudoers, &context)? {
         Some(tags) => {
             if !tags.contains(&Tag::NoPasswd) {
                 // authenticate user using pam


Test patch:
diff --git a/lib/sudo-common/tests/env_tests.rs b/lib/sudo-common/tests/env_tests.rs
index d9ebf8fd..6b7c5d49 100644
--- a/lib/sudo-common/tests/env_tests.rs
+++ b/lib/sudo-common/tests/env_tests.rs
@@ -1,11 +1,11 @@
 use std::collections::HashSet;
-
 use sudo_cli::SudoOptions;
 use sudo_common::{
-    context::{CommandAndArguments, Context},
+    context::{CommandAndArguments, Configuration, Context},
     env::{get_target_environment, Environment},
 };
 use sudo_system::{Group, User};
+use sudoers::Settings;
 
 const TESTS: &str = "
 > env
@@ -22,12 +22,10 @@ const TESTS: &str = "
     TERM=xterm
     _=/usr/bin/sudo
 > sudo env
-    HOME=/root
     HOSTNAME=test-ubuntu
     LANG=en_US.UTF-8
     LANGUAGE=en_US.UTF-8
     LC_ALL=en_US.UTF-8
-    LOGNAME=root
     LS_COLORS=cd=40;33;01:*.jpg=01;35:*.mp3=00;36:
     MAIL=/var/mail/root
     PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@@ -37,14 +35,11 @@ const TESTS: &str = "
     SUDO_UID=1000
     SUDO_USER=test
     TERM=xterm
-    USER=root
 > sudo -u test env
-    HOME=/home/test
     HOSTNAME=test-ubuntu
     LANG=en_US.UTF-8
     LANGUAGE=en_US.UTF-8
     LC_ALL=en_US.UTF-8
-    LOGNAME=test
     LS_COLORS=cd=40;33;01:*.jpg=01;35:*.mp3=00;36:
     MAIL=/var/mail/test
     PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@@ -54,7 +49,6 @@ const TESTS: &str = "
     SUDO_UID=1000
     SUDO_USER=test
     TERM=xterm
-    USER=test
 ";
 
 fn parse_env_commands(input: &str) -> Vec<(&str, Environment)> {
@@ -76,7 +70,7 @@ fn parse_env_commands(input: &str) -> Vec<(&str, Environment)> {
         .collect()
 }
 
-fn create_test_context(sudo_options: &SudoOptions) -> Context {
+fn create_test_context<'a>(sudo_options: &'a SudoOptions, settings: &'a Settings) -> Context<'a> {
     let command = CommandAndArguments::try_from(sudo_options.external_args.as_slice()).unwrap();
 
     let current_user = User {
@@ -130,9 +124,16 @@ fn create_test_context(sudo_options: &SudoOptions) -> Context {
             root_group
         },
         target_environment: Default::default(),
-        preserve_env: sudo_options.preserve_env,
         set_home: sudo_options.set_home,
         preserve_env_list: sudo_options.preserve_env_list.clone(),
+        login: sudo_options.login,
+        shell: sudo_options.shell,
+        chdir: sudo_options.directory.clone(),
+        env_delete: settings.env_delete(),
+        env_keep: settings.env_keep(),
+        env_check: settings.env_check(),
+        always_set_home: settings.always_set_home(),
+        use_pty: settings.use_pty(),
     }
 }
 
@@ -147,7 +148,8 @@ fn test_environment_variable_filtering() {
 
     for (cmd, expected_env) in parts {
         let options = SudoOptions::try_parse_from(cmd.split_whitespace()).unwrap();
-        let context = create_test_context(&options);
+        let settings = Settings::default();
+        let context = create_test_context(&options, &settings);
         let resulting_env = get_target_environment(initial_env.clone(), &context);
 
         let resulting_env = environment_to_set(resulting_env);
diff --git a/test-binaries/checkpermit.rs b/test-binaries/checkpermit.rs
index 51e14ce5..2031de57 100644
--- a/test-binaries/checkpermit.rs
+++ b/test-binaries/checkpermit.rs
@@ -60,6 +60,14 @@ impl PartialEq<UserRecord> for UserRecord {
 impl Eq for UserRecord {}
 
 impl UnixUser for UserRecord {
+    fn has_uid(&self, uid: libc::gid_t) -> bool {
+        self.0 == uid
+    }
+
+    fn has_name(&self, name: &str) -> bool {
+        self.1.as_ref().map_or(false, |s| s == name)
+    }
+
     fn is_root(&self) -> bool {
         self.has_name("root") && self.has_uid(0)
     }
diff --git a/test-framework/.gitignore b/test-framework/.gitignore
new file mode 100644
index 00000000..869df07d
--- /dev/null
+++ b/test-framework/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
\ No newline at end of file
diff --git a/test-framework/Cargo.toml b/test-framework/Cargo.toml
new file mode 100644
index 00000000..dace5b16
--- /dev/null
+++ b/test-framework/Cargo.toml
@@ -0,0 +1,2 @@
+[workspace]
+members = ["sudo-test", "sudo-compliance-tests"]
\ No newline at end of file
diff --git a/test-framework/README.md b/test-framework/README.md
new file mode 100644
index 00000000..1ed7923b
--- /dev/null
+++ b/test-framework/README.md
@@ -0,0 +1,26 @@
+# Compliance tests
+
+This directory contains compliance tests where we check that the `sudo-rs` command line tool behaves as the original `sudo` for the use cases that we support.
+
+## Dependencies
+
+To run these tests you need to have docker and docker-buildx installed and the docker daemon must be running.
+On Arch Linux you can install the relevant packages with the following command:
+
+``` console
+$ sudo pacman -S docker docker-buildx
+```
+
+## Running the tests
+
+To run the compliance tests against the original sudo execute the following command from this directory:
+
+``` console
+$ cargo test -p sudo-compliance-tests
+```
+
+To run the tests against sudo-rs set the `SUDO_UNDER_TEST` variable to `ours` before invoking Cargo:
+
+``` console
+$ SUDO_UNDER_TEST=ours cargo test -p sudo-compliance-tests
+```
diff --git a/test-framework/sudo-compliance-tests/Cargo.toml b/test-framework/sudo-compliance-tests/Cargo.toml
new file mode 100644
index 00000000..0fcce80e
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "sudo-compliance-tests"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+doctest = false
+
+[dev-dependencies]
+pretty_assertions = "1.3.0"
+sudo-test.path = "../sudo-test"
\ No newline at end of file
diff --git a/test-framework/sudo-compliance-tests/src/lib.rs b/test-framework/sudo-compliance-tests/src/lib.rs
new file mode 100644
index 00000000..d2cb4631
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/lib.rs
@@ -0,0 +1,181 @@
+#![cfg(test)]
+
+use std::collections::HashMap;
+
+use pretty_assertions::assert_eq;
+use sudo_test::{As, EnvBuilder};
+
+type Error = Box<dyn std::error::Error>;
+type Result<T> = core::result::Result<T, Error>;
+
+macro_rules! assert_contains {
+    ($haystack:expr, $needle:expr) => {
+        let haystack = &$haystack;
+        let needle = &$needle;
+
+        assert!(
+            haystack.contains(needle),
+            "{haystack:?} did not contain {needle:?}"
+        )
+    };
+}
+
+fn parse_env_output(env_output: &str) -> Result<HashMap<&str, &str>> {
+    let mut env = HashMap::new();
+    for line in env_output.lines() {
+        if let Some((key, value)) = line.split_once('=') {
+            env.insert(key, value);
+        }
+    }
+
+    Ok(env)
+}
+
+#[test]
+fn cannot_sudo_with_empty_sudoers_file() -> Result<()> {
+    let env = EnvBuilder::default().build()?;
+
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert_eq!(Some(1), output.status.code());
+    assert_contains!(output.stderr, "root is not in the sudoers file");
+
+    Ok(())
+}
+
+#[test]
+fn cannot_sudo_if_sudoers_file_is_world_writable() -> Result<()> {
+    let env = EnvBuilder::default().sudoers_chmod("446").build()?;
+
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert_eq!(Some(1), output.status.code());
+    assert_contains!(output.stderr, "/etc/sudoers is world writable");
+
+    Ok(())
+}
+
+#[test]
+fn can_sudo_as_root_if_root_is_in_sudoers_file() -> Result<()> {
+    let env = EnvBuilder::default()
+        .sudoers("root    ALL=(ALL:ALL) ALL")
+        .build()?;
+
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
+}
+
+#[test]
+fn can_sudo_as_user_if_users_group_is_in_sudoers_file_and_password_provided() -> Result<()> {
+    let username = "ferris";
+    let groupname = "rustaceans";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("%{groupname}    ALL=(ALL:ALL) ALL"))
+        .user(username, &[groupname])
+        .user_password(username, password)
+        .build()?;
+
+    let output = env.exec(
+        &["sudo", "-S", "true"],
+        As::User { name: username },
+        Some(password),
+    )?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
+}
+
+#[test]
+fn cannot_sudo_as_user_if_users_group_is_in_sudoers_file_and_password_is_not_provided() -> Result<()>
+{
+    let username = "ferris";
+    let groupname = "rustaceans";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("%{groupname}    ALL=(ALL:ALL) ALL"))
+        .user(username, &[groupname])
+        .user_password(username, password)
+        .build()?;
+
+    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
+    assert_eq!(Some(1), output.status.code());
+    assert_contains!(output.stderr, "no password was provided");
+
+    Ok(())
+}
+
+#[test]
+fn cannot_sudo_if_sudoers_has_invalid_syntax() -> Result<()> {
+    let env = EnvBuilder::default().sudoers("invalid syntax").build()?;
+
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert!(!output.status.success());
+    assert_eq!(Some(1), output.status.code());
+    assert_contains!(output.stderr, "syntax error");
+
+    Ok(())
+}
+
+// see 'environment' section in`man sudo`
+// see 'command environment' section in`man sudoers`
+#[test]
+fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
+    // 'env_reset' is enabled by default
+    let env = EnvBuilder::default()
+        .sudoers("root    ALL=(ALL:ALL) ALL")
+        .build()?;
+
+    let stdout = env.stdout(&["env"], As::Root, None)?;
+    let normal_env = parse_env_output(&stdout)?;
+
+    // run sudo in an empty environment
+    let stdout = env.stdout(&["env", "-i", "sudo", "/usr/bin/env"], As::Root, None)?;
+    let mut sudo_env = parse_env_output(&stdout)?;
+
+    // # man sudo
+    // "Set to the mail spool of the target user"
+    assert_eq!(Some("/var/mail/root"), sudo_env.remove("MAIL"));
+
+    // "Set to the home directory of the target user"
+    assert_eq!(Some("/root"), sudo_env.remove("HOME"));
+
+    // "Set to the login name of the target user"
+    assert_eq!(Some("root"), sudo_env.remove("LOGNAME"));
+
+    // "Set to the command run by sudo, including any args"
+    assert_eq!(Some("/usr/bin/env"), sudo_env.remove("SUDO_COMMAND"));
+
+    // "Set to the group-ID of the user who invoked sudo"
+    assert_eq!(Some("0"), sudo_env.remove("SUDO_GID"));
+
+    // "Set to the user-ID of the user who invoked sudo"
+    assert_eq!(Some("0"), sudo_env.remove("SUDO_UID"));
+
+    // "Set to the login name of the user who invoked sudo"
+    assert_eq!(Some("root"), sudo_env.remove("SUDO_USER"));
+
+    // "Set to the same value as LOGNAME"
+    assert_eq!(Some("root"), sudo_env.remove("USER"));
+
+    // # man sudoers
+    // "The HOME, MAIL, SHELL, LOGNAME and USER environment variables are initialized based on the target user"
+    assert_eq!(Some("/bin/bash"), sudo_env.remove("SHELL"));
+
+    // "If the PATH and TERM variables are not preserved from the user's environment, they will be set to default values."
+    let sudo_path = sudo_env.remove("PATH").expect("PATH not set");
+
+    let normal_path = normal_env["PATH"];
+    assert_ne!(normal_path, sudo_path);
+
+    let default_path = "/usr/bin:/bin:/usr/sbin:/sbin";
+    assert_eq!(default_path, sudo_path);
+
+    let default_term = "unknown";
+    assert_eq!(Some(default_term), sudo_env.remove("TERM"));
+
+    let empty = HashMap::new();
+    assert_eq!(empty, sudo_env);
+
+    Ok(())
+}
diff --git a/test-framework/sudo-test/Cargo.toml b/test-framework/sudo-test/Cargo.toml
new file mode 100644
index 00000000..e11d75c1
--- /dev/null
+++ b/test-framework/sudo-test/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "sudo-test"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+doctest = false
+
+[dependencies]
+tempfile = "3.4.0"
diff --git a/test-framework/sudo-test/src/docker.rs b/test-framework/sudo-test/src/docker.rs
new file mode 100644
index 00000000..92d3e6cf
--- /dev/null
+++ b/test-framework/sudo-test/src/docker.rs
@@ -0,0 +1,201 @@
+use core::str;
+use std::{
+    fs,
+    process::{Command, ExitStatus, Stdio},
+};
+
+use tempfile::NamedTempFile;
+
+use crate::{helpers, Result};
+
+const DEFAULT_COMMAND: &[&str] = &["sleep", "infinity"];
+
+pub struct Container {
+    id: String,
+}
+
+impl Container {
+    pub fn new(image: &str) -> Result<Self> {
+        let mut cmd = Command::new("docker");
+        cmd.args(["run", "-d", "--rm", image]).args(DEFAULT_COMMAND);
+        let id = helpers::stdout(&mut cmd, None)?;
+        validate_docker_id(&id, &cmd)?;
+
+        Ok(Container { id })
+    }
+
+    pub fn exec(
+        &self,
+        cmd: &[impl AsRef<str>],
+        user: As,
+        stdin: Option<&str>,
+    ) -> Result<ExecOutput> {
+        helpers::run(&mut self.docker_cmd(cmd, user, stdin.is_some()), stdin)
+    }
+
+    /// Returns `$cmd`'s stdout if it successfully exists
+    pub fn stdout(&self, cmd: &[impl AsRef<str>], user: As, stdin: Option<&str>) -> Result<String> {
+        helpers::stdout(&mut self.docker_cmd(cmd, user, stdin.is_some()), stdin)
+    }
+
+    fn docker_cmd(&self, cmd: &[impl AsRef<str>], user: As, with_stdin: bool) -> Command {
+        let mut docker_cmd = Command::new("docker");
+        docker_cmd.arg("exec");
+        if with_stdin {
+            docker_cmd.arg("-i");
+        }
+        if let Some(user) = user.as_string() {
+            docker_cmd.arg("--user");
+            docker_cmd.arg(user);
+        }
+        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<()> {
+        let mut temp_file = NamedTempFile::new()?;
+        fs::write(&mut temp_file, file_contents)?;
+
+        let src_path = temp_file.path().display().to_string();
+        let dest_path = format!("{}:{path_in_container}", self.id);
+
+        helpers::stdout(
+            Command::new("docker").args(["cp", &src_path, &dest_path]),
+            None,
+        )?;
+
+        Ok(())
+    }
+}
+
+#[derive(Clone, Copy)]
+pub enum As<'a> {
+    Root,
+    User { name: &'a str },
+    // UserGroup { user: &'a str, group: &'a str },
+}
+
+impl<'a> As<'a> {
+    fn as_string(&self) -> Option<String> {
+        let s = match self {
+            As::Root => return None,
+            As::User { name } => name.to_string(),
+        };
+        Some(s)
+    }
+}
+
+impl Drop for Container {
+    fn drop(&mut self) {
+        // running this to completion would block the current thread for several seconds so just
+        // fire and forget
+        let _ = Command::new("docker")
+            .args(["stop", &self.id])
+            .stdout(Stdio::null())
+            .stderr(Stdio::null())
+            .spawn();
+    }
+}
+
+pub struct ExecOutput {
+    pub status: ExitStatus,
+    pub stderr: String,
+    pub stdout: String,
+}
+
+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(),
+        );
+    }
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{thread, time::Duration};
+
+    use super::*;
+
+    const IMAGE: &str = "ubuntu:22.04";
+
+    #[test]
+    #[ignore = "slow"]
+    fn eventually_removes_container_on_drop() -> Result<()> {
+        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 = 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 = helpers::stdout(&mut check_cmd, None)?;
+        assert_eq!(0, matches.lines().count());
+
+        Ok(())
+    }
+
+    #[test]
+    fn exec_as_root_works() -> Result<()> {
+        let docker = Container::new(IMAGE)?;
+        let output = docker.exec(&["true"], As::Root, None)?;
+        assert!(output.status.success());
+        let output = docker.exec(&["false"], As::Root, None)?;
+        assert_eq!(Some(1), output.status.code());
+        Ok(())
+    }
+
+    #[test]
+    fn exec_as_user_named_root_works() -> Result<()> {
+        let docker = Container::new(IMAGE)?;
+        let output = docker.exec(&["true"], As::User { name: "root" }, None)?;
+        assert!(output.status.success());
+        Ok(())
+    }
+
+    #[test]
+    fn exec_as_non_root_user_works() -> Result<()> {
+        let docker = Container::new(IMAGE)?;
+        let username = "ferris";
+        let output = docker.exec(&["useradd", username], As::Root, None)?;
+        assert!(output.status.success());
+        let output = docker.exec(&["true"], As::User { name: username }, None)?;
+        assert!(output.status.success());
+        Ok(())
+    }
+
+    #[test]
+    fn cp_works() -> Result<()> {
+        let docker = Container::new(IMAGE)?;
+        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 docker = Container::new(IMAGE)?;
+
+        docker.stdout(&["tee", "greeting"], As::Root, Some(expected))?;
+
+        let actual = docker.stdout(&["cat", "greeting"], As::Root, None)?;
+
+        assert_eq!(expected, actual);
+
+        Ok(())
+    }
+}
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
new file mode 100644
index 00000000..8dc7d118
--- /dev/null
+++ b/test-framework/sudo-test/src/lib.rs
@@ -0,0 +1,367 @@
+use std::{
+    collections::{HashMap, HashSet},
+    env,
+    fs::File,
+    path::PathBuf,
+    process::{Command, Stdio},
+    sync::Once,
+};
+
+use docker::Container;
+
+pub use docker::{As, ExecOutput};
+
+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");
+
+/// test environment builder
+#[derive(Default)]
+pub struct EnvBuilder {
+    sudoers: String,
+    sudoers_chmod: Option<String>,
+    sudoers_chown: Option<String>,
+    username_to_groups: HashMap<String, HashSet<String>>,
+    username_to_passwords: HashMap<String, String>,
+}
+
+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"
+        );
+
+        let mut set = HashSet::new();
+        for group in groups {
+            assert!(
+                !set.contains(*group),
+                "group `{group}` declared more than once"
+            );
+
+            set.insert(group.to_string());
+        }
+
+        assert!(!set.contains(username), "do not list $username in $groups");
+
+        self.username_to_groups.insert(username.to_string(), set);
+
+        self
+    }
+
+    pub fn user_password(&mut self, username: &str, password: &str) -> &mut Self {
+        self.username_to_passwords
+            .insert(username.to_string(), password.to_string());
+        self
+    }
+
+    /// appends content to the `/etc/sudoers` file
+    ///
+    /// 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_SUDOERS_CHOWN: &str = "root:root";
+
+    /// NOTE 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 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
+    }
+
+    pub fn build(&self) -> Result<Env> {
+        static ONCE: Once = Once::new();
+        ONCE.call_once(|| {
+            build_base_image().expect("fatal error: could not build the base Docker image")
+        });
+
+        let container = Container::new(BASE_IMAGE)?;
+
+        let mut groups = get_groups(&container)?;
+        let mut users = get_users(&container)?;
+
+        // 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,
+        )?;
+
+        for user_groups in self.username_to_groups.values() {
+            for user_group in user_groups {
+                if !groups.contains(user_group) {
+                    container.stdout(&["groupadd", user_group], As::Root, None)?;
+
+                    groups.insert(user_group.to_string());
+                }
+            }
+        }
+
+        for (username, user_groups) in &self.username_to_groups {
+            let mut cmd = vec!["useradd", "-m", username];
+            let group_list;
+            if !user_groups.is_empty() {
+                group_list = user_groups.iter().cloned().collect::<Vec<_>>().join(",");
+                cmd.extend_from_slice(&["-G", &group_list]);
+            }
+            container.stdout(&cmd, As::Root, None)?;
+
+            users.insert(username.to_string());
+            groups.insert(username.to_string());
+        }
+
+        for (username, password) in &self.username_to_passwords {
+            assert!(
+                users.contains(username),
+                "cannot assign password to non-existing user: {username}"
+            );
+
+            let stdin = format!("{username}:{password}");
+            container.stdout(&["chpasswd"], As::Root, Some(&stdin))?;
+        }
+
+        Ok(Env { container, users })
+    }
+}
+
+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)
+        }
+    }
+}
+
+fn build_base_image() -> Result<()> {
+    let repo_root = repo_root();
+    let mut cmd = Command::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));
+        }
+    }
+
+    helpers::stdout(&mut cmd, None)?;
+
+    Ok(())
+}
+
+fn repo_root() -> PathBuf {
+    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+    path.pop();
+    path.pop();
+    path
+}
+
+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());
+        }
+    }
+
+    Ok(groups)
+}
+
+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());
+        }
+    }
+
+    Ok(users)
+}
+
+/// test environment
+pub struct Env {
+    container: Container,
+    users: HashSet<String>,
+}
+
+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"
+            );
+        }
+
+        self.container.exec(cmd, user, stdin)
+    }
+
+    /// 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"
+            );
+        }
+
+        self.container.stdout(cmd, user, stdin)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn group_creation_works() -> Result<()> {
+        let username = "ferris";
+        let groupname = "rustaceans";
+        let env = EnvBuilder::default().user(username, &[groupname]).build()?;
+
+        let groups = get_groups(&env.container)?;
+        assert!(groups.contains(groupname));
+
+        Ok(())
+    }
+
+    #[test]
+    fn user_creation_works() -> Result<()> {
+        let new_user = "ferris";
+        let env = EnvBuilder::default().user(new_user, &[]).build()?;
+
+        let users = get_users(&env.container)?;
+        assert!(users.contains(new_user));
+
+        Ok(())
+    }
+
+    #[test]
+    fn created_user_has_a_home() -> Result<()> {
+        let new_user = "ferris";
+        let env = EnvBuilder::default().user(new_user, &[]).build()?;
+
+        let output = env.exec(&["sh", "-c", "[ -d /home/ferris ]"], As::Root, None)?;
+        assert!(output.status.success());
+
+        Ok(())
+    }
+
+    #[test]
+    fn created_user_belongs_to_group_named_after_themselves() -> Result<()> {
+        let new_user = "ferris";
+        let env = EnvBuilder::default().user(new_user, &[]).build()?;
+
+        let output = env.exec(&["groups"], As::User { name: new_user }, None)?;
+        assert!(output.status.success());
+
+        let groups = output.stdout.split(' ').collect::<HashSet<_>>();
+        assert!(groups.contains(new_user));
+
+        Ok(())
+    }
+
+    #[test]
+    fn creating_user_part_of_existing_group_works() -> Result<()> {
+        let user = "ferris";
+        let group = "users";
+        let env = EnvBuilder::default().user(user, &[group]).build()?;
+
+        let output = env.exec(&["groups"], As::User { name: user }, None)?;
+        assert!(output.status.success());
+
+        let user_groups = output.stdout.split(' ').collect::<HashSet<_>>();
+        dbg!(&user_groups);
+        assert!(user_groups.contains(group));
+
+        Ok(())
+    }
+
+    #[test]
+    fn sudoers_file_get_created_with_expected_contents() -> Result<()> {
+        let expected = "Hello, root!";
+        let env = EnvBuilder::default().sudoers(expected).build()?;
+
+        let output = env.exec(&["cat", "/etc/sudoers"], As::Root, None)?;
+        assert!(output.status.success());
+
+        let actual = output.stdout;
+        assert_eq!(expected, actual);
+
+        Ok(())
+    }
+}
diff --git a/test-framework/sudo-test/src/ours.Dockerfile b/test-framework/sudo-test/src/ours.Dockerfile
new file mode 100644
index 00000000..5c6bbd95
--- /dev/null
+++ b/test-framework/sudo-test/src/ours.Dockerfile
@@ -0,0 +1,11 @@
+FROM rust:1.67
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends libclang-dev libpam0g-dev
+# cache the crates.io index in the image for faster local testing
+RUN cargo search sudo
+WORKDIR /usr/src/sudo
+COPY . .
+# TODO should use `--locked` but the repository does not include a `Cargo.lock` file
+RUN cargo install --debug --path sudo
+# HACK sudo-rs is hard-coded to use /etc/sudoers.test
+RUN ln -s sudoers /etc/sudoers.test
diff --git a/test-framework/sudo-test/src/ours.Dockerfile.dockerignore b/test-framework/sudo-test/src/ours.Dockerfile.dockerignore
new file mode 100644
index 00000000..dbff2d2f
--- /dev/null
+++ b/test-framework/sudo-test/src/ours.Dockerfile.dockerignore
@@ -0,0 +1,4 @@
+target/**
+.git/**
+# changes in test framework not trigger a rebuild of sudo binary
+test-framework/**
\ No newline at end of file
diff --git a/test-framework/sudo-test/src/theirs.Dockerfile b/test-framework/sudo-test/src/theirs.Dockerfile
new file mode 100644
index 00000000..745bc059
--- /dev/null
+++ b/test-framework/sudo-test/src/theirs.Dockerfile
@@ -0,0 +1,3 @@
+FROM ubuntu:22.04
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends sudo