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 crate::types::Scope::Project
634 } else if is_zoios() {
635 crate::types::Scope::System
636 } else {
637 crate::types::Scope::User
638 }
639}
640
641pub fn get_desktop_environment() -> Option<String> {
643 if cfg!(target_os = "windows") {
644 return Some("windows".to_string());
645 }
646 if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
647 && !de.is_empty()
648 {
649 return Some(de.to_lowercase());
650 }
651 if let Ok(ds) = std::env::var("DESKTOP_SESSION")
652 && !ds.is_empty()
653 {
654 return Some(ds.to_lowercase());
655 }
656 None
657}
658
659pub fn get_display_server() -> Option<String> {
661 if cfg!(target_os = "windows") {
662 return Some("windows".to_string());
663 }
664 if cfg!(target_os = "macos") {
665 return Some("quartz".to_string());
666 }
667 if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
668 && !st.is_empty()
669 {
670 return Some(st.to_lowercase());
671 }
672 None
673}
674
675pub fn get_kernel_version() -> Option<String> {
677 if cfg!(unix) {
678 let output = Command::new("uname").arg("-r").output().ok()?;
679 if output.status.success() {
680 return Some(
681 String::from_utf8_lossy(&output.stdout).trim().to_string()
682 );
683 }
684 } else if cfg!(target_os = "windows") {
685 let output = Command::new("pwsh")
686 .arg("-Command")
687 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
688 .output()
689 .ok()?;
690 if output.status.success() {
691 return Some(
692 String::from_utf8_lossy(&output.stdout).trim().to_string()
693 );
694 }
695 }
696 None
697}
698
699pub fn get_init_system() -> Option<String> {
701 if let Ok(val) = std::env::var("ZOI_INIT") {
702 return Some(val.to_lowercase());
703 }
704
705 let run_systemd =
706 crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
707 if run_systemd.exists() {
708 return Some("systemd".to_string());
709 }
710
711 let run_openrc =
712 crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
713 if run_openrc.exists() {
714 return Some("openrc".to_string());
715 }
716
717 let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
718 if let Ok(target) = std::fs::read_link(&sbin_init) {
719 let target_str = target.to_string_lossy();
720 if target_str.contains("systemd") {
721 return Some("systemd".to_string());
722 } else if target_str.contains("openrc") {
723 return Some("openrc".to_string());
724 } else if target_str.contains("busybox") {
725 return Some("busybox".to_string());
726 }
727 }
728
729 None
730}
731
732pub fn get_privilege_escalator() -> Option<String> {
735 if command_exists("sudo") {
736 Some("sudo".to_string())
737 } else if command_exists("doas") {
738 Some("doas".to_string())
739 } else {
740 None
741 }
742}
743
744pub fn get_distro_version() -> Option<String> {
746 if let Some(info) = get_linux_distribution_info()
747 && let Some(vid) = info.get("VERSION_ID")
748 {
749 return Some(vid.clone());
750 }
751 if cfg!(target_os = "macos") {
752 let output = Command::new("sw_vers")
753 .arg("-productVersion")
754 .output()
755 .ok()?;
756 if output.status.success() {
757 return Some(
758 String::from_utf8_lossy(&output.stdout).trim().to_string()
759 );
760 }
761 } else if cfg!(target_os = "windows") {
762 let output = Command::new("pwsh")
763 .arg("-Command")
764 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
765 .output()
766 .ok()?;
767 if output.status.success() {
768 return Some(
769 String::from_utf8_lossy(&output.stdout).trim().to_string()
770 );
771 }
772 }
773 None
774}
775
776pub fn get_cpu_info() -> Option<String> {
778 if cfg!(target_os = "linux") {
779 if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
780 for line in cpuinfo.lines() {
781 if line.starts_with("model name")
782 && let Some((_, model)) = line.split_once(':')
783 {
784 return Some(model.trim().to_string());
785 }
786 }
787 }
788 } else if cfg!(target_os = "macos") {
789 let output = Command::new("sysctl")
790 .arg("-n")
791 .arg("machdep.cpu.brand_string")
792 .output()
793 .ok()?;
794 if output.status.success() {
795 return Some(
796 String::from_utf8_lossy(&output.stdout).trim().to_string()
797 );
798 }
799 } else if cfg!(target_os = "windows") {
800 let output = Command::new("pwsh")
801 .arg("-Command")
802 .arg("(Get-CimInstance Win32_Processor).Name")
803 .output()
804 .ok()?;
805 if output.status.success() {
806 return Some(
807 String::from_utf8_lossy(&output.stdout).trim().to_string()
808 );
809 }
810 }
811 None
812}
813
814pub fn get_gpu_info() -> Option<String> {
816 if cfg!(target_os = "linux") {
817 if let Ok(output) = Command::new("lspci").output()
818 && output.status.success()
819 {
820 let stdout = String::from_utf8_lossy(&output.stdout);
821 for line in stdout.lines() {
822 if (line.contains("VGA compatible controller")
823 || line.contains("3D controller"))
824 && let Some((_, model)) = line.split_once(": ")
825 {
826 return Some(model.trim().to_string());
827 }
828 }
829 }
830 } else if cfg!(target_os = "macos") {
831 let output = Command::new("system_profiler")
832 .arg("SPDisplaysDataType")
833 .output()
834 .ok()?;
835 if output.status.success() {
836 let stdout = String::from_utf8_lossy(&output.stdout);
837 for line in stdout.lines() {
838 if line.trim().starts_with("Chipset Model:")
839 && let Some((_, model)) = line.split_once(':')
840 {
841 return Some(model.trim().to_string());
842 }
843 }
844 }
845 } else if cfg!(target_os = "windows") {
846 let output = Command::new("pwsh")
847 .arg("-Command")
848 .arg("(Get-CimInstance Win32_VideoController).Name")
849 .output()
850 .ok()?;
851 if output.status.success() {
852 return Some(
853 String::from_utf8_lossy(&output.stdout).trim().to_string()
854 );
855 }
856 }
857 None
858}
859
860pub fn get_native_package_manager() -> Option<String> {
864 let os = std::env::consts::OS;
865 match os {
866 "linux" => get_linux_distro_family()
867 .map(|family| {
868 match family.as_str() {
869 "debian" => "apt",
870 "arch" => "pacman",
871 "fedora" => "dnf",
872 "suse" => "zypper",
873 "gentoo" => "portage",
874 "alpine" => "apk",
875 "void" => "xbps-install",
876 "solus" => "eopkg",
877 "guix" => "guix",
878 "zoios" => "zoi",
879 _ => "unknown"
880 }
881 .to_string()
882 })
883 .filter(|s| s != "unknown"),
884 "macos" => {
885 if command_exists("brew") {
886 Some("brew".to_string())
887 } else if command_exists("port") {
888 Some("macports".to_string())
889 } else {
890 None
891 }
892 }
893 "windows" => {
894 if command_exists("scoop") {
895 Some("scoop".to_string())
896 } else if command_exists("choco") {
897 Some("choco".to_string())
898 } else if command_exists("winget") {
899 Some("winget".to_string())
900 } else {
901 None
902 }
903 }
904 _ => None
905 }
906}
907
908pub fn get_all_available_package_managers() -> Vec<String> {
913 let mut managers = Vec::new();
914 let all_possible_managers = [
915 "apt",
916 "pacman",
917 "yay",
918 "paru",
919 "pikaur",
920 "trizen",
921 "dnf",
922 "yum",
923 "zypper",
924 "portage",
925 "apk",
926 "snap",
927 "flatpak",
928 "nix",
929 "brew",
930 "port",
931 "scoop",
932 "choco",
933 "winget",
934 "pkg",
935 "pkg_add",
936 "xbps-install",
937 "eopkg",
938 "guix",
939 "mas"
940 ];
941
942 for manager in &all_possible_managers {
943 if command_exists(manager) {
944 managers.push(manager.to_string());
945 }
946 }
947 managers.sort();
948 managers.dedup();
949 managers
950}
951
952pub fn format_bytes(bytes: u64) -> String {
954 const KIB: u64 = 1024;
955 const MIB: u64 = 1024 * KIB;
956 const GIB: u64 = 1024 * MIB;
957 if bytes >= GIB {
958 format!("{:.2} GiB", bytes as f64 / GIB as f64)
959 } else if bytes >= MIB {
960 format!("{:.2} MiB", bytes as f64 / MIB as f64)
961 } else if bytes >= KIB {
962 format!("{:.2} KiB", bytes as f64 / KIB as f64)
963 } else {
964 format!("{bytes} B")
965 }
966}
967
968pub fn format_size_diff(diff: i64) -> String {
971 if diff == 0 {
972 return "0 B".to_string();
973 }
974 let sign = if diff > 0 { "+" } else { "-" };
975 let bytes = diff.unsigned_abs();
976 format!("{}{}", sign, format_bytes(bytes))
977}
978
979pub fn is_safe_path(base: &Path, path: &Path) -> bool {
985 let base_exists = base.exists();
986 let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
987 if path
988 .components()
989 .any(|component| matches!(component, std::path::Component::ParentDir))
990 {
991 return false;
992 }
993
994 let joined = if path.is_absolute() {
995 path.to_path_buf()
996 } else {
997 base.join(path)
998 };
999 let mut normalized = PathBuf::new();
1000 for component in joined.components() {
1001 match component {
1002 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
1003 normalized.push(component);
1004 }
1005 std::path::Component::CurDir => {}
1006 std::path::Component::Normal(p) => normalized.push(p),
1007 std::path::Component::ParentDir => return false
1008 }
1009 }
1010 if !normalized.starts_with(&base) {
1011 return false;
1012 }
1013
1014 if !base_exists {
1015 return true;
1016 }
1017
1018 let mut existing = joined.as_path();
1019 loop {
1020 if existing.exists() || existing.is_symlink() {
1021 return existing
1022 .canonicalize()
1023 .is_ok_and(|resolved| resolved.starts_with(&base));
1024 }
1025 let Some(parent) = existing.parent() else {
1026 return false;
1027 };
1028 existing = parent;
1029 }
1030}
1031
1032pub fn safe_join(base: &Path, relative: &Path) -> Result<PathBuf> {
1039 if relative.is_absolute()
1040 || relative.components().any(|component| {
1041 matches!(
1042 component,
1043 std::path::Component::ParentDir
1044 | std::path::Component::Prefix(_)
1045 | std::path::Component::RootDir
1046 )
1047 })
1048 || !is_safe_path(base, relative)
1049 {
1050 return Err(anyhow!(
1051 "Path escapes its base directory: {}",
1052 relative.display()
1053 ));
1054 }
1055 Ok(base.join(relative))
1056}
1057
1058pub fn validate_path_component(value: &str) -> Result<()> {
1065 if value.is_empty()
1066 || value == "."
1067 || value == ".."
1068 || value.contains('/')
1069 || value.contains('\\')
1070 || value.as_bytes().contains(&0)
1071 {
1072 return Err(anyhow!("Invalid path component: {value}"));
1073 }
1074 Ok(())
1075}
1076
1077pub fn read_file_within(base: &Path, path: &Path) -> Result<String> {
1084 let base = base.canonicalize()?;
1085 let path = path.canonicalize()?;
1086 if !path.starts_with(&base) {
1087 return Err(anyhow!(
1088 "Path escapes its base directory: {}",
1089 path.display()
1090 ));
1091 }
1092 Ok(fs::read_to_string(path)?)
1093}
1094
1095pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
1101 if link.exists() || link.is_symlink() {
1102 fs::remove_file(link)?;
1103 }
1104 #[cfg(unix)]
1105 {
1106 std::os::unix::fs::symlink(target, link)
1107 }
1108 #[cfg(windows)]
1109 {
1110 if std::os::windows::fs::symlink_file(target, link).is_err() {
1111 if fs::hard_link(target, link).is_err() {
1112 fs::copy(target, link)?;
1113 }
1114 }
1115 Ok(())
1116 }
1117}
1118
1119pub fn is_admin() -> bool {
1121 #[cfg(unix)]
1122 {
1123 nix::unistd::getuid().is_root()
1124 }
1125 #[cfg(windows)]
1126 {
1127 false
1128 }
1129}
1130
1131pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
1138 let status = if cfg!(target_os = "windows") {
1139 Command::new("pwsh")
1140 .arg("-Command")
1141 .arg(command_str)
1142 .status()?
1143 } else {
1144 Command::new("bash").arg("-c").arg(command_str).status()?
1145 };
1146 if !status.success() {
1147 return Err(anyhow!("Command failed: {command_str}"));
1148 }
1149 Ok(())
1150}
1151
1152pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
1160 let status = if cfg!(target_os = "windows") {
1161 Command::new("pwsh")
1162 .arg("-Command")
1163 .arg(command_str)
1164 .stdout(std::process::Stdio::null())
1165 .stderr(std::process::Stdio::null())
1166 .status()?
1167 } else {
1168 Command::new("bash")
1169 .arg("-c")
1170 .arg(command_str)
1171 .stdout(std::process::Stdio::null())
1172 .stderr(std::process::Stdio::null())
1173 .status()?
1174 };
1175 if !status.success() {
1176 return Err(anyhow!("Command failed: {command_str}"));
1177 }
1178 Ok(())
1179}
1180
1181pub fn is_mini_mode() -> bool {
1183 std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
1184}
1185
1186pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
1188 if yes {
1189 return true;
1190 }
1191 if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
1192 return false;
1193 }
1194 print!("{prompt} [y/N]: ");
1195 let _ = stdout().flush();
1196 let mut input = String::new();
1197 if stdin().read_line(&mut input).is_err() {
1198 return false;
1199 }
1200 input.trim().eq_ignore_ascii_case("y")
1201}
1202
1203pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
1209 if !path.exists() {
1210 return Ok(());
1211 }
1212 for entry in walkdir::WalkDir::new(path) {
1213 let entry = entry?;
1214 let mut perms = fs::metadata(entry.path())?.permissions();
1215 if !perms.readonly() {
1216 perms.set_readonly(true);
1217 fs::set_permissions(entry.path(), perms)?;
1218 }
1219 }
1220 Ok(())
1221}
1222
1223pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1229 if !path.exists() {
1230 return Ok(());
1231 }
1232 for entry in walkdir::WalkDir::new(path) {
1233 let entry = entry?;
1234 let mut perms = fs::metadata(entry.path())?.permissions();
1235 if perms.readonly() {
1236 #[cfg(unix)]
1237 {
1238 use std::os::unix::fs::PermissionsExt;
1239 let mode = perms.mode();
1240 perms.set_mode(mode | 0o200);
1241 }
1242 #[cfg(not(unix))]
1243 {
1244 perms.set_readonly(false);
1245 }
1246 fs::set_permissions(entry.path(), perms)?;
1247 }
1248 }
1249 Ok(())
1250}
1251
1252#[cfg(unix)]
1259pub fn set_path_owner(
1260 path: &Path,
1261 owner: &str,
1262 group: &str
1263) -> anyhow::Result<()> {
1264 use nix::unistd::{Gid, Group, Uid, User, chown};
1265 let uid = if let Ok(u) = owner.parse::<u32>() {
1266 Some(Uid::from_raw(u))
1267 } else if !owner.is_empty() {
1268 Some(
1269 User::from_name(owner)
1270 .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1271 .ok_or_else(|| anyhow!("User not found: {owner}"))?
1272 .uid
1273 )
1274 } else {
1275 None
1276 };
1277 let gid = if let Ok(g) = group.parse::<u32>() {
1278 Some(Gid::from_raw(g))
1279 } else if !group.is_empty() {
1280 Some(
1281 Group::from_name(group)
1282 .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1283 .ok_or_else(|| anyhow!("Group not found: {group}"))?
1284 .gid
1285 )
1286 } else {
1287 None
1288 };
1289 chown(path, uid, gid)
1290 .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1291 Ok(())
1292}
1293
1294pub fn is_platform_compatible(
1296 current_platform: &str,
1297 allowed_platforms: &[String]
1298) -> bool {
1299 let os_part = current_platform
1300 .split('-')
1301 .next()
1302 .unwrap_or(current_platform);
1303 let os = match os_part {
1304 "darwin" => "macos",
1305 other => other
1306 };
1307 allowed_platforms.iter().any(|p| {
1308 if let Some(rest) = p.strip_prefix("ci:") {
1309 let target = rest.split(':').next().unwrap_or_default();
1310 target == current_platform || target == os
1311 } else {
1312 let p_norm = if p == "darwin" { "macos" } else { p };
1313 p_norm == "all" || p_norm == os || p_norm == current_platform
1314 }
1315 })
1316}
1317
1318pub fn check_license(license: &str) {
1320 if license.is_empty() || license.eq_ignore_ascii_case("None") {
1321 return;
1322 }
1323 if license.eq_ignore_ascii_case("Proprietary")
1324 || license.eq_ignore_ascii_case("Unknown")
1325 {
1326 return;
1327 }
1328 if is_external_license_link(license) {
1329 println!(
1330 "{} The license is available at: {}",
1331 "Warning:".yellow(),
1332 license.cyan().bold()
1333 );
1334 return;
1335 }
1336 match spdx::Expression::parse(license) {
1337 Ok(expr) => {
1338 if !expr.evaluate(|req| match req.license {
1339 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1340 spdx::LicenseItem::Other { .. } => false
1341 }) {
1342 println!(
1343 "{} License '{}' is not an OSI approved license.",
1344 "Warning:".yellow(),
1345 license.yellow().bold()
1346 );
1347 }
1348 }
1349 Err(_) => {
1350 println!(
1351 "{} Could not parse license expression '{}'. It may not be a \
1352 valid SPDX identifier.",
1353 "Warning:".yellow(),
1354 license.yellow().bold()
1355 );
1356 }
1357 }
1358}
1359
1360fn is_external_license_link(license: &str) -> bool {
1363 let trimmed = license.trim();
1364 trimmed.starts_with("http://") || trimmed.starts_with("https://")
1365}
1366
1367pub fn confirm_untrusted_source(
1374 source_type: &crate::types::SourceType,
1375 yes: bool
1376) -> anyhow::Result<()> {
1377 if is_mini_mode() {
1378 return Ok(());
1379 }
1380 if source_type == &crate::types::SourceType::OfficialRepo {
1381 return Ok(());
1382 }
1383 let warning_message = match source_type {
1384 crate::types::SourceType::UntrustedRepo(repo) => {
1385 format!(
1386 "The package from repository '@{repo}' is not an official Zoi \
1387 repository."
1388 )
1389 }
1390 crate::types::SourceType::LocalFile => {
1391 "You are installing from a local file.".to_string()
1392 }
1393 crate::types::SourceType::Url => {
1394 "You are installing from a remote URL. This script will be \
1395 executed with your user's permissions, which could lead to remote \
1396 code execution if the source is malicious."
1397 .to_string()
1398 }
1399 crate::types::SourceType::GitRepo(repo) => format!(
1400 "You are installing from an external git repository '{repo}'. \
1401 This script will be executed with your user's permissions."
1402 ),
1403 crate::types::SourceType::OfficialRepo => return Ok(())
1404 };
1405 println!(
1406 "\n{}: {}",
1407 "SECURITY WARNING".yellow().bold(),
1408 warning_message
1409 );
1410 if ask_for_confirmation(
1411 "This source is not trusted. Are you sure you want to continue?",
1412 yes
1413 ) {
1414 Ok(())
1415 } else {
1416 Err(anyhow!("Operation aborted by user."))
1417 }
1418}
1419
1420pub fn expand_placeholders(
1432 path: &str,
1433 version_dir: &Path,
1434 scope: crate::types::Scope
1435) -> Result<String> {
1436 let placeholders =
1437 ["${pkgstore}", "${usrroot}", "${usrhome}", "${applications}"];
1438 for placeholder in placeholders {
1439 if let Some(index) = path.find(placeholder) {
1440 if index != 0 || path[placeholder.len()..].contains("${") {
1441 return Err(anyhow!("Invalid placeholder path: {path}"));
1442 }
1443 let suffix =
1444 path[placeholder.len()..].trim_start_matches(['/', '\\']);
1445 if Path::new(suffix).components().any(|component| {
1446 matches!(component, std::path::Component::ParentDir)
1447 }) {
1448 return Err(anyhow!(
1449 "Placeholder path escapes its root: {path}"
1450 ));
1451 }
1452 }
1453 }
1454 if Path::new(path)
1455 .components()
1456 .any(|component| matches!(component, std::path::Component::ParentDir))
1457 {
1458 return Err(anyhow!("Path contains parent components: {path}"));
1459 }
1460
1461 let mut expanded = path.to_string();
1462 expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1463 expanded = expanded.replace(
1464 "${usrroot}",
1465 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1466 );
1467 if let Some(home_dir) = get_user_home() {
1468 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1469 }
1470
1471 let applications_dir = match scope {
1472 crate::types::Scope::System => PathBuf::from("/Applications"),
1473 crate::types::Scope::User => get_user_home().map_or_else(
1474 || PathBuf::from("/Applications"),
1475 |h| h.join("Applications")
1476 ),
1477 crate::types::Scope::Project => std::env::current_dir()
1478 .unwrap_or_default()
1479 .join("Applications")
1480 };
1481 expanded = expanded
1482 .replace("${applications}", &applications_dir.to_string_lossy());
1483
1484 Ok(expanded)
1485}
1486
1487#[must_use]
1510pub fn strip_staging_placeholder(path: &str) -> String {
1511 if let Some(rest) = path.strip_prefix("${usrroot}/") {
1512 return rest.to_string();
1513 }
1514 if let Some(rest) = path.strip_prefix("${usrroot}") {
1515 return rest.trim_start_matches('/').to_string();
1516 }
1517 if let Some(rest) = path.strip_prefix("${usrhome}/") {
1518 return rest.to_string();
1519 }
1520 if let Some(rest) = path.strip_prefix("${usrhome}") {
1521 return rest.trim_start_matches('/').to_string();
1522 }
1523 if let Some(rest) = path.strip_prefix("${pkgstore}/") {
1524 return format!("pkgstore/{rest}");
1525 }
1526 if let Some(rest) = path.strip_prefix("${pkgstore}") {
1527 return format!("pkgstore/{}", rest.trim_start_matches('/'));
1528 }
1529 if let Some(rest) = path.strip_prefix("${applications}/") {
1530 return rest.to_string();
1531 }
1532 if let Some(rest) = path.strip_prefix("${applications}") {
1533 return rest.trim_start_matches('/').to_string();
1534 }
1535 if let Some(rest) = path.strip_prefix("usrroot/") {
1536 return rest.to_string();
1537 }
1538 if let Some(rest) = path.strip_prefix("usrhome/") {
1539 return rest.to_string();
1540 }
1541 path.to_string()
1542}
1543
1544#[must_use]
1554pub fn expand_staging_root_placeholders(path: &str) -> String {
1555 let mut expanded = path.to_string();
1556 expanded = expanded.replace(
1557 "${usrroot}",
1558 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1559 );
1560 if let Some(home_dir) = get_user_home() {
1561 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1562 }
1563 expanded
1564}
1565
1566pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1568 let path = path.as_ref();
1569 if !path.starts_with("~") {
1570 return path.to_path_buf();
1571 }
1572 if let Some(home_dir) = get_user_home() {
1573 if path == Path::new("~") {
1574 return home_dir;
1575 }
1576 if let Ok(stripped) = path.strip_prefix("~/") {
1577 return home_dir.join(stripped);
1578 }
1579 }
1580 path.to_path_buf()
1581}
1582
1583pub fn get_current_shell() -> Option<Shell> {
1585 if cfg!(windows) {
1586 return Some(Shell::PowerShell);
1587 }
1588 if let Ok(shell_path) = std::env::var("SHELL") {
1589 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1590 match shell_name {
1591 "bash" => Some(Shell::Bash),
1592 "zsh" => Some(Shell::Zsh),
1593 "fish" => Some(Shell::Fish),
1594 "elvish" => Some(Shell::Elvish),
1595 "pwsh" => Some(Shell::PowerShell),
1596 _ => None
1597 }
1598 } else {
1599 None
1600 }
1601}
1602
1603const TRUSTED_SHELLS: &[&str] = &[
1613 "bash",
1614 "sh",
1615 "dash",
1616 "zsh",
1617 "fish",
1618 "elvish",
1619 "ksh",
1620 "mksh",
1621 "ash",
1622 "pwsh",
1623 "powershell",
1624 "nu"
1625];
1626
1627pub fn resolve_shell_binary() -> Result<String> {
1640 Ok(trusted_shell_name(std::env::var("SHELL").ok().as_deref()))
1641}
1642
1643fn trusted_shell_name(shell_path: Option<&str>) -> String {
1647 let default = if cfg!(windows) { "pwsh" } else { "bash" };
1648
1649 let Some(shell_path) = shell_path else {
1650 return default.to_string();
1651 };
1652
1653 let file_name = Path::new(shell_path).file_name().and_then(|n| n.to_str());
1656
1657 match file_name {
1658 Some(name) if TRUSTED_SHELLS.contains(&name) => name.to_string(),
1659 _ => default.to_string()
1663 }
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668 use super::*;
1669
1670 #[test]
1671 fn detects_http_and_https_external_license_links() {
1672 assert!(is_external_license_link("https://example.com/LICENSE"));
1673 assert!(is_external_license_link("http://example.com/license"));
1674 assert!(is_external_license_link(" https://example.com/MIT.txt "));
1675 }
1676
1677 #[test]
1678 fn accepts_trusted_shell_paths() {
1679 assert_eq!(trusted_shell_name(Some("/bin/bash")), "bash");
1681 assert_eq!(trusted_shell_name(Some("/usr/bin/zsh")), "zsh");
1682 assert_eq!(trusted_shell_name(Some("/usr/local/bin/fish")), "fish");
1683 assert_eq!(trusted_shell_name(Some("bash")), "bash");
1684 }
1685
1686 #[test]
1687 fn rejects_untrusted_shell_values() {
1688 let default = if cfg!(windows) { "pwsh" } else { "bash" };
1689
1690 assert_eq!(trusted_shell_name(Some("/tmp/evil/payload")), default);
1692 assert_eq!(trusted_shell_name(Some("bash; rm -rf /")), default);
1694 assert_eq!(trusted_shell_name(Some("$(id)")), default);
1695 assert_eq!(trusted_shell_name(Some("")), default);
1696 assert_eq!(trusted_shell_name(Some("/bin/../evil")), default);
1697 assert_eq!(trusted_shell_name(None), default);
1699 }
1700
1701 #[test]
1702 fn does_not_treat_spdx_or_special_values_as_links() {
1703 assert!(!is_external_license_link("MIT"));
1704 assert!(!is_external_license_link("Apache-2.0"));
1705 assert!(!is_external_license_link("None"));
1706 assert!(!is_external_license_link("Proprietary"));
1707 assert!(!is_external_license_link("Unknown"));
1708 }
1709
1710 #[test]
1711 fn url_license_is_flagged_before_spdx_parsing() {
1712 assert!(is_external_license_link("https://example.com/LICENSE"));
1713 assert!(
1714 spdx::Expression::parse("https://example.com/LICENSE").is_err()
1715 );
1716 }
1717
1718 #[test]
1719 fn staging_placeholder_strips_to_sysroot_relative_form() {
1720 assert_eq!(
1721 strip_staging_placeholder("${usrroot}/usr/share/fonts/foo.ttf"),
1722 "usr/share/fonts/foo.ttf"
1723 );
1724 assert_eq!(
1725 strip_staging_placeholder("${usrhome}/.local/share/fonts/foo.ttf"),
1726 ".local/share/fonts/foo.ttf"
1727 );
1728 assert_eq!(
1729 strip_staging_placeholder("${pkgstore}/bin/foo"),
1730 "pkgstore/bin/foo"
1731 );
1732 assert_eq!(
1733 strip_staging_placeholder("${applications}/MyApp.app"),
1734 "MyApp.app"
1735 );
1736 assert_eq!(
1737 strip_staging_placeholder("/usr/share/fonts/foo.ttf"),
1738 "/usr/share/fonts/foo.ttf"
1739 );
1740 }
1741
1742 #[test]
1743 fn staging_root_expansion_uses_canonical_locations() {
1744 let expanded =
1745 expand_staging_root_placeholders("${usrroot}/usr/share/fonts");
1746 assert!(
1747 expanded.ends_with("/usr/share/fonts"),
1748 "unexpected expansion: {expanded}"
1749 );
1750 assert_eq!(
1752 expand_staging_root_placeholders("${pkgstore}/bin/foo"),
1753 "${pkgstore}/bin/foo"
1754 );
1755 }
1756}