#![forbid(unsafe_code)]
mod cli;
mod help;
use std::{
env, ffi,
fs::{File, Permissions},
io::{self, BufRead, Read, Seek, Write},
os::unix::{
fs::fchown,
prelude::{MetadataExt, PermissionsExt},
},
path::{Path, PathBuf},
process::Command,
str,
};
use crate::{
common::resolve::CurrentUser,
sudo::{candidate_sudoers_file, diagnostic},
sudoers::{self, Sudoers},
system::{
Hostname, User,
file::{FileLock, create_temporary_dir},
interface::UserId,
signal::{SignalStream, SignalsState, consts::*, register_handlers},
},
};
use self::cli::{VisudoAction, VisudoOptions};
use self::help::{USAGE_MSG, long_help_message};
const VERSION: &str = env!("CARGO_PKG_VERSION");
macro_rules! io_msg {
($err:expr, $($tt:tt)*) => {
io::Error::new($err.kind(), format!("{}: {}", format_args!($($tt)*), $err))
};
}
pub fn main() {
if User::effective_uid() != User::real_uid() || User::effective_gid() != User::real_gid() {
println_ignore_io_error!(
"Visudo must not be installed as setuid binary.\n\
Please notify your packager about this misconfiguration.\n\
To prevent privilege escalation visudo will now abort.
"
);
std::process::exit(1);
}
let options = match VisudoOptions::from_env() {
Ok(options) => options,
Err(error) => {
println_ignore_io_error!("visudo: {error}\n{USAGE_MSG}");
std::process::exit(1);
}
};
let cmd = match options.action {
VisudoAction::Help => {
println_ignore_io_error!("{}", long_help_message());
std::process::exit(0);
}
VisudoAction::Version => {
println_ignore_io_error!("visudo-rs {VERSION}");
std::process::exit(0);
}
VisudoAction::Check => check,
VisudoAction::Run => run,
};
match cmd(options.file.as_deref(), options.perms, options.owner) {
Ok(()) => {}
Err(error) => {
eprintln_ignore_io_error!("visudo: {error}");
std::process::exit(1);
}
}
}
fn check(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> {
let mut sudoers_path = file_arg
.map(PathBuf::from)
.unwrap_or_else(candidate_sudoers_file);
let sudoers_file = File::open(if sudoers_path == Path::new("-") {
sudoers_path = PathBuf::from("stdin");
Path::new("/dev/stdin")
} else {
&sudoers_path
})
.map_err(|err| io_msg!(err, "unable to open {}", sudoers_path.display()))?;
let metadata = sudoers_file.metadata()?;
if file_arg.is_none() || perms {
let mode = metadata.permissions().mode() & 0o777;
if mode != 0o440 {
return Err(io::Error::other(format!(
"{}: bad permissions, should be mode 0440, but found {mode:04o}",
sudoers_path.display()
)));
}
}
if file_arg.is_none() || owner {
let owner = (metadata.uid(), metadata.gid());
if owner != (0, 0) {
return Err(io::Error::other(format!(
"{}: wrong owner (uid, gid) should be (0, 0), but found {owner:?}",
sudoers_path.display()
)));
}
}
let (_sudoers, errors) = Sudoers::read(&sudoers_file, &sudoers_path)?;
if errors.is_empty() {
writeln!(io::stdout(), "{}: parsed OK", sudoers_path.display())?;
return Ok(());
}
for crate::sudoers::Error {
message,
source,
location,
} in errors
{
let path = source.as_deref().unwrap_or(&sudoers_path);
diagnostic::diagnostic!("syntax error: {message}", path @ location);
}
Err(io::Error::other("invalid sudoers file"))
}
fn run(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> {
let sudoers_path = &file_arg
.map(PathBuf::from)
.unwrap_or_else(candidate_sudoers_file);
let (sudoers_file, existed) = if sudoers_path.exists() {
let file = File::options()
.read(true)
.write(true)
.open(sudoers_path)
.map_err(|err| {
io_msg!(
err,
"Failed to open existing sudoers file at {sudoers_path:?}"
)
})?;
(file, true)
} else {
let file = File::create(sudoers_path)
.map_err(|err| io_msg!(err, "Failed to create sudoers file at {sudoers_path:?}"))?;
if file_arg.is_some() {
file.set_permissions(Permissions::from_mode(0o640))
.map_err(|err| {
io_msg!(
err,
"Failed to set permissions on new sudoers file at {sudoers_path:?}"
)
})?;
}
(file, false)
};
let lock = FileLock::exclusive(&sudoers_file, true).map_err(|err| {
if err.kind() == io::ErrorKind::WouldBlock {
io_msg!(err, "{} busy, try again later", sudoers_path.display())
} else {
err
}
})?;
if perms || file_arg.is_none() {
sudoers_file.set_permissions(Permissions::from_mode(0o440))?;
}
if owner || file_arg.is_none() {
fchown(&sudoers_file, Some(0), Some(0))?;
}
let signal_stream = SignalStream::init()?;
let handlers = register_handlers(
[SIGTERM, SIGHUP, SIGINT, SIGQUIT],
&mut SignalsState::save()?,
)?;
let tmp_dir = create_temporary_dir()?;
let tmp_path = tmp_dir.join("sudoers");
{
let tmp_dir = tmp_dir.clone();
std::thread::spawn(|| -> io::Result<()> {
signal_stream.recv()?;
let _ = std::fs::remove_dir_all(tmp_dir);
drop(handlers);
std::process::exit(1)
});
}
let tmp_file = File::options()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)?;
tmp_file.set_permissions(Permissions::from_mode(0o600))?;
let result = edit_sudoers_file(
existed,
sudoers_file,
sudoers_path,
lock,
tmp_file,
&tmp_path,
);
std::fs::remove_dir_all(tmp_dir)?;
result
}
fn edit_sudoers_file(
existed: bool,
mut sudoers_file: File,
sudoers_path: &Path,
lock: FileLock,
mut tmp_file: File,
tmp_path: &Path,
) -> io::Result<()> {
let mut stderr = io::stderr();
let mut sudoers_contents = Vec::new();
let current_user: User = match CurrentUser::resolve() {
Ok(user) => user.into(),
Err(err) => {
writeln!(stderr, "visudo: cannot resolve : {err}")?;
return Ok(());
}
};
let host_name = Hostname::resolve();
if existed {
sudoers_file.read_to_end(&mut sudoers_contents)?;
sudoers_file.rewind()?;
tmp_file.write_all(&sudoers_contents)?;
}
let editor_path = Sudoers::read(sudoers_contents.as_slice(), sudoers_path)?
.0
.visudo_editor_path(&host_name, ¤t_user, ¤t_user)
.ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "no usable editor could be found")
})?;
loop {
Command::new(&editor_path.0)
.args(&editor_path.1)
.arg("--")
.arg(tmp_path)
.spawn()
.map_err(|_| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"specified editor ({}) could not be used",
editor_path.0.display()
),
)
})?
.wait_with_output()?;
let (sudoers, errors) = File::open(tmp_path)
.and_then(|reader| Sudoers::read(reader, tmp_path))
.map_err(|err| {
io_msg!(
err,
"unable to re-open temporary file ({}), {} unchanged",
tmp_path.display(),
sudoers_path.display()
)
})?;
if !errors.is_empty() {
writeln!(
stderr,
"The provided sudoers file format is not recognized or contains syntax errors. Please review:\n"
)?;
for crate::sudoers::Error {
message,
source,
location,
} in errors
{
let path = source.as_deref().unwrap_or(sudoers_path);
diagnostic::diagnostic!("syntax error: {message}", path @ location);
}
writeln!(stderr)?;
match ask_response(
"What now? e(x)it without saving / (e)dit again: ",
"xe",
'x',
)? {
'x' => return Ok(()),
_ => continue,
}
} else {
if sudoers_path == Path::new("/etc/sudoers")
&& sudo_visudo_is_allowed(sudoers, &host_name) == Some(false)
{
writeln!(
stderr,
"It looks like you have removed your ability to run 'sudo visudo' again.\n"
)?;
match ask_response(
"What now? e(x)it without saving / (e)dit again / lock me out and (S)ave: ",
"xeS",
'x',
)? {
'x' => return Ok(()),
'S' => {}
_ => continue,
}
}
break;
}
}
let tmp_contents = std::fs::read(tmp_path)?;
if tmp_contents == sudoers_contents {
writeln!(stderr, "visudo: {} unchanged", tmp_path.display())?;
} else {
sudoers_file.write_all(&tmp_contents)?;
let new_size = sudoers_file.stream_position()?;
sudoers_file.set_len(new_size)?;
}
lock.unlock()?;
Ok(())
}
fn sudo_visudo_is_allowed(mut sudoers: Sudoers, host_name: &Hostname) -> Option<bool> {
let sudo_user =
User::from_name(&ffi::CString::new(env::var("SUDO_USER").ok()?).ok()?).ok()??;
let super_user = User::from_uid(UserId::ROOT).ok()??;
let request = sudoers::Request {
user: &super_user,
group: &super_user.primary_group().ok()?,
command: &env::current_exe().ok()?,
arguments: &[],
};
Some(matches!(
sudoers
.check(&sudo_user, host_name, request)
.authorization(),
sudoers::Authorization::Allowed { .. }
))
}
pub(crate) fn ask_response(
prompt: &str,
valid_responses: &str,
safe_choice: char,
) -> io::Result<char> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut stderr = io::stderr();
let stdin_handle = stdin.lock();
let mut stdout_handle = stdout.lock();
let mut lines = stdin_handle.lines();
loop {
stdout_handle.write_all(prompt.as_bytes())?;
stdout_handle.flush()?;
match lines.next() {
Some(Ok(answer))
if answer
.chars()
.next()
.is_some_and(|input| valid_responses.contains(input)) =>
{
return Ok(answer.chars().next().unwrap());
}
Some(Ok(answer)) => writeln!(stderr, "Invalid option: '{answer}'\n",)?,
Some(Err(err)) => writeln!(stderr, "Invalid response: {err}\n",)?,
None => {
writeln!(stderr, "visudo: cannot read user input")?;
return Ok(safe_choice);
}
}
}
}