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.
This commit is contained in:
Thiago Kenji Okada
2026-02-23 21:17:06 +00:00
parent 9e118c795a
commit f41d52f5c0
5 changed files with 281 additions and 96 deletions
@@ -25,7 +25,7 @@ from .models import (
Profile,
Remote,
)
from .process import SSH_DEFAULT_OPTS, run_wrapper
from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper
from .utils import Args, dict_to_flags
FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"]
@@ -192,9 +192,7 @@ def copy_closure(
Also supports copying a closure from a remote to another remote."""
sshopts = os.getenv("NIX_SSHOPTS", "")
extra_env = {
"NIX_SSHOPTS": " ".join(filter(lambda x: x, [sshopts, *SSH_DEFAULT_OPTS]))
}
env = {"NIX_SSHOPTS": " ".join(filter(lambda x: x, [sshopts, *SSH_DEFAULT_OPTS]))}
def nix_copy_closure(host: Remote, to: bool) -> None:
run_wrapper(
@@ -205,7 +203,7 @@ def copy_closure(
host.host,
closure,
],
extra_env=extra_env,
env=env,
)
def nix_copy(to_host: Remote, from_host: Remote) -> None:
@@ -221,7 +219,7 @@ def copy_closure(
f"ssh://{to_host.host}",
closure,
],
extra_env=extra_env,
env=env,
)
match (to_host, from_host):
@@ -703,7 +701,11 @@ def switch_to_configuration(
run_wrapper(
[*cmd, path_to_config / "bin/switch-to-configuration", str(action)],
extra_env={"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0"},
env={
"LOCALE_ARCHIVE": PRESERVE_ENV,
"NIXOS_NO_CHECK": PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0",
},
remote=target_host,
sudo=sudo,
)
@@ -5,10 +5,10 @@ import os
import re
import shlex
import subprocess
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from ipaddress import AddressValueError, IPv6Address
from typing import Final, Self, TextIO, TypedDict, Unpack
from typing import Final, Self, TextIO, TypedDict, Unpack, override
from . import tmpdir
@@ -23,7 +23,30 @@ SSH_DEFAULT_OPTS: Final = [
"ControlPersist=60",
]
type Args = Sequence[str | bytes | os.PathLike[str] | os.PathLike[bytes]]
class _PreserveEnv:
__slots__ = ()
@override
def __repr__(self) -> str:
return "PRESERVE"
PRESERVE_ENV: Final = _PreserveEnv()
@dataclass(frozen=True)
class _RawShellArg:
value: str
@override
def __str__(self) -> str:
return self.value
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
type Args = Sequence[Arg]
type EnvValue = str | _PreserveEnv
@dataclass(frozen=True)
@@ -106,70 +129,77 @@ def run_wrapper(
args: Args,
*,
check: bool = True,
extra_env: dict[str, str] | None = None,
env: Mapping[str, EnvValue] | None = None,
remote: Remote | None = None,
sudo: bool = False,
**kwargs: Unpack[RunKwargs],
) -> subprocess.CompletedProcess[str]:
"Wrapper around `subprocess.run` that supports extra functionality."
env = None
process_input = None
run_args = args
run_args: list[Arg] = list(args)
final_args: list[Arg]
normalized_env = _normalize_env(env)
resolved_env = _resolve_env_local(normalized_env)
if remote:
if extra_env:
extra_env_args = [f"{env}={value}" for env, value in extra_env.items()]
args = ["env", *extra_env_args, *args]
# Apply env for the *remote command* (not for ssh itself)
remote_run_args: list[Arg | _RawShellArg] = []
remote_run_args.extend(run_args)
if normalized_env:
remote_run_args = _prefix_env_cmd_remote(run_args, normalized_env)
if sudo:
if remote.sudo_password:
args = ["sudo", "--prompt=", "--stdin", *args]
remote_run_args = ["sudo", "--prompt=", "--stdin", *remote_run_args]
process_input = remote.sudo_password + "\n"
else:
args = ["sudo", *args]
run_args = [
remote_run_args = ["sudo", *remote_run_args]
ssh_args: list[Arg] = [
"ssh",
*remote.opts,
*SSH_DEFAULT_OPTS,
remote.ssh_host(),
"--",
# SSH will join the parameters here and pass it to the shell, so we
# need to quote it to avoid issues.
# We can't use `shlex.join`, otherwise we will hit MAX_ARG_STRLEN
# limits when the command becomes too big.
*[shlex.quote(str(a)) for a in args],
*[_quote_remote_arg(a) for a in remote_run_args],
]
final_args = ssh_args
popen_env = None # keep ssh's environment normal
else:
if extra_env:
env = os.environ | extra_env
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`.
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", ""))
# Using --preserve-env is less than ideal since it will cause
# the following warn during usage:
# > warning: $HOME ('/home/<user>') is not owned by you,
# > falling back to the one defined in the 'passwd' file ('/root')
# However, right now it is the only way to guarantee the semantics
# expected for the commands, e.g. activation with systemd-run
# expects access to environment variables like LOCALE_ARCHIVE,
# NIXOS_NO_CHECK.
# For now, for anyone that the above warn bothers you, please
# use `sudo nixos-rebuild` instead of `--sudo` flag.
run_args = ["sudo", "--preserve-env", *sudo_args, *run_args]
final_args = ["sudo", *sudo_args, *run_args]
# No need to pass env to subprocess.run; keep sudo's own env
# default.
popen_env = None
else:
# Non-sudo 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
logger.debug(
"calling run with args=%r, kwargs=%r, extra_env=%r",
run_args,
"calling run with args=%r, kwargs=%r, env=%r",
_sanitize_env_run_args(remote_run_args if remote else run_args),
kwargs,
extra_env,
env,
)
try:
r = subprocess.run(
run_args,
final_args,
check=check,
env=env,
env=popen_env,
input=process_input,
# Hope nobody is using NixOS with non-UTF8 encodings, but
# "surrogateescape" should still work in those systems.
text=True,
errors="surrogateescape",
**kwargs,
@@ -195,13 +225,95 @@ def run_wrapper(
raise
# SSH does not send the signals to the process when running without usage of
# pseudo-TTY (that causes a whole other can of worms), so if the process is
# long running (e.g.: a build) this will result in the underlying process
# staying alive.
# See: https://stackoverflow.com/a/44354466
# Issue: https://github.com/NixOS/nixpkgs/issues/403269
def _resolve_env(env: Mapping[str, EnvValue] | None) -> dict[str, str]:
normalized = _normalize_env(env)
return _resolve_env_local(normalized)
def _normalize_env(env: Mapping[str, EnvValue] | None) -> dict[str, EnvValue]:
"""
Normalize env mapping, but preserve some environment variables by default.
"""
return {"PATH": PRESERVE_ENV, **(env or {})}
def _resolve_env_local(env: dict[str, EnvValue]) -> dict[str, str]:
"""
Resolve env mapping where values can be:
- PRESERVE_ENV: copy from current os.environ (if present)
- str: explicit value
"""
result: dict[str, str] = {}
for k, v in env.items():
if isinstance(v, _PreserveEnv):
cur = os.environ.get(k)
if cur is not None:
result[k] = cur
else:
result[k] = v
return result
def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Arg]:
"""
Prefix a command with `env -i K=V ... -- <cmd...>` to set vars for the
command.
"""
if not resolved_env:
return list(cmd)
assigns = [f"{k}={v}" for k, v in resolved_env.items()]
return ["env", "-i", *assigns, *cmd]
def _prefix_env_cmd_remote(
cmd: Args,
env: dict[str, EnvValue],
) -> list[Arg | _RawShellArg]:
"""
Prefix remote commands with env assignments. Preserve markers are expanded
by the remote shell at execution time.
"""
assigns: list[str | _RawShellArg] = []
for k, v in env.items():
if v is PRESERVE_ENV:
assigns.append(_RawShellArg(f"{k}=${{{k}-}}"))
else:
assigns.append(f"{k}={v}")
return ["env", "-i", *assigns, *cmd]
def _quote_remote_arg(arg: Arg | _RawShellArg) -> str:
if isinstance(arg, _RawShellArg):
return str(arg)
return shlex.quote(str(arg))
def _sanitize_env_run_args(run_args: list[Arg] | list[Arg | _RawShellArg]) -> list[Arg]:
"""
Sanitize long or sensitive environment variables from logs.
"""
sanitized: list[Arg] = []
for value in run_args:
if isinstance(value, str) and value.startswith("PATH="):
sanitized.append("PATH=<PATH>")
elif isinstance(value, str | bytes | os.PathLike):
sanitized.append(value)
else:
sanitized.append(str(value))
return sanitized
def _kill_long_running_ssh_process(args: Args, remote: Remote) -> None:
"""
SSH does not send the signals to the process when running without usage of
pseudo-TTY (that causes a whole other can of worms), so if the process is
long running (e.g.: a build) this will result in the underlying process
staying alive.
See: https://stackoverflow.com/a/44354466
Issue: https://github.com/NixOS/nixpkgs/issues/403269
"""
logger.info("cleaning-up remote process, please wait...")
# We need to escape both the shell and regex here (since pkill interprets
@@ -248,7 +248,6 @@ def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None:
| {
"env": {
"NIXOS_INSTALL_BOOTLOADER": "0",
"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1",
}
}
),
@@ -487,7 +486,6 @@ def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None:
call(
[
"sudo",
"--preserve-env",
"nix-env",
"-p",
Path("/nix/var/nix/profiles/system"),
@@ -505,21 +503,15 @@ def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None:
call(
[
"sudo",
"--preserve-env",
"env",
"-i",
"NIXOS_INSTALL_BOOTLOADER=1",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
config_path / "bin/switch-to-configuration",
"switch",
],
check=True,
**(
DEFAULT_RUN_KWARGS
| {
"env": {
"NIXOS_INSTALL_BOOTLOADER": "1",
"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1",
}
}
),
**DEFAULT_RUN_KWARGS,
),
]
)
@@ -625,6 +617,9 @@ def test_execute_nix_switch_build_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@build-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"mktemp",
"-d",
"-t",
@@ -640,6 +635,9 @@ def test_execute_nix_switch_build_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@build-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"nix-store",
"--realise",
str(config_path),
@@ -656,6 +654,9 @@ def test_execute_nix_switch_build_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@build-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"readlink",
"-f",
"/tmp/tmpdir/config",
@@ -670,6 +671,9 @@ def test_execute_nix_switch_build_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@build-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"rm",
"-rf",
"/tmp/tmpdir",
@@ -699,6 +703,9 @@ def test_execute_nix_switch_build_target_host(
"user@target-host",
"--",
"sudo",
"env",
"-i",
"PATH=${PATH-}",
"nix-env",
"-p",
"/nix/var/nix/profiles/system",
@@ -714,6 +721,9 @@ def test_execute_nix_switch_build_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@target-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"test",
"-d",
"/run/systemd/system",
@@ -729,6 +739,10 @@ def test_execute_nix_switch_build_target_host(
"--",
"sudo",
"env",
"-i",
"PATH=${PATH-}",
"LOCALE_ARCHIVE=${LOCALE_ARCHIVE-}",
"NIXOS_NO_CHECK=${NIXOS_NO_CHECK-}",
"NIXOS_INSTALL_BOOTLOADER=0",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
@@ -806,6 +820,9 @@ def test_execute_nix_switch_flake_target_host(
"user@localhost",
"--",
"sudo",
"env",
"-i",
"PATH=${PATH-}",
"nix-env",
"-p",
"/nix/var/nix/profiles/system",
@@ -821,6 +838,9 @@ def test_execute_nix_switch_flake_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@localhost",
"--",
"env",
"-i",
"PATH=${PATH-}",
"test",
"-d",
"/run/systemd/system",
@@ -836,6 +856,10 @@ def test_execute_nix_switch_flake_target_host(
"--",
"sudo",
"env",
"-i",
"PATH=${PATH-}",
"LOCALE_ARCHIVE=${LOCALE_ARCHIVE-}",
"NIXOS_NO_CHECK=${NIXOS_NO_CHECK-}",
"NIXOS_INSTALL_BOOTLOADER=0",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
@@ -912,6 +936,9 @@ def test_execute_nix_switch_flake_build_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@localhost",
"--",
"env",
"-i",
"PATH=${PATH-}",
"nix",
"--extra-experimental-features",
"'nix-command flakes'",
@@ -1121,6 +1148,9 @@ def test_execute_build_dry_run_build_and_target_remote(
*nr.process.SSH_DEFAULT_OPTS,
"user@build-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"nix",
"--extra-experimental-features",
"'nix-command flakes'",
@@ -1304,7 +1334,6 @@ def test_execute_switch_store_path(mock_run: Mock, tmp_path: Path) -> None:
| {
"env": {
"NIXOS_INSTALL_BOOTLOADER": "0",
"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1",
}
}
),
@@ -1354,6 +1383,9 @@ def test_execute_switch_store_path_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@remote-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"test",
"-f",
str(config_path / "nixos-version"),
@@ -1368,6 +1400,9 @@ def test_execute_switch_store_path_target_host(
"user@remote-host",
"--",
"sudo",
"env",
"-i",
"PATH=${PATH-}",
"nix-env",
"-p",
"/nix/var/nix/profiles/system",
@@ -1383,6 +1418,9 @@ def test_execute_switch_store_path_target_host(
*nr.process.SSH_DEFAULT_OPTS,
"user@remote-host",
"--",
"env",
"-i",
"PATH=${PATH-}",
"test",
"-d",
"/run/systemd/system",
@@ -1398,6 +1436,10 @@ def test_execute_switch_store_path_target_host(
"--",
"sudo",
"env",
"-i",
"PATH=${PATH-}",
"LOCALE_ARCHIVE=${LOCALE_ARCHIVE-}",
"NIXOS_NO_CHECK=${NIXOS_NO_CHECK-}",
"NIXOS_INSTALL_BOOTLOADER=0",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
@@ -134,9 +134,7 @@ def test_build_remote(
"user@host",
Path("/path/to/file"),
],
extra_env={
"NIX_SSHOPTS": " ".join(["--ssh opts", *p.SSH_DEFAULT_OPTS])
},
env={"NIX_SSHOPTS": " ".join(["--ssh opts", *p.SSH_DEFAULT_OPTS])},
),
call(
["mktemp", "-d", "-t", "nixos-rebuild.XXXXX"],
@@ -208,9 +206,7 @@ def test_build_remote_flake(
"user@host",
Path("/path/to/file"),
],
extra_env={
"NIX_SSHOPTS": " ".join(["--ssh opts", *p.SSH_DEFAULT_OPTS])
},
env={"NIX_SSHOPTS": " ".join(["--ssh opts", *p.SSH_DEFAULT_OPTS])},
),
call(
[
@@ -241,7 +237,7 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None:
n.copy_closure(closure, target_host)
mock_run.assert_called_with(
["nix-copy-closure", "--to", "user@target.host", closure],
extra_env={"NIX_SSHOPTS": " ".join(p.SSH_DEFAULT_OPTS)},
env={"NIX_SSHOPTS": " ".join(p.SSH_DEFAULT_OPTS)},
)
monkeypatch.setenv("NIX_SSHOPTS", "--ssh build-opt")
@@ -249,15 +245,11 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None:
n.copy_closure(closure, None, build_host, {"copy_flag": True})
mock_run.assert_called_with(
["nix-copy-closure", "--copy-flag", "--from", "user@build.host", closure],
extra_env={
"NIX_SSHOPTS": " ".join(["--ssh build-opt", *p.SSH_DEFAULT_OPTS])
},
env={"NIX_SSHOPTS": " ".join(["--ssh build-opt", *p.SSH_DEFAULT_OPTS])},
)
monkeypatch.setenv("NIX_SSHOPTS", "--ssh build-target-opt")
extra_env = {
"NIX_SSHOPTS": " ".join(["--ssh build-target-opt", *p.SSH_DEFAULT_OPTS])
}
env = {"NIX_SSHOPTS": " ".join(["--ssh build-target-opt", *p.SSH_DEFAULT_OPTS])}
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
n.copy_closure(closure, target_host, build_host, {"copy_flag": True})
mock_run.assert_called_with(
@@ -273,7 +265,7 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None:
"ssh://user@target.host",
closure,
],
extra_env=extra_env,
env=env,
)
@@ -723,7 +715,11 @@ def test_switch_to_configuration_without_systemd_run(
)
mock_run.assert_called_with(
[profile_path / "bin/switch-to-configuration", "switch"],
extra_env={"NIXOS_INSTALL_BOOTLOADER": "0"},
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
sudo=False,
remote=None,
)
@@ -760,7 +756,11 @@ def test_switch_to_configuration_without_systemd_run(
config_path / "specialisation/special/bin/switch-to-configuration",
"test",
],
extra_env={"NIXOS_INSTALL_BOOTLOADER": "1"},
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
sudo=True,
remote=target_host,
)
@@ -791,7 +791,11 @@ def test_switch_to_configuration_with_systemd_run(
profile_path / "bin/switch-to-configuration",
"switch",
],
extra_env={"NIXOS_INSTALL_BOOTLOADER": "0"},
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
sudo=False,
remote=None,
)
@@ -816,7 +820,11 @@ def test_switch_to_configuration_with_systemd_run(
config_path / "specialisation/special/bin/switch-to-configuration",
"test",
],
extra_env={"NIXOS_INSTALL_BOOTLOADER": "1"},
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
sudo=True,
remote=target_host,
)
@@ -7,6 +7,7 @@ import nixos_rebuild.models as m
import nixos_rebuild.process as p
@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True)
@patch("subprocess.run", autospec=True)
def test_run(mock_run: Any) -> None:
p.run_wrapper(["test", "--with", "flags"], check=True)
@@ -19,22 +20,27 @@ def test_run(mock_run: Any) -> None:
input=None,
)
with patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True):
p.run_wrapper(
["test", "--with", "flags"],
check=False,
sudo=True,
extra_env={"FOO": "bar"},
)
p.run_wrapper(
["test", "--with", "flags"],
check=False,
sudo=True,
env={"FOO": "bar"},
)
mock_run.assert_called_with(
["sudo", "--preserve-env", "test", "--with", "flags"],
[
"sudo",
"env",
"-i",
"PATH=/path/to/bin",
"FOO=bar",
"test",
"--with",
"flags",
],
check=False,
text=True,
errors="surrogateescape",
env={
"PATH": "/path/to/bin",
"FOO": "bar",
},
env=None,
input=None,
)
@@ -51,6 +57,9 @@ def test_run(mock_run: Any) -> None:
*p.SSH_DEFAULT_OPTS,
"user@localhost",
"--",
"env",
"-i",
"PATH=${PATH-}",
"test",
"--with",
"'some flags'",
@@ -66,7 +75,7 @@ def test_run(mock_run: Any) -> None:
["test", "--with", "flags"],
check=True,
sudo=True,
extra_env={"FOO": "bar"},
env={"FOO": "bar"},
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password"),
)
mock_run.assert_called_with(
@@ -81,6 +90,8 @@ def test_run(mock_run: Any) -> None:
"--prompt=",
"--stdin",
"env",
"-i",
"PATH=${PATH-}",
"FOO=bar",
"test",
"--with",
@@ -164,14 +175,24 @@ def test_ssh_host() -> None:
@patch("subprocess.run", autospec=True)
def test_custom_sudo_args(mock_run: Any) -> None:
with patch.dict(p.os.environ, {"NIX_SUDOOPTS": "--custom foo --args"}):
with patch.dict(
p.os.environ,
{"NIX_SUDOOPTS": "--custom foo --args", "PATH": "/path/to/bin"},
clear=True,
):
p.run_wrapper(
["test"],
check=False,
sudo=True,
)
mock_run.assert_called_with(
["sudo", "--preserve-env", "--custom", "foo", "--args", "test"],
[
"sudo",
"--custom",
"foo",
"--args",
"test",
],
check=False,
env=None,
input=None,