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(format!("command -v {command}"))
109 .stdout(std::process::Stdio::null())
110 .stderr(std::process::Stdio::null())
111 .status()
112 .is_ok_and(|status| status.success())
113 }
114}
115
116pub fn get_platform() -> Result<String> {
127 let os = match std::env::consts::OS {
128 "linux" => "linux",
129 "macos" | "darwin" => "macos",
130 "windows" => "windows",
131 unsupported_os => {
132 return Err(anyhow!(
133 "Unsupported operating system: {unsupported_os}"
134 ));
135 }
136 };
137 let arch = match std::env::consts::ARCH {
138 "x86_64" | "amd64" => "amd64",
139 "aarch64" | "arm64" => "arm64",
140 "x86" | "i386" | "i686" => "386",
141 unsupported_arch => {
142 return Err(anyhow!(
143 "Unsupported architecture: {unsupported_arch}"
144 ));
145 }
146 };
147 Ok(format!("{os}-{arch}"))
148}
149
150pub fn get_user_home() -> Option<PathBuf> {
153 if let Ok(user_var) =
154 std::env::var("SUDO_USER").or_else(|_| std::env::var("DOAS_USER"))
155 {
156 #[cfg(unix)]
157 {
158 use nix::unistd::User;
159 if let Ok(Some(user)) = User::from_name(&user_var) {
160 return Some(user.dir);
161 }
162 }
163 #[cfg(not(unix))]
164 let _ = user_var;
165 }
166 home::home_dir()
167}
168
169pub fn get_db_root() -> Result<std::path::PathBuf> {
175 if let Ok(path) = std::env::var("ZOI_DB_DIR") {
176 return Ok(std::path::PathBuf::from(path));
177 }
178 let home_dir = get_user_home()
179 .ok_or_else(|| anyhow!("Could not find home directory."))?;
180 Ok(home_dir.join(".zoi").join("pkgs").join("db"))
181}
182
183pub fn get_store_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
196 match scope {
197 crate::types::Scope::User => {
198 let home_dir = get_user_home()
199 .ok_or_else(|| anyhow!("Could not find home directory."))?;
200 Ok(crate::sysroot::apply_sysroot(
201 home_dir.join(".zoi").join("pkgs").join("store")
202 ))
203 }
204 crate::types::Scope::System => {
205 if cfg!(target_os = "windows") {
206 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
207 "C:\\ProgramData\\zoi\\pkgs\\store"
208 )))
209 } else {
210 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
211 "/var/lib/zoi/pkgs/store"
212 )))
213 }
214 }
215 crate::types::Scope::Project => {
216 let current_dir = std::env::current_dir()?;
217 Ok(current_dir.join(".zoi").join("pkgs").join("store"))
218 }
219 }
220}
221
222pub fn get_db_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
229 match scope {
230 crate::types::Scope::User => {
231 let home_dir = get_user_home()
232 .ok_or_else(|| anyhow!("Could not find home directory."))?;
233 Ok(crate::sysroot::apply_sysroot(
234 home_dir.join(".zoi").join("pkgs").join("db")
235 ))
236 }
237 crate::types::Scope::System => {
238 if cfg!(target_os = "windows") {
239 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
240 "C:\\ProgramData\\zoi\\pkgs\\db"
241 )))
242 } else {
243 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
244 "/var/lib/zoi/pkgs/db"
245 )))
246 }
247 }
248 crate::types::Scope::Project => {
249 let current_dir = std::env::current_dir()?;
250 Ok(current_dir.join(".zoi").join("pkgs").join("db"))
251 }
252 }
253}
254
255pub fn get_git_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
262 match scope {
263 crate::types::Scope::User => {
264 let home_dir = get_user_home()
265 .ok_or_else(|| anyhow!("Could not find home directory."))?;
266 Ok(crate::sysroot::apply_sysroot(
267 home_dir.join(".zoi").join("pkgs").join("git")
268 ))
269 }
270 crate::types::Scope::System => {
271 if cfg!(target_os = "windows") {
272 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
273 "C:\\ProgramData\\zoi\\pkgs\\git"
274 )))
275 } else {
276 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
277 "/var/lib/zoi/pkgs/git"
278 )))
279 }
280 }
281 crate::types::Scope::Project => {
282 let current_dir = std::env::current_dir()?;
283 Ok(current_dir.join(".zoi").join("pkgs").join("git"))
284 }
285 }
286}
287
288pub fn generate_package_id(
296 registry_handle: &str,
297 repo_path: &str,
298 package_name: &str
299) -> String {
300 let format_string =
301 format!("#{registry_handle}@{repo_path}/{package_name}");
302 let mut hasher = Sha512::new();
303 hasher.update(format_string.as_bytes());
304 let result = hasher.finalize();
305 let hex_string = hex::encode(result);
306 hex_string[..32].to_string()
307}
308
309pub fn generate_versioned_package_id(
311 registry_handle: &str,
312 repo_path: &str,
313 package_name: &str,
314 version: &str
315) -> String {
316 let format_string =
317 format!("#{registry_handle}@{repo_path}/{package_name}@{version}");
318 let mut hasher = Sha512::new();
319 hasher.update(format_string.as_bytes());
320 let result = hasher.finalize();
321 let hex_string = hex::encode(result);
322 hex_string[..32].to_string()
323}
324
325pub fn get_package_dir_name(package_id: &str, package_name: &str) -> String {
328 format!("{package_id}-{package_name}")
329}
330
331pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
337 let src = if src.as_os_str().is_empty() {
338 Path::new(".")
339 } else {
340 src
341 };
342 fs::create_dir_all(dst)?;
343 for entry in fs::read_dir(src)? {
344 let entry = entry?;
345 let ty = entry.file_type()?;
346 if ty.is_dir() {
347 copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
348 } else {
349 fs::copy(entry.path(), dst.join(entry.file_name()))?;
350 }
351 }
352 Ok(())
353}
354
355pub fn retry_backoff_sleep(attempt: u32) {
360 let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
361 let jitter = u64::from(
362 std::time::SystemTime::now()
363 .duration_since(std::time::UNIX_EPOCH)
364 .unwrap_or(Duration::from_secs(0))
365 .subsec_millis()
366 % 200
367 );
368 let sleep_ms = (base_ms + jitter).min(8000);
369 std::thread::sleep(Duration::from_millis(sleep_ms));
370}
371
372pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
375 let path = crate::sysroot::apply_sysroot("/etc/os-release");
376 if let Ok(contents) = fs::read_to_string(path) {
377 let info: HashMap<String, String> = contents
378 .lines()
379 .filter_map(|line| {
380 let mut parts = line.splitn(2, '=');
381 let key = parts.next()?;
382 let value = parts.next()?.trim_matches('"').to_string();
383 if key.is_empty() {
384 None
385 } else {
386 Some((key.to_string(), value))
387 }
388 })
389 .collect();
390 if info.is_empty() { None } else { Some(info) }
391 } else {
392 None
393 }
394}
395
396pub fn get_linux_distro_family() -> Option<String> {
412 if is_zoios() {
413 return Some("zoios".to_string());
414 }
415 if let Some(info) = get_linux_distribution_info() {
416 if let Some(id_like) = info.get("ID_LIKE") {
417 let families: Vec<&str> = id_like.split_whitespace().collect();
418 if families.contains(&"debian") {
419 return Some("debian".to_string());
420 }
421 if families.contains(&"arch") {
422 return Some("arch".to_string());
423 }
424 if families.contains(&"fedora") {
425 return Some("fedora".to_string());
426 }
427 if families.contains(&"rhel") {
428 return Some("fedora".to_string());
429 }
430 if families.contains(&"suse") {
431 return Some("suse".to_string());
432 }
433 if families.contains(&"gentoo") {
434 return Some("gentoo".to_string());
435 }
436 }
437 if let Some(id) = info.get("ID") {
438 return match id.as_str() {
439 "debian" | "ubuntu" | "linuxmint" | "pop" | "kali"
440 | "kubuntu" | "lubuntu" | "xubuntu" | "zorin"
441 | "elementary" => Some("debian".to_string()),
442 "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
443 Some("arch".to_string())
444 }
445 "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => {
446 Some("fedora".to_string())
447 }
448 "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => {
449 Some("suse".to_string())
450 }
451 "gentoo" => Some("gentoo".to_string()),
452 "alpine" => Some("alpine".to_string()),
453 "void" => Some("void".to_string()),
454 "solus" => Some("solus".to_string()),
455 "guix" => Some("guix".to_string()),
456 _ => None
457 };
458 }
459 }
460 None
461}
462
463pub fn get_linux_distribution() -> Option<String> {
465 get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
466}
467
468pub fn is_zoios() -> bool {
471 if let Some(info) = get_linux_distribution_info() {
472 if let Some(id) = info.get("ID")
473 && (id == "zoios" || id == "parlex")
474 {
475 return true;
476 }
477 if let Some(id_like) = info.get("ID_LIKE")
478 && id_like.split_whitespace().any(|s| s == "zoios")
479 {
480 return true;
481 }
482 }
483 false
484}
485
486pub fn resolve_fallback_scope() -> crate::types::Scope {
488 if std::path::Path::new("zoi.lua").exists()
489 || std::path::Path::new("zoi.yaml").exists()
490 {
491 crate::types::Scope::Project
492 } else if is_zoios() {
493 crate::types::Scope::System
494 } else {
495 crate::types::Scope::User
496 }
497}
498
499pub fn get_desktop_environment() -> Option<String> {
501 if cfg!(target_os = "windows") {
502 return Some("windows".to_string());
503 }
504 if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
505 && !de.is_empty()
506 {
507 return Some(de.to_lowercase());
508 }
509 if let Ok(ds) = std::env::var("DESKTOP_SESSION")
510 && !ds.is_empty()
511 {
512 return Some(ds.to_lowercase());
513 }
514 None
515}
516
517pub fn get_display_server() -> Option<String> {
519 if cfg!(target_os = "windows") {
520 return Some("windows".to_string());
521 }
522 if cfg!(target_os = "macos") {
523 return Some("quartz".to_string());
524 }
525 if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
526 && !st.is_empty()
527 {
528 return Some(st.to_lowercase());
529 }
530 None
531}
532
533pub fn get_kernel_version() -> Option<String> {
535 if cfg!(unix) {
536 let output = Command::new("uname").arg("-r").output().ok()?;
537 if output.status.success() {
538 return Some(
539 String::from_utf8_lossy(&output.stdout).trim().to_string()
540 );
541 }
542 } else if cfg!(target_os = "windows") {
543 let output = Command::new("pwsh")
544 .arg("-Command")
545 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
546 .output()
547 .ok()?;
548 if output.status.success() {
549 return Some(
550 String::from_utf8_lossy(&output.stdout).trim().to_string()
551 );
552 }
553 }
554 None
555}
556
557pub fn get_init_system() -> Option<String> {
559 if let Ok(val) = std::env::var("ZOI_INIT") {
560 return Some(val.to_lowercase());
561 }
562
563 let run_systemd =
564 crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
565 if run_systemd.exists() {
566 return Some("systemd".to_string());
567 }
568
569 let run_openrc =
570 crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
571 if run_openrc.exists() {
572 return Some("openrc".to_string());
573 }
574
575 let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
576 if let Ok(target) = std::fs::read_link(&sbin_init) {
577 let target_str = target.to_string_lossy();
578 if target_str.contains("systemd") {
579 return Some("systemd".to_string());
580 } else if target_str.contains("openrc") {
581 return Some("openrc".to_string());
582 } else if target_str.contains("busybox") {
583 return Some("busybox".to_string());
584 }
585 }
586
587 None
588}
589
590pub fn get_privilege_escalator() -> Option<String> {
593 if command_exists("sudo") {
594 Some("sudo".to_string())
595 } else if command_exists("doas") {
596 Some("doas".to_string())
597 } else {
598 None
599 }
600}
601
602pub fn get_distro_version() -> Option<String> {
604 if let Some(info) = get_linux_distribution_info()
605 && let Some(vid) = info.get("VERSION_ID")
606 {
607 return Some(vid.clone());
608 }
609 if cfg!(target_os = "macos") {
610 let output = Command::new("sw_vers")
611 .arg("-productVersion")
612 .output()
613 .ok()?;
614 if output.status.success() {
615 return Some(
616 String::from_utf8_lossy(&output.stdout).trim().to_string()
617 );
618 }
619 } else if cfg!(target_os = "windows") {
620 let output = Command::new("pwsh")
621 .arg("-Command")
622 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
623 .output()
624 .ok()?;
625 if output.status.success() {
626 return Some(
627 String::from_utf8_lossy(&output.stdout).trim().to_string()
628 );
629 }
630 }
631 None
632}
633
634pub fn get_cpu_info() -> Option<String> {
636 if cfg!(target_os = "linux") {
637 if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
638 for line in cpuinfo.lines() {
639 if line.starts_with("model name")
640 && let Some((_, model)) = line.split_once(':')
641 {
642 return Some(model.trim().to_string());
643 }
644 }
645 }
646 } else if cfg!(target_os = "macos") {
647 let output = Command::new("sysctl")
648 .arg("-n")
649 .arg("machdep.cpu.brand_string")
650 .output()
651 .ok()?;
652 if output.status.success() {
653 return Some(
654 String::from_utf8_lossy(&output.stdout).trim().to_string()
655 );
656 }
657 } else if cfg!(target_os = "windows") {
658 let output = Command::new("pwsh")
659 .arg("-Command")
660 .arg("(Get-CimInstance Win32_Processor).Name")
661 .output()
662 .ok()?;
663 if output.status.success() {
664 return Some(
665 String::from_utf8_lossy(&output.stdout).trim().to_string()
666 );
667 }
668 }
669 None
670}
671
672pub fn get_gpu_info() -> Option<String> {
674 if cfg!(target_os = "linux") {
675 if let Ok(output) = Command::new("lspci").output()
676 && output.status.success()
677 {
678 let stdout = String::from_utf8_lossy(&output.stdout);
679 for line in stdout.lines() {
680 if (line.contains("VGA compatible controller")
681 || line.contains("3D controller"))
682 && let Some((_, model)) = line.split_once(": ")
683 {
684 return Some(model.trim().to_string());
685 }
686 }
687 }
688 } else if cfg!(target_os = "macos") {
689 let output = Command::new("system_profiler")
690 .arg("SPDisplaysDataType")
691 .output()
692 .ok()?;
693 if output.status.success() {
694 let stdout = String::from_utf8_lossy(&output.stdout);
695 for line in stdout.lines() {
696 if line.trim().starts_with("Chipset Model:")
697 && let Some((_, model)) = line.split_once(':')
698 {
699 return Some(model.trim().to_string());
700 }
701 }
702 }
703 } else if cfg!(target_os = "windows") {
704 let output = Command::new("pwsh")
705 .arg("-Command")
706 .arg("(Get-CimInstance Win32_VideoController).Name")
707 .output()
708 .ok()?;
709 if output.status.success() {
710 return Some(
711 String::from_utf8_lossy(&output.stdout).trim().to_string()
712 );
713 }
714 }
715 None
716}
717
718pub fn get_native_package_manager() -> Option<String> {
722 let os = std::env::consts::OS;
723 match os {
724 "linux" => get_linux_distro_family()
725 .map(|family| {
726 match family.as_str() {
727 "debian" => "apt",
728 "arch" => "pacman",
729 "fedora" => "dnf",
730 "suse" => "zypper",
731 "gentoo" => "portage",
732 "alpine" => "apk",
733 "void" => "xbps-install",
734 "solus" => "eopkg",
735 "guix" => "guix",
736 "zoios" => "zoi",
737 _ => "unknown"
738 }
739 .to_string()
740 })
741 .filter(|s| s != "unknown"),
742 "macos" => {
743 if command_exists("brew") {
744 Some("brew".to_string())
745 } else if command_exists("port") {
746 Some("macports".to_string())
747 } else {
748 None
749 }
750 }
751 "windows" => {
752 if command_exists("scoop") {
753 Some("scoop".to_string())
754 } else if command_exists("choco") {
755 Some("choco".to_string())
756 } else if command_exists("winget") {
757 Some("winget".to_string())
758 } else {
759 None
760 }
761 }
762 _ => None
763 }
764}
765
766pub fn get_all_available_package_managers() -> Vec<String> {
771 let mut managers = Vec::new();
772 let all_possible_managers = [
773 "apt",
774 "pacman",
775 "yay",
776 "paru",
777 "pikaur",
778 "trizen",
779 "dnf",
780 "yum",
781 "zypper",
782 "portage",
783 "apk",
784 "snap",
785 "flatpak",
786 "nix",
787 "brew",
788 "port",
789 "scoop",
790 "choco",
791 "winget",
792 "pkg",
793 "pkg_add",
794 "xbps-install",
795 "eopkg",
796 "guix",
797 "mas"
798 ];
799
800 for manager in &all_possible_managers {
801 if command_exists(manager) {
802 managers.push(manager.to_string());
803 }
804 }
805 managers.sort();
806 managers.dedup();
807 managers
808}
809
810pub fn format_bytes(bytes: u64) -> String {
812 const KIB: u64 = 1024;
813 const MIB: u64 = 1024 * KIB;
814 const GIB: u64 = 1024 * MIB;
815 if bytes >= GIB {
816 format!("{:.2} GiB", bytes as f64 / GIB as f64)
817 } else if bytes >= MIB {
818 format!("{:.2} MiB", bytes as f64 / MIB as f64)
819 } else if bytes >= KIB {
820 format!("{:.2} KiB", bytes as f64 / KIB as f64)
821 } else {
822 format!("{bytes} B")
823 }
824}
825
826pub fn format_size_diff(diff: i64) -> String {
829 if diff == 0 {
830 return "0 B".to_string();
831 }
832 let sign = if diff > 0 { "+" } else { "-" };
833 let bytes = diff.unsigned_abs();
834 format!("{} {}", sign, format_bytes(bytes))
835}
836
837pub fn is_safe_path(base: &Path, path: &Path) -> bool {
843 let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
844 let joined = if path.is_absolute() {
845 path.to_path_buf()
846 } else {
847 base.join(path)
848 };
849 let mut normalized = PathBuf::new();
850 for component in joined.components() {
851 match component {
852 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
853 normalized.push(component);
854 }
855 std::path::Component::CurDir => {}
856 std::path::Component::ParentDir => {
857 if !normalized.pop() {
858 return false;
859 }
860 }
861 std::path::Component::Normal(p) => normalized.push(p)
862 }
863 }
864 normalized.starts_with(&base)
865}
866
867pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
873 if link.exists() || link.is_symlink() {
874 fs::remove_file(link)?;
875 }
876 #[cfg(unix)]
877 {
878 std::os::unix::fs::symlink(target, link)
879 }
880 #[cfg(windows)]
881 {
882 if std::os::windows::fs::symlink_file(target, link).is_err() {
883 if fs::hard_link(target, link).is_err() {
884 fs::copy(target, link)?;
885 }
886 }
887 Ok(())
888 }
889}
890
891pub fn is_admin() -> bool {
893 #[cfg(unix)]
894 {
895 nix::unistd::getuid().is_root()
896 }
897 #[cfg(windows)]
898 {
899 false
900 }
901}
902
903pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
910 let status = if cfg!(target_os = "windows") {
911 Command::new("pwsh")
912 .arg("-Command")
913 .arg(command_str)
914 .status()?
915 } else {
916 Command::new("bash").arg("-c").arg(command_str).status()?
917 };
918 if !status.success() {
919 return Err(anyhow!("Command failed: {command_str}"));
920 }
921 Ok(())
922}
923
924pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
932 let status = if cfg!(target_os = "windows") {
933 Command::new("pwsh")
934 .arg("-Command")
935 .arg(command_str)
936 .stdout(std::process::Stdio::null())
937 .stderr(std::process::Stdio::null())
938 .status()?
939 } else {
940 Command::new("bash")
941 .arg("-c")
942 .arg(command_str)
943 .stdout(std::process::Stdio::null())
944 .stderr(std::process::Stdio::null())
945 .status()?
946 };
947 if !status.success() {
948 return Err(anyhow!("Command failed: {command_str}"));
949 }
950 Ok(())
951}
952
953pub fn is_mini_mode() -> bool {
955 std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
956}
957
958pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
960 if yes {
961 return true;
962 }
963 if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
964 return false;
965 }
966 print!("{prompt} [y/N]: ");
967 let _ = stdout().flush();
968 let mut input = String::new();
969 if stdin().read_line(&mut input).is_err() {
970 return false;
971 }
972 input.trim().eq_ignore_ascii_case("y")
973}
974
975pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
981 if !path.exists() {
982 return Ok(());
983 }
984 for entry in walkdir::WalkDir::new(path) {
985 let entry = entry?;
986 let mut perms = fs::metadata(entry.path())?.permissions();
987 if !perms.readonly() {
988 perms.set_readonly(true);
989 fs::set_permissions(entry.path(), perms)?;
990 }
991 }
992 Ok(())
993}
994
995pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1001 if !path.exists() {
1002 return Ok(());
1003 }
1004 for entry in walkdir::WalkDir::new(path) {
1005 let entry = entry?;
1006 let mut perms = fs::metadata(entry.path())?.permissions();
1007 if perms.readonly() {
1008 #[cfg(unix)]
1009 {
1010 use std::os::unix::fs::PermissionsExt;
1011 let mode = perms.mode();
1012 perms.set_mode(mode | 0o200);
1013 }
1014 #[cfg(not(unix))]
1015 {
1016 perms.set_readonly(false);
1017 }
1018 fs::set_permissions(entry.path(), perms)?;
1019 }
1020 }
1021 Ok(())
1022}
1023
1024#[cfg(unix)]
1031pub fn set_path_owner(
1032 path: &Path,
1033 owner: &str,
1034 group: &str
1035) -> anyhow::Result<()> {
1036 use nix::unistd::{Gid, Group, Uid, User, chown};
1037 let uid = if let Ok(u) = owner.parse::<u32>() {
1038 Some(Uid::from_raw(u))
1039 } else if !owner.is_empty() {
1040 Some(
1041 User::from_name(owner)
1042 .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1043 .ok_or_else(|| anyhow!("User not found: {owner}"))?
1044 .uid
1045 )
1046 } else {
1047 None
1048 };
1049 let gid = if let Ok(g) = group.parse::<u32>() {
1050 Some(Gid::from_raw(g))
1051 } else if !group.is_empty() {
1052 Some(
1053 Group::from_name(group)
1054 .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1055 .ok_or_else(|| anyhow!("Group not found: {group}"))?
1056 .gid
1057 )
1058 } else {
1059 None
1060 };
1061 chown(path, uid, gid)
1062 .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1063 Ok(())
1064}
1065
1066pub fn is_platform_compatible(
1068 current_platform: &str,
1069 allowed_platforms: &[String]
1070) -> bool {
1071 let os_part = current_platform
1072 .split('-')
1073 .next()
1074 .unwrap_or(current_platform);
1075 let os = match os_part {
1076 "darwin" => "macos",
1077 other => other
1078 };
1079 allowed_platforms.iter().any(|p| {
1080 if let Some(rest) = p.strip_prefix("ci:") {
1081 let target = rest.split(':').next().unwrap_or_default();
1082 target == current_platform || target == os
1083 } else {
1084 let p_norm = if p == "darwin" { "macos" } else { p };
1085 p_norm == "all" || p_norm == os || p_norm == current_platform
1086 }
1087 })
1088}
1089
1090pub fn check_license(license: &str) {
1092 if license.is_empty() || license.eq_ignore_ascii_case("None") {
1093 return;
1094 }
1095 if license.eq_ignore_ascii_case("Proprietary")
1096 || license.eq_ignore_ascii_case("Unknown")
1097 {
1098 return;
1099 }
1100 match spdx::Expression::parse(license) {
1101 Ok(expr) => {
1102 if !expr.evaluate(|req| match req.license {
1103 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1104 spdx::LicenseItem::Other { .. } => false
1105 }) {
1106 println!(
1107 "{} License '{}' is not an OSI approved license.",
1108 "Warning:".yellow(),
1109 license.yellow().bold()
1110 );
1111 }
1112 }
1113 Err(_) => {
1114 println!(
1115 "{} Could not parse license expression '{}'. It may not be a \
1116 valid SPDX identifier.",
1117 "Warning:".yellow(),
1118 license.yellow().bold()
1119 );
1120 }
1121 }
1122}
1123
1124pub fn confirm_untrusted_source(
1131 source_type: &crate::types::SourceType,
1132 yes: bool
1133) -> anyhow::Result<()> {
1134 if is_mini_mode() {
1135 return Ok(());
1136 }
1137 if source_type == &crate::types::SourceType::OfficialRepo {
1138 return Ok(());
1139 }
1140 let warning_message = match source_type {
1141 crate::types::SourceType::UntrustedRepo(repo) => {
1142 format!(
1143 "The package from repository '@{repo}' is not an official Zoi \
1144 repository."
1145 )
1146 }
1147 crate::types::SourceType::LocalFile => {
1148 "You are installing from a local file.".to_string()
1149 }
1150 crate::types::SourceType::Url => {
1151 "You are installing from a remote URL. This script will be \
1152 executed with your user's permissions, which could lead to remote \
1153 code execution if the source is malicious."
1154 .to_string()
1155 }
1156 crate::types::SourceType::GitRepo(repo) => format!(
1157 "You are installing from an external git repository '{repo}'. \
1158 This script will be executed with your user's permissions."
1159 ),
1160 crate::types::SourceType::OfficialRepo => return Ok(())
1161 };
1162 println!(
1163 "\n{}: {}",
1164 "SECURITY WARNING".yellow().bold(),
1165 warning_message
1166 );
1167 if ask_for_confirmation(
1168 "This source is not trusted. Are you sure you want to continue?",
1169 yes
1170 ) {
1171 Ok(())
1172 } else {
1173 Err(anyhow!("Operation aborted by user."))
1174 }
1175}
1176
1177pub fn expand_placeholders(
1184 path: &str,
1185 version_dir: &Path,
1186 scope: crate::types::Scope
1187) -> Result<String> {
1188 let mut expanded = path.to_string();
1189 expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1190 expanded = expanded.replace(
1191 "${usrroot}",
1192 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1193 );
1194 if let Some(home_dir) = get_user_home() {
1195 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1196 }
1197
1198 let applications_dir = match scope {
1199 crate::types::Scope::System => PathBuf::from("/Applications"),
1200 crate::types::Scope::User => get_user_home().map_or_else(
1201 || PathBuf::from("/Applications"),
1202 |h| h.join("Applications")
1203 ),
1204 crate::types::Scope::Project => std::env::current_dir()
1205 .unwrap_or_default()
1206 .join("Applications")
1207 };
1208 expanded = expanded
1209 .replace("${applications}", &applications_dir.to_string_lossy());
1210
1211 Ok(expanded)
1212}
1213
1214pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1216 let path = path.as_ref();
1217 if !path.starts_with("~") {
1218 return path.to_path_buf();
1219 }
1220 if let Some(home_dir) = get_user_home() {
1221 if path == Path::new("~") {
1222 return home_dir;
1223 }
1224 if let Ok(stripped) = path.strip_prefix("~/") {
1225 return home_dir.join(stripped);
1226 }
1227 }
1228 path.to_path_buf()
1229}
1230
1231pub fn get_current_shell() -> Option<Shell> {
1233 if cfg!(windows) {
1234 return Some(Shell::PowerShell);
1235 }
1236 if let Ok(shell_path) = std::env::var("SHELL") {
1237 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1238 match shell_name {
1239 "bash" => Some(Shell::Bash),
1240 "zsh" => Some(Shell::Zsh),
1241 "fish" => Some(Shell::Fish),
1242 "elvish" => Some(Shell::Elvish),
1243 "pwsh" => Some(Shell::PowerShell),
1244 _ => None
1245 }
1246 } else {
1247 None
1248 }
1249}