Files
nixpkgs/nixos/tests/userborn-migration.nix
r-vdp 3d3e3dbfc4 nixos/userborn: import legacy /var/lib/nixos state on first run
ids that were allocated and later freed only live in
/var/lib/nixos/{uid,gid}-map. userborn allocates from /etc/passwd
alone, so without this a new user could get a previously-used uid and
inherit file ownership.

Service has a ConditionPathExists on the uid-map and a sentinel under
/var/lib/userborn, so it runs at most once and is a no-op on fresh
installs.
2026-08-13 12:16:35 +02:00

145 lines
4.8 KiB
Nix

{ lib, ... }:
# Verifies that switching from update-users-groups.pl to userborn honours
# /var/lib/nixos/{uid,gid}-map and declarative-{users,groups}, so removed
# users keep their ids reserved and are not reassigned.
{
name = "userborn-migration";
meta.maintainers = with lib.maintainers; [ rvdp ];
nodes.machine = {
services.userborn.enable = false;
systemd.sysusers.enable = false;
users.mutableUsers = true;
users.users = {
survivor = {
isNormalUser = true;
};
ghost = {
isNormalUser = true;
};
intruder = {
isNormalUser = true;
# Only created under userborn. Must not get ghost's old uid.
enable = lib.mkDefault false;
};
};
specialisation = {
# Still perl-managed. ghost is removed but kept in uid-map.
base-ghost-removed.configuration = {
users.users.ghost.enable = lib.mkForce false;
};
# Switch to userborn with ghost gone and a new unpinned user.
userborn.configuration = {
services.userborn.enable = lib.mkForce true;
users.users.ghost.enable = lib.mkForce false;
users.users.intruder.enable = true;
};
# ghost re-added under userborn. Must keep its original uid.
userborn-revived.configuration = {
services.userborn.enable = lib.mkForce true;
users.users.intruder.enable = true;
};
# The migration service must be removable from the closure.
userborn-without-import.configuration = {
services.userborn.enable = lib.mkForce true;
services.userborn.importLegacyState = false;
users.users.intruder.enable = true;
};
};
};
testScript =
# python
''
import json
machine.wait_for_unit("multi-user.target")
def uid(name: str) -> int:
return int(machine.succeed(f"id --user {name}").strip())
def switch(specialisation: str) -> None:
machine.succeed(
f"/run/booted-system/specialisation/{specialisation}/bin/switch-to-configuration switch 2>&1 | tee /dev/stderr"
)
with subtest("perl: capture allocated state"):
survivor_uid = uid("survivor")
ghost_uid = uid("ghost")
uid_map = json.loads(machine.succeed("cat /var/lib/nixos/uid-map"))
t.assertEqual(uid_map["ghost"], ghost_uid)
t.assertEqual(uid_map["survivor"], survivor_uid)
with subtest("perl: remove ghost"):
switch("base-ghost-removed")
machine.fail("getent passwd ghost")
uid_map = json.loads(machine.succeed("cat /var/lib/nixos/uid-map"))
t.assertEqual(
uid_map["ghost"], ghost_uid, "perl script must retain removed users in uid-map"
)
with subtest("userborn: legacy state is imported"):
# The import only runs while userborn's state directory is absent.
machine.fail("test -e /var/lib/userborn")
switch("userborn")
machine.succeed("test -d /var/lib/userborn")
# ghost must be a locked stub with its original uid.
ghost_passwd = machine.succeed("getent passwd ghost").strip()
t.assertEqual(
int(ghost_passwd.split(":")[2]),
ghost_uid,
f"ghost stub has wrong uid: {ghost_passwd}",
)
ghost_shadow = machine.succeed("getent shadow ghost").strip()
t.assertTrue(
ghost_shadow.split(":")[1].startswith("!"),
f"ghost stub is not locked: {ghost_shadow}",
)
# previous-userborn.json is replaced by ExecStartPost after use,
# so check the journal instead.
machine.succeed(
"journalctl -u userborn-import-legacy.service --grep 'synthesised.*previous-userborn.json'"
)
with subtest("userborn: no uid collision for new user"):
intruder_uid = uid("intruder")
t.assertNotEqual(
intruder_uid,
ghost_uid,
"intruder was allocated ghost's old uid; migration failed to reserve it",
)
t.assertEqual(uid("survivor"), survivor_uid, "survivor uid changed across migration")
with subtest("userborn: revival keeps original uid"):
switch("userborn-revived")
t.assertEqual(
uid("ghost"),
ghost_uid,
"ghost was not revived with its original uid",
)
with subtest("idempotency"):
machine.succeed("systemctl restart userborn-import-legacy.service")
result = machine.succeed(
"systemctl show -p ConditionResult userborn-import-legacy.service"
).strip()
t.assertEqual(result, "ConditionResult=no")
with subtest("legacy import service can be excluded"):
switch("userborn-without-import")
machine.fail("systemctl cat userborn-import-legacy.service")
'';
}