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}