Skip to main content

helixforge_platform/
lib.rs

1//! Read-only Linux identification and executable detection.
2
3use helixforge_core::PackageManager;
4use std::{env, fs, path::Path};
5use thiserror::Error;
6
7#[derive(Debug, Clone, Eq, PartialEq)]
8pub struct LinuxInfo {
9    pub id: String,
10    pub pretty_name: String,
11    pub id_like: Vec<String>,
12    pub manager: Option<PackageManager>,
13}
14
15#[derive(Debug, Error)]
16pub enum PlatformError {
17    #[error("cannot read /etc/os-release: {0}")]
18    OsRelease(#[from] std::io::Error),
19    #[error("missing ID field in os-release")]
20    MissingId,
21}
22
23impl LinuxInfo {
24    pub fn detect() -> Result<Self, PlatformError> {
25        // Most Linux distributions expose this file, including Garuda.
26        let contents = fs::read_to_string("/etc/os-release")?;
27        Self::from_os_release(&contents)
28    }
29
30    pub fn from_os_release(text: &str) -> Result<Self, PlatformError> {
31        let mut id = String::new();
32        let mut pretty_name = String::new();
33        let mut id_like = Vec::new();
34        for line in text.lines() {
35            if line.trim_start().starts_with('#') {
36                continue;
37            }
38            if let Some((key, value)) = line.split_once('=') {
39                let val = value.trim().trim_matches('"').trim_matches('\'');
40                match key.trim() {
41                    "ID" => id = val.to_ascii_lowercase(),
42                    "PRETTY_NAME" => pretty_name = val.to_owned(),
43                    "ID_LIKE" => {
44                        id_like = val
45                            .split_whitespace()
46                            .map(str::to_ascii_lowercase)
47                            .collect()
48                    }
49                    _ => {}
50                }
51            }
52        }
53        if id.is_empty() {
54            return Err(PlatformError::MissingId);
55        }
56        if pretty_name.is_empty() {
57            pretty_name = id.clone();
58        }
59        let family = std::iter::once(id.as_str()).chain(id_like.iter().map(String::as_str));
60        let names: Vec<_> = family.collect();
61        let any = |set: &[&str]| names.iter().any(|n| set.contains(n));
62        let manager = if any(&["arch", "garuda", "manjaro", "endeavouros", "cachyos"]) {
63            Some(PackageManager::Arch)
64        } else if any(&["debian", "ubuntu", "linuxmint", "zorin", "pop"]) {
65            Some(PackageManager::Debian)
66        } else if any(&["fedora", "rhel", "centos"]) {
67            Some(PackageManager::Fedora)
68        } else if any(&[
69            "suse",
70            "opensuse",
71            "opensuse-tumbleweed",
72            "opensuse-leap",
73            "sles",
74        ]) {
75            Some(PackageManager::Suse)
76        } else if any(&["alpine"]) {
77            Some(PackageManager::Alpine)
78        } else if any(&["void"]) {
79            Some(PackageManager::Void)
80        } else if any(&["solus"]) {
81            Some(PackageManager::Solus)
82        } else {
83            None
84        };
85        Ok(Self {
86            id,
87            pretty_name,
88            id_like,
89            manager,
90        })
91    }
92}
93
94/// Inspect PATH without spawning a shell. Used for read-only package detection.
95pub fn binary_exists(binary: &str) -> bool {
96    if binary.is_empty() || binary.contains('/') {
97        return false;
98    }
99    let Some(paths) = env::var_os("PATH") else {
100        return false;
101    };
102    env::split_paths(&paths).any(|dir| is_executable(&dir.join(binary)))
103}
104
105/// Whether the file at `path` is an executable.
106pub fn binary_exists_at(path: &Path) -> bool {
107    is_executable(path)
108}
109
110#[cfg(unix)]
111fn is_executable(path: &Path) -> bool {
112    use std::os::unix::fs::PermissionsExt;
113    fs::metadata(path)
114        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
115        .unwrap_or(false)
116}
117
118#[cfg(not(unix))]
119fn is_executable(path: &Path) -> bool {
120    path.is_file()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn garuda_maps_to_arch() {
129        let info =
130            LinuxInfo::from_os_release("ID=garuda\nID_LIKE=arch\nPRETTY_NAME=\"Garuda Linux\"\n")
131                .unwrap();
132        assert_eq!(info.manager, Some(PackageManager::Arch));
133        assert_eq!(info.pretty_name, "Garuda Linux");
134    }
135
136    #[test]
137    fn ubuntu_maps_to_debian() {
138        let info = LinuxInfo::from_os_release("ID=ubuntu\nID_LIKE=debian\n").unwrap();
139        assert_eq!(info.manager, Some(PackageManager::Debian));
140    }
141
142    #[test]
143    fn the_other_families_are_recognised() {
144        for (text, manager) in [
145            (
146                "ID=opensuse-tumbleweed\nID_LIKE=\"opensuse suse\"\n",
147                PackageManager::Suse,
148            ),
149            ("ID=alpine\n", PackageManager::Alpine),
150            ("ID=void\n", PackageManager::Void),
151            ("ID=solus\n", PackageManager::Solus),
152        ] {
153            assert_eq!(
154                LinuxInfo::from_os_release(text).unwrap().manager,
155                Some(manager),
156                "{text}"
157            );
158        }
159    }
160
161    #[test]
162    fn unknown_os_is_read_only() {
163        let info = LinuxInfo::from_os_release("ID=experimental\n").unwrap();
164        assert_eq!(info.manager, None);
165    }
166
167    #[test]
168    fn path_injection_is_rejected() {
169        assert!(!binary_exists("/bin/sh"));
170        assert!(!binary_exists("../foo"));
171    }
172}