systemd: fix /etc/localtime symlink

This is essentially a revert of e8a805af10
in favor of a proper fix because unlike first assumed, this affects
not just systemd internally, but virtually any program that uses
icu::TimeZone to read the system's time zone.

And while we could patch icu, I believe this will take far too long and
we arguably don't know who else depends on the same assumption icu made.

All I know is every chromium-based browser[^0], including electron,
calls icu::TimeZone::detectHostTimeZone(), which returns the special
time zone Etc/Unknown instead of the configured one when using
timedated, either directly via timedatectl set-timezone or via D-Bus.

The reason for this is icu::TimeZone assumes[^1] every valid
/etc/localtime symlink includes /zoneinfo/ before the zoneinfo (olson)
timezone id.

This is a pretty decent guess and also applied to NixOS since ever, up
until we bumped systemd to v258 in 1c5d47dd78,
which included an upstream change that made the symlink relative.

The issue is, a relative /etc/localtime symlink in other distros results
in e.g. ../usr/share/zoneinfo/UTC, not zoneinfo/UTC. For NixOS, however,
we patch systemd to use /etc/zoneinfo instead of /usr/share/zoneinfo.

zoneinfo/UTC breaks the assumption icu::TimeZone makes, because
zoneinfo/ is not /zoneinfo/. We could try to trick it by needlessly
making the relative symlink ./zoneinfo/ instead, but this is both more
fragile and makes the patch significantly larger. And as stated above
already, we simply don't know who else makes similar assumptions.

So to fix this for good, we force the symlink to be absolute, just like
we had it prior to systemd v258.

Note that we could also simply unconditionally set

~~~nix
{
  systemd.services."systemd-timedated".environment.SYSTEMD_ETC_LOCALTIME = "/etc/localtime";
}
~~~

to make the symlink absolute. But I'd argue this would cause this
edge-case to slowly slide into obscurity, almost asking for it to
resurface in the future eventually.

[0]: https://source.chromium.org/chromium/chromium/src/+/refs/tags/146.0.7680.177:services/device/time_zone_monitor/time_zone_monitor.cc;l=57-59;bpv=0
[1]: https://github.com/unicode-org/icu/blob/release-78.3/icu4c/source/common/putil.cpp#L686-L695
This commit is contained in:
emilylange
2026-04-04 05:06:44 +02:00
parent 638b993283
commit 2fb95a568c
@@ -5,12 +5,12 @@ Subject: [PATCH] Change /usr/share/zoneinfo to /etc/zoneinfo
NixOS uses this path.
---
man/localtime.xml | 4 ++--
src/basic/time-util.c | 8 ++++----
src/firstboot/firstboot.c | 9 +++------
src/nspawn/nspawn.c | 4 ++--
src/timedate/timedated.c | 8 ++++----
5 files changed, 15 insertions(+), 18 deletions(-)
man/localtime.xml | 4 ++--
src/basic/time-util.c | 8 ++++----
src/firstboot/firstboot.c | 9 +++------
src/nspawn/nspawn.c | 4 ++--
src/timedate/timedated.c | 15 +++++++++------
5 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/man/localtime.xml b/man/localtime.xml
index 3a13e04a27..4fd58068a1 100644
@@ -35,7 +35,7 @@ index 3a13e04a27..4fd58068a1 100644
<literal>Etc/UTC</literal>. The resulting link should lead to the
corresponding binary
diff --git a/src/basic/time-util.c b/src/basic/time-util.c
index 5dd00af952..e682337ef0 100644
index 5dd00af952..b97a41f6ac 100644
--- a/src/basic/time-util.c
+++ b/src/basic/time-util.c
@@ -1443,7 +1443,7 @@ static int get_timezones_from_zone1970_tab(char ***ret) {
@@ -70,7 +70,7 @@ index 5dd00af952..e682337ef0 100644
return r; /* Return EINVAL if not a symlink */
- const char *e = PATH_STARTSWITH_SET(t, "/usr/share/zoneinfo/", "../usr/share/zoneinfo/");
+ const char *e = PATH_STARTSWITH_SET(t, "/etc/zoneinfo/", "../etc/zoneinfo/", "zoneinfo/");
+ const char *e = PATH_STARTSWITH_SET(t, "/etc/zoneinfo/", "../etc/zoneinfo/");
if (!e)
return -EINVAL;
if (!timezone_is_valid(e, LOG_DEBUG))
@@ -103,7 +103,7 @@ index ae1899593c..20d3071114 100644
return log_error_errno(r, "Failed to create /etc/localtime symlink: %m");
diff --git a/src/nspawn/nspawn.c b/src/nspawn/nspawn.c
index a0052c1c88..39f2b1f1e5 100644
index a0052c1c88..cd4a48c9ef 100644
--- a/src/nspawn/nspawn.c
+++ b/src/nspawn/nspawn.c
@@ -1856,8 +1856,8 @@ int userns_mkdir(const char *root, const char *path, mode_t mode, uid_t uid, gid
@@ -112,13 +112,13 @@ index a0052c1c88..39f2b1f1e5 100644
path,
- "../usr/share/zoneinfo/",
- "/usr/share/zoneinfo/");
+ "../etc/zoneinfo/", "zoneinfo/",
+ "../etc/zoneinfo/",
+ "/etc/zoneinfo/");
}
static bool etc_writable(void) {
diff --git a/src/timedate/timedated.c b/src/timedate/timedated.c
index 1455830cba..532fe3684b 100644
index 1455830cba..6849ada033 100644
--- a/src/timedate/timedated.c
+++ b/src/timedate/timedated.c
@@ -268,7 +268,7 @@ static int context_read_data(Context *c) {
@@ -139,7 +139,7 @@ index 1455830cba..532fe3684b 100644
if (unlink(etc_localtime()) < 0 && errno != ENOENT)
return -errno;
@@ -300,9 +300,9 @@ static int context_write_data_timezone(Context *c) {
@@ -300,17 +300,20 @@ static int context_write_data_timezone(Context *c) {
return 0;
}
@@ -151,3 +151,16 @@ index 1455830cba..532fe3684b 100644
if (!p)
return -ENOMEM;
source = p;
}
- return symlinkat_atomic_full(source, AT_FDCWD, etc_localtime(),
- !secure_getenv("SYSTEMD_ETC_LOCALTIME"));
+ /* With zoneinfo being at /etc/zoneinfo instead of /usr/share/zoneinfo on NixOS, and etc_localtime() left untouched
+ * at /etc/localtime, a relative symlink would look like zoneinfo/UTC instead of ../usr/share/zoneinfo/UTC. This is
+ * an issue because not just systemd itself (src/basic/time-util.c @@ get_timezone), but also others like icu::TimeZone
+ * reject such symlinks. Forcing the symlink to be absolute (/etc/zoneinfo/UTC) takes care of at least both of them. */
+ return symlinkat_atomic_full(source, AT_FDCWD, etc_localtime(), /* flags */ 0);
}
static const char* etc_adjtime(void) {