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 portável)
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(
93    force_lang: Option<&str>,
94    config_dir_override: Option<&Path>,
95) -> Language {
96    resolve_language_detailed(force_lang, config_dir_override).language
97}
98
99/// Like [`resolve_language`], but returns diagnostics for `locale` / tests.
100#[must_use]
101pub fn resolve_language_detailed(
102    force_lang: Option<&str>,
103    config_dir_override: Option<&Path>,
104) -> LocaleResolution {
105    let system_raw = sys_locale::get_locale();
106    let persisted_raw = read_persisted_lang(config_dir_override);
107
108    // Layer 1: CLI --lang
109    if let Some(code) = force_lang {
110        match negotiate_code(code) {
111            Some(language) => {
112                return LocaleResolution {
113                    language,
114                    source: LocaleSource::CliFlag,
115                    system_raw,
116                    persisted_raw,
117                };
118            }
119            None => {
120                tracing::warn!(
121                    target: "ssh_cli::locale",
122                    code,
123                    "invalid or unsupported --lang; falling through precedence"
124                );
125            }
126        }
127    }
128
129    // G-AUD-12: env lang store removed — use `--lang` or `locale set` (XDG).
130
131    // Layer 2: persisted preference (was layer 3)
132    if let Some(ref raw) = persisted_raw {
133        match negotiate_code(raw) {
134            Some(language) => {
135                return LocaleResolution {
136                    language,
137                    source: LocaleSource::Persisted,
138                    system_raw,
139                    persisted_raw,
140                };
141            }
142            None => {
143                tracing::warn!(
144                    target: "ssh_cli::locale",
145                    code = %raw,
146                    "persisted lang preference unsupported; falling through"
147                );
148            }
149        }
150    }
151
152    // Layer 4: OS via sys-locale (cross-platform abstraction — never raw LANG)
153    if let Some(ref locale) = system_raw {
154        match negotiate_code(locale) {
155            Some(language) => {
156                return LocaleResolution {
157                    language,
158                    source: LocaleSource::System,
159                    system_raw,
160                    persisted_raw,
161                };
162            }
163            None => {
164                tracing::warn!(
165                    target: "ssh_cli::locale",
166                    system_locale = %locale,
167                    "OS locale did not negotiate to a supported language; using default en"
168                );
169            }
170        }
171    } else {
172        tracing::warn!(
173            target: "ssh_cli::locale",
174            "sys_locale::get_locale returned None (container/distroless/WASM); using default en"
175        );
176    }
177
178    // Layer 5: deterministic default
179    LocaleResolution {
180        language: Language::English,
181        source: LocaleSource::Default,
182        system_raw,
183        persisted_raw,
184    }
185}
186
187/// Sets the global language (once at process startup).
188///
189/// Subsequent calls are silently ignored — `OnceLock` guarantees immutability
190/// after first set (no mixed languages in one session).
191pub fn set_language(language: Language) {
192    let _ = GLOBAL_LANGUAGE.set(language);
193}
194
195/// Returns the current global language.
196///
197/// If `set_language` has not been called yet, returns [`Language::English`]
198/// as a safe fallback for code run before initialization.
199#[must_use]
200pub fn current_language() -> Language {
201    GLOBAL_LANGUAGE.get().copied().unwrap_or(Language::English)
202}
203
204/// Normalizes a raw locale string for BCP47 parse.
205///
206/// - Strips encoding suffix (`.UTF-8`, `.utf8`)
207/// - Strips `@modifier` (e.g. `@euro`)
208/// - Converts `_` separators to `-`
209/// - Trims whitespace
210///
211/// Does **not** treat `C` / `POSIX` / `C.UTF-8` as English — those parse as
212/// invalid/unsupported and fall through negotiation.
213#[must_use]
214pub fn normalize_raw_locale(raw: &str) -> String {
215    let s = raw.trim();
216    let s = s.split('.').next().unwrap_or(s);
217    let s = s.split('@').next().unwrap_or(s);
218    s.replace('_', "-")
219}
220
221/// Parses a raw OS/CLI locale into a [`LanguageIdentifier`].
222///
223/// Returns `None` for empty, `C`, `POSIX`, or malformed tags.
224#[must_use]
225pub fn parse_language_identifier(raw: &str) -> Option<LanguageIdentifier> {
226    let normalized = normalize_raw_locale(raw);
227    if normalized.is_empty() {
228        return None;
229    }
230    // POSIX "C" / "POSIX" are not user language preferences.
231    if normalized.eq_ignore_ascii_case("c") || normalized.eq_ignore_ascii_case("posix") {
232        return None;
233    }
234    LanguageIdentifier::from_str(&normalized).ok()
235}
236
237/// Negotiates a raw code against the available product locales.
238///
239/// Uses `fluent-langneg` Lookup strategy with default `en`.
240#[must_use]
241pub fn negotiate_code(raw: &str) -> Option<Language> {
242    let requested = parse_language_identifier(raw)?;
243    negotiate_langid(&requested)
244}
245
246/// Negotiates a structured identifier against available locales.
247#[must_use]
248pub fn negotiate_langid(requested: &LanguageIdentifier) -> Option<Language> {
249    let available: Vec<LanguageIdentifier> = Language::AVAILABLE
250        .iter()
251        .map(|l| l.language_identifier())
252        .collect();
253    let default = Language::English.language_identifier();
254    let supported = negotiate_languages(
255        std::slice::from_ref(requested),
256        &available,
257        Some(&default),
258        NegotiationStrategy::Lookup,
259    );
260    let first = supported.first()?;
261    // If only default was returned because request was unrelated (e.g. fr-FR),
262    // treat as no match so callers can fall through — unless request itself is en.
263    if let Some(lang) = Language::from_langid(first) {
264        // When Lookup returns default for unsupported languages, detect that
265        // the requested primary language is not en/pt.
266        let req_lang = requested.language.as_str();
267        if lang == Language::English
268            && req_lang != "en"
269            && !Language::AVAILABLE
270                .iter()
271                .any(|a| a.language_identifier().language == requested.language)
272        {
273            return None;
274        }
275        return Some(lang);
276    }
277    None
278}
279
280/// Directory that holds `config.toml` / `lang` (respects override and XDG).
281#[must_use]
282pub fn resolve_config_dir(config_dir_override: Option<&Path>) -> Option<PathBuf> {
283    if let Some(p) = config_dir_override {
284        if p.is_dir() || !p.exists() {
285            return Some(p.to_path_buf());
286        }
287        // File path → parent dir
288        return p.parent().map(|d| d.to_path_buf());
289    }
290    crate::vps::default_config_path()
291        .ok()
292        .and_then(|cfg| cfg.parent().map(|d| d.to_path_buf()))
293}
294
295/// Path to the persisted language preference file.
296#[must_use]
297pub fn lang_preference_path(config_dir_override: Option<&Path>) -> Option<PathBuf> {
298    resolve_config_dir(config_dir_override).map(|d| d.join(LANG_PREFERENCE_FILE))
299}
300
301/// Reads the persisted language preference (trimmed, non-empty).
302#[must_use]
303pub fn read_persisted_lang(config_dir_override: Option<&Path>) -> Option<String> {
304    let path = lang_preference_path(config_dir_override)?;
305    let content = std::fs::read_to_string(&path).ok()?;
306    let trimmed = content.trim();
307    if trimmed.is_empty() {
308        None
309    } else {
310        Some(trimmed.to_string())
311    }
312}
313
314/// Writes the persisted language preference (BCP47 of a supported language).
315///
316/// Creates the config directory if needed. On Unix, sets mode `0o600`.
317///
318/// # Errors
319/// Returns I/O errors from create/write/permissions.
320pub fn write_persisted_lang(
321    language: Language,
322    config_dir_override: Option<&Path>,
323) -> std::io::Result<PathBuf> {
324    let dir = resolve_config_dir(config_dir_override).ok_or_else(|| {
325        std::io::Error::new(
326            std::io::ErrorKind::NotFound,
327            "configuration directory unavailable",
328        )
329    })?;
330    std::fs::create_dir_all(&dir)?;
331    let path = dir.join(LANG_PREFERENCE_FILE);
332    let body = format!("{}\n", language.bcp47());
333    std::fs::write(&path, body.as_bytes())?;
334    crate::fs_perm::set_secret_file_mode(&path).map_err(|e| match e {
335        crate::errors::SshCliError::Io(io) => io,
336        other => std::io::Error::other(other.to_string()),
337    })?;
338    Ok(path)
339}
340
341/// Removes the persisted language preference if present.
342///
343/// # Errors
344/// Propagates unexpected I/O errors (ignores NotFound).
345pub fn clear_persisted_lang(config_dir_override: Option<&Path>) -> std::io::Result<()> {
346    if let Some(path) = lang_preference_path(config_dir_override) {
347        match std::fs::remove_file(&path) {
348            Ok(()) => Ok(()),
349            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
350            Err(e) => Err(e),
351        }
352    } else {
353        Ok(())
354    }
355}
356
357/// Clap `value_parser` for `--lang`: accepts BCP47 tags that negotiate to a
358/// supported product locale (`en`, `en-US`, `pt-BR`, `pt`, …).
359pub fn parse_lang_cli_arg(s: &str) -> Result<String, String> {
360    match negotiate_code(s) {
361        Some(lang) => Ok(lang.bcp47().to_string()),
362        None => Err(format!(
363            "unsupported language '{s}' (supported: en, pt-BR; BCP47 tags that negotiate to these)"
364        )),
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn normalize_strips_encoding_and_underscore() {
374        assert_eq!(normalize_raw_locale("pt_BR.UTF-8"), "pt-BR");
375        assert_eq!(normalize_raw_locale("en_US.utf8"), "en-US");
376        assert_eq!(normalize_raw_locale("de_DE@euro"), "de-DE");
377        assert_eq!(normalize_raw_locale("  pt-BR  "), "pt-BR");
378    }
379
380    #[test]
381    fn parse_rejects_c_and_posix() {
382        assert!(parse_language_identifier("C").is_none());
383        assert!(parse_language_identifier("POSIX").is_none());
384        assert!(parse_language_identifier("C.UTF-8").is_none());
385        assert!(parse_language_identifier("").is_none());
386    }
387
388    #[test]
389    fn parse_accepts_bcp47_variants() {
390        assert!(parse_language_identifier("pt-BR").is_some());
391        assert!(parse_language_identifier("pt_BR.UTF-8").is_some());
392        assert!(parse_language_identifier("en-GB").is_some());
393        assert!(parse_language_identifier("en").is_some());
394    }
395
396    #[test]
397    fn negotiate_pt_br_and_prefixes() {
398        assert_eq!(negotiate_code("pt-BR"), Some(Language::Portuguese));
399        assert_eq!(negotiate_code("pt_BR.UTF-8"), Some(Language::Portuguese));
400        assert_eq!(negotiate_code("pt"), Some(Language::Portuguese));
401        assert_eq!(negotiate_code("en-US"), Some(Language::English));
402        assert_eq!(negotiate_code("en-GB"), Some(Language::English));
403        assert_eq!(negotiate_code("en"), Some(Language::English));
404    }
405
406    #[test]
407    fn negotiate_unsupported_returns_none() {
408        assert_eq!(negotiate_code("fr-FR"), None);
409        assert_eq!(negotiate_code("zh-Hans-CN"), None);
410        assert_eq!(negotiate_code("xx-YY"), None);
411        assert_eq!(negotiate_code("C"), None);
412    }
413
414    #[test]
415    fn parse_lang_cli_arg_ok_and_err() {
416        assert_eq!(parse_lang_cli_arg("pt-BR").unwrap(), "pt-BR");
417        assert_eq!(parse_lang_cli_arg("en").unwrap(), "en");
418        assert!(parse_lang_cli_arg("fr-FR").is_err());
419    }
420
421    #[test]
422    fn resolve_force_pt_returns_portuguese() {
423        let result = resolve_language(Some("pt-BR"), None);
424        assert_eq!(result, Language::Portuguese);
425    }
426
427    #[test]
428    fn resolve_force_en_returns_english() {
429        let result = resolve_language(Some("en-US"), None);
430        assert_eq!(result, Language::English);
431    }
432
433    #[test]
434    fn resolve_force_invalid_falls_through() {
435        crate::test_util::env::remove_var(crate::constants::ENV_LANG);
436        let result = resolve_language_detailed(Some("xx-YY"), None);
437        assert!(
438            result.language == Language::English || result.language == Language::Portuguese,
439            "must return a valid language"
440        );
441        assert_ne!(result.source, LocaleSource::CliFlag);
442    }
443
444    #[test]
445    fn resolve_without_force_returns_valid_language() {
446        crate::test_util::env::remove_var(crate::constants::ENV_LANG);
447        let result = resolve_language(None, None);
448        assert!(
449            result == Language::English || result == Language::Portuguese,
450            "resolve_language must return a valid language"
451        );
452    }
453
454    #[test]
455    fn current_language_fallback_english_before_set() {
456        let result = current_language();
457        assert!(
458            result == Language::English || result == Language::Portuguese,
459            "current_language must return a valid language"
460        );
461    }
462
463    #[test]
464    fn locale_source_as_str_stable() {
465        assert_eq!(LocaleSource::CliFlag.as_str(), "cli_flag");
466        assert_eq!(LocaleSource::Default.as_str(), "default");
467    }
468
469    #[test]
470    fn persisted_lang_roundtrip() {
471        let dir = tempfile::tempdir().expect("tempdir");
472        let path = write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
473        assert!(path.exists());
474        let raw = read_persisted_lang(Some(dir.path())).expect("read");
475        assert_eq!(negotiate_code(&raw), Some(Language::Portuguese));
476        clear_persisted_lang(Some(dir.path())).expect("clear");
477        assert!(read_persisted_lang(Some(dir.path())).is_none());
478    }
479
480    #[test]
481    fn resolve_uses_persisted_when_no_flag_or_env() {
482        crate::test_util::env::remove_var(crate::constants::ENV_LANG);
483        let dir = tempfile::tempdir().expect("tempdir");
484        write_persisted_lang(Language::Portuguese, Some(dir.path())).expect("write");
485        let r = resolve_language_detailed(None, Some(dir.path()));
486        // Persisted wins over system when flag/env absent — unless system somehow
487        // is forced; with force None and no env, source should be Persisted.
488        assert_eq!(r.language, Language::Portuguese);
489        assert_eq!(r.source, LocaleSource::Persisted);
490    }
491}