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