55import re
66import shlex
77import subprocess
8- from collections .abc import Sequence
8+ from collections .abc import Mapping , Sequence
99from dataclasses import dataclass
1010from ipaddress import AddressValueError , IPv6Address
11- from typing import Final , Self , TextIO , TypedDict , Unpack
11+ from typing import Final , Self , TextIO , TypedDict , Unpack , override
1212
1313from . import tmpdir
1414
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+
204308def _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