Skip to main content

upstream_rs/services/packaging/
package_installer.rs

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    /// Install package files from a release
620    /// Returns the updated package with installation paths set
621    #[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            // Fallback: add out_path to PATH
987            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}