rars-cli 0.3.1

Command-line interface for the rars RAR archive toolkit.
use crate::{CliError, CliResult};
use rars::{Archive as DetectedArchive, ArchiveReadOptions, ArchiveReader, Error};
use std::fs;
use std::io::IsTerminal;
use zeroize::Zeroizing;

pub(crate) type Password = Zeroizing<Vec<u8>>;

pub(crate) fn password_bytes(password: &Option<Password>) -> Option<&[u8]> {
    password.as_deref().map(Vec::as_slice)
}

fn read_options(password: Option<&[u8]>) -> ArchiveReadOptions<'_> {
    match password {
        Some(password) => ArchiveReadOptions::with_password(password),
        None => ArchiveReadOptions::new(),
    }
}

pub(crate) fn resolve_password(
    inline: Option<&str>,
    path: Option<&str>,
) -> CliResult<Option<Password>> {
    if let Some(value) = inline {
        return Ok(Some(read_password_value(value)?));
    }
    if let Some(path) = path {
        let bytes = Zeroizing::new(fs::read(path)?);
        return Ok(Some(trim_password_line(bytes)));
    }
    Ok(None)
}

fn read_password_value(value: &str) -> CliResult<Password> {
    if value == "-" {
        let mut bytes = Zeroizing::new(Vec::new());
        std::io::Read::read_to_end(&mut std::io::stdin(), &mut bytes)?;
        return Ok(trim_password_line(bytes));
    }
    Ok(Zeroizing::new(value.as_bytes().to_vec()))
}

fn trim_password_line(mut bytes: Password) -> Password {
    while matches!(bytes.last(), Some(b'\n' | b'\r')) {
        bytes.pop();
    }
    bytes
}

pub(crate) fn read_archive_path_prompting(
    path: &str,
    password: &mut Option<Password>,
) -> CliResult<DetectedArchive> {
    match ArchiveReader::read_path_with_options(path, read_options(password_bytes(password))) {
        Ok(archive) => Ok(archive),
        Err(error) if password.is_none() && error_needs_password(&error) => {
            if let Some(prompted) = prompt_password_if_tty()? {
                *password = Some(prompted);
                ArchiveReader::read_path_with_options(path, read_options(password_bytes(password)))
                    .map_err(|err| read_archive_cli_error(path, err))
            } else {
                Err(read_archive_cli_error(path, error))
            }
        }
        Err(error) => Err(read_archive_cli_error(path, error)),
    }
}

pub(crate) fn parse_archives_prompting(
    paths: &[String],
    password: &mut Option<Password>,
) -> CliResult<Vec<DetectedArchive>> {
    let mut archives = Vec::new();
    for path in paths {
        archives.push(read_archive_path_prompting(path, password)?);
    }
    Ok(archives)
}

pub(crate) fn ensure_password_for_archives_extract(
    archives: &[DetectedArchive],
    password: &mut Option<Password>,
) -> CliResult<()> {
    if password.is_none()
        && archives
            .iter()
            .any(|archive| archive.members().any(|member| member.meta.is_encrypted))
    {
        if let Some(prompted) = prompt_password_if_tty()? {
            *password = Some(prompted);
        }
    }
    Ok(())
}

pub(crate) fn ensure_password_for_extract(
    archive: &DetectedArchive,
    password: &mut Option<Password>,
) -> CliResult<()> {
    ensure_password_for_archives_extract(std::slice::from_ref(archive), password)
}

fn prompt_password_if_tty() -> CliResult<Option<Password>> {
    if !should_prompt_password(std::io::stdin().is_terminal()) {
        return Ok(None);
    }
    let line = rpassword::prompt_password("password: ")?;
    Ok(Some(trim_password_line(Zeroizing::new(line.into_bytes()))))
}

pub(crate) fn should_prompt_password(stdin_is_terminal: bool) -> bool {
    stdin_is_terminal
}

pub(crate) fn error_needs_password(error: &Error) -> bool {
    match error {
        Error::NeedPassword => true,
        Error::AtArchiveOffset { source, .. } | Error::AtEntry { source, .. } => {
            error_needs_password(source)
        }
        _ => false,
    }
}

pub(crate) fn error_is_password_class(error: &Error) -> bool {
    match error {
        Error::NeedPassword | Error::WrongPasswordOrCorruptData => true,
        Error::AtArchiveOffset { source, .. } | Error::AtEntry { source, .. } => {
            error_is_password_class(source)
        }
        _ => false,
    }
}

fn read_archive_error(path: &str, err: Error) -> String {
    match err {
        Error::Io(error) => format!("failed to read archive '{path}': {}", error.message),
        Error::UnsupportedSignature => {
            format!(
                "failed to identify archive '{path}': {}",
                Error::UnsupportedSignature
            )
        }
        other => format!("failed to parse archive '{path}': {other}"),
    }
}

fn read_archive_cli_error(path: &str, err: Error) -> CliError {
    let message = read_archive_error(path, err.clone());
    if error_is_password_class(&err) {
        CliError::password(message)
    } else {
        CliError::general(message)
    }
}

pub(crate) fn classify_rars_error(
    error: Error,
    message: impl FnOnce(&Error) -> String,
) -> CliError {
    if error_is_password_class(&error) {
        CliError::password(message(&error))
    } else {
        CliError::general(message(&error))
    }
}