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 = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
986 let joined = if path.is_absolute() {
987 path.to_path_buf()
988 } else {
989 base.join(path)
990 };
991 let mut normalized = PathBuf::new();
992 for component in joined.components() {
993 match component {
994 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
995 normalized.push(component);
996 }
997 std::path::Component::CurDir => {}
998 std::path::Component::ParentDir => {
999 if !normalized.pop() {
1000 return false;
1001 }
1002 }
1003 std::path::Component::Normal(p) => normalized.push(p)
1004 }
1005 }
1006 normalized.starts_with(&base)
1007}
1008
1009pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
1015 if link.exists() || link.is_symlink() {
1016 fs::remove_file(link)?;
1017 }
1018 #[cfg(unix)]
1019 {
1020 std::os::unix::fs::symlink(target, link)
1021 }
1022 #[cfg(windows)]
1023 {
1024 if std::os::windows::fs::symlink_file(target, link).is_err() {
1025 if fs::hard_link(target, link).is_err() {
1026 fs::copy(target, link)?;
1027 }
1028 }
1029 Ok(())
1030 }
1031}
1032
1033pub fn is_admin() -> bool {
1035 #[cfg(unix)]
1036 {
1037 nix::unistd::getuid().is_root()
1038 }
1039 #[cfg(windows)]
1040 {
1041 false
1042 }
1043}
1044
1045pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
1052 let status = if cfg!(target_os = "windows") {
1053 Command::new("pwsh")
1054 .arg("-Command")
1055 .arg(command_str)
1056 .status()?
1057 } else {
1058 Command::new("bash").arg("-c").arg(command_str).status()?
1059 };
1060 if !status.success() {
1061 return Err(anyhow!("Command failed: {command_str}"));
1062 }
1063 Ok(())
1064}
1065
1066pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
1074 let status = if cfg!(target_os = "windows") {
1075 Command::new("pwsh")
1076 .arg("-Command")
1077 .arg(command_str)
1078 .stdout(std::process::Stdio::null())
1079 .stderr(std::process::Stdio::null())
1080 .status()?
1081 } else {
1082 Command::new("bash")
1083 .arg("-c")
1084 .arg(command_str)
1085 .stdout(std::process::Stdio::null())
1086 .stderr(std::process::Stdio::null())
1087 .status()?
1088 };
1089 if !status.success() {
1090 return Err(anyhow!("Command failed: {command_str}"));
1091 }
1092 Ok(())
1093}
1094
1095pub fn is_mini_mode() -> bool {
1097 std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
1098}
1099
1100pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
1102 if yes {
1103 return true;
1104 }
1105 if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
1106 return false;
1107 }
1108 print!("{prompt} [y/N]: ");
1109 let _ = stdout().flush();
1110 let mut input = String::new();
1111 if stdin().read_line(&mut input).is_err() {
1112 return false;
1113 }
1114 input.trim().eq_ignore_ascii_case("y")
1115}
1116
1117pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
1123 if !path.exists() {
1124 return Ok(());
1125 }
1126 for entry in walkdir::WalkDir::new(path) {
1127 let entry = entry?;
1128 let mut perms = fs::metadata(entry.path())?.permissions();
1129 if !perms.readonly() {
1130 perms.set_readonly(true);
1131 fs::set_permissions(entry.path(), perms)?;
1132 }
1133 }
1134 Ok(())
1135}
1136
1137pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1143 if !path.exists() {
1144 return Ok(());
1145 }
1146 for entry in walkdir::WalkDir::new(path) {
1147 let entry = entry?;
1148 let mut perms = fs::metadata(entry.path())?.permissions();
1149 if perms.readonly() {
1150 #[cfg(unix)]
1151 {
1152 use std::os::unix::fs::PermissionsExt;
1153 let mode = perms.mode();
1154 perms.set_mode(mode | 0o200);
1155 }
1156 #[cfg(not(unix))]
1157 {
1158 perms.set_readonly(false);
1159 }
1160 fs::set_permissions(entry.path(), perms)?;
1161 }
1162 }
1163 Ok(())
1164}
1165
1166#[cfg(unix)]
1173pub fn set_path_owner(
1174 path: &Path,
1175 owner: &str,
1176 group: &str
1177) -> anyhow::Result<()> {
1178 use nix::unistd::{Gid, Group, Uid, User, chown};
1179 let uid = if let Ok(u) = owner.parse::<u32>() {
1180 Some(Uid::from_raw(u))
1181 } else if !owner.is_empty() {
1182 Some(
1183 User::from_name(owner)
1184 .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1185 .ok_or_else(|| anyhow!("User not found: {owner}"))?
1186 .uid
1187 )
1188 } else {
1189 None
1190 };
1191 let gid = if let Ok(g) = group.parse::<u32>() {
1192 Some(Gid::from_raw(g))
1193 } else if !group.is_empty() {
1194 Some(
1195 Group::from_name(group)
1196 .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1197 .ok_or_else(|| anyhow!("Group not found: {group}"))?
1198 .gid
1199 )
1200 } else {
1201 None
1202 };
1203 chown(path, uid, gid)
1204 .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1205 Ok(())
1206}
1207
1208pub fn is_platform_compatible(
1210 current_platform: &str,
1211 allowed_platforms: &[String]
1212) -> bool {
1213 let os_part = current_platform
1214 .split('-')
1215 .next()
1216 .unwrap_or(current_platform);
1217 let os = match os_part {
1218 "darwin" => "macos",
1219 other => other
1220 };
1221 allowed_platforms.iter().any(|p| {
1222 if let Some(rest) = p.strip_prefix("ci:") {
1223 let target = rest.split(':').next().unwrap_or_default();
1224 target == current_platform || target == os
1225 } else {
1226 let p_norm = if p == "darwin" { "macos" } else { p };
1227 p_norm == "all" || p_norm == os || p_norm == current_platform
1228 }
1229 })
1230}
1231
1232pub fn check_license(license: &str) {
1234 if license.is_empty() || license.eq_ignore_ascii_case("None") {
1235 return;
1236 }
1237 if license.eq_ignore_ascii_case("Proprietary")
1238 || license.eq_ignore_ascii_case("Unknown")
1239 {
1240 return;
1241 }
1242 if is_external_license_link(license) {
1243 println!(
1244 "{} The license is available at: {}",
1245 "Warning:".yellow(),
1246 license.cyan().bold()
1247 );
1248 return;
1249 }
1250 match spdx::Expression::parse(license) {
1251 Ok(expr) => {
1252 if !expr.evaluate(|req| match req.license {
1253 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1254 spdx::LicenseItem::Other { .. } => false
1255 }) {
1256 println!(
1257 "{} License '{}' is not an OSI approved license.",
1258 "Warning:".yellow(),
1259 license.yellow().bold()
1260 );
1261 }
1262 }
1263 Err(_) => {
1264 println!(
1265 "{} Could not parse license expression '{}'. It may not be a \
1266 valid SPDX identifier.",
1267 "Warning:".yellow(),
1268 license.yellow().bold()
1269 );
1270 }
1271 }
1272}
1273
1274fn is_external_license_link(license: &str) -> bool {
1277 let trimmed = license.trim();
1278 trimmed.starts_with("http://") || trimmed.starts_with("https://")
1279}
1280
1281pub fn confirm_untrusted_source(
1288 source_type: &crate::types::SourceType,
1289 yes: bool
1290) -> anyhow::Result<()> {
1291 if is_mini_mode() {
1292 return Ok(());
1293 }
1294 if source_type == &crate::types::SourceType::OfficialRepo {
1295 return Ok(());
1296 }
1297 let warning_message = match source_type {
1298 crate::types::SourceType::UntrustedRepo(repo) => {
1299 format!(
1300 "The package from repository '@{repo}' is not an official Zoi \
1301 repository."
1302 )
1303 }
1304 crate::types::SourceType::LocalFile => {
1305 "You are installing from a local file.".to_string()
1306 }
1307 crate::types::SourceType::Url => {
1308 "You are installing from a remote URL. This script will be \
1309 executed with your user's permissions, which could lead to remote \
1310 code execution if the source is malicious."
1311 .to_string()
1312 }
1313 crate::types::SourceType::GitRepo(repo) => format!(
1314 "You are installing from an external git repository '{repo}'. \
1315 This script will be executed with your user's permissions."
1316 ),
1317 crate::types::SourceType::OfficialRepo => return Ok(())
1318 };
1319 println!(
1320 "\n{}: {}",
1321 "SECURITY WARNING".yellow().bold(),
1322 warning_message
1323 );
1324 if ask_for_confirmation(
1325 "This source is not trusted. Are you sure you want to continue?",
1326 yes
1327 ) {
1328 Ok(())
1329 } else {
1330 Err(anyhow!("Operation aborted by user."))
1331 }
1332}
1333
1334pub fn expand_placeholders(
1346 path: &str,
1347 version_dir: &Path,
1348 scope: crate::types::Scope
1349) -> Result<String> {
1350 let mut expanded = path.to_string();
1351 expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1352 expanded = expanded.replace(
1353 "${usrroot}",
1354 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1355 );
1356 if let Some(home_dir) = get_user_home() {
1357 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1358 }
1359
1360 let applications_dir = match scope {
1361 crate::types::Scope::System => PathBuf::from("/Applications"),
1362 crate::types::Scope::User => get_user_home().map_or_else(
1363 || PathBuf::from("/Applications"),
1364 |h| h.join("Applications")
1365 ),
1366 crate::types::Scope::Project => std::env::current_dir()
1367 .unwrap_or_default()
1368 .join("Applications")
1369 };
1370 expanded = expanded
1371 .replace("${applications}", &applications_dir.to_string_lossy());
1372
1373 Ok(expanded)
1374}
1375
1376#[must_use]
1399pub fn strip_staging_placeholder(path: &str) -> String {
1400 if let Some(rest) = path.strip_prefix("${usrroot}/") {
1401 return rest.to_string();
1402 }
1403 if let Some(rest) = path.strip_prefix("${usrroot}") {
1404 return rest.trim_start_matches('/').to_string();
1405 }
1406 if let Some(rest) = path.strip_prefix("${usrhome}/") {
1407 return rest.to_string();
1408 }
1409 if let Some(rest) = path.strip_prefix("${usrhome}") {
1410 return rest.trim_start_matches('/').to_string();
1411 }
1412 if let Some(rest) = path.strip_prefix("${pkgstore}/") {
1413 return format!("pkgstore/{rest}");
1414 }
1415 if let Some(rest) = path.strip_prefix("${pkgstore}") {
1416 return format!("pkgstore/{}", rest.trim_start_matches('/'));
1417 }
1418 if let Some(rest) = path.strip_prefix("${applications}/") {
1419 return rest.to_string();
1420 }
1421 if let Some(rest) = path.strip_prefix("${applications}") {
1422 return rest.trim_start_matches('/').to_string();
1423 }
1424 if let Some(rest) = path.strip_prefix("usrroot/") {
1425 return rest.to_string();
1426 }
1427 if let Some(rest) = path.strip_prefix("usrhome/") {
1428 return rest.to_string();
1429 }
1430 path.to_string()
1431}
1432
1433#[must_use]
1443pub fn expand_staging_root_placeholders(path: &str) -> String {
1444 let mut expanded = path.to_string();
1445 expanded = expanded.replace(
1446 "${usrroot}",
1447 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1448 );
1449 if let Some(home_dir) = get_user_home() {
1450 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1451 }
1452 expanded
1453}
1454
1455pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1457 let path = path.as_ref();
1458 if !path.starts_with("~") {
1459 return path.to_path_buf();
1460 }
1461 if let Some(home_dir) = get_user_home() {
1462 if path == Path::new("~") {
1463 return home_dir;
1464 }
1465 if let Ok(stripped) = path.strip_prefix("~/") {
1466 return home_dir.join(stripped);
1467 }
1468 }
1469 path.to_path_buf()
1470}
1471
1472pub fn get_current_shell() -> Option<Shell> {
1474 if cfg!(windows) {
1475 return Some(Shell::PowerShell);
1476 }
1477 if let Ok(shell_path) = std::env::var("SHELL") {
1478 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1479 match shell_name {
1480 "bash" => Some(Shell::Bash),
1481 "zsh" => Some(Shell::Zsh),
1482 "fish" => Some(Shell::Fish),
1483 "elvish" => Some(Shell::Elvish),
1484 "pwsh" => Some(Shell::PowerShell),
1485 _ => None
1486 }
1487 } else {
1488 None
1489 }
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::*;
1495
1496 #[test]
1497 fn detects_http_and_https_external_license_links() {
1498 assert!(is_external_license_link("https://example.com/LICENSE"));
1499 assert!(is_external_license_link("http://example.com/license"));
1500 assert!(is_external_license_link(" https://example.com/MIT.txt "));
1501 }
1502
1503 #[test]
1504 fn does_not_treat_spdx_or_special_values_as_links() {
1505 assert!(!is_external_license_link("MIT"));
1506 assert!(!is_external_license_link("Apache-2.0"));
1507 assert!(!is_external_license_link("None"));
1508 assert!(!is_external_license_link("Proprietary"));
1509 assert!(!is_external_license_link("Unknown"));
1510 }
1511
1512 #[test]
1513 fn url_license_is_flagged_before_spdx_parsing() {
1514 assert!(is_external_license_link("https://example.com/LICENSE"));
1515 assert!(
1516 spdx::Expression::parse("https://example.com/LICENSE").is_err()
1517 );
1518 }
1519
1520 #[test]
1521 fn staging_placeholder_strips_to_sysroot_relative_form() {
1522 assert_eq!(
1523 strip_staging_placeholder("${usrroot}/usr/share/fonts/foo.ttf"),
1524 "usr/share/fonts/foo.ttf"
1525 );
1526 assert_eq!(
1527 strip_staging_placeholder("${usrhome}/.local/share/fonts/foo.ttf"),
1528 ".local/share/fonts/foo.ttf"
1529 );
1530 assert_eq!(
1531 strip_staging_placeholder("${pkgstore}/bin/foo"),
1532 "pkgstore/bin/foo"
1533 );
1534 assert_eq!(
1535 strip_staging_placeholder("${applications}/MyApp.app"),
1536 "MyApp.app"
1537 );
1538 assert_eq!(
1539 strip_staging_placeholder("/usr/share/fonts/foo.ttf"),
1540 "/usr/share/fonts/foo.ttf"
1541 );
1542 }
1543
1544 #[test]
1545 fn staging_root_expansion_uses_canonical_locations() {
1546 let expanded =
1547 expand_staging_root_placeholders("${usrroot}/usr/share/fonts");
1548 assert!(
1549 expanded.ends_with("/usr/share/fonts"),
1550 "unexpected expansion: {expanded}"
1551 );
1552 assert_eq!(
1554 expand_staging_root_placeholders("${pkgstore}/bin/foo"),
1555 "${pkgstore}/bin/foo"
1556 );
1557 }
1558}