renc 0.1.2

Rust Encryption Engine compatible with the zenc file format
Documentation
use clap::{Parser, Subcommand};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use zeroize::Zeroize;

use renc::{Mode, RencError};

#[derive(Parser)]
#[command(name = "renc", version, about = "Rust Encryption Engine")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Keygen,
    Encrypt {
        file: PathBuf,
        #[arg(long)]
        password: bool,
        #[arg(long, value_name = "base64_pubkey")]
        to: Option<String>,
    },
    Decrypt {
        file: PathBuf,
    },
}

fn main() {
    if let Err(err) = run() {
        let _ = renc::utils::json::emit_error(err.code(), &err.message());
        std::process::exit(1);
    }
}

fn run() -> Result<(), RencError> {
    let cli = Cli::parse();
    match cli.command {
        Commands::Keygen => {
            let keypair = renc::generate_keypair()?;
            renc::utils::json::emit_keygen(
                &keypair.public_key_base64,
                &keypair.secret_key_base64,
            )?;
        }
        Commands::Encrypt { file, password, to } => {
            if password == to.is_some() {
                return Err(RencError::InvalidArguments(
                    "Specify exactly one of --password or --to".to_string(),
                ));
            }
            let size = fs::metadata(&file)?.len();
            renc::utils::json::emit_start(&file, size)?;

            let output = encrypt_output_path(&file)?;
            let mut progress = |bytes: u64, percent: f64| {
                renc::utils::json::emit_progress(bytes, percent)
            };

            if password {
                let mut secret = read_stdin_secret()?;
                let result = renc::encrypt_file_with_password(
                    &file,
                    &output,
                    &secret,
                    Some(&mut progress),
                );
                secret.zeroize();
                let done = result?;
                renc::utils::json::emit_done(&done.output, &done.hash_hex)?;
            } else if let Some(recipient) = to {
                let done = renc::encrypt_file_with_pubkey(
                    &file,
                    &output,
                    &recipient,
                    Some(&mut progress),
                )?;
                renc::utils::json::emit_done(&done.output, &done.hash_hex)?;
            }
        }
        Commands::Decrypt { file } => {
            let header = renc::read_header_from_file(&file)?;
            let size = fs::metadata(&file)?.len();
            renc::utils::json::emit_start(&file, size)?;

            let output = decrypt_output_path(&file)?;
            let mut progress = |bytes: u64, percent: f64| {
                renc::utils::json::emit_progress(bytes, percent)
            };

            match header.mode {
                Mode::Password => {
                    let mut secret = read_stdin_secret()?;
                    let result = renc::decrypt_file_with_password(
                        &file,
                        &output,
                        &secret,
                        Some(&mut progress),
                    );
                    secret.zeroize();
                    let done = result?;
                    renc::utils::json::emit_done(&done.output, &done.hash_hex)?;
                }
                Mode::Pubkey => {
                    let secret = read_stdin_secret()?;
                    let mut secret_str = String::from_utf8(secret)
                        .map_err(|_| RencError::InvalidKey("Secret key must be UTF-8".into()))?;
                    let result = renc::decrypt_file_with_secret(
                        &file,
                        &output,
                        &secret_str,
                        Some(&mut progress),
                    );
                    secret_str.zeroize();
                    let done = result?;
                    renc::utils::json::emit_done(&done.output, &done.hash_hex)?;
                }
            }
        }
    }

    Ok(())
}

fn read_stdin_secret() -> Result<Vec<u8>, RencError> {
    let mut buffer = Vec::new();
    std::io::stdin().read_to_end(&mut buffer)?;
    while buffer
        .last()
        .is_some_and(|byte| byte.is_ascii_whitespace())
    {
        buffer.pop();
    }
    if buffer.is_empty() {
        return Err(RencError::InvalidArguments(
            "Secret input was empty".to_string(),
        ));
    }
    Ok(buffer)
}

fn encrypt_output_path(input: &Path) -> Result<PathBuf, RencError> {
    let file_name = input
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| RencError::InvalidArguments("Invalid input filename".to_string()))?;
    let mut new_name = String::with_capacity(file_name.len() + 5);
    new_name.push_str(file_name);
    new_name.push_str(".renc");
    Ok(input.with_file_name(new_name))
}

fn decrypt_output_path(input: &Path) -> Result<PathBuf, RencError> {
    let file_name = input
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| RencError::InvalidArguments("Invalid input filename".to_string()))?;
    let new_name = if let Some(stripped) = file_name.strip_suffix(".renc") {
        if stripped.is_empty() {
            "output".to_string()
        } else {
            stripped.to_string()
        }
    } else {
        format!("{file_name}.dec")
    };
    Ok(input.with_file_name(new_name))
}