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