Skip to main content

zoi_core/
utils.rs

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
17/// Creates an HTTP client with Zoi's default configuration.
18pub 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
96/// Returns a standard Zoi platform identifier (e.g. "linux-amd64", "windows-arm64").
97///
98/// This string is used extensively in registries and package definitions to
99/// handle platform-specific dependencies and build artifacts.
100pub 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
116/// Returns the home directory of the current user, or the original user if run via sudo or doas.
117pub 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
132/// Returns the root directory for the package database.
133pub 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
141/// Returns the root directory of the package store for a given scope.
142///
143/// Store Locations:
144/// - `User`: `~/.zoi/pkgs/store/`
145/// - `System`: `/var/lib/zoi/pkgs/store/` (Linux) or `C:\ProgramData\zoi\pkgs\store` (Windows)
146/// - `Project`: `./.zoi/pkgs/store/` (Relative to current project root)
147pub 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
228/// Generates a unique, origin-aware ID for a package.
229///
230/// This ID prevents collisions between packages with the same name that reside
231/// in different registries or repository tiers.
232///
233/// ID Format: `#{registry-handle}@{repo-path}/{package-name}`
234/// Hashed Result: First 32 characters of the SHA-512 hash of the ID string.
235pub 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
244/// Generates a unique ID for a package including its version.
245pub 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
262/// Creates the directory name for the package in the store.
263/// Format: `{hash}-{name}`
264pub 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
287/// Performs a jittered exponential backoff sleep.
288///
289/// Used during network retries to prevent thundering herd problems and
290/// improve reliability on unstable connections.
291pub 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
324/// Detects the general family of a Linux distribution (e.g. "debian", "arch", "fedora").
325///
326/// This is used to map specific distributions to their primary package manager
327/// and standard filesystem locations.
328///
329/// Strategy:
330/// - ID_LIKE Check: We first check the `ID_LIKE` field in `/etc/os-release`.
331///   This is the most reliable way to identify derivatives (e.g. Ubuntu is `debian`).
332/// - Direct ID Match: If `ID_LIKE` is missing, we fall back to the primary `ID`.
333/// - Normalization: We group similar distros under a common "family" key
334///   to simplify downstream logic (e.g. Rocky, Alma, and CentOS all map to `fedora`
335///   because they share the DNF/RPM ecosystem).
336pub 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
387/// Returns true if the current system is a ZoiOS-based distribution (like Parlex).
388pub 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
404/// Resolves the default installation scope based on the current environment.
405pub 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
608/// Identifies the primary package manager for the current operating system.
609///
610/// This is used to resolve `native:` dependencies.
611pub 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
656/// Scans the system for all supported package managers.
657///
658/// This provides the list of available managers shown in `zoi info` and
659/// used to validate `manager:` prefixes in dependency strings.
660pub 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
724/// Verifies that a given path is "Safe" and doesn't attempt to escape the base directory.
725///
726/// This is a critical security check against "Path Traversal" attacks in
727/// package archives or Lua scripts.
728pub 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}