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(sudo_user) = std::env::var("SUDO_USER") {
119 #[cfg(unix)]
120 {
121 use nix::unistd::User;
122 if let Ok(Some(user)) = User::from_name(&sudo_user) {
123 return Some(user.dir);
124 }
125 }
126 #[cfg(not(unix))]
127 let _ = sudo_user;
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_distro_version() -> Option<String> {
467 if let Some(info) = get_linux_distribution_info()
468 && let Some(vid) = info.get("VERSION_ID")
469 {
470 return Some(vid.clone());
471 }
472 if cfg!(target_os = "macos") {
473 let output = Command::new("sw_vers")
474 .arg("-productVersion")
475 .output()
476 .ok()?;
477 if output.status.success() {
478 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
479 }
480 } else if cfg!(target_os = "windows") {
481 let output = Command::new("pwsh")
482 .arg("-Command")
483 .arg("(Get-CimInstance Win32_OperatingSystem).Version")
484 .output()
485 .ok()?;
486 if output.status.success() {
487 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
488 }
489 }
490 None
491}
492
493pub fn get_cpu_info() -> Option<String> {
494 if cfg!(target_os = "linux") {
495 if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
496 for line in cpuinfo.lines() {
497 if line.starts_with("model name")
498 && let Some((_, model)) = line.split_once(':')
499 {
500 return Some(model.trim().to_string());
501 }
502 }
503 }
504 } else if cfg!(target_os = "macos") {
505 let output = Command::new("sysctl")
506 .arg("-n")
507 .arg("machdep.cpu.brand_string")
508 .output()
509 .ok()?;
510 if output.status.success() {
511 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
512 }
513 } else if cfg!(target_os = "windows") {
514 let output = Command::new("pwsh")
515 .arg("-Command")
516 .arg("(Get-CimInstance Win32_Processor).Name")
517 .output()
518 .ok()?;
519 if output.status.success() {
520 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
521 }
522 }
523 None
524}
525
526pub fn get_gpu_info() -> Option<String> {
527 if cfg!(target_os = "linux") {
528 if let Ok(output) = Command::new("lspci").output()
529 && output.status.success()
530 {
531 let stdout = String::from_utf8_lossy(&output.stdout);
532 for line in stdout.lines() {
533 if (line.contains("VGA compatible controller") || line.contains("3D controller"))
534 && let Some((_, model)) = line.split_once(": ")
535 {
536 return Some(model.trim().to_string());
537 }
538 }
539 }
540 } else if cfg!(target_os = "macos") {
541 let output = Command::new("system_profiler")
542 .arg("SPDisplaysDataType")
543 .output()
544 .ok()?;
545 if output.status.success() {
546 let stdout = String::from_utf8_lossy(&output.stdout);
547 for line in stdout.lines() {
548 if line.trim().starts_with("Chipset Model:")
549 && let Some((_, model)) = line.split_once(':')
550 {
551 return Some(model.trim().to_string());
552 }
553 }
554 }
555 } else if cfg!(target_os = "windows") {
556 let output = Command::new("pwsh")
557 .arg("-Command")
558 .arg("(Get-CimInstance Win32_VideoController).Name")
559 .output()
560 .ok()?;
561 if output.status.success() {
562 return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
563 }
564 }
565 None
566}
567
568pub fn get_native_package_manager() -> Option<String> {
572 let os = std::env::consts::OS;
573 match os {
574 "linux" => get_linux_distro_family()
575 .map(|family| {
576 match family.as_str() {
577 "debian" => "apt",
578 "arch" => "pacman",
579 "fedora" => "dnf",
580 "suse" => "zypper",
581 "gentoo" => "portage",
582 "alpine" => "apk",
583 "void" => "xbps-install",
584 "solus" => "eopkg",
585 "guix" => "guix",
586 "zoios" => "zoi",
587 _ => "unknown",
588 }
589 .to_string()
590 })
591 .filter(|s| s != "unknown"),
592 "macos" => {
593 if command_exists("brew") {
594 Some("brew".to_string())
595 } else if command_exists("port") {
596 Some("macports".to_string())
597 } else {
598 None
599 }
600 }
601 "windows" => {
602 if command_exists("scoop") {
603 Some("scoop".to_string())
604 } else if command_exists("choco") {
605 Some("choco".to_string())
606 } else if command_exists("winget") {
607 Some("winget".to_string())
608 } else {
609 None
610 }
611 }
612 _ => None,
613 }
614}
615
616pub fn get_all_available_package_managers() -> Vec<String> {
621 let mut managers = Vec::new();
622 let all_possible_managers = [
623 "apt",
624 "pacman",
625 "yay",
626 "paru",
627 "pikaur",
628 "trizen",
629 "dnf",
630 "yum",
631 "zypper",
632 "portage",
633 "apk",
634 "snap",
635 "flatpak",
636 "nix",
637 "brew",
638 "port",
639 "scoop",
640 "choco",
641 "winget",
642 "pkg",
643 "pkg_add",
644 "xbps-install",
645 "eopkg",
646 "guix",
647 "mas",
648 ];
649
650 for manager in &all_possible_managers {
651 if command_exists(manager) {
652 managers.push(manager.to_string());
653 }
654 }
655 managers.sort();
656 managers.dedup();
657 managers
658}
659
660pub fn format_bytes(bytes: u64) -> String {
661 const KIB: u64 = 1024;
662 const MIB: u64 = 1024 * KIB;
663 const GIB: u64 = 1024 * MIB;
664 if bytes >= GIB {
665 format!("{:.2} GiB", bytes as f64 / GIB as f64)
666 } else if bytes >= MIB {
667 format!("{:.2} MiB", bytes as f64 / MIB as f64)
668 } else if bytes >= KIB {
669 format!("{:.2} KiB", bytes as f64 / KIB as f64)
670 } else {
671 format!("{} B", bytes)
672 }
673}
674
675pub fn format_size_diff(diff: i64) -> String {
676 if diff == 0 {
677 return "0 B".to_string();
678 }
679 let sign = if diff > 0 { "+" } else { "-" };
680 let bytes = diff.unsigned_abs();
681 format!("{} {}", sign, format_bytes(bytes))
682}
683
684pub fn is_safe_path(base: &Path, path: &Path) -> bool {
689 let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
690 let joined = if path.is_absolute() {
691 path.to_path_buf()
692 } else {
693 base.join(path)
694 };
695 let mut normalized = PathBuf::new();
696 for component in joined.components() {
697 match component {
698 std::path::Component::Prefix(_) => normalized.push(component),
699 std::path::Component::RootDir => normalized.push(component),
700 std::path::Component::CurDir => {}
701 std::path::Component::ParentDir => {
702 if !normalized.pop() {
703 return false;
704 }
705 }
706 std::path::Component::Normal(p) => normalized.push(p),
707 }
708 }
709 normalized.starts_with(&base)
710}
711
712pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
713 if link.exists() || link.is_symlink() {
714 fs::remove_file(link)?;
715 }
716 #[cfg(unix)]
717 {
718 std::os::unix::fs::symlink(target, link)
719 }
720 #[cfg(windows)]
721 {
722 if std::os::windows::fs::symlink_file(target, link).is_err() {
723 if fs::hard_link(target, link).is_err() {
724 fs::copy(target, link)?;
725 }
726 }
727 Ok(())
728 }
729}
730
731pub fn is_admin() -> bool {
732 #[cfg(unix)]
733 {
734 nix::unistd::getuid().is_root()
735 }
736 #[cfg(windows)]
737 {
738 false
739 }
740}
741
742pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
743 let status = if cfg!(target_os = "windows") {
744 Command::new("pwsh")
745 .arg("-Command")
746 .arg(command_str)
747 .status()?
748 } else {
749 Command::new("bash").arg("-c").arg(command_str).status()?
750 };
751 if !status.success() {
752 return Err(anyhow!("Command failed: {}", command_str));
753 }
754 Ok(())
755}
756
757pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
758 let status = if cfg!(target_os = "windows") {
759 Command::new("pwsh")
760 .arg("-Command")
761 .arg(command_str)
762 .stdout(std::process::Stdio::null())
763 .stderr(std::process::Stdio::null())
764 .status()?
765 } else {
766 Command::new("bash")
767 .arg("-c")
768 .arg(command_str)
769 .stdout(std::process::Stdio::null())
770 .stderr(std::process::Stdio::null())
771 .status()?
772 };
773 if !status.success() {
774 return Err(anyhow!("Command failed: {}", command_str));
775 }
776 Ok(())
777}
778
779pub fn is_mini_mode() -> bool {
780 std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
781}
782
783pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
784 if yes {
785 return true;
786 }
787 if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
788 return false;
789 }
790 print!("{} [y/N]: ", prompt);
791 let _ = stdout().flush();
792 let mut input = String::new();
793 if stdin().read_line(&mut input).is_err() {
794 return false;
795 }
796 input.trim().eq_ignore_ascii_case("y")
797}
798
799pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
800 if !path.exists() {
801 return Ok(());
802 }
803 for entry in walkdir::WalkDir::new(path) {
804 let entry = entry?;
805 let mut perms = fs::metadata(entry.path())?.permissions();
806 if !perms.readonly() {
807 perms.set_readonly(true);
808 fs::set_permissions(entry.path(), perms)?;
809 }
810 }
811 Ok(())
812}
813
814pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
815 if !path.exists() {
816 return Ok(());
817 }
818 for entry in walkdir::WalkDir::new(path) {
819 let entry = entry?;
820 let mut perms = fs::metadata(entry.path())?.permissions();
821 if perms.readonly() {
822 #[cfg(unix)]
823 {
824 use std::os::unix::fs::PermissionsExt;
825 let mode = perms.mode();
826 perms.set_mode(mode | 0o200);
827 }
828 #[cfg(not(unix))]
829 {
830 perms.set_readonly(false);
831 }
832 fs::set_permissions(entry.path(), perms)?;
833 }
834 }
835 Ok(())
836}
837
838#[cfg(unix)]
839pub fn set_path_owner(path: &Path, owner: &str, group: &str) -> anyhow::Result<()> {
840 use nix::unistd::{Gid, Group, Uid, User, chown};
841 let uid = if let Ok(u) = owner.parse::<u32>() {
842 Some(Uid::from_raw(u))
843 } else if !owner.is_empty() {
844 Some(
845 User::from_name(owner)
846 .map_err(|e| anyhow!("Error looking up user '{}': {}", owner, e))?
847 .ok_or_else(|| anyhow!("User not found: {}", owner))?
848 .uid,
849 )
850 } else {
851 None
852 };
853 let gid = if let Ok(g) = group.parse::<u32>() {
854 Some(Gid::from_raw(g))
855 } else if !group.is_empty() {
856 Some(
857 Group::from_name(group)
858 .map_err(|e| anyhow!("Error looking up group '{}': {}", group, e))?
859 .ok_or_else(|| anyhow!("Group not found: {}", group))?
860 .gid,
861 )
862 } else {
863 None
864 };
865 chown(path, uid, gid).map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
866 Ok(())
867}
868
869pub fn is_platform_compatible(current_platform: &str, allowed_platforms: &[String]) -> bool {
870 let os_part = current_platform
871 .split('-')
872 .next()
873 .unwrap_or(current_platform);
874 let os = match os_part {
875 "darwin" => "macos",
876 other => other,
877 };
878 allowed_platforms.iter().any(|p| {
879 if let Some(rest) = p.strip_prefix("ci:") {
880 let target = rest.split(':').next().unwrap_or_default();
881 target == current_platform || target == os
882 } else {
883 let p_norm = if p == "darwin" { "macos" } else { p };
884 p_norm == "all" || p_norm == os || p_norm == current_platform
885 }
886 })
887}
888
889pub fn check_license(license: &str) {
890 if license.is_empty() {
891 return;
892 }
893 if license.eq_ignore_ascii_case("Proprietary") || license.eq_ignore_ascii_case("Unknown") {
894 return;
895 }
896 if let Ok(expr) = spdx::Expression::parse(license)
897 && !expr.evaluate(|req| match req.license {
898 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
899 spdx::LicenseItem::Other { .. } => false,
900 })
901 {}
902}
903
904pub fn confirm_untrusted_source(
905 source_type: &crate::types::SourceType,
906 yes: bool,
907) -> anyhow::Result<()> {
908 if is_mini_mode() {
909 return Ok(());
910 }
911 if source_type == &crate::types::SourceType::OfficialRepo {
912 return Ok(());
913 }
914 let warning_message = match source_type {
915 crate::types::SourceType::UntrustedRepo(repo) => {
916 format!(
917 "The package from repository '@{}' is not an official Zoi repository.",
918 repo
919 )
920 }
921 crate::types::SourceType::LocalFile => "You are installing from a local file.".to_string(),
922 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(),
923 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),
924 _ => return Ok(()),
925 };
926 println!(
927 "\n{}: {}",
928 "SECURITY WARNING".yellow().bold(),
929 warning_message
930 );
931 if ask_for_confirmation(
932 "This source is not trusted. Are you sure you want to continue?",
933 yes,
934 ) {
935 Ok(())
936 } else {
937 Err(anyhow!("Operation aborted by user."))
938 }
939}
940
941pub fn expand_placeholders(
942 path: &str,
943 version_dir: &Path,
944 scope: crate::types::Scope,
945) -> Result<String> {
946 let mut expanded = path.to_string();
947 expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
948 expanded = expanded.replace(
949 "${usrroot}",
950 &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy(),
951 );
952 if let Some(home_dir) = get_user_home() {
953 expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
954 }
955
956 let applications_dir = match scope {
957 crate::types::Scope::System => PathBuf::from("/Applications"),
958 crate::types::Scope::User => get_user_home()
959 .map(|h| h.join("Applications"))
960 .unwrap_or_else(|| PathBuf::from("/Applications")),
961 crate::types::Scope::Project => std::env::current_dir()
962 .unwrap_or_default()
963 .join("Applications"),
964 };
965 expanded = expanded.replace("${applications}", &applications_dir.to_string_lossy());
966
967 Ok(expanded)
968}
969
970pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
971 let path = path.as_ref();
972 if !path.starts_with("~") {
973 return path.to_path_buf();
974 }
975 if let Some(home_dir) = get_user_home() {
976 if path == Path::new("~") {
977 return home_dir;
978 }
979 if let Ok(stripped) = path.strip_prefix("~/") {
980 return home_dir.join(stripped);
981 }
982 }
983 path.to_path_buf()
984}
985
986pub fn get_current_shell() -> Option<Shell> {
987 if cfg!(windows) {
988 return Some(Shell::PowerShell);
989 }
990 if let Ok(shell_path) = std::env::var("SHELL") {
991 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
992 match shell_name {
993 "bash" => Some(Shell::Bash),
994 "zsh" => Some(Shell::Zsh),
995 "fish" => Some(Shell::Fish),
996 "elvish" => Some(Shell::Elvish),
997 "pwsh" => Some(Shell::PowerShell),
998 _ => None,
999 }
1000 } else {
1001 None
1002 }
1003}