Skip to main content

zoi_core/
config.rs

1use crate::sysroot::apply_sysroot;
2use crate::types::{Config, Registry, RepoConfig};
3use crate::utils::{get_db_root, get_user_home};
4use anyhow::{Result, anyhow};
5use colored::*;
6use serde_yaml::Value;
7use std::collections::{HashMap, HashSet};
8use std::fs;
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11
12/// Returns the default registry URL for Zoi.
13///
14/// This is typically hardcoded or provided via an environment variable during the build.
15pub fn get_default_registry() -> String {
16    option_env!("ZOI_DEFAULT_REGISTRY")
17        .unwrap_or("community")
18        .to_string()
19}
20
21/// Returns the built-in PGP authorities for the default registry.
22pub fn get_builtin_authorities() -> Vec<String> {
23    let auth_str = option_env!("ZOI_BUILTIN_AUTHORITIES").unwrap_or_default();
24    if auth_str.is_empty() {
25        Vec::new()
26    } else {
27        auth_str.split(',').map(|s| s.to_string()).collect()
28    }
29}
30
31fn get_system_config_path() -> Result<PathBuf> {
32    if cfg!(target_os = "windows") {
33        Ok(apply_sysroot(PathBuf::from(
34            "C:\\ProgramData\\zoi\\config.yaml",
35        )))
36    } else {
37        Ok(apply_sysroot(PathBuf::from("/etc/zoi/config.yaml")))
38    }
39}
40
41fn get_user_config_path() -> Result<PathBuf> {
42    let home_dir = get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
43    Ok(apply_sysroot(home_dir.join(".zoi"))
44        .join("pkgs")
45        .join("config.yaml"))
46}
47
48fn get_project_config_path() -> Result<PathBuf> {
49    let current_dir = std::env::current_dir()?;
50    Ok(apply_sysroot(current_dir.join(".zoi"))
51        .join("pkgs")
52        .join("config.yaml"))
53}
54
55fn get_git_root() -> Result<PathBuf> {
56    let home_dir = get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
57    Ok(apply_sysroot(home_dir.join(".zoi"))
58        .join("pkgs")
59        .join("git"))
60}
61
62fn get_remote_policy_cache_path() -> Result<PathBuf> {
63    if cfg!(target_os = "windows") {
64        Ok(apply_sysroot(PathBuf::from(
65            "C:\\ProgramData\\zoi\\policy.cache.yaml",
66        )))
67    } else {
68        Ok(apply_sysroot(PathBuf::from("/etc/zoi/policy.cache.yaml")))
69    }
70}
71
72fn read_yaml_value(path: &Path) -> Result<Value> {
73    if !path.exists() {
74        return Ok(Value::Null);
75    }
76    let content = fs::read_to_string(path)?;
77    serde_yaml::from_str(&content).map_err(Into::into)
78}
79
80fn read_config_from_path(path: &Path) -> Result<Config> {
81    if !path.exists() {
82        return Ok(Config::default());
83    }
84    let content = fs::read_to_string(path)?;
85    serde_yaml::from_str(&content).map_err(Into::into)
86}
87
88/// Loads and merges configuration from system, user, and project-local paths.
89///
90/// Zoi uses a hierarchical configuration model with the following precedence:
91/// - System: (`/etc/zoi/config.yaml`) - Defines global machine policy.
92/// - User: (`~/.zoi/pkgs/config.yaml`) - Defines user preferences.
93/// - Project: (`./.zoi/pkgs/config.yaml`) - Local overrides for a specific project.
94///
95/// Policy Enforcement: If a field is marked as `unoverridable` in the
96/// system-level policy, Zoi will ignore any overrides found in user or project configs.
97pub fn read_config() -> Result<Config> {
98    let system_val = read_yaml_value(&get_system_config_path()?)?;
99    let user_val = read_yaml_value(&get_user_config_path()?)?;
100    let project_val = read_yaml_value(&get_project_config_path()?)?;
101
102    let mut system_cfg: Config = serde_yaml::from_value(system_val.clone()).unwrap_or_default();
103    let user_cfg: Config = serde_yaml::from_value(user_val.clone()).unwrap_or_default();
104    let project_cfg: Config = serde_yaml::from_value(project_val.clone()).unwrap_or_default();
105
106    if let Ok(cache_path) = get_remote_policy_cache_path()
107        && cache_path.exists()
108        && let Ok(cache_content) = fs::read_to_string(&cache_path)
109        && let Ok(remote_policy) = serde_yaml::from_str::<crate::types::Policy>(&cache_content)
110    {
111        merge_policies(&mut system_cfg.policy, &remote_policy);
112    }
113
114    let system_policy = system_cfg.policy.clone();
115    let mut merged_cfg = Config {
116        policy: system_policy.clone(),
117        ..Default::default()
118    };
119
120    merged_cfg.repos = system_cfg.repos;
121    if !system_policy.repos_unoverridable {
122        merged_cfg.repos.extend(user_cfg.repos);
123        merged_cfg.repos.extend(project_cfg.repos);
124    }
125    merged_cfg.repos.sort();
126    merged_cfg.repos.dedup();
127
128    merged_cfg.added_registries = system_cfg.added_registries;
129    if !system_policy.added_registries_unoverridable {
130        merged_cfg
131            .added_registries
132            .extend(user_cfg.added_registries);
133        merged_cfg
134            .added_registries
135            .extend(project_cfg.added_registries);
136    }
137    let mut seen_registries = HashSet::new();
138    merged_cfg
139        .added_registries
140        .retain(|r| seen_registries.insert(r.url.clone()));
141
142    merged_cfg.git_repos = system_cfg.git_repos;
143    if !system_policy.git_repos_unoverridable {
144        merged_cfg.git_repos.extend(user_cfg.git_repos);
145        merged_cfg.git_repos.extend(project_cfg.git_repos);
146    }
147    merged_cfg.git_repos.sort();
148    merged_cfg.git_repos.dedup();
149
150    merged_cfg.package_managers = project_cfg
151        .package_managers
152        .or(user_cfg.package_managers)
153        .or(system_cfg.package_managers);
154    merged_cfg.native_package_manager = project_cfg
155        .native_package_manager
156        .or(user_cfg.native_package_manager)
157        .or(system_cfg.native_package_manager);
158    merged_cfg.registry = project_cfg
159        .registry
160        .or(user_cfg.registry)
161        .or(system_cfg.registry);
162
163    merged_cfg.remote_policy = system_cfg.remote_policy;
164
165    if project_val.get("telemetry_enabled").is_some()
166        && !system_policy.telemetry_enabled_unoverridable
167    {
168        merged_cfg.telemetry_enabled = project_cfg.telemetry_enabled;
169    } else if user_val.get("telemetry_enabled").is_some()
170        && !system_policy.telemetry_enabled_unoverridable
171    {
172        merged_cfg.telemetry_enabled = user_cfg.telemetry_enabled;
173    } else {
174        merged_cfg.telemetry_enabled = system_cfg.telemetry_enabled;
175    }
176
177    if project_val.get("audit_log_enabled").is_some()
178        && !system_policy.audit_log_enabled_unoverridable
179    {
180        merged_cfg.audit_log_enabled = project_cfg.audit_log_enabled;
181    } else if user_val.get("audit_log_enabled").is_some()
182        && !system_policy.audit_log_enabled_unoverridable
183    {
184        merged_cfg.audit_log_enabled = user_cfg.audit_log_enabled;
185    } else {
186        merged_cfg.audit_log_enabled = system_cfg.audit_log_enabled;
187    }
188
189    if project_val.get("rollback_enabled").is_some()
190        && !system_policy.rollback_enabled_unoverridable
191    {
192        merged_cfg.rollback_enabled = project_cfg.rollback_enabled;
193    } else if user_val.get("rollback_enabled").is_some()
194        && !system_policy.rollback_enabled_unoverridable
195    {
196        merged_cfg.rollback_enabled = user_cfg.rollback_enabled;
197    } else {
198        merged_cfg.rollback_enabled = system_cfg.rollback_enabled;
199    }
200
201    if project_val.get("default_registry").is_some()
202        && !system_policy.default_registry_unoverridable
203    {
204        merged_cfg.default_registry = project_cfg.default_registry;
205    } else if user_val.get("default_registry").is_some()
206        && !system_policy.default_registry_unoverridable
207    {
208        merged_cfg.default_registry = user_cfg.default_registry;
209    } else {
210        merged_cfg.default_registry = system_cfg.default_registry;
211    }
212
213    if project_val.get("jobs").is_some() && !system_policy.jobs_unoverridable {
214        merged_cfg.jobs = project_cfg.jobs;
215    } else if user_val.get("jobs").is_some() && !system_policy.jobs_unoverridable {
216        merged_cfg.jobs = user_cfg.jobs;
217    } else {
218        merged_cfg.jobs = system_cfg.jobs;
219    }
220
221    if project_val.get("protect_db").is_some() && !system_policy.protect_db_unoverridable {
222        merged_cfg.protect_db = project_cfg.protect_db;
223    } else if user_val.get("protect_db").is_some() && !system_policy.protect_db_unoverridable {
224        merged_cfg.protect_db = user_cfg.protect_db;
225    } else {
226        merged_cfg.protect_db = system_cfg.protect_db;
227    }
228
229    if project_val.get("max_resolution_depth").is_some()
230        && !system_policy.max_resolution_depth_unoverridable
231    {
232        merged_cfg.max_resolution_depth = project_cfg.max_resolution_depth;
233    } else if user_val.get("max_resolution_depth").is_some()
234        && !system_policy.max_resolution_depth_unoverridable
235    {
236        merged_cfg.max_resolution_depth = user_cfg.max_resolution_depth;
237    } else {
238        merged_cfg.max_resolution_depth = system_cfg.max_resolution_depth;
239    }
240
241    if project_val.get("offline_mode").is_some() && !system_policy.offline_mode_unoverridable {
242        merged_cfg.offline_mode = project_cfg.offline_mode;
243    } else if user_val.get("offline_mode").is_some() && !system_policy.offline_mode_unoverridable {
244        merged_cfg.offline_mode = user_cfg.offline_mode;
245    } else {
246        merged_cfg.offline_mode = system_cfg.offline_mode;
247    }
248
249    if project_val
250        .get("policy")
251        .and_then(|p| p.get("advisory_enforcement_unoverridable"))
252        .is_some()
253        && !system_policy.advisory_enforcement_unoverridable
254    {
255        merged_cfg.policy.advisory_enforcement_unoverridable =
256            project_cfg.policy.advisory_enforcement_unoverridable;
257    } else if user_val
258        .get("policy")
259        .and_then(|p| p.get("advisory_enforcement_unoverridable"))
260        .is_some()
261        && !system_policy.advisory_enforcement_unoverridable
262    {
263        merged_cfg.policy.advisory_enforcement_unoverridable =
264            user_cfg.policy.advisory_enforcement_unoverridable;
265    } else {
266        merged_cfg.policy.advisory_enforcement_unoverridable =
267            system_cfg.policy.advisory_enforcement_unoverridable;
268    }
269
270    merged_cfg.versions = system_cfg.versions;
271    merged_cfg.versions.extend(user_cfg.versions);
272    merged_cfg.versions.extend(project_cfg.versions);
273
274    merged_cfg.pkg_dirs = system_cfg.pkg_dirs;
275    if !system_policy.pkg_dirs_unoverridable {
276        merged_cfg.pkg_dirs.extend(user_cfg.pkg_dirs);
277        merged_cfg.pkg_dirs.extend(project_cfg.pkg_dirs);
278    }
279    merged_cfg.pkg_dirs.sort();
280    merged_cfg.pkg_dirs.dedup();
281
282    merged_cfg.cache_mirrors = system_cfg.cache_mirrors;
283    if !system_policy.cache_mirrors_unoverridable {
284        merged_cfg.cache_mirrors.extend(user_cfg.cache_mirrors);
285        merged_cfg.cache_mirrors.extend(project_cfg.cache_mirrors);
286    }
287    merged_cfg.cache_mirrors.sort();
288    merged_cfg.cache_mirrors.dedup();
289
290    if !system_policy.allow_deny_lists_unoverridable {
291        if project_cfg.policy.allowed_licenses.is_some() {
292            merged_cfg.policy.allowed_licenses = project_cfg.policy.allowed_licenses;
293        } else if user_cfg.policy.allowed_licenses.is_some() {
294            merged_cfg.policy.allowed_licenses = user_cfg.policy.allowed_licenses;
295        }
296        if project_cfg.policy.denied_licenses.is_some() {
297            merged_cfg.policy.denied_licenses = project_cfg.policy.denied_licenses;
298        } else if user_cfg.policy.denied_licenses.is_some() {
299            merged_cfg.policy.denied_licenses = user_cfg.policy.denied_licenses;
300        }
301        if project_cfg.policy.allowed_packages.is_some() {
302            merged_cfg.policy.allowed_packages = project_cfg.policy.allowed_packages;
303        } else if user_cfg.policy.allowed_packages.is_some() {
304            merged_cfg.policy.allowed_packages = user_cfg.policy.allowed_packages;
305        }
306        if project_cfg.policy.denied_packages.is_some() {
307            merged_cfg.policy.denied_packages = project_cfg.policy.denied_packages;
308        } else if user_cfg.policy.denied_packages.is_some() {
309            merged_cfg.policy.denied_packages = user_cfg.policy.denied_packages;
310        }
311        if project_cfg.policy.allowed_repos.is_some() {
312            merged_cfg.policy.allowed_repos = project_cfg.policy.allowed_repos;
313        } else if user_cfg.policy.allowed_repos.is_some() {
314            merged_cfg.policy.allowed_repos = user_cfg.policy.allowed_repos;
315        }
316        if project_cfg.policy.denied_repos.is_some() {
317            merged_cfg.policy.denied_repos = project_cfg.policy.denied_repos;
318        } else if user_cfg.policy.denied_repos.is_some() {
319            merged_cfg.policy.denied_repos = user_cfg.policy.denied_repos;
320        }
321    }
322
323    if !system_policy.signature_enforcement_unoverridable {
324        if project_cfg.policy.signature_enforcement.is_some() {
325            merged_cfg.policy.signature_enforcement = project_cfg.policy.signature_enforcement;
326        } else if user_cfg.policy.signature_enforcement.is_some() {
327            merged_cfg.policy.signature_enforcement = user_cfg.policy.signature_enforcement;
328        }
329    }
330
331    if let Some(url) = merged_cfg.registry.take()
332        && merged_cfg.default_registry.is_none()
333    {
334        merged_cfg.default_registry = Some(Registry {
335            handle: String::new(),
336            url,
337            advisory_prefix: None,
338            authorities: None,
339        });
340    }
341
342    if merged_cfg.default_registry.is_none() {
343        merged_cfg.default_registry = Some(Registry {
344            handle: "zoidberg".to_string(),
345            url: get_default_registry(),
346            advisory_prefix: Some("ZSA".to_string()),
347            authorities: Some(get_builtin_authorities()),
348        });
349    } else if let Some(ref mut reg) = merged_cfg.default_registry
350        && reg.url == get_default_registry()
351        && reg.authorities.as_ref().is_none_or(|a| a.is_empty())
352    {
353        let builtin = get_builtin_authorities();
354        if !builtin.is_empty() {
355            reg.authorities = Some(builtin);
356        }
357    }
358
359    if merged_cfg.repos.is_empty()
360        && let Some(reg) = &merged_cfg.default_registry
361        && !reg.handle.is_empty()
362    {
363        let db_root = get_db_root()?;
364        let repo_path = db_root.join(&reg.handle);
365        if repo_path.join("repo.yaml").exists()
366            && let Ok(repo_config) = read_repo_config(&repo_path)
367        {
368            merged_cfg.repos = repo_config
369                .repos
370                .into_iter()
371                .filter(|r| r.active)
372                .map(|r| r.name)
373                .collect();
374        }
375    }
376
377    if project_val.get("system_generations_limit").is_some()
378        && !system_policy.system_generations_limit_unoverridable
379    {
380        merged_cfg.system_generations_limit = project_cfg.system_generations_limit;
381    } else if user_val.get("system_generations_limit").is_some()
382        && !system_policy.system_generations_limit_unoverridable
383    {
384        merged_cfg.system_generations_limit = user_cfg.system_generations_limit;
385    } else {
386        merged_cfg.system_generations_limit = system_cfg.system_generations_limit;
387    }
388
389    Ok(merged_cfg)
390}
391
392/// Writes the user-specific configuration to disk.
393pub fn write_user_config(config: &Config) -> Result<()> {
394    let config_path = get_user_config_path()?;
395    let parent_dir = config_path
396        .parent()
397        .ok_or_else(|| anyhow!("Invalid config path"))?;
398    fs::create_dir_all(parent_dir)?;
399    let content = serde_yaml::to_string(config)?;
400    fs::write(config_path, content)?;
401    Ok(())
402}
403
404pub fn verify_remote_file(url: &str, trusted_keys: &[String]) -> Result<Vec<u8>> {
405    let client = crate::utils::get_http_client()?;
406
407    let response = client.get(url).send()?;
408    if !response.status().is_success() {
409        return Err(anyhow!(
410            "Failed to fetch remote file '{}': {}",
411            url,
412            response.status()
413        ));
414    }
415    let data = response.bytes()?;
416
417    let sig_url = format!("{}.sig", url);
418    let sig_response = client.get(&sig_url).send()?;
419    if !sig_response.status().is_success() {
420        return Err(anyhow!(
421            "Failed to fetch signature for '{}': {}",
422            url,
423            sig_response.status()
424        ));
425    }
426    let sig = sig_response.bytes()?;
427
428    let trusted_certs = crate::pgp::get_certs_by_name_or_fingerprint(trusted_keys)?;
429
430    let temp_dir = tempfile::Builder::new().prefix("zoi-verify-").tempdir()?;
431    let data_path = temp_dir.path().join("data");
432    let sig_path = temp_dir.path().join("sig");
433
434    fs::write(&data_path, &data)?;
435    fs::write(&sig_path, &sig)?;
436
437    crate::pgp::verify_detached_signature_multi_key(&data_path, &sig_path, trusted_certs)?;
438
439    Ok(data.to_vec())
440}
441
442pub fn add_repo(repo_name: &str) -> Result<()> {
443    let mut config = read_config_from_path(&get_user_config_path()?)?;
444    let lower_repo_name = repo_name.to_lowercase();
445    if config.repos.contains(&lower_repo_name) {
446        return Err(anyhow!(
447            "Repository '{}' already exists in user config.",
448            repo_name
449        ));
450    }
451    config.repos.push(lower_repo_name);
452    write_user_config(&config)
453}
454
455pub fn remove_repo(repo_name: &str) -> Result<()> {
456    let mut config = read_config_from_path(&get_user_config_path()?)?;
457    let lower_repo_name = repo_name.to_lowercase();
458    if let Some(pos) = config.repos.iter().position(|r| r == &lower_repo_name) {
459        config.repos.remove(pos);
460        write_user_config(&config)
461    } else {
462        Err(anyhow!(
463            "Repository '{}' not found in user config.",
464            repo_name
465        ))
466    }
467}
468
469pub fn interactive_add_repo() -> Result<()> {
470    let config = read_config()?;
471    let all_repos = get_all_repos()?;
472
473    let available_repos: Vec<_> = all_repos
474        .into_iter()
475        .filter(|r| !config.repos.contains(&r.to_lowercase()))
476        .collect();
477
478    if available_repos.is_empty() {
479        println!("{}", "No new repositories available to add.".yellow());
480        return Ok(());
481    }
482
483    println!("{}", "Available repositories to add:".green());
484    for (i, repo) in available_repos.iter().enumerate() {
485        println!("[{}] {}", i + 1, repo);
486    }
487
488    print!(
489        "\n{}",
490        "Select a repository to add (or 'q' to quit): ".yellow()
491    );
492    io::stdout().flush()?;
493
494    let mut input = String::new();
495    io::stdin().read_line(&mut input)?;
496    let input = input.trim();
497
498    if input == "q" {
499        println!("Aborted.");
500        return Ok(());
501    }
502
503    let choice: usize = match input.parse() {
504        Ok(num) => num,
505        Err(_) => return Err(anyhow!("Invalid input.")),
506    };
507
508    if choice > 0 && choice <= available_repos.len() {
509        let repo_to_add = &available_repos[choice - 1];
510        add_repo(repo_to_add)?;
511        println!("Repository '{}' added successfully.", repo_to_add.green());
512    } else {
513        return Err(anyhow!("Invalid selection."));
514    }
515
516    Ok(())
517}
518
519pub fn get_all_repos() -> Result<Vec<String>> {
520    let db_root = get_db_root()?;
521    let config = read_config()?;
522
523    if let Some(default_reg) = config.default_registry
524        && !default_reg.handle.is_empty()
525    {
526        let default_reg_path = db_root.join(default_reg.handle);
527        if default_reg_path.join("repo.yaml").exists() {
528            let repo_config = read_repo_config(&default_reg_path)?;
529            return Ok(repo_config.repos.into_iter().map(|r| r.name).collect());
530        }
531    }
532
533    Ok(Vec::new())
534}
535
536pub fn clone_git_repo(url: &str) -> Result<()> {
537    let git_root = get_git_root()?;
538    fs::create_dir_all(&git_root)?;
539    let repo_name = url
540        .trim_end_matches('/')
541        .split('/')
542        .next_back()
543        .unwrap_or("repo")
544        .trim_end_matches(".git");
545    let target = git_root.join(repo_name);
546    if target.exists() {
547        return Err(anyhow!(
548            "Git repo '{}' already exists at {}",
549            repo_name,
550            target.display()
551        ));
552    }
553    println!("Cloning '{}' into {}...", url.cyan(), target.display());
554    let status = std::process::Command::new("git")
555        .arg("clone")
556        .arg("--depth=1")
557        .arg(url)
558        .arg(&target)
559        .status()?;
560    if !status.success() {
561        return Err(anyhow!("git clone failed"));
562    }
563
564    let mut config = read_config_from_path(&get_user_config_path()?)?;
565    if !config.git_repos.iter().any(|repo_url| repo_url == url) {
566        config.git_repos.push(url.to_string());
567        write_user_config(&config)?;
568    }
569
570    println!(
571        "Cloned git repo as '{}' (use with '#git@{}/<pkg>')",
572        repo_name.green(),
573        repo_name
574    );
575    Ok(())
576}
577
578pub fn list_git_repos() -> Result<Vec<String>> {
579    let git_root = get_git_root()?;
580    if !git_root.exists() {
581        return Ok(Vec::new());
582    }
583
584    let mut repos = Vec::new();
585    for entry in fs::read_dir(git_root)? {
586        let entry = entry?;
587        if entry.path().is_dir() {
588            repos.push(entry.file_name().to_string_lossy().into_owned());
589        }
590    }
591    repos.sort();
592    Ok(repos)
593}
594
595pub fn remove_git_repo(repo_name: &str) -> Result<()> {
596    let git_root = get_git_root()?;
597    let target = git_root.join(repo_name);
598    if !target.exists() {
599        return Err(anyhow!("Git repository '{}' not found.", repo_name));
600    }
601
602    let mut config = read_config_from_path(&get_user_config_path()?)?;
603    let mut removed = false;
604    config.git_repos.retain(|url| {
605        let name_from_url = url
606            .trim_end_matches('/')
607            .split('/')
608            .next_back()
609            .unwrap_or_default()
610            .trim_end_matches(".git");
611        if name_from_url == repo_name {
612            removed = true;
613            false
614        } else {
615            true
616        }
617    });
618
619    if removed {
620        write_user_config(&config)?;
621    }
622
623    fs::remove_dir_all(&target)?;
624    println!(
625        "Removed git repository '{}' from {}",
626        repo_name.green(),
627        target.display()
628    );
629    Ok(())
630}
631
632pub fn add_cache_mirror(url: &str) -> Result<()> {
633    let mut config = read_config_from_path(&get_user_config_path()?)?;
634    if config.cache_mirrors.iter().any(|existing| existing == url) {
635        return Err(anyhow!("Cache mirror '{}' already exists.", url));
636    }
637    config.cache_mirrors.push(url.to_string());
638    write_user_config(&config)
639}
640
641pub fn remove_cache_mirror(url: &str) -> Result<()> {
642    let mut config = read_config_from_path(&get_user_config_path()?)?;
643    if let Some(pos) = config
644        .cache_mirrors
645        .iter()
646        .position(|existing| existing == url)
647    {
648        config.cache_mirrors.remove(pos);
649        write_user_config(&config)
650    } else {
651        Err(anyhow!("Cache mirror '{}' not found.", url))
652    }
653}
654
655pub fn set_default_registry(url: &str) -> Result<()> {
656    let mut config = read_config_from_path(&get_user_config_path()?)?;
657    config.default_registry = Some(Registry {
658        handle: String::new(),
659        url: url.to_string(),
660        advisory_prefix: None,
661        authorities: None,
662    });
663    write_user_config(&config)
664}
665
666pub fn set_user_default_registry(default_registry: Option<Registry>) -> Result<()> {
667    let mut config = read_config_from_path(&get_user_config_path()?)?;
668    config.default_registry = default_registry;
669    write_user_config(&config)
670}
671
672pub fn add_added_registry(url: &str) -> Result<()> {
673    let mut config = read_config_from_path(&get_user_config_path()?)?;
674    if config.added_registries.iter().any(|r| r.url == url) {
675        return Err(anyhow!("Registry with URL '{}' already exists.", url));
676    }
677    config.added_registries.push(Registry {
678        handle: String::new(),
679        url: url.to_string(),
680        advisory_prefix: None,
681        authorities: None,
682    });
683    write_user_config(&config)
684}
685
686pub fn remove_added_registry(handle_or_url: &str) -> Result<()> {
687    let mut config = read_config_from_path(&get_user_config_path()?)?;
688    if let Some(pos) = config
689        .added_registries
690        .iter()
691        .position(|r| r.handle == handle_or_url || r.url == handle_or_url)
692    {
693        let removed_registry = config.added_registries.remove(pos);
694        if !removed_registry.handle.is_empty() {
695            let db_root = get_db_root()?;
696            let repo_path = db_root.join(removed_registry.handle);
697            if repo_path.exists() {
698                fs::remove_dir_all(repo_path)?;
699            }
700        }
701        write_user_config(&config)
702    } else {
703        Err(anyhow!(
704            "Added registry with handle or URL '{}' not found.",
705            handle_or_url
706        ))
707    }
708}
709
710pub fn read_repo_config(db_path: &Path) -> Result<RepoConfig> {
711    let config_path = db_path.join("repo.yaml");
712    if !config_path.exists() {
713        return Err(anyhow!(
714            "repo.yaml not found in the root of the package database."
715        ));
716    }
717    let content = fs::read_to_string(config_path)?;
718    let config: RepoConfig = serde_yaml::from_str(&content)?;
719    Ok(config)
720}
721
722pub fn read_user_config() -> Result<Config> {
723    read_config_from_path(&get_user_config_path()?)
724}
725
726pub fn update_global_versions(versions: HashMap<String, String>) -> Result<()> {
727    let mut config = read_user_config()?;
728    config.versions.extend(versions);
729    write_user_config(&config)
730}
731
732/// Synchronizes the remote security policy if configured in system `config.yaml`.
733///
734/// This allows organizations to manage security constraints (allowed/denied lists)
735/// centrally. It verifies the policy's PGP signature before caching it locally.
736pub fn sync_remote_policy() -> Result<()> {
737    let config = read_config()?;
738    let Some(remote_cfg) = &config.remote_policy else {
739        return Ok(());
740    };
741
742    println!("{} Syncing remote security policy...", "::".bold().blue());
743
744    if crate::offline::is_offline() {
745        return Err(anyhow!("Cannot sync remote policy in offline mode."));
746    }
747
748    let client = crate::utils::get_http_client()?;
749
750    let policy_content = client.get(&remote_cfg.url).send()?.text()?;
751    let sig_content = client.get(&remote_cfg.signature_url).send()?.bytes()?;
752
753    let trusted_certs = crate::pgp::get_certs_by_name_or_fingerprint(&remote_cfg.trusted_keys)?;
754
755    let temp_dir = tempfile::Builder::new().prefix("zoi-policy-").tempdir()?;
756    let temp_policy_path = temp_dir.path().join("policy.yaml");
757    let temp_sig_path = temp_dir.path().join("policy.yaml.sig");
758
759    fs::write(&temp_policy_path, &policy_content)?;
760    fs::write(&temp_sig_path, &sig_content)?;
761
762    crate::pgp::verify_detached_signature_multi_key(
763        &temp_policy_path,
764        &temp_sig_path,
765        trusted_certs,
766    )?;
767
768    let cache_path = get_remote_policy_cache_path()?;
769    if let Some(parent) = cache_path.parent() {
770        fs::create_dir_all(parent)?;
771    }
772    fs::write(cache_path, policy_content)?;
773
774    println!(
775        "{}",
776        "Remote policy verified and cached successfully.".green()
777    );
778    Ok(())
779}
780
781/// Merges a remote security policy into the local machine policy.
782///
783/// Precedence: If a field is already unoverridable in the base (machine) policy,
784/// it remains so. Otherwise, the remote policy's unoverridable flags and lists
785/// are added/merged into the local state.
786fn merge_policies(base: &mut crate::types::Policy, remote: &crate::types::Policy) {
787    if remote.repos_unoverridable {
788        base.repos_unoverridable = true;
789    }
790    if remote.telemetry_enabled_unoverridable {
791        base.telemetry_enabled_unoverridable = true;
792    }
793    if remote.audit_log_enabled_unoverridable {
794        base.audit_log_enabled_unoverridable = true;
795    }
796    if remote.rollback_enabled_unoverridable {
797        base.rollback_enabled_unoverridable = true;
798    }
799    if remote.default_registry_unoverridable {
800        base.default_registry_unoverridable = true;
801    }
802    if remote.added_registries_unoverridable {
803        base.added_registries_unoverridable = true;
804    }
805    if remote.git_repos_unoverridable {
806        base.git_repos_unoverridable = true;
807    }
808    if remote.allow_deny_lists_unoverridable {
809        base.allow_deny_lists_unoverridable = true;
810    }
811    if remote.signature_enforcement_unoverridable {
812        base.signature_enforcement_unoverridable = true;
813    }
814    if remote.protect_db_unoverridable {
815        base.protect_db_unoverridable = true;
816    }
817    if remote.max_resolution_depth_unoverridable {
818        base.max_resolution_depth_unoverridable = true;
819    }
820    if remote.offline_mode_unoverridable {
821        base.offline_mode_unoverridable = true;
822    }
823    if remote.pkg_dirs_unoverridable {
824        base.pkg_dirs_unoverridable = true;
825    }
826    if remote.cache_mirrors_unoverridable {
827        base.cache_mirrors_unoverridable = true;
828    }
829    if remote.jobs_unoverridable {
830        base.jobs_unoverridable = true;
831    }
832    if remote.advisory_enforcement_unoverridable {
833        base.advisory_enforcement_unoverridable = true;
834    }
835
836    if let Some(allowed) = &remote.allowed_licenses {
837        base.allowed_licenses
838            .get_or_insert_with(Vec::new)
839            .extend(allowed.clone());
840    }
841    if let Some(denied) = &remote.denied_licenses {
842        base.denied_licenses
843            .get_or_insert_with(Vec::new)
844            .extend(denied.clone());
845    }
846    if let Some(allowed) = &remote.allowed_packages {
847        base.allowed_packages
848            .get_or_insert_with(Vec::new)
849            .extend(allowed.clone());
850    }
851    if let Some(denied) = &remote.denied_packages {
852        base.denied_packages
853            .get_or_insert_with(Vec::new)
854            .extend(denied.clone());
855    }
856    if let Some(allowed) = &remote.allowed_repos {
857        base.allowed_repos
858            .get_or_insert_with(Vec::new)
859            .extend(allowed.clone());
860    }
861    if let Some(denied) = &remote.denied_repos {
862        base.denied_repos
863            .get_or_insert_with(Vec::new)
864            .extend(denied.clone());
865    }
866
867    if let Some(remote_sig) = &remote.signature_enforcement {
868        if let Some(ref mut base_sig) = base.signature_enforcement {
869            if remote_sig.enable {
870                base_sig.enable = true;
871            }
872            base_sig
873                .trusted_keys
874                .extend(remote_sig.trusted_keys.clone());
875            base_sig.trusted_keys.sort();
876            base_sig.trusted_keys.dedup();
877        } else {
878            base.signature_enforcement = Some(remote_sig.clone());
879        }
880    }
881}