Skip to main content

rusty_stack/platform/
detection.rs

1//! Linux distribution detection and package manager identification.
2//!
3//! Parses `/etc/os-release` (primary), falls back to `/etc/lsb-release`,
4//! `/etc/arch-release`, and `/etc/debian_version`. Detects the distro family
5//! and the appropriate package manager.
6//!
7//! # Validation Assertions
8//!
9//! - **VAL-PLAT-007**: Distro detection identifies correct Linux distribution
10//! - **VAL-PLAT-008**: Package manager detection matches distro family,
11//!   `MLSTACK_PKG_MANAGER` env var overrides auto-detection
12
13use serde::{Deserialize, Serialize};
14use std::fs;
15use std::path::Path;
16
17// ===========================================================================
18// Public Types
19// ===========================================================================
20
21/// Linux distribution family classification.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum DistroFamily {
24    Debian,
25    Arch,
26    Rhel,
27    Suse,
28    Unknown,
29}
30
31impl DistroFamily {
32    /// Human-readable label.
33    pub fn label(self) -> &'static str {
34        match self {
35            DistroFamily::Debian => "debian",
36            DistroFamily::Arch => "arch",
37            DistroFamily::Rhel => "rhel",
38            DistroFamily::Suse => "suse",
39            DistroFamily::Unknown => "unknown",
40        }
41    }
42}
43
44/// Known package managers.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub enum PackageManager {
47    Apt,
48    Pacman,
49    Dnf,
50    Yum,
51    Zypper,
52    Unknown,
53}
54
55impl PackageManager {
56    /// Human-readable label.
57    pub fn label(self) -> &'static str {
58        match self {
59            PackageManager::Apt => "apt",
60            PackageManager::Pacman => "pacman",
61            PackageManager::Dnf => "dnf",
62            PackageManager::Yum => "yum",
63            PackageManager::Zypper => "zypper",
64            PackageManager::Unknown => "unknown",
65        }
66    }
67}
68
69/// Complete distribution information detected from the system.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct DistroInfo {
72    /// Distribution ID (e.g., "ubuntu", "arch", "fedora").
73    pub id: String,
74    /// Full distribution name (e.g., "Ubuntu 24.04 LTS").
75    pub name: String,
76    /// Distribution version (e.g., "24.04", "rolling", "42").
77    pub version: String,
78    /// Distribution codename (e.g., "noble", "bookworm").
79    pub codename: String,
80    /// Distribution family.
81    pub family: DistroFamily,
82    /// Detected package manager.
83    pub pkg_manager: PackageManager,
84    /// The `ID_LIKE` field from `/etc/os-release`.
85    pub id_like: String,
86}
87
88impl Default for DistroInfo {
89    fn default() -> Self {
90        Self {
91            id: "unknown".to_string(),
92            name: "Unknown Linux".to_string(),
93            version: "unknown".to_string(),
94            codename: String::new(),
95            family: DistroFamily::Unknown,
96            pkg_manager: PackageManager::Unknown,
97            id_like: String::new(),
98        }
99    }
100}
101
102// ===========================================================================
103// Public API
104// ===========================================================================
105
106/// Detect the Linux distribution by parsing system files.
107///
108/// Follows a priority chain:
109/// 1. `/etc/os-release` (standard, preferred)
110/// 2. `/etc/lsb-release` (older Ubuntu/Debian)
111/// 3. `/etc/arch-release` (Arch Linux marker file)
112/// 4. `/etc/debian_version` (Debian fallback)
113///
114/// After identifying the distribution, determines the family and package
115/// manager. The `MLSTACK_PKG_MANAGER` environment variable overrides
116/// auto-detected package manager.
117pub fn detect_distribution() -> DistroInfo {
118    let mut info = DistroInfo::default();
119
120    // Method 1: Parse /etc/os-release
121    if parse_os_release(&mut info) {
122        finalize_distro_info(&mut info);
123        return info;
124    }
125
126    // Method 2: Fallback to /etc/lsb-release
127    if parse_lsb_release(&mut info) {
128        finalize_distro_info(&mut info);
129        return info;
130    }
131
132    // Method 3: Arch Linux marker file
133    if Path::new("/etc/arch-release").exists() {
134        info.id = "arch".to_string();
135        info.name = "Arch Linux".to_string();
136        info.version = "rolling".to_string();
137        finalize_distro_info(&mut info);
138        return info;
139    }
140
141    // Method 4: Debian version file
142    if Path::new("/etc/debian_version").exists() {
143        info.id = "debian".to_string();
144        info.name = "Debian GNU/Linux".to_string();
145        if let Ok(version) = fs::read_to_string("/etc/debian_version") {
146            info.version = version.trim().to_string();
147        }
148        finalize_distro_info(&mut info);
149        return info;
150    }
151
152    // Last resort: unknown
153    finalize_distro_info(&mut info);
154    info
155}
156
157/// Detect the package manager from the distro family or environment override.
158///
159/// Checks `MLSTACK_PKG_MANAGER` env var first, then falls back to
160/// family-based detection.
161pub fn detect_package_manager(info: &DistroInfo) -> PackageManager {
162    // Environment variable override
163    if let Ok(pkg_mgr) = std::env::var("MLSTACK_PKG_MANAGER") {
164        let pkg_mgr = pkg_mgr.trim().to_lowercase();
165        if let Some(pm) = match_pkg_manager_str(&pkg_mgr) {
166            return pm;
167        }
168    }
169
170    // Family-based detection
171    match info.family {
172        DistroFamily::Debian => PackageManager::Apt,
173        DistroFamily::Arch => PackageManager::Pacman,
174        DistroFamily::Rhel => PackageManager::Dnf,
175        DistroFamily::Suse => PackageManager::Zypper,
176        DistroFamily::Unknown => PackageManager::Unknown,
177    }
178}
179
180/// Parse a key-value file (like `/etc/os-release`) into a map.
181///
182/// Handles quoted and unquoted values, comments, and blank lines.
183/// This is a public helper for testing with mock files.
184pub fn parse_key_value_file(content: &str) -> std::collections::HashMap<String, String> {
185    let mut map = std::collections::HashMap::new();
186    for line in content.lines() {
187        let line = line.trim();
188        if line.is_empty() || line.starts_with('#') {
189            continue;
190        }
191        if let Some((key, value)) = line.split_once('=') {
192            let key = key.trim().to_string();
193            let value = value
194                .trim()
195                .trim_matches('"')
196                .trim_matches('\'')
197                .to_string();
198            map.insert(key, value);
199        }
200    }
201    map
202}
203
204// ===========================================================================
205// Private Implementation
206// ===========================================================================
207
208/// Parse `/etc/os-release` and populate `DistroInfo`.
209fn parse_os_release(info: &mut DistroInfo) -> bool {
210    let content = match fs::read_to_string("/etc/os-release") {
211        Ok(c) => c,
212        Err(_) => return false,
213    };
214
215    let map = parse_key_value_file(&content);
216
217    info.id = map.get("ID").cloned().unwrap_or_default().to_lowercase();
218    info.name = map.get("NAME").cloned().unwrap_or_default();
219    info.version = map.get("VERSION_ID").cloned().unwrap_or_default();
220    info.codename = map.get("VERSION_CODENAME").cloned().unwrap_or_default();
221    info.id_like = map.get("ID_LIKE").cloned().unwrap_or_default();
222
223    // Handle rolling releases
224    if info.version.is_empty() && is_rolling_distro(&info.id) {
225        info.version = "rolling".to_string();
226    }
227
228    !info.id.is_empty()
229}
230
231/// Parse `/etc/lsb-release` and populate `DistroInfo`.
232fn parse_lsb_release(info: &mut DistroInfo) -> bool {
233    let content = match fs::read_to_string("/etc/lsb-release") {
234        Ok(c) => c,
235        Err(_) => return false,
236    };
237
238    let map = parse_key_value_file(&content);
239
240    info.id = map
241        .get("DISTRIB_ID")
242        .cloned()
243        .unwrap_or_default()
244        .to_lowercase();
245    info.name = map.get("DISTRIB_ID").cloned().unwrap_or_default();
246    info.version = map.get("DISTRIB_RELEASE").cloned().unwrap_or_default();
247    info.codename = map.get("DISTRIB_CODENAME").cloned().unwrap_or_default();
248
249    !info.id.is_empty()
250}
251
252/// Determine the distro family from the distro ID and ID_LIKE fields.
253fn classify_distro_family(id: &str, id_like: &str) -> DistroFamily {
254    let id_lower = id.to_lowercase();
255    let id_like_lower = id_like.to_lowercase();
256
257    // Direct ID matches
258    match id_lower.as_str() {
259        "debian" | "ubuntu" | "linuxmint" | "pop" | "elementary" | "kali" | "mx" | "devuan"
260        | "raspbian" => return DistroFamily::Debian,
261        "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" | "arco" | "artix"
262        | "rebornos" => return DistroFamily::Arch,
263        "fedora" | "rhel" | "centos" | "rocky" | "almalinux" | "ol" | "oracle" | "scientific"
264        | "centos-stream" => return DistroFamily::Rhel,
265        "opensuse-leap" | "opensuse-tumbleweed" | "opensuse" | "sles" | "suse" | "sle" => {
266            return DistroFamily::Suse
267        }
268        _ => {}
269    }
270
271    // ID_LIKE matches
272    if id_like_lower.contains("debian") || id_like_lower.contains("ubuntu") {
273        return DistroFamily::Debian;
274    }
275    if id_like_lower.contains("arch") {
276        return DistroFamily::Arch;
277    }
278    if id_like_lower.contains("fedora")
279        || id_like_lower.contains("rhel")
280        || id_like_lower.contains("centos")
281    {
282        return DistroFamily::Rhel;
283    }
284    if id_like_lower.contains("suse") {
285        return DistroFamily::Suse;
286    }
287
288    DistroFamily::Unknown
289}
290
291/// Check if a distro ID is a known rolling release.
292fn is_rolling_distro(id: &str) -> bool {
293    matches!(
294        id,
295        "arch"
296            | "cachyos"
297            | "manjaro"
298            | "endeavouros"
299            | "garuda"
300            | "arco"
301            | "artix"
302            | "rebornos"
303            | "opensuse-tumbleweed"
304    )
305}
306
307/// Match a package manager string to the enum.
308fn match_pkg_manager_str(s: &str) -> Option<PackageManager> {
309    match s {
310        "apt" | "apt-get" => Some(PackageManager::Apt),
311        "pacman" => Some(PackageManager::Pacman),
312        "dnf" => Some(PackageManager::Dnf),
313        "yum" => Some(PackageManager::Yum),
314        "zypper" => Some(PackageManager::Zypper),
315        _ => None,
316    }
317}
318
319/// Finalize distro info: classify family and detect package manager.
320fn finalize_distro_info(info: &mut DistroInfo) {
321    info.family = classify_distro_family(&info.id, &info.id_like);
322    info.pkg_manager = detect_package_manager(info);
323}
324
325// ===========================================================================
326// Tests
327// ===========================================================================
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    /// RAII guard that removes an env var on creation and restores the
334    /// original value (or removes it if it was absent) on drop.
335    /// This prevents race conditions when tests run in parallel and one
336    /// test sets an env var that another test reads.
337    struct EnvVarGuard {
338        key: String,
339        original: Option<String>,
340    }
341
342    impl EnvVarGuard {
343        /// Remove the env var (saving its current value for later restore).
344        fn remove(key: &str) -> Self {
345            let original = std::env::var(key).ok();
346            std::env::remove_var(key);
347            Self {
348                key: key.to_string(),
349                original,
350            }
351        }
352    }
353
354    impl Drop for EnvVarGuard {
355        fn drop(&mut self) {
356            match &self.original {
357                Some(v) => std::env::set_var(&self.key, v),
358                None => std::env::remove_var(&self.key),
359            }
360        }
361    }
362
363    // -----------------------------------------------------------------------
364    // VAL-PLAT-007: Distro detection identifies correct Linux distribution
365    // -----------------------------------------------------------------------
366
367    #[test]
368    fn test_distro_detection_parses_os_release() {
369        // Create a mock os-release file
370        let dir = tempfile::tempdir().unwrap();
371        let os_release = dir.path().join("os-release");
372        fs::write(
373            &os_release,
374            r#"NAME="Ubuntu"
375VERSION="24.04 LTS (Noble Numbat)"
376ID=ubuntu
377ID_LIKE=debian
378PRETTY_NAME="Ubuntu 24.04 LTS"
379VERSION_ID="24.04"
380VERSION_CODENAME=noble
381"#,
382        )
383        .unwrap();
384
385        let content = fs::read_to_string(&os_release).unwrap();
386        let map = parse_key_value_file(&content);
387
388        assert_eq!(map.get("ID"), Some(&"ubuntu".to_string()));
389        assert_eq!(map.get("NAME"), Some(&"Ubuntu".to_string()));
390        assert_eq!(map.get("VERSION_ID"), Some(&"24.04".to_string()));
391        assert_eq!(map.get("VERSION_CODENAME"), Some(&"noble".to_string()));
392        assert_eq!(map.get("ID_LIKE"), Some(&"debian".to_string()));
393    }
394
395    #[test]
396    fn test_distro_detection_arch_linux() {
397        let content = r#"NAME="Arch Linux"
398PRETTY_NAME="Arch Linux"
399ID=arch
400BUILD_ID=rolling
401"#;
402        let map = parse_key_value_file(content);
403        assert_eq!(map.get("ID"), Some(&"arch".to_string()));
404        assert_eq!(map.get("NAME"), Some(&"Arch Linux".to_string()));
405    }
406
407    #[test]
408    fn test_distro_detection_fedora() {
409        let content = r#"NAME="Fedora Linux"
410VERSION="41 (Workstation Edition)"
411ID=fedora
412VERSION_ID=41
413ID_LIKE="rhel fedora"
414"#;
415        let map = parse_key_value_file(content);
416        assert_eq!(map.get("ID"), Some(&"fedora".to_string()));
417        assert_eq!(map.get("VERSION_ID"), Some(&"41".to_string()));
418    }
419
420    #[test]
421    fn test_distro_detection_opensuse() {
422        let content = r#"NAME="openSUSE Leap"
423VERSION="15.5"
424ID="opensuse-leap"
425VERSION_ID="15.5"
426ID_LIKE="suse opensuse"
427"#;
428        let map = parse_key_value_file(content);
429        assert_eq!(map.get("ID"), Some(&"opensuse-leap".to_string()));
430        assert_eq!(map.get("VERSION_ID"), Some(&"15.5".to_string()));
431    }
432
433    #[test]
434    fn test_distro_detection_cachyos() {
435        let content = r#"NAME="CachyOS"
436ID=cachyos
437ID_LIKE=arch
438"#;
439        let map = parse_key_value_file(content);
440        assert_eq!(map.get("ID"), Some(&"cachyos".to_string()));
441        assert_eq!(map.get("ID_LIKE"), Some(&"arch".to_string()));
442    }
443
444    #[test]
445    fn test_distro_family_classification_debian() {
446        assert_eq!(
447            classify_distro_family("ubuntu", "debian"),
448            DistroFamily::Debian
449        );
450        assert_eq!(classify_distro_family("debian", ""), DistroFamily::Debian);
451        assert_eq!(
452            classify_distro_family("linuxmint", "debian"),
453            DistroFamily::Debian
454        );
455        assert_eq!(
456            classify_distro_family("pop", "ubuntu debian"),
457            DistroFamily::Debian
458        );
459        assert_eq!(
460            classify_distro_family("kali", "debian"),
461            DistroFamily::Debian
462        );
463    }
464
465    #[test]
466    fn test_distro_family_classification_arch() {
467        assert_eq!(classify_distro_family("arch", ""), DistroFamily::Arch);
468        assert_eq!(
469            classify_distro_family("cachyos", "arch"),
470            DistroFamily::Arch
471        );
472        assert_eq!(
473            classify_distro_family("manjaro", "arch"),
474            DistroFamily::Arch
475        );
476        assert_eq!(
477            classify_distro_family("endeavouros", "arch"),
478            DistroFamily::Arch
479        );
480    }
481
482    #[test]
483    fn test_distro_family_classification_rhel() {
484        assert_eq!(
485            classify_distro_family("fedora", "rhel fedora"),
486            DistroFamily::Rhel
487        );
488        assert_eq!(
489            classify_distro_family("rocky", "rhel centos"),
490            DistroFamily::Rhel
491        );
492        assert_eq!(
493            classify_distro_family("almalinux", "rhel centos"),
494            DistroFamily::Rhel
495        );
496        assert_eq!(
497            classify_distro_family("centos", "rhel fedora"),
498            DistroFamily::Rhel
499        );
500    }
501
502    #[test]
503    fn test_distro_family_classification_suse() {
504        assert_eq!(
505            classify_distro_family("opensuse-leap", "suse opensuse"),
506            DistroFamily::Suse
507        );
508        assert_eq!(
509            classify_distro_family("opensuse-tumbleweed", "suse opensuse"),
510            DistroFamily::Suse
511        );
512        assert_eq!(classify_distro_family("sles", "suse"), DistroFamily::Suse);
513    }
514
515    #[test]
516    fn test_distro_family_classification_unknown() {
517        assert_eq!(classify_distro_family("gentoo", ""), DistroFamily::Unknown);
518        assert_eq!(classify_distro_family("alpine", ""), DistroFamily::Unknown);
519    }
520
521    #[test]
522    fn test_rolling_distro_detection() {
523        assert!(is_rolling_distro("arch"));
524        assert!(is_rolling_distro("cachyos"));
525        assert!(is_rolling_distro("manjaro"));
526        assert!(is_rolling_distro("endeavouros"));
527        assert!(is_rolling_distro("opensuse-tumbleweed"));
528        assert!(!is_rolling_distro("ubuntu"));
529        assert!(!is_rolling_distro("fedora"));
530        assert!(!is_rolling_distro("debian"));
531    }
532
533    // -----------------------------------------------------------------------
534    // VAL-PLAT-008: Package manager detection matches distro family
535    // -----------------------------------------------------------------------
536
537    #[test]
538    fn test_package_manager_debian_family() {
539        let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
540        let info = DistroInfo {
541            id: "ubuntu".to_string(),
542            family: DistroFamily::Debian,
543            ..Default::default()
544        };
545        assert_eq!(detect_package_manager(&info), PackageManager::Apt);
546    }
547
548    #[test]
549    fn test_package_manager_arch_family() {
550        let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
551        let info = DistroInfo {
552            id: "arch".to_string(),
553            family: DistroFamily::Arch,
554            ..Default::default()
555        };
556        assert_eq!(detect_package_manager(&info), PackageManager::Pacman);
557    }
558
559    #[test]
560    fn test_package_manager_rhel_family() {
561        let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
562        let info = DistroInfo {
563            id: "fedora".to_string(),
564            family: DistroFamily::Rhel,
565            ..Default::default()
566        };
567        assert_eq!(detect_package_manager(&info), PackageManager::Dnf);
568    }
569
570    #[test]
571    fn test_package_manager_suse_family() {
572        let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
573        let info = DistroInfo {
574            id: "opensuse-leap".to_string(),
575            family: DistroFamily::Suse,
576            ..Default::default()
577        };
578        assert_eq!(detect_package_manager(&info), PackageManager::Zypper);
579    }
580
581    #[test]
582    fn test_package_manager_unknown_family() {
583        let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
584        let info = DistroInfo {
585            id: "alpine".to_string(),
586            family: DistroFamily::Unknown,
587            ..Default::default()
588        };
589        assert_eq!(detect_package_manager(&info), PackageManager::Unknown);
590    }
591
592    #[test]
593    fn test_package_manager_env_override() {
594        let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
595        let info = DistroInfo {
596            id: "arch".to_string(),
597            family: DistroFamily::Arch,
598            ..Default::default()
599        };
600
601        // Set override
602        std::env::set_var("MLSTACK_PKG_MANAGER", "apt");
603        assert_eq!(detect_package_manager(&info), PackageManager::Apt);
604
605        // Guard restores original state on drop
606    }
607
608    #[test]
609    fn test_match_pkg_manager_str() {
610        assert_eq!(match_pkg_manager_str("apt"), Some(PackageManager::Apt));
611        assert_eq!(match_pkg_manager_str("apt-get"), Some(PackageManager::Apt));
612        assert_eq!(
613            match_pkg_manager_str("pacman"),
614            Some(PackageManager::Pacman)
615        );
616        assert_eq!(match_pkg_manager_str("dnf"), Some(PackageManager::Dnf));
617        assert_eq!(match_pkg_manager_str("yum"), Some(PackageManager::Yum));
618        assert_eq!(
619            match_pkg_manager_str("zypper"),
620            Some(PackageManager::Zypper)
621        );
622        assert_eq!(match_pkg_manager_str("unknown"), None);
623    }
624
625    // -----------------------------------------------------------------------
626    // Real system detection (not mocked, validates against current system)
627    // -----------------------------------------------------------------------
628
629    #[test]
630    fn test_detect_distribution_real_system() {
631        let info = detect_distribution();
632        // On this system (CachyOS/Arch), we should detect something
633        assert!(
634            !info.id.is_empty(),
635            "Distro ID should be non-empty on a real system"
636        );
637        assert!(
638            !info.name.is_empty(),
639            "Distro name should be non-empty on a real system"
640        );
641        // Should have a valid family
642        assert!(
643            info.family != DistroFamily::Unknown || info.id == "unknown",
644            "Should classify family for known distros"
645        );
646    }
647
648    #[test]
649    fn test_detect_distribution_real_system_distro_info_serde_roundtrip() {
650        let info = detect_distribution();
651        let json = serde_json::to_string(&info).unwrap();
652        let back: DistroInfo = serde_json::from_str(&json).unwrap();
653        assert_eq!(info, back);
654    }
655
656    // -----------------------------------------------------------------------
657    // parse_key_value_file edge cases
658    // -----------------------------------------------------------------------
659
660    #[test]
661    fn test_parse_key_value_file_empty() {
662        let map = parse_key_value_file("");
663        assert!(map.is_empty());
664    }
665
666    #[test]
667    fn test_parse_key_value_file_comments_and_blanks() {
668        let content = "# This is a comment\n\nID=arch\n# Another comment\nNAME=\"Arch Linux\"\n";
669        let map = parse_key_value_file(content);
670        assert_eq!(map.get("ID"), Some(&"arch".to_string()));
671        assert_eq!(map.get("NAME"), Some(&"Arch Linux".to_string()));
672        assert_eq!(map.len(), 2);
673    }
674
675    #[test]
676    fn test_parse_key_value_file_single_quoted() {
677        let content = "ID='ubuntu'\nNAME='Ubuntu'";
678        let map = parse_key_value_file(content);
679        assert_eq!(map.get("ID"), Some(&"ubuntu".to_string()));
680        assert_eq!(map.get("NAME"), Some(&"Ubuntu".to_string()));
681    }
682
683    #[test]
684    fn test_parse_key_value_file_unquoted() {
685        let content = "ID=arch\nVERSION_ID=rolling";
686        let map = parse_key_value_file(content);
687        assert_eq!(map.get("ID"), Some(&"arch".to_string()));
688        assert_eq!(map.get("VERSION_ID"), Some(&"rolling".to_string()));
689    }
690}