tar-install 0.2.0

Library core for safely installing Linux app tarballs into desktop-friendly locations.
Documentation
use crate::filename::FilenameGuess;
use anyhow::{anyhow, Context, Result};
use bzip2::read::BzDecoder;
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Component, Path, PathBuf};
use tar::Archive;
use xz2::read::XzDecoder;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveEntry {
    pub path: PathBuf,
    pub is_file: bool,
    pub is_dir: bool,
    pub is_symlink: bool,
    pub executable: bool,
    pub size: u64,
    pub unsafe_reason: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutableCandidate {
    pub path: PathBuf,
    pub score: i32,
    pub reason: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveInspection {
    pub archive_path: PathBuf,
    pub filename_guess: FilenameGuess,
    pub safe: bool,
    pub entries_count: usize,
    pub common_root: Option<PathBuf>,
    pub executable_candidates: Vec<ExecutableCandidate>,
    pub icon_candidates: Vec<PathBuf>,
    pub desktop_candidates: Vec<PathBuf>,
    pub manifest_candidates: Vec<PathBuf>,
    pub unsafe_entries: Vec<ArchiveEntry>,
    pub notes: Vec<String>,
}

pub fn open_tar_reader(path: &Path) -> Result<Box<dyn Read>> {
    let file = File::open(path).with_context(|| format!("failed to open archive: {}", path.display()))?;
    let reader = BufReader::new(file);
    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
    if name.ends_with(".tar.xz") || name.ends_with(".txz") {
        Ok(Box::new(XzDecoder::new(reader)))
    } else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
        Ok(Box::new(GzDecoder::new(reader)))
    } else if name.ends_with(".tar.bz2") || name.ends_with(".tbz2") {
        Ok(Box::new(BzDecoder::new(reader)))
    } else if name.ends_with(".tar") {
        Ok(Box::new(reader))
    } else {
        Err(anyhow!("unsupported archive extension; supported: .tar.xz, .txz, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar"))
    }
}

pub fn inspect_archive(path: &Path) -> Result<ArchiveInspection> {
    let guess = crate::filename::guess_from_filename(path);
    let reader = open_tar_reader(path)?;
    let mut archive = Archive::new(reader);
    let mut entries = Vec::new();

    for entry in archive.entries().context("failed to read tar entries")? {
        let entry = entry.context("failed to read tar entry")?;
        let header = entry.header();
        let entry_type = header.entry_type();
        let raw_path = entry.path().context("failed to read tar entry path")?.to_path_buf();
        let mode = header.mode().unwrap_or(0);
        let size = header.size().unwrap_or(0);
        let unsafe_reason = unsafe_path_reason(&raw_path);
        entries.push(ArchiveEntry {
            path: raw_path,
            is_file: entry_type.is_file(),
            is_dir: entry_type.is_dir(),
            is_symlink: entry_type.is_symlink(),
            executable: (mode & 0o111) != 0,
            size,
            unsafe_reason,
        });
    }

    let unsafe_entries: Vec<_> = entries.iter().filter(|e| e.unsafe_reason.is_some()).cloned().collect();
    let safe = unsafe_entries.is_empty();
    let common_root = common_root(&entries);
    let executable_candidates = executable_candidates(&entries, &guess);
    let icon_candidates = entries.iter()
        .filter(|e| e.is_file && is_icon_path(&e.path))
        .map(|e| e.path.clone())
        .collect();
    let desktop_candidates = entries.iter()
        .filter(|e| e.is_file && e.path.extension() == Some(OsStr::new("desktop")))
        .map(|e| e.path.clone())
        .collect();
    let manifest_candidates = entries.iter()
        .filter(|e| e.is_file && is_manifest_path(&e.path))
        .map(|e| e.path.clone())
        .collect();

    let mut notes = guess.notes.clone();
    if !safe {
        notes.push("archive contains unsafe paths and must not be extracted directly".to_string());
    }
    if executable_candidates.is_empty() {
        notes.push("no executable candidate was confidently detected".to_string());
    }

    Ok(ArchiveInspection {
        archive_path: path.to_path_buf(),
        filename_guess: guess,
        safe,
        entries_count: entries.len(),
        common_root,
        executable_candidates,
        icon_candidates,
        desktop_candidates,
        manifest_candidates,
        unsafe_entries,
        notes,
    })
}

pub fn unsafe_path_reason(path: &Path) -> Option<String> {
    if path.is_absolute() {
        return Some("absolute path".to_string());
    }
    for comp in path.components() {
        match comp {
            Component::ParentDir => return Some("path traversal using ..".to_string()),
            Component::RootDir | Component::Prefix(_) => return Some("root/prefix path".to_string()),
            _ => {}
        }
    }
    None
}

fn common_root(entries: &[ArchiveEntry]) -> Option<PathBuf> {
    let mut roots = BTreeSet::new();
    for e in entries {
        if let Some(first) = e.path.components().next() {
            if let Component::Normal(s) = first {
                roots.insert(PathBuf::from(s));
            }
        }
    }
    if roots.len() == 1 { roots.into_iter().next() } else { None }
}

fn executable_candidates(entries: &[ArchiveEntry], guess: &FilenameGuess) -> Vec<ExecutableCandidate> {
    let app = guess.app.clone().unwrap_or_default();
    let arch = guess.architecture.clone().unwrap_or_default();
    let mut candidates = Vec::new();

    for e in entries {
        if !e.is_file || e.unsafe_reason.is_some() {
            continue;
        }
        let file_name = e.path.file_name().and_then(|s| s.to_str()).unwrap_or_default();
        let app_image = is_app_image(file_name);
        if !e.executable && !app_image {
            continue;
        }
        if !app_image && looks_like_library_or_helper(file_name) {
            continue;
        }
        let score = score_executable(file_name, &app, &arch, &e.path);
        if score > 0 {
            candidates.push(ExecutableCandidate {
                path: e.path.clone(),
                score,
                reason: explain_score(file_name, &app, &arch, score),
            });
        }
    }
    candidates.sort_by(|a, b| b.score.cmp(&a.score).then(a.path.cmp(&b.path)));
    candidates
}

fn score_executable(file_name: &str, app: &str, arch: &str, path: &Path) -> i32 {
    let lower = file_name.to_ascii_lowercase();
    let app_lower = app.to_ascii_lowercase();
    let app_us = app_lower.replace('-', "_");
    let app_dash = app_lower.replace('_', "-");
    let mut score = 1;

    if is_app_image(file_name) {
        score += 120;
    }

    if !app_lower.is_empty() {
        if lower == app_lower || lower == app_us || lower == app_dash {
            score += 100;
        }
        if !arch.is_empty() && (lower == format!("{}-{}", app_dash, arch) || lower == format!("{}_{}", app_us, arch)) {
            score += 85;
        }
        if lower.starts_with(&app_lower) || lower.starts_with(&app_us) || lower.starts_with(&app_dash) {
            score += 45;
        }
        if path.iter().any(|p| p.to_string_lossy().eq_ignore_ascii_case("bin")) {
            score += 15;
        }
    }

    if lower.ends_with(".sh") || lower.ends_with(".run") {
        score += 5;
    }
    score
}

fn explain_score(file_name: &str, app: &str, arch: &str, score: i32) -> String {
    if is_app_image(file_name) {
        "AppImage file".to_string()
    } else if !app.is_empty() && file_name.eq_ignore_ascii_case(app) {
        "exact filename matches guessed app name".to_string()
    } else if !app.is_empty() && !arch.is_empty() && file_name.to_ascii_lowercase().contains(&arch.to_ascii_lowercase()) {
        "filename contains guessed app and architecture pattern".to_string()
    } else if score >= 45 {
        "filename starts with guessed app name".to_string()
    } else {
        "executable file".to_string()
    }
}

fn is_app_image(file_name: &str) -> bool {
    file_name.to_ascii_lowercase().ends_with(".appimage")
}

fn looks_like_library_or_helper(file_name: &str) -> bool {
    let lower = file_name.to_ascii_lowercase();
    lower.ends_with(".so")
        || lower.contains(".so.")
        || lower.ends_with(".dll")
        || lower.ends_with(".dylib")
        || lower.ends_with(".a")
        || lower == "crashpad_handler"
        || lower == "chrome-sandbox"
}

fn is_icon_path(path: &Path) -> bool {
    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
    if !matches!(ext.as_str(), "png" | "svg" | "xpm") {
        return false;
    }
    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
    name.contains("icon") || name.contains("logo") || path.iter().any(|p| p.to_string_lossy().eq_ignore_ascii_case("icons"))
}

fn is_manifest_path(path: &Path) -> bool {
    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
    matches!(name.as_str(), "tarapp.yml" | "tarapp.yaml" | ".tarapp.yml" | ".tarapp.yaml" | "manifest.yml" | "manifest.yaml")
}

pub fn read_text_entry(path: &Path, entry_path: &Path, max_bytes: u64) -> Result<String> {
    let reader = open_tar_reader(path)?;
    let mut archive = Archive::new(reader);
    for entry in archive.entries().context("failed to read tar entries")? {
        let mut entry = entry.context("failed to read tar entry")?;
        let raw_path = entry.path().context("failed to read tar entry path")?.to_path_buf();
        if raw_path == entry_path {
            let size = entry.header().size().unwrap_or(0);
            if size > max_bytes {
                return Err(anyhow!("entry is too large to read as text: {}", raw_path.display()));
            }
            let mut text = String::new();
            entry.read_to_string(&mut text).with_context(|| format!("failed to read text entry: {}", raw_path.display()))?;
            return Ok(text);
        }
    }
    Err(anyhow!("entry not found: {}", entry_path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn appimage_is_candidate_without_execute_bit() {
        let entries = vec![
            ArchiveEntry {
                path: PathBuf::from("MyApp/MyApp.AppImage"),
                is_file: true,
                is_dir: false,
                is_symlink: false,
                executable: false,
                size: 1024,
                unsafe_reason: None,
            },
            ArchiveEntry {
                path: PathBuf::from("MyApp/resources/helper"),
                is_file: true,
                is_dir: false,
                is_symlink: false,
                executable: true,
                size: 1024,
                unsafe_reason: None,
            },
        ];
        let guess = FilenameGuess {
            raw_stem: "myapp".to_string(),
            app: Some("myapp".to_string()),
            version: None,
            os: None,
            architecture: None,
            confidence: 0.8,
            notes: Vec::new(),
        };

        let candidates = executable_candidates(&entries, &guess);

        assert_eq!(candidates.first().map(|c| c.path.as_path()), Some(Path::new("MyApp/MyApp.AppImage")));
        assert_eq!(candidates.first().map(|c| c.reason.as_str()), Some("AppImage file"));
    }
}