Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 31 additions & 18 deletions redis/_parsers/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -886,23 +886,39 @@ def parse_zadd(response, **options):
return int(response)


def _parse_client_info_fields(value):
"""Tokenize a single ``key=value`` client-info blob into a dict.

The server emits this space-separated format for each client in
``CLIENT INFO`` and ``CLIENT LIST``, and inside ``ACL LOG`` entries.
Two quirks have to be preserved: a value may contain ``=`` (a client
name set to ``foo=bar``), so only the first ``=`` splits key from value;
and a value may contain spaces (a Unix-socket ``addr``/``laddr`` path
such as ``/tmp/redis sock/redis.sock``), so a token with no ``=`` is
reattached to the previous value. ``last_key is None`` guards the leading
and empty/whitespace-only cases, which yield an empty dict rather than
raising.
"""
fields = {}
last_key = None
for token in value.split(" "):
if not token:
# Collapse runs of whitespace so a double space between pairs does
# not leave a trailing space glued onto the previous value.
continue
Comment on lines +905 to +908

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve repeated spaces in Unix-socket paths

On a Unix-socket deployment whose socket filename contains two or more consecutive spaces, discarding empty split components collapses those spaces in addr/laddr. In particular, CLIENT LIST previously reattached each empty component and preserved the exact address, whereas this shared parser silently turns /tmp/redis sock/redis.sock:0 into /tmp/redis sock/redis.sock:0; retain empty components when they belong to a continued field value rather than treating every run as redundant separators.

AGENTS.md reference: AGENTS.md:L159-L163

Useful? React with 👍 / 👎.

if "=" in token:
key, val = token.split("=", 1)
fields[key] = val
last_key = key
elif last_key is not None:
fields[last_key] += " " + token
return fields


def parse_client_list(response, **options):
clients = []
for c in str_if_bytes(response).splitlines():
client_dict = {}
tokens = c.split(" ")
last_key = None
for token in tokens:
if "=" in token:
# Values might contain '='
key, value = token.split("=", 1)
client_dict[key] = value
last_key = key
else:
# Values may include spaces. For instance, when running Redis via a Unix socket — such as
# "/tmp/redis sock/redis.sock" — the addr or laddr field will include a space.
client_dict[last_key] += " " + token

client_dict = _parse_client_info_fields(c)
if client_dict:
clients.append(client_dict)
return clients
Expand Down Expand Up @@ -1413,10 +1429,7 @@ def parse_client_info(value):
Parsing client-info in ACL Log in following format.
"key1=value1 key2=value2 key3=value3"
"""
client_info = {}
for info in str_if_bytes(value).strip().split():
key, value = info.split("=")
client_info[key] = value
client_info = _parse_client_info_fields(str_if_bytes(value).strip())

# Those fields are defined as int in networking.c
for int_key in {
Expand Down
10 changes: 10 additions & 0 deletions tests/test_asyncio/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,16 @@ async def test_client_setname(self, r: redis.Redis):
"redis_py_test",
)

@pytest.mark.onlynoncluster
@skip_if_server_version_lt("6.2.0")
async def test_client_info_name_with_equals(self, r: redis.Redis):
# A client name may contain "=", which the "key=value" CLIENT INFO
# format made easy to mis-split. Check the name survives the round trip
# through the real server rather than only the unit-tested parser.
await r.client_setname("test=name")
info = await r.client_info()
assert info["name"] == "test=name"

@skip_if_server_version_lt("7.2.0")
async def test_client_setinfo(self, r: redis.Redis):
from redis.utils import get_lib_version
Expand Down
10 changes: 10 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,16 @@ def test_client_info(self, r):
assert isinstance(info, dict)
assert "addr" in info

@pytest.mark.onlynoncluster
@skip_if_server_version_lt("6.2.0")
def test_client_info_name_with_equals(self, r):
# A client name may contain "=", which the "key=value" CLIENT INFO
# format made easy to mis-split. Check the name survives the round trip
# through the real server rather than only the unit-tested parser.
r.client_setname("test=name")
info = r.client_info()
assert info["name"] == "test=name"

@pytest.mark.onlynoncluster
@skip_if_server_version_lt("5.0.0")
def test_client_list_types_not_replica(self, r):
Expand Down
42 changes: 42 additions & 0 deletions tests/test_parsers/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
pairs_to_dict_typed,
parse_acl_log,
parse_acl_log_resp3_to_resp2_legacy,
parse_client_info,
parse_client_list,
parse_command,
parse_info,
Expand Down Expand Up @@ -99,6 +100,47 @@ def test_parse_client_list():
assert clients == expected


@pytest.mark.fixed_client
def test_parse_client_info():
# A CLIENT INFO value can contain both a space (a unix-socket addr such as
# "/tmp/redis sock/redis.sock") and an "=" (a client name like
# "test=_complex_[name]"), the same tricky data parse_client_list handles.
# Int-typed fields are additionally coerced to int.
info = (
"id=7 addr=/tmp/redis sock/redis.sock:0 name=test=_complex_[name] "
"age=-1 db=0 lib-ver="
)
assert parse_client_info(info) == {
"id": 7,
"addr": "/tmp/redis sock/redis.sock:0",
"name": "test=_complex_[name]",
"age": -1,
"db": 0,
"lib-ver": "",
}


@pytest.mark.fixed_client
def test_parse_client_info_empty():
# An empty or whitespace-only client-info stays an empty dict rather than
# raising. ACL LOG entries omit client-info as "" (see parse_acl_log), so
# this path must not crash.
assert parse_client_info("") == {}
assert parse_client_info(" ") == {}


@pytest.mark.fixed_client
def test_parse_client_list_shares_guard():
# parse_client_list and parse_client_info parse the same server blob and
# now share one tokenizer, so the leading-token guard added for CLIENT INFO
# also protects CLIENT LIST. A line whose first token has no "=" (only
# possible with malformed input) is skipped instead of raising KeyError.
assert parse_client_list("junk id=3") == [{"id": "3"}]
# A double space between pairs must not glue a trailing space onto the
# previous value.
assert parse_client_list("id=1 name=foo") == [{"id": "1", "name": "foo"}]


@pytest.mark.fixed_client
def test_parse_command_preserves_acl_categories():
response = [
Expand Down