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
205pub 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
322pub 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}