Skip to main content

zoi_core/
utils.rs

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
17/// Creates an HTTP client with Zoi's default configuration.
18///
19/// # Errors
20///
21/// Returns an error if Zoi is in offline mode or if the HTTP client cannot be
22/// built.
23pub 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
45/// Builds a blocking HTTP client with a custom timeout.
46///
47/// # Errors
48///
49/// Returns an error if Zoi is in offline mode or if the HTTP client cannot be
50/// built.
51pub 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
67/// Creates a symbolic link for a directory, handling platform-specific
68/// requirements.
69///
70/// # Errors
71///
72/// Returns an error if the symlink or directory operations fail.
73pub 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
96/// Checks if a command exists in the system's PATH.
97pub 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            // Pass the command as a positional argument so metadata supplied
109            // names cannot alter the shell program being executed.
110            .arg("command -v -- \"$1\"")
111            .arg("zoi-command-exists")
112            .arg(command)
113            .stdout(std::process::Stdio::null())
114            .stderr(std::process::Stdio::null())
115            .status()
116            .is_ok_and(|status| status.success())
117    }
118}
119
120/// Returns a standard Zoi platform identifier (e.g. "linux-amd64",
121/// "windows-arm64").
122///
123/// This string is used extensively in registries and package definitions to
124/// handle platform-specific dependencies and build artifacts.
125///
126/// # Errors
127///
128/// Returns an error if the current operating system or architecture is
129/// unsupported.
130pub fn get_platform() -> Result<String> {
131    let os = match std::env::consts::OS {
132        "linux" => "linux",
133        "macos" | "darwin" => "macos",
134        "windows" => "windows",
135        unsupported_os => {
136            return Err(anyhow!(
137                "Unsupported operating system: {unsupported_os}"
138            ));
139        }
140    };
141    let arch = match std::env::consts::ARCH {
142        "x86_64" | "amd64" => "amd64",
143        "aarch64" | "arm64" => "arm64",
144        "x86" | "i386" | "i686" => "386",
145        unsupported_arch => {
146            return Err(anyhow!(
147                "Unsupported architecture: {unsupported_arch}"
148            ));
149        }
150    };
151    Ok(format!("{os}-{arch}"))
152}
153
154/// Returns the home directory of the current user, or the original user if run
155/// via sudo or doas.
156pub fn get_user_home() -> Option<PathBuf> {
157    if let Ok(user_var) =
158        std::env::var("SUDO_USER").or_else(|_| std::env::var("DOAS_USER"))
159    {
160        #[cfg(unix)]
161        {
162            use nix::unistd::User;
163            if let Ok(Some(user)) = User::from_name(&user_var) {
164                return Some(user.dir);
165            }
166        }
167        #[cfg(not(unix))]
168        let _ = user_var;
169    }
170    home::home_dir()
171}
172
173/// Returns Zoi's user configuration directory according to the XDG Base
174/// Directory specification.
175///
176/// # Errors
177///
178/// Returns an error if no suitable user configuration directory can be found.
179pub fn get_user_config_dir() -> Result<PathBuf> {
180    let root =
181        xdg_or_platform_dir("XDG_CONFIG_HOME", dirs::config_dir, |home| {
182            home.join(".config")
183        })?;
184    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
185}
186
187/// Returns Zoi's user data directory according to the XDG Base Directory
188/// specification.
189///
190/// # Errors
191///
192/// Returns an error if no suitable user data directory can be found.
193pub fn get_user_data_dir() -> Result<PathBuf> {
194    let root =
195        xdg_or_platform_dir("XDG_DATA_HOME", dirs::data_local_dir, |home| {
196            home.join(".local/share")
197        })?;
198    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
199}
200
201/// Returns Zoi's user cache directory according to the XDG Base Directory
202/// specification.
203///
204/// # Errors
205///
206/// Returns an error if no suitable user cache directory can be found.
207pub fn get_user_cache_dir() -> Result<PathBuf> {
208    let root =
209        xdg_or_platform_dir("XDG_CACHE_HOME", dirs::cache_dir, |home| {
210            home.join(".cache")
211        })?;
212    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
213}
214
215/// Returns Zoi's user state directory, honoring `XDG_STATE_HOME` first.
216///
217/// # Errors
218///
219/// Returns an error if no suitable user state directory can be found.
220pub fn get_user_state_dir() -> Result<PathBuf> {
221    let root =
222        xdg_or_platform_dir("XDG_STATE_HOME", dirs::data_local_dir, |home| {
223            home.join(".local/state")
224        })?;
225    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
226}
227
228/// Resolves an XDG directory, falling back to the native platform convention.
229fn xdg_or_platform_dir(
230    variable: &str,
231    platform_dir: impl FnOnce() -> Option<PathBuf>,
232    unix_fallback: impl FnOnce(PathBuf) -> PathBuf
233) -> Result<PathBuf> {
234    if let Some(path) =
235        std::env::var_os(variable).filter(|path| !path.is_empty())
236    {
237        let path = PathBuf::from(path);
238        if path.is_absolute() {
239            return Ok(path);
240        }
241    }
242    if cfg!(unix) && !cfg!(target_os = "macos") {
243        return get_user_home()
244            .map(unix_fallback)
245            .ok_or_else(|| anyhow!("Could not find home directory."));
246    }
247    platform_dir()
248        .ok_or_else(|| anyhow!("Could not determine platform directory."))
249}
250
251/// Returns the directory used for user-installed command shims.
252///
253/// On Unix this is the conventional user bin directory (`~/.local/bin`) so
254/// shims are immediately available on most distributions without extra PATH
255/// configuration. Other platforms keep a dedicated directory inside Zoi's
256/// data location.
257///
258/// # Errors
259///
260/// Returns an error if the home directory cannot be determined.
261pub fn get_user_bin_dir() -> Result<PathBuf> {
262    #[cfg(unix)]
263    {
264        let home = get_user_home()
265            .ok_or_else(|| anyhow!("Could not find home directory."))?;
266        Ok(crate::sysroot::apply_sysroot(
267            home.join(".local").join("bin")
268        ))
269    }
270    #[cfg(not(unix))]
271    {
272        Ok(get_user_data_dir()?.join("pkgs").join("bin"))
273    }
274}
275
276/// Returns the directory used for system-wide command shims.
277///
278/// On Linux this follows the distro convention of `/usr/bin`. On macOS the
279/// SIP-protected `/usr/bin` cannot be written to, so `/usr/local/bin` is used
280/// instead. Windows keeps a dedicated directory below `ProgramData`.
281#[must_use]
282pub fn get_system_bin_dir() -> PathBuf {
283    let path = if cfg!(target_os = "windows") {
284        PathBuf::from("C:\\ProgramData\\zoi\\pkgs\\bin")
285    } else if cfg!(target_os = "macos") {
286        PathBuf::from("/usr/local/bin")
287    } else {
288        PathBuf::from("/usr/bin")
289    };
290    crate::sysroot::apply_sysroot(path)
291}
292
293/// Returns the directory used for user shell-completion files.
294///
295/// # Errors
296///
297/// Returns an error if the user data directory cannot be determined.
298pub fn get_user_completions_dir(shell: &str) -> Result<PathBuf> {
299    Ok(get_user_data_dir()?.join("pkgs").join("shell").join(shell))
300}
301
302/// Returns the platform-native directory for system-wide Zoi data.
303pub fn get_system_data_dir() -> PathBuf {
304    let path = if cfg!(target_os = "windows") {
305        PathBuf::from("C:\\ProgramData\\zoi")
306    } else if cfg!(target_os = "macos") {
307        PathBuf::from("/Library/Application Support/zoi")
308    } else {
309        PathBuf::from("/var/lib/zoi")
310    };
311    crate::sysroot::apply_sysroot(path)
312}
313
314/// Returns the platform-native directory for system-wide Zoi configuration.
315pub fn get_system_config_dir() -> PathBuf {
316    let path = if cfg!(target_os = "windows") {
317        PathBuf::from("C:\\ProgramData\\zoi")
318    } else if cfg!(target_os = "macos") {
319        PathBuf::from("/Library/Application Support/zoi")
320    } else {
321        PathBuf::from("/etc/zoi")
322    };
323    crate::sysroot::apply_sysroot(path)
324}
325
326/// Returns the platform-native directory for system-wide Zoi cache files.
327pub fn get_system_cache_dir() -> PathBuf {
328    let path = if cfg!(target_os = "windows") {
329        PathBuf::from("C:\\ProgramData\\zoi\\cache")
330    } else if cfg!(target_os = "macos") {
331        PathBuf::from("/Library/Caches/zoi")
332    } else {
333        PathBuf::from("/var/cache/zoi")
334    };
335    crate::sysroot::apply_sysroot(path)
336}
337
338/// Returns the root directory for the package database.
339///
340/// # Errors
341///
342/// Returns an error if the home directory cannot be determined.
343pub fn get_db_root() -> Result<std::path::PathBuf> {
344    if let Ok(path) = std::env::var("ZOI_DB_DIR") {
345        return Ok(std::path::PathBuf::from(path));
346    }
347    Ok(get_user_data_dir()?.join("pkgs").join("db"))
348}
349
350/// Returns the root directory of the package store for a given scope.
351///
352/// Store Locations:
353/// - `User`: `$XDG_DATA_HOME/zoi/pkgs/store/`, or the native fallback.
354/// - `System`: `/var/lib/zoi/pkgs/store/` (Linux) or
355///   `C:\ProgramData\zoi\pkgs\store` (Windows)
356/// - `Project`: `./.zoi/pkgs/store/` (Relative to current project root)
357///
358/// # Errors
359///
360/// Returns an error if the home directory or current directory cannot be
361/// determined.
362pub fn get_store_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
363    match scope {
364        crate::types::Scope::User => {
365            Ok(get_user_data_dir()?.join("pkgs").join("store"))
366        }
367        crate::types::Scope::System => {
368            Ok(get_system_data_dir().join("pkgs").join("store"))
369        }
370        crate::types::Scope::Project => {
371            let current_dir = std::env::current_dir()?;
372            Ok(current_dir.join(".zoi").join("pkgs").join("store"))
373        }
374    }
375}
376
377/// Returns the root directory of the package database for a given scope.
378///
379/// # Errors
380///
381/// Returns an error if the home directory or current directory cannot be
382/// determined.
383pub fn get_db_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
384    match scope {
385        crate::types::Scope::User => {
386            Ok(get_user_data_dir()?.join("pkgs").join("db"))
387        }
388        crate::types::Scope::System => {
389            Ok(get_system_data_dir().join("pkgs").join("db"))
390        }
391        crate::types::Scope::Project => {
392            let current_dir = std::env::current_dir()?;
393            Ok(current_dir.join(".zoi").join("pkgs").join("db"))
394        }
395    }
396}
397
398/// Returns the root directory for Git repositories for a given scope.
399///
400/// # Errors
401///
402/// Returns an error if the home directory or current directory cannot be
403/// determined.
404pub fn get_git_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
405    match scope {
406        crate::types::Scope::User => {
407            Ok(get_user_data_dir()?.join("pkgs").join("git"))
408        }
409        crate::types::Scope::System => {
410            Ok(get_system_data_dir().join("pkgs").join("git"))
411        }
412        crate::types::Scope::Project => {
413            let current_dir = std::env::current_dir()?;
414            Ok(current_dir.join(".zoi").join("pkgs").join("git"))
415        }
416    }
417}
418
419/// Generates a unique, origin-aware ID for a package.
420///
421/// This ID prevents collisions between packages with the same name that reside
422/// in different registries or repository tiers.
423///
424/// ID Format: `#{registry-handle}@{repo-path}/{package-name}`
425/// Hashed Result: First 32 characters of the SHA-512 hash of the ID string.
426pub fn generate_package_id(
427    registry_handle: &str,
428    repo_path: &str,
429    package_name: &str
430) -> String {
431    let format_string =
432        format!("#{registry_handle}@{repo_path}/{package_name}");
433    let mut hasher = Sha512::new();
434    hasher.update(format_string.as_bytes());
435    let result = hasher.finalize();
436    let hex_string = hex::encode(result);
437    hex_string[..32].to_string()
438}
439
440/// Generates a unique ID for a package including its version.
441pub fn generate_versioned_package_id(
442    registry_handle: &str,
443    repo_path: &str,
444    package_name: &str,
445    version: &str
446) -> String {
447    let format_string =
448        format!("#{registry_handle}@{repo_path}/{package_name}@{version}");
449    let mut hasher = Sha512::new();
450    hasher.update(format_string.as_bytes());
451    let result = hasher.finalize();
452    let hex_string = hex::encode(result);
453    hex_string[..32].to_string()
454}
455
456/// Creates the directory name for the package in the store.
457/// Format: `{hash}-{name}`
458pub fn get_package_dir_name(package_id: &str, package_name: &str) -> String {
459    format!("{package_id}-{package_name}")
460}
461
462/// Recursively copies all files and subdirectories from source to destination.
463///
464/// # Errors
465///
466/// Returns an error if the directory creation or file copy operation fails.
467pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
468    let src = if src.as_os_str().is_empty() {
469        Path::new(".")
470    } else {
471        src
472    };
473    fs::create_dir_all(dst)?;
474    for entry in fs::read_dir(src)? {
475        let entry = entry?;
476        let ty = entry.file_type()?;
477        if ty.is_dir() {
478            copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
479        } else if ty.is_symlink() {
480            let target = fs::read_link(entry.path())?;
481            let destination = dst.join(entry.file_name());
482            #[cfg(unix)]
483            std::os::unix::fs::symlink(target, destination)?;
484            #[cfg(windows)]
485            {
486                if target.is_dir() {
487                    std::os::windows::fs::symlink_dir(target, destination)?;
488                } else {
489                    std::os::windows::fs::symlink_file(target, destination)?;
490                }
491            }
492        } else {
493            fs::copy(entry.path(), dst.join(entry.file_name()))?;
494        }
495    }
496    Ok(())
497}
498
499/// Performs a jittered exponential backoff sleep.
500///
501/// Used during network retries to prevent thundering herd problems and
502/// improve reliability on unstable connections.
503pub fn retry_backoff_sleep(attempt: u32) {
504    let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
505    let jitter = u64::from(
506        std::time::SystemTime::now()
507            .duration_since(std::time::UNIX_EPOCH)
508            .unwrap_or(Duration::from_secs(0))
509            .subsec_millis()
510            % 200
511    );
512    let sleep_ms = (base_ms + jitter).min(8000);
513    std::thread::sleep(Duration::from_millis(sleep_ms));
514}
515
516/// Retrieves information about the current Linux distribution from
517/// /etc/os-release.
518pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
519    let path = crate::sysroot::apply_sysroot("/etc/os-release");
520    if let Ok(contents) = fs::read_to_string(path) {
521        let info: HashMap<String, String> = contents
522            .lines()
523            .filter_map(|line| {
524                let mut parts = line.splitn(2, '=');
525                let key = parts.next()?;
526                let value = parts.next()?.trim_matches('"').to_string();
527                if key.is_empty() {
528                    None
529                } else {
530                    Some((key.to_string(), value))
531                }
532            })
533            .collect();
534        if info.is_empty() { None } else { Some(info) }
535    } else {
536        None
537    }
538}
539
540/// Detects the general family of a Linux distribution (e.g. "debian", "arch",
541/// "fedora").
542///
543/// This is used to map specific distributions to their primary package manager
544/// and standard filesystem locations.
545///
546/// Strategy:
547/// - `ID_LIKE` Check: We first check the `ID_LIKE` field in `/etc/os-release`.
548///   This is the most reliable way to identify derivatives (e.g. Ubuntu is
549///   `debian`).
550/// - Direct ID Match: If `ID_LIKE` is missing, we fall back to the primary
551///   `ID`.
552/// - Normalization: We group similar distros under a common "family" key to
553///   simplify downstream logic (e.g. Rocky, Alma, and `CentOS` all map to
554///   `fedora` because they share the DNF/RPM ecosystem).
555pub fn get_linux_distro_family() -> Option<String> {
556    if is_zoios() {
557        return Some("zoios".to_string());
558    }
559    if let Some(info) = get_linux_distribution_info() {
560        if let Some(id_like) = info.get("ID_LIKE") {
561            let families: Vec<&str> = id_like.split_whitespace().collect();
562            if families.contains(&"debian") {
563                return Some("debian".to_string());
564            }
565            if families.contains(&"arch") {
566                return Some("arch".to_string());
567            }
568            if families.contains(&"fedora") {
569                return Some("fedora".to_string());
570            }
571            if families.contains(&"rhel") {
572                return Some("fedora".to_string());
573            }
574            if families.contains(&"suse") {
575                return Some("suse".to_string());
576            }
577            if families.contains(&"gentoo") {
578                return Some("gentoo".to_string());
579            }
580        }
581        if let Some(id) = info.get("ID") {
582            return match id.as_str() {
583                "debian" | "ubuntu" | "linuxmint" | "pop" | "kali"
584                | "kubuntu" | "lubuntu" | "xubuntu" | "zorin"
585                | "elementary" => Some("debian".to_string()),
586                "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
587                    Some("arch".to_string())
588                }
589                "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => {
590                    Some("fedora".to_string())
591                }
592                "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => {
593                    Some("suse".to_string())
594                }
595                "gentoo" => Some("gentoo".to_string()),
596                "alpine" => Some("alpine".to_string()),
597                "void" => Some("void".to_string()),
598                "solus" => Some("solus".to_string()),
599                "guix" => Some("guix".to_string()),
600                _ => None
601            };
602        }
603    }
604    None
605}
606
607/// Returns the ID of the current Linux distribution (e.g. "debian", "fedora").
608pub fn get_linux_distribution() -> Option<String> {
609    get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
610}
611
612/// Returns true if the current system is a ZoiOS-based distribution (like
613/// Parlex).
614pub fn is_zoios() -> bool {
615    if let Some(info) = get_linux_distribution_info() {
616        if let Some(id) = info.get("ID")
617            && (id == "zoios" || id == "parlex")
618        {
619            return true;
620        }
621        if let Some(id_like) = info.get("ID_LIKE")
622            && id_like.split_whitespace().any(|s| s == "zoios")
623        {
624            return true;
625        }
626    }
627    false
628}
629
630/// Resolves the default installation scope based on the current environment.
631pub fn resolve_fallback_scope() -> crate::types::Scope {
632    if std::path::Path::new("zoi.lua").exists() {
633        crate::types::Scope::Project
634    } else if is_zoios() {
635        crate::types::Scope::System
636    } else {
637        crate::types::Scope::User
638    }
639}
640
641/// Returns the name of the current desktop environment (e.g. "gnome", "kde").
642pub fn get_desktop_environment() -> Option<String> {
643    if cfg!(target_os = "windows") {
644        return Some("windows".to_string());
645    }
646    if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
647        && !de.is_empty()
648    {
649        return Some(de.to_lowercase());
650    }
651    if let Ok(ds) = std::env::var("DESKTOP_SESSION")
652        && !ds.is_empty()
653    {
654        return Some(ds.to_lowercase());
655    }
656    None
657}
658
659/// Returns the name of the current display server (e.g. "wayland", "x11").
660pub fn get_display_server() -> Option<String> {
661    if cfg!(target_os = "windows") {
662        return Some("windows".to_string());
663    }
664    if cfg!(target_os = "macos") {
665        return Some("quartz".to_string());
666    }
667    if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
668        && !st.is_empty()
669    {
670        return Some(st.to_lowercase());
671    }
672    None
673}
674
675/// Returns the version of the operating system kernel.
676pub fn get_kernel_version() -> Option<String> {
677    if cfg!(unix) {
678        let output = Command::new("uname").arg("-r").output().ok()?;
679        if output.status.success() {
680            return Some(
681                String::from_utf8_lossy(&output.stdout).trim().to_string()
682            );
683        }
684    } else if cfg!(target_os = "windows") {
685        let output = Command::new("pwsh")
686            .arg("-Command")
687            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
688            .output()
689            .ok()?;
690        if output.status.success() {
691            return Some(
692                String::from_utf8_lossy(&output.stdout).trim().to_string()
693            );
694        }
695    }
696    None
697}
698
699/// Returns the name of the system's init system (e.g. "systemd", "openrc").
700pub fn get_init_system() -> Option<String> {
701    if let Ok(val) = std::env::var("ZOI_INIT") {
702        return Some(val.to_lowercase());
703    }
704
705    let run_systemd =
706        crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
707    if run_systemd.exists() {
708        return Some("systemd".to_string());
709    }
710
711    let run_openrc =
712        crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
713    if run_openrc.exists() {
714        return Some("openrc".to_string());
715    }
716
717    let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
718    if let Ok(target) = std::fs::read_link(&sbin_init) {
719        let target_str = target.to_string_lossy();
720        if target_str.contains("systemd") {
721            return Some("systemd".to_string());
722        } else if target_str.contains("openrc") {
723            return Some("openrc".to_string());
724        } else if target_str.contains("busybox") {
725            return Some("busybox".to_string());
726        }
727    }
728
729    None
730}
731
732/// Returns the name of the available privilege escalation tool (e.g. "sudo",
733/// "doas").
734pub fn get_privilege_escalator() -> Option<String> {
735    if command_exists("sudo") {
736        Some("sudo".to_string())
737    } else if command_exists("doas") {
738        Some("doas".to_string())
739    } else {
740        None
741    }
742}
743
744/// Returns the version of the current distribution.
745pub fn get_distro_version() -> Option<String> {
746    if let Some(info) = get_linux_distribution_info()
747        && let Some(vid) = info.get("VERSION_ID")
748    {
749        return Some(vid.clone());
750    }
751    if cfg!(target_os = "macos") {
752        let output = Command::new("sw_vers")
753            .arg("-productVersion")
754            .output()
755            .ok()?;
756        if output.status.success() {
757            return Some(
758                String::from_utf8_lossy(&output.stdout).trim().to_string()
759            );
760        }
761    } else if cfg!(target_os = "windows") {
762        let output = Command::new("pwsh")
763            .arg("-Command")
764            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
765            .output()
766            .ok()?;
767        if output.status.success() {
768            return Some(
769                String::from_utf8_lossy(&output.stdout).trim().to_string()
770            );
771        }
772    }
773    None
774}
775
776/// Returns a short description of the system's CPU.
777pub fn get_cpu_info() -> Option<String> {
778    if cfg!(target_os = "linux") {
779        if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
780            for line in cpuinfo.lines() {
781                if line.starts_with("model name")
782                    && let Some((_, model)) = line.split_once(':')
783                {
784                    return Some(model.trim().to_string());
785                }
786            }
787        }
788    } else if cfg!(target_os = "macos") {
789        let output = Command::new("sysctl")
790            .arg("-n")
791            .arg("machdep.cpu.brand_string")
792            .output()
793            .ok()?;
794        if output.status.success() {
795            return Some(
796                String::from_utf8_lossy(&output.stdout).trim().to_string()
797            );
798        }
799    } else if cfg!(target_os = "windows") {
800        let output = Command::new("pwsh")
801            .arg("-Command")
802            .arg("(Get-CimInstance Win32_Processor).Name")
803            .output()
804            .ok()?;
805        if output.status.success() {
806            return Some(
807                String::from_utf8_lossy(&output.stdout).trim().to_string()
808            );
809        }
810    }
811    None
812}
813
814/// Returns a short description of the system's GPU.
815pub fn get_gpu_info() -> Option<String> {
816    if cfg!(target_os = "linux") {
817        if let Ok(output) = Command::new("lspci").output()
818            && output.status.success()
819        {
820            let stdout = String::from_utf8_lossy(&output.stdout);
821            for line in stdout.lines() {
822                if (line.contains("VGA compatible controller")
823                    || line.contains("3D controller"))
824                    && let Some((_, model)) = line.split_once(": ")
825                {
826                    return Some(model.trim().to_string());
827                }
828            }
829        }
830    } else if cfg!(target_os = "macos") {
831        let output = Command::new("system_profiler")
832            .arg("SPDisplaysDataType")
833            .output()
834            .ok()?;
835        if output.status.success() {
836            let stdout = String::from_utf8_lossy(&output.stdout);
837            for line in stdout.lines() {
838                if line.trim().starts_with("Chipset Model:")
839                    && let Some((_, model)) = line.split_once(':')
840                {
841                    return Some(model.trim().to_string());
842                }
843            }
844        }
845    } else if cfg!(target_os = "windows") {
846        let output = Command::new("pwsh")
847            .arg("-Command")
848            .arg("(Get-CimInstance Win32_VideoController).Name")
849            .output()
850            .ok()?;
851        if output.status.success() {
852            return Some(
853                String::from_utf8_lossy(&output.stdout).trim().to_string()
854            );
855        }
856    }
857    None
858}
859
860/// Identifies the primary package manager for the current operating system.
861///
862/// This is used to resolve `native:` dependencies.
863pub fn get_native_package_manager() -> Option<String> {
864    let os = std::env::consts::OS;
865    match os {
866        "linux" => get_linux_distro_family()
867            .map(|family| {
868                match family.as_str() {
869                    "debian" => "apt",
870                    "arch" => "pacman",
871                    "fedora" => "dnf",
872                    "suse" => "zypper",
873                    "gentoo" => "portage",
874                    "alpine" => "apk",
875                    "void" => "xbps-install",
876                    "solus" => "eopkg",
877                    "guix" => "guix",
878                    "zoios" => "zoi",
879                    _ => "unknown"
880                }
881                .to_string()
882            })
883            .filter(|s| s != "unknown"),
884        "macos" => {
885            if command_exists("brew") {
886                Some("brew".to_string())
887            } else if command_exists("port") {
888                Some("macports".to_string())
889            } else {
890                None
891            }
892        }
893        "windows" => {
894            if command_exists("scoop") {
895                Some("scoop".to_string())
896            } else if command_exists("choco") {
897                Some("choco".to_string())
898            } else if command_exists("winget") {
899                Some("winget".to_string())
900            } else {
901                None
902            }
903        }
904        _ => None
905    }
906}
907
908/// Scans the system for all supported package managers.
909///
910/// This provides the list of available managers shown in `zoi info` and
911/// used to validate `manager:` prefixes in dependency strings.
912pub fn get_all_available_package_managers() -> Vec<String> {
913    let mut managers = Vec::new();
914    let all_possible_managers = [
915        "apt",
916        "pacman",
917        "yay",
918        "paru",
919        "pikaur",
920        "trizen",
921        "dnf",
922        "yum",
923        "zypper",
924        "portage",
925        "apk",
926        "snap",
927        "flatpak",
928        "nix",
929        "brew",
930        "port",
931        "scoop",
932        "choco",
933        "winget",
934        "pkg",
935        "pkg_add",
936        "xbps-install",
937        "eopkg",
938        "guix",
939        "mas"
940    ];
941
942    for manager in &all_possible_managers {
943        if command_exists(manager) {
944            managers.push(manager.to_string());
945        }
946    }
947    managers.sort();
948    managers.dedup();
949    managers
950}
951
952/// Formats a byte count into a human-readable string (e.g. "1.24 MiB").
953pub fn format_bytes(bytes: u64) -> String {
954    const KIB: u64 = 1024;
955    const MIB: u64 = 1024 * KIB;
956    const GIB: u64 = 1024 * MIB;
957    if bytes >= GIB {
958        format!("{:.2} GiB", bytes as f64 / GIB as f64)
959    } else if bytes >= MIB {
960        format!("{:.2} MiB", bytes as f64 / MIB as f64)
961    } else if bytes >= KIB {
962        format!("{:.2} KiB", bytes as f64 / KIB as f64)
963    } else {
964        format!("{bytes} B")
965    }
966}
967
968/// Formats a size difference into a signed human-readable string (e.g. "+50
969/// B").
970pub fn format_size_diff(diff: i64) -> String {
971    if diff == 0 {
972        return "0 B".to_string();
973    }
974    let sign = if diff > 0 { "+" } else { "-" };
975    let bytes = diff.unsigned_abs();
976    format!("{}{}", sign, format_bytes(bytes))
977}
978
979/// Verifies that a given path is "Safe" and doesn't attempt to escape the base
980/// directory.
981///
982/// This is a critical security check against "Path Traversal" attacks in
983/// package archives or Lua scripts.
984pub fn is_safe_path(base: &Path, path: &Path) -> bool {
985    let base_exists = base.exists();
986    let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
987    if path
988        .components()
989        .any(|component| matches!(component, std::path::Component::ParentDir))
990    {
991        return false;
992    }
993
994    let joined = if path.is_absolute() {
995        path.to_path_buf()
996    } else {
997        base.join(path)
998    };
999    let mut normalized = PathBuf::new();
1000    for component in joined.components() {
1001        match component {
1002            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
1003                normalized.push(component);
1004            }
1005            std::path::Component::CurDir => {}
1006            std::path::Component::Normal(p) => normalized.push(p),
1007            std::path::Component::ParentDir => return false
1008        }
1009    }
1010    if !normalized.starts_with(&base) {
1011        return false;
1012    }
1013
1014    if !base_exists {
1015        return true;
1016    }
1017
1018    let mut existing = joined.as_path();
1019    loop {
1020        if existing.exists() || existing.is_symlink() {
1021            return existing
1022                .canonicalize()
1023                .is_ok_and(|resolved| resolved.starts_with(&base));
1024        }
1025        let Some(parent) = existing.parent() else {
1026            return false;
1027        };
1028        existing = parent;
1029    }
1030}
1031
1032/// Joins a relative path to a base after validating the path.
1033///
1034/// # Errors
1035///
1036/// Returns an error if `relative` is absolute, contains parent components, or
1037/// would resolve outside `base`.
1038pub fn safe_join(base: &Path, relative: &Path) -> Result<PathBuf> {
1039    if relative.is_absolute()
1040        || relative.components().any(|component| {
1041            matches!(
1042                component,
1043                std::path::Component::ParentDir
1044                    | std::path::Component::Prefix(_)
1045                    | std::path::Component::RootDir
1046            )
1047        })
1048        || !is_safe_path(base, relative)
1049    {
1050        return Err(anyhow!(
1051            "Path escapes its base directory: {}",
1052            relative.display()
1053        ));
1054    }
1055    Ok(base.join(relative))
1056}
1057
1058/// Validates a value used as one filesystem path component.
1059///
1060/// # Errors
1061///
1062/// Returns an error if the value is empty, a dot component, or contains a path
1063/// separator or NUL byte.
1064pub fn validate_path_component(value: &str) -> Result<()> {
1065    if value.is_empty()
1066        || value == "."
1067        || value == ".."
1068        || value.contains('/')
1069        || value.contains('\\')
1070        || value.as_bytes().contains(&0)
1071    {
1072        return Err(anyhow!("Invalid path component: {value}"));
1073    }
1074    Ok(())
1075}
1076
1077/// Reads a file only when its resolved path remains inside `base`.
1078///
1079/// # Errors
1080///
1081/// Returns an error if either path cannot be canonicalized, the file is outside
1082/// `base`, or the file cannot be read.
1083pub fn read_file_within(base: &Path, path: &Path) -> Result<String> {
1084    let base = base.canonicalize()?;
1085    let path = path.canonicalize()?;
1086    if !path.starts_with(&base) {
1087        return Err(anyhow!(
1088            "Path escapes its base directory: {}",
1089            path.display()
1090        ));
1091    }
1092    Ok(fs::read_to_string(path)?)
1093}
1094
1095/// Creates a symbolic link for a file, handling platform-specific requirements.
1096///
1097/// # Errors
1098///
1099/// Returns an error if the symlink operation fails.
1100pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
1101    if link.exists() || link.is_symlink() {
1102        fs::remove_file(link)?;
1103    }
1104    #[cfg(unix)]
1105    {
1106        std::os::unix::fs::symlink(target, link)
1107    }
1108    #[cfg(windows)]
1109    {
1110        if std::os::windows::fs::symlink_file(target, link).is_err() {
1111            if fs::hard_link(target, link).is_err() {
1112                fs::copy(target, link)?;
1113            }
1114        }
1115        Ok(())
1116    }
1117}
1118
1119/// Checks if the current process has administrative privileges.
1120pub fn is_admin() -> bool {
1121    #[cfg(unix)]
1122    {
1123        nix::unistd::getuid().is_root()
1124    }
1125    #[cfg(windows)]
1126    {
1127        false
1128    }
1129}
1130
1131/// Executes a shell command and returns an error if it fails.
1132///
1133/// # Errors
1134///
1135/// Returns an error if the command fails to execute or returns a non-zero exit
1136/// status.
1137pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
1138    let status = if cfg!(target_os = "windows") {
1139        Command::new("pwsh")
1140            .arg("-Command")
1141            .arg(command_str)
1142            .status()?
1143    } else {
1144        Command::new("bash").arg("-c").arg(command_str).status()?
1145    };
1146    if !status.success() {
1147        return Err(anyhow!("Command failed: {command_str}"));
1148    }
1149    Ok(())
1150}
1151
1152/// Executes a shell command quietly (suppressing output) and returns an error
1153/// if it fails.
1154///
1155/// # Errors
1156///
1157/// Returns an error if the command fails to execute or returns a non-zero exit
1158/// status.
1159pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
1160    let status = if cfg!(target_os = "windows") {
1161        Command::new("pwsh")
1162            .arg("-Command")
1163            .arg(command_str)
1164            .stdout(std::process::Stdio::null())
1165            .stderr(std::process::Stdio::null())
1166            .status()?
1167    } else {
1168        Command::new("bash")
1169            .arg("-c")
1170            .arg(command_str)
1171            .stdout(std::process::Stdio::null())
1172            .stderr(std::process::Stdio::null())
1173            .status()?
1174    };
1175    if !status.success() {
1176        return Err(anyhow!("Command failed: {command_str}"));
1177    }
1178    Ok(())
1179}
1180
1181/// Returns true if Zoi is running in "Mini" mode (lightweight, zero-sync).
1182pub fn is_mini_mode() -> bool {
1183    std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
1184}
1185
1186/// Prompts the user for confirmation (y/N) unless the `yes` flag is set.
1187pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
1188    if yes {
1189        return true;
1190    }
1191    if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
1192        return false;
1193    }
1194    print!("{prompt} [y/N]: ");
1195    let _ = stdout().flush();
1196    let mut input = String::new();
1197    if stdin().read_line(&mut input).is_err() {
1198        return false;
1199    }
1200    input.trim().eq_ignore_ascii_case("y")
1201}
1202
1203/// Recursively sets a directory and its contents to be read-only.
1204///
1205/// # Errors
1206///
1207/// Returns an error if the permission change fails.
1208pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
1209    if !path.exists() {
1210        return Ok(());
1211    }
1212    for entry in walkdir::WalkDir::new(path) {
1213        let entry = entry?;
1214        let mut perms = fs::metadata(entry.path())?.permissions();
1215        if !perms.readonly() {
1216            perms.set_readonly(true);
1217            fs::set_permissions(entry.path(), perms)?;
1218        }
1219    }
1220    Ok(())
1221}
1222
1223/// Recursively ensures a directory and its contents are writable.
1224///
1225/// # Errors
1226///
1227/// Returns an error if the permission change fails.
1228pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1229    if !path.exists() {
1230        return Ok(());
1231    }
1232    for entry in walkdir::WalkDir::new(path) {
1233        let entry = entry?;
1234        let mut perms = fs::metadata(entry.path())?.permissions();
1235        if perms.readonly() {
1236            #[cfg(unix)]
1237            {
1238                use std::os::unix::fs::PermissionsExt;
1239                let mode = perms.mode();
1240                perms.set_mode(mode | 0o200);
1241            }
1242            #[cfg(not(unix))]
1243            {
1244                perms.set_readonly(false);
1245            }
1246            fs::set_permissions(entry.path(), perms)?;
1247        }
1248    }
1249    Ok(())
1250}
1251
1252/// Sets the owner and group for a file or directory (Unix only).
1253///
1254/// # Errors
1255///
1256/// Returns an error if the owner or group lookup fails, or if the chown
1257/// operation fails.
1258#[cfg(unix)]
1259pub fn set_path_owner(
1260    path: &Path,
1261    owner: &str,
1262    group: &str
1263) -> anyhow::Result<()> {
1264    use nix::unistd::{Gid, Group, Uid, User, chown};
1265    let uid = if let Ok(u) = owner.parse::<u32>() {
1266        Some(Uid::from_raw(u))
1267    } else if !owner.is_empty() {
1268        Some(
1269            User::from_name(owner)
1270                .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1271                .ok_or_else(|| anyhow!("User not found: {owner}"))?
1272                .uid
1273        )
1274    } else {
1275        None
1276    };
1277    let gid = if let Ok(g) = group.parse::<u32>() {
1278        Some(Gid::from_raw(g))
1279    } else if !group.is_empty() {
1280        Some(
1281            Group::from_name(group)
1282                .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1283                .ok_or_else(|| anyhow!("Group not found: {group}"))?
1284                .gid
1285        )
1286    } else {
1287        None
1288    };
1289    chown(path, uid, gid)
1290        .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1291    Ok(())
1292}
1293
1294/// Checks if a target platform is compatible with a list of allowed platforms.
1295pub fn is_platform_compatible(
1296    current_platform: &str,
1297    allowed_platforms: &[String]
1298) -> bool {
1299    let os_part = current_platform
1300        .split('-')
1301        .next()
1302        .unwrap_or(current_platform);
1303    let os = match os_part {
1304        "darwin" => "macos",
1305        other => other
1306    };
1307    allowed_platforms.iter().any(|p| {
1308        if let Some(rest) = p.strip_prefix("ci:") {
1309            let target = rest.split(':').next().unwrap_or_default();
1310            target == current_platform || target == os
1311        } else {
1312            let p_norm = if p == "darwin" { "macos" } else { p };
1313            p_norm == "all" || p_norm == os || p_norm == current_platform
1314        }
1315    })
1316}
1317
1318/// Validates a package license against SPDX and organizational policies.
1319pub fn check_license(license: &str) {
1320    if license.is_empty() || license.eq_ignore_ascii_case("None") {
1321        return;
1322    }
1323    if license.eq_ignore_ascii_case("Proprietary")
1324        || license.eq_ignore_ascii_case("Unknown")
1325    {
1326        return;
1327    }
1328    if is_external_license_link(license) {
1329        println!(
1330            "{} The license is available at: {}",
1331            "Warning:".yellow(),
1332            license.cyan().bold()
1333        );
1334        return;
1335    }
1336    match spdx::Expression::parse(license) {
1337        Ok(expr) => {
1338            if !expr.evaluate(|req| match req.license {
1339                spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1340                spdx::LicenseItem::Other { .. } => false
1341            }) {
1342                println!(
1343                    "{} License '{}' is not an OSI approved license.",
1344                    "Warning:".yellow(),
1345                    license.yellow().bold()
1346                );
1347            }
1348        }
1349        Err(_) => {
1350            println!(
1351                "{} Could not parse license expression '{}'. It may not be a \
1352                 valid SPDX identifier.",
1353                "Warning:".yellow(),
1354                license.yellow().bold()
1355            );
1356        }
1357    }
1358}
1359
1360/// Returns `true` if the license value is an external link (a URL) pointing to
1361/// the license text hosted elsewhere instead of an SPDX identifier.
1362fn is_external_license_link(license: &str) -> bool {
1363    let trimmed = license.trim();
1364    trimmed.starts_with("http://") || trimmed.starts_with("https://")
1365}
1366
1367/// Prompts the user to confirm installation from an untrusted (non-official)
1368/// source.
1369///
1370/// # Errors
1371///
1372/// Returns an error if the user aborts the operation.
1373pub fn confirm_untrusted_source(
1374    source_type: &crate::types::SourceType,
1375    yes: bool
1376) -> anyhow::Result<()> {
1377    if is_mini_mode() {
1378        return Ok(());
1379    }
1380    if source_type == &crate::types::SourceType::OfficialRepo {
1381        return Ok(());
1382    }
1383    let warning_message = match source_type {
1384        crate::types::SourceType::UntrustedRepo(repo) => {
1385            format!(
1386                "The package from repository '@{repo}' is not an official Zoi \
1387                 repository."
1388            )
1389        }
1390        crate::types::SourceType::LocalFile => {
1391            "You are installing from a local file.".to_string()
1392        }
1393        crate::types::SourceType::Url => {
1394            "You are installing from a remote URL. This script will be \
1395             executed with your user's permissions, which could lead to remote \
1396             code execution if the source is malicious."
1397                .to_string()
1398        }
1399        crate::types::SourceType::GitRepo(repo) => format!(
1400            "You are installing from an external git repository '{repo}'. \
1401             This script will be executed with your user's permissions."
1402        ),
1403        crate::types::SourceType::OfficialRepo => return Ok(())
1404    };
1405    println!(
1406        "\n{}: {}",
1407        "SECURITY WARNING".yellow().bold(),
1408        warning_message
1409    );
1410    if ask_for_confirmation(
1411        "This source is not trusted. Are you sure you want to continue?",
1412        yes
1413    ) {
1414        Ok(())
1415    } else {
1416        Err(anyhow!("Operation aborted by user."))
1417    }
1418}
1419
1420/// Expands standard Zoi path placeholders (e.g. `${pkgstore}`, `${usrhome}`)
1421/// in a string.
1422///
1423/// Note that `${createpkgdir}` is deliberately not expanded here: it depends
1424/// on the directory Zoi was invoked from during installation, so resolving it
1425/// at cleanup time could target an unrelated path. Pooled installations
1426/// record the absolute location for these entries instead.
1427///
1428/// # Errors
1429///
1430/// Returns an error if the current directory cannot be determined.
1431pub fn expand_placeholders(
1432    path: &str,
1433    version_dir: &Path,
1434    scope: crate::types::Scope
1435) -> Result<String> {
1436    let placeholders =
1437        ["${pkgstore}", "${usrroot}", "${usrhome}", "${applications}"];
1438    for placeholder in placeholders {
1439        if let Some(index) = path.find(placeholder) {
1440            if index != 0 || path[placeholder.len()..].contains("${") {
1441                return Err(anyhow!("Invalid placeholder path: {path}"));
1442            }
1443            let suffix =
1444                path[placeholder.len()..].trim_start_matches(['/', '\\']);
1445            if Path::new(suffix).components().any(|component| {
1446                matches!(component, std::path::Component::ParentDir)
1447            }) {
1448                return Err(anyhow!(
1449                    "Placeholder path escapes its root: {path}"
1450                ));
1451            }
1452        }
1453    }
1454    if Path::new(path)
1455        .components()
1456        .any(|component| matches!(component, std::path::Component::ParentDir))
1457    {
1458        return Err(anyhow!("Path contains parent components: {path}"));
1459    }
1460
1461    let mut expanded = path.to_string();
1462    expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1463    expanded = expanded.replace(
1464        "${usrroot}",
1465        &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1466    );
1467    if let Some(home_dir) = get_user_home() {
1468        expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1469    }
1470
1471    let applications_dir = match scope {
1472        crate::types::Scope::System => PathBuf::from("/Applications"),
1473        crate::types::Scope::User => get_user_home().map_or_else(
1474            || PathBuf::from("/Applications"),
1475            |h| h.join("Applications")
1476        ),
1477        crate::types::Scope::Project => std::env::current_dir()
1478            .unwrap_or_default()
1479            .join("Applications")
1480    };
1481    expanded = expanded
1482        .replace("${applications}", &applications_dir.to_string_lossy());
1483
1484    Ok(expanded)
1485}
1486
1487/// Strips Zoi staging placeholders to sysroot-relative form for matching.
1488///
1489/// Install manifests record system files with placeholders (e.g.
1490/// `${usrroot}/usr/share/fonts/foo.ttf`), while hook triggers, file
1491/// databases, and other matchers work with sysroot-relative paths (e.g.
1492/// `usr/share/fonts/foo.ttf`). This is the canonical stripping logic shared
1493/// by all crates, it mirrors the placeholder roots used by
1494/// [`expand_placeholders`], `expand_pooled_path` in `zoi-install`, and the
1495/// staging-directory mapping in `zoi-package`, but stays scope- and
1496/// version-independent so it can be used transaction-wide:
1497///
1498/// - `${usrroot}/...` -> `...` (matches `apply_sysroot("/")` expansion,
1499///   relativized).
1500/// - `${usrhome}/...` -> `...` (stable across machines, unlike expanding to the
1501///   current home directory).
1502/// - `${pkgstore}/...` -> `pkgstore/...` (namespaced so store-internal files
1503///   never match system triggers).
1504/// - `${applications}/...` -> `...`.
1505/// - Bare staging forms (`usrroot/...`, `usrhome/...`, `pkgstore/...`) are
1506///   handled the same way for robustness.
1507/// - Everything else (including already-absolute `${createpkgdir}` records) is
1508///   returned unchanged.
1509#[must_use]
1510pub fn strip_staging_placeholder(path: &str) -> String {
1511    if let Some(rest) = path.strip_prefix("${usrroot}/") {
1512        return rest.to_string();
1513    }
1514    if let Some(rest) = path.strip_prefix("${usrroot}") {
1515        return rest.trim_start_matches('/').to_string();
1516    }
1517    if let Some(rest) = path.strip_prefix("${usrhome}/") {
1518        return rest.to_string();
1519    }
1520    if let Some(rest) = path.strip_prefix("${usrhome}") {
1521        return rest.trim_start_matches('/').to_string();
1522    }
1523    if let Some(rest) = path.strip_prefix("${pkgstore}/") {
1524        return format!("pkgstore/{rest}");
1525    }
1526    if let Some(rest) = path.strip_prefix("${pkgstore}") {
1527        return format!("pkgstore/{}", rest.trim_start_matches('/'));
1528    }
1529    if let Some(rest) = path.strip_prefix("${applications}/") {
1530        return rest.to_string();
1531    }
1532    if let Some(rest) = path.strip_prefix("${applications}") {
1533        return rest.trim_start_matches('/').to_string();
1534    }
1535    if let Some(rest) = path.strip_prefix("usrroot/") {
1536        return rest.to_string();
1537    }
1538    if let Some(rest) = path.strip_prefix("usrhome/") {
1539        return rest.to_string();
1540    }
1541    path.to_string()
1542}
1543
1544/// Expands only the location-roots (`${usrroot}`, `${usrhome}`) using the
1545/// same locations as [`expand_placeholders`].
1546///
1547/// Unlike [`expand_placeholders`], this needs neither a version directory
1548/// nor a scope, so it can be used transaction-wide when matching hook
1549/// triggers against installed files. `${pkgstore}`, `${applications}`,
1550/// `${createpkgdir}`, and `${pkgluadir}` are left untouched: the store needs
1551/// a per-package version directory to resolve, and the others are
1552/// scope-dependent.
1553#[must_use]
1554pub fn expand_staging_root_placeholders(path: &str) -> String {
1555    let mut expanded = path.to_string();
1556    expanded = expanded.replace(
1557        "${usrroot}",
1558        &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1559    );
1560    if let Some(home_dir) = get_user_home() {
1561        expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1562    }
1563    expanded
1564}
1565
1566/// Expands the `~` character to the user's home directory.
1567pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1568    let path = path.as_ref();
1569    if !path.starts_with("~") {
1570        return path.to_path_buf();
1571    }
1572    if let Some(home_dir) = get_user_home() {
1573        if path == Path::new("~") {
1574            return home_dir;
1575        }
1576        if let Ok(stripped) = path.strip_prefix("~/") {
1577            return home_dir.join(stripped);
1578        }
1579    }
1580    path.to_path_buf()
1581}
1582
1583/// Detects and returns the current shell being used.
1584pub fn get_current_shell() -> Option<Shell> {
1585    if cfg!(windows) {
1586        return Some(Shell::PowerShell);
1587    }
1588    if let Ok(shell_path) = std::env::var("SHELL") {
1589        let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1590        match shell_name {
1591            "bash" => Some(Shell::Bash),
1592            "zsh" => Some(Shell::Zsh),
1593            "fish" => Some(Shell::Fish),
1594            "elvish" => Some(Shell::Elvish),
1595            "pwsh" => Some(Shell::PowerShell),
1596            _ => None
1597        }
1598    } else {
1599        None
1600    }
1601}
1602
1603/// The shell program names Zoi is willing to execute on the user's behalf.
1604///
1605/// `SHELL` is an environment variable, so it is attacker-influenced whenever
1606/// Zoi runs inside an environment it does not control: a CI job with a polluted
1607/// environment, a package post-install hook, or a container entrypoint. Passing
1608/// that value straight to `Command::new` turns the variable into an arbitrary
1609/// program-execution primitive, which is exactly what the command-injection
1610/// scanners flag. Restricting the executable to a known set of shells means the
1611/// worst an attacker achieves is picking *which* shell runs, not *what* runs.
1612const TRUSTED_SHELLS: &[&str] = &[
1613    "bash",
1614    "sh",
1615    "dash",
1616    "zsh",
1617    "fish",
1618    "elvish",
1619    "ksh",
1620    "mksh",
1621    "ash",
1622    "pwsh",
1623    "powershell",
1624    "nu"
1625];
1626
1627/// Resolves the shell binary to launch for interactive sessions.
1628///
1629/// The value of `SHELL` is used when it names one of [`TRUSTED_SHELLS`],
1630/// otherwise the platform default is returned. Only the *file name* is
1631/// inspected: an absolute path such as `/bin/zsh` is fine, but
1632/// `/tmp/evil/payload` is not a shell and must never be executed.
1633///
1634/// # Errors
1635///
1636/// This function currently cannot fail; the `Result` is kept so callers can
1637/// treat shell resolution as fallible if the trusted set ever needs to load
1638/// from configuration.
1639pub fn resolve_shell_binary() -> Result<String> {
1640    Ok(trusted_shell_name(std::env::var("SHELL").ok().as_deref()))
1641}
1642
1643/// The pure half of [`resolve_shell_binary`], split out so it can be tested
1644/// without mutating the process-wide `SHELL` variable (which would race with
1645/// every other test in the binary).
1646fn trusted_shell_name(shell_path: Option<&str>) -> String {
1647    let default = if cfg!(windows) { "pwsh" } else { "bash" };
1648
1649    let Some(shell_path) = shell_path else {
1650        return default.to_string();
1651    };
1652
1653    // `file_name` rather than the raw value, so `/bin/bash` matches while
1654    // `bash; rm -rf /` does not.
1655    let file_name = Path::new(shell_path).file_name().and_then(|n| n.to_str());
1656
1657    match file_name {
1658        Some(name) if TRUSTED_SHELLS.contains(&name) => name.to_string(),
1659        // `SHELL` is unset, empty, or names something we do not trust. Falling
1660        // back to the default is the safe outcome: the user still gets a
1661        // shell, just not the one they may have been tricked into asking for.
1662        _ => default.to_string()
1663    }
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668    use super::*;
1669
1670    #[test]
1671    fn detects_http_and_https_external_license_links() {
1672        assert!(is_external_license_link("https://example.com/LICENSE"));
1673        assert!(is_external_license_link("http://example.com/license"));
1674        assert!(is_external_license_link("  https://example.com/MIT.txt  "));
1675    }
1676
1677    #[test]
1678    fn accepts_trusted_shell_paths() {
1679        // An absolute path is fine as long as the final component is a shell.
1680        assert_eq!(trusted_shell_name(Some("/bin/bash")), "bash");
1681        assert_eq!(trusted_shell_name(Some("/usr/bin/zsh")), "zsh");
1682        assert_eq!(trusted_shell_name(Some("/usr/local/bin/fish")), "fish");
1683        assert_eq!(trusted_shell_name(Some("bash")), "bash");
1684    }
1685
1686    #[test]
1687    fn rejects_untrusted_shell_values() {
1688        let default = if cfg!(windows) { "pwsh" } else { "bash" };
1689
1690        // An arbitrary program must never be handed to Command::new.
1691        assert_eq!(trusted_shell_name(Some("/tmp/evil/payload")), default);
1692        // Shell metacharacters must not survive as a program name.
1693        assert_eq!(trusted_shell_name(Some("bash; rm -rf /")), default);
1694        assert_eq!(trusted_shell_name(Some("$(id)")), default);
1695        assert_eq!(trusted_shell_name(Some("")), default);
1696        assert_eq!(trusted_shell_name(Some("/bin/../evil")), default);
1697        // Unset falls back to the platform default.
1698        assert_eq!(trusted_shell_name(None), default);
1699    }
1700
1701    #[test]
1702    fn does_not_treat_spdx_or_special_values_as_links() {
1703        assert!(!is_external_license_link("MIT"));
1704        assert!(!is_external_license_link("Apache-2.0"));
1705        assert!(!is_external_license_link("None"));
1706        assert!(!is_external_license_link("Proprietary"));
1707        assert!(!is_external_license_link("Unknown"));
1708    }
1709
1710    #[test]
1711    fn url_license_is_flagged_before_spdx_parsing() {
1712        assert!(is_external_license_link("https://example.com/LICENSE"));
1713        assert!(
1714            spdx::Expression::parse("https://example.com/LICENSE").is_err()
1715        );
1716    }
1717
1718    #[test]
1719    fn staging_placeholder_strips_to_sysroot_relative_form() {
1720        assert_eq!(
1721            strip_staging_placeholder("${usrroot}/usr/share/fonts/foo.ttf"),
1722            "usr/share/fonts/foo.ttf"
1723        );
1724        assert_eq!(
1725            strip_staging_placeholder("${usrhome}/.local/share/fonts/foo.ttf"),
1726            ".local/share/fonts/foo.ttf"
1727        );
1728        assert_eq!(
1729            strip_staging_placeholder("${pkgstore}/bin/foo"),
1730            "pkgstore/bin/foo"
1731        );
1732        assert_eq!(
1733            strip_staging_placeholder("${applications}/MyApp.app"),
1734            "MyApp.app"
1735        );
1736        assert_eq!(
1737            strip_staging_placeholder("/usr/share/fonts/foo.ttf"),
1738            "/usr/share/fonts/foo.ttf"
1739        );
1740    }
1741
1742    #[test]
1743    fn staging_root_expansion_uses_canonical_locations() {
1744        let expanded =
1745            expand_staging_root_placeholders("${usrroot}/usr/share/fonts");
1746        assert!(
1747            expanded.ends_with("/usr/share/fonts"),
1748            "unexpected expansion: {expanded}"
1749        );
1750        // Store placeholder needs a version dir, so it stays untouched.
1751        assert_eq!(
1752            expand_staging_root_placeholders("${pkgstore}/bin/foo"),
1753            "${pkgstore}/bin/foo"
1754        );
1755    }
1756}