Skip to main content

zoi_install/
installer.rs

1//! Main installer logic.
2
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Result, anyhow};
8use colored::Colorize;
9use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
10use zoi_core::{cache, config, pgp, pkgdir, recorder, types};
11use zoi_db as db;
12use zoi_hooks as hooks;
13use zoi_resolver::local;
14
15use crate::resolver::InstallNode;
16use crate::{manifest, plan, prebuilt, util};
17
18/// Downloads and caches a package archive.
19///
20/// This function handles:
21/// - Checking pkg-dirs and the archive cache.
22/// - Downloading from mirrors if not found locally.
23/// - Verifying hashes and PGP signatures.
24///
25/// # Errors
26///
27/// Returns an error if:
28/// - The configuration cannot be read.
29/// - The archive cache directory cannot be created.
30/// - Zoi is offline and the archive is missing.
31/// - The download fails.
32/// - Hash verification fails.
33/// - Signature verification fails.
34pub fn download_and_cache_archive(
35    node: &InstallNode,
36    details: &plan::PrebuiltDetails,
37    pb: Option<&ProgressBar>,
38    verbose: bool
39) -> Result<PathBuf> {
40    let config = config::read_config()?;
41    let signature_policy =
42        config.policy.signature_enforcement.filter(|p| p.enable);
43
44    let archive_cache_root = cache::get_archive_cache_root()?;
45    fs::create_dir_all(&archive_cache_root)?;
46
47    let archive_filename = details
48        .info
49        .final_url
50        .split('/')
51        .next_back()
52        .unwrap_or("archive.zpa");
53    let cached_archive_path = archive_cache_root.join(archive_filename);
54    let sig_filename = format!("{archive_filename}.sig");
55    let cached_sig_path = archive_cache_root.join(&sig_filename);
56
57    let archive_path = if let Some(path) =
58        pkgdir::find_in_pkg_dirs(archive_filename)
59    {
60        if pb.is_none() {
61            println!("Found archive in pkg-dir: {}", path.display());
62        }
63        path
64    } else if cached_archive_path.exists() {
65        if pb.is_none() {
66            println!("Using cached archive: {}", cached_archive_path.display());
67        }
68        cached_archive_path.clone()
69    } else {
70        if zoi_core::offline::is_offline() {
71            return Err(anyhow!(
72                "Archive not found in cache and cannot download: Zoi is in \
73                 offline mode. Missing: {archive_filename}"
74            ));
75        }
76        let part_path =
77            archive_cache_root.join(format!("{archive_filename}.part"));
78
79        if part_path.exists() && pb.is_none() {
80            println!("Resuming partial download: {}", part_path.display());
81        }
82
83        let mut last_error = None;
84        let candidate_urls =
85            cache::mirror_candidate_urls(&details.info.final_url);
86        let mut downloaded = false;
87        for candidate_url in candidate_urls {
88            match util::download_file_with_progress(
89                &candidate_url,
90                &part_path,
91                pb,
92                Some(details.download_size)
93            ) {
94                Ok(()) => {
95                    downloaded = true;
96                    break;
97                }
98                Err(e) => last_error = Some((candidate_url, e))
99            }
100        }
101        if !downloaded {
102            let (url, error) = last_error.ok_or_else(|| {
103                anyhow!("archive download failed but no error recorded")
104            })?;
105            return Err(anyhow!(
106                "Failed to download package archive from {url}: {error}"
107            ));
108        }
109
110        fs::rename(&part_path, &cached_archive_path)?;
111        cached_archive_path.clone()
112    };
113
114    if let Some(hash_url) = &details.info.hash_url {
115        let hash = db::get_package_hash_from_db(
116            &node.registry_handle,
117            &node.pkg.name,
118            node.sub_package.as_deref(),
119            &node.pkg.repo
120        )
121        .unwrap_or(None)
122        .filter(|h| !h.is_empty())
123        .or_else(|| {
124            util::get_expected_hash(hash_url, Some(archive_filename)).ok()
125        });
126
127        if let Some(ref hash) = hash
128            && !util::verify_file_hash(&archive_path, hash, pb)?
129        {
130            return Err(anyhow!("Hash verification failed"));
131        }
132    }
133
134    let authorities = config
135        .default_registry
136        .as_ref()
137        .filter(|r| r.handle == node.registry_handle)
138        .and_then(|r| r.authorities.as_ref())
139        .or_else(|| {
140            config
141                .added_registries
142                .iter()
143                .find(|r| r.handle == node.registry_handle)
144                .and_then(|r| r.authorities.as_ref())
145        });
146    let has_authorities = authorities.is_some_and(|a| !a.is_empty());
147    let pgp_identifiers: Option<Vec<String>> = signature_policy
148        .as_ref()
149        .map(|p| p.trusted_keys.clone())
150        .or_else(|| authorities.cloned());
151
152    if let Some(pgp_url) = &details.info.pgp_url {
153        if let Some(ref identifiers) = pgp_identifiers
154            && !identifiers.is_empty()
155        {
156            let sig_path = if cached_sig_path.exists() {
157                cached_sig_path.clone()
158            } else {
159                if zoi_core::offline::is_offline() {
160                    return Err(anyhow!(
161                        "Signature not found in cache and cannot download: \
162                         Zoi is in offline mode."
163                    ));
164                }
165                let temp_dir =
166                    tempfile::Builder::new().prefix("zoi-sig-dl-").tempdir()?;
167                let temp_sig_path = temp_dir.path().join(&sig_filename);
168                let mut last_error = None;
169                let mut downloaded = false;
170                for candidate_url in cache::mirror_candidate_urls(pgp_url) {
171                    match util::download_file_with_progress(
172                        &candidate_url,
173                        &temp_sig_path,
174                        pb,
175                        None
176                    ) {
177                        Ok(()) => {
178                            downloaded = true;
179                            break;
180                        }
181                        Err(e) => last_error = Some((candidate_url, e))
182                    }
183                }
184                if !downloaded {
185                    let (url, error) = last_error.ok_or_else(|| {
186                        anyhow!(
187                            "signature download failed but no error recorded"
188                        )
189                    })?;
190                    return Err(anyhow!(
191                        "Failed to download signature from {url}: {error}"
192                    ));
193                }
194                fs::copy(&temp_sig_path, &cached_sig_path)?;
195                cached_sig_path.clone()
196            };
197
198            if verbose {
199                println!("Verifying signature...");
200            }
201            let trusted_certs =
202                pgp::get_certs_by_name_or_fingerprint(identifiers)?;
203            pgp::verify_detached_signature_multi_key(
204                &archive_path,
205                &sig_path,
206                trusted_certs
207            )?;
208            if verbose {
209                println!("{}", "Signature verified successfully.".green());
210            }
211        }
212    } else if has_authorities {
213        let msg = format!(
214            "Warning: Installing unsigned package '{}' from a registry that \
215             claims to be secure.",
216            node.pkg.name
217        );
218        if let Some(p) = pb {
219            p.println(msg.yellow().to_string());
220        } else {
221            println!("{}", msg.yellow());
222        }
223        if signature_policy.is_some() {
224            return Err(anyhow!(
225                "Signature enforcement is active, but no PGP URL found for \
226                 package"
227            ));
228        }
229    }
230
231    Ok(archive_path)
232}
233
234/// Information about a package that has been prepared for installation.
235#[derive(Clone)]
236pub struct PreparedNode {
237    /// Path to the downloaded or built archive.
238    pub archive_path: PathBuf,
239    /// The method used for installation (e.g. "pre-compiled", "source").
240    pub install_method: String,
241    /// Whether the archive was built from source.
242    pub is_build: bool
243}
244
245/// Performs the non-destructive first phase of installation: "Preparation".
246///
247/// Preparation includes:
248/// - Downloading pre-built archives from the registry.
249/// - Verifying checksums and PGP signatures (Root of Trust).
250/// - Or, building the package from source in a temporary sandbox if requested.
251///
252/// This phase always runs in user-space and does not modify the system state
253/// or the package store.
254///
255/// # Errors
256///
257/// Returns an error if:
258/// - The archive cannot be downloaded or built.
259/// - The progress bar style cannot be created.
260pub fn prepare_node(
261    node: &InstallNode,
262    action: &plan::InstallAction,
263    m: Option<&MultiProgress>,
264    build_type: Option<&str>,
265    verbose: bool
266) -> Result<PreparedNode> {
267    let pkg = &node.pkg;
268    let version = &node.version;
269
270    let pb_style = ProgressStyle::default_bar()
271        .template(
272            "{spinner:.green} {msg:30.cyan} [{bar:40.cyan/blue}] {percent}%"
273        )?
274        .progress_chars("#>-");
275
276    let spinner_style = ProgressStyle::default_spinner()
277        .template("{spinner:.green} {msg:30.cyan}")?
278        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ");
279
280    let display_name = if let Some(sub) = &node.sub_package {
281        format!("{}:{}", pkg.name, sub)
282    } else {
283        pkg.name.clone()
284    };
285    let version_display = if node.revision == "1" {
286        version.clone()
287    } else {
288        format!("{}-{}", version, node.revision)
289    };
290    let message = format!("zoi:{display_name}@{version_display}");
291
292    let pb = if let Some(m_inner) = m {
293        let pb = m_inner.add(ProgressBar::new(100));
294        pb.set_style(pb_style);
295        pb.set_message(message.clone());
296        Some(pb)
297    } else {
298        None
299    };
300
301    let (archive_path, install_method, is_build) = match action {
302        plan::InstallAction::DownloadAndInstall(details) => {
303            if let Some(p) = &pb {
304                p.set_message("Downloading package...");
305            }
306            let archive_path = download_and_cache_archive(
307                node,
308                details,
309                pb.as_ref(),
310                verbose
311            )?;
312            (archive_path, "pre-compiled".to_string(), false)
313        }
314        plan::InstallAction::InstallFromArchive(archive_path) => {
315            if archive_path.to_string_lossy().ends_with(".zsa") {
316                if let Some(p) = &pb {
317                    p.set_style(spinner_style);
318                    p.enable_steady_tick(std::time::Duration::from_millis(100));
319                    p.set_message(format!("Building {display_name}..."));
320                }
321                let archive_path = prebuilt::build_archive(
322                    archive_path,
323                    pkg,
324                    node.sub_package.as_deref(),
325                    build_type,
326                    pb.as_ref(),
327                    !verbose
328                )?;
329                match archive_path {
330                    Some(path) => (path, "source".to_string(), true),
331                    None => (PathBuf::new(), "meta".to_string(), false)
332                }
333            } else {
334                if let Some(p) = &pb {
335                    p.set_message("Using local archive...");
336                    p.finish();
337                }
338                (archive_path.clone(), "pre-compiled".to_string(), false)
339            }
340        }
341        plan::InstallAction::BuildAndInstall => {
342            if let Some(p) = &pb {
343                p.set_style(spinner_style);
344                p.enable_steady_tick(std::time::Duration::from_millis(100));
345                p.set_message(format!("Building {display_name}..."));
346            }
347            let pkg_lua_path = Path::new(&node.source);
348            let archive_path = prebuilt::build_archive(
349                pkg_lua_path,
350                pkg,
351                node.sub_package.as_deref(),
352                build_type,
353                pb.as_ref(),
354                !verbose
355            )?;
356
357            match archive_path {
358                Some(path) => (path, "source".to_string(), true),
359                None => (PathBuf::new(), "meta".to_string(), false)
360            }
361        }
362    };
363
364    if let Some(p) = pb {
365        p.finish_and_clear();
366    }
367
368    Ok(PreparedNode {
369        archive_path,
370        install_method,
371        is_build
372    })
373}
374
375/// Performs the destructive second phase of installation: "Execution".
376///
377/// This phase takes a `PreparedNode` and:
378/// - Unpacks the archive into the versioned store directory.
379/// - Creates binary shims in the global Zoi `bin` directory.
380/// - Registers the installation in the registry database and lockfile.
381///
382/// Just-in-Time Escalation: If the target scope is `system`, this function
383/// will spawn a privileged sub-process (`sudo zoi helper elevate-install-node`)
384/// to perform the final file moves, keeping the main CLI unprivileged.
385///
386/// # Errors
387///
388/// Returns an error if:
389/// - Hooks fail to run.
390/// - Privilege escalation fails.
391/// - The archive cannot be unpacked.
392/// - The manifest cannot be created or written.
393/// - The package cannot be recorded in the database.
394pub fn install_prepared_node(
395    node: &InstallNode,
396    prepared: &PreparedNode,
397    m: Option<&MultiProgress>,
398    yes: bool,
399    record: bool,
400    link_bins: bool,
401    _verbose: bool
402) -> Result<types::InstallManifest> {
403    let pkg = &node.pkg;
404    let version = &node.version;
405    let handle = &node.registry_handle;
406    let is_direct = matches!(node.reason, types::InstallReason::Direct);
407
408    let pb_style = ProgressStyle::default_spinner()
409        .template("{spinner:.green} {msg:30.cyan}")?
410        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ");
411
412    let main_pb = if let Some(m_inner) = m {
413        if is_direct {
414            None
415        } else {
416            let pb = m_inner.add(ProgressBar::new_spinner());
417            pb.set_style(pb_style.clone());
418            let name = if let Some(sub) = &node.sub_package {
419                format!("{}:{}", pkg.name, sub)
420            } else {
421                pkg.name.clone()
422            };
423            let version_display = if node.revision == "1" {
424                version.clone()
425            } else {
426                format!("{}-{}", version, node.revision)
427            };
428            pb.set_message(format!("zoi:{name}@{version_display}"));
429            pb.enable_steady_tick(std::time::Duration::from_millis(100));
430            Some(pb)
431        }
432    } else {
433        None
434    };
435
436    let step_pb = if is_direct && let Some(m_inner) = m {
437        let pb = m_inner.add(ProgressBar::new_spinner());
438        pb.set_style(pb_style);
439        pb.enable_steady_tick(std::time::Duration::from_millis(100));
440        Some(pb)
441    } else {
442        None
443    };
444
445    if let Some(hooks) = &pkg.hooks {
446        if let Some(pb) = &step_pb {
447            pb.set_message("Running pre-install hooks...");
448        }
449        hooks::run_hooks(hooks, hooks::HookType::PreInstall, pkg.scope)?;
450    }
451
452    let sub_package_to_install = node.sub_package.clone();
453    let sub_packages_vec = sub_package_to_install.clone().map(|s| vec![s]);
454
455    let archive_path = &prepared.archive_path;
456    let install_method = &prepared.install_method;
457
458    let needs_escalation =
459        pkg.scope == types::Scope::System && !zoi_core::utils::is_admin();
460
461    let install_manifest = if needs_escalation {
462        let escalator =
463            zoi_core::utils::get_privilege_escalator().ok_or_else(|| {
464                anyhow!(
465                    "Root privileges required for system scope installation, \
466                     but neither 'sudo' nor 'doas' was found."
467                )
468            })?;
469
470        if let Some(pb) = step_pb.as_ref().or(main_pb.as_ref()) {
471            pb.set_message(format!(
472                "Waiting for {escalator} privileges to install system \
473                 package..."
474            ));
475        }
476
477        let node_json = serde_json::to_string(node)?;
478        let mut temp_file = tempfile::NamedTempFile::new()?;
479        temp_file.write_all(node_json.as_bytes())?;
480        let temp_path = temp_file.path();
481
482        let mut cmd = std::process::Command::new(escalator);
483        cmd.arg(std::env::current_exe()?);
484        cmd.arg("helper").arg("elevate-install-node");
485        cmd.arg("--node-json").arg(temp_path);
486        cmd.arg("--archive").arg(archive_path);
487        cmd.arg("--install-method").arg(install_method);
488        if yes {
489            cmd.arg("--yes");
490        }
491        if link_bins {
492            cmd.arg("--link-bins");
493        }
494
495        let status = cmd
496            .status()
497            .map_err(|e| anyhow!("Failed to spawn privilege escalator: {e}"))?;
498        if !status.success() {
499            return Err(anyhow!("Escalated installation failed."));
500        }
501
502        let version_dir = local::get_package_version_dir(
503            pkg.scope,
504            &node.registry_handle,
505            &pkg.repo,
506            &pkg.name,
507            &node.version
508        )?;
509        let manifest_filename = if let Some(sub) = &node.sub_package {
510            format!("manifest-{sub}.yaml")
511        } else {
512            "manifest.yaml".to_string()
513        };
514        let manifest_path = version_dir.join(manifest_filename);
515        let content = std::fs::read_to_string(&manifest_path)?;
516        let install_manifest: types::InstallManifest =
517            serde_yaml::from_str(&content)?;
518
519        install_manifest
520    } else {
521        if let Some(pb) = step_pb.as_ref().or(main_pb.as_ref()) {
522            pb.set_message(format!("Installing {}...", pkg.name.cyan()));
523        }
524
525        let installed_files = crate::pkg_install::run(
526            archive_path,
527            Some(pkg.scope),
528            &node.registry_handle,
529            Some(&node.version),
530            yes,
531            sub_packages_vec,
532            link_bins,
533            step_pb.as_ref().or(main_pb.as_ref())
534        )?;
535
536        if let types::InstallReason::Dependency { ref parent } = node.reason {
537            let package_dir = local::get_package_dir(
538                pkg.scope, handle, &pkg.repo, &pkg.name
539            )?;
540            local::add_dependent(&package_dir, parent)?;
541        }
542
543        let install_manifest = manifest::create_manifest(
544            pkg,
545            node.reason.clone(),
546            node.dependencies.clone(),
547            Some(install_method.clone()),
548            installed_files,
549            handle,
550            node.repo_type.clone(),
551            &node.chosen_options,
552            &node.chosen_optionals,
553            sub_package_to_install.clone()
554        )?;
555
556        if record {
557            local::write_manifest(&install_manifest)?;
558            local::persist_package_source(
559                &install_manifest,
560                Path::new(&node.source)
561            )?;
562        }
563
564        install_manifest
565    };
566
567    if prepared.is_build {
568        let _ = fs::remove_file(archive_path);
569    }
570
571    if record {
572        if let Ok(conn) = db::open_connection("local")
573            && let Ok(pkg_id) = db::update_package(
574                &conn,
575                pkg,
576                handle,
577                Some(pkg.scope),
578                sub_package_to_install.as_deref(),
579                Some(&node.reason)
580            )
581        {
582            let _ = db::clear_package_files(&conn, pkg_id);
583            let _ = db::index_package_files(
584                &conn,
585                pkg_id,
586                &install_manifest.installed_files
587            );
588        }
589
590        if let Err(e) = recorder::record_package(
591            pkg,
592            &node.reason,
593            &node.dependencies,
594            handle,
595            &node.repo_type,
596            &node.chosen_options,
597            &node.chosen_optionals,
598            sub_package_to_install.as_deref()
599        ) {
600            eprintln!(
601                "Warning: failed to record package installation for '{}': {}",
602                pkg.name, e
603            );
604        }
605    }
606
607    if let Some(hooks) = &pkg.hooks {
608        if let Some(pb) = &step_pb {
609            pb.set_message("Running post-install hooks...");
610        }
611        hooks::run_hooks(hooks, hooks::HookType::PostInstall, pkg.scope)?;
612    }
613
614    if let Some(pb) = main_pb {
615        pb.finish();
616    }
617    if let Some(pb) = step_pb {
618        pb.finish();
619    }
620
621    util::send_telemetry("install", pkg, handle, Some(install_method));
622
623    Ok(install_manifest)
624}
625
626/// Performs both preparation and execution phases for an install node.
627///
628/// # Errors
629///
630/// Returns an error if preparation or execution fails.
631pub fn install_node(
632    node: &InstallNode,
633    action: &plan::InstallAction,
634    m: Option<&MultiProgress>,
635    build_type: Option<&str>,
636    yes: bool,
637    record: bool,
638    link_bins: bool,
639    verbose: bool
640) -> Result<types::InstallManifest> {
641    let prepared = prepare_node(node, action, m, build_type, verbose)?;
642    install_prepared_node(node, &prepared, m, yes, record, link_bins, verbose)
643}