rars-cli 0.3.1

Command-line interface for the rars RAR archive toolkit.
use crate::password::Password;
use crate::time::{source_dos_mtime, source_unix_mtime};
use crate::{CliResult, DOS_ARCHIVE_ATTR};
use std::collections::HashSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
use zeroize::Zeroizing;

pub(crate) struct OwnedInput {
    pub(crate) name: Vec<u8>,
    pub(crate) data: Vec<u8>,
    pub(crate) file_attr: u8,
    pub(crate) unix_mode: Option<u32>,
    pub(crate) unix_mtime: Option<u32>,
    pub(crate) dos_mtime: u32,
    pub(crate) password: Option<Password>,
}

pub(crate) fn read_inputs(paths: &[String], password: Option<&[u8]>) -> CliResult<Vec<OwnedInput>> {
    let mut out = Vec::new();
    for path in paths {
        let path = Path::new(path);
        let base = input_archive_base(path)?;
        collect_input(path, &base, password, &mut out)?;
    }
    if out.is_empty() {
        return Err("no regular input files found".into());
    }
    reject_duplicate_input_names(&out)?;
    Ok(out)
}

fn collect_input(
    path: &Path,
    archive_name: &Path,
    password: Option<&[u8]>,
    out: &mut Vec<OwnedInput>,
) -> CliResult<()> {
    let link_meta = fs::symlink_metadata(path)
        .map_err(|err| format!("failed to stat input '{}': {err}", path.display()))?;
    if link_meta.file_type().is_symlink() {
        return Err(format!(
            "input '{}' is a symlink; refusing to follow it",
            path.display()
        )
        .into());
    }
    let meta = fs::metadata(path)
        .map_err(|err| format!("failed to stat input '{}': {err}", path.display()))?;
    if meta.is_dir() {
        let mut children = fs::read_dir(path)
            .map_err(|err| format!("failed to read directory '{}': {err}", path.display()))?
            .collect::<Result<Vec<_>, _>>()
            .map_err(|err| format!("failed to read directory '{}': {err}", path.display()))?;
        children.sort_by_key(|entry| entry.file_name());
        for child in children {
            let child_path = child.path();
            let child_name = archive_name.join(child.file_name());
            collect_input(&child_path, &child_name, password, out)?;
        }
    } else {
        let unix_mtime = source_unix_mtime(&meta);
        let dos_mtime = source_dos_mtime(&meta);
        let unix_mode = source_unix_mode(&meta);
        let name = archive_path_bytes(archive_name)?;
        out.push(OwnedInput {
            name,
            data: read_file(path, "input")?,
            file_attr: DOS_ARCHIVE_ATTR,
            unix_mode,
            unix_mtime,
            dos_mtime,
            password: password.map(|p| Zeroizing::new(p.to_vec())),
        });
    }
    Ok(())
}

fn input_archive_base(path: &Path) -> CliResult<PathBuf> {
    if path.is_absolute() {
        return path
            .file_name()
            .map(PathBuf::from)
            .ok_or_else(|| "input path has no file name".into());
    }
    let mut out = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Normal(part) => out.push(part),
            Component::CurDir => {}
            _ => return Err(format!("unsafe input archive path: {}", path.display()).into()),
        }
    }
    if out.as_os_str().is_empty() {
        return Err("input path has no file name".into());
    }
    Ok(out)
}

fn archive_path_bytes(path: &Path) -> CliResult<Vec<u8>> {
    let mut parts = Vec::new();
    for component in path.components() {
        let Component::Normal(part) = component else {
            return Err(format!("unsafe input archive path: {}", path.display()).into());
        };
        parts.push(part.to_string_lossy().into_owned());
    }
    if parts.is_empty() {
        return Err("input path has no file name".into());
    }
    Ok(parts.join("/").into_bytes())
}

fn reject_duplicate_input_names(entries: &[OwnedInput]) -> CliResult<()> {
    let mut seen = HashSet::new();
    for entry in entries {
        if !seen.insert(entry.name.clone()) {
            return Err(format!(
                "multiple input entries map to archive name '{}'",
                String::from_utf8_lossy(&entry.name)
            )
            .into());
        }
    }
    Ok(())
}

fn read_file(path: &Path, role: &str) -> CliResult<Vec<u8>> {
    fs::read(path)
        .map_err(|err| format!("failed to read {role} '{}': {err}", path.display()).into())
}

#[cfg(unix)]
fn source_unix_mode(metadata: &fs::Metadata) -> Option<u32> {
    use std::os::unix::fs::PermissionsExt;

    Some(metadata.permissions().mode())
}

#[cfg(not(unix))]
fn source_unix_mode(_metadata: &fs::Metadata) -> Option<u32> {
    None
}

pub(crate) fn rar15_file_attr(entry: &OwnedInput) -> u32 {
    entry
        .unix_mode
        .unwrap_or_else(|| u32::from(entry.file_attr))
}

pub(crate) fn rar50_file_attr(entry: &OwnedInput) -> u64 {
    u64::from(
        entry
            .unix_mode
            .unwrap_or_else(|| u32::from(entry.file_attr)),
    )
}