#![warn(clippy::all, clippy::pedantic)]
use clap::{Arg, ArgAction, Command};
use libcrypt_rs::Crypt;
use std::{
    env,
    ffi::{CStr, CString},
    os::unix::process::CommandExt,
    path::Path,
    process, str,
};

use su::Error;

fn cli() -> Command {
    clap::Command::new("su")
        .about("run a command with a substitute user and group ID")
        .author("Nathan Fisher")
        .version(env!("CARGO_PKG_VERSION"))
        .before_long_help(
            "su allows to run commands with a substitute user and group ID. When \
            called without arguments, su defaults to running an interactive shell as \
            root. For backward compatibility su defaults to not change the current \
            directory and to only set the environment variables HOME and SHELL (plus \
            USER and LOGNAME if the target username is not root).",
        )
        .args([
            Arg::new("login")
                .short('l')
                .long("login")
                .help(
                    "Starts the shell as login shell with an environment similar to a real login.",
                )
                .action(ArgAction::SetTrue),
            Arg::new("preserve")
                .short('p')
                .long("preserve")
                .visible_short_alias('m')
                .help("Preserves the whole environment.")
                .conflicts_with("login")
                .action(ArgAction::SetTrue),
            Arg::new("command")
                .short('c')
                .long("command")
                .help("Pass command to the shell")
                .num_args(1),
            Arg::new("shell")
                .short('s')
                .long("shell")
                .help("Run the specified shell instead of the default.")
                .long_help(
"Run the specified shell instead of the default. The shell to
run is selected according to the following rules, in order:
    •   the shell specified with --shell
    •   the shell specified in the environment variable SHELL,
    if the --preserve-environment option is used
    •   the shell listed in the passwd entry of the target user
    •   /bin/sh"
                )
                .num_args(1),
            Arg::new("user").default_value("root").num_args(1),
        ])
}

#[allow(clippy::similar_names)]
fn main() -> Result<(), Error> {
    let mut cmd = {
        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());
        };
        'check_user: {
            let uid = unsafe { libc::getuid() };
            if uid == 0 {
                break 'check_user;
            }
            if let Ok(groups) = su::get_group_names() {
                if groups.contains(&"wheel".to_string()) {
                    validate_pw(user)?;
                    break 'check_user;
                }
            }
            return Err(Error::PermissionDenied);
        }
        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 uid = pw.pw_uid;
        let gid = pw.pw_gid;
        let (shell, args) = if let Some(s) = matches.get_one::<String>("command") {
            let mut it = s.split_whitespace();
            (it.next().unwrap().to_string(), Some(it))
        } else if let Some(s) = matches.get_one::<String>("shell") {
            (s.to_string(), None)
        } else if matches.get_flag("preserve") {
            if let Ok(s) = env::var("SHELL") {
                (s, None)
            } else {
                (get_user_shell(&pw).unwrap_or("/bin/sh").to_string(), None)
            }
        } else {
            (get_user_shell(&pw).unwrap_or("/bin/sh").to_string(), None)
        };
        let mut cmd = process::Command::new(&shell);
        cmd.uid(uid).gid(gid);
        if let Some(args) = args {
            cmd.args(&args.collect::<Vec<&str>>());
        }
        if matches.get_flag("login") {
            let home = unsafe { CStr::from_ptr(pw.pw_dir).to_str()? };
            let path = if uid == 0 {
                "/usr/bin:/usr/sbin:/bin:/sbin"
            } else {
                "/usr/bin:/bin"
            };
            cmd.env_clear();
            cmd.envs([
                ("SHELL", shell.as_str()),
                ("USER", user),
                ("LOGNAME", user),
                ("HOME", home),
                ("PATH", path),
            ]);
            if let Ok(term) = env::var("TERM") {
                cmd.env("TERM", term);
            }
            cmd.current_dir(home);
            let shellpath = Path::new(&shell);
            let shellname = shellpath
                .file_name()
                .ok_or(Error::Other("Unknown shell name".to_string()))?
                .to_str()
                .ok_or(Error::Other("Unknown shell name".to_string()))?;
            match shellname {
                "bash" | "zsh" | "ash" | "mksh" | "fish" | "csh" | "tcsh" | "ksh" | "pdksh"
                | "rc" | "nu" => {
                    cmd.arg("-l");
                }
                _ => {}
            }
        }
        cmd
    };
    Err(cmd.exec().into())
}

fn get_user_shell(pw: &libc::passwd) -> Result<&str, str::Utf8Error> {
    if pw.pw_shell.is_null() {
        Ok("/bin/sh")
    } else {
        unsafe { CStr::from_ptr(pw.pw_shell).to_str() }
    }
}

fn validate_pw(user: &str) -> Result<(), Error> {
    let oldpass = unsafe {
        let spw = libc::getspnam(CString::new(user)?.as_ptr());
        if spw.is_null() {
            return Err(Error::Other("null pointer".to_string()));
        }
        CStr::from_ptr((*spw).sp_pwdp).to_str()?
    };
    // deny empty passwords
    if oldpass.starts_with('\0') {
        return Err(Error::InvalidPassword);
    }
    let pass = rpassword::prompt_password("Password: ")?;
    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() {
        Ok(())
    } else {
        Err(Error::IncorrectPassword)
    }
}
