Skip to main content

zoi_install/
installer.rs

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