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("kernel_retention").is_some()
446        && !system_policy.kernel_retention_unoverridable
447    {
448        merged_cfg.kernel_retention = project_cfg.kernel_retention;
449    } else if user_val.get("kernel_retention").is_some()
450        && !system_policy.kernel_retention_unoverridable
451    {
452        merged_cfg.kernel_retention = user_cfg.kernel_retention;
453    } else if system_val.get("kernel_retention").is_some() {
454        merged_cfg.kernel_retention = system_cfg.kernel_retention;
455    }
456
457    if project_val.get("max_delta_steps").is_some()
458        && !system_policy.max_delta_steps_unoverridable
459    {
460        merged_cfg.max_delta_steps = project_cfg.max_delta_steps;
461    } else if user_val.get("max_delta_steps").is_some()
462        && !system_policy.max_delta_steps_unoverridable
463    {
464        merged_cfg.max_delta_steps = user_cfg.max_delta_steps;
465    } else {
466        merged_cfg.max_delta_steps = system_cfg.max_delta_steps;
467    }
468
469    Ok(merged_cfg)
470}
471
472/// Writes the user-specific configuration to disk.
473///
474/// # Errors
475///
476/// Returns an error if the configuration directory cannot be created or the
477/// file cannot be written.
478pub fn write_user_config(config: &Config) -> Result<()> {
479    let config_path = get_user_config_path()?;
480    let parent_dir = config_path
481        .parent()
482        .ok_or_else(|| anyhow!("Invalid config path"))?;
483    fs::create_dir_all(parent_dir)?;
484    let content = serde_yaml::to_string(config)?;
485    fs::write(config_path, content)?;
486    Ok(())
487}
488
489/// Verifies a remote file against a set of trusted PGP keys.
490///
491/// # Errors
492///
493/// Returns an error if:
494/// - The file or its signature cannot be fetched.
495/// - The signature is invalid or not signed by a trusted key.
496pub fn verify_remote_file(
497    url: &str,
498    trusted_keys: &[String]
499) -> Result<Vec<u8>> {
500    let client = crate::utils::get_http_client()?;
501
502    let response = client.get(url).send()?;
503    if !response.status().is_success() {
504        return Err(anyhow!(
505            "Failed to fetch remote file '{url}': {}",
506            response.status()
507        ));
508    }
509    let data = response.bytes()?;
510
511    let sig_url = format!("{url}.sig");
512    let sig_response = client.get(&sig_url).send()?;
513    if !sig_response.status().is_success() {
514        return Err(anyhow!(
515            "Failed to fetch signature for '{url}': {}",
516            sig_response.status()
517        ));
518    }
519    let sig = sig_response.bytes()?;
520
521    let trusted_certs =
522        crate::pgp::get_certs_by_name_or_fingerprint(trusted_keys)?;
523
524    let temp_dir = tempfile::Builder::new().prefix("zoi-verify-").tempdir()?;
525    let data_path = temp_dir.path().join("data");
526    let sig_path = temp_dir.path().join("sig");
527
528    fs::write(&data_path, &data)?;
529    fs::write(&sig_path, &sig)?;
530
531    crate::pgp::verify_detached_signature_multi_key(
532        &data_path,
533        &sig_path,
534        trusted_certs
535    )?;
536
537    Ok(data.to_vec())
538}
539
540/// Adds a repository to the user-specific configuration.
541///
542/// # Errors
543///
544/// Returns an error if the repository already exists or if the configuration
545/// cannot be written.
546pub fn add_repo(repo_name: &str) -> Result<()> {
547    let mut config = read_config_from_path(&get_user_config_path()?)?;
548    let lower_repo_name = repo_name.to_lowercase();
549    if config.repos.contains(&lower_repo_name) {
550        return Err(anyhow!(
551            "Repository '{repo_name}' already exists in user config."
552        ));
553    }
554    config.repos.push(lower_repo_name);
555    write_user_config(&config)
556}
557
558/// Removes a repository from the user-specific configuration.
559///
560/// # Errors
561///
562/// Returns an error if the repository is not found or if the configuration
563/// cannot be written.
564pub fn remove_repo(repo_name: &str) -> Result<()> {
565    let mut config = read_config_from_path(&get_user_config_path()?)?;
566    let lower_repo_name = repo_name.to_lowercase();
567    if let Some(pos) = config.repos.iter().position(|r| r == &lower_repo_name) {
568        config.repos.remove(pos);
569        write_user_config(&config)
570    } else {
571        Err(anyhow!(
572            "Repository '{repo_name}' not found in user config."
573        ))
574    }
575}
576
577/// Prompts the user to interactively add a repository.
578///
579/// # Errors
580///
581/// Returns an error if:
582/// - Input/output operations fail.
583/// - The user provides invalid input.
584/// - The repository cannot be added.
585pub fn interactive_add_repo() -> Result<()> {
586    let config = read_config()?;
587    let all_repos = get_all_repos()?;
588
589    let available_repos: Vec<_> = all_repos
590        .into_iter()
591        .filter(|r| !config.repos.contains(&r.to_lowercase()))
592        .collect();
593
594    if available_repos.is_empty() {
595        println!("{}", "No new repositories available to add.".yellow());
596        return Ok(());
597    }
598
599    println!("{}", "Available repositories to add:".green());
600    for (i, repo) in available_repos.iter().enumerate() {
601        println!("[{}] {}", i + 1, repo);
602    }
603
604    print!(
605        "\n{}",
606        "Select a repository to add (or 'q' to quit): ".yellow()
607    );
608    io::stdout().flush()?;
609
610    let mut input = String::new();
611    io::stdin().read_line(&mut input)?;
612    let input = input.trim();
613
614    if input == "q" {
615        println!("Aborted.");
616        return Ok(());
617    }
618
619    let choice: usize = match input.parse() {
620        Ok(num) => num,
621        Err(_) => return Err(anyhow!("Invalid input."))
622    };
623
624    if choice > 0 && choice <= available_repos.len() {
625        let repo_to_add = available_repos
626            .get(choice - 1)
627            .ok_or_else(|| anyhow!("Invalid choice"))?;
628        add_repo(repo_to_add)?;
629        println!("Repository '{}' added successfully.", repo_to_add.green());
630    } else {
631        return Err(anyhow!("Invalid selection."));
632    }
633
634    Ok(())
635}
636
637/// Returns a list of all available repositories from the default registry.
638///
639/// # Errors
640///
641/// Returns an error if the repository configuration cannot be read.
642pub fn get_all_repos() -> Result<Vec<String>> {
643    let db_root = get_db_root()?;
644    let config = read_config()?;
645
646    if let Some(default_reg) = config.default_registry
647        && !default_reg.handle.is_empty()
648    {
649        let default_reg_path = db_root.join(default_reg.handle);
650        if default_reg_path.join("repo.yaml").exists() {
651            let repo_config = read_repo_config(&default_reg_path)?;
652            return Ok(repo_config.repos.into_iter().map(|r| r.name).collect());
653        }
654    }
655
656    Ok(Vec::new())
657}
658
659/// Clones a git repository into the local git root.
660///
661/// # Errors
662///
663/// Returns an error if:
664/// - The target directory already exists.
665/// - The `git clone` command fails.
666/// - The user configuration cannot be updated.
667pub fn clone_git_repo(url: &str) -> Result<()> {
668    let git_root = get_git_root()?;
669    fs::create_dir_all(&git_root)?;
670    let repo_name = url
671        .trim_end_matches('/')
672        .split('/')
673        .next_back()
674        .unwrap_or("repo")
675        .trim_end_matches(".git");
676    let target = git_root.join(repo_name);
677    if target.exists() {
678        return Err(anyhow!(
679            "Git repo '{repo_name}' already exists at {}",
680            target.display()
681        ));
682    }
683    println!("Cloning '{}' into {}...", url.cyan(), target.display());
684    let status = std::process::Command::new("git")
685        .arg("clone")
686        .arg("--depth=1")
687        .arg(url)
688        .arg(&target)
689        .status()?;
690    if !status.success() {
691        return Err(anyhow!("git clone failed"));
692    }
693
694    let mut config = read_config_from_path(&get_user_config_path()?)?;
695    if !config.git_repos.iter().any(|repo_url| repo_url == url) {
696        config.git_repos.push(url.to_string());
697        write_user_config(&config)?;
698    }
699
700    println!(
701        "Cloned git repo as '{}' (use with '#git@{}/<pkg>')",
702        repo_name.green(),
703        repo_name
704    );
705    Ok(())
706}
707
708/// Lists all cloned git repositories.
709///
710/// # Errors
711///
712/// Returns an error if the git root directory cannot be read.
713pub fn list_git_repos() -> Result<Vec<String>> {
714    let git_root = get_git_root()?;
715    if !git_root.exists() {
716        return Ok(Vec::new());
717    }
718
719    let mut repos = Vec::new();
720    for entry in fs::read_dir(git_root)? {
721        let entry = entry?;
722        if entry.path().is_dir() {
723            repos.push(entry.file_name().to_string_lossy().into_owned());
724        }
725    }
726    repos.sort();
727    Ok(repos)
728}
729
730/// Removes a cloned git repository.
731///
732/// # Errors
733///
734/// Returns an error if the repository is not found or cannot be removed.
735pub fn remove_git_repo(repo_name: &str) -> Result<()> {
736    let git_root = get_git_root()?;
737    let target = git_root.join(repo_name);
738    if !target.exists() {
739        return Err(anyhow!("Git repository '{repo_name}' not found."));
740    }
741
742    let mut config = read_config_from_path(&get_user_config_path()?)?;
743    let mut removed = false;
744    config.git_repos.retain(|url| {
745        let name_from_url = url
746            .trim_end_matches('/')
747            .split('/')
748            .next_back()
749            .unwrap_or_default()
750            .trim_end_matches(".git");
751        if name_from_url == repo_name {
752            removed = true;
753            false
754        } else {
755            true
756        }
757    });
758
759    if removed {
760        write_user_config(&config)?;
761    }
762
763    fs::remove_dir_all(&target)?;
764    println!(
765        "Removed git repository '{}' from {}",
766        repo_name.green(),
767        target.display()
768    );
769    Ok(())
770}
771
772/// Adds a cache mirror URL to the user-specific configuration.
773///
774/// # Errors
775///
776/// Returns an error if the mirror already exists or the configuration cannot be
777/// written.
778pub fn add_cache_mirror(url: &str) -> Result<()> {
779    let mut config = read_config_from_path(&get_user_config_path()?)?;
780    if config.cache_mirrors.iter().any(|existing| existing == url) {
781        return Err(anyhow!("Cache mirror '{url}' already exists."));
782    }
783    config.cache_mirrors.push(url.to_string());
784    write_user_config(&config)
785}
786
787/// Removes a cache mirror URL from the user-specific configuration.
788///
789/// # Errors
790///
791/// Returns an error if the mirror is not found or the configuration cannot be
792/// written.
793pub fn remove_cache_mirror(url: &str) -> Result<()> {
794    let mut config = read_config_from_path(&get_user_config_path()?)?;
795    if let Some(pos) = config
796        .cache_mirrors
797        .iter()
798        .position(|existing| existing == url)
799    {
800        config.cache_mirrors.remove(pos);
801        write_user_config(&config)
802    } else {
803        Err(anyhow!("Cache mirror '{url}' not found."))
804    }
805}
806
807/// Returns the built-in trust authorities for a built-in registry.
808///
809/// Only the official `set` registry carries Zoi's built-in Root-of-Trust
810/// authorities (the PGP keys embedded at build time). Third-party and other
811/// built-in registries have no authorities and return `None` so their state is
812/// not implicitly trusted.
813fn authorities_for_builtin(
814    builtin: &crate::types::BuiltinRegistry
815) -> Option<Vec<String>> {
816    if !builtin.set {
817        return None;
818    }
819    let authorities = get_builtin_authorities();
820    if authorities.is_empty() {
821        None
822    } else {
823        Some(authorities)
824    }
825}
826
827/// Sets the default registry in the user-specific configuration.
828///
829/// Accepts either a built-in registry handle (e.g. `zoidberg`) or a URL. When
830/// a handle matching a built-in registry is given, its metadata is used to
831/// populate the registry entry. This effectively marks that registry as the
832/// single "set" (default) registry for Zoi, so setting a new registry clears
833/// any previously set default.
834///
835/// # Errors
836///
837/// Returns an error if the configuration cannot be written.
838pub fn set_default_registry(url_or_handle: &str) -> Result<()> {
839    let mut config = read_config_from_path(&get_user_config_path()?)?;
840
841    let registry =
842        if let Some(builtin) = crate::builtin::registry::get(url_or_handle) {
843            let authorities = authorities_for_builtin(&builtin);
844            Registry {
845                handle: builtin.handle.clone(),
846                url: builtin.git,
847                name: Some(builtin.name),
848                description: Some(builtin.description),
849                advisory_prefix: None,
850                authorities
851            }
852        } else {
853            Registry {
854                handle: String::new(),
855                url: url_or_handle.to_string(),
856                name: None,
857                description: None,
858                advisory_prefix: None,
859                authorities: None
860            }
861        };
862
863    config.default_registry = Some(registry);
864    write_user_config(&config)
865}
866
867/// Sets the default registry in the user-specific configuration.
868///
869/// # Errors
870///
871/// Returns an error if the configuration cannot be written.
872pub fn set_user_default_registry(
873    default_registry: Option<Registry>
874) -> Result<()> {
875    let mut config = read_config_from_path(&get_user_config_path()?)?;
876    config.default_registry = default_registry;
877    write_user_config(&config)
878}
879
880/// Adds a registry to the list of added registries in the user-specific
881/// configuration.
882///
883/// Accepts either a built-in registry handle (e.g. `docker`) or a URL. When a
884/// handle matching a built-in registry is given, its metadata is used to
885/// populate the registry entry.
886///
887/// # Errors
888///
889/// Returns an error if the registry already exists, or if the configuration
890/// cannot be written.
891pub fn add_added_registry(url_or_handle: &str) -> Result<()> {
892    let mut config = read_config_from_path(&get_user_config_path()?)?;
893
894    let (handle, url, name, description, authorities) =
895        if let Some(builtin) = crate::builtin::registry::get(url_or_handle) {
896            let authorities = authorities_for_builtin(&builtin);
897            (
898                Some(builtin.handle),
899                builtin.git,
900                Some(builtin.name),
901                Some(builtin.description),
902                authorities
903            )
904        } else {
905            (None, url_or_handle.to_string(), None, None, None)
906        };
907
908    push_added_registry(
909        &mut config,
910        Registry {
911            handle: handle.unwrap_or_default(),
912            url,
913            name,
914            description,
915            advisory_prefix: None,
916            authorities
917        },
918        url_or_handle
919    )?;
920
921    write_user_config(&config)
922}
923
924/// Adds a fully resolved registry to the configuration.
925///
926/// Unlike [`add_added_registry`], which can only fill in metadata for built-in
927/// registries, this accepts a registry whose `repo.yaml` has already been
928/// fetched and parsed, so a registry added by URL immediately records its real
929/// handle, name, description and advisory prefix.
930///
931/// # Errors
932///
933/// Returns an error if the registry already exists, its handle is empty, or
934/// the configuration cannot be written.
935pub fn add_resolved_registry(registry: Registry) -> Result<()> {
936    if registry.handle.is_empty() {
937        return Err(anyhow!(
938            "Cannot add registry from '{}': it declares no 'name' to use as a \
939             handle.",
940            registry.url
941        ));
942    }
943
944    let mut config = read_config_from_path(&get_user_config_path()?)?;
945    let identity = registry.url.clone();
946    push_added_registry(&mut config, registry, &identity)?;
947
948    write_user_config(&config)
949}
950
951/// Validates a registry and appends it to `config`, rejecting duplicates.
952///
953/// A registry is a duplicate if either its URL or its handle is already
954/// registered, so a registry cannot be added twice under two spellings of the
955/// same identity.
956///
957/// # Errors
958///
959/// Returns an error if the registry is already present.
960fn push_added_registry(
961    config: &mut Config,
962    registry: Registry,
963    identity: &str
964) -> Result<()> {
965    if config
966        .added_registries
967        .iter()
968        .any(|r| r.url == registry.url)
969        || (!registry.handle.is_empty()
970            && config
971                .added_registries
972                .iter()
973                .any(|r| r.handle == registry.handle))
974    {
975        return Err(anyhow!(
976            "Registry with URL or handle '{identity}' already exists."
977        ));
978    }
979
980    config.added_registries.push(registry);
981    Ok(())
982}
983
984/// Removes an added registry by its handle or URL.
985///
986/// # Errors
987///
988/// Returns an error if the registry is not found or the configuration cannot be
989/// updated.
990pub fn remove_added_registry(handle_or_url: &str) -> Result<()> {
991    let mut config = read_config_from_path(&get_user_config_path()?)?;
992    if let Some(pos) = config
993        .added_registries
994        .iter()
995        .position(|r| r.handle == handle_or_url || r.url == handle_or_url)
996    {
997        let removed_registry = config.added_registries.remove(pos);
998        if !removed_registry.handle.is_empty() {
999            let db_root = get_db_root()?;
1000            let repo_path = db_root.join(removed_registry.handle);
1001            if repo_path.exists() {
1002                fs::remove_dir_all(repo_path)?;
1003            }
1004        }
1005        write_user_config(&config)
1006    } else {
1007        Err(anyhow!(
1008            "Added registry with handle or URL '{handle_or_url}' not found."
1009        ))
1010    }
1011}
1012
1013/// Reads the repository configuration from the specified database path.
1014///
1015/// # Errors
1016///
1017/// Returns an error if `repo.yaml` is not found or cannot be parsed.
1018pub fn read_repo_config(db_path: &Path) -> Result<RepoConfig> {
1019    let config_path = crate::utils::safe_join(db_path, Path::new("repo.yaml"))?;
1020    if !config_path.exists() {
1021        return Err(anyhow!(
1022            "repo.yaml not found in the root of the package database."
1023        ));
1024    }
1025    let content = fs::read_to_string(config_path)?;
1026    let config: RepoConfig = serde_yaml::from_str(&content)?;
1027    Ok(config)
1028}
1029
1030/// Reads the user-specific configuration.
1031///
1032/// # Errors
1033///
1034/// Returns an error if the configuration cannot be read or parsed.
1035pub fn read_user_config() -> Result<Config> {
1036    read_config_from_path(&get_user_config_path()?)
1037}
1038
1039/// Updates the global package versions in the user-specific configuration.
1040///
1041/// # Errors
1042///
1043/// Returns an error if the configuration cannot be read or written.
1044pub fn update_global_versions<S: ::std::hash::BuildHasher>(
1045    versions: HashMap<String, String, S>
1046) -> Result<()> {
1047    let mut config = read_user_config()?;
1048    config.versions.extend(versions);
1049    write_user_config(&config)
1050}
1051
1052/// Synchronizes the remote security policy if configured in system
1053/// `config.yaml`.
1054///
1055/// This allows organizations to manage security constraints (allowed/denied
1056/// lists) centrally. It verifies the policy's PGP signature before caching it
1057/// locally.
1058///
1059/// # Errors
1060///
1061/// Returns an error if:
1062/// - The configuration cannot be read.
1063/// - The system is in offline mode and a sync is required.
1064/// - The remote policy or its signature cannot be downloaded.
1065/// - The signature verification fails.
1066pub fn sync_remote_policy() -> Result<()> {
1067    let config = read_config()?;
1068    let Some(remote_cfg) = &config.remote_policy else {
1069        return Ok(());
1070    };
1071
1072    println!("{} Syncing remote security policy...", "::".bold().blue());
1073
1074    if crate::offline::is_offline() {
1075        return Err(anyhow!("Cannot sync remote policy in offline mode."));
1076    }
1077
1078    let client = crate::utils::get_http_client()?;
1079
1080    let policy_content = client.get(&remote_cfg.url).send()?.text()?;
1081    let sig_content = client.get(&remote_cfg.signature_url).send()?.bytes()?;
1082
1083    let trusted_certs =
1084        crate::pgp::get_certs_by_name_or_fingerprint(&remote_cfg.trusted_keys)?;
1085
1086    let temp_dir = tempfile::Builder::new().prefix("zoi-policy-").tempdir()?;
1087    let temp_policy_path = temp_dir.path().join("policy.yaml");
1088    let temp_sig_path = temp_dir.path().join("policy.yaml.sig");
1089
1090    fs::write(&temp_policy_path, &policy_content)?;
1091    fs::write(&temp_sig_path, &sig_content)?;
1092
1093    crate::pgp::verify_detached_signature_multi_key(
1094        &temp_policy_path,
1095        &temp_sig_path,
1096        trusted_certs
1097    )?;
1098
1099    let cache_path = get_remote_policy_cache_path();
1100    if let Some(parent) = cache_path.parent() {
1101        fs::create_dir_all(parent)?;
1102    }
1103    fs::write(cache_path, policy_content)?;
1104
1105    println!(
1106        "{}",
1107        "Remote policy verified and cached successfully.".green()
1108    );
1109    Ok(())
1110}
1111
1112/// Merges a remote security policy into the local machine policy.
1113///
1114/// Precedence: If a field is already unoverridable in the base (machine)
1115/// policy, it remains so. Otherwise, the remote policy's unoverridable flags
1116/// and lists are added/merged into the local state.
1117fn merge_policies(
1118    base: &mut crate::types::Policy,
1119    remote: &crate::types::Policy
1120) {
1121    if remote.repos_unoverridable {
1122        base.repos_unoverridable = true;
1123    }
1124    if remote.telemetry_enabled_unoverridable {
1125        base.telemetry_enabled_unoverridable = true;
1126    }
1127    if remote.audit_log_enabled_unoverridable {
1128        base.audit_log_enabled_unoverridable = true;
1129    }
1130    if remote.rollback_enabled_unoverridable {
1131        base.rollback_enabled_unoverridable = true;
1132    }
1133    if remote.default_registry_unoverridable {
1134        base.default_registry_unoverridable = true;
1135    }
1136    if remote.added_registries_unoverridable {
1137        base.added_registries_unoverridable = true;
1138    }
1139    if remote.git_repos_unoverridable {
1140        base.git_repos_unoverridable = true;
1141    }
1142    if remote.allow_deny_lists_unoverridable {
1143        base.allow_deny_lists_unoverridable = true;
1144    }
1145    if remote.signature_enforcement_unoverridable {
1146        base.signature_enforcement_unoverridable = true;
1147    }
1148    if remote.protect_db_unoverridable {
1149        base.protect_db_unoverridable = true;
1150    }
1151    if remote.max_resolution_depth_unoverridable {
1152        base.max_resolution_depth_unoverridable = true;
1153    }
1154    if remote.offline_mode_unoverridable {
1155        base.offline_mode_unoverridable = true;
1156    }
1157    if remote.pkg_dirs_unoverridable {
1158        base.pkg_dirs_unoverridable = true;
1159    }
1160    if remote.cache_mirrors_unoverridable {
1161        base.cache_mirrors_unoverridable = true;
1162    }
1163    if remote.jobs_unoverridable {
1164        base.jobs_unoverridable = true;
1165    }
1166    if remote.advisory_enforcement_unoverridable {
1167        base.advisory_enforcement_unoverridable = true;
1168    }
1169
1170    if let Some(allowed) = &remote.allowed_licenses {
1171        base.allowed_licenses
1172            .get_or_insert_with(Vec::new)
1173            .extend(allowed.clone());
1174    }
1175    if let Some(denied) = &remote.denied_licenses {
1176        base.denied_licenses
1177            .get_or_insert_with(Vec::new)
1178            .extend(denied.clone());
1179    }
1180    if let Some(allowed) = &remote.allowed_packages {
1181        base.allowed_packages
1182            .get_or_insert_with(Vec::new)
1183            .extend(allowed.clone());
1184    }
1185    if let Some(denied) = &remote.denied_packages {
1186        base.denied_packages
1187            .get_or_insert_with(Vec::new)
1188            .extend(denied.clone());
1189    }
1190    if let Some(allowed) = &remote.allowed_repos {
1191        base.allowed_repos
1192            .get_or_insert_with(Vec::new)
1193            .extend(allowed.clone());
1194    }
1195    if let Some(denied) = &remote.denied_repos {
1196        base.denied_repos
1197            .get_or_insert_with(Vec::new)
1198            .extend(denied.clone());
1199    }
1200
1201    if let Some(remote_sig) = &remote.signature_enforcement {
1202        if let Some(ref mut base_sig) = base.signature_enforcement {
1203            if remote_sig.enable {
1204                base_sig.enable = true;
1205            }
1206            base_sig
1207                .trusted_keys
1208                .extend(remote_sig.trusted_keys.clone());
1209            base_sig.trusted_keys.sort();
1210            base_sig.trusted_keys.dedup();
1211        } else {
1212            base.signature_enforcement = Some(remote_sig.clone());
1213        }
1214    }
1215}