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.
This commit is contained in:
Thiago Kenji Okada
2025-06-19 23:51:48 +01:00
parent 2576cf9ca3
commit 0dce56f3f5
2 changed files with 69 additions and 7 deletions
@@ -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,
@@ -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(