From 1b096940902bfd21bf18208dc29736f172595505 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Mon, 6 Jul 2026 13:25:08 +0100 Subject: [PATCH 1/4] Handle Linux distros without VERSION_ID --- src/system/os.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/src/system/os.rs b/src/system/os.rs index b83cb22b..bcdb8043 100644 --- a/src/system/os.rs +++ b/src/system/os.rs @@ -1,4 +1,7 @@ -use std::fmt::{self, Display}; +use std::{ + fmt::{self, Display}, + fs, +}; use serde::{Deserialize, Serialize}; use sysinfo::System; @@ -20,17 +23,21 @@ impl SupportedOs { /// Expects `std::env::consts::OS` as input /// /// For Linux, the distribution is identified via `sysinfo::System::distribution_id()`. - /// The OS version is read from `sysinfo::System::os_version()`. + /// The OS version is read from `sysinfo::System::os_version()`, falling back to + /// `VERSION_ID` or `BUILD_ID` from os-release. pub fn from_os(os: &str) -> Result { - let os_version = System::os_version().ok_or(anyhow!("Failed to get OS version"))?; match os { "linux" => { let os_id = System::distribution_id(); + let os_version = linux_os_version()?; Ok(Self::Linux(LinuxDistribution::from_id(&os_id, &os_version))) } - "macos" => Ok(Self::Macos { - version: os_version, - }), + "macos" => { + let os_version = System::os_version().ok_or(anyhow!("Failed to get OS version"))?; + Ok(Self::Macos { + version: os_version, + }) + } unsupported => bail!("Unsupported operating system: {unsupported}"), } } @@ -51,6 +58,62 @@ impl SupportedOs { } } +fn linux_os_version() -> Result { + System::os_version() + .or_else(read_linux_os_release_version) + .ok_or(anyhow!( + "Failed to get Linux OS version from sysinfo or os-release" + )) +} + +fn read_linux_os_release_version() -> Option { + ["/etc/os-release", "/usr/lib/os-release"] + .into_iter() + .find_map(|path| { + fs::read_to_string(path) + .ok() + .and_then(|contents| parse_os_release_version(&contents)) + }) +} + +fn parse_os_release_version(contents: &str) -> Option { + os_release_value(contents, "VERSION_ID").or_else(|| os_release_value(contents, "BUILD_ID")) +} + +fn os_release_value(contents: &str, key: &str) -> Option { + contents.lines().find_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + + let (candidate, value) = line.split_once('=')?; + if candidate.trim() != key { + return None; + } + + Some(unquote_os_release_value(value.trim()).to_string()) + }) +} + +fn unquote_os_release_value(value: &str) -> &str { + if let Some(value) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + return value; + } + + if let Some(value) = value + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + { + return value; + } + + value +} + impl Display for SupportedOs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.id(), self.version()) @@ -137,4 +200,46 @@ mod tests { let err = SupportedOs::from_os("windows").unwrap_err(); assert_eq!(err.to_string(), "Unsupported operating system: windows"); } + + #[test] + fn parse_os_release_version_prefers_version_id() { + let contents = r#" + ID=ubuntu + VERSION_ID="24.04" + BUILD_ID=rolling + "#; + + assert_eq!(parse_os_release_version(contents).as_deref(), Some("24.04")); + } + + #[test] + fn parse_os_release_version_falls_back_to_build_id() { + let contents = r#" + NAME="Arch Linux" + ID=arch + BUILD_ID=rolling + "#; + + assert_eq!( + parse_os_release_version(contents).as_deref(), + Some("rolling") + ); + } + + #[test] + fn parse_os_release_version_handles_single_quoted_values() { + let contents = "ID=example\nVERSION_ID='1.2'\n"; + + assert_eq!(parse_os_release_version(contents).as_deref(), Some("1.2")); + } + + #[test] + fn parse_os_release_version_returns_none_without_version_fields() { + let contents = r#" + NAME="Unknown Linux" + ID=unknown + "#; + + assert_eq!(parse_os_release_version(contents), None); + } } From c4fc09c81efd7534f6dc030d48b3727e0e844717 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Mon, 6 Jul 2026 13:36:09 +0100 Subject: [PATCH 2/4] Model os-release parsing --- src/system/os.rs | 136 ++++++++++++++++++++++++++++------------------- 1 file changed, 81 insertions(+), 55 deletions(-) diff --git a/src/system/os.rs b/src/system/os.rs index bcdb8043..3ad6acfb 100644 --- a/src/system/os.rs +++ b/src/system/os.rs @@ -29,11 +29,19 @@ impl SupportedOs { match os { "linux" => { let os_id = System::distribution_id(); - let os_version = linux_os_version()?; + let os_version = System::os_version() + .or_else(|| { + OsRelease::read().and_then(|release| release.version().map(str::to_owned)) + }) + .ok_or(anyhow!( + "Failed to get Linux OS version from sysinfo or os-release" + ))?; + Ok(Self::Linux(LinuxDistribution::from_id(&os_id, &os_version))) } "macos" => { let os_version = System::os_version().ok_or(anyhow!("Failed to get OS version"))?; + Ok(Self::Macos { version: os_version, }) @@ -58,60 +66,69 @@ impl SupportedOs { } } -fn linux_os_version() -> Result { - System::os_version() - .or_else(read_linux_os_release_version) - .ok_or(anyhow!( - "Failed to get Linux OS version from sysinfo or os-release" - )) +#[derive(Debug, Eq, PartialEq)] +struct OsRelease { + version_id: Option, + build_id: Option, } -fn read_linux_os_release_version() -> Option { - ["/etc/os-release", "/usr/lib/os-release"] - .into_iter() - .find_map(|path| { - fs::read_to_string(path) - .ok() - .and_then(|contents| parse_os_release_version(&contents)) - }) -} +impl OsRelease { + fn read() -> Option { + ["/etc/os-release", "/usr/lib/os-release"] + .into_iter() + .filter_map(|path| fs::read_to_string(path).ok()) + .map(|contents| Self::parse(&contents)) + .find(|release| release.version().is_some()) + } -fn parse_os_release_version(contents: &str) -> Option { - os_release_value(contents, "VERSION_ID").or_else(|| os_release_value(contents, "BUILD_ID")) -} + fn parse(contents: &str) -> Self { + let mut release = Self { + version_id: None, + build_id: None, + }; -fn os_release_value(contents: &str, key: &str) -> Option { - contents.lines().find_map(|line| { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - return None; - } + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } - let (candidate, value) = line.split_once('=')?; - if candidate.trim() != key { - return None; - } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = Self::unquote_value(value.trim()).to_string(); - Some(unquote_os_release_value(value.trim()).to_string()) - }) -} + match key.trim() { + "VERSION_ID" => release.version_id = Some(value), + "BUILD_ID" => release.build_id = Some(value), + _ => {} + } + } -fn unquote_os_release_value(value: &str) -> &str { - if let Some(value) = value - .strip_prefix('"') - .and_then(|value| value.strip_suffix('"')) - { - return value; + release } - if let Some(value) = value - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - { - return value; + fn version(&self) -> Option<&str> { + self.version_id.as_deref().or(self.build_id.as_deref()) } - value + fn unquote_value(value: &str) -> &str { + if let Some(value) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + return value; + } + + if let Some(value) = value + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + { + return value; + } + + value + } } impl Display for SupportedOs { @@ -202,44 +219,53 @@ mod tests { } #[test] - fn parse_os_release_version_prefers_version_id() { + fn os_release_version_prefers_version_id() { let contents = r#" ID=ubuntu VERSION_ID="24.04" BUILD_ID=rolling "#; + let release = OsRelease::parse(contents); - assert_eq!(parse_os_release_version(contents).as_deref(), Some("24.04")); + assert_eq!(release.version(), Some("24.04")); } #[test] - fn parse_os_release_version_falls_back_to_build_id() { + fn os_release_version_falls_back_to_build_id() { let contents = r#" NAME="Arch Linux" ID=arch BUILD_ID=rolling "#; + let release = OsRelease::parse(contents); - assert_eq!( - parse_os_release_version(contents).as_deref(), - Some("rolling") - ); + assert_eq!(release.version(), Some("rolling")); } #[test] - fn parse_os_release_version_handles_single_quoted_values() { + fn os_release_parse_handles_single_quoted_values() { let contents = "ID=example\nVERSION_ID='1.2'\n"; + let release = OsRelease::parse(contents); + + assert_eq!(release.version(), Some("1.2")); + } + + #[test] + fn os_release_parse_allows_whitespace_around_key() { + let contents = "VERSION_ID = \"24.04\"\n"; + let release = OsRelease::parse(contents); - assert_eq!(parse_os_release_version(contents).as_deref(), Some("1.2")); + assert_eq!(release.version(), Some("24.04")); } #[test] - fn parse_os_release_version_returns_none_without_version_fields() { + fn os_release_version_returns_none_without_version_fields() { let contents = r#" NAME="Unknown Linux" ID=unknown "#; + let release = OsRelease::parse(contents); - assert_eq!(parse_os_release_version(contents), None); + assert_eq!(release.version(), None); } } From b9f467454691fadd3bee327f54e0815ea0d80672 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Mon, 6 Jul 2026 13:38:56 +0100 Subject: [PATCH 3/4] Use lazy Linux version error --- src/system/os.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/system/os.rs b/src/system/os.rs index 3ad6acfb..3c4121ee 100644 --- a/src/system/os.rs +++ b/src/system/os.rs @@ -33,9 +33,9 @@ impl SupportedOs { .or_else(|| { OsRelease::read().and_then(|release| release.version().map(str::to_owned)) }) - .ok_or(anyhow!( - "Failed to get Linux OS version from sysinfo or os-release" - ))?; + .ok_or_else(|| { + anyhow!("Failed to get Linux OS version from sysinfo or os-release") + })?; Ok(Self::Linux(LinuxDistribution::from_id(&os_id, &os_version))) } From f5e314375ba6f3704f542fa96524cb0906e11e19 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Mon, 6 Jul 2026 14:31:56 +0100 Subject: [PATCH 4/4] Inline os-release value parsing --- src/system/os.rs | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/system/os.rs b/src/system/os.rs index 3c4121ee..100f80f2 100644 --- a/src/system/os.rs +++ b/src/system/os.rs @@ -66,7 +66,6 @@ impl SupportedOs { } } -#[derive(Debug, Eq, PartialEq)] struct OsRelease { version_id: Option, build_id: Option, @@ -96,7 +95,17 @@ impl OsRelease { let Some((key, value)) = line.split_once('=') else { continue; }; - let value = Self::unquote_value(value.trim()).to_string(); + let value = value.trim(); + let value = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + }) + .unwrap_or(value) + .to_string(); match key.trim() { "VERSION_ID" => release.version_id = Some(value), @@ -111,24 +120,6 @@ impl OsRelease { fn version(&self) -> Option<&str> { self.version_id.as_deref().or(self.build_id.as_deref()) } - - fn unquote_value(value: &str) -> &str { - if let Some(value) = value - .strip_prefix('"') - .and_then(|value| value.strip_suffix('"')) - { - return value; - } - - if let Some(value) = value - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - { - return value; - } - - value - } } impl Display for SupportedOs {