1use anyhow::{Result, anyhow};
2use clap_complete::Shell;
3use colored::Colorize;
4use crossterm::tty::IsTty;
5use sha2::{Digest, Sha512};
6use std::collections::HashMap;
7use std::fs;
8use std::io::{Write, stdin, stdout};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::OnceLock;
12use std::time::Duration;
13
14#[cfg(unix)]
15use nix;
16
17pub fn get_http_client() -> Result<&'static reqwest::blocking::Client> {
19 if crate::offline::is_offline() {
20 return Err(anyhow!(
21 "Cannot create HTTP client: Zoi is in offline mode."
22 ));
23 }
24 static HTTP_CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
25 if let Some(client) = HTTP_CLIENT.get() {
26 return Ok(client);
27 }
28 let client = reqwest::blocking::Client::builder()
29 .user_agent("zoi")
30 .timeout(Duration::from_secs(60))
31 .use_rustls_tls()
32 .build()
33 .map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?;
34 let _ = HTTP_CLIENT.set(client);
35 HTTP_CLIENT
36 .get()
37 .ok_or_else(|| anyhow!("HTTP_CLIENT should be set but was missing"))
38}
39
40pub fn build_blocking_http_client(timeout_secs: u64) -> Result<reqwest::blocking::Client> {
41 if crate::offline::is_offline() {
42 return Err(anyhow!(
43 "Cannot create HTTP client: Zoi is in offline mode."
44 ));
45 }
46 let client = reqwest::blocking::Client::builder()
47 .user_agent("zoi")
48 .timeout(Duration::from_secs(timeout_secs))
49 .use_rustls_tls()
50 .build()?;
51 Ok(client)
52}
53
54pub fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
55 if link.exists() || link.is_symlink() {
56 if link.is_dir() && !link.is_symlink() {
57 fs::remove_dir_all(link)?;
58 } else {
59 fs::remove_file(link)?;
60 }
61 }
62 #[cfg(unix)]
63 {
64 std::os::unix::fs::symlink(target, link)?;
65 }
66 #[cfg(windows)]
67 {
68 if std::os::windows::fs::symlink_dir(target, link).is_err() {
69 if junction::create(target, link).is_err() {
70 copy_dir_all(target, link)?;
71 }
72 }
73 }
74 Ok(())
75}
76
77pub fn command_exists(command: &str) -> bool {
78 if cfg!(target_os = "windows") {
79 Command::new("where")
80 .arg(command)
81 .stdout(std::process::Stdio::null())
82 .stderr(std::process::Stdio::null())
83 .status()
84 .is_ok_and(|status| status.success())
85 } else {
86 Command::new("bash")
87 .arg("-c")
88 .arg(format!("command -v {}", command))
89 .stdout(std::process::Stdio::null())
90 .stderr(std::process::Stdio::null())
91 .status()
92 .is_ok_and(|status| status.success())
93 }
94}
95
96pub fn get_platform() -> Result<String> {
101 let os = match std::env::consts::OS {
102 "linux" => "linux",
103 "macos" | "darwin" => "macos",
104 "windows" => "windows",
105 unsupported_os => return Err(anyhow!("Unsupported operating system: {}", unsupported_os)),
106 };
107 let arch = match std::env::consts::ARCH {
108 "x86_64" | "amd64" => "amd64",
109 "aarch64" | "arm64" => "arm64",
110 "x86" | "i386" | "i686" => "386",
111 unsupported_arch => return Err(anyhow!("Unsupported architecture: {}", unsupported_arch)),
112 };
113 Ok(format!("{}-{}", os, arch))
114}
115
116pub fn get_user_home() -> Option<PathBuf> {
118 if let Ok(user_var) = std::env::var("SUDO_USER").or_else(|_| std::env::var("DOAS_USER")) {
119 #[cfg(unix)]
120 {
121 use nix::unistd::User;
122 if let Ok(Some(user)) = User::from_name(&user_var) {
123 return Some(user.dir);
124 }
125 }
126 #[cfg(not(unix))]
127 let _ = user_var;
128 }
129 home::home_dir()
130}
131
132pub fn get_db_root() -> Result<std::path::PathBuf> {
134 if let Ok(path) = std::env::var("ZOI_DB_DIR") {
135 return Ok(std::path::PathBuf::from(path));
136 }
137 let home_dir = get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
138 Ok(home_dir.join(".zoi").join("pkgs").join("db"))
139}
140
141pub fn get_store_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
148 match scope {
149 crate::types::Scope::User => {
150 let home_dir =
151 get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
152 Ok(crate::sysroot::apply_sysroot(
153 home_dir.join(".zoi").join("pkgs").join("store"),
154 ))
155 }
156 crate::types::Scope::System => {
157 if cfg!(target_os = "windows") {
158 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
159 "C:\\ProgramData\\zoi\\pkgs\\store",
160 )))
161 } else {
162 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
163 "/var/lib/zoi/pkgs/store",
164 )))
165 }
166 }
167 crate::types::Scope::Project => {
168 let current_dir = std::env::current_dir()?;
169 Ok(current_dir.join(".zoi").join("pkgs").join("store"))
170 }
171 }
172}
173
174pub fn get_db_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
175 match scope {
176 crate::types::Scope::User => {
177 let home_dir =
178 get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
179 Ok(crate::sysroot::apply_sysroot(
180 home_dir.join(".zoi").join("pkgs").join("db"),
181 ))
182 }
183 crate::types::Scope::System => {
184 if cfg!(target_os = "windows") {
185 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
186 "C:\\ProgramData\\zoi\\pkgs\\db",
187 )))
188 } else {
189 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
190 "/var/lib/zoi/pkgs/db",
191 )))
192 }
193 }
194 crate::types::Scope::Project => {
195 let current_dir = std::env::current_dir()?;
196 Ok(current_dir.join(".zoi").join("pkgs").join("db"))
197 }
198 }
199}
200
201pub fn get_git_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
202 match scope {
203 crate::types::Scope::User => {
204 let home_dir =
205 get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
206 Ok(crate::sysroot::apply_sysroot(
207 home_dir.join(".zoi").join("pkgs").join("git"),
208 ))
209 }
210 crate::types::Scope::System => {
211 if cfg!(target_os = "windows") {
212 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
213 "C:\\ProgramData\\zoi\\pkgs\\git",
214 )))
215 } else {
216 Ok(crate::sysroot::apply_sysroot(PathBuf::from(
217 "/var/lib/zoi/pkgs/git",
218 )))
219 }
220 }
221 crate::types::Scope::Project => {
222 let current_dir = std::env::current_dir()?;
223 Ok(current_dir.join(".zoi").join("pkgs").join("git"))
224 }
225 }
226}
227
228pub fn generate_package_id(registry_handle: &str, repo_path: &str, package_name: &str) -> String {
236 let format_string = format!("#{}@{}/{}", registry_handle, repo_path, package_name);
237 let mut hasher = Sha512::new();
238 hasher.update(format_string.as_bytes());
239 let result = hasher.finalize();
240 let hex_string = hex::encode(result);
241 hex_string[..32].to_string()
242}
243
244pub fn generate_versioned_package_id(
246 registry_handle: &str,
247 repo_path: &str,
248 package_name: &str,
249 version: &str,
250) -> String {
251 let format_string = format!(
252 "#{}@{}/{}@{}",
253 registry_handle, repo_path, package_name, version
254 );
255 let mut hasher = Sha512::new();
256 hasher.update(format_string.as_bytes());
257 let result = hasher.finalize();
258 let hex_string = hex::encode(result);
259 hex_string[..32].to_string()
260}
261
262pub fn get_package_dir_name(package_id: &str, package_name: &str) -> String {
265 format!("{}-{}", package_id, package_name)
266}
267
268pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
269 let src = if src.as_os_str().is_empty() {
270 Path::new(".")
271 } else {
272 src
273 };
274 fs::create_dir_all(dst)?;
275 for entry in fs::read_dir(src)? {
276 let entry = entry?;
277 let ty = entry.file_type()?;
278 if ty.is_dir() {
279 copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
280 } else {
281 fs::copy(entry.path(), dst.join(entry.file_name()))?;
282 }
283 }
284 Ok(())
285}
286
287pub fn retry_backoff_sleep(attempt: u32) {
292 let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
293 let jitter = (std::time::SystemTime::now()
294 .duration_since(std::time::UNIX_EPOCH)
295 .unwrap_or(Duration::from_secs(0))
296 .subsec_millis()
297 % 200) as u64;
298 let sleep_ms = (base_ms + jitter).min(8000);
299 std::thread::sleep(Duration::from_millis(sleep_ms));
300}
301
302pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
303 let path = crate::sysroot::apply_sysroot("/etc/os-release");
304 if let Ok(contents) = fs::read_to_string(path) {
305 let info: HashMap<String, String> = contents
306 .lines()
307 .filter_map(|line| {
308 let mut parts = line.splitn(2, '=');
309 let key = parts.next()?;
310 let value = parts.next()?.trim_matches('"').to_string();
311 if key.is_empty() {
312 None
313 } else {
314 Some((key.to_string(), value))
315 }
316 })
317 .collect();
318 if info.is_empty() { None } else { Some(info) }
319 } else {
320 None
321 }
322}
323
324pub fn get_linux_distro_family() -> Option<String> {
337 if is_zoios() {
338 return Some("zoios".to_string());
339 }
340 if let Some(info) = get_linux_distribution_info() {
341 if let Some(id_like) = info.get("ID_LIKE") {
342 let families: Vec<&str> = id_like.split_whitespace().collect();
343 if families.contains(&"debian") {
344 return Some("debian".to_string());
345 }
346 if families.contains(&"arch") {
347 return Some("arch".to_string());
348 }
349 if families.contains(&"fedora") {
350 return Some("fedora".to_string());
351 }
352 if families.contains(&"rhel") {
353 return Some("fedora".to_string());
354 }
355 if families.contains(&"suse") {
356 return Some("suse".to_string());
357 }
358 if families.contains(&"gentoo") {
359 return Some("gentoo".to_string());
360 }
361 }
362 if let Some(id) = info.get("ID") {
363 return match id.as_str() {
364 "debian" | "ubuntu" | "linuxmint" | "pop" | "kali" | "kubuntu" | "lubuntu"
365 | "xubuntu" | "zorin" | "elementary" => Some("debian".to_string()),
366 "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
367 Some("arch".to_string())
368 }
369 "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => Some("fedora".to_string()),
370 "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => Some("suse".to_string()),
371 "gentoo" => Some("gentoo".to_string()),
372 "alpine" => Some("alpine".to_string()),
373 "void" => Some("void".to_string()),
374 "solus" => Some("solus".to_string()),
375 "guix" => Some("guix".to_string()),
376 _ => None,
377 };
378 }
379 }
380 None
381}
382
383pub fn get_linux_distribution() -> Option<String> {
384 get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
385}
386
387pub fn is_zoios() -> bool {
389 if let Some(info) = get_linux_distribution_info() {
390 if let Some(id) = info.get("ID")
391 && (id == "zoios" || id == "parlex")
392 {
393 return true;
394 }
395 if let Some(id_like) = info.get("ID_LIKE")
396 && id_like.split_whitespace().any(|s| s == "zoios")
397 {
398 return true;
399 }
400 }
401 false
402}
403
404pub fn resolve_fallback_scope() -> crate::types::Scope {
406 if std::path::Path::new("zoi.lua").exists() || std::path::Path::new("zoi.yaml").exists() {
407 crate::types::Scope::Project
408 } else if is_zoios() {
409 crate::types::Scope::System
410 } else {
411 crate::types::Scope::User
412 }
413}
414
415pub fn get_desktop_environment() -> Option<String> {
416 if cfg!(target_os = "windows") {
417 return Some("windows".to_string());
418 }
419 if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
420 && !de.is_empty()
421 {
422 return Some(de.to_lowercase());
423 }
424 if let Ok(ds) = std::env::var("DESKTOP_SESSION")
425 && !ds.is_empty()
426 {
427 return Some(ds.to_lowercase());
428 }
429 None
430}
431
432pub fn get_display_server() -> Option<String> {
433 if cfg!(target_os = "windows") {
434 return Some("windows".to_string());
435 }
436 if cfg!(target_os = "macos") {
437 return Some("quartz".to_string());
438 }
439 if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
440 && !st.is_empty()
441 {
442 return Some(st.to_lowercase());
443 }
444 None
445}
446
447pub fn get_kernel_version() -> Option<String> {
448 if cfg!(unix) {
449 let output = Command::new("uname").arg("-r").output().ok()?;
450 if output.status.success() {
451 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
452 }
453 } else if cfg!(target_os = "windows") {
454 let output = Command::new("pwsh")
455 .arg("-Command")
456 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
457 .output()
458 .ok()?;
459 if output.status.success() {
460 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
461 }
462 }
463 None
464}
465
466pub fn get_init_system() -> Option<String> {
467 if let Ok(val) = std::env::var("ZOI_INIT") {
468 return Some(val.to_lowercase());
469 }
470
471 let run_systemd = crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
472 if run_systemd.exists() {
473 return Some("systemd".to_string());
474 }
475
476 let run_openrc = crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
477 if run_openrc.exists() {
478 return Some("openrc".to_string());
479 }
480
481 let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
482 if let Ok(target) = std::fs::read_link(&sbin_init) {
483 let target_str = target.to_string_lossy();
484 if target_str.contains("systemd") {
485 return Some("systemd".to_string());
486 } else if target_str.contains("openrc") {
487 return Some("openrc".to_string());
488 } else if target_str.contains("busybox") {
489 return Some("busybox".to_string());
490 }
491 }
492
493 None
494}
495
496pub fn get_privilege_escalator() -> Option<String> {
497 if command_exists("sudo") {
498 Some("sudo".to_string())
499 } else if command_exists("doas") {
500 Some("doas".to_string())
501 } else {
502 None
503 }
504}
505
506pub fn get_distro_version() -> Option<String> {
507 if let Some(info) = get_linux_distribution_info()
508 && let Some(vid) = info.get("VERSION_ID")
509 {
510 return Some(vid.clone());
511 }
512 if cfg!(target_os = "macos") {
513 let output = Command::new("sw_vers")
514 .arg("-productVersion")
515 .output()
516 .ok()?;
517 if output.status.success() {
518 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
519 }
520 } else if cfg!(target_os = "windows") {
521 let output = Command::new("pwsh")
522 .arg("-Command")
523 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
524 .output()
525 .ok()?;
526 if output.status.success() {
527 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
528 }
529 }
530 None
531}
532
533pub fn get_cpu_info() -> Option<String> {
534 if cfg!(target_os = "linux") {
535 if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
536 for line in cpuinfo.lines() {
537 if line.starts_with("model name")
538 && let Some((_, model)) = line.split_once(':')
539 {
540 return Some(model.trim().to_string());
541 }
542 }
543 }
544 } else if cfg!(target_os = "macos") {
545 let output = Command::new("sysctl")
546 .arg("-n")
547 .arg("machdep.cpu.brand_string")
548 .output()
549 .ok()?;
550 if output.status.success() {
551 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
552 }
553 } else if cfg!(target_os = "windows") {
554 let output = Command::new("pwsh")
555 .arg("-Command")
556 .arg("(Get-CimInstance Win32_Processor).Name")
557 .output()
558 .ok()?;
559 if output.status.success() {
560 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
561 }
562 }
563 None
564}
565
566pub fn get_gpu_info() -> Option<String> {
567 if cfg!(target_os = "linux") {
568 if let Ok(output) = Command::new("lspci").output()
569 && output.status.success()
570 {
571 let stdout = String::from_utf8_lossy(&output.stdout);
572 for line in stdout.lines() {
573 if (line.contains("VGA compatible controller") || line.contains("3D controller"))
574 && let Some((_, model)) = line.split_once(": ")
575 {
576 return Some(model.trim().to_string());
577 }
578 }
579 }
580 } else if cfg!(target_os = "macos") {
581 let output = Command::new("system_profiler")
582 .arg("SPDisplaysDataType")
583 .output()
584 .ok()?;
585 if output.status.success() {
586 let stdout = String::from_utf8_lossy(&output.stdout);
587 for line in stdout.lines() {
588 if line.trim().starts_with("Chipset Model:")
589 && let Some((_, model)) = line.split_once(':')
590 {
591 return Some(model.trim().to_string());
592 }
593 }
594 }
595 } else if cfg!(target_os = "windows") {
596 let output = Command::new("pwsh")
597 .arg("-Command")
598 .arg("(Get-CimInstance Win32_VideoController).Name")
599 .output()
600 .ok()?;
601 if output.status.success() {
602 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
603 }
604 }
605 None
606}
607
608pub fn get_native_package_manager() -> Option<String> {
612 let os = std::env::consts::OS;
613 match os {
614 "linux" => get_linux_distro_family()
615 .map(|family| {
616 match family.as_str() {
617 "debian" => "apt",
618 "arch" => "pacman",
619 "fedora" => "dnf",
620 "suse" => "zypper",
621 "gentoo" => "portage",
622 "alpine" => "apk",
623 "void" => "xbps-install",
624 "solus" => "eopkg",
625 "guix" => "guix",
626 "zoios" => "zoi",
627 _ => "unknown",
628 }
629 .to_string()
630 })
631 .filter(|s| s != "unknown"),
632 "macos" => {
633 if command_exists("brew") {
634 Some("brew".to_string())
635 } else if command_exists("port") {
636 Some("macports".to_string())
637 } else {
638 None
639 }
640 }
641 "windows" => {
642 if command_exists("scoop") {
643 Some("scoop".to_string())
644 } else if command_exists("choco") {
645 Some("choco".to_string())
646 } else if command_exists("winget") {
647 Some("winget".to_string())
648 } else {
649 None
650 }
651 }
652 _ => None,
653 }
654}
655
656pub fn get_all_available_package_managers() -> Vec<String> {
661 let mut managers = Vec::new();
662 let all_possible_managers = [
663 "apt",
664 "pacman",
665 "yay",
666 "paru",
667 "pikaur",
668 "trizen",
669 "dnf",
670 "yum",
671 "zypper",
672 "portage",
673 "apk",
674 "snap",
675 "flatpak",
676 "nix",
677 "brew",
678 "port",
679 "scoop",
680 "choco",
681 "winget",
682 "pkg",
683 "pkg_add",
684 "xbps-install",
685 "eopkg",
686 "guix",
687 "mas",
688 ];
689
690 for manager in &all_possible_managers {
691 if command_exists(manager) {
692 managers.push(manager.to_string());
693 }
694 }
695 managers.sort();
696 managers.dedup();
697 managers
698}
699
700pub fn format_bytes(bytes: u64) -> String {
701 const KIB: u64 = 1024;
702 const MIB: u64 = 1024 * KIB;
703 const GIB: u64 = 1024 * MIB;
704 if bytes >= GIB {
705 format!("{:.2} GiB", bytes as f64 / GIB as f64)
706 } else if bytes >= MIB {
707 format!("{:.2} MiB", bytes as f64 / MIB as f64)
708 } else if bytes >= KIB {
709 format!("{:.2} KiB", bytes as f64 / KIB as f64)
710 } else {
711 format!("{} B", bytes)
712 }
713}
714
715pub fn format_size_diff(diff: i64) -> String {
716 if diff == 0 {
717 return "0 B".to_string();
718 }
719 let sign = if diff > 0 { "+" } else { "-" };
720 let bytes = diff.unsigned_abs();
721 format!("{} {}", sign, format_bytes(bytes))
722}
723
724pub fn is_safe_path(base: &Path, path: &Path) -> bool {
729 let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
730 let joined = if path.is_absolute() {
731 path.to_path_buf()
732 } else {
733 base.join(path)
734 };
735 let mut normalized = PathBuf::new();
736 for component in joined.components() {
737 match component {
738 std::path::Component::Prefix(_) => normalized.push(component),
739 std::path::Component::RootDir => normalized.push(component),
740 std::path::Component::CurDir => {}
741 std::path::Component::ParentDir => {
742 if !normalized.pop() {
743 return false;
744 }
745 }
746 std::path::Component::Normal(p) => normalized.push(p),
747 }
748 }
749 normalized.starts_with(&base)
750}
751
752pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
753 if link.exists() || link.is_symlink() {
754 fs::remove_file(link)?;
755 }
756 #[cfg(unix)]
757 {
758 std::os::unix::fs::symlink(target, link)
759 }
760 #[cfg(windows)]
761 {
762 if std::os::windows::fs::symlink_file(target, link).is_err() {
763 if fs::hard_link(target, link).is_err() {
764 fs::copy(target, link)?;
765 }
766 }
767 Ok(())
768 }
769}
770
771pub fn is_admin() -> bool {
772 #[cfg(unix)]
773 {
774 nix::unistd::getuid().is_root()
775 }
776 #[cfg(windows)]
777 {
778 false
779 }
780}
781
782pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
783 let status = if cfg!(target_os = "windows") {
784 Command::new("pwsh")
785 .arg("-Command")
786 .arg(command_str)
787 .status()?
788 } else {
789 Command::new("bash").arg("-c").arg(command_str).status()?
790 };
791 if !status.success() {
792 return Err(anyhow!("Command failed: {}", command_str));
793 }
794 Ok(())
795}
796
797pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
798 let status = if cfg!(target_os = "windows") {
799 Command::new("pwsh")
800 .arg("-Command")
801 .arg(command_str)
802 .stdout(std::process::Stdio::null())
803 .stderr(std::process::Stdio::null())
804 .status()?
805 } else {
806 Command::new("bash")
807 .arg("-c")
808 .arg(command_str)
809 .stdout(std::process::Stdio::null())
810 .stderr(std::process::Stdio::null())
811 .status()?
812 };
813 if !status.success() {
814 return Err(anyhow!("Command failed: {}", command_str));
815 }
816 Ok(())
817}
818
819pub fn is_mini_mode() -> bool {
820 std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
821}
822
823pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
824 if yes {
825 return true;
826 }
827 if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
828 return false;
829 }
830 print!("{} [y/N]: ", prompt);
831 let _ = stdout().flush();
832 let mut input = String::new();
833 if stdin().read_line(&mut input).is_err() {
834 return false;
835 }
836 input.trim().eq_ignore_ascii_case("y")
837}
838
839pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
840 if !path.exists() {
841 return Ok(());
842 }
843 for entry in walkdir::WalkDir::new(path) {
844 let entry = entry?;
845 let mut perms = fs::metadata(entry.path())?.permissions();
846 if !perms.readonly() {
847 perms.set_readonly(true);
848 fs::set_permissions(entry.path(), perms)?;
849 }
850 }
851 Ok(())
852}
853
854pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
855 if !path.exists() {
856 return Ok(());
857 }
858 for entry in walkdir::WalkDir::new(path) {
859 let entry = entry?;
860 let mut perms = fs::metadata(entry.path())?.permissions();
861 if perms.readonly() {
862 #[cfg(unix)]
863 {
864 use std::os::unix::fs::PermissionsExt;
865 let mode = perms.mode();
866 perms.set_mode(mode | 0o200);
867 }
868 #[cfg(not(unix))]
869 {
870 perms.set_readonly(false);
871 }
872 fs::set_permissions(entry.path(), perms)?;
873 }
874 }
875 Ok(())
876}
877
878#[cfg(unix)]
879pub fn set_path_owner(path: &Path, owner: &str, group: &str) -> anyhow::Result<()> {
880 use nix::unistd::{Gid, Group, Uid, User, chown};
881 let uid = if let Ok(u) = owner.parse::<u32>() {
882 Some(Uid::from_raw(u))
883 } else if !owner.is_empty() {
884 Some(
885 User::from_name(owner)
886 .map_err(|e| anyhow!("Error looking up user '{}': {}", owner, e))?
887 .ok_or_else(|| anyhow!("User not found: {}", owner))?
888 .uid,
889 )
890 } else {
891 None
892 };
893 let gid = if let Ok(g) = group.parse::<u32>() {
894 Some(Gid::from_raw(g))
895 } else if !group.is_empty() {
896 Some(
897 Group::from_name(group)
898 .map_err(|e| anyhow!("Error looking up group '{}': {}", group, e))?
899 .ok_or_else(|| anyhow!("Group not found: {}", group))?
900 .gid,
901 )
902 } else {
903 None
904 };
905 chown(path, uid, gid).map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
906 Ok(())
907}
908
909pub fn is_platform_compatible(current_platform: &str, allowed_platforms: &[String]) -> bool {
910 let os_part = current_platform
911 .split('-')
912 .next()
913 .unwrap_or(current_platform);
914 let os = match os_part {
915 "darwin" => "macos",
916 other => other,
917 };
918 allowed_platforms.iter().any(|p| {
919 if let Some(rest) = p.strip_prefix("ci:") {
920 let target = rest.split(':').next().unwrap_or_default();
921 target == current_platform || target == os
922 } else {
923 let p_norm = if p == "darwin" { "macos" } else { p };
924 p_norm == "all" || p_norm == os || p_norm == current_platform
925 }
926 })
927}
928
929pub fn check_license(license: &str) {
930 if license.is_empty() {
931 return;
932 }
933 if license.eq_ignore_ascii_case("Proprietary") || license.eq_ignore_ascii_case("Unknown") {
934 return;
935 }
936 if let Ok(expr) = spdx::Expression::parse(license)
937 && !expr.evaluate(|req| match req.license {
938 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
939 spdx::LicenseItem::Other { .. } => false,
940 })
941 {}
942}
943
944pub fn confirm_untrusted_source(
945 source_type: &crate::types::SourceType,
946 yes: bool,
947) -> anyhow::Result<()> {
948 if is_mini_mode() {
949 return Ok(());
950 }
951 if source_type == &crate::types::SourceType::OfficialRepo {
952 return Ok(());
953 }
954 let warning_message = match source_type {
955 crate::types::SourceType::UntrustedRepo(repo) => {
956 format!(
957 "The package from repository '@{}' is not an official Zoi repository.",
958 repo
959 )
960 }
961 crate::types::SourceType::LocalFile => "You are installing from a local file.".to_string(),
962 crate::types::SourceType::Url => "You are installing from a remote URL. This script will be executed with your user's permissions, which could lead to remote code execution if the source is malicious.".to_string(),
963 crate::types::SourceType::GitRepo(repo) => format!("You are installing from an external git repository '{}'. This script will be executed with your user's permissions.", repo),
964 _ => return Ok(()),
965 };
966 println!(
967 "\n{}: {}",
968 "SECURITY WARNING".yellow().bold(),
969 warning_message
970 );
971 if ask_for_confirmation(
972 "This source is not trusted. Are you sure you want to continue?",
973 yes,
974 ) {
975 Ok(())
976 } else {
977 Err(anyhow!("Operation aborted by user."))
978 }
979}
980
981pub fn expand_placeholders(
982 path: &str,
983 version_dir: &Path,
984 scope: crate::types::Scope,
985) -> Result<String> {
986 let mut expanded = path.to_string();
987 expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
988 expanded = expanded.replace(
989 "${usrroot}",
990 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy(),
991 );
992 if let Some(home_dir) = get_user_home() {
993 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
994 }
995
996 let applications_dir = match scope {
997 crate::types::Scope::System => PathBuf::from("/Applications"),
998 crate::types::Scope::User => get_user_home()
999 .map(|h| h.join("Applications"))
1000 .unwrap_or_else(|| PathBuf::from("/Applications")),
1001 crate::types::Scope::Project => std::env::current_dir()
1002 .unwrap_or_default()
1003 .join("Applications"),
1004 };
1005 expanded = expanded.replace("${applications}", &applications_dir.to_string_lossy());
1006
1007 Ok(expanded)
1008}
1009
1010pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1011 let path = path.as_ref();
1012 if !path.starts_with("~") {
1013 return path.to_path_buf();
1014 }
1015 if let Some(home_dir) = get_user_home() {
1016 if path == Path::new("~") {
1017 return home_dir;
1018 }
1019 if let Ok(stripped) = path.strip_prefix("~/") {
1020 return home_dir.join(stripped);
1021 }
1022 }
1023 path.to_path_buf()
1024}
1025
1026pub fn get_current_shell() -> Option<Shell> {
1027 if cfg!(windows) {
1028 return Some(Shell::PowerShell);
1029 }
1030 if let Ok(shell_path) = std::env::var("SHELL") {
1031 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1032 match shell_name {
1033 "bash" => Some(Shell::Bash),
1034 "zsh" => Some(Shell::Zsh),
1035 "fish" => Some(Shell::Fish),
1036 "elvish" => Some(Shell::Elvish),
1037 "pwsh" => Some(Shell::PowerShell),
1038 _ => None,
1039 }
1040 } else {
1041 None
1042 }
1043}