diff --git a/docs/USER_RECORD.md b/docs/USER_RECORD.md index 9d6d8c1d03..5335e145b5 100644 --- a/docs/USER_RECORD.md +++ b/docs/USER_RECORD.md @@ -273,6 +273,9 @@ This must be a string, and should follow the semantics defined in the It's probably wise to use a location string processable by geo-location subsystems, but this is not enforced nor required. Example: `Berlin, Germany` or `Basement, Room 3a`. +`birthDate` → A string in ISO 8601 calendar date format (`YYYY-MM-DD`) indicating the user's date +of birth. The earliest representable year is 1900. This field is optional. + `disposition` → A string, one of `intrinsic`, `system`, `dynamic`, `regular`, `container`, `foreign`, `reserved`. If specified clarifies the disposition of the user, i.e. the context it is defined in. diff --git a/man/homectl.xml b/man/homectl.xml index a59efd7112..bb827d1e6b 100644 --- a/man/homectl.xml +++ b/man/homectl.xml @@ -366,6 +366,16 @@ + + + + Takes a birth date for the user in ISO 8601 calendar date format + (YYYY-MM-DD). The earliest representable year is 1900. If an empty string is + passed the birth date is reset to unset. + + + + diff --git a/src/basic/time-util.c b/src/basic/time-util.c index 4ba5bfafd7..1e426bb8f9 100644 --- a/src/basic/time-util.c +++ b/src/basic/time-util.c @@ -1893,9 +1893,8 @@ TimestampStyle timestamp_style_from_string(const char *s) { return t; } -int parse_calendar_date(const char *s, usec_t *ret) { +int parse_calendar_date_full(const char *s, bool allow_pre_epoch, usec_t *ret_usec, struct tm *ret_tm) { struct tm parsed_tm = {}, copy_tm; - usec_t usec; const char *k; int r; @@ -1906,9 +1905,22 @@ int parse_calendar_date(const char *s, usec_t *ret) { return -EINVAL; copy_tm = parsed_tm; - r = mktime_or_timegm_usec(©_tm, /* utc= */ true, &usec); - if (r < 0) - return r; + + usec_t usec = USEC_INFINITY; + + if (allow_pre_epoch) { + /* For birth dates we use timegm() directly since we need to accept pre-epoch dates. + * timegm() returns (time_t) -1 both on error and for one second before the epoch. + * Initialize wday to -1 beforehand: if it remains -1 after the call, it's a genuine + * error; if timegm() changed it, the date was successfully normalized. */ + copy_tm.tm_wday = -1; + if (timegm(©_tm) == (time_t) -1 && copy_tm.tm_wday == -1) + return -EINVAL; + } else { + r = mktime_or_timegm_usec(©_tm, /* utc= */ true, &usec); + if (r < 0) + return r; + } /* Refuse non-normalized dates, e.g. Feb 30 */ if (copy_tm.tm_mday != parsed_tm.tm_mday || @@ -1916,8 +1928,15 @@ int parse_calendar_date(const char *s, usec_t *ret) { copy_tm.tm_year != parsed_tm.tm_year) return -EINVAL; - if (ret) - *ret = usec; + if (ret_usec) + *ret_usec = usec; + if (ret_tm) { + /* Reset to unset, then fill in only the date fields we parsed and validated */ + *ret_tm = BIRTH_DATE_UNSET; + ret_tm->tm_mday = parsed_tm.tm_mday; + ret_tm->tm_mon = parsed_tm.tm_mon; + ret_tm->tm_year = parsed_tm.tm_year; + } return 0; } diff --git a/src/basic/time-util.h b/src/basic/time-util.h index c397188901..5b6a524c48 100644 --- a/src/basic/time-util.h +++ b/src/basic/time-util.h @@ -1,6 +1,7 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ #pragma once +#include #include #include "basic-forward.h" @@ -180,7 +181,23 @@ const char* etc_localtime(void); int mktime_or_timegm_usec(struct tm *tm, bool utc, usec_t *ret); int localtime_or_gmtime_usec(usec_t t, bool utc, struct tm *ret); -int parse_calendar_date(const char *s, usec_t *ret); + +int parse_calendar_date_full(const char *s, bool allow_pre_epoch, usec_t *ret_usec, struct tm *ret_tm); + +static inline int parse_calendar_date(const char *s, usec_t *ret) { + return parse_calendar_date_full(s, /* allow_pre_epoch= */ false, ret, NULL); +} + +#define BIRTH_DATE_UNSET \ + (const struct tm) { \ + .tm_year = INT_MIN, \ + } + +#define BIRTH_DATE_IS_SET(tm) ((tm).tm_year != INT_MIN) + +static inline int parse_birth_date(const char *s, struct tm *ret) { + return parse_calendar_date_full(s, /* allow_pre_epoch= */ true, NULL, ret); +} uint32_t usec_to_jiffies(usec_t usec); usec_t jiffies_to_usec(uint32_t jiffies); diff --git a/src/home/homectl.c b/src/home/homectl.c index 2b92ab6481..5bb9de1819 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -3945,6 +3945,7 @@ static int help(int argc, char *argv[], void *userdata) { " --alias=ALIAS Define alias usernames for this account\n" " --email-address=EMAIL Email address for user\n" " --location=LOCATION Set location of user on earth\n" + " --birth-date=[DATE] Set user birth date (YYYY-MM-DD)\n" " --icon-name=NAME Icon name for user\n" " -d --home-dir=PATH Home directory\n" " -u --uid=UID Numeric UID for user\n" @@ -4109,6 +4110,7 @@ static int parse_argv(int argc, char *argv[]) { ARG_LOCKED, ARG_SSH_AUTHORIZED_KEYS, ARG_LOCATION, + ARG_BIRTH_DATE, ARG_ICON_NAME, ARG_PASSWORD_HINT, ARG_NICE, @@ -4195,6 +4197,7 @@ static int parse_argv(int argc, char *argv[]) { { "alias", required_argument, NULL, ARG_ALIAS }, { "email-address", required_argument, NULL, ARG_EMAIL_ADDRESS }, { "location", required_argument, NULL, ARG_LOCATION }, + { "birth-date", required_argument, NULL, ARG_BIRTH_DATE }, { "password-hint", required_argument, NULL, ARG_PASSWORD_HINT }, { "icon-name", required_argument, NULL, ARG_ICON_NAME }, { "home-dir", required_argument, NULL, 'd' }, /* Compatible with useradd(8) */ @@ -4408,6 +4411,22 @@ static int parse_argv(int argc, char *argv[]) { break; } + case ARG_BIRTH_DATE: + if (isempty(optarg)) { + r = drop_from_identity("birthDate"); + if (r < 0) + return r; + } else { + r = parse_birth_date(optarg, /* ret= */ NULL); + if (r < 0) + return log_error_errno(r, "Invalid birth date (expected YYYY-MM-DD): %s", optarg); + + r = parse_string_field(&arg_identity_extra, "birthDate", optarg); + if (r < 0) + return r; + } + break; + case ARG_CIFS_SERVICE: if (!isempty(optarg)) { r = parse_cifs_service(optarg, /* ret_host= */ NULL, /* ret_service= */ NULL, /* ret_path= */ NULL); diff --git a/src/shared/user-record-show.c b/src/shared/user-record-show.c index a43328c9fe..11edf2557c 100644 --- a/src/shared/user-record-show.c +++ b/src/shared/user-record-show.c @@ -342,6 +342,8 @@ void user_record_show(UserRecord *hr, bool show_full_group_info) { printf(" Email: %s\n", hr->email_address); if (hr->location) printf(" Location: %s\n", hr->location); + if (BIRTH_DATE_IS_SET(hr->birth_date)) + printf(" Birth Date: %04d-%02d-%02d\n", hr->birth_date.tm_year + 1900, hr->birth_date.tm_mon + 1, hr->birth_date.tm_mday); if (hr->password_hint) printf(" Passw. Hint: %s\n", hr->password_hint); if (hr->icon_name) diff --git a/src/shared/user-record.c b/src/shared/user-record.c index c65bab4ff4..4dfb2c72d7 100644 --- a/src/shared/user-record.c +++ b/src/shared/user-record.c @@ -46,6 +46,7 @@ UserRecord* user_record_new(void) { .nice_level = INT_MAX, .not_before_usec = UINT64_MAX, .not_after_usec = UINT64_MAX, + .birth_date = BIRTH_DATE_UNSET, .locked = -1, .storage = _USER_STORAGE_INVALID, .access_mode = MODE_INVALID, @@ -417,6 +418,28 @@ static int json_dispatch_filename_or_path(const char *name, sd_json_variant *var return 0; } +static int json_dispatch_birth_date(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + struct tm *ret = ASSERT_PTR(userdata); + const char *s; + int r; + + if (sd_json_variant_is_null(variant)) { + *ret = BIRTH_DATE_UNSET; + return 0; + } + + if (!sd_json_variant_is_string(variant)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not a string.", strna(name)); + + s = sd_json_variant_string(variant); + + r = parse_birth_date(s, ret); + if (r < 0) + return json_log(variant, flags, r, "JSON field '%s' is not a valid ISO 8601 date (expected YYYY-MM-DD).", strna(name)); + + return 0; +} + static int json_dispatch_home_directory(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { char **s = userdata; const char *n; @@ -1500,7 +1523,8 @@ int user_group_record_mangle( /* Personally Identifiable Information (PII) — avoid leaking in logs */ "realName", "location", - "emailAddress") + "emailAddress", + "birthDate") sd_json_variant_sensitive(sd_json_variant_by_key(v, key)); /* Check if we have the special sections and if they match our flags set */ @@ -1595,6 +1619,7 @@ int user_record_load(UserRecord *h, sd_json_variant *v, UserRecordLoadFlags load { "emailAddress", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, email_address), SD_JSON_STRICT }, { "iconName", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, icon_name), SD_JSON_STRICT }, { "location", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, location), 0 }, + { "birthDate", SD_JSON_VARIANT_STRING, json_dispatch_birth_date, offsetof(UserRecord, birth_date), 0 }, { "disposition", SD_JSON_VARIANT_STRING, json_dispatch_user_disposition, offsetof(UserRecord, disposition), 0 }, { "lastChangeUSec", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, offsetof(UserRecord, last_change_usec), 0 }, { "lastPasswordChangeUSec", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, offsetof(UserRecord, last_password_change_usec), 0 }, diff --git a/src/shared/user-record.h b/src/shared/user-record.h index c3d52d2bd7..4eb35d43e4 100644 --- a/src/shared/user-record.h +++ b/src/shared/user-record.h @@ -1,6 +1,8 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ #pragma once +#include + #include "sd-id128.h" #include "bitfield.h" @@ -266,6 +268,7 @@ typedef struct UserRecord { char *password_hint; char *icon_name; char *location; + struct tm birth_date; char *blob_directory; Hashmap *blob_manifest; diff --git a/src/test/test-time-util.c b/src/test/test-time-util.c index 172056a9bf..d3d4fd5f9b 100644 --- a/src/test/test-time-util.c +++ b/src/test/test-time-util.c @@ -1307,4 +1307,36 @@ TEST(parse_calendar_date) { ASSERT_ERROR(parse_calendar_date("06-15-2023", &usec), EINVAL); /* wrong order */ } +TEST(parse_birth_date) { + struct tm tm; + + /* Valid dates */ + ASSERT_OK(parse_birth_date("2000-06-15", &tm)); + ASSERT_EQ(tm.tm_year, 100); /* 2000 - 1900 */ + ASSERT_EQ(tm.tm_mon, 5); /* June, 0-indexed */ + ASSERT_EQ(tm.tm_mday, 15); + + /* Pre-epoch dates */ + ASSERT_OK(parse_birth_date("1960-03-25", &tm)); + ASSERT_EQ(tm.tm_year, 60); + ASSERT_EQ(tm.tm_mon, 2); + ASSERT_EQ(tm.tm_mday, 25); + + /* NULL ret is allowed (validation only) */ + ASSERT_OK(parse_birth_date("2000-01-01", NULL)); + + /* Non-date fields should not be relied upon */ + ASSERT_OK(parse_birth_date("2000-06-15", &tm)); + ASSERT_FALSE(BIRTH_DATE_IS_SET(BIRTH_DATE_UNSET)); + + /* Non-normalized dates */ + ASSERT_ERROR(parse_birth_date("2023-02-29", &tm), EINVAL); + ASSERT_ERROR(parse_birth_date("2023-04-31", &tm), EINVAL); + + /* Malformed input */ + ASSERT_ERROR(parse_birth_date("", &tm), EINVAL); + ASSERT_ERROR(parse_birth_date("not-a-date", &tm), EINVAL); + ASSERT_ERROR(parse_birth_date("2023-06-15T00:00:00", &tm), EINVAL); +} + DEFINE_TEST_MAIN_WITH_INTRO(LOG_INFO, intro); diff --git a/src/test/test-user-record.c b/src/test/test-user-record.c index c807ffe83a..480a3f33eb 100644 --- a/src/test/test-user-record.c +++ b/src/test/test-user-record.c @@ -96,6 +96,15 @@ TEST(self_changes) { SD_JSON_BUILD_PAIR_OBJECT("privileged", SD_JSON_BUILD_PAIR_UNSIGNED("notInHardCodedList", 99999))); ASSERT_TRUE(user_record_self_changes_allowed(curr, new)); + + /* birthDate is NOT self-modifiable (admin-only) */ + USER(&curr, + SD_JSON_BUILD_PAIR_STRING("userName", "test"), + SD_JSON_BUILD_PAIR_STRING("birthDate", "1990-01-01")); + USER(&new, + SD_JSON_BUILD_PAIR_STRING("userName", "test"), + SD_JSON_BUILD_PAIR_STRING("birthDate", "1990-06-15")); + ASSERT_FALSE(user_record_self_changes_allowed(curr, new)); } DEFINE_TEST_MAIN(LOG_INFO);