Skip to content

Commit acb6624

Browse files
authored
userdb: add birthDate field to JSON user records (#40954)
Add an optional field that can be used to store a user's birth date. userdb already stores personal metadata (`emailAddress`, `realName`, `location`) so `birthDate` is a natural fit.
2 parents ba1caf0 + 7a85887 commit acb6624

File tree

11 files changed

+199
-18
lines changed

11 files changed

+199
-18
lines changed

docs/USER_RECORD.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,9 @@ This must be a string, and should follow the semantics defined in the
273273
It's probably wise to use a location string processable by geo-location subsystems, but this is not enforced nor required.
274274
Example: `Berlin, Germany` or `Basement, Room 3a`.
275275

276+
`birthDate` → A string in ISO 8601 calendar date format (`YYYY-MM-DD`) indicating the user's date
277+
of birth. The earliest representable year is 1900. This field is optional.
278+
276279
`disposition` → A string, one of `intrinsic`, `system`, `dynamic`, `regular`,
277280
`container`, `foreign`, `reserved`. If specified clarifies the disposition of the user,
278281
i.e. the context it is defined in.

man/homectl.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,16 @@
366366
<xi:include href="version-info.xml" xpointer="v245"/></listitem>
367367
</varlistentry>
368368

369+
<varlistentry>
370+
<term><option>--birth-date=<optional><replaceable>DATE</replaceable></optional></option></term>
371+
372+
<listitem><para>Takes a birth date for the user in ISO 8601 calendar date format
373+
(<literal>YYYY-MM-DD</literal>). The earliest representable year is 1900. If an empty string is
374+
passed the birth date is reset to unset.</para>
375+
376+
<xi:include href="version-info.xml" xpointer="v261"/></listitem>
377+
</varlistentry>
378+
369379
<varlistentry>
370380
<term><option>--icon-name=<replaceable>ICON</replaceable></option></term>
371381

src/basic/time-util.c

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1892,3 +1892,51 @@ TimestampStyle timestamp_style_from_string(const char *s) {
18921892
return TIMESTAMP_US_UTC;
18931893
return t;
18941894
}
1895+
1896+
int parse_calendar_date_full(const char *s, bool allow_pre_epoch, usec_t *ret_usec, struct tm *ret_tm) {
1897+
struct tm parsed_tm = {}, copy_tm;
1898+
const char *k;
1899+
int r;
1900+
1901+
assert(s);
1902+
1903+
k = strptime(s, "%Y-%m-%d", &parsed_tm);
1904+
if (!k || *k)
1905+
return -EINVAL;
1906+
1907+
copy_tm = parsed_tm;
1908+
1909+
usec_t usec = USEC_INFINITY;
1910+
1911+
if (allow_pre_epoch) {
1912+
/* For birth dates we use timegm() directly since we need to accept pre-epoch dates.
1913+
* timegm() returns (time_t) -1 both on error and for one second before the epoch.
1914+
* Initialize wday to -1 beforehand: if it remains -1 after the call, it's a genuine
1915+
* error; if timegm() changed it, the date was successfully normalized. */
1916+
copy_tm.tm_wday = -1;
1917+
if (timegm(&copy_tm) == (time_t) -1 && copy_tm.tm_wday == -1)
1918+
return -EINVAL;
1919+
} else {
1920+
r = mktime_or_timegm_usec(&copy_tm, /* utc= */ true, &usec);
1921+
if (r < 0)
1922+
return r;
1923+
}
1924+
1925+
/* Refuse non-normalized dates, e.g. Feb 30 */
1926+
if (copy_tm.tm_mday != parsed_tm.tm_mday ||
1927+
copy_tm.tm_mon != parsed_tm.tm_mon ||
1928+
copy_tm.tm_year != parsed_tm.tm_year)
1929+
return -EINVAL;
1930+
1931+
if (ret_usec)
1932+
*ret_usec = usec;
1933+
if (ret_tm) {
1934+
/* Reset to unset, then fill in only the date fields we parsed and validated */
1935+
*ret_tm = BIRTH_DATE_UNSET;
1936+
ret_tm->tm_mday = parsed_tm.tm_mday;
1937+
ret_tm->tm_mon = parsed_tm.tm_mon;
1938+
ret_tm->tm_year = parsed_tm.tm_year;
1939+
}
1940+
1941+
return 0;
1942+
}

src/basic/time-util.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* SPDX-License-Identifier: LGPL-2.1-or-later */
22
#pragma once
33

4+
#include <limits.h>
45
#include <time.h>
56

67
#include "basic-forward.h"
@@ -181,6 +182,23 @@ const char* etc_localtime(void);
181182
int mktime_or_timegm_usec(struct tm *tm, bool utc, usec_t *ret);
182183
int localtime_or_gmtime_usec(usec_t t, bool utc, struct tm *ret);
183184

185+
int parse_calendar_date_full(const char *s, bool allow_pre_epoch, usec_t *ret_usec, struct tm *ret_tm);
186+
187+
static inline int parse_calendar_date(const char *s, usec_t *ret) {
188+
return parse_calendar_date_full(s, /* allow_pre_epoch= */ false, ret, NULL);
189+
}
190+
191+
#define BIRTH_DATE_UNSET \
192+
(const struct tm) { \
193+
.tm_year = INT_MIN, \
194+
}
195+
196+
#define BIRTH_DATE_IS_SET(tm) ((tm).tm_year != INT_MIN)
197+
198+
static inline int parse_birth_date(const char *s, struct tm *ret) {
199+
return parse_calendar_date_full(s, /* allow_pre_epoch= */ true, NULL, ret);
200+
}
201+
184202
uint32_t usec_to_jiffies(usec_t usec);
185203
usec_t jiffies_to_usec(uint32_t jiffies);
186204

src/home/homectl.c

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3945,6 +3945,7 @@ static int help(void) {
39453945
" --alias=ALIAS Define alias usernames for this account\n"
39463946
" --email-address=EMAIL Email address for user\n"
39473947
" --location=LOCATION Set location of user on earth\n"
3948+
" --birth-date=[DATE] Set user birth date (YYYY-MM-DD)\n"
39483949
" --icon-name=NAME Icon name for user\n"
39493950
" -d --home-dir=PATH Home directory\n"
39503951
" -u --uid=UID Numeric UID for user\n"
@@ -4113,6 +4114,7 @@ static int parse_argv(int argc, char *argv[]) {
41134114
ARG_LOCKED,
41144115
ARG_SSH_AUTHORIZED_KEYS,
41154116
ARG_LOCATION,
4117+
ARG_BIRTH_DATE,
41164118
ARG_ICON_NAME,
41174119
ARG_PASSWORD_HINT,
41184120
ARG_NICE,
@@ -4199,6 +4201,7 @@ static int parse_argv(int argc, char *argv[]) {
41994201
{ "alias", required_argument, NULL, ARG_ALIAS },
42004202
{ "email-address", required_argument, NULL, ARG_EMAIL_ADDRESS },
42014203
{ "location", required_argument, NULL, ARG_LOCATION },
4204+
{ "birth-date", required_argument, NULL, ARG_BIRTH_DATE },
42024205
{ "password-hint", required_argument, NULL, ARG_PASSWORD_HINT },
42034206
{ "icon-name", required_argument, NULL, ARG_ICON_NAME },
42044207
{ "home-dir", required_argument, NULL, 'd' }, /* Compatible with useradd(8) */
@@ -4412,6 +4415,22 @@ static int parse_argv(int argc, char *argv[]) {
44124415
break;
44134416
}
44144417

4418+
case ARG_BIRTH_DATE:
4419+
if (isempty(optarg)) {
4420+
r = drop_from_identity("birthDate");
4421+
if (r < 0)
4422+
return r;
4423+
} else {
4424+
r = parse_birth_date(optarg, /* ret= */ NULL);
4425+
if (r < 0)
4426+
return log_error_errno(r, "Invalid birth date (expected YYYY-MM-DD): %s", optarg);
4427+
4428+
r = parse_string_field(&arg_identity_extra, "birthDate", optarg);
4429+
if (r < 0)
4430+
return r;
4431+
}
4432+
break;
4433+
44154434
case ARG_CIFS_SERVICE:
44164435
if (!isempty(optarg)) {
44174436
r = parse_cifs_service(optarg, /* ret_host= */ NULL, /* ret_service= */ NULL, /* ret_path= */ NULL);

src/shared/user-record-show.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ void user_record_show(UserRecord *hr, bool show_full_group_info) {
342342
printf(" Email: %s\n", hr->email_address);
343343
if (hr->location)
344344
printf(" Location: %s\n", hr->location);
345+
if (BIRTH_DATE_IS_SET(hr->birth_date))
346+
printf(" Birth Date: %04d-%02d-%02d\n", hr->birth_date.tm_year + 1900, hr->birth_date.tm_mon + 1, hr->birth_date.tm_mday);
345347
if (hr->password_hint)
346348
printf(" Passw. Hint: %s\n", hr->password_hint);
347349
if (hr->icon_name)

src/shared/user-record.c

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ UserRecord* user_record_new(void) {
4646
.nice_level = INT_MAX,
4747
.not_before_usec = UINT64_MAX,
4848
.not_after_usec = UINT64_MAX,
49+
.birth_date = BIRTH_DATE_UNSET,
4950
.locked = -1,
5051
.storage = _USER_STORAGE_INVALID,
5152
.access_mode = MODE_INVALID,
@@ -417,6 +418,28 @@ static int json_dispatch_filename_or_path(const char *name, sd_json_variant *var
417418
return 0;
418419
}
419420

421+
static int json_dispatch_birth_date(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) {
422+
struct tm *ret = ASSERT_PTR(userdata);
423+
const char *s;
424+
int r;
425+
426+
if (sd_json_variant_is_null(variant)) {
427+
*ret = BIRTH_DATE_UNSET;
428+
return 0;
429+
}
430+
431+
if (!sd_json_variant_is_string(variant))
432+
return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not a string.", strna(name));
433+
434+
s = sd_json_variant_string(variant);
435+
436+
r = parse_birth_date(s, ret);
437+
if (r < 0)
438+
return json_log(variant, flags, r, "JSON field '%s' is not a valid ISO 8601 date (expected YYYY-MM-DD).", strna(name));
439+
440+
return 0;
441+
}
442+
420443
static int json_dispatch_home_directory(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) {
421444
char **s = userdata;
422445
const char *n;
@@ -1500,7 +1523,8 @@ int user_group_record_mangle(
15001523
/* Personally Identifiable Information (PII) — avoid leaking in logs */
15011524
"realName",
15021525
"location",
1503-
"emailAddress")
1526+
"emailAddress",
1527+
"birthDate")
15041528
sd_json_variant_sensitive(sd_json_variant_by_key(v, key));
15051529

15061530
/* 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
15951619
{ "emailAddress", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, email_address), SD_JSON_STRICT },
15961620
{ "iconName", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, icon_name), SD_JSON_STRICT },
15971621
{ "location", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, location), 0 },
1622+
{ "birthDate", SD_JSON_VARIANT_STRING, json_dispatch_birth_date, offsetof(UserRecord, birth_date), 0 },
15981623
{ "disposition", SD_JSON_VARIANT_STRING, json_dispatch_user_disposition, offsetof(UserRecord, disposition), 0 },
15991624
{ "lastChangeUSec", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, offsetof(UserRecord, last_change_usec), 0 },
16001625
{ "lastPasswordChangeUSec", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, offsetof(UserRecord, last_password_change_usec), 0 },

src/shared/user-record.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/* SPDX-License-Identifier: LGPL-2.1-or-later */
22
#pragma once
33

4+
#include <time.h>
5+
46
#include "sd-id128.h"
57

68
#include "bitfield.h"
@@ -266,6 +268,7 @@ typedef struct UserRecord {
266268
char *password_hint;
267269
char *icon_name;
268270
char *location;
271+
struct tm birth_date;
269272

270273
char *blob_directory;
271274
Hashmap *blob_manifest;

src/sysupdate/sysupdate-resource.c

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -433,24 +433,10 @@ static int process_magic_file(
433433
if (iovec_memcmp(&IOVEC_MAKE(expected_hash, sizeof(expected_hash)), hash) != 0)
434434
log_warning("Hash of best before marker file '%s' has unexpected value, proceeding anyway.", fn);
435435

436-
struct tm parsed_tm = {};
437-
const char *n = strptime(e, "%Y-%m-%d", &parsed_tm);
438-
if (!n || *n != 0) {
439-
/* Doesn't parse? Then it's not a best-before date */
440-
log_warning("Found best before marker with an invalid date, ignoring: %s", fn);
441-
return 0;
442-
}
443-
444-
struct tm copy_tm = parsed_tm;
445436
usec_t best_before;
446-
r = mktime_or_timegm_usec(&copy_tm, /* utc= */ true, &best_before);
447-
if (r < 0)
448-
return log_error_errno(r, "Failed to convert best before time: %m");
449-
if (copy_tm.tm_mday != parsed_tm.tm_mday ||
450-
copy_tm.tm_mon != parsed_tm.tm_mon ||
451-
copy_tm.tm_year != parsed_tm.tm_year) {
452-
/* date was not normalized? (e.g. "30th of feb") */
453-
log_warning("Found best before marker with a non-normalized data, ignoring: %s", fn);
437+
r = parse_calendar_date(e, &best_before);
438+
if (r < 0) {
439+
log_warning_errno(r, "Found best before marker with an invalid date, ignoring: %s", fn);
454440
return 0;
455441
}
456442

src/test/test-time-util.c

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,4 +1281,62 @@ static int intro(void) {
12811281
return EXIT_SUCCESS;
12821282
}
12831283

1284+
TEST(parse_calendar_date) {
1285+
usec_t usec;
1286+
1287+
/* Valid dates */
1288+
ASSERT_OK(parse_calendar_date("2000-01-01", &usec));
1289+
ASSERT_OK(parse_calendar_date("1970-01-01", &usec));
1290+
ASSERT_EQ(usec, 0u); /* epoch */
1291+
ASSERT_OK(parse_calendar_date("2000-02-29", &usec)); /* leap year */
1292+
1293+
/* NULL ret is allowed (validation only) */
1294+
ASSERT_OK(parse_calendar_date("2000-06-15", NULL));
1295+
1296+
/* Non-normalized dates */
1297+
ASSERT_ERROR(parse_calendar_date("2023-02-29", &usec), EINVAL); /* not a leap year */
1298+
ASSERT_ERROR(parse_calendar_date("2023-04-31", &usec), EINVAL); /* April has 30 days */
1299+
ASSERT_ERROR(parse_calendar_date("2023-13-01", &usec), EINVAL); /* month 13 */
1300+
ASSERT_ERROR(parse_calendar_date("2023-00-01", &usec), EINVAL); /* month 0 */
1301+
1302+
/* Malformed input */
1303+
ASSERT_ERROR(parse_calendar_date("", &usec), EINVAL);
1304+
ASSERT_ERROR(parse_calendar_date("not-a-date", &usec), EINVAL);
1305+
ASSERT_ERROR(parse_calendar_date("2023-06-15T00:00:00", &usec), EINVAL); /* trailing time */
1306+
ASSERT_ERROR(parse_calendar_date("2023/06/15", &usec), EINVAL); /* wrong separator */
1307+
ASSERT_ERROR(parse_calendar_date("06-15-2023", &usec), EINVAL); /* wrong order */
1308+
}
1309+
1310+
TEST(parse_birth_date) {
1311+
struct tm tm;
1312+
1313+
/* Valid dates */
1314+
ASSERT_OK(parse_birth_date("2000-06-15", &tm));
1315+
ASSERT_EQ(tm.tm_year, 100); /* 2000 - 1900 */
1316+
ASSERT_EQ(tm.tm_mon, 5); /* June, 0-indexed */
1317+
ASSERT_EQ(tm.tm_mday, 15);
1318+
1319+
/* Pre-epoch dates */
1320+
ASSERT_OK(parse_birth_date("1960-03-25", &tm));
1321+
ASSERT_EQ(tm.tm_year, 60);
1322+
ASSERT_EQ(tm.tm_mon, 2);
1323+
ASSERT_EQ(tm.tm_mday, 25);
1324+
1325+
/* NULL ret is allowed (validation only) */
1326+
ASSERT_OK(parse_birth_date("2000-01-01", NULL));
1327+
1328+
/* Non-date fields should not be relied upon */
1329+
ASSERT_OK(parse_birth_date("2000-06-15", &tm));
1330+
ASSERT_FALSE(BIRTH_DATE_IS_SET(BIRTH_DATE_UNSET));
1331+
1332+
/* Non-normalized dates */
1333+
ASSERT_ERROR(parse_birth_date("2023-02-29", &tm), EINVAL);
1334+
ASSERT_ERROR(parse_birth_date("2023-04-31", &tm), EINVAL);
1335+
1336+
/* Malformed input */
1337+
ASSERT_ERROR(parse_birth_date("", &tm), EINVAL);
1338+
ASSERT_ERROR(parse_birth_date("not-a-date", &tm), EINVAL);
1339+
ASSERT_ERROR(parse_birth_date("2023-06-15T00:00:00", &tm), EINVAL);
1340+
}
1341+
12841342
DEFINE_TEST_MAIN_WITH_INTRO(LOG_INFO, intro);

0 commit comments

Comments
 (0)