1use std::collections::{HashMap, HashSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14
15use anyhow::{Result, anyhow};
16use colored::Colorize;
17use git2::build::{CheckoutBuilder, RepoBuilder};
18use git2::{FetchOptions, RemoteCallbacks, Repository, ResetType};
19use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
20use rayon::prelude::*;
21use tempfile::Builder;
22use walkdir::WalkDir;
23use zoi_core::{config, offline, pgp, types, utils as core_utils};
24use zoi_db as db;
25use zoi_install::util as install_util;
26use zoi_lua::parser as lua_parser;
27
28pub mod zrepo;
29
30fn refresh_registry_db(
40 registry_handle: &str,
41 registry_path: &Path,
42 m: Option<&MultiProgress>,
43 verbose: bool,
44 pb: Option<&ProgressBar>
45) -> Result<()> {
46 if verbose {
47 let msg = format!(
48 "Refreshing metadata database for {}...",
49 registry_handle.cyan()
50 );
51 if let Some(m_ref) = m {
52 let _ = m_ref.println(&msg);
53 } else {
54 println!("{msg}");
55 }
56 }
57
58 let mut conn = db::open_connection(registry_handle)?;
59 db::clear_registry(&conn)?;
60
61 let mut pkg_files = Vec::new();
62 let mut sec_files = Vec::new();
63 for entry in WalkDir::new(registry_path)
64 .into_iter()
65 .filter_map(std::result::Result::ok)
66 {
67 if entry.file_type().is_file() {
68 let name = entry.file_name().to_string_lossy();
69 if name.ends_with(".pkg.lua") {
70 pkg_files.push(entry.path().to_path_buf());
71 } else if name.ends_with(".sec.yaml") {
72 sec_files.push(entry.path().to_path_buf());
73 }
74 }
75 }
76
77 let repo_config = config::read_repo_config(registry_path).ok();
78 let _advisory_prefix = repo_config
79 .as_ref()
80 .and_then(|rc| rc.advisory_prefix.clone());
81 let platform = core_utils::get_platform().unwrap_or_default();
82
83 let has_size_tpl = repo_config
84 .as_ref()
85 .and_then(|rc| rc.pkg.iter().find(|p| p.link_type == "main"))
86 .and_then(|p| p.size.as_ref())
87 .is_some();
88
89 let has_files_tpl = repo_config
90 .as_ref()
91 .and_then(|rc| rc.pkg.iter().find(|p| p.link_type == "main"))
92 .and_then(|p| p.files.as_ref())
93 .is_some();
94
95 let client = if has_size_tpl || has_files_tpl {
96 core_utils::get_http_client().ok()
97 } else {
98 None
99 };
100
101 if let Some(p) = pb {
102 p.set_length(pkg_files.len() as u64);
103 p.set_position(0);
104 p.set_message(format!("Indexing {}", registry_handle.cyan()));
105 }
106
107 let parsed_results: Vec<(
108 types::Package,
109 PathBuf,
110 Option<Vec<String>>,
111 Option<(u64, u64)>,
112 Option<String>
113 )> = pkg_files
114 .par_iter()
115 .filter_map(|path| {
116 if let Some(p) = pb {
117 p.inc(1);
118 }
119 let path_str = path.to_string_lossy();
120 if let Ok(mut pkg) =
121 lua_parser::parse_lua_package(&path_str, None, None, true)
122 {
123 if pkg.repo.is_empty()
124 && let Ok(rel_path) = path.strip_prefix(registry_path)
125 && let Some(parent) = rel_path.parent()
126 {
127 let mut repo_path =
128 parent.to_string_lossy().to_string().replace('\\', "/");
129 let pkg_name_suffix = format!("/{}", pkg.name);
130 if repo_path.ends_with(&pkg_name_suffix) {
131 repo_path = repo_path
132 [..repo_path.len() - pkg_name_suffix.len()]
133 .to_string();
134 } else if repo_path == pkg.name {
135 repo_path = String::new();
136 }
137 pkg.repo = repo_path;
138 }
139
140 let mut file_list = None;
141 if let Some(c) = &client
142 && let Some(rc) = &repo_config
143 && let Some(pkg_link) =
144 rc.pkg.iter().find(|p| p.link_type == "main")
145 && let Some(files_url_template) = &pkg_link.files
146 {
147 let version = pkg
148 .version
149 .clone()
150 .unwrap_or_else(|| "latest".to_string());
151 let files_url = install_util::resolve_url_placeholders(
152 files_url_template,
153 &pkg.name,
154 &pkg.repo,
155 &version,
156 &platform
157 );
158
159 if let Ok(response) = c.get(&files_url).send()
160 && response.status().is_success()
161 && let Ok(content) = response.text()
162 {
163 file_list = Some(
164 content
165 .lines()
166 .map(|l| l.trim().to_string())
167 .filter(|l| !l.is_empty())
168 .collect()
169 );
170 }
171 }
172
173 let mut size_info = None;
174 if let Some(c) = &client
175 && let Some(rc) = &repo_config
176 && let Some(pkg_link) =
177 rc.pkg.iter().find(|p| p.link_type == "main")
178 && let Some(size_url_template) = &pkg_link.size
179 {
180 let version = pkg
181 .version
182 .clone()
183 .unwrap_or_else(|| "latest".to_string());
184 let size_url = install_util::resolve_url_placeholders(
185 size_url_template,
186 &pkg.name,
187 &pkg.repo,
188 &version,
189 &platform
190 );
191
192 if let Ok(response) = c.get(&size_url).send()
193 && response.status().is_success()
194 && let Ok(content) = response.text()
195 {
196 let mut download_size = 0u64;
197 let mut installed_size = 0u64;
198 for line in content.lines() {
199 if let Some((key, val)) = line.split_once(':')
200 && let Ok(num) = val.trim().parse::<u64>()
201 {
202 match key.trim() {
203 "down" => download_size = num,
204 "install" => installed_size = num,
205 _ => {}
206 }
207 }
208 }
209 if download_size > 0 || installed_size > 0 {
210 size_info = Some((download_size, installed_size));
211 }
212 }
213 }
214
215 let mut hash_info = None;
216 if let Some(c) = &client
217 && let Some(rc) = &repo_config
218 && let Some(pkg_link) =
219 rc.pkg.iter().find(|p| p.link_type == "main")
220 && let Some(hash_url_template) = &pkg_link.hash
221 {
222 let version = pkg
223 .version
224 .clone()
225 .unwrap_or_else(|| "latest".to_string());
226 let hash_url = install_util::resolve_url_placeholders(
227 hash_url_template,
228 &pkg.name,
229 &pkg.repo,
230 &version,
231 &platform
232 );
233
234 if let Ok(response) = c.get(&hash_url).send()
235 && response.status().is_success()
236 && let Ok(content) = response.text()
237 {
238 let is_valid_hash = |s: &str| {
239 let len = s.len();
240 (len == 128 || len == 64 || len == 32)
241 && s.chars().all(|c| c.is_ascii_hexdigit())
242 };
243 for word in content.split_whitespace() {
244 if is_valid_hash(word) {
245 hash_info = Some(word.to_string());
246 break;
247 }
248 }
249 }
250 }
251
252 Some((pkg, path.clone(), file_list, size_info, hash_info))
253 } else {
254 None
255 }
256 })
257 .collect();
258
259 let parsed_advisories: Vec<(types::Advisory, String)> = sec_files
260 .par_iter()
261 .filter_map(|path| {
262 if let Ok(content) = fs::read_to_string(path)
263 && let Ok(advisory) =
264 serde_yaml::from_str::<types::Advisory>(&content)
265 && let Ok(rel_path) = path.strip_prefix(registry_path)
266 && let Some(parent) = rel_path.parent()
267 {
268 let repo_path =
269 parent.to_string_lossy().to_string().replace('\\', "/");
270 return Some((advisory, repo_path));
271 }
272 None
273 })
274 .collect();
275
276 let tx = conn.transaction()?;
277
278 for (pkg, _path, file_list, size_info, hash_info) in parsed_results {
279 let pkg_id =
280 db::update_package(&tx, &pkg, registry_handle, None, None, None)?;
281
282 if let Some((down_size, install_size)) = size_info {
283 let _ = db::set_package_sizes(&tx, pkg_id, down_size, install_size);
284 }
285
286 if let Some(hash) = &hash_info {
287 let _ = db::set_package_hash(&tx, pkg_id, hash);
288 }
289
290 if let Some(subs) = &pkg.sub_packages {
291 for sub in subs {
292 if let Err(e) = db::update_package(
293 &tx,
294 &pkg,
295 registry_handle,
296 None,
297 Some(sub),
298 None
299 ) {
300 eprintln!(
301 "Warning: failed to sync sub-package '{}:{}': {}",
302 pkg.name, sub, e
303 );
304 } else if let Ok(sub_id) = db::get_package_id(
305 &tx,
306 &pkg.name,
307 Some(sub),
308 &pkg.repo,
309 registry_handle
310 ) {
311 if let Some((down_size, install_size)) = &size_info {
312 let _ = db::set_package_sizes(
313 &tx,
314 sub_id,
315 *down_size,
316 *install_size
317 );
318 }
319 if let Some(hash) = &hash_info {
320 let _ = db::set_package_hash(&tx, sub_id, hash);
321 }
322 }
323 }
324 }
325
326 if let Some(list) = file_list {
327 let _ = db::index_package_files(&tx, pkg_id, &list);
328 }
329 }
330
331 for (advisory, repo) in parsed_advisories {
332 let _ = db::update_advisory(&tx, &advisory, &repo, registry_handle);
333 }
334
335 tx.commit()?;
336
337 if let Some(p) = pb {
338 p.finish_and_clear();
339 }
340
341 Ok(())
342}
343
344fn verify_registry_signature(
349 repo_path: &Path,
350 authorities: &[String],
351 verbose: bool
352) -> Result<()> {
353 if authorities.is_empty() {
354 return Ok(());
355 }
356
357 if verbose {
358 println!("Verifying registry signature...");
359 }
360
361 let repo = Repository::open(repo_path)
362 .map_err(|e| anyhow!("Failed to open registry repository: {e}"))?;
363 let head = repo
364 .head()
365 .map_err(|e| anyhow!("Failed to get repository HEAD: {e}"))?;
366 let target = head
367 .target()
368 .ok_or_else(|| anyhow!("HEAD is not a direct reference"))?;
369 let commit = repo
370 .find_commit(target)
371 .map_err(|e| anyhow!("Failed to find HEAD commit: {e}"))?;
372
373 let (sig, data) =
374 repo.extract_signature(&commit.id(), None).map_err(|_| {
375 anyhow!("Registry commit is not signed. Sync aborted for security.")
376 })?;
377
378 let sig_bytes = &*sig;
379 let data_bytes = &*data;
380
381 let trusted_certs = pgp::get_certs_by_name_or_fingerprint(authorities)?;
382
383 let mut verified = false;
384 for cert in trusted_certs {
385 if pgp::verify_detached_signature_raw(data_bytes, sig_bytes, &cert)
386 .is_ok()
387 {
388 verified = true;
389 break;
390 }
391 }
392
393 if verified {
394 if verbose {
395 println!("{}", "Registry signature verified successfully.".green());
396 }
397 Ok(())
398 } else {
399 Err(anyhow!(
400 "Registry commit was signed but not by any authorized authority. \
401 Sync aborted."
402 ))
403 }
404}
405
406fn sync_git_repos(verbose: bool, scope: types::Scope) -> Result<()> {
411 if offline::is_offline() {
412 println!(
413 "\n{}",
414 "Zoi is offline. Skipping sync of external git repositories."
415 .yellow()
416 );
417 return Ok(());
418 }
419 let git_root = core_utils::get_git_base_dir(scope)?;
420 if !git_root.exists() {
421 return Ok(());
422 }
423
424 if verbose {
425 println!("\n{}", "Syncing external git repositories...".green());
426 }
427
428 let config = config::read_config()?;
429 let configured_git_repos_names: HashSet<String> = config
430 .git_repos
431 .iter()
432 .map(|url| {
433 url.trim_end_matches('/')
434 .split('/')
435 .next_back()
436 .unwrap_or_default()
437 .trim_end_matches(".git")
438 .to_string()
439 })
440 .collect();
441
442 for entry in fs::read_dir(git_root)? {
443 let entry = entry?;
444 let path = entry.path();
445 if path.is_dir() && path.join(".git").exists() {
446 let Some(repo_name_os) = path.file_name() else {
447 continue;
448 };
449 let repo_name = repo_name_os.to_string_lossy();
450
451 if !configured_git_repos_names.contains(repo_name.as_ref()) {
452 println!(
453 "Removing untracked git repository '{}'...",
454 repo_name.yellow()
455 );
456 fs::remove_dir_all(&path)?;
457 continue;
458 }
459
460 println!("Pulling changes for '{}'...", repo_name.cyan());
461
462 let mut cmd = Command::new("git");
463 cmd.arg("-C").arg(&path).arg("pull");
464
465 if verbose {
466 let status = cmd
467 .stdout(Stdio::inherit())
468 .stderr(Stdio::inherit())
469 .status()?;
470 if !status.success() {
471 eprintln!(
472 "{}: Failed to pull changes for '{}'.",
473 "Warning".yellow(),
474 repo_name
475 );
476 }
477 } else {
478 let output = cmd.output()?;
479 if !output.status.success() {
480 eprintln!(
481 "{}: Failed to pull changes for '{}'.",
482 "Warning".yellow(),
483 repo_name
484 );
485 eprintln!("{}", String::from_utf8_lossy(&output.stderr));
486 }
487 }
488 }
489 }
490 Ok(())
491}
492
493fn run_verbose_at_path(db_url: &str, db_path: &Path) -> Result<()> {
495 if db_path.exists() {
496 let status = Command::new("git")
497 .arg("-C")
498 .arg(db_path)
499 .arg("pull")
500 .stdout(Stdio::inherit())
501 .stderr(Stdio::inherit())
502 .status()?;
503 if !status.success() {
504 return Err(anyhow!(
505 "Failed to pull changes from the remote repository."
506 ));
507 }
508 } else {
509 let status = Command::new("git")
510 .arg("clone")
511 .arg("--depth=1")
512 .arg("--progress")
513 .arg(db_url)
514 .arg(db_path)
515 .stdout(Stdio::inherit())
516 .stderr(Stdio::inherit())
517 .status()?;
518 if !status.success() {
519 return Err(anyhow!("Failed to clone the package repository."));
520 }
521 }
522 Ok(())
523}
524
525fn run_quiet_git_at_path(db_url: &str, db_path: &Path) -> Result<()> {
527 if db_path.exists() {
528 let output = Command::new("git")
529 .arg("-C")
530 .arg(db_path)
531 .arg("pull")
532 .output()?;
533 if !output.status.success() {
534 let stderr = String::from_utf8_lossy(&output.stderr);
535 return Err(anyhow!(
536 "Failed to pull changes from the remote repository. {}",
537 stderr.trim()
538 ));
539 }
540 } else {
541 let output = Command::new("git")
542 .arg("clone")
543 .arg("--depth=1")
544 .arg("--quiet")
545 .arg(db_url)
546 .arg(db_path)
547 .output()?;
548 if !output.status.success() {
549 let stderr = String::from_utf8_lossy(&output.stderr);
550 return Err(anyhow!(
551 "Failed to clone the package repository. {}",
552 stderr.trim()
553 ));
554 }
555 }
556 Ok(())
557}
558
559fn run_non_verbose_at_path(
561 db_url: &str,
562 db_path: &Path,
563 m: Option<&MultiProgress>,
564 pb: Option<&ProgressBar>
565) -> Result<()> {
566 let internal_m;
567 let m_ref = if let Some(m_ptr) = m {
568 m_ptr
569 } else {
570 internal_m = MultiProgress::new();
571 &internal_m
572 };
573
574 let fetch_style = ProgressStyle::default_bar()
575 .template(
576 "{spinner:.green} [{elapsed_precise}] {msg:30.cyan} \
577 [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)"
578 )?
579 .progress_chars("#>-");
580
581 let pb_internal;
582 let pb_to_use = if let Some(p) = pb {
583 p
584 } else {
585 pb_internal = m_ref.add(ProgressBar::new(0));
586 pb_internal.set_style(fetch_style);
587 &pb_internal
588 };
589
590 if db_path.exists() {
591 let repo = Repository::open(db_path)?;
592 let mut remote = repo.find_remote("origin")?;
593
594 let mut cb = RemoteCallbacks::new();
595 let pb_clone = pb_to_use.clone();
596 cb.transfer_progress(move |stats| {
597 if stats.total_deltas() > 0 {
598 pb_clone.set_length(stats.total_deltas() as u64);
599 pb_clone.set_position(stats.indexed_deltas() as u64);
600 }
601 true
602 });
603
604 let head_symref = repo.find_reference("refs/remotes/origin/HEAD")?;
605 let remote_default_ref = head_symref
606 .symbolic_target()?
607 .ok_or_else(|| anyhow!("Remote HEAD is not a symbolic ref"))?;
608 let short_branch_name = remote_default_ref
609 .strip_prefix("refs/remotes/origin/")
610 .ok_or_else(|| {
611 anyhow!(
612 "Could not determine default branch name from remote HEAD"
613 )
614 })?;
615
616 let mut fo = FetchOptions::new();
617 fo.remote_callbacks(cb);
618 pb_to_use.set_message(format!("Fetching {}", db_url.cyan()));
619 remote.fetch(&[short_branch_name], Some(&mut fo), None)?;
620
621 let fetch_head = repo.find_reference("FETCH_HEAD")?;
622 let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
623 let analysis = repo.merge_analysis(&[&fetch_commit])?;
624
625 if analysis.0.is_up_to_date() {
626 } else if analysis.0.is_fast_forward() {
627 let refname = format!("refs/heads/{short_branch_name}");
628 let mut reference = repo.find_reference(&refname)?;
629 reference.set_target(fetch_commit.id(), "Fast-forwarding")?;
630 repo.set_head(&refname)?;
631
632 let mut checkout_builder = CheckoutBuilder::new();
633 let pb_clone = pb_to_use.clone();
634 checkout_builder.force().progress(move |_path, cur, total| {
635 if total > 0 {
636 pb_clone.set_length(total as u64);
637 pb_clone.set_position(cur as u64);
638 }
639 });
640
641 pb_to_use.set_message(format!("Checkout {}", db_url.cyan()));
642 repo.checkout_head(Some(&mut checkout_builder))?;
643 } else {
644 println!(
645 "{}",
646 "Cannot fast-forward. Please run `git pull` manually.".yellow()
647 );
648 }
649 } else {
650 if let Some(parent) = db_path.parent() {
651 fs::create_dir_all(parent)?;
652 }
653
654 let mut cb = RemoteCallbacks::new();
655 let pb_clone = pb_to_use.clone();
656 cb.transfer_progress(move |stats| {
657 if stats.total_deltas() > 0 {
658 pb_clone.set_length(stats.total_deltas() as u64);
659 }
660 pb_clone.set_position(stats.indexed_deltas() as u64);
661 true
662 });
663
664 let mut fo = FetchOptions::new();
665 fo.remote_callbacks(cb);
666
667 let mut checkout_builder = CheckoutBuilder::new();
668 let pb_clone = pb_to_use.clone();
669 checkout_builder.progress(move |_path, cur, total| {
670 if total > 0 {
671 pb_clone.set_length(total as u64);
672 }
673 pb_clone.set_position(cur as u64);
674 });
675
676 pb_to_use.set_message(format!("Cloning {}", db_url.cyan()));
677 RepoBuilder::new()
678 .fetch_options(fo)
679 .with_checkout(checkout_builder)
680 .clone(db_url, db_path)?;
681 }
682
683 Ok(())
684}
685
686fn try_sync_at_path(
689 db_url: &str,
690 db_path: &Path,
691 verbose: bool,
692 m: Option<&MultiProgress>,
693 pb: Option<&ProgressBar>
694) -> Result<()> {
695 let local_path = Path::new(db_url);
697 if local_path.is_dir() {
698 if verbose {
699 let msg = format!("Syncing local registry: {}", db_url.cyan());
700 if let Some(m_ref) = m {
701 let _ = m_ref.println(&msg);
702 } else {
703 println!("{msg}");
704 }
705 }
706
707 if db_path.exists() {
710 if db_path.is_symlink() {
711 if let Ok(target) = fs::read_link(db_path)
713 && target == local_path
714 {
715 return Ok(());
716 }
717 fs::remove_file(db_path)?;
718 } else {
719 fs::remove_dir_all(db_path)?;
721 }
722 }
723
724 if let Some(parent) = db_path.parent() {
725 fs::create_dir_all(parent)?;
726 }
727
728 return core_utils::symlink_dir(local_path, db_path)
729 .map_err(|e| anyhow!("Failed to symlink local registry: {e}"));
730 }
731
732 if offline::is_offline() {
733 if db_path.exists() {
734 let msg = format!(
735 "Zoi is offline. Skipping update for existing registry at {}",
736 db_path.display()
737 );
738 if let Some(m_ref) = m {
739 let _ = m_ref.println(&msg);
740 } else {
741 println!("{msg}");
742 }
743 return Ok(());
744 }
745 return Err(anyhow!(
746 "Cannot sync registry '{db_url}': Zoi is offline and registry is \
747 not cloned."
748 ));
749 }
750 if db_path.exists()
751 && let Ok(repo) = Repository::open(db_path)
752 && let Ok(remote) = repo.find_remote("origin")
753 && let Ok(remote_url) = remote.url()
754 && remote_url != db_url
755 {
756 let msg = format!(
757 "Registry URL has changed from {}. Updating origin to {}.",
758 remote_url.yellow(),
759 db_url.cyan()
760 );
761 if let Some(m_ref) = m {
762 m_ref.println(&msg)?;
763 } else {
764 println!("{msg}");
765 }
766 repo.remote_set_url("origin", db_url)?;
767 }
768
769 if verbose {
770 run_verbose_at_path(db_url, db_path)
771 } else {
772 match run_non_verbose_at_path(db_url, db_path, m, pb) {
773 Ok(()) => Ok(()),
774 Err(libgit_error) => {
775 let msg = format!(
776 "Git progress sync failed for {}: {}. Retrying with \
777 system git...",
778 db_url.yellow(),
779 libgit_error
780 );
781 if let Some(p) = pb {
782 p.println(msg);
783 } else if let Some(m_ref) = m {
784 m_ref.println(msg)?;
785 } else {
786 eprintln!("{msg}");
787 }
788 run_quiet_git_at_path(db_url, db_path)
789 }
790 }
791 }
792}
793
794fn sync_pgp_keys(
800 repo_config: &types::RepoConfig,
801 verbose: bool,
802 pb: Option<&ProgressBar>
803) {
804 if verbose {
805 println!("\n{}", "Syncing PGP keys from repository...".green());
806 }
807
808 if repo_config.pgp.is_empty() {
809 if verbose {
810 println!("No PGP keys defined in repo.yaml.");
811 }
812 return;
813 }
814
815 if let Some(p) = pb {
816 p.set_length(repo_config.pgp.len() as u64);
817 p.set_position(0);
818 p.set_message(format!("PGP Keys {}", repo_config.name.cyan()));
819 }
820
821 for key_info in &repo_config.pgp {
822 let key_source = &key_info.key;
823 let key_name = &key_info.name;
824
825 if let Some(p) = pb {
826 p.set_message(format!("PGP Key: {key_name}"));
827 }
828
829 let result = if key_source.starts_with("http") {
830 pgp::add_key_from_url(key_source, key_name, !verbose)
831 } else if key_source.len() == 40
832 && key_source.chars().all(|c| c.is_ascii_hexdigit())
833 {
834 pgp::add_key_from_fingerprint(key_source, key_name, !verbose)
835 } else {
836 Err(anyhow!(
837 "Invalid key source '{key_source}': must be a URL or a \
838 40-character fingerprint."
839 ))
840 };
841
842 if let Err(e) = result {
843 let err_msg = format!(
844 "{} Failed to import key '{}': {}",
845 "Warning:".yellow(),
846 key_name,
847 e
848 );
849 if let Some(p) = pb {
850 p.println(err_msg);
851 } else {
852 eprintln!("{err_msg}");
853 }
854 }
855 if let Some(p) = pb {
856 p.inc(1);
857 }
858 }
859}
860
861fn sync_pgp_keys_at_path(
863 db_path: &Path,
864 verbose: bool,
865 pb: Option<&ProgressBar>
866) -> Result<()> {
867 if !db_path.join("repo.yaml").exists() {
868 if verbose {
869 println!(
870 "{}",
871 "repo.yaml not found, skipping PGP key sync.".yellow()
872 );
873 }
874 return Ok(());
875 }
876
877 let repo_config = config::read_repo_config(db_path)?;
878 sync_pgp_keys(&repo_config, verbose, pb);
879 Ok(())
880}
881
882fn fetch_handle_by_cloning(url: &str, verbose: bool) -> Result<String> {
885 let temp_dir = Builder::new().prefix("zoi-handle-fetch").tempdir()?;
886 if verbose {
887 println!("Cloning '{}' to fetch handle...", url.cyan());
888 }
889 let status = std::process::Command::new("git")
890 .arg("clone")
891 .arg("--depth=1")
892 .arg(url)
893 .arg(temp_dir.path())
894 .stdout(if verbose {
895 Stdio::inherit()
896 } else {
897 Stdio::null()
898 })
899 .stderr(if verbose {
900 Stdio::inherit()
901 } else {
902 Stdio::null()
903 })
904 .status()?;
905
906 if !status.success() {
907 return Err(anyhow!("git clone failed to fetch handle"));
908 }
909
910 let repo_config = config::read_repo_config(temp_dir.path())?;
911 Ok(repo_config.name)
912}
913
914fn parse_full_repo_url(url: &str) -> Option<(String, String)> {
916 let url = url.trim_end_matches(".git").trim_end_matches('/');
917 if let Some(path) = url.strip_prefix("https://github.com/") {
918 Some(("github".to_string(), path.to_string()))
919 } else if let Some(path) = url.strip_prefix("https://gitlab.com/") {
920 Some(("gitlab".to_string(), path.to_string()))
921 } else {
922 url.strip_prefix("https://codeberg.org/")
923 .map(|path| ("codeberg".to_string(), path.to_string()))
924 }
925}
926
927fn fetch_repo_yaml_content(url: &str) -> Result<String> {
930 if is_repo_yaml_url(url) {
933 let client = core_utils::get_http_client().ok();
934 if let Some(c) = client
935 && let Ok(response) = c.get(url).send()
936 && response.status().is_success()
937 {
938 return Ok(response.text()?);
939 }
940 }
941
942 let (provider, repo_path) = parse_full_repo_url(url).ok_or_else(|| {
943 anyhow!("Unsupported git provider or URL format for direct fetch.")
944 })?;
945
946 let branches = ["main", "master"];
947 for branch in &branches {
948 let repo_yaml_url = match provider.as_str() {
949 "github" => format!(
950 "https://raw.githubusercontent.com/{repo_path}/{branch}/repo.yaml"
951 ),
952 "gitlab" => format!(
953 "https://gitlab.com/{repo_path}/-/raw/{branch}/repo.yaml"
954 ),
955 "codeberg" => format!(
956 "https://codeberg.org/{repo_path}/raw/branch/{branch}/repo.yaml"
957 ),
958 _ => continue,
959 };
960
961 let client = core_utils::get_http_client().ok();
962 if let Some(c) = client
963 && let Ok(response) = c.get(&repo_yaml_url).send()
964 && response.status().is_success()
965 {
966 println!("Found repo.yaml at: {}", repo_yaml_url.cyan());
967 return Ok(response.text()?);
968 }
969 }
970
971 Err(anyhow!(
972 "Could not find 'repo.yaml' in repo '{repo_path}' on branches main or \
973 master."
974 ))
975}
976
977pub fn is_repo_yaml_url(url: &str) -> bool {
984 let url = url.split(['?', '#']).next().unwrap_or(url);
985 let path = url
986 .split("://")
987 .nth(1)
988 .and_then(|rest| rest.find('/').map(|i| &rest[i..]));
989 path.is_some_and(|p| {
990 Path::new(p)
991 .extension()
992 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
993 || Path::new(p)
994 .extension()
995 .is_some_and(|ext| ext.eq_ignore_ascii_case("yml"))
996 })
997}
998
999pub fn fetch_repo_config_over_http(url: &str) -> Result<types::RepoConfig> {
1010 if offline::is_offline() {
1011 return Err(anyhow!("Cannot fetch '{url}': Zoi is offline."));
1012 }
1013
1014 let client = core_utils::get_http_client()?;
1015 let response = client.get(url).send()?;
1016 if !response.status().is_success() {
1017 return Err(anyhow!(
1018 "Failed to fetch '{url}': server responded {}",
1019 response.status()
1020 ));
1021 }
1022
1023 let content = response.text()?;
1024 let repo_config: types::RepoConfig = serde_yaml::from_str(&content)
1025 .map_err(|e| anyhow!("Invalid repo.yaml at '{url}': {e}"))?;
1026
1027 if repo_config.name.is_empty() {
1028 return Err(anyhow!("repo.yaml at '{url}' has no 'name' field."));
1029 }
1030
1031 Ok(repo_config)
1032}
1033
1034fn fetch_handle_for_url(url: &str, verbose: bool) -> Result<String> {
1037 if verbose {
1038 println!(
1039 "Attempting to fetch handle for '{}' directly...",
1040 url.cyan()
1041 );
1042 }
1043
1044 let local_path = Path::new(url);
1046 if local_path.is_dir() {
1047 if verbose {
1048 println!("Detected local directory registry.");
1049 }
1050 let repo_config = config::read_repo_config(local_path)?;
1051 return Ok(repo_config.name);
1052 }
1053
1054 match fetch_repo_yaml_content(url) {
1055 Ok(content) => {
1056 let repo_config: types::RepoConfig =
1057 serde_yaml::from_str(&content)?;
1058 if verbose {
1059 println!("Successfully fetched and parsed repo.yaml.");
1060 }
1061 Ok(repo_config.name)
1062 }
1063 Err(e) => {
1064 if verbose {
1065 println!(
1066 "Direct fetch failed: {}. Falling back to cloning \
1067 repository...",
1068 e.to_string().yellow()
1069 );
1070 }
1071 fetch_handle_by_cloning(url, verbose)
1072 }
1073 }
1074}
1075
1076pub fn resolve_registry_from_url(url: &str) -> Result<types::Registry> {
1086 let repo_config = fetch_repo_config_over_http(url)?;
1087
1088 Ok(types::Registry {
1089 handle: repo_config.name.clone(),
1090 url: url.to_string(),
1091 name: Some(repo_config.name),
1092 description: if repo_config.description.is_empty() {
1093 None
1094 } else {
1095 Some(repo_config.description)
1096 },
1097 advisory_prefix: repo_config.advisory_prefix,
1098 authorities: None
1099 })
1100}
1101
1102enum RegistrySource {
1104 Zrepo {
1107 hash: String
1109 },
1110 Git {
1114 pre_sync_head: Option<git2::Oid>
1116 },
1117 Cached
1120}
1121
1122fn fetch_registry_content(
1135 reg: &mut types::Registry,
1136 target_dir: &Path,
1137 db_root: &Path,
1138 verbose: bool,
1139 fallback: bool,
1140 m: Option<&MultiProgress>,
1141 pb: Option<&ProgressBar>
1142) -> Result<RegistrySource> {
1143 let published_config: Option<types::RepoConfig> =
1146 if is_repo_yaml_url(®.url) && !offline::is_offline() {
1147 match fetch_repo_config_over_http(®.url) {
1148 Ok(rc) => Some(rc),
1149 Err(e) => {
1150 if verbose {
1151 println!(
1152 "{} Could not refresh repo.yaml from {}: {}",
1153 "Warning:".yellow(),
1154 reg.url.yellow(),
1155 e
1156 );
1157 }
1158 config::read_repo_config(target_dir).ok()
1161 }
1162 }
1163 } else {
1164 None
1165 };
1166
1167 if let Some(repo_config) = &published_config {
1168 let zrepo_links = zrepo::candidate_links(repo_config);
1169 if !zrepo_links.is_empty() {
1170 if offline::is_offline() {
1171 if !target_dir.exists() {
1177 return Err(anyhow!(
1178 "Cannot sync registry '{}': Zoi is offline and the \
1179 registry has not been downloaded yet.",
1180 reg.handle
1181 ));
1182 }
1183 if verbose {
1184 println!(
1185 "{}",
1186 "Zoi is offline. Skipping registry snapshot update."
1187 .yellow()
1188 );
1189 }
1190 return Ok(RegistrySource::Cached);
1191 }
1192
1193 sync_pgp_keys(repo_config, verbose, pb);
1196
1197 let authorities = reg.authorities.clone().unwrap_or_default();
1198 if let Some(p) = pb {
1199 p.set_message(format!(
1200 "Fetching snapshot for {}",
1201 reg.handle.cyan()
1202 ));
1203 }
1204
1205 match zrepo::sync_zrepo(
1206 &zrepo_links,
1207 db_root,
1208 ®.handle,
1209 target_dir,
1210 &authorities,
1211 pb,
1212 verbose
1213 ) {
1214 Ok(hash) => return Ok(RegistrySource::Zrepo { hash }),
1215 Err(e) => {
1216 let git_urls: Vec<String> = repo_config
1222 .git
1223 .iter()
1224 .map(|link| link.url.clone())
1225 .collect();
1226
1227 if !fallback || git_urls.is_empty() {
1228 return Err(e);
1229 }
1230
1231 let msg = format!(
1232 "{} Could not fetch the registry snapshot: {}. \
1233 Falling back to the Git repository.",
1234 "Warning:".yellow(),
1235 e
1236 );
1237 if let Some(p) = pb {
1238 p.println(&msg);
1239 } else {
1240 eprintln!("{msg}");
1241 }
1242 }
1243 }
1244 }
1245 }
1246
1247 let mut candidate_urls = vec![reg.url.clone()];
1250
1251 if let Some(repo_config) = &published_config {
1252 candidate_urls.clear();
1253 for git_link in &repo_config.git {
1254 if !candidate_urls.contains(&git_link.url) {
1255 candidate_urls.push(git_link.url.clone());
1256 }
1257 }
1258 if candidate_urls.is_empty() {
1259 return Err(anyhow!(
1260 "Registry '{}' publishes neither 'zrepo' nor 'git' links in \
1261 its repo.yaml, so there is nothing to sync.",
1262 reg.handle
1263 ));
1264 }
1265 } else if fallback
1266 && target_dir.exists()
1267 && let Ok(repo_config) = config::read_repo_config(target_dir)
1268 {
1269 for git_link in
1270 repo_config.git.iter().filter(|g| g.link_type == "mirror")
1271 {
1272 if git_link.url != reg.url
1273 && !candidate_urls.contains(&git_link.url)
1274 {
1275 candidate_urls.push(git_link.url.clone());
1276 }
1277 }
1278 }
1279
1280 let pre_sync_head = match Repository::open(target_dir) {
1283 Ok(repo) => repo.head().ok().and_then(|head| head.target()),
1284 Err(_) => None
1285 };
1286
1287 let mut last_error = None;
1288 for url in candidate_urls {
1289 if let Err(e) = try_sync_at_path(&url, target_dir, verbose, m, pb) {
1290 let msg = format!("Sync with {} failed: {}", url.yellow(), e);
1291 if let Some(p) = pb {
1292 p.println(&msg);
1293 } else if let Some(m_ref) = m {
1294 let _ = m_ref.println(&msg);
1295 } else {
1296 eprintln!("{msg}");
1297 }
1298 last_error = Some(e);
1299 } else {
1300 if url != reg.url {
1301 reg.url = url;
1302 }
1303 return Ok(RegistrySource::Git { pre_sync_head });
1304 }
1305 }
1306
1307 Err(last_error.unwrap_or_else(|| anyhow!("All sync candidates failed.")))
1308}
1309
1310fn sync_registry(
1326 mut reg: types::Registry,
1327 db_root: &Path,
1328 verbose: bool,
1329 fallback: bool,
1330 m: Option<&MultiProgress>
1331) -> Result<(types::Registry, bool)> {
1332 let mut reg_changed = false;
1333
1334 let pb = if !verbose && let Some(m_ref) = m {
1335 let p = m_ref.add(ProgressBar::new(0));
1336 p.set_style(
1337 ProgressStyle::default_bar()
1338 .template(
1339 "{spinner:.green} [{elapsed_precise}] {msg:30.cyan} \
1340 [{bar:40.cyan/blue}] {percent}%"
1341 )?
1342 .progress_chars("#>-")
1343 );
1344 p.enable_steady_tick(std::time::Duration::from_millis(120));
1345 Some(p)
1346 } else {
1347 None
1348 };
1349
1350 if reg.handle.is_empty() {
1351 if let Some(p) = &pb {
1352 p.set_message(format!("Fetching handle for {}", reg.url.cyan()));
1353 }
1354 let handle = fetch_handle_for_url(®.url, verbose)?;
1355 reg.handle = handle;
1356 reg_changed = true;
1357 }
1358
1359 let target_dir = db_root.join(®.handle);
1360 let pb_ref = pb.as_ref();
1361
1362 let source = match fetch_registry_content(
1363 &mut reg,
1364 &target_dir,
1365 db_root,
1366 verbose,
1367 fallback,
1368 m,
1369 pb_ref
1370 ) {
1371 Ok(source) => source,
1372 Err(e) => {
1373 if let Some(p) = &pb {
1374 p.abandon_with_message("Sync failed.".red().to_string());
1375 }
1376 return Err(e);
1377 }
1378 };
1379
1380 let is_local = Path::new(®.url).is_dir();
1381
1382 if matches!(source, RegistrySource::Cached) {
1386 if let Some(p) = pb {
1387 p.finish_with_message(format!("Cached {}", reg.handle.cyan()));
1388 }
1389 return Ok((reg, reg_changed));
1390 }
1391
1392 if let RegistrySource::Git { pre_sync_head } = &source
1393 && !is_local
1394 && let Some(authorities) = ®.authorities
1395 && let Err(e) =
1396 verify_registry_signature(&target_dir, authorities, verbose)
1397 {
1398 let rollback_msg = if let Some(oid) = pre_sync_head {
1399 if let Ok(repo) = Repository::open(&target_dir) {
1400 if let Ok(object) = repo.find_object(*oid, None) {
1401 let mut checkout = CheckoutBuilder::new();
1402 checkout.force();
1403 if repo
1404 .reset(&object, ResetType::Hard, Some(&mut checkout))
1405 .is_ok()
1406 {
1407 "Rolled back to previous signed commit.".to_string()
1408 } else {
1409 "Failed to rollback. Repository may be in an \
1410 inconsistent state."
1411 .to_string()
1412 }
1413 } else {
1414 "Could not find previous HEAD object.".to_string()
1415 }
1416 } else {
1417 "Could not open repository for rollback.".to_string()
1418 }
1419 } else {
1420 let _ = fs::remove_dir_all(&target_dir);
1421 "Removed unsigned clone.".to_string()
1422 };
1423
1424 let msg = format!(
1425 "Security: Registry signature check failed for {}: {}. {}",
1426 reg.url.red(),
1427 e,
1428 rollback_msg.yellow(),
1429 );
1430 if let Some(m_ref) = m {
1431 m_ref.println(&msg)?;
1432 } else {
1433 eprintln!("{msg}");
1434 }
1435 return Err(e);
1436 }
1437
1438 if !is_local {
1439 sync_pgp_keys_at_path(&target_dir, verbose, pb_ref)?;
1440 }
1441
1442 let snapshot_unchanged = match &source {
1446 RegistrySource::Zrepo { hash } => {
1447 match db::open_connection(®.handle) {
1448 Ok(conn) => {
1449 let indexed =
1450 db::get_meta(&conn, zrepo::ZREPO_HASH_META_KEY)
1451 .ok()
1452 .flatten();
1453 indexed.as_deref() == Some(hash.as_str())
1454 }
1455 Err(_) => false
1456 }
1457 }
1458 RegistrySource::Git { .. } | RegistrySource::Cached => false
1459 };
1460
1461 if snapshot_unchanged {
1462 if verbose {
1463 println!(
1464 "{}",
1465 "Registry snapshot is unchanged; keeping the existing index."
1466 .green()
1467 );
1468 }
1469 } else {
1470 let mut db_downloaded = false;
1471 if !is_local
1472 && let Ok(repo_config) = config::read_repo_config(&target_dir)
1473 && let Some(db_url_template) = &repo_config.db
1474 {
1475 let platform = core_utils::get_platform().unwrap_or_default();
1476 let db_url = install_util::resolve_url_placeholders(
1477 db_url_template,
1478 "",
1479 "",
1480 "",
1481 &platform
1482 );
1483
1484 if let Ok(db_path) = db::get_db_path(®.handle) {
1485 if let Some(p) = pb_ref {
1486 p.set_message("Downloading pre-indexed DB...");
1487 } else if verbose {
1488 println!("Downloading pre-indexed DB from {db_url}...");
1489 }
1490 if let Some(parent) = db_path.parent() {
1491 let _ = fs::create_dir_all(parent);
1492 }
1493
1494 let temp_db_path = db_path.with_extension("db.tmp");
1495 if install_util::download_file_with_progress(
1496 &db_url,
1497 &temp_db_path,
1498 pb_ref,
1499 None
1500 )
1501 .is_ok()
1502 {
1503 if fs::rename(&temp_db_path, &db_path).is_ok() {
1504 db_downloaded = true;
1505 if verbose {
1506 println!("Successfully downloaded pre-indexed DB.");
1507 }
1508 }
1509 } else {
1510 let _ = fs::remove_file(&temp_db_path);
1511 }
1512 }
1513 }
1514
1515 if !db_downloaded {
1516 refresh_registry_db(®.handle, &target_dir, m, verbose, pb_ref)?;
1517 }
1518
1519 if let RegistrySource::Zrepo { hash } = &source
1522 && let Ok(conn) = db::open_connection(®.handle)
1523 {
1524 let _ = db::set_meta(&conn, zrepo::ZREPO_HASH_META_KEY, hash);
1525 }
1526 }
1527
1528 if let Ok(repo_config) = config::read_repo_config(&target_dir)
1529 && repo_config.advisory_prefix != reg.advisory_prefix
1530 {
1531 reg.advisory_prefix = repo_config.advisory_prefix;
1532 reg_changed = true;
1533 }
1534
1535 if let Some(p) = pb {
1536 p.finish_with_message(format!("Synced {}", reg.handle.cyan()));
1537 }
1538
1539 Ok((reg, reg_changed))
1540}
1541
1542pub fn run_local(
1551 verbose: bool,
1552 _fallback: bool,
1553 force: bool,
1554 frozen: bool
1555) -> Result<()> {
1556 let local_db_root = zoi_core::sysroot::apply_sysroot(
1557 std::env::current_dir()?
1558 .join(".zoi")
1559 .join("pkgs")
1560 .join("db")
1561 );
1562 fs::create_dir_all(&local_db_root)?;
1563
1564 let registries: Vec<(String, String, String)> = if frozen {
1565 let lockfile = zoi_project::lockfile::read_zoi_lock()?;
1566 lockfile
1567 .registries
1568 .into_iter()
1569 .map(|(handle, lr)| (handle, lr.url, lr.revision))
1570 .collect()
1571 } else {
1572 let project = zoi_project::config::load_with_env(&HashMap::new())?;
1573
1574 project
1575 .registries
1576 .into_iter()
1577 .map(|(handle, spec)| {
1578 let rev =
1579 spec.revision.clone().unwrap_or_else(|| "main".to_string());
1580 (handle, spec.url, rev)
1581 })
1582 .collect()
1583 };
1584
1585 if registries.is_empty() {
1586 println!("{} No registries found in zoi.lua.", "::".bold().yellow());
1587 return Ok(());
1588 }
1589
1590 let m = if verbose {
1591 None
1592 } else {
1593 Some(MultiProgress::new())
1594 };
1595
1596 let results: Vec<((String, String), String)> = registries
1597 .into_par_iter()
1598 .map(|(handle, url, revision)| {
1599 let target_dir = local_db_root.join(&handle);
1600
1601 if force && target_dir.exists() {
1602 fs::remove_dir_all(&target_dir)?;
1603 }
1604
1605 try_sync_at_path(&url, &target_dir, verbose, m.as_ref(), None)?;
1606
1607 if !revision.is_empty() {
1608 if verbose {
1609 println!(
1610 " Checking out revision '{revision}' for registry \
1611 '{handle}'..."
1612 );
1613 }
1614 let status = Command::new("git")
1615 .arg("-C")
1616 .arg(&target_dir)
1617 .arg("checkout")
1618 .arg(&revision)
1619 .stdout(if verbose {
1620 Stdio::inherit()
1621 } else {
1622 Stdio::null()
1623 })
1624 .stderr(if verbose {
1625 Stdio::inherit()
1626 } else {
1627 Stdio::null()
1628 })
1629 .status()
1630 .map_err(|e| anyhow!("Failed to run git checkout: {e}"))?;
1631 if !status.success() {
1632 return Err(anyhow!(
1633 "Failed to checkout revision '{revision}' for \
1634 registry '{handle}'"
1635 ));
1636 }
1637 }
1638
1639 refresh_registry_db(
1640 &handle,
1641 &target_dir,
1642 m.as_ref(),
1643 verbose,
1644 None
1645 )?;
1646
1647 let resolved_hash = if frozen {
1648 revision.clone()
1649 } else if let Ok(repo) = git2::Repository::open(&target_dir) {
1650 repo.head()
1651 .ok()
1652 .and_then(|h| h.target().map(|oid| oid.to_string()))
1653 .unwrap_or(revision.clone())
1654 } else {
1655 revision.clone()
1656 };
1657
1658 Ok(((handle, url), resolved_hash))
1659 })
1660 .collect::<Result<Vec<_>>>()?;
1661
1662 if !frozen {
1663 let mut lockfile = zoi_project::lockfile::read_zoi_lock()?;
1664 for ((handle, url), revision) in results {
1665 lockfile
1666 .registries
1667 .insert(handle, types::LockRegistryV2 { revision, url });
1668 }
1669 lockfile.version = "2".to_string();
1670 zoi_project::lockfile::write_zoi_lock(&mut lockfile)?;
1671 }
1672
1673 println!("{} Local sync complete.", "::".bold().blue());
1674 Ok(())
1675}
1676
1677pub fn run(
1688 verbose: bool,
1689 fallback: bool,
1690 no_pm: bool,
1691 force: bool,
1692 scope: Option<types::Scope>
1693) -> Result<()> {
1694 let merged_config = config::read_config()?;
1695 if force {
1696 println!(
1697 "{} Force sync: removing existing databases and re-syncing from \
1698 scratch...",
1699 "::".bold().yellow()
1700 );
1701 }
1702
1703 let effective_scope = scope.unwrap_or_else(|| {
1704 if core_utils::is_admin() || zoi_core::sysroot::get_sysroot().is_some()
1705 {
1706 types::Scope::System
1707 } else {
1708 types::Scope::User
1709 }
1710 });
1711
1712 if merged_config.protect_db || force {
1713 let db_root = core_utils::get_db_base_dir(effective_scope)?;
1714 if db_root.exists() {
1715 if verbose || force {
1716 println!("Making package database writable...");
1717 }
1718 if let Err(e) = core_utils::set_path_writable(&db_root) {
1719 eprintln!("Warning: could not make db writable: {e}");
1720 }
1721 }
1722 }
1723
1724 let mut config = config::read_user_config()?;
1725 let mut needs_config_update = false;
1726
1727 if config.default_registry.is_none() {
1728 let merged_config = config::read_config()?;
1729 if merged_config.default_registry.is_some() {
1730 config.default_registry = merged_config.default_registry;
1731 }
1732 }
1733
1734 let db_root = core_utils::get_db_base_dir(effective_scope)?;
1735 let mut registries_to_sync = Vec::new();
1736
1737 if let Some(default_reg) = &config.default_registry {
1738 registries_to_sync.push((default_reg.clone(), true));
1739 }
1740
1741 for reg in &config.added_registries {
1742 registries_to_sync.push((reg.clone(), false));
1743 }
1744
1745 if force {
1746 for (reg, _) in ®istries_to_sync {
1747 let db_file = db_root.join(format!("{}.db", reg.handle));
1748 if db_file.exists() {
1749 if verbose {
1750 println!("Removing database: {}", db_file.display());
1751 }
1752 std::fs::remove_file(&db_file)?;
1753 }
1754 let clone_dir = db_root.join(®.handle);
1755 if clone_dir.exists() {
1756 if verbose {
1757 println!(
1758 "Removing clone directory: {}",
1759 clone_dir.display()
1760 );
1761 }
1762 std::fs::remove_dir_all(&clone_dir)?;
1763 }
1764 }
1765 }
1766
1767 if !registries_to_sync.is_empty() {
1768 println!("{} Syncing registries...", "::".bold().blue());
1769 let m = if verbose {
1770 None
1771 } else {
1772 Some(MultiProgress::new())
1773 };
1774
1775 let results: Vec<Result<(types::Registry, bool, bool)>> =
1776 registries_to_sync
1777 .into_par_iter()
1778 .map(|(reg, is_default)| {
1779 let (synced_reg, changed) = sync_registry(
1780 reg,
1781 &db_root,
1782 verbose,
1783 fallback,
1784 m.as_ref()
1785 )?;
1786 Ok((synced_reg, changed, is_default))
1787 })
1788 .collect();
1789
1790 let mut updated_added_registries = Vec::new();
1791 for res in results {
1792 let (reg, changed, is_default) = res?;
1793 if changed {
1794 needs_config_update = true;
1795 }
1796 if is_default {
1797 config.default_registry = Some(reg);
1798 } else {
1799 updated_added_registries.push(reg);
1800 }
1801 }
1802 config.added_registries = updated_added_registries;
1803 }
1804
1805 if !no_pm {
1806 if verbose {
1807 println!("\n{}", "Updating system configuration...".green());
1808 }
1809 config.native_package_manager =
1810 core_utils::get_native_package_manager();
1811 config.package_managers =
1812 Some(core_utils::get_all_available_package_managers());
1813 needs_config_update = true;
1814 if verbose {
1815 println!("System configuration updated.");
1816 }
1817 }
1818
1819 if needs_config_update {
1820 config::write_user_config(&config)?;
1821 }
1822
1823 let _ = config::sync_remote_policy();
1824
1825 sync_git_repos(verbose, effective_scope)?;
1826
1827 if merged_config.protect_db {
1828 let db_root = core_utils::get_db_base_dir(effective_scope)?;
1829 if db_root.exists() {
1830 if verbose {
1831 println!("Making package database read-only...");
1832 }
1833 if let Err(e) = core_utils::set_path_read_only(&db_root) {
1834 eprintln!("Warning: could not make db read-only: {e}");
1835 }
1836 }
1837 }
1838
1839 Ok(())
1840}