1use 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
18pub 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#[derive(Clone)]
236pub struct PreparedNode {
237 pub archive_path: PathBuf,
239 pub install_method: String,
241 pub is_build: bool
243}
244
245pub 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
375pub 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
626pub 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}