Skip to main content

zoi_sync/
lib.rs

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