Repository: memorysafety/sudo-rs
Pull request #74 (2023-03-13 11:04:51)

change cmdline passed to sudoers-check to use a Path + arguments
The sudoers checker internally split the command it was passed into a "binary" and "arguments" part (for wildcard matching); on the caller side this string was made by concatenating the binary and the arguments. This PR fixes that.

This also fixes a bug (discovered by this refactor), where a SHA2 digest would be computed by trying to open the complete argument as a file.

Also there is a small refactor so that the command and arguments are now bundled into the `Request` structure (the idea is now that this structure marks the things that are "under user control", whereas `am_user` and `on_host` are under system control, i.e. the request is the thing being checked)

Closing issue #64 


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index bbfb9633..a36978e4 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -24,15 +24,24 @@ jobs:
         working-directory: test-framework
         run: cargo test -p sudo-test
 
-      - name: Test sudo-rs
+      - name: Run all compliance tests against original sudo
+        working-directory: test-framework
+        run: cargo test -p sudo-compliance-tests -- --include-ignored
+
+      - name: Run gated compliance tests against sudo-rs
         working-directory: test-framework
         env:
           SUDO_UNDER_TEST: ours
-        run: cargo test -p sudo-compliance-tests || true
+        run: cargo test -p sudo-compliance-tests
 
-      - name: Test original sudo
+      - name: Check that we didn't forgot to gate a passing compliance test
         working-directory: test-framework
-        run: cargo test -p sudo-compliance-tests
+        env:
+          SUDO_UNDER_TEST: ours
+        run: |
+          tmpfile="$(mktemp)"
+          cargo test -p sudo-compliance-tests -- --ignored | tee "$tmpfile"
+          grep 'test result: FAILED. 0 passed' "$tmpfile" || exit 1
 
   build-and-test:
     runs-on: ubuntu-latest
diff --git a/lib/sudo-common/src/context.rs b/lib/sudo-common/src/context.rs
index e2a79c6e..e09df7db 100644
--- a/lib/sudo-common/src/context.rs
+++ b/lib/sudo-common/src/context.rs
@@ -11,16 +11,6 @@ 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;
 
diff --git a/lib/sudoers/src/compute_hash.rs b/lib/sudoers/src/compute_hash.rs
index 2b2858dc..39395648 100644
--- a/lib/sudoers/src/compute_hash.rs
+++ b/lib/sudoers/src/compute_hash.rs
@@ -1,9 +1,9 @@
-pub fn sha2(bits: u16, path: &str) -> Box<[u8]> {
+pub fn sha2(bits: u16, path: &std::path::Path) -> Box<[u8]> {
     use digest::{Digest, DynDigest};
 
     fn compute_hash(
         mut hasher: Box<dyn DynDigest>,
-        path: &str,
+        path: &std::path::Path,
     ) -> Result<Box<[u8]>, std::io::Error> {
         use std::fs::File;
         use std::io::{BufRead, BufReader};
@@ -38,7 +38,7 @@ pub fn sha2(bits: u16, path: &str) -> Box<[u8]> {
 mod test {
     #[test]
     fn check_hash() {
-        let file_to_check = "/bin/more";
+        let file_to_check = std::path::Path::new("/bin/more");
         use crate::basic_parser::parse_eval;
         use crate::tokens::Sha2;
         use std::process::Command;
diff --git a/lib/sudoers/src/lib.rs b/lib/sudoers/src/lib.rs
index ecb2c004..a8fb97ce 100644
--- a/lib/sudoers/src/lib.rs
+++ b/lib/sudoers/src/lib.rs
@@ -33,6 +33,8 @@ pub struct Sudoers {
 pub struct Request<'a, User: UnixUser, Group: UnixGroup> {
     pub user: &'a User,
     pub group: &'a Group,
+    pub command: &'a Path,
+    pub arguments: &'a str,
 }
 
 /// This function takes a file argument for a sudoers file and processes it.
@@ -86,17 +88,18 @@ pub fn check_permission<User: UnixUser + PartialEq<User>, Group: UnixGroup>(
         settings: _,
     }: &Sudoers,
     am_user: &User,
-    request: Request<User, Group>,
     on_host: &str,
-    cmdline: &str,
+    request: Request<User, Group>,
 ) -> Option<Vec<Tag>> {
+    let cmdline = (request.command, request.arguments);
+
     let user_aliases = get_aliases(&aliases.user, &match_user(am_user));
     let host_aliases = get_aliases(&aliases.host, &match_token(on_host));
     let cmnd_aliases = get_aliases(&aliases.cmnd, &match_command(cmdline));
     let runas_user_aliases = get_aliases(&aliases.runas, &match_user(request.user));
     let runas_group_aliases = get_aliases(&aliases.runas, &match_group_alias(request.group));
 
-    let mut sha2_eq = check_all_sha2(cmdline);
+    let mut sha2_eq = check_all_sha2(request.command);
 
     let allowed_commands = rules
         .iter()
@@ -208,10 +211,8 @@ fn match_token<T: basic_parser::Token + std::ops::Deref<Target = String>>(
     move |token| token.as_str() == text
 }
 
-fn match_command(text: &str) -> (impl Fn(&Command) -> bool + '_) {
-    let text = split_args(text);
-    let (cmd, args) = (text[0], text[1..].join(" "));
-    move |(cmdpat, argpat)| cmdpat.matches(cmd) && argpat.matches(&args)
+fn match_command<'a>((cmd, args): (&'a Path, &'a str)) -> (impl Fn(&Command) -> bool + 'a) {
+    move |(cmdpat, argpat)| cmdpat.matches_path(cmd) && argpat.matches(args)
 }
 
 /// Find all the aliases that a object is a member of; this requires [sanitize_alias_table] to have run first;
@@ -476,14 +477,14 @@ fn sanitize_alias_table<T>(table: &Vec<Def<T>>, diagnostics: &mut Vec<Error>) ->
 
 mod compute_hash;
 
-fn check_all_sha2(cmdline: &str) -> impl FnMut(&Box<[u8]>) -> bool + '_ {
+fn check_all_sha2(binary: &Path) -> impl FnMut(&Box<[u8]>) -> bool + '_ {
     use compute_hash::sha2;
 
     let mut memo = std::collections::HashMap::new(); // pun not intended
 
     move |bytes| {
         let bits = 8 * bytes.len() as u16;
-        memo.entry(bits).or_insert_with(|| sha2(bits, cmdline)) == bytes
+        memo.entry(bits).or_insert_with(|| sha2(bits, binary)) == bytes
     }
 }
 
@@ -526,10 +527,7 @@ mod test {
 
     macro_rules! request {
         ($user:ident, $group:ident) => {
-            Request {
-                user: &Named(stringify!($user)),
-                group: &Named(stringify!($group)),
-            }
+            (&Named(stringify!($user)), &Named(stringify!($group)))
         };
     }
 
@@ -605,22 +603,23 @@ mod test {
 
     #[test]
     fn permission_test() {
-        let root = || Request::<Named, Named> {
-            user: &Named("root"),
-            group: &Named("root"),
-        };
+        let root = || (&Named("root"), &Named("root"));
 
         macro_rules! FAIL {
             ([$($sudo:expr),*], $user:expr => $req:expr, $server:expr; $command:expr) => {
                 let (Sudoers { rules,aliases,settings }, _) = analyze(sudoer![$($sudo),*]);
-                assert_eq!(check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $req, $server, $command), None);
+                let cmdvec = $command.split_whitespace().collect::<Vec<_>>();
+                let req = Request { user: $req.0, group: $req.1, command: cmdvec[0].as_ref(), arguments: &cmdvec[1..].join(" ") };
+                assert_eq!(check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $server, req), None);
             }
         }
 
         macro_rules! pass {
             ([$($sudo:expr),*], $user:expr => $req:expr, $server:expr; $command:expr $(=> [$($list:expr),*])?) => {
                 let (Sudoers { rules,aliases,settings }, _) = analyze(sudoer![$($sudo),*]);
-                let result = check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $req, $server, $command);
+                let cmdvec = $command.split_whitespace().collect::<Vec<_>>();
+                let req = Request { user: $req.0, group: $req.1, command: &cmdvec[0].as_ref(), arguments: &cmdvec[1..].join(" ") };
+                let result = check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $server, req);
                 $(assert_eq!(result, Some(vec![$($list),*]));)?
                 assert!(!result.is_none());
             }
diff --git a/lib/sudoers/src/tokens.rs b/lib/sudoers/src/tokens.rs
index 5da4b32b..7a3c0a3b 100644
--- a/lib/sudoers/src/tokens.rs
+++ b/lib/sudoers/src/tokens.rs
@@ -141,10 +141,6 @@ impl Token for Upper {
 /// limited to 1024 characters.
 pub type Command = (glob::Pattern, glob::Pattern);
 
-pub fn split_args(text: &str) -> Vec<&str> {
-    text.split_whitespace().collect::<Vec<_>>()
-}
-
 impl Token for Command {
     const MAX_LEN: usize = 1024;
 
@@ -152,7 +148,7 @@ impl Token for Command {
         let cvt_err = |pat: Result<_, glob::PatternError>| {
             pat.map_err(|err| format!("wildcard pattern error {err}"))
         };
-        let mut cmdvec = split_args(&s);
+        let mut cmdvec = s.split_whitespace().collect::<Vec<_>>();
         if cmdvec.len() == 1 {
             // if no arguments are mentioned, anything is allowed
             cmdvec.push("*");
diff --git a/sudo/src/main.rs b/sudo/src/main.rs
index 3ce70087..47437ba4 100644
--- a/sudo/src/main.rs
+++ b/sudo/src/main.rs
@@ -23,12 +23,13 @@ fn check_sudoers(sudoers: &Sudoers, context: &Context) -> Result<Option<Vec<Tag>
     Ok(sudoers::check_permission(
         sudoers,
         &context.current_user,
+        &context.hostname,
         sudoers::Request {
             user: &context.target_user,
             group: &context.target_group,
+            command: &context.command.command,
+            arguments: &context.command.arguments.join(" "),
         },
-        &context.hostname,
-        context.command.to_string().as_str(),
     ))
 }
 


Test patch:
diff --git a/test-binaries/checkpermit.rs b/test-binaries/checkpermit.rs
index 2031de57..ad66b182 100644
--- a/test-binaries/checkpermit.rs
+++ b/test-binaries/checkpermit.rs
@@ -12,15 +12,20 @@ fn chatty_check_permission(
         "Is '{}' allowed on '{}' to run: '{}' (as {}:{})?",
         am_user, on_host, chosen_poison, user, group
     );
+    let (command, arguments) = {
+        let mut items = chosen_poison.split_whitespace();
+        (items.next().unwrap(), items.collect::<Vec<_>>().join(" "))
+    };
     let result = sudoers::check_permission(
         &sudoers,
         &am_user,
+        on_host,
         sudoers::Request {
             user: &user,
             group: &group,
+            command: command.as_ref(),
+            arguments: &arguments,
         },
-        on_host,
-        chosen_poison,
     );
     println!("OUTCOME: {result:?}");
 }
diff --git a/test-framework/README.md b/test-framework/README.md
index 1ed7923b..647c5207 100644
--- a/test-framework/README.md
+++ b/test-framework/README.md
@@ -13,14 +13,20 @@ $ 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:
+To run all the compliance tests against the original sudo execute the following command from this directory:
 
 ``` console
-$ cargo test -p sudo-compliance-tests
+$ cargo test -p sudo-compliance-tests -- --include-ignored
 ```
 
-To run the tests against sudo-rs set the `SUDO_UNDER_TEST` variable to `ours` before invoking Cargo:
+To run the "gated" compliance 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
 ```
+
+## Gating CI on selected tests
+
+Tests (`#[test]` functions) that exercise behavior not yet implemented in sudo-rs MUST be marked as `#[ignored]`.
+When said behavior is implemented in sudo-rs, the `#[ignored]` attribute MUST be removed from the test.
+CI will run `#[ignored]` tests against sudo-rs and fail the build if any of them passes -- as that indicates that an `#[ignored]` attribute was not removed.
diff --git a/test-framework/sudo-compliance-tests/src/child_process.rs b/test-framework/sudo-compliance-tests/src/child_process.rs
new file mode 100644
index 00000000..de72caad
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/child_process.rs
@@ -0,0 +1,53 @@
+use pretty_assertions::assert_eq;
+use sudo_test::{As, EnvBuilder};
+
+use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
+
+#[test]
+fn sudo_forwards_childs_exit_code() -> Result<()> {
+    let env = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
+
+    let expected = 42;
+    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 = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
+
+    let expected = "hello";
+    let output = env.exec(&["sudo", "echo", expected], As::Root, None)?;
+    assert_eq!(expected, output.stdout);
+    assert!(output.stderr.is_empty());
+
+    Ok(())
+}
+
+#[test]
+fn sudo_forwards_childs_stderr() -> Result<()> {
+    let env = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
+
+    let expected = "hello";
+    let output = env.exec(
+        &["sudo", "sh", "-c", &format!(">&2 echo {expected}")],
+        As::Root,
+        None,
+    )?;
+    assert_eq!(expected, output.stderr);
+    assert!(output.stdout.is_empty());
+
+    Ok(())
+}
diff --git a/test-framework/sudo-compliance-tests/src/env_reset.rs b/test-framework/sudo-compliance-tests/src/env_reset.rs
new file mode 100644
index 00000000..bf42d1e5
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/env_reset.rs
@@ -0,0 +1,119 @@
+use std::collections::HashMap;
+
+use pretty_assertions::assert_eq;
+use sudo_test::{As, EnvBuilder};
+
+use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
+
+// NOTE if 'env_reset' is not in `/etc/sudoers` it is enabled by default
+
+// see 'environment' section in`man sudo`
+// see 'command environment' section in`man sudoers`
+#[ignore]
+#[test]
+fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
+    let env = EnvBuilder::default()
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
+
+    let stdout = env.stdout(&["env"], As::Root, None)?;
+    let normal_env = parse_env_output(&stdout)?;
+
+    let sudo_abs_path = env.stdout(&["which", "sudo"], As::Root, None)?;
+    let env_abs_path = env.stdout(&["which", "env"], As::Root, None)?;
+
+    // run sudo in an empty environment
+    let stdout = env.stdout(
+        &["env", "-i", &sudo_abs_path, &env_abs_path],
+        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(())
+}
+
+#[ignore]
+#[test]
+fn env_reset_mode_clears_env_vars() -> Result<()> {
+    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 = 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 = 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));
+
+    Ok(())
+}
+
+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)
+}
diff --git a/test-framework/sudo-compliance-tests/src/flag_user.rs b/test-framework/sudo-compliance-tests/src/flag_user.rs
new file mode 100644
index 00000000..01686c59
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/flag_user.rs
@@ -0,0 +1,66 @@
+use pretty_assertions::assert_eq;
+use sudo_test::{As, EnvBuilder};
+
+use crate::{Result, SUDOERS_FERRIS_ALL_NOPASSWD, SUDOERS_ROOT_ALL_NOPASSWD};
+
+#[test]
+fn root_can_become_another_user_by_name() -> Result<()> {
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
+
+    let expected = env.stdout(&["id"], As::User { name: username }, None)?;
+    let actual = env.stdout(&["sudo", "-u", username, "id"], As::Root, None)?;
+
+    assert_eq!(expected, actual);
+
+    Ok(())
+}
+
+#[test]
+fn root_can_become_another_user_by_uid() -> Result<()> {
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .sudoers(SUDOERS_ROOT_ALL_NOPASSWD)
+        .build()?;
+
+    let uid = env
+        .stdout(&["id", "-u"], As::User { name: username }, None)?
+        .parse::<u32>()?;
+    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 env = EnvBuilder::default()
+        .user("ferris", &[])
+        .user("someone_else", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+        .build()?;
+
+    let expected = env.stdout(
+        &["id"],
+        As::User {
+            name: "someone_else",
+        },
+        None,
+    )?;
+    let actual = env.stdout(
+        &["sudo", "-u", "someone_else", "id"],
+        As::User { name: "ferris" },
+        None,
+    )?;
+
+    assert_eq!(expected, actual);
+
+    Ok(())
+}
diff --git a/test-framework/sudo-compliance-tests/src/lib.rs b/test-framework/sudo-compliance-tests/src/lib.rs
index 16c5b140..908a14ef 100644
--- a/test-framework/sudo-compliance-tests/src/lib.rs
+++ b/test-framework/sudo-compliance-tests/src/lib.rs
@@ -1,337 +1,19 @@
 #![cfg(test)]
 
-use std::collections::HashMap;
+#[macro_use]
+mod macros;
 
-use pretty_assertions::assert_eq;
-use sudo_test::{As, EnvBuilder};
+mod child_process;
+mod env_reset;
+mod flag_user;
+mod nopasswd;
+mod pass_auth;
+mod perms;
+mod sudoers;
 
 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());
-
-    if sudo_test::is_original_sudo() {
-        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());
-
-    if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "/etc/sudoers is world writable");
-    }
-
-    Ok(())
-}
-
-// man sudoers > User Authentication:
-// "A password is not required if the invoking user is root"
-#[test]
-fn can_sudo_as_root_without_providing_a_password_if_root_user_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_root_without_providing_a_password_if_roots_group_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_correct_password_is_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_incorrect_password_is_provided(
-) -> Result<()> {
-    let username = "ferris";
-    let groupname = "rustaceans";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("%{groupname}    ALL=(ALL:ALL) ALL"))
-        .user(username, &[groupname])
-        .user_password(username, "strong-password")
-        .build()?;
-
-    let output = env.exec(
-        &["sudo", "-S", "true"],
-        As::User { name: username },
-        Some("incorrect-password"),
-    )?;
-    assert!(!output.status.success());
-    assert_eq!(Some(1), output.status.code());
-
-    if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "incorrect password attempt");
-    }
-
-    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());
-
-    if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "no password was provided");
-    }
-
-    Ok(())
-}
-
-#[test]
-fn can_sudo_as_user_if_user_is_in_sudoers_file_and_correct_password_is_provided() -> Result<()> {
-    let username = "ferris";
-    let password = "strong-password";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
-        .user(username, &["rustaceans"])
-        .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_user_is_in_sudoers_file_and_incorrect_password_is_provided() -> Result<()>
-{
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
-        .user(username, &["rustaceans"])
-        .user_password(username, "strong-password")
-        .build()?;
-
-    let output = env.exec(
-        &["sudo", "-S", "true"],
-        As::User { name: username },
-        Some("incorrect-password"),
-    )?;
-    assert!(!output.status.success());
-    assert_eq!(Some(1), output.status.code());
-
-    if sudo_test::is_original_sudo() {
-        assert_contains!(output.stderr, "incorrect password attempt");
-    }
-
-    Ok(())
-}
-
-#[test]
-fn cannot_sudo_as_user_if_user_is_in_sudoers_file_and_password_is_not_provided() -> Result<()> {
-    let username = "ferris";
-    let password = "strong-password";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
-        .user(username, &["rustaceans"])
-        .user_password(username, password)
-        .build()?;
-
-    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");
-    }
-
-    Ok(())
-}
-
-#[test]
-fn can_sudo_as_user_without_providing_a_password_if_users_group_is_in_sudoers_file_and_nopasswd_is_set(
-) -> Result<()> {
-    let username = "ferris";
-    let groupname = "rustaceans";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("%{groupname}    ALL=(ALL:ALL) NOPASSWD: ALL"))
-        .user(username, &[groupname])
-        .build()?;
-
-    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    Ok(())
-}
-
-#[test]
-fn can_sudo_as_user_without_providing_a_password_if_user_is_in_sudoers_file_and_nopasswd_is_set(
-) -> Result<()> {
-    let username = "ferris";
-    let env = EnvBuilder::default()
-        .sudoers(&format!("{username}    ALL=(ALL:ALL) NOPASSWD: ALL"))
-        .user(username, &["rustaceans"])
-        .build()?;
-
-    let output = env.exec(&["sudo", "-S", "true"], As::User { name: username }, None)?;
-    assert!(output.status.success(), "{}", output.stderr);
-
-    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());
-
-    if sudo_test::is_original_sudo() {
-        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)?;
-
-    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 = env.stdout(
-        &["env", "-i", &sudo_abs_path, &env_abs_path],
-        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(())
-}
+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";
diff --git a/test-framework/sudo-compliance-tests/src/macros.rs b/test-framework/sudo-compliance-tests/src/macros.rs
new file mode 100644
index 00000000..0a61d5f8
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/macros.rs
@@ -0,0 +1,11 @@
+macro_rules! assert_contains {
+    ($haystack:expr, $needle:expr) => {
+        let haystack = &$haystack;
+        let needle = &$needle;
+
+        assert!(
+            haystack.contains(needle),
+            "{haystack:?} did not contain {needle:?}"
+        )
+    };
+}
diff --git a/test-framework/sudo-compliance-tests/src/nopasswd.rs b/test-framework/sudo-compliance-tests/src/nopasswd.rs
new file mode 100644
index 00000000..163edd0c
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/nopasswd.rs
@@ -0,0 +1,85 @@
+//! Scenarios where a password does not need to be provided
+
+use sudo_test::{As, EnvBuilder};
+
+use crate::{Result, SUDOERS_ROOT_ALL};
+
+// man sudoers > User Authentication:
+// "A password is not required if the invoking user is root"
+#[ignore]
+#[test]
+fn can_sudo_as_root_without_providing_a_password_if_root_user_is_in_sudoers_file() -> Result<()> {
+    let env = EnvBuilder::default().sudoers(SUDOERS_ROOT_ALL).build()?;
+
+    let output = env.exec(&["sudo", "true"], As::Root, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
+}
+
+#[ignore]
+#[test]
+fn can_sudo_as_root_without_providing_a_password_if_roots_group_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(())
+}
+
+#[ignore]
+#[test]
+fn can_sudo_as_user_without_providing_a_password_if_users_group_is_in_sudoers_file_and_nopasswd_is_set(
+) -> Result<()> {
+    let username = "ferris";
+    let groupname = "rustaceans";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("%{groupname}    ALL=(ALL:ALL) NOPASSWD: ALL"))
+        .user(username, &[groupname])
+        .build()?;
+
+    let output = env.exec(&["sudo", "true"], As::User { name: username }, None)?;
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
+}
+
+#[test]
+fn can_sudo_as_user_without_providing_a_password_if_user_is_in_sudoers_file_and_nopasswd_is_set(
+) -> Result<()> {
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) NOPASSWD: ALL"))
+        .user(username, &["rustaceans"])
+        .build()?;
+
+    let output = env.exec(&["sudo", "true"], As::User { name: username }, 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_can_become_themselves_without_providing_a_password() -> Result<()> {
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+        .build()?;
+
+    let output = env.exec(
+        &["sudo", "-u", username, "true"],
+        As::User { name: username },
+        None,
+    )?;
+
+    assert!(output.status.success(), "{}", output.stderr);
+
+    Ok(())
+}
diff --git a/test-framework/sudo-compliance-tests/src/pass_auth.rs b/test-framework/sudo-compliance-tests/src/pass_auth.rs
new file mode 100644
index 00000000..b77f8c51
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/pass_auth.rs
@@ -0,0 +1,159 @@
+//! Scenarios where password authentication is needed
+
+use pretty_assertions::assert_eq;
+
+use sudo_test::{As, EnvBuilder};
+
+use crate::Result;
+
+#[ignore]
+#[test]
+fn can_sudo_as_user_if_users_group_is_in_sudoers_file_and_correct_password_is_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_incorrect_password_is_provided(
+) -> Result<()> {
+    let username = "ferris";
+    let groupname = "rustaceans";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("%{groupname}    ALL=(ALL:ALL) ALL"))
+        .user(username, &[groupname])
+        .user_password(username, "strong-password")
+        .build()?;
+
+    let output = env.exec(
+        &["sudo", "-S", "true"],
+        As::User { name: username },
+        Some("incorrect-password"),
+    )?;
+    assert!(!output.status.success());
+    assert_eq!(Some(1), output.status.code());
+
+    if sudo_test::is_original_sudo() {
+        assert_contains!(output.stderr, "incorrect password attempt");
+    }
+
+    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());
+
+    if sudo_test::is_original_sudo() {
+        assert_contains!(output.stderr, "no password was provided");
+    }
+
+    Ok(())
+}
+
+#[ignore]
+#[test]
+fn can_sudo_as_user_if_user_is_in_sudoers_file_and_correct_password_is_provided() -> Result<()> {
+    let username = "ferris";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+        .user(username, &["rustaceans"])
+        .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_user_is_in_sudoers_file_and_incorrect_password_is_provided() -> Result<()>
+{
+    let username = "ferris";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+        .user(username, &["rustaceans"])
+        .user_password(username, "strong-password")
+        .build()?;
+
+    let output = env.exec(
+        &["sudo", "-S", "true"],
+        As::User { name: username },
+        Some("incorrect-password"),
+    )?;
+    assert!(!output.status.success());
+    assert_eq!(Some(1), output.status.code());
+
+    if sudo_test::is_original_sudo() {
+        assert_contains!(output.stderr, "incorrect password attempt");
+    }
+
+    Ok(())
+}
+
+#[test]
+fn cannot_sudo_as_user_if_user_is_in_sudoers_file_and_password_is_not_provided() -> Result<()> {
+    let username = "ferris";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .sudoers(&format!("{username}    ALL=(ALL:ALL) ALL"))
+        .user(username, &["rustaceans"])
+        .user_password(username, password)
+        .build()?;
+
+    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");
+    }
+
+    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());
+
+    if sudo_test::is_original_sudo() {
+        assert_contains!(output.stderr, "syntax error");
+    }
+
+    Ok(())
+}
diff --git a/test-framework/sudo-compliance-tests/src/perms.rs b/test-framework/sudo-compliance-tests/src/perms.rs
new file mode 100644
index 00000000..84b9a4db
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/perms.rs
@@ -0,0 +1,57 @@
+use pretty_assertions::assert_eq;
+use sudo_test::{As, EnvBuilder};
+
+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 = EnvBuilder::default()
+        .user("ferris", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+        .text_file(path, "root:root", "000", expected)
+        .build()?;
+
+    let stdout = env.stdout(&["sudo", "cat", path], As::User { name: "ferris" }, None)?;
+    assert_eq!(expected, stdout);
+
+    Ok(())
+}
+
+#[test]
+fn user_can_write_file_owned_by_root() -> Result<()> {
+    let path = "/root/file";
+    let env = EnvBuilder::default()
+        .user("ferris", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+        .text_file(path, "root:root", "000", "")
+        .build()?;
+
+    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 = EnvBuilder::default()
+        .user("ferris", &[])
+        .sudoers(SUDOERS_FERRIS_ALL_NOPASSWD)
+        .text_file(
+            path,
+            "root:root",
+            "100",
+            r#"#!/bin/sh
+exit 0"#,
+        )
+        .build()?;
+
+    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
new file mode 100644
index 00000000..5b375325
--- /dev/null
+++ b/test-framework/sudo-compliance-tests/src/sudoers.rs
@@ -0,0 +1,61 @@
+use sudo_test::{As, EnvBuilder};
+
+use crate::Result;
+
+#[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());
+
+    if sudo_test::is_original_sudo() {
+        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());
+
+    if sudo_test::is_original_sudo() {
+        assert_contains!(output.stderr, "/etc/sudoers is world writable");
+    }
+
+    Ok(())
+}
+
+#[ignore]
+#[test]
+fn user_specifications_evaluated_bottom_to_top() -> Result<()> {
+    let username = "ferris";
+    let password = "strong-password";
+    let env = EnvBuilder::default()
+        .user(username, &[])
+        .user_password(username, password)
+        .sudoers("ferris ALL=(ALL:ALL) NOPASSWD: ALL")
+        // overrides the preceding NOPASSWD
+        .sudoers("ferris ALL=(ALL:ALL) ALL")
+        .build()?;
+
+    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");
+    }
+
+    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-test/src/lib.rs b/test-framework/sudo-test/src/lib.rs
index 882cbfc4..968cb307 100644
--- a/test-framework/sudo-test/src/lib.rs
+++ b/test-framework/sudo-test/src/lib.rs
@@ -2,7 +2,7 @@ use std::{
     collections::{HashMap, HashSet},
     env,
     fs::File,
-    path::PathBuf,
+    path::{Path, PathBuf},
     process::{Command, Stdio},
     sync::Once,
 };
@@ -30,10 +30,17 @@ pub struct EnvBuilder {
     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>,
 }
 
+struct TextFile {
+    contents: String,
+    chmod: String,
+    chown: String,
+}
+
 impl EnvBuilder {
     /// add an user to the environment
     ///
@@ -112,6 +119,29 @@ impl EnvBuilder {
         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.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
+    }
+
     pub fn build(&self) -> Result<Env> {
         static ONCE: Once = Once::new();
         ONCE.call_once(|| {
@@ -196,6 +226,13 @@ impl EnvBuilder {
             container.stdout(&["chpasswd"], As::Root, Some(&stdin))?;
         }
 
+        for (path, text_file) in &self.text_files {
+            container.cp(path, &text_file.contents)?;
+
+            container.stdout(&["chown", &text_file.chown, path], As::Root, None)?;
+            container.stdout(&["chmod", &text_file.chmod, path], As::Root, None)?;
+        }
+
         Ok(Env { container, users })
     }
 }
@@ -419,4 +456,25 @@ mod tests {
         assert_eq!(expected, actual);
         Ok(())
     }
+
+    #[test]
+    fn text_file_gets_created_with_right_perms() -> Result<()> {
+        let chown = "ferris:ferris";
+        let chmod = "600";
+        let expected_contents = "hello";
+        let path = "/root/file";
+        let env = EnvBuilder::default()
+            .user("ferris", &[])
+            .text_file(path, chown, chmod, expected_contents)
+            .build()?;
+
+        let actual_contents = env.stdout(&["cat", path], As::Root, None)?;
+        assert_eq!(expected_contents, &actual_contents);
+
+        let ls_l = env.stdout(&["ls", "-l", path], As::Root, None)?;
+        assert!(ls_l.starts_with("-rw-------"));
+        assert!(ls_l.contains("ferris ferris"));
+
+        Ok(())
+    }
 }