Files
gentoo/dev-python/pylibmc/files/pylibmc-1.6.3-doctest-libmemcached-1.0.18.patch
Sam James 0890f85156 dev-python/pylibmc: fix tests w/ >=libmemcached-1.0.18; add missing zlib dep
Also, drop a patch we don't seem to need anymore.

Closes: https://bugs.gentoo.org/978301
Signed-off-by: Sam James <sam@gentoo.org>
2026-07-03 19:06:56 +01:00

329 lines
11 KiB
Diff

https://github.com/lericson/pylibmc/issues/302
https://github.com/lericson/pylibmc/pull/303
From 76c966a113a02e170926f622da4dc8a4f2ba6f21 Mon Sep 17 00:00:00 2001
From: "Bryan C. Mills" <bryan@duolingo.com>
Date: Tue, 10 Mar 2026 00:37:37 -0400
Subject: [PATCH 1/3] Fix doctests.txt for libmemcache 1.0.18
Fixes lericson/pylibmc#302.
---
tests/doctests.txt | 4 +++-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/tests/doctests.txt b/tests/doctests.txt
index 3bc5728..6ed44bd 100644
--- a/tests/doctests.txt
+++ b/tests/doctests.txt
@@ -209,11 +209,13 @@ Per-error exceptions
... except pylibmc.NotFound:
... print("ok")
ok
+>>> c.set_behaviors({"verify_keys": True})
>>> try:
... c.incr(chr(0))
-... except (pylibmc.ProtocolError, pylibmc.SocketCreateError):
+... except pylibmc.BadKeyProvided:
... print("ok")
ok
+>>> c.set_behaviors({"verify_keys": False})
Behaviors.
>>> c.set_behaviors({"tcp_nodelay": True, "hash": 6})
From de096912844a6df42da8f9ad2b826769ee643a01 Mon Sep 17 00:00:00 2001
From: "Bryan C. Mills" <bryan@duolingo.com>
Date: Tue, 10 Mar 2026 00:41:30 -0400
Subject: [PATCH 2/3] Fix a use-after-free bug in gets
Fixes lericson/pylibmc#300.
---
src/_pylibmcmodule.c | 16 +----
tests/test_gets_use_after_free.py | 107 ++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 13 deletions(-)
create mode 100644 tests/test_gets_use_after_free.py
diff --git a/src/_pylibmcmodule.c b/src/_pylibmcmodule.c
index 545b658..83ca929 100644
--- a/src/_pylibmcmodule.c
+++ b/src/_pylibmcmodule.c
@@ -759,8 +759,6 @@ static PyObject *PylibMC_Client_gets(PylibMC_Client *self, PyObject *arg) {
*keys = PyBytes_AS_STRING(arg);
*keylengths = (size_t)PyBytes_GET_SIZE(arg);
- Py_DECREF(arg);
-
Py_BEGIN_ALLOW_THREADS;
rc = memcached_mget(self->mc, keys, keylengths, 1);
@@ -769,6 +767,8 @@ static PyObject *PylibMC_Client_gets(PylibMC_Client *self, PyObject *arg) {
Py_END_ALLOW_THREADS;
+ Py_DECREF(arg);
+
int miss = 0;
int fail = 0;
if (rc == MEMCACHED_SUCCESS && res != NULL) {
@@ -2463,14 +2463,6 @@ static int _key_normalized_obj(PyObject **key) {
key_str = PyBytes_AS_STRING(retval);
key_sz = PyBytes_GET_SIZE(retval);
rc = _key_normalized_str(&key_str, &key_sz);
- if (rc == 2) {
- retval = PyBytes_FromStringAndSize(key_str, key_sz);
- if (retval != NULL) {
- rc = 1;
- } else {
- rc = 0;
- }
- }
END:
if (retval != orig_key) {
@@ -2488,9 +2480,7 @@ static int _key_normalized_obj(PyObject **key) {
}
/**
- * Normalize memcached key.
- *
- * Returns 0 if invalid, 1 if already normalized, and 2 if mutated.
+ * Validate a memcached key. Returns 0 if invalid, 1 if valid.
*/
static int _key_normalized_str(char **str, Py_ssize_t *size) {
/* libmemcached pads max_key_size with one byte for null termination */
diff --git a/tests/test_gets_use_after_free.py b/tests/test_gets_use_after_free.py
new file mode 100644
index 0000000..fdcb861
--- /dev/null
+++ b/tests/test_gets_use_after_free.py
@@ -0,0 +1,107 @@
+"""Regression test for premature Py_DECREF in PylibMC_Client_gets.
+
+The bug only triggered with str keys: _key_normalized_obj encodes the
+str into a new bytes object, and the call to Py_DECREF
+deallocated it immediately (BEFORE it was passed to memcached_mget),
+causing a use-after-free and potential reads of arbitrary keys.
+
+Requires a running memcached (see MEMCACHED_HOST/PORT env vars).
+"""
+
+import os
+import subprocess
+import sys
+import textwrap
+
+import pytest
+
+from pylibmc.test import make_test_client
+from tests import PylibmcTestCase
+
+
+def _run_with_debug_malloc(script, timeout=10):
+ """Run a Python script in a subprocess with PYTHONMALLOC=debug.
+
+ The debug allocator fills freed memory with 0xDD, making
+ use-after-free bugs deterministically detectable.
+ """
+ return subprocess.run(
+ [sys.executable, "-c", script],
+ env={**os.environ, "PYTHONMALLOC": "debug"},
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+
+
+class TestGetsUseAfterFree(PylibmcTestCase):
+
+ def test_gets_str_key_reads_freed_buffer(self):
+ """gets(str_key) sends garbage because the key buffer is freed first.
+
+ With PYTHONMALLOC=debug the freed buffer becomes 0xDD bytes, so
+ memcached_mget asks for a nonexistent key and returns a miss.
+ get(str_key) works correctly as a control.
+
+ This test runs in a subprocess so that PYTHONMALLOC=debug is
+ active regardless of how the outer test runner was invoked.
+ """
+ host = os.environ.get("MEMCACHED_HOST", "127.0.0.1")
+ port = os.environ.get("MEMCACHED_PORT", "11211")
+
+ result = _run_with_debug_malloc(textwrap.dedent(f"""\
+ import sys
+ import pylibmc
+
+ mc = pylibmc.Client(
+ ["{host}:{port}"], behaviors={{"cas": True}},
+ )
+
+ key_str = "test_gets_uaf"
+ key_bytes = key_str.encode("utf-8")
+ assert mc.set(key_bytes, 42)
+
+ # Control: get() Py_DECREFs AFTER memcached_get — no UAF.
+ val = mc.get(key_str)
+ if val != 42:
+ print(f"get() failed: {{val!r}}", file=sys.stderr)
+ sys.exit(2)
+
+ # Bug: gets() Py_DECREFs BEFORE memcached_mget — UAF.
+ value, cas = mc.gets(key_str)
+ if value != 42:
+ print(
+ f"BUG: gets({{key_str!r}}) = ({{value!r}}, {{cas!r}}), "
+ f"expected (42, <cas>)\\n"
+ f" get({{key_str!r}}) = 42 (correct)\\n"
+ f"Key buffer was used after free in PylibMC_Client_gets.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ """))
+
+ if result.returncode == 1:
+ pytest.fail(
+ f"gets() use-after-free confirmed:\n{result.stderr.strip()}"
+ )
+ elif result.returncode == 2:
+ pytest.fail(
+ f"get() failed (is memcached running?):\n"
+ f"{result.stderr.strip()}"
+ )
+ elif result.returncode != 0:
+ pytest.fail(
+ f"Subprocess failed (exit {result.returncode}):\n"
+ f"{result.stderr.strip()}"
+ )
+
+ def test_gets_bytes_key_not_affected(self):
+ """gets(bytes_key) was not affected by the bug.
+
+ This serves as a control case for the test.
+ """
+ mc = make_test_client(binary=False, behaviors={"cas": True})
+ key = b"test_gets_bytes_ok"
+ assert mc.set(key, 99)
+ value, _cas = mc.gets(key)
+ assert value == 99
From fa897d09b8b2d084279bb2a635611f26d7023f61 Mon Sep 17 00:00:00 2001
From: "Bryan C. Mills" <bryan@duolingo.com>
Date: Tue, 10 Mar 2026 00:42:29 -0400
Subject: [PATCH 3/3] Fix a masked error in get_multi
Fixes lericson/pylibmc#301.
---
src/_pylibmcmodule.c | 13 +++--
tests/test_fetch_multi_error_masking.py | 65 +++++++++++++++++++++++++
2 files changed, 75 insertions(+), 3 deletions(-)
create mode 100644 tests/test_fetch_multi_error_masking.py
diff --git a/src/_pylibmcmodule.c b/src/_pylibmcmodule.c
index 83ca929..39293b8 100644
--- a/src/_pylibmcmodule.c
+++ b/src/_pylibmcmodule.c
@@ -1666,7 +1666,10 @@ static pylibmc_mget_res _fetch_multi(memcached_st *mc,
result = memcached_fetch_result(mc, result, &res.rc);
if (result == NULL || res.rc == MEMCACHED_END) {
- /* This is how libmecached signals EOF. */
+ /* memcached_fetch_result returns NULL once all servers are
+ * exhausted. res.rc distinguishes normal completion
+ * (MEMCACHED_END, MEMCACHED_NOTFOUND) from errors
+ * (e.g. MEMCACHED_CONNECTION_FAILURE). */
break;
} else if (res.rc == MEMCACHED_BAD_KEY_PROVIDED
|| res.rc == MEMCACHED_NO_KEY_PROVIDED) {
@@ -1678,7 +1681,6 @@ static pylibmc_mget_res _fetch_multi(memcached_st *mc,
}
}
- res.rc = MEMCACHED_SUCCESS;
return res;
}
@@ -1797,7 +1799,12 @@ static PyObject *PylibMC_Client_get_multi(
res = _fetch_multi(self->mc, req);
Py_END_ALLOW_THREADS;
- if (res.rc != MEMCACHED_SUCCESS) {
+ /* _fetch_multi returns the rc from the final memcached_fetch_result
+ * call. Per the libmemcached docs, that function sets rc to END on
+ * successful conclusion, or NOTFOUND if no keys matched — both are
+ * normal outcomes for get_multi. Any other code (e.g.
+ * CONNECTION_FAILURE) is an actual error. */
+ if (res.rc != MEMCACHED_END && res.rc != MEMCACHED_NOTFOUND) {
PylibMC_ErrFromMemcached(self, res.err_func, res.rc);
goto earlybird;
}
diff --git a/tests/test_fetch_multi_error_masking.py b/tests/test_fetch_multi_error_masking.py
new file mode 100644
index 0000000..ef66833
--- /dev/null
+++ b/tests/test_fetch_multi_error_masking.py
@@ -0,0 +1,65 @@
+"""Regression test for error masking in _fetch_multi.
+
+If the connection failed after the initial memcached_mget had already succeeded,
+_fetch_multi would interpret the NULL return from memcached_fetch_result as EOF
+and unconditionally overwrite the result's status with MEMCACHED_SUCCESS.
+"""
+
+import socket
+import threading
+import time
+import unittest
+
+import pytest
+
+import pylibmc
+
+
+def _mock_text_server():
+ """Start a mock memcached that sends one VALUE then drops the connection.
+
+ Returns (port, thread). The server socket is cleaned up by the
+ handler thread after a single accepted connection.
+ """
+ server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server_sock.bind(("127.0.0.1", 0))
+ port = server_sock.getsockname()[1]
+ server_sock.listen(1)
+ server_sock.settimeout(10)
+
+ def handler():
+ try:
+ conn, _ = server_sock.accept()
+ data = b""
+ while b"\r\n" not in data:
+ chunk = conn.recv(4096)
+ if not chunk:
+ break
+ data += chunk
+ conn.sendall(b"VALUE key1 0 5\r\nhello\r\n")
+ time.sleep(0.05)
+ conn.close()
+ except Exception:
+ pass
+ finally:
+ server_sock.close()
+
+ t = threading.Thread(target=handler, daemon=True)
+ t.start()
+ return port, t
+
+
+class TestFetchMultiErrorMasking(unittest.TestCase):
+
+ def test_get_multi_returns_error_on_connection_drop(self):
+ port, server_thread = _mock_text_server()
+
+ mc = pylibmc.Client([f"127.0.0.1:{port}"])
+ try:
+ result = mc.get_multi([b"key1", b"key2", b"key3"])
+ pytest.fail(f"get_multi failed to raise pylibmc.Error. Partial results: {result!r}")
+ except pylibmc.Error:
+ return # Correct: connection error raised.
+ finally:
+ server_thread.join()