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
13 changes: 12 additions & 1 deletion redis/asyncio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1768,8 +1768,11 @@ async def _execute_transaction( # noqa: C901
errors.append((i, err))

# parse the EXEC.
# Read with disable_decoding=True so that NEVER_DECODE commands
# (e.g. DUMP) preserve their binary data when decode_responses=True.
# Individual elements are selectively decoded below.
try:
response = await self.parse_response(connection, "_")
response = await connection.read_response(disable_decoding=True)
except ExecAbortError as err:
if errors:
raise errors[0][1] from err
Expand Down Expand Up @@ -1806,6 +1809,14 @@ async def _execute_transaction( # noqa: C901
# Remove keys entry, it needs only for cache.
options.pop("keys", None)

# Selectively decode responses: NEVER_DECODE commands (e.g. DUMP)
# keep their binary data, all others are decoded normally.
if isinstance(r, bytes):
if NEVER_DECODE in options:
options.pop(NEVER_DECODE)
elif connection.encoder.decode_responses:
r = connection.encoder.decode(r)

if command_name in self.response_callbacks:
r = self.response_callbacks[command_name](r, **options)
if inspect.isawaitable(r):
Expand Down
12 changes: 11 additions & 1 deletion redis/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1855,8 +1855,11 @@ def _execute_transaction(
errors.append((i, e))

# parse the EXEC.
# Read with disable_decoding=True so that NEVER_DECODE commands
# (e.g. DUMP) preserve their binary data when decode_responses=True.
# Individual elements are selectively decoded below.
try:
response = self.parse_response(connection, "_")
response = connection.read_response(disable_decoding=True)
except ExecAbortError:
if errors:
raise errors[0][1]
Expand Down Expand Up @@ -1890,6 +1893,13 @@ def _execute_transaction(
# Remove keys entry, it needs only for cache.
options.pop("keys", None)
command_name = args[0]
# Selectively decode responses: NEVER_DECODE commands (e.g. DUMP)
# keep their binary data, all others are decoded normally.
if isinstance(r, bytes):
if NEVER_DECODE in options:
options.pop(NEVER_DECODE)
elif connection.encoder.decode_responses:
r = connection.encoder.decode(r)
Copy link

Choose a reason for hiding this comment

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

Non-bytes responses silently skip decoding in transactions

High Severity

Reading the EXEC response with disable_decoding=True leaves ALL nested data as raw bytes, but the selective decoding only handles the case where r itself is bytes. Commands returning lists (e.g., LRANGE, SMEMBERS, MGET, HGETALL, KEYS) produce list-of-bytes responses where isinstance(r, list) is true, so the isinstance(r, bytes) check is false and no decoding occurs. This silently returns bytes instead of strings for all non-scalar command responses in transactional pipelines with decode_responses=True.

Additional Locations (1)
Fix in Cursor Fix in Web

if command_name in self.response_callbacks:
r = self.response_callbacks[command_name](r, **options)
data.append(r)
Expand Down
24 changes: 24 additions & 0 deletions tests/test_asyncio/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,30 @@ async def test_pipeline_json_module_access(self, r):
assert "JSON.SET" in r.response_callbacks
assert "JSON.GET" in r.response_callbacks

async def test_pipeline_dump_with_decode_responses(self, decoded_r):
"""DUMP returns binary data that should not be decoded in a pipeline.

Regression test for https://github.com/redis/redis-py/issues/1884
"""
await decoded_r.set("dumpee", "some_value")
async with decoded_r.pipeline(transaction=False) as pipe:
pipe.dump("dumpee")
result = await pipe.execute()
assert len(result) == 1
assert isinstance(result[0], bytes)

async def test_pipeline_transaction_dump_with_decode_responses(self, decoded_r):
"""DUMP returns binary data that should not be decoded in a transactional pipeline.

Regression test for https://github.com/redis/redis-py/issues/1884
"""
await decoded_r.set("dumpee", "some_value")
async with decoded_r.pipeline(transaction=True) as pipe:
pipe.dump("dumpee")
result = await pipe.execute()
assert len(result) == 1
assert isinstance(result[0], bytes)


@pytest.mark.asyncio
class TestAsyncPipelineOperationDurationMetricsRecording:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,31 @@ def test_pipeline_with_msetex(self, r):
)


def test_pipeline_dump_with_decode_responses(self, decoded_r):
"""DUMP returns binary data that should not be decoded in a pipeline.

Regression test for https://github.com/redis/redis-py/issues/1884
"""
decoded_r.set("dumpee", "some_value")
with decoded_r.pipeline(transaction=False) as pipe:
pipe.dump("dumpee")
result = pipe.execute()
assert len(result) == 1
assert isinstance(result[0], bytes)

def test_pipeline_transaction_dump_with_decode_responses(self, decoded_r):
"""DUMP returns binary data that should not be decoded in a transactional pipeline.

Regression test for https://github.com/redis/redis-py/issues/1884
"""
decoded_r.set("dumpee", "some_value")
with decoded_r.pipeline(transaction=True) as pipe:
pipe.dump("dumpee")
result = pipe.execute()
assert len(result) == 1
assert isinstance(result[0], bytes)


class TestPipelineMetricsRecording:
"""
Unit tests that verify metrics are properly recorded from Pipeline
Expand Down
Loading