From 592ca6f0efd80b58c318a84e4a9e75624883cfc5 Mon Sep 17 00:00:00 2001 From: Adrian Vovk Date: Sun, 4 Feb 2024 11:56:05 -0500 Subject: [PATCH 1/3] pam_systemd: Let user record override env vars The user record should be the source of truth for the user's environment variables, and the user should be able to override them in much the same way that they can if they simply append the variable to their ~/.profile For example, before $LANG would never get set to the user's preferred language, because the service manager always ensures that $LANG is set to something (either the localed config, or a compiled-in default). Thus the user's preferredLanguage setting was always ignored --- src/login/pam_systemd.c | 44 +++++++++-------------------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/src/login/pam_systemd.c b/src/login/pam_systemd.c index db623a3eaa..9999351756 100644 --- a/src/login/pam_systemd.c +++ b/src/login/pam_systemd.c @@ -617,48 +617,25 @@ static int apply_user_record_settings( } STRV_FOREACH(i, ur->environment) { - _cleanup_free_ char *n = NULL; - const char *e; - - assert_se(e = strchr(*i, '=')); /* environment was already validated while parsing JSON record, this thus must hold */ - - n = strndup(*i, e - *i); - if (!n) - return pam_log_oom(handle); - - if (pam_getenv(handle, n)) { - pam_debug_syslog(handle, debug, - "PAM environment variable $%s already set, not changing based on record.", *i); - continue; - } - r = pam_putenv_and_log(handle, *i, debug); if (r != PAM_SUCCESS) return r; } if (ur->email_address) { - if (pam_getenv(handle, "EMAIL")) - pam_debug_syslog(handle, debug, - "PAM environment variable $EMAIL already set, not changing based on user record."); - else { - _cleanup_free_ char *joined = NULL; + _cleanup_free_ char *joined = NULL; - joined = strjoin("EMAIL=", ur->email_address); - if (!joined) - return pam_log_oom(handle); + joined = strjoin("EMAIL=", ur->email_address); + if (!joined) + return pam_log_oom(handle); - r = pam_putenv_and_log(handle, joined, debug); - if (r != PAM_SUCCESS) - return r; - } + r = pam_putenv_and_log(handle, joined, debug); + if (r != PAM_SUCCESS) + return r; } if (ur->time_zone) { - if (pam_getenv(handle, "TZ")) - pam_debug_syslog(handle, debug, - "PAM environment variable $TZ already set, not changing based on user record."); - else if (!timezone_is_valid(ur->time_zone, LOG_DEBUG)) + if (!timezone_is_valid(ur->time_zone, LOG_DEBUG)) pam_debug_syslog(handle, debug, "Time zone specified in user record is not valid locally, not setting $TZ."); else { @@ -675,10 +652,7 @@ static int apply_user_record_settings( } if (ur->preferred_language) { - if (pam_getenv(handle, "LANG")) - pam_debug_syslog(handle, debug, - "PAM environment variable $LANG already set, not changing based on user record."); - else if (locale_is_installed(ur->preferred_language) <= 0) + if (locale_is_installed(ur->preferred_language) <= 0) pam_debug_syslog(handle, debug, "Preferred language specified in user record is not valid or not installed, not setting $LANG."); else { From fa485e8fc5adfbfce28af865c689376840562bab Mon Sep 17 00:00:00 2001 From: Adrian Vovk Date: Sun, 4 Feb 2024 12:18:08 -0500 Subject: [PATCH 2/3] locale-util: Restrict valid locales This further restricts the charset of locales to better reflect what locales actually look like. This allows us to safely join locale names using the `:` character, for instance, which cannot appear in a locale name and is used by the `$LANGUAGE` env var --- src/basic/locale-util.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/basic/locale-util.c b/src/basic/locale-util.c index 9e70c3f01f..e6da5dbc63 100644 --- a/src/basic/locale-util.c +++ b/src/basic/locale-util.c @@ -260,7 +260,10 @@ bool locale_is_valid(const char *name) { if (!filename_is_valid(name)) return false; - if (!string_is_safe(name)) + /* Locales look like: ll_CC.ENC@variant, where ll and CC are alphabetic, ENC is alphanumeric with + * dashes, and variant seems to be alphabetic. + * See: https://www.gnu.org/software/gettext/manual/html_node/Locale-Names.html */ + if (!in_charset(name, ALPHANUMERICAL "_.-@")) return false; return true; From 49e55abb7f74f0ae38e81356654746affa0d290f Mon Sep 17 00:00:00 2001 From: Adrian Vovk Date: Sun, 4 Feb 2024 12:27:01 -0500 Subject: [PATCH 3/3] user-record: Add languages field This field is like preferredLanguage, but takes a priority list of languages instead. If an app isn't translated into a user's primary language, it can fall back to one of the other languages in the list thus making the app more accessible to the user. For instance: in my experience, many Ukrainians are fluent in Russian, often significantly better than English (especially if they are of a generation that grew up during the USSR). Such a person might set this new variable to ["uk_UA.UTF-8", "ru_UA.UTF-8"] so that software that lacks Ukrainian translations will first try Russian translations before defaulting to English. Fixes #31290 --- docs/USER_RECORD.md | 21 ++++++--- man/homectl.xml | 9 ++-- shell-completion/bash/homectl | 3 ++ src/home/homectl.c | 62 ++++++++++++++++++++----- src/login/pam_systemd.c | 37 +++++++++++---- src/shared/user-record-show.c | 19 ++++---- src/shared/user-record.c | 85 ++++++++++++++++++++++++++++++++++- src/shared/user-record.h | 2 + 8 files changed, 199 insertions(+), 39 deletions(-) diff --git a/docs/USER_RECORD.md b/docs/USER_RECORD.md index 60f75bf39d..aba45c39f4 100644 --- a/docs/USER_RECORD.md +++ b/docs/USER_RECORD.md @@ -310,11 +310,22 @@ string. The string should be a `tzdata` compatible location string, for example: `Europe/Berlin`. `preferredLanguage` → A string indicating the preferred language/locale for the -user. When logging in +user. It is combined with the `additionalLanguages` field to initialize the `$LANG` +and `$LANGUAGE` environment variables on login; see below for more details. This string +should be in a format compatible with the `$LANG` environment variable, for example: +`de_DE.UTF-8`. + +`additionalLanguages` → An array of strings indicating the preferred languages/locales +that should be used in the event that translations for the `preferredLanguage` are +missing, listed in order of descending priority. This allows multi-lingual users to +specify all the languages that they know, so software lacking translations in the user's +primary language can try another language that the user knows rather than falling back to +the default English. All entries in this field must be valid locale names, compatible with +the `$LANG` variable, for example: `de_DE.UTF-8`. When logging in [`pam_systemd`](https://www.freedesktop.org/software/systemd/man/pam_systemd.html) -will automatically initialize the `$LANG` environment variable from this -string. The string hence should be in a format compatible with this environment -variable, for example: `de_DE.UTF8`. +will prepend `preferredLanguage` (if set) to this list (if set), remove duplicates, +and then automatically initialize the `$LANGUAGE` variable with the resulting list. +It will also initialize `$LANG` variable with the first entry in the resulting list. `niceLevel` → An integer value in the range -20…19. When logging in [`pam_systemd`](https://www.freedesktop.org/software/systemd/man/pam_systemd.html) @@ -744,7 +755,7 @@ that may be used in this section are identical to the equally named ones in the `regular` section (i.e. at the top-level object). Specifically, these are: `iconName`, `location`, `shell`, `umask`, `environment`, `timeZone`, -`preferredLanguage`, `niceLevel`, `resourceLimits`, `locked`, `notBeforeUSec`, +`preferredLanguage`, `additionalLanguages`, `niceLevel`, `resourceLimits`, `locked`, `notBeforeUSec`, `notAfterUSec`, `storage`, `diskSize`, `diskSizeRelative`, `skeletonDirectory`, `accessMode`, `tasksMax`, `memoryHigh`, `memoryMax`, `cpuWeight`, `ioWeight`, `mountNoDevices`, `mountNoSuid`, `mountNoExecute`, `cifsDomain`, diff --git a/man/homectl.xml b/man/homectl.xml index 0143b2ac4e..0e79f82e0f 100644 --- a/man/homectl.xml +++ b/man/homectl.xml @@ -366,10 +366,11 @@ LANG - Takes a specifier indicating the preferred language of the user. The - $LANG environment variable is initialized from this value on login, and thus a - value suitable for this environment variable is accepted here, for example - . + Takes a comma- or colon-separated list of languages preferred by the user, ordered + by descending priority. The $LANG and $LANGUAGE environment + variables are initialized from this value on login, and thus values suitible for these environment + variables are accepted here, for example . This option may + be used more than once, in which case the language lists are concatenated. diff --git a/shell-completion/bash/homectl b/shell-completion/bash/homectl index 0a7bd0d13c..a9a77d474b 100644 --- a/shell-completion/bash/homectl +++ b/shell-completion/bash/homectl @@ -146,6 +146,9 @@ _homectl() { --cifs-user-name) comps=$(compgen -A user -- "$cur" ) ;; + --language) + comps=$(localectl list-locales 2>/dev/null) + ;; esac COMPREPLY=( $(compgen -W '$comps' -- "$cur") ) return 0 diff --git a/src/home/homectl.c b/src/home/homectl.c index 222bf36e58..72a24e7156 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2435,7 +2435,7 @@ static int help(int argc, char *argv[], void *userdata) { " --shell=PATH Shell for account\n" " --setenv=VARIABLE[=VALUE] Set an environment variable at log-in\n" " --timezone=TIMEZONE Set a time-zone\n" - " --language=LOCALE Set preferred language\n" + " --language=LOCALE Set preferred languages\n" " --ssh-authorized-keys=KEYS\n" " Specify SSH public keys\n" " --pkcs11-token-uri=URI URI to PKCS#11 security token containing\n" @@ -2547,6 +2547,7 @@ static int help(int argc, char *argv[], void *userdata) { } static int parse_argv(int argc, char *argv[]) { + _cleanup_strv_free_ char **arg_languages = NULL; enum { ARG_VERSION = 0x100, @@ -3121,26 +3122,46 @@ static int parse_argv(int argc, char *argv[]) { break; - case ARG_LANGUAGE: - if (isempty(optarg)) { - r = drop_from_identity("language"); + case ARG_LANGUAGE: { + const char *p = optarg; + + if (isempty(p)) { + r = drop_from_identity("preferredLanguage"); if (r < 0) return r; + r = drop_from_identity("additionalLanguages"); + if (r < 0) + return r; + + arg_languages = strv_free(arg_languages); break; } - if (!locale_is_valid(optarg)) - return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Locale '%s' is not valid.", optarg); + for (;;) { + _cleanup_free_ char *word = NULL; - if (locale_is_installed(optarg) <= 0) - log_warning("Locale '%s' is not installed, accepting anyway.", optarg); + r = extract_first_word(&p, &word, ",:", 0); + if (r < 0) + return log_error_errno(r, "Failed to parse locale list: %m"); + if (r == 0) + break; - r = json_variant_set_field_string(&arg_identity_extra, "preferredLanguage", optarg); - if (r < 0) - return log_error_errno(r, "Failed to set preferredLanguage field: %m"); + if (!locale_is_valid(word)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Locale '%s' is not valid.", word); + + if (locale_is_installed(word) <= 0) + log_warning("Locale '%s' is not installed, accepting anyway.", word); + + r = strv_consume(&arg_languages, TAKE_PTR(word)); + if (r < 0) + return log_oom(); + + strv_uniq(arg_languages); + } break; + } case ARG_NOSUID: case ARG_NODEV: @@ -4021,6 +4042,25 @@ static int parse_argv(int argc, char *argv[]) { if (arg_disk_size != UINT64_MAX || arg_disk_size_relative != UINT64_MAX) arg_and_resize = true; + if (!strv_isempty(arg_languages)) { + char **additional; + + r = json_variant_set_field_string(&arg_identity_extra, "preferredLanguage", arg_languages[0]); + if (r < 0) + return log_error_errno(r, "Failed to update preferred language: %m"); + + additional = strv_skip(arg_languages, 1); + if (!strv_isempty(additional)) { + r = json_variant_set_field_strv(&arg_identity_extra, "additionalLanguages", additional); + if (r < 0) + return log_error_errno(r, "Failed to update additional language list: %m"); + } else { + r = drop_from_identity("additionalLanguages"); + if (r < 0) + return r; + } + } + return 1; } diff --git a/src/login/pam_systemd.c b/src/login/pam_systemd.c index 9999351756..197729dd34 100644 --- a/src/login/pam_systemd.c +++ b/src/login/pam_systemd.c @@ -606,6 +606,7 @@ static int apply_user_record_settings( bool debug, uint64_t default_capability_bounding_set, uint64_t default_capability_ambient_set) { + _cleanup_strv_free_ char **langs = NULL; int r; assert(handle); @@ -651,18 +652,38 @@ static int apply_user_record_settings( } } - if (ur->preferred_language) { - if (locale_is_installed(ur->preferred_language) <= 0) - pam_debug_syslog(handle, debug, - "Preferred language specified in user record is not valid or not installed, not setting $LANG."); - else { - _cleanup_free_ char *joined = NULL; + r = user_record_languages(ur, &langs); + if (r < 0) + pam_syslog_errno(handle, LOG_ERR, r, + "Failed to acquire user's language preferences, ignoring: %m"); + else if (strv_isempty(langs)) + ; /* User has no preference set so we do nothing */ + else if (locale_is_installed(langs[0]) <= 0) + pam_debug_syslog(handle, debug, + "Preferred languages specified in user record are not installed locally, not setting $LANG or $LANGUAGE."); + else { + _cleanup_free_ char *lang = NULL; - joined = strjoin("LANG=", ur->preferred_language); + lang = strjoin("LANG=", langs[0]); + if (!lang) + return pam_log_oom(handle); + + r = pam_putenv_and_log(handle, lang, debug); + if (r != PAM_SUCCESS) + return r; + + if (strv_length(langs) > 1) { + _cleanup_free_ char *joined = NULL, *language = NULL; + + joined = strv_join(langs, ":"); if (!joined) return pam_log_oom(handle); - r = pam_putenv_and_log(handle, joined, debug); + language = strjoin("LANGUAGE=", joined); + if (!language) + return pam_log_oom(handle); + + r = pam_putenv_and_log(handle, language, debug); if (r != PAM_SUCCESS) return r; } diff --git a/src/shared/user-record-show.c b/src/shared/user-record-show.c index 28fa7a8632..ac0c7a776a 100644 --- a/src/shared/user-record-show.c +++ b/src/shared/user-record-show.c @@ -23,6 +23,7 @@ const char *user_record_state_color(const char *state) { } void user_record_show(UserRecord *hr, bool show_full_group_info) { + _cleanup_strv_free_ char **langs = NULL; const char *hd, *ip, *shell; UserStorage storage; usec_t t; @@ -237,15 +238,15 @@ void user_record_show(UserRecord *hr, bool show_full_group_info) { if (hr->time_zone) printf(" Time Zone: %s\n", hr->time_zone); - if (hr->preferred_language) - printf(" Language: %s\n", hr->preferred_language); - - if (!strv_isempty(hr->environment)) - STRV_FOREACH(i, hr->environment) { - printf(i == hr->environment ? - " Environment: %s\n" : - " %s\n", *i); - } + r = user_record_languages(hr, &langs); + if (r < 0) { + errno = -r; + printf(" Languages: (can't acquire: %m)\n"); + } else if (!strv_isempty(langs)) { + STRV_FOREACH(i, langs) + printf(i == langs ? " Languages: %s" : ", %s", *i); + printf("\n"); + } if (hr->locked >= 0) printf(" Locked: %s\n", yes_no(hr->locked)); diff --git a/src/shared/user-record.c b/src/shared/user-record.c index 47ef30418e..966abc5c42 100644 --- a/src/shared/user-record.c +++ b/src/shared/user-record.c @@ -10,6 +10,7 @@ #include "glyph-util.h" #include "hexdecoct.h" #include "hostname-util.h" +#include "locale-util.h" #include "memory-util.h" #include "path-util.h" #include "pkcs11-util.h" @@ -146,6 +147,7 @@ static UserRecord* user_record_free(UserRecord *h) { strv_free(h->environment); free(h->time_zone); free(h->preferred_language); + strv_free(h->additional_languages); rlimit_free_all(h->rlimits); free(h->skeleton_directory); @@ -535,6 +537,62 @@ static int json_dispatch_environment(const char *name, JsonVariant *variant, Jso return strv_free_and_replace(*l, n); } +static int json_dispatch_locale(const char *name, JsonVariant *variant, JsonDispatchFlags flags, void *userdata) { + char **s = userdata; + const char *n; + int r; + + if (json_variant_is_null(variant)) { + *s = mfree(*s); + return 0; + } + + if (!json_variant_is_string(variant)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not a string.", strna(name)); + + n = json_variant_string(variant); + + if (!locale_is_valid(n)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not a valid locale.", strna(name)); + + r = free_and_strdup(s, n); + if (r < 0) + return json_log(variant, flags, r, "Failed to allocate string: %m"); + + return 0; +} + +static int json_dispatch_locales(const char *name, JsonVariant *variant, JsonDispatchFlags flags, void *userdata) { + _cleanup_strv_free_ char **n = NULL; + char ***l = userdata; + const char *locale; + JsonVariant *e; + int r; + + if (json_variant_is_null(variant)) { + *l = strv_free(*l); + return 0; + } + + if (!json_variant_is_array(variant)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not an array of strings.", strna(name)); + + JSON_VARIANT_ARRAY_FOREACH(e, variant) { + if (!json_variant_is_string(e)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not an array of strings.", strna(name)); + + locale = json_variant_string(e); + if (!locale_is_valid(locale)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not an array of valid locales.", strna(name)); + + r = strv_extend(&n, locale); + if (r < 0) + return json_log_oom(variant, flags); + } + + return strv_free_and_replace(*l, n); +} + JSON_DISPATCH_ENUM_DEFINE(json_dispatch_user_disposition, UserDisposition, user_disposition_from_string); static JSON_DISPATCH_ENUM_DEFINE(json_dispatch_user_storage, UserStorage, user_storage_from_string); @@ -1171,7 +1229,8 @@ static int dispatch_per_machine(const char *name, JsonVariant *variant, JsonDisp { "umask", JSON_VARIANT_UNSIGNED, json_dispatch_umask, offsetof(UserRecord, umask), 0 }, { "environment", JSON_VARIANT_ARRAY, json_dispatch_environment, offsetof(UserRecord, environment), 0 }, { "timeZone", JSON_VARIANT_STRING, json_dispatch_string, offsetof(UserRecord, time_zone), JSON_SAFE }, - { "preferredLanguage", JSON_VARIANT_STRING, json_dispatch_string, offsetof(UserRecord, preferred_language), JSON_SAFE }, + { "preferredLanguage", JSON_VARIANT_STRING, json_dispatch_locale, offsetof(UserRecord, preferred_language), 0 }, + { "additionalLanguages", JSON_VARIANT_ARRAY, json_dispatch_locales, offsetof(UserRecord, additional_languages), 0 }, { "niceLevel", _JSON_VARIANT_TYPE_INVALID, json_dispatch_nice, offsetof(UserRecord, nice_level), 0 }, { "resourceLimits", _JSON_VARIANT_TYPE_INVALID, json_dispatch_rlimits, offsetof(UserRecord, rlimits), 0 }, { "locked", JSON_VARIANT_BOOLEAN, json_dispatch_tristate, offsetof(UserRecord, locked), 0 }, @@ -1506,7 +1565,8 @@ int user_record_load(UserRecord *h, JsonVariant *v, UserRecordLoadFlags load_fla { "umask", JSON_VARIANT_UNSIGNED, json_dispatch_umask, offsetof(UserRecord, umask), 0 }, { "environment", JSON_VARIANT_ARRAY, json_dispatch_environment, offsetof(UserRecord, environment), 0 }, { "timeZone", JSON_VARIANT_STRING, json_dispatch_string, offsetof(UserRecord, time_zone), JSON_SAFE }, - { "preferredLanguage", JSON_VARIANT_STRING, json_dispatch_string, offsetof(UserRecord, preferred_language), JSON_SAFE }, + { "preferredLanguage", JSON_VARIANT_STRING, json_dispatch_locale, offsetof(UserRecord, preferred_language), 0 }, + { "additionalLanguages", JSON_VARIANT_ARRAY, json_dispatch_locales, offsetof(UserRecord, additional_languages), 0 }, { "niceLevel", _JSON_VARIANT_TYPE_INVALID, json_dispatch_nice, offsetof(UserRecord, nice_level), 0 }, { "resourceLimits", _JSON_VARIANT_TYPE_INVALID, json_dispatch_rlimits, offsetof(UserRecord, rlimits), 0 }, { "locked", JSON_VARIANT_BOOLEAN, json_dispatch_tristate, offsetof(UserRecord, locked), 0 }, @@ -2034,6 +2094,27 @@ uint64_t user_record_capability_ambient_set(UserRecord *h) { return parse_caps_strv(h->capability_ambient_set) & user_record_capability_bounding_set(h); } +int user_record_languages(UserRecord *h, char ***ret) { + _cleanup_strv_free_ char **l = NULL; + int r; + + assert(h); + assert(ret); + + if (h->preferred_language) { + l = strv_new(h->preferred_language); + if (!l) + return -ENOMEM; + } + + r = strv_extend_strv(&l, h->additional_languages, /* filter_duplicates= */ true); + if (r < 0) + return r; + + *ret = TAKE_PTR(l); + return 0; +} + uint64_t user_record_ratelimit_next_try(UserRecord *h) { assert(h); diff --git a/src/shared/user-record.h b/src/shared/user-record.h index c8e402fd07..0afbc796d2 100644 --- a/src/shared/user-record.h +++ b/src/shared/user-record.h @@ -252,6 +252,7 @@ typedef struct UserRecord { char **environment; char *time_zone; char *preferred_language; + char **additional_languages; int nice_level; struct rlimit *rlimits[_RLIMIT_MAX]; @@ -415,6 +416,7 @@ AutoResizeMode user_record_auto_resize_mode(UserRecord *h); uint64_t user_record_rebalance_weight(UserRecord *h); uint64_t user_record_capability_bounding_set(UserRecord *h); uint64_t user_record_capability_ambient_set(UserRecord *h); +int user_record_languages(UserRecord *h, char ***ret); int user_record_build_image_path(UserStorage storage, const char *user_name_and_realm, char **ret);