From 5e801c2aa9e1b90c2a5463d72efb0a514079cc82 Mon Sep 17 00:00:00 2001 From: baby-joel Date: Mon, 13 Apr 2026 17:21:02 -0400 Subject: [PATCH 1/3] test: add failing test for empty MISE_OVERRIDE_CONFIG_FILENAMES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When MISE_OVERRIDE_CONFIG_FILENAMES is set to an empty string, split(':') produces [""] — a set containing one empty string. This non-empty set replaces the default config filename list, injecting "" as a config path. During Config::load, this empty path reaches config_root() which panics on path.parent().unwrap() because the absolutized empty string resolves to the filesystem root (/). The e2e test reproduces the exact panic seen in CI when vfox plugins spawn nested mise processes that inherit the parent's MISE_OVERRIDE_CONFIG_FILENAMES. Also adds a unit test documenting the correct filter pattern. --- .../test_override_config_filenames_empty | 23 ++++++++++++++++ src/env.rs | 26 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100755 e2e/config/test_override_config_filenames_empty diff --git a/e2e/config/test_override_config_filenames_empty b/e2e/config/test_override_config_filenames_empty new file mode 100755 index 0000000000..dd310ae3f1 --- /dev/null +++ b/e2e/config/test_override_config_filenames_empty @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Regression test: MISE_OVERRIDE_CONFIG_FILENAMES set to empty string +# should not cause a panic. An empty value should be treated the same +# as the variable being unset (i.e., use default config filenames). +# +# Background: split(':') on "" produces [""], a set with one empty-string +# entry. This non-empty set bypasses the default config filename list and +# injects "" as a config filename, which eventually causes a panic in +# config_root() during Config::load. +# +# See: https://github.com/jdx/mise/issues/XXXX + +echo 'tools.dummy = "1"' >mise.toml + +# Empty string — should behave like unset (use mise.toml) +MISE_OVERRIDE_CONFIG_FILENAMES="" assert_succeed "mise ls" + +# Colon-only — split produces ["", ""], both empty, should behave like unset +MISE_OVERRIDE_CONFIG_FILENAMES=":" assert_succeed "mise ls" + +# Trailing/leading colons — should ignore empty segments +echo 'tools.dummy = "2"' >mise.ci.toml +MISE_OVERRIDE_CONFIG_FILENAMES=":mise.ci.toml:" assert "mise ls dummy" "dummy 2 (missing) ~/workdir/mise.ci.toml 2" diff --git a/src/env.rs b/src/env.rs index c18af75726..ddae7dbc59 100644 --- a/src/env.rs +++ b/src/env.rs @@ -834,6 +834,32 @@ mod tests { remove_var("MISE_TEST_PATH"); } + #[test] + fn test_split_colon_filters_empty_segments() { + // Verify that split(':').filter(|s| !s.is_empty()) correctly + // handles empty strings, which is the pattern needed for + // MISE_OVERRIDE_CONFIG_FILENAMES and similar colon-separated + // env vars. Without the filter, "" produces [""] (length 1), + // which causes panics downstream in config_root(). + let cases: Vec<(&str, Vec<&str>)> = vec![ + ("", vec![]), // empty string + (":", vec![]), // colon only + (":::", vec![]), // multiple colons + ("mise.toml", vec!["mise.toml"]), // normal single + ("a:b", vec!["a", "b"]), // normal multi + (":a:b:", vec!["a", "b"]), // leading/trailing colons + ("a::b", vec!["a", "b"]), // consecutive colons + ]; + for (input, expected) in cases { + let result: Vec = input + .split(':') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + assert_eq!(result, expected, "input: {input:?}"); + } + } + #[test] fn test_token_overwrite() { // Clean up any existing environment variables that might interfere From f085403a68068f36971950686ffe73cf8dc380c4 Mon Sep 17 00:00:00 2001 From: baby-joel Date: Mon, 13 Apr 2026 17:22:48 -0400 Subject: [PATCH 2/3] fix: filter empty segments in colon-separated env var parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MISE_OVERRIDE_CONFIG_FILENAMES and MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES split their values on ':' but don't filter empty segments. When set to an empty string (e.g., by a parent process clearing it via VAR='' cmd), split(':') produces [""] — a one-element set containing an empty string. This non-empty set replaces the default config filename list and injects "" as a config path. During Config::load, the empty path absolutizes to the filesystem root, which has no parent directory, causing config_root() to panic on path.parent().unwrap(). Extract split_colon_list() helper that filters empty segments. Both env vars now use it. Also handles trailing/leading colons and consecutive colons gracefully. --- .../test_override_config_filenames_empty | 4 +- src/env.rs | 45 ++++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/e2e/config/test_override_config_filenames_empty b/e2e/config/test_override_config_filenames_empty index dd310ae3f1..425b44ec72 100755 --- a/e2e/config/test_override_config_filenames_empty +++ b/e2e/config/test_override_config_filenames_empty @@ -8,7 +8,7 @@ # injects "" as a config filename, which eventually causes a panic in # config_root() during Config::load. # -# See: https://github.com/jdx/mise/issues/XXXX +# See: https://github.com/jdx/mise/pull/9076 echo 'tools.dummy = "1"' >mise.toml @@ -20,4 +20,4 @@ MISE_OVERRIDE_CONFIG_FILENAMES=":" assert_succeed "mise ls" # Trailing/leading colons — should ignore empty segments echo 'tools.dummy = "2"' >mise.ci.toml -MISE_OVERRIDE_CONFIG_FILENAMES=":mise.ci.toml:" assert "mise ls dummy" "dummy 2 (missing) ~/workdir/mise.ci.toml 2" +MISE_OVERRIDE_CONFIG_FILENAMES=":mise.ci.toml:" assert "mise ls dummy" "dummy 2.0.0 (missing) ~/workdir/mise.ci.toml 2" diff --git a/src/env.rs b/src/env.rs index ddae7dbc59..20fd8a656b 100644 --- a/src/env.rs +++ b/src/env.rs @@ -244,14 +244,14 @@ pub static MISE_DEFAULT_CONFIG_FILENAME: Lazy = Lazy::new(|| { pub static MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES: Lazy>> = Lazy::new(|| match var("MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES") { Ok(v) if v == "none" => Some([].into()), - Ok(v) => Some(v.split(':').map(|s| s.to_string()).collect()), + Ok(v) => Some(split_colon_list(&v)), Err(_) => { miserc::get_override_tool_versions_filenames().map(|v| v.iter().cloned().collect()) } }); pub static MISE_OVERRIDE_CONFIG_FILENAMES: Lazy> = Lazy::new(|| match var("MISE_OVERRIDE_CONFIG_FILENAMES") { - Ok(v) => v.split(':').map(|s| s.to_string()).collect(), + Ok(v) => split_colon_list(&v), Err(_) => miserc::get_override_config_filenames() .map(|v| v.iter().cloned().collect()) .unwrap_or_default(), @@ -738,6 +738,18 @@ fn linux_glibc_version() -> Option<(u32, u32)> { None } +/// Split a colon-separated string into a set, filtering empty segments. +/// Empty segments arise from empty strings, leading/trailing colons, or +/// consecutive colons — all of which should be ignored rather than +/// injected as empty paths into config discovery. +fn split_colon_list(value: &str) -> IndexSet { + value + .split(':') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + fn filename(path: &str) -> &str { path.rsplit_once(path::MAIN_SEPARATOR_STR) .map(|(_, file)| file) @@ -835,27 +847,20 @@ mod tests { } #[test] - fn test_split_colon_filters_empty_segments() { - // Verify that split(':').filter(|s| !s.is_empty()) correctly - // handles empty strings, which is the pattern needed for - // MISE_OVERRIDE_CONFIG_FILENAMES and similar colon-separated - // env vars. Without the filter, "" produces [""] (length 1), - // which causes panics downstream in config_root(). + fn test_split_colon_list() { let cases: Vec<(&str, Vec<&str>)> = vec![ - ("", vec![]), // empty string - (":", vec![]), // colon only - (":::", vec![]), // multiple colons - ("mise.toml", vec!["mise.toml"]), // normal single - ("a:b", vec!["a", "b"]), // normal multi - (":a:b:", vec!["a", "b"]), // leading/trailing colons - ("a::b", vec!["a", "b"]), // consecutive colons + ("", vec![]), // empty string — was causing panic + (":", vec![]), // colon only + (":::", vec![]), // multiple colons + ("mise.toml", vec!["mise.toml"]), + ("a:b", vec!["a", "b"]), + (":a:b:", vec!["a", "b"]), // leading/trailing colons + ("a::b", vec!["a", "b"]), // consecutive colons ]; for (input, expected) in cases { - let result: Vec = input - .split(':') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); + let result = split_colon_list(input); + let expected: IndexSet = + expected.into_iter().map(|s| s.to_string()).collect(); assert_eq!(result, expected, "input: {input:?}"); } } From 4f5c5d8b46f1a16468d0c1237eb300789e317de8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:36:37 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- src/env.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/env.rs b/src/env.rs index 20fd8a656b..79b33ed072 100644 --- a/src/env.rs +++ b/src/env.rs @@ -849,18 +849,17 @@ mod tests { #[test] fn test_split_colon_list() { let cases: Vec<(&str, Vec<&str>)> = vec![ - ("", vec![]), // empty string — was causing panic - (":", vec![]), // colon only - (":::", vec![]), // multiple colons + ("", vec![]), // empty string — was causing panic + (":", vec![]), // colon only + (":::", vec![]), // multiple colons ("mise.toml", vec!["mise.toml"]), ("a:b", vec!["a", "b"]), - (":a:b:", vec!["a", "b"]), // leading/trailing colons - ("a::b", vec!["a", "b"]), // consecutive colons + (":a:b:", vec!["a", "b"]), // leading/trailing colons + ("a::b", vec!["a", "b"]), // consecutive colons ]; for (input, expected) in cases { let result = split_colon_list(input); - let expected: IndexSet = - expected.into_iter().map(|s| s.to_string()).collect(); + let expected: IndexSet = expected.into_iter().map(|s| s.to_string()).collect(); assert_eq!(result, expected, "input: {input:?}"); } }