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: 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/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/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 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/extop/dds.py b/Lib/ldap/extop/dds.py index 7fab0813..a970d71d 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 or self.requestName, b'') self.entryName = entryName self.requestTtl = requestTtl or self.defaultRequestTtl diff --git a/Lib/ldap/ldapobject.py b/Lib/ldap/ldapobject.py index 7e7b8158..057fe71a 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 ): @@ -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 @@ -833,7 +840,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. """ @@ -848,7 +855,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 ): 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/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 {} 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/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..356f95ea 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 @@ -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. 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 diff --git a/Lib/slapdtest/_slapdtest.py b/Lib/slapdtest/_slapdtest.py index 4110d945..b60313b0 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 @@ -259,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: @@ -276,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 @@ -347,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 @@ -407,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 @@ -452,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 """ @@ -460,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): @@ -508,14 +519,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 []) @@ -574,9 +588,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): @@ -584,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): @@ -613,4 +628,4 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - cls.server.stop() + cls.server.stop(False) diff --git a/Modules/LDAPObject.c b/Modules/LDAPObject.c index 71fac73e..f96a6068 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); @@ -1472,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[] = { @@ -1501,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/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 */ 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_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) 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 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): """ 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"