sudo-rs 0.2.13

A memory safe implementation of sudo and su.
Documentation
#![allow(unsafe_code)]

use std::ffi::OsString;
use std::fs::File;
use std::io::{Read, Seek, Write};
use std::net::Shutdown;
use std::os::unix::{fs::OpenOptionsExt, net::UnixStream, process::ExitStatusExt};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::{io, process};

use crate::common::SudoPath;
use crate::exec::ExitReason;
use crate::log::{user_error, user_info};
use crate::system::file::{FileLock, create_temporary_dir};
use crate::system::wait::{Wait, WaitError, WaitOptions};
use crate::system::{ForkResult, fork, mark_fds_as_cloexec};

struct ParentFileInfo<'a> {
    path: &'a Path,
    file: File,
    lock: FileLock,
    old_data: Vec<u8>,
    new_data_rx: UnixStream,
    new_data: Option<Vec<u8>>,
}

struct ChildFileInfo<'a> {
    path: &'a Path,
    old_data: Vec<u8>,
    tempfile_path: Option<PathBuf>,
    new_data_tx: UnixStream,
}

pub(super) fn edit_files(
    editor: (PathBuf, Vec<OsString>),
    selected_files: Vec<(&SudoPath, File)>,
) -> io::Result<ExitReason> {
    let mut files = vec![];
    let mut child_files = vec![];
    for (path, mut file) in selected_files {
        // Error for special files
        let metadata = file.metadata().map_err(|e| {
            io::Error::new(
                e.kind(),
                xlat!(
                    "failed to read metadata for {path}: {error}",
                    path = path.display(),
                    error = e
                ),
            )
        })?;
        if !metadata.is_file() {
            return Err(io::Error::other(xlat!(
                "file {path} is not a regular file",
                path = path.display()
            )));
        }

        // Take file lock
        let lock = FileLock::exclusive(&file, true).map_err(|e| {
            io::Error::new(
                e.kind(),
                xlat!(
                    "failed to lock {path}: {error}",
                    path = path.display(),
                    error = e
                ),
            )
        })?;

        // Read file
        let mut old_data = Vec::new();
        file.read_to_end(&mut old_data).map_err(|e| {
            io::Error::new(
                e.kind(),
                xlat!(
                    "failed to read {path}: {error}",
                    path = path.display(),
                    error = e
                ),
            )
        })?;

        // Create socket
        let (parent_socket, child_socket) = UnixStream::pair()?;

        files.push(ParentFileInfo {
            path,
            file,
            lock,
            old_data: old_data.clone(),
            new_data_rx: parent_socket,
            new_data: None,
        });

        child_files.push(ChildFileInfo {
            path,
            old_data,
            tempfile_path: None,
            new_data_tx: child_socket,
        });
    }

    // Spawn child
    // SAFETY: There should be no other threads at this point.
    let ForkResult::Parent(command_pid) = unsafe { fork() }? else {
        drop(files);
        handle_child(editor, child_files)
    };
    drop(child_files);

    for file in &mut files {
        // Read from socket
        file.new_data =
            Some(read_stream(&mut file.new_data_rx).map_err(|e| {
                io::Error::new(e.kind(), format!("Failed to read from socket: {e}"))
            })?);
    }

    // If child has error, exit with non-zero exit code
    let status = loop {
        match command_pid.wait(WaitOptions::new()) {
            Ok((_, status)) => break status,
            Err(WaitError::Io(err)) if err.kind() == io::ErrorKind::Interrupted => {}
            Err(err) => panic!("{err:?}"),
        }
    };
    assert!(status.did_exit());

    if let Some(signal) = status.term_signal() {
        return Ok(ExitReason::Signal(signal));
    } else if let Some(code) = status.exit_status() {
        if code != 0 {
            return Ok(ExitReason::Code(code));
        }
    } else {
        return Ok(ExitReason::Code(1));
    }

    for mut file in files {
        let data = file.new_data.expect("filled in above");
        if data == file.old_data {
            // File unchanged. No need to write it again.
            user_info!("{path} unchanged", path = file.path.display());
            continue;
        }

        // FIXME check if modified since reading and if so ask user what to do

        // Write file
        (move || {
            file.file.rewind()?;
            file.file.write_all(&data)?;
            file.file.set_len(
                data.len()
                    .try_into()
                    .expect("more than 18 exabyte of data???"),
            )
        })()
        .map_err(|e| {
            io::Error::new(
                e.kind(),
                xlat!(
                    "failed to write {path}: {error}",
                    path = file.path.display(),
                    error = e
                ),
            )
        })?;

        drop(file.lock);
    }

    Ok(ExitReason::Code(0))
}

struct TempDirDropGuard(PathBuf);

impl Drop for TempDirDropGuard {
    fn drop(&mut self) {
        if let Err(e) = std::fs::remove_dir_all(&self.0) {
            user_error!(
                "failed to remove temporary directory {path}: {error}",
                path = self.0.display(),
                error = e
            );
        };
    }
}

fn handle_child(editor: (PathBuf, Vec<OsString>), file: Vec<ChildFileInfo<'_>>) -> ! {
    match handle_child_inner(editor, file) {
        Ok(()) => process::exit(0),
        Err(err) => {
            user_error!("{error}", error = err);
            process::exit(1);
        }
    }
}

// FIXME maybe use pipes once std::io::pipe has been stabilized long enough.
fn handle_child_inner(
    editor: (PathBuf, Vec<OsString>),
    mut files: Vec<ChildFileInfo<'_>>,
) -> Result<(), String> {
    mark_fds_as_cloexec().map_err(|e| format!("Failed to mark fds as CLOEXEC: {e}"))?;

    // Drop root privileges.
    // SAFETY: setuid does not change any memory and only affects OS state.
    unsafe {
        libc::setuid(libc::getuid());
    }

    let tempdir = TempDirDropGuard(
        create_temporary_dir()
            .map_err(|e| xlat!("failed to create temporary directory: {error}", error = e))?,
    );

    for (i, file) in files.iter_mut().enumerate() {
        // Create temp file
        let dir = tempdir.0.join(format!("{i}"));
        std::fs::create_dir(&dir).map_err(|e| {
            xlat!(
                "failed to create temporary directory {path}: {error}",
                path = dir.display(),
                error = e
            )
        })?;
        let tempfile_path = dir.join(file.path.file_name().expect("file must have filename"));
        let mut tempfile = std::fs::OpenOptions::new()
            .read(true)
            .write(true)
            .create_new(true)
            .mode(0o600)
            .open(&tempfile_path)
            .map_err(|e| {
                xlat!(
                    "failed to create temporary file {path}: {error}",
                    path = tempfile_path.display(),
                    error = e
                )
            })?;

        // Write to temp file
        tempfile.write_all(&file.old_data).map_err(|e| {
            xlat!(
                "failed to write to temporary file {path}: {error}",
                path = tempfile_path.display(),
                error = e
            )
        })?;
        drop(tempfile);
        file.tempfile_path = Some(tempfile_path);
    }

    // Spawn editor
    let status = Command::new(&editor.0)
        .args(editor.1)
        .args(
            files
                .iter()
                .map(|file| file.tempfile_path.as_ref().expect("filled in above")),
        )
        .status()
        .map_err(|e| {
            xlat!(
                "failed to run editor {path}: {error}",
                path = editor.0.display(),
                error = e
            )
        })?;

    if !status.success() {
        drop(tempdir);

        if let Some(signal) = status.signal() {
            process::exit(128 + signal);
        }
        process::exit(status.code().unwrap_or(1));
    }

    for mut file in files {
        let tempfile_path = file.tempfile_path.as_ref().expect("filled in above");

        // Read from temp file
        let new_data = std::fs::read(tempfile_path).map_err(|e| {
            xlat!(
                "failed to read from temporary file {path}: {error}",
                path = tempfile_path.display(),
                error = e
            )
        })?;

        // FIXME preserve temporary file if the original couldn't be written to
        std::fs::remove_file(tempfile_path).map_err(|e| {
            xlat!(
                "failed to remove temporary file {path}: {error}",
                path = tempfile_path.display(),
                error = e
            )
        })?;

        // If the file has been changed to be empty, ask the user what to do.
        if new_data.is_empty() && new_data != file.old_data {
            // TRANSLATORS: the initial letters of 'yes' and 'no' responses, in that order
            let answers = xlat!("yn");

            match crate::visudo::ask_response(
                &xlat!(
                    "sudoedit: truncate {path} to zero? (y/n) [n] ",
                    path = file.path.display()
                ),
                answers,
                answers
                    .chars()
                    .last()
                    .expect("translation files are corrupted"),
            ) {
                Ok(val) if answers.starts_with(val) => {}
                // a fallback: also accept 'yes' based on Debian's apt behaviour
                Ok('y') if !answers.contains('y') => {}
                _ => {
                    user_info!("not overwriting {path}", path = file.path.display());

                    // Parent ignores write when new data matches old data
                    write_stream(&mut file.new_data_tx, &file.old_data)
                        .map_err(|e| xlat!("failed to write data to parent: {error}", error = e))?;

                    continue;
                }
            }
        }

        // Write to socket
        write_stream(&mut file.new_data_tx, &new_data)
            .map_err(|e| xlat!("failed to write data to parent: {error}", error = e))?;
    }

    process::exit(0);
}

fn write_stream(socket: &mut UnixStream, data: &[u8]) -> io::Result<()> {
    socket.write_all(data)?;
    socket.shutdown(Shutdown::Both)?;
    Ok(())
}

fn read_stream(socket: &mut UnixStream) -> io::Result<Vec<u8>> {
    let mut new_data = Vec::new();
    socket.read_to_end(&mut new_data)?;
    Ok(new_data)
}