Skip to main content

tar_install/
archive.rs

1use crate::filename::FilenameGuess;
2use anyhow::{anyhow, Context, Result};
3use bzip2::read::BzDecoder;
4use flate2::read::GzDecoder;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7use std::ffi::OsStr;
8use std::fs::File;
9use std::io::{BufReader, Read};
10use std::path::{Component, Path, PathBuf};
11use tar::Archive;
12use xz2::read::XzDecoder;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ArchiveEntry {
16    pub path: PathBuf,
17    pub is_file: bool,
18    pub is_dir: bool,
19    pub is_symlink: bool,
20    pub executable: bool,
21    pub size: u64,
22    pub unsafe_reason: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ExecutableCandidate {
27    pub path: PathBuf,
28    pub score: i32,
29    pub reason: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ArchiveInspection {
34    pub archive_path: PathBuf,
35    pub filename_guess: FilenameGuess,
36    pub safe: bool,
37    pub entries_count: usize,
38    pub common_root: Option<PathBuf>,
39    pub executable_candidates: Vec<ExecutableCandidate>,
40    pub icon_candidates: Vec<PathBuf>,
41    pub desktop_candidates: Vec<PathBuf>,
42    pub manifest_candidates: Vec<PathBuf>,
43    pub unsafe_entries: Vec<ArchiveEntry>,
44    pub notes: Vec<String>,
45}
46
47pub fn open_tar_reader(path: &Path) -> Result<Box<dyn Read>> {
48    let file = File::open(path).with_context(|| format!("failed to open archive: {}", path.display()))?;
49    let reader = BufReader::new(file);
50    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
51    if name.ends_with(".tar.xz") || name.ends_with(".txz") {
52        Ok(Box::new(XzDecoder::new(reader)))
53    } else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
54        Ok(Box::new(GzDecoder::new(reader)))
55    } else if name.ends_with(".tar.bz2") || name.ends_with(".tbz2") {
56        Ok(Box::new(BzDecoder::new(reader)))
57    } else if name.ends_with(".tar") {
58        Ok(Box::new(reader))
59    } else {
60        Err(anyhow!("unsupported archive extension; supported: .tar.xz, .txz, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar"))
61    }
62}
63
64pub fn inspect_archive(path: &Path) -> Result<ArchiveInspection> {
65    let guess = crate::filename::guess_from_filename(path);
66    let reader = open_tar_reader(path)?;
67    let mut archive = Archive::new(reader);
68    let mut entries = Vec::new();
69
70    for entry in archive.entries().context("failed to read tar entries")? {
71        let entry = entry.context("failed to read tar entry")?;
72        let header = entry.header();
73        let entry_type = header.entry_type();
74        let raw_path = entry.path().context("failed to read tar entry path")?.to_path_buf();
75        let mode = header.mode().unwrap_or(0);
76        let size = header.size().unwrap_or(0);
77        let unsafe_reason = unsafe_path_reason(&raw_path);
78        entries.push(ArchiveEntry {
79            path: raw_path,
80            is_file: entry_type.is_file(),
81            is_dir: entry_type.is_dir(),
82            is_symlink: entry_type.is_symlink(),
83            executable: (mode & 0o111) != 0,
84            size,
85            unsafe_reason,
86        });
87    }
88
89    let unsafe_entries: Vec<_> = entries.iter().filter(|e| e.unsafe_reason.is_some()).cloned().collect();
90    let safe = unsafe_entries.is_empty();
91    let common_root = common_root(&entries);
92    let executable_candidates = executable_candidates(&entries, &guess);
93    let icon_candidates = entries.iter()
94        .filter(|e| e.is_file && is_icon_path(&e.path))
95        .map(|e| e.path.clone())
96        .collect();
97    let desktop_candidates = entries.iter()
98        .filter(|e| e.is_file && e.path.extension() == Some(OsStr::new("desktop")))
99        .map(|e| e.path.clone())
100        .collect();
101    let manifest_candidates = entries.iter()
102        .filter(|e| e.is_file && is_manifest_path(&e.path))
103        .map(|e| e.path.clone())
104        .collect();
105
106    let mut notes = guess.notes.clone();
107    if !safe {
108        notes.push("archive contains unsafe paths and must not be extracted directly".to_string());
109    }
110    if executable_candidates.is_empty() {
111        notes.push("no executable candidate was confidently detected".to_string());
112    }
113
114    Ok(ArchiveInspection {
115        archive_path: path.to_path_buf(),
116        filename_guess: guess,
117        safe,
118        entries_count: entries.len(),
119        common_root,
120        executable_candidates,
121        icon_candidates,
122        desktop_candidates,
123        manifest_candidates,
124        unsafe_entries,
125        notes,
126    })
127}
128
129pub fn unsafe_path_reason(path: &Path) -> Option<String> {
130    if path.is_absolute() {
131        return Some("absolute path".to_string());
132    }
133    for comp in path.components() {
134        match comp {
135            Component::ParentDir => return Some("path traversal using ..".to_string()),
136            Component::RootDir | Component::Prefix(_) => return Some("root/prefix path".to_string()),
137            _ => {}
138        }
139    }
140    None
141}
142
143fn common_root(entries: &[ArchiveEntry]) -> Option<PathBuf> {
144    let mut roots = BTreeSet::new();
145    for e in entries {
146        if let Some(first) = e.path.components().next() {
147            if let Component::Normal(s) = first {
148                roots.insert(PathBuf::from(s));
149            }
150        }
151    }
152    if roots.len() == 1 { roots.into_iter().next() } else { None }
153}
154
155fn executable_candidates(entries: &[ArchiveEntry], guess: &FilenameGuess) -> Vec<ExecutableCandidate> {
156    let app = guess.app.clone().unwrap_or_default();
157    let arch = guess.architecture.clone().unwrap_or_default();
158    let mut candidates = Vec::new();
159
160    for e in entries {
161        if !e.is_file || e.unsafe_reason.is_some() {
162            continue;
163        }
164        let file_name = e.path.file_name().and_then(|s| s.to_str()).unwrap_or_default();
165        let app_image = is_app_image(file_name);
166        if !e.executable && !app_image {
167            continue;
168        }
169        if !app_image && looks_like_library_or_helper(file_name) {
170            continue;
171        }
172        let score = score_executable(file_name, &app, &arch, &e.path);
173        if score > 0 {
174            candidates.push(ExecutableCandidate {
175                path: e.path.clone(),
176                score,
177                reason: explain_score(file_name, &app, &arch, score),
178            });
179        }
180    }
181    candidates.sort_by(|a, b| b.score.cmp(&a.score).then(a.path.cmp(&b.path)));
182    candidates
183}
184
185fn score_executable(file_name: &str, app: &str, arch: &str, path: &Path) -> i32 {
186    let lower = file_name.to_ascii_lowercase();
187    let app_lower = app.to_ascii_lowercase();
188    let app_us = app_lower.replace('-', "_");
189    let app_dash = app_lower.replace('_', "-");
190    let mut score = 1;
191
192    if is_app_image(file_name) {
193        score += 120;
194    }
195
196    if !app_lower.is_empty() {
197        if lower == app_lower || lower == app_us || lower == app_dash {
198            score += 100;
199        }
200        if !arch.is_empty() && (lower == format!("{}-{}", app_dash, arch) || lower == format!("{}_{}", app_us, arch)) {
201            score += 85;
202        }
203        if lower.starts_with(&app_lower) || lower.starts_with(&app_us) || lower.starts_with(&app_dash) {
204            score += 45;
205        }
206        if path.iter().any(|p| p.to_string_lossy().eq_ignore_ascii_case("bin")) {
207            score += 15;
208        }
209    }
210
211    if lower.ends_with(".sh") || lower.ends_with(".run") {
212        score += 5;
213    }
214    score
215}
216
217fn explain_score(file_name: &str, app: &str, arch: &str, score: i32) -> String {
218    if is_app_image(file_name) {
219        "AppImage file".to_string()
220    } else if !app.is_empty() && file_name.eq_ignore_ascii_case(app) {
221        "exact filename matches guessed app name".to_string()
222    } else if !app.is_empty() && !arch.is_empty() && file_name.to_ascii_lowercase().contains(&arch.to_ascii_lowercase()) {
223        "filename contains guessed app and architecture pattern".to_string()
224    } else if score >= 45 {
225        "filename starts with guessed app name".to_string()
226    } else {
227        "executable file".to_string()
228    }
229}
230
231fn is_app_image(file_name: &str) -> bool {
232    file_name.to_ascii_lowercase().ends_with(".appimage")
233}
234
235fn looks_like_library_or_helper(file_name: &str) -> bool {
236    let lower = file_name.to_ascii_lowercase();
237    lower.ends_with(".so")
238        || lower.contains(".so.")
239        || lower.ends_with(".dll")
240        || lower.ends_with(".dylib")
241        || lower.ends_with(".a")
242        || lower == "crashpad_handler"
243        || lower == "chrome-sandbox"
244}
245
246fn is_icon_path(path: &Path) -> bool {
247    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
248    if !matches!(ext.as_str(), "png" | "svg" | "xpm") {
249        return false;
250    }
251    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
252    name.contains("icon") || name.contains("logo") || path.iter().any(|p| p.to_string_lossy().eq_ignore_ascii_case("icons"))
253}
254
255fn is_manifest_path(path: &Path) -> bool {
256    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
257    matches!(name.as_str(), "tarapp.yml" | "tarapp.yaml" | ".tarapp.yml" | ".tarapp.yaml" | "manifest.yml" | "manifest.yaml")
258}
259
260pub fn read_text_entry(path: &Path, entry_path: &Path, max_bytes: u64) -> Result<String> {
261    let reader = open_tar_reader(path)?;
262    let mut archive = Archive::new(reader);
263    for entry in archive.entries().context("failed to read tar entries")? {
264        let mut entry = entry.context("failed to read tar entry")?;
265        let raw_path = entry.path().context("failed to read tar entry path")?.to_path_buf();
266        if raw_path == entry_path {
267            let size = entry.header().size().unwrap_or(0);
268            if size > max_bytes {
269                return Err(anyhow!("entry is too large to read as text: {}", raw_path.display()));
270            }
271            let mut text = String::new();
272            entry.read_to_string(&mut text).with_context(|| format!("failed to read text entry: {}", raw_path.display()))?;
273            return Ok(text);
274        }
275    }
276    Err(anyhow!("entry not found: {}", entry_path.display()))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn appimage_is_candidate_without_execute_bit() {
285        let entries = vec![
286            ArchiveEntry {
287                path: PathBuf::from("MyApp/MyApp.AppImage"),
288                is_file: true,
289                is_dir: false,
290                is_symlink: false,
291                executable: false,
292                size: 1024,
293                unsafe_reason: None,
294            },
295            ArchiveEntry {
296                path: PathBuf::from("MyApp/resources/helper"),
297                is_file: true,
298                is_dir: false,
299                is_symlink: false,
300                executable: true,
301                size: 1024,
302                unsafe_reason: None,
303            },
304        ];
305        let guess = FilenameGuess {
306            raw_stem: "myapp".to_string(),
307            app: Some("myapp".to_string()),
308            version: None,
309            os: None,
310            architecture: None,
311            confidence: 0.8,
312            notes: Vec::new(),
313        };
314
315        let candidates = executable_candidates(&entries, &guess);
316
317        assert_eq!(candidates.first().map(|c| c.path.as_path()), Some(Path::new("MyApp/MyApp.AppImage")));
318        assert_eq!(candidates.first().map(|c| c.reason.as_str()), Some("AppImage file"));
319    }
320}