1#[cfg(target_os = "linux")]
2use crate::services::integration::AppImageExtractor;
3use crate::{
4 models::common::{DesktopEntry, enums::TrustMode},
5 models::{common::enums::Filetype, provider::Release, upstream::Package},
6 providers::provider_manager::ProviderManager,
7 services::{
8 integration::{
9 CompletionManager, DesktopManager, IconManager, ShellManager, SymlinkManager,
10 compression_handler, permission_handler,
11 },
12 packaging::{
13 PackagePhase, PackageProgressEvent, PackageRemover,
14 bundle_handler::BundleHandler,
15 disk_impact::{DiskImpact, asset_size_estimate, install_impact_from_download},
16 transaction_recorder::{PackageTransaction, failed_package, successful_package},
17 },
18 storage::{
19 package_storage::PackageStorage,
20 transaction_storage::{TransactionKind, UndoActionKind},
21 },
22 trust::{
23 ChecksumVerificationStatus, SignatureScheme, SignatureVerificationStatus,
24 TrustVerificationStatus, TrustVerifier, TrustedSignatureKeys,
25 },
26 },
27 utils::{filesystem::safe_move, static_paths::UpstreamPaths},
28};
29
30use anyhow::{Context, Result, anyhow};
31use chrono::Utc;
32use console::style;
33use std::{
34 fs,
35 path::{Path, PathBuf},
36 time::{SystemTime, UNIX_EPOCH},
37};
38
39use crate::utils::{
40 filename_parser::{parse_arch, parse_os},
41 platform::platform_info::{ArchitectureInfo, CpuArch, OSKind},
42};
43
44macro_rules! message {
45 ($cb:expr, $($arg:tt)*) => {{
46 if let Some(cb) = $cb.as_mut() {
47 cb(&format!($($arg)*));
48 }
49 }};
50}
51
52macro_rules! progress {
53 ($cb:expr, $event:expr) => {{
54 if let Some(cb) = $cb.as_mut() {
55 cb($event);
56 }
57 }};
58}
59
60pub struct PackageInstaller<'a> {
61 provider_manager: &'a ProviderManager,
62 paths: &'a UpstreamPaths,
63 download_cache: PathBuf,
64 extract_cache: PathBuf,
65}
66
67#[derive(Debug, Clone)]
68pub enum PackageTransactionContext {
69 Record {
70 kind: TransactionKind,
71 undo_kind: Option<UndoActionKind>,
72 },
73 CoveredByParent,
74}
75
76impl PackageTransactionContext {
77 pub fn install() -> Self {
78 Self::Record {
79 kind: TransactionKind::Install,
80 undo_kind: Some(UndoActionKind::Remove),
81 }
82 }
83
84 pub fn build() -> Self {
85 Self::Record {
86 kind: TransactionKind::Build,
87 undo_kind: Some(UndoActionKind::Remove),
88 }
89 }
90}
91
92pub struct InstallPreview {
93 pub release_name: String,
94 pub release_tag: String,
95 pub asset_name: String,
96 pub resolved_filetype: Filetype,
97 pub disk_impact: DiskImpact,
98}
99
100impl<'a> PackageInstaller<'a> {
101 fn package_cache_key(package_name: &str) -> String {
102 let timestamp = SystemTime::now()
103 .duration_since(UNIX_EPOCH)
104 .map(|d| d.as_nanos())
105 .unwrap_or(0);
106
107 let sanitized = package_name
108 .chars()
109 .map(|c| {
110 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
111 c
112 } else {
113 '_'
114 }
115 })
116 .collect::<String>();
117
118 format!("{}-{}", sanitized, timestamp)
119 }
120
121 pub fn new(provider_manager: &'a ProviderManager, paths: &'a UpstreamPaths) -> Result<Self> {
122 let temp_path = std::env::temp_dir().join(format!("upstream-{}", std::process::id()));
123 let download_cache = temp_path.join("downloads");
124 let extract_cache = temp_path.join("extracts");
125
126 fs::create_dir_all(&download_cache).context(format!(
127 "Failed to create download cache directory at '{}'",
128 download_cache.display()
129 ))?;
130 fs::create_dir_all(&extract_cache).context(format!(
131 "Failed to create extraction cache directory at '{}'",
132 extract_cache.display()
133 ))?;
134
135 Ok(Self {
136 provider_manager,
137 paths,
138 download_cache,
139 extract_cache,
140 })
141 }
142
143 #[allow(clippy::too_many_arguments)]
144 pub async fn install_release_with_progress<F, H, P>(
145 &self,
146 package_storage: &mut PackageStorage,
147 trusted_keys: &TrustedSignatureKeys,
148 package: Package,
149 version: &Option<String>,
150 add_entry: &bool,
151 trust_mode: TrustMode,
152 transaction_context: PackageTransactionContext,
153 download_progress_callback: &mut Option<F>,
154 message_callback: &mut Option<H>,
155 progress_callback: &mut Option<P>,
156 ) -> Result<Package>
157 where
158 F: FnMut(u64, u64),
159 H: FnMut(&str),
160 P: FnMut(PackageProgressEvent),
161 {
162 let package_name = package.name.clone();
163 let transaction = self.start_transaction(transaction_context, package_name.clone())?;
164 let result = self
165 .install_release_inner(
166 package_storage,
167 trusted_keys,
168 package,
169 version,
170 add_entry,
171 trust_mode,
172 download_progress_callback,
173 message_callback,
174 progress_callback,
175 )
176 .await;
177
178 self.finish_transaction(transaction, &package_name, result)
179 }
180
181 #[allow(clippy::too_many_arguments)]
182 pub async fn install_local_artifact<H>(
183 &self,
184 package_storage: &mut PackageStorage,
185 package: Package,
186 artifact_path: &Path,
187 version: crate::models::common::version::Version,
188 add_entry: &bool,
189 transaction_context: PackageTransactionContext,
190 message_callback: &mut Option<H>,
191 ) -> Result<Package>
192 where
193 H: FnMut(&str),
194 {
195 let package_name = package.name.clone();
196 let transaction = self.start_transaction(transaction_context, package_name.clone())?;
197 let result = self
198 .install_local_artifact_inner(
199 package_storage,
200 package,
201 artifact_path,
202 version,
203 add_entry,
204 message_callback,
205 )
206 .await;
207
208 self.finish_transaction(transaction, &package_name, result)
209 }
210
211 pub async fn preview_single_install(
212 &self,
213 package: &Package,
214 version: &Option<String>,
215 ) -> Result<InstallPreview> {
216 if package.install_path.is_some() {
217 return Err(anyhow!("Package '{}' is already installed", package.name));
218 }
219
220 let release = if let Some(version_tag) = version {
221 self.provider_manager
222 .get_release_by_tag(
223 &package.repo_slug,
224 version_tag,
225 &package.provider,
226 package.base_url.as_deref(),
227 )
228 .await
229 .context(format!(
230 "Failed to fetch release '{}' for '{}'. Verify the version tag exists",
231 version_tag, package.repo_slug
232 ))?
233 } else {
234 self.provider_manager
235 .get_latest_release(
236 &package.repo_slug,
237 &package.provider,
238 &package.channel,
239 package.base_url.as_deref(),
240 )
241 .await
242 .context(format!(
243 "Failed to fetch latest {} release for '{}'",
244 package.channel, package.repo_slug
245 ))?
246 };
247
248 let best_asset = self
249 .provider_manager
250 .find_recommended_asset(&release, package)
251 .context(format!(
252 "Could not find a compatible asset for '{}' (filetype: {:?}, arch: detected automatically)",
253 package.name, package.filetype
254 ))?;
255
256 let resolved_filetype = if package.filetype == Filetype::Auto {
257 best_asset.filetype
258 } else {
259 package.filetype
260 };
261
262 Ok(InstallPreview {
263 release_name: release.name,
264 release_tag: release.tag,
265 asset_name: best_asset.name.clone(),
266 resolved_filetype,
267 disk_impact: install_impact_from_download(asset_size_estimate(best_asset.size)),
268 })
269 }
270
271 fn start_transaction(
272 &self,
273 context: PackageTransactionContext,
274 package_name: String,
275 ) -> Result<Option<PackageTransaction>> {
276 match context {
277 PackageTransactionContext::Record { kind, undo_kind } => Ok(Some(
278 PackageTransaction::start(self.paths, kind, vec![package_name], undo_kind)?,
279 )),
280 PackageTransactionContext::CoveredByParent => Ok(None),
281 }
282 }
283
284 fn finish_transaction(
285 &self,
286 transaction: Option<PackageTransaction>,
287 package_name: &str,
288 result: Result<Package>,
289 ) -> Result<Package> {
290 match (result, transaction) {
291 (Ok(installed_package), Some(transaction)) => {
292 transaction.complete(vec![successful_package(
293 package_name.to_string(),
294 None,
295 Some(installed_package.version.to_string()),
296 )])?;
297 Ok(installed_package)
298 }
299 (Err(err), Some(transaction)) => {
300 let summary = crate::output::error_summary(&err);
301 transaction.fail(
302 vec![failed_package(
303 package_name.to_string(),
304 None,
305 None,
306 summary.clone(),
307 )],
308 summary,
309 )?;
310 Err(err)
311 }
312 (Ok(installed_package), None) => Ok(installed_package),
313 (Err(err), None) => Err(err),
314 }
315 }
316
317 #[allow(clippy::too_many_arguments)]
318 async fn install_release_inner<F, H, P>(
319 &self,
320 package_storage: &mut PackageStorage,
321 trusted_keys: &TrustedSignatureKeys,
322 package: Package,
323 version: &Option<String>,
324 add_entry: &bool,
325 trust_mode: TrustMode,
326 download_progress_callback: &mut Option<F>,
327 message_callback: &mut Option<H>,
328 progress_callback: &mut Option<P>,
329 ) -> Result<Package>
330 where
331 F: FnMut(u64, u64),
332 H: FnMut(&str),
333 P: FnMut(PackageProgressEvent),
334 {
335 let package_name = package.name.clone();
336 let mut installed_package = self
337 .perform_install_with_progress(
338 package,
339 version,
340 trust_mode,
341 trusted_keys,
342 download_progress_callback,
343 message_callback,
344 progress_callback,
345 )
346 .await
347 .context(format!(
348 "Failed to perform installation for '{}'",
349 package_name
350 ))?;
351
352 if *add_entry {
353 progress!(
354 progress_callback,
355 PackageProgressEvent::Phase(PackagePhase::CreatingDesktopEntry)
356 );
357
358 if let Err(err) = self
359 .add_desktop_entry(&mut installed_package, message_callback)
360 .await
361 {
362 return self.fail_after_partial_install(
363 installed_package,
364 err.context("Failed to create desktop integration"),
365 message_callback,
366 );
367 }
368 }
369
370 progress!(
371 progress_callback,
372 PackageProgressEvent::Phase(PackagePhase::SavingMetadata)
373 );
374 if let Err(err) = package_storage
375 .add_or_update_package(installed_package.clone())
376 .context(format!(
377 "Failed to save package '{}' to storage",
378 installed_package.name
379 ))
380 {
381 return self.fail_after_partial_install(installed_package, err, message_callback);
382 }
383
384 Ok(installed_package)
385 }
386
387 async fn install_local_artifact_inner<H>(
388 &self,
389 package_storage: &mut PackageStorage,
390 package: Package,
391 artifact_path: &Path,
392 version: crate::models::common::version::Version,
393 add_entry: &bool,
394 message_callback: &mut Option<H>,
395 ) -> Result<Package>
396 where
397 H: FnMut(&str),
398 {
399 let mut installed_package = self
400 .install_local_artifact_files(package, artifact_path, version, message_callback)
401 .context("Failed to install local artifact")?;
402
403 if *add_entry
404 && let Err(err) = self
405 .add_desktop_entry(&mut installed_package, message_callback)
406 .await
407 {
408 return self.fail_after_partial_install(
409 installed_package,
410 err.context("Failed to create desktop integration"),
411 message_callback,
412 );
413 }
414
415 if let Err(err) = package_storage
416 .add_or_update_package(installed_package.clone())
417 .context(format!(
418 "Failed to save package '{}' to storage",
419 installed_package.name
420 ))
421 {
422 return self.fail_after_partial_install(installed_package, err, message_callback);
423 }
424
425 Ok(installed_package)
426 }
427
428 async fn add_desktop_entry<H>(
429 &self,
430 installed_package: &mut Package,
431 message_callback: &mut Option<H>,
432 ) -> Result<()>
433 where
434 H: FnMut(&str),
435 {
436 #[cfg(target_os = "linux")]
437 let appimage_extractor =
438 AppImageExtractor::new().context("Failed to initialize appimage extractor")?;
439
440 #[cfg(target_os = "linux")]
441 let icon_manager = IconManager::new(self.paths, &appimage_extractor);
442 #[cfg(not(target_os = "linux"))]
443 let icon_manager = IconManager::new(self.paths);
444
445 #[cfg(target_os = "linux")]
446 let desktop_manager = DesktopManager::new(self.paths, &appimage_extractor);
447 #[cfg(not(target_os = "linux"))]
448 let desktop_manager = DesktopManager::new(self.paths);
449
450 let install_path = installed_package.install_path.clone().ok_or_else(|| {
451 anyhow!(
452 "Package '{}' has no install path after installation",
453 installed_package.name
454 )
455 })?;
456
457 let icon_path = icon_manager
458 .add_icon(
459 &installed_package.name,
460 &install_path,
461 &installed_package.filetype,
462 message_callback,
463 )
464 .await
465 .context(format!(
466 "Failed to add icon for '{}'",
467 installed_package.name
468 ))?;
469
470 installed_package.icon_path = icon_path;
471
472 let desktop_entry = DesktopEntry::from_package(installed_package);
473
474 desktop_manager
475 .create_entry(
476 &install_path,
477 &installed_package.filetype,
478 desktop_entry,
479 message_callback,
480 )
481 .await
482 .context(format!(
483 "Failed to create desktop entry for '{}'",
484 installed_package.name
485 ))?;
486
487 Ok(())
488 }
489
490 fn fail_after_partial_install<H>(
491 &self,
492 installed_package: Package,
493 err: anyhow::Error,
494 message_callback: &mut Option<H>,
495 ) -> Result<Package>
496 where
497 H: FnMut(&str),
498 {
499 match self.cleanup_partial_install(&installed_package, message_callback) {
500 Ok(()) => Err(err.context(format!(
501 "Rolled back partial install for '{}'",
502 installed_package.name
503 ))),
504 Err(cleanup_err) => Err(anyhow!(
505 "{}. Additionally failed to roll back partial install for '{}': {}",
506 err,
507 installed_package.name,
508 cleanup_err
509 )),
510 }
511 }
512
513 fn cleanup_partial_install<H>(
514 &self,
515 installed_package: &Package,
516 message_callback: &mut Option<H>,
517 ) -> Result<()>
518 where
519 H: FnMut(&str),
520 {
521 if installed_package.install_path.is_none() {
522 return Ok(());
523 }
524
525 PackageRemover::new(self.paths)
526 .remove_package_files(installed_package, message_callback)
527 .context(format!(
528 "Failed to clean up partial install for '{}'",
529 installed_package.name
530 ))
531 }
532
533 #[allow(clippy::too_many_arguments)]
534 async fn perform_install_with_progress<F, H, P>(
535 &self,
536 package: Package,
537 version: &Option<String>,
538 trust_mode: TrustMode,
539 trusted_keys: &TrustedSignatureKeys,
540 download_progress_callback: &mut Option<F>,
541 message_callback: &mut Option<H>,
542 progress_callback: &mut Option<P>,
543 ) -> Result<Package>
544 where
545 F: FnMut(u64, u64),
546 H: FnMut(&str),
547 P: FnMut(PackageProgressEvent),
548 {
549 if package.install_path.is_some() {
550 return Err(anyhow!("Package '{}' is already installed", package.name));
551 }
552
553 progress!(
554 progress_callback,
555 PackageProgressEvent::Phase(PackagePhase::ResolvingRelease)
556 );
557
558 let release = if let Some(version_tag) = version {
559 message!(
560 message_callback,
561 "Fetching release for version '{}' ...",
562 version_tag
563 );
564 self.provider_manager
565 .get_release_by_tag(
566 &package.repo_slug,
567 version_tag,
568 &package.provider,
569 package.base_url.as_deref(),
570 )
571 .await
572 .context(format!(
573 "Failed to fetch release '{}' for '{}'. Verify the version tag exists",
574 version_tag, package.repo_slug
575 ))?
576 } else {
577 message!(message_callback, "Fetching latest release ...");
578 self.provider_manager
579 .get_latest_release(
580 &package.repo_slug,
581 &package.provider,
582 &package.channel,
583 package.base_url.as_deref(),
584 )
585 .await
586 .context(format!(
587 "Failed to fetch latest {} release for '{}'",
588 package.channel, package.repo_slug
589 ))?
590 };
591
592 let progress_callback = std::cell::RefCell::new(progress_callback.as_mut());
593 let mut bridged_progress = Some(|event: PackageProgressEvent| {
594 if let Some(cb) = progress_callback.borrow_mut().as_deref_mut() {
595 cb(event);
596 }
597 });
598 let mut bridged_download_progress = Some(|downloaded: u64, total: u64| {
599 if let Some(cb) = download_progress_callback.as_mut() {
600 cb(downloaded, total);
601 }
602 if let Some(cb) = progress_callback.borrow_mut().as_deref_mut() {
603 cb(PackageProgressEvent::Download { downloaded, total });
604 }
605 });
606
607 self.install_package_files(
608 package,
609 &release,
610 trust_mode,
611 trusted_keys,
612 &mut bridged_download_progress,
613 message_callback,
614 &mut bridged_progress,
615 )
616 .await
617 }
618
619 #[allow(clippy::too_many_arguments)]
622 pub async fn install_package_files<F, H, P>(
623 &self,
624 mut package: Package,
625 release: &Release,
626 trust_mode: TrustMode,
627 trusted_keys: &TrustedSignatureKeys,
628 download_progress_callback: &mut Option<F>,
629 message_callback: &mut Option<H>,
630 progress_callback: &mut Option<P>,
631 ) -> Result<Package>
632 where
633 F: FnMut(u64, u64),
634 H: FnMut(&str),
635 P: FnMut(PackageProgressEvent),
636 {
637 let cache_key = Self::package_cache_key(&package.name);
638 let package_download_cache = self.download_cache.join(&cache_key);
639 let package_extract_cache = self.extract_cache.join(&cache_key);
640 fs::create_dir_all(&package_download_cache).context(format!(
641 "Failed to create package download cache '{}'",
642 package_download_cache.display()
643 ))?;
644 fs::create_dir_all(&package_extract_cache).context(format!(
645 "Failed to create package extraction cache '{}'",
646 package_extract_cache.display()
647 ))?;
648
649 message!(message_callback, "Selecting asset from '{}'", release.name);
650
651 let best_asset = self
652 .provider_manager
653 .find_recommended_asset(release, &package)
654 .context(format!(
655 "Could not find a compatible asset for '{}' (filetype: {:?}, arch: detected automatically)",
656 package.name, package.filetype
657 ))?;
658
659 if package.filetype == Filetype::Auto {
660 message!(
661 message_callback,
662 "Resolved filetype to '{}'",
663 &best_asset.filetype
664 );
665 package.filetype = best_asset.filetype;
666 }
667
668 progress!(
669 progress_callback,
670 PackageProgressEvent::Phase(PackagePhase::DownloadingPackage)
671 );
672
673 let download_path = self
674 .provider_manager
675 .download_asset(
676 &best_asset,
677 &package.provider,
678 &package_download_cache,
679 download_progress_callback,
680 )
681 .await
682 .context(format!("Failed to download asset '{}'", best_asset.name))?;
683
684 let trust_verifier = TrustVerifier::new(
685 self.provider_manager,
686 &package_download_cache,
687 trust_mode,
688 trusted_keys,
689 );
690 let status = trust_verifier
691 .verify_file(
692 &download_path,
693 release,
694 &package.provider,
695 download_progress_callback,
696 message_callback,
697 progress_callback,
698 )
699 .await
700 .context("Failed trust verification")?;
701
702 match status {
703 TrustVerificationStatus::Skipped => {
704 message!(
705 message_callback,
706 "{}",
707 style("Skipping checksum/signature verification (--trust none)").yellow()
708 );
709 }
710 TrustVerificationStatus::Verified {
711 checksum,
712 signature,
713 } => {
714 match checksum {
715 ChecksumVerificationStatus::NotChecked => {}
716 ChecksumVerificationStatus::Verified => {
717 message!(message_callback, "{}", style("Checksum verified").green());
718 }
719 ChecksumVerificationStatus::Missing => {
720 if matches!(trust_mode, TrustMode::Signature | TrustMode::All) {
721 message!(
722 message_callback,
723 "{}",
724 style("Checksum missing (warning)").yellow()
725 );
726 } else {
727 message!(
728 message_callback,
729 "{}",
730 style("No checksum available").yellow()
731 );
732 }
733 }
734 }
735
736 match signature {
737 SignatureVerificationStatus::NotChecked => {}
738 SignatureVerificationStatus::Verified {
739 scheme,
740 key_id,
741 signature_asset,
742 } => {
743 let scheme_name = match scheme {
744 SignatureScheme::Minisign => "minisign",
745 SignatureScheme::Cosign => "cosign",
746 };
747 if let Some(id) = key_id {
748 message!(
749 message_callback,
750 "{}",
751 style(format!(
752 "{} signature verified with key '{}'",
753 scheme_name, id
754 ))
755 .green()
756 );
757 } else {
758 message!(
759 message_callback,
760 "{}",
761 style(format!("{scheme_name} signature verified")).green()
762 );
763 }
764 if !signature_asset.is_empty() {
765 message!(
766 message_callback,
767 "Verified against signature asset '{}'",
768 signature_asset
769 );
770 }
771 }
772 SignatureVerificationStatus::MissingSignature => {
773 if matches!(trust_mode, TrustMode::Checksum | TrustMode::All) {
774 message!(
775 message_callback,
776 "{}",
777 style("Signature missing (warning)").yellow()
778 );
779 } else {
780 message!(
781 message_callback,
782 "{}",
783 style("No signature available").yellow()
784 );
785 }
786 }
787 SignatureVerificationStatus::InvalidSignature
788 | SignatureVerificationStatus::NoTrustedKeyMatched => {}
789 }
790 }
791 }
792
793 progress!(
794 progress_callback,
795 PackageProgressEvent::Phase(PackagePhase::InstallingCompletions)
796 );
797 if let Err(err) = CompletionManager::new(self.paths)
798 .install_from_release_assets(
799 &package.name,
800 release,
801 self.provider_manager,
802 &package.provider,
803 &package_download_cache,
804 message_callback,
805 )
806 .await
807 {
808 progress!(
809 progress_callback,
810 PackageProgressEvent::Warning(format!("Completion install skipped: {err}"))
811 );
812 }
813
814 progress!(
815 progress_callback,
816 PackageProgressEvent::Phase(PackagePhase::InstallingPackage)
817 );
818
819 package.version = release.version.clone();
820
821 match package.filetype {
822 Filetype::AppImage => {
823 #[cfg(target_os = "linux")]
824 {
825 self.handle_appimage(&download_path, package, message_callback)
826 .await
827 .context("Failed to install AppImage")
828 }
829 #[cfg(not(target_os = "linux"))]
830 {
831 anyhow::bail!("AppImage installation is only supported on Linux hosts");
832 }
833 }
834 Filetype::MacApp => BundleHandler::new(self.paths, &self.extract_cache)
835 .install_app_bundle(&download_path, package, message_callback)
836 .context("Failed to install macOS app bundle"),
837 Filetype::MacDmg => BundleHandler::new(self.paths, &self.extract_cache)
838 .install_dmg(&download_path, package, message_callback)
839 .context("Failed to install macOS disk image"),
840 Filetype::Compressed => {
841 progress!(
842 progress_callback,
843 PackageProgressEvent::Phase(PackagePhase::ExtractingPackage)
844 );
845 self.handle_compressed(
846 &download_path,
847 &package_extract_cache,
848 package,
849 message_callback,
850 )
851 .context("Failed to install compressed file")
852 }
853 Filetype::Archive => {
854 progress!(
855 progress_callback,
856 PackageProgressEvent::Phase(PackagePhase::ExtractingPackage)
857 );
858 self.handle_archive(
859 &download_path,
860 &package_extract_cache,
861 package,
862 message_callback,
863 )
864 .context("Failed to install archive")
865 }
866 _ => {
867 progress!(
868 progress_callback,
869 PackageProgressEvent::Phase(PackagePhase::CreatingRuntimeLinks)
870 );
871 self.handle_file(&download_path, package, message_callback)
872 .context("Failed to install file")
873 }
874 }
875 }
876
877 pub(crate) fn install_local_artifact_files<H>(
878 &self,
879 mut package: Package,
880 artifact_path: &Path,
881 version: crate::models::common::version::Version,
882 message_callback: &mut Option<H>,
883 ) -> Result<Package>
884 where
885 H: FnMut(&str),
886 {
887 if !artifact_path.exists() {
888 return Err(anyhow!(
889 "Local artifact path '{}' does not exist",
890 artifact_path.display()
891 ));
892 }
893
894 message!(message_callback, "Installing local artifact ...");
895 package.version = version;
896
897 if artifact_path.is_dir() {
898 return self
899 .handle_archive(
900 artifact_path,
901 &self.extract_cache,
902 package,
903 message_callback,
904 )
905 .context("Failed to install local artifact directory");
906 }
907
908 self.handle_file(artifact_path, package, message_callback)
909 .context("Failed to install local artifact file")
910 }
911
912 fn handle_archive<H>(
913 &self,
914 asset_path: &Path,
915 extract_cache: &Path,
916 mut package: Package,
917 message_callback: &mut Option<H>,
918 ) -> Result<Package>
919 where
920 H: FnMut(&str),
921 {
922 let filename = asset_path
923 .file_name()
924 .ok_or_else(|| anyhow!("Invalid archive path: no filename"))?
925 .to_string_lossy()
926 .to_string();
927 message!(message_callback, "Extracting directory '{filename}' ...");
928
929 let extracted_path = compression_handler::decompress(asset_path, extract_cache)
930 .context(format!("Failed to extract archive '{}'", filename))?;
931
932 if extracted_path.is_file() {
933 return self.handle_file(&extracted_path, package, message_callback);
934 }
935
936 if let Err(err) = CompletionManager::new(self.paths).install_from_root(
937 &package.name,
938 &extracted_path,
939 message_callback,
940 ) {
941 message!(
942 message_callback,
943 "{}",
944 style(format!("Completion install skipped: {err}")).yellow()
945 );
946 }
947
948 if let Some(app_bundle_path) =
949 BundleHandler::find_macos_app_bundle(&extracted_path, &package.name)
950 .context("Failed to detect .app bundle in extracted archive")?
951 {
952 return BundleHandler::new(self.paths, &self.extract_cache)
953 .install_app_bundle(&app_bundle_path, package, message_callback)
954 .context("Failed to install app bundle from archive");
955 }
956
957 let dirname = extracted_path
958 .file_name()
959 .ok_or_else(|| anyhow!("Invalid path: no filename"))?;
960 let out_path = self.paths.install.archives_dir.join(dirname);
961 let install_root = Self::select_nested_archive_root(&extracted_path, &package)
962 .unwrap_or_else(|| extracted_path.clone());
963
964 message!(
965 message_callback,
966 "Moving directory to '{}' ...",
967 out_path.display()
968 );
969
970 safe_move::move_file_or_dir(&install_root, &out_path).context(format!(
971 "Failed to move extracted directory from '{}' to '{}'",
972 install_root.display(),
973 out_path.display()
974 ))?;
975
976 let shell_manager = ShellManager::new(&self.paths.config.paths_file);
977
978 message!(message_callback, "Searching for executable ...");
979
980 let Some(exec_path) = permission_handler::find_executable(&out_path, &package.name) else {
981 message!(
982 message_callback,
983 "{}",
984 style("Could not automatically locate executable").yellow()
985 );
986 shell_manager
988 .add_to_paths(&out_path)
989 .context(format!("Failed to add '{}' to PATH", out_path.display()))?;
990 message!(message_callback, "Added '{}' to PATH", out_path.display());
991 package.exec_path = None;
992 package.install_path = Some(out_path);
993 package.last_upgraded = Utc::now();
994 return Ok(package);
995 };
996
997 permission_handler::make_executable(&exec_path).context(format!(
998 "Failed to make '{}' executable",
999 exec_path.display()
1000 ))?;
1001
1002 message!(
1003 message_callback,
1004 "Added executable permission for '{}'",
1005 exec_path
1006 .file_name()
1007 .map(|n| n.to_string_lossy().to_string())
1008 .unwrap_or_else(|| exec_path.display().to_string())
1009 );
1010
1011 let path_to_add = exec_path
1012 .parent()
1013 .ok_or_else(|| anyhow!("Executable has no parent directory"))?;
1014
1015 shell_manager
1016 .add_to_paths(path_to_add)
1017 .context(format!("Failed to add '{}' to PATH", path_to_add.display()))?;
1018
1019 message!(
1020 message_callback,
1021 "Added '{}' to PATH",
1022 path_to_add.display()
1023 );
1024
1025 let symlink_manager = SymlinkManager::new(&self.paths.integration.symlinks_dir);
1026
1027 symlink_manager
1028 .add_link(&exec_path, &package.name)
1029 .context(format!("Failed to create symlink for '{}'", package.name))?;
1030
1031 message!(
1032 message_callback,
1033 "Created symlink: {} → {}",
1034 package.name,
1035 out_path.display()
1036 );
1037
1038 package.exec_path = Some(exec_path);
1039 package.install_path = Some(out_path);
1040 package.last_upgraded = Utc::now();
1041 Ok(package)
1042 }
1043
1044 fn select_nested_archive_root(extracted_path: &Path, package: &Package) -> Option<PathBuf> {
1045 if !extracted_path.is_dir() {
1046 return None;
1047 }
1048
1049 let architecture = ArchitectureInfo::new();
1050 let mut candidates = fs::read_dir(extracted_path)
1051 .ok()?
1052 .flatten()
1053 .filter_map(|entry| {
1054 let file_type = entry.file_type().ok()?;
1055 if !file_type.is_dir() {
1056 return None;
1057 }
1058
1059 let name = entry.file_name().to_string_lossy().to_string();
1060 let target_os = parse_os(&name)?;
1061 let target_arch = parse_arch(&name)?;
1062
1063 if target_os != architecture.os_kind {
1064 return None;
1065 }
1066
1067 let lower = name.to_ascii_lowercase();
1068 if let Some(pattern) = package.exclude_pattern.as_deref()
1069 && lower.contains(&pattern.to_ascii_lowercase())
1070 {
1071 return None;
1072 }
1073
1074 let arch_score = Self::nested_arch_score(&architecture.cpu_arch, &target_arch)?;
1075 permission_handler::find_executable(&entry.path(), &package.name)?;
1076 let score = Self::nested_archive_score(
1077 &name,
1078 &target_os,
1079 arch_score,
1080 package.match_pattern.as_deref(),
1081 );
1082
1083 Some((score, name, entry.path()))
1084 })
1085 .collect::<Vec<_>>();
1086
1087 if candidates.is_empty() {
1088 return None;
1089 }
1090
1091 candidates.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
1092 candidates.into_iter().next().map(|(_, _, path)| path)
1093 }
1094
1095 fn nested_arch_score(host_arch: &CpuArch, target_arch: &CpuArch) -> Option<i32> {
1096 if host_arch == target_arch {
1097 return Some(100);
1098 }
1099
1100 if *host_arch == CpuArch::X86_64 && *target_arch == CpuArch::X86 {
1101 return Some(40);
1102 }
1103
1104 if *host_arch == CpuArch::Aarch64 && *target_arch == CpuArch::Arm {
1105 return Some(40);
1106 }
1107
1108 None
1109 }
1110
1111 fn nested_archive_score(
1112 name: &str,
1113 target_os: &OSKind,
1114 arch_score: i32,
1115 match_pattern: Option<&str>,
1116 ) -> i32 {
1117 let lower = name.to_ascii_lowercase();
1118 let mut score = arch_score;
1119
1120 if *target_os == OSKind::Linux {
1121 score += Self::linux_abi_score(&lower);
1122 }
1123
1124 if let Some(pattern) = match_pattern
1125 && lower.contains(&pattern.to_ascii_lowercase())
1126 {
1127 score += 100;
1128 }
1129
1130 score
1131 }
1132
1133 fn linux_abi_score(name: &str) -> i32 {
1134 #[cfg(all(target_os = "linux", target_env = "musl"))]
1135 {
1136 if name.contains("musl") {
1137 return 30;
1138 }
1139 if name.contains("gnu") || name.contains("glibc") {
1140 return 10;
1141 }
1142 return 0;
1143 }
1144
1145 #[cfg(all(target_os = "linux", not(target_env = "musl")))]
1146 {
1147 if name.contains("linux-gnu") && !name.contains("glibc") {
1148 return 30;
1149 }
1150 if name.contains("glibc") {
1151 return 20;
1152 }
1153 if name.contains("musl") {
1154 return 10;
1155 }
1156 0
1157 }
1158
1159 #[cfg(not(target_os = "linux"))]
1160 {
1161 let _ = name;
1162 0
1163 }
1164 }
1165
1166 fn handle_compressed<H>(
1167 &self,
1168 asset_path: &Path,
1169 extract_cache: &Path,
1170 package: Package,
1171 message_callback: &mut Option<H>,
1172 ) -> Result<Package>
1173 where
1174 H: FnMut(&str),
1175 {
1176 let filename = asset_path
1177 .file_name()
1178 .ok_or_else(|| anyhow!("Invalid compressed path: no filename"))?
1179 .to_string_lossy()
1180 .to_string();
1181 message!(message_callback, "Extracting file '{}' ...", filename);
1182
1183 let extracted_path = compression_handler::decompress(asset_path, extract_cache)
1184 .context(format!("Failed to decompress '{}'", filename))?;
1185
1186 self.handle_file(&extracted_path, package, message_callback)
1187 }
1188
1189 #[cfg(target_os = "linux")]
1190 async fn handle_appimage<H>(
1191 &self,
1192 asset_path: &Path,
1193 mut package: Package,
1194 message_callback: &mut Option<H>,
1195 ) -> Result<Package>
1196 where
1197 H: FnMut(&str),
1198 {
1199 let filename = asset_path
1200 .file_name()
1201 .ok_or_else(|| anyhow!("Invalid path: no filename"))?;
1202 let out_path = self.paths.install.appimages_dir.join(filename);
1203
1204 message!(
1205 message_callback,
1206 "Moving file to '{}' ...",
1207 out_path.display()
1208 );
1209
1210 safe_move::move_file_or_dir(asset_path, &out_path).context(format!(
1211 "Failed to move AppImage to '{}'",
1212 out_path.display()
1213 ))?;
1214
1215 permission_handler::make_executable(&out_path).context(format!(
1216 "Failed to make AppImage '{}' executable",
1217 filename.to_string_lossy()
1218 ))?;
1219
1220 message!(message_callback, "Made '{}' executable", filename.display());
1221
1222 match crate::services::integration::AppImageExtractor::new() {
1223 Ok(extractor) => match extractor
1224 .extract(&package.name, &out_path, message_callback)
1225 .await
1226 {
1227 Ok(root) => {
1228 if let Err(err) = CompletionManager::new(self.paths).install_from_root(
1229 &package.name,
1230 &root,
1231 message_callback,
1232 ) {
1233 message!(
1234 message_callback,
1235 "{}",
1236 style(format!("Completion install skipped: {err}")).yellow()
1237 );
1238 }
1239 }
1240 Err(err) => {
1241 message!(
1242 message_callback,
1243 "{}",
1244 style(format!("AppImage completion scan skipped: {err}")).yellow()
1245 );
1246 }
1247 },
1248 Err(err) => {
1249 message!(
1250 message_callback,
1251 "{}",
1252 style(format!("AppImage completion scan skipped: {err}")).yellow()
1253 );
1254 }
1255 }
1256
1257 SymlinkManager::new(&self.paths.integration.symlinks_dir)
1258 .add_link(&out_path, &package.name)
1259 .context(format!("Failed to create symlink for '{}'", package.name))?;
1260
1261 message!(
1262 message_callback,
1263 "Created symlink: {} → {}",
1264 package.name,
1265 out_path.display()
1266 );
1267
1268 package.install_path = Some(out_path.clone());
1269 package.exec_path = Some(out_path);
1270 package.last_upgraded = Utc::now();
1271 Ok(package)
1272 }
1273
1274 fn handle_file<H>(
1275 &self,
1276 asset_path: &Path,
1277 mut package: Package,
1278 message_callback: &mut Option<H>,
1279 ) -> Result<Package>
1280 where
1281 H: FnMut(&str),
1282 {
1283 let filename = asset_path
1284 .file_name()
1285 .ok_or_else(|| anyhow!("Invalid path: no filename"))?;
1286 let out_path = self.paths.install.binaries_dir.join(filename);
1287
1288 message!(
1289 message_callback,
1290 "Moving file to '{}' ...",
1291 out_path.display()
1292 );
1293
1294 safe_move::move_file_or_dir(asset_path, &out_path)
1295 .context(format!("Failed to move binary to '{}'", out_path.display()))?;
1296
1297 permission_handler::make_executable(&out_path).context(format!(
1298 "Failed to make binary '{}' executable",
1299 filename.to_string_lossy()
1300 ))?;
1301
1302 message!(message_callback, "Made '{}' executable", filename.display());
1303
1304 SymlinkManager::new(&self.paths.integration.symlinks_dir)
1305 .add_link(&out_path, &package.name)
1306 .context(format!("Failed to create symlink for '{}'", package.name))?;
1307
1308 message!(
1309 message_callback,
1310 "Created symlink: {} → {}",
1311 package.name,
1312 out_path.display()
1313 );
1314
1315 package.install_path = Some(out_path.clone());
1316 package.exec_path = Some(out_path);
1317 package.last_upgraded = Utc::now();
1318 Ok(package)
1319 }
1320}
1321
1322impl<'a> Drop for PackageInstaller<'a> {
1323 fn drop(&mut self) {
1324 let _ = fs::remove_dir_all(&self.extract_cache);
1325 let _ = fs::remove_dir_all(&self.download_cache);
1326 }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::PackageInstaller;
1332 use crate::models::common::enums::{Channel, Filetype, Provider};
1333 use crate::models::upstream::Package;
1334 use crate::utils::test_support;
1335 use std::fs;
1336
1337 fn make_package(
1338 name: &str,
1339 match_pattern: Option<&str>,
1340 exclude_pattern: Option<&str>,
1341 ) -> Package {
1342 Package::with_defaults(
1343 name.to_string(),
1344 format!("owner/{name}"),
1345 Filetype::Archive,
1346 match_pattern.map(str::to_string),
1347 exclude_pattern.map(str::to_string),
1348 Channel::Stable,
1349 Provider::Github,
1350 None,
1351 )
1352 }
1353
1354 #[cfg(target_os = "linux")]
1355 fn host_linux_gnu_dir() -> Option<&'static str> {
1356 if cfg!(target_arch = "x86_64") {
1357 Some("x86_64-unknown-linux-gnu")
1358 } else if cfg!(target_arch = "x86") {
1359 Some("x86_32-unknown-linux-gnu")
1360 } else if cfg!(target_arch = "aarch64") {
1361 Some("aarch64-unknown-linux-gnu")
1362 } else if cfg!(target_arch = "arm") {
1363 Some("armv7-unknown-linux-gnueabihf")
1364 } else {
1365 None
1366 }
1367 }
1368
1369 #[cfg(target_os = "linux")]
1370 fn host_linux_glibc_dir() -> Option<&'static str> {
1371 if cfg!(target_arch = "x86_64") {
1372 Some("x86_64-unknown-linux-gnu-glibc2.28")
1373 } else if cfg!(target_arch = "x86") {
1374 Some("x86_32-unknown-linux-gnu-glibc2.28")
1375 } else if cfg!(target_arch = "aarch64") {
1376 Some("aarch64-unknown-linux-gnu-glibc2.28")
1377 } else if cfg!(target_arch = "arm") {
1378 Some("armv7-unknown-linux-gnueabihf-glibc2.28")
1379 } else {
1380 None
1381 }
1382 }
1383
1384 #[cfg(target_os = "linux")]
1385 fn host_linux_musl_dir() -> Option<&'static str> {
1386 if cfg!(target_arch = "x86_64") {
1387 Some("x86_64-unknown-linux-musl")
1388 } else if cfg!(target_arch = "x86") {
1389 Some("x86_32-unknown-linux-musl")
1390 } else if cfg!(target_arch = "aarch64") {
1391 Some("aarch64-unknown-linux-musl")
1392 } else if cfg!(target_arch = "arm") {
1393 Some("armv7-unknown-linux-musleabihf")
1394 } else {
1395 None
1396 }
1397 }
1398
1399 #[test]
1400 fn package_cache_key_sanitizes_disallowed_characters() {
1401 let key = PackageInstaller::package_cache_key("my/pkg v1.0");
1402 assert!(key.starts_with("my_pkg_v1_0-"));
1403 assert!(
1404 key.chars()
1405 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1406 );
1407 }
1408
1409 #[cfg(target_os = "linux")]
1410 #[test]
1411 fn nested_archive_root_prefers_host_linux_gnu_payload() {
1412 let Some(expected_dir) = host_linux_gnu_dir() else {
1413 return;
1414 };
1415 let root = test_support::temp_root("upstream-installer-test", "nested-broot");
1416 let extracted = root.join("broot_1.56.4");
1417 fs::create_dir_all(&extracted).expect("create extracted root");
1418
1419 for dir in [
1420 "x86_64-pc-windows-gnu",
1421 "x86_64-unknown-linux-musl",
1422 "x86_64-unknown-linux-gnu-glibc2.28",
1423 "x86_64-unknown-linux-gnu",
1424 "aarch64-unknown-linux-gnu",
1425 "aarch64-unknown-linux-musl",
1426 "armv7-unknown-linux-gnueabihf",
1427 "armv7-unknown-linux-musleabihf",
1428 ] {
1429 let payload = extracted.join(dir);
1430 fs::create_dir_all(&payload).expect("create payload");
1431 fs::write(
1432 payload.join(if dir.contains("windows") {
1433 "broot.exe"
1434 } else {
1435 "broot"
1436 }),
1437 b"bin",
1438 )
1439 .expect("write payload binary");
1440 }
1441
1442 fs::create_dir_all(extracted.join("completion")).expect("create completion");
1443 fs::write(extracted.join("broot.1"), b"manpage").expect("write manpage");
1444
1445 let selected = PackageInstaller::select_nested_archive_root(
1446 &extracted,
1447 &make_package("broot", None, None),
1448 )
1449 .expect("select nested root");
1450
1451 assert!(selected.ends_with(expected_dir));
1452
1453 fs::remove_dir_all(&root).expect("cleanup");
1454 }
1455
1456 #[cfg(target_os = "linux")]
1457 #[test]
1458 fn nested_archive_root_honors_match_and_exclude_patterns() {
1459 let (Some(musl_dir), Some(gnu_dir), Some(glibc_dir)) = (
1460 host_linux_musl_dir(),
1461 host_linux_gnu_dir(),
1462 host_linux_glibc_dir(),
1463 ) else {
1464 return;
1465 };
1466 let root = test_support::temp_root("upstream-installer-test", "nested-patterns");
1467 let extracted = root.join("tool_1.0.0");
1468 fs::create_dir_all(&extracted).expect("create extracted root");
1469
1470 for dir in [musl_dir, gnu_dir, glibc_dir] {
1471 let payload = extracted.join(dir);
1472 fs::create_dir_all(&payload).expect("create payload");
1473 fs::write(payload.join("tool"), b"bin").expect("write payload binary");
1474 }
1475
1476 let selected_musl = PackageInstaller::select_nested_archive_root(
1477 &extracted,
1478 &make_package("tool", Some("musl"), None),
1479 )
1480 .expect("select musl root");
1481 assert!(selected_musl.ends_with(musl_dir));
1482
1483 let selected_glibc = PackageInstaller::select_nested_archive_root(
1484 &extracted,
1485 &make_package("tool", None, Some("linux-gnu")),
1486 )
1487 .expect("select non-excluded root");
1488 assert!(selected_glibc.ends_with(musl_dir));
1489
1490 fs::remove_dir_all(&root).expect("cleanup");
1491 }
1492
1493 #[test]
1494 fn nested_archive_root_ignores_ordinary_archive_layouts() {
1495 let root = test_support::temp_root("upstream-installer-test", "ordinary-archive");
1496 let extracted = root.join("tool_1.0.0");
1497 fs::create_dir_all(extracted.join("bin")).expect("create bin");
1498 fs::write(extracted.join("bin").join("tool"), b"bin").expect("write binary");
1499 fs::create_dir_all(extracted.join("docs")).expect("create docs");
1500
1501 assert!(
1502 PackageInstaller::select_nested_archive_root(
1503 &extracted,
1504 &make_package("tool", None, None),
1505 )
1506 .is_none()
1507 );
1508
1509 fs::remove_dir_all(&root).expect("cleanup");
1510 }
1511}