mqttv5-cli 0.27.1

Superior CLI tool for MQTT v5.0 - unified client and broker commands with ergonomic input design
use anyhow::{bail, Context, Result};
use argon2::password_hash::{PasswordHasher, Salt, SaltString};
use argon2::Argon2;
use clap::Args;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;

#[derive(Args)]
pub struct PasswdCommand {
    #[arg(help = "Username to add/update/delete")]
    pub username: String,

    #[arg(help = "Password file path")]
    pub file: Option<PathBuf>,

    #[arg(long, short, help = "Create new password file (overwrites if exists)")]
    pub create: bool,

    #[arg(
        long,
        short,
        help = "Batch mode - password provided on command line (WARNING: visible in process list)"
    )]
    pub batch: Option<String>,

    #[arg(
        long,
        short = 'D',
        help = "Delete the specified user",
        conflicts_with = "batch"
    )]
    pub delete: bool,

    #[arg(
        long,
        short = 'n',
        help = "Output hash to stdout instead of file",
        conflicts_with_all = &["file", "create", "delete"]
    )]
    pub stdout: bool,
}

pub fn execute(cmd: &PasswdCommand) -> Result<()> {
    if cmd.username.contains(':') {
        bail!("Username cannot contain ':' character");
    }

    if cmd.stdout {
        return handle_stdout_mode(cmd);
    }

    let file_path = cmd
        .file
        .as_ref()
        .context("Password file path required (use -n for stdout mode)")?;

    if cmd.delete {
        return handle_delete(cmd, file_path);
    }

    handle_add_or_update(cmd, file_path)
}

fn hash_password(password: &str) -> Result<String> {
    let mut bytes = [0u8; Salt::RECOMMENDED_LENGTH];
    getrandom::fill(&mut bytes)
        .map_err(|e| anyhow::anyhow!("Failed to generate random salt: {e}"))?;
    let salt = SaltString::encode_b64(&bytes)
        .map_err(|e| anyhow::anyhow!("Failed to encode salt: {e}"))?;
    let argon2 = Argon2::default();
    argon2
        .hash_password(password.as_bytes(), &salt)
        .map(|hash| hash.to_string())
        .map_err(|e| anyhow::anyhow!("Failed to hash password: {e}"))
}

fn handle_stdout_mode(cmd: &PasswdCommand) -> Result<()> {
    let password = get_password(cmd)?;
    let hash = hash_password(&password)?;

    println!("{}:{}", cmd.username, hash);
    Ok(())
}

fn handle_delete(cmd: &PasswdCommand, file_path: &PathBuf) -> Result<()> {
    if !file_path.exists() {
        bail!("Password file does not exist: {}", file_path.display());
    }

    let mut users = read_password_file(file_path)?;

    if users.remove(&cmd.username).is_none() {
        bail!("User '{}' not found in password file", cmd.username);
    }

    write_password_file(file_path, &users)?;
    println!("Deleted user: {}", cmd.username);
    Ok(())
}

fn handle_add_or_update(cmd: &PasswdCommand, file_path: &PathBuf) -> Result<()> {
    let mut users = if cmd.create || !file_path.exists() {
        HashMap::new()
    } else {
        read_password_file(file_path)?
    };

    let password = get_password(cmd)?;
    let hash = hash_password(&password)?;

    let action = if users.contains_key(&cmd.username) {
        "Updated"
    } else {
        "Added"
    };

    users.insert(cmd.username.clone(), hash);
    write_password_file(file_path, &users)?;

    println!("{} user: {}", action, cmd.username);
    Ok(())
}

fn get_password(cmd: &PasswdCommand) -> Result<String> {
    if let Some(ref password) = cmd.batch {
        if !cmd.stdout {
            eprintln!("Warning: Using -b is insecure on multi-user systems");
        }
        return Ok(password.clone());
    }

    let password = rpassword::prompt_password("Password: ").context("Failed to read password")?;

    if password.is_empty() {
        bail!("Password cannot be empty");
    }

    let confirm = rpassword::prompt_password("Confirm password: ")
        .context("Failed to read password confirmation")?;

    if password != confirm {
        bail!("Passwords do not match");
    }

    Ok(password)
}

fn read_password_file(path: &PathBuf) -> Result<HashMap<String, String>> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("Failed to read password file: {}", path.display()))?;

    let mut users = HashMap::new();

    for (line_num, line) in content.lines().enumerate() {
        let line = line.trim();

        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        let parts: Vec<&str> = line.splitn(2, ':').collect();
        if parts.len() != 2 {
            eprintln!("Warning: Invalid format at line {}: {}", line_num + 1, line);
            continue;
        }

        let username = parts[0].trim().to_string();
        let hash = parts[1].trim().to_string();

        if username.is_empty() {
            eprintln!("Warning: Empty username at line {}", line_num + 1);
            continue;
        }

        users.insert(username, hash);
    }

    Ok(users)
}

fn write_password_file(path: &PathBuf, users: &HashMap<String, String>) -> Result<()> {
    let mut content = String::new();

    let mut usernames: Vec<&String> = users.keys().collect();
    usernames.sort();

    for username in usernames {
        if let Some(hash) = users.get(username) {
            let _ = writeln!(content, "{username}:{hash}");
        }
    }

    let mut file = OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(true)
        .open(path)
        .with_context(|| format!("Failed to open password file: {}", path.display()))?;

    file.write_all(content.as_bytes())
        .with_context(|| format!("Failed to write password file: {}", path.display()))?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let permissions = std::fs::Permissions::from_mode(0o600);
        std::fs::set_permissions(path, permissions)
            .with_context(|| format!("Failed to set file permissions: {}", path.display()))?;
    }

    Ok(())
}