diff --git a/Cargo.lock b/Cargo.lock index 1579c4437..5a58c053f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4829,16 +4829,6 @@ dependencies = [ "syn", ] -[[package]] -name = "sys-info" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -6141,7 +6131,6 @@ dependencies = [ "rustls", "serde", "serde_json", - "sys-info", "tempfile", "thiserror 2.0.18", "tokio", @@ -6160,6 +6149,7 @@ dependencies = [ "uv-normalize", "uv-pep440", "uv-pep508", + "uv-platform", "uv-platform-tags", "uv-preview", "uv-pypi-types", @@ -6751,14 +6741,17 @@ dependencies = [ "fs-err", "goblin", "indoc", + "insta", "procfs", "regex", + "rustix", "target-lexicon", "thiserror 2.0.18", "tracing", "uv-fs", "uv-platform-tags", "uv-static", + "windows-registry", ] [[package]] @@ -6888,7 +6881,6 @@ dependencies = [ "schemars", "serde", "serde_json", - "sys-info", "target-lexicon", "temp-env", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 9c51388aa..062340a8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -228,6 +228,7 @@ rustc-hash = { version = "2.0.0" } rustix = { version = "1.0.0", default-features = false, features = [ "fs", "std", + "system", ] } same-file = { version = "1.0.6" } schemars = { version = "1.0.0", features = ["url2"] } @@ -242,7 +243,6 @@ sha2 = { version = "0.10.8" } smallvec = { version = "1.13.2" } spdx = { version = "0.13.0" } syn = { version = "2.0.77" } -sys-info = { version = "0.9.1" } tar = { version = "0.4.43" } target-lexicon = { version = "0.13.0" } tempfile = { version = "3.14.0" } diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index 07c7cea82..709c9041d 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -59,7 +59,7 @@ rmp-serde = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -sys-info = { workspace = true } +uv-platform = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } diff --git a/crates/uv-client/src/linehaul.rs b/crates/uv-client/src/linehaul.rs index 705d107b3..cb2bcf124 100644 --- a/crates/uv-client/src/linehaul.rs +++ b/crates/uv-client/src/linehaul.rs @@ -94,7 +94,7 @@ impl LineHaul { // Build Distro as Linehaul expects. let distro: Option = if cfg!(target_os = "linux") { // Gather distribution info from /etc/os-release. - sys_info::linux_os_release().ok().map(|info| Distro { + uv_platform::host::LinuxOsRelease::from_env().map(|info| Distro { // e.g., Jammy, Focal, etc. id: info.version_codename, // e.g., Ubuntu, Fedora, etc. diff --git a/crates/uv-client/tests/it/user_agent_version.rs b/crates/uv-client/tests/it/user_agent_version.rs index b4d13b3b0..8d04d4706 100644 --- a/crates/uv-client/tests/it/user_agent_version.rs +++ b/crates/uv-client/tests/it/user_agent_version.rs @@ -275,7 +275,7 @@ async fn test_user_agent_has_linehaul() -> Result<()> { .distro .expect("got no distro, but expected one in linehaul"); // Gather distribution info from /etc/os-release. - let release_info = sys_info::linux_os_release() + let release_info = uv_platform::host::LinuxOsRelease::from_env() .expect("got no os release info, but expected one in linux"); assert_eq!(distro_info.id, release_info.version_codename); assert_eq!(distro_info.name, release_info.name); diff --git a/crates/uv-platform/Cargo.toml b/crates/uv-platform/Cargo.toml index f7f590dd6..8ef729c81 100644 --- a/crates/uv-platform/Cargo.toml +++ b/crates/uv-platform/Cargo.toml @@ -27,8 +27,15 @@ target-lexicon = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true } + [target.'cfg(target_os = "linux")'.dependencies] procfs = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +windows-registry = { workspace = true } + [dev-dependencies] +insta = { workspace = true } indoc = { workspace = true } diff --git a/crates/uv-platform/src/host.rs b/crates/uv-platform/src/host.rs new file mode 100644 index 000000000..2968e1d7b --- /dev/null +++ b/crates/uv-platform/src/host.rs @@ -0,0 +1,247 @@ +//! Host system information (OS type, kernel release, distro metadata). + +use std::convert::Infallible; +use std::fmt; +use std::str::FromStr; + +/// The operating system type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OsType { + /// Linux, with the kernel type from `/proc/sys/kernel/ostype` (typically `"Linux"`). + Linux(String), + /// macOS / Darwin. + Darwin, + /// Windows NT. + WindowsNt, +} + +impl OsType { + /// Returns the operating system type for the current host. + /// + /// Returns `None` on unsupported platforms. + pub fn from_env() -> Option { + #[cfg(target_os = "linux")] + { + fs_err::read_to_string("/proc/sys/kernel/ostype") + .ok() + .map(|s| Self::Linux(s.trim().to_string())) + } + #[cfg(target_os = "macos")] + { + Some(Self::Darwin) + } + #[cfg(target_os = "windows")] + { + Some(Self::WindowsNt) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + None + } + } +} + +impl fmt::Display for OsType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Linux(os_type) => f.write_str(os_type), + Self::Darwin => f.write_str("Darwin"), + Self::WindowsNt => f.write_str("Windows_NT"), + } + } +} + +/// The OS kernel release version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OsRelease { + /// Unix kernel release from `uname -r` (e.g., `"6.8.0-90-generic"`). + Unix(String), + /// Windows build number from the registry (e.g., `"22631"`). + Windows(String), +} + +impl OsRelease { + /// Returns the OS kernel release for the current host. + /// + /// Returns `None` on unsupported platforms or if the release cannot be read. + pub fn from_env() -> Option { + #[cfg(unix)] + { + let uname = rustix::system::uname(); + let release = uname.release().to_str().ok()?; + Some(Self::Unix(release.to_string())) + } + #[cfg(windows)] + { + let key = windows_registry::LOCAL_MACHINE + .open(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") + .ok()?; + Some(Self::Windows(key.get_string("CurrentBuildNumber").ok()?)) + } + #[cfg(not(any(unix, windows)))] + { + None + } + } +} + +impl fmt::Display for OsRelease { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unix(release) => f.write_str(release), + Self::Windows(build) => f.write_str(build), + } + } +} + +/// Parsed fields from `/etc/os-release`. +#[derive(Debug, Clone, Default)] +pub struct LinuxOsRelease { + /// Distribution name (e.g., `"Ubuntu"`). + pub name: Option, + /// Version identifier (e.g., `"22.04"`). + pub version_id: Option, + /// Version codename (e.g., `"jammy"`). + pub version_codename: Option, +} + +impl LinuxOsRelease { + /// Reads and parses `/etc/os-release` on Linux. + /// + /// Returns `None` on non-Linux platforms or if the file cannot be read. + pub fn from_env() -> Option { + #[cfg(target_os = "linux")] + { + Some( + fs_err::read_to_string("/etc/os-release") + .ok()? + .parse() + .unwrap(), + ) + } + #[cfg(not(target_os = "linux"))] + { + None + } + } +} + +impl FromStr for LinuxOsRelease { + type Err = Infallible; + + /// Parse the contents of an os-release file (KEY=VALUE format, optionally quoted). + fn from_str(contents: &str) -> Result { + let mut release = Self::default(); + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = unquote(value); + match key { + "NAME" => release.name = Some(value.to_string()), + "VERSION_ID" => release.version_id = Some(value.to_string()), + "VERSION_CODENAME" => release.version_codename = Some(value.to_string()), + _ => {} + } + } + Ok(release) + } +} + +/// Strip matching single or double quotes from a value. +fn unquote(s: &str) -> &str { + for quote in ['"', '\''] { + if let Some(inner) = s.strip_prefix(quote).and_then(|s| s.strip_suffix(quote)) { + return inner; + } + } + s +} + +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + + use super::*; + + #[test] + fn test_parse_os_release_ubuntu() { + let contents = "\ +NAME=\"Ubuntu\" +VERSION_ID=\"22.04\" +VERSION_CODENAME=jammy +ID=ubuntu +"; + let release: LinuxOsRelease = contents.parse().unwrap(); + assert_debug_snapshot!(release, @r#" + LinuxOsRelease { + name: Some( + "Ubuntu", + ), + version_id: Some( + "22.04", + ), + version_codename: Some( + "jammy", + ), + } + "#); + } + + #[test] + fn test_parse_os_release_empty() { + let release: LinuxOsRelease = "".parse().unwrap(); + assert_eq!(release.name, None); + assert_eq!(release.version_id, None); + assert_eq!(release.version_codename, None); + } + + #[test] + fn test_parse_os_release_comments_and_blanks() { + let contents = "\ +# This is a comment + +NAME='Fedora Linux' +VERSION_ID=40 +"; + let release: LinuxOsRelease = contents.parse().unwrap(); + assert_eq!(release.name.as_deref(), Some("Fedora Linux")); + assert_eq!(release.version_id.as_deref(), Some("40")); + assert_eq!(release.version_codename, None); + } + + #[test] + fn test_unquote() { + assert_eq!(unquote("\"hello\""), "hello"); + assert_eq!(unquote("'hello'"), "hello"); + assert_eq!(unquote("hello"), "hello"); + assert_eq!(unquote("\"\""), ""); + assert_eq!(unquote(""), ""); + } + + #[test] + fn test_os_type_returns_value() { + let os_type = + OsType::from_env().expect("OsType should be available on supported platforms"); + #[cfg(target_os = "linux")] + assert!(matches!(os_type, OsType::Linux(_))); + #[cfg(target_os = "macos")] + assert_eq!(os_type, OsType::Darwin); + #[cfg(target_os = "windows")] + assert_eq!(os_type, OsType::WindowsNt); + } + + #[test] + fn test_os_release_returns_value() { + let os_release = + OsRelease::from_env().expect("OsRelease should be available on supported platforms"); + #[cfg(unix)] + assert!(matches!(os_release, OsRelease::Unix(_))); + #[cfg(windows)] + assert!(matches!(os_release, OsRelease::Windows(_))); + } +} diff --git a/crates/uv-platform/src/lib.rs b/crates/uv-platform/src/lib.rs index e3055ea8e..4157a4331 100644 --- a/crates/uv-platform/src/lib.rs +++ b/crates/uv-platform/src/lib.rs @@ -13,6 +13,7 @@ pub use crate::os::Os; mod arch; mod cpuinfo; +pub mod host; mod libc; mod os; diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 759375103..6d0258434 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -58,7 +58,6 @@ same-file = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sys-info = { workspace = true } target-lexicon = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 6b3f193a0..7d1b53599 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -1137,8 +1137,12 @@ impl InterpreterInfo { // invalidate the cache (e.g.) on OS upgrades. cache_digest(&( ARCH, - sys_info::os_type().unwrap_or_default(), - sys_info::os_release().unwrap_or_default(), + uv_platform::host::OsType::from_env() + .map(|os_type| os_type.to_string()) + .unwrap_or_default(), + uv_platform::host::OsRelease::from_env() + .map(|os_release| os_release.to_string()) + .unwrap_or_default(), )), // We use the absolute path for the cache entry to avoid cache collisions for relative // paths. But we don't want to query the executable with symbolic links resolved because