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