#![warn(clippy::all, clippy::pedantic)]
use clap::{Arg, ValueHint};
use libcrypt_rs::Crypt;
use su::Error;
use std::{
    ffi::{CStr, CString},
    fs::{DirBuilder, File, OpenOptions},
    io::{self, Write},
    os::unix::{
        fs::DirBuilderExt,
        prelude::{CommandExt, OpenOptionsExt},
    },
    path::PathBuf,
    process::Command,
};

static PROGNAME: &str = "jah";

#[allow(clippy::similar_names)]
fn main() -> Result<(), Error> {
    // Put the entire command into a let binding to a code block, ensuring that
    // any values we create on the way to the final Command struct are freed
    // before wee exec into the final command.
    let mut cmd = {
        // get_group_names internally creates a libc::pw struct. At least on some
        // architectures with musl, this memory location gets re-used when we create
        // another pw struct, this time for the target user. We can either use all
        // owned variables or else just move this block to the beginning of the
        // program in which case the borrows will all be valid.
        let grnames = get_group_names()?;
        // if we have a group named for this program and the user is a member
        // of it, we'll skip validation
        if !grnames.contains(&PROGNAME.to_string()) {
            // members of "wheel" are always allowed with a password
            if grnames.contains(&"wheel".to_string()) {
                validate_pw()?;
            } else {
                // nobody else can use the program
                return Err(Error::PermissionDenied);
            }
        }
        let matches = cli().get_matches();
        // theoretically this can never fail since we have a default value, but
        // I'm not a fan of `unwrap()`
        let Some(user) = matches.get_one::<String>("user") else {
            return Err("no user given".into());
        };
        let Some(group) = matches.get_one::<String>("group") else {
            return Err("no group given".into());
        };
        let pw = unsafe {
            let pw = libc::getpwnam(CString::new(user.as_str())?.as_ptr());
            if pw.is_null() {
                return Err(Error::Other("getpwnam: null pointer".to_string()));
            }
            *pw
        };
        let gr = unsafe {
            let gr = libc::getgrnam(CString::new(group.as_str())?.as_ptr());
            if gr.is_null() {
                return Err(Error::Other("getgrnam: null pointer".to_string()));
            }
            *gr
        };
        let home = unsafe { CStr::from_ptr(pw.pw_dir).to_str()? };
        let uid = pw.pw_uid;
        let gid = gr.gr_gid;
        let Some(mut command) = matches.get_many::<String>("command") else {
            return Err("no command given".into());
        };
        let Some(cmd) = command.next() else {
            return Err("no command given".into());
        };
        let mut cmd = Command::new(cmd);
        cmd.uid(uid).gid(gid).env("HOME", home);
        command.for_each(|arg| {
            cmd.arg(arg);
        });
        cmd
    };
    Err(cmd.exec().into())
}

fn get_group_names() -> Result<Vec<String>, Error> {
    let mut gids: Vec<libc::gid_t> = vec![0; 1];
    unsafe {
        let num = libc::getgroups(0, gids.as_mut_ptr());
        gids = vec![0; num.try_into()?];
        libc::getgroups(num, gids.as_mut_ptr());
    }
    let mut names = vec![];
    for id in gids {
        let name = unsafe {
            let gr = libc::getgrgid(id);
            if gr.is_null() {
                id.to_string()
            } else {
                let name = (*gr).gr_name;
                // if we don't take ownership here the OS can (and will)
                // reuse the memory
                CStr::from_ptr(name).to_str()?.to_string()
            }
        };
        names.push(name);
    }
    Ok(names)
}

fn validate_pw() -> Result<(), Error> {
    // return a tuple from this unsafe block in order to group as much unsafe
    // into a single block as possible
    let (name, oldpass) = unsafe {
        let uid = libc::getuid();
        let pw = libc::getpwuid(uid);
        if pw.is_null() {
            return Err(Error::Other("null pointer".to_string()));
        }
        let name = (*pw).pw_name;
        let spw = libc::getspnam(name);
        if spw.is_null() {
            return Err(Error::Other("null pointer".to_string()));
        }
        (
            CStr::from_ptr(name).to_str()?,
            CStr::from_ptr((*spw).sp_pwdp).to_str()?,
        )
    };
    // If we've already validated the password in the past five minutes, skip
    // doing it again
    if check_ts(name) {
        return Ok(());
    }
    let pass = rpassword::prompt_password(format!("[{PROGNAME}] password for {name}: "))?;
    // deny empty passwords
    if oldpass.starts_with('\0') {
        return Err(Error::InvalidPassword);
    }
    let mut engine = Crypt::new();
    // libcrypt gets the encryption salt from the old hashed password
    engine.set_salt(oldpass.to_string())?;
    engine.encrypt(pass)?;
    if oldpass == engine.encrypted.as_str() {
        set_ts(name)?;
        Ok(())
    } else {
        Err(Error::IncorrectPassword)
    }
}

// uses a timestamp file to see if we've authenticated in the past five minutes
// and if so skip re-authenticating
fn check_ts(name: &str) -> bool {
    let ts_file: PathBuf = ["/run/jah/ts", name].iter().collect();
    if ts_file.exists() {
        // We'll go ahead and throw out any errors here using `ok()` and fall
        // back to validating via password
        if let Some(elapsed) = File::open(&ts_file)
            .and_then(|x| x.metadata())
            .and_then(|x| x.modified())
            .ok()
            .and_then(|x| x.elapsed().ok())
        {
            if elapsed.as_secs() < 300 {
                return true;
            }
        }
    }
    false
}

// creates (if it doesn't exist) and sets a timestamp file to see if we've
// authenticated in the past five minutes
fn set_ts(name: &str) -> Result<(), io::Error> {
    let mut ts_file = PathBuf::from("/run/jah/ts");
    if !ts_file.exists() {
        DirBuilder::new()
            .recursive(true)
            // deny access for all but root
            .mode(0o700)
            .create(&ts_file)?;
    }
    ts_file.push(name);
    let mut fd = OpenOptions::new()
        .write(true)
        .truncate(true)
        .create(true)
        // deny access for all but root
        .mode(0o600)
        .open(&ts_file)?;
    let _n = fd.write(&[])?;
    Ok(())
}

pub fn cli() -> clap::Command {
    clap::Command::new(PROGNAME)
        .about("Run COMMAND as another user")
        .author("Nathan Fisher")
        .version(env!("CARGO_PKG_VERSION"))
        .before_long_help(format!(
            "The {PROGNAME} program allows to run a command as another user (default root). \
            This command is kept intentionally simple for the sake of security and \
            has no configuration file. Members of the group \"wheel\" (if it exists) \
            can run any command after giving their password. Members of the special \
            group \"{PROGNAME}\" (if it exists) can run any command without a password, \
            provided they also belong to the \"wheel\" group."
        ))
        .args([
            Arg::new("user")
                .help("The user that the command will run as")
                .short('u')
                .long("user")
                .default_value("root")
                .num_args(1),
            Arg::new("group")
                .help("The group that the command will run as")
                .short('g')
                .long("group")
                .default_value("wheel")
                .num_args(1),
            // Waiting on https://github.com/rust-lang/rust/issues/90747
            //Arg::new("groups")
            //    .help("A comma delimited list of supplementary groups to add to the process")
            //    .short('G')
            //    .long("groups")
            //    .value_delimiter(',')
            //    .num_args(1..)
            //    .required(false),
            Arg::new("command")
                .value_name("COMMAND")
                .value_hint(ValueHint::CommandWithArguments)
                .allow_hyphen_values(true)
                .trailing_var_arg(true)
                .num_args(1..)
                .required(true),
        ])
}
