Skip to main content

ssh_cli/
locale.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Cross-platform language detection and resolution (Rules Rust i18n).
5//!
6//! ## Precedence (highest → lowest) — four product layers
7//!
8//! 1. CLI `--lang` flag (validated BCP47; must negotiate to a supported locale)
9//! 2. Persisted preference (`<config_dir>/lang`, XDG; 0o600 when written) via `locale set`
10//! 3. OS locale via `sys_locale::get_locale()` (never raw `LANG` / `LC_*` in portable code)
11//! 4. Fallback: [`Language::English`] (`en`)
12//!
13//! `SSH_CLI_LANG` is a **historical constant name only** — not read as a product
14//! config store (G-AUD-12). Use `--lang` or `locale set`.
15//!
16//! ## Pipeline
17//!
18//! Raw string → strip encoding/modifier → `_`→`-` → `LanguageIdentifier`
19//! (`unic-langid`) → negotiate against available locales (`fluent-langneg`) →
20//! map to [`Language`] → publish once in `OnceLock`.
21//!
22//! Detection failure is **never** silent: `tracing::warn!` records the miss
23//! (observability / stderr diagnostics; no remote telemetry).
24
25use std::path::{Path, PathBuf};
26use std::str::FromStr;
27use std::sync::OnceLock;
28
29use fluent_langneg::{negotiate_languages, NegotiationStrategy};
30use unic_langid::LanguageIdentifier;
31
32use crate::i18n::Language;
33
34/// Global language state — set once at initialization.
35///
36/// Concurrent access: `OnceLock` (single writer at boot via `set_language`;
37/// subsequent sets ignored). `Language` is `Copy + Sync`.
38static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
39
40/// Which precedence layer won resolution (diagnostics / `locale` subcommand).
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42#[non_exhaustive]
43pub enum LocaleSource {
44    /// CLI `--lang`.
45    CliFlag,
46    /// Historical layer id only — product no longer reads `SSH_CLI_LANG` as a store.
47    EnvVar,
48    /// XDG (or override) `lang` preference file (`locale set`).
49    Persisted,
50    /// `sys_locale::get_locale()`.
51    System,
52    /// Deterministic default (`en`).
53    Default,
54}
55
56impl LocaleSource {
57    /// Stable machine id for JSON / tests.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::CliFlag => "cli_flag",
62            Self::EnvVar => "env_var",
63            Self::Persisted => "persisted",
64            Self::System => "system",
65            Self::Default => "default",
66        }
67    }
68}
69
70/// Full resolution result (language + winning layer + raw inputs for diagnostics).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct LocaleResolution {
73    /// Negotiated product language.
74    pub language: Language,
75    /// Winning precedence layer.
76    pub source: LocaleSource,
77    /// Raw OS locale string when layer was system (if any).
78    pub system_raw: Option<String>,
79    /// Raw persisted file content when present.
80    pub persisted_raw: Option<String>,
81}
82
83/// File name for the persisted language preference (sibling of `config.toml`).
84pub const LANG_PREFERENCE_FILE: &str = crate::constants::LANG_PREFERENCE_FILE_NAME;
85
86/// Resolves language using the CLI / XDG / OS / default precedence hierarchy.
87///
88/// `config_dir_override` is the same optional directory used by `--config-dir`
89/// (tests / isolation). When `None`, XDG via `directories` applies.
90/// Product does **not** read `SSH_CLI_HOME` or `SSH_CLI_LANG` as config stores.
91#[must_use]
92pub fn resolve_language(force_lang: Option<&str>, config_dir_override: Option<&Path>) -> Language {
93    resolve_language_detailed(force_lang, config_dir_override).language
94}
95
96/// Like [`resolve_language`], but returns diagnostics for `locale` / tests.
97#[must_use]
98pub fn resolve_language_detailed(
99    force_lang: Option<&str>,
100    config_dir_override: Option<&Path>,
101) -> LocaleResolution {
102    let system_raw = sys_locale::get_locale();
103    let persisted_raw = read_persisted_lang(config_dir_override);
104
105    // Layer 1: CLI --lang
106    if let Some(code) = force_lang {
107        match negotiate_code(code) {
108            Some(language) => {
109                return LocaleResolution {
110                    language,
111                    source: LocaleSource::CliFlag,
112                    system_raw,
113                    persisted_raw,
114                };
115            }
116            None => {
117                tracing::warn!(
118                    target: "ssh_cli::locale",
119                    code,
120                    "invalid or unsupported --lang; falling through precedence"
121                );
122            }
123        }
124    }
125
126    // G-AUD-12: env lang store removed — use `--lang` or `locale set` (XDG).
127
128    // Layer 2: persisted preference (was layer 3)
129    if let Some(ref raw) = persisted_raw {
130        match negotiate_code(raw) {
131            Some(language) => {
132                return LocaleResolution {
133                    language,
134                    source: LocaleSource::Persisted,
135                    system_raw,
136                    persisted_raw,
137                };
138            }
139            None => {
140                tracing::warn!(
141                    target: "ssh_cli::locale",
142                    code = %raw,
143                    "persisted lang preference unsupported; falling through"
144                );
145            }
146        }
147    }
148
149    // Layer 4: OS via sys-locale (cross-platform abstraction — never raw LANG)
150    if let Some(ref locale) = system_raw {
151        match negotiate_code(locale) {
152            Some(language) => {
153                return LocaleResolution {
154                    language,
155                    source: LocaleSource::System,
156                    system_raw,
157                    persisted_raw,
158                };
159            }
160            None => {
161                tracing::warn!(
162                    target: "ssh_cli::locale",
163                    system_locale = %locale,
164                    "OS locale did not negotiate to a supported language; using default en"
165                );
166            }
167        }
168    } else {
169        tracing::warn!(
170            target: "ssh_cli::locale",
171            "sys_locale::get_locale returned None (container/distroless/WASM); using default en"
172        );
173    }
174
175    // Layer 5: deterministic default
176    LocaleResolution {
177        language: Language::English,
178        source: LocaleSource::Default,
179        system_raw,
180        persisted_raw,
181    }
182}
183
184/// Sets the global language (once at process startup).
185///
186/// Subsequent calls are silently ignored — `OnceLock` guarantees immutability
187/// after first set (no mixed languages in one session).
188pub fn set_language(language: Language) {
189    let _ = GLOBAL_LANGUAGE.set(language);
190}
191
192/// Returns the current global language.
193///
194/// If `set_language` has not been called yet, returns [`Language::English`]
195/// as a safe fallback for code run before initialization.
196#[must_use]
197pub fn current_language() -> Language {
198    GLOBAL_LANGUAGE.get().copied().unwrap_or(Language::English)
199}
200
201/// Normalizes a raw locale string for BCP47 parse.
202///
203/// - Strips encoding suffix (`.UTF-8`, `.utf8`)
204/// - Strips `@modifier` (e.g. `@euro`)
205/// - Converts `_` separators to `-`
206/// - Trims whitespace
207///
208/// Does **not** treat `C` / `POSIX` / `C.UTF-8` as English — those parse as
209/// invalid/unsupported and fall through negotiation.
210#[must_use]
211pub fn normalize_raw_locale(raw: &str) -> String {
212    let s = raw.trim();
213    let s = s.split('.').next().unwrap_or(s);
214    let s = s.split('@').next().unwrap_or(s);
215    s.replace('_', "-")
216}
217
218/// Parses a raw OS/CLI locale into a [`LanguageIdentifier`].
219///
220/// Returns `None` for empty, `C`, `POSIX`, or malformed tags.
221#[must_use]
222pub fn parse_language_identifier(raw: &str) -> Option<LanguageIdentifier> {
223    let normalized = normalize_raw_locale(raw);
224    if normalized.is_empty() {
225        return None;
226    }
227    // POSIX "C" / "POSIX" are not user language preferences.
228    if normalized.eq_ignore_ascii_case("c") || normalized.eq_ignore_ascii_case("posix") {
229        return None;
230    }
231    LanguageIdentifier::from_str(&normalized).ok()
232}
233
234/// Negotiates a raw code against the available product locales.
235///
236/// Uses `fluent-langneg` Lookup strategy with default `en`.
237#[must_use]
238pub fn negotiate_code(raw: &str) -> Option<Language> {
239    let requested = parse_language_identifier(raw)?;
240    negotiate_langid(&requested)
241}
242
243/// Negotiates a structured identifier against available locales.
244#[must_use]
245pub fn negotiate_langid(requested: &LanguageIdentifier) -> Option<Language> {
246    let available: Vec<LanguageIdentifier> = Language::AVAILABLE
247        .iter()
248        .map(|l| l.language_identifier())
249        .collect();
250    let default = Language::English.language_identifier();
251    let supported = negotiate_languages(
252        std::slice::from_ref(requested),
253        &available,
254        Some(&default),
255        NegotiationStrategy::Lookup,
256    );
257    let first = supported.first()?;
258    // If only default was returned because request was unrelated (e.g. fr-FR),
259    // treat as no match so callers can fall through — unless request itself is en.
260    if let Some(lang) = Language::from_langid(first) {
261        // When Lookup returns default for unsupported languages, detect that
262        // the requested primary language is not en/pt.
263        let req_lang = requested.language.as_str();
264        if lang == Language::English
265            && req_lang != "en"
266            && !Language::AVAILABLE
267                .iter()
268                .any(|a| a.language_identifier().language == requested.language)
269        {
270            return None;
271        }
272        return Some(lang);
273    }
274    None
275}
276
277/// Directory that holds `config.toml` / `lang` (respects override and XDG).
278#[must_use]
279pub fn resolve_config_dir(config_dir_override: Option<&Path>) -> Option<PathBuf> {
280    if let Some(p) = config_dir_override {
281        if p.is_dir() || !p.exists() {
282            return Some(p.to_path_buf());
283        }
284        // File path → parent dir
285        return p.parent().map(|d| d.to_path_buf());
286    }
287    crate::vps::default_config_path()
288        .ok()
289        .and_then(|cfg| cfg.parent().map(|d| d.to_path_buf()))
290}
291
292/// Path to the persisted language preference file.
293#[must_use]
294pub fn lang_preference_path(config_dir_override: Option<&Path>) -> Option<PathBuf> {
295    resolve_config_dir(config_dir_override).map(|d| d.join(LANG_PREFERENCE_FILE))
296}
297
298/// Reads the persisted language preference (trimmed, non-empty).
299#[must_use]
300pub fn read_persisted_lang(config_dir_override: Option<&Path>) -> Option<String> {
301    let path = lang_preference_path(config_dir_override)?;
302    let content = std::fs::read_to_string(&path).ok()?;
303    let trimmed = content.trim();
304    if trimmed.is_empty() {
305        None
306    } else {
307        Some(trimmed.to_string())
308    }
309}
310
311/// Writes the persisted language preference (BCP47 of a supported language).
312///
313/// Creates the config directory if needed. On Unix, sets mode `0o600`.
314///
315/// # Errors
316/// Returns I/O errors from create/write/permissions.
317pub fn write_persisted_lang(
318    language: Language,
319    config_dir_override: Option<&Path>,
320) -> std::io::Result<PathBuf> {
321    let dir = resolve_config_dir(config_dir_override).ok_or_else(|| {
322        std::io::Error::new(
323            std::io::ErrorKind::NotFound,
324            "configuration directory unavailable",
325        )
326    })?;
327    std::fs::create_dir_all(&dir)?;
328    let path = dir.join(LANG_PREFERENCE_FILE);
329    let body = format!("{}\n", language.bcp47());
330    std::fs::write(&path, body.as_bytes())?;
331    crate::fs_perm::set_secret_file_mode(&path).map_err(|e| match e {
332        crate::errors::SshCliError::Io(io) => io,
333        other => std::io::Error::other(other.to_string()),
334    })?;
335    Ok(path)
336}
337
338/// Removes the persisted language preference if present.
339///
340/// # Errors
341/// Propagates unexpected I/O errors (ignores NotFound).
342pub fn clear_persisted_lang(config_dir_override: Option<&Path>) -> std::io::Result<()> {
343    if let Some(path) = lang_preference_path(config_dir_override) {
344        match std::fs::remove_file(&path) {
345            Ok(()) => Ok(()),
346            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
347            Err(e) => Err(e),
348        }
349    } else {
350        Ok(())
351    }
352}
353
354/// Clap `value_parser` for `--lang`: accepts BCP47 tags that negotiate to a
355/// supported product locale (`en`, `en-US`, `pt-BR`, `pt`, …).
356pub fn parse_lang_cli_arg(s: &str) -> Result<String, String> {
357    match negotiate_code(s) {
358        Some(lang) => Ok(lang.bcp47().to_string()),
359        None => Err(format!(
360            "unsupported language '{s}' (supported: en, pt-BR; BCP47 tags that negotiate to these)"
361        )),
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn normalize_strips_encoding_and_underscore() {
371        assert_eq!(normalize_raw_locale("pt_BR.UTF-8"), "pt-BR");
372        assert_eq!(normalize_raw_locale("en_US.utf8"), "en-US");
373        assert_eq!(normalize_raw_locale("de_DE@euro"), "de-DE");
374        assert_eq!(normalize_raw_locale("  pt-BR  "), "pt-BR");
375    }
376
377    #[test]
378    fn parse_rejects_c_and_posix() {
379        assert!(parse_language_identifier("C").is_none());
380        assert!(parse_language_identifier("POSIX").is_none());
381        assert!(parse_language_identifier("C.UTF-8").is_none());
382        assert!(parse_language_identifier("").is_none());
383    }
384
385    #[test]
386    fn parse_accepts_bcp47_variants() {
387        assert!(parse_language_identifier("pt-BR").is_some());
388        assert!(parse_language_identifier("pt_BR.UTF-8").is_some());
389        assert!(parse_language_identifier("en-GB").is_some());
390        assert!(parse_language_identifier("en").is_some());
391    }
392
393    #[test]
394    fn negotiate_pt_br_and_prefixes() {
395        assert_eq!(negotiate_code("pt-BR"), Some(Language::Portuguese));
396        assert_eq!(negotiate_code("pt_BR.UTF-8"), Some(Language::Portuguese));
397        assert_eq!(negotiate_code("pt"), Some(Language::Portuguese));
398        assert_eq!(negotiate_code("en-US"), Some(Language::English));
399        assert_eq!(negotiate_code("en-GB"), Some(Language::English));
400        assert_eq!(negotiate_code("en"), Some(Language::English));
401    }
402
403    #[test]
404    fn negotiate_unsupported_returns_none() {
405        assert_eq!(negotiate_code("fr-FR"), None);
406        assert_eq!(negotiate_code("zh-Hans-CN"), None);
407        assert_eq!(negotiate_code("xx-YY"), None);
408        assert_eq!(negotiate_code("C"), None);
409    }
410
411    #[test]
412    fn parse_lang_cli_arg_ok_and_err() {
413        assert_eq!(parse_lang_cli_arg("pt-BR").unwrap(), "pt-BR");
414        assert_eq!(parse_lang_cli_arg("en").unwrap(), "en");
415        assert!(parse_lang_cli_arg("fr-FR").is_err());
416    }
417
418    #[test]
419    fn resolve_force_pt_returns_portuguese() {
420        let result = resolve_language(Some("pt-BR"), None);
421        assert_eq!(result, Language::Portuguese);
422    }
423
424    #[test]
425    fn resolve_force_en_returns_english() {
426        let result = resolve_language(Some("en-US"), None);
427        assert_eq!(result, Language::English);
428    }
429
430    #[test]
431    fn resolve_force_invalid_falls_through() {
432        crate::test_util::env::remove_var(crate::constants::ENV_LANG);
433        let result = resolve_language_detailed(Some("xx-YY"), None);
434        assert!(
435            result.language == Language::English || result.language == Language::Portuguese,
436            "must return a valid language"
437        );
438        assert_ne!(result.source, LocaleSource::CliFlag);
439    }
440
441    #[test]
442    fn resolve_without_force_returns_valid_language() {
443        crate::test_util::env::remove_var(crate::constants::ENV_LANG);
444        let result = resolve_language(None, None);
445        assert!(
446            result == Language::English || result == Language::Portuguese,
447            "resolve_language must return a valid language"
448        );
449    }
450
451    #[test]
452    fn current_language_fallback_english_before_set() {
453        let result = current_language();
454        assert!(
455            result == Language::English || result == Language::Portuguese,
456            "current_language must return a valid language"
457        );
458    }
459
460    #[test]
461    fn locale_source_as_str_stable() {
462        assert_eq!(LocaleSource::CliFlag.as_str(), "cli_flag");
463        assert_eq!(LocaleSource::Default.as_str(), "default");
464    }
465
466    #[test]
467    fn persisted_lang_roundtrip() {
468        let dir = tempfile::tempdir().expect("tempdir");
469        let path = write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
470        assert!(path.exists());
471        let raw = read_persisted_lang(Some(dir.path())).expect("read");
472        assert_eq!(negotiate_code(&raw), Some(Language::Portuguese));
473        clear_persisted_lang(Some(dir.path())).expect("clear");
474        assert!(read_persisted_lang(Some(dir.path())).is_none());
475    }
476
477    #[test]
478    fn resolve_uses_persisted_when_no_flag_or_env() {
479        crate::test_util::env::remove_var(crate::constants::ENV_LANG);
480        let dir = tempfile::tempdir().expect("tempdir");
481        write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
482        let r = resolve_language_detailed(None, Some(dir.path()));
483        // Persisted wins over system when flag/env absent — unless system somehow
484        // is forced; with force None and no env, source should be Persisted.
485        assert_eq!(r.language, Language::Portuguese);
486        assert_eq!(r.source, LocaleSource::Persisted);
487    }
488}