1use std::collections::HashMap;
2use std::fs;
3use std::io::{Write, stdin, stdout};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::sync::OnceLock;
7use std::time::Duration;
8
9use anyhow::{Result, anyhow};
10use clap_complete::Shell;
11use colored::Colorize;
12use crossterm::tty::IsTty;
13#[cfg(unix)]
14use nix;
15use sha2::{Digest, Sha512};
16
17pub fn get_http_client() -> Result<&'static reqwest::blocking::Client> {
24 static HTTP_CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
25 if crate::offline::is_offline() {
26 return Err(anyhow!(
27 "Cannot create HTTP client: Zoi is in offline mode."
28 ));
29 }
30 if let Some(client) = HTTP_CLIENT.get() {
31 return Ok(client);
32 }
33 let client = reqwest::blocking::Client::builder()
34 .user_agent("zoi")
35 .timeout(Duration::from_mins(1))
36 .use_rustls_tls()
37 .build()
38 .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?;
39 let _ = HTTP_CLIENT.set(client);
40 HTTP_CLIENT
41 .get()
42 .ok_or_else(|| anyhow!("HTTP_CLIENT should be set but was missing"))
43}
44
45pub fn build_blocking_http_client(
52 timeout_secs: u64
53) -> Result<reqwest::blocking::Client> {
54 if crate::offline::is_offline() {
55 return Err(anyhow!(
56 "Cannot create HTTP client: Zoi is in offline mode."
57 ));
58 }
59 let client = reqwest::blocking::Client::builder()
60 .user_agent("zoi")
61 .timeout(Duration::from_secs(timeout_secs))
62 .use_rustls_tls()
63 .build()?;
64 Ok(client)
65}
66
67pub fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
74 if link.exists() || link.is_symlink() {
75 if link.is_dir() && !link.is_symlink() {
76 fs::remove_dir_all(link)?;
77 } else {
78 fs::remove_file(link)?;
79 }
80 }
81 #[cfg(unix)]
82 {
83 std::os::unix::fs::symlink(target, link)?;
84 }
85 #[cfg(windows)]
86 {
87 if std::os::windows::fs::symlink_dir(target, link).is_err() {
88 if junction::create(target, link).is_err() {
89 copy_dir_all(target, link)?;
90 }
91 }
92 }
93 Ok(())
94}
95
96pub fn command_exists(command: &str) -> bool {
98 if cfg!(target_os = "windows") {
99 Command::new("where")
100 .arg(command)
101 .stdout(std::process::Stdio::null())
102 .stderr(std::process::Stdio::null())
103 .status()
104 .is_ok_and(|status| status.success())
105 } else {
106 Command::new("bash")
107 .arg("-c")
108 .arg("command -v -- \"$1\"")
111 .arg("zoi-command-exists")
112 .arg(command)
113 .stdout(std::process::Stdio::null())
114 .stderr(std::process::Stdio::null())
115 .status()
116 .is_ok_and(|status| status.success())
117 }
118}
119
120pub fn get_platform() -> Result<String> {
131 let os = match std::env::consts::OS {
132 "linux" => "linux",
133 "macos" | "darwin" => "macos",
134 "windows" => "windows",
135 unsupported_os => {
136 return Err(anyhow!(
137 "Unsupported operating system: {unsupported_os}"
138 ));
139 }
140 };
141 let arch = match std::env::consts::ARCH {
142 "x86_64" | "amd64" => "amd64",
143 "aarch64" | "arm64" => "arm64",
144 "x86" | "i386" | "i686" => "386",
145 unsupported_arch => {
146 return Err(anyhow!(
147 "Unsupported architecture: {unsupported_arch}"
148 ));
149 }
150 };
151 Ok(format!("{os}-{arch}"))
152}
153
154pub fn get_user_home() -> Option<PathBuf> {
157 if let Ok(user_var) =
158 std::env::var("SUDO_USER").or_else(|_| std::env::var("DOAS_USER"))
159 {
160 #[cfg(unix)]
161 {
162 use nix::unistd::User;
163 if let Ok(Some(user)) = User::from_name(&user_var) {
164 return Some(user.dir);
165 }
166 }
167 #[cfg(not(unix))]
168 let _ = user_var;
169 }
170 home::home_dir()
171}
172
173pub fn get_user_config_dir() -> Result<PathBuf> {
180 let root =
181 xdg_or_platform_dir("XDG_CONFIG_HOME", dirs::config_dir, |home| {
182 home.join(".config")
183 })?;
184 Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
185}
186
187pub fn get_user_data_dir() -> Result<PathBuf> {
194 let root =
195 xdg_or_platform_dir("XDG_DATA_HOME", dirs::data_local_dir, |home| {
196 home.join(".local/share")
197 })?;
198 Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
199}
200
201pub fn get_user_cache_dir() -> Result<PathBuf> {
208 let root =
209 xdg_or_platform_dir("XDG_CACHE_HOME", dirs::cache_dir, |home| {
210 home.join(".cache")
211 })?;
212 Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
213}
214
215pub fn get_user_state_dir() -> Result<PathBuf> {
221 let root =
222 xdg_or_platform_dir("XDG_STATE_HOME", dirs::data_local_dir, |home| {
223 home.join(".local/state")
224 })?;
225 Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
226}
227
228fn xdg_or_platform_dir(
230 variable: &str,
231 platform_dir: impl FnOnce() -> Option<PathBuf>,
232 unix_fallback: impl FnOnce(PathBuf) -> PathBuf
233) -> Result<PathBuf> {
234 if let Some(path) =
235 std::env::var_os(variable).filter(|path| !path.is_empty())
236 {
237 let path = PathBuf::from(path);
238 if path.is_absolute() {
239 return Ok(path);
240 }
241 }
242 if cfg!(unix) && !cfg!(target_os = "macos") {
243 return get_user_home()
244 .map(unix_fallback)
245 .ok_or_else(|| anyhow!("Could not find home directory."));
246 }
247 platform_dir()
248 .ok_or_else(|| anyhow!("Could not determine platform directory."))
249}
250
251pub fn get_user_bin_dir() -> Result<PathBuf> {
262 #[cfg(unix)]
263 {
264 let home = get_user_home()
265 .ok_or_else(|| anyhow!("Could not find home directory."))?;
266 Ok(crate::sysroot::apply_sysroot(
267 home.join(".local").join("bin")
268 ))
269 }
270 #[cfg(not(unix))]
271 {
272 Ok(get_user_data_dir()?.join("pkgs").join("bin"))
273 }
274}
275
276#[must_use]
282pub fn get_system_bin_dir() -> PathBuf {
283 let path = if cfg!(target_os = "windows") {
284 PathBuf::from("C:\\ProgramData\\zoi\\pkgs\\bin")
285 } else if cfg!(target_os = "macos") {
286 PathBuf::from("/usr/local/bin")
287 } else {
288 PathBuf::from("/usr/bin")
289 };
290 crate::sysroot::apply_sysroot(path)
291}
292
293pub fn get_user_completions_dir(shell: &str) -> Result<PathBuf> {
299 Ok(get_user_data_dir()?.join("pkgs").join("shell").join(shell))
300}
301
302pub fn get_system_data_dir() -> PathBuf {
304 let path = if cfg!(target_os = "windows") {
305 PathBuf::from("C:\\ProgramData\\zoi")
306 } else if cfg!(target_os = "macos") {
307 PathBuf::from("/Library/Application Support/zoi")
308 } else {
309 PathBuf::from("/var/lib/zoi")
310 };
311 crate::sysroot::apply_sysroot(path)
312}
313
314pub fn get_system_config_dir() -> PathBuf {
316 let path = if cfg!(target_os = "windows") {
317 PathBuf::from("C:\\ProgramData\\zoi")
318 } else if cfg!(target_os = "macos") {
319 PathBuf::from("/Library/Application Support/zoi")
320 } else {
321 PathBuf::from("/etc/zoi")
322 };
323 crate::sysroot::apply_sysroot(path)
324}
325
326pub fn get_system_cache_dir() -> PathBuf {
328 let path = if cfg!(target_os = "windows") {
329 PathBuf::from("C:\\ProgramData\\zoi\\cache")
330 } else if cfg!(target_os = "macos") {
331 PathBuf::from("/Library/Caches/zoi")
332 } else {
333 PathBuf::from("/var/cache/zoi")
334 };
335 crate::sysroot::apply_sysroot(path)
336}
337
338pub fn get_db_root() -> Result<std::path::PathBuf> {
344 if let Ok(path) = std::env::var("ZOI_DB_DIR") {
345 return Ok(std::path::PathBuf::from(path));
346 }
347 Ok(get_user_data_dir()?.join("pkgs").join("db"))
348}
349
350pub fn get_store_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
363 match scope {
364 crate::types::Scope::User => {
365 Ok(get_user_data_dir()?.join("pkgs").join("store"))
366 }
367 crate::types::Scope::System => {
368 Ok(get_system_data_dir().join("pkgs").join("store"))
369 }
370 crate::types::Scope::Project => {
371 let current_dir = std::env::current_dir()?;
372 Ok(current_dir.join(".zoi").join("pkgs").join("store"))
373 }
374 }
375}
376
377pub fn get_db_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
384 match scope {
385 crate::types::Scope::User => {
386 Ok(get_user_data_dir()?.join("pkgs").join("db"))
387 }
388 crate::types::Scope::System => {
389 Ok(get_system_data_dir().join("pkgs").join("db"))
390 }
391 crate::types::Scope::Project => {
392 let current_dir = std::env::current_dir()?;
393 Ok(current_dir.join(".zoi").join("pkgs").join("db"))
394 }
395 }
396}
397
398pub fn get_git_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
405 match scope {
406 crate::types::Scope::User => {
407 Ok(get_user_data_dir()?.join("pkgs").join("git"))
408 }
409 crate::types::Scope::System => {
410 Ok(get_system_data_dir().join("pkgs").join("git"))
411 }
412 crate::types::Scope::Project => {
413 let current_dir = std::env::current_dir()?;
414 Ok(current_dir.join(".zoi").join("pkgs").join("git"))
415 }
416 }
417}
418
419pub fn generate_package_id(
427 registry_handle: &str,
428 repo_path: &str,
429 package_name: &str
430) -> String {
431 let format_string =
432 format!("#{registry_handle}@{repo_path}/{package_name}");
433 let mut hasher = Sha512::new();
434 hasher.update(format_string.as_bytes());
435 let result = hasher.finalize();
436 let hex_string = hex::encode(result);
437 hex_string[..32].to_string()
438}
439
440pub fn generate_versioned_package_id(
442 registry_handle: &str,
443 repo_path: &str,
444 package_name: &str,
445 version: &str
446) -> String {
447 let format_string =
448 format!("#{registry_handle}@{repo_path}/{package_name}@{version}");
449 let mut hasher = Sha512::new();
450 hasher.update(format_string.as_bytes());
451 let result = hasher.finalize();
452 let hex_string = hex::encode(result);
453 hex_string[..32].to_string()
454}
455
456pub fn get_package_dir_name(package_id: &str, package_name: &str) -> String {
459 format!("{package_id}-{package_name}")
460}
461
462pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
468 let src = if src.as_os_str().is_empty() {
469 Path::new(".")
470 } else {
471 src
472 };
473 fs::create_dir_all(dst)?;
474 for entry in fs::read_dir(src)? {
475 let entry = entry?;
476 let ty = entry.file_type()?;
477 if ty.is_dir() {
478 copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
479 } else if ty.is_symlink() {
480 let target = fs::read_link(entry.path())?;
481 let destination = dst.join(entry.file_name());
482 #[cfg(unix)]
483 std::os::unix::fs::symlink(target, destination)?;
484 #[cfg(windows)]
485 {
486 if target.is_dir() {
487 std::os::windows::fs::symlink_dir(target, destination)?;
488 } else {
489 std::os::windows::fs::symlink_file(target, destination)?;
490 }
491 }
492 } else {
493 fs::copy(entry.path(), dst.join(entry.file_name()))?;
494 }
495 }
496 Ok(())
497}
498
499pub fn retry_backoff_sleep(attempt: u32) {
504 let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
505 let jitter = u64::from(
506 std::time::SystemTime::now()
507 .duration_since(std::time::UNIX_EPOCH)
508 .unwrap_or(Duration::from_secs(0))
509 .subsec_millis()
510 % 200
511 );
512 let sleep_ms = (base_ms + jitter).min(8000);
513 std::thread::sleep(Duration::from_millis(sleep_ms));
514}
515
516pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
519 let path = crate::sysroot::apply_sysroot("/etc/os-release");
520 if let Ok(contents) = fs::read_to_string(path) {
521 let info: HashMap<String, String> = contents
522 .lines()
523 .filter_map(|line| {
524 let mut parts = line.splitn(2, '=');
525 let key = parts.next()?;
526 let value = parts.next()?.trim_matches('"').to_string();
527 if key.is_empty() {
528 None
529 } else {
530 Some((key.to_string(), value))
531 }
532 })
533 .collect();
534 if info.is_empty() { None } else { Some(info) }
535 } else {
536 None
537 }
538}
539
540pub fn get_linux_distro_family() -> Option<String> {
556 if is_zoios() {
557 return Some("zoios".to_string());
558 }
559 if let Some(info) = get_linux_distribution_info() {
560 if let Some(id_like) = info.get("ID_LIKE") {
561 let families: Vec<&str> = id_like.split_whitespace().collect();
562 if families.contains(&"debian") {
563 return Some("debian".to_string());
564 }
565 if families.contains(&"arch") {
566 return Some("arch".to_string());
567 }
568 if families.contains(&"fedora") {
569 return Some("fedora".to_string());
570 }
571 if families.contains(&"rhel") {
572 return Some("fedora".to_string());
573 }
574 if families.contains(&"suse") {
575 return Some("suse".to_string());
576 }
577 if families.contains(&"gentoo") {
578 return Some("gentoo".to_string());
579 }
580 }
581 if let Some(id) = info.get("ID") {
582 return match id.as_str() {
583 "debian" | "ubuntu" | "linuxmint" | "pop" | "kali"
584 | "kubuntu" | "lubuntu" | "xubuntu" | "zorin"
585 | "elementary" => Some("debian".to_string()),
586 "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
587 Some("arch".to_string())
588 }
589 "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => {
590 Some("fedora".to_string())
591 }
592 "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => {
593 Some("suse".to_string())
594 }
595 "gentoo" => Some("gentoo".to_string()),
596 "alpine" => Some("alpine".to_string()),
597 "void" => Some("void".to_string()),
598 "solus" => Some("solus".to_string()),
599 "guix" => Some("guix".to_string()),
600 _ => None
601 };
602 }
603 }
604 None
605}
606
607pub fn get_linux_distribution() -> Option<String> {
609 get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
610}
611
612pub fn is_zoios() -> bool {
615 if let Some(info) = get_linux_distribution_info() {
616 if let Some(id) = info.get("ID")
617 && (id == "zoios" || id == "parlex")
618 {
619 return true;
620 }
621 if let Some(id_like) = info.get("ID_LIKE")
622 && id_like.split_whitespace().any(|s| s == "zoios")
623 {
624 return true;
625 }
626 }
627 false
628}
629
630pub fn resolve_fallback_scope() -> crate::types::Scope {
632 if std::path::Path::new("zoi.lua").exists()
633 || std::path::Path::new("zoi.yaml").exists()
634 {
635 crate::types::Scope::Project
636 } else if is_zoios() {
637 crate::types::Scope::System
638 } else {
639 crate::types::Scope::User
640 }
641}
642
643pub fn get_desktop_environment() -> Option<String> {
645 if cfg!(target_os = "windows") {
646 return Some("windows".to_string());
647 }
648 if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
649 && !de.is_empty()
650 {
651 return Some(de.to_lowercase());
652 }
653 if let Ok(ds) = std::env::var("DESKTOP_SESSION")
654 && !ds.is_empty()
655 {
656 return Some(ds.to_lowercase());
657 }
658 None
659}
660
661pub fn get_display_server() -> Option<String> {
663 if cfg!(target_os = "windows") {
664 return Some("windows".to_string());
665 }
666 if cfg!(target_os = "macos") {
667 return Some("quartz".to_string());
668 }
669 if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
670 && !st.is_empty()
671 {
672 return Some(st.to_lowercase());
673 }
674 None
675}
676
677pub fn get_kernel_version() -> Option<String> {
679 if cfg!(unix) {
680 let output = Command::new("uname").arg("-r").output().ok()?;
681 if output.status.success() {
682 return Some(
683 String::from_utf8_lossy(&output.stdout).trim().to_string()
684 );
685 }
686 } else if cfg!(target_os = "windows") {
687 let output = Command::new("pwsh")
688 .arg("-Command")
689 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
690 .output()
691 .ok()?;
692 if output.status.success() {
693 return Some(
694 String::from_utf8_lossy(&output.stdout).trim().to_string()
695 );
696 }
697 }
698 None
699}
700
701pub fn get_init_system() -> Option<String> {
703 if let Ok(val) = std::env::var("ZOI_INIT") {
704 return Some(val.to_lowercase());
705 }
706
707 let run_systemd =
708 crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
709 if run_systemd.exists() {
710 return Some("systemd".to_string());
711 }
712
713 let run_openrc =
714 crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
715 if run_openrc.exists() {
716 return Some("openrc".to_string());
717 }
718
719 let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
720 if let Ok(target) = std::fs::read_link(&sbin_init) {
721 let target_str = target.to_string_lossy();
722 if target_str.contains("systemd") {
723 return Some("systemd".to_string());
724 } else if target_str.contains("openrc") {
725 return Some("openrc".to_string());
726 } else if target_str.contains("busybox") {
727 return Some("busybox".to_string());
728 }
729 }
730
731 None
732}
733
734pub fn get_privilege_escalator() -> Option<String> {
737 if command_exists("sudo") {
738 Some("sudo".to_string())
739 } else if command_exists("doas") {
740 Some("doas".to_string())
741 } else {
742 None
743 }
744}
745
746pub fn get_distro_version() -> Option<String> {
748 if let Some(info) = get_linux_distribution_info()
749 && let Some(vid) = info.get("VERSION_ID")
750 {
751 return Some(vid.clone());
752 }
753 if cfg!(target_os = "macos") {
754 let output = Command::new("sw_vers")
755 .arg("-productVersion")
756 .output()
757 .ok()?;
758 if output.status.success() {
759 return Some(
760 String::from_utf8_lossy(&output.stdout).trim().to_string()
761 );
762 }
763 } else if cfg!(target_os = "windows") {
764 let output = Command::new("pwsh")
765 .arg("-Command")
766 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
767 .output()
768 .ok()?;
769 if output.status.success() {
770 return Some(
771 String::from_utf8_lossy(&output.stdout).trim().to_string()
772 );
773 }
774 }
775 None
776}
777
778pub fn get_cpu_info() -> Option<String> {
780 if cfg!(target_os = "linux") {
781 if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
782 for line in cpuinfo.lines() {
783 if line.starts_with("model name")
784 && let Some((_, model)) = line.split_once(':')
785 {
786 return Some(model.trim().to_string());
787 }
788 }
789 }
790 } else if cfg!(target_os = "macos") {
791 let output = Command::new("sysctl")
792 .arg("-n")
793 .arg("machdep.cpu.brand_string")
794 .output()
795 .ok()?;
796 if output.status.success() {
797 return Some(
798 String::from_utf8_lossy(&output.stdout).trim().to_string()
799 );
800 }
801 } else if cfg!(target_os = "windows") {
802 let output = Command::new("pwsh")
803 .arg("-Command")
804 .arg("(Get-CimInstance Win32_Processor).Name")
805 .output()
806 .ok()?;
807 if output.status.success() {
808 return Some(
809 String::from_utf8_lossy(&output.stdout).trim().to_string()
810 );
811 }
812 }
813 None
814}
815
816pub fn get_gpu_info() -> Option<String> {
818 if cfg!(target_os = "linux") {
819 if let Ok(output) = Command::new("lspci").output()
820 && output.status.success()
821 {
822 let stdout = String::from_utf8_lossy(&output.stdout);
823 for line in stdout.lines() {
824 if (line.contains("VGA compatible controller")
825 || line.contains("3D controller"))
826 && let Some((_, model)) = line.split_once(": ")
827 {
828 return Some(model.trim().to_string());
829 }
830 }
831 }
832 } else if cfg!(target_os = "macos") {
833 let output = Command::new("system_profiler")
834 .arg("SPDisplaysDataType")
835 .output()
836 .ok()?;
837 if output.status.success() {
838 let stdout = String::from_utf8_lossy(&output.stdout);
839 for line in stdout.lines() {
840 if line.trim().starts_with("Chipset Model:")
841 && let Some((_, model)) = line.split_once(':')
842 {
843 return Some(model.trim().to_string());
844 }
845 }
846 }
847 } else if cfg!(target_os = "windows") {
848 let output = Command::new("pwsh")
849 .arg("-Command")
850 .arg("(Get-CimInstance Win32_VideoController).Name")
851 .output()
852 .ok()?;
853 if output.status.success() {
854 return Some(
855 String::from_utf8_lossy(&output.stdout).trim().to_string()
856 );
857 }
858 }
859 None
860}
861
862pub fn get_native_package_manager() -> Option<String> {
866 let os = std::env::consts::OS;
867 match os {
868 "linux" => get_linux_distro_family()
869 .map(|family| {
870 match family.as_str() {
871 "debian" => "apt",
872 "arch" => "pacman",
873 "fedora" => "dnf",
874 "suse" => "zypper",
875 "gentoo" => "portage",
876 "alpine" => "apk",
877 "void" => "xbps-install",
878 "solus" => "eopkg",
879 "guix" => "guix",
880 "zoios" => "zoi",
881 _ => "unknown"
882 }
883 .to_string()
884 })
885 .filter(|s| s != "unknown"),
886 "macos" => {
887 if command_exists("brew") {
888 Some("brew".to_string())
889 } else if command_exists("port") {
890 Some("macports".to_string())
891 } else {
892 None
893 }
894 }
895 "windows" => {
896 if command_exists("scoop") {
897 Some("scoop".to_string())
898 } else if command_exists("choco") {
899 Some("choco".to_string())
900 } else if command_exists("winget") {
901 Some("winget".to_string())
902 } else {
903 None
904 }
905 }
906 _ => None
907 }
908}
909
910pub fn get_all_available_package_managers() -> Vec<String> {
915 let mut managers = Vec::new();
916 let all_possible_managers = [
917 "apt",
918 "pacman",
919 "yay",
920 "paru",
921 "pikaur",
922 "trizen",
923 "dnf",
924 "yum",
925 "zypper",
926 "portage",
927 "apk",
928 "snap",
929 "flatpak",
930 "nix",
931 "brew",
932 "port",
933 "scoop",
934 "choco",
935 "winget",
936 "pkg",
937 "pkg_add",
938 "xbps-install",
939 "eopkg",
940 "guix",
941 "mas"
942 ];
943
944 for manager in &all_possible_managers {
945 if command_exists(manager) {
946 managers.push(manager.to_string());
947 }
948 }
949 managers.sort();
950 managers.dedup();
951 managers
952}
953
954pub fn format_bytes(bytes: u64) -> String {
956 const KIB: u64 = 1024;
957 const MIB: u64 = 1024 * KIB;
958 const GIB: u64 = 1024 * MIB;
959 if bytes >= GIB {
960 format!("{:.2} GiB", bytes as f64 / GIB as f64)
961 } else if bytes >= MIB {
962 format!("{:.2} MiB", bytes as f64 / MIB as f64)
963 } else if bytes >= KIB {
964 format!("{:.2} KiB", bytes as f64 / KIB as f64)
965 } else {
966 format!("{bytes} B")
967 }
968}
969
970pub fn format_size_diff(diff: i64) -> String {
973 if diff == 0 {
974 return "0 B".to_string();
975 }
976 let sign = if diff > 0 { "+" } else { "-" };
977 let bytes = diff.unsigned_abs();
978 format!("{}{}", sign, format_bytes(bytes))
979}
980
981pub fn is_safe_path(base: &Path, path: &Path) -> bool {
987 let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
988 let joined = if path.is_absolute() {
989 path.to_path_buf()
990 } else {
991 base.join(path)
992 };
993 let mut normalized = PathBuf::new();
994 for component in joined.components() {
995 match component {
996 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
997 normalized.push(component);
998 }
999 std::path::Component::CurDir => {}
1000 std::path::Component::ParentDir => {
1001 if !normalized.pop() {
1002 return false;
1003 }
1004 }
1005 std::path::Component::Normal(p) => normalized.push(p)
1006 }
1007 }
1008 normalized.starts_with(&base)
1009}
1010
1011pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
1017 if link.exists() || link.is_symlink() {
1018 fs::remove_file(link)?;
1019 }
1020 #[cfg(unix)]
1021 {
1022 std::os::unix::fs::symlink(target, link)
1023 }
1024 #[cfg(windows)]
1025 {
1026 if std::os::windows::fs::symlink_file(target, link).is_err() {
1027 if fs::hard_link(target, link).is_err() {
1028 fs::copy(target, link)?;
1029 }
1030 }
1031 Ok(())
1032 }
1033}
1034
1035pub fn is_admin() -> bool {
1037 #[cfg(unix)]
1038 {
1039 nix::unistd::getuid().is_root()
1040 }
1041 #[cfg(windows)]
1042 {
1043 false
1044 }
1045}
1046
1047pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
1054 let status = if cfg!(target_os = "windows") {
1055 Command::new("pwsh")
1056 .arg("-Command")
1057 .arg(command_str)
1058 .status()?
1059 } else {
1060 Command::new("bash").arg("-c").arg(command_str).status()?
1061 };
1062 if !status.success() {
1063 return Err(anyhow!("Command failed: {command_str}"));
1064 }
1065 Ok(())
1066}
1067
1068pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
1076 let status = if cfg!(target_os = "windows") {
1077 Command::new("pwsh")
1078 .arg("-Command")
1079 .arg(command_str)
1080 .stdout(std::process::Stdio::null())
1081 .stderr(std::process::Stdio::null())
1082 .status()?
1083 } else {
1084 Command::new("bash")
1085 .arg("-c")
1086 .arg(command_str)
1087 .stdout(std::process::Stdio::null())
1088 .stderr(std::process::Stdio::null())
1089 .status()?
1090 };
1091 if !status.success() {
1092 return Err(anyhow!("Command failed: {command_str}"));
1093 }
1094 Ok(())
1095}
1096
1097pub fn is_mini_mode() -> bool {
1099 std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
1100}
1101
1102pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
1104 if yes {
1105 return true;
1106 }
1107 if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
1108 return false;
1109 }
1110 print!("{prompt} [y/N]: ");
1111 let _ = stdout().flush();
1112 let mut input = String::new();
1113 if stdin().read_line(&mut input).is_err() {
1114 return false;
1115 }
1116 input.trim().eq_ignore_ascii_case("y")
1117}
1118
1119pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
1125 if !path.exists() {
1126 return Ok(());
1127 }
1128 for entry in walkdir::WalkDir::new(path) {
1129 let entry = entry?;
1130 let mut perms = fs::metadata(entry.path())?.permissions();
1131 if !perms.readonly() {
1132 perms.set_readonly(true);
1133 fs::set_permissions(entry.path(), perms)?;
1134 }
1135 }
1136 Ok(())
1137}
1138
1139pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1145 if !path.exists() {
1146 return Ok(());
1147 }
1148 for entry in walkdir::WalkDir::new(path) {
1149 let entry = entry?;
1150 let mut perms = fs::metadata(entry.path())?.permissions();
1151 if perms.readonly() {
1152 #[cfg(unix)]
1153 {
1154 use std::os::unix::fs::PermissionsExt;
1155 let mode = perms.mode();
1156 perms.set_mode(mode | 0o200);
1157 }
1158 #[cfg(not(unix))]
1159 {
1160 perms.set_readonly(false);
1161 }
1162 fs::set_permissions(entry.path(), perms)?;
1163 }
1164 }
1165 Ok(())
1166}
1167
1168#[cfg(unix)]
1175pub fn set_path_owner(
1176 path: &Path,
1177 owner: &str,
1178 group: &str
1179) -> anyhow::Result<()> {
1180 use nix::unistd::{Gid, Group, Uid, User, chown};
1181 let uid = if let Ok(u) = owner.parse::<u32>() {
1182 Some(Uid::from_raw(u))
1183 } else if !owner.is_empty() {
1184 Some(
1185 User::from_name(owner)
1186 .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1187 .ok_or_else(|| anyhow!("User not found: {owner}"))?
1188 .uid
1189 )
1190 } else {
1191 None
1192 };
1193 let gid = if let Ok(g) = group.parse::<u32>() {
1194 Some(Gid::from_raw(g))
1195 } else if !group.is_empty() {
1196 Some(
1197 Group::from_name(group)
1198 .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1199 .ok_or_else(|| anyhow!("Group not found: {group}"))?
1200 .gid
1201 )
1202 } else {
1203 None
1204 };
1205 chown(path, uid, gid)
1206 .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1207 Ok(())
1208}
1209
1210pub fn is_platform_compatible(
1212 current_platform: &str,
1213 allowed_platforms: &[String]
1214) -> bool {
1215 let os_part = current_platform
1216 .split('-')
1217 .next()
1218 .unwrap_or(current_platform);
1219 let os = match os_part {
1220 "darwin" => "macos",
1221 other => other
1222 };
1223 allowed_platforms.iter().any(|p| {
1224 if let Some(rest) = p.strip_prefix("ci:") {
1225 let target = rest.split(':').next().unwrap_or_default();
1226 target == current_platform || target == os
1227 } else {
1228 let p_norm = if p == "darwin" { "macos" } else { p };
1229 p_norm == "all" || p_norm == os || p_norm == current_platform
1230 }
1231 })
1232}
1233
1234pub fn check_license(license: &str) {
1236 if license.is_empty() || license.eq_ignore_ascii_case("None") {
1237 return;
1238 }
1239 if license.eq_ignore_ascii_case("Proprietary")
1240 || license.eq_ignore_ascii_case("Unknown")
1241 {
1242 return;
1243 }
1244 match spdx::Expression::parse(license) {
1245 Ok(expr) => {
1246 if !expr.evaluate(|req| match req.license {
1247 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1248 spdx::LicenseItem::Other { .. } => false
1249 }) {
1250 println!(
1251 "{} License '{}' is not an OSI approved license.",
1252 "Warning:".yellow(),
1253 license.yellow().bold()
1254 );
1255 }
1256 }
1257 Err(_) => {
1258 println!(
1259 "{} Could not parse license expression '{}'. It may not be a \
1260 valid SPDX identifier.",
1261 "Warning:".yellow(),
1262 license.yellow().bold()
1263 );
1264 }
1265 }
1266}
1267
1268pub fn confirm_untrusted_source(
1275 source_type: &crate::types::SourceType,
1276 yes: bool
1277) -> anyhow::Result<()> {
1278 if is_mini_mode() {
1279 return Ok(());
1280 }
1281 if source_type == &crate::types::SourceType::OfficialRepo {
1282 return Ok(());
1283 }
1284 let warning_message = match source_type {
1285 crate::types::SourceType::UntrustedRepo(repo) => {
1286 format!(
1287 "The package from repository '@{repo}' is not an official Zoi \
1288 repository."
1289 )
1290 }
1291 crate::types::SourceType::LocalFile => {
1292 "You are installing from a local file.".to_string()
1293 }
1294 crate::types::SourceType::Url => {
1295 "You are installing from a remote URL. This script will be \
1296 executed with your user's permissions, which could lead to remote \
1297 code execution if the source is malicious."
1298 .to_string()
1299 }
1300 crate::types::SourceType::GitRepo(repo) => format!(
1301 "You are installing from an external git repository '{repo}'. \
1302 This script will be executed with your user's permissions."
1303 ),
1304 crate::types::SourceType::OfficialRepo => return Ok(())
1305 };
1306 println!(
1307 "\n{}: {}",
1308 "SECURITY WARNING".yellow().bold(),
1309 warning_message
1310 );
1311 if ask_for_confirmation(
1312 "This source is not trusted. Are you sure you want to continue?",
1313 yes
1314 ) {
1315 Ok(())
1316 } else {
1317 Err(anyhow!("Operation aborted by user."))
1318 }
1319}
1320
1321pub fn expand_placeholders(
1333 path: &str,
1334 version_dir: &Path,
1335 scope: crate::types::Scope
1336) -> Result<String> {
1337 let mut expanded = path.to_string();
1338 expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1339 expanded = expanded.replace(
1340 "${usrroot}",
1341 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1342 );
1343 if let Some(home_dir) = get_user_home() {
1344 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1345 }
1346
1347 let applications_dir = match scope {
1348 crate::types::Scope::System => PathBuf::from("/Applications"),
1349 crate::types::Scope::User => get_user_home().map_or_else(
1350 || PathBuf::from("/Applications"),
1351 |h| h.join("Applications")
1352 ),
1353 crate::types::Scope::Project => std::env::current_dir()
1354 .unwrap_or_default()
1355 .join("Applications")
1356 };
1357 expanded = expanded
1358 .replace("${applications}", &applications_dir.to_string_lossy());
1359
1360 Ok(expanded)
1361}
1362
1363pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1365 let path = path.as_ref();
1366 if !path.starts_with("~") {
1367 return path.to_path_buf();
1368 }
1369 if let Some(home_dir) = get_user_home() {
1370 if path == Path::new("~") {
1371 return home_dir;
1372 }
1373 if let Ok(stripped) = path.strip_prefix("~/") {
1374 return home_dir.join(stripped);
1375 }
1376 }
1377 path.to_path_buf()
1378}
1379
1380pub fn get_current_shell() -> Option<Shell> {
1382 if cfg!(windows) {
1383 return Some(Shell::PowerShell);
1384 }
1385 if let Ok(shell_path) = std::env::var("SHELL") {
1386 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1387 match shell_name {
1388 "bash" => Some(Shell::Bash),
1389 "zsh" => Some(Shell::Zsh),
1390 "fish" => Some(Shell::Fish),
1391 "elvish" => Some(Shell::Elvish),
1392 "pwsh" => Some(Shell::PowerShell),
1393 _ => None
1394 }
1395 } else {
1396 None
1397 }
1398}