Skip to main content

zoi_core/
config.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Result, anyhow};
7use colored::Colorize;
8use serde_yaml::Value;
9
10use crate::sysroot::apply_sysroot;
11use crate::types::{Config, Registry, RepoConfig};
12use crate::utils::{get_db_root, get_user_config_dir, get_user_data_dir};
13
14/// Returns the default registry URL for Zoi.
15///
16/// This is typically hardcoded or provided via an environment variable during
17/// the build.
18pub fn get_default_registry() -> String {
19    option_env!("ZOI_DEFAULT_REGISTRY")
20        .unwrap_or("community")
21        .to_string()
22}
23
24/// Returns the built-in PGP authorities for the default registry.
25pub fn get_builtin_authorities() -> Vec<String> {
26    let auth_str = option_env!("ZOI_BUILTIN_AUTHORITIES").unwrap_or_default();
27    if auth_str.is_empty() {
28        Vec::new()
29    } else {
30        auth_str.split(',').map(ToString::to_string).collect()
31    }
32}
33
34/// Returns the path to the system-wide configuration file.
35fn get_system_config_path() -> PathBuf {
36    crate::utils::get_system_config_dir().join("config.yaml")
37}
38
39/// Returns the path to the user-specific configuration file.
40fn get_user_config_path() -> Result<PathBuf> {
41    Ok(get_user_config_dir()?.join("config.yaml"))
42}
43
44/// Returns the path to the project-local configuration file.
45fn get_project_config_path() -> Result<PathBuf> {
46    let current_dir = std::env::current_dir()?;
47    Ok(apply_sysroot(current_dir.join(".zoi"))
48        .join("pkgs")
49        .join("config.yaml"))
50}
51
52/// Returns the path to the directory where git repositories are cloned.
53fn get_git_root() -> Result<PathBuf> {
54    Ok(get_user_data_dir()?.join("pkgs").join("git"))
55}
56
57/// Returns the path to the remote policy cache file.
58fn get_remote_policy_cache_path() -> PathBuf {
59    crate::utils::get_system_cache_dir().join("policy.cache.yaml")
60}
61
62/// Reads a YAML value from the specified path.
63fn read_yaml_value(path: &Path) -> Result<Value> {
64    if !path.exists() {
65        return Ok(Value::Null);
66    }
67    let content = fs::read_to_string(path)?;
68    serde_yaml::from_str(&content).map_err(Into::into)
69}
70
71/// Reads a Zoi configuration from the specified path.
72fn read_config_from_path(path: &Path) -> Result<Config> {
73    if !path.exists() {
74        return Ok(Config::default());
75    }
76    let content = fs::read_to_string(path)?;
77    serde_yaml::from_str(&content).map_err(Into::into)
78}
79
80/// Loads and merges configuration from system, user, and project-local paths.
81///
82/// Zoi uses a hierarchical configuration model with the following precedence:
83/// - System: (`/etc/zoi/config.yaml`) - Defines global machine policy.
84/// - User: (`$XDG_CONFIG_HOME/zoi/config.yaml`) - Defines user preferences.
85/// - Project: (`./.zoi/pkgs/config.yaml`) - Local overrides for a specific
86///   project.
87///
88/// Policy Enforcement: If a field is marked as `unoverridable` in the
89/// system-level policy, Zoi will ignore any overrides found in user or project
90/// configs.
91///
92/// # Errors
93///
94/// Returns an error if any of the configuration files cannot be read or parsed.
95pub fn read_config() -> Result<Config> {
96    let system_path = get_system_config_path();
97    let system_val = read_yaml_value(&system_path)?;
98    let user_val = read_yaml_value(&get_user_config_path()?)?;
99    let project_val = read_yaml_value(&get_project_config_path()?)?;
100
101    let mut system_cfg: Config =
102        serde_yaml::from_value(system_val.clone()).unwrap_or_default();
103    let user_cfg: Config =
104        serde_yaml::from_value(user_val.clone()).unwrap_or_default();
105    let project_cfg: Config =
106        serde_yaml::from_value(project_val.clone()).unwrap_or_default();
107
108    let cache_path = get_remote_policy_cache_path();
109    if cache_path.exists()
110        && let Ok(cache_content) = fs::read_to_string(&cache_path)
111        && let Ok(remote_policy) =
112            serde_yaml::from_str::<crate::types::Policy>(&cache_content)
113    {
114        merge_policies(&mut system_cfg.policy, &remote_policy);
115    }
116
117    let system_policy = system_cfg.policy.clone();
118    let mut merged_cfg = Config {
119        policy: system_policy.clone(),
120        ..Default::default()
121    };
122
123    merged_cfg.repos = system_cfg.repos;
124    if !system_policy.repos_unoverridable {
125        merged_cfg.repos.extend(user_cfg.repos);
126        merged_cfg.repos.extend(project_cfg.repos);
127    }
128    merged_cfg.repos.sort();
129    merged_cfg.repos.dedup();
130
131    merged_cfg.added_registries = system_cfg.added_registries;
132    if !system_policy.added_registries_unoverridable {
133        merged_cfg
134            .added_registries
135            .extend(user_cfg.added_registries);
136        merged_cfg
137            .added_registries
138            .extend(project_cfg.added_registries);
139    }
140    let mut seen_registries = HashSet::new();
141    merged_cfg
142        .added_registries
143        .retain(|r| seen_registries.insert(r.url.clone()));
144
145    merged_cfg.git_repos = system_cfg.git_repos;
146    if !system_policy.git_repos_unoverridable {
147        merged_cfg.git_repos.extend(user_cfg.git_repos);
148        merged_cfg.git_repos.extend(project_cfg.git_repos);
149    }
150    merged_cfg.git_repos.sort();
151    merged_cfg.git_repos.dedup();
152
153    merged_cfg.package_managers = project_cfg
154        .package_managers
155        .or(user_cfg.package_managers)
156        .or(system_cfg.package_managers);
157    merged_cfg.native_package_manager = project_cfg
158        .native_package_manager
159        .or(user_cfg.native_package_manager)
160        .or(system_cfg.native_package_manager);
161    merged_cfg.registry = project_cfg
162        .registry
163        .or(user_cfg.registry)
164        .or(system_cfg.registry);
165
166    merged_cfg.remote_policy = system_cfg.remote_policy;
167
168    if project_val.get("telemetry_enabled").is_some()
169        && !system_policy.telemetry_enabled_unoverridable
170    {
171        merged_cfg.telemetry_enabled = project_cfg.telemetry_enabled;
172    } else if user_val.get("telemetry_enabled").is_some()
173        && !system_policy.telemetry_enabled_unoverridable
174    {
175        merged_cfg.telemetry_enabled = user_cfg.telemetry_enabled;
176    } else {
177        merged_cfg.telemetry_enabled = system_cfg.telemetry_enabled;
178    }
179
180    if project_val.get("audit_log_enabled").is_some()
181        && !system_policy.audit_log_enabled_unoverridable
182    {
183        merged_cfg.audit_log_enabled = project_cfg.audit_log_enabled;
184    } else if user_val.get("audit_log_enabled").is_some()
185        && !system_policy.audit_log_enabled_unoverridable
186    {
187        merged_cfg.audit_log_enabled = user_cfg.audit_log_enabled;
188    } else {
189        merged_cfg.audit_log_enabled = system_cfg.audit_log_enabled;
190    }
191
192    if project_val.get("rollback_enabled").is_some()
193        && !system_policy.rollback_enabled_unoverridable
194    {
195        merged_cfg.rollback_enabled = project_cfg.rollback_enabled;
196    } else if user_val.get("rollback_enabled").is_some()
197        && !system_policy.rollback_enabled_unoverridable
198    {
199        merged_cfg.rollback_enabled = user_cfg.rollback_enabled;
200    } else {
201        merged_cfg.rollback_enabled = system_cfg.rollback_enabled;
202    }
203
204    if project_val.get("default_registry").is_some()
205        && !system_policy.default_registry_unoverridable
206    {
207        merged_cfg.default_registry = project_cfg.default_registry;
208    } else if user_val.get("default_registry").is_some()
209        && !system_policy.default_registry_unoverridable
210    {
211        merged_cfg.default_registry = user_cfg.default_registry;
212    } else {
213        merged_cfg.default_registry = system_cfg.default_registry;
214    }
215
216    if project_val.get("jobs").is_some() && !system_policy.jobs_unoverridable {
217        merged_cfg.jobs = project_cfg.jobs;
218    } else if user_val.get("jobs").is_some()
219        && !system_policy.jobs_unoverridable
220    {
221        merged_cfg.jobs = user_cfg.jobs;
222    } else {
223        merged_cfg.jobs = system_cfg.jobs;
224    }
225
226    if project_val.get("protect_db").is_some()
227        && !system_policy.protect_db_unoverridable
228    {
229        merged_cfg.protect_db = project_cfg.protect_db;
230    } else if user_val.get("protect_db").is_some()
231        && !system_policy.protect_db_unoverridable
232    {
233        merged_cfg.protect_db = user_cfg.protect_db;
234    } else {
235        merged_cfg.protect_db = system_cfg.protect_db;
236    }
237
238    if project_val.get("max_resolution_depth").is_some()
239        && !system_policy.max_resolution_depth_unoverridable
240    {
241        merged_cfg.max_resolution_depth = project_cfg.max_resolution_depth;
242    } else if user_val.get("max_resolution_depth").is_some()
243        && !system_policy.max_resolution_depth_unoverridable
244    {
245        merged_cfg.max_resolution_depth = user_cfg.max_resolution_depth;
246    } else {
247        merged_cfg.max_resolution_depth = system_cfg.max_resolution_depth;
248    }
249
250    if project_val.get("offline_mode").is_some()
251        && !system_policy.offline_mode_unoverridable
252    {
253        merged_cfg.offline_mode = project_cfg.offline_mode;
254    } else if user_val.get("offline_mode").is_some()
255        && !system_policy.offline_mode_unoverridable
256    {
257        merged_cfg.offline_mode = user_cfg.offline_mode;
258    } else {
259        merged_cfg.offline_mode = system_cfg.offline_mode;
260    }
261
262    if project_val
263        .get("policy")
264        .and_then(|p| p.get("advisory_enforcement_unoverridable"))
265        .is_some()
266        && !system_policy.advisory_enforcement_unoverridable
267    {
268        merged_cfg.policy.advisory_enforcement_unoverridable =
269            project_cfg.policy.advisory_enforcement_unoverridable;
270    } else if user_val
271        .get("policy")
272        .and_then(|p| p.get("advisory_enforcement_unoverridable"))
273        .is_some()
274        && !system_policy.advisory_enforcement_unoverridable
275    {
276        merged_cfg.policy.advisory_enforcement_unoverridable =
277            user_cfg.policy.advisory_enforcement_unoverridable;
278    } else {
279        merged_cfg.policy.advisory_enforcement_unoverridable =
280            system_cfg.policy.advisory_enforcement_unoverridable;
281    }
282
283    merged_cfg.versions = system_cfg.versions;
284    merged_cfg.versions.extend(user_cfg.versions);
285    merged_cfg.versions.extend(project_cfg.versions);
286
287    merged_cfg.pkg_dirs = system_cfg.pkg_dirs;
288    if !system_policy.pkg_dirs_unoverridable {
289        merged_cfg.pkg_dirs.extend(user_cfg.pkg_dirs);
290        merged_cfg.pkg_dirs.extend(project_cfg.pkg_dirs);
291    }
292    merged_cfg.pkg_dirs.sort();
293    merged_cfg.pkg_dirs.dedup();
294
295    merged_cfg.cache_mirrors = system_cfg.cache_mirrors;
296    if !system_policy.cache_mirrors_unoverridable {
297        merged_cfg.cache_mirrors.extend(user_cfg.cache_mirrors);
298        merged_cfg.cache_mirrors.extend(project_cfg.cache_mirrors);
299    }
300    merged_cfg.cache_mirrors.sort();
301    merged_cfg.cache_mirrors.dedup();
302
303    if !system_policy.allow_deny_lists_unoverridable {
304        if project_cfg.policy.allowed_licenses.is_some() {
305            merged_cfg.policy.allowed_licenses =
306                project_cfg.policy.allowed_licenses;
307        } else if user_cfg.policy.allowed_licenses.is_some() {
308            merged_cfg.policy.allowed_licenses =
309                user_cfg.policy.allowed_licenses;
310        }
311        if project_cfg.policy.denied_licenses.is_some() {
312            merged_cfg.policy.denied_licenses =
313                project_cfg.policy.denied_licenses;
314        } else if user_cfg.policy.denied_licenses.is_some() {
315            merged_cfg.policy.denied_licenses = user_cfg.policy.denied_licenses;
316        }
317        if project_cfg.policy.allowed_packages.is_some() {
318            merged_cfg.policy.allowed_packages =
319                project_cfg.policy.allowed_packages;
320        } else if user_cfg.policy.allowed_packages.is_some() {
321            merged_cfg.policy.allowed_packages =
322                user_cfg.policy.allowed_packages;
323        }
324        if project_cfg.policy.denied_packages.is_some() {
325            merged_cfg.policy.denied_packages =
326                project_cfg.policy.denied_packages;
327        } else if user_cfg.policy.denied_packages.is_some() {
328            merged_cfg.policy.denied_packages = user_cfg.policy.denied_packages;
329        }
330        if project_cfg.policy.allowed_repos.is_some() {
331            merged_cfg.policy.allowed_repos = project_cfg.policy.allowed_repos;
332        } else if user_cfg.policy.allowed_repos.is_some() {
333            merged_cfg.policy.allowed_repos = user_cfg.policy.allowed_repos;
334        }
335        if project_cfg.policy.denied_repos.is_some() {
336            merged_cfg.policy.denied_repos = project_cfg.policy.denied_repos;
337        } else if user_cfg.policy.denied_repos.is_some() {
338            merged_cfg.policy.denied_repos = user_cfg.policy.denied_repos;
339        }
340    }
341
342    if !system_policy.signature_enforcement_unoverridable {
343        if project_cfg.policy.signature_enforcement.is_some() {
344            merged_cfg.policy.signature_enforcement =
345                project_cfg.policy.signature_enforcement;
346        } else if user_cfg.policy.signature_enforcement.is_some() {
347            merged_cfg.policy.signature_enforcement =
348                user_cfg.policy.signature_enforcement;
349        }
350    }
351
352    if let Some(url) = merged_cfg.registry.take()
353        && merged_cfg.default_registry.is_none()
354    {
355        merged_cfg.default_registry = Some(Registry {
356            handle: String::new(),
357            url,
358            name: None,
359            description: None,
360            advisory_prefix: None,
361            authorities: None
362        });
363    }
364
365    if merged_cfg.default_registry.is_none() {
366        // Derive the default registry from the single built-in `set` registry
367        // (Zoidberg) when none has been configured by the user.
368        let set_registry = crate::builtin::registry::get_set();
369        let (handle, url, advisory_prefix, authorities) = match set_registry {
370            Ok(Some(reg)) => {
371                let builtin = get_builtin_authorities();
372                (
373                    reg.handle,
374                    reg.git,
375                    Some("ZSA".to_string()),
376                    if builtin.is_empty() {
377                        None
378                    } else {
379                        Some(builtin)
380                    }
381                )
382            }
383            // No set registry found, or the built-in definitions are
384            // invalid. Fall back to the historical zoidberg defaults.
385            Ok(None) | Err(_) => (
386                "zoidberg".to_string(),
387                get_default_registry(),
388                Some("ZSA".to_string()),
389                {
390                    let b = get_builtin_authorities();
391                    if b.is_empty() { None } else { Some(b) }
392                }
393            )
394        };
395        merged_cfg.default_registry = Some(Registry {
396            handle,
397            url,
398            name: None,
399            description: None,
400            advisory_prefix,
401            authorities
402        });
403    } else if let Some(ref mut reg) = merged_cfg.default_registry
404        && reg.url == get_default_registry()
405        && reg.authorities.as_ref().is_none_or(Vec::is_empty)
406    {
407        let builtin = get_builtin_authorities();
408        if !builtin.is_empty() {
409            reg.authorities = Some(builtin);
410        }
411    }
412
413    if merged_cfg.repos.is_empty()
414        && let Some(reg) = &merged_cfg.default_registry
415        && !reg.handle.is_empty()
416    {
417        let db_root = get_db_root()?;
418        let repo_path = db_root.join(&reg.handle);
419        if repo_path.join("repo.yaml").exists()
420            && let Ok(repo_config) = read_repo_config(&repo_path)
421        {
422            merged_cfg.repos = repo_config
423                .repos
424                .into_iter()
425                .filter(|r| r.active)
426                .map(|r| r.name)
427                .collect();
428        }
429    }
430
431    if project_val.get("system_generations_limit").is_some()
432        && !system_policy.system_generations_limit_unoverridable
433    {
434        merged_cfg.system_generations_limit =
435            project_cfg.system_generations_limit;
436    } else if user_val.get("system_generations_limit").is_some()
437        && !system_policy.system_generations_limit_unoverridable
438    {
439        merged_cfg.system_generations_limit = user_cfg.system_generations_limit;
440    } else {
441        merged_cfg.system_generations_limit =
442            system_cfg.system_generations_limit;
443    }
444
445    if project_val.get("max_delta_steps").is_some()
446        && !system_policy.max_delta_steps_unoverridable
447    {
448        merged_cfg.max_delta_steps = project_cfg.max_delta_steps;
449    } else if user_val.get("max_delta_steps").is_some()
450        && !system_policy.max_delta_steps_unoverridable
451    {
452        merged_cfg.max_delta_steps = user_cfg.max_delta_steps;
453    } else {
454        merged_cfg.max_delta_steps = system_cfg.max_delta_steps;
455    }
456
457    Ok(merged_cfg)
458}
459
460/// Writes the user-specific configuration to disk.
461///
462/// # Errors
463///
464/// Returns an error if the configuration directory cannot be created or the
465/// file cannot be written.
466pub fn write_user_config(config: &Config) -> Result<()> {
467    let config_path = get_user_config_path()?;
468    let parent_dir = config_path
469        .parent()
470        .ok_or_else(|| anyhow!("Invalid config path"))?;
471    fs::create_dir_all(parent_dir)?;
472    let content = serde_yaml::to_string(config)?;
473    fs::write(config_path, content)?;
474    Ok(())
475}
476
477/// Verifies a remote file against a set of trusted PGP keys.
478///
479/// # Errors
480///
481/// Returns an error if:
482/// - The file or its signature cannot be fetched.
483/// - The signature is invalid or not signed by a trusted key.
484pub fn verify_remote_file(
485    url: &str,
486    trusted_keys: &[String]
487) -> Result<Vec<u8>> {
488    let client = crate::utils::get_http_client()?;
489
490    let response = client.get(url).send()?;
491    if !response.status().is_success() {
492        return Err(anyhow!(
493            "Failed to fetch remote file '{url}': {}",
494            response.status()
495        ));
496    }
497    let data = response.bytes()?;
498
499    let sig_url = format!("{url}.sig");
500    let sig_response = client.get(&sig_url).send()?;
501    if !sig_response.status().is_success() {
502        return Err(anyhow!(
503            "Failed to fetch signature for '{url}': {}",
504            sig_response.status()
505        ));
506    }
507    let sig = sig_response.bytes()?;
508
509    let trusted_certs =
510        crate::pgp::get_certs_by_name_or_fingerprint(trusted_keys)?;
511
512    let temp_dir = tempfile::Builder::new().prefix("zoi-verify-").tempdir()?;
513    let data_path = temp_dir.path().join("data");
514    let sig_path = temp_dir.path().join("sig");
515
516    fs::write(&data_path, &data)?;
517    fs::write(&sig_path, &sig)?;
518
519    crate::pgp::verify_detached_signature_multi_key(
520        &data_path,
521        &sig_path,
522        trusted_certs
523    )?;
524
525    Ok(data.to_vec())
526}
527
528/// Adds a repository to the user-specific configuration.
529///
530/// # Errors
531///
532/// Returns an error if the repository already exists or if the configuration
533/// cannot be written.
534pub fn add_repo(repo_name: &str) -> Result<()> {
535    let mut config = read_config_from_path(&get_user_config_path()?)?;
536    let lower_repo_name = repo_name.to_lowercase();
537    if config.repos.contains(&lower_repo_name) {
538        return Err(anyhow!(
539            "Repository '{repo_name}' already exists in user config."
540        ));
541    }
542    config.repos.push(lower_repo_name);
543    write_user_config(&config)
544}
545
546/// Removes a repository from the user-specific configuration.
547///
548/// # Errors
549///
550/// Returns an error if the repository is not found or if the configuration
551/// cannot be written.
552pub fn remove_repo(repo_name: &str) -> Result<()> {
553    let mut config = read_config_from_path(&get_user_config_path()?)?;
554    let lower_repo_name = repo_name.to_lowercase();
555    if let Some(pos) = config.repos.iter().position(|r| r == &lower_repo_name) {
556        config.repos.remove(pos);
557        write_user_config(&config)
558    } else {
559        Err(anyhow!(
560            "Repository '{repo_name}' not found in user config."
561        ))
562    }
563}
564
565/// Prompts the user to interactively add a repository.
566///
567/// # Errors
568///
569/// Returns an error if:
570/// - Input/output operations fail.
571/// - The user provides invalid input.
572/// - The repository cannot be added.
573pub fn interactive_add_repo() -> Result<()> {
574    let config = read_config()?;
575    let all_repos = get_all_repos()?;
576
577    let available_repos: Vec<_> = all_repos
578        .into_iter()
579        .filter(|r| !config.repos.contains(&r.to_lowercase()))
580        .collect();
581
582    if available_repos.is_empty() {
583        println!("{}", "No new repositories available to add.".yellow());
584        return Ok(());
585    }
586
587    println!("{}", "Available repositories to add:".green());
588    for (i, repo) in available_repos.iter().enumerate() {
589        println!("[{}] {}", i + 1, repo);
590    }
591
592    print!(
593        "\n{}",
594        "Select a repository to add (or 'q' to quit): ".yellow()
595    );
596    io::stdout().flush()?;
597
598    let mut input = String::new();
599    io::stdin().read_line(&mut input)?;
600    let input = input.trim();
601
602    if input == "q" {
603        println!("Aborted.");
604        return Ok(());
605    }
606
607    let choice: usize = match input.parse() {
608        Ok(num) => num,
609        Err(_) => return Err(anyhow!("Invalid input."))
610    };
611
612    if choice > 0 && choice <= available_repos.len() {
613        let repo_to_add = available_repos
614            .get(choice - 1)
615            .ok_or_else(|| anyhow!("Invalid choice"))?;
616        add_repo(repo_to_add)?;
617        println!("Repository '{}' added successfully.", repo_to_add.green());
618    } else {
619        return Err(anyhow!("Invalid selection."));
620    }
621
622    Ok(())
623}
624
625/// Returns a list of all available repositories from the default registry.
626///
627/// # Errors
628///
629/// Returns an error if the repository configuration cannot be read.
630pub fn get_all_repos() -> Result<Vec<String>> {
631    let db_root = get_db_root()?;
632    let config = read_config()?;
633
634    if let Some(default_reg) = config.default_registry
635        && !default_reg.handle.is_empty()
636    {
637        let default_reg_path = db_root.join(default_reg.handle);
638        if default_reg_path.join("repo.yaml").exists() {
639            let repo_config = read_repo_config(&default_reg_path)?;
640            return Ok(repo_config.repos.into_iter().map(|r| r.name).collect());
641        }
642    }
643
644    Ok(Vec::new())
645}
646
647/// Clones a git repository into the local git root.
648///
649/// # Errors
650///
651/// Returns an error if:
652/// - The target directory already exists.
653/// - The `git clone` command fails.
654/// - The user configuration cannot be updated.
655pub fn clone_git_repo(url: &str) -> Result<()> {
656    let git_root = get_git_root()?;
657    fs::create_dir_all(&git_root)?;
658    let repo_name = url
659        .trim_end_matches('/')
660        .split('/')
661        .next_back()
662        .unwrap_or("repo")
663        .trim_end_matches(".git");
664    let target = git_root.join(repo_name);
665    if target.exists() {
666        return Err(anyhow!(
667            "Git repo '{repo_name}' already exists at {}",
668            target.display()
669        ));
670    }
671    println!("Cloning '{}' into {}...", url.cyan(), target.display());
672    let status = std::process::Command::new("git")
673        .arg("clone")
674        .arg("--depth=1")
675        .arg(url)
676        .arg(&target)
677        .status()?;
678    if !status.success() {
679        return Err(anyhow!("git clone failed"));
680    }
681
682    let mut config = read_config_from_path(&get_user_config_path()?)?;
683    if !config.git_repos.iter().any(|repo_url| repo_url == url) {
684        config.git_repos.push(url.to_string());
685        write_user_config(&config)?;
686    }
687
688    println!(
689        "Cloned git repo as '{}' (use with '#git@{}/<pkg>')",
690        repo_name.green(),
691        repo_name
692    );
693    Ok(())
694}
695
696/// Lists all cloned git repositories.
697///
698/// # Errors
699///
700/// Returns an error if the git root directory cannot be read.
701pub fn list_git_repos() -> Result<Vec<String>> {
702    let git_root = get_git_root()?;
703    if !git_root.exists() {
704        return Ok(Vec::new());
705    }
706
707    let mut repos = Vec::new();
708    for entry in fs::read_dir(git_root)? {
709        let entry = entry?;
710        if entry.path().is_dir() {
711            repos.push(entry.file_name().to_string_lossy().into_owned());
712        }
713    }
714    repos.sort();
715    Ok(repos)
716}
717
718/// Removes a cloned git repository.
719///
720/// # Errors
721///
722/// Returns an error if the repository is not found or cannot be removed.
723pub fn remove_git_repo(repo_name: &str) -> Result<()> {
724    let git_root = get_git_root()?;
725    let target = git_root.join(repo_name);
726    if !target.exists() {
727        return Err(anyhow!("Git repository '{repo_name}' not found."));
728    }
729
730    let mut config = read_config_from_path(&get_user_config_path()?)?;
731    let mut removed = false;
732    config.git_repos.retain(|url| {
733        let name_from_url = url
734            .trim_end_matches('/')
735            .split('/')
736            .next_back()
737            .unwrap_or_default()
738            .trim_end_matches(".git");
739        if name_from_url == repo_name {
740            removed = true;
741            false
742        } else {
743            true
744        }
745    });
746
747    if removed {
748        write_user_config(&config)?;
749    }
750
751    fs::remove_dir_all(&target)?;
752    println!(
753        "Removed git repository '{}' from {}",
754        repo_name.green(),
755        target.display()
756    );
757    Ok(())
758}
759
760/// Adds a cache mirror URL to the user-specific configuration.
761///
762/// # Errors
763///
764/// Returns an error if the mirror already exists or the configuration cannot be
765/// written.
766pub fn add_cache_mirror(url: &str) -> Result<()> {
767    let mut config = read_config_from_path(&get_user_config_path()?)?;
768    if config.cache_mirrors.iter().any(|existing| existing == url) {
769        return Err(anyhow!("Cache mirror '{url}' already exists."));
770    }
771    config.cache_mirrors.push(url.to_string());
772    write_user_config(&config)
773}
774
775/// Removes a cache mirror URL from the user-specific configuration.
776///
777/// # Errors
778///
779/// Returns an error if the mirror is not found or the configuration cannot be
780/// written.
781pub fn remove_cache_mirror(url: &str) -> Result<()> {
782    let mut config = read_config_from_path(&get_user_config_path()?)?;
783    if let Some(pos) = config
784        .cache_mirrors
785        .iter()
786        .position(|existing| existing == url)
787    {
788        config.cache_mirrors.remove(pos);
789        write_user_config(&config)
790    } else {
791        Err(anyhow!("Cache mirror '{url}' not found."))
792    }
793}
794
795/// Returns the built-in trust authorities for a built-in registry.
796///
797/// Only the official `set` registry carries Zoi's built-in Root-of-Trust
798/// authorities (the PGP keys embedded at build time). Third-party and other
799/// built-in registries have no authorities and return `None` so their state is
800/// not implicitly trusted.
801fn authorities_for_builtin(
802    builtin: &crate::types::BuiltinRegistry
803) -> Option<Vec<String>> {
804    if !builtin.set {
805        return None;
806    }
807    let authorities = get_builtin_authorities();
808    if authorities.is_empty() {
809        None
810    } else {
811        Some(authorities)
812    }
813}
814
815/// Sets the default registry in the user-specific configuration.
816///
817/// Accepts either a built-in registry handle (e.g. `zoidberg`) or a URL. When
818/// a handle matching a built-in registry is given, its metadata is used to
819/// populate the registry entry. This effectively marks that registry as the
820/// single "set" (default) registry for Zoi, so setting a new registry clears
821/// any previously set default.
822///
823/// # Errors
824///
825/// Returns an error if the configuration cannot be written.
826pub fn set_default_registry(url_or_handle: &str) -> Result<()> {
827    let mut config = read_config_from_path(&get_user_config_path()?)?;
828
829    let registry =
830        if let Some(builtin) = crate::builtin::registry::get(url_or_handle) {
831            let authorities = authorities_for_builtin(&builtin);
832            Registry {
833                handle: builtin.handle.clone(),
834                url: builtin.git,
835                name: Some(builtin.name),
836                description: Some(builtin.description),
837                advisory_prefix: None,
838                authorities
839            }
840        } else {
841            Registry {
842                handle: String::new(),
843                url: url_or_handle.to_string(),
844                name: None,
845                description: None,
846                advisory_prefix: None,
847                authorities: None
848            }
849        };
850
851    config.default_registry = Some(registry);
852    write_user_config(&config)
853}
854
855/// Sets the default registry in the user-specific configuration.
856///
857/// # Errors
858///
859/// Returns an error if the configuration cannot be written.
860pub fn set_user_default_registry(
861    default_registry: Option<Registry>
862) -> Result<()> {
863    let mut config = read_config_from_path(&get_user_config_path()?)?;
864    config.default_registry = default_registry;
865    write_user_config(&config)
866}
867
868/// Adds a registry to the list of added registries in the user-specific
869/// configuration.
870///
871/// Accepts either a built-in registry handle (e.g. `docker`) or a URL. When a
872/// handle matching a built-in registry is given, its metadata is used to
873/// populate the registry entry.
874///
875/// # Errors
876///
877/// Returns an error if the registry already exists, or if the configuration
878/// cannot be written.
879pub fn add_added_registry(url_or_handle: &str) -> Result<()> {
880    let mut config = read_config_from_path(&get_user_config_path()?)?;
881
882    let (handle, url, name, description, authorities) =
883        if let Some(builtin) = crate::builtin::registry::get(url_or_handle) {
884            let authorities = authorities_for_builtin(&builtin);
885            (
886                Some(builtin.handle),
887                builtin.git,
888                Some(builtin.name),
889                Some(builtin.description),
890                authorities
891            )
892        } else {
893            (None, url_or_handle.to_string(), None, None, None)
894        };
895
896    if config.added_registries.iter().any(|r| r.url == url)
897        || config
898            .added_registries
899            .iter()
900            .any(|r| handle.as_deref().is_some_and(|h| r.handle == h))
901    {
902        return Err(anyhow!(
903            "Registry with URL or handle '{url_or_handle}' already exists."
904        ));
905    }
906
907    config.added_registries.push(Registry {
908        handle: handle.unwrap_or_default(),
909        url,
910        name,
911        description,
912        advisory_prefix: None,
913        authorities
914    });
915    write_user_config(&config)
916}
917
918/// Removes an added registry by its handle or URL.
919///
920/// # Errors
921///
922/// Returns an error if the registry is not found or the configuration cannot be
923/// updated.
924pub fn remove_added_registry(handle_or_url: &str) -> Result<()> {
925    let mut config = read_config_from_path(&get_user_config_path()?)?;
926    if let Some(pos) = config
927        .added_registries
928        .iter()
929        .position(|r| r.handle == handle_or_url || r.url == handle_or_url)
930    {
931        let removed_registry = config.added_registries.remove(pos);
932        if !removed_registry.handle.is_empty() {
933            let db_root = get_db_root()?;
934            let repo_path = db_root.join(removed_registry.handle);
935            if repo_path.exists() {
936                fs::remove_dir_all(repo_path)?;
937            }
938        }
939        write_user_config(&config)
940    } else {
941        Err(anyhow!(
942            "Added registry with handle or URL '{handle_or_url}' not found."
943        ))
944    }
945}
946
947/// Reads the repository configuration from the specified database path.
948///
949/// # Errors
950///
951/// Returns an error if `repo.yaml` is not found or cannot be parsed.
952pub fn read_repo_config(db_path: &Path) -> Result<RepoConfig> {
953    let config_path = db_path.join("repo.yaml");
954    if !config_path.exists() {
955        return Err(anyhow!(
956            "repo.yaml not found in the root of the package database."
957        ));
958    }
959    let content = fs::read_to_string(config_path)?;
960    let config: RepoConfig = serde_yaml::from_str(&content)?;
961    Ok(config)
962}
963
964/// Reads the user-specific configuration.
965///
966/// # Errors
967///
968/// Returns an error if the configuration cannot be read or parsed.
969pub fn read_user_config() -> Result<Config> {
970    read_config_from_path(&get_user_config_path()?)
971}
972
973/// Updates the global package versions in the user-specific configuration.
974///
975/// # Errors
976///
977/// Returns an error if the configuration cannot be read or written.
978pub fn update_global_versions<S: ::std::hash::BuildHasher>(
979    versions: HashMap<String, String, S>
980) -> Result<()> {
981    let mut config = read_user_config()?;
982    config.versions.extend(versions);
983    write_user_config(&config)
984}
985
986/// Synchronizes the remote security policy if configured in system
987/// `config.yaml`.
988///
989/// This allows organizations to manage security constraints (allowed/denied
990/// lists) centrally. It verifies the policy's PGP signature before caching it
991/// locally.
992///
993/// # Errors
994///
995/// Returns an error if:
996/// - The configuration cannot be read.
997/// - The system is in offline mode and a sync is required.
998/// - The remote policy or its signature cannot be downloaded.
999/// - The signature verification fails.
1000pub fn sync_remote_policy() -> Result<()> {
1001    let config = read_config()?;
1002    let Some(remote_cfg) = &config.remote_policy else {
1003        return Ok(());
1004    };
1005
1006    println!("{} Syncing remote security policy...", "::".bold().blue());
1007
1008    if crate::offline::is_offline() {
1009        return Err(anyhow!("Cannot sync remote policy in offline mode."));
1010    }
1011
1012    let client = crate::utils::get_http_client()?;
1013
1014    let policy_content = client.get(&remote_cfg.url).send()?.text()?;
1015    let sig_content = client.get(&remote_cfg.signature_url).send()?.bytes()?;
1016
1017    let trusted_certs =
1018        crate::pgp::get_certs_by_name_or_fingerprint(&remote_cfg.trusted_keys)?;
1019
1020    let temp_dir = tempfile::Builder::new().prefix("zoi-policy-").tempdir()?;
1021    let temp_policy_path = temp_dir.path().join("policy.yaml");
1022    let temp_sig_path = temp_dir.path().join("policy.yaml.sig");
1023
1024    fs::write(&temp_policy_path, &policy_content)?;
1025    fs::write(&temp_sig_path, &sig_content)?;
1026
1027    crate::pgp::verify_detached_signature_multi_key(
1028        &temp_policy_path,
1029        &temp_sig_path,
1030        trusted_certs
1031    )?;
1032
1033    let cache_path = get_remote_policy_cache_path();
1034    if let Some(parent) = cache_path.parent() {
1035        fs::create_dir_all(parent)?;
1036    }
1037    fs::write(cache_path, policy_content)?;
1038
1039    println!(
1040        "{}",
1041        "Remote policy verified and cached successfully.".green()
1042    );
1043    Ok(())
1044}
1045
1046/// Merges a remote security policy into the local machine policy.
1047///
1048/// Precedence: If a field is already unoverridable in the base (machine)
1049/// policy, it remains so. Otherwise, the remote policy's unoverridable flags
1050/// and lists are added/merged into the local state.
1051fn merge_policies(
1052    base: &mut crate::types::Policy,
1053    remote: &crate::types::Policy
1054) {
1055    if remote.repos_unoverridable {
1056        base.repos_unoverridable = true;
1057    }
1058    if remote.telemetry_enabled_unoverridable {
1059        base.telemetry_enabled_unoverridable = true;
1060    }
1061    if remote.audit_log_enabled_unoverridable {
1062        base.audit_log_enabled_unoverridable = true;
1063    }
1064    if remote.rollback_enabled_unoverridable {
1065        base.rollback_enabled_unoverridable = true;
1066    }
1067    if remote.default_registry_unoverridable {
1068        base.default_registry_unoverridable = true;
1069    }
1070    if remote.added_registries_unoverridable {
1071        base.added_registries_unoverridable = true;
1072    }
1073    if remote.git_repos_unoverridable {
1074        base.git_repos_unoverridable = true;
1075    }
1076    if remote.allow_deny_lists_unoverridable {
1077        base.allow_deny_lists_unoverridable = true;
1078    }
1079    if remote.signature_enforcement_unoverridable {
1080        base.signature_enforcement_unoverridable = true;
1081    }
1082    if remote.protect_db_unoverridable {
1083        base.protect_db_unoverridable = true;
1084    }
1085    if remote.max_resolution_depth_unoverridable {
1086        base.max_resolution_depth_unoverridable = true;
1087    }
1088    if remote.offline_mode_unoverridable {
1089        base.offline_mode_unoverridable = true;
1090    }
1091    if remote.pkg_dirs_unoverridable {
1092        base.pkg_dirs_unoverridable = true;
1093    }
1094    if remote.cache_mirrors_unoverridable {
1095        base.cache_mirrors_unoverridable = true;
1096    }
1097    if remote.jobs_unoverridable {
1098        base.jobs_unoverridable = true;
1099    }
1100    if remote.advisory_enforcement_unoverridable {
1101        base.advisory_enforcement_unoverridable = true;
1102    }
1103
1104    if let Some(allowed) = &remote.allowed_licenses {
1105        base.allowed_licenses
1106            .get_or_insert_with(Vec::new)
1107            .extend(allowed.clone());
1108    }
1109    if let Some(denied) = &remote.denied_licenses {
1110        base.denied_licenses
1111            .get_or_insert_with(Vec::new)
1112            .extend(denied.clone());
1113    }
1114    if let Some(allowed) = &remote.allowed_packages {
1115        base.allowed_packages
1116            .get_or_insert_with(Vec::new)
1117            .extend(allowed.clone());
1118    }
1119    if let Some(denied) = &remote.denied_packages {
1120        base.denied_packages
1121            .get_or_insert_with(Vec::new)
1122            .extend(denied.clone());
1123    }
1124    if let Some(allowed) = &remote.allowed_repos {
1125        base.allowed_repos
1126            .get_or_insert_with(Vec::new)
1127            .extend(allowed.clone());
1128    }
1129    if let Some(denied) = &remote.denied_repos {
1130        base.denied_repos
1131            .get_or_insert_with(Vec::new)
1132            .extend(denied.clone());
1133    }
1134
1135    if let Some(remote_sig) = &remote.signature_enforcement {
1136        if let Some(ref mut base_sig) = base.signature_enforcement {
1137            if remote_sig.enable {
1138                base_sig.enable = true;
1139            }
1140            base_sig
1141                .trusted_keys
1142                .extend(remote_sig.trusted_keys.clone());
1143            base_sig.trusted_keys.sort();
1144            base_sig.trusted_keys.dedup();
1145        } else {
1146            base.signature_enforcement = Some(remote_sig.clone());
1147        }
1148    }
1149}