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