diff --git a/Cargo.lock b/Cargo.lock index 3d911013..619c7792 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1563,6 +1563,7 @@ dependencies = [ "uu_nologin", "uu_renice", "uu_rev", + "uu_runuser", "uu_setpgid", "uu_setsid", "uu_uuidgen", @@ -1761,6 +1762,15 @@ dependencies = [ "uucore 0.2.2", ] +[[package]] +name = "uu_runuser" +version = "0.0.1" +dependencies = [ + "clap", + "nix 0.31.3", + "uucore 0.2.2", +] + [[package]] name = "uu_setpgid" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index f9129a8c..82b9eb38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ feat_common_core = [ "nologin", "renice", "rev", + "runuser", "setpgid", "setsid", "uuidgen", @@ -118,6 +119,7 @@ mountpoint = { optional = true, version = "0.0.1", package = "uu_mountpoint", pa nologin = { optional = true, version = "0.0.1", package = "uu_nologin", path = "src/uu/nologin" } renice = { optional = true, version = "0.0.1", package = "uu_renice", path = "src/uu/renice" } rev = { optional = true, version = "0.0.1", package = "uu_rev", path = "src/uu/rev" } +runuser = { optional = true, version = "0.0.1", package = "uu_runuser", path = "src/uu/runuser" } setpgid = { optional = true, version = "0.0.1", package = "uu_setpgid", path = "src/uu/setpgid" } setsid = { optional = true, version = "0.0.1", package = "uu_setsid", path ="src/uu/setsid" } uuidgen = { optional = true, version = "0.0.1", package = "uu_uuidgen", path ="src/uu/uuidgen" } diff --git a/src/uu/runuser/Cargo.toml b/src/uu/runuser/Cargo.toml new file mode 100644 index 00000000..737d6027 --- /dev/null +++ b/src/uu/runuser/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "uu_runuser" +version = "0.0.1" +edition = "2021" + +[lib] +path = "src/runuser.rs" + +[[bin]] +name = "runuser" +path = "src/main.rs" + +[dependencies] +uucore = { workspace = true } +clap = { workspace = true } +nix = { workspace = true, features = ["process", "user"] } diff --git a/src/uu/runuser/runuser.md b/src/uu/runuser/runuser.md new file mode 100644 index 00000000..e24a01f7 --- /dev/null +++ b/src/uu/runuser/runuser.md @@ -0,0 +1,8 @@ +# runuser + +``` +runuser [options] -u user [[--] command [argument...]] +runuser [options] [-] [user [argument...]] +``` + +run a command with substitute user and group ID diff --git a/src/uu/runuser/src/main.rs b/src/uu/runuser/src/main.rs new file mode 100644 index 00000000..13b1ad13 --- /dev/null +++ b/src/uu/runuser/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_runuser); diff --git a/src/uu/runuser/src/runuser.rs b/src/uu/runuser/src/runuser.rs new file mode 100644 index 00000000..27ba8036 --- /dev/null +++ b/src/uu/runuser/src/runuser.rs @@ -0,0 +1,390 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use clap::{crate_version, Arg, ArgAction, ArgGroup, Command}; +use uucore::error::UResult; +use uucore::{format_usage, help_about, help_usage}; + +const ABOUT: &str = help_about!("runuser.md"); +const USAGE: &str = help_usage!("runuser.md"); + +#[cfg(target_os = "linux")] +mod linux { + use nix::unistd::{setgid, setgroups, setsid, setuid, Gid, Uid}; + use std::env::var; + use std::error::Error; + use std::fs::read_to_string; + use std::io::Error as IOError; + use std::process::Command as RunCommand; + use uucore::error::{UResult, USimpleError}; + + pub fn get_conf(filename: &str, query: &str) -> Result>, IOError> { + let file = read_to_string(filename)?; + let line = file + .lines() + .find(|line| line.starts_with(&format!("{}:", query))); + + match line { + Some(line) => { + let parts: Vec = line.split(':').map(|s| s.to_string()).collect(); + + Ok(Some(parts)) + } + None => Ok(None), + } + } + + pub struct UserEntry { + pub uid: u32, + pub gid: u32, + pub home: String, + pub shell: String, + } + + pub fn get_user_info(username: &str) -> Result, Box> { + let info = get_conf("/etc/passwd", username)?; + + match info { + Some(parts) => { + if parts.len() < 7 { + return Err("Invalid passwd format".into()); + } + let [_, _, ref uid_str, ref gid_str, _, ref home_dir, ref shell_path] = parts[0..7] + else { + unreachable!() + }; + let uid = uid_str.parse::()?; + let gid = gid_str.parse::()?; + + Ok(Some(UserEntry { + uid, + gid, + home: home_dir.to_string(), + shell: shell_path.to_string(), + })) + } + None => Ok(None), + } + } + + #[cfg(target_os = "linux")] + pub fn get_group_info(groupname: &str) -> Result, Box> { + let info = get_conf("/etc/group", groupname)?; + + match info { + Some(parts) => { + let gid = parts.get(2).ok_or("Invalid group format")?.parse::()?; + + Ok(Some(gid)) + } + None => Ok(None), + } + } + + #[cfg(target_os = "linux")] + pub fn set_ids(uid: u32, gid: u32, supp_gids: &Vec) -> UResult<()> { + setgroups(supp_gids.as_slice()) + .map_err(|e| USimpleError::new(1, format!("Failed to set supp Gid: {}", e)))?; + setgid(Gid::from_raw(gid)) + .map_err(|e| USimpleError::new(1, format!("Failed to set Gid to {}: {}", gid, e)))?; + setuid(Uid::from_raw(uid)) + .map_err(|e| USimpleError::new(1, format!("Failed to set Uid to {}: {}", uid, e)))?; + + Ok(()) + } + + pub fn prepare_env( + cmd: &mut RunCommand, + home_dir: &str, + shell_path: &str, + username: &str, + preserve_env: bool, + whitelist_env: &Vec, + is_login: bool, + ) { + const ROOT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"; + + if is_login { + if preserve_env { + println!("--preserve-environment is ignored in case `--login` is enabled"); + } + cmd.env_clear(); + } + if is_login || !preserve_env { + match username { + "root" => { + cmd.env("PATH", ROOT_PATH); + } + _ => { + cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin"); + } + } + cmd.env("HOME", home_dir); + cmd.env("USER", username); + cmd.env("LOGNAME", username); + cmd.env("SHELL", shell_path); + } + for item in whitelist_env { + if !["HOME", "USER", "LOGNAME", "SHELL", "PATH"].contains(&item.as_str()) { + if let Ok(val) = var(item) { + cmd.env(item, val); + } + } + } + } + + #[cfg(target_os = "linux")] + pub fn run(cmd: &mut RunCommand) -> UResult { + let status = cmd + .spawn() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => USimpleError::new(127, "command was not found"), + e => USimpleError::new(126, format!("Failed to spawn for process: {}", e)), + })? + .wait() + .map_err(|e| USimpleError::new(126, format!("Failed to wait for process: {}", e)))?; + + Ok(status.code().unwrap_or(0)) + } + + #[cfg(target_os = "linux")] + pub fn sep_session() -> UResult<()> { + setsid().map_err(|e| USimpleError::new(1, format!("Failed to set Sid: {}", e)))?; + + Ok(()) + } +} + +#[cfg(target_os = "linux")] +use linux::*; +#[cfg(target_os = "linux")] +use uucore::error::USimpleError; + +#[cfg(target_os = "linux")] +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + use nix::unistd::Gid; + use std::process::Command as RunCommand; + let matches = uu_app().try_get_matches_from(args)?; + let mut is_login = false; + let mut command_args = Vec::new(); + if *matches.get_one::("login").unwrap_or(&false) { + command_args.push("-l".to_string()); + is_login = true; + } + if *matches.get_one::("fast").unwrap_or(&false) { + command_args.push("-f".to_string()); + } + if matches.contains_id("cmd") { + let command = match matches.get_one::("session_command") { + Some(cmd) => cmd, + None => { + sep_session()?; + matches.get_one::("command").unwrap() + } + }; + command_args.push("-c".to_string()); + command_args.push(command.to_string()); + } + let supp_gids = if let Some(supp_groups) = matches.get_many::("supp_group") { + let mut supp_gids = Vec::new(); + + for supp_group in supp_groups { + supp_gids.push(Gid::from_raw( + get_group_info(supp_group) + .map_err(|e| { + USimpleError::new(1, format!("Failed to get supp group info: {}", e)) + })? + .ok_or(USimpleError::new(1, "Supp group doesn't exist"))?, + )); + } + + Some(supp_gids) + } else { + None + }; + let overwritten_gid = if let Some(overwritten_group) = matches.get_one::("group") { + Some( + get_group_info(overwritten_group) + .map_err(|e| USimpleError::new(1, format!("Failed to get group info: {}", e)))? + .ok_or(USimpleError::new(1, "Group doesn't exist"))?, + ) + } else { + None + }; + let rest: Vec = matches + .get_many::("rest") + .unwrap_or_default() + .map(|s| s.to_string()) + .collect::>() + .to_vec(); + let (username, path, command_args) = match matches.get_one::("user") { + Some(username) => match matches.contains_id("cmd") { + true => ( + Some(username.to_string()), + matches.get_one::("shell").cloned(), + command_args, + ), + false => { + if rest.is_empty() { + return Err(USimpleError::new(1, "Incorrect usage")); + } + let path = rest[0].clone(); + let args = rest[1..].to_vec(); + + (Some(username.to_string()), Some(path), args) + } + }, + None => { + let mut rest = rest.clone(); + let mut username: Option = None; + + if let Some(arg) = rest.first() { + if arg == "-" { + is_login = true; + command_args.push("-l".to_string()); + rest.remove(0); + } + } + if let Some(arg) = rest.first() { + username = Some(arg.to_string()); + rest.remove(0); + } + match matches.contains_id("cmd") { + true => ( + username, + matches.get_one::("shell").cloned(), + command_args, + ), + false => { + command_args.extend(rest.clone()); + + (username, None, command_args) + } + } + } + }; + let user_info = get_user_info(&username.clone().unwrap_or("root".to_string())) + .map_err(|e| USimpleError::new(1, format!("Failed to get user info: {}", e)))? + .ok_or(USimpleError::new(1, "User doesn't exist"))?; + + set_ids( + user_info.uid, + overwritten_gid.unwrap_or(user_info.gid), + &supp_gids.unwrap_or(Vec::new()), + )?; + + let mut cmd = RunCommand::new(path.unwrap_or(user_info.shell.clone())); + cmd.args(&command_args); + prepare_env( + &mut cmd, + &user_info.home, + &user_info.shell, + &username.unwrap_or("root".to_string()), + *matches.get_one::("preserve_env").unwrap_or(&false), + &matches + .get_many::("whitelist_env") + .unwrap_or_default() + .map(|s| s.to_string()) + .collect::>() + .to_vec(), + is_login, + ); + let status = run(&mut cmd)?; + + if status != 0 { + return Err(USimpleError::new( + status + 128, + format!("Process exited with status code {}", status), + )); + } + + Ok(()) +} + +// TODO: I haven't found if this code can actually work well +// on non-linux unix operating systems. +#[cfg(not(target_os = "linux"))] +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches = uu_app().try_get_matches_from(args)?; + + Err(uucore::error::USimpleError::new( + 1, + "`runuser` is only available on linux", + )) +} + +pub fn uu_app() -> Command { + // TODO: to --pty yet + Command::new(uucore::util_name()) + .version(crate_version!()) + .about(ABOUT) + .override_usage(format_usage(USAGE)) + .arg(Arg::new("user").short('u').long("user").value_name("user")) + .arg( + Arg::new("preserve_env") + .short('p') + .long("preserve-environment") + .visible_short_alias('m') + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("whitelist_env") + .short('w') + .long("whitelist-environment") + .value_name("list") + .num_args(0..), + ) + .arg( + Arg::new("group") + .short('g') + .long("group") + .value_name("group"), + ) + .arg( + Arg::new("supp_group") + .short('F') + .long("supp-group") + .value_name("supp-group"), + ) + .arg( + Arg::new("login") + .short('l') + .long("login") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("command") + .short('c') + .long("command") + .value_name("command"), + ) + .arg( + Arg::new("session_command") + .long("session-command") + .value_name("command"), + ) + .group(ArgGroup::new("cmd").args(["command", "session_command"])) + .arg( + Arg::new("fast") + .short('f') + .long("fast") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("shell") + .short('s') + .long("shell") + .value_name("shell"), + ) + .arg( + Arg::new("rest") + .num_args(0..) + .hide(true) + .allow_hyphen_values(true) + .trailing_var_arg(true), + ) +} diff --git a/tests/by-util/test_runuser.rs b/tests/by-util/test_runuser.rs new file mode 100644 index 00000000..82959c15 --- /dev/null +++ b/tests/by-util/test_runuser.rs @@ -0,0 +1,65 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; + +#[test] +#[cfg(target_os = "linux")] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); +} + +#[test] +#[cfg(target_os = "linux")] +fn invalid_user() { + new_ucmd!() + .arg("--user=fools_have_this_username") + .arg("--command=\"echo hello_world\"") + .fails() + .code_is(1) + .stderr_contains("User doesn't exist"); +} + +#[test] +#[cfg(target_os = "linux")] +fn invalid_group() { + new_ucmd!() + .arg("--user=root") + .arg("--group=hopefully_nonexistant_group") + .arg("--command=\"echo hello_world\"") + .fails() + .code_is(1) + .stderr_contains("Group doesn't exist"); +} + +#[test] +#[cfg(target_os = "linux")] +fn invalid_supp_group() { + new_ucmd!() + .arg("--user=root") + .arg("--supp-group=does_anyone_read_this") + .arg("--command=\"echo hello_world\"") + .fails() + .code_is(1) + .stderr_contains("Supp group doesn't exist"); +} + +#[test] +#[cfg(target_os = "linux")] +fn missing_command() { + new_ucmd!() + .arg("--user=root") + .fails() + .code_is(1) + .stderr_contains("Incorrect usage"); +} + +#[cfg(not(target_os = "linux"))] +fn unsupported_platform() { + new_ucmd!() + .fails() + .code_is(1) + .stderr_contains("`runuser` is only available on linux"); +} diff --git a/tests/tests.rs b/tests/tests.rs index 09ffc245..c8fe4f93 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -63,6 +63,10 @@ mod test_renice; #[path = "by-util/test_rev.rs"] mod test_rev; +#[cfg(feature = "runuser")] +#[path = "by-util/test_runuser.rs"] +mod test_runuser; + #[cfg(feature = "setpgid")] #[path = "by-util/test_setpgid.rs"] mod test_setpgid;