Skip to main content

zoi_sync/
lib.rs

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