From 23cf6f042d925efbcb108473490020909e6c01a0 Mon Sep 17 00:00:00 2001 From: John Costa Date: Thu, 9 Jul 2026 13:20:34 -0700 Subject: [PATCH 1/3] locale: honor LC_ALL and LC_MESSAGES precedence over LANG (#8922) detect_system_locale() only read LANG, ignoring LC_ALL and LC_MESSAGES. POSIX message-locale precedence is LC_ALL > LC_MESSAGES > LANG: the first of those that is set and non-empty wins (set-but-empty is treated as unset), falling back to en-US when none qualify. Extract the precedence + encoding-stripping into a pure resolve_locale_string helper that takes an env lookup closure, so the logic is unit-tested without mutating process-global env vars (which race under parallel test threads). detect_system_locale() passes std::env::var through it, preserving the exact old string handling (only which variable supplies the value changed). --- src/uucore/src/lib/mods/locale.rs | 83 ++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 73bdb8b235..4b3d1c87aa 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -424,14 +424,35 @@ pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String { get_message_internal(id, Some(ftl_args)) } -/// Function to detect system locale from environment variables -fn detect_system_locale() -> Result { - let locale_str = std::env::var("LANG") - .unwrap_or_else(|_| DEFAULT_LOCALE.to_string()) +/// Resolve the locale string from an environment lookup, applying the +/// POSIX message-locale precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. +/// +/// The first of those variables that is set AND non-empty wins; a +/// set-but-empty value is treated as unset. If none qualify, falls back to +/// [`DEFAULT_LOCALE`]. The winning value is stripped of any `.` +/// suffix (e.g. `fr_FR.UTF-8` -> `fr_FR`), matching the previous `LANG`-only +/// behavior exactly (no additional normalization is applied). +/// +/// `lookup` is injected so the precedence logic is unit-testable without +/// mutating process-global env vars (which races under parallel test threads). +fn resolve_locale_string(lookup: impl Fn(&str) -> Option) -> String { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .find_map(|key| lookup(key).filter(|value| !value.is_empty())) + .as_deref() + .unwrap_or(DEFAULT_LOCALE) .split('.') .next() .unwrap_or(DEFAULT_LOCALE) - .to_string(); + .to_string() +} + +/// Function to detect system locale from environment variables. +/// +/// Honors the POSIX message-locale precedence `LC_ALL` > `LC_MESSAGES` > +/// `LANG` (see [`resolve_locale_string`]). +fn detect_system_locale() -> Result { + let locale_str = resolve_locale_string(|key| std::env::var(key).ok()); LanguageIdentifier::from_str(&locale_str).map_err(|_| { LocalizationError::ParseLocale(format!("Failed to parse locale: {locale_str}")) }) @@ -441,7 +462,8 @@ fn detect_system_locale() -> Result { /// Always loads common strings in addition to utility-specific strings. /// /// This function initializes the localization system based on the system's locale -/// preferences (via the LANG environment variable) or falls back to English +/// preferences (via the `LC_ALL` > `LC_MESSAGES` > `LANG` environment variables, +/// in that precedence order) or falls back to English /// if the system locale cannot be determined or the locale file doesn't exist. /// English is always loaded as a fallback. /// @@ -1379,6 +1401,55 @@ invalid-syntax = This is { $missing .unwrap(); } + /// Build an env lookup closure from a fixed list of key/value pairs so the + /// precedence logic can be tested without touching process-global env vars. + fn env_lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let owned: Vec<(String, String)> = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |key: &str| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) + } + + #[test] + fn test_resolve_locale_lc_all_wins_over_unset() { + // Issue #8922 repro: LC_ALL set, LC_MESSAGES/LANG unset. + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_lc_messages_when_lc_all_unset() { + let resolved = resolve_locale_string(env_lookup(&[("LC_MESSAGES", "de_DE.UTF-8")])); + assert_eq!(resolved, "de_DE"); + } + + #[test] + fn test_resolve_locale_lang_still_works() { + // Regression: LANG alone must keep working. + let resolved = resolve_locale_string(env_lookup(&[("LANG", "es_ES.UTF-8")])); + assert_eq!(resolved, "es_ES"); + } + + #[test] + fn test_resolve_locale_empty_treated_as_unset() { + // Set-but-empty LC_ALL is skipped in favor of a non-empty LANG. + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", ""), ("LANG", "fr_FR")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_lc_all_precedence_over_lang() { + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR"), ("LANG", "es_ES")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_nothing_set_falls_back_to_default() { + let resolved = resolve_locale_string(|_| None); + assert_eq!(resolved, DEFAULT_LOCALE); + } + #[test] fn test_detect_system_locale_from_lang_env() { // Test locale parsing logic directly instead of relying on environment variables From 9cc0982709c9cd880769e93f7eeb9432c2b19d92 Mon Sep 17 00:00:00 2001 From: John Costa Date: Thu, 9 Jul 2026 13:28:50 -0700 Subject: [PATCH 2/3] locale: test LC_ALL precedence over LC_MESSAGES (#8922) --- src/uucore/src/lib/mods/locale.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 4b3d1c87aa..6b8959535e 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -1444,6 +1444,13 @@ invalid-syntax = This is { $missing assert_eq!(resolved, "fr_FR"); } + #[test] + fn test_resolve_locale_lc_all_precedence_over_lc_messages() { + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR"), ("LC_MESSAGES", "de_DE")])); + assert_eq!(resolved, "fr_FR"); + } + #[test] fn test_resolve_locale_nothing_set_falls_back_to_default() { let resolved = resolve_locale_string(|_| None); From c0463bf841a5521de2cceb64cdf91363055c9751 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 11 Jul 2026 16:11:04 -0700 Subject: [PATCH 3/3] locale: honor LANGUAGE with gettext semantics The GNU testsuite -mb runs set LANGUAGE=C LANG=C before overriding LC_ALL to fr_FR.UTF-8, and GNU tools stay English there because gettext gives LANGUAGE priority over LC_ALL. Walk LANGUAGE for the first usable entry (trimmed, codeset-stripped); C/POSIX entries and a C/POSIX resolved locale (including C.UTF-8) resolve to English explicitly. Deliberate divergences from gettext's per-catalog walk are documented in the doc comment. Also fix test_env_french and test_chmod_colored_output, which set only LANG and relied on the LANG-only lookup this branch removes; the harness's default LC_ALL=C now correctly outranks it. --- src/uucore/src/lib/mods/locale.rs | 198 ++++++++++++++++++++++++++++-- tests/by-util/test_chmod.rs | 2 +- tests/by-util/test_env.rs | 14 ++- 3 files changed, 205 insertions(+), 9 deletions(-) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 6b8959535e..a3de863154 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -433,18 +433,57 @@ pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String { /// suffix (e.g. `fr_FR.UTF-8` -> `fr_FR`), matching the previous `LANG`-only /// behavior exactly (no additional normalization is applied). /// +/// On top of that, `LANGUAGE` is honored with gettext semantics: it is a +/// colon-separated priority list of languages that overrides the resolved +/// locale for message translation, but only when the locale resolved from +/// `LC_ALL`/`LC_MESSAGES`/`LANG` is not `C`/`POSIX` (gettext ignores +/// `LANGUAGE` in the C locale). `LANGUAGE` is likewise disabled when none of +/// `LC_ALL`/`LC_MESSAGES`/`LANG` is set, since an unset locale is the C +/// locale. A resolved locale of `C`/`POSIX`, and an entry of `C` or `POSIX` +/// in the list, both mean "no translation" and resolve to +/// [`DEFAULT_LOCALE`] (English) explicitly. Each `LANGUAGE` entry is trimmed +/// and stripped of any `.` suffix before validation. Only the +/// first usable entry is taken; there is no per-entry translation-catalog +/// fallback like gettext's full walk. Deliberate divergence: when every +/// `LANGUAGE` entry is unusable, we fall back to the resolved locale, +/// whereas gettext would emit untranslated msgids instead. +/// /// `lookup` is injected so the precedence logic is unit-testable without /// mutating process-global env vars (which races under parallel test threads). fn resolve_locale_string(lookup: impl Fn(&str) -> Option) -> String { - ["LC_ALL", "LC_MESSAGES", "LANG"] + let Some(resolved) = ["LC_ALL", "LC_MESSAGES", "LANG"] .into_iter() .find_map(|key| lookup(key).filter(|value| !value.is_empty())) - .as_deref() - .unwrap_or(DEFAULT_LOCALE) - .split('.') - .next() - .unwrap_or(DEFAULT_LOCALE) - .to_string() + else { + return DEFAULT_LOCALE.to_string(); + }; + let resolved = resolved.split('.').next().unwrap_or(DEFAULT_LOCALE); + + // gettext semantics: a C/POSIX locale means "no translation" (English + // here) and disables LANGUAGE entirely. + if resolved == "C" || resolved == "POSIX" { + return DEFAULT_LOCALE.to_string(); + } + + // init_localization resolves a single locale, so take the first usable + // LANGUAGE entry rather than walking the list per-catalog like gettext. + if let Some(language) = lookup("LANGUAGE").filter(|value| !value.is_empty()) { + for entry in language.split(':') { + let entry = entry.trim().split('.').next().unwrap_or_default(); + if entry.is_empty() { + continue; + } + if entry == "C" || entry == "POSIX" { + // gettext: "no translation" requested, use English. + return DEFAULT_LOCALE.to_string(); + } + if LanguageIdentifier::from_str(entry).is_ok() { + return entry.to_string(); + } + } + } + + resolved.to_string() } /// Function to detect system locale from environment variables. @@ -1457,6 +1496,151 @@ invalid-syntax = This is { $missing assert_eq!(resolved, DEFAULT_LOCALE); } + #[test] + fn test_resolve_locale_language_wins_over_lc_all() { + // gettext: LANGUAGE has priority over LC_ALL for messages when the + // resolved locale is not C/POSIX. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "de_DE"), + ])); + assert_eq!(resolved, "de_DE"); + } + + #[test] + fn test_resolve_locale_language_ignored_when_locale_is_c() { + // gettext: the C locale disables LANGUAGE entirely AND means "no + // translation", so it resolves to English (DEFAULT_LOCALE) explicitly. + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "C"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_c_forces_english_despite_lc_all_fr() { + // Exact GNU testsuite -mb harness scenario: LANGUAGE=C LANG=C are set + // first, then only LC_ALL is overridden to fr_FR.UTF-8. The resolved + // locale is fr_FR (not C), so LANGUAGE applies, and a LANGUAGE entry + // of C means "no translation": DEFAULT_LOCALE (English) is returned + // explicitly, matching GNU. + let resolved = resolve_locale_string(env_lookup(&[ + ("LANGUAGE", "C"), + ("LANG", "C"), + ("LC_ALL", "fr_FR.UTF-8"), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_entry_codeset_stripped() { + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "es_ES.UTF-8"), + ("LANGUAGE", "fr_FR.UTF-8:de_DE"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_ignored_when_locale_is_posix() { + // Like C, a POSIX locale disables LANGUAGE and resolves to English + // (DEFAULT_LOCALE) explicitly. + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "POSIX"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_c_with_codeset_disables_language() { + // The C/POSIX gate compares the codeset-stripped locale, so C.UTF-8 + // (the default on Debian and most containers) also disables LANGUAGE, + // matching gettext's "C." rule. + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "C.UTF-8"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_invalid_entry_falls_through() { + // An unparsable entry must not clobber a later valid one. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "es_ES"), + ("LANGUAGE", "not!valid:fr_FR"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_only_empty_entries_ignored() { + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "::")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_posix_entry_forces_english() { + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "POSIX"), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_entry_whitespace_trimmed() { + // " C " must be recognized as C after trimming, not fall through to + // the French locale as an unparsable entry. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", " C "), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_c_with_codeset_forces_english() { + // The codeset suffix is stripped before the C/POSIX comparison. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "C.UTF-8"), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_all_invalid_falls_back_to_locale() { + // Deliberate divergence from gettext: an entirely unusable LANGUAGE + // list falls back to the resolved locale (gettext would emit + // untranslated msgids instead). + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "not!valid"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_list_takes_first_nonempty_entry() { + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "es_ES.UTF-8"), + ("LANGUAGE", ":fr_FR:de_DE"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_empty_language_ignored() { + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_ignored_when_no_locale_env_set() { + // gettext consults LANGUAGE only when a locale is actually set via + // LC_ALL/LC_MESSAGES/LANG (an unset locale is C, which disables it). + let resolved = resolve_locale_string(env_lookup(&[("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + #[test] fn test_detect_system_locale_from_lang_env() { // Test locale parsing logic directly instead of relying on environment variables diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index a1ac2b2b1d..19439b7ea7 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -1414,7 +1414,7 @@ fn test_chmod_colored_output() { new_ucmd!() .arg("--invalid-option") .env("CLICOLOR_FORCE", "1") - .env("LANG", "fr_FR.UTF-8") + .env("LC_ALL", "fr_FR.UTF-8") .fails() .code_is(1) .stderr_contains("\x1b[31merreur\x1b[0m") // Red "erreur" in French diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index 8756550fa2..3b9d683163 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -1958,11 +1958,23 @@ fn test_simulation_of_terminal_pty_write_in_data_and_sends_eot_automatically() { fn test_env_french() { new_ucmd!() .arg("--verbo") - .env("LANG", "fr_FR") + .env("LC_ALL", "fr_FR") .fails() .stderr_contains("erreur : argument inattendu"); } +#[test] +fn test_env_language_c_overrides_lc_all() { + // GNU testsuite -mb harness scenario: LANGUAGE=C keeps output English + // even though LC_ALL selects a French locale (gettext semantics). + new_ucmd!() + .arg("--verbo") + .env("LC_ALL", "fr_FR") + .env("LANGUAGE", "C") + .fails() + .stderr_contains("error: unexpected argument"); +} + #[test] fn test_shebang_error() { new_ucmd!()