helixforge_platform/
lib.rs1use 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 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
94pub 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
105pub 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}