#![forbid(unsafe_code)]
use std::ffi::OsStr;
use std::str;
use std::{borrow::Cow, ffi::OsString, mem};
use crate::common::{DisplayOsStr, SudoPath, SudoString};
use crate::log::user_warn;
pub mod help;
pub mod help_edit;
#[cfg(test)]
mod tests;
pub enum SudoAction {
Edit(SudoEditOptions),
Help(SudoHelpOptions),
List(SudoListOptions),
RemoveTimestamp(SudoRemoveTimestampOptions),
ResetTimestamp(SudoResetTimestampOptions),
Run(SudoRunOptions),
Validate(SudoValidateOptions),
Version(SudoVersionOptions),
}
pub(super) fn is_sudoedit(command_path: Option<OsString>) -> bool {
std::path::Path::new(&command_path.unwrap_or_default())
.file_name()
.is_some_and(|name| name.as_encoded_bytes().starts_with(b"sudoedit"))
}
impl SudoAction {
pub fn from_env() -> Result<Self, String> {
Self::try_parse_from(std::env::args_os())
}
pub fn try_parse_from<I, T>(iter: I) -> Result<Self, String>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let opts = SudoOptions::try_parse_from(iter)?;
opts.validate()
}
}
pub struct SudoHelpOptions {}
impl TryFrom<SudoOptions> for SudoHelpOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let help = mem::take(&mut opts.help);
debug_assert!(help);
Ok(Self {})
}
}
pub struct SudoVersionOptions {}
impl TryFrom<SudoOptions> for SudoVersionOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let version = mem::take(&mut opts.version);
debug_assert!(version);
Ok(Self {})
}
}
pub struct SudoRemoveTimestampOptions {}
impl TryFrom<SudoOptions> for SudoRemoveTimestampOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let remove_timestamp = mem::take(&mut opts.remove_timestamp);
debug_assert!(remove_timestamp);
reject_all("--remove-timestamp", opts)?;
Ok(Self {})
}
}
pub struct SudoResetTimestampOptions {}
impl TryFrom<SudoOptions> for SudoResetTimestampOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let reset_timestamp = mem::take(&mut opts.reset_timestamp);
debug_assert!(reset_timestamp);
reject_all("--reset-timestamp", opts)?;
Ok(Self {})
}
}
pub struct SudoValidateOptions {
pub askpass: bool,
pub bell: bool,
pub reset_timestamp: bool,
pub non_interactive: bool,
pub stdin: bool,
pub prompt: Option<String>,
pub group: Option<SudoString>,
pub user: Option<SudoString>,
}
impl TryFrom<SudoOptions> for SudoValidateOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let validate = mem::take(&mut opts.validate);
debug_assert!(validate);
let askpass = mem::take(&mut opts.askpass);
let bell = mem::take(&mut opts.bell);
let reset_timestamp = mem::take(&mut opts.reset_timestamp);
let non_interactive = mem::take(&mut opts.non_interactive);
let stdin = mem::take(&mut opts.stdin);
let prompt = mem::take(&mut opts.prompt);
let group = mem::take(&mut opts.group);
let user = mem::take(&mut opts.user);
if bell && stdin {
return Err(xlat!(
"{context} cannot be used together with {option}",
context = "--bell",
option = "--stdin"
));
}
reject_all("--validate", opts)?;
Ok(Self {
askpass,
bell,
reset_timestamp,
non_interactive,
stdin,
prompt,
group,
user,
})
}
}
pub struct SudoEditOptions {
pub askpass: bool,
pub bell: bool,
pub reset_timestamp: bool,
pub non_interactive: bool,
pub stdin: bool,
pub prompt: Option<String>,
pub chdir: Option<SudoPath>,
pub group: Option<SudoString>,
pub user: Option<SudoString>,
pub positional_args: Vec<OsString>,
}
impl TryFrom<SudoOptions> for SudoEditOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let edit = mem::take(&mut opts.edit);
debug_assert!(edit);
let askpass = mem::take(&mut opts.askpass);
let bell = mem::take(&mut opts.bell);
let reset_timestamp = mem::take(&mut opts.reset_timestamp);
let non_interactive = mem::take(&mut opts.non_interactive);
let stdin = mem::take(&mut opts.stdin);
let prompt = mem::take(&mut opts.prompt);
let chdir = mem::take(&mut opts.chdir);
let group = mem::take(&mut opts.group);
let user = mem::take(&mut opts.user);
let positional_args = mem::take(&mut opts.positional_args);
if bell && stdin {
return Err(xlat!(
"{context} cannot be used together with {option}",
context = "--bell",
option = "--stdin"
));
}
reject_all("--edit", opts)?;
if positional_args.is_empty() {
return Err(xlat!("must specify at least one file path").into());
}
Ok(Self {
askpass,
bell,
reset_timestamp,
non_interactive,
stdin,
prompt,
chdir,
group,
user,
positional_args,
})
}
}
pub struct SudoListOptions {
pub askpass: bool,
pub bell: bool,
pub list: List,
pub reset_timestamp: bool,
pub non_interactive: bool,
pub stdin: bool,
pub prompt: Option<String>,
pub group: Option<SudoString>,
pub other_user: Option<SudoString>,
pub user: Option<SudoString>,
pub positional_args: Vec<OsString>,
}
impl TryFrom<SudoOptions> for SudoListOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let askpass = mem::take(&mut opts.askpass);
let bell = mem::take(&mut opts.bell);
let list = opts.list.take().unwrap();
let reset_timestamp = mem::take(&mut opts.reset_timestamp);
let non_interactive = mem::take(&mut opts.non_interactive);
let stdin = mem::take(&mut opts.stdin);
let prompt = mem::take(&mut opts.prompt);
let group = mem::take(&mut opts.group);
let other_user = mem::take(&mut opts.other_user);
let user = mem::take(&mut opts.user);
let positional_args = mem::take(&mut opts.positional_args);
if bell && stdin {
return Err(xlat!(
"{context} cannot be used together with {option}",
context = "--bell",
option = "--stdin"
));
}
let has_command = !positional_args.is_empty();
let valid_user_flag = user.is_none() || has_command;
if !valid_user_flag {
return Err(xlat!(
"'{option}' flag must be accompanied by a command",
option = "--user"
));
}
reject_all("--list", opts)?;
Ok(Self {
askpass,
bell,
list,
reset_timestamp,
non_interactive,
stdin,
prompt,
group,
other_user,
user,
positional_args,
})
}
}
pub struct SudoRunOptions {
pub askpass: bool,
pub bell: bool,
pub background: bool,
pub reset_timestamp: bool,
pub non_interactive: bool,
pub stdin: bool,
pub prompt: Option<String>,
pub chdir: Option<SudoPath>,
pub group: Option<SudoString>,
pub user: Option<SudoString>,
pub env_var_list: Vec<(String, String)>,
pub login: bool,
pub shell: bool,
pub positional_args: Vec<OsString>,
}
impl TryFrom<SudoOptions> for SudoRunOptions {
type Error = String;
fn try_from(mut opts: SudoOptions) -> Result<Self, Self::Error> {
let askpass = mem::take(&mut opts.askpass);
let bell = mem::take(&mut opts.bell);
let background = mem::take(&mut opts.background);
let reset_timestamp = mem::take(&mut opts.reset_timestamp);
let non_interactive = mem::take(&mut opts.non_interactive);
let stdin = mem::take(&mut opts.stdin);
let prompt = mem::take(&mut opts.prompt);
let chdir = mem::take(&mut opts.chdir);
let group = mem::take(&mut opts.group);
let user = mem::take(&mut opts.user);
let env_var_list = mem::take(&mut opts.env_var_list);
let login = mem::take(&mut opts.login);
let shell = mem::take(&mut opts.shell);
let positional_args = mem::take(&mut opts.positional_args);
if bell && stdin {
return Err(xlat!(
"{context} cannot be used together with {option}",
context = "--bell",
option = "--stdin"
));
}
let context = match (login, shell, positional_args.is_empty()) {
(true, false, _) => "--login",
(false, true, _) => "--shell",
(false, false, false) => xlat!("command (positional argument)"),
(true, true, _) => {
return Err(xlat!(
"{context} cannot be used together with {option}",
context = "--login",
option = "--shell"
));
}
(false, false, true) => {
if cfg!(debug_assertions) {
panic!();
} else {
return Err(xlat!(
"expected one of: --login, --shell, a command as a positional argument"
)
.into());
}
}
};
reject_all(context, opts)?;
Ok(Self {
askpass,
bell,
background,
reset_timestamp,
non_interactive,
stdin,
prompt,
chdir,
group,
user,
env_var_list,
login,
shell,
positional_args,
})
}
}
#[derive(Default)]
struct SudoOptions {
askpass: bool,
bell: bool,
background: bool,
chdir: Option<SudoPath>,
group: Option<SudoString>,
login: bool,
non_interactive: bool,
other_user: Option<SudoString>,
shell: bool,
stdin: bool,
prompt: Option<String>,
user: Option<SudoString>,
env_var_list: Vec<(String, String)>,
edit: bool,
help: bool,
list: Option<List>,
remove_timestamp: bool,
reset_timestamp: bool,
validate: bool,
version: bool,
positional_args: Vec<OsString>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum List {
Once,
Verbose,
}
impl List {
#[must_use]
pub fn is_verbose(&self) -> bool {
matches!(self, Self::Verbose)
}
}
enum SudoArg {
Flag(String),
Argument(String, String),
Environment(String, String),
Rest(Vec<OsString>),
}
fn demand_utf8(arg: &OsStr) -> String {
xlat!(
"argument '{arg}' is not valid UTF-8",
arg = DisplayOsStr(arg)
)
}
impl SudoArg {
const TAKES_ARGUMENT_SHORT: &'static [char] = &['D', 'g', 'h', 'p', 'R', 'U', 'u'];
const TAKES_ARGUMENT: &'static [&'static str] = &[
"chdir",
"group",
"host",
"chroot",
"other-user",
"user",
"prompt",
];
fn normalize_arguments<I>(mut arg_iter: I) -> Result<Vec<Self>, String>
where
I: Iterator<Item = OsString>,
{
let mut processed = vec![];
while let Some(arg) = arg_iter.next() {
if arg == "--" {
processed.push(SudoArg::Rest(arg_iter.collect()));
break;
} else if let Some(unprefixed) = arg.as_encoded_bytes().strip_prefix(b"--") {
let unprefixed = str::from_utf8(unprefixed).map_err(|_| demand_utf8(&arg))?;
if let Some((key, value)) = unprefixed.split_once('=') {
if !Self::TAKES_ARGUMENT.contains(&key) && key != "preserve-env" {
Err(xlat!(
"'{option}' does not take any arguments",
option = key
))?;
}
processed.push(SudoArg::Argument("--".to_string() + key, value.to_string()));
} else if Self::TAKES_ARGUMENT.contains(&unprefixed) {
let arg = String::from_utf8(arg.into_encoded_bytes()).expect("already checked");
if let Some(next) = arg_iter.next() {
let next = str::from_utf8(next.as_encoded_bytes())
.map_err(|_| demand_utf8(&next))?;
processed.push(SudoArg::Argument(arg, next.to_owned()));
} else {
Err(xlat!("'{option}' expects an argument", option = arg))?;
}
} else {
let arg = String::from_utf8(arg.into_encoded_bytes()).expect("already checked");
processed.push(SudoArg::Flag(arg));
}
} else if let Some(unprefixed) = arg.as_encoded_bytes().strip_prefix(b"-") {
let unprefixed = str::from_utf8(unprefixed).map_err(|_| demand_utf8(&arg))?;
let mut chars = unprefixed.chars();
while let Some(curr) = chars.next() {
let flag = format!("-{curr}");
if Self::TAKES_ARGUMENT_SHORT.contains(&curr) {
let rest = chars.as_str();
let next = chars.next();
if next == Some('=') {
Err(xlat!("invalid option '='"))?;
}
if next.is_some() {
processed.push(SudoArg::Argument(flag, rest.to_string()));
} else if let Some(next) = arg_iter.next() {
let next = str::from_utf8(next.as_encoded_bytes())
.map_err(|_| demand_utf8(&next))?;
processed.push(SudoArg::Argument(flag, next.to_owned()));
} else if curr == 'h' {
processed.push(SudoArg::Flag(flag));
} else {
Err(xlat!("'{option}' expects an argument", option = flag))?;
}
break;
} else {
processed.push(SudoArg::Flag(flag));
}
}
} else if let Some((key, value)) = try_to_env_var(&arg)? {
processed.push(SudoArg::Environment(key, value));
} else {
let mut rest = vec![arg];
rest.extend(arg_iter);
processed.push(SudoArg::Rest(rest));
break;
}
}
Ok(processed)
}
}
impl SudoOptions {
fn validate(self) -> Result<SudoAction, String> {
let action = if self.help {
SudoAction::Help(self.try_into()?)
} else if self.version {
SudoAction::Version(self.try_into()?)
} else if self.remove_timestamp {
SudoAction::RemoveTimestamp(self.try_into()?)
} else if self.validate {
SudoAction::Validate(self.try_into()?)
} else if self.list.is_some() {
SudoAction::List(self.try_into()?)
} else if self.edit {
SudoAction::Edit(self.try_into()?)
} else {
let is_run = self.login | self.shell | !self.positional_args.is_empty();
if is_run {
SudoAction::Run(self.try_into()?)
} else if self.reset_timestamp {
SudoAction::ResetTimestamp(self.try_into()?)
} else {
return Err(xlat!("expected one of these actions: --help, --version, --remove-timestamp, --validate, --list, --edit, --login, --shell, a command as a positional argument, --reset-timestamp").into());
}
};
Ok(action)
}
fn try_parse_from<I, T>(iter: I) -> Result<Self, String>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut arg_iter = iter.into_iter().map(Into::into);
let invoked_as_sudoedit = is_sudoedit(arg_iter.next());
let mut options = Self {
edit: invoked_as_sudoedit,
..Self::default()
};
let arg_iter = SudoArg::normalize_arguments(arg_iter)?
.into_iter()
.peekable();
for arg in arg_iter {
match arg {
SudoArg::Flag(flag) => match flag.as_str() {
"-A" | "--askpass" => {
options.askpass = true;
}
"-B" | "--bell" => {
options.bell = true;
}
"-b" | "--background" => {
options.background = true;
}
"-E" | "--preserve-env" => {
user_warn!(
"preserving the entire environment is not supported, '{flag}' is ignored",
flag = flag
)
}
"-e" | "--edit" if !invoked_as_sudoedit => {
options.edit = true;
}
"-H" | "--set-home" => {
}
"-h" | "--help" => {
options.help = true;
}
"-i" | "--login" => {
options.login = true;
}
"-K" | "--remove-timestamp" => {
options.remove_timestamp = true;
}
"-k" | "--reset-timestamp" => {
options.reset_timestamp = true;
}
"-l" | "--list" => match options.list {
None => options.list = Some(List::Once),
Some(List::Once) => options.list = Some(List::Verbose),
Some(List::Verbose) => {}
},
"-n" | "--non-interactive" => {
options.non_interactive = true;
}
"-S" | "--stdin" => {
options.stdin = true;
}
"-s" | "--shell" => {
options.shell = true;
}
"-V" | "--version" => {
options.version = true;
}
"-v" | "--validate" => {
options.validate = true;
}
_option => {
Err(xlat!("invalid option provided"))?;
}
},
SudoArg::Argument(option, value) => match option.as_str() {
"-D" | "--chdir" => {
options.chdir = Some(SudoPath::from_cli_string(value));
}
"-E" | "--preserve-env" => {
options
.env_var_list
.extend(value.split(',').filter_map(|var| {
std::env::var(var)
.ok()
.map(|value| (var.to_string(), value))
}));
}
"-g" | "--group" => {
options.group = Some(SudoString::from_cli_string(value));
}
"-p" | "--prompt" => {
options.prompt = Some(value);
}
"-U" | "--other-user" => {
options.other_user = Some(SudoString::from_cli_string(value));
}
"-u" | "--user" => {
options.user = Some(SudoString::from_cli_string(value));
}
_option => {
Err(xlat!("invalid option provided"))?;
}
},
SudoArg::Environment(key, value) => {
options.env_var_list.push((key, value));
}
SudoArg::Rest(mut rest) => {
if let Some(cmd) = rest.first() {
let cmd = std::path::Path::new(cmd);
let is_dir = cmd.as_os_str().as_encoded_bytes().ends_with(b"/");
if !options.edit
&& !is_dir
&& (cmd.ends_with("sudoedit") || cmd.ends_with("sudoedit-rs"))
{
user_warn!("sudoedit doesn't need to be run via sudo");
options.edit = true;
rest.remove(0);
}
}
options.positional_args = rest;
}
}
}
Ok(options)
}
}
fn try_to_env_var(arg: &OsStr) -> Result<Option<(String, String)>, String> {
if !arg.as_encoded_bytes().contains(&b'=') {
return Ok(None);
}
let arg = str::from_utf8(arg.as_encoded_bytes()).map_err(|_| demand_utf8(arg))?;
let (name, value) = arg
.split_once('=')
.expect("checked above that an = is present");
if name.chars().all(|c| c.is_alphanumeric() || c == '_') {
Ok(Some((name.to_owned(), value.to_owned())))
} else {
Ok(None)
}
}
trait IsAbsent {
fn is_absent(&self) -> bool;
}
impl IsAbsent for bool {
fn is_absent(&self) -> bool {
!*self
}
}
impl<T> IsAbsent for Option<T> {
fn is_absent(&self) -> bool {
self.is_none()
}
}
impl<T> IsAbsent for Vec<T> {
fn is_absent(&self) -> bool {
self.is_empty()
}
}
fn ensure_is_absent(context: &str, thing: &dyn IsAbsent, name: &str) -> Result<(), String> {
if thing.is_absent() {
Ok(())
} else {
Err(xlat!(
"{context} cannot be used together with {option}",
context = context,
option = name
))
}
}
fn reject_all(context: &str, opts: SudoOptions) -> Result<(), String> {
macro_rules! check_options {
($($field:ident $(= $name:expr)?,)*) => {{
let SudoOptions { $($field),* } = opts;
$(
let name = check_options!(@name $field $($name)?);
ensure_is_absent(context, &$field, &name)?;
)*
Ok(())
}};
(@name $field:ident) => {{
let name = concat!("--", stringify!($field));
if name.contains('_') {
Cow::Owned(name.replace('_', "-"))
} else {
Cow::Borrowed(name)
}
}};
(@name $field:ident $name:expr) => {
$name
};
}
check_options!(
askpass,
bell,
background,
chdir,
edit,
group,
help,
list,
login,
non_interactive,
other_user,
remove_timestamp,
reset_timestamp,
shell,
stdin,
prompt,
user,
validate,
version,
positional_args = xlat!("command"),
env_var_list = xlat!("environment variable"),
)
}