Skip to main content

soar_core/package/
install.rs

1use std::{
2    env, fs,
3    io::Write,
4    path::{Path, PathBuf},
5    process::Command,
6    thread::sleep,
7    time::Duration,
8};
9
10use chrono::Utc;
11use serde_json::json;
12use soar_config::{
13    config::Config,
14    packages::{BinaryMapping, BuildConfig, PackageHooks, SandboxConfig},
15};
16use soar_db::repository::core::{
17    CoreRepository, InstalledPackageWithPortable, NewInstalledPackage,
18};
19use soar_dl::{
20    download::Download,
21    error::DownloadError,
22    filter::Filter,
23    oci::OciDownload,
24    types::{OverwriteMode, Progress},
25};
26use soar_utils::{
27    error::FileSystemResult,
28    fs::{safe_remove, walk_dir},
29    hash::calculate_checksum,
30};
31use tracing::{debug, trace, warn};
32
33use crate::{
34    constants::INSTALL_MARKER_FILE,
35    database::{connection::DieselDatabase, models::Package},
36    error::{ErrorContext, SoarError},
37    utils::get_extract_dir,
38    SoarResult,
39};
40
41/// Early validation of relative paths before download.
42/// Rejects paths containing `..` or absolute paths.
43fn validate_relative_path(relative_path: &str, path_type: &str) -> SoarResult<()> {
44    if Path::new(relative_path).is_absolute() {
45        return Err(SoarError::Custom(format!(
46            "{} '{}' must be a relative path, not absolute",
47            path_type, relative_path
48        )));
49    }
50
51    if relative_path.contains("..") {
52        return Err(SoarError::Custom(format!(
53            "{} '{}' contains path traversal components",
54            path_type, relative_path
55        )));
56    }
57
58    Ok(())
59}
60
61/// Validate that a path is contained within a base directory (post-extraction check).
62/// Returns the canonicalized path if valid, or an error if the path escapes the base.
63fn validate_path_containment(
64    base_dir: &Path,
65    relative_path: &str,
66    path_type: &str,
67) -> SoarResult<PathBuf> {
68    let joined_path = base_dir.join(relative_path);
69
70    let canonical_base = base_dir
71        .canonicalize()
72        .with_context(|| format!("canonicalizing base directory {}", base_dir.display()))?;
73
74    let canonical_path = joined_path.canonicalize().with_context(|| {
75        format!(
76            "canonicalizing {} path {}",
77            path_type,
78            joined_path.display()
79        )
80    })?;
81
82    if !canonical_path.starts_with(&canonical_base) {
83        return Err(SoarError::Custom(format!(
84            "{} '{}' escapes install directory (path traversal)",
85            path_type, relative_path
86        )));
87    }
88
89    Ok(canonical_path)
90}
91
92use crate::utils::substitute_placeholders;
93
94/// Marker content to verify partial install matches current package
95#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
96pub struct InstallMarker {
97    pub pkg_id: String,
98    pub version: String,
99    pub bsum: Option<String>,
100}
101
102impl InstallMarker {
103    pub fn read_from_dir(install_dir: &Path) -> Option<Self> {
104        let marker_path = install_dir.join(INSTALL_MARKER_FILE);
105        let content = fs::read_to_string(&marker_path).ok()?;
106        serde_json::from_str(&content).ok()
107    }
108
109    pub fn matches_package(&self, package: &Package) -> bool {
110        self.pkg_id == package.pkg_id
111            && self.version == package.version
112            && self.bsum == package.bsum
113    }
114}
115
116pub struct PackageInstaller {
117    package: Package,
118    install_dir: PathBuf,
119    progress_callback: Option<std::sync::Arc<dyn Fn(Progress) + Send + Sync>>,
120    db: DieselDatabase,
121    config: Config,
122    globs: Vec<String>,
123    nested_extract: Option<String>,
124    extract_root: Option<String>,
125    hooks: Option<PackageHooks>,
126    build: Option<BuildConfig>,
127    sandbox: Option<SandboxConfig>,
128    arch_map: Option<std::collections::HashMap<String, String>>,
129}
130
131#[derive(Clone, Default, Debug)]
132pub struct InstallTarget {
133    pub package: Package,
134    pub existing_install: Option<crate::database::models::InstalledPackage>,
135    pub pinned: bool,
136    pub profile: Option<String>,
137    pub portable: Option<String>,
138    pub portable_home: Option<String>,
139    pub portable_config: Option<String>,
140    pub portable_share: Option<String>,
141    pub portable_cache: Option<String>,
142    pub entrypoint: Option<String>,
143    pub binaries: Option<Vec<BinaryMapping>>,
144    pub nested_extract: Option<String>,
145    pub extract_root: Option<String>,
146    pub hooks: Option<PackageHooks>,
147    pub build: Option<BuildConfig>,
148    pub sandbox: Option<SandboxConfig>,
149    pub arch_map: Option<std::collections::HashMap<String, String>>,
150}
151
152impl PackageInstaller {
153    pub async fn new<P: AsRef<Path>>(
154        target: &InstallTarget,
155        install_dir: P,
156        progress_callback: Option<std::sync::Arc<dyn Fn(Progress) + Send + Sync>>,
157        db: DieselDatabase,
158        globs: Vec<String>,
159        config: Config,
160    ) -> SoarResult<Self> {
161        let install_dir = install_dir.as_ref().to_path_buf();
162        let package = &target.package;
163        trace!(
164            pkg_name = package.pkg_name,
165            pkg_id = package.pkg_id,
166            install_dir = %install_dir.display(),
167            "creating package installer"
168        );
169        let profile = config.default_profile.clone();
170
171        // Early validation of extract_root and nested_extract paths
172        if let Some(ref extract_root) = target.extract_root {
173            validate_relative_path(extract_root, "extract_root")?;
174        }
175        if let Some(ref nested_extract) = target.nested_extract {
176            validate_relative_path(nested_extract, "nested_extract")?;
177        }
178
179        // Check if there's a pending install for this exact version we can resume
180        let has_pending = db.with_conn(|conn| {
181            CoreRepository::has_pending_install(
182                conn,
183                &package.pkg_id,
184                &package.pkg_name,
185                &package.repo_name,
186                &package.version,
187            )
188        })?;
189
190        trace!(
191            pkg_id = package.pkg_id,
192            pkg_name = package.pkg_name,
193            repo_name = package.repo_name,
194            version = package.version,
195            has_pending = has_pending,
196            "checking for pending install"
197        );
198
199        let needs_new_record = if has_pending {
200            trace!("resuming existing pending install");
201            false
202        } else {
203            match &target.existing_install {
204                None => true,
205                Some(existing) => existing.version != package.version || existing.is_installed,
206            }
207        };
208
209        if needs_new_record {
210            trace!(
211                "inserting new package record for version {}",
212                package.version
213            );
214            let repo_name = &package.repo_name;
215            let pkg_id = &package.pkg_id;
216            let pkg_name = &package.pkg_name;
217            let pkg_type = package.pkg_type.as_deref();
218            let version = &package.version;
219            let size = package.ghcr_size.unwrap_or(package.size.unwrap_or(0)) as i64;
220            let installed_path = install_dir.to_string_lossy();
221            let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
222
223            // Clean up any orphaned pending installs (different versions) before creating new record
224            let orphaned_paths = db.with_conn(|conn| {
225                CoreRepository::delete_pending_installs(conn, pkg_id, pkg_name, repo_name)
226            })?;
227            for path in orphaned_paths {
228                let path = std::path::Path::new(&path);
229                if path.exists() {
230                    fs::remove_dir_all(path).ok();
231                }
232            }
233
234            let new_package = NewInstalledPackage {
235                repo_name,
236                pkg_id,
237                pkg_name,
238                pkg_type,
239                version,
240                size,
241                checksum: None,
242                installed_path: &installed_path,
243                installed_date: &installed_date,
244                profile: &profile,
245                pinned: target.pinned,
246                is_installed: false,
247                detached: false,
248                unlinked: false,
249                provides: None,
250                install_patterns: Some(json!(globs)),
251            };
252
253            db.with_conn(|conn| CoreRepository::insert(conn, &new_package))?;
254        }
255
256        Ok(Self {
257            package: package.clone(),
258            install_dir,
259            progress_callback,
260            db,
261            config,
262            globs,
263            nested_extract: target.nested_extract.clone(),
264            extract_root: target.extract_root.clone(),
265            hooks: target.hooks.clone(),
266            build: target.build.clone(),
267            sandbox: target.sandbox.clone(),
268            arch_map: target.arch_map.clone(),
269        })
270    }
271
272    /// Run a hook command with environment variables set.
273    fn run_hook(&self, hook_name: &str, command: &str) -> SoarResult<()> {
274        use super::hooks::{run_hook, HookEnv};
275
276        let env = HookEnv {
277            install_dir: &self.install_dir,
278            pkg_name: &self.package.pkg_name,
279            pkg_id: &self.package.pkg_id,
280            pkg_version: &self.package.version,
281        };
282
283        run_hook(hook_name, command, &env, self.sandbox.as_ref())
284    }
285
286    /// Run post_download hook if configured.
287    pub fn run_post_download_hook(&self) -> SoarResult<()> {
288        if let Some(ref hooks) = self.hooks {
289            if let Some(ref cmd) = hooks.post_download {
290                self.run_hook("post_download", cmd)?;
291            }
292        }
293        Ok(())
294    }
295
296    /// Run post_extract hook if configured.
297    pub fn run_post_extract_hook(&self) -> SoarResult<()> {
298        if let Some(ref hooks) = self.hooks {
299            if let Some(ref cmd) = hooks.post_extract {
300                self.run_hook("post_extract", cmd)?;
301            }
302        }
303        Ok(())
304    }
305
306    /// Run post_install hook if configured.
307    pub fn run_post_install_hook(&self) -> SoarResult<()> {
308        if let Some(ref hooks) = self.hooks {
309            if let Some(ref cmd) = hooks.post_install {
310                self.run_hook("post_install", cmd)?;
311            }
312        }
313        Ok(())
314    }
315
316    /// Check if build dependencies are available.
317    fn check_build_dependencies(&self, deps: &[String]) -> SoarResult<()> {
318        for dep in deps {
319            let result = Command::new("which").arg(dep).output();
320
321            match result {
322                Ok(output) if !output.status.success() => {
323                    warn!("Build dependency '{}' not found in PATH", dep);
324                }
325                Err(_) => {
326                    warn!("Could not check for build dependency '{}'", dep);
327                }
328                _ => {
329                    trace!("Build dependency '{}' found", dep);
330                }
331            }
332        }
333        Ok(())
334    }
335
336    /// Run build commands if configured.
337    pub fn run_build(&self) -> SoarResult<()> {
338        use crate::sandbox;
339
340        let build_config = match &self.build {
341            Some(config) if !config.commands.is_empty() => config,
342            _ => return Ok(()),
343        };
344
345        debug!(
346            "building package {} with {} commands",
347            self.package.pkg_name,
348            build_config.commands.len()
349        );
350
351        if !build_config.dependencies.is_empty() {
352            self.check_build_dependencies(&build_config.dependencies)?;
353        }
354
355        let bin_dir = self.config.get_bin_path()?;
356        let nproc = std::thread::available_parallelism()
357            .map(|p| p.get().to_string())
358            .unwrap_or_else(|_| "1".to_string());
359
360        let use_sandbox = sandbox::is_landlock_supported();
361
362        if use_sandbox {
363            debug!("running build with Landlock sandbox");
364        } else {
365            if self.sandbox.as_ref().is_some_and(|s| s.require) {
366                return Err(SoarError::Custom(
367                    "Build requires sandbox but Landlock is not available on this system. \
368                     Either upgrade to Linux 5.13+ or set sandbox.require = false."
369                        .into(),
370                ));
371            }
372            warn!(
373                "Landlock not supported, running build without sandbox ({} commands)",
374                build_config.commands.len()
375            );
376        }
377
378        for (i, cmd) in build_config.commands.iter().enumerate() {
379            debug!(
380                "running build command {}/{}: {}",
381                i + 1,
382                build_config.commands.len(),
383                cmd
384            );
385
386            let status = if use_sandbox {
387                let env_vars: Vec<(&str, String)> = vec![
388                    (
389                        "INSTALL_DIR",
390                        self.install_dir.to_string_lossy().to_string(),
391                    ),
392                    ("BIN_DIR", bin_dir.to_string_lossy().to_string()),
393                    ("PKG_NAME", self.package.pkg_name.clone()),
394                    ("PKG_ID", self.package.pkg_id.clone()),
395                    ("PKG_VERSION", self.package.version.clone()),
396                    ("NPROC", nproc.clone()),
397                ];
398
399                let mut sandbox_cmd = sandbox::SandboxedCommand::new(cmd)
400                    .working_dir(&self.install_dir)
401                    .read_path(&bin_dir)
402                    .envs(env_vars);
403
404                if let Some(s) = &self.sandbox {
405                    let config = sandbox::SandboxConfig::new().with_network(if s.network {
406                        sandbox::NetworkConfig::allow_all()
407                    } else {
408                        sandbox::NetworkConfig::default()
409                    });
410                    sandbox_cmd = sandbox_cmd.config(config);
411                    for path in &s.fs_read {
412                        sandbox_cmd = sandbox_cmd.read_path(path);
413                    }
414                    for path in &s.fs_write {
415                        sandbox_cmd = sandbox_cmd.write_path(path);
416                    }
417                }
418                sandbox_cmd.run()?
419            } else {
420                Command::new("sh")
421                    .arg("-c")
422                    .arg(cmd)
423                    .env("INSTALL_DIR", &self.install_dir)
424                    .env("BIN_DIR", &bin_dir)
425                    .env("PKG_NAME", &self.package.pkg_name)
426                    .env("PKG_ID", &self.package.pkg_id)
427                    .env("PKG_VERSION", &self.package.version)
428                    .env("NPROC", &nproc)
429                    .current_dir(&self.install_dir)
430                    .status()
431                    .with_context(|| format!("executing build command {}", i + 1))?
432            };
433
434            if !status.success() {
435                return Err(SoarError::Custom(format!(
436                    "Build command {} failed with exit code: {}",
437                    i + 1,
438                    status.code().unwrap_or(-1)
439                )));
440            }
441        }
442
443        debug!("build completed successfully");
444        Ok(())
445    }
446
447    fn write_marker(&self) -> SoarResult<()> {
448        fs::create_dir_all(&self.install_dir).with_context(|| {
449            format!("creating install directory {}", self.install_dir.display())
450        })?;
451
452        let marker = InstallMarker {
453            pkg_id: self.package.pkg_id.clone(),
454            version: self.package.version.clone(),
455            bsum: self.package.bsum.clone(),
456        };
457
458        let marker_path = self.install_dir.join(INSTALL_MARKER_FILE);
459        let mut file = fs::File::create(&marker_path)
460            .with_context(|| format!("creating marker file {}", marker_path.display()))?;
461        let content = serde_json::to_string(&marker)
462            .map_err(|e| SoarError::Custom(format!("Failed to serialize marker: {e}")))?;
463        file.write_all(content.as_bytes())
464            .with_context(|| format!("writing marker file {}", marker_path.display()))?;
465
466        Ok(())
467    }
468
469    fn remove_marker(&self) -> SoarResult<()> {
470        let marker_path = self.install_dir.join(INSTALL_MARKER_FILE);
471        if marker_path.exists() {
472            fs::remove_file(&marker_path)
473                .with_context(|| format!("removing marker file {}", marker_path.display()))?;
474        }
475        Ok(())
476    }
477
478    pub async fn download_package(&self) -> SoarResult<Option<String>> {
479        debug!(
480            pkg_name = self.package.pkg_name,
481            pkg_id = self.package.pkg_id,
482            "starting package download"
483        );
484        self.write_marker()?;
485
486        let package = &self.package;
487        let output_path = self.install_dir.join(&package.pkg_name);
488
489        // fallback to download_url for repositories without ghcr
490        let (url, output_path) = if let Some(ref ghcr_pkg) = self.package.ghcr_pkg {
491            debug!("source: {} (OCI)", ghcr_pkg);
492            (ghcr_pkg, &self.install_dir)
493        } else {
494            debug!("source: {}", self.package.download_url);
495            (&self.package.download_url, &output_path.to_path_buf())
496        };
497
498        if self.package.ghcr_pkg.is_some() {
499            trace!(url = url.as_str(), "using OCI/GHCR download");
500            let mut dl = OciDownload::new(url.as_str())
501                .output(output_path.to_string_lossy())
502                .parallel(self.config.ghcr_concurrency.unwrap_or(8))
503                .overwrite(OverwriteMode::Skip);
504
505            if let Some(ref cb) = self.progress_callback {
506                let cb = cb.clone();
507                dl = dl.progress(move |p| {
508                    cb(p);
509                });
510            }
511
512            if !self.globs.is_empty() {
513                dl = dl.filter(Filter {
514                    globs: self.globs.clone(),
515                    ..Default::default()
516                });
517            }
518
519            let mut retries = 0;
520            let mut last_error: Option<DownloadError> = None;
521            loop {
522                if retries > 5 {
523                    if let Some(ref callback) = self.progress_callback {
524                        callback(Progress::Aborted);
525                    }
526                    // Return error after max retries
527                    return Err(last_error.unwrap_or_else(|| {
528                        DownloadError::Multiple {
529                            errors: vec!["Download failed after 5 retries".into()],
530                        }
531                    }))?;
532                }
533                match dl.clone().execute() {
534                    Ok(_) => {
535                        debug!("OCI download completed successfully");
536                        break;
537                    }
538                    Err(err) => {
539                        if matches!(
540                            err,
541                            DownloadError::HttpError {
542                                status: 429,
543                                ..
544                            } | DownloadError::Network(_)
545                        ) {
546                            warn!(retry = retries, "download failed, retrying after delay");
547                            sleep(Duration::from_secs(5));
548                            retries += 1;
549                            if retries > 1 {
550                                if let Some(ref callback) = self.progress_callback {
551                                    callback(Progress::Error);
552                                }
553                            }
554                            last_error = Some(err);
555                        } else {
556                            return Err(err)?;
557                        }
558                    }
559                }
560            }
561
562            // Run post_download hook for OCI packages
563            // For OCI packages, content is directly placed, so post_extract also applies
564            self.run_post_download_hook()?;
565            self.run_post_extract_hook()?;
566            self.run_build()?;
567
568            Ok(None)
569        } else {
570            trace!(url = url.as_str(), "using direct download");
571            let extract_dir = get_extract_dir(&self.install_dir);
572
573            let should_extract = self
574                .package
575                .pkg_type
576                .as_deref()
577                .is_some_and(|t| t == "archive");
578
579            let mut dl = Download::new(url.as_str())
580                .output(output_path.to_string_lossy())
581                .overwrite(OverwriteMode::Skip)
582                .extract(should_extract)
583                .extract_to(&extract_dir);
584
585            if let Some(ref cb) = self.progress_callback {
586                let cb = cb.clone();
587                dl = dl.progress(move |p| {
588                    cb(p);
589                });
590            }
591
592            let file_path = dl.execute()?;
593
594            self.run_post_download_hook()?;
595
596            let checksum = if PathBuf::from(&file_path).exists() {
597                Some(calculate_checksum(&file_path)?)
598            } else {
599                None
600            };
601
602            let extract_path = PathBuf::from(&extract_dir);
603            if extract_path.exists() {
604                fs::remove_file(file_path).ok();
605
606                for entry in fs::read_dir(&extract_path)
607                    .with_context(|| format!("reading {} directory", extract_path.display()))?
608                {
609                    let entry = entry.with_context(|| {
610                        format!("reading entry from directory {}", extract_path.display())
611                    })?;
612                    let from = entry.path();
613                    let to = self.install_dir.join(entry.file_name());
614                    fs::rename(&from, &to).with_context(|| {
615                        format!("renaming {} to {}", from.display(), to.display())
616                    })?;
617                }
618
619                fs::remove_dir_all(&extract_path).ok();
620            }
621
622            // Handle extract_root: move contents from subdirectory to install root
623            if let Some(ref root_dir) = self.extract_root {
624                let root_dir = substitute_placeholders(
625                    root_dir,
626                    Some(&self.package.version),
627                    self.arch_map.as_ref(),
628                );
629                let root_path =
630                    validate_path_containment(&self.install_dir, &root_dir, "extract_root")?;
631
632                if root_path.is_dir() {
633                    debug!(
634                        "applying extract_root: moving contents from {} to {}",
635                        root_path.display(),
636                        self.install_dir.display()
637                    );
638                    // Move all contents from root_path to install_dir
639                    for entry in fs::read_dir(&root_path).with_context(|| {
640                        format!("reading extract_root directory {}", root_path.display())
641                    })? {
642                        let entry = entry.with_context(|| {
643                            format!("reading entry from directory {}", root_path.display())
644                        })?;
645                        let from = entry.path();
646                        let to = self.install_dir.join(entry.file_name());
647                        if to.exists() {
648                            if to.is_dir() {
649                                fs::remove_dir_all(&to).ok();
650                            } else {
651                                fs::remove_file(&to).ok();
652                            }
653                        }
654                        fs::rename(&from, &to).with_context(|| {
655                            format!("moving {} to {}", from.display(), to.display())
656                        })?;
657                    }
658                    fs::remove_dir_all(&root_path).ok();
659                } else {
660                    warn!("extract_root '{}' not found in package", root_dir);
661                }
662            }
663
664            // Handle nested_extract: extract an archive within the package
665            if let Some(ref nested_archive) = self.nested_extract {
666                let nested_archive = substitute_placeholders(
667                    nested_archive,
668                    Some(&self.package.version),
669                    self.arch_map.as_ref(),
670                );
671                let archive_path = validate_path_containment(
672                    &self.install_dir,
673                    &nested_archive,
674                    "nested_extract",
675                )?;
676
677                if archive_path.is_file() {
678                    debug!("extracting nested archive: {}", archive_path.display());
679                    let nested_extract_dir = get_extract_dir(&self.install_dir);
680
681                    compak::extract_archive(&archive_path, &nested_extract_dir).map_err(|e| {
682                        SoarError::Custom(format!(
683                            "Failed to extract nested archive {}: {}",
684                            archive_path.display(),
685                            e
686                        ))
687                    })?;
688
689                    fs::remove_file(&archive_path).ok();
690
691                    // Move extracted contents to install_dir
692                    let nested_extract_path = PathBuf::from(&nested_extract_dir);
693                    if nested_extract_path.exists() {
694                        for entry in fs::read_dir(&nested_extract_path).with_context(|| {
695                            format!(
696                                "reading nested extract directory {}",
697                                nested_extract_path.display()
698                            )
699                        })? {
700                            let entry = entry.with_context(|| {
701                                format!(
702                                    "reading entry from directory {}",
703                                    nested_extract_path.display()
704                                )
705                            })?;
706                            let from = entry.path();
707                            let to = self.install_dir.join(entry.file_name());
708                            if to.exists() {
709                                if to.is_dir() {
710                                    fs::remove_dir_all(&to).ok();
711                                } else {
712                                    fs::remove_file(&to).ok();
713                                }
714                            }
715                            fs::rename(&from, &to).with_context(|| {
716                                format!("moving {} to {}", from.display(), to.display())
717                            })?;
718                        }
719                        fs::remove_dir_all(&nested_extract_path).ok();
720                    }
721                } else {
722                    warn!(
723                        "nested_extract archive '{}' not found in package",
724                        nested_archive
725                    );
726                }
727            }
728
729            self.run_post_extract_hook()?;
730            self.run_build()?;
731
732            Ok(checksum)
733        }
734    }
735
736    pub async fn record(
737        &self,
738        unlinked: bool,
739        portable: Option<&str>,
740        portable_home: Option<&str>,
741        portable_config: Option<&str>,
742        portable_share: Option<&str>,
743        portable_cache: Option<&str>,
744    ) -> SoarResult<()> {
745        debug!(
746            pkg_name = self.package.pkg_name,
747            pkg_id = self.package.pkg_id,
748            unlinked = unlinked,
749            "recording installation"
750        );
751        let package = &self.package;
752        let repo_name = &package.repo_name;
753        let pkg_name = &package.pkg_name;
754        let pkg_id = &package.pkg_id;
755        let version = &package.version;
756        let size = package.ghcr_size.unwrap_or(package.size.unwrap_or(0)) as i64;
757        let checksum = package.bsum.as_deref();
758        let provides = package.provides.clone();
759
760        let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
761
762        let installed_path = self.install_dir.to_string_lossy();
763        let record_id: Option<i32> = self.db.with_conn(|conn| {
764            CoreRepository::record_installation(
765                conn,
766                repo_name,
767                pkg_name,
768                pkg_id,
769                version,
770                size,
771                provides,
772                checksum,
773                &installed_date,
774                &installed_path,
775            )
776        })?;
777
778        let record_id = record_id.ok_or_else(|| {
779            SoarError::Custom(format!(
780                "Failed to record installation for {}#{}: package not found in database",
781                pkg_name, pkg_id
782            ))
783        })?;
784
785        if portable.is_some()
786            || portable_home.is_some()
787            || portable_config.is_some()
788            || portable_share.is_some()
789            || portable_cache.is_some()
790        {
791            let base_dir = env::current_dir()
792                .map_err(|_| SoarError::Custom("Error retrieving current directory".into()))?;
793
794            let resolve_path = |opt: Option<&str>| -> Option<String> {
795                opt.map(|p| {
796                    if p.is_empty() {
797                        String::new()
798                    } else {
799                        let path = PathBuf::from(p);
800                        let absolute = if path.is_absolute() {
801                            path
802                        } else {
803                            base_dir.join(path)
804                        };
805                        absolute.to_string_lossy().into_owned()
806                    }
807                })
808            };
809
810            let portable_path = resolve_path(portable);
811            let portable_home = resolve_path(portable_home);
812            let portable_config = resolve_path(portable_config);
813            let portable_share = resolve_path(portable_share);
814            let portable_cache = resolve_path(portable_cache);
815
816            self.db.with_conn(|conn| {
817                CoreRepository::upsert_portable(
818                    conn,
819                    record_id,
820                    portable_path.as_deref(),
821                    portable_home.as_deref(),
822                    portable_config.as_deref(),
823                    portable_share.as_deref(),
824                    portable_cache.as_deref(),
825                )
826            })?;
827        }
828
829        if !unlinked {
830            self.db
831                .with_conn(|conn| CoreRepository::unlink_others(conn, pkg_name, pkg_id, version))?;
832
833            let alternate_packages: Vec<InstalledPackageWithPortable> =
834                self.db.with_conn(|conn| {
835                    CoreRepository::find_alternates(conn, pkg_name, pkg_id, version)
836                })?;
837
838            for alt_pkg in alternate_packages {
839                let installed_path = PathBuf::from(&alt_pkg.installed_path);
840
841                let mut remove_action = |path: &Path| -> FileSystemResult<()> {
842                    if let Ok(real_path) = fs::read_link(path) {
843                        if real_path.parent() == Some(&installed_path) {
844                            safe_remove(path)?;
845                        }
846                    }
847                    Ok(())
848                };
849                walk_dir(&self.config.get_desktop_path()?, &mut remove_action)?;
850
851                let mut remove_action = |path: &Path| -> FileSystemResult<()> {
852                    if let Ok(real_path) = fs::read_link(path) {
853                        if real_path.parent() == Some(&installed_path) {
854                            safe_remove(path)?;
855                        }
856                    }
857                    Ok(())
858                };
859                walk_dir(self.config.get_icons_path(), &mut remove_action)?;
860
861                if let Some(ref provides) = alt_pkg.provides {
862                    let bin_path = self.config.get_bin_path()?;
863                    for provide in provides {
864                        for name in provide.bin_symlink_names() {
865                            let link = bin_path.join(name);
866                            if link.is_symlink() || link.is_file() {
867                                std::fs::remove_file(&link).with_context(|| {
868                                    format!("removing provide {}", link.display())
869                                })?;
870                            }
871                        }
872                    }
873                }
874            }
875        }
876
877        self.remove_marker()?;
878
879        Ok(())
880    }
881}