Skip to main content

tar_install/
install.rs

1use crate::archive::{inspect_archive, open_tar_reader, read_text_entry, unsafe_path_reason, ArchiveInspection};
2use crate::desktop::{write_desktop_entry, DesktopEntryInput};
3use crate::filename::{normalize_arch, normalize_os};
4use crate::paths::{self, InstallScope, InstallTargets};
5use crate::recipe::{display_name_from_id, sanitize_command, sanitize_id, AppRecipe, InstallInput};
6use crate::state::{load_state, save_state, InstalledApp};
7use anyhow::{anyhow, bail, Context, Result};
8use sha2::{Digest, Sha256};
9use std::fs;
10use std::io::Read;
11use std::os::unix::fs::{self as unix_fs, PermissionsExt};
12use std::path::{Path, PathBuf};
13use tar::Archive;
14use tempfile::tempdir;
15use walkdir::WalkDir;
16
17#[derive(Debug, Clone)]
18pub struct InstallPlan {
19    pub archive: PathBuf,
20    pub scope: InstallScope,
21    pub app_id: String,
22    pub app_name: String,
23    pub version: Option<String>,
24    pub probe_version: bool,
25    pub exec_path_inside_app: PathBuf,
26    pub command_name: String,
27    pub icon_path_inside_app: Option<PathBuf>,
28    pub targets: InstallTargets,
29    pub categories: Vec<String>,
30    pub terminal: bool,
31    pub notes: Vec<String>,
32}
33
34#[derive(Debug, Clone)]
35pub struct InstallReport {
36    pub plan: InstallPlan,
37    pub installed: InstalledApp,
38}
39
40#[derive(Debug, Clone)]
41pub struct RemoveReport {
42    pub id: String,
43    pub removed_paths: Vec<PathBuf>,
44}
45
46#[derive(Debug, Clone)]
47pub enum InstallProgress {
48    Planning,
49    Extracting { current: u64, total: u64, path: PathBuf },
50    Copying { current: u64, total: u64, path: PathBuf },
51    Integrating { step: &'static str },
52    Finished,
53}
54
55pub fn make_plan(archive_path: &Path, scope: InstallScope, input: &InstallInput) -> Result<(InstallPlan, ArchiveInspection)> {
56    let inspection = inspect_archive(archive_path)?;
57    if !inspection.safe {
58        bail!("archive contains unsafe paths; run `tarminal inspect` for details");
59    }
60    if !input.force {
61        if let Some(reason) = incompatible_platform_reason(&inspection.filename_guess) {
62            bail!("{} (use --force to override)", reason);
63        }
64    }
65
66    let embedded_recipe = if input.recipe.is_none() {
67        inspection.manifest_candidates.first()
68            .and_then(|p| read_text_entry(archive_path, p, 64 * 1024).ok())
69            .and_then(|text| serde_yaml::from_str::<AppRecipe>(&text).ok())
70    } else {
71        None
72    };
73    let recipe = input.recipe.as_ref().or(embedded_recipe.as_ref());
74
75    let guessed_app = inspection.filename_guess.app.clone();
76    let app_id = input.id.clone()
77        .or_else(|| recipe.and_then(|r| r.id.clone()))
78        .or_else(|| guessed_app.clone())
79        .map(|s| sanitize_id(&s))
80        .filter(|s| !s.is_empty())
81        .ok_or_else(|| anyhow!("cannot determine app id; use --id or --config"))?;
82
83    let app_name = input.name.clone()
84        .or_else(|| recipe.and_then(|r| r.name.clone()))
85        .unwrap_or_else(|| display_name_from_id(&app_id));
86
87    let version = input.version.clone()
88        .or_else(|| recipe.and_then(|r| r.version.clone()))
89        .or_else(|| inspection.filename_guess.version.clone());
90
91    let probe_version = input.probe_version
92        .or_else(|| recipe.and_then(|r| r.probe_version))
93        .unwrap_or(true);
94
95    let exec_from_input = input.exec.clone().or_else(|| recipe.and_then(|r| r.exec.clone()));
96    let exec_path_inside_app = if let Some(exec) = exec_from_input {
97        PathBuf::from(exec)
98    } else {
99        inspection.executable_candidates.first()
100            .map(|c| strip_common_root(&c.path, inspection.common_root.as_deref()))
101            .ok_or_else(|| anyhow!("cannot determine executable; use --exec or --config"))?
102    };
103
104    let command_name = input.command.clone()
105        .or_else(|| recipe.and_then(|r| r.command.clone()))
106        .unwrap_or_else(|| sanitize_command(&app_id));
107
108    let icon_path_inside_app = input.icon.clone()
109        .or_else(|| recipe.and_then(|r| r.icon.clone()))
110        .map(PathBuf::from)
111        .or_else(|| inspection.icon_candidates.first().map(|p| strip_common_root(p, inspection.common_root.as_deref())));
112
113    let targets = paths::targets(scope, &app_id, &command_name)?;
114    let categories = recipe.and_then(|r| r.desktop.as_ref()).and_then(|d| d.categories.clone()).unwrap_or_else(|| vec!["Utility".to_string()]);
115    let terminal = recipe.and_then(|r| r.desktop.as_ref()).and_then(|d| d.terminal).unwrap_or(false);
116
117    Ok((InstallPlan {
118        archive: archive_path.to_path_buf(),
119        scope,
120        app_id,
121        app_name,
122        version,
123        probe_version,
124        exec_path_inside_app,
125        command_name,
126        icon_path_inside_app,
127        targets,
128        categories,
129        terminal,
130        notes: inspection.notes.clone(),
131    }, inspection))
132}
133
134pub fn install_archive(archive_path: &Path, scope: InstallScope, input: InstallInput) -> Result<InstallReport> {
135    install_archive_with_progress(archive_path, scope, input, None)
136}
137
138pub fn install_archive_with_progress(
139    archive_path: &Path,
140    scope: InstallScope,
141    input: InstallInput,
142    progress: Option<&dyn Fn(InstallProgress)>,
143) -> Result<InstallReport> {
144    emit_progress(progress, InstallProgress::Planning);
145
146    let (mut plan, inspection) = make_plan(archive_path, scope, &input)?;
147
148    if plan.targets.app_dir.exists() && !input.force {
149        bail!("install directory already exists: {} (use --force to overwrite)", plan.targets.app_dir.display());
150    }
151
152    let staging = tempdir().context("failed to create temporary extraction directory")?;
153    safe_extract_to(archive_path, staging.path(), progress, inspection.entries_count as u64)?;
154
155    let source_root = if let Some(root) = inspection.common_root.as_ref() {
156        let candidate = staging.path().join(root);
157        if candidate.exists() { candidate } else { staging.path().to_path_buf() }
158    } else {
159        staging.path().to_path_buf()
160    };
161
162    if plan.targets.app_dir.exists() {
163        emit_progress(progress, InstallProgress::Integrating { step: "removing previous install" });
164        fs::remove_dir_all(&plan.targets.app_dir)
165            .with_context(|| format!("failed to remove existing install dir: {}", plan.targets.app_dir.display()))?;
166    }
167    if let Some(parent) = plan.targets.app_dir.parent() {
168        fs::create_dir_all(parent).with_context(|| format!("failed to create app parent dir: {}", parent.display()))?;
169    }
170    copy_dir_all(&source_root, &plan.targets.app_dir, progress)?;
171
172    let exec_abs = plan.targets.app_dir.join(&plan.exec_path_inside_app);
173    if !exec_abs.exists() {
174        bail!("resolved executable does not exist after extraction: {}", exec_abs.display());
175    }
176    ensure_executable(&exec_abs)?;
177
178    if plan.version.is_none() && plan.probe_version {
179        emit_progress(progress, InstallProgress::Integrating { step: "detecting version" });
180
181        match crate::version::detect_installed_version(
182            &plan.targets.app_dir,
183            &plan.exec_path_inside_app,
184            &plan.command_name,
185            &plan.app_id,
186            &plan.app_name,
187        ) {
188            Ok(Some(found)) => {
189                plan.notes.push(format!("version detected from {}", found.source));
190                plan.version = Some(found.version);
191            }
192            Ok(None) => {
193                plan.notes.push("version could not be detected from metadata or command probes".to_string());
194            }
195            Err(err) => {
196                plan.notes.push(format!("version probe failed: {err:#}"));
197            }
198        }
199    }
200
201    emit_progress(progress, InstallProgress::Integrating { step: "writing command wrapper" });
202    write_wrapper(&plan.targets.command_path, &plan.targets.app_dir, &exec_abs)?;
203
204    emit_progress(progress, InstallProgress::Integrating { step: "installing icon" });
205    let icon_paths = if let Some(icon_inside) = plan.icon_path_inside_app.as_ref() {
206        install_icon(&plan, icon_inside).unwrap_or_default()
207    } else {
208        Vec::new()
209    };
210
211    emit_progress(progress, InstallProgress::Integrating { step: "writing desktop entry" });
212    write_desktop_entry(&plan.targets.desktop_path, &DesktopEntryInput {
213        name: &plan.app_name,
214        generic_name: None,
215        comment: Some("Installed from a Linux tarball by Tarminal"),
216        exec_path: &plan.targets.command_path,
217        icon_name: &plan.app_id,
218        categories: &plan.categories,
219        terminal: plan.terminal,
220    })?;
221
222    emit_progress(progress, InstallProgress::Integrating { step: "saving state" });
223    let sha256 = sha256_file(archive_path).ok();
224    let installed = InstalledApp {
225        id: plan.app_id.clone(),
226        name: plan.app_name.clone(),
227        version: plan.version.clone(),
228        scope: plan.scope,
229        install_dir: plan.targets.app_dir.clone(),
230        command_name: plan.command_name.clone(),
231        command_path: plan.targets.command_path.clone(),
232        desktop_path: plan.targets.desktop_path.clone(),
233        icon_paths,
234        source_archive: Some(archive_path.to_path_buf()),
235        source_sha256: sha256,
236    };
237
238    let mut db = load_state(&plan.targets.state_path)?;
239    db.apps.insert(installed.id.clone(), installed.clone());
240    save_state(&plan.targets.state_path, &db)?;
241
242    emit_progress(progress, InstallProgress::Finished);
243    Ok(InstallReport { plan, installed })
244}
245
246pub fn remove_app(scope: InstallScope, app_id: &str) -> Result<RemoveReport> {
247    let id = sanitize_id(app_id);
248    let dummy = paths::targets(scope, &id, &id)?;
249    let mut db = load_state(&dummy.state_path)?;
250    let app = db.apps.remove(&id).ok_or_else(|| anyhow!("app is not installed in {:?} scope: {}", scope, id))?;
251
252    let mut removed = Vec::new();
253    remove_path(&app.install_dir, &mut removed)?;
254    remove_path(&app.command_path, &mut removed)?;
255    remove_path(&app.desktop_path, &mut removed)?;
256    for icon in &app.icon_paths {
257        remove_path(icon, &mut removed)?;
258    }
259    save_state(&dummy.state_path, &db)?;
260    Ok(RemoveReport { id, removed_paths: removed })
261}
262
263pub fn doctor_app(scope: InstallScope, app_id: &str) -> Result<Vec<String>> {
264    let id = sanitize_id(app_id);
265    let dummy = paths::targets(scope, &id, &id)?;
266    let db = load_state(&dummy.state_path)?;
267    let app = db.apps.get(&id).ok_or_else(|| anyhow!("app is not installed in {:?} scope: {}", scope, id))?;
268    let mut lines = Vec::new();
269    lines.push(format!("id: {}", app.id));
270    lines.push(format!("name: {}", app.name));
271    lines.push(format!("install dir: {} [{}]", app.install_dir.display(), exists_text(&app.install_dir)));
272    lines.push(format!("command: {} [{}]", app.command_path.display(), exists_text(&app.command_path)));
273    lines.push(format!("desktop: {} [{}]", app.desktop_path.display(), exists_text(&app.desktop_path)));
274    for icon in &app.icon_paths {
275        lines.push(format!("icon: {} [{}]", icon.display(), exists_text(icon)));
276    }
277    Ok(lines)
278}
279
280pub fn list_apps(scope: InstallScope) -> Result<Vec<InstalledApp>> {
281    let dummy = paths::targets(scope, "dummy", "dummy")?;
282    let db = load_state(&dummy.state_path)?;
283    Ok(db.apps.values().cloned().collect())
284}
285
286fn emit_progress(progress: Option<&dyn Fn(InstallProgress)>, event: InstallProgress) {
287    if let Some(callback) = progress {
288        callback(event);
289    }
290}
291
292fn incompatible_platform_reason(guess: &crate::filename::FilenameGuess) -> Option<String> {
293    if let Some(archive_os) = guess.os.as_deref() {
294        let current_os = normalize_os(std::env::consts::OS)
295            .unwrap_or_else(|| std::env::consts::OS.to_string());
296
297        if archive_os != current_os {
298            return Some(format!(
299                "archive appears to be for {}, but this system is {}",
300                archive_os, current_os
301            ));
302        }
303    }
304
305    if let Some(archive_arch) = guess.architecture.as_deref() {
306        let current_arch = normalize_arch(std::env::consts::ARCH);
307
308        if archive_arch != current_arch {
309            return Some(format!(
310                "archive appears to be for {} architecture, but this system is {}",
311                archive_arch, current_arch
312            ));
313        }
314    }
315
316    None
317}
318
319fn strip_common_root(path: &Path, root: Option<&Path>) -> PathBuf {
320    if let Some(root) = root {
321        path.strip_prefix(root).unwrap_or(path).to_path_buf()
322    } else {
323        path.to_path_buf()
324    }
325}
326
327fn safe_extract_to(
328    archive_path: &Path,
329    dest: &Path,
330    progress: Option<&dyn Fn(InstallProgress)>,
331    total_entries: u64,
332) -> Result<()> {
333    let reader = open_tar_reader(archive_path)?;
334    let mut archive = Archive::new(reader);
335    let mut current = 0_u64;
336
337    for entry in archive.entries().context("failed to read tar entries")? {
338        let mut entry = entry.context("failed to read tar entry")?;
339        let entry_type = entry.header().entry_type();
340        let raw_path = entry.path().context("failed to read tar entry path")?.to_path_buf();
341        current += 1;
342
343        emit_progress(progress, InstallProgress::Extracting {
344            current,
345            total: total_entries,
346            path: raw_path.clone(),
347        });
348
349        if let Some(reason) = unsafe_path_reason(&raw_path) {
350            bail!("unsafe path in archive: {} ({})", raw_path.display(), reason);
351        }
352
353        let out_path = dest.join(&raw_path);
354
355        if entry_type.is_dir() {
356            fs::create_dir_all(&out_path)?;
357        } else if entry_type.is_file() {
358            if let Some(parent) = out_path.parent() {
359                fs::create_dir_all(parent)?;
360            }
361            let mut out = fs::File::create(&out_path)
362                .with_context(|| format!("failed to create extracted file: {}", out_path.display()))?;
363            std::io::copy(&mut entry, &mut out)?;
364            let mode = entry.header().mode().unwrap_or(0o644);
365            fs::set_permissions(&out_path, fs::Permissions::from_mode(mode & 0o777))?;
366        } else if entry_type.is_symlink() {
367            let link_target = entry
368                .link_name()
369                .context("failed to read symlink target")?
370                .ok_or_else(|| anyhow!("symlink entry has no target: {}", raw_path.display()))?
371                .into_owned();
372
373            validate_safe_symlink(&raw_path, &link_target)?;
374
375            if let Some(parent) = out_path.parent() {
376                fs::create_dir_all(parent)?;
377            }
378
379            if fs::symlink_metadata(&out_path).is_ok() {
380                bail!("refusing to overwrite existing path with symlink: {}", raw_path.display());
381            }
382
383            unix_fs::symlink(&link_target, &out_path)
384                .with_context(|| format!("failed to create symlink: {} -> {}", raw_path.display(), link_target.display()))?;
385        } else if entry_type.is_hard_link() {
386            bail!("hard link entries are not supported yet: {}", raw_path.display());
387        }
388    }
389
390    Ok(())
391}
392
393fn validate_safe_symlink(link_path: &Path, target: &Path) -> Result<()> {
394    if target.is_absolute() {
395        bail!(
396            "unsafe symlink target in archive: {} -> {} (absolute target)",
397            link_path.display(),
398            target.display()
399        );
400    }
401
402    let base = link_path.parent().unwrap_or_else(|| Path::new(""));
403    let resolved = normalize_relative_path(&base.join(target)).ok_or_else(|| {
404        anyhow!(
405            "unsafe symlink target in archive: {} -> {} (escapes archive root)",
406            link_path.display(),
407            target.display()
408        )
409    })?;
410
411    if resolved.as_os_str().is_empty() {
412        bail!(
413            "unsafe symlink target in archive: {} -> {} (empty target)",
414            link_path.display(),
415            target.display()
416        );
417    }
418
419    Ok(())
420}
421
422fn normalize_relative_path(path: &Path) -> Option<PathBuf> {
423    let mut normalized = PathBuf::new();
424
425    for component in path.components() {
426        match component {
427            std::path::Component::Normal(part) => normalized.push(part),
428            std::path::Component::CurDir => {}
429            std::path::Component::ParentDir => {
430                if !normalized.pop() {
431                    return None;
432                }
433            }
434            std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
435        }
436    }
437
438    Some(normalized)
439}
440
441fn copy_dir_all(src: &Path, dst: &Path, progress: Option<&dyn Fn(InstallProgress)>) -> Result<()> {
442    fs::create_dir_all(dst)?;
443
444    let mut entries = Vec::new();
445    for entry in WalkDir::new(src).follow_links(false) {
446        entries.push(entry?);
447    }
448
449    let total = entries.iter().filter(|entry| entry.path() != src).count() as u64;
450    let mut current = 0_u64;
451
452    for entry in entries {
453        let rel = entry.path().strip_prefix(src)?;
454        if rel.as_os_str().is_empty() {
455            continue;
456        }
457
458        current += 1;
459        emit_progress(progress, InstallProgress::Copying {
460            current,
461            total,
462            path: rel.to_path_buf(),
463        });
464
465        let to = dst.join(rel);
466
467        if entry.file_type().is_dir() {
468            fs::create_dir_all(&to)?;
469        } else if entry.file_type().is_file() {
470            if let Some(parent) = to.parent() {
471                fs::create_dir_all(parent)?;
472            }
473            fs::copy(entry.path(), &to)
474                .with_context(|| format!("failed to copy {} to {}", entry.path().display(), to.display()))?;
475            let perms = fs::metadata(entry.path())?.permissions();
476            fs::set_permissions(&to, perms)?;
477        } else if entry.file_type().is_symlink() {
478            if let Some(parent) = to.parent() {
479                fs::create_dir_all(parent)?;
480            }
481
482            let target = fs::read_link(entry.path())
483                .with_context(|| format!("failed to read symlink: {}", entry.path().display()))?;
484
485            if fs::symlink_metadata(&to).is_ok() {
486                fs::remove_file(&to)
487                    .with_context(|| format!("failed to replace existing symlink target: {}", to.display()))?;
488            }
489
490            unix_fs::symlink(&target, &to)
491                .with_context(|| format!("failed to copy symlink {} -> {}", to.display(), target.display()))?;
492        }
493    }
494
495    Ok(())
496}
497
498fn ensure_executable(path: &Path) -> Result<()> {
499    let mut perms = fs::metadata(path)?.permissions();
500    let mode = perms.mode();
501    if (mode & 0o111) == 0 {
502        perms.set_mode(mode | 0o755);
503        fs::set_permissions(path, perms)?;
504    }
505    Ok(())
506}
507
508fn write_wrapper(path: &Path, app_dir: &Path, exec_abs: &Path) -> Result<()> {
509    if let Some(parent) = path.parent() {
510        fs::create_dir_all(parent).with_context(|| format!("failed to create command dir: {}", parent.display()))?;
511    }
512    let content = format!(
513        "#!/usr/bin/env bash\nset -e\nAPPDIR={}\ncd \"$APPDIR\"\nexec {} \"$@\"\n",
514        shell_quote(&app_dir.to_string_lossy()),
515        shell_quote(&exec_abs.to_string_lossy()),
516    );
517    fs::write(path, content).with_context(|| format!("failed to write wrapper: {}", path.display()))?;
518    fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
519    Ok(())
520}
521
522fn shell_quote(s: &str) -> String {
523    let escaped = s.replace('\'', "'\"'\"'");
524    format!("'{}'", escaped)
525}
526
527fn install_icon(plan: &InstallPlan, icon_inside: &Path) -> Result<Vec<PathBuf>> {
528    let src = plan.targets.app_dir.join(icon_inside);
529    if !src.exists() {
530        return Ok(Vec::new());
531    }
532    fs::create_dir_all(&plan.targets.icon_dir)?;
533    let ext = src.extension().and_then(|s| s.to_str()).unwrap_or("png");
534    let dest = plan.targets.icon_dir.join(format!("{}.{}", plan.app_id, ext));
535    fs::copy(&src, &dest).with_context(|| format!("failed to install icon: {}", dest.display()))?;
536    Ok(vec![dest])
537}
538
539fn sha256_file(path: &Path) -> Result<String> {
540    let mut file = fs::File::open(path)?;
541    let mut hasher = Sha256::new();
542    let mut buf = [0u8; 8192];
543    loop {
544        let n = file.read(&mut buf)?;
545        if n == 0 { break; }
546        hasher.update(&buf[..n]);
547    }
548    Ok(hex::encode(hasher.finalize()))
549}
550
551fn remove_path(path: &Path, removed: &mut Vec<PathBuf>) -> Result<()> {
552    if !path.exists() {
553        return Ok(());
554    }
555    let meta = fs::symlink_metadata(path)?;
556    if meta.is_dir() {
557        fs::remove_dir_all(path)?;
558    } else {
559        fs::remove_file(path)?;
560    }
561    removed.push(path.to_path_buf());
562    Ok(())
563}
564
565fn exists_text(path: &Path) -> &'static str {
566    if path.exists() { "ok" } else { "missing" }
567}