1#![forbid(unsafe_code)]
2
3mod cli;
4mod help;
5
6use std::{
7 env, ffi,
8 fs::{File, Permissions},
9 io::{self, BufRead, Read, Seek, Write},
10 os::unix::{
11 fs::fchown,
12 prelude::{MetadataExt, PermissionsExt},
13 },
14 path::{Path, PathBuf},
15 process::Command,
16 str,
17};
18
19use crate::{
20 common::resolve::CurrentUser,
21 sudo::{candidate_sudoers_file, diagnostic},
22 sudoers::{self, Sudoers},
23 system::{
24 Hostname, User,
25 file::{FileLock, create_temporary_dir},
26 interface::UserId,
27 signal::{SignalStream, SignalsState, consts::*, register_handlers},
28 },
29};
30
31use self::cli::{VisudoAction, VisudoOptions};
32use self::help::{USAGE_MSG, long_help_message};
33
34const VERSION: &str = env!("CARGO_PKG_VERSION");
35
36macro_rules! io_msg {
37 ($err:expr, $($tt:tt)*) => {
38 io::Error::new($err.kind(), format!("{}: {}", format_args!($($tt)*), $err))
39 };
40}
41
42pub fn main() {
43 if User::effective_uid() != User::real_uid() || User::effective_gid() != User::real_gid() {
44 println_ignore_io_error!(
45 "Visudo must not be installed as setuid binary.\n\
46 Please notify your packager about this misconfiguration.\n\
47 To prevent privilege escalation visudo will now abort.
48 "
49 );
50 std::process::exit(1);
51 }
52
53 let options = match VisudoOptions::from_env() {
54 Ok(options) => options,
55 Err(error) => {
56 println_ignore_io_error!("visudo: {error}\n{USAGE_MSG}");
57 std::process::exit(1);
58 }
59 };
60
61 let cmd = match options.action {
62 VisudoAction::Help => {
63 println_ignore_io_error!("{}", long_help_message());
64 std::process::exit(0);
65 }
66 VisudoAction::Version => {
67 println_ignore_io_error!("visudo-rs {VERSION}");
68 std::process::exit(0);
69 }
70 VisudoAction::Check => check,
71 VisudoAction::Run => run,
72 };
73
74 match cmd(options.file.as_deref(), options.perms, options.owner) {
75 Ok(()) => {}
76 Err(error) => {
77 eprintln_ignore_io_error!("visudo: {error}");
78 std::process::exit(1);
79 }
80 }
81}
82
83fn check(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> {
84 let mut sudoers_path = file_arg
85 .map(PathBuf::from)
86 .unwrap_or_else(candidate_sudoers_file);
87
88 let sudoers_file = File::open(if sudoers_path == Path::new("-") {
89 sudoers_path = PathBuf::from("stdin");
91 Path::new("/dev/stdin")
92 } else {
93 &sudoers_path
94 })
95 .map_err(|err| io_msg!(err, "unable to open {}", sudoers_path.display()))?;
96
97 let metadata = sudoers_file.metadata()?;
98
99 if file_arg.is_none() || perms {
100 let mode = metadata.permissions().mode() & 0o777;
102
103 if mode != 0o440 {
104 return Err(io::Error::other(format!(
105 "{}: bad permissions, should be mode 0440, but found {mode:04o}",
106 sudoers_path.display()
107 )));
108 }
109 }
110
111 if file_arg.is_none() || owner {
112 let owner = (metadata.uid(), metadata.gid());
113
114 if owner != (0, 0) {
115 return Err(io::Error::other(format!(
116 "{}: wrong owner (uid, gid) should be (0, 0), but found {owner:?}",
117 sudoers_path.display()
118 )));
119 }
120 }
121
122 let (_sudoers, errors) = Sudoers::read(&sudoers_file, &sudoers_path)?;
123
124 if errors.is_empty() {
125 writeln!(io::stdout(), "{}: parsed OK", sudoers_path.display())?;
126 return Ok(());
127 }
128
129 for crate::sudoers::Error {
130 message,
131 source,
132 location,
133 } in errors
134 {
135 let path = source.as_deref().unwrap_or(&sudoers_path);
136 diagnostic::diagnostic!("syntax error: {message}", path @ location);
137 }
138
139 Err(io::Error::other("invalid sudoers file"))
140}
141
142fn run(file_arg: Option<&str>, perms: bool, owner: bool) -> io::Result<()> {
143 let sudoers_path = &file_arg
144 .map(PathBuf::from)
145 .unwrap_or_else(candidate_sudoers_file);
146
147 let (sudoers_file, existed) = if sudoers_path.exists() {
148 let file = File::options()
149 .read(true)
150 .write(true)
151 .open(sudoers_path)
152 .map_err(|err| {
153 io_msg!(
154 err,
155 "Failed to open existing sudoers file at {sudoers_path:?}"
156 )
157 })?;
158
159 (file, true)
160 } else {
161 let file = File::create(sudoers_path)
163 .map_err(|err| io_msg!(err, "Failed to create sudoers file at {sudoers_path:?}"))?;
164
165 if file_arg.is_some() {
168 file.set_permissions(Permissions::from_mode(0o640))
169 .map_err(|err| {
170 io_msg!(
171 err,
172 "Failed to set permissions on new sudoers file at {sudoers_path:?}"
173 )
174 })?;
175 }
176 (file, false)
177 };
178
179 let lock = FileLock::exclusive(&sudoers_file, true).map_err(|err| {
180 if err.kind() == io::ErrorKind::WouldBlock {
181 io_msg!(err, "{} busy, try again later", sudoers_path.display())
182 } else {
183 err
184 }
185 })?;
186
187 if perms || file_arg.is_none() {
188 sudoers_file.set_permissions(Permissions::from_mode(0o440))?;
189 }
190
191 if owner || file_arg.is_none() {
192 fchown(&sudoers_file, Some(0), Some(0))?;
193 }
194
195 let signal_stream = SignalStream::init()?;
196
197 let handlers = register_handlers(
198 [SIGTERM, SIGHUP, SIGINT, SIGQUIT],
199 &mut SignalsState::save()?,
200 )?;
201
202 let tmp_dir = create_temporary_dir()?;
203 let tmp_path = tmp_dir.join("sudoers");
204
205 {
206 let tmp_dir = tmp_dir.clone();
207 std::thread::spawn(|| -> io::Result<()> {
208 signal_stream.recv()?;
209
210 let _ = std::fs::remove_dir_all(tmp_dir);
211
212 drop(handlers);
213
214 std::process::exit(1)
215 });
216 }
217
218 let tmp_file = File::options()
219 .read(true)
220 .write(true)
221 .create(true)
222 .truncate(true)
223 .open(&tmp_path)?;
224
225 tmp_file.set_permissions(Permissions::from_mode(0o600))?;
226
227 let result = edit_sudoers_file(
228 existed,
229 sudoers_file,
230 sudoers_path,
231 lock,
232 tmp_file,
233 &tmp_path,
234 );
235
236 std::fs::remove_dir_all(tmp_dir)?;
237
238 result
239}
240
241fn edit_sudoers_file(
242 existed: bool,
243 mut sudoers_file: File,
244 sudoers_path: &Path,
245 lock: FileLock,
246 mut tmp_file: File,
247 tmp_path: &Path,
248) -> io::Result<()> {
249 let mut stderr = io::stderr();
250
251 let mut sudoers_contents = Vec::new();
252
253 let current_user: User = match CurrentUser::resolve() {
255 Ok(user) => user.into(),
256 Err(err) => {
257 writeln!(stderr, "visudo: cannot resolve : {err}")?;
258 return Ok(());
259 }
260 };
261
262 let host_name = Hostname::resolve();
263
264 if existed {
265 sudoers_file.read_to_end(&mut sudoers_contents)?;
267 sudoers_file.rewind()?;
269 tmp_file.write_all(&sudoers_contents)?;
271 }
272
273 let editor_path = Sudoers::read(sudoers_contents.as_slice(), sudoers_path)?
274 .0
275 .visudo_editor_path(&host_name, ¤t_user, ¤t_user)
276 .ok_or_else(|| {
277 io::Error::new(io::ErrorKind::NotFound, "no usable editor could be found")
278 })?;
279
280 loop {
281 Command::new(&editor_path.0)
282 .args(&editor_path.1)
283 .arg("--")
284 .arg(tmp_path)
285 .spawn()
286 .map_err(|_| {
287 io::Error::new(
288 io::ErrorKind::NotFound,
289 format!(
290 "specified editor ({}) could not be used",
291 editor_path.0.display()
292 ),
293 )
294 })?
295 .wait_with_output()?;
296
297 let (sudoers, errors) = File::open(tmp_path)
298 .and_then(|reader| Sudoers::read(reader, tmp_path))
299 .map_err(|err| {
300 io_msg!(
301 err,
302 "unable to re-open temporary file ({}), {} unchanged",
303 tmp_path.display(),
304 sudoers_path.display()
305 )
306 })?;
307
308 if !errors.is_empty() {
309 writeln!(
310 stderr,
311 "The provided sudoers file format is not recognized or contains syntax errors. Please review:\n"
312 )?;
313
314 for crate::sudoers::Error {
315 message,
316 source,
317 location,
318 } in errors
319 {
320 let path = source.as_deref().unwrap_or(sudoers_path);
321 diagnostic::diagnostic!("syntax error: {message}", path @ location);
322 }
323
324 writeln!(stderr)?;
325
326 match ask_response(
327 "What now? e(x)it without saving / (e)dit again: ",
328 "xe",
329 'x',
330 )? {
331 'x' => return Ok(()),
332 _ => continue,
333 }
334 } else {
335 if sudoers_path == Path::new("/etc/sudoers")
336 && sudo_visudo_is_allowed(sudoers, &host_name) == Some(false)
337 {
338 writeln!(
339 stderr,
340 "It looks like you have removed your ability to run 'sudo visudo' again.\n"
341 )?;
342 match ask_response(
343 "What now? e(x)it without saving / (e)dit again / lock me out and (S)ave: ",
344 "xeS",
345 'x',
346 )? {
347 'x' => return Ok(()),
348 'S' => {}
349 _ => continue,
350 }
351 }
352
353 break;
354 }
355 }
356
357 let tmp_contents = std::fs::read(tmp_path)?;
358 if tmp_contents == sudoers_contents {
360 writeln!(stderr, "visudo: {} unchanged", tmp_path.display())?;
361 } else {
362 sudoers_file.write_all(&tmp_contents)?;
363 let new_size = sudoers_file.stream_position()?;
364 sudoers_file.set_len(new_size)?;
365 }
366
367 lock.unlock()?;
368
369 Ok(())
370}
371
372fn sudo_visudo_is_allowed(mut sudoers: Sudoers, host_name: &Hostname) -> Option<bool> {
379 let sudo_user =
380 User::from_name(&ffi::CString::new(env::var("SUDO_USER").ok()?).ok()?).ok()??;
381
382 let super_user = User::from_uid(UserId::ROOT).ok()??;
383
384 let request = sudoers::Request {
385 user: &super_user,
386 group: &super_user.primary_group().ok()?,
387 command: &env::current_exe().ok()?,
388 arguments: &[],
389 };
390
391 Some(matches!(
392 sudoers
393 .check(&sudo_user, host_name, request)
394 .authorization(),
395 sudoers::Authorization::Allowed { .. }
396 ))
397}
398
399pub(crate) fn ask_response(
401 prompt: &str,
402 valid_responses: &str,
403 safe_choice: char,
404) -> io::Result<char> {
405 let stdin = io::stdin();
406 let stdout = io::stdout();
407 let mut stderr = io::stderr();
408
409 let stdin_handle = stdin.lock();
410 let mut stdout_handle = stdout.lock();
411
412 let mut lines = stdin_handle.lines();
413
414 loop {
415 stdout_handle.write_all(prompt.as_bytes())?;
416 stdout_handle.flush()?;
417
418 match lines.next() {
419 Some(Ok(answer))
420 if answer
421 .chars()
422 .next()
423 .is_some_and(|input| valid_responses.contains(input)) =>
424 {
425 return Ok(answer.chars().next().unwrap());
426 }
427 Some(Ok(answer)) => writeln!(stderr, "Invalid option: '{answer}'\n",)?,
428 Some(Err(err)) => writeln!(stderr, "Invalid response: {err}\n",)?,
429 None => {
430 writeln!(stderr, "visudo: cannot read user input")?;
431 return Ok(safe_choice);
432 }
433 }
434 }
435}