1use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10
11use anyhow::{Result, anyhow};
12use colored::Colorize;
13use git2::build::{CheckoutBuilder, RepoBuilder};
14use git2::{FetchOptions, RemoteCallbacks, Repository, ResetType};
15use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
16use rayon::prelude::*;
17use tempfile::Builder;
18use walkdir::WalkDir;
19use zoi_core::{config, offline, pgp, types, utils as core_utils};
20use zoi_db as db;
21use zoi_install::util as install_util;
22use zoi_lua::parser as lua_parser;
23
24fn refresh_registry_db(
34 registry_handle: &str,
35 registry_path: &Path,
36 m: Option<&MultiProgress>,
37 verbose: bool,
38 pb: Option<&ProgressBar>
39) -> Result<()> {
40 if verbose {
41 let msg = format!(
42 "Refreshing metadata database for {}...",
43 registry_handle.cyan()
44 );
45 if let Some(m_ref) = m {
46 let _ = m_ref.println(&msg);
47 } else {
48 println!("{msg}");
49 }
50 }
51
52 let mut conn = db::open_connection(registry_handle)?;
53 db::clear_registry(&conn)?;
54
55 let mut pkg_files = Vec::new();
56 let mut sec_files = Vec::new();
57 for entry in WalkDir::new(registry_path)
58 .into_iter()
59 .filter_map(std::result::Result::ok)
60 {
61 if entry.file_type().is_file() {
62 let name = entry.file_name().to_string_lossy();
63 if name.ends_with(".pkg.lua") {
64 pkg_files.push(entry.path().to_path_buf());
65 } else if name.ends_with(".sec.yaml") {
66 sec_files.push(entry.path().to_path_buf());
67 }
68 }
69 }
70
71 let repo_config = config::read_repo_config(registry_path).ok();
72 let _advisory_prefix = repo_config
73 .as_ref()
74 .and_then(|rc| rc.advisory_prefix.clone());
75 let platform = core_utils::get_platform().unwrap_or_default();
76
77 let has_size_tpl = repo_config
78 .as_ref()
79 .and_then(|rc| rc.pkg.iter().find(|p| p.link_type == "main"))
80 .and_then(|p| p.size.as_ref())
81 .is_some();
82
83 let has_files_tpl = repo_config
84 .as_ref()
85 .and_then(|rc| rc.pkg.iter().find(|p| p.link_type == "main"))
86 .and_then(|p| p.files.as_ref())
87 .is_some();
88
89 let client = if has_size_tpl || has_files_tpl {
90 core_utils::get_http_client().ok()
91 } else {
92 None
93 };
94
95 if let Some(p) = pb {
96 p.set_length(pkg_files.len() as u64);
97 p.set_position(0);
98 p.set_message(format!("Indexing {}", registry_handle.cyan()));
99 }
100
101 let parsed_results: Vec<(
102 types::Package,
103 PathBuf,
104 Option<Vec<String>>,
105 Option<(u64, u64)>,
106 Option<String>
107 )> = pkg_files
108 .par_iter()
109 .filter_map(|path| {
110 if let Some(p) = pb {
111 p.inc(1);
112 }
113 let path_str = path.to_string_lossy();
114 if let Ok(mut pkg) =
115 lua_parser::parse_lua_package(&path_str, None, None, true)
116 {
117 if pkg.repo.is_empty()
118 && let Ok(rel_path) = path.strip_prefix(registry_path)
119 && let Some(parent) = rel_path.parent()
120 {
121 let mut repo_path =
122 parent.to_string_lossy().to_string().replace('\\', "/");
123 let pkg_name_suffix = format!("/{}", pkg.name);
124 if repo_path.ends_with(&pkg_name_suffix) {
125 repo_path = repo_path
126 [..repo_path.len() - pkg_name_suffix.len()]
127 .to_string();
128 } else if repo_path == pkg.name {
129 repo_path = String::new();
130 }
131 pkg.repo = repo_path;
132 }
133
134 let mut file_list = None;
135 if let Some(c) = &client
136 && let Some(rc) = &repo_config
137 && let Some(pkg_link) =
138 rc.pkg.iter().find(|p| p.link_type == "main")
139 && let Some(files_url_template) = &pkg_link.files
140 {
141 let version = pkg
142 .version
143 .clone()
144 .unwrap_or_else(|| "latest".to_string());
145 let files_url = install_util::resolve_url_placeholders(
146 files_url_template,
147 &pkg.name,
148 &pkg.repo,
149 &version,
150 &platform
151 );
152
153 if let Ok(response) = c.get(&files_url).send()
154 && response.status().is_success()
155 && let Ok(content) = response.text()
156 {
157 file_list = Some(
158 content
159 .lines()
160 .map(|l| l.trim().to_string())
161 .filter(|l| !l.is_empty())
162 .collect()
163 );
164 }
165 }
166
167 let mut size_info = None;
168 if let Some(c) = &client
169 && let Some(rc) = &repo_config
170 && let Some(pkg_link) =
171 rc.pkg.iter().find(|p| p.link_type == "main")
172 && let Some(size_url_template) = &pkg_link.size
173 {
174 let version = pkg
175 .version
176 .clone()
177 .unwrap_or_else(|| "latest".to_string());
178 let size_url = install_util::resolve_url_placeholders(
179 size_url_template,
180 &pkg.name,
181 &pkg.repo,
182 &version,
183 &platform
184 );
185
186 if let Ok(response) = c.get(&size_url).send()
187 && response.status().is_success()
188 && let Ok(content) = response.text()
189 {
190 let mut download_size = 0u64;
191 let mut installed_size = 0u64;
192 for line in content.lines() {
193 if let Some((key, val)) = line.split_once(':')
194 && let Ok(num) = val.trim().parse::<u64>()
195 {
196 match key.trim() {
197 "down" => download_size = num,
198 "install" => installed_size = num,
199 _ => {}
200 }
201 }
202 }
203 if download_size > 0 || installed_size > 0 {
204 size_info = Some((download_size, installed_size));
205 }
206 }
207 }
208
209 let mut hash_info = None;
210 if let Some(c) = &client
211 && let Some(rc) = &repo_config
212 && let Some(pkg_link) =
213 rc.pkg.iter().find(|p| p.link_type == "main")
214 && let Some(hash_url_template) = &pkg_link.hash
215 {
216 let version = pkg
217 .version
218 .clone()
219 .unwrap_or_else(|| "latest".to_string());
220 let hash_url = install_util::resolve_url_placeholders(
221 hash_url_template,
222 &pkg.name,
223 &pkg.repo,
224 &version,
225 &platform
226 );
227
228 if let Ok(response) = c.get(&hash_url).send()
229 && response.status().is_success()
230 && let Ok(content) = response.text()
231 {
232 let is_valid_hash = |s: &str| {
233 let len = s.len();
234 (len == 128 || len == 64 || len == 32)
235 && s.chars().all(|c| c.is_ascii_hexdigit())
236 };
237 for word in content.split_whitespace() {
238 if is_valid_hash(word) {
239 hash_info = Some(word.to_string());
240 break;
241 }
242 }
243 }
244 }
245
246 Some((pkg, path.clone(), file_list, size_info, hash_info))
247 } else {
248 None
249 }
250 })
251 .collect();
252
253 let parsed_advisories: Vec<(types::Advisory, String)> = sec_files
254 .par_iter()
255 .filter_map(|path| {
256 if let Ok(content) = fs::read_to_string(path)
257 && let Ok(advisory) =
258 serde_yaml::from_str::<types::Advisory>(&content)
259 && let Ok(rel_path) = path.strip_prefix(registry_path)
260 && let Some(parent) = rel_path.parent()
261 {
262 let repo_path =
263 parent.to_string_lossy().to_string().replace('\\', "/");
264 return Some((advisory, repo_path));
265 }
266 None
267 })
268 .collect();
269
270 let tx = conn.transaction()?;
271
272 for (pkg, _path, file_list, size_info, hash_info) in parsed_results {
273 let pkg_id =
274 db::update_package(&tx, &pkg, registry_handle, None, None, None)?;
275
276 if let Some((down_size, install_size)) = size_info {
277 let _ = db::set_package_sizes(&tx, pkg_id, down_size, install_size);
278 }
279
280 if let Some(hash) = &hash_info {
281 let _ = db::set_package_hash(&tx, pkg_id, hash);
282 }
283
284 if let Some(subs) = &pkg.sub_packages {
285 for sub in subs {
286 if let Err(e) = db::update_package(
287 &tx,
288 &pkg,
289 registry_handle,
290 None,
291 Some(sub),
292 None
293 ) {
294 eprintln!(
295 "Warning: failed to sync sub-package '{}:{}': {}",
296 pkg.name, sub, e
297 );
298 } else if let Ok(sub_id) = db::get_package_id(
299 &tx,
300 &pkg.name,
301 Some(sub),
302 &pkg.repo,
303 registry_handle
304 ) {
305 if let Some((down_size, install_size)) = &size_info {
306 let _ = db::set_package_sizes(
307 &tx,
308 sub_id,
309 *down_size,
310 *install_size
311 );
312 }
313 if let Some(hash) = &hash_info {
314 let _ = db::set_package_hash(&tx, sub_id, hash);
315 }
316 }
317 }
318 }
319
320 if let Some(list) = file_list {
321 let _ = db::index_package_files(&tx, pkg_id, &list);
322 }
323 }
324
325 for (advisory, repo) in parsed_advisories {
326 let _ = db::update_advisory(&tx, &advisory, &repo, registry_handle);
327 }
328
329 tx.commit()?;
330
331 if let Some(p) = pb {
332 p.finish_and_clear();
333 }
334
335 Ok(())
336}
337
338fn verify_registry_signature(
343 repo_path: &Path,
344 authorities: &[String],
345 verbose: bool
346) -> Result<()> {
347 if authorities.is_empty() {
348 return Ok(());
349 }
350
351 if verbose {
352 println!("Verifying registry signature...");
353 }
354
355 let repo = Repository::open(repo_path)
356 .map_err(|e| anyhow!("Failed to open registry repository: {e}"))?;
357 let head = repo
358 .head()
359 .map_err(|e| anyhow!("Failed to get repository HEAD: {e}"))?;
360 let target = head
361 .target()
362 .ok_or_else(|| anyhow!("HEAD is not a direct reference"))?;
363 let commit = repo
364 .find_commit(target)
365 .map_err(|e| anyhow!("Failed to find HEAD commit: {e}"))?;
366
367 let (sig, data) =
368 repo.extract_signature(&commit.id(), None).map_err(|_| {
369 anyhow!("Registry commit is not signed. Sync aborted for security.")
370 })?;
371
372 let sig_bytes = &*sig;
373 let data_bytes = &*data;
374
375 let trusted_certs = pgp::get_certs_by_name_or_fingerprint(authorities)?;
376
377 let mut verified = false;
378 for cert in trusted_certs {
379 if pgp::verify_detached_signature_raw(data_bytes, sig_bytes, &cert)
380 .is_ok()
381 {
382 verified = true;
383 break;
384 }
385 }
386
387 if verified {
388 if verbose {
389 println!("{}", "Registry signature verified successfully.".green());
390 }
391 Ok(())
392 } else {
393 Err(anyhow!(
394 "Registry commit was signed but not by any authorized authority. \
395 Sync aborted."
396 ))
397 }
398}
399
400fn sync_git_repos(verbose: bool, scope: types::Scope) -> Result<()> {
405 if offline::is_offline() {
406 println!(
407 "\n{}",
408 "Zoi is offline. Skipping sync of external git repositories."
409 .yellow()
410 );
411 return Ok(());
412 }
413 let git_root = core_utils::get_git_base_dir(scope)?;
414 if !git_root.exists() {
415 return Ok(());
416 }
417
418 if verbose {
419 println!("\n{}", "Syncing external git repositories...".green());
420 }
421
422 let config = config::read_config()?;
423 let configured_git_repos_names: HashSet<String> = config
424 .git_repos
425 .iter()
426 .map(|url| {
427 url.trim_end_matches('/')
428 .split('/')
429 .next_back()
430 .unwrap_or_default()
431 .trim_end_matches(".git")
432 .to_string()
433 })
434 .collect();
435
436 for entry in fs::read_dir(git_root)? {
437 let entry = entry?;
438 let path = entry.path();
439 if path.is_dir() && path.join(".git").exists() {
440 let Some(repo_name_os) = path.file_name() else {
441 continue;
442 };
443 let repo_name = repo_name_os.to_string_lossy();
444
445 if !configured_git_repos_names.contains(repo_name.as_ref()) {
446 println!(
447 "Removing untracked git repository '{}'...",
448 repo_name.yellow()
449 );
450 fs::remove_dir_all(&path)?;
451 continue;
452 }
453
454 println!("Pulling changes for '{}'...", repo_name.cyan());
455
456 let mut cmd = Command::new("git");
457 cmd.arg("-C").arg(&path).arg("pull");
458
459 if verbose {
460 let status = cmd
461 .stdout(Stdio::inherit())
462 .stderr(Stdio::inherit())
463 .status()?;
464 if !status.success() {
465 eprintln!(
466 "{}: Failed to pull changes for '{}'.",
467 "Warning".yellow(),
468 repo_name
469 );
470 }
471 } else {
472 let output = cmd.output()?;
473 if !output.status.success() {
474 eprintln!(
475 "{}: Failed to pull changes for '{}'.",
476 "Warning".yellow(),
477 repo_name
478 );
479 eprintln!("{}", String::from_utf8_lossy(&output.stderr));
480 }
481 }
482 }
483 }
484 Ok(())
485}
486
487fn run_verbose_at_path(db_url: &str, db_path: &Path) -> Result<()> {
489 if db_path.exists() {
490 let status = Command::new("git")
491 .arg("-C")
492 .arg(db_path)
493 .arg("pull")
494 .stdout(Stdio::inherit())
495 .stderr(Stdio::inherit())
496 .status()?;
497 if !status.success() {
498 return Err(anyhow!(
499 "Failed to pull changes from the remote repository."
500 ));
501 }
502 } else {
503 let status = Command::new("git")
504 .arg("clone")
505 .arg("--depth=1")
506 .arg("--progress")
507 .arg(db_url)
508 .arg(db_path)
509 .stdout(Stdio::inherit())
510 .stderr(Stdio::inherit())
511 .status()?;
512 if !status.success() {
513 return Err(anyhow!("Failed to clone the package repository."));
514 }
515 }
516 Ok(())
517}
518
519fn run_quiet_git_at_path(db_url: &str, db_path: &Path) -> Result<()> {
521 if db_path.exists() {
522 let output = Command::new("git")
523 .arg("-C")
524 .arg(db_path)
525 .arg("pull")
526 .output()?;
527 if !output.status.success() {
528 let stderr = String::from_utf8_lossy(&output.stderr);
529 return Err(anyhow!(
530 "Failed to pull changes from the remote repository. {}",
531 stderr.trim()
532 ));
533 }
534 } else {
535 let output = Command::new("git")
536 .arg("clone")
537 .arg("--depth=1")
538 .arg("--quiet")
539 .arg(db_url)
540 .arg(db_path)
541 .output()?;
542 if !output.status.success() {
543 let stderr = String::from_utf8_lossy(&output.stderr);
544 return Err(anyhow!(
545 "Failed to clone the package repository. {}",
546 stderr.trim()
547 ));
548 }
549 }
550 Ok(())
551}
552
553fn run_non_verbose_at_path(
555 db_url: &str,
556 db_path: &Path,
557 m: Option<&MultiProgress>,
558 pb: Option<&ProgressBar>
559) -> Result<()> {
560 let internal_m;
561 let m_ref = if let Some(m_ptr) = m {
562 m_ptr
563 } else {
564 internal_m = MultiProgress::new();
565 &internal_m
566 };
567
568 let fetch_style = ProgressStyle::default_bar()
569 .template(
570 "{spinner:.green} [{elapsed_precise}] {msg:30.cyan} \
571 [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)"
572 )?
573 .progress_chars("#>-");
574
575 let pb_internal;
576 let pb_to_use = if let Some(p) = pb {
577 p
578 } else {
579 pb_internal = m_ref.add(ProgressBar::new(0));
580 pb_internal.set_style(fetch_style);
581 &pb_internal
582 };
583
584 if db_path.exists() {
585 let repo = Repository::open(db_path)?;
586 let mut remote = repo.find_remote("origin")?;
587
588 let mut cb = RemoteCallbacks::new();
589 let pb_clone = pb_to_use.clone();
590 cb.transfer_progress(move |stats| {
591 if stats.total_deltas() > 0 {
592 pb_clone.set_length(stats.total_deltas() as u64);
593 pb_clone.set_position(stats.indexed_deltas() as u64);
594 }
595 true
596 });
597
598 let head_symref = repo.find_reference("refs/remotes/origin/HEAD")?;
599 let remote_default_ref = head_symref
600 .symbolic_target()?
601 .ok_or_else(|| anyhow!("Remote HEAD is not a symbolic ref"))?;
602 let short_branch_name = remote_default_ref
603 .strip_prefix("refs/remotes/origin/")
604 .ok_or_else(|| {
605 anyhow!(
606 "Could not determine default branch name from remote HEAD"
607 )
608 })?;
609
610 let mut fo = FetchOptions::new();
611 fo.remote_callbacks(cb);
612 pb_to_use.set_message(format!("Fetching {}", db_url.cyan()));
613 remote.fetch(&[short_branch_name], Some(&mut fo), None)?;
614
615 let fetch_head = repo.find_reference("FETCH_HEAD")?;
616 let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
617 let analysis = repo.merge_analysis(&[&fetch_commit])?;
618
619 if analysis.0.is_up_to_date() {
620 } else if analysis.0.is_fast_forward() {
621 let refname = format!("refs/heads/{short_branch_name}");
622 let mut reference = repo.find_reference(&refname)?;
623 reference.set_target(fetch_commit.id(), "Fast-forwarding")?;
624 repo.set_head(&refname)?;
625
626 let mut checkout_builder = CheckoutBuilder::new();
627 let pb_clone = pb_to_use.clone();
628 checkout_builder.force().progress(move |_path, cur, total| {
629 if total > 0 {
630 pb_clone.set_length(total as u64);
631 pb_clone.set_position(cur as u64);
632 }
633 });
634
635 pb_to_use.set_message(format!("Checkout {}", db_url.cyan()));
636 repo.checkout_head(Some(&mut checkout_builder))?;
637 } else {
638 println!(
639 "{}",
640 "Cannot fast-forward. Please run `git pull` manually.".yellow()
641 );
642 }
643 } else {
644 if let Some(parent) = db_path.parent() {
645 fs::create_dir_all(parent)?;
646 }
647
648 let mut cb = RemoteCallbacks::new();
649 let pb_clone = pb_to_use.clone();
650 cb.transfer_progress(move |stats| {
651 if stats.total_deltas() > 0 {
652 pb_clone.set_length(stats.total_deltas() as u64);
653 }
654 pb_clone.set_position(stats.indexed_deltas() as u64);
655 true
656 });
657
658 let mut fo = FetchOptions::new();
659 fo.remote_callbacks(cb);
660
661 let mut checkout_builder = CheckoutBuilder::new();
662 let pb_clone = pb_to_use.clone();
663 checkout_builder.progress(move |_path, cur, total| {
664 if total > 0 {
665 pb_clone.set_length(total as u64);
666 }
667 pb_clone.set_position(cur as u64);
668 });
669
670 pb_to_use.set_message(format!("Cloning {}", db_url.cyan()));
671 RepoBuilder::new()
672 .fetch_options(fo)
673 .with_checkout(checkout_builder)
674 .clone(db_url, db_path)?;
675 }
676
677 Ok(())
678}
679
680fn try_sync_at_path(
683 db_url: &str,
684 db_path: &Path,
685 verbose: bool,
686 m: Option<&MultiProgress>,
687 pb: Option<&ProgressBar>
688) -> Result<()> {
689 let local_path = Path::new(db_url);
691 if local_path.is_dir() {
692 if verbose {
693 let msg = format!("Syncing local registry: {}", db_url.cyan());
694 if let Some(m_ref) = m {
695 let _ = m_ref.println(&msg);
696 } else {
697 println!("{msg}");
698 }
699 }
700
701 if db_path.exists() {
704 if db_path.is_symlink() {
705 if let Ok(target) = fs::read_link(db_path)
707 && target == local_path
708 {
709 return Ok(());
710 }
711 fs::remove_file(db_path)?;
712 } else {
713 fs::remove_dir_all(db_path)?;
715 }
716 }
717
718 if let Some(parent) = db_path.parent() {
719 fs::create_dir_all(parent)?;
720 }
721
722 return core_utils::symlink_dir(local_path, db_path)
723 .map_err(|e| anyhow!("Failed to symlink local registry: {e}"));
724 }
725
726 if offline::is_offline() {
727 if db_path.exists() {
728 let msg = format!(
729 "Zoi is offline. Skipping update for existing registry at {}",
730 db_path.display()
731 );
732 if let Some(m_ref) = m {
733 let _ = m_ref.println(&msg);
734 } else {
735 println!("{msg}");
736 }
737 return Ok(());
738 }
739 return Err(anyhow!(
740 "Cannot sync registry '{db_url}': Zoi is offline and registry is \
741 not cloned."
742 ));
743 }
744 if db_path.exists()
745 && let Ok(repo) = Repository::open(db_path)
746 && let Ok(remote) = repo.find_remote("origin")
747 && let Ok(remote_url) = remote.url()
748 && remote_url != db_url
749 {
750 let msg = format!(
751 "Registry URL has changed from {}. Updating origin to {}.",
752 remote_url.yellow(),
753 db_url.cyan()
754 );
755 if let Some(m_ref) = m {
756 m_ref.println(&msg)?;
757 } else {
758 println!("{msg}");
759 }
760 repo.remote_set_url("origin", db_url)?;
761 }
762
763 if verbose {
764 run_verbose_at_path(db_url, db_path)
765 } else {
766 match run_non_verbose_at_path(db_url, db_path, m, pb) {
767 Ok(()) => Ok(()),
768 Err(libgit_error) => {
769 let msg = format!(
770 "Git progress sync failed for {}: {}. Retrying with \
771 system git...",
772 db_url.yellow(),
773 libgit_error
774 );
775 if let Some(p) = pb {
776 p.println(msg);
777 } else if let Some(m_ref) = m {
778 m_ref.println(msg)?;
779 } else {
780 eprintln!("{msg}");
781 }
782 run_quiet_git_at_path(db_url, db_path)
783 }
784 }
785 }
786}
787
788fn sync_pgp_keys_at_path(
790 db_path: &Path,
791 verbose: bool,
792 pb: Option<&ProgressBar>
793) -> Result<()> {
794 if verbose {
795 println!("\n{}", "Syncing PGP keys from repository...".green());
796 }
797 if !db_path.join("repo.yaml").exists() {
798 if verbose {
799 println!(
800 "{}",
801 "repo.yaml not found, skipping PGP key sync.".yellow()
802 );
803 }
804 return Ok(());
805 }
806
807 let repo_config = config::read_repo_config(db_path)?;
808
809 if repo_config.pgp.is_empty() {
810 if verbose {
811 println!("No PGP keys defined in repo.yaml.");
812 }
813 return Ok(());
814 }
815
816 if let Some(p) = pb {
817 p.set_length(repo_config.pgp.len() as u64);
818 p.set_position(0);
819 p.set_message(format!("PGP Keys {}", repo_config.name.cyan()));
820 }
821
822 for key_info in repo_config.pgp {
823 let key_source = &key_info.key;
824 let key_name = &key_info.name;
825
826 if let Some(p) = pb {
827 p.set_message(format!("PGP Key: {key_name}"));
828 }
829
830 let result = if key_source.starts_with("http") {
831 pgp::add_key_from_url(key_source, key_name, !verbose)
832 } else if key_source.len() == 40
833 && key_source.chars().all(|c| c.is_ascii_hexdigit())
834 {
835 pgp::add_key_from_fingerprint(key_source, key_name, !verbose)
836 } else {
837 Err(anyhow!(
838 "Invalid key source '{key_source}': must be a URL or a \
839 40-character fingerprint."
840 ))
841 };
842
843 if let Err(e) = result {
844 let err_msg = format!(
845 "{} Failed to import key '{}': {}",
846 "Warning:".yellow(),
847 key_name,
848 e
849 );
850 if let Some(p) = pb {
851 p.println(err_msg);
852 } else {
853 eprintln!("{err_msg}");
854 }
855 }
856 if let Some(p) = pb {
857 p.inc(1);
858 }
859 }
860
861 Ok(())
862}
863
864fn fetch_handle_by_cloning(url: &str, verbose: bool) -> Result<String> {
867 let temp_dir = Builder::new().prefix("zoi-handle-fetch").tempdir()?;
868 if verbose {
869 println!("Cloning '{}' to fetch handle...", url.cyan());
870 }
871 let status = std::process::Command::new("git")
872 .arg("clone")
873 .arg("--depth=1")
874 .arg(url)
875 .arg(temp_dir.path())
876 .stdout(if verbose {
877 Stdio::inherit()
878 } else {
879 Stdio::null()
880 })
881 .stderr(if verbose {
882 Stdio::inherit()
883 } else {
884 Stdio::null()
885 })
886 .status()?;
887
888 if !status.success() {
889 return Err(anyhow!("git clone failed to fetch handle"));
890 }
891
892 let repo_config = config::read_repo_config(temp_dir.path())?;
893 Ok(repo_config.name)
894}
895
896fn parse_full_repo_url(url: &str) -> Option<(String, String)> {
898 let url = url.trim_end_matches(".git").trim_end_matches('/');
899 if let Some(path) = url.strip_prefix("https://github.com/") {
900 Some(("github".to_string(), path.to_string()))
901 } else if let Some(path) = url.strip_prefix("https://gitlab.com/") {
902 Some(("gitlab".to_string(), path.to_string()))
903 } else {
904 url.strip_prefix("https://codeberg.org/")
905 .map(|path| ("codeberg".to_string(), path.to_string()))
906 }
907}
908
909fn fetch_repo_yaml_content(url: &str) -> Result<String> {
912 let (provider, repo_path) = parse_full_repo_url(url).ok_or_else(|| {
913 anyhow!("Unsupported git provider or URL format for direct fetch.")
914 })?;
915
916 let branches = ["main", "master"];
917 for branch in &branches {
918 let repo_yaml_url = match provider.as_str() {
919 "github" => format!(
920 "https://raw.githubusercontent.com/{repo_path}/{branch}/repo.yaml"
921 ),
922 "gitlab" => format!(
923 "https://gitlab.com/{repo_path}/-/raw/{branch}/repo.yaml"
924 ),
925 "codeberg" => format!(
926 "https://codeberg.org/{repo_path}/raw/branch/{branch}/repo.yaml"
927 ),
928 _ => continue,
929 };
930
931 let client = core_utils::get_http_client().ok();
932 if let Some(c) = client
933 && let Ok(response) = c.get(&repo_yaml_url).send()
934 && response.status().is_success()
935 {
936 println!("Found repo.yaml at: {}", repo_yaml_url.cyan());
937 return Ok(response.text()?);
938 }
939 }
940
941 Err(anyhow!(
942 "Could not find 'repo.yaml' in repo '{repo_path}' on branches main or \
943 master."
944 ))
945}
946
947fn fetch_handle_for_url(url: &str, verbose: bool) -> Result<String> {
950 if verbose {
951 println!(
952 "Attempting to fetch handle for '{}' directly...",
953 url.cyan()
954 );
955 }
956
957 let local_path = Path::new(url);
959 if local_path.is_dir() {
960 if verbose {
961 println!("Detected local directory registry.");
962 }
963 let repo_config = config::read_repo_config(local_path)?;
964 return Ok(repo_config.name);
965 }
966
967 match fetch_repo_yaml_content(url) {
968 Ok(content) => {
969 let repo_config: types::RepoConfig =
970 serde_yaml::from_str(&content)?;
971 if verbose {
972 println!("Successfully fetched and parsed repo.yaml.");
973 }
974 Ok(repo_config.name)
975 }
976 Err(e) => {
977 if verbose {
978 println!(
979 "Direct fetch failed: {}. Falling back to cloning \
980 repository...",
981 e.to_string().yellow()
982 );
983 }
984 fetch_handle_by_cloning(url, verbose)
985 }
986 }
987}
988
989fn sync_registry(
1005 mut reg: types::Registry,
1006 db_root: &Path,
1007 verbose: bool,
1008 fallback: bool,
1009 m: Option<&MultiProgress>
1010) -> Result<(types::Registry, bool)> {
1011 let mut reg_changed = false;
1012
1013 let pb = if !verbose && let Some(m_ref) = m {
1014 let p = m_ref.add(ProgressBar::new(0));
1015 p.set_style(
1016 ProgressStyle::default_bar()
1017 .template(
1018 "{spinner:.green} [{elapsed_precise}] {msg:30.cyan} \
1019 [{bar:40.cyan/blue}] {percent}%"
1020 )?
1021 .progress_chars("#>-")
1022 );
1023 p.enable_steady_tick(std::time::Duration::from_millis(120));
1024 Some(p)
1025 } else {
1026 None
1027 };
1028
1029 if reg.handle.is_empty() {
1030 if let Some(p) = &pb {
1031 p.set_message(format!("Fetching handle for {}", reg.url.cyan()));
1032 }
1033 let handle = fetch_handle_for_url(®.url, verbose)?;
1034 reg.handle = handle;
1035 reg_changed = true;
1036 }
1037
1038 let target_dir = db_root.join(®.handle);
1039
1040 let mut candidate_urls = vec![reg.url.clone()];
1041
1042 if fallback
1043 && target_dir.exists()
1044 && let Ok(repo_config) = config::read_repo_config(&target_dir)
1045 {
1046 for git_link in
1047 repo_config.git.iter().filter(|g| g.link_type == "mirror")
1048 {
1049 if git_link.url != reg.url
1050 && !candidate_urls.contains(&git_link.url)
1051 {
1052 candidate_urls.push(git_link.url.clone());
1053 }
1054 }
1055 }
1056
1057 let pre_sync_head = match Repository::open(&target_dir) {
1058 Ok(repo) => match repo.head() {
1059 Ok(head) => head.target(),
1060 Err(_) => None
1061 },
1062 Err(_) => None
1063 };
1064
1065 let mut sync_success = false;
1066 let mut last_error = None;
1067
1068 for url in candidate_urls {
1069 if let Err(e) =
1070 try_sync_at_path(&url, &target_dir, verbose, m, pb.as_ref())
1071 {
1072 let msg = format!("Sync with {} failed: {}", url.yellow(), e);
1073 if let Some(p) = &pb {
1074 p.println(&msg);
1075 } else if let Some(m_ref) = m {
1076 let _ = m_ref.println(&msg);
1077 } else {
1078 eprintln!("{msg}");
1079 }
1080 last_error = Some(e);
1081 } else {
1082 if url != reg.url {
1083 reg.url = url;
1084 reg_changed = true;
1085 }
1086 sync_success = true;
1087 break;
1088 }
1089 }
1090
1091 let is_local = Path::new(®.url).is_dir();
1092
1093 if sync_success {
1094 if !is_local
1095 && let Some(authorities) = ®.authorities
1096 && let Err(e) =
1097 verify_registry_signature(&target_dir, authorities, verbose)
1098 {
1099 let rollback_msg = if let Some(oid) = pre_sync_head {
1100 if let Ok(repo) = Repository::open(&target_dir) {
1101 if let Ok(object) = repo.find_object(oid, None) {
1102 let mut checkout = CheckoutBuilder::new();
1103 checkout.force();
1104 if repo
1105 .reset(
1106 &object,
1107 ResetType::Hard,
1108 Some(&mut checkout)
1109 )
1110 .is_ok()
1111 {
1112 "Rolled back to previous signed commit.".to_string()
1113 } else {
1114 "Failed to rollback. Repository may be in an \
1115 inconsistent state."
1116 .to_string()
1117 }
1118 } else {
1119 "Could not find previous HEAD object.".to_string()
1120 }
1121 } else {
1122 "Could not open repository for rollback.".to_string()
1123 }
1124 } else {
1125 let _ = fs::remove_dir_all(&target_dir);
1126 "Removed unsigned clone.".to_string()
1127 };
1128
1129 let msg = format!(
1130 "Security: Registry signature check failed for {}: {}. {}",
1131 reg.url.red(),
1132 e,
1133 rollback_msg.yellow(),
1134 );
1135 if let Some(m_ref) = m {
1136 m_ref.println(&msg)?;
1137 } else {
1138 eprintln!("{msg}");
1139 }
1140 return Err(e);
1141 }
1142
1143 if !is_local {
1144 sync_pgp_keys_at_path(&target_dir, verbose, pb.as_ref())?;
1145 }
1146
1147 let mut db_downloaded = false;
1148 if !is_local
1149 && let Ok(repo_config) = config::read_repo_config(&target_dir)
1150 && let Some(db_url_template) = &repo_config.db
1151 {
1152 let platform = core_utils::get_platform().unwrap_or_default();
1153 let db_url = install_util::resolve_url_placeholders(
1154 db_url_template,
1155 "",
1156 "",
1157 "",
1158 &platform
1159 );
1160
1161 if let Ok(db_path) = db::get_db_path(®.handle) {
1162 if let Some(p) = pb.as_ref() {
1163 p.set_message("Downloading pre-indexed DB...");
1164 } else if verbose {
1165 println!("Downloading pre-indexed DB from {db_url}...");
1166 }
1167 if let Some(parent) = db_path.parent() {
1168 let _ = fs::create_dir_all(parent);
1169 }
1170
1171 let temp_db_path = db_path.with_extension("db.tmp");
1172 if install_util::download_file_with_progress(
1173 &db_url,
1174 &temp_db_path,
1175 pb.as_ref(),
1176 None
1177 )
1178 .is_ok()
1179 {
1180 if fs::rename(&temp_db_path, &db_path).is_ok() {
1181 db_downloaded = true;
1182 if verbose {
1183 println!("Successfully downloaded pre-indexed DB.");
1184 }
1185 }
1186 } else {
1187 let _ = fs::remove_file(&temp_db_path);
1188 }
1189 }
1190 }
1191
1192 if !db_downloaded {
1193 refresh_registry_db(
1194 ®.handle,
1195 &target_dir,
1196 m,
1197 verbose,
1198 pb.as_ref()
1199 )?;
1200 }
1201
1202 if let Ok(repo_config) = config::read_repo_config(&target_dir)
1203 && repo_config.advisory_prefix != reg.advisory_prefix
1204 {
1205 reg.advisory_prefix = repo_config.advisory_prefix;
1206 reg_changed = true;
1207 }
1208
1209 if let Some(p) = pb {
1210 p.finish_with_message(format!("Synced {}", reg.handle.cyan()));
1211 }
1212 } else {
1213 let e = last_error
1214 .unwrap_or_else(|| anyhow!("All sync candidates failed."));
1215 if let Some(p) = &pb {
1216 p.abandon_with_message("Sync failed.".red().to_string());
1217 }
1218 return Err(e);
1219 }
1220
1221 Ok((reg, reg_changed))
1222}
1223
1224pub fn run_local(
1233 verbose: bool,
1234 _fallback: bool,
1235 force: bool,
1236 frozen: bool
1237) -> Result<()> {
1238 let local_db_root = zoi_core::sysroot::apply_sysroot(
1239 std::env::current_dir()?
1240 .join(".zoi")
1241 .join("pkgs")
1242 .join("db")
1243 );
1244 fs::create_dir_all(&local_db_root)?;
1245
1246 let registries: Vec<(String, String, String)> = if frozen {
1247 let lockfile = zoi_project::lockfile::read_zoi_lock()?;
1248 lockfile
1249 .registries
1250 .into_iter()
1251 .map(|(handle, lr)| (handle, lr.url, lr.revision))
1252 .collect()
1253 } else {
1254 let project = zoi_project::config::load_with_env(&HashMap::new())?;
1255
1256 project
1257 .registries
1258 .into_iter()
1259 .map(|(handle, spec)| {
1260 let rev =
1261 spec.revision.clone().unwrap_or_else(|| "main".to_string());
1262 (handle, spec.url, rev)
1263 })
1264 .collect()
1265 };
1266
1267 if registries.is_empty() {
1268 println!("{} No registries found in zoi.lua.", "::".bold().yellow());
1269 return Ok(());
1270 }
1271
1272 let m = if verbose {
1273 None
1274 } else {
1275 Some(MultiProgress::new())
1276 };
1277
1278 let results: Vec<((String, String), String)> = registries
1279 .into_par_iter()
1280 .map(|(handle, url, revision)| {
1281 let target_dir = local_db_root.join(&handle);
1282
1283 if force && target_dir.exists() {
1284 fs::remove_dir_all(&target_dir)?;
1285 }
1286
1287 try_sync_at_path(&url, &target_dir, verbose, m.as_ref(), None)?;
1288
1289 if !revision.is_empty() {
1290 if verbose {
1291 println!(
1292 " Checking out revision '{revision}' for registry \
1293 '{handle}'..."
1294 );
1295 }
1296 let status = Command::new("git")
1297 .arg("-C")
1298 .arg(&target_dir)
1299 .arg("checkout")
1300 .arg(&revision)
1301 .stdout(if verbose {
1302 Stdio::inherit()
1303 } else {
1304 Stdio::null()
1305 })
1306 .stderr(if verbose {
1307 Stdio::inherit()
1308 } else {
1309 Stdio::null()
1310 })
1311 .status()
1312 .map_err(|e| anyhow!("Failed to run git checkout: {e}"))?;
1313 if !status.success() {
1314 return Err(anyhow!(
1315 "Failed to checkout revision '{revision}' for \
1316 registry '{handle}'"
1317 ));
1318 }
1319 }
1320
1321 refresh_registry_db(
1322 &handle,
1323 &target_dir,
1324 m.as_ref(),
1325 verbose,
1326 None
1327 )?;
1328
1329 let resolved_hash = if frozen {
1330 revision.clone()
1331 } else if let Ok(repo) = git2::Repository::open(&target_dir) {
1332 repo.head()
1333 .ok()
1334 .and_then(|h| h.target().map(|oid| oid.to_string()))
1335 .unwrap_or(revision.clone())
1336 } else {
1337 revision.clone()
1338 };
1339
1340 Ok(((handle, url), resolved_hash))
1341 })
1342 .collect::<Result<Vec<_>>>()?;
1343
1344 if !frozen {
1345 let mut lockfile = zoi_project::lockfile::read_zoi_lock()?;
1346 for ((handle, url), revision) in results {
1347 lockfile
1348 .registries
1349 .insert(handle, types::LockRegistryV2 { revision, url });
1350 }
1351 lockfile.version = "2".to_string();
1352 zoi_project::lockfile::write_zoi_lock(&mut lockfile)?;
1353 }
1354
1355 println!("{} Local sync complete.", "::".bold().blue());
1356 Ok(())
1357}
1358
1359pub fn run(
1370 verbose: bool,
1371 fallback: bool,
1372 no_pm: bool,
1373 force: bool,
1374 scope: Option<types::Scope>
1375) -> Result<()> {
1376 let merged_config = config::read_config()?;
1377 if force {
1378 println!(
1379 "{} Force sync: removing existing databases and re-syncing from \
1380 scratch...",
1381 "::".bold().yellow()
1382 );
1383 }
1384
1385 let effective_scope = scope.unwrap_or_else(|| {
1386 if core_utils::is_admin() || zoi_core::sysroot::get_sysroot().is_some()
1387 {
1388 types::Scope::System
1389 } else {
1390 types::Scope::User
1391 }
1392 });
1393
1394 if merged_config.protect_db || force {
1395 let db_root = core_utils::get_db_base_dir(effective_scope)?;
1396 if db_root.exists() {
1397 if verbose || force {
1398 println!("Making package database writable...");
1399 }
1400 if let Err(e) = core_utils::set_path_writable(&db_root) {
1401 eprintln!("Warning: could not make db writable: {e}");
1402 }
1403 }
1404 }
1405
1406 let mut config = config::read_user_config()?;
1407 let mut needs_config_update = false;
1408
1409 if config.default_registry.is_none() {
1410 let merged_config = config::read_config()?;
1411 if merged_config.default_registry.is_some() {
1412 config.default_registry = merged_config.default_registry;
1413 }
1414 }
1415
1416 let db_root = core_utils::get_db_base_dir(effective_scope)?;
1417 let mut registries_to_sync = Vec::new();
1418
1419 if let Some(default_reg) = &config.default_registry {
1420 registries_to_sync.push((default_reg.clone(), true));
1421 }
1422
1423 for reg in &config.added_registries {
1424 registries_to_sync.push((reg.clone(), false));
1425 }
1426
1427 if force {
1428 for (reg, _) in ®istries_to_sync {
1429 let db_file = db_root.join(format!("{}.db", reg.handle));
1430 if db_file.exists() {
1431 if verbose {
1432 println!("Removing database: {}", db_file.display());
1433 }
1434 std::fs::remove_file(&db_file)?;
1435 }
1436 let clone_dir = db_root.join(®.handle);
1437 if clone_dir.exists() {
1438 if verbose {
1439 println!(
1440 "Removing clone directory: {}",
1441 clone_dir.display()
1442 );
1443 }
1444 std::fs::remove_dir_all(&clone_dir)?;
1445 }
1446 }
1447 }
1448
1449 if !registries_to_sync.is_empty() {
1450 println!("{} Syncing registries...", "::".bold().blue());
1451 let m = if verbose {
1452 None
1453 } else {
1454 Some(MultiProgress::new())
1455 };
1456
1457 let results: Vec<Result<(types::Registry, bool, bool)>> =
1458 registries_to_sync
1459 .into_par_iter()
1460 .map(|(reg, is_default)| {
1461 let (synced_reg, changed) = sync_registry(
1462 reg,
1463 &db_root,
1464 verbose,
1465 fallback,
1466 m.as_ref()
1467 )?;
1468 Ok((synced_reg, changed, is_default))
1469 })
1470 .collect();
1471
1472 let mut updated_added_registries = Vec::new();
1473 for res in results {
1474 let (reg, changed, is_default) = res?;
1475 if changed {
1476 needs_config_update = true;
1477 }
1478 if is_default {
1479 config.default_registry = Some(reg);
1480 } else {
1481 updated_added_registries.push(reg);
1482 }
1483 }
1484 config.added_registries = updated_added_registries;
1485 }
1486
1487 if !no_pm {
1488 if verbose {
1489 println!("\n{}", "Updating system configuration...".green());
1490 }
1491 config.native_package_manager =
1492 core_utils::get_native_package_manager();
1493 config.package_managers =
1494 Some(core_utils::get_all_available_package_managers());
1495 needs_config_update = true;
1496 if verbose {
1497 println!("System configuration updated.");
1498 }
1499 }
1500
1501 if needs_config_update {
1502 config::write_user_config(&config)?;
1503 }
1504
1505 let _ = config::sync_remote_policy();
1506
1507 sync_git_repos(verbose, effective_scope)?;
1508
1509 if merged_config.protect_db {
1510 let db_root = core_utils::get_db_base_dir(effective_scope)?;
1511 if db_root.exists() {
1512 if verbose {
1513 println!("Making package database read-only...");
1514 }
1515 if let Err(e) = core_utils::set_path_read_only(&db_root) {
1516 eprintln!("Warning: could not make db read-only: {e}");
1517 }
1518 }
1519 }
1520
1521 Ok(())
1522}