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//!
6//! Registry content can arrive either as a Git clone or as a zstd-compressed
7//! `.zrepo` snapshot. See the [`zrepo`] module for the snapshot transport and
8//! its incremental `.zdelta` updates.
9
10use 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
30/// Rebuilds the `SQLite` metadata database from the raw registry files.
31///
32/// This is the "Indexing Phase" of a sync. It:
33/// - Scans the local Git clone for all `.pkg.lua` and `.sec.yaml` files.
34/// - Parses each file (using the Lua VM where needed) to extract version info,
35///   descriptions, dependencies, and security advisories.
36/// - Fetches remote metadata (sizes and file lists) if configured in
37///   `repo.yaml`.
38/// - Atomic Commit: Updates the `SQLite` tables within a single transaction.
39fn 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
344/// Verifies the PGP signature of the latest commit in a registry repository.
345///
346/// This ensures that the registry state hasn't been tampered with on the
347/// server. If verification fails, the sync is aborted for security reasons.
348fn 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
406/// Synchronizes raw Git repositories that contain Zoi packages.
407///
408/// These are cloned into Zoi's git root and are typically used for
409/// personal or third-party package collections that are not full registries.
410fn 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
493/// Syncs a repository at a path using system git with verbose output.
494fn 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
525/// Syncs a repository at a path using system git with minimal output.
526fn 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
559/// Syncs a repository at a path using libgit2 with progress reporting.
560fn 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
686/// Attempts to sync a repository, handling local paths and falling back to
687/// system git if needed.
688fn 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    // Check if it's a local directory
696    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        // For local registries, we ensure db_path is a symlink to the
708        // local_path
709        if db_path.exists() {
710            if db_path.is_symlink() {
711                // It's already a symlink, check if it points to the right place
712                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                // If it's a directory (from a previous git sync), remove it
720                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
794/// Imports PGP keys from a parsed repository configuration.
795///
796/// A key that cannot be imported is reported but does not fail the sync: the
797/// key may simply already be present, and refusing to sync over that would
798/// make a registry unusable for reasons outside the user's control.
799fn 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
861/// Imports PGP keys from the repo.yaml file in a repository.
862fn 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
882/// Clones a repository to a temporary directory to read its repo.yaml and get
883/// its handle.
884fn 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
914/// Parses a Git URL to identify the provider and repository path.
915fn 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
927/// Attempts to fetch the repo.yaml file directly from common Git providers
928/// without cloning.
929fn fetch_repo_yaml_content(url: &str) -> Result<String> {
930    // A URL that already points at a YAML document is fetched verbatim, which
931    // is how non-Git registries publish their manifest.
932    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
977/// Returns true when `url` looks like it points directly at a `repo.yaml`
978/// document rather than at a Git repository.
979///
980/// Only URLs whose path ends in a YAML extension qualify. Without that guard a
981/// plain repository URL such as `https://github.com/org/repo` would be
982/// requested verbatim and return an HTML page instead of YAML.
983pub 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
999/// Fetches and parses `repo.yaml` from an HTTP(S) URL.
1000///
1001/// This is the entry point for registries that are published as documents
1002/// rather than cloned as Git repositories. The document names the handle and
1003/// describes where the content lives, either as a `.zrepo` snapshot or as a
1004/// set of `git:` links.
1005///
1006/// # Errors
1007///
1008/// Returns an error if the URL cannot be fetched or the YAML is invalid.
1009pub 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
1034/// Resolves the registry handle for a given URL, using direct fetch or cloning
1035/// as needed.
1036fn 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    // Check if it's a local directory
1045    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
1076/// Resolves a registry's full identity from a URL that points at a `repo.yaml`
1077/// document.
1078///
1079/// Used by `zoi registry add <url>` so the registry is recorded with its real
1080/// handle and metadata instead of a URL that only gets resolved at sync time.
1081///
1082/// # Errors
1083///
1084/// Returns an error if the document cannot be fetched or parsed.
1085pub 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
1102/// How a registry's content tree was obtained during a sync.
1103enum RegistrySource {
1104    /// The content came from a zstd-compressed `.zrepo` snapshot whose SHA-256
1105    /// is recorded so an unchanged snapshot can skip re-indexing.
1106    Zrepo {
1107        /// SHA-256 of the `.zrepo` file that is now installed.
1108        hash: String
1109    },
1110    /// The content came from a Git clone. `pre_sync_head` is the commit that
1111    /// was checked out before the sync, used to roll back if signature
1112    /// verification fails.
1113    Git {
1114        /// The HEAD commit oid before this sync ran.
1115        pre_sync_head: Option<git2::Oid>
1116    },
1117    /// Nothing was fetched because Zoi is offline and a usable copy is already
1118    /// on disk. The existing tree is left exactly as it is.
1119    Cached
1120}
1121
1122/// Obtains the registry's content tree at `target_dir`, choosing between the
1123/// `.zrepo` snapshot transport and the Git clone transport.
1124///
1125/// A registry configured by the URL of its `repo.yaml` document is resolved
1126/// from that document first, because the document names both the handle and the
1127/// transport. If it advertises `zrepo:` links the snapshot is used; otherwise
1128/// its `git:` links are used, which keeps every existing registry working
1129/// through exactly the same code path as before.
1130///
1131/// # Errors
1132///
1133/// Returns an error if the content cannot be obtained by any transport.
1134fn 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    // A `repo.yaml` URL is the canonical identity of a non-Git registry and
1144    // the only place its `zrepo:` links can come from, so fetch it up front.
1145    let published_config: Option<types::RepoConfig> =
1146        if is_repo_yaml_url(&reg.url) && !offline::is_offline() {
1147            match fetch_repo_config_over_http(&reg.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                    // Fall back to the previously synced copy so an
1159                    // unreachable manifest does not block the sync.
1160                    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                // Nothing can be fetched, so the previously synced tree is
1172                // the best answer available. Returning here matters: falling
1173                // through would send the sync down the Git path, which would
1174                // then fail signature verification on a directory that is not
1175                // a Git repository at all.
1176                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            // Import the registry's PGP keys before the snapshot is fetched:
1194            // verifying its signature requires them in the keyring.
1195            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                &reg.handle,
1209                target_dir,
1210                &authorities,
1211                pb,
1212                verbose
1213            ) {
1214                Ok(hash) => return Ok(RegistrySource::Zrepo { hash }),
1215                Err(e) => {
1216                    // A registry that publishes a broken or unreachable
1217                    // snapshot is still usable through its Git repository, so
1218                    // try that before giving up. The failure is always
1219                    // reported: a silently downgraded sync would hide a broken
1220                    // publishing pipeline.
1221                    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    // Git transport. A published `repo.yaml` supplies the clone URLs; a plain
1248    // Git URL registry clones itself.
1249    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    // Captured before the pull so a failed signature check can roll back to
1281    // the last known-good commit.
1282    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
1310/// Synchronizes a single registry (default or added) with its remote source.
1311///
1312/// Logic Flow:
1313/// - Handle Resolution: If the handle is missing, it resolves it from the
1314///   remote `repo.yaml`.
1315/// - Transport Selection: A registry publishing `zrepo:` links is synced from
1316///   its `.zrepo` snapshot (using a `.zdelta` patch when one is available);
1317///   otherwise its Git repository is cloned or pulled, trying mirrors defined
1318///   in the `repo.yaml` if the primary fails.
1319/// - Signature Verification: Git registries verify the signature of the latest
1320///   commit; `.zrepo` registries verify the signature of the snapshot.
1321/// - Key Sync: Automatically imports PGP keys defined in the registry's
1322///   `repo.yaml`.
1323/// - Indexing: Triggers `refresh_registry_db` to update the local `SQLite`
1324///   cache, skipped when the snapshot is byte-identical to the indexed one.
1325fn 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(&reg.url, verbose)?;
1355        reg.handle = handle;
1356        reg_changed = true;
1357    }
1358
1359    let target_dir = db_root.join(&reg.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(&reg.url).is_dir();
1381
1382    // Offline with a usable copy already on disk: nothing was fetched, so
1383    // there is nothing to verify, re-import, or re-index. Leave the registry
1384    // exactly as it is.
1385    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) = &reg.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    // A `.zrepo` snapshot that is byte-identical to the one the local index
1443    // was built from cannot change any metadata, so the expensive re-parse of
1444    // every `.pkg.lua` is skipped.
1445    let snapshot_unchanged = match &source {
1446        RegistrySource::Zrepo { hash } => {
1447            match db::open_connection(&reg.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(&reg.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(&reg.handle, &target_dir, m, verbose, pb_ref)?;
1517        }
1518
1519        // Record which snapshot the index now reflects, so the next sync can
1520        // detect a no-op update.
1521        if let RegistrySource::Zrepo { hash } = &source
1522            && let Ok(conn) = db::open_connection(&reg.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
1542/// Performs a project-local sync of registries.
1543///
1544/// In Specification v2, projects can have their own isolated package databases
1545/// stored in `./.zoi/pkgs/db`. This ensures that a project's dependencies
1546/// are reproducible and independent of the user's global registry state.
1547/// # Errors
1548///
1549/// Returns an error if the sync process fails.
1550pub 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
1677/// The primary entry point for synchronizing Zoi registries and system state.
1678///
1679/// This function:
1680/// - Synchronizes all configured global registries.
1681/// - Updates local `SQLite` indexes.
1682/// - Detects and records available native package managers.
1683/// - Synchronizes the remote security policy if configured.
1684/// # Errors
1685///
1686/// Returns an error if the sync process fails.
1687pub 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 &registries_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(&reg.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}