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, sysroot, 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 get_db_path() -> Result<PathBuf> {
360 let home_dir = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
361 Ok(sysroot::apply_sysroot(
362 home_dir.join(".zoi").join("pkgs").join("db"),
363 ))
364}
365
366fn get_git_root() -> Result<PathBuf> {
367 let home_dir = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
368 Ok(sysroot::apply_sysroot(
369 home_dir.join(".zoi").join("pkgs").join("git"),
370 ))
371}
372
373fn sync_git_repos(verbose: bool) -> Result<()> {
378 if offline::is_offline() {
379 println!(
380 "\n{}",
381 "Zoi is offline. Skipping sync of external git repositories.".yellow()
382 );
383 return Ok(());
384 }
385 let git_root = get_git_root()?;
386 if !git_root.exists() {
387 return Ok(());
388 }
389
390 if verbose {
391 println!("\n{}", "Syncing external git repositories...".green());
392 }
393
394 let config = config::read_config()?;
395 let configured_git_repos_names: HashSet<String> = config
396 .git_repos
397 .iter()
398 .map(|url| {
399 url.trim_end_matches('/')
400 .split('/')
401 .next_back()
402 .unwrap_or_default()
403 .trim_end_matches(".git")
404 .to_string()
405 })
406 .collect();
407
408 for entry in fs::read_dir(git_root)? {
409 let entry = entry?;
410 let path = entry.path();
411 if path.is_dir() && path.join(".git").exists() {
412 let Some(repo_name_os) = path.file_name() else {
413 continue;
414 };
415 let repo_name = repo_name_os.to_string_lossy();
416
417 if !configured_git_repos_names.contains(repo_name.as_ref()) {
418 println!(
419 "Removing untracked git repository '{}'...",
420 repo_name.yellow()
421 );
422 fs::remove_dir_all(&path)?;
423 continue;
424 }
425
426 println!("Pulling changes for '{}'...", repo_name.cyan());
427
428 let mut cmd = Command::new("git");
429 cmd.arg("-C").arg(&path).arg("pull");
430
431 if verbose {
432 let status = cmd
433 .stdout(Stdio::inherit())
434 .stderr(Stdio::inherit())
435 .status()?;
436 if !status.success() {
437 eprintln!(
438 "{}: Failed to pull changes for '{}'.",
439 "Warning".yellow(),
440 repo_name
441 );
442 }
443 } else {
444 let output = cmd.output()?;
445 if !output.status.success() {
446 eprintln!(
447 "{}: Failed to pull changes for '{}'.",
448 "Warning".yellow(),
449 repo_name
450 );
451 eprintln!("{}", String::from_utf8_lossy(&output.stderr));
452 }
453 }
454 }
455 }
456 Ok(())
457}
458
459fn run_verbose_at_path(db_url: &str, db_path: &Path) -> Result<()> {
460 if db_path.exists() {
461 let status = Command::new("git")
462 .arg("-C")
463 .arg(db_path)
464 .arg("pull")
465 .stdout(Stdio::inherit())
466 .stderr(Stdio::inherit())
467 .status()?;
468 if !status.success() {
469 return Err(anyhow!(
470 "Failed to pull changes from the remote repository."
471 ));
472 }
473 } else {
474 let status = Command::new("git")
475 .arg("clone")
476 .arg("--depth=1")
477 .arg("--progress")
478 .arg(db_url)
479 .arg(db_path)
480 .stdout(Stdio::inherit())
481 .stderr(Stdio::inherit())
482 .status()?;
483 if !status.success() {
484 return Err(anyhow!("Failed to clone the package repository."));
485 }
486 }
487 Ok(())
488}
489
490fn run_quiet_git_at_path(db_url: &str, db_path: &Path) -> Result<()> {
491 if db_path.exists() {
492 let output = Command::new("git")
493 .arg("-C")
494 .arg(db_path)
495 .arg("pull")
496 .output()?;
497 if !output.status.success() {
498 let stderr = String::from_utf8_lossy(&output.stderr);
499 return Err(anyhow!(
500 "Failed to pull changes from the remote repository. {}",
501 stderr.trim()
502 ));
503 }
504 } else {
505 let output = Command::new("git")
506 .arg("clone")
507 .arg("--depth=1")
508 .arg("--quiet")
509 .arg(db_url)
510 .arg(db_path)
511 .output()?;
512 if !output.status.success() {
513 let stderr = String::from_utf8_lossy(&output.stderr);
514 return Err(anyhow!(
515 "Failed to clone the package repository. {}",
516 stderr.trim()
517 ));
518 }
519 }
520 Ok(())
521}
522
523fn run_non_verbose_at_path(
524 db_url: &str,
525 db_path: &Path,
526 m: Option<&MultiProgress>,
527 pb: Option<&ProgressBar>,
528) -> Result<()> {
529 let internal_m;
530 let m_ref = if let Some(m_ptr) = m {
531 m_ptr
532 } else {
533 internal_m = MultiProgress::new();
534 &internal_m
535 };
536
537 let fetch_style = ProgressStyle::default_bar()
538 .template(
539 "{spinner:.green} [{elapsed_precise}] {msg:30.cyan} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)",
540 )?
541 .progress_chars("#>-");
542
543 let pb_internal;
544 let pb_to_use = if let Some(p) = pb {
545 p
546 } else {
547 pb_internal = m_ref.add(ProgressBar::new(0));
548 pb_internal.set_style(fetch_style);
549 &pb_internal
550 };
551
552 if db_path.exists() {
553 let repo = Repository::open(db_path)?;
554 let mut remote = repo.find_remote("origin")?;
555
556 let mut cb = RemoteCallbacks::new();
557 let pb_clone = pb_to_use.clone();
558 cb.transfer_progress(move |stats| {
559 if stats.total_deltas() > 0 {
560 pb_clone.set_length(stats.total_deltas() as u64);
561 pb_clone.set_position(stats.indexed_deltas() as u64);
562 }
563 true
564 });
565
566 let head_symref = repo.find_reference("refs/remotes/origin/HEAD")?;
567 let remote_default_ref = head_symref
568 .symbolic_target()?
569 .ok_or_else(|| anyhow!("Remote HEAD is not a symbolic ref"))?;
570 let short_branch_name = remote_default_ref
571 .strip_prefix("refs/remotes/origin/")
572 .ok_or_else(|| anyhow!("Could not determine default branch name from remote HEAD"))?;
573
574 let mut fo = FetchOptions::new();
575 fo.remote_callbacks(cb);
576 pb_to_use.set_message(format!("Fetching {}", db_url.cyan()));
577 remote.fetch(&[short_branch_name], Some(&mut fo), None)?;
578
579 let fetch_head = repo.find_reference("FETCH_HEAD")?;
580 let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
581 let analysis = repo.merge_analysis(&[&fetch_commit])?;
582
583 if analysis.0.is_up_to_date() {
584 } else if analysis.0.is_fast_forward() {
585 let refname = format!("refs/heads/{}", short_branch_name);
586 let mut reference = repo.find_reference(&refname)?;
587 reference.set_target(fetch_commit.id(), "Fast-forwarding")?;
588 repo.set_head(&refname)?;
589
590 let mut checkout_builder = CheckoutBuilder::new();
591 let pb_clone = pb_to_use.clone();
592 checkout_builder.force().progress(move |_path, cur, total| {
593 if total > 0 {
594 pb_clone.set_length(total as u64);
595 pb_clone.set_position(cur as u64);
596 }
597 });
598
599 pb_to_use.set_message(format!("Checkout {}", db_url.cyan()));
600 repo.checkout_head(Some(&mut checkout_builder))?;
601 } else {
602 println!(
603 "{}",
604 "Cannot fast-forward. Please run `git pull` manually.".yellow()
605 );
606 }
607 } else {
608 if let Some(parent) = db_path.parent() {
609 fs::create_dir_all(parent)?;
610 }
611
612 let mut cb = RemoteCallbacks::new();
613 let pb_clone = pb_to_use.clone();
614 cb.transfer_progress(move |stats| {
615 if stats.total_deltas() > 0 {
616 pb_clone.set_length(stats.total_deltas() as u64);
617 }
618 pb_clone.set_position(stats.indexed_deltas() as u64);
619 true
620 });
621
622 let mut fo = FetchOptions::new();
623 fo.remote_callbacks(cb);
624
625 let mut checkout_builder = CheckoutBuilder::new();
626 let pb_clone = pb_to_use.clone();
627 checkout_builder.progress(move |_path, cur, total| {
628 if total > 0 {
629 pb_clone.set_length(total as u64);
630 }
631 pb_clone.set_position(cur as u64);
632 });
633
634 pb_to_use.set_message(format!("Cloning {}", db_url.cyan()));
635 RepoBuilder::new()
636 .fetch_options(fo)
637 .with_checkout(checkout_builder)
638 .clone(db_url, db_path)?;
639 }
640
641 Ok(())
642}
643
644fn try_sync_at_path(
645 db_url: &str,
646 db_path: &Path,
647 verbose: bool,
648 m: Option<&MultiProgress>,
649 pb: Option<&ProgressBar>,
650) -> Result<()> {
651 if offline::is_offline() {
652 if db_path.exists() {
653 let msg = format!(
654 "Zoi is offline. Skipping update for existing registry at {}",
655 db_path.display()
656 );
657 if let Some(m_ref) = m {
658 let _ = m_ref.println(&msg);
659 } else {
660 println!("{}", msg);
661 }
662 return Ok(());
663 } else {
664 return Err(anyhow!(
665 "Cannot sync registry '{}': Zoi is offline and registry is not cloned.",
666 db_url
667 ));
668 }
669 }
670 if db_path.exists()
671 && let Ok(repo) = Repository::open(db_path)
672 && let Ok(remote) = repo.find_remote("origin")
673 && let Ok(remote_url) = remote.url()
674 && remote_url != db_url
675 {
676 let msg = format!(
677 "Registry URL has changed from {}. Updating origin to {}.",
678 remote_url.yellow(),
679 db_url.cyan()
680 );
681 if let Some(m_ref) = m {
682 m_ref.println(&msg)?;
683 } else {
684 println!("{}", msg);
685 }
686 repo.remote_set_url("origin", db_url)?;
687 }
688
689 if verbose {
690 run_verbose_at_path(db_url, db_path)
691 } else {
692 match run_non_verbose_at_path(db_url, db_path, m, pb) {
693 Ok(()) => Ok(()),
694 Err(libgit_error) => {
695 let msg = format!(
696 "Git progress sync failed for {}: {}. Retrying with system git...",
697 db_url.yellow(),
698 libgit_error
699 );
700 if let Some(p) = pb {
701 p.println(msg);
702 } else if let Some(m_ref) = m {
703 m_ref.println(msg)?;
704 } else {
705 eprintln!("{}", msg);
706 }
707 run_quiet_git_at_path(db_url, db_path)
708 }
709 }
710 }
711}
712
713fn sync_pgp_keys_at_path(db_path: &Path, verbose: bool, pb: Option<&ProgressBar>) -> Result<()> {
714 if verbose {
715 println!("\n{}", "Syncing PGP keys from repository...".green());
716 }
717 if !db_path.join("repo.yaml").exists() {
718 if verbose {
719 println!("{}", "repo.yaml not found, skipping PGP key sync.".yellow());
720 }
721 return Ok(());
722 }
723
724 let repo_config = config::read_repo_config(db_path)?;
725
726 if repo_config.pgp.is_empty() {
727 if verbose {
728 println!("No PGP keys defined in repo.yaml.");
729 }
730 return Ok(());
731 }
732
733 if let Some(p) = pb {
734 p.set_length(repo_config.pgp.len() as u64);
735 p.set_position(0);
736 p.set_message(format!("PGP Keys {}", repo_config.name.cyan()));
737 }
738
739 for key_info in repo_config.pgp {
740 let key_source = &key_info.key;
741 let key_name = &key_info.name;
742
743 if let Some(p) = pb {
744 p.set_message(format!("PGP Key: {}", key_name));
745 }
746
747 let result = if key_source.starts_with("http") {
748 pgp::add_key_from_url(key_source, key_name, !verbose)
749 } else if key_source.len() == 40 && key_source.chars().all(|c| c.is_ascii_hexdigit()) {
750 pgp::add_key_from_fingerprint(key_source, key_name, !verbose)
751 } else {
752 Err(anyhow!(
753 "Invalid key source '{}': must be a URL or a 40-character fingerprint.",
754 key_source
755 ))
756 };
757
758 if let Err(e) = result {
759 let err_msg = format!(
760 "{} Failed to import key '{}': {}",
761 "Warning:".yellow(),
762 key_name,
763 e
764 );
765 if let Some(p) = pb {
766 p.println(err_msg);
767 } else {
768 eprintln!("{}", err_msg);
769 }
770 }
771 if let Some(p) = pb {
772 p.inc(1);
773 }
774 }
775
776 Ok(())
777}
778
779fn fetch_handle_by_cloning(url: &str, verbose: bool) -> Result<String> {
780 let temp_dir = Builder::new().prefix("zoi-handle-fetch").tempdir()?;
781 if verbose {
782 println!("Cloning '{}' to fetch handle...", url.cyan());
783 }
784 let status = std::process::Command::new("git")
785 .arg("clone")
786 .arg("--depth=1")
787 .arg(url)
788 .arg(temp_dir.path())
789 .stdout(if verbose {
790 Stdio::inherit()
791 } else {
792 Stdio::null()
793 })
794 .stderr(if verbose {
795 Stdio::inherit()
796 } else {
797 Stdio::null()
798 })
799 .status()?;
800
801 if !status.success() {
802 return Err(anyhow!("git clone failed to fetch handle"));
803 }
804
805 let repo_config = config::read_repo_config(temp_dir.path())?;
806 Ok(repo_config.name)
807}
808
809fn parse_full_repo_url(url: &str) -> Option<(String, String)> {
810 let url = url.trim_end_matches(".git").trim_end_matches('/');
811 if let Some(path) = url.strip_prefix("https://github.com/") {
812 Some(("github".to_string(), path.to_string()))
813 } else if let Some(path) = url.strip_prefix("https://gitlab.com/") {
814 Some(("gitlab".to_string(), path.to_string()))
815 } else {
816 url.strip_prefix("https://codeberg.org/")
817 .map(|path| ("codeberg".to_string(), path.to_string()))
818 }
819}
820
821fn fetch_repo_yaml_content(url: &str) -> Result<String> {
822 let (provider, repo_path) = parse_full_repo_url(url)
823 .ok_or_else(|| anyhow!("Unsupported git provider or URL format for direct fetch."))?;
824
825 let branches = ["main", "master"];
826 for branch in &branches {
827 let repo_yaml_url = match provider.as_str() {
828 "github" => format!(
829 "https://raw.githubusercontent.com/{}/{}/repo.yaml",
830 repo_path, branch
831 ),
832 "gitlab" => format!(
833 "https://gitlab.com/{}/-/raw/{}/repo.yaml",
834 repo_path, branch
835 ),
836 "codeberg" => format!(
837 "https://codeberg.org/{}/raw/branch/{}/repo.yaml",
838 repo_path, branch
839 ),
840 _ => continue,
841 };
842
843 let client = core_utils::get_http_client().ok();
844 if let Some(c) = client
845 && let Ok(response) = c.get(&repo_yaml_url).send()
846 && response.status().is_success()
847 {
848 println!("Found repo.yaml at: {}", repo_yaml_url.cyan());
849 return Ok(response.text()?);
850 }
851 }
852
853 Err(anyhow!(
854 "Could not find 'repo.yaml' in repo '{}' on branches main or master.",
855 repo_path
856 ))
857}
858
859fn fetch_handle_for_url(url: &str, verbose: bool) -> Result<String> {
860 if verbose {
861 println!(
862 "Attempting to fetch handle for '{}' directly...",
863 url.cyan()
864 );
865 }
866 match fetch_repo_yaml_content(url) {
867 Ok(content) => {
868 let repo_config: types::RepoConfig = serde_yaml::from_str(&content)?;
869 if verbose {
870 println!("Successfully fetched and parsed repo.yaml.");
871 }
872 Ok(repo_config.name)
873 }
874 Err(e) => {
875 if verbose {
876 println!(
877 "Direct fetch failed: {}. Falling back to cloning repository...",
878 e.to_string().yellow()
879 );
880 }
881 fetch_handle_by_cloning(url, verbose)
882 }
883 }
884}
885
886fn sync_registry(
897 mut reg: types::Registry,
898 db_root: &Path,
899 verbose: bool,
900 fallback: bool,
901 m: Option<&MultiProgress>,
902) -> Result<(types::Registry, bool)> {
903 let mut reg_changed = false;
904
905 let pb = if !verbose && let Some(m_ref) = m {
906 let p = m_ref.add(ProgressBar::new(0));
907 p.set_style(
908 ProgressStyle::default_bar()
909 .template(
910 "{spinner:.green} [{elapsed_precise}] {msg:30.cyan} [{bar:40.cyan/blue}] {percent}%",
911 )?
912 .progress_chars("#>-"),
913 );
914 p.enable_steady_tick(std::time::Duration::from_millis(120));
915 Some(p)
916 } else {
917 None
918 };
919
920 if reg.handle.is_empty() {
921 if let Some(p) = &pb {
922 p.set_message(format!("Fetching handle for {}", reg.url.cyan()));
923 }
924 let handle = fetch_handle_for_url(®.url, verbose)?;
925 reg.handle = handle;
926 reg_changed = true;
927 }
928
929 let target_dir = db_root.join(®.handle);
930
931 let mut candidate_urls = vec![reg.url.clone()];
932
933 if fallback
934 && target_dir.exists()
935 && let Ok(repo_config) = config::read_repo_config(&target_dir)
936 {
937 for git_link in repo_config.git.iter().filter(|g| g.link_type == "mirror") {
938 if git_link.url != reg.url && !candidate_urls.contains(&git_link.url) {
939 candidate_urls.push(git_link.url.clone());
940 }
941 }
942 }
943
944 let pre_sync_head = match Repository::open(&target_dir) {
945 Ok(repo) => match repo.head() {
946 Ok(head) => head.target(),
947 Err(_) => None,
948 },
949 Err(_) => None,
950 };
951
952 let mut sync_success = false;
953 let mut last_error = None;
954
955 for url in candidate_urls {
956 if let Err(e) = try_sync_at_path(&url, &target_dir, verbose, m, pb.as_ref()) {
957 let msg = format!("Sync with {} failed: {}", url.yellow(), e);
958 if let Some(p) = &pb {
959 p.println(&msg);
960 } else if let Some(m_ref) = m {
961 let _ = m_ref.println(&msg);
962 } else {
963 eprintln!("{}", msg);
964 }
965 last_error = Some(e);
966 } else {
967 if url != reg.url {
968 reg.url = url;
969 reg_changed = true;
970 }
971 sync_success = true;
972 break;
973 }
974 }
975
976 if !sync_success {
977 let e = last_error.unwrap_or_else(|| anyhow!("All sync candidates failed."));
978 if let Some(p) = &pb {
979 p.abandon_with_message("Sync failed.".red().to_string());
980 }
981 return Err(e);
982 } else {
983 if let Some(authorities) = ®.authorities
984 && let Err(e) = verify_registry_signature(&target_dir, authorities, verbose)
985 {
986 let rollback_msg = if let Some(oid) = pre_sync_head {
987 if let Ok(repo) = Repository::open(&target_dir) {
988 if let Ok(object) = repo.find_object(oid, None) {
989 let mut checkout = CheckoutBuilder::new();
990 checkout.force();
991 if repo
992 .reset(&object, ResetType::Hard, Some(&mut checkout))
993 .is_ok()
994 {
995 "Rolled back to previous signed commit.".to_string()
996 } else {
997 "Failed to rollback. Repository may be in an inconsistent state."
998 .to_string()
999 }
1000 } else {
1001 "Could not find previous HEAD object.".to_string()
1002 }
1003 } else {
1004 "Could not open repository for rollback.".to_string()
1005 }
1006 } else {
1007 let _ = fs::remove_dir_all(&target_dir);
1008 "Removed unsigned clone.".to_string()
1009 };
1010
1011 let msg = format!(
1012 "Security: Registry signature check failed for {}: {}. {}",
1013 reg.url.red(),
1014 e,
1015 rollback_msg.yellow(),
1016 );
1017 if let Some(m_ref) = m {
1018 m_ref.println(&msg)?;
1019 } else {
1020 eprintln!("{}", msg);
1021 }
1022 return Err(e);
1023 }
1024
1025 sync_pgp_keys_at_path(&target_dir, verbose, pb.as_ref())?;
1026
1027 let mut db_downloaded = false;
1028 if let Ok(repo_config) = config::read_repo_config(&target_dir)
1029 && let Some(db_url_template) = &repo_config.db
1030 {
1031 let platform = core_utils::get_platform().unwrap_or_default();
1032 let db_url =
1033 install_util::resolve_url_placeholders(db_url_template, "", "", "", &platform);
1034
1035 if let Ok(db_path) = db::get_db_path(®.handle) {
1036 if let Some(p) = pb.as_ref() {
1037 p.set_message("Downloading pre-indexed DB...");
1038 } else if verbose {
1039 println!("Downloading pre-indexed DB from {}...", db_url);
1040 }
1041 if let Some(parent) = db_path.parent() {
1042 let _ = fs::create_dir_all(parent);
1043 }
1044
1045 let temp_db_path = db_path.with_extension("db.tmp");
1046 if install_util::download_file_with_progress(
1047 &db_url,
1048 &temp_db_path,
1049 pb.as_ref(),
1050 None,
1051 )
1052 .is_ok()
1053 {
1054 if fs::rename(&temp_db_path, &db_path).is_ok() {
1055 db_downloaded = true;
1056 if verbose {
1057 println!("Successfully downloaded pre-indexed DB.");
1058 }
1059 }
1060 } else {
1061 let _ = fs::remove_file(&temp_db_path);
1062 }
1063 }
1064 }
1065
1066 if !db_downloaded {
1067 refresh_registry_db(®.handle, &target_dir, m, verbose, pb.as_ref())?;
1068 }
1069
1070 if let Ok(repo_config) = config::read_repo_config(&target_dir)
1071 && repo_config.advisory_prefix != reg.advisory_prefix
1072 {
1073 reg.advisory_prefix = repo_config.advisory_prefix;
1074 reg_changed = true;
1075 }
1076
1077 if let Some(p) = pb {
1078 p.finish_with_message(format!("Synced {}", reg.handle.cyan()));
1079 }
1080 }
1081
1082 Ok((reg, reg_changed))
1083}
1084
1085pub fn run_local(verbose: bool, _fallback: bool, force: bool, frozen: bool) -> Result<()> {
1091 let local_db_root = std::env::current_dir()?
1092 .join(".zoi")
1093 .join("pkgs")
1094 .join("db");
1095 fs::create_dir_all(&local_db_root)?;
1096
1097 let registries: Vec<(String, String, String)> = if frozen {
1098 let lockfile = zoi_project::lockfile::read_zoi_lock()?;
1099 lockfile
1100 .registries
1101 .into_iter()
1102 .map(|(handle, lr)| (handle, lr.url, lr.revision))
1103 .collect()
1104 } else {
1105 let project = zoi_project::config::load_with_env(HashMap::new())?;
1106
1107 project
1108 .registries
1109 .into_iter()
1110 .map(|(handle, spec)| {
1111 let rev = spec.revision.clone().unwrap_or_else(|| "main".to_string());
1112 (handle, spec.url, rev)
1113 })
1114 .collect()
1115 };
1116
1117 if registries.is_empty() {
1118 println!("{} No registries found in zoi.lua.", "::".bold().yellow());
1119 return Ok(());
1120 }
1121
1122 let m = if verbose {
1123 None
1124 } else {
1125 Some(MultiProgress::new())
1126 };
1127
1128 let results: Vec<((String, String), String)> = registries
1129 .into_par_iter()
1130 .map(|(handle, url, revision)| {
1131 let target_dir = local_db_root.join(&handle);
1132
1133 if force && target_dir.exists() {
1134 fs::remove_dir_all(&target_dir)?;
1135 }
1136
1137 try_sync_at_path(&url, &target_dir, verbose, m.as_ref(), None)?;
1138
1139 if !revision.is_empty() {
1140 if verbose {
1141 println!(
1142 " Checking out revision '{}' for registry '{}'...",
1143 revision, handle
1144 );
1145 }
1146 let status = Command::new("git")
1147 .arg("-C")
1148 .arg(&target_dir)
1149 .arg("checkout")
1150 .arg(&revision)
1151 .stdout(if verbose {
1152 Stdio::inherit()
1153 } else {
1154 Stdio::null()
1155 })
1156 .stderr(if verbose {
1157 Stdio::inherit()
1158 } else {
1159 Stdio::null()
1160 })
1161 .status()
1162 .map_err(|e| anyhow!("Failed to run git checkout: {}", e))?;
1163 if !status.success() {
1164 return Err(anyhow!(
1165 "Failed to checkout revision '{}' for registry '{}'",
1166 revision,
1167 handle
1168 ));
1169 }
1170 }
1171
1172 refresh_registry_db(&handle, &target_dir, m.as_ref(), verbose, None)?;
1173
1174 let resolved_hash = if frozen {
1175 revision.clone()
1176 } else if let Ok(repo) = git2::Repository::open(&target_dir) {
1177 repo.head()
1178 .ok()
1179 .and_then(|h| h.target().map(|oid| oid.to_string()))
1180 .unwrap_or(revision.clone())
1181 } else {
1182 revision.clone()
1183 };
1184
1185 Ok(((handle, url), resolved_hash))
1186 })
1187 .collect::<Result<Vec<_>>>()?;
1188
1189 if !frozen {
1190 let mut lockfile = zoi_project::lockfile::read_zoi_lock()?;
1191 for ((handle, url), revision) in results {
1192 lockfile
1193 .registries
1194 .insert(handle, types::LockRegistryV2 { revision, url });
1195 }
1196 lockfile.version = "2".to_string();
1197 zoi_project::lockfile::write_zoi_lock(&mut lockfile)?;
1198 }
1199
1200 println!("{} Local sync complete.", "::".bold().blue());
1201 Ok(())
1202}
1203
1204pub fn run(verbose: bool, fallback: bool, no_pm: bool, force: bool) -> Result<()> {
1212 let merged_config = config::read_config()?;
1213 if force {
1214 println!(
1215 "{} Force sync: removing existing databases and re-syncing from scratch...",
1216 "::".bold().yellow()
1217 );
1218 }
1219 if merged_config.protect_db || force {
1220 let db_root = get_db_path()?;
1221 if db_root.exists() {
1222 if verbose || force {
1223 println!("Making package database writable...");
1224 }
1225 if let Err(e) = core_utils::set_path_writable(&db_root) {
1226 eprintln!("Warning: could not make db writable: {}", e);
1227 }
1228 }
1229 }
1230
1231 let mut config = config::read_user_config()?;
1232 let mut needs_config_update = false;
1233
1234 if config.default_registry.is_none() {
1235 let merged_config = config::read_config()?;
1236 if merged_config.default_registry.is_some() {
1237 config.default_registry = merged_config.default_registry;
1238 }
1239 }
1240
1241 let db_root = get_db_path()?;
1242 let mut registries_to_sync = Vec::new();
1243
1244 if let Some(default_reg) = &config.default_registry {
1245 registries_to_sync.push((default_reg.clone(), true));
1246 }
1247
1248 for reg in &config.added_registries {
1249 registries_to_sync.push((reg.clone(), false));
1250 }
1251
1252 if force {
1253 for (reg, _) in ®istries_to_sync {
1254 let db_file = db_root.join(format!("{}.db", reg.handle));
1255 if db_file.exists() {
1256 if verbose {
1257 println!("Removing database: {}", db_file.display());
1258 }
1259 std::fs::remove_file(&db_file)?;
1260 }
1261 let clone_dir = db_root.join(®.handle);
1262 if clone_dir.exists() {
1263 if verbose {
1264 println!("Removing clone directory: {}", clone_dir.display());
1265 }
1266 std::fs::remove_dir_all(&clone_dir)?;
1267 }
1268 }
1269 }
1270
1271 if !registries_to_sync.is_empty() {
1272 println!("{} Syncing registries...", "::".bold().blue());
1273 let m = if verbose {
1274 None
1275 } else {
1276 Some(MultiProgress::new())
1277 };
1278
1279 let results: Vec<Result<(types::Registry, bool, bool)>> = registries_to_sync
1280 .into_par_iter()
1281 .map(|(reg, is_default)| {
1282 let (synced_reg, changed) =
1283 sync_registry(reg, &db_root, verbose, fallback, m.as_ref())?;
1284 Ok((synced_reg, changed, is_default))
1285 })
1286 .collect();
1287
1288 let mut updated_added_registries = Vec::new();
1289 for res in results {
1290 let (reg, changed, is_default) = res?;
1291 if changed {
1292 needs_config_update = true;
1293 }
1294 if is_default {
1295 config.default_registry = Some(reg);
1296 } else {
1297 updated_added_registries.push(reg);
1298 }
1299 }
1300 config.added_registries = updated_added_registries;
1301 }
1302
1303 if !no_pm {
1304 if verbose {
1305 println!("\n{}", "Updating system configuration...".green());
1306 }
1307 config.native_package_manager = core_utils::get_native_package_manager();
1308 config.package_managers = Some(core_utils::get_all_available_package_managers());
1309 needs_config_update = true;
1310 if verbose {
1311 println!("System configuration updated.");
1312 }
1313 }
1314
1315 if needs_config_update {
1316 config::write_user_config(&config)?;
1317 }
1318
1319 let _ = config::sync_remote_policy();
1320
1321 sync_git_repos(verbose)?;
1322
1323 if merged_config.protect_db {
1324 let db_root = get_db_path()?;
1325 if db_root.exists() {
1326 if verbose {
1327 println!("Making package database read-only...");
1328 }
1329 if let Err(e) = core_utils::set_path_read_only(&db_root) {
1330 eprintln!("Warning: could not make db read-only: {}", e);
1331 }
1332 }
1333 }
1334
1335 Ok(())
1336}