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}