Skip to content

Commit ad30ddd

Browse files
committed
nixos-rebuild-ng: rework env handling in process.run_wrapper
Replace `extra_env` with a typed `env` mapping and introduce `PRESERVE_ENV` for variables that must be inherited from the caller. For remote execution, pass environment through `env -i` on the remote side and expand preserved values there (e.g. `${PATH-}`), so SSH's own environment is not polluted. For local sudo execution, stop using `--preserve-env` and only inject `env -i ...` when explicit `env` values are requested; otherwise keep `subprocess.run(env=None)` to preserve default behavior. For local execution without sudo we only replace the environment variables when `env != None`. Fix #491850.
1 parent c6cfceb commit ad30ddd

File tree

5 files changed

+281
-96
lines changed

5 files changed

+281
-96
lines changed

pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
Profile,
2626
Remote,
2727
)
28-
from .process import SSH_DEFAULT_OPTS, run_wrapper
28+
from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper
2929
from .utils import Args, dict_to_flags
3030

3131
FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"]
@@ -192,9 +192,7 @@ def copy_closure(
192192
Also supports copying a closure from a remote to another remote."""
193193

194194
sshopts = os.getenv("NIX_SSHOPTS", "")
195-
extra_env = {
196-
"NIX_SSHOPTS": " ".join(filter(lambda x: x, [sshopts, *SSH_DEFAULT_OPTS]))
197-
}
195+
env = {"NIX_SSHOPTS": " ".join(filter(lambda x: x, [sshopts, *SSH_DEFAULT_OPTS]))}
198196

199197
def nix_copy_closure(host: Remote, to: bool) -> None:
200198
run_wrapper(
@@ -205,7 +203,7 @@ def nix_copy_closure(host: Remote, to: bool) -> None:
205203
host.host,
206204
closure,
207205
],
208-
extra_env=extra_env,
206+
env=env,
209207
)
210208

211209
def nix_copy(to_host: Remote, from_host: Remote) -> None:
@@ -221,7 +219,7 @@ def nix_copy(to_host: Remote, from_host: Remote) -> None:
221219
f"ssh://{to_host.host}",
222220
closure,
223221
],
224-
extra_env=extra_env,
222+
env=env,
225223
)
226224

227225
match (to_host, from_host):
@@ -703,7 +701,11 @@ def switch_to_configuration(
703701

704702
run_wrapper(
705703
[*cmd, path_to_config / "bin/switch-to-configuration", str(action)],
706-
extra_env={"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0"},
704+
env={
705+
"LOCALE_ARCHIVE": PRESERVE_ENV,
706+
"NIXOS_NO_CHECK": PRESERVE_ENV,
707+
"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0",
708+
},
707709
remote=target_host,
708710
sudo=sudo,
709711
)

pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py

Lines changed: 155 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
import re
66
import shlex
77
import subprocess
8-
from collections.abc import Sequence
8+
from collections.abc import Mapping, Sequence
99
from dataclasses import dataclass
1010
from ipaddress import AddressValueError, IPv6Address
11-
from typing import Final, Self, TextIO, TypedDict, Unpack
11+
from typing import Final, Self, TextIO, TypedDict, Unpack, override
1212

1313
from . import tmpdir
1414

@@ -23,7 +23,26 @@
2323
"ControlPersist=60",
2424
]
2525

26-
type Args = Sequence[str | bytes | os.PathLike[str] | os.PathLike[bytes]]
26+
27+
class _PreserveEnv:
28+
__slots__ = ()
29+
30+
@override
31+
def __repr__(self) -> str:
32+
return "PRESERVE"
33+
34+
35+
PRESERVE_ENV: Final = _PreserveEnv()
36+
37+
38+
@dataclass(frozen=True)
39+
class _RawShellArg:
40+
value: str
41+
42+
43+
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
44+
type Args = Sequence[Arg]
45+
type EnvValue = str | _PreserveEnv
2746

2847

2948
@dataclass(frozen=True)
@@ -106,70 +125,77 @@ def run_wrapper(
106125
args: Args,
107126
*,
108127
check: bool = True,
109-
extra_env: dict[str, str] | None = None,
128+
env: Mapping[str, EnvValue] | None = None,
110129
remote: Remote | None = None,
111130
sudo: bool = False,
112131
**kwargs: Unpack[RunKwargs],
113132
) -> subprocess.CompletedProcess[str]:
114133
"Wrapper around `subprocess.run` that supports extra functionality."
115-
env = None
116134
process_input = None
117-
run_args = args
135+
run_args: list[Arg] = list(args)
136+
final_args: list[Arg]
137+
138+
normalized_env = _normalize_env(env)
139+
resolved_env = _resolve_env_local(normalized_env)
118140

119141
if remote:
120-
if extra_env:
121-
extra_env_args = [f"{env}={value}" for env, value in extra_env.items()]
122-
args = ["env", *extra_env_args, *args]
142+
# Apply env for the *remote command* (not for ssh itself)
143+
remote_run_args: list[Arg | _RawShellArg] = []
144+
remote_run_args.extend(run_args)
145+
if normalized_env:
146+
remote_run_args = _prefix_env_cmd_remote(run_args, normalized_env)
147+
123148
if sudo:
124149
if remote.sudo_password:
125-
args = ["sudo", "--prompt=", "--stdin", *args]
150+
remote_run_args = ["sudo", "--prompt=", "--stdin", *remote_run_args]
126151
process_input = remote.sudo_password + "\n"
127152
else:
128-
args = ["sudo", *args]
129-
run_args = [
153+
remote_run_args = ["sudo", *remote_run_args]
154+
155+
ssh_args: list[Arg] = [
130156
"ssh",
131157
*remote.opts,
132158
*SSH_DEFAULT_OPTS,
133159
remote.ssh_host(),
134160
"--",
135-
# SSH will join the parameters here and pass it to the shell, so we
136-
# need to quote it to avoid issues.
137-
# We can't use `shlex.join`, otherwise we will hit MAX_ARG_STRLEN
138-
# limits when the command becomes too big.
139-
*[shlex.quote(str(a)) for a in args],
161+
*[_quote_remote_arg(a) for a in remote_run_args],
140162
]
163+
final_args = ssh_args
164+
popen_env = None # keep ssh's environment normal
165+
141166
else:
142-
if extra_env:
143-
env = os.environ | extra_env
144167
if sudo:
168+
# subprocess.run(env=...) would affect sudo, but sudo may drop env
169+
# for the target command.
170+
# So we inject env via `sudo env ... cmd`.
171+
if env is not None and resolved_env:
172+
run_args = _prefix_env_cmd(run_args, resolved_env)
173+
145174
sudo_args = shlex.split(os.getenv("NIX_SUDOOPTS", ""))
146-
# Using --preserve-env is less than ideal since it will cause
147-
# the following warn during usage:
148-
# > warning: $HOME ('/home/<user>') is not owned by you,
149-
# > falling back to the one defined in the 'passwd' file ('/root')
150-
# However, right now it is the only way to guarantee the semantics
151-
# expected for the commands, e.g. activation with systemd-run
152-
# expects access to environment variables like LOCALE_ARCHIVE,
153-
# NIXOS_NO_CHECK.
154-
# For now, for anyone that the above warn bothers you, please
155-
# use `sudo nixos-rebuild` instead of `--sudo` flag.
156-
run_args = ["sudo", "--preserve-env", *sudo_args, *run_args]
175+
final_args = ["sudo", *sudo_args, *run_args]
176+
177+
# No need to pass env to subprocess.run; keep sudo's own env
178+
# default.
179+
popen_env = None
180+
else:
181+
# Non-sudo local: we can fully control the environment with
182+
# subprocess.run(env=...)
183+
final_args = run_args
184+
popen_env = None if env is None else resolved_env
157185

158186
logger.debug(
159-
"calling run with args=%r, kwargs=%r, extra_env=%r",
160-
run_args,
187+
"calling run with args=%r, kwargs=%r, env=%r",
188+
_sanitize_env_run_args(remote_run_args if remote else run_args),
161189
kwargs,
162-
extra_env,
190+
env,
163191
)
164192

165193
try:
166194
r = subprocess.run(
167-
run_args,
195+
final_args,
168196
check=check,
169-
env=env,
197+
env=popen_env,
170198
input=process_input,
171-
# Hope nobody is using NixOS with non-UTF8 encodings, but
172-
# "surrogateescape" should still work in those systems.
173199
text=True,
174200
errors="surrogateescape",
175201
**kwargs,
@@ -195,13 +221,99 @@ def run_wrapper(
195221
raise
196222

197223

198-
# SSH does not send the signals to the process when running without usage of
199-
# pseudo-TTY (that causes a whole other can of worms), so if the process is
200-
# long running (e.g.: a build) this will result in the underlying process
201-
# staying alive.
202-
# See: https://stackoverflow.com/a/44354466
203-
# Issue: https://github.com/NixOS/nixpkgs/issues/403269
224+
def _resolve_env(env: Mapping[str, EnvValue] | None) -> dict[str, str]:
225+
normalized = _normalize_env(env)
226+
return _resolve_env_local(normalized)
227+
228+
229+
def _normalize_env(env: Mapping[str, EnvValue] | None) -> dict[str, EnvValue]:
230+
"""
231+
Normalize env mapping, but preserve some environment variables by default.
232+
"""
233+
return {"PATH": PRESERVE_ENV, **(env or {})}
234+
235+
236+
def _resolve_env_local(env: dict[str, EnvValue]) -> dict[str, str]:
237+
"""
238+
Resolve env mapping where values can be:
239+
- PRESERVE_ENV: copy from current os.environ (if present)
240+
- str: explicit value
241+
"""
242+
result: dict[str, str] = {}
243+
244+
for k, v in env.items():
245+
if isinstance(v, _PreserveEnv):
246+
cur = os.environ.get(k)
247+
if cur is not None:
248+
result[k] = cur
249+
else:
250+
result[k] = v
251+
return result
252+
253+
254+
def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Arg]:
255+
"""
256+
Prefix a command with `env -i K=V ... -- <cmd...>` to set vars for the
257+
command.
258+
"""
259+
if not resolved_env:
260+
return list(cmd)
261+
262+
assigns = [f"{k}={v}" for k, v in resolved_env.items()]
263+
return ["env", "-i", *assigns, *cmd]
264+
265+
266+
def _prefix_env_cmd_remote(
267+
cmd: Args,
268+
env: dict[str, EnvValue],
269+
) -> list[Arg | _RawShellArg]:
270+
"""
271+
Prefix remote commands with env assignments. Preserve markers are expanded
272+
by the remote shell at execution time.
273+
"""
274+
assigns: list[str | _RawShellArg] = []
275+
for k, v in env.items():
276+
if v is PRESERVE_ENV:
277+
assigns.append(_RawShellArg(f"{k}=${{{k}-}}"))
278+
else:
279+
assigns.append(f"{k}={v}")
280+
return ["env", "-i", *assigns, *cmd]
281+
282+
283+
def _quote_remote_arg(arg: Arg | _RawShellArg) -> str:
284+
if isinstance(arg, _RawShellArg):
285+
return arg.value
286+
return shlex.quote(str(arg))
287+
288+
289+
def _sanitize_env_run_args(run_args: Sequence[object]) -> list[Arg]:
290+
"""
291+
Sanitize long or sensitive environment variables from logs.
292+
"""
293+
sanitized: list[str | bytes | os.PathLike[str] | os.PathLike[bytes]] = []
294+
for a in run_args:
295+
value = a.value if isinstance(a, _RawShellArg) else a
296+
if isinstance(value, str) and value.startswith("PATH"):
297+
sanitized.append("PATH=<PATH>")
298+
elif isinstance(
299+
value,
300+
str | bytes | os.PathLike,
301+
):
302+
sanitized.append(value)
303+
else:
304+
sanitized.append(str(value))
305+
return sanitized
306+
307+
204308
def _kill_long_running_ssh_process(args: Args, remote: Remote) -> None:
309+
"""
310+
SSH does not send the signals to the process when running without usage of
311+
pseudo-TTY (that causes a whole other can of worms), so if the process is
312+
long running (e.g.: a build) this will result in the underlying process
313+
staying alive.
314+
See: https://stackoverflow.com/a/44354466
315+
Issue: https://github.com/NixOS/nixpkgs/issues/403269
316+
"""
205317
logger.info("cleaning-up remote process, please wait...")
206318

207319
# We need to escape both the shell and regex here (since pkill interprets

0 commit comments

Comments
 (0)