From 0dce56f3f52ee767c6d7aa816675d33c1c12a630 Mon Sep 17 00:00:00 2001 From: Thiago Kenji Okada Date: Thu, 19 Jun 2025 20:29:23 +0100 Subject: [PATCH] nixos-rebuild-ng: validate NixOS configuration path When `path://` or `git+file://` protocol is used in Flake mode (that is the most common case since we normalize the paths, see PR #375493) and the current working directory in a symlink pointing base store path to the Nix store (e.g., /run/opengl-driver/lib), there is a nasty bug where Nix resolves the path as the Nix store path of the current derivation instead of the target derivation. Since we blindly activate this path, this can corrupt the installation and break some other activation scripts, like `systemd-boot-builder.py`. While it is possible to recover this situation using `nix-env -p /nix/var/nix/profiles/system --delete-generations old`, this is far from ideal. This commit solves it by validating that the resolved NixOS configuration path includes at least `$out/nixos-version`. I am not sure if this is going to break some cases so there is a escape hatch in the form of the environment variable `NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM`, but in general it looks safe. --- .../src/nixos_rebuild/__init__.py | 26 ++++++++++ .../nixos-rebuild-ng/src/tests/test_main.py | 50 ++++++++++++++++--- 2 files changed, 69 insertions(+), 7 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 838b8913abb3..fb68b219c1f9 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 @@ -5,6 +5,7 @@ import os import sys from pathlib import Path from subprocess import CalledProcessError, run +from textwrap import dedent from typing import Final, assert_never from . import nix, tmpdir @@ -329,6 +330,30 @@ def reexec( os.execve(current, argv, os.environ | {"_NIXOS_REBUILD_REEXEC": "1"}) +def validate_nixos_config(path_to_config: Path) -> None: + if not (path_to_config / "nixos-version").exists() and not os.environ.get( + "NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM" + ): + msg = dedent( + # the lowercase for the first letter below is proposital + f""" + your NixOS configuration path seems to be missing essential files. + To avoid corrupting your current NixOS installation, the activation will abort. + + This could be caused by Nix bug: https://github.com/NixOS/nix/issues/13367. + This is the evaluated NixOS configuration path: {path_to_config}. + Change the directory to somewhere else (e.g., `cd $HOME`) before trying again. + + If you think this is a mistake, you can set the environment variable + NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM to 1 + and re-run the command to continue. + Please open an issue if this is the case. + """ + ).strip() + logger.error(msg) + sys.exit(1) + + def execute(argv: list[str]) -> None: args, args_groups = parse_args(argv) @@ -488,6 +513,7 @@ def execute(argv: list[str]) -> None: copy_flags=copy_flags, ) if action in (Action.SWITCH, Action.BOOT): + validate_nixos_config(path_to_config) nix.set_profile( profile, path_to_config, 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 a66d70498c5f..345c8bd32f79 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 @@ -213,7 +213,11 @@ def test_reexec_flake( ) -@patch.dict(os.environ, {}, clear=True) +@patch.dict( + os.environ, + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, + clear=True, +) @patch("subprocess.run", autospec=True) def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None: nixpkgs_path = tmp_path / "nixpkgs" @@ -291,7 +295,15 @@ def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None: "boot", ], check=True, - **(DEFAULT_RUN_KWARGS | {"env": {"NIXOS_INSTALL_BOOTLOADER": "0"}}), + **( + DEFAULT_RUN_KWARGS + | { + "env": { + "NIXOS_INSTALL_BOOTLOADER": "0", + "NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1", + } + } + ), ), ] ) @@ -421,7 +433,11 @@ def test_execute_nix_build_image_flake(mock_run: Mock, tmp_path: Path) -> None: ) -@patch.dict(os.environ, {}, clear=True) +@patch.dict( + os.environ, + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, + clear=True, +) @patch("subprocess.run", autospec=True) def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None: config_path = tmp_path / "test" @@ -498,13 +514,25 @@ def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None: "switch", ], check=True, - **(DEFAULT_RUN_KWARGS | {"env": {"NIXOS_INSTALL_BOOTLOADER": "1"}}), + **( + DEFAULT_RUN_KWARGS + | { + "env": { + "NIXOS_INSTALL_BOOTLOADER": "1", + "NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1", + } + } + ), ), ] ) -@patch.dict(os.environ, {}, clear=True) +@patch.dict( + os.environ, + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, + clear=True, +) @patch("subprocess.run", autospec=True) @patch("uuid.uuid4", autospec=True) @patch(get_qualified_name(nr.cleanup_ssh), autospec=True) @@ -714,7 +742,11 @@ def test_execute_nix_switch_build_target_host( ) -@patch.dict(os.environ, {}, clear=True) +@patch.dict( + os.environ, + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, + clear=True, +) @patch("subprocess.run", autospec=True) @patch(get_qualified_name(nr.cleanup_ssh), autospec=True) def test_execute_nix_switch_flake_target_host( @@ -817,7 +849,11 @@ def test_execute_nix_switch_flake_target_host( ) -@patch.dict(os.environ, {}, clear=True) +@patch.dict( + os.environ, + {"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"}, + clear=True, +) @patch("subprocess.run", autospec=True) @patch(get_qualified_name(nr.cleanup_ssh), autospec=True) def test_execute_nix_switch_flake_build_host(