Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 0 additions & 98 deletions crates/vfox/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,63 +44,6 @@ pub fn arch() -> String {
}
}

/// Detect the libc environment type at runtime.
/// Returns `Some("gnu")` on glibc Linux, `Some("musl")` on musl Linux, `None` elsewhere.
// NOTE: This logic mirrors is_musl_system() in src/platform.rs. Keep in sync.
#[cfg(target_os = "linux")]
pub(crate) fn env_type() -> Option<String> {
use std::sync::LazyLock as Lazy;
static ENV_TYPE: Lazy<Option<String>> = Lazy::new(|| {
// Allow explicit override via environment variable (only gnu/musl accepted)
if let Ok(val) = std::env::var("MISE_LIBC") {
match val.to_lowercase().as_str() {
"musl" => return Some("musl".to_string()),
"glibc" | "gnu" => return Some("gnu".to_string()),
_ => {} // invalid value ignored, fall through to runtime detection
}
}
// If glibc's dynamic linker exists, this is a glibc system
for dir in ["/lib", "/lib64"] {
if has_file_prefix(dir, "ld-linux-") {
return Some("gnu".to_string());
}
}
// No glibc linker found — check for musl's
for dir in ["/lib", "/lib64"] {
if has_file_prefix(dir, "ld-musl-") {
return Some("musl".to_string());
}
}
// No linker found at all (e.g., scratch/busybox container) —
// fall back to the binary's compile-time target
if cfg!(target_env = "musl") {
return Some("musl".to_string());
}
if cfg!(target_env = "gnu") {
return Some("gnu".to_string());
}
None
});
ENV_TYPE.clone()
}

#[cfg(target_os = "linux")]
fn has_file_prefix(dir: &str, prefix: &str) -> bool {
std::fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(prefix))
})
.unwrap_or(false)
}

/// On non-Linux platforms, libc variant is not applicable.
#[cfg(not(target_os = "linux"))]
pub(crate) fn env_type() -> Option<String> {
None
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -116,45 +59,4 @@ mod tests {
let arch = arch();
assert!(!arch.is_empty());
}

#[test]
fn test_env_type() {
let et = env_type();
match et.as_deref() {
Some("gnu") | Some("musl") | None => {}
other => panic!("unexpected env_type: {other:?}"),
}
}

#[cfg(not(target_os = "linux"))]
#[test]
fn test_env_type_non_linux_returns_none() {
assert_eq!(env_type(), None);
}

#[cfg(target_os = "linux")]
#[test]
fn test_env_type_returns_some_on_linux() {
// On any Linux system (glibc or musl), env_type() should return
// Some("gnu") or Some("musl") — never None. Even in minimal
// containers with no linker, the compile-time fallback applies.
let et = env_type();
assert!(
et.is_some(),
"env_type() returned None on Linux — expected Some(\"gnu\") or Some(\"musl\")"
);
}

#[cfg(all(target_os = "linux", target_env = "musl"))]
#[test]
fn test_env_type_musl_binary_returns_musl() {
// A musl-compiled binary should always report musl, regardless of
// the host system's linker files (covers scratch containers).
let et = env_type();
assert_eq!(
et.as_deref(),
Some("musl"),
"musl-compiled binary should return Some(\"musl\")"
);
}
}
6 changes: 3 additions & 3 deletions crates/vfox/src/runtime.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::config::{arch, env_type, os};
use crate::config::{arch, os};
use mlua::{UserData, UserDataFields};
use std::path::PathBuf;
use std::sync::LazyLock as Lazy;
Expand All @@ -17,7 +17,7 @@ static RUNTIME: Lazy<Mutex<Runtime>> = Lazy::new(|| {
Mutex::new(Runtime {
os: os(),
arch: arch(),
env_type: env_type(),
env_type: None,
version: "0.6.0".to_string(), // https://github.com/version-fox/vfox/releases
plugin_dir_path: PathBuf::new(),
})
Expand Down Expand Up @@ -64,7 +64,7 @@ impl Runtime {
let mut runtime = RUNTIME.lock().unwrap();
runtime.os = os();
runtime.arch = arch();
runtime.env_type = env_type();
runtime.env_type = None;
}
}

Expand Down
142 changes: 124 additions & 18 deletions src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,33 +199,78 @@ impl From<&str> for Platform {
}
}

/// Detect whether the system uses musl libc at runtime.
/// Checks for the absence of glibc's dynamic linker (`ld-linux-*`) in /lib and /lib64.
/// On glibc systems, `ld-linux-*` is always present (even if musl-tools is installed
/// for cross-compilation, which also places `ld-musl-*` in /lib). On musl-only systems
/// (Alpine, Void musl, etc.), only `ld-musl-*` exists without `ld-linux-*`.
// NOTE: This logic is mirrored in crates/vfox/src/config.rs env_type(). Keep in sync.
/// Detect the current libc variant on Linux.
///
/// Returns `Some("gnu")` on glibc Linux, `Some("musl")` on musl Linux,
/// `None` on non-Linux or when the variant can't be determined (e.g. minimal
/// containers compiled against an unusual target_env).
///
/// Detection order on Linux:
/// 1. `/etc/os-release` ID/ID_LIKE — strong signal for known musl distros.
/// Necessary because compat shims like `gcompat` on Alpine install
/// `/lib/ld-linux-*` alongside `/lib/ld-musl-*`, which would otherwise
/// cause the linker-based fallback to misclassify the system as glibc.
/// 2. Linker file presence in `/lib` and `/lib64`.
/// 3. Compile-time target (`target_env`) — for scratch/busybox containers
/// with no linker files.
#[cfg(target_os = "linux")]
fn is_musl_system() -> bool {
pub fn detect_libc() -> Option<&'static str> {
use std::sync::LazyLock;
static IS_MUSL: LazyLock<bool> = LazyLock::new(|| {
// If glibc's dynamic linker exists, this is a glibc system
static DETECTED: LazyLock<Option<&'static str>> = LazyLock::new(|| {
if let Some(true) = musl_from_os_release("/etc/os-release") {
return Some("musl");
}
for dir in ["/lib", "/lib64"] {
if has_file_prefix(dir, "ld-linux-") {
return false;
return Some("gnu");
}
}
// No glibc linker found — check for musl's
for dir in ["/lib", "/lib64"] {
if has_file_prefix(dir, "ld-musl-") {
return true;
return Some("musl");
}
}
// No linker found at all (e.g., scratch/busybox container) —
// fall back to the binary's compile-time target
cfg!(target_env = "musl")
if cfg!(target_env = "musl") {
return Some("musl");
}
if cfg!(target_env = "gnu") {
return Some("gnu");
}
None
});
*IS_MUSL
*DETECTED
}

#[cfg(not(target_os = "linux"))]
pub fn detect_libc() -> Option<&'static str> {
None
}

#[cfg(target_os = "linux")]
fn musl_from_os_release(path: &str) -> Option<bool> {
let content = std::fs::read_to_string(path).ok()?;
let mut ids: Vec<String> = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
if key == "ID" || key == "ID_LIKE" {
let value = value.trim().trim_matches('"').trim_matches('\'');
ids.extend(value.split_whitespace().map(str::to_string));
}
}
// Known musl-libc distros. Compat shims (gcompat) don't change this — the
// underlying libc is still musl.
const MUSL_DISTROS: &[&str] = &["alpine", "postmarketos", "chimera"];
if ids.iter().any(|id| MUSL_DISTROS.contains(&id.as_str())) {
return Some(true);
}
None
}

#[cfg(target_os = "linux")]
Expand All @@ -239,9 +284,8 @@ fn has_file_prefix(dir: &str, prefix: &str) -> bool {
.unwrap_or(false)
}

#[cfg(not(target_os = "linux"))]
fn is_musl_system() -> bool {
false
detect_libc() == Some("musl")
}

#[cfg(test)]
Expand Down Expand Up @@ -395,4 +439,66 @@ mod tests {
platform.to_key()
);
}

#[cfg(target_os = "linux")]
#[test]
fn test_os_release_alpine_id_is_musl() {
let tmp = std::env::temp_dir().join("mise-libc-alpine");
std::fs::write(
&tmp,
"NAME=\"Alpine Linux\"\nID=alpine\nVERSION_ID=3.22.4\n",
)
.unwrap();
assert_eq!(musl_from_os_release(tmp.to_str().unwrap()), Some(true));
let _ = std::fs::remove_file(&tmp);
}

#[cfg(target_os = "linux")]
#[test]
fn test_os_release_id_like_alpine_is_musl() {
let tmp = std::env::temp_dir().join("mise-libc-id-like");
std::fs::write(&tmp, "ID=postmarketos\nID_LIKE=\"alpine\"\n").unwrap();
assert_eq!(musl_from_os_release(tmp.to_str().unwrap()), Some(true));
let _ = std::fs::remove_file(&tmp);
}

#[cfg(target_os = "linux")]
#[test]
fn test_os_release_debian_returns_none() {
let tmp = std::env::temp_dir().join("mise-libc-debian");
std::fs::write(&tmp, "ID=debian\nID_LIKE=\"\"\n").unwrap();
assert_eq!(musl_from_os_release(tmp.to_str().unwrap()), None);
let _ = std::fs::remove_file(&tmp);
}

#[cfg(target_os = "linux")]
#[test]
fn test_os_release_missing_returns_none() {
assert_eq!(musl_from_os_release("/nonexistent/os-release"), None);
}

#[cfg(target_os = "linux")]
#[test]
fn test_os_release_comments_and_blank_lines_do_not_short_circuit() {
// Regression: previously `split_once('=')?` returned None on the first
// comment or blank line, causing the function to ignore the `ID=` line
// that came after and silently fall back to linker-based detection.
let tmp = std::env::temp_dir().join("mise-libc-comments");
std::fs::write(
&tmp,
"# this is a comment\n\nNAME=\"Alpine Linux\"\nID=alpine\n",
)
.unwrap();
assert_eq!(musl_from_os_release(tmp.to_str().unwrap()), Some(true));
let _ = std::fs::remove_file(&tmp);
}

#[cfg(target_os = "linux")]
#[test]
fn test_os_release_whitespace_around_key_tolerated() {
let tmp = std::env::temp_dir().join("mise-libc-whitespace");
std::fs::write(&tmp, " ID = alpine \n").unwrap();
assert_eq!(musl_from_os_release(tmp.to_str().unwrap()), Some(true));
let _ = std::fs::remove_file(&tmp);
}
}
5 changes: 4 additions & 1 deletion src/plugins/vfox_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ impl VfoxPlugin {
pub fn vfox(&self) -> Result<(Vfox, mpsc::Receiver<String>)> {
let settings = Settings::get();
let env_type = if settings.os() == "linux" {
settings.libc().map(str::to_string)
settings
.libc()
.map(str::to_string)
.or_else(|| crate::platform::detect_libc().map(str::to_string))
} else {
None
};
Expand Down
Loading