From 414ae1de91543a1c0fee0738f97fe1a33d0fe666 Mon Sep 17 00:00:00 2001 From: Florian Best Date: Thu, 14 Aug 2025 02:50:35 +0200 Subject: [PATCH 01/16] fix(extop.dds): fix unset RefreshRequest.requestValue >>> from ldap.extop.dds import RefreshRequest >>> req = RefreshRequest(RefreshRequest.requestName, 'uid=temp,dc=freeiam,dc=org', 86400) >>> repr(req) Traceback (most recent call last): File "", line 1, in File "/usr/lib/python3/dist-packages/ldap/extop/__init__.py", line 31, in __repr__ return f'{self.__class__.__name__}({self.requestName},{self.requestValue})' ^^^^^^^^^^^^^^^^^ AttributeError: 'RefreshRequest' object has no attribute 'requestValue'. Did you mean: 'requestName'? --- Lib/ldap/extop/dds.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/ldap/extop/dds.py b/Lib/ldap/extop/dds.py index 7fab0813..50695ea4 100644 --- a/Lib/ldap/extop/dds.py +++ b/Lib/ldap/extop/dds.py @@ -35,6 +35,7 @@ class RefreshRequestValue(univ.Sequence): ) def __init__(self,requestName=None,entryName=None,requestTtl=None): + super().__init__(requestName, b'') self.entryName = entryName self.requestTtl = requestTtl or self.defaultRequestTtl From 0506a0f0a1824dd3028910ab3d8c94b0a0cfd039 Mon Sep 17 00:00:00 2001 From: Florian Best Date: Tue, 21 Oct 2025 10:07:14 +0200 Subject: [PATCH 02/16] fix(extop.dds): make passing of requestName optional again requestName is already set at class member. It seems there is code out there which is not giving it as value but passing None. in that case, fallback to the class member. Fixes: 414ae1de91543a1c0fee0738f97fe1a33d0fe666 --- Lib/ldap/extop/dds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/ldap/extop/dds.py b/Lib/ldap/extop/dds.py index 50695ea4..a970d71d 100644 --- a/Lib/ldap/extop/dds.py +++ b/Lib/ldap/extop/dds.py @@ -35,7 +35,7 @@ class RefreshRequestValue(univ.Sequence): ) def __init__(self,requestName=None,entryName=None,requestTtl=None): - super().__init__(requestName, b'') + super().__init__(requestName or self.requestName, b'') self.entryName = entryName self.requestTtl = requestTtl or self.defaultRequestTtl From 2f0135cfd6004e8d5a10295fe307c5111e8da8ae Mon Sep 17 00:00:00 2001 From: dotlambda Date: Fri, 10 Oct 2025 16:00:13 -0700 Subject: [PATCH 03/16] remove superfluous dependency on setuptools-scm --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8781155d..77783f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,6 @@ [build-system] requires = [ "setuptools", - "setuptools-scm", ] build-backend = "setuptools.build_meta" From aca9cb5fdbab78918fe6905cfe1cff8549039c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Tue, 25 Jun 2024 12:38:24 +0100 Subject: [PATCH 04/16] Add OpenLDAPSyncreplCookie Fixes: https://github.com/python-ldap/python-ldap/issues/562 --- Doc/reference/ldap-syncrepl.rst | 3 + Lib/ldap/syncrepl.py | 70 +++++++++++++ Lib/slapdtest/_slapdtest.py | 5 + Tests/__init__.py | 1 + Tests/t_ldap_syncrepl.py | 175 +++++++++++++++++++++++++++++++- 5 files changed, 253 insertions(+), 1 deletion(-) diff --git a/Doc/reference/ldap-syncrepl.rst b/Doc/reference/ldap-syncrepl.rst index b3b2cf9a..046b15a9 100644 --- a/Doc/reference/ldap-syncrepl.rst +++ b/Doc/reference/ldap-syncrepl.rst @@ -20,3 +20,6 @@ This module defines the following classes: .. autoclass:: ldap.syncrepl.SyncreplConsumer :members: + +.. autoclass:: ldap.syncrepl.OpenLDAPSyncreplCookie + :members: diff --git a/Lib/ldap/syncrepl.py b/Lib/ldap/syncrepl.py index fd0c1285..0e1b6a3f 100644 --- a/Lib/ldap/syncrepl.py +++ b/Lib/ldap/syncrepl.py @@ -4,6 +4,7 @@ See https://www.python-ldap.org/ for project details. """ +from typing import AnyStr, Dict, List, Tuple, Union from uuid import UUID # Imports from pyasn1 @@ -15,6 +16,7 @@ from ldap import RES_SEARCH_RESULT, RES_SEARCH_ENTRY, RES_INTERMEDIATE __all__ = [ + 'OpenLDAPSyncreplCookie', 'SyncreplConsumer', ] @@ -535,3 +537,71 @@ def syncrepl_refreshdone(self): follows. """ pass + + +class OpenLDAPSyncreplCookie: + """ + OpenLDAPSyncreplCookie - allows a consumer to track a cookie across a + refreshAndPersist syncrepl session against a multi-provider OpenLDAP cluster + """ + + rid: int = 0 + sid: int = 0 + _csnset: Dict[int, str] + + def __init__(self, cookie: AnyStr = "") -> None: + self._csnset = {} + + if cookie: + self.update(cookie) + + def _parse_csn(self, csn: str) -> Tuple[str, str, str, str]: + time, order, sid, other = csn.split('#', 3) + return (time, order, sid, other) + + def _parse_cookie(self, cookie: AnyStr) -> Dict[str, Union[str, List[str]]]: + if isinstance(cookie, bytes): + cookie = cookie.decode() + + result = {} + parts = cookie.split(',') + for part in parts: + if part.startswith('rid='): + result['rid'] = part[4:] + elif part.startswith('sid='): + result['sid'] = part[4:] + elif part.startswith('csn='): + result['csn'] = part[4:].split(';') + elif part.startswith('delcsn='): + result['delcsn'] = part[7:] + else: + # Did not recognize this cookie part + pass + return result + + def update(self, cookie: AnyStr): + """ + Update the CSN set based on a cookie we just received, use in + syncrepl_set_cookie() to track the session state. + """ + components = self._parse_cookie(cookie) + for csn in components.get('csn', []): + _, _, sid, _ = self._parse_csn(csn) + if sid not in self._csnset or self._csnset[sid] < csn: + self._csnset[sid] = csn + + return self + + def unparse(self) -> str: + """ + Return the cookie as a string, use in syncrepl_get_cookie() or when + storing the state for later use. + """ + cookie = 'rid={:03},sid={:03x}'.format(self.rid or 0, self.sid or 0) + if self._csnset: + cookie += ',csn=' + cookie += ';'.join(csn for sid, csn in sorted(self._csnset.items())) + return cookie + + def __str__(self): + return self.unparse() diff --git a/Lib/slapdtest/_slapdtest.py b/Lib/slapdtest/_slapdtest.py index 4110d945..00764ac3 100644 --- a/Lib/slapdtest/_slapdtest.py +++ b/Lib/slapdtest/_slapdtest.py @@ -41,6 +41,11 @@ cn: module olcModuleLoad: back_%(database)s +dn: olcDatabase=config,cn=config +objectClass: olcDatabaseConfig +olcDatabase: config +olcRootDN: %(rootdn)s + dn: olcDatabase=%(database)s,cn=config objectClass: olcDatabaseConfig objectClass: olcMdbConfig diff --git a/Tests/__init__.py b/Tests/__init__.py index ea28d0ce..1a6a8836 100644 --- a/Tests/__init__.py +++ b/Tests/__init__.py @@ -21,3 +21,4 @@ from . import t_untested_mods from . import t_ldap_controls_libldap from . import t_ldap_options +from . import t_ldap_syncrepl diff --git a/Tests/t_ldap_syncrepl.py b/Tests/t_ldap_syncrepl.py index 6acc82c4..ab134a79 100644 --- a/Tests/t_ldap_syncrepl.py +++ b/Tests/t_ldap_syncrepl.py @@ -13,7 +13,8 @@ import ldap from ldap.ldapobject import SimpleLDAPObject -from ldap.syncrepl import SyncreplConsumer, SyncInfoMessage +from ldap.syncrepl import SyncreplConsumer, SyncInfoMessage, \ + OpenLDAPSyncreplCookie from slapdtest import SlapdObject, SlapdTestCase @@ -37,6 +38,10 @@ olcModuleLoad: back_%(database)s olcModuleLoad: syncprov +dn: olcDatabase=config,cn=config +objectClass: olcDatabaseConfig +olcRootDN: %(rootdn)s + dn: olcDatabase=%(database)s,cn=config objectClass: olcDatabaseConfig objectClass: olcMdbConfig @@ -442,6 +447,174 @@ def setUp(self): self.suffix = self.server.suffix +class TestMPRSyncrepl(BaseSyncreplTests, SlapdTestCase): + class MPRClient(SyncreplClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cookie = OpenLDAPSyncreplCookie() + + def syncrepl_set_cookie(self, cookie): + self.cookie.update(cookie) + super().syncrepl_set_cookie(self.cookie.unparse()) + + def setUp(self): + super().setUp() + self.tester = self.MPRClient( + self.server.ldap_uri, + self.server.root_dn, + self.server.root_pw, + bytes_mode=False + ) + self.suffix = self.server.suffix + + # An active MPR should not have a sid=000 server in it + if self.server.server_id == 0: + self.skipTest("Server got serverid 0 assigned") + + def test_mpr_refresh_and_persist(self): + """ + Make sure we process cookie updates from a live MPR cluster correctly + """ + # Assumes that server_id is not used before the call to start() + self.server2 = self.server_class() + if self.server.server_id == self.server2.server_id: + self.server2.server_id += 1 + if self.server2.server_id % 4096 == 0: + self.server2.server_id = 1 + + with self.server2 as server2: + tester2 = self.MPRClient( + self.server2.ldap_uri, + self.server2.root_dn, + self.server2.root_pw, + bytes_mode=False + ) + self.addCleanup(tester2.unbind_s) + + self.tester.search( + self.suffix, + 'refreshAndPersist', + ) + + # Run a quick refresh, that shouldn't have any changes. + while self.tester.refresh_done is not True: + poll_result = self.tester.poll( + all=0, + timeout=None + ) + self.assertTrue(poll_result) + + # Again, server data should not have changed. + self.assertEqual(self.tester.dn_attrs, LDAP_ENTRIES) + + # set up replication between both + coords = [(1, self.server.ldap_uri, self.suffix, + self.server.root_dn, self.server.root_pw), + (2, self.server2.ldap_uri, self.suffix, + self.server2.root_dn, self.server2.root_pw), + ] + modifications = [ + (ldap.MOD_ADD, "olcSyncrepl", [ + ('rid=%d provider=%s searchbase="%s" type=refreshAndPersist ' + 'bindmethod=simple binddn="%s" credentials="%s" ' + 'retry="1 +"' % coord).encode() for coord in coords]), + # do we still support 2.4.x? Change to olcMultiProvider if not + (ldap.MOD_REPLACE, "olcMirrorMode", [b"TRUE"]), + ] + + self.tester.modify_s( + "olcDatabase={1}%s,cn=config" % (self.server.database), + modifications) + tester2.modify_s( + "olcDatabase={1}%s,cn=config" % (self.server.database), + modifications) + + tester2.search( + self.suffix, + 'refreshAndPersist', + ) + + # Wait till server2 catches up + while tester2.refresh_done is not True or \ + tester2.cookie.unparse() != self.tester.cookie.unparse(): + try: + poll_result = tester2.poll( + all=0, + timeout=None + ) + self.assertTrue(poll_result) + except ldap.NO_SUCH_OBJECT: + # 2.6+ Allows a refreshAndPersist against an empty DB, but + # with older ones we need to retry until there's at least + # one entry + tester2.search( + self.suffix, + 'refreshAndPersist', + ) + + # Again, server data should not have changed. + self.assertEqual(tester2.dn_attrs, LDAP_ENTRIES) + + # From here on, things get little hairy, server1 might not have + # finished its refresh from 2 and we can't easily confirm this + # without cn=monitor. We just read back our CSNs and make sure + # we've seen both. + + # send some mods to both + modification = [('objectClass', [b'device'])] + self.tester.add_s("cn=server1,%s" % self.suffix, modification) + + csn1 = self.tester.read_s("cn=server1,%s" % self.suffix, + attrlist=['entryCSN'] + )['entryCSN'][0].decode('utf8') + + tester2.add_s("cn=server2,%s" % self.suffix, modification) + csn2 = tester2.read_s("cn=server2,%s" % self.suffix, + attrlist=['entryCSN'] + )['entryCSN'][0].decode('utf8') + + new_state = LDAP_ENTRIES.copy() + new_state["cn=server1,%s" % self.suffix] = { + "objectClass": [b"device"], + "cn": [b"server1"], + } + new_state["cn=server2,%s" % self.suffix] = { + "objectClass": [b"device"], + "cn": [b"server2"], + } + + # Wait for the cookie to sync up, a failure would be that this + # doesn't happen, so impose a timeout + while csn1 not in self.tester.cookie.unparse() or \ + csn2 not in self.tester.cookie.unparse() or \ + csn1 not in tester2.cookie.unparse() or \ + csn2 not in tester2.cookie.unparse(): + if csn1 not in self.tester.cookie.unparse() or \ + csn2 not in self.tester.cookie.unparse(): + poll_result = self.tester.poll( + all=0, + timeout=5 + ) + self.assertTrue(poll_result) + if csn1 not in tester2.cookie.unparse() or \ + csn2 not in tester2.cookie.unparse(): + poll_result = tester2.poll( + all=0, + timeout=5 + ) + self.assertTrue(poll_result) + + self.assertEqual(self.tester.cookie.unparse(), + tester2.cookie.unparse()) + self.assertEqual(self.tester.dn_attrs, new_state) + self.assertEqual(tester2.dn_attrs, new_state) + + # self.tester seems to have been unbound by the time + # self.addCleanup callbacks get called? Cleanup manually... + self.tester.delete_s("cn=server1,%s" % self.suffix) + self.tester.delete_s("cn=server2,%s" % self.suffix) + + class DecodeSyncreplProtoTests(unittest.TestCase): """ Tests of the ASN.1 decoder for tricky cases or past issues to ensure that From 886139877811aaefedff400d85fd81c545385893 Mon Sep 17 00:00:00 2001 From: Simon Pichugin Date: Mon, 6 Oct 2025 19:39:49 -0700 Subject: [PATCH 05/16] Prepare a new release cherry-pick bf666e918615b00dbcd1bbe71542522eedfdffc1 --- CHANGES | 29 +++++++++++++++++++++++++++++ Lib/ldap/cidict.py | 6 +++--- Lib/ldap/ldapobject.py | 2 +- Lib/ldap/pkginfo.py | 2 +- Lib/ldapurl.py | 2 +- Lib/ldif.py | 2 +- Lib/slapdtest/__init__.py | 2 +- 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CHANGES b/CHANGES index 0491b6ef..05fcf4b6 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,32 @@ +Released 3.4.5 2025-10-10 + +Security fixes: +* CVE-2025-61911 (GHSA-r7r6-cc7p-4v5m): Enforce ``str`` input in + ``ldap.filter.escape_filter_chars`` with ``escape_mode=1``; ensure proper + escaping. (thanks to lukas-eu) +* CVE-2025-61912 (GHSA-p34h-wq7j-h5v6): Correct NUL escaping in + ``ldap.dn.escape_dn_chars`` to ``\00`` per RFC 4514. (thanks to aradona91) + +Fixes: +* ReconnectLDAPObject now properly reconnects on UNAVAILABLE, CONNECT_ERROR + and TIMEOUT exceptions (previously only SERVER_DOWN), fixing reconnection + issues especially during server restarts +* Fixed syncrepl.py to use named constants instead of raw decimal values + for result types +* Fixed error handling in SearchNoOpMixIn to prevent a undefined variable error + +Tests: +* Added comprehensive reconnection test cases including concurrent operation + handling and server restart scenarios + +Doc/ +* Updated installation docs and fixed various documentation typos +* Added ReadTheDocs configuration file + +Infrastructure: +* Add testing and document support for Python 3.13 + +---------------------------------------------------------------- Released 3.4.4 2022-11-17 Fixes: diff --git a/Lib/ldap/cidict.py b/Lib/ldap/cidict.py index f846fd29..65041e0a 100644 --- a/Lib/ldap/cidict.py +++ b/Lib/ldap/cidict.py @@ -85,7 +85,7 @@ def strlist_minus(a,b): a,b are supposed to be lists of case-insensitive strings. """ warnings.warn( - "strlist functions are deprecated and will be removed in 3.5", + "strlist functions are deprecated and will be removed in 4.0", category=DeprecationWarning, stacklevel=2, ) @@ -105,7 +105,7 @@ def strlist_intersection(a,b): Return intersection of two lists of case-insensitive strings a,b. """ warnings.warn( - "strlist functions are deprecated and will be removed in 3.5", + "strlist functions are deprecated and will be removed in 4.0", category=DeprecationWarning, stacklevel=2, ) @@ -125,7 +125,7 @@ def strlist_union(a,b): Return union of two lists of case-insensitive strings a,b. """ warnings.warn( - "strlist functions are deprecated and will be removed in 3.5", + "strlist functions are deprecated and will be removed in 4.0", category=DeprecationWarning, stacklevel=2, ) diff --git a/Lib/ldap/ldapobject.py b/Lib/ldap/ldapobject.py index 7e7b8158..c94df89d 100644 --- a/Lib/ldap/ldapobject.py +++ b/Lib/ldap/ldapobject.py @@ -833,7 +833,7 @@ class ReconnectLDAPObject(SimpleLDAPObject): This class also implements the pickle protocol. - .. versionadded:: 3.5 + .. versionadded:: 3.4.5 The exceptions :py:exc:`ldap.SERVER_DOWN`, :py:exc:`ldap.UNAVAILABLE`, :py:exc:`ldap.CONNECT_ERROR` and :py:exc:`ldap.TIMEOUT` (configurable via :py:attr:`_reconnect_exceptions`) now trigger a reconnect. """ diff --git a/Lib/ldap/pkginfo.py b/Lib/ldap/pkginfo.py index 18ead66c..2ac6852d 100644 --- a/Lib/ldap/pkginfo.py +++ b/Lib/ldap/pkginfo.py @@ -1,6 +1,6 @@ """ meta attributes for packaging which does not import any dependencies """ -__version__ = '3.4.4' +__version__ = '3.4.5' __author__ = 'python-ldap project' __license__ = 'Python style' diff --git a/Lib/ldapurl.py b/Lib/ldapurl.py index b4dfd890..57900028 100644 --- a/Lib/ldapurl.py +++ b/Lib/ldapurl.py @@ -4,7 +4,7 @@ See https://www.python-ldap.org/ for details. """ -__version__ = '3.4.4' +__version__ = '3.4.5' __all__ = [ # constants diff --git a/Lib/ldif.py b/Lib/ldif.py index fa41321c..7bfe5b4c 100644 --- a/Lib/ldif.py +++ b/Lib/ldif.py @@ -3,7 +3,7 @@ See https://www.python-ldap.org/ for details. """ -__version__ = '3.4.4' +__version__ = '3.4.5' __all__ = [ # constants diff --git a/Lib/slapdtest/__init__.py b/Lib/slapdtest/__init__.py index 7c410180..0fabc4c4 100644 --- a/Lib/slapdtest/__init__.py +++ b/Lib/slapdtest/__init__.py @@ -4,7 +4,7 @@ See https://www.python-ldap.org/ for details. """ -__version__ = '3.4.4' +__version__ = '3.4.5' from slapdtest._slapdtest import SlapdObject, SlapdTestCase, SysLogHandler from slapdtest._slapdtest import requires_ldapi, requires_sasl, requires_tls From 7ab1fa99e95710f9007d5cd5736fd383cb0863c6 Mon Sep 17 00:00:00 2001 From: Iwan Date: Sat, 17 Jan 2026 15:26:55 +0100 Subject: [PATCH 06/16] fix(ldap.schema): Explicitly close url file to avoid ResourceWarning in Python 3.14 --- Lib/ldap/schema/subentry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/ldap/schema/subentry.py b/Lib/ldap/schema/subentry.py index b83d819b..3f73df71 100644 --- a/Lib/ldap/schema/subentry.py +++ b/Lib/ldap/schema/subentry.py @@ -476,10 +476,10 @@ def urlfetch(uri,trace_level=0): l.unbind_s() del l else: - ldif_file = urlopen(uri) - ldif_parser = ldif.LDIFRecordList(ldif_file,max_entries=1) - ldif_parser.parse() - subschemasubentry_dn,s_temp = ldif_parser.all_records[0] + with urlopen(uri) as ldif_file: + ldif_parser = ldif.LDIFRecordList(ldif_file,max_entries=1) + ldif_parser.parse() + subschemasubentry_dn,s_temp = ldif_parser.all_records[0] # Work-around for mixed-cased attribute names subschemasubentry_entry = ldap.cidict.cidict() s_temp = s_temp or {} From 142a9ca2f3b01ce8e52a298abae1b542f657a393 Mon Sep 17 00:00:00 2001 From: Florian Best Date: Wed, 6 May 2026 18:04:04 +0200 Subject: [PATCH 07/16] fix(ldif): explicitly close sockets after fetching URLs fixes resource warnings in Python 3.14 --- Lib/ldif.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/ldif.py b/Lib/ldif.py index 7bfe5b4c..356f95ea 100644 --- a/Lib/ldif.py +++ b/Lib/ldif.py @@ -373,7 +373,8 @@ def _next_key_and_value(self): if self._process_url_schemes: u = urlparse(url) if u[0] in self._process_url_schemes: - attr_value = urlopen(url).read() + with urlopen(url) as fd: + attr_value = fd.read() else: # All values should be valid ascii; we support UTF-8 as a # non-official, backwards compatibility layer. From de9119d7a0eb7a81931206f756003800bab5766f Mon Sep 17 00:00:00 2001 From: Florian Best Date: Wed, 6 May 2026 18:26:15 +0200 Subject: [PATCH 08/16] ci(github): update github actions > Ubuntu with Python pypy3.10 > Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/checkout@v4, actions/setup-python@v5. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ --- .github/workflows/ci.yml | 4 ++-- .github/workflows/tox-fedora.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06d0b2ed..36fde319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - "ubuntu-22.04" steps: - name: Checkout - uses: "actions/checkout@v4" + uses: "actions/checkout@v6" - name: Install apt dependencies run: | set -ex @@ -40,7 +40,7 @@ jobs: - name: Disable AppArmor run: sudo aa-disable /usr/sbin/slapd - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true diff --git a/.github/workflows/tox-fedora.yml b/.github/workflows/tox-fedora.yml index bc6f45c5..cef8df1a 100644 --- a/.github/workflows/tox-fedora.yml +++ b/.github/workflows/tox-fedora.yml @@ -9,7 +9,7 @@ jobs: tox_test: name: Tox env "${{matrix.tox_env}}" on Fedora steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Run Tox tests uses: fedora-python/tox-github-action@main with: From 54be2ee27426d81b6cd7a897b872e9401292c2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Wed, 29 Oct 2025 13:31:06 +0000 Subject: [PATCH 09/16] test: Switch to using slapd + -T for tool use Using a locally compiled OpenLDAP impossible without this as libtool wrappers eat argv[0] of the symlinked binaries. Using a locally compiled OpenLDAP still often needs changes to the configuration, this should be a reasonably easy step forward. --- Lib/slapdtest/_slapdtest.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Lib/slapdtest/_slapdtest.py b/Lib/slapdtest/_slapdtest.py index 00764ac3..1563659b 100644 --- a/Lib/slapdtest/_slapdtest.py +++ b/Lib/slapdtest/_slapdtest.py @@ -264,7 +264,6 @@ def _find_commands(self): self.PATH_LDAPDELETE = self._find_command('ldapdelete') self.PATH_LDAPMODIFY = self._find_command('ldapmodify') self.PATH_LDAPWHOAMI = self._find_command('ldapwhoami') - self.PATH_SLAPADD = self._find_command('slapadd') self.PATH_SLAPD = os.environ.get('SLAPD', None) if not self.PATH_SLAPD: @@ -281,7 +280,7 @@ def _find_command(self, cmd, in_sbin=False): if command is None: raise ValueError( "Command '{}' not found. Set the {} environment variable to " - "override slapdtest's search path.".format(cmd, var_name) + "override slapdtest's search path: {}.".format(cmd, var_name, path) ) return command @@ -352,6 +351,7 @@ def gen_config(self): 'cafile': self.cafile, 'servercert': self.servercert, 'serverkey': self.serverkey, + 'slapd_path': self.SBIN_PATH, } return self.slapd_conf_template % config_dict @@ -513,14 +513,17 @@ def _cli_auth_args(self): # no cover to avoid spurious coverage changes def _cli_popen(self, ldapcommand, extra_args=None, ldap_uri=None, - stdin_data=None): # pragma: no cover + stdin_data=None, tool=None): # pragma: no cover if ldap_uri is None: ldap_uri = self.default_ldap_uri if ldapcommand.split("/")[-1].startswith("ldap"): args = [ldapcommand, '-H', ldap_uri] + self._cli_auth_args() else: - args = [ldapcommand, '-F', self._slapd_conf] + if tool: + args = [ldapcommand, '-T', tool, '-F', self._slapd_conf] + else: + args = [ldapcommand, '-F', self._slapd_conf] args += (extra_args or []) @@ -579,9 +582,10 @@ def slapadd(self, ldif, extra_args=None): Runs slapadd on this slapd instance, passing it the ldif content """ self._cli_popen( - self.PATH_SLAPADD, + self.PATH_SLAPD, stdin_data=ldif.encode("utf-8") if ldif else None, extra_args=extra_args, + tool='add' ) def __enter__(self): From 0dcaa430f2234abb6cbf53113b0f31b30e68b0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Wed, 29 Oct 2025 13:40:10 +0000 Subject: [PATCH 10/16] test: Store logs and keep failed run data on failure --- Lib/slapdtest/_slapdtest.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Lib/slapdtest/_slapdtest.py b/Lib/slapdtest/_slapdtest.py index 1563659b..b60313b0 100644 --- a/Lib/slapdtest/_slapdtest.py +++ b/Lib/slapdtest/_slapdtest.py @@ -412,12 +412,17 @@ def _start_slapd(self): '-F', self._slapd_conf, '-h', ' '.join(urls), ] + stderr = None if self._log.isEnabledFor(logging.DEBUG): slapd_args.extend(['-d', '-1']) + stderr = os.open(os.path.join(self.testrundir, 'slapd.log'), os.O_WRONLY|os.O_CREAT) else: slapd_args.extend(['-d', '0']) self._log.info('starting slapd: %r', ' '.join(slapd_args)) - self._proc = subprocess.Popen(slapd_args) + self._proc = subprocess.Popen(slapd_args, stderr=stderr) + if stderr is not None: + os.close(stderr) + stderr = None # Waits until the LDAP server socket is open, or slapd crashed deadline = time.monotonic() + 10 # no cover to avoid spurious coverage changes, see @@ -457,7 +462,7 @@ def start(self): self._proc.pid, self.ldap_uri, self.ldapi_uri ) - def stop(self): + def stop(self, cleanup=True): """ Stops the slapd server, and waits for it to terminate and cleans up """ @@ -465,7 +470,8 @@ def stop(self): self._log.debug('stopping slapd with pid %d', self._proc.pid) self._proc.terminate() self.wait() - self._cleanup_rundir() + if cleanup: + self._cleanup_rundir() atexit.unregister(self.stop) def restart(self): @@ -593,7 +599,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - self.stop() + self.stop(exc_type is None) class SlapdTestCase(unittest.TestCase): @@ -622,4 +628,4 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - cls.server.stop() + cls.server.stop(False) From 7df168b90581ebf5a1821440f3bfb97b152ac4db Mon Sep 17 00:00:00 2001 From: Florian Best Date: Fri, 8 Aug 2025 09:19:45 +0200 Subject: [PATCH 11/16] feat(ldapobject): allow passing `uri=None` in `SimpleLDAPObject` and `ReconnectLDAPObject` to be consistent with `initialize()`. Fixes: 7af31254dcb22a58686cb140ac320af9a6f967fc Issue: #465 --- Lib/ldap/ldapobject.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/ldap/ldapobject.py b/Lib/ldap/ldapobject.py index c94df89d..fdbd09bc 100644 --- a/Lib/ldap/ldapobject.py +++ b/Lib/ldap/ldapobject.py @@ -67,7 +67,7 @@ class SimpleLDAPObject: } def __init__( - self,uri, + self,uri=None, trace_level=0,trace_file=None,trace_stack_limit=5,bytes_mode=None, bytes_strictness=None, fileno=None ): @@ -848,7 +848,7 @@ class ReconnectLDAPObject(SimpleLDAPObject): _reconnect_exceptions = (ldap.SERVER_DOWN, ldap.UNAVAILABLE, ldap.CONNECT_ERROR, ldap.TIMEOUT) def __init__( - self,uri, + self,uri=None, trace_level=0,trace_file=None,trace_stack_limit=5,bytes_mode=None, bytes_strictness=None, retry_max=1, retry_delay=60.0, fileno=None ): From 4364ede2b6b2da7bbec474548b6c5850c0d08669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6niger?= Date: Sun, 7 Jan 2024 09:44:32 +0100 Subject: [PATCH 12/16] test: Test valid and invalid attrlist parameters Add tests test_valid_attrlist_parameter_types and test_invalid_attrlist_parameter_types which test the behaviour when passing different Python types. All iterables which return only strings should pass, everything else should raise a TypeError. --- Tests/t_ldapobject.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Tests/t_ldapobject.py b/Tests/t_ldapobject.py index 87a829a3..ab334b7b 100644 --- a/Tests/t_ldapobject.py +++ b/Tests/t_ldapobject.py @@ -478,6 +478,7 @@ def test_dse(self): [self.server.suffix.encode('utf-8')] ) + def test_compare_s_true(self): base = self.server.suffix l = self._ldap_conn @@ -569,6 +570,38 @@ def test_slapadd(self): ("myAttribute", b'foobar'), ]) + def test_valid_attrlist_parameter_types(self): + """Tests the case when a valid parameter type is passed to search_ext + + Any iterable which only contains strings should not raise any errors. + """ + + l = self._ldap_conn + + valid_attrlist_parameters = [{"a": "2"}, ["a", "b"], {}, set(), set(["a", "b"])] + + for attrlist in valid_attrlist_parameters: + out = l.search_ext( + "%s" % self.server.suffix, ldap.SCOPE_SUBTREE, attrlist=attrlist + ) + + def test_invalid_attrlist_parameter_types(self): + """Tests the case when an invalid parameter type is passed to search_ext + + Any object type that is either not a interable or does contain something + that isn't a string should raise a TypeError. The exception is the string type itself. + """ + + invalid_attrlist_parameters = [{1: 2}, 0, object(), "string"] + + l = self._ldap_conn + + for attrlist in invalid_attrlist_parameters: + with self.assertRaises(TypeError): + l.search_ext( + "%s" % self.server.suffix, ldap.SCOPE_SUBTREE, attrlist=attrlist + ) + class Test01_ReconnectLDAPObject(Test00_SimpleLDAPObject): """ From f3f792e70d0d231efafb459d346e9f8e4bc1ca65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6niger?= Date: Sun, 7 Jan 2024 10:14:55 +0100 Subject: [PATCH 13/16] fix(LDAPObject): Prevent memory errors in attrs_from_List Function `PySequence_Length` can return -1 on iterables like `dict`. The following PyMem_NEW still succeeds due `PyMem_NEW(char *, -1 + 1)` being equivalent to `char** PyMem_Malloc(1)`, which then can result in a segmentation fault later on. Solution: Use `seq` and `PySequence_Size` to determine the size of the sequence. This way any iterable which contains only strings can be used. Co-authored-by: Christian Heimes --- Modules/LDAPObject.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/LDAPObject.c b/Modules/LDAPObject.c index 71fac73e..a008b5f9 100644 --- a/Modules/LDAPObject.c +++ b/Modules/LDAPObject.c @@ -288,7 +288,10 @@ attrs_from_List(PyObject *attrlist, char ***attrsp) if (seq == NULL) goto error; - len = PySequence_Length(attrlist); + len = PySequence_Size(seq); + if (len == -1) { + goto error; + } attrs = PyMem_NEW(char *, len + 1); From e6439247eed77dd6ea3bb03b8241ac02278ae62c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Thu, 5 Oct 2023 12:23:57 +0100 Subject: [PATCH 14/16] feat(ldap): retrieve libldap version on load --- Modules/constants.c | 8 ++++++++ Modules/ldapmodule.c | 4 ++++ Modules/pythonldap.h | 2 ++ 3 files changed, 14 insertions(+) diff --git a/Modules/constants.c b/Modules/constants.c index f0a0da94..64804e8d 100644 --- a/Modules/constants.c +++ b/Modules/constants.c @@ -229,6 +229,14 @@ LDAPinit_constants(PyObject *m) if (PyModule_AddIntConstant(m, "LIBLDAP_R", thread_safe) != 0) return -1; + if (ldap_get_option(NULL, LDAP_OPT_API_INFO, &ldap_version_info) != LDAP_SUCCESS) { + PyErr_SetString(PyExc_ImportError, "unrecognised libldap version"); + return -1; + } + if (PyModule_AddIntConstant(m, "_VENDOR_VERSION_RUNTIME", + ldap_version_info.ldapai_vendor_version ) != 0) + return -1; + /* Generated constants -- see Lib/ldap/constants.py */ #define add_err(n) do { \ diff --git a/Modules/ldapmodule.c b/Modules/ldapmodule.c index cb3f58fb..d0735356 100644 --- a/Modules/ldapmodule.c +++ b/Modules/ldapmodule.c @@ -5,6 +5,10 @@ #define _STR(x) #x #define STR(x) _STR(x) +LDAPAPIInfo ldap_version_info = { + .ldapai_info_version = LDAP_API_INFO_VERSION, +}; + static char version_str[] = STR(LDAPMODULE_VERSION); static char author_str[] = STR(LDAPMODULE_AUTHOR); static char license_str[] = STR(LDAPMODULE_LICENSE); diff --git a/Modules/pythonldap.h b/Modules/pythonldap.h index 7703af5e..35ed0d92 100644 --- a/Modules/pythonldap.h +++ b/Modules/pythonldap.h @@ -71,6 +71,8 @@ PYLDAP_FUNC(PyObject *) LDAPerror(LDAP *); PYLDAP_FUNC(PyObject *) LDAPraise_for_message(LDAP *, LDAPMessage *m); PYLDAP_FUNC(PyObject *) LDAPerr(int errnum); +PYLDAP_DATA(LDAPAPIInfo) ldap_version_info; + #ifndef LDAP_CONTROL_PAGE_OID #define LDAP_CONTROL_PAGE_OID "1.2.840.113556.1.4.319" #endif /* !LDAP_CONTROL_PAGE_OID */ From 6e471e3b828dc8a382086c7d54a4f8d3b4fd3a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Mon, 21 Aug 2023 19:09:58 +0100 Subject: [PATCH 15/16] feat(ldapobject): Add wrapper for ldap_connect --- Lib/ldap/ldapobject.py | 7 +++++++ Modules/LDAPObject.c | 33 +++++++++++++++++++++++++++++++++ Tests/t_cext.py | 23 +++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/Lib/ldap/ldapobject.py b/Lib/ldap/ldapobject.py index fdbd09bc..057fe71a 100644 --- a/Lib/ldap/ldapobject.py +++ b/Lib/ldap/ldapobject.py @@ -171,6 +171,13 @@ def fileno(self): """ return self.get_option(ldap.OPT_DESC) + def connect(self): + """ + connect() -> None + Establishes LDAP connection if needed. + """ + return self._ldap_call(self._l.connect) + def abandon_ext(self,msgid,serverctrls=None,clientctrls=None): """ abandon_ext(msgid[,serverctrls=None[,clientctrls=None]]) -> None diff --git a/Modules/LDAPObject.c b/Modules/LDAPObject.c index a008b5f9..f96a6068 100644 --- a/Modules/LDAPObject.c +++ b/Modules/LDAPObject.c @@ -1475,6 +1475,38 @@ l_ldap_extended_operation(LDAPObject *self, PyObject *args) return PyLong_FromLong(msgid); } +/* ldap_connect */ + +static PyObject * +l_ldap_connect(LDAPObject *self, PyObject Py_UNUSED(args)) +{ +#if LDAP_VENDOR_VERSION >= 20500 + int ldaperror; + + if (ldap_version_info.ldapai_vendor_version < 20500) +#endif + { + PyErr_SetString(PyExc_NotImplementedError, + "loaded libldap doesn't support this feature"); + return NULL; + } + +#if LDAP_VENDOR_VERSION >= 20500 + if (not_valid(self)) + return NULL; + + LDAP_BEGIN_ALLOW_THREADS(self); + ldaperror = ldap_connect(self->ldap); + LDAP_END_ALLOW_THREADS(self); + + if ( ldaperror != LDAP_SUCCESS ) + return LDAPerror(self->ldap); + + Py_INCREF(Py_None); + return Py_None; +#endif +} + /* methods */ static PyMethodDef methods[] = { @@ -1504,6 +1536,7 @@ static PyMethodDef methods[] = { {"cancel", (PyCFunction)l_ldap_cancel, METH_VARARGS}, #endif {"extop", (PyCFunction)l_ldap_extended_operation, METH_VARARGS}, + {"connect", (PyCFunction)l_ldap_connect, METH_NOARGS}, {NULL, NULL} }; diff --git a/Tests/t_cext.py b/Tests/t_cext.py index 33fbf29a..846127f8 100644 --- a/Tests/t_cext.py +++ b/Tests/t_cext.py @@ -280,6 +280,29 @@ def test_simple_anonymous_bind(self): self.assertEqual(pmsg, []) self.assertEqual(ctrls, []) + @unittest.skipUnless( + _ldap.VENDOR_VERSION >= 20500 and \ + _ldap._VENDOR_VERSION_RUNTIME >= 20500, + reason="Test requires libldap 2.5+" + ) + def test_connect(self): + l = self._open_conn(bind=False) + invalid_fileno = l.get_option(_ldap.OPT_DESC) + l.connect() + fileno = l.get_option(_ldap.OPT_DESC) + self.assertNotEqual(invalid_fileno, fileno) + + self._bind_conn(l) + + @unittest.skipUnless( + _ldap._VENDOR_VERSION_RUNTIME < 20500, + reason="Test requires linking to libldap < 2.5" + ) + def test_connect_notimpl(self): + l = self._open_conn(bind=False) + with self.assertRaises(NotImplementedError): + l.connect() + def test_anon_rootdse_search(self): l = self._open_conn(bind=False) # see if we can get the rootdse with anon search (without prior bind) From bd877c978d4f029737c033139d7c8429950a8ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Fri, 6 Oct 2023 11:21:06 +0100 Subject: [PATCH 16/16] feat(ldapobject): Add documentation for LDAPObject.connect() --- Doc/reference/ldap.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Doc/reference/ldap.rst b/Doc/reference/ldap.rst index 1d095adb..397b7663 100644 --- a/Doc/reference/ldap.rst +++ b/Doc/reference/ldap.rst @@ -973,6 +973,15 @@ and wait for and return with the server's result, or with The *dn* and *attr* arguments are text strings; see :ref:`bytes_mode`. +.. py:method:: LDAPObject.connect() -> None + + Opens a connection to the server if one is not established already. If that + fails, an instance of :py:exc:`ldap.LDAPError` is raised. + + Requires libldap 2.5+ and will fail with :py:exc:`NotImplementedError` + if that is not met. + + .. py:method:: LDAPObject.delete(dn) -> int .. py:method:: LDAPObject.delete_s(dn) -> None