Skip to content

Commit d207c0f

Browse files
authored
feat(connectors): allow HiddenValue in _env (#1853)
1 parent 7539d6c commit d207c0f

4 files changed

Lines changed: 51 additions & 7 deletions

File tree

src/pyinfra/api/arguments.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616
from pyinfra.api.exceptions import ArgumentTypeError
1717
from pyinfra.api.util import raise_if_bad_type
1818
from pyinfra.context import ctx_config
19+
from pyinfra.api.hiddenvalue import HiddenValue
1920

2021
if TYPE_CHECKING:
2122
from pyinfra.api import Config, Host, State
2223

2324
T = TypeVar("T")
2425
default_sentinel = object()
2526

27+
EnvValue = str | HiddenValue
28+
2629

2730
class ArgumentMeta(Generic[T]):
2831
description: str
@@ -62,7 +65,7 @@ class ConnectorArguments(TypedDict, total=False):
6265
# Shell arguments
6366
_shell_executable: str
6467
_chdir: str
65-
_env: Mapping[str, str]
68+
_env: Mapping[str, EnvValue]
6669

6770
# Connector control (outside of command generation)
6871
_success_exit_codes: Iterable[int]
@@ -79,10 +82,13 @@ class ConnectorArguments(TypedDict, total=False):
7982
_temp_dir: str
8083

8184

82-
def generate_env(config: Config, value: dict) -> dict:
83-
env = {key: os.environ[key] for key in config.INHERIT_ENV if key in os.environ}
85+
def generate_env(config: Config, value: Mapping[str, EnvValue] | None) -> dict[str, EnvValue]:
86+
env: dict[str, EnvValue] = {
87+
key: os.environ[key] for key in config.INHERIT_ENV if key in os.environ
88+
}
8489
env.update(config.ENV)
85-
env.update(value)
90+
if value is not None:
91+
env.update(value)
8692
return env
8793

8894

src/pyinfra/api/arguments_typed.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55

66
from typing_extensions import ParamSpec, Protocol
77

8+
from pyinfra.api.hiddenvalue import HiddenValue
9+
810
if TYPE_CHECKING:
911
from pyinfra.api.operation import OperationMeta
1012

1113
P = ParamSpec("P")
1214

15+
EnvValue = str | HiddenValue
16+
1317

1418
# Unfortunately we have to re-type out all of the global arguments here because
1519
# Python typing doesn't (yet) support merging kwargs. This acts as the operation
@@ -43,7 +47,7 @@ def __call__(
4347
# Shell arguments
4448
_shell_executable: None | str = None,
4549
_chdir: None | str = None,
46-
_env: None | Mapping[str, str] = None,
50+
_env: None | Mapping[str, EnvValue] = None,
4751
# Connector control
4852
_success_exit_codes: Iterable[int] = (0,),
4953
_timeout: None | int = None,

src/pyinfra/connectors/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ def make_unix_command(
428428
# Quote the whole `key=value` pair so arbitrary values cannot break
429429
# out into additional shell tokens. Invalid identifiers in `key` will
430430
# fail safely when the shell rejects the resulting `export` statement.
431-
env_bits.append(QuoteString(f"{key}={value}"))
431+
env_bits.append(QuoteString(StringCommand(key, value, _separator="=")))
432432
env_bits.append("&&")
433433
env_bits.append(command)
434434
command = StringCommand(*env_bits)

tests/test_connectors/test_util.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from unittest import TestCase
22
from unittest.mock import MagicMock, patch
33

4-
from pyinfra.api import Config, State
4+
from pyinfra.api import Config, State, HiddenValue
55
from pyinfra.connectors.util import (
66
CommandOutput,
77
OutputLine,
@@ -147,6 +147,40 @@ def test_command_env_injection_attempt(self):
147147
== "sh -c 'export '\"'\"'KEY=value\"; id; echo \"'\"'\"' && uptime'"
148148
)
149149

150+
def test_command_env_hidden_value(self):
151+
command = make_unix_command(
152+
"uptime",
153+
_env={
154+
"KEY": HiddenValue("super-secret"),
155+
},
156+
)
157+
158+
raw = command.get_raw_value()
159+
masked = command.get_masked_value()
160+
assert "super-secret" in raw
161+
assert "super-secret" not in masked
162+
assert "*MASKED*" in masked
163+
assert "KEY=*MASKED*" in masked
164+
165+
def test_command_env_hidden_value_with_shell_chars(self):
166+
command = make_unix_command(
167+
"uptime",
168+
_env={
169+
"KEY": HiddenValue("abc; id; echo bad"),
170+
},
171+
)
172+
173+
raw = command.get_raw_value()
174+
masked = command.get_masked_value()
175+
176+
assert "abc; id; echo bad" in raw
177+
assert "abc; id; echo bad" not in masked
178+
assert "*MASKED*" in masked
179+
assert "KEY=*MASKED*" in masked
180+
181+
# The raw command should still quote the assignment as one shell token.
182+
assert "'\"'\"'KEY=abc; id; echo bad'\"'\"'" in raw
183+
150184
def test_command_chdir(self):
151185
command = make_unix_command("uptime", _chdir="/opt/somedir")
152186
assert command.get_raw_value() == "sh -c 'cd /opt/somedir && uptime'"

0 commit comments

Comments
 (0)