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> {
257 Ok(get_user_data_dir()?.join("pkgs").join("bin"))
258}
259
260pub fn get_user_completions_dir(shell: &str) -> Result<PathBuf> {
266 Ok(get_user_data_dir()?.join("pkgs").join("shell").join(shell))
267}
268
269pub fn get_system_data_dir() -> PathBuf {
271 let path = if cfg!(target_os = "windows") {
272 PathBuf::from("C:\\ProgramData\\zoi")
273 } else if cfg!(target_os = "macos") {
274 PathBuf::from("/Library/Application Support/zoi")
275 } else {
276 PathBuf::from("/var/lib/zoi")
277 };
278 crate::sysroot::apply_sysroot(path)
279}
280
281pub fn get_system_config_dir() -> PathBuf {
283 let path = if cfg!(target_os = "windows") {
284 PathBuf::from("C:\\ProgramData\\zoi")
285 } else if cfg!(target_os = "macos") {
286 PathBuf::from("/Library/Application Support/zoi")
287 } else {
288 PathBuf::from("/etc/zoi")
289 };
290 crate::sysroot::apply_sysroot(path)
291}
292
293pub fn get_system_cache_dir() -> PathBuf {
295 let path = if cfg!(target_os = "windows") {
296 PathBuf::from("C:\\ProgramData\\zoi\\cache")
297 } else if cfg!(target_os = "macos") {
298 PathBuf::from("/Library/Caches/zoi")
299 } else {
300 PathBuf::from("/var/cache/zoi")
301 };
302 crate::sysroot::apply_sysroot(path)
303}
304
305pub fn get_db_root() -> Result<std::path::PathBuf> {
311 if let Ok(path) = std::env::var("ZOI_DB_DIR") {
312 return Ok(std::path::PathBuf::from(path));
313 }
314 Ok(get_user_data_dir()?.join("pkgs").join("db"))
315}
316
317pub fn get_store_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
330 match scope {
331 crate::types::Scope::User => {
332 Ok(get_user_data_dir()?.join("pkgs").join("store"))
333 }
334 crate::types::Scope::System => {
335 Ok(get_system_data_dir().join("pkgs").join("store"))
336 }
337 crate::types::Scope::Project => {
338 let current_dir = std::env::current_dir()?;
339 Ok(current_dir.join(".zoi").join("pkgs").join("store"))
340 }
341 }
342}
343
344pub fn get_db_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
351 match scope {
352 crate::types::Scope::User => {
353 Ok(get_user_data_dir()?.join("pkgs").join("db"))
354 }
355 crate::types::Scope::System => {
356 Ok(get_system_data_dir().join("pkgs").join("db"))
357 }
358 crate::types::Scope::Project => {
359 let current_dir = std::env::current_dir()?;
360 Ok(current_dir.join(".zoi").join("pkgs").join("db"))
361 }
362 }
363}
364
365pub fn get_git_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
372 match scope {
373 crate::types::Scope::User => {
374 Ok(get_user_data_dir()?.join("pkgs").join("git"))
375 }
376 crate::types::Scope::System => {
377 Ok(get_system_data_dir().join("pkgs").join("git"))
378 }
379 crate::types::Scope::Project => {
380 let current_dir = std::env::current_dir()?;
381 Ok(current_dir.join(".zoi").join("pkgs").join("git"))
382 }
383 }
384}
385
386pub fn generate_package_id(
394 registry_handle: &str,
395 repo_path: &str,
396 package_name: &str
397) -> String {
398 let format_string =
399 format!("#{registry_handle}@{repo_path}/{package_name}");
400 let mut hasher = Sha512::new();
401 hasher.update(format_string.as_bytes());
402 let result = hasher.finalize();
403 let hex_string = hex::encode(result);
404 hex_string[..32].to_string()
405}
406
407pub fn generate_versioned_package_id(
409 registry_handle: &str,
410 repo_path: &str,
411 package_name: &str,
412 version: &str
413) -> String {
414 let format_string =
415 format!("#{registry_handle}@{repo_path}/{package_name}@{version}");
416 let mut hasher = Sha512::new();
417 hasher.update(format_string.as_bytes());
418 let result = hasher.finalize();
419 let hex_string = hex::encode(result);
420 hex_string[..32].to_string()
421}
422
423pub fn get_package_dir_name(package_id: &str, package_name: &str) -> String {
426 format!("{package_id}-{package_name}")
427}
428
429pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
435 let src = if src.as_os_str().is_empty() {
436 Path::new(".")
437 } else {
438 src
439 };
440 fs::create_dir_all(dst)?;
441 for entry in fs::read_dir(src)? {
442 let entry = entry?;
443 let ty = entry.file_type()?;
444 if ty.is_dir() {
445 copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
446 } else if ty.is_symlink() {
447 let target = fs::read_link(entry.path())?;
448 let destination = dst.join(entry.file_name());
449 #[cfg(unix)]
450 std::os::unix::fs::symlink(target, destination)?;
451 #[cfg(windows)]
452 {
453 if target.is_dir() {
454 std::os::windows::fs::symlink_dir(target, destination)?;
455 } else {
456 std::os::windows::fs::symlink_file(target, destination)?;
457 }
458 }
459 } else {
460 fs::copy(entry.path(), dst.join(entry.file_name()))?;
461 }
462 }
463 Ok(())
464}
465
466pub fn retry_backoff_sleep(attempt: u32) {
471 let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
472 let jitter = u64::from(
473 std::time::SystemTime::now()
474 .duration_since(std::time::UNIX_EPOCH)
475 .unwrap_or(Duration::from_secs(0))
476 .subsec_millis()
477 % 200
478 );
479 let sleep_ms = (base_ms + jitter).min(8000);
480 std::thread::sleep(Duration::from_millis(sleep_ms));
481}
482
483pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
486 let path = crate::sysroot::apply_sysroot("/etc/os-release");
487 if let Ok(contents) = fs::read_to_string(path) {
488 let info: HashMap<String, String> = contents
489 .lines()
490 .filter_map(|line| {
491 let mut parts = line.splitn(2, '=');
492 let key = parts.next()?;
493 let value = parts.next()?.trim_matches('"').to_string();
494 if key.is_empty() {
495 None
496 } else {
497 Some((key.to_string(), value))
498 }
499 })
500 .collect();
501 if info.is_empty() { None } else { Some(info) }
502 } else {
503 None
504 }
505}
506
507pub fn get_linux_distro_family() -> Option<String> {
523 if is_zoios() {
524 return Some("zoios".to_string());
525 }
526 if let Some(info) = get_linux_distribution_info() {
527 if let Some(id_like) = info.get("ID_LIKE") {
528 let families: Vec<&str> = id_like.split_whitespace().collect();
529 if families.contains(&"debian") {
530 return Some("debian".to_string());
531 }
532 if families.contains(&"arch") {
533 return Some("arch".to_string());
534 }
535 if families.contains(&"fedora") {
536 return Some("fedora".to_string());
537 }
538 if families.contains(&"rhel") {
539 return Some("fedora".to_string());
540 }
541 if families.contains(&"suse") {
542 return Some("suse".to_string());
543 }
544 if families.contains(&"gentoo") {
545 return Some("gentoo".to_string());
546 }
547 }
548 if let Some(id) = info.get("ID") {
549 return match id.as_str() {
550 "debian" | "ubuntu" | "linuxmint" | "pop" | "kali"
551 | "kubuntu" | "lubuntu" | "xubuntu" | "zorin"
552 | "elementary" => Some("debian".to_string()),
553 "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
554 Some("arch".to_string())
555 }
556 "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => {
557 Some("fedora".to_string())
558 }
559 "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => {
560 Some("suse".to_string())
561 }
562 "gentoo" => Some("gentoo".to_string()),
563 "alpine" => Some("alpine".to_string()),
564 "void" => Some("void".to_string()),
565 "solus" => Some("solus".to_string()),
566 "guix" => Some("guix".to_string()),
567 _ => None
568 };
569 }
570 }
571 None
572}
573
574pub fn get_linux_distribution() -> Option<String> {
576 get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
577}
578
579pub fn is_zoios() -> bool {
582 if let Some(info) = get_linux_distribution_info() {
583 if let Some(id) = info.get("ID")
584 && (id == "zoios" || id == "parlex")
585 {
586 return true;
587 }
588 if let Some(id_like) = info.get("ID_LIKE")
589 && id_like.split_whitespace().any(|s| s == "zoios")
590 {
591 return true;
592 }
593 }
594 false
595}
596
597pub fn resolve_fallback_scope() -> crate::types::Scope {
599 if std::path::Path::new("zoi.lua").exists()
600 || std::path::Path::new("zoi.yaml").exists()
601 {
602 crate::types::Scope::Project
603 } else if is_zoios() {
604 crate::types::Scope::System
605 } else {
606 crate::types::Scope::User
607 }
608}
609
610pub fn get_desktop_environment() -> Option<String> {
612 if cfg!(target_os = "windows") {
613 return Some("windows".to_string());
614 }
615 if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
616 && !de.is_empty()
617 {
618 return Some(de.to_lowercase());
619 }
620 if let Ok(ds) = std::env::var("DESKTOP_SESSION")
621 && !ds.is_empty()
622 {
623 return Some(ds.to_lowercase());
624 }
625 None
626}
627
628pub fn get_display_server() -> Option<String> {
630 if cfg!(target_os = "windows") {
631 return Some("windows".to_string());
632 }
633 if cfg!(target_os = "macos") {
634 return Some("quartz".to_string());
635 }
636 if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
637 && !st.is_empty()
638 {
639 return Some(st.to_lowercase());
640 }
641 None
642}
643
644pub fn get_kernel_version() -> Option<String> {
646 if cfg!(unix) {
647 let output = Command::new("uname").arg("-r").output().ok()?;
648 if output.status.success() {
649 return Some(
650 String::from_utf8_lossy(&output.stdout).trim().to_string()
651 );
652 }
653 } else if cfg!(target_os = "windows") {
654 let output = Command::new("pwsh")
655 .arg("-Command")
656 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
657 .output()
658 .ok()?;
659 if output.status.success() {
660 return Some(
661 String::from_utf8_lossy(&output.stdout).trim().to_string()
662 );
663 }
664 }
665 None
666}
667
668pub fn get_init_system() -> Option<String> {
670 if let Ok(val) = std::env::var("ZOI_INIT") {
671 return Some(val.to_lowercase());
672 }
673
674 let run_systemd =
675 crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
676 if run_systemd.exists() {
677 return Some("systemd".to_string());
678 }
679
680 let run_openrc =
681 crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
682 if run_openrc.exists() {
683 return Some("openrc".to_string());
684 }
685
686 let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
687 if let Ok(target) = std::fs::read_link(&sbin_init) {
688 let target_str = target.to_string_lossy();
689 if target_str.contains("systemd") {
690 return Some("systemd".to_string());
691 } else if target_str.contains("openrc") {
692 return Some("openrc".to_string());
693 } else if target_str.contains("busybox") {
694 return Some("busybox".to_string());
695 }
696 }
697
698 None
699}
700
701pub fn get_privilege_escalator() -> Option<String> {
704 if command_exists("sudo") {
705 Some("sudo".to_string())
706 } else if command_exists("doas") {
707 Some("doas".to_string())
708 } else {
709 None
710 }
711}
712
713pub fn get_distro_version() -> Option<String> {
715 if let Some(info) = get_linux_distribution_info()
716 && let Some(vid) = info.get("VERSION_ID")
717 {
718 return Some(vid.clone());
719 }
720 if cfg!(target_os = "macos") {
721 let output = Command::new("sw_vers")
722 .arg("-productVersion")
723 .output()
724 .ok()?;
725 if output.status.success() {
726 return Some(
727 String::from_utf8_lossy(&output.stdout).trim().to_string()
728 );
729 }
730 } else if cfg!(target_os = "windows") {
731 let output = Command::new("pwsh")
732 .arg("-Command")
733 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
734 .output()
735 .ok()?;
736 if output.status.success() {
737 return Some(
738 String::from_utf8_lossy(&output.stdout).trim().to_string()
739 );
740 }
741 }
742 None
743}
744
745pub fn get_cpu_info() -> Option<String> {
747 if cfg!(target_os = "linux") {
748 if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
749 for line in cpuinfo.lines() {
750 if line.starts_with("model name")
751 && let Some((_, model)) = line.split_once(':')
752 {
753 return Some(model.trim().to_string());
754 }
755 }
756 }
757 } else if cfg!(target_os = "macos") {
758 let output = Command::new("sysctl")
759 .arg("-n")
760 .arg("machdep.cpu.brand_string")
761 .output()
762 .ok()?;
763 if output.status.success() {
764 return Some(
765 String::from_utf8_lossy(&output.stdout).trim().to_string()
766 );
767 }
768 } else if cfg!(target_os = "windows") {
769 let output = Command::new("pwsh")
770 .arg("-Command")
771 .arg("(Get-CimInstance Win32_Processor).Name")
772 .output()
773 .ok()?;
774 if output.status.success() {
775 return Some(
776 String::from_utf8_lossy(&output.stdout).trim().to_string()
777 );
778 }
779 }
780 None
781}
782
783pub fn get_gpu_info() -> Option<String> {
785 if cfg!(target_os = "linux") {
786 if let Ok(output) = Command::new("lspci").output()
787 && output.status.success()
788 {
789 let stdout = String::from_utf8_lossy(&output.stdout);
790 for line in stdout.lines() {
791 if (line.contains("VGA compatible controller")
792 || line.contains("3D controller"))
793 && let Some((_, model)) = line.split_once(": ")
794 {
795 return Some(model.trim().to_string());
796 }
797 }
798 }
799 } else if cfg!(target_os = "macos") {
800 let output = Command::new("system_profiler")
801 .arg("SPDisplaysDataType")
802 .output()
803 .ok()?;
804 if output.status.success() {
805 let stdout = String::from_utf8_lossy(&output.stdout);
806 for line in stdout.lines() {
807 if line.trim().starts_with("Chipset Model:")
808 && let Some((_, model)) = line.split_once(':')
809 {
810 return Some(model.trim().to_string());
811 }
812 }
813 }
814 } else if cfg!(target_os = "windows") {
815 let output = Command::new("pwsh")
816 .arg("-Command")
817 .arg("(Get-CimInstance Win32_VideoController).Name")
818 .output()
819 .ok()?;
820 if output.status.success() {
821 return Some(
822 String::from_utf8_lossy(&output.stdout).trim().to_string()
823 );
824 }
825 }
826 None
827}
828
829pub fn get_native_package_manager() -> Option<String> {
833 let os = std::env::consts::OS;
834 match os {
835 "linux" => get_linux_distro_family()
836 .map(|family| {
837 match family.as_str() {
838 "debian" => "apt",
839 "arch" => "pacman",
840 "fedora" => "dnf",
841 "suse" => "zypper",
842 "gentoo" => "portage",
843 "alpine" => "apk",
844 "void" => "xbps-install",
845 "solus" => "eopkg",
846 "guix" => "guix",
847 "zoios" => "zoi",
848 _ => "unknown"
849 }
850 .to_string()
851 })
852 .filter(|s| s != "unknown"),
853 "macos" => {
854 if command_exists("brew") {
855 Some("brew".to_string())
856 } else if command_exists("port") {
857 Some("macports".to_string())
858 } else {
859 None
860 }
861 }
862 "windows" => {
863 if command_exists("scoop") {
864 Some("scoop".to_string())
865 } else if command_exists("choco") {
866 Some("choco".to_string())
867 } else if command_exists("winget") {
868 Some("winget".to_string())
869 } else {
870 None
871 }
872 }
873 _ => None
874 }
875}
876
877pub fn get_all_available_package_managers() -> Vec<String> {
882 let mut managers = Vec::new();
883 let all_possible_managers = [
884 "apt",
885 "pacman",
886 "yay",
887 "paru",
888 "pikaur",
889 "trizen",
890 "dnf",
891 "yum",
892 "zypper",
893 "portage",
894 "apk",
895 "snap",
896 "flatpak",
897 "nix",
898 "brew",
899 "port",
900 "scoop",
901 "choco",
902 "winget",
903 "pkg",
904 "pkg_add",
905 "xbps-install",
906 "eopkg",
907 "guix",
908 "mas"
909 ];
910
911 for manager in &all_possible_managers {
912 if command_exists(manager) {
913 managers.push(manager.to_string());
914 }
915 }
916 managers.sort();
917 managers.dedup();
918 managers
919}
920
921pub fn format_bytes(bytes: u64) -> String {
923 const KIB: u64 = 1024;
924 const MIB: u64 = 1024 * KIB;
925 const GIB: u64 = 1024 * MIB;
926 if bytes >= GIB {
927 format!("{:.2} GiB", bytes as f64 / GIB as f64)
928 } else if bytes >= MIB {
929 format!("{:.2} MiB", bytes as f64 / MIB as f64)
930 } else if bytes >= KIB {
931 format!("{:.2} KiB", bytes as f64 / KIB as f64)
932 } else {
933 format!("{bytes} B")
934 }
935}
936
937pub fn format_size_diff(diff: i64) -> String {
940 if diff == 0 {
941 return "0 B".to_string();
942 }
943 let sign = if diff > 0 { "+" } else { "-" };
944 let bytes = diff.unsigned_abs();
945 format!("{}{}", sign, format_bytes(bytes))
946}
947
948pub fn is_safe_path(base: &Path, path: &Path) -> bool {
954 let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
955 let joined = if path.is_absolute() {
956 path.to_path_buf()
957 } else {
958 base.join(path)
959 };
960 let mut normalized = PathBuf::new();
961 for component in joined.components() {
962 match component {
963 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
964 normalized.push(component);
965 }
966 std::path::Component::CurDir => {}
967 std::path::Component::ParentDir => {
968 if !normalized.pop() {
969 return false;
970 }
971 }
972 std::path::Component::Normal(p) => normalized.push(p)
973 }
974 }
975 normalized.starts_with(&base)
976}
977
978pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
984 if link.exists() || link.is_symlink() {
985 fs::remove_file(link)?;
986 }
987 #[cfg(unix)]
988 {
989 std::os::unix::fs::symlink(target, link)
990 }
991 #[cfg(windows)]
992 {
993 if std::os::windows::fs::symlink_file(target, link).is_err() {
994 if fs::hard_link(target, link).is_err() {
995 fs::copy(target, link)?;
996 }
997 }
998 Ok(())
999 }
1000}
1001
1002pub fn is_admin() -> bool {
1004 #[cfg(unix)]
1005 {
1006 nix::unistd::getuid().is_root()
1007 }
1008 #[cfg(windows)]
1009 {
1010 false
1011 }
1012}
1013
1014pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
1021 let status = if cfg!(target_os = "windows") {
1022 Command::new("pwsh")
1023 .arg("-Command")
1024 .arg(command_str)
1025 .status()?
1026 } else {
1027 Command::new("bash").arg("-c").arg(command_str).status()?
1028 };
1029 if !status.success() {
1030 return Err(anyhow!("Command failed: {command_str}"));
1031 }
1032 Ok(())
1033}
1034
1035pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
1043 let status = if cfg!(target_os = "windows") {
1044 Command::new("pwsh")
1045 .arg("-Command")
1046 .arg(command_str)
1047 .stdout(std::process::Stdio::null())
1048 .stderr(std::process::Stdio::null())
1049 .status()?
1050 } else {
1051 Command::new("bash")
1052 .arg("-c")
1053 .arg(command_str)
1054 .stdout(std::process::Stdio::null())
1055 .stderr(std::process::Stdio::null())
1056 .status()?
1057 };
1058 if !status.success() {
1059 return Err(anyhow!("Command failed: {command_str}"));
1060 }
1061 Ok(())
1062}
1063
1064pub fn is_mini_mode() -> bool {
1066 std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
1067}
1068
1069pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
1071 if yes {
1072 return true;
1073 }
1074 if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
1075 return false;
1076 }
1077 print!("{prompt} [y/N]: ");
1078 let _ = stdout().flush();
1079 let mut input = String::new();
1080 if stdin().read_line(&mut input).is_err() {
1081 return false;
1082 }
1083 input.trim().eq_ignore_ascii_case("y")
1084}
1085
1086pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
1092 if !path.exists() {
1093 return Ok(());
1094 }
1095 for entry in walkdir::WalkDir::new(path) {
1096 let entry = entry?;
1097 let mut perms = fs::metadata(entry.path())?.permissions();
1098 if !perms.readonly() {
1099 perms.set_readonly(true);
1100 fs::set_permissions(entry.path(), perms)?;
1101 }
1102 }
1103 Ok(())
1104}
1105
1106pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1112 if !path.exists() {
1113 return Ok(());
1114 }
1115 for entry in walkdir::WalkDir::new(path) {
1116 let entry = entry?;
1117 let mut perms = fs::metadata(entry.path())?.permissions();
1118 if perms.readonly() {
1119 #[cfg(unix)]
1120 {
1121 use std::os::unix::fs::PermissionsExt;
1122 let mode = perms.mode();
1123 perms.set_mode(mode | 0o200);
1124 }
1125 #[cfg(not(unix))]
1126 {
1127 perms.set_readonly(false);
1128 }
1129 fs::set_permissions(entry.path(), perms)?;
1130 }
1131 }
1132 Ok(())
1133}
1134
1135#[cfg(unix)]
1142pub fn set_path_owner(
1143 path: &Path,
1144 owner: &str,
1145 group: &str
1146) -> anyhow::Result<()> {
1147 use nix::unistd::{Gid, Group, Uid, User, chown};
1148 let uid = if let Ok(u) = owner.parse::<u32>() {
1149 Some(Uid::from_raw(u))
1150 } else if !owner.is_empty() {
1151 Some(
1152 User::from_name(owner)
1153 .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1154 .ok_or_else(|| anyhow!("User not found: {owner}"))?
1155 .uid
1156 )
1157 } else {
1158 None
1159 };
1160 let gid = if let Ok(g) = group.parse::<u32>() {
1161 Some(Gid::from_raw(g))
1162 } else if !group.is_empty() {
1163 Some(
1164 Group::from_name(group)
1165 .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1166 .ok_or_else(|| anyhow!("Group not found: {group}"))?
1167 .gid
1168 )
1169 } else {
1170 None
1171 };
1172 chown(path, uid, gid)
1173 .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1174 Ok(())
1175}
1176
1177pub fn is_platform_compatible(
1179 current_platform: &str,
1180 allowed_platforms: &[String]
1181) -> bool {
1182 let os_part = current_platform
1183 .split('-')
1184 .next()
1185 .unwrap_or(current_platform);
1186 let os = match os_part {
1187 "darwin" => "macos",
1188 other => other
1189 };
1190 allowed_platforms.iter().any(|p| {
1191 if let Some(rest) = p.strip_prefix("ci:") {
1192 let target = rest.split(':').next().unwrap_or_default();
1193 target == current_platform || target == os
1194 } else {
1195 let p_norm = if p == "darwin" { "macos" } else { p };
1196 p_norm == "all" || p_norm == os || p_norm == current_platform
1197 }
1198 })
1199}
1200
1201pub fn check_license(license: &str) {
1203 if license.is_empty() || license.eq_ignore_ascii_case("None") {
1204 return;
1205 }
1206 if license.eq_ignore_ascii_case("Proprietary")
1207 || license.eq_ignore_ascii_case("Unknown")
1208 {
1209 return;
1210 }
1211 match spdx::Expression::parse(license) {
1212 Ok(expr) => {
1213 if !expr.evaluate(|req| match req.license {
1214 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1215 spdx::LicenseItem::Other { .. } => false
1216 }) {
1217 println!(
1218 "{} License '{}' is not an OSI approved license.",
1219 "Warning:".yellow(),
1220 license.yellow().bold()
1221 );
1222 }
1223 }
1224 Err(_) => {
1225 println!(
1226 "{} Could not parse license expression '{}'. It may not be a \
1227 valid SPDX identifier.",
1228 "Warning:".yellow(),
1229 license.yellow().bold()
1230 );
1231 }
1232 }
1233}
1234
1235pub fn confirm_untrusted_source(
1242 source_type: &crate::types::SourceType,
1243 yes: bool
1244) -> anyhow::Result<()> {
1245 if is_mini_mode() {
1246 return Ok(());
1247 }
1248 if source_type == &crate::types::SourceType::OfficialRepo {
1249 return Ok(());
1250 }
1251 let warning_message = match source_type {
1252 crate::types::SourceType::UntrustedRepo(repo) => {
1253 format!(
1254 "The package from repository '@{repo}' is not an official Zoi \
1255 repository."
1256 )
1257 }
1258 crate::types::SourceType::LocalFile => {
1259 "You are installing from a local file.".to_string()
1260 }
1261 crate::types::SourceType::Url => {
1262 "You are installing from a remote URL. This script will be \
1263 executed with your user's permissions, which could lead to remote \
1264 code execution if the source is malicious."
1265 .to_string()
1266 }
1267 crate::types::SourceType::GitRepo(repo) => format!(
1268 "You are installing from an external git repository '{repo}'. \
1269 This script will be executed with your user's permissions."
1270 ),
1271 crate::types::SourceType::OfficialRepo => return Ok(())
1272 };
1273 println!(
1274 "\n{}: {}",
1275 "SECURITY WARNING".yellow().bold(),
1276 warning_message
1277 );
1278 if ask_for_confirmation(
1279 "This source is not trusted. Are you sure you want to continue?",
1280 yes
1281 ) {
1282 Ok(())
1283 } else {
1284 Err(anyhow!("Operation aborted by user."))
1285 }
1286}
1287
1288pub fn expand_placeholders(
1300 path: &str,
1301 version_dir: &Path,
1302 scope: crate::types::Scope
1303) -> Result<String> {
1304 let mut expanded = path.to_string();
1305 expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1306 expanded = expanded.replace(
1307 "${usrroot}",
1308 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1309 );
1310 if let Some(home_dir) = get_user_home() {
1311 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1312 }
1313
1314 let applications_dir = match scope {
1315 crate::types::Scope::System => PathBuf::from("/Applications"),
1316 crate::types::Scope::User => get_user_home().map_or_else(
1317 || PathBuf::from("/Applications"),
1318 |h| h.join("Applications")
1319 ),
1320 crate::types::Scope::Project => std::env::current_dir()
1321 .unwrap_or_default()
1322 .join("Applications")
1323 };
1324 expanded = expanded
1325 .replace("${applications}", &applications_dir.to_string_lossy());
1326
1327 Ok(expanded)
1328}
1329
1330pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1332 let path = path.as_ref();
1333 if !path.starts_with("~") {
1334 return path.to_path_buf();
1335 }
1336 if let Some(home_dir) = get_user_home() {
1337 if path == Path::new("~") {
1338 return home_dir;
1339 }
1340 if let Ok(stripped) = path.strip_prefix("~/") {
1341 return home_dir.join(stripped);
1342 }
1343 }
1344 path.to_path_buf()
1345}
1346
1347pub fn get_current_shell() -> Option<Shell> {
1349 if cfg!(windows) {
1350 return Some(Shell::PowerShell);
1351 }
1352 if let Ok(shell_path) = std::env::var("SHELL") {
1353 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1354 match shell_name {
1355 "bash" => Some(Shell::Bash),
1356 "zsh" => Some(Shell::Zsh),
1357 "fish" => Some(Shell::Fish),
1358 "elvish" => Some(Shell::Elvish),
1359 "pwsh" => Some(Shell::PowerShell),
1360 _ => None
1361 }
1362 } else {
1363 None
1364 }
1365}