From 7086284f14d654d452f439e3968ee5964a1272b5 Mon Sep 17 00:00:00 2001 From: "R. Ryantm" Date: Thu, 30 Apr 2026 15:42:32 +0000 Subject: [PATCH 01/46] dhcpcd: 10.3.1 -> 10.3.2 --- pkgs/by-name/dh/dhcpcd/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/dh/dhcpcd/package.nix b/pkgs/by-name/dh/dhcpcd/package.nix index fd0119918706..b7ef86a31b75 100644 --- a/pkgs/by-name/dh/dhcpcd/package.nix +++ b/pkgs/by-name/dh/dhcpcd/package.nix @@ -15,13 +15,13 @@ stdenv.mkDerivation (finalAttrs: { pname = "dhcpcd"; - version = "10.3.1"; + version = "10.3.2"; src = fetchFromGitHub { owner = "NetworkConfiguration"; repo = "dhcpcd"; rev = "v${finalAttrs.version}"; - sha256 = "sha256-L2rR6/qMHWVth2GR3VAoBZmhA6lmCLddbi0VvEG5r70="; + sha256 = "sha256-tJV533j/nQT/PP5KVPJCgTo0Lu8NNMIGnJBvYUG8ufw="; }; nativeBuildInputs = [ pkg-config ]; From c0cbeb9d8e5a731ba88500fb01690cb92455fc25 Mon Sep 17 00:00:00 2001 From: Doron Behar Date: Tue, 5 May 2026 18:07:13 +0300 Subject: [PATCH 02/46] nixos/virtualisation: explain resolution is only relevant to grub --- nixos/modules/virtualisation/qemu-vm.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index b58cdd8ac5f7..5f9ef2eedc84 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -552,7 +552,7 @@ in y = 768; }; description = '' - The resolution of the virtual machine display. + The resolution of the virtual machine display (relevant only if virtualised machine uses grub bootloader). ''; }; From 44c6c2ef166cc796b417d9885b966489b23a2bf9 Mon Sep 17 00:00:00 2001 From: Doron Behar Date: Tue, 5 May 2026 22:56:15 +0300 Subject: [PATCH 03/46] nixos/virtualisation: remove hard-coded virtio-gpu-pci device from aarch machines In 9e78baf5c75ed87360dfcab4c5eeeee2256be2dc, Aarch machines were added the `-device virtio-gpu-pci` argument, to mirror `-vga std` option that was added unconditionally to virtualised x86_64 machines. Since then, in a7ca287ecb3bd5589d2f815a27f1152071d92b57, the `-vga std` option was removed, because that is the default value of this option embedded in QEMU since version 2.2. Removing it from the hard-coded list of qemu options makes it possible for the user to choose their own `-vga` value. Similarly, for Aarch virtualised machines, choosing unconditionally the `-device virtio-gpu-pci` option instead of e.g `-device virtio-gpu-gl-pci`, can be bothersome too - see: https://discourse.nixos.org/t/test-an-aarch64-linux-vm-wayland-compositor-from-an-x86-64-linux-host/77416/2 --- nixos/modules/virtualisation/qemu-vm.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index 5f9ef2eedc84..1d879218a690 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -1380,7 +1380,6 @@ in "-device usb-tablet,bus=usb-bus.0" ]) (mkIf pkgs.stdenv.hostPlatform.isAarch [ - "-device virtio-gpu-pci" "-device usb-ehci,id=usb0" "-device usb-kbd" "-device usb-tablet" From 97c68e5d5cac7af2d1bda3f8449f821b9f404961 Mon Sep 17 00:00:00 2001 From: Sergei Trofimovich Date: Sun, 10 May 2026 10:52:48 +0100 Subject: [PATCH 04/46] btrfs-progs: 6.19.1 -> 7.0 Changes: https://raw.githubusercontent.com/kdave/btrfs-progs/v7.0/CHANGES --- pkgs/by-name/bt/btrfs-progs/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/bt/btrfs-progs/package.nix b/pkgs/by-name/bt/btrfs-progs/package.nix index 80f500c9ad30..e5034da09c2e 100644 --- a/pkgs/by-name/bt/btrfs-progs/package.nix +++ b/pkgs/by-name/bt/btrfs-progs/package.nix @@ -21,11 +21,11 @@ stdenv.mkDerivation (finalAttrs: { pname = "btrfs-progs"; - version = "6.19.1"; + version = "7.0"; src = fetchurl { url = "mirror://kernel/linux/kernel/people/kdave/btrfs-progs/btrfs-progs-v${finalAttrs.version}.tar.xz"; - hash = "sha256-uyfh7FTnw8C3suWW+FOnPAej1y8hvJQEIHPCTb8EV5Y="; + hash = "sha256-wobWh2y81yMnoLQX5M/SgDU+wj43tUn9vNeACoMtmpk="; }; nativeBuildInputs = [ From 76c8d4509934c8d32a8203a62c36df5aadf7de43 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Wed, 20 May 2026 19:40:45 +0200 Subject: [PATCH 05/46] switch-to-configuration-ng: honour X-* directives in user-unit migration pass The post-activation pass added in 5cc82c4922f8 to handle units migrating from a per-user manager (home-manager) to NixOS unconditionally restarts or starts any candidate. dbus-broker.service explicitly opts out of restarts via reloadIfChanged because restarting the session bus kills running clients; the second pass ignored that and restarted it anyway. Apply the same X-ReloadIfChanged / X-RestartIfChanged / RefuseManualStop / RefuseManualStart / X-OnlyManualStart checks that handle_modified_unit performs, so a migrated unit is reloaded, skipped, restarted or started as its directives require. Covered by new switch-test specialisations for reloadIfChanged and restartIfChanged = false. --- nixos/tests/switch-test.nix | 39 ++++ .../sw/switch-to-configuration-ng/src/main.rs | 216 ++++++++++++++++-- 2 files changed, 238 insertions(+), 17 deletions(-) diff --git a/nixos/tests/switch-test.nix b/nixos/tests/switch-test.nix index 480d284ada1d..e83e43430fae 100644 --- a/nixos/tests/switch-test.nix +++ b/nixos/tests/switch-test.nix @@ -739,6 +739,22 @@ in ''; }; + # As above, but with reloadIfChanged: pass 2 must reload, not + # restart. + userServiceMigratedToNixosReloadOnly.configuration = { + imports = [ userServiceMigratedToNixosNoStop.configuration ]; + systemd.user.services.migrated = { + reloadIfChanged = true; + serviceConfig.ExecReload = "${pkgs.coreutils}/bin/true"; + }; + }; + + # As above, but with restartIfChanged = false: pass 2 must skip it. + userServiceMigratedToNixosNoRestart.configuration = { + imports = [ userServiceMigratedToNixosNoStop.configuration ]; + systemd.user.services.migrated.restartIfChanged = false; + }; + no_inhibitors.configuration.system.switch.inhibitors = lib.mkForce { }; inhibitors.configuration.system.switch.inhibitors = lib.mkForce { @@ -1818,6 +1834,29 @@ in out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner") assert_contains(out, "nixos") + # Pass 2 must honour reloadIfChanged. + switch_to_specialisation("${machine}", "") + machine.fail(f"sudo -u usertest {user_env} systemctl --user is-active migrated.service") + seed_home_unit() + out = switch_to_specialisation("${machine}", "userServiceMigratedToNixosReloadOnly") + assert_lacks(out, "restarting (post-activation) the following user units: migrated.service") + assert_contains(out, "reloading (post-activation) the following user units: migrated.service") + user_systemctl("is-active migrated.service") + # Reloaded only, so the home ExecStart never re-ran. + out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner") + assert_contains(out, "home") + + # Pass 2 must honour restartIfChanged = false. + switch_to_specialisation("${machine}", "") + machine.fail(f"sudo -u usertest {user_env} systemctl --user is-active migrated.service") + seed_home_unit() + out = switch_to_specialisation("${machine}", "userServiceMigratedToNixosNoRestart") + assert_lacks(out, "\nrestarting (post-activation) the following user units: migrated.service") + assert_contains(out, "NOT restarting (post-activation) the following user units: migrated.service") + user_systemctl("is-active migrated.service") + out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner") + assert_contains(out, "home") + # Units that remain shadowed by ~/.config must be left alone in both # passes even though /etc now also defines them. switch_to_specialisation("${machine}", "") diff --git a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs index d9568c7b5464..6046d3437ae0 100644 --- a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs +++ b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs @@ -688,12 +688,7 @@ fn handle_modified_unit( let reload_list = scope.reload_list_file(); let use_restart_as_stop_and_start = new_unit_info.is_none(); - if matches!( - unit, - "sysinit.target" | "basic.target" | "multi-user.target" | "graphical.target" - ) || unit.ends_with(".unit") - || unit.ends_with(".slice") - { + if cannot_be_restarted_directly(unit) { // Do nothing. These cannot be restarted directly. // Slices and Paths don't have to be restarted since properties (resource limits and @@ -940,6 +935,17 @@ fn parse_fstab(fstab: impl BufRead) -> (HashMap, HashMap bool { + matches!( + unit, + "sysinit.target" | "basic.target" | "multi-user.target" | "graphical.target" + ) || unit.ends_with(".path") + || unit.ends_with(".slice") +} + // Returns a HashMap containing the same contents as the passed in `units`, minus the units in // `units_to_filter`. fn filter_units( @@ -957,6 +963,50 @@ fn filter_units( res } +/// Action to take on a unit that migrated to NixOS ownership during the +/// post-activation pass. Honours the same X-* directives as +/// `handle_modified_unit`. +#[derive(Debug, PartialEq)] +enum MigrationAction { + Skip, + Reload, + Restart, + Start, +} + +impl MigrationAction { + /// Action to take on a migrated unit that is still active. + fn for_active_unit(unit: &str, new_unit_info: &UnitInfo) -> Self { + if cannot_be_restarted_directly(unit) { + return Self::Skip; + } + + if parse_systemd_bool(Some(new_unit_info), "Service", "X-ReloadIfChanged", false) { + return Self::Reload; + } + + if !parse_systemd_bool(Some(new_unit_info), "Service", "X-RestartIfChanged", true) + || parse_systemd_bool(Some(new_unit_info), "Unit", "RefuseManualStop", false) + || parse_systemd_bool(Some(new_unit_info), "Unit", "X-OnlyManualStart", false) + { + return Self::Skip; + } + + Self::Restart + } + + /// Action to take on a migrated unit that the previous owner stopped. + fn for_stopped_unit(new_unit_info: &UnitInfo) -> Self { + if parse_systemd_bool(Some(new_unit_info), "Unit", "RefuseManualStart", false) + || parse_systemd_bool(Some(new_unit_info), "Unit", "X-OnlyManualStart", false) + { + return Self::Skip; + } + + Self::Start + } +} + fn unit_is_active(conn: &LocalConnection, unit: &str) -> Result { let unit_object_path = conn .with_proxy( @@ -1510,29 +1560,40 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { let active_after = get_active_units(&systemd)?; + let mut to_reload = HashMap::new(); let mut to_restart = HashMap::new(); let mut to_start = HashMap::new(); + let mut to_skip = HashMap::new(); for unit in &migration_candidates { - match active_after.get(unit) { + // Honour X-* directives so reloadIfChanged/restartIfChanged hold. + let new_unit_file = new_unit_dir.join(unit); + let new_unit_info = parse_unit(&new_unit_file, &new_unit_file)?; + + let action = match active_after.get(unit) { Some(unit_state) => { + // Only act if /etc now wins (i.e. the higher-priority + // copy is gone). Read errors are treated as "leave alone". let now_etc = unit_state .proxy .get("org.freedesktop.systemd1.Unit", "FragmentPath") .map(|p: String| p.starts_with(fragment_prefix)) .unwrap_or(false); - if now_etc { - // Still running with the previous manager's binary; - // restart so the /etc definition takes effect. - to_restart.insert(unit.clone(), ()); + if !now_etc { + // Still shadowed (or read error); leave it alone. + continue; } - // else: still shadowed by ~/.config, leave it alone. + MigrationAction::for_active_unit(unit, &new_unit_info) } - None => { - // Stopped by the previous manager; start the /etc copy. - to_start.insert(unit.clone(), ()); - } - } + // Stopped by the previous manager; start the /etc copy. + None => MigrationAction::for_stopped_unit(&new_unit_info), + }; + match action { + MigrationAction::Skip => to_skip.insert(unit.clone(), ()), + MigrationAction::Reload => to_reload.insert(unit.clone(), ()), + MigrationAction::Restart => to_restart.insert(unit.clone(), ()), + MigrationAction::Start => to_start.insert(unit.clone(), ()), + }; } // Re-start active targets so any other newly-unmasked dependencies are @@ -1543,6 +1604,24 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { } } + if !to_skip.is_empty() { + print_units("NOT restarting (post-activation)", &to_skip); + } + + print_units("reloading (post-activation)", &to_reload); + for unit in to_reload.keys() { + match systemd.reload_unit(unit, "replace") { + Ok(job_path) => { + submitted_jobs.borrow_mut().insert(job_path, Job::Reload); + } + Err(err) => { + eprintln!("Failed to reload user unit {unit}: {err}"); + exit_code = 4; + } + } + } + block_on_jobs(&dbus_conn, &submitted_jobs); + print_units("restarting (post-activation)", &to_restart); for unit in to_restart.keys() { match systemd.restart_unit(unit, "replace") { @@ -2863,4 +2942,107 @@ After=dev-disk-by\x2dlabel-root.device ); } } + + fn unit_info( + sections: &[(&str, &[(&str, &str)])], + ) -> HashMap>> { + sections + .iter() + .map(|(section, kvs)| { + ( + section.to_string(), + kvs.iter() + .map(|(k, v)| (k.to_string(), vec![v.to_string()])) + .collect(), + ) + }) + .collect() + } + + #[test] + fn migration_action_for_active_unit() { + use super::MigrationAction; + + // Plain service: restart. + assert_eq!( + MigrationAction::for_active_unit("foo.service", &unit_info(&[])), + MigrationAction::Restart + ); + + // reloadIfChanged must reload, not restart. + assert_eq!( + MigrationAction::for_active_unit( + "foo.service", + &unit_info(&[("Service", &[("X-ReloadIfChanged", "true")])]) + ), + MigrationAction::Reload + ); + + // X-RestartIfChanged=false (restartIfChanged = false) must skip. + assert_eq!( + MigrationAction::for_active_unit( + "foo.service", + &unit_info(&[("Service", &[("X-RestartIfChanged", "false")])]) + ), + MigrationAction::Skip + ); + + // RefuseManualStop must skip. + assert_eq!( + MigrationAction::for_active_unit( + "foo.service", + &unit_info(&[("Unit", &[("RefuseManualStop", "yes")])]) + ), + MigrationAction::Skip + ); + + // X-OnlyManualStart must skip. + assert_eq!( + MigrationAction::for_active_unit( + "foo.service", + &unit_info(&[("Unit", &[("X-OnlyManualStart", "yes")])]) + ), + MigrationAction::Skip + ); + + // Units that cannot be restarted directly must skip. + for unit in [ + "sysinit.target", + "basic.target", + "multi-user.target", + "graphical.target", + "foo.path", + "bar.slice", + ] { + assert_eq!( + MigrationAction::for_active_unit(unit, &unit_info(&[])), + MigrationAction::Skip, + "{unit}" + ); + } + } + + #[test] + fn migration_action_for_stopped_unit() { + use super::MigrationAction; + + assert_eq!( + MigrationAction::for_stopped_unit(&unit_info(&[])), + MigrationAction::Start + ); + assert_eq!( + MigrationAction::for_stopped_unit(&unit_info(&[( + "Unit", + &[("RefuseManualStart", "true")] + )])), + MigrationAction::Skip + ); + assert_eq!( + MigrationAction::for_stopped_unit(&unit_info(&[( + "Unit", + &[("X-OnlyManualStart", "true")] + )])), + MigrationAction::Skip + ); + } } From 6ced06a1b337cdf403b1e53407cac9cb156b856b Mon Sep 17 00:00:00 2001 From: r-vdp Date: Wed, 20 May 2026 19:46:27 +0200 Subject: [PATCH 06/46] switch-to-configuration-ng: rework user-unit migration candidate selection The previous "FragmentPath not under /etc" deny-list swept up units that were never managed by a per-user manager (e.g. dbus-broker, whose FragmentPath systemd reports under /run/current-system/sw/share via systemd.packages) and missed nothing it should have caught, but for the wrong reason. Make the intent explicit. A unit is a migration candidate iff it is active, the new generation defines it in /etc/systemd/user, and either * its FragmentPath is under $XDG_CONFIG_HOME/systemd/user (the home-manager case; ~/.config shadows /etc, so we must wait for sd-switch to remove the copy), or * its FragmentPath is anywhere else outside /etc and the previous generation did not have it in /etc (package-shipped units found via $XDG_DATA_HOME / $XDG_DATA_DIRS, e.g. ~/.nix-profile/share; /etc outranks these so it wins on daemon-reload). The "previous generation did not have it" guard keeps units that have always been in /etc, but whose FragmentPath systemd reports elsewhere, out of the candidate set, and the existing now_etc check verifies /etc actually won before acting. Compare FragmentPath by parent directory instead of string prefix while here. Covered by a new switch-test case that seeds a unit in ~/.local/share/systemd/user. --- nixos/tests/switch-test.nix | 39 +++++++++ .../sw/switch-to-configuration-ng/src/main.rs | 85 +++++++++++++++---- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/nixos/tests/switch-test.nix b/nixos/tests/switch-test.nix index e83e43430fae..4126645f59e0 100644 --- a/nixos/tests/switch-test.nix +++ b/nixos/tests/switch-test.nix @@ -826,6 +826,15 @@ in RemainAfterExit=true ExecStart=${pkgs.runtimeShell} -c 'echo home > %t/migrated-owner' ''; + + # Unit file placed in ~/.local/share/systemd/user (lower priority than + # /etc) to simulate a package-shipped unit. + dataMigratedUnit = pkgs.writeText "migrated.service" '' + [Service] + Type=oneshot + RemainAfterExit=true + ExecStart=${pkgs.runtimeShell} -c 'echo data > %t/migrated-owner' + ''; in # python '' @@ -1857,6 +1866,36 @@ in out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner") assert_contains(out, "home") + # Migration from a lower-priority search-path entry ($XDG_DATA_HOME + # here, standing in for ~/.nix-profile/share etc.). /etc outranks + # these, so pass 2 must restart onto the /etc definition. + switch_to_specialisation("${machine}", "") + machine.fail(f"sudo -u usertest {user_env} systemctl --user is-active migrated.service") + machine.succeed( + "sudo -u usertest mkdir -p ~usertest/.local/share/systemd/user", + "sudo -u usertest cp ${dataMigratedUnit} ~usertest/.local/share/systemd/user/migrated.service", + ) + user_systemctl("daemon-reload") + user_systemctl("start migrated.service") + user_systemctl("is-active migrated.service") + out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner") + assert_contains(out, "data") + out = user_systemctl("show -p FragmentPath migrated.service") + assert_contains(out, "/.local/share/systemd/user/migrated.service") + out = switch_to_specialisation("${machine}", "userServiceMigratedShadowed") + assert_contains(out, "restarting (post-activation) the following user units: migrated.service") + user_systemctl("is-active migrated.service") + out = user_systemctl("show -p FragmentPath migrated.service") + assert_contains(out, "/etc/systemd/user/migrated.service") + out = machine.succeed(f"sudo -u usertest {user_env} cat /run/user/1001/migrated-owner") + assert_contains(out, "nixos") + # Switching again must NOT touch it: /etc already had it, so it is + # not a candidate even though the lower-priority copy is still there. + out = switch_to_specialisation("${machine}", "userServiceMigratedShadowed") + assert_lacks(out, "migrated.service") + machine.succeed("sudo -u usertest rm -rf ~usertest/.local/share/systemd") + user_systemctl("daemon-reload") + # Units that remain shadowed by ~/.config must be left alone in both # passes even though /etc now also defines them. switch_to_specialisation("${machine}", "") diff --git a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs index 6046d3437ae0..bebf3c46a4bf 100644 --- a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs +++ b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs @@ -1395,26 +1395,79 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { let current_active_units = get_active_units(&systemd)?; + let old_unit_dir = old_toplevel.join(scope.etc_dir()); let new_unit_dir = toplevel.join(scope.etc_dir()); - let fragment_prefix = scope - .current_dir() - .to_str() - .expect("scope dir is valid UTF-8"); + let fragment_dir = scope.current_dir(); - // Units that are currently running from a non-/etc location (typically - // ~/.config/systemd/user, i.e. home-manager) but that the new NixOS - // configuration also defines. Pass 1 will skip these because of the - // FragmentPath filter; if the per-user activation (sd-switch) later drops - // its copy, we need a second pass to bring the NixOS-owned definition up. + // Determine $XDG_CONFIG_HOME/systemd/user from the user manager's own + // environment (we are spawned with env_clear()). + let user_config_unit_dir: Option = match systemd.environment() { + Err(err) => { + log::debug!("Failed to read user manager environment: {err}"); + None + } + Ok(env) => { + let lookup = |key: &str| { + env.iter().find_map(|kv| { + kv.strip_prefix(key) + .and_then(|rest| rest.strip_prefix('=')) + .filter(|v| Path::new(v).is_absolute()) + .map(PathBuf::from) + }) + }; + let config_home = + lookup("XDG_CONFIG_HOME").or_else(|| lookup("HOME").map(|h| h.join(".config"))); + if config_home.is_none() { + log::debug!( + "Neither $XDG_CONFIG_HOME nor $HOME is set in the user manager's environment" + ); + } + config_home.map(|config_home| config_home.join("systemd/user")) + } + }; + + if user_config_unit_dir.is_none() { + log::debug!( + "Could not determine $XDG_CONFIG_HOME/systemd/user; \ + units shadowed by ~/.config will not be considered for migration" + ); + } + + // Units active from a non-/etc location that the new generation defines + // in /etc/systemd/user. Pass 1 skips these (FragmentPath filter); pass 2 + // brings the /etc definition into effect once /etc has won. Two cases: + // * ~/.config/systemd/user (home-manager): shadows /etc, so wait for + // the per-user activation (sd-switch) to remove its copy. + // * anywhere else outside /etc ($XDG_DATA_HOME, $XDG_DATA_DIRS, ...): + // /etc outranks these, so only act when /etc is gaining the unit; + // if the previous generation already had it, leave it alone. + // Pass 2's `now_etc` check verifies /etc actually won before acting. let migration_candidates: Vec = current_active_units .iter() .filter(|(unit, _)| new_unit_dir.join(unit).exists()) - .filter(|(_, unit_state)| { - !unit_state + .filter(|(unit, unit_state)| { + let Ok(fragment_path) = unit_state .proxy - .get("org.freedesktop.systemd1.Unit", "FragmentPath") - .map(|p: String| p.starts_with(fragment_prefix)) - .unwrap_or(false) + .get::("org.freedesktop.systemd1.Unit", "FragmentPath") + else { + return false; + }; + let fragment_parent = Path::new(&fragment_path).parent(); + + // Already in /etc: handled by pass 1. + if fragment_parent == Some(fragment_dir) { + return false; + } + + // Loaded from ~/.config/systemd/user, which shadows /etc. + if let Some(dir) = &user_config_unit_dir { + if fragment_parent == Some(dir.as_path()) { + return true; + } + } + + // Elsewhere: only act if /etc is gaining the unit this switch. + !old_unit_dir.join(unit).exists() }) .map(|(unit, _)| unit.clone()) .collect(); @@ -1422,7 +1475,7 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { collect_unit_changes( &toplevel, scope, - &old_toplevel.join(scope.etc_dir()), + &old_unit_dir, &new_unit_dir, ¤t_active_units, &mut units_to_stop, @@ -1577,7 +1630,7 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { let now_etc = unit_state .proxy .get("org.freedesktop.systemd1.Unit", "FragmentPath") - .map(|p: String| p.starts_with(fragment_prefix)) + .map(|p: String| Path::new(&p).parent() == Some(fragment_dir)) .unwrap_or(false); if !now_etc { // Still shadowed (or read error); leave it alone. From 9dc20fcb5590998f4d52f7f5360a06d090de7d55 Mon Sep 17 00:00:00 2001 From: "R. Ryantm" Date: Fri, 22 May 2026 12:47:06 +0000 Subject: [PATCH 07/46] libgit2: 1.9.3 -> 1.9.4 --- pkgs/by-name/li/libgit2/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/li/libgit2/package.nix b/pkgs/by-name/li/libgit2/package.nix index bfac9149df06..568fa09201aa 100644 --- a/pkgs/by-name/li/libgit2/package.nix +++ b/pkgs/by-name/li/libgit2/package.nix @@ -22,7 +22,7 @@ stdenv.mkDerivation (finalAttrs: { pname = "libgit2"; - version = "1.9.3"; + version = "1.9.4"; # also check the following packages for updates: python3Packages.pygit2 and libgit2-glib outputs = [ @@ -35,7 +35,7 @@ stdenv.mkDerivation (finalAttrs: { owner = "libgit2"; repo = "libgit2"; tag = "v${finalAttrs.version}"; - hash = "sha256-nJrRdPs86oGNL4W2CJb16oSUgfzYr9A2i5sw9BAehME="; + hash = "sha256-ZKUiz3pdFE2SKxh53X2oyr7hs32Njj5YVA0OXDXz7h0="; }; cmakeFlags = [ From 734846393f8c523fb79fe21cf6747ebf5306f13a Mon Sep 17 00:00:00 2001 From: Alyssa Ross Date: Fri, 20 Mar 2026 09:31:27 +0100 Subject: [PATCH 08/46] qemu: 10.2.2 -> 11.0.0 Co-authored-by: Emily --- pkgs/by-name/qe/qemu/package.nix | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/pkgs/by-name/qe/qemu/package.nix b/pkgs/by-name/qe/qemu/package.nix index 31515f58d636..dea71a96bd84 100644 --- a/pkgs/by-name/qe/qemu/package.nix +++ b/pkgs/by-name/qe/qemu/package.nix @@ -140,11 +140,11 @@ stdenv.mkDerivation (finalAttrs: { + lib.optionalString nixosTestRunner "-for-vm-tests" + lib.optionalString toolsOnly "-utils" + lib.optionalString userOnly "-user"; - version = "10.2.2"; + version = "11.0.0"; src = fetchurl { url = "https://download.qemu.org/qemu-${finalAttrs.version}.tar.xz"; - hash = "sha256-eEspb/KcFBeqcjI6vLLS6pq5dxck9Xfc14XDsE8h4XY="; + hash = "sha256-wEyjYBJlPzLRHGdNNwz1KnEOfT8Ywti2PkkyBSpIVNY="; }; depsBuildBuild = [ @@ -165,6 +165,8 @@ stdenv.mkDerivation (finalAttrs: { # For python changes other than simple package additions, ping @dramforever for review. # Don't change `python3Packages` to `python3.pkgs.*`, breaks cross-compilation. python3Packages.distlib + python3Packages.setuptools + python3Packages.wheel # Hooks from the python package are needed to add `$pythonPath` so # `python/scripts/mkvenv.py` can detect `meson` otherwise the vendored meson without patches will be used. python3Packages.python @@ -374,7 +376,11 @@ stdenv.mkDerivation (finalAttrs: { # tests can still timeout on slower systems doCheck = false; - nativeCheckInputs = [ socat ]; + nativeCheckInputs = [ + python3Packages.pygdbmi + python3Packages.qemu-qmp + socat + ]; preCheck = '' # time limits are a little meagre for a build machine that's # potentially under load. @@ -444,7 +450,7 @@ stdenv.mkDerivation (finalAttrs: { license = lib.licenses.gpl2Plus; maintainers = with lib.maintainers; [ qyliss ]; teams = lib.optionals xenSupport xen.meta.teams; - platforms = lib.platforms.unix; + platforms = with lib.systems.inspect; patternLogicalAnd patterns.is64bit patterns.isUnix; } # toolsOnly: Does not have qemu-kvm and there's no main support tool # userOnly: There's one qemu- for every architecture @@ -453,7 +459,14 @@ stdenv.mkDerivation (finalAttrs: { } # userOnly: https://qemu.readthedocs.io/en/master/user/main.html#supported-operating-systems // lib.optionalAttrs userOnly { - platforms = with lib.platforms; (linux ++ freebsd ++ openbsd ++ netbsd); + platforms = + with lib.systems.inspect; + patternLogicalAnd patterns.is64bit [ + patterns.isLinux + patterns.isFreeBSD + patterns.isOpenBSD + patterns.isNetBSD + ]; description = "QEMU User space emulator - launch executables compiled for one CPU on another CPU"; }; }) From 663a59e0b6d7575d7465ea0f03b6fa66f6298038 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Sat, 23 May 2026 12:50:06 +0200 Subject: [PATCH 09/46] nixos/activation: run user nixos-activation.service exactly once per switch stc-ng starts every active target (including default.target) and then explicitly restarts nixos-activation.service. As a Type=oneshot without RemainAfterExit the unit is inactive after login, so the default.target start job re-runs it via Wants=, and the explicit restart runs it again (or, depending on ordering, SIGTERMs the currently running script and re-runs it). Set RemainAfterExit=yes so target starts are a no-op for an already-run activation, and restartIfChanged=false so the unit-diff pass leaves it alone when the script changes. The explicit restart in stc-ng remains the single trigger per switch. Print that restart so it is visible in the switch output, and drop it from the "NOT restarting" list. Extend the user-activation-scripts test to assert the activation is only run once, and never killed. --- nixos/modules/system/activation/activation-script.nix | 3 +++ nixos/tests/switch-test.nix | 7 ++++--- nixos/tests/user-activation-scripts.nix | 8 ++++++++ pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs | 4 ++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/nixos/modules/system/activation/activation-script.nix b/nixos/modules/system/activation/activation-script.nix index 8fd274276a61..338fc1911c01 100644 --- a/nixos/modules/system/activation/activation-script.nix +++ b/nixos/modules/system/activation/activation-script.nix @@ -322,6 +322,9 @@ in description = "Run user-specific NixOS activation"; script = config.system.userActivationScripts.script; unitConfig.ConditionUser = "!@system"; + # switch-to-configuration restarts this explicitly on every switch. + restartIfChanged = false; + serviceConfig.RemainAfterExit = true; serviceConfig.Type = "oneshot"; wantedBy = [ "default.target" ]; }; diff --git a/nixos/tests/switch-test.nix b/nixos/tests/switch-test.nix index 4126645f59e0..256c25108593 100644 --- a/nixos/tests/switch-test.nix +++ b/nixos/tests/switch-test.nix @@ -1754,9 +1754,10 @@ in out = switch_to_specialisation("${machine}", "simpleUserService") user_systemctl("is-active usertest.service") - # No-op switch does nothing + # No-op switch leaves the test unit alone. out = switch_to_specialisation("${machine}", "simpleUserService") - assert_lacks(out, "user units:") + assert_lacks(out, "usertest.service") + assert_contains(out, "restarting the following user units: nixos-activation.service") # Modifying the unit stop-starts it (default stopIfChanged=true) out = switch_to_specialisation("${machine}", "simpleUserServiceModified") @@ -1773,7 +1774,7 @@ in # reloadIfChanged=true reloads instead out = switch_to_specialisation("${machine}", "simpleUserServiceReload") assert_lacks(out, "stopping the following user units:") - assert_lacks(out, "restarting the following user units:") + assert_lacks(out, "restarting the following user units: usertest.service") assert_contains(out, "reloading the following user units: usertest.service") user_systemctl("is-active usertest.service") diff --git a/nixos/tests/user-activation-scripts.nix b/nixos/tests/user-activation-scripts.nix index e8ea2d05c465..8e29a0ab3ae1 100644 --- a/nixos/tests/user-activation-scripts.nix +++ b/nixos/tests/user-activation-scripts.nix @@ -13,6 +13,7 @@ isNormalUser = true; }; systemd.user.tmpfiles.users.alice.rules = [ "r %h/file-to-remove" ]; + specialisation.changed.configuration.system.userActivationScripts.bar = "true"; }; testScript = '' @@ -36,5 +37,12 @@ machine.succeed("/run/current-system/bin/switch-to-configuration test") verify_user_activation_run_count(2) machine.succeed("[[ ! -f /home/alice/file-to-remove ]] || false") + # Activation must not be killed while running. + machine.fail("journalctl -b _SYSTEMD_USER_UNIT=nixos-activation.service | grep -q 'code=killed'") + + # Changed activation script: still exactly one run. + machine.succeed("/run/current-system/specialisation/changed/bin/switch-to-configuration test") + verify_user_activation_run_count(3) + machine.fail("journalctl -b _SYSTEMD_USER_UNIT=nixos-activation.service | grep -q 'code=killed'") ''; } diff --git a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs index bebf3c46a4bf..20c80a50baa5 100644 --- a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs +++ b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs @@ -1486,6 +1486,9 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { &mut units_to_filter, )?; + // Restarted unconditionally below; don't list it as skipped. + units_to_skip.remove("nixos-activation.service"); + let print_units = |verb: &str, units: &HashMap| { if units.is_empty() { return; @@ -1586,6 +1589,7 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> { // Toplevels with system.activatable = false do not ship this unit; mirror // the system scope's tolerance for a missing activate script. if new_unit_dir.join("nixos-activation.service").exists() { + eprintln!("restarting the following user units: nixos-activation.service"); match systemd.restart_unit("nixos-activation.service", "replace") { Ok(_) => { log::debug!("waiting for nixos activation to finish"); From b1a881edb95a7caa4b9afbf8114fa718e106dbe1 Mon Sep 17 00:00:00 2001 From: Maximilian Bosch Date: Sat, 16 May 2026 18:06:33 +0200 Subject: [PATCH 10/46] nixos/user-activation-scripts: refactor assert By using unittest's assertEqual you actually see how often the activation script was being run, i.e. the expected value. (cherry picked from commit 864a84d3e9dff8cbc24ebdadfd8fbae2d2b5e3b7) --- nixos/tests/user-activation-scripts.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nixos/tests/user-activation-scripts.nix b/nixos/tests/user-activation-scripts.nix index 8e29a0ab3ae1..f43332af033f 100644 --- a/nixos/tests/user-activation-scripts.nix +++ b/nixos/tests/user-activation-scripts.nix @@ -18,8 +18,9 @@ testScript = '' def verify_user_activation_run_count(n): - machine.succeed( - '[[ "$(find /home/alice/ -name user-activation-ran.\\* | wc -l)" == %s ]]' % n + t.assertEqual( + n, + int(machine.succeed('find /home/alice/ -name user-activation-ran.\\* | wc -l').rstrip()) ) From 273dacf4f27a7b0ce69f11a409cccef8595772fb Mon Sep 17 00:00:00 2001 From: "R. Ryantm" Date: Sun, 24 May 2026 00:32:45 +0000 Subject: [PATCH 11/46] capstone: 5.0.7 -> 5.0.8 --- pkgs/by-name/ca/capstone/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/ca/capstone/package.nix b/pkgs/by-name/ca/capstone/package.nix index 2f0bb919d37c..23d59748b193 100644 --- a/pkgs/by-name/ca/capstone/package.nix +++ b/pkgs/by-name/ca/capstone/package.nix @@ -8,13 +8,13 @@ stdenv.mkDerivation (finalAttrs: { pname = "capstone"; - version = "5.0.7"; + version = "5.0.8"; src = fetchFromGitHub { owner = "capstone-engine"; repo = "capstone"; rev = finalAttrs.version; - hash = "sha256-+6QReHZK+iIXspizy6Kvk7cj016HOKgiaKSaP4h7mao="; + hash = "sha256-Ww/tv95ba0mBgRvQBMAbb/RbMjdgWwMcCVngtWDv1Vc="; }; cmakeFlags = [ From da38ab0e39b94e1db1247cda2451946005ce9bf3 Mon Sep 17 00:00:00 2001 From: Jakob Klepp <1183303+truh@users.noreply.github.com> Date: Wed, 27 May 2026 17:02:22 +0200 Subject: [PATCH 12/46] nixos-rebuild-ng: use hash token in ControlPath Previously used token %n refers to the hostname which can be `too long for Unix domain socket` --- pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py index 9f6baaa178a9..567cb87c429c 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py @@ -19,7 +19,7 @@ SSH_DEFAULT_OPTS: Final = [ "-o", "ControlMaster=auto", "-o", - f"ControlPath={tmpdir.TMPDIR_PATH / 'ssh-%n'}", + f"ControlPath={tmpdir.TMPDIR_PATH / 'ssh-%C'}", "-o", "ControlPersist=60", ] From e20cd6adb006694024503cc668763774d6724f83 Mon Sep 17 00:00:00 2001 From: Benjamin Sparks Date: Wed, 27 May 2026 23:22:57 +0200 Subject: [PATCH 13/46] nixos-test-driver: adher to `select`'s interface TL;DR add `assert` to correct location for type-narrowing typeshed recently updated their definition of `select` in https://github.com/python/typeshed/commit/bcb6399e155cb6444150fe98dfd9c24eb0343016. The result is that the members of the lists passed to `select` now require an upper bound of `FileDescriptorLike`. The `self.shell` variable is typed as `socket | None`, but previously passed checking against typeshed due to the missing upper-bound. Despite the source code including a type-narrowing `assert`, `ty` does not make use of it within the inner function that `select` is used in, meaning `self.shell` wasn't narrowed at the appropriate place. Now that typeshed has added this upper-bound, type-checking starts to fail, as evidenced in https://github.com/NixOS/nixpkgs/pull/523288#issuecomment-4529738483. So the correct fix is just to add the `assert` into the inner function. --- nixos/lib/test-driver/src/test_driver/machine/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nixos/lib/test-driver/src/test_driver/machine/__init__.py b/nixos/lib/test-driver/src/test_driver/machine/__init__.py index 790da705cf4f..db96445af912 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -1031,6 +1031,7 @@ class QemuMachine(BaseMachine): As soon as we read some data from the socket here, we assume that our root shell is operational. """ + assert self.shell (ready, _, _) = select.select([self.shell], [], [], timeout_secs) return bool(ready) From 3133e5783011608e549b82a72daa6d3e2a7593e5 Mon Sep 17 00:00:00 2001 From: c6rg0 Date: Thu, 28 May 2026 16:14:49 +0100 Subject: [PATCH 14/46] ruff: 0.15.14 -> 0.15.15 --- pkgs/by-name/ru/ruff/package.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkgs/by-name/ru/ruff/package.nix b/pkgs/by-name/ru/ruff/package.nix index 08d40e4e30ff..75992b42240e 100644 --- a/pkgs/by-name/ru/ruff/package.nix +++ b/pkgs/by-name/ru/ruff/package.nix @@ -16,7 +16,7 @@ rustPlatform.buildRustPackage (finalAttrs: { pname = "ruff"; - version = "0.15.14"; + version = "0.15.15"; __structuredAttrs = true; @@ -24,12 +24,12 @@ rustPlatform.buildRustPackage (finalAttrs: { owner = "astral-sh"; repo = "ruff"; tag = finalAttrs.version; - hash = "sha256-Z8UhVS+YbYAxVWodU/I+p3Ns5/EpmzBTChcbkvJwe6Y="; + hash = "sha256-WpjOOCYLZ1d8XPUx3qNHD+fuK6t65u/1/ZezABWpBD0="; }; cargoBuildFlags = [ "--package=ruff" ]; - cargoHash = "sha256-GnRC5jXySAna7uAKPDtpPQUJe8AKqVSU+ynmGKZtfTs="; + cargoHash = "sha256-SfkoLl43Y1DNqIRW+HljVcEHWhedTS99SGhMvkQ4dmo="; nativeBuildInputs = [ installShellFiles ]; From 59cdf0f127dd6bbbcfe044bbe17f2b7ed951c9a4 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:12:01 +0200 Subject: [PATCH 15/46] nixos-rebuild-ng: introduce Elevator abstraction Adds elevate.py with an Elevator base, NoElevator and SudoElevator. Nothing uses it yet. This is a pure addition so the next commit can swap run_wrapper over without mixing new code with the move. The SudoElevator wrapping is byte-for-byte what run_wrapper currently emits (including NIX_SUDOOPTS handling), so behaviour is unchanged once the switch happens. Motivated by #507054: threading a second `run0: bool` alongside `sudo: bool` through every signature does not scale and makes invalid combinations representable. --- .../src/nixos_rebuild/elevate.py | 190 ++++++++++++++++++ .../src/tests/test_elevate.py | 50 +++++ 2 files changed, 240 insertions(+) create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py create mode 100644 pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py new file mode 100644 index 000000000000..e75fd099d678 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py @@ -0,0 +1,190 @@ +"""Privilege-elevation backends for activation commands. + +An :class:`Elevator` describes how to wrap a command so it runs as root, +both on the local machine and on a remote target host over SSH (where no +controlling terminal is available), and how to feed it a pre-supplied +password when the backend supports that. ``run_wrapper`` and its callers +carry a single ``elevate: Elevator`` value and let it produce the command +prefix and stdin. +""" + +from __future__ import annotations + +import os +import shlex +from abc import ABC, abstractmethod +from dataclasses import dataclass, field, replace +from enum import Enum +from typing import Final, Self, override + + +@dataclass(frozen=True) +class Wrapped: + """Result of wrapping a command for elevation.""" + + #: Arguments to prepend to the command. Kept as ``list[str]`` rather than + #: the wider ``process.Args`` because elevators only ever prepend plain + #: strings; callers splice this into their existing ``Args`` list. + prefix: list[str] + #: Text to send on the wrapped command's stdin (typically a password + #: followed by a newline), or ``None`` to leave stdin alone. + stdin: str | None = None + + +class Elevator(ABC): + """How to gain root for activation commands.""" + + #: CLI name, e.g. ``sudo`` or ``run0``. + name: str + + @property + def elevates(self) -> bool: + """Whether this elevator actually changes privileges. + + ``run_wrapper`` uses this to decide between passing ``env`` to + :func:`subprocess.run` directly (unprivileged local case) and + injecting it via ``env -i`` inside the wrapped command (where the + elevator may otherwise scrub the environment). + """ + return True + + @abstractmethod + def wrap_local(self) -> Wrapped: + """Wrap a command run on the local machine.""" + + @abstractmethod + def wrap_remote(self) -> Wrapped: + """Wrap a command run on a target host over SSH. + + The remote side has no controlling terminal, so backends that need + interactive prompts must either accept a pre-supplied password + (see :meth:`with_password`) or rely on a passwordless policy on + the target. + """ + + @abstractmethod + def with_password(self, password: str) -> Self: + """Return a copy that will feed *password* to the backend. + + Backends that have no stdin path for credentials must raise + :class:`ElevateError` with a hint pointing at the alternative + (e.g. a polkit rule). + """ + + def on_remote_failure(self) -> str | None: + """Optional hint to print when a remote elevated command fails.""" + return None + + +class ElevateError(Exception): + """Raised for invalid elevator/flag combinations.""" + + +@dataclass(frozen=True) +class NoElevator(Elevator): + name: str = "none" + + @property + @override + def elevates(self) -> bool: + return False + + @override + def wrap_local(self) -> Wrapped: + return Wrapped(prefix=[]) + + @override + def wrap_remote(self) -> Wrapped: + return Wrapped(prefix=[]) + + @override + def with_password(self, password: str) -> Self: + raise ElevateError( + "--ask-elevate-password requires --elevate to select an elevation method" + ) + + +@dataclass(frozen=True) +class SudoElevator(Elevator): + """Wrap with ``sudo``, optionally feeding the password on stdin. + + Extra arguments come from ``NIX_SUDOOPTS`` for backwards + compatibility with the previous implementation in ``run_wrapper``. + """ + + name: str = "sudo" + password: str | None = None + extra_opts: list[str] = field( + default_factory=lambda: shlex.split(os.getenv("NIX_SUDOOPTS", "")) + ) + + @override + def wrap_local(self) -> Wrapped: + # Local sudo can prompt on /dev/tty itself, so the password is + # only piped when one was supplied explicitly. + return self._wrap() + + @override + def wrap_remote(self) -> Wrapped: + return self._wrap() + + def _wrap(self) -> Wrapped: + if self.password is not None: + return Wrapped( + prefix=["sudo", "--prompt=", "--stdin", *self.extra_opts], + stdin=self.password + "\n", + ) + return Wrapped(prefix=["sudo", *self.extra_opts]) + + @override + def with_password(self, password: str) -> Self: + return replace(self, password=password) + + @override + def on_remote_failure(self) -> str | None: + if self.password is None: + return ( + "while running command with remote sudo, did you forget to " + "use --ask-elevate-password?" + ) + return None + + +class ElevatorKind(Enum): + """CLI-selectable elevation backends. + + The enum *value* is the :class:`Elevator` subclass to instantiate, + ``str(member)`` is what ``--elevate`` accepts on the command line. + Extended by later commits. + """ + + NONE = NoElevator + SUDO = SudoElevator + + @override + def __str__(self) -> str: + return self.name.lower() + + def make(self) -> Elevator: + """Instantiate a fresh, unparameterised elevator of this kind.""" + cls: type[Elevator] = self.value + return cls() + + @classmethod + def choices(cls) -> list[str]: + """All ``--elevate`` values, for argparse ``choices=``.""" + return [str(m) for m in cls] + + @classmethod + def from_name(cls, name: str) -> Elevator: + try: + return cls[name.upper()].make() + except KeyError: + raise ElevateError( + f"unknown elevation method {name!r}, choose from: " + + ", ".join(cls.choices()) + ) from None + + +#: Singleton used as the default ``elevate=`` argument throughout. +NO_ELEVATOR: Final[Elevator] = NoElevator() diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py new file mode 100644 index 000000000000..349d36827448 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py @@ -0,0 +1,50 @@ +import pytest +from pytest import MonkeyPatch + +import nixos_rebuild.elevate as e + + +def test_no_elevator() -> None: + n = e.NoElevator() + assert not n.elevates + assert n.wrap_local() == e.Wrapped(prefix=[]) + assert n.wrap_remote() == e.Wrapped(prefix=[]) + with pytest.raises(e.ElevateError): + n.with_password("x") + + +def test_sudo_elevator(monkeypatch: MonkeyPatch) -> None: + monkeypatch.delenv("NIX_SUDOOPTS", raising=False) + + s = e.SudoElevator() + assert s.elevates + assert s.wrap_local() == e.Wrapped(prefix=["sudo"]) + assert s.wrap_remote() == e.Wrapped(prefix=["sudo"]) + assert s.on_remote_failure() is not None + + sp = s.with_password("hunter2") + assert sp.wrap_remote() == e.Wrapped( + prefix=["sudo", "--prompt=", "--stdin"], + stdin="hunter2\n", + ) + assert sp.on_remote_failure() is None + # original unchanged + assert s.password is None + + +def test_sudo_elevator_extra_opts(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("NIX_SUDOOPTS", "--preserve-env=FOO -H") + s = e.SudoElevator() + assert s.wrap_local() == e.Wrapped(prefix=["sudo", "--preserve-env=FOO", "-H"]) + assert s.with_password("p").wrap_remote() == e.Wrapped( + prefix=["sudo", "--prompt=", "--stdin", "--preserve-env=FOO", "-H"], + stdin="p\n", + ) + + +def test_elevator_kind() -> None: + assert isinstance(e.ElevatorKind.from_name("sudo"), e.SudoElevator) + assert isinstance(e.ElevatorKind.from_name("none"), e.NoElevator) + with pytest.raises(e.ElevateError): + e.ElevatorKind.from_name("doas") + assert set(e.ElevatorKind.choices()) == {"none", "sudo"} From b9a7b55898b8d3a671694a60ba2a8b8634832c2c Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:20:39 +0200 Subject: [PATCH 16/46] nixos-rebuild-ng: run_wrapper: delegate sudo wrapping to Elevator Replaces the inline sudo command assembly with calls into the Elevator introduced in the previous commit. The `sudo: bool` parameter is kept as a temporary shim that constructs a SudoElevator internally, so no caller or test changes are needed and the existing test suite proves the move is behaviour-preserving. The shim is removed in the next commit. --- .../src/nixos_rebuild/process.py | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py index 9f6baaa178a9..3a0bd047a5d9 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py @@ -12,6 +12,7 @@ from ipaddress import AddressValueError, IPv6Address from typing import Final, Literal, Self, TextIO, TypedDict, Unpack, override from . import tmpdir +from .elevate import NO_ELEVATOR, Elevator, SudoElevator logger: Final = logging.getLogger(__name__) @@ -130,10 +131,16 @@ def run_wrapper( env: Mapping[str, EnvValue] | None = None, append_local_env: Mapping[str, str] | None = None, remote: Remote | None = None, + elevate: Elevator = NO_ELEVATOR, sudo: bool = False, **kwargs: Unpack[RunKwargs], ) -> subprocess.CompletedProcess[str]: "Wrapper around `subprocess.run` that supports extra functionality." + # Back-compat shim while callers are migrated to ``elevate=``; + # removed in the next commit. + if sudo: + elevate = SudoElevator(password=remote.sudo_password if remote else None) + process_input = None run_args: list[Arg] = list(args) final_args: list[Arg] @@ -142,7 +149,10 @@ def run_wrapper( resolved_env = _resolve_env_local(normalized_env) if remote: + wrapped = elevate.wrap_remote() + process_input = wrapped.stdin remote_run_args: list[Arg] = [ + *wrapped.prefix, "/bin/sh", "-c", _remote_shell_script(normalized_env), @@ -150,20 +160,6 @@ def run_wrapper( *run_args, ] - if sudo: - sudo_args = shlex.split(os.getenv("NIX_SUDOOPTS", "")) - if remote.sudo_password: - remote_run_args = [ - "sudo", - "--prompt=", - "--stdin", - *sudo_args, - *remote_run_args, - ] - process_input = remote.sudo_password + "\n" - else: - remote_run_args = ["sudo", *sudo_args, *remote_run_args] - ssh_args: list[Arg] = [ "ssh", *remote.opts, @@ -176,22 +172,19 @@ def run_wrapper( popen_env = None # keep ssh's environment normal else: - if sudo: - # subprocess.run(env=...) would affect sudo, but sudo may drop env - # for the target command. - # So we inject env via `sudo env ... cmd`. + wrapped = elevate.wrap_local() + process_input = wrapped.stdin + if elevate.elevates: + # subprocess.run(env=...) would affect the elevator process, + # which may then drop env for the target command. Inject env + # via `env -i ... cmd` instead so it survives. if env is not None and resolved_env: run_args = _prefix_env_cmd(run_args, resolved_env) - - sudo_args = shlex.split(os.getenv("NIX_SUDOOPTS", "")) - final_args = ["sudo", *sudo_args, *run_args] - - # No need to pass env to subprocess.run; keep sudo's own env - # default. + final_args = [*wrapped.prefix, *run_args] popen_env = None else: - # Non-sudo local: we can fully control the environment with - # subprocess.run(env=...) + # Unprivileged local: we can fully control the environment + # with subprocess.run(env=...) final_args = run_args popen_env = None if env is None else resolved_env @@ -225,16 +218,14 @@ def run_wrapper( return r except KeyboardInterrupt: - # sudo commands are activation only and unlikely to be long running - if remote and not sudo: + # elevated commands are activation only and unlikely to be long + # running + if remote and not elevate.elevates: _kill_long_running_ssh_process(args, remote) raise except subprocess.CalledProcessError: - if sudo and remote and remote.sudo_password is None: - logger.error( - "while running command with remote sudo, did you forget to use " - "--ask-sudo-password?" - ) + if remote and (hint := elevate.on_remote_failure()): + logger.error(hint) raise From c0941322a4f423dc42dd0a4c06be1bc0b513d4c2 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:26:41 +0200 Subject: [PATCH 17/46] nixos-rebuild-ng: thread Elevator through the call chain Mechanical replacement of `sudo: bool` with `elevate: Elevator` across run_wrapper, nix.py and services.py, plus test updates. The temporary shim in run_wrapper is removed. `args.elevator` is constructed in execute() from the existing --sudo / --ask-sudo-password flags so the CLI surface is unchanged in this commit. --- .../src/nixos_rebuild/__init__.py | 10 ++- .../nixos-rebuild-ng/src/nixos_rebuild/nix.py | 29 ++++--- .../src/nixos_rebuild/process.py | 8 +- .../src/nixos_rebuild/services.py | 10 +-- .../ni/nixos-rebuild-ng/src/tests/test_nix.py | 86 ++++++++++--------- .../src/tests/test_process.py | 11 ++- 6 files changed, 81 insertions(+), 73 deletions(-) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py index 71480548fb50..28b6c3e44d75 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py @@ -6,6 +6,7 @@ from typing import Final, assert_never from . import nix, services from .constants import EXECUTABLE, WITH_SHELL_FILES +from .elevate import NO_ELEVATOR, SudoElevator from .models import Action, BuildAttr, Flake, GroupedNixArgs, Profile from .process import Remote from .utils import LogFormatter @@ -321,7 +322,7 @@ def execute(argv: list[str]) -> None: args, grouped_nix_args = parse_args(argv) if args.upgrade or args.upgrade_all: - nix.upgrade_channels(args.upgrade_all, args.sudo) + nix.upgrade_channels(args.upgrade_all, args.elevator) action = Action(args.action) # Only run shell scripts from the Nixpkgs tree if the action is @@ -339,6 +340,13 @@ def execute(argv: list[str]) -> None: profile = Profile.from_arg(args.profile_name) target_host = Remote.from_arg(args.target_host, args.ask_sudo_password) build_host = Remote.from_arg(args.build_host, False, validate_opts=False) + args.elevator = ( + SudoElevator( + password=target_host.sudo_password if target_host else None, + ) + if args.sudo + else NO_ELEVATOR + ) build_attr = BuildAttr.from_arg(args.attr, args.file) flake = Flake.from_arg(args.flake, target_host) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py index c6b5b4d63c45..38894b734133 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py @@ -14,6 +14,7 @@ from textwrap import dedent from typing import Final, Literal from . import tmpdir +from .elevate import NO_ELEVATOR, Elevator from .models import ( Action, BuildAttr, @@ -453,7 +454,7 @@ def get_generations(profile: Profile) -> list[Generation]: def get_generations_from_nix_env( profile: Profile, target_host: Remote | None = None, - sudo: bool = False, + elevate: Elevator = NO_ELEVATOR, ) -> list[Generation]: """Get all NixOS generations from profile with nix-env. Needs root. @@ -468,7 +469,7 @@ def get_generations_from_nix_env( ["nix-env", "-p", profile.path, "--list-generations"], stdout=PIPE, remote=target_host, - sudo=sudo, + elevate=elevate, ) def parse_line(line: str) -> Generation: @@ -600,12 +601,12 @@ def repl_flake(flake: Flake, flake_flags: Args | None = None) -> None: ) -def rollback(profile: Profile, target_host: Remote | None, sudo: bool) -> Path: +def rollback(profile: Profile, target_host: Remote | None, elevate: Elevator) -> Path: "Rollback Nix profile, like one created by `nixos-rebuild switch`." run_wrapper( ["nix-env", "--rollback", "-p", profile.path], remote=target_host, - sudo=sudo, + elevate=elevate, ) # Rollback config PATH is the own profile return profile.path @@ -614,11 +615,11 @@ def rollback(profile: Profile, target_host: Remote | None, sudo: bool) -> Path: def rollback_temporary_profile( profile: Profile, target_host: Remote | None, - sudo: bool, + elevate: Elevator, ) -> Path | None: "Rollback a temporary Nix profile, like one created by `nixos-rebuild test`." generations = get_generations_from_nix_env( - profile, target_host=target_host, sudo=sudo + profile, target_host=target_host, elevate=elevate ) previous_gen_id = None for generation in generations: @@ -635,7 +636,7 @@ def set_profile( profile: Profile, path_to_config: Path, target_host: Remote | None, - sudo: bool, + elevate: Elevator, ) -> None: "Set a path as the current active Nix profile." if not os.environ.get( @@ -668,7 +669,7 @@ def set_profile( run_wrapper( ["nix-env", "-p", profile.path, "--set", path_to_config], remote=target_host, - sudo=sudo, + elevate=elevate, ) @@ -676,7 +677,7 @@ def switch_to_configuration( path_to_config: Path, action: Literal[Action.SWITCH, Action.BOOT, Action.TEST, Action.DRY_ACTIVATE], target_host: Remote | None, - sudo: bool, + elevate: Elevator, install_bootloader: bool = False, specialisation: str | None = None, ) -> None: @@ -716,7 +717,7 @@ def switch_to_configuration( "NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0", }, remote=target_host, - sudo=sudo, + elevate=elevate, # switch-to-configuration is not expected to produce meaningful # stdout, but if it (or any of its children) does, it would leak # into our stdout and break the "only the store path on stdout" @@ -728,7 +729,7 @@ def switch_to_configuration( def upgrade_channels( all_channels: bool = False, - sudo: bool = False, + elevate: Elevator = NO_ELEVATOR, channels_dir: Path = Path("/nix/var/nix/profiles/per-user/root/channels/"), ) -> None: """Upgrade channels for classic Nix. @@ -736,10 +737,10 @@ def upgrade_channels( It will either upgrade just the `nixos` channel (including any channel that has a `.update-on-nixos-rebuild` file) or all. """ - if not sudo and os.geteuid() != 0: + if not elevate.elevates and os.geteuid() != 0: raise NixOSRebuildError( "if you pass the '--upgrade' or '--upgrade-all' flag, you must " - "also pass '--sudo' or run the command as root (e.g., with sudo)" + "also pass '--elevate' or run the command as root" ) channel_updated = False @@ -752,7 +753,7 @@ def upgrade_channels( run_wrapper( ["nix-channel", "--update", channel_path.name], check=False, - sudo=sudo, + elevate=elevate, ) channel_updated = True diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py index 3a0bd047a5d9..121c33980459 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py @@ -12,7 +12,7 @@ from ipaddress import AddressValueError, IPv6Address from typing import Final, Literal, Self, TextIO, TypedDict, Unpack, override from . import tmpdir -from .elevate import NO_ELEVATOR, Elevator, SudoElevator +from .elevate import NO_ELEVATOR, Elevator logger: Final = logging.getLogger(__name__) @@ -132,15 +132,9 @@ def run_wrapper( append_local_env: Mapping[str, str] | None = None, remote: Remote | None = None, elevate: Elevator = NO_ELEVATOR, - sudo: bool = False, **kwargs: Unpack[RunKwargs], ) -> subprocess.CompletedProcess[str]: "Wrapper around `subprocess.run` that supports extra functionality." - # Back-compat shim while callers are migrated to ``elevate=``; - # removed in the next commit. - if sudo: - elevate = SudoElevator(password=remote.sudo_password if remote else None) - process_input = None run_args: list[Arg] = list(args) final_args: list[Arg] diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py index 4de74aa9c4d4..5237075a17ec 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py @@ -132,12 +132,12 @@ def _rollback_system( ) -> Path: match action: case Action.SWITCH | Action.BOOT: - path_to_config = nix.rollback(profile, target_host, sudo=args.sudo) + path_to_config = nix.rollback(profile, target_host, elevate=args.elevator) case Action.TEST | Action.BUILD: maybe_path_to_config = nix.rollback_temporary_profile( profile, target_host, - sudo=args.sudo, + elevate=args.elevator, ) if maybe_path_to_config: path_to_config = maybe_path_to_config @@ -231,13 +231,13 @@ def _activate_system( profile, path_to_config, target_host=target_host, - sudo=args.sudo, + elevate=args.elevator, ) nix.switch_to_configuration( path_to_config, action, target_host=target_host, - sudo=args.sudo, + elevate=args.elevator, specialisation=args.specialisation, install_bootloader=args.install_bootloader, ) @@ -247,7 +247,7 @@ def _activate_system( path_to_config, action, target_host=target_host, - sudo=args.sudo, + elevate=args.elevator, specialisation=args.specialisation, install_bootloader=args.install_bootloader, ) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py index 9b225eca4e84..ef198fdac275 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py @@ -9,12 +9,15 @@ from unittest.mock import ANY, Mock, call, patch import pytest from pytest import MonkeyPatch +import nixos_rebuild.elevate as e import nixos_rebuild.models as m import nixos_rebuild.nix as n import nixos_rebuild.process as p from .helpers import get_qualified_name +SUDO = e.SudoElevator() + @patch( get_qualified_name(n.run_wrapper, n), @@ -531,7 +534,7 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None: ["nix-env", "-p", path, "--list-generations"], stdout=PIPE, remote=None, - sudo=False, + elevate=e.NO_ELEVATOR, ) remote = m.Remote("user@host", [], "password", "ssh") @@ -539,7 +542,7 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None: get_qualified_name(n.run_wrapper, n), autospec=True, return_value=return_value ) as mock_run: assert n.get_generations_from_nix_env( - m.Profile("system", path), remote, True + m.Profile("system", path), remote, SUDO ) == [ m.Generation(id=2082, current=False, timestamp="2024-11-07 22:58:56"), m.Generation(id=2083, current=False, timestamp="2024-11-07 22:59:41"), @@ -549,7 +552,7 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None: ["nix-env", "-p", path, "--list-generations"], stdout=PIPE, remote=remote, - sudo=True, + elevate=SUDO, ) @@ -640,19 +643,19 @@ def test_rollback(mock_run: Mock, tmp_path: Path) -> None: profile = m.Profile("system", path) - assert n.rollback(profile, None, False) == profile.path + assert n.rollback(profile, None, e.NO_ELEVATOR) == profile.path mock_run.assert_called_with( ["nix-env", "--rollback", "-p", path], remote=None, - sudo=False, + elevate=e.NO_ELEVATOR, ) target_host = m.Remote("user@localhost", [], None, "ssh") - assert n.rollback(profile, target_host, True) == profile.path + assert n.rollback(profile, target_host, SUDO) == profile.path mock_run.assert_called_with( ["nix-env", "--rollback", "-p", path], remote=target_host, - sudo=True, + elevate=SUDO, ) @@ -672,7 +675,7 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None: """), ) assert ( - n.rollback_temporary_profile(m.Profile("system", path), None, False) + n.rollback_temporary_profile(m.Profile("system", path), None, e.NO_ELEVATOR) == path.parent / "system-2083-link" ) mock_run.assert_called_with( @@ -684,12 +687,12 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None: ], stdout=PIPE, remote=None, - sudo=False, + elevate=e.NO_ELEVATOR, ) target_host = m.Remote("user@localhost", [], None, "ssh") assert ( - n.rollback_temporary_profile(m.Profile("foo", path), target_host, True) + n.rollback_temporary_profile(m.Profile("foo", path), target_host, SUDO) == path.parent / "foo-2083-link" ) mock_run.assert_called_with( @@ -701,12 +704,12 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None: ], stdout=PIPE, remote=target_host, - sudo=True, + elevate=SUDO, ) with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run: mock_run.return_value = CompletedProcess([], 0, stdout="") - assert n.rollback_temporary_profile(profile, None, False) is None + assert n.rollback_temporary_profile(profile, None, e.NO_ELEVATOR) is None @patch(get_qualified_name(n.run_wrapper, n), autospec=True) @@ -719,25 +722,25 @@ def test_set_profile(mock_run: Mock) -> None: m.Profile("system", profile_path), config_path, target_host=None, - sudo=False, + elevate=e.NO_ELEVATOR, ) mock_run.assert_called_with( ["nix-env", "-p", profile_path, "--set", config_path], remote=None, - sudo=False, + elevate=e.NO_ELEVATOR, ) mock_run.return_value = CompletedProcess([], 1) - with pytest.raises(m.NixOSRebuildError) as e: + with pytest.raises(m.NixOSRebuildError) as exc: n.set_profile( m.Profile("system", profile_path), config_path, target_host=None, - sudo=False, + elevate=e.NO_ELEVATOR, ) - assert str(e.value).startswith( + assert str(exc.value).startswith( "error: your NixOS configuration path seems to be missing essential files." ) @@ -756,7 +759,7 @@ def test_switch_to_configuration_without_systemd_run( n.switch_to_configuration( profile_path, m.Action.SWITCH, - sudo=False, + elevate=e.NO_ELEVATOR, target_host=None, specialisation=None, install_bootloader=False, @@ -768,21 +771,21 @@ def test_switch_to_configuration_without_systemd_run( "NIXOS_NO_CHECK": p.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "0", }, - sudo=False, + elevate=e.NO_ELEVATOR, remote=None, stdout=sys.stderr, ) - with pytest.raises(m.NixOSRebuildError) as e: + with pytest.raises(m.NixOSRebuildError) as exc: n.switch_to_configuration( config_path, m.Action.BOOT, - sudo=False, + elevate=e.NO_ELEVATOR, target_host=None, specialisation="special", ) assert ( - str(e.value) + str(exc.value) == "error: '--specialisation' can only be used with 'switch' and 'test'" ) @@ -795,7 +798,7 @@ def test_switch_to_configuration_without_systemd_run( n.switch_to_configuration( Path("/path/to/config"), m.Action.TEST, - sudo=True, + elevate=SUDO, target_host=target_host, install_bootloader=True, specialisation="special", @@ -810,7 +813,7 @@ def test_switch_to_configuration_without_systemd_run( "NIXOS_NO_CHECK": p.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1", }, - sudo=True, + elevate=SUDO, remote=target_host, stdout=sys.stderr, ) @@ -830,7 +833,7 @@ def test_switch_to_configuration_with_systemd_run( n.switch_to_configuration( profile_path, m.Action.SWITCH, - sudo=False, + elevate=e.NO_ELEVATOR, target_host=None, specialisation=None, install_bootloader=False, @@ -846,7 +849,7 @@ def test_switch_to_configuration_with_systemd_run( "NIXOS_NO_CHECK": p.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "0", }, - sudo=False, + elevate=e.NO_ELEVATOR, remote=None, stdout=sys.stderr, ) @@ -860,7 +863,7 @@ def test_switch_to_configuration_with_systemd_run( n.switch_to_configuration( Path("/path/to/config"), m.Action.TEST, - sudo=True, + elevate=SUDO, target_host=target_host, install_bootloader=True, specialisation="special", @@ -876,7 +879,7 @@ def test_switch_to_configuration_with_systemd_run( "NIXOS_NO_CHECK": p.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1", }, - sudo=True, + elevate=SUDO, remote=target_host, stdout=sys.stderr, ) @@ -887,11 +890,13 @@ def test_switch_to_configuration_with_systemd_run( def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> None: tmp_path = Path(tmpdir) - with pytest.raises(m.NixOSRebuildError) as e: - n.upgrade_channels(all_channels=False, sudo=False, channels_dir=tmp_path) - assert str(e.value) == ( + with pytest.raises(m.NixOSRebuildError) as exc: + n.upgrade_channels( + all_channels=False, elevate=e.NO_ELEVATOR, channels_dir=tmp_path + ) + assert str(exc.value) == ( "error: if you pass the '--upgrade' or '--upgrade-all' flag, you must " - "also pass '--sudo' or run the command as root (e.g., with sudo)" + "also pass '--elevate' or run the command as root" ) (tmp_path / "nixos").mkdir() @@ -899,19 +904,20 @@ def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> N (tmp_path / "nixos-hardware" / ".update-on-nixos-rebuild").touch() (tmp_path / "home-manager").mkdir() - # should work because we are passing sudo=True even with os.geteuid == 1000 - n.upgrade_channels(all_channels=False, sudo=True, channels_dir=tmp_path) + # should work because we are passing an elevator even with os.geteuid == 1000 + n.upgrade_channels(all_channels=False, elevate=SUDO, channels_dir=tmp_path) + # Path.glob order is filesystem-dependent, so don't assert call order. mock_run.assert_has_calls( [ call( ["nix-channel", "--update", "nixos-hardware"], check=False, - sudo=True, + elevate=SUDO, ), call( ["nix-channel", "--update", "nixos"], check=False, - sudo=True, + elevate=SUDO, ), ], any_order=True, @@ -921,23 +927,23 @@ def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> N # root check mock_geteuid.return_value = 0 - n.upgrade_channels(all_channels=True, sudo=False, channels_dir=tmp_path) + n.upgrade_channels(all_channels=True, elevate=e.NO_ELEVATOR, channels_dir=tmp_path) mock_run.assert_has_calls( [ call( ["nix-channel", "--update", "home-manager"], check=False, - sudo=False, + elevate=e.NO_ELEVATOR, ), call( ["nix-channel", "--update", "nixos-hardware"], check=False, - sudo=False, + elevate=e.NO_ELEVATOR, ), call( ["nix-channel", "--update", "nixos"], check=False, - sudo=False, + elevate=e.NO_ELEVATOR, ), ], any_order=True, diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py index 3780b21f26d5..af8f510c5f49 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py @@ -3,6 +3,7 @@ from unittest.mock import patch from pytest import MonkeyPatch +import nixos_rebuild.elevate as e import nixos_rebuild.models as m import nixos_rebuild.process as p @@ -43,7 +44,7 @@ def test_run_wrapper(mock_run: Any) -> None: p.run_wrapper( ["test", "--with", "flags"], check=False, - sudo=True, + elevate=e.SudoElevator(), env={"FOO": "bar"}, ) mock_run.assert_called_with( @@ -67,7 +68,6 @@ def test_run_wrapper(mock_run: Any) -> None: p.run_wrapper( ["test", "--with", "flags"], check=False, - sudo=False, append_local_env={"FOO": "bar"}, ) mock_run.assert_called_with( @@ -89,7 +89,6 @@ def test_run_wrapper(mock_run: Any) -> None: p.run_wrapper( ["test", "--with", "flags"], check=False, - sudo=False, env={"PATH": "/"}, append_local_env={"FOO": "bar"}, ) @@ -140,7 +139,7 @@ def test_run_wrapper(mock_run: Any) -> None: p.run_wrapper( ["test", "--with", "flags"], check=True, - sudo=True, + elevate=e.SudoElevator(password="password"), env={"FOO": "bar"}, remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"), ) @@ -262,7 +261,7 @@ def test_custom_sudo_args(mock_run: Any) -> None: p.run_wrapper( ["test"], check=False, - sudo=True, + elevate=e.SudoElevator(), ) mock_run.assert_called_with( [ @@ -287,7 +286,7 @@ def test_custom_sudo_args(mock_run: Any) -> None: p.run_wrapper( ["test"], check=False, - sudo=True, + elevate=e.SudoElevator(), remote=m.Remote("user@localhost", [], None, "ssh"), ) mock_run.assert_called_with( From a769269aef810723207d77412a61cc651778e2c1 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:27:32 +0200 Subject: [PATCH 18/46] nixos-rebuild-ng: add --elevate and --ask-elevate-password flags --elevate={none,sudo} picks the privilege-elevation backend and --ask-elevate-password (-S) prompts locally and feeds the password to whichever backend is selected. --sudo and --ask-sudo-password are kept as silent aliases that map onto the new flags. --use-remote-sudo stays deprecated as before. --- .../src/nixos_rebuild/__init__.py | 68 +++++++++++++------ .../nixos-rebuild-ng/src/tests/test_main.py | 28 ++++++++ 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py index 28b6c3e44d75..bff1ccab7ff6 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py @@ -6,7 +6,7 @@ from typing import Final, assert_never from . import nix, services from .constants import EXECUTABLE, WITH_SHELL_FILES -from .elevate import NO_ELEVATOR, SudoElevator +from .elevate import NO_ELEVATOR, ElevateError, ElevatorKind from .models import Action, BuildAttr, Flake, GroupedNixArgs, Profile from .process import Remote from .utils import LogFormatter @@ -164,18 +164,32 @@ def get_parser() -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentPa help="JSON output, only implemented for 'list-generations' right now", ) main_parser.add_argument( - "--ask-sudo-password", - "-S", - action="store_true", - help="Asks for sudo password for remote activation, implies --sudo", + "--elevate", + choices=ElevatorKind.choices(), + default=None, + help="Privilege-elevation method for activation commands", ) main_parser.add_argument( - "--sudo", action="store_true", help="Prefixes activation commands with sudo" + "--ask-elevate-password", + "-S", + action="store_true", + help="Prompt locally for the password to feed to the elevation " + "method, implies --elevate=sudo if --elevate is not given", + ) + main_parser.add_argument( + "--sudo", + action="store_true", + help="Alias for '--elevate=sudo'", + ) + main_parser.add_argument( + "--ask-sudo-password", + action="store_true", + help="Alias for '--elevate=sudo --ask-elevate-password'", ) main_parser.add_argument( "--use-remote-sudo", action="store_true", - help="Deprecated, use '--sudo' instead", + help="Deprecated, use '--elevate=sudo' instead", ) main_parser.add_argument("--no-ssh-tty", action="store_true", help="Deprecated") main_parser.add_argument( @@ -246,16 +260,32 @@ def parse_args( args.action = Action.DRY_BUILD.value if args.ask_sudo_password: - args.sudo = True + args.ask_elevate_password = True + + if args.use_remote_sudo: + parser_warn("--use-remote-sudo is deprecated, use --elevate=sudo instead") + + # Map the elevate flags onto an Elevator. The password itself is + # attached later via Elevator.with_password() once the target host + # (used in the prompt) is known. + if args.elevate is not None: + args.elevator = ElevatorKind.from_name(args.elevate) + elif args.sudo or args.use_remote_sudo or args.ask_sudo_password: + args.elevator = ElevatorKind.SUDO.make() + elif args.ask_elevate_password: + # -S historically implied --sudo. Keep that for muscle memory + # but be explicit now that there is more than one backend. + parser_warn( + "--ask-elevate-password without --elevate, falling back to --elevate=sudo" + ) + args.elevator = ElevatorKind.SUDO.make() + else: + args.elevator = NO_ELEVATOR if args.install_grub: parser_warn("--install-grub is deprecated, use --install-bootloader instead") args.install_bootloader = True - if args.use_remote_sudo: - parser_warn("--use-remote-sudo is deprecated, use --sudo instead") - args.sudo = True - if args.fast: parser_warn("--fast is deprecated, use --no-reexec instead") args.no_reexec = True @@ -338,15 +368,13 @@ def execute(argv: list[str]) -> None: services.reexec(argv, args, grouped_nix_args) profile = Profile.from_arg(args.profile_name) - target_host = Remote.from_arg(args.target_host, args.ask_sudo_password) + target_host = Remote.from_arg(args.target_host, args.ask_elevate_password) build_host = Remote.from_arg(args.build_host, False, validate_opts=False) - args.elevator = ( - SudoElevator( - password=target_host.sudo_password if target_host else None, - ) - if args.sudo - else NO_ELEVATOR - ) + if target_host and target_host.sudo_password: + try: + args.elevator = args.elevator.with_password(target_host.sudo_password) + except ElevateError as ex: + sys.exit(f"error: {ex}") build_attr = BuildAttr.from_arg(args.attr, args.file) flake = Flake.from_arg(args.flake, target_host) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py index 3ebba4edc5b6..27335feeae5d 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py @@ -21,6 +21,34 @@ DEFAULT_RUN_KWARGS = { } +def test_parse_args_elevate() -> None: + r, _ = nr.parse_args(["nixos-rebuild", "switch"]) + assert r.elevator is nr.elevate.NO_ELEVATOR + + r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=sudo"]) + assert isinstance(r.elevator, nr.elevate.SudoElevator) + + # back-compat aliases + for flag in ("--sudo", "--use-remote-sudo"): + r, _ = nr.parse_args(["nixos-rebuild", "switch", flag]) + assert isinstance(r.elevator, nr.elevate.SudoElevator) + + r, _ = nr.parse_args(["nixos-rebuild", "switch", "--ask-sudo-password"]) + assert isinstance(r.elevator, nr.elevate.SudoElevator) + assert r.ask_elevate_password + + # -S without --elevate implies sudo + r, _ = nr.parse_args(["nixos-rebuild", "switch", "-S"]) + assert isinstance(r.elevator, nr.elevate.SudoElevator) + + # explicit --elevate wins over the --sudo alias + r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=none", "--sudo"]) + assert isinstance(r.elevator, nr.elevate.NoElevator) + + with pytest.raises(SystemExit): + nr.parse_args(["nixos-rebuild", "switch", "--elevate=doas"]) + + def test_parse_args() -> None: with pytest.raises(SystemExit) as e: nr.parse_args(["nixos-rebuild", "unknown-action"]) From 0273aad113950a3bcaf70b883e4db05637bd8ffc Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:28:44 +0200 Subject: [PATCH 19/46] nixos-rebuild-ng: move password prompt out of Remote Remote no longer carries auth state, the elevator owns the password end-to-end. The prompt itself lives in Elevator.with_prompted_password() so the CLI entry point stays a thin orchestrator and all elevation-related plumbing is in one module. --- .../src/nixos_rebuild/__init__.py | 19 ++++++------ .../src/nixos_rebuild/elevate.py | 12 ++++++++ .../src/nixos_rebuild/process.py | 21 +++++--------- .../src/nixos_rebuild/services.py | 6 +--- .../src/tests/test_elevate.py | 22 ++++++++++++++ .../nixos-rebuild-ng/src/tests/test_models.py | 4 +-- .../ni/nixos-rebuild-ng/src/tests/test_nix.py | 20 ++++++------- .../src/tests/test_process.py | 29 +++++++++---------- 8 files changed, 76 insertions(+), 57 deletions(-) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py index bff1ccab7ff6..c1ad2bde3d00 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py @@ -6,7 +6,7 @@ from typing import Final, assert_never from . import nix, services from .constants import EXECUTABLE, WITH_SHELL_FILES -from .elevate import NO_ELEVATOR, ElevateError, ElevatorKind +from .elevate import NO_ELEVATOR, ElevatorKind from .models import Action, BuildAttr, Flake, GroupedNixArgs, Profile from .process import Remote from .utils import LogFormatter @@ -266,8 +266,8 @@ def parse_args( parser_warn("--use-remote-sudo is deprecated, use --elevate=sudo instead") # Map the elevate flags onto an Elevator. The password itself is - # attached later via Elevator.with_password() once the target host - # (used in the prompt) is known. + # attached later via Elevator.with_prompted_password() once the + # target host (used in the prompt) is known. if args.elevate is not None: args.elevator = ElevatorKind.from_name(args.elevate) elif args.sudo or args.use_remote_sudo or args.ask_sudo_password: @@ -368,13 +368,12 @@ def execute(argv: list[str]) -> None: services.reexec(argv, args, grouped_nix_args) profile = Profile.from_arg(args.profile_name) - target_host = Remote.from_arg(args.target_host, args.ask_elevate_password) - build_host = Remote.from_arg(args.build_host, False, validate_opts=False) - if target_host and target_host.sudo_password: - try: - args.elevator = args.elevator.with_password(target_host.sudo_password) - except ElevateError as ex: - sys.exit(f"error: {ex}") + target_host = Remote.from_arg(args.target_host) + build_host = Remote.from_arg(args.build_host, validate_opts=False) + args.elevator = args.elevator.with_prompted_password( + ask=args.ask_elevate_password, + host_label=target_host.host if target_host else "localhost", + ) build_attr = BuildAttr.from_arg(args.attr, args.file) flake = Flake.from_arg(args.flake, target_host) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py index e75fd099d678..be9e35fce613 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py @@ -10,6 +10,7 @@ prefix and stdin. from __future__ import annotations +import getpass import os import shlex from abc import ABC, abstractmethod @@ -71,6 +72,17 @@ class Elevator(ABC): (e.g. a polkit rule). """ + def with_prompted_password(self, *, ask: bool, host_label: str) -> Self: + """Prompt locally for a password and return a copy carrying it. + + No-op when *ask* is false. May raise :class:`ElevateError` (e.g. + on :class:`NoElevator`). + """ + if not ask: + return self + password = getpass.getpass(f"[{self.name}] password for {host_label}: ") + return self.with_password(password) + def on_remote_failure(self) -> str | None: """Optional hint to print when a remote elevated command fails.""" return None diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py index 121c33980459..bad599b36643 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py @@ -1,5 +1,4 @@ import atexit -import getpass import logging import os import re @@ -46,14 +45,12 @@ type EnvValue = str | Literal[_Env.PRESERVE_ENV] class Remote: host: str opts: list[str] - sudo_password: str | None store_type: str @classmethod def from_arg( cls, host: str | None, - ask_sudo_password: bool | None, validate_opts: bool = True, ) -> Self | None: if not host: @@ -66,25 +63,21 @@ class Remote: opts = shlex.split(os.getenv("NIX_SSHOPTS", "")) if validate_opts: - cls._validate_opts(opts, ask_sudo_password) - sudo_password = None - if ask_sudo_password: - sudo_password = getpass.getpass(f"[sudo] password for {host}: ") - return cls(host, opts, sudo_password, store_type) + cls._validate_opts(opts) + return cls(host, opts, store_type) @staticmethod - def _validate_opts(opts: list[str], ask_sudo_password: bool | None) -> None: + def _validate_opts(opts: list[str]) -> None: for o in opts: if o in ["-t", "-tt", "RequestTTY=yes", "RequestTTY=force"]: logger.warning( f"detected option '{o}' in NIX_SSHOPTS. SSH's TTY may " "cause issues, it is recommended to remove this option" ) - if not ask_sudo_password: - logger.warning( - "if you want to prompt for sudo password use " - "'--ask-sudo-password' option instead" - ) + logger.warning( + "if you want to prompt for a password for remote " + "elevation use '--ask-elevate-password' instead" + ) def ssh_host(self) -> str: """Fix up host string for SSH. diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py index 5237075a17ec..96d9d16e76f2 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py @@ -35,11 +35,7 @@ def reexec( return drv = None - # Parsing the args here but ignore ask_sudo_password since it is not - # needed and we would end up asking sudo password twice - if flake := Flake.from_arg( - args.flake, Remote.from_arg(args.target_host, ask_sudo_password=None) - ): + if flake := Flake.from_arg(args.flake, Remote.from_arg(args.target_host)): drv = nix.build_flake( NIXOS_REBUILD_ATTR, flake, diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py index 349d36827448..6d6231408adb 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py @@ -48,3 +48,25 @@ def test_elevator_kind() -> None: with pytest.raises(e.ElevateError): e.ElevatorKind.from_name("doas") assert set(e.ElevatorKind.choices()) == {"none", "sudo"} + + +def test_with_prompted_password(monkeypatch: MonkeyPatch) -> None: + prompts: list[str] = [] + + def fake_getpass(prompt: str) -> str: + prompts.append(prompt) + return "hunter2" + + monkeypatch.setattr(e.getpass, "getpass", fake_getpass) + + s = e.SudoElevator() + assert s.with_prompted_password(ask=False, host_label="x") is s + assert prompts == [] + + sp = s.with_prompted_password(ask=True, host_label="user@host") + assert isinstance(sp, e.SudoElevator) + assert sp.password == "hunter2" + assert prompts == ["[sudo] password for user@host: "] + + with pytest.raises(e.ElevateError): + e.NoElevator().with_prompted_password(ask=True, host_label="localhost") diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py index 64ffce686976..fa9a41f93a42 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_models.py @@ -81,7 +81,7 @@ def test_flake_parse(mock_node: Mock, tmpdir: Path, monkeypatch: MonkeyPatch) -> autospec=True, return_value=subprocess.CompletedProcess([], 0, stdout="remote\n"), ): - target_host = m.Remote("target@remote", [], None, "ssh") + target_host = m.Remote("target@remote", [], "ssh") assert m.Flake.parse("/path/to/flake", target_host) == m.Flake( "/path/to/flake", 'nixosConfigurations."remote"' ) @@ -201,7 +201,7 @@ def test_flake_from_arg( ), ): assert m.Flake.from_arg( - "/path/to", m.Remote("user@host", [], None, "ssh") + "/path/to", m.Remote("user@host", [], "ssh") ) == m.Flake("/path/to", 'nixosConfigurations."remote-hostname"') diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py index ef198fdac275..3d5a5093230b 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py @@ -86,7 +86,7 @@ def test_build_flake(mock_run: Mock, monkeypatch: MonkeyPatch, tmpdir: Path) -> def test_build_remote( mock_uuid4: Mock, mock_run: Mock, monkeypatch: MonkeyPatch ) -> None: - build_host = m.Remote("user@host", [], None, "ssh") + build_host = m.Remote("user@host", [], "ssh") monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts") def run_wrapper_side_effect( @@ -180,7 +180,7 @@ def test_build_remote_flake( ) -> None: monkeypatch.chdir(tmpdir) flake = m.Flake.parse("/flake.nix#hostname") - build_host = m.Remote("user@host", [], None, "ssh") + build_host = m.Remote("user@host", [], "ssh") monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts") assert n.build_remote_flake( @@ -240,9 +240,9 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None: n.copy_closure(closure, None) mock_run.assert_not_called() - target_host = m.Remote("user@target.host", [], None, "ssh") - build_host = m.Remote("user@build.host", [], None, "ssh") - target_host_ng = m.Remote("user@target.host", [], None, "ssh-ng") + target_host = m.Remote("user@target.host", [], "ssh") + build_host = m.Remote("user@build.host", [], "ssh") + target_host_ng = m.Remote("user@target.host", [], "ssh-ng") with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run: n.copy_closure(closure, target_host) mock_run.assert_called_with( @@ -537,7 +537,7 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None: elevate=e.NO_ELEVATOR, ) - remote = m.Remote("user@host", [], "password", "ssh") + remote = m.Remote("user@host", [], "ssh") with patch( get_qualified_name(n.run_wrapper, n), autospec=True, return_value=return_value ) as mock_run: @@ -650,7 +650,7 @@ def test_rollback(mock_run: Mock, tmp_path: Path) -> None: elevate=e.NO_ELEVATOR, ) - target_host = m.Remote("user@localhost", [], None, "ssh") + target_host = m.Remote("user@localhost", [], "ssh") assert n.rollback(profile, target_host, SUDO) == profile.path mock_run.assert_called_with( ["nix-env", "--rollback", "-p", path], @@ -690,7 +690,7 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None: elevate=e.NO_ELEVATOR, ) - target_host = m.Remote("user@localhost", [], None, "ssh") + target_host = m.Remote("user@localhost", [], "ssh") assert ( n.rollback_temporary_profile(m.Profile("foo", path), target_host, SUDO) == path.parent / "foo-2083-link" @@ -789,7 +789,7 @@ def test_switch_to_configuration_without_systemd_run( == "error: '--specialisation' can only be used with 'switch' and 'test'" ) - target_host = m.Remote("user@localhost", [], None, "ssh") + target_host = m.Remote("user@localhost", [], "ssh") with monkeypatch.context() as mp: mp.setenv("LOCALE_ARCHIVE", "/path/to/locale") mp.setenv("PATH", "/path/to/bin") @@ -854,7 +854,7 @@ def test_switch_to_configuration_with_systemd_run( stdout=sys.stderr, ) - target_host = m.Remote("user@localhost", [], None, "ssh") + target_host = m.Remote("user@localhost", [], "ssh") with monkeypatch.context() as mp: mp.setenv("LOCALE_ARCHIVE", "/path/to/locale") mp.setenv("PATH", "/path/to/bin") diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py index af8f510c5f49..4d6421146a8d 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py @@ -111,7 +111,7 @@ def test_run_wrapper(mock_run: Any) -> None: p.run_wrapper( ["test", "--with", "some flags"], check=True, - remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"), + remote=m.Remote("user@localhost", ["--ssh", "opt"], "ssh"), ) mock_run.assert_called_with( [ @@ -141,7 +141,7 @@ def test_run_wrapper(mock_run: Any) -> None: check=True, elevate=e.SudoElevator(password="password"), env={"FOO": "bar"}, - remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"), + remote=m.Remote("user@localhost", ["--ssh", "opt"], "ssh"), ) mock_run.assert_called_with( [ @@ -180,7 +180,7 @@ def test__kill_long_running_ssh_process(mock_run: Any) -> None: "build", "/nix/store/la0c8nmpr9xfclla0n4f3qq9iwgdrq4g-nixos-system-sankyuu-nixos-25.05.20250424.f771eb4.drv^*", ], - m.Remote("user@localhost", opts=[], sudo_password=None, store_type="ssh"), + m.Remote("user@localhost", opts=[], store_type="ssh"), ) mock_run.assert_called_with( [ @@ -203,21 +203,18 @@ def test__kill_long_running_ssh_process(mock_run: Any) -> None: def test_remote_from_name(monkeypatch: MonkeyPatch) -> None: monkeypatch.setenv("NIX_SSHOPTS", "") - assert m.Remote.from_arg("user@localhost", None, False) == m.Remote( + assert m.Remote.from_arg("user@localhost", validate_opts=False) == m.Remote( "user@localhost", opts=[], - sudo_password=None, store_type="ssh", ) - with patch("getpass.getpass", autospec=True, return_value="password"): - monkeypatch.setenv("NIX_SSHOPTS", "-f foo -b bar -t") - assert m.Remote.from_arg("user@localhost", True, True) == m.Remote( - "user@localhost", - opts=["-f", "foo", "-b", "bar", "-t"], - sudo_password="password", - store_type="ssh", - ) + monkeypatch.setenv("NIX_SSHOPTS", "-f foo -b bar -t") + assert m.Remote.from_arg("user@localhost") == m.Remote( + "user@localhost", + opts=["-f", "foo", "-b", "bar", "-t"], + store_type="ssh", + ) def test_ssh_host() -> None: @@ -239,13 +236,13 @@ def test_ssh_host() -> None: } for host_input, expected in ssh_remotes.items(): - remote = m.Remote.from_arg(host_input, None, False) + remote = m.Remote.from_arg(host_input, validate_opts=False) assert remote is not None assert remote.ssh_host() == expected assert remote.store_type == "ssh" for host_input, expected in ssh_ng_remotes.items(): - remote = m.Remote.from_arg(host_input, None, False) + remote = m.Remote.from_arg(host_input, validate_opts=False) assert remote is not None assert remote.ssh_host() == expected assert remote.store_type == "ssh-ng" @@ -287,7 +284,7 @@ def test_custom_sudo_args(mock_run: Any) -> None: ["test"], check=False, elevate=e.SudoElevator(), - remote=m.Remote("user@localhost", [], None, "ssh"), + remote=m.Remote("user@localhost", [], "ssh"), ) mock_run.assert_called_with( [ From 4d4952ae28c707aeb66c7def830fd1dd60669510 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:34:20 +0200 Subject: [PATCH 20/46] polkit-stdin-agent: init at 0.3.0 A non-interactive polkit authentication agent that answers PAM prompts from a file descriptor. Provides the missing `sudo --stdin` equivalent for run0/systemd-run, which authorise via polkit and otherwise require a controlling terminal for pkttyagent. Used by the upcoming `nixos-rebuild --elevate=run0 --ask-elevate-password`. The machine performing the elevation enables system.tools.nixos-rebuild.enableRun0Elevation (added in the next commit), which puts this package on PATH locally and at /sw/bin on a remote target. --- .../by-name/po/polkit-stdin-agent/package.nix | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 pkgs/by-name/po/polkit-stdin-agent/package.nix diff --git a/pkgs/by-name/po/polkit-stdin-agent/package.nix b/pkgs/by-name/po/polkit-stdin-agent/package.nix new file mode 100644 index 000000000000..7377d1727ca6 --- /dev/null +++ b/pkgs/by-name/po/polkit-stdin-agent/package.nix @@ -0,0 +1,48 @@ +{ + lib, + rustPlatform, + fetchFromGitea, + nix-update-script, + nixosTests, +}: + +rustPlatform.buildRustPackage (finalAttrs: { + pname = "polkit-stdin-agent"; + version = "0.3.0"; + + src = fetchFromGitea { + domain = "codeberg.org"; + owner = "r-vdp"; + repo = "polkit-stdin-agent"; + tag = "v${finalAttrs.version}"; + hash = "sha256-Nl/+IBbUEsxSKSWLXwUB3mV4iAG0z9mv+Bl6CSeFzR4="; + }; + + cargoHash = "sha256-Eb/7ejVmtG5FNSh66gZO3337KCPNi+xtYVC5qyFKJzg="; + + strictDeps = true; + __structuredAttrs = true; + + passthru = { + updateScript = nix-update-script { }; + tests = { inherit (nixosTests) nixos-rebuild-target-host; }; + }; + + meta = { + description = "Non-interactive polkit authentication agent that answers PAM prompts from a file descriptor"; + longDescription = '' + Registers a per-process polkit authentication agent for a wrapped + command and answers the PAM conversation from a file descriptor + instead of /dev/tty, giving run0 / systemd-run the same + "password on stdin" ergonomics as `sudo --stdin`. + + Used by `nixos-rebuild --elevate=run0 --ask-elevate-password` to + authenticate on a target host over SSH without allocating a TTY. + ''; + homepage = "https://codeberg.org/r-vdp/polkit-stdin-agent"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ rvdp ]; + platforms = lib.platforms.linux; + mainProgram = "polkit-stdin-agent"; + }; +}) From c30b2c06d953e6f986162c5809d864eae52e61b5 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:32:13 +0200 Subject: [PATCH 21/46] nixos-rebuild-ng: add --elevate=run0 Locally this just prefixes activation commands with `run0 --`, so the user's normal polkit agent (graphical or pkttyagent) handles auth. For --target-host, run0 would need a controlling terminal, which we deliberately do not allocate over SSH. Instead the equivalent `systemd-run --uid=0 --pipe --wait ...` form is used. Without --ask-elevate-password this relies on a polkit rule on the target granting the deploying user `org.freedesktop.systemd1.manage-units`. With --ask-elevate-password the command is wrapped in polkit-stdin-agent, which registers a per-process polkit agent for the child and answers the PAM conversation from stdin. A transient unit does not inherit the SSH login environment. On NixOS the unit's PATH is just the systemd store path, so neither `env` nor `nix-env` would be found inside it. Elevator.wrap_remote() therefore now takes the env mapping and the command, and returns the full remote argv: sudo keeps the existing `/bin/sh -c 'exec /usr/bin/env -i ...'` wrapper inside itself, while run0 puts the shell wrapper *around* systemd-run and forwards the resolved values into the unit via --setenv. The Arg/Args/EnvValue/PRESERVE_ENV types and the env-shell helper move from process.py into elevate.py to avoid a circular import, and callers are updated to import them from there. polkit-stdin-agent is resolved on the machine doing the elevation rather than baked in as a host-arch store path, which would be wrong for --no-reexec, cross-arch deploys (re-exec hits ENOEXEC and falls back), --rollback/--store-path, and Darwin deployers. Locally that is PATH. Remotely, Elevator.for_target_config() binds the elevator to the toplevel just placed on the target, and a small /bin/sh picker tries /sw/bin/polkit-stdin-agent (target-arch, in the copied closure) then PATH, exiting with an actionable error pointing at system.tools.nixos-rebuild.enableRun0Elevation when neither resolves. That option (added here) puts the agent in environment.systemPackages and asserts security.polkit.enable. A nixos-rebuild-target-host subtest exercises the full remote run0 path (polkit-stdin-agent + systemd-run + activation) end-to-end. Drop the now-unused stdenv argument from package.nix while here. Closes #507054. --- nixos/modules/installer/tools/tools.nix | 21 ++ nixos/tests/nixos-rebuild-target-host.nix | 22 ++ pkgs/by-name/ni/nixos-rebuild-ng/package.nix | 1 - .../src/nixos_rebuild/elevate.py | 248 ++++++++++++++++-- .../nixos-rebuild-ng/src/nixos_rebuild/nix.py | 4 +- .../src/nixos_rebuild/process.py | 63 +---- .../src/nixos_rebuild/services.py | 10 + .../src/tests/test_elevate.py | 103 +++++++- .../nixos-rebuild-ng/src/tests/test_main.py | 35 +-- .../ni/nixos-rebuild-ng/src/tests/test_nix.py | 16 +- .../src/tests/test_process.py | 131 +++++++-- 11 files changed, 533 insertions(+), 121 deletions(-) diff --git a/nixos/modules/installer/tools/tools.nix b/nixos/modules/installer/tools/tools.nix index 086d8971d466..e7f48a3243ea 100644 --- a/nixos/modules/installer/tools/tools.nix +++ b/nixos/modules/installer/tools/tools.nix @@ -314,6 +314,27 @@ in name = "nixos-rebuild"; package = config.system.build.nixos-rebuild; }) + ( + { config, ... }: + { + options.system.tools.nixos-rebuild.enableRun0Elevation = lib.mkEnableOption '' + support for being targeted by `nixos-rebuild --elevate=run0 + --ask-elevate-password`. + + This enables polkit and adds {command}`polkit-stdin-agent` to + {option}`environment.systemPackages` so that a deploying host + can find a target-architecture agent at + {file}`/sw/bin/polkit-stdin-agent` after copying the + closure (which is required for cross-architecture deploys and + mismatched nixpkgs revisions to work). + ''; + + config = lib.mkIf config.system.tools.nixos-rebuild.enableRun0Elevation { + security.polkit.enable = lib.mkDefault true; + environment.systemPackages = [ pkgs.polkit-stdin-agent ]; + }; + } + ) (mkToolModule { name = "nixos-version"; package = nixos-version; diff --git a/nixos/tests/nixos-rebuild-target-host.nix b/nixos/tests/nixos-rebuild-target-host.nix index c37c4ce2ee76..ae872a205a0e 100644 --- a/nixos/tests/nixos-rebuild-target-host.nix +++ b/nixos/tests/nixos-rebuild-target-host.nix @@ -22,6 +22,8 @@ }; system.includeBuildDependencies = true; + # Needed so the offline build of the target config succeeds. + system.extraDependencies = [ pkgs.polkit-stdin-agent ]; virtualisation = { cores = 2; @@ -49,6 +51,11 @@ users.users.alice.extraGroups = [ "wheel" ]; users.users.bob.extraGroups = [ "wheel" ]; + # Needed for --elevate=run0. NixOS's default polkit admin rule is + # `unix-group:wheel`, so bob (in wheel) can authenticate with his + # own password via polkit-stdin-agent. + system.tools.nixos-rebuild.enableRun0Elevation = true; + # Disable sudo for root to ensure sudo isn't called without `--sudo` security.sudo.extraRules = lib.mkForce [ { @@ -142,6 +149,7 @@ deployer.copy_from_host("${configFile "config-1-deployed"}", "/root/configuration-1.nix") deployer.copy_from_host("${configFile "config-2-deployed"}", "/root/configuration-2.nix") deployer.copy_from_host("${configFile "config-3-deployed"}", "/root/configuration-3.nix") + deployer.copy_from_host("${configFile "config-4-deployed"}", "/root/configuration-4.nix") deployer.copy_from_host("${targetNetworkJSON}", "/root/target-network.json") deployer.copy_from_host("${targetConfigJSON}", "/root/target-configuration.json") @@ -168,6 +176,20 @@ target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip() assert target_hostname == "config-3-deployed", f"{target_hostname=}" + with subtest("Deploy to bob@target with run0 and password"): + # polkit-stdin-agent registers an agent for systemd-run on the + # target and answers the PAM conversation with the password we + # supply locally. The agent is resolved on the target from + # /sw/bin (see Run0Elevator._remote_agent_argv). + deployer.send_chars("nixos-rebuild switch -I nixos-config=/root/configuration-4.nix --target-host bob@target --elevate=run0 --ask-elevate-password\n") + deployer.wait_until_tty_matches("1", "\\[run0\\] password for bob@target") + deployer.send_chars("${nodes.target.users.users.bob.password}\n") + deployer.wait_until_tty_matches("1", "Done. The new configuration is /nix/store/.*config-4-deployed") + target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip() + assert target_hostname == "config-4-deployed", f"{target_hostname=}" + # The target-arch agent is reachable at the stable sw/bin path. + target.succeed("test -x /run/current-system/sw/bin/polkit-stdin-agent") + with subtest("Deploy works with very long TMPDIR"): tmp_dir = "/var/folder/veryveryveryveryverylongpathnamethatdoesnotworkwithcontrolpath" deployer.succeed(f"mkdir -p {tmp_dir}") diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/package.nix b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix index 136962b1b7a9..8112f3d867f8 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/package.nix +++ b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix @@ -1,6 +1,5 @@ { lib, - stdenv, callPackage, installShellFiles, mkShell, diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py index be9e35fce613..c69eb097445d 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py @@ -6,6 +6,12 @@ controlling terminal is available), and how to feed it a pre-supplied password when the backend supports that. ``run_wrapper`` and its callers carry a single ``elevate: Elevator`` value and let it produce the command prefix and stdin. + +The remote case has no controlling terminal and the elevated command's +environment depends on the backend (``sudo`` inherits the SSH login env, +while the run0 backend starts a transient unit with only systemd's +default ``PATH``), so each backend builds the full remote argv itself +via :meth:`Elevator.wrap_remote`. """ from __future__ import annotations @@ -14,24 +20,82 @@ import getpass import os import shlex from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace from enum import Enum -from typing import Final, Self, override +from pathlib import Path, PurePosixPath +from typing import ClassVar, Final, Literal, Self, override + +# Kept here (rather than in process.py) so that elevators can build remote +# argvs without a circular import. +type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes] +type Args = Sequence[Arg] + + +class _Env(Enum): + PRESERVE_ENV = "PRESERVE" + + @override + def __repr__(self) -> str: + return self.value + + +#: Sentinel meaning "copy this variable from the environment the wrapped +#: command would naturally see" (``os.environ`` locally, the SSH login +#: shell's environment remotely). +PRESERVE_ENV: Final = _Env.PRESERVE_ENV + +type EnvValue = str | Literal[_Env.PRESERVE_ENV] + + +def _remote_env_shell_argv( + prefix: Sequence[str], + env: Mapping[str, EnvValue], + args: Args, +) -> list[Arg]: + """Build `` /bin/sh -c 'exec /usr/bin/env -i K=V… "$@"' sh ``. + + The wrapper runs in the SSH login session, resolves ``PRESERVE_ENV`` + variables against that session's environment, and re-execs the command + with exactly that set. ``/usr/bin/env`` is referenced by absolute path so + the wrapper does not depend on ``PATH`` itself (provided on NixOS via + ``environment.usrbinenv``). + """ + assigns: list[str] = [] + for k, v in env.items(): + if v is PRESERVE_ENV: + assigns.append(f'{k}="${{{k}-}}"') + else: + assigns.append(f"{k}={shlex.quote(v)}") + script = f'exec /usr/bin/env -i {" ".join(assigns)} "$@"' + return [*prefix, "/bin/sh", "-c", script, "sh", *args] @dataclass(frozen=True) class Wrapped: - """Result of wrapping a command for elevation.""" + """Result of wrapping a command for local elevation.""" - #: Arguments to prepend to the command. Kept as ``list[str]`` rather than - #: the wider ``process.Args`` because elevators only ever prepend plain - #: strings; callers splice this into their existing ``Args`` list. + #: Arguments to prepend to the command. prefix: list[str] #: Text to send on the wrapped command's stdin (typically a password #: followed by a newline), or ``None`` to leave stdin alone. stdin: str | None = None +@dataclass(frozen=True) +class RemoteWrapped: + """Result of wrapping a command for remote elevation over SSH. + + Unlike :class:`Wrapped` this carries the *full* remote argv: backends + differ in where the env-resolution shell wrapper must sit relative to + the elevator (inside ``sudo``, but *around* ``systemd-run``), so a + plain prefix is not expressive enough. + """ + + argv: list[Arg] + stdin: str | None = None + + class Elevator(ABC): """How to gain root for activation commands.""" @@ -54,13 +118,18 @@ class Elevator(ABC): """Wrap a command run on the local machine.""" @abstractmethod - def wrap_remote(self) -> Wrapped: + def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped: """Wrap a command run on a target host over SSH. The remote side has no controlling terminal, so backends that need interactive prompts must either accept a pre-supplied password (see :meth:`with_password`) or rely on a passwordless policy on the target. + + *env* is the environment to establish for the elevated command. + :data:`PRESERVE_ENV` values are resolved against the SSH login + shell's environment, and the backend must do so before any step + that replaces it with a service-style one. """ @abstractmethod @@ -83,6 +152,17 @@ class Elevator(ABC): password = getpass.getpass(f"[{self.name}] password for {host_label}: ") return self.with_password(password) + def for_target_config(self, toplevel: PurePosixPath | Path) -> Self: + """Return a copy bound to the toplevel being activated on the target. + + Backends that need a helper binary on the remote + (:class:`Run0Elevator`'s ``polkit-stdin-agent``) use this to find + a target-architecture copy inside the just-copied closure. No-op + by default. + """ + del toplevel # unused in the base implementation + return self + def on_remote_failure(self) -> str | None: """Optional hint to print when a remote elevated command fails.""" return None @@ -106,8 +186,8 @@ class NoElevator(Elevator): return Wrapped(prefix=[]) @override - def wrap_remote(self) -> Wrapped: - return Wrapped(prefix=[]) + def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped: + return RemoteWrapped(argv=_remote_env_shell_argv([], env, args)) @override def with_password(self, password: str) -> Self: @@ -134,13 +214,6 @@ class SudoElevator(Elevator): def wrap_local(self) -> Wrapped: # Local sudo can prompt on /dev/tty itself, so the password is # only piped when one was supplied explicitly. - return self._wrap() - - @override - def wrap_remote(self) -> Wrapped: - return self._wrap() - - def _wrap(self) -> Wrapped: if self.password is not None: return Wrapped( prefix=["sudo", "--prompt=", "--stdin", *self.extra_opts], @@ -148,6 +221,21 @@ class SudoElevator(Elevator): ) return Wrapped(prefix=["sudo", *self.extra_opts]) + @override + def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped: + # sudo runs inside the SSH login session, so the env wrapper can + # sit *inside* it and ${VAR-} resolves against the login env. + if self.password is not None: + prefix = ["sudo", "--prompt=", "--stdin", *self.extra_opts] + stdin = self.password + "\n" + else: + prefix = ["sudo", *self.extra_opts] + stdin = None + return RemoteWrapped( + argv=_remote_env_shell_argv(prefix, env, args), + stdin=stdin, + ) + @override def with_password(self, password: str) -> Self: return replace(self, password=password) @@ -162,16 +250,144 @@ class SudoElevator(Elevator): return None +@dataclass(frozen=True) +class Run0Elevator(Elevator): + """Wrap with systemd's polkit-based ``run0``. + + Locally, ``run0`` is used directly and the user's polkit agent + (graphical or ``pkttyagent``) handles any prompts. + + Remotely we spell out the explicit ``systemd-run --uid=0 --pipe`` + form instead. ``run0`` would internally do the same thing when stdio + is not a TTY (see systemd ``src/run/run.c``), but going through + ``systemd-run`` directly gives us ``--setenv K=V``, which we need to + forward the SSH login environment into the transient unit (whose own + ``PATH`` on NixOS is just the systemd store path), and keeps the + argv independent of whether the SSH session happens to have a TTY. + + Authorisation comes from either a polkit rule on the target granting + ``org.freedesktop.systemd1.manage-units`` to the deploying user, or + from ``polkit-stdin-agent`` for ``--ask-elevate-password``. + """ + + name: str = "run0" + password: str | None = None + #: ``${toplevel}/sw/bin/polkit-stdin-agent`` on the target, set via + #: :meth:`for_target_config`. ``None`` falls back to the target's ``PATH``. + remote_agent: str | None = None + + #: Non-interactive equivalent of ``run0`` (see the class docstring). + REMOTE_BASE: ClassVar[tuple[str, ...]] = ( + "systemd-run", + "--uid=0", + "--pipe", + "--quiet", + "--wait", + "--collect", + "--service-type=exec", + "--send-sighup", + ) + + @override + def wrap_local(self) -> Wrapped: + if self.password is not None: + # Resolved from PATH, same requirement as the remote case: the + # machine doing the elevation needs + # system.tools.nixos-rebuild.enableRun0Elevation. + return Wrapped( + prefix=["polkit-stdin-agent", "--password-fd=0", "--", "run0", "--"], + stdin=self.password + "\n", + ) + return Wrapped(prefix=["run0", "--"]) + + @override + def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped: + # /bin/sh wrapper resolves PRESERVE_ENV in the SSH login session + # and forwards the result into the unit via --setenv. + setenvs: list[str] = [] + for k, v in env.items(): + if v is PRESERVE_ENV: + setenvs.append(f'--setenv={k}="${{{k}-}}"') + else: + setenvs.append(f"--setenv={shlex.quote(f'{k}={v}')}") + script = f'exec {shlex.join(self.REMOTE_BASE)} {" ".join(setenvs)} -- "$@"' + argv: list[Arg] = ["/bin/sh", "-c", script, "sh", *args] + if self.password is not None: + # polkit has no `sudo --stdin` equivalent. polkit-stdin-agent + # registers a per-process agent for the wrapped command and + # answers the PAM conversation from its stdin. + argv = self._remote_agent_argv(argv) + return RemoteWrapped(argv=argv, stdin=self.password + "\n") + return RemoteWrapped(argv=argv) + + #: POSIX sh fragment that picks the first runnable agent from the + #: positional parameters up to ``--`` and execs it with the remainder. + #: ``command -v`` covers both absolute paths and ``PATH`` lookups. + _AGENT_PICKER: ClassVar[str] = ( + "agent=; " + "for a; do " + "shift; " + '[ "$a" = -- ] && break; ' + '[ -z "$agent" ] && command -v "$a" >/dev/null 2>&1 && agent="$a"; ' + "done; " + '[ -n "$agent" ] && exec "$agent" --password-fd=0 -- "$@"; ' + 'echo "nixos-rebuild: polkit-stdin-agent not found on target host ' + '(set system.tools.nixos-rebuild.enableRun0Elevation = true)" >&2; ' + "exit 127" + ) + + def _remote_agent_argv(self, inner: list[Arg]) -> list[Arg]: + """Wrap *inner* in a target-side agent lookup. + + The deployer's own agent may be the wrong arch/nixpkgs (cross-arch + deploys, Darwin deployers, ``--no-reexec``), so resolve on the + target instead: first ``${toplevel}/sw/bin/polkit-stdin-agent`` + (present when ``system.tools.nixos-rebuild.enableRun0Elevation`` + is set), then bare ``polkit-stdin-agent`` on the SSH login PATH. + :data:`_AGENT_PICKER` exits 127 with a hint if neither is found. + """ + candidates: list[str] = [] + if self.remote_agent is not None: + candidates.append(self.remote_agent) + candidates.append("polkit-stdin-agent") + return ["/bin/sh", "-c", self._AGENT_PICKER, "sh", *candidates, "--", *inner] + + @override + def with_password(self, password: str) -> Self: + return replace(self, password=password) + + @override + def for_target_config(self, toplevel: PurePosixPath | Path) -> Self: + return replace( + self, remote_agent=str(toplevel / "sw" / "bin" / "polkit-stdin-agent") + ) + + @override + def on_remote_failure(self) -> str | None: + if self.password is None: + return ( + "while running command with remote run0. Either pass " + "--ask-elevate-password, or grant the deploying user the " + "polkit action 'org.freedesktop.systemd1.manage-units' on " + "the target host (security.polkit.extraConfig)." + ) + return ( + "while running command with remote run0. If the error above " + "mentions polkit-stdin-agent or PolicyKit1, the target host " + "needs system.tools.nixos-rebuild.enableRun0Elevation = true." + ) + + class ElevatorKind(Enum): """CLI-selectable elevation backends. The enum *value* is the :class:`Elevator` subclass to instantiate, ``str(member)`` is what ``--elevate`` accepts on the command line. - Extended by later commits. """ NONE = NoElevator SUDO = SudoElevator + RUN0 = Run0Elevator @override def __str__(self) -> str: diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py index 38894b734133..c9d79868f8f4 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py @@ -14,7 +14,7 @@ from textwrap import dedent from typing import Final, Literal from . import tmpdir -from .elevate import NO_ELEVATOR, Elevator +from .elevate import NO_ELEVATOR, PRESERVE_ENV, Elevator from .models import ( Action, BuildAttr, @@ -26,7 +26,7 @@ from .models import ( Profile, Remote, ) -from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper +from .process import SSH_DEFAULT_OPTS, run_wrapper from .utils import Args, dict_to_flags FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"] diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py index bad599b36643..61408ab8aaf5 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py @@ -4,14 +4,20 @@ import os import re import shlex import subprocess -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from dataclasses import dataclass -from enum import Enum from ipaddress import AddressValueError, IPv6Address -from typing import Final, Literal, Self, TextIO, TypedDict, Unpack, override +from typing import Final, Self, TextIO, TypedDict, Unpack from . import tmpdir -from .elevate import NO_ELEVATOR, Elevator +from .elevate import ( + NO_ELEVATOR, + PRESERVE_ENV, + Arg, + Args, + Elevator, + EnvValue, +) logger: Final = logging.getLogger(__name__) @@ -25,22 +31,6 @@ SSH_DEFAULT_OPTS: Final = [ ] -class _Env(Enum): - PRESERVE_ENV = "PRESERVE" - - @override - def __repr__(self) -> str: - return self.value - - -PRESERVE_ENV: Final = _Env.PRESERVE_ENV - - -type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes] -type Args = Sequence[Arg] -type EnvValue = str | Literal[_Env.PRESERVE_ENV] - - @dataclass(frozen=True) class Remote: host: str @@ -136,16 +126,9 @@ def run_wrapper( resolved_env = _resolve_env_local(normalized_env) if remote: - wrapped = elevate.wrap_remote() - process_input = wrapped.stdin - remote_run_args: list[Arg] = [ - *wrapped.prefix, - "/bin/sh", - "-c", - _remote_shell_script(normalized_env), - "sh", - *run_args, - ] + rwrapped = elevate.wrap_remote(normalized_env, run_args) + process_input = rwrapped.stdin + remote_run_args: list[Arg] = rwrapped.argv ssh_args: list[Arg] = [ "ssh", @@ -246,7 +229,7 @@ def _resolve_env_local(env: dict[str, EnvValue]) -> dict[str, str]: return result -def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Arg]: +def _prefix_env_cmd(cmd: Args, resolved_env: dict[str, str]) -> list[Arg]: """ Prefix a command with `env -i K=V ... -- ` to set vars for the command. @@ -258,24 +241,6 @@ def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Ar return ["env", "-i", *assigns, *cmd] -def _remote_shell_script(env: Mapping[str, EnvValue]) -> str: - """ - Build the POSIX shell wrapper used for remote execution over SSH. - - SSH sends the remote command as a shell-interpreted command line, so we - need a wrapper to establish a clean environment before `exec`-ing the real - command. This wrapper is always run under `/bin/sh -c` so preserved - variables like `${PATH-}` do not depend on the remote user's login shell. - """ - shell_assigns: list[str] = [] - for k, v in env.items(): - if v is PRESERVE_ENV: - shell_assigns.append(f'{k}="${{{k}-}}"') - else: - shell_assigns.append(f"{k}={shlex.quote(v)}") - return f'exec env -i {" ".join(shell_assigns)} "$@"' - - def _quote_remote_arg(arg: Arg) -> str: return shlex.quote(str(arg)) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py index 96d9d16e76f2..9994941ebb4d 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py @@ -298,6 +298,11 @@ def build_and_activate_system( copy_flags=grouped_nix_args.copy_flags, ) elif args.rollback: + if target_host is not None: + # The elevated `nix-env --rollback` runs before path_to_config + # is known, so point the elevator at the profile to find a + # target-arch helper in the *current* generation's sw/bin. + args.elevator = args.elevator.for_target_config(profile.path) path_to_config = _rollback_system( action=action, args=args, @@ -315,6 +320,11 @@ def build_and_activate_system( grouped_nix_args=grouped_nix_args, ) + if target_host is not None and not args.rollback: + # Prefer the helper from the toplevel we just copied to the + # target (correct arch, independent of re-exec / nixpkgs pin). + args.elevator = args.elevator.for_target_config(path_to_config) + current_config = Path("/run/current-system") if args.diff: if current_config.exists(): diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py index 6d6231408adb..25a1c3260cda 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py @@ -1,3 +1,5 @@ +from pathlib import PurePosixPath + import pytest from pytest import MonkeyPatch @@ -8,7 +10,10 @@ def test_no_elevator() -> None: n = e.NoElevator() assert not n.elevates assert n.wrap_local() == e.Wrapped(prefix=[]) - assert n.wrap_remote() == e.Wrapped(prefix=[]) + rw = n.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"]) + assert rw.stdin is None + assert rw.argv[:2] == ["/bin/sh", "-c"] + assert rw.argv[-1] == "cmd" with pytest.raises(e.ElevateError): n.with_password("x") @@ -19,14 +24,16 @@ def test_sudo_elevator(monkeypatch: MonkeyPatch) -> None: s = e.SudoElevator() assert s.elevates assert s.wrap_local() == e.Wrapped(prefix=["sudo"]) - assert s.wrap_remote() == e.Wrapped(prefix=["sudo"]) + rw = s.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"]) + assert rw.argv[0] == "sudo" + assert rw.argv[1:3] == ["/bin/sh", "-c"] + assert rw.stdin is None assert s.on_remote_failure() is not None sp = s.with_password("hunter2") - assert sp.wrap_remote() == e.Wrapped( - prefix=["sudo", "--prompt=", "--stdin"], - stdin="hunter2\n", - ) + rw = sp.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"]) + assert rw.argv[:3] == ["sudo", "--prompt=", "--stdin"] + assert rw.stdin == "hunter2\n" assert sp.on_remote_failure() is None # original unchanged assert s.password is None @@ -36,18 +43,94 @@ def test_sudo_elevator_extra_opts(monkeypatch: MonkeyPatch) -> None: monkeypatch.setenv("NIX_SUDOOPTS", "--preserve-env=FOO -H") s = e.SudoElevator() assert s.wrap_local() == e.Wrapped(prefix=["sudo", "--preserve-env=FOO", "-H"]) - assert s.with_password("p").wrap_remote() == e.Wrapped( - prefix=["sudo", "--prompt=", "--stdin", "--preserve-env=FOO", "-H"], - stdin="p\n", + rw = s.with_password("p").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"]) + assert rw.argv[:5] == ["sudo", "--prompt=", "--stdin", "--preserve-env=FOO", "-H"] + assert rw.stdin == "p\n" + + +def test_run0_elevator() -> None: + r = e.Run0Elevator() + assert r.elevates + assert r.wrap_local() == e.Wrapped(prefix=["run0", "--"]) + assert r.on_remote_failure() is not None + + rp = r.with_password("hunter2") + w = rp.wrap_local() + assert w.prefix[0] == "polkit-stdin-agent" + assert "run0" in w.prefix + assert w.stdin == "hunter2\n" + # With a password the failure hint points at the agent, not at -S. + hint = rp.on_remote_failure() + assert hint is not None and "polkit-stdin-agent" in hint + + +def test_run0_elevator_remote() -> None: + r = e.Run0Elevator() + + # No password: /bin/sh wrapper around systemd-run, env passed via + # --setenv so it is resolved in the SSH login shell rather than + # inside the transient unit (which has a useless PATH on NixOS). + rw = r.wrap_remote( + {"PATH": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1"}, + ["nix-env", "-p", "/profile"], ) + assert rw.stdin is None + assert rw.argv[:2] == ["/bin/sh", "-c"] + script = rw.argv[2] + assert isinstance(script, str) + assert script.startswith("exec systemd-run --uid=0 --pipe ") + assert '--setenv=PATH="${PATH-}"' in script + assert "--setenv=NIXOS_INSTALL_BOOTLOADER=1" in script + assert script.endswith(' -- "$@"') + assert rw.argv[3:] == ["sh", "nix-env", "-p", "/profile"] + + # With password: an agent-picker /bin/sh wraps the inner /bin/sh, so + # the agent is registered for the inner shell (and the systemd-run it + # execs into). With no toplevel bound the only candidate is bare-name + # PATH lookup. + rw = r.with_password("pw").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"]) + assert rw.stdin == "pw\n" + assert rw.argv[:3] == ["/bin/sh", "-c", e.Run0Elevator._AGENT_PICKER] + sep = rw.argv.index("--") + assert rw.argv[3:sep] == ["sh", "polkit-stdin-agent"] + assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"] + + # Explicit values containing spaces are shell-quoted inside the + # script (the whole thing is later shlex.quoted again for SSH). + rw = r.wrap_remote({"FOO": "a b"}, ["cmd"]) + script = rw.argv[2] + assert isinstance(script, str) + assert "--setenv='FOO=a b'" in script + + +def test_run0_for_target_config() -> None: + toplevel = PurePosixPath("/nix/store/aaaa-nixos-system") + r = e.Run0Elevator().with_password("pw").for_target_config(toplevel) + assert r.remote_agent == f"{toplevel}/sw/bin/polkit-stdin-agent" + + rw = r.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"]) + sep = rw.argv.index("--") + # Order matters: target-arch toplevel first, then PATH. + assert rw.argv[4:sep] == [ + f"{toplevel}/sw/bin/polkit-stdin-agent", + "polkit-stdin-agent", + ] + # Inner argv is preserved verbatim after the separator. + assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"] + assert rw.argv[-1] == "cmd" + + # Non-run0 elevators ignore the toplevel. + s = e.SudoElevator() + assert s.for_target_config(toplevel) is s def test_elevator_kind() -> None: assert isinstance(e.ElevatorKind.from_name("sudo"), e.SudoElevator) + assert isinstance(e.ElevatorKind.from_name("run0"), e.Run0Elevator) assert isinstance(e.ElevatorKind.from_name("none"), e.NoElevator) with pytest.raises(e.ElevateError): e.ElevatorKind.from_name("doas") - assert set(e.ElevatorKind.choices()) == {"none", "sudo"} + assert set(e.ElevatorKind.choices()) == {"none", "sudo", "run0"} def test_with_prompted_password(monkeypatch: MonkeyPatch) -> None: diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py index 27335feeae5d..1e0e5779ac90 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py @@ -28,6 +28,9 @@ def test_parse_args_elevate() -> None: r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=sudo"]) assert isinstance(r.elevator, nr.elevate.SudoElevator) + r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=run0"]) + assert isinstance(r.elevator, nr.elevate.Run0Elevator) + # back-compat aliases for flag in ("--sudo", "--use-remote-sudo"): r, _ = nr.parse_args(["nixos-rebuild", "switch", flag]) @@ -691,7 +694,7 @@ def test_execute_nix_switch_build_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "mktemp", "-d", @@ -710,7 +713,7 @@ def test_execute_nix_switch_build_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "nix-store", "--realise", @@ -730,7 +733,7 @@ def test_execute_nix_switch_build_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "readlink", "-f", @@ -748,7 +751,7 @@ def test_execute_nix_switch_build_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "rm", "-rf", @@ -781,7 +784,7 @@ def test_execute_nix_switch_build_target_host( "sudo", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "nix-env", "-p", @@ -800,7 +803,7 @@ def test_execute_nix_switch_build_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "test", "-d", @@ -818,7 +821,7 @@ def test_execute_nix_switch_build_target_host( "sudo", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""", "sh", *nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX, str(config_path / "bin/switch-to-configuration"), @@ -907,7 +910,7 @@ def test_execute_nix_switch_flake_target_host( "sudo", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "nix-env", "-p", @@ -926,7 +929,7 @@ def test_execute_nix_switch_flake_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "test", "-d", @@ -944,7 +947,7 @@ def test_execute_nix_switch_flake_target_host( "sudo", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""", "sh", *nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX, str(config_path / "bin/switch-to-configuration"), @@ -1032,7 +1035,7 @@ def test_execute_nix_switch_flake_build_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "nix", "--extra-experimental-features", @@ -1271,7 +1274,7 @@ def test_execute_build_dry_run_build_and_target_remote( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "nix", "--extra-experimental-features", @@ -1526,7 +1529,7 @@ def test_execute_switch_store_path_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "test", "-f", @@ -1544,7 +1547,7 @@ def test_execute_switch_store_path_target_host( "sudo", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "nix-env", "-p", @@ -1563,7 +1566,7 @@ def test_execute_switch_store_path_target_host( "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "test", "-d", @@ -1581,7 +1584,7 @@ def test_execute_switch_store_path_target_host( "sudo", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""", "sh", *nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX, str(config_path / "bin/switch-to-configuration"), diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py index 3d5a5093230b..8100f396cdb7 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py @@ -767,8 +767,8 @@ def test_switch_to_configuration_without_systemd_run( mock_run.assert_called_with( [profile_path / "bin/switch-to-configuration", "switch"], env={ - "LOCALE_ARCHIVE": p.PRESERVE_ENV, - "NIXOS_NO_CHECK": p.PRESERVE_ENV, + "LOCALE_ARCHIVE": e.PRESERVE_ENV, + "NIXOS_NO_CHECK": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "0", }, elevate=e.NO_ELEVATOR, @@ -809,8 +809,8 @@ def test_switch_to_configuration_without_systemd_run( "test", ], env={ - "LOCALE_ARCHIVE": p.PRESERVE_ENV, - "NIXOS_NO_CHECK": p.PRESERVE_ENV, + "LOCALE_ARCHIVE": e.PRESERVE_ENV, + "NIXOS_NO_CHECK": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1", }, elevate=SUDO, @@ -845,8 +845,8 @@ def test_switch_to_configuration_with_systemd_run( "switch", ], env={ - "LOCALE_ARCHIVE": p.PRESERVE_ENV, - "NIXOS_NO_CHECK": p.PRESERVE_ENV, + "LOCALE_ARCHIVE": e.PRESERVE_ENV, + "NIXOS_NO_CHECK": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "0", }, elevate=e.NO_ELEVATOR, @@ -875,8 +875,8 @@ def test_switch_to_configuration_with_systemd_run( "test", ], env={ - "LOCALE_ARCHIVE": p.PRESERVE_ENV, - "NIXOS_NO_CHECK": p.PRESERVE_ENV, + "LOCALE_ARCHIVE": e.PRESERVE_ENV, + "NIXOS_NO_CHECK": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1", }, elevate=SUDO, diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py index 4d6421146a8d..2abdd26ebe07 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py @@ -8,24 +8,41 @@ import nixos_rebuild.models as m import nixos_rebuild.process as p -def test_remote_shell_script() -> None: - assert p._remote_shell_script({"PATH": p.PRESERVE_ENV}) == ( - '''exec env -i PATH="${PATH-}" "$@"''' - ) - assert p._remote_shell_script( +def test_remote_env_shell_argv() -> None: + assert e._remote_env_shell_argv([], {"PATH": e.PRESERVE_ENV}, ["cmd"]) == [ + "/bin/sh", + "-c", + '''exec /usr/bin/env -i PATH="${PATH-}" "$@"''', + "sh", + "cmd", + ] + assert e._remote_env_shell_argv( + ["sudo"], { - "PATH": p.PRESERVE_ENV, - "LOCALE_ARCHIVE": p.PRESERVE_ENV, - "NIXOS_NO_CHECK": p.PRESERVE_ENV, + "PATH": e.PRESERVE_ENV, + "LOCALE_ARCHIVE": e.PRESERVE_ENV, + "NIXOS_NO_CHECK": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "0", - } - ) == ( - """exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """ - '''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"''' - ) - assert p._remote_shell_script({"PATH": p.PRESERVE_ENV, "FOO": "some value"}) == ( - '''exec env -i PATH="${PATH-}" FOO='some value' "$@"''' - ) + }, + ["cmd", "arg"], + ) == [ + "sudo", + "/bin/sh", + "-c", + """exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """ + '''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"''', + "sh", + "cmd", + "arg", + ] + assert e._remote_env_shell_argv( + [], {"PATH": e.PRESERVE_ENV, "FOO": "some value"}, [] + ) == [ + "/bin/sh", + "-c", + '''exec /usr/bin/env -i PATH="${PATH-}" FOO='some value' "$@"''', + "sh", + ] @patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True) @@ -123,7 +140,7 @@ def test_run_wrapper(mock_run: Any) -> None: "--", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "test", "--with", @@ -156,7 +173,7 @@ def test_run_wrapper(mock_run: Any) -> None: "--stdin", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" FOO=bar "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" FOO=bar "$@"'""", "sh", "test", "--with", @@ -248,6 +265,82 @@ def test_ssh_host() -> None: assert remote.store_type == "ssh-ng" +@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True) +@patch("subprocess.run", autospec=True) +def test_run_wrapper_run0(mock_run: Any) -> None: + p.run_wrapper(["cmd", "arg"], elevate=e.Run0Elevator()) + mock_run.assert_called_with( + ["run0", "--", "cmd", "arg"], + check=True, + text=True, + errors="surrogateescape", + env=None, + input=None, + ) + + run0_script = ( + "exec systemd-run --uid=0 --pipe --quiet --wait --collect " + "--service-type=exec --send-sighup " + '--setenv=PATH="${PATH-}" -- "$@"' + ) + + p.run_wrapper( + ["cmd", "arg"], + elevate=e.Run0Elevator(), + remote=m.Remote("user@host", [], "ssh"), + ) + mock_run.assert_called_with( + [ + "ssh", + *p.SSH_DEFAULT_OPTS, + "user@host", + "--", + "/bin/sh", + "-c", + p._quote_remote_arg(run0_script), + "sh", + "cmd", + "arg", + ], + check=True, + text=True, + errors="surrogateescape", + env=None, + input=None, + ) + + p.run_wrapper( + ["cmd"], + elevate=e.Run0Elevator().with_password("pw"), + remote=m.Remote("user@host", [], "ssh"), + ) + mock_run.assert_called_with( + [ + "ssh", + *p.SSH_DEFAULT_OPTS, + "user@host", + "--", + "/bin/sh", + "-c", + p._quote_remote_arg(e.Run0Elevator._AGENT_PICKER), + "sh", + # No toplevel bound, so the only candidate is PATH lookup. + "polkit-stdin-agent", + "--", + "/bin/sh", + "-c", + p._quote_remote_arg(run0_script), + "sh", + "cmd", + ], + check=True, + text=True, + errors="surrogateescape", + env=None, + input="pw\n", + ) + + @patch("subprocess.run", autospec=True) def test_custom_sudo_args(mock_run: Any) -> None: with patch.dict( @@ -298,7 +391,7 @@ def test_custom_sudo_args(mock_run: Any) -> None: "--args", "/bin/sh", "-c", - """'exec env -i PATH="${PATH-}" "$@"'""", + """'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""", "sh", "test", ], From dbbee0dd3a2219015ca58704100b81377187dad7 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:34:49 +0200 Subject: [PATCH 22/46] nixos-rebuild-ng: document --elevate / --ask-elevate-password --- .../ni/nixos-rebuild-ng/nixos-rebuild.8.scd | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd b/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd index d4f83ac9d803..7ec6def5ca21 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd +++ b/pkgs/by-name/ni/nixos-rebuild-ng/nixos-rebuild.8.scd @@ -21,7 +21,7 @@ nixos-rebuild - reconfigure a NixOS machine _nixos-rebuild_ \[--verbose] [--quiet] [--max-jobs MAX_JOBS] [--cores CORES] [--log-format LOG_FORMAT] [--keep-going] [--keep-failed] [--fallback] [--repair] [--option OPTION OPTION] [--builders BUILDERS] [--include INCLUDE]++ \[--print-build-logs] [--show-trace] [--accept-flake-config] [--refresh] [--impure] [--offline] [--no-net] [--recreate-lock-file] [--no-update-lock-file] [--no-write-lock-file] [--no-registries] [--commit-lock-file]++ \[--update-input UPDATE_INPUT] [--override-input OVERRIDE_INPUT OVERRIDE_INPUT] [--no-build-output] [--use-substitutes] [--help] [--debug] [--file FILE] [--attr ATTR] [--flake [FLAKE]] [--no-flake] [--install-bootloader]++ - \[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--store-path STORE_PATH] [--upgrade] [--upgrade-all] [--json] [--ask-sudo-password] [--sudo] [--no-reexec]++ + \[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--store-path STORE_PATH] [--upgrade] [--upgrade-all] [--json] [--elevate {none,sudo,run0}] [--ask-elevate-password] [--no-reexec]++ \[--build-host BUILD_HOST] [--target-host TARGET_HOST] [--no-build-nix] [--image-variant IMAGE_VARIANT]++ \[{switch,boot,test,build,edit,repl,dry-build,dry-run,dry-activate,build-image,build-vm,build-vm-with-bootloader,list-generations}] @@ -269,17 +269,41 @@ It must be one of the following: target-host connection to cache.nixos.org is faster than the connection between hosts. +*--elevate* {none,sudo,run0} + Privilege-elevation method used for activation commands. Setting this + option allows deploying as a non-root user. + + _sudo_ prefixes commands with *sudo*. Additional sudo options can be + passed via the NIX_SUDOOPTS environment variable. + + _run0_ uses systemd's polkit-based elevation. Locally this runs *run0* + directly so the user's normal polkit agent handles any prompts. With + *--target-host* the equivalent _systemd-run --uid=0 --pipe_ form is + used (no remote TTY is allocated). Unless *--ask-elevate-password* is + also passed, the deploying user must be granted the polkit action + _org.freedesktop.systemd1.manage-units_ on the target host without + authentication, e.g. via _security.polkit.extraConfig_. + +*--ask-elevate-password*, *-S* + Prompt locally for a password and feed it to the elevation method. + Implies *--elevate=sudo* if *--elevate* is not given. + + For _sudo_ this uses *sudo --stdin*. For _run0_ the command is wrapped + in *polkit-stdin-agent*, which registers a per-process polkit agent + and answers the PAM conversation from the supplied password. The + machine performing the elevation (the local host, or the target host + with *--target-host*) must set + _system.tools.nixos-rebuild.enableRun0Elevation = true_. + *--elevate=run0 --ask-elevate-password* is not usable otherwise. + *--sudo* - When set, *nixos-rebuild* prefixes activation commands with sudo. - Setting this option allows deploying as a non-root user. + Alias for *--elevate=sudo*. - You can set sudo options by defining the NIX_SUDOOPTS environment - variable. +*--ask-sudo-password* + Alias for *--elevate=sudo --ask-elevate-password*. -*--ask-sudo-password*, *-S* - When set, *nixos-rebuild* will ask for sudo password for remote - activation (i.e.: on *--target-host*) at the start of the build process. - Implies *--sudo*. +*--use-remote-sudo* + Deprecated, use *--elevate=sudo* instead. *--file* _path_, *-f* _path_ Build the NixOS system from the specified file. The file must From f5c8639dd50cb768590f47da7cbdc101ef981215 Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Fri, 29 May 2026 10:01:04 +1000 Subject: [PATCH 23/46] nixos/nspawn-container: use ty instead of mypy the test driver package is also using ty https://github.com/NixOS/nixpkgs/commit/85d58871aebce2ac2a1a04b8812a4a8869827bf3 --- .../nspawn-container/run-nspawn/package.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix b/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix index 4f7a0e68748b..9177cd54825d 100644 --- a/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix @@ -3,10 +3,10 @@ e2fsprogs, iproute2, lib, - mypy, ruff, setuptools, systemd, + ty, }: buildPythonApplication { @@ -35,13 +35,13 @@ buildPythonApplication { doCheck = true; nativeCheckInputs = [ - mypy ruff + ty ]; checkPhase = '' - echo -e "\x1b[32m## run mypy\x1b[0m" - mypy run_nspawn + echo -e "\x1b[32m## run ty\x1b[0m" + ty check --error-on-warning run_nspawn echo -e "\x1b[32m## run ruff check\x1b[0m" ruff check . echo -e "\x1b[32m## run ruff format\x1b[0m" From c14bfbf0463a36a9702c4fc312fa9d5d264d7c45 Mon Sep 17 00:00:00 2001 From: Jost Alemann Date: Sat, 23 May 2026 11:25:39 +0200 Subject: [PATCH 24/46] ty: 0.0.38 -> 0.0.40 Changelog: https://github.com/astral-sh/ty/releases/tag/0.0.40 Diff: https://github.com/astral-sh/ty/compare/0.0.38...0.0.40 --- pkgs/by-name/ty/ty/package.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkgs/by-name/ty/ty/package.nix b/pkgs/by-name/ty/ty/package.nix index 0dde0db10465..6433db1b5d0d 100644 --- a/pkgs/by-name/ty/ty/package.nix +++ b/pkgs/by-name/ty/ty/package.nix @@ -17,7 +17,7 @@ rustPlatform.buildRustPackage (finalAttrs: { pname = "ty"; - version = "0.0.38"; + version = "0.0.40"; __structuredAttrs = true; src = fetchFromGitHub { @@ -25,7 +25,7 @@ rustPlatform.buildRustPackage (finalAttrs: { repo = "ty"; tag = finalAttrs.version; fetchSubmodules = true; - hash = "sha256-70Y5i9m2h2+Jc44jLOf+gXX/PeDbURRJ80y+6h5SlRk="; + hash = "sha256-kdfPnyQXYtf3BDrYCFGfX0bMoPGjRpyH3aUeRZBiUKY="; }; # For Darwin platforms, remove the integration test for file notifications, @@ -39,7 +39,7 @@ rustPlatform.buildRustPackage (finalAttrs: { cargoBuildFlags = [ "--package=ty" ]; - cargoHash = "sha256-+c2JfB55w9otmmgTFIDMwkpASJV7bIMEf0uqRXjk/QM="; + cargoHash = "sha256-yUbHTzUGNdpm3b1s/SkcpFGdp7WjN+xO+CVrPPwrh6A="; nativeBuildInputs = [ installShellFiles ]; buildInputs = [ rust-jemalloc-sys ]; From 61542c96d144e59669ec2c909bc08ed308bb4a38 Mon Sep 17 00:00:00 2001 From: Jost Alemann Date: Wed, 27 May 2026 22:45:01 +0200 Subject: [PATCH 25/46] ty: add ddogfoodd to maintainers --- pkgs/by-name/ty/ty/package.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/pkgs/by-name/ty/ty/package.nix b/pkgs/by-name/ty/ty/package.nix index 6433db1b5d0d..8b1c65bf1be6 100644 --- a/pkgs/by-name/ty/ty/package.nix +++ b/pkgs/by-name/ty/ty/package.nix @@ -101,6 +101,7 @@ rustPlatform.buildRustPackage (finalAttrs: { mainProgram = "ty"; maintainers = with lib.maintainers; [ bengsparks + ddogfoodd figsoda GaetanLepage ]; From db81e7567f3b9fcc3465cf42ad032f913b24b34c Mon Sep 17 00:00:00 2001 From: Andrew Childs Date: Fri, 22 May 2026 11:21:06 +0900 Subject: [PATCH 26/46] linux: use correct strip program when stripping vmlinux Otherwise may fail during cross complation with: [...]/strip: Unable to recognise the architecture of the input file: `/[...]/vmlinux` --- pkgs/os-specific/linux/kernel/build.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/os-specific/linux/kernel/build.nix b/pkgs/os-specific/linux/kernel/build.nix index ac191a3ba149..04cbc24e9c02 100644 --- a/pkgs/os-specific/linux/kernel/build.nix +++ b/pkgs/os-specific/linux/kernel/build.nix @@ -510,7 +510,7 @@ lib.makeOverridable ( preFixup = '' if [ -z "''${dontStrip-}" -a -e $out/vmlinux ]; then - strip -v -S -p $out/vmlinux + $STRIP -v -S -p $out/vmlinux fi ''; From fd573a70ddefbf3770f8339c956ec53785a99420 Mon Sep 17 00:00:00 2001 From: Sergei Trofimovich Date: Sat, 30 May 2026 09:16:43 +0100 Subject: [PATCH 27/46] capstone: 5.0.8 -> 5.0.9 Changes: https://github.com/capstone-engine/capstone/releases/tag/5.0.9 --- pkgs/by-name/ca/capstone/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/ca/capstone/package.nix b/pkgs/by-name/ca/capstone/package.nix index 23d59748b193..b464b9d53885 100644 --- a/pkgs/by-name/ca/capstone/package.nix +++ b/pkgs/by-name/ca/capstone/package.nix @@ -8,13 +8,13 @@ stdenv.mkDerivation (finalAttrs: { pname = "capstone"; - version = "5.0.8"; + version = "5.0.9"; src = fetchFromGitHub { owner = "capstone-engine"; repo = "capstone"; rev = finalAttrs.version; - hash = "sha256-Ww/tv95ba0mBgRvQBMAbb/RbMjdgWwMcCVngtWDv1Vc="; + hash = "sha256-uAiiKWKGjEATPE0Xc3g+aOLCz5ffIlDmf+7jaGwaZ4I="; }; cmakeFlags = [ From 68157d88863aa070260b1b970470e1faea78ae3f Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Sat, 30 May 2026 17:52:06 +0200 Subject: [PATCH 28/46] linux/common-config: enable deflate/zstd for erofs Signed-off-by: Paul Meyer --- pkgs/os-specific/linux/kernel/common-config.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkgs/os-specific/linux/kernel/common-config.nix b/pkgs/os-specific/linux/kernel/common-config.nix index d1734a2b32c5..e0083addb03f 100644 --- a/pkgs/os-specific/linux/kernel/common-config.nix +++ b/pkgs/os-specific/linux/kernel/common-config.nix @@ -773,6 +773,9 @@ let SQUASHFS_LZ4 = yes; SQUASHFS_ZSTD = yes; + EROFS_FS_ZIP_DEFLATE = whenAtLeast "6.6" yes; + EROFS_FS_ZIP_ZSTD = whenAtLeast "6.10" yes; + # Native Language Support modules, needed by some filesystems NLS = yes; NLS_DEFAULT = freeform "utf8"; From 1ac3c5dc9969eb532bc5fb22bfedaa4f2b4293c0 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Fri, 17 Apr 2026 10:57:19 +0200 Subject: [PATCH 29/46] nixos/shadow: use file capabilities for newuidmap/newgidmap Writing a multi-line /proc//[ug]id_map only requires CAP_SETUID/CAP_SETGID over the parent user namespace, not full root. shadow's own --with-fcaps install mode (70971457b761) sets exactly cap_setuid+ep / cap_setgid+ep, and Arch, Fedora and Debian have shipped these binaries with file capabilities instead of setuid for years. The setuid variant already drops to the same single capability before the uid_map write (see lib/idmapping.c), so the privilege at the point attacker-controlled data reaches the kernel is unchanged. The reduction is in the startup window: with file capabilities the process never has euid 0 and never holds the full capability set during NSS lookups, /etc/subuid parsing and /proc/ opening. The only functional difference is that mapping host uid 0 into a child namespace additionally needs CAP_SETFCAP, which the setuid path got implicitly. NixOS never puts uid 0 into auto-allocated subuid ranges, and granting it manually is a deliberate root-equivalent configuration; the release notes document the override for that case. nixosTests.{shadow,podman,docker-rootless} pass; the latter two exercise newuidmap/newgidmap via rootless containers. Supersedes #461172. Co-authored-by: Rasheeq Azad --- nixos/doc/manual/release-notes/rl-2611.section.md | 2 +- nixos/modules/programs/shadow.nix | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 798e204a578d..d94e2419c116 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -24,4 +24,4 @@ -- Create the first release note entry in this section! +- The `newuidmap` and `newgidmap` security wrappers are now installed with `cap_setuid`/`cap_setgid` file capabilities instead of the setuid-root bit, matching shadow's `--with-fcaps` install mode and other major distributions. Rootless containers (podman, docker-rootless, unprivileged user namespaces) are unaffected. The only behavioural change is that mapping host uid 0 via `/etc/subuid` (which NixOS never configures by default) additionally requires `cap_setfcap`; users who explicitly grant uid 0 in a subuid range can restore the previous behaviour with `security.wrappers.newuidmap.capabilities = lib.mkForce "cap_setuid,cap_setfcap+ep";`. diff --git a/nixos/modules/programs/shadow.nix b/nixos/modules/programs/shadow.nix index d179283ac559..3c53531d90f7 100644 --- a/nixos/modules/programs/shadow.nix +++ b/nixos/modules/programs/shadow.nix @@ -267,13 +267,22 @@ in group = "root"; inherit source; }; + mkCapRoot = capabilities: source: { + inherit capabilities source; + owner = "root"; + group = "root"; + }; in { su = mkSetuidRoot "${config.security.shadow.su.package}/bin/su"; sg = mkSetuidRoot "${cfg.package.out}/bin/sg"; newgrp = mkSetuidRoot "${cfg.package.out}/bin/newgrp"; - newuidmap = mkSetuidRoot "${cfg.package.out}/bin/newuidmap"; - newgidmap = mkSetuidRoot "${cfg.package.out}/bin/newgidmap"; + # File capabilities instead of setuid root, mirroring shadow's + # own --with-fcaps install mode and what Arch/Fedora/Debian ship. + # The kernel only requires CAP_SETUID/CAP_SETGID over the parent + # userns to write a multi-line /proc//[ug]id_map. + newuidmap = mkCapRoot "cap_setuid+ep" "${cfg.package.out}/bin/newuidmap"; + newgidmap = mkCapRoot "cap_setgid+ep" "${cfg.package.out}/bin/newgidmap"; } // lib.optionalAttrs config.users.mutableUsers { chsh = mkSetuidRoot "${cfg.package.out}/bin/chsh"; From b59952e5239ca943c2a36cd63f36eaa08f3099eb Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Jun 2025 13:48:49 +0100 Subject: [PATCH 30/46] =?UTF-8?q?lib/systems:=20don=E2=80=99t=20specify=20?= =?UTF-8?q?`multi=5Fv7=5Fdefconfig`=20explicitly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This has been the default for `defconfig` upstream since 32872c04ffd5a855c43802c5aa76c711eb021948, which is in all our supported kernels. --- lib/systems/platforms.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/systems/platforms.nix b/lib/systems/platforms.nix index ae78b4edc9d4..a9c70cad19f9 100644 --- a/lib/systems/platforms.nix +++ b/lib/systems/platforms.nix @@ -306,7 +306,7 @@ rec { linux-kernel = { name = "armv7l-hf-multiplatform"; Major = "2.6"; # Using "2.6" enables 2.6 kernel syscalls in glibc. - baseConfig = "multi_v7_defconfig"; + baseConfig = "defconfig"; DTB = true; autoModules = true; preferBuiltin = true; From 53f91beef8956255656ed88407afb5a1f61ca5ea Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Jun 2025 15:03:38 +0100 Subject: [PATCH 31/46] lib/systems: remove unused `linux-kernel.Major` field --- lib/systems/platforms.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/systems/platforms.nix b/lib/systems/platforms.nix index a9c70cad19f9..91fcbe3862e4 100644 --- a/lib/systems/platforms.nix +++ b/lib/systems/platforms.nix @@ -305,7 +305,6 @@ rec { armv7l-hf-multiplatform = { linux-kernel = { name = "armv7l-hf-multiplatform"; - Major = "2.6"; # Using "2.6" enables 2.6 kernel syscalls in glibc. baseConfig = "defconfig"; DTB = true; autoModules = true; From c76c290c147b7b5b4695e18cad4b773e520b8c42 Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Jun 2025 13:44:01 +0100 Subject: [PATCH 32/46] lib/systems: remove redundant kernel configuration These are now already set upstream and should result in no change, even for the Raspberry Pi vendor kernels. --- lib/systems/platforms.nix | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/lib/systems/platforms.nix b/lib/systems/platforms.nix index 91fcbe3862e4..d35bea4dd469 100644 --- a/lib/systems/platforms.nix +++ b/lib/systems/platforms.nix @@ -192,11 +192,6 @@ rec { DTB = true; autoModules = true; preferBuiltin = true; - extraConfig = '' - # Disable OABI to have seccomp_filter (required for systemd) - # https://github.com/raspberrypi/firmware/issues/651 - OABI_COMPAT n - ''; target = "zImage"; }; gcc = { @@ -311,19 +306,6 @@ rec { preferBuiltin = true; target = "zImage"; extraConfig = '' - # Serial port for Raspberry Pi 3. Wasn't included in ARMv7 defconfig - # until 4.17. - SERIAL_8250_BCM2835AUX y - SERIAL_8250_EXTENDED y - SERIAL_8250_SHARE_IRQ y - - # Hangs ODROID-XU4 - ARM_BIG_LITTLE_CPUIDLE n - - # Disable OABI to have seccomp_filter (required for systemd) - # https://github.com/raspberrypi/firmware/issues/651 - OABI_COMPAT n - # >=5.12 fails with: # drivers/net/ethernet/micrel/ks8851_common.o: in function `ks8851_probe_common': # ks8851_common.c:(.text+0x179c): undefined reference to `__this_module' @@ -362,22 +344,6 @@ rec { autoModules = true; preferBuiltin = true; extraConfig = '' - # Raspberry Pi 3 stuff. Not needed for s >= 4.10. - ARCH_BCM2835 y - BCM2835_MBOX y - BCM2835_WDT y - RASPBERRYPI_FIRMWARE y - RASPBERRYPI_POWER y - SERIAL_8250_BCM2835AUX y - SERIAL_8250_EXTENDED y - SERIAL_8250_SHARE_IRQ y - - # Cavium ThunderX stuff. - PCI_HOST_THUNDER_ECAM y - - # Nvidia Tegra stuff. - PCI_TEGRA y - # The default (=y) forces us to have the XHCI firmware available in initrd, # which our initrd builder can't currently do easily. USB_XHCI_TEGRA m From d408bc4f02ecb5a9ee338f6b469834000e7dd6b9 Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Jun 2025 13:45:38 +0100 Subject: [PATCH 33/46] lib/systems: remove obsolete `KS8851_MLL` workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was fixed by 51bb08dd04a05035a64504faa47651d36b0f3125 upstream, which is in all our supported kernels ≥ 5.12. --- lib/systems/platforms.nix | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/systems/platforms.nix b/lib/systems/platforms.nix index d35bea4dd469..c3ad042dd9d5 100644 --- a/lib/systems/platforms.nix +++ b/lib/systems/platforms.nix @@ -305,13 +305,6 @@ rec { autoModules = true; preferBuiltin = true; target = "zImage"; - extraConfig = '' - # >=5.12 fails with: - # drivers/net/ethernet/micrel/ks8851_common.o: in function `ks8851_probe_common': - # ks8851_common.c:(.text+0x179c): undefined reference to `__this_module' - # See: https://lore.kernel.org/netdev/20210116164828.40545-1-marex@denx.de/T/ - KS8851_MLL y - ''; }; gcc = { # Some table about fpu flags: From 9c5ca61078405508747011d179690de59d587bdf Mon Sep 17 00:00:00 2001 From: Emily Date: Tue, 24 Jun 2025 16:48:12 +0100 Subject: [PATCH 34/46] lib/systems: remove broken kernel configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these successfully build a kernel, and as we’re moving away from putting kernel configuration in the platform structure and don’t package the vendor kernels that all but `fuloong2f_n32` expect, there’s no point to keeping these kernel configurations in Nixpkgs. --- lib/systems/platforms.nix | 104 -------------------------------------- 1 file changed, 104 deletions(-) diff --git a/lib/systems/platforms.nix b/lib/systems/platforms.nix index c3ad042dd9d5..5722fe87909d 100644 --- a/lib/systems/platforms.nix +++ b/lib/systems/platforms.nix @@ -212,15 +212,6 @@ rec { }; zero-gravitas = { - linux-kernel = { - name = "zero-gravitas"; - - baseConfig = "zero-gravitas_defconfig"; - # Target verified by checking /boot on reMarkable 1 device - target = "zImage"; - autoModules = false; - DTB = true; - }; gcc = { fpu = "neon"; cpu = "cortex-a9"; @@ -228,15 +219,6 @@ rec { }; zero-sugar = { - linux-kernel = { - name = "zero-sugar"; - - baseConfig = "zero-sugar_defconfig"; - DTB = true; - autoModules = false; - preferBuiltin = true; - target = "zImage"; - }; gcc = { cpu = "cortex-a7"; fpu = "neon-vfpv4"; @@ -269,24 +251,6 @@ rec { }; }; - guruplug = lib.recursiveUpdate sheevaplug { - # Define `CONFIG_MACH_GURUPLUG' (see - # ) - # and other GuruPlug-specific things. Requires the `guruplug-defconfig' - # patch. - linux-kernel.baseConfig = "guruplug_defconfig"; - }; - - beaglebone = lib.recursiveUpdate armv7l-hf-multiplatform { - linux-kernel = { - name = "beaglebone"; - baseConfig = "bb.org_defconfig"; - autoModules = false; - extraConfig = ""; # TBD kernel config - target = "zImage"; - }; - }; - # https://developer.android.com/ndk/guides/abis#v7a armv7a-android = { linux-kernel.name = "armeabi-v7a"; @@ -370,74 +334,6 @@ rec { }; fuloong2f_n32 = { - linux-kernel = { - name = "fuloong2f_n32"; - baseConfig = "lemote2f_defconfig"; - autoModules = false; - extraConfig = '' - MIGRATION n - COMPACTION n - - # nixos mounts some cgroup - CGROUPS y - - BLK_DEV_RAM y - BLK_DEV_INITRD y - BLK_DEV_CRYPTOLOOP m - BLK_DEV_DM m - DM_CRYPT m - MD y - EXT4_FS m - USB_STORAGE_CYPRESS_ATACB m - - IP_PNP y - IP_PNP_DHCP y - IP_PNP_BOOTP y - NFS_FS y - ROOT_NFS y - TUN m - NFS_V4 y - NFS_V4_1 y - NFS_FSCACHE y - NFSD m - NFSD_V2_ACL y - NFSD_V3 y - NFSD_V3_ACL y - NFSD_V4 y - - # Fail to build - DRM n - SCSI_ADVANSYS n - USB_ISP1362_HCD n - SND_SOC n - SND_ALI5451 n - FB_SAVAGE n - SCSI_NSP32 n - ATA_SFF n - SUNGEM n - IRDA n - ATM_HE n - SCSI_ACARD n - BLK_DEV_CMD640_ENHANCED n - - FUSE_FS m - - # Needed for udev >= 150 - SYSFS_DEPRECATED_V2 n - - VGA_CONSOLE n - VT_HW_CONSOLE_BINDING y - SERIAL_8250_CONSOLE y - FRAMEBUFFER_CONSOLE y - EXT2_FS y - EXT3_FS y - MAGIC_SYSRQ y - - # The kernel doesn't boot at all, with FTRACE - FTRACE n - ''; - target = "vmlinux"; - }; gcc = { arch = "loongson2f"; float = "hard"; From af6485561c479599e1091b8aa9a7b6cb23d14aa4 Mon Sep 17 00:00:00 2001 From: Emily Date: Tue, 24 Jun 2025 15:41:57 +0100 Subject: [PATCH 35/46] lib/systems: remove unused platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were not used by the selection logic or `lib/systems/examples.nix`, so any consumer would have to be including them explicitly. Since we’re moving away from setting Linux kernel configuration in the platform structure, breaking these explictily won’t reduce the convenience and will help signal to any such expert users that they need to move their kernel configuration elsewhere. --- lib/systems/platforms.nix | 54 --------------------------------------- 1 file changed, 54 deletions(-) diff --git a/lib/systems/platforms.nix b/lib/systems/platforms.nix index 5722fe87909d..ee220ce31cf2 100644 --- a/lib/systems/platforms.nix +++ b/lib/systems/platforms.nix @@ -226,31 +226,6 @@ rec { }; }; - utilite = { - linux-kernel = { - name = "utilite"; - maseConfig = "multi_v7_defconfig"; - autoModules = false; - extraConfig = '' - # Ubi for the mtd - MTD_UBI y - UBIFS_FS y - UBIFS_FS_XATTR y - UBIFS_FS_ADVANCED_COMPR y - UBIFS_FS_LZO y - UBIFS_FS_ZLIB y - UBIFS_FS_DEBUG n - ''; - makeFlags = [ "LOADADDR=0x10800000" ]; - target = "uImage"; - DTB = true; - }; - gcc = { - cpu = "cortex-a9"; - fpu = "neon"; - }; - }; - # https://developer.android.com/ndk/guides/abis#v7a armv7a-android = { linux-kernel.name = "armeabi-v7a"; @@ -379,35 +354,6 @@ rec { }; }; - # based on: - # https://www.mail-archive.com/qemu-discuss@nongnu.org/msg05179.html - # https://gmplib.org/~tege/qemu.html#mips64-debian - mips64el-qemu-linux-gnuabi64 = { - linux-kernel = { - name = "mips64el"; - baseConfig = "64r2el_defconfig"; - target = "vmlinuz"; - autoModules = false; - DTB = true; - # for qemu 9p passthrough filesystem - extraConfig = '' - MIPS_MALTA y - PAGE_SIZE_4KB y - CPU_LITTLE_ENDIAN y - CPU_MIPS64_R2 y - 64BIT y - CPU_MIPS64_R2 y - - NET_9P y - NET_9P_VIRTIO y - 9P_FS y - 9P_FS_POSIX_ACL y - PCI y - VIRTIO_PCI y - ''; - }; - }; - ## ## Other ## From 0c19eb3e5559a9e27a3402dda4357d0cd2b58dae Mon Sep 17 00:00:00 2001 From: Emily Date: Tue, 24 Jun 2025 18:56:51 +0100 Subject: [PATCH 36/46] lib/systems: unify ARMv5 platforms with stock kernel configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `sheevaplug` kernel configuration was added a very long time ago and has not been adjusted for years. `pogoplug4` was identical to `sheevaplug` except for an even more stripped‐down kernel configuration, no device tree support, and a different load address for the uImage. These days, the stock kernel configuration builds and there has been an upstream device tree for the Pogoplug Series 4 for years; unify `sheevaplug` and `pogoplug4` into an `armv5tel-multiplatform` that uses the standard configuration. ARMv5 was also the only platform that defaulted to uImage, the [legacy U‐Boot image format] that is deprecated upstream. Our bootloader machinery in NixOS does not handle these images in any special way and even the original ARMv6 Raspberry Pi platform defaults to the standard zImage. We switch `armv5tel-multiplatform` to zImage to match. [legacy U‐Boot image format]: https://docs.u-boot.org/en/latest/usage/cmd/bootm.html#legacy-boot It is of course natural to worry about backwards compatibility here: this switches to a different kernel image format and drops support for root on NFS along with random oddities like KGDB and LatencyTOP. Renaming the platform is intended to help mitigate this risk. The reality, however, is that it is currently very difficult to build a configuration for ARMv5. I found online as an example configuration from many years ago; it already set `autoModules`, and builds U‐Boot using `CONFIG_DISTRO_DEFAULTS`, which should work out of the box without requiring the legacy U‐Boot image format. Even then, however, I confirmed with the author that it hasn’t been used in years, and I could barely get it to build with a modern Nixpkgs: OpenSSH is broken, Nix is broken, multiple default `environment.systemPackages` in the SD image profile are broken, `boot.initrd.includeDefaultModules` is broken, and `hardware.enableAllHardware` is broken. I conclude that if anyone is actively building systems on ARMv5, they have a forked Nixpkgs or a very custom setup. Given our general move to standard boot chains and no platform‐specific hacks, and the decaying state of our unofficial support for 32‐bit ARM, I think it is not worth maintaining support for the legacy image format for this one ancient platform. If anyone is running a heavily stripped‐down NixOS configuration on mission‐critical SheevaPlugs using a custom Nix‐free deployment setup relying on the legacy U‐Boot image format and somehow none of these kernel changes manage to loudly break their build, hopefully they’ll at least notice the release notes entry! Otherwise there’s always JTAG… --- doc/release-notes/rl-2611.section.md | 4 + lib/systems/examples.nix | 10 +- lib/systems/platforms.nix | 137 +----------------- .../linux/make-bootstrap-tools-cross.nix | 2 +- pkgs/test/cross/default.nix | 2 +- pkgs/top-level/release-cross.nix | 6 +- 6 files changed, 17 insertions(+), 144 deletions(-) diff --git a/doc/release-notes/rl-2611.section.md b/doc/release-notes/rl-2611.section.md index 6aa19f0952b7..4b36a630421d 100644 --- a/doc/release-notes/rl-2611.section.md +++ b/doc/release-notes/rl-2611.section.md @@ -19,6 +19,10 @@ - `uhttpmock` providing 0.0 ABI was removed. `uhttpmock_1_0` providing 1.0 ABI was renamed to `uhttpmock` and `uhttpmock_1_0` was kept as an alias. +- The ARMv5 Linux kernel build now uses a standard configuration and generates a standard compressed image instead of the deprecated legacy U‐Boot image format. + `lib.systems.{examples,platforms}.{sheevaplug,pogoplug4}` have been unified into `lib.systems.examples.armv5tel-multiplatform`. + Note that there is no official support for ARMv5 and it is not possible to build even a simple NixOS configuration out of the box. + - `requireFile` now sets `meta.license = lib.licenses.unfree` by default. Users of `requireFile`-based derivations that preserve this default will need to explicitly allow their evaluation as described in [](#sec-allow-unfree). - `librest` providing 0.7 ABI was removed. `librest_1_0` providing 1.0 ABI was renamed to `librest` and `librest_1_0` was kept as an alias. diff --git a/lib/systems/examples.nix b/lib/systems/examples.nix index 18c954ffc06e..055f727d53c9 100644 --- a/lib/systems/examples.nix +++ b/lib/systems/examples.nix @@ -40,10 +40,9 @@ rec { rust.rustcTarget = "powerpc-unknown-linux-gnu"; }; - sheevaplug = { + armv5tel-multiplatform = { config = "armv5tel-unknown-linux-gnueabi"; - } - // platforms.sheevaplug; + }; raspberryPi = { config = "armv6l-unknown-linux-gnueabihf"; @@ -99,11 +98,6 @@ rec { useLLVM = true; }; - pogoplug4 = { - config = "armv5tel-unknown-linux-gnueabi"; - } - // platforms.pogoplug4; - ben-nanonote = { config = "mipsel-unknown-linux-uclibc"; } diff --git a/lib/systems/platforms.nix b/lib/systems/platforms.nix index ee220ce31cf2..b10c787728a0 100644 --- a/lib/systems/platforms.nix +++ b/lib/systems/platforms.nix @@ -46,138 +46,15 @@ rec { ## ARM ## - pogoplug4 = { + armv5tel-multiplatform = { linux-kernel = { - name = "pogoplug4"; + name = "armv5tel-multiplatform"; baseConfig = "multi_v5_defconfig"; - autoModules = false; - extraConfig = '' - # Ubi for the mtd - MTD_UBI y - UBIFS_FS y - UBIFS_FS_XATTR y - UBIFS_FS_ADVANCED_COMPR y - UBIFS_FS_LZO y - UBIFS_FS_ZLIB y - UBIFS_FS_DEBUG n - ''; - makeFlags = [ "LOADADDR=0x8000" ]; - target = "uImage"; - # TODO reenable once manual-config's config actually builds a .dtb and this is checked to be working - #DTB = true; - }; - gcc = { - arch = "armv5te"; - }; - }; - - sheevaplug = { - linux-kernel = { - name = "sheevaplug"; - - baseConfig = "multi_v5_defconfig"; - autoModules = false; - extraConfig = '' - BLK_DEV_RAM y - BLK_DEV_INITRD y - BLK_DEV_CRYPTOLOOP m - BLK_DEV_DM m - DM_CRYPT m - MD y - BTRFS_FS m - XFS_FS m - JFS_FS m - EXT4_FS m - USB_STORAGE_CYPRESS_ATACB m - - # mv cesa requires this sw fallback, for mv-sha1 - CRYPTO_SHA1 y - # Fast crypto - CRYPTO_TWOFISH y - CRYPTO_TWOFISH_COMMON y - CRYPTO_BLOWFISH y - CRYPTO_BLOWFISH_COMMON y - - IP_PNP y - IP_PNP_DHCP y - NFS_FS y - ROOT_NFS y - TUN m - NFS_V4 y - NFS_V4_1 y - NFS_FSCACHE y - NFSD m - NFSD_V2_ACL y - NFSD_V3 y - NFSD_V3_ACL y - NFSD_V4 y - NETFILTER y - IP_NF_IPTABLES y - IP_NF_FILTER y - IP_NF_MATCH_ADDRTYPE y - IP_NF_TARGET_LOG y - IP_NF_MANGLE y - IPV6 m - VLAN_8021Q m - - CIFS y - CIFS_XATTR y - CIFS_POSIX y - CIFS_FSCACHE y - CIFS_ACL y - - WATCHDOG y - WATCHDOG_CORE y - ORION_WATCHDOG m - - ZRAM m - NETCONSOLE m - - # Disable OABI to have seccomp_filter (required for systemd) - # https://github.com/raspberrypi/firmware/issues/651 - OABI_COMPAT n - - # Fail to build - DRM n - SCSI_ADVANSYS n - USB_ISP1362_HCD n - SND_SOC n - SND_ALI5451 n - FB_SAVAGE n - SCSI_NSP32 n - ATA_SFF n - SUNGEM n - IRDA n - ATM_HE n - SCSI_ACARD n - BLK_DEV_CMD640_ENHANCED n - - FUSE_FS m - - # systemd uses cgroups - CGROUPS y - - # Latencytop - LATENCYTOP y - - # Ubi for the mtd - MTD_UBI y - UBIFS_FS y - UBIFS_FS_XATTR y - UBIFS_FS_ADVANCED_COMPR y - UBIFS_FS_LZO y - UBIFS_FS_ZLIB y - UBIFS_FS_DEBUG n - - # Kdb, for kernel troubles - KGDB y - KGDB_SERIAL_CONSOLE y - KGDB_KDB y - ''; - makeFlags = [ "LOADADDR=0x0200000" ]; - target = "uImage"; - DTB = true; # Beyond 3.10 + DTB = true; + autoModules = true; + preferBuiltin = true; + target = "zImage"; }; gcc = { arch = "armv5te"; @@ -407,7 +284,7 @@ rec { if version == null then pc else if lib.versionOlder version "6" then - sheevaplug + armv5tel-multiplatform else if lib.versionOlder version "7" then raspberrypi else diff --git a/pkgs/stdenv/linux/make-bootstrap-tools-cross.nix b/pkgs/stdenv/linux/make-bootstrap-tools-cross.nix index d69cc836a19e..5b670400ef43 100644 --- a/pkgs/stdenv/linux/make-bootstrap-tools-cross.nix +++ b/pkgs/stdenv/linux/make-bootstrap-tools-cross.nix @@ -25,7 +25,7 @@ lib.mapAttrs (n: make) ( # on how to request an upload. # Sort following the sorting in `./default.nix` `bootstrapFiles` argument. - armv5tel-unknown-linux-gnueabi = sheevaplug; + armv5tel-unknown-linux-gnueabi = armv5tel-multiplatform; armv6l-unknown-linux-gnueabihf = raspberryPi; armv7l-unknown-linux-gnueabihf = armv7l-hf-multiplatform; aarch64-unknown-linux-gnu = aarch64-multiplatform; diff --git a/pkgs/test/cross/default.nix b/pkgs/test/cross/default.nix index c5320f72a4bc..b4a007fa17e2 100644 --- a/pkgs/test/cross/default.nix +++ b/pkgs/test/cross/default.nix @@ -189,7 +189,7 @@ let pkgs.pkgsLLVM.stdenv pkgs.pkgsStatic.bash pkgs.pkgsCross.arm-embedded.stdenv - pkgs.pkgsCross.sheevaplug.stdenv # for armv5tel + pkgs.pkgsCross.armv5tel-multiplatform.stdenv pkgs.pkgsCross.raspberryPi.stdenv # for armv6l pkgs.pkgsCross.armv7l-hf-multiplatform.stdenv pkgs.pkgsCross.m68k.stdenv diff --git a/pkgs/top-level/release-cross.nix b/pkgs/top-level/release-cross.nix index e264d1cf8590..cc0ab49a8c40 100644 --- a/pkgs/top-level/release-cross.nix +++ b/pkgs/top-level/release-cross.nix @@ -197,8 +197,8 @@ in crossIphone32 = mapTestOnCross systems.examples.iphone32 darwinCommon; - # Test some cross builds to the Sheevaplug - crossSheevaplugLinux = mapTestOnCross systems.examples.sheevaplug ( + # Test some cross builds to ARMv5 + armv5tel = mapTestOnCross systems.examples.armv5tel-multiplatform ( linuxCommon // { ubootSheevaplug = nativePlatforms; @@ -235,8 +235,6 @@ in # Linux on armv7l-hf armv7l-hf = mapTestOnCross systems.examples.armv7l-hf-multiplatform linuxCommon; - pogoplug4 = mapTestOnCross systems.examples.pogoplug4 linuxCommon; - # Linux on aarch64 aarch64 = mapTestOnCross systems.examples.aarch64-multiplatform linuxCommon; aarch64-musl = mapTestOnCross systems.examples.aarch64-multiplatform-musl linuxCommon; From 38dec9fcff007aac9f36e596f0740ae0dcad63b7 Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Jun 2025 13:08:36 +0100 Subject: [PATCH 37/46] =?UTF-8?q?makeInitrd{,NG}:=20drop=20legacy=20U?= =?UTF-8?q?=E2=80=90Boot=20image=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that ARMv5 no longer uses `uImage`, there’s no point in keeping around support for this deprecated format that the rest of the NixOS infrastructure doesn’t handle. Modern U‐Boot setups should not be affected in any way by this. --- .../manual/release-notes/rl-2611.section.md | 2 + .../kernel/initrd-compressor-meta.nix | 7 ---- pkgs/build-support/kernel/make-initrd-ng.nix | 38 +++++-------------- pkgs/build-support/kernel/make-initrd.nix | 33 +++++----------- pkgs/build-support/kernel/make-initrd.sh | 8 +--- 5 files changed, 21 insertions(+), 67 deletions(-) diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 798e204a578d..8397e17792cf 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -18,6 +18,8 @@ - `boot.vesa` has been removed. It was deprecated in 2020 because Xorg now works better with kernel modesetting. If you still need the legacy VESA 800x600 fallback, set `boot.kernelParams = [ "vga=0x317" "nomodeset" ];` directly. +- Support for the legacy U‐Boot image format has been removed from the initrd generators, as it is deprecated upstream and no longer used by any platform in Nixpkgs. + - Python 2 has been removed from the top-level package set, as it is long past end-of-life. The `python2`, `python27`, `python2Full`, `python27Full`, `python2Packages`, and `python27Packages` attributes, along with the legacy `python`, `pythonFull`, and `pythonPackages` aliases, now throw an error directing you to `python3`. The `isPy2` and `isPy27` package flags have been removed accordingly. The only remaining Python 2 interpreter is vendored inside the `resholve` package for its `oil` dependency and is not exposed for general use. ## Other Notable Changes {#sec-release-26.11-notable-changes} diff --git a/pkgs/build-support/kernel/initrd-compressor-meta.nix b/pkgs/build-support/kernel/initrd-compressor-meta.nix index 52e009967b50..728b9291627f 100644 --- a/pkgs/build-support/kernel/initrd-compressor-meta.nix +++ b/pkgs/build-support/kernel/initrd-compressor-meta.nix @@ -1,18 +1,15 @@ rec { cat = { executable = pkgs: "cat"; - ubootName = "none"; extension = ".cpio"; }; gzip = { executable = pkgs: "${pkgs.gzip}/bin/gzip"; defaultArgs = [ "-9n" ]; - ubootName = "gzip"; extension = ".gz"; }; bzip2 = { executable = pkgs: "${pkgs.bzip2}/bin/bzip2"; - ubootName = "bzip2"; extension = ".bz2"; }; xz = { @@ -29,24 +26,20 @@ rec { "--check=crc32" "--lzma1=dict=512KiB" ]; - ubootName = "lzma"; extension = ".lzma"; }; lz4 = { executable = pkgs: "${pkgs.lz4}/bin/lz4"; defaultArgs = [ "-l" ]; - ubootName = "lz4"; extension = ".lz4"; }; lzop = { executable = pkgs: "${pkgs.lzop}/bin/lzop"; - ubootName = "lzo"; extension = ".lzo"; }; zstd = { executable = pkgs: "${pkgs.zstd}/bin/zstd"; defaultArgs = [ "-10" ]; - ubootName = "zstd"; extension = ".zst"; }; pigz = gzip // { diff --git a/pkgs/build-support/kernel/make-initrd-ng.nix b/pkgs/build-support/kernel/make-initrd-ng.nix index 768af8f5c48d..8fe1f553c9ed 100644 --- a/pkgs/build-support/kernel/make-initrd-ng.nix +++ b/pkgs/build-support/kernel/make-initrd-ng.nix @@ -4,14 +4,13 @@ let # from it. compressors = import ./initrd-compressor-meta.nix; # Get the basename of the actual compression program from the whole - # compression command, for the purpose of guessing the u-boot + # compression command, for the purpose of guessing the # compression type and filename extension. compressorName = fullCommand: builtins.elemAt (builtins.match "([^ ]*/)?([^ ]+).*" fullCommand) 1; in { stdenvNoCC, cpio, - ubootTools, lib, pkgsBuildHost, makeInitrdNGTool, @@ -57,22 +56,13 @@ in # symlinks to store paths. prepend ? [ ], - # Whether to wrap the initramfs in a u-boot image. - makeUInitrd ? stdenvNoCC.hostPlatform.linux-kernel.target == "uImage", - - # If generating a u-boot image, the architecture to use. The default - # guess may not align with u-boot's nomenclature correctly, so it can - # be overridden. - # See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L81-106 for a list. - uInitrdArch ? stdenvNoCC.hostPlatform.ubootArch, - - # The name of the compression, as recognised by u-boot. - # See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L195-204 for a list. - # If this isn't guessed, you may want to complete the metadata above and send a PR :) - uInitrdCompression ? - _compressorMeta.ubootName - or (throw "Unrecognised compressor ${_compressorName}, please specify uInitrdCompression"), + # Deprecated; remove in 27.05. + makeUInitrd ? null, + uInitrdArch ? null, + uInitrdCompression ? null, }: +assert lib.assertMsg (makeUInitrd == null && uInitrdArch == null && uInitrdCompression == null) + "makeInitrdNg: U‐Boot legacy image support has been removed as it is deprecated upstream and ARMv5 kernels no longer default to uImage"; stdenvNoCC.mkDerivation (finalAttrs: { __structuredAttrs = true; @@ -83,11 +73,8 @@ stdenvNoCC.mkDerivation (finalAttrs: { inherit name extension - makeUInitrd - uInitrdArch prepend ; - ${if makeUInitrd then "uInitrdCompression" else null} = uInitrdCompression; compress = "${_compressorExecutable} ${lib.escapeShellArgs _compressorArgsReal}"; contentsJSON = builtins.toJSON contents; @@ -95,8 +82,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { nativeBuildInputs = [ makeInitrdNGTool cpio - ] - ++ lib.optional makeUInitrd ubootTools; + ]; buildCommand = '' mkdir -p ./root/{run,tmp,var/empty} @@ -109,13 +95,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { done (cd root && find . -print0 | sort -z | cpio --quiet -o -H newc -R +0:+0 --reproducible --null | eval -- $compress >> "$out/initrd") - if [ -n "$makeUInitrd" ]; then - mkimage -A "$uInitrdArch" -O linux -T ramdisk -C "$uInitrdCompression" -d "$out/initrd" $out/initrd.img - # Compatibility symlink - ln -sf "initrd.img" "$out/initrd" - else - ln -s "initrd" "$out/initrd$extension" - fi + ln -s "initrd" "$out/initrd$extension" ''; passthru = { diff --git a/pkgs/build-support/kernel/make-initrd.nix b/pkgs/build-support/kernel/make-initrd.nix index 364507ba8e7e..a6624486ffbe 100644 --- a/pkgs/build-support/kernel/make-initrd.nix +++ b/pkgs/build-support/kernel/make-initrd.nix @@ -10,18 +10,16 @@ # of algorithms. let # Some metadata on various compression programs, relevant to naming - # the initramfs file and, if applicable, generating a u-boot image - # from it. + # the initramfs file. compressors = import ./initrd-compressor-meta.nix; # Get the basename of the actual compression program from the whole - # compression command, for the purpose of guessing the u-boot + # compression command, for the purpose of guessing the # compression type and filename extension. compressorName = fullCommand: builtins.elemAt (builtins.match "([^ ]*/)?([^ ]+).*" fullCommand) 1; in { stdenvNoCC, cpio, - ubootTools, lib, pkgsBuildHost, # Name of the derivation (not of the resulting file!) @@ -65,22 +63,13 @@ in # symlinks to store paths. prepend ? [ ], - # Whether to wrap the initramfs in a u-boot image. - makeUInitrd ? stdenvNoCC.hostPlatform.linux-kernel.target or "dummy" == "uImage", - - # If generating a u-boot image, the architecture to use. The default - # guess may not align with u-boot's nomenclature correctly, so it can - # be overridden. - # See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L81-106 for a list. - uInitrdArch ? stdenvNoCC.hostPlatform.linuxArch, - - # The name of the compression, as recognised by u-boot. - # See https://gitlab.denx.de/u-boot/u-boot/-/blob/9bfb567e5f1bfe7de8eb41f8c6d00f49d2b9a426/common/image.c#L195-204 for a list. - # If this isn't guessed, you may want to complete the metadata above and send a PR :) - uInitrdCompression ? - _compressorMeta.ubootName - or (throw "Unrecognised compressor ${_compressorName}, please specify uInitrdCompression"), + # Deprecated; remove in 27.05. + makeUInitrd ? null, + uInitrdArch ? null, + uInitrdCompression ? null, }: +assert lib.assertMsg (makeUInitrd == null && uInitrdArch == null && uInitrdCompression == null) + "makeInitrd: U‐Boot legacy image support has been removed as it is deprecated upstream and ARMv5 kernels no longer default to uImage"; stdenvNoCC.mkDerivation (finalAttrs: { __structuredAttrs = true; @@ -91,18 +80,14 @@ stdenvNoCC.mkDerivation (finalAttrs: { inherit name extension - makeUInitrd - uInitrdArch prepend ; - ${if makeUInitrd then "uInitrdCompression" else null} = uInitrdCompression; builder = ./make-initrd.sh; nativeBuildInputs = [ cpio - ] - ++ lib.optional makeUInitrd ubootTools; + ]; compress = "${_compressorExecutable} ${lib.escapeShellArgs _compressorArgsReal}"; diff --git a/pkgs/build-support/kernel/make-initrd.sh b/pkgs/build-support/kernel/make-initrd.sh index f3bc977279b5..cc30ba0175db 100644 --- a/pkgs/build-support/kernel/make-initrd.sh +++ b/pkgs/build-support/kernel/make-initrd.sh @@ -36,10 +36,4 @@ done (cd root && find * .[^.*] -exec touch -h -d '@1' '{}' +) (cd root && find * .[^.*] -print0 | sort -z | cpio --quiet -o -H newc -R +0:+0 --reproducible --null | eval -- $compress >> "$out/initrd") -if [ -n "$makeUInitrd" ]; then - mkimage -A "$uInitrdArch" -O linux -T ramdisk -C "$uInitrdCompression" -d "$out/initrd" $out/initrd.img - # Compatibility symlink - ln -sf "initrd.img" "$out/initrd" -else - ln -s "initrd" "$out/initrd$extension" -fi +ln -s "initrd" "$out/initrd$extension" From efbf25d7576b1bb049499e0e5fa09ac120b2bae8 Mon Sep 17 00:00:00 2001 From: Emily Date: Wed, 25 Jun 2025 13:11:53 +0100 Subject: [PATCH 38/46] linux: drop uImage support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that ARMv5 no longer uses `uImage`, there’s no point in keeping around support for this deprecated format that the rest of the NixOS infrastructure doesn’t handle. Modern U‐Boot setups should not be affected in any way by this. --- doc/release-notes/rl-2611.section.md | 2 ++ pkgs/os-specific/linux/kernel/build.nix | 33 ++----------------------- 2 files changed, 4 insertions(+), 31 deletions(-) diff --git a/doc/release-notes/rl-2611.section.md b/doc/release-notes/rl-2611.section.md index 4b36a630421d..1df5e6b9a01c 100644 --- a/doc/release-notes/rl-2611.section.md +++ b/doc/release-notes/rl-2611.section.md @@ -23,6 +23,8 @@ `lib.systems.{examples,platforms}.{sheevaplug,pogoplug4}` have been unified into `lib.systems.examples.armv5tel-multiplatform`. Note that there is no official support for ARMv5 and it is not possible to build even a simple NixOS configuration out of the box. +- Support for the legacy U‐Boot image format has been removed from the Linux kernel builders, as it is deprecated upstream and no longer used by any platform in Nixpkgs. + - `requireFile` now sets `meta.license = lib.licenses.unfree` by default. Users of `requireFile`-based derivations that preserve this default will need to explicitly allow their evaluation as described in [](#sec-allow-unfree). - `librest` providing 0.7 ABI was removed. `librest_1_0` providing 1.0 ABI was renamed to `librest` and `librest_1_0` was kept as an alias. diff --git a/pkgs/os-specific/linux/kernel/build.nix b/pkgs/os-specific/linux/kernel/build.nix index 04cbc24e9c02..23a08f5beaa4 100644 --- a/pkgs/os-specific/linux/kernel/build.nix +++ b/pkgs/os-specific/linux/kernel/build.nix @@ -19,7 +19,6 @@ zlib, pahole, kmod, - ubootTools, fetchpatch, rustc-unwrapped, rust-bindgen-unwrapped, @@ -116,31 +115,6 @@ lib.makeOverridable ( ; }; - # Folding in `ubootTools` in the default nativeBuildInputs is problematic, as - # it makes updating U-Boot cumbersome, since it will go above the current - # threshold of rebuilds - # - # To prevent these needless rounds of staging for U-Boot builds, we can - # limit the inclusion of ubootTools to target platforms where uImage *may* - # be produced. - # - # This command lists those (kernel-named) platforms: - # .../linux $ grep -l uImage ./arch/*/Makefile | cut -d'/' -f3 | sort - # - # This is still a guesstimation, but since none of our cached platforms - # coincide in that list, this gives us "perfect" decoupling here. - linuxPlatformsUsingUImage = [ - "arc" - "arm" - "csky" - "mips" - "powerpc" - "sh" - "sparc" - "xtensa" - ]; - needsUbootTools = lib.elem stdenv.hostPlatform.linuxArch linuxPlatformsUsingUImage; - config = let attrName = attr: "CONFIG_" + attr; @@ -267,7 +241,6 @@ lib.makeOverridable ( kmod hexdump ] - ++ optional needsUbootTools ubootTools ++ optionals (lib.versionAtLeast version "5.2") [ cpio pahole @@ -536,12 +509,10 @@ lib.makeOverridable ( kernelAtLeast = lib.versionAtLeast baseVersion; }; - # Some image types need special install targets (e.g. uImage is installed with make uinstall on arm) + # Some image types need special install targets installTargets = [ (stdenv.hostPlatform.linux-kernel.installTarget or ( - if target == "uImage" && stdenv.hostPlatform.linuxArch == "arm" then - "uinstall" - else if + if (target == "zImage" || target == "Image.gz" || target == "vmlinuz.efi") && builtins.elem stdenv.hostPlatform.linuxArch [ "arm" From bda3d9ef3698485db769cba08eb6af1341cf6ab0 Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:05 +0200 Subject: [PATCH 39/46] linux_testing: 7.1-rc4 -> 7.1-rc6 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index fd9cf5dac790..ce7caad77c1c 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -1,7 +1,7 @@ { "testing": { - "version": "7.1-rc4", - "hash": "sha256:06z73f1vprl4adbdj01h3407p3f8bl8jsz91zx454c62k2jg3w40", + "version": "7.1-rc6", + "hash": "sha256:1fmbsjhdrkzim6vzqc40raikv1szfw28q0lbvap8a1g77an0qi58", "lts": false }, "6.1": { From 3e9caa186f62c8e1dc03b403004f888fc71d6b3c Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:20 +0200 Subject: [PATCH 40/46] linux_7_0: 7.0.10 -> 7.0.11 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index ce7caad77c1c..99ce785eed42 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -35,8 +35,8 @@ "lts": true }, "7.0": { - "version": "7.0.10", - "hash": "sha256:1p1j9s0b4qv9m0pm6vj477rqgyd1b0lsk0gy74cks3n2cbmpfj89", + "version": "7.0.11", + "hash": "sha256:012307ni1v555a1rgzsxsg99pj8fplrghvhw0jk3c4d0vmb86v75", "lts": false } } From 28254d87ca1a4f205a74ca48690daf940d723185 Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:27 +0200 Subject: [PATCH 41/46] linux_6_18: 6.18.33 -> 6.18.34 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 99ce785eed42..df241fb95ae1 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -30,8 +30,8 @@ "lts": true }, "6.18": { - "version": "6.18.33", - "hash": "sha256:10mp1ypsdz42jr26g1xxbw806mvpy0n35418fhsgxxlr4lqgy5kg", + "version": "6.18.34", + "hash": "sha256:0q6palsvwx0gnisjr658hlngfpvyzv0k5q4pvdk23122zcr4f334", "lts": true }, "7.0": { From 221fae9c48d2b98fc0cb02ef21cfe48a4238d68a Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:34 +0200 Subject: [PATCH 42/46] linux_6_12: 6.12.91 -> 6.12.92 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index df241fb95ae1..2f030c72634a 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -25,8 +25,8 @@ "lts": true }, "6.12": { - "version": "6.12.91", - "hash": "sha256:0sbrb612b653w64g5jkpbf68y0fka2sgnwblam41k7wz2sgapwhg", + "version": "6.12.92", + "hash": "sha256:0gly5wld3x8l0f3zk9pspsw1q2d7zbjbx4c2ndb49b1wvfvpdqqg", "lts": true }, "6.18": { From 10035889cf19e0264b390247844d497d716b5c56 Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:40 +0200 Subject: [PATCH 43/46] linux_6_6: 6.6.141 -> 6.6.142 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 2f030c72634a..4f037c04cfdc 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -20,8 +20,8 @@ "lts": true }, "6.6": { - "version": "6.6.141", - "hash": "sha256:1qbzxgqs7q9gyqfrf0j7p0pgjxnjj5mibamhm280mf9anqp6bhiv", + "version": "6.6.142", + "hash": "sha256:0w1bdzp9x1sqcr9xlk7dvylhs7kycghjabfgd3iv49ydfmx61xmj", "lts": true }, "6.12": { From 2a612a8cbf3bd600afea16628592ba3d52568d75 Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:46 +0200 Subject: [PATCH 44/46] linux_6_1: 6.1.174 -> 6.1.175 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 4f037c04cfdc..62ecc569be5f 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -5,8 +5,8 @@ "lts": false }, "6.1": { - "version": "6.1.174", - "hash": "sha256:0vp07x4v82qnmc1pifv3ynp2ab5mvlbfnpqvs5893bi3yrnk927d", + "version": "6.1.175", + "hash": "sha256:11fapr04y96p9ja6mfzm7bcd3zb4dzyw6qrh7c11bss9wjlq9s9p", "lts": true }, "5.15": { From 661833f61e0b987bb8900086a71b8411468de5a4 Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:51 +0200 Subject: [PATCH 45/46] linux_5_15: 5.15.208 -> 5.15.209 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 62ecc569be5f..eac04ed0a54b 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -10,8 +10,8 @@ "lts": true }, "5.15": { - "version": "5.15.208", - "hash": "sha256:0wmi50q8vgblhbh77d1a4sw4snymr6srqd22bxcjg9i7wcv70gdm", + "version": "5.15.209", + "hash": "sha256:1d0yhbpqlkr1znahky15dfavr6dzb3wb8c15k9qqvkf2xb3pfv9l", "lts": true }, "5.10": { From d07b0d9dacbe69dd061c3d738b60c58e9a9db9f4 Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Mon, 1 Jun 2026 18:25:56 +0200 Subject: [PATCH 46/46] linux_5_10: 5.10.257 -> 5.10.258 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index eac04ed0a54b..f2e87f79b99c 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -15,8 +15,8 @@ "lts": true }, "5.10": { - "version": "5.10.257", - "hash": "sha256:1lghcrxc1fqarvym03jrcda2a3labc887ci9yjqgbmv3nphzvc88", + "version": "5.10.258", + "hash": "sha256:1rdldzb3g33v6zvcmxafqpkjgqpp4n5qlxwb77wfd5jpzhgcnz4y", "lts": true }, "6.6": {