]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
user-record: Add languages field 31206/head
authorAdrian Vovk <adrianvovk@gmail.com>
Sun, 4 Feb 2024 17:27:01 +0000 (12:27 -0500)
committerAdrian Vovk <adrianvovk@gmail.com>
Tue, 13 Feb 2024 22:39:14 +0000 (17:39 -0500)
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
man/homectl.xml
shell-completion/bash/homectl
src/home/homectl.c
src/login/pam_systemd.c
src/shared/user-record-show.c
src/shared/user-record.c
src/shared/user-record.h

index 60f75bf39d19ff0a9cab2fa471a5022c70d498ad..aba45c39f42bc1b9f4b13592ee7b1fe5dc6542b8 100644 (file)
@@ -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`,
index 0143b2ac4ebdb7b723215d3f7048e0d6fa87805b..0e79f82e0f273229f92c7f305f0770bf44f725e1 100644 (file)
       <varlistentry>
         <term><option>--language=</option><replaceable>LANG</replaceable></term>
 
-        <listitem><para>Takes a specifier indicating the preferred language of the user. The
-        <varname>$LANG</varname> environment variable is initialized from this value on login, and thus a
-        value suitable for this environment variable is accepted here, for example
-        <option>--language=de_DE.UTF8</option>.</para>
+        <listitem><para>Takes a comma- or colon-separated list of languages preferred by the user, ordered
+        by descending priority. The <varname>$LANG</varname> and <varname>$LANGUAGE</varname> environment
+        variables are initialized from this value on login, and thus values suitible for these environment
+        variables are accepted here, for example <option>--language=de_DE.UTF-8</option>. This option may
+        be used more than once, in which case the language lists are concatenated.</para>
 
         <xi:include href="version-info.xml" xpointer="v245"/></listitem>
       </varlistentry>
index 0a7bd0d13c88685de0c886de7fad7dbca54eab8f..a9a77d474bb564f65b365c7fe08034f4df5bce37 100644 (file)
@@ -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
index 222bf36e581b241937a8bfd1e902f4fb2a7827a3..72a24e7156265058a95e6b436ae3521dba19c89c 100644 (file)
@@ -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;
 }
 
index 999935175674a76dcbd093fe5680b5140cb5d9d7..197729dd345d047d6cebc720c7c24aa592c0ac77 100644 (file)
@@ -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;
+
+                lang = strjoin("LANG=", langs[0]);
+                if (!lang)
+                        return pam_log_oom(handle);
 
-                        joined = strjoin("LANG=", ur->preferred_language);
+                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;
                 }
index 28fa7a86324892cd3425808dd7e1e5ca9678007a..ac0c7a776ab3e0ac3236a0f8083cd75fb34d34f4 100644 (file)
@@ -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));
index 47ef30418ebcfe43aafe3f6a0a5f8c2eb4599434..966abc5c42142cfa3aad407bba23b1330fe032fa 100644 (file)
@@ -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);
 
index c8e402fd0717a7b81c7cb50330ccd70ddf083961..0afbc796d2809ec16e6c67026f0f6bc655a7ca2a 100644 (file)
@@ -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);