Skip to main content

sudo_rs/visudo/
mod.rs

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        // portability: /dev/stdin 'almost POSIX' and exists on BSD and Linux systems
90        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        // For some reason, the MSB of the mode is on so we need to mask it.
101        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        // Create a sudoers file if it doesn't exist.
162        let file = File::create(sudoers_path)
163            .map_err(|err| io_msg!(err, "Failed to create sudoers file at {sudoers_path:?}"))?;
164
165        // ogvisudo sets the permissions of the file so it can be read and written by the user and
166        // read by the group if the `-f` argument was passed.
167        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    // Since visudo is meant to run as root, resolve shouldn't fail
254    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        // If the sudoers file existed, read its contents and write them into the temporary file.
266        sudoers_file.read_to_end(&mut sudoers_contents)?;
267        // Rewind the sudoers file so it can be written later.
268        sudoers_file.rewind()?;
269        // Write to the temporary file.
270        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, &current_user, &current_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    // Only write to the sudoers file if the contents changed.
359    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
372// To detect potential lock-outs if the user called "sudo visudo".
373// Note that SUDO_USER will normally be set by sudo.
374//
375// This returns Some(false) if visudo is forbidden under the given config;
376// Some(true) if it is allowed; and None if it cannot be determined, which
377// will be the case if e.g. visudo was simply run as root.
378fn 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
399// This will panic if valid_responses is empty.
400pub(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}