From 6a92648b6f13113fe075dc256c2c7ffc01e5236c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Wed, 6 Apr 2022 11:10:51 +0100 Subject: [PATCH 1/5] Control and extop rework --- Lib/ldap/__init__.py | 6 +++++ Lib/ldap/controls/__init__.py | 11 +++++---- Lib/ldap/controls/deref.py | 4 +--- Lib/ldap/controls/libldap.py | 8 +------ Lib/ldap/controls/openldap.py | 3 --- Lib/ldap/controls/pagedresults.py | 5 +--- Lib/ldap/controls/ppolicy.py | 5 +--- Lib/ldap/controls/psearch.py | 4 +--- Lib/ldap/controls/pwdpolicy.py | 6 +---- Lib/ldap/controls/readentry.py | 6 +---- Lib/ldap/controls/simple.py | 13 +++-------- Lib/ldap/controls/sss.py | 6 +---- Lib/ldap/controls/vlv.py | 7 +----- Lib/ldap/extop/__init__.py | 38 +++++++++++++++++++++++++++++-- Lib/ldap/syncrepl.py | 6 +---- 15 files changed, 62 insertions(+), 66 deletions(-) diff --git a/Lib/ldap/__init__.py b/Lib/ldap/__init__.py index b1797078..c8c74ddc 100644 --- a/Lib/ldap/__init__.py +++ b/Lib/ldap/__init__.py @@ -43,6 +43,12 @@ if k.startswith('OPT_'): OPT_NAMES_DICT[v]=k +# OID to class registries +KNOWN_RESPONSE_CONTROLS = {} +KNOWN_INTERMEDIATE_RESPONSES = {} +KNOWN_EXTENDED_RESPONSES = {} + + class DummyLock: """Define dummy class with methods compatible to threading.Lock""" def __init__(self): diff --git a/Lib/ldap/controls/__init__.py b/Lib/ldap/controls/__init__.py index 73557168..c6de528d 100644 --- a/Lib/ldap/controls/__init__.py +++ b/Lib/ldap/controls/__init__.py @@ -15,12 +15,12 @@ ImportError(f'ldap {__version__} and _ldap {_ldap.__version__} version mismatch!') import ldap +from ldap import KNOWN_RESPONSE_CONTROLS from pyasn1.error import PyAsn1Error __all__ = [ - 'KNOWN_RESPONSE_CONTROLS', # Classes 'AssertionControl', 'BooleanControl', @@ -37,9 +37,6 @@ 'DecodeControlTuples', ] -# response control OID to class registry -KNOWN_RESPONSE_CONTROLS = {} - class RequestControl: """ @@ -77,6 +74,12 @@ class ResponseControl: sets the criticality of the received control (boolean) """ + def __init_subclass__(cls): + if not getattr(cls, 'controlType', None): + return + + KNOWN_RESPONSE_CONTROLS.setdefault(cls.controlType, cls) + def __init__(self,controlType=None,criticality=False): self.controlType = controlType self.criticality = criticality diff --git a/Lib/ldap/controls/deref.py b/Lib/ldap/controls/deref.py index e5b2a7ec..8e58584a 100644 --- a/Lib/ldap/controls/deref.py +++ b/Lib/ldap/controls/deref.py @@ -11,7 +11,7 @@ ] import ldap.controls -from ldap.controls import LDAPControl,KNOWN_RESPONSE_CONTROLS +from ldap.controls import LDAPControl import pyasn1_modules.rfc2251 from pyasn1.type import namedtype,univ,tag @@ -114,5 +114,3 @@ def decodeControlValue(self,encodedControlValue): self.derefRes[str(deref_attr)].append((str(deref_val),partial_attrs_dict)) except KeyError: self.derefRes[str(deref_attr)] = [(str(deref_val),partial_attrs_dict)] - -KNOWN_RESPONSE_CONTROLS[DereferenceControl.controlType] = DereferenceControl diff --git a/Lib/ldap/controls/libldap.py b/Lib/ldap/controls/libldap.py index 9a102379..76c754f0 100644 --- a/Lib/ldap/controls/libldap.py +++ b/Lib/ldap/controls/libldap.py @@ -13,7 +13,7 @@ import ldap -from ldap.controls import RequestControl,LDAPControl,KNOWN_RESPONSE_CONTROLS +from ldap.controls import RequestControl,LDAPControl class AssertionControl(RequestControl): @@ -33,8 +33,6 @@ def __init__(self,criticality=True,filterstr='(objectClass=*)'): def encodeControlValue(self): return _ldap.encode_assertion_control(self.filterstr) -KNOWN_RESPONSE_CONTROLS[ldap.CONTROL_ASSERT] = AssertionControl - class MatchedValuesControl(RequestControl): """ @@ -54,8 +52,6 @@ def __init__(self,criticality=False,filterstr='(objectClass=*)'): def encodeControlValue(self): return _ldap.encode_valuesreturnfilter_control(self.filterstr) -KNOWN_RESPONSE_CONTROLS[ldap.CONTROL_VALUESRETURNFILTER] = MatchedValuesControl - class SimplePagedResultsControl(LDAPControl): """ @@ -77,5 +73,3 @@ def encodeControlValue(self): def decodeControlValue(self,encodedControlValue): self.size,self.cookie = _ldap.decode_page_control(encodedControlValue) - -KNOWN_RESPONSE_CONTROLS[ldap.CONTROL_PAGEDRESULTS] = SimplePagedResultsControl diff --git a/Lib/ldap/controls/openldap.py b/Lib/ldap/controls/openldap.py index 24040ed7..5540989a 100644 --- a/Lib/ldap/controls/openldap.py +++ b/Lib/ldap/controls/openldap.py @@ -39,9 +39,6 @@ def decodeControlValue(self,encodedControlValue): self.numSearchContinuations = int(decodedValue[2]) -ldap.controls.KNOWN_RESPONSE_CONTROLS[SearchNoOpControl.controlType] = SearchNoOpControl - - class SearchNoOpMixIn: """ Mix-in class to be used with class LDAPObject and friends. diff --git a/Lib/ldap/controls/pagedresults.py b/Lib/ldap/controls/pagedresults.py index 12ca573d..ced06e05 100644 --- a/Lib/ldap/controls/pagedresults.py +++ b/Lib/ldap/controls/pagedresults.py @@ -11,7 +11,7 @@ # Imports from python-ldap 2.4+ import ldap.controls -from ldap.controls import RequestControl,ResponseControl,KNOWN_RESPONSE_CONTROLS +from ldap.controls import RequestControl,ResponseControl # Imports from pyasn1 from pyasn1.type import tag,namedtype,univ,constraint @@ -44,6 +44,3 @@ def decodeControlValue(self,encodedControlValue): decodedValue,_ = decoder.decode(encodedControlValue,asn1Spec=PagedResultsControlValue()) self.size = int(decodedValue.getComponentByName('size')) self.cookie = bytes(decodedValue.getComponentByName('cookie')) - - -KNOWN_RESPONSE_CONTROLS[SimplePagedResultsControl.controlType] = SimplePagedResultsControl diff --git a/Lib/ldap/controls/ppolicy.py b/Lib/ldap/controls/ppolicy.py index f3a8416d..6f8eff0c 100644 --- a/Lib/ldap/controls/ppolicy.py +++ b/Lib/ldap/controls/ppolicy.py @@ -11,7 +11,7 @@ # Imports from python-ldap 2.4+ from ldap.controls import ( - ResponseControl, ValueLessRequestControl, KNOWN_RESPONSE_CONTROLS + ResponseControl, ValueLessRequestControl ) # Imports from pyasn1 @@ -100,6 +100,3 @@ def decodeControlValue(self,encodedControlValue): error = ppolicyValue.getComponentByName('error') if error.hasValue(): self.error = int(error) - - -KNOWN_RESPONSE_CONTROLS[PasswordPolicyControl.controlType] = PasswordPolicyControl diff --git a/Lib/ldap/controls/psearch.py b/Lib/ldap/controls/psearch.py index 32900c8b..23d13441 100644 --- a/Lib/ldap/controls/psearch.py +++ b/Lib/ldap/controls/psearch.py @@ -14,7 +14,7 @@ # Imports from python-ldap 2.4+ import ldap.controls -from ldap.controls import RequestControl,ResponseControl,KNOWN_RESPONSE_CONTROLS +from ldap.controls import RequestControl,ResponseControl # Imports from pyasn1 from pyasn1.type import namedtype,namedval,univ,constraint @@ -125,5 +125,3 @@ def decodeControlValue(self,encodedControlValue): else: self.changeNumber = None return (self.changeType,self.previousDN,self.changeNumber) - -KNOWN_RESPONSE_CONTROLS[EntryChangeNotificationControl.controlType] = EntryChangeNotificationControl diff --git a/Lib/ldap/controls/pwdpolicy.py b/Lib/ldap/controls/pwdpolicy.py index 54f1a700..b6fc8c33 100644 --- a/Lib/ldap/controls/pwdpolicy.py +++ b/Lib/ldap/controls/pwdpolicy.py @@ -12,7 +12,7 @@ # Imports from python-ldap 2.4+ import ldap.controls -from ldap.controls import RequestControl,ResponseControl,ValueLessRequestControl,KNOWN_RESPONSE_CONTROLS +from ldap.controls import ResponseControl class PasswordExpiringControl(ResponseControl): @@ -24,8 +24,6 @@ class PasswordExpiringControl(ResponseControl): def decodeControlValue(self,encodedControlValue): self.gracePeriod = int(encodedControlValue) -KNOWN_RESPONSE_CONTROLS[PasswordExpiringControl.controlType] = PasswordExpiringControl - class PasswordExpiredControl(ResponseControl): """ @@ -35,5 +33,3 @@ class PasswordExpiredControl(ResponseControl): def decodeControlValue(self,encodedControlValue): self.passwordExpired = encodedControlValue=='0' - -KNOWN_RESPONSE_CONTROLS[PasswordExpiredControl.controlType] = PasswordExpiredControl diff --git a/Lib/ldap/controls/readentry.py b/Lib/ldap/controls/readentry.py index 7b2a7e89..468d481a 100644 --- a/Lib/ldap/controls/readentry.py +++ b/Lib/ldap/controls/readentry.py @@ -8,7 +8,7 @@ import ldap from pyasn1.codec.ber import encoder,decoder -from ldap.controls import LDAPControl,KNOWN_RESPONSE_CONTROLS +from ldap.controls import LDAPControl from pyasn1_modules.rfc2251 import AttributeDescriptionList,SearchResultEntry @@ -63,8 +63,6 @@ class PreReadControl(ReadEntryControl): """ controlType = ldap.CONTROL_PRE_READ -KNOWN_RESPONSE_CONTROLS[PreReadControl.controlType] = PreReadControl - class PostReadControl(ReadEntryControl): """ @@ -83,5 +81,3 @@ class PostReadControl(ReadEntryControl): after the operation was done by the server """ controlType = ldap.CONTROL_POST_READ - -KNOWN_RESPONSE_CONTROLS[PostReadControl.controlType] = PostReadControl diff --git a/Lib/ldap/controls/simple.py b/Lib/ldap/controls/simple.py index 96837e2a..b6274c9e 100644 --- a/Lib/ldap/controls/simple.py +++ b/Lib/ldap/controls/simple.py @@ -5,7 +5,7 @@ """ import struct,ldap -from ldap.controls import RequestControl,ResponseControl,LDAPControl,KNOWN_RESPONSE_CONTROLS +from ldap.controls import RequestControl,ResponseControl from pyasn1.type import univ from pyasn1.codec.ber import encoder,decoder @@ -31,7 +31,7 @@ def encodeControlValue(self): return None -class OctetStringInteger(LDAPControl): +class OctetStringInteger: """ Base class with controlValue being unsigend integer values @@ -51,7 +51,7 @@ def decodeControlValue(self,encodedControlValue): self.integerValue = struct.unpack('!Q',encodedControlValue)[0] -class BooleanControl(LDAPControl): +class BooleanControl: """ Base class for simple request controls with boolean control value. @@ -82,8 +82,6 @@ class ManageDSAITControl(ValueLessRequestControl): def __init__(self,criticality=False): ValueLessRequestControl.__init__(self,ldap.CONTROL_MANAGEDSAIT,criticality=False) -KNOWN_RESPONSE_CONTROLS[ldap.CONTROL_MANAGEDSAIT] = ManageDSAITControl - class RelaxRulesControl(ValueLessRequestControl): """ @@ -93,8 +91,6 @@ class RelaxRulesControl(ValueLessRequestControl): def __init__(self,criticality=False): ValueLessRequestControl.__init__(self,ldap.CONTROL_RELAX,criticality=False) -KNOWN_RESPONSE_CONTROLS[ldap.CONTROL_RELAX] = RelaxRulesControl - class ProxyAuthzControl(RequestControl): """ @@ -134,9 +130,6 @@ def decodeControlValue(self,encodedControlValue): self.authzId = encodedControlValue -KNOWN_RESPONSE_CONTROLS[AuthorizationIdentityResponseControl.controlType] = AuthorizationIdentityResponseControl - - class GetEffectiveRightsControl(RequestControl): """ Get Effective Rights Control diff --git a/Lib/ldap/controls/sss.py b/Lib/ldap/controls/sss.py index e6ee3686..0dbbf532 100644 --- a/Lib/ldap/controls/sss.py +++ b/Lib/ldap/controls/sss.py @@ -14,9 +14,7 @@ import sys import ldap -from ldap.ldapobject import LDAPObject -from ldap.controls import (RequestControl, ResponseControl, - KNOWN_RESPONSE_CONTROLS, DecodeControlTuples) +from ldap.controls import RequestControl, ResponseControl from pyasn1.type import univ, namedtype, tag, namedval, constraint from pyasn1.codec.ber import encoder, decoder @@ -130,5 +128,3 @@ def decodeControlValue(self, encoded): # backward compatibility class attributes self.result = self.sortResult self.attribute_type_error = self.attributeType - -KNOWN_RESPONSE_CONTROLS[SSSResponseControl.controlType] = SSSResponseControl diff --git a/Lib/ldap/controls/vlv.py b/Lib/ldap/controls/vlv.py index 5fc7ce88..7cb4b482 100644 --- a/Lib/ldap/controls/vlv.py +++ b/Lib/ldap/controls/vlv.py @@ -12,8 +12,7 @@ import ldap from ldap.ldapobject import LDAPObject -from ldap.controls import (RequestControl, ResponseControl, - KNOWN_RESPONSE_CONTROLS, DecodeControlTuples) +from ldap.controls import RequestControl, ResponseControl from pyasn1.type import univ, namedtype, tag, namedval, constraint from pyasn1.codec.ber import encoder, decoder @@ -88,8 +87,6 @@ def encodeControlValue(self): p.setComponentByName('target', target) return encoder.encode(p) -KNOWN_RESPONSE_CONTROLS[VLVRequestControl.controlType] = VLVRequestControl - class VirtualListViewResultType(univ.Enumerated): namedValues = namedval.NamedValues( @@ -138,5 +135,3 @@ def decodeControlValue(self,encoded): self.content_count = self.contentCount self.result = self.virtualListViewResult self.context_id = self.contextID - -KNOWN_RESPONSE_CONTROLS[VLVResponseControl.controlType] = VLVResponseControl diff --git a/Lib/ldap/extop/__init__.py b/Lib/ldap/extop/__init__.py index dc9aea2f..a4383f78 100644 --- a/Lib/ldap/extop/__init__.py +++ b/Lib/ldap/extop/__init__.py @@ -10,6 +10,7 @@ """ from ldap import __version__ +from ldap import KNOWN_EXTENDED_RESPONSES, KNOWN_INTERMEDIATE_RESPONSES class ExtendedRequest: @@ -42,12 +43,18 @@ class ExtendedResponse: """ Generic base class for a LDAPv3 extended operation response - requestName - OID as string of the LDAPv3 extended operation response + responseName + OID as string of the LDAPv3 extended operation response or None encodedResponseValue BER-encoded ASN.1 value of the LDAPv3 extended operation response """ + def __init_subclass__(cls): + if not getattr(cls, 'responseName', None): + return + + KNOWN_EXTENDED_RESPONSES.setdefault(cls.responseName, cls) + def __init__(self,responseName,encodedResponseValue): self.responseName = responseName self.responseValue = self.decodeResponseValue(encodedResponseValue) @@ -63,6 +70,33 @@ def decodeResponseValue(self,value): return value +class IntermediateResponse: + """ + Generic base class for a LDAPv3 intermediate response message + + responseName + OID as string of the LDAPv3 intermediate response message or None + encodedResponseValue + BER-encoded ASN.1 value of the LDAPv3 intermediate response message + """ + + def __init_subclass__(cls): + if not getattr(cls, 'responseName', None): + return + + KNOWN_INTERMEDIATE_RESPONSES.setdefault(cls.responseName, cls) + + def __repr__(self): + return f'{self.__class__.__name__}({self.responseName},{self.responseValue})' + + def decodeResponseValue(self,value): + """ + decodes the BER-encoded ASN.1 extended operation response value and + sets the appropriate class attributes + """ + return value + + # Import sub-modules from ldap.extop.dds import * from ldap.extop.passwd import PasswordModifyResponse diff --git a/Lib/ldap/syncrepl.py b/Lib/ldap/syncrepl.py index fd0c1285..c59641e1 100644 --- a/Lib/ldap/syncrepl.py +++ b/Lib/ldap/syncrepl.py @@ -11,7 +11,7 @@ from pyasn1.codec.ber import encoder, decoder from ldap.pkginfo import __version__, __author__, __license__ -from ldap.controls import RequestControl, ResponseControl, KNOWN_RESPONSE_CONTROLS +from ldap.controls import RequestControl, ResponseControl from ldap import RES_SEARCH_RESULT, RES_SEARCH_ENTRY, RES_INTERMEDIATE __all__ = [ @@ -159,8 +159,6 @@ def decodeControlValue(self, encodedControlValue): self.state = self.__class__.opnames[int(state)] self.entryUUID = str(uuid) -KNOWN_RESPONSE_CONTROLS[SyncStateControl.controlType] = SyncStateControl - class SyncDoneValue(univ.Sequence): """ @@ -200,8 +198,6 @@ def decodeControlValue(self, encodedControlValue): else: self.refreshDeletes = None -KNOWN_RESPONSE_CONTROLS[SyncDoneControl.controlType] = SyncDoneControl - class RefreshDelete(univ.Sequence): """ From f1da4bb6d6cecf2a3d961c1a3e6fda687225c6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Thu, 24 Mar 2022 11:32:42 +0000 Subject: [PATCH 2/5] Draft new response handling API --- Doc/fake_ldap_module_for_documentation.py | 2 + Lib/ldap/__init__.py | 1 + Lib/ldap/connection.py | 224 +++++++++++ Lib/ldap/response.py | 216 ++++++++++ Modules/LDAPObject.c | 459 ++++++++++++++++++++++ Modules/constants.c | 16 +- Modules/ldapcontrol.c | 66 +++- Modules/ldapmodule.c | 38 ++ Modules/pythonldap.h | 6 + 9 files changed, 1016 insertions(+), 12 deletions(-) create mode 100644 Lib/ldap/connection.py create mode 100644 Lib/ldap/response.py diff --git a/Doc/fake_ldap_module_for_documentation.py b/Doc/fake_ldap_module_for_documentation.py index 30807819..7e10ac29 100644 --- a/Doc/fake_ldap_module_for_documentation.py +++ b/Doc/fake_ldap_module_for_documentation.py @@ -28,3 +28,5 @@ def get_option(num): class LDAPError: pass + +_exceptions = {} diff --git a/Lib/ldap/__init__.py b/Lib/ldap/__init__.py index c8c74ddc..29c5fb99 100644 --- a/Lib/ldap/__init__.py +++ b/Lib/ldap/__init__.py @@ -35,6 +35,7 @@ assert _ldap.__version__==__version__, \ ImportError(f'ldap {__version__} and _ldap {_ldap.__version__} version mismatch!') from _ldap import * +from _ldap import _exceptions # call into libldap to initialize it right now LIBLDAP_API_INFO = _ldap.get_option(_ldap.OPT_API_INFO) diff --git a/Lib/ldap/connection.py b/Lib/ldap/connection.py new file mode 100644 index 00000000..d7e043ca --- /dev/null +++ b/Lib/ldap/connection.py @@ -0,0 +1,224 @@ +""" +connection.py - wraps class _ldap.LDAPObject + +See https://www.python-ldap.org/ for details. +""" + +from ldap.pkginfo import __version__, __author__, __license__ + +__all__ = [ + 'Connection', +] + + +from numbers import Real +from typing import AnyStr, Optional, Union + +import ldap +from ldap.controls import DecodeControlTuples, RequestControl +from ldap.extop import ExtendedRequest +from ldap.ldapobject import SimpleLDAPObject, NO_UNIQUE_ENTRY +from ldap.response import ( + Response, + SearchEntry, SearchReference, SearchResult, + IntermediateResponse, ExtendedResult, +) + +from ldapurl import LDAPUrl + +RequestControls = Optional[list[RequestControl]] + + +# TODO: remove _ext and _s functions as we rework request API +class Connection(SimpleLDAPObject): + resp_ctrl_classes = None + + def __init__(self, uri: Union[LDAPUrl, str, None], **kwargs): + if isinstance(uri, LDAPUrl): + uri = uri.unparse() + super().__init__(uri, **kwargs) + + def result(self, msgid: int = ldap.RES_ANY, *, all: int = 1, + timeout: Optional[float] = None) -> Optional[list[Response]]: + """ + result([msgid: int = RES_ANY [, all: int = 1 [, timeout : + Optional[float] = None]]]) -> Optional[list[Response]] + + This method is used to wait for and return the result of an + operation previously initiated by one of the LDAP asynchronous + operation routines (e.g. search(), modify(), etc.) They all + return an invocation identifier (a message id) upon successful + initiation of their operation. This id is guaranteed to be + unique across an LDAP session, and can be used to request the + result of a specific operation via the msgid parameter of the + result() method. + + If the result of a specific operation is required, msgid should + be set to the invocation message id returned when the operation + was initiated; otherwise RES_ANY should be supplied. + + The all parameter is used to wait until a final response for + a given operation is received, this is useful with operations + (like search) that generate multiple responses and is used + to select whether a single item should be returned or to wait + for all the responses before returning. + + Using search as an example: A search response is made up of + zero or more search entries followed by a search result. If all + is 0, search entries will be returned one at a time as they + come in, via separate calls to result(). If all is 1, the + search response will be returned in its entirety, i.e. after + all entries and the final search result have been received. If + all is 2, all search entries that have been received so far + will be returned. + + The method returns a list of messages or None if polling and no + messages arrived yet. + + The result() method will block for timeout seconds, or + indefinitely if timeout is negative. A timeout of 0 will + effect a poll. The timeout can be expressed as a floating-point + value. If timeout is None the default in self.timeout is used. + + If a timeout occurs, a TIMEOUT exception is raised, unless + polling (timeout = 0), in which case None is returned. + """ + + if timeout is None: + timeout = self.timeout + + messages = self._ldap_call(self._l.result, msgid, all, timeout) + + if messages is None: + return None + + results = [] + for msgid, msgtype, controls, data in messages: + controls = DecodeControlTuples(controls, self.resp_ctrl_classes) + + m = Response(msgid, msgtype, controls, **data) + results.append(m) + + return results + + def bind_s(self, dn: Optional[str] = None, + cred: Optional[AnyStr] = None, *, + method: int = ldap.AUTH_SIMPLE, + ctrls: RequestControls = None) -> ldap.response.BindResult: + msgid = self.bind(dn, cred, method) + responses = self.result(msgid) + result, = responses + return result + + def compare_s(self, dn: str, attr: str, value: bytes, *, + ctrls: RequestControls = None + ) -> ldap.response.CompareResult: + "TODO: remove _s functions introducing a better request API" + msgid = self.compare_ext(dn, attr, value, serverctrls=ctrls) + responses = self.result(msgid) + result, = responses + return bool(result) + + def delete_s(self, dn: str, *, + ctrls: RequestControls = None) -> ldap.response.DeleteResult: + msgid = self.delete_ext(dn, serverctrls=ctrls) + responses = self.result(msgid) + result, = responses + return result + + def extop_s(self, oid: Optional[str] = None, + value: Optional[bytes] = None, *, + request: Optional[ExtendedRequest] = None, + ctrls: RequestControls = None + ) -> list[Union[IntermediateResponse, ExtendedResult]]: + if request is not None: + oid = request.requestName + value = request.encodedRequestValue() + + msgid = self.extop(oid, value, serverctrls=ctrls) + return self.result(msgid) + + def search_s(self, base: Optional[str] = None, + scope: int = ldap.SCOPE_SUBTREE, + filter: str = "(objectClass=*)", + attrlist: Optional[list[str]] = None, *, + attrsonly: bool = False, + ctrls: RequestControls = None, + sizelimit: int = 0, timelimit: int = -1, + timeout: Optional[Real] = None + ) -> list[Union[SearchEntry, SearchReference]]: + if timeout is None: + timeout = timelimit + + msgid = self.search_ext(base, scope, filter, attrlist=attrlist, + attrsonly=attrsonly, serverctrls=ctrls, + sizelimit=sizelimit, timeout=timelimit) + result = self.result(msgid, timeout=timeout) + result[-1].raise_for_result() + return result[:-1] + + def search_subschemasubentry_s( + self, dn: Optional[str] = None) -> Optional[str]: + """ + Returns the distinguished name of the sub schema sub entry + for a part of a DIT specified by dn. + + None as result indicates that the DN of the sub schema sub entry could + not be determined. + """ + empty_dn = '' + attrname = 'subschemaSubentry' + if dn is None: + dn = empty_dn + try: + r = self.search_s(dn, ldap.SCOPE_BASE, None, [attrname]) + except (ldap.NO_SUCH_OBJECT, ldap.NO_SUCH_ATTRIBUTE, + ldap.INSUFFICIENT_ACCESS): + r = [] + except ldap.UNDEFINED_TYPE: + return None + + attr = r and ldap.cidict.cidict(r[0].attrs).get(attrname) + if attr: + return attr[0].decode('utf-8') + elif dn: + # Try to find sub schema sub entry in root DSE + return self.search_subschemasubentry_s(dn=empty_dn) + else: + # If dn was already rootDSE we can return here + return None + + def read_s(self, dn: str, filterstr: Optional[str] = None, + attrlist: Optional[list[str]] = None, + ctrls: RequestControls = None, + timeout: int = -1) -> dict[str, bytes]: + """ + Reads and returns a single entry specified by `dn'. + + Other attributes just like those passed to `search_s()' + """ + r = self.search_s(dn, ldap.SCOPE_BASE, filterstr, + attrlist=attrlist, ctrls=ctrls, timeout=timeout) + if r: + return r[0].attrs + else: + return None + + def find_unique_entry(self, base: Optional[str] = None, + scope: int = ldap.SCOPE_SUBTREE, + filter: str = "(objectClass=*)", + attrlist: Optional[list[str]] = None, *, + attrsonly: bool = False, + ctrls: RequestControls = None, + timelimit: int = -1, + timeout: Optional[Real] = None + ) -> list[Union[SearchEntry, SearchReference]]: + """ + Returns a unique entry, raises exception if not unique + """ + r = self.search_s(base, scope, filter, attrlist=attrlist, + attrsonly=attrsonly, ctrls=ctrls, timeout=timeout, + sizelimit=2) + if len(r) != 1: + raise NO_UNIQUE_ENTRY(f'No or non-unique search result for {filter}') + return r[0] diff --git a/Lib/ldap/response.py b/Lib/ldap/response.py new file mode 100644 index 00000000..1e75c4ad --- /dev/null +++ b/Lib/ldap/response.py @@ -0,0 +1,216 @@ +""" +response.py - classes for LDAP responses + +See https://www.python-ldap.org/ for details. +""" + +from ldap.pkginfo import __version__, __author__, __license__ + +__all__ = [ + 'Response', + 'Result', + + 'SearchEntry', + 'SearchReference', + 'SearchResult', + + 'IntermediateResponse', + 'ExtendedResult', + + 'BindResult', + 'ModifyResult', + 'AddResult', + 'DeleteResult', + 'ModRDNResult', + 'CompareResult', +] + +from typing import Optional + +import ldap +from ldap.controls import ResponseControl +from ldap.extop import ExtendedResponse, Intermediate + + +_SUCCESS_CODES = [ + ldap.SUCCESS.errnum, + ldap.COMPARE_TRUE.errnum, + ldap.COMPARE_FALSE.errnum, + ldap.SASL_BIND_IN_PROGRESS.errnum, +] + + +class Response: + msgid: int + msgtype: int + controls: list[ResponseControl] + + __subclasses: dict[int, type] = {} + + def __init_subclass__(cls): + if not hasattr(cls, 'msgtype'): + return + c = __class__.__subclasses.setdefault(cls.msgtype, cls) + assert issubclass(cls, c) + + def __new__(cls, msgid, msgtype, controls=None, **kwargs): + if cls is not __class__: + instance = super().__new__(cls) + instance.msgid = msgid + instance.msgtype = msgtype + instance.controls = controls + return instance + + c = __class__.__subclasses.get(msgtype) + if c: + return c.__new__(c, msgid, msgtype, controls, **kwargs) + + instance = super().__new__(cls) + instance.msgid = msgid + instance.msgtype = msgtype + instance.controls = controls + return instance + + +class Result(Response): + result: int + matcheddn: str + message: str + referrals: Optional[list[str]] + + def __new__(cls, msgid, msgtype, controls, + result, matcheddn, message, referrals, **kwargs): + instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) + + instance.result = result + instance.matcheddn = matcheddn + instance.message = message + instance.referrals = referrals + + return instance + + def raise_for_result(self) -> 'Result': + if self.result in _SUCCESS_CODES: + return self + raise ldap._exceptions.get(self.result, ldap.LDAPError)(self) + + def __repr__(self): + return (f"{self.__class__.__name__}" + f"(msgid={self.msgid}, result={self.result})") + + +class SearchEntry(Response): + msgtype = ldap.RES_SEARCH_ENTRY + + dn: str + attrs: dict[str, Optional[list[bytes]]] + + def __new__(cls, msgid, msgtype, controls, dn, attrs, **kwargs): + instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) + + instance.dn = dn + instance.attrs = attrs + + return instance + + +class SearchReference(Response): + msgtype = ldap.RES_SEARCH_REFERENCE + + referrals: list[str] + + def __new__(cls, msgid, msgtype, controls, referrals, **kwargs): + instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) + + instance.referrals = referrals + + return instance + + +class SearchResult(Result): + msgtype = ldap.RES_SEARCH_RESULT + + +class IntermediateResponse(Response): + msgtype = ldap.RES_INTERMEDIATE + + oid: Optional[str] + value: Optional[bytes] + + __subclasses: dict[str, type] = {} + + def __new__(cls, msgid, msgtype, controls=None, name=None, + value=None, *, defaultClass: Optional[Intermediate] = None, + **kwargs): + if cls is not __class__: + instance = super().__new__(cls, ) + return instance + + c = __class__.__subclasses.get(msgtype) + if c: + return c.__new__(c, msgid, msgtype, controls, **kwargs) + + instance = super().__new__(cls) + instance.msgid = msgid + instance.msgtype = msgtype + instance.controls = controls + return instance + + +class BindResult(Result): + msgtype = ldap.RES_BIND + + servercreds: Optional[bytes] + + +class ModifyResult(Result): + msgtype = ldap.RES_MODIFY + + +class AddResult(Result): + msgtype = ldap.RES_ADD + + +class DeleteResult(Result): + msgtype = ldap.RES_DELETE + + +class ModRDNResult(Result): + msgtype = ldap.RES_MODRDN + + +class CompareResult(Result): + msgtype = ldap.RES_COMPARE + + def __bool__(self) -> bool: + if self.result == ldap.COMPARE_FALSE.errnum: + return False + if self.result == ldap.COMPARE_TRUE.errnum: + return True + raise ldap._exceptions.get(self.result, ldap.LDAPError)(self) + + +class ExtendedResult(Result): + msgtype = ldap.RES_EXTENDED + + oid: Optional[str] + value: Optional[bytes] + # TODO: how to subclass these dynamically? (UnsolicitedResponse, ...), + # is it just with __new__? + + +class UnsolicitedResponse(ExtendedResult): + msgid = ldap.RES_UNSOLICITED + + __subclasses: dict[str, type] = {} + + def __new__(cls, msgid, msgtype, controls=None, *, + name, value=None, **kwargs): + if cls is __class__: + c = __class__.__subclasses.get(msgtype) + if c: + return c.__new__(c, msgid, msgtype, controls, + name=name, value=value, **kwargs) + + return super().__new__(cls, msgid, msgtype, controls, + name=name, value=value, **kwargs) diff --git a/Modules/LDAPObject.c b/Modules/LDAPObject.c index 71fac73e..779e175f 100644 --- a/Modules/LDAPObject.c +++ b/Modules/LDAPObject.c @@ -10,6 +10,33 @@ #include #endif +PyStructSequence_Field message_fields[] = { + { + .name = "msgid", + }, + { + .name = "type", + }, + { + .name = "controls", + }, + { + .name = "data", + }, + { + .name = NULL, + } +}; + +PyStructSequence_Desc message_tuple_desc = { + .name = "_ldap._RawLDAPMessage", + .doc = "LDAP Message returned from native code", + .fields = message_fields, + .n_in_sequence = 4, +}; + +PyTypeObject message_tuple_type; + static void free_attrs(char ***); /* constructor */ @@ -1035,6 +1062,437 @@ l_ldap_rename(LDAPObject *self, PyObject *args) return PyLong_FromLong(msgid); } +/* Connection.result() */ + +static PyObject * +l_ldap_result(LDAPObject *self, PyObject *args) +{ + PyObject *retval = NULL, *pytmp = NULL; + LDAPMessage *result = NULL, *msg; + BerElement *ber = NULL; + int rc = LDAP_SUCCESS, res_type, msgid = LDAP_RES_ANY; + int count, msg_index = 0, all = 1; + double timeout = -1.0; + struct timeval tv, *tvp = NULL; + + if (!PyArg_ParseTuple + (args, "|iid:result", &msgid, &all, &timeout)) + return NULL; + if (not_valid(self)) + return NULL; + + if (timeout >= 0) { + tvp = &tv; + set_timeval_from_double(tvp, timeout); + } + + LDAP_BEGIN_ALLOW_THREADS(self); + res_type = ldap_result(self->ldap, msgid, all, tvp, &result); + LDAP_END_ALLOW_THREADS(self); + + /* LDAP or system error */ + if ( res_type < 0 ) { + result = NULL; + rc = res_type; + goto error; + } + + if ( res_type == 0 ) { + /* Polls return None, timeouts raise an exception */ + if ( timeout == 0 ) { + Py_RETURN_NONE; + } else { + rc = LDAP_TIMEOUT; + goto error; + } + } + + count = ldap_count_messages( self->ldap, result ); + if ( (retval = PyList_New(count)) == NULL ) { + goto error; + } + + for ( msg = ldap_first_message( self->ldap, result ); + msg; + msg = ldap_next_message( self->ldap, msg ) ) { + PyObject *msgtuple, *data; + LDAPControl **controls = NULL; + + msgid = ldap_msgid( msg ); + res_type = ldap_msgtype( msg ); + + if ( (pytmp = PyStructSequence_New( &message_tuple_type )) == NULL ) { + goto error; + } + PyList_SET_ITEM( retval, msg_index++, pytmp ); + msgtuple = pytmp; + + if ( (pytmp = PyLong_FromUnsignedLong( msgid )) == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( msgtuple, 0, pytmp ); + + if ( (pytmp = PyLong_FromUnsignedLong( res_type )) == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( msgtuple, 1, pytmp ); + + if ( (pytmp = PyDict_New()) == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( msgtuple, 3, pytmp ); + data = pytmp; + + if ( res_type == LDAP_RES_SEARCH_ENTRY ) { + struct berval dn, attr, *values = NULL; + PyObject *attrdict; + + if ( (rc = ldap_get_dn_ber( self->ldap, msg, &ber, &dn )) != LDAP_SUCCESS ) { + goto error; + } + pytmp = PyUnicode_FromString( dn.bv_val ); + if ( !pytmp || PyDict_SetItemString( data, "dn", pytmp ) ) { + goto error; + } + Py_DECREF( pytmp ); + + pytmp = PyDict_New(); + if ( !pytmp || PyDict_SetItemString( data, "attrs", pytmp ) ) { + goto error; + } + Py_DECREF( pytmp ); + attrdict = pytmp; + pytmp = NULL; + + while ( (rc = ldap_get_attribute_ber( self->ldap, msg, ber, + &attr, &values )) == LDAP_SUCCESS ) { + PyObject *value_list = NULL; + struct berval *bv; + int index = 0; + + if ( attr.bv_val == NULL ) { + break; + } + + /* + * Some servers will send multiple attribute entries with same + * name, be prepared for that and just merge them. + * https://github.com/python-ldap/python-ldap/issues/218 + */ + value_list = PyDict_GetItemString( attrdict, attr.bv_val ); + if ( value_list == NULL ) { + count = 0; + if ( values ) { + for ( bv = values; bv->bv_val != NULL; bv++ ) count++; + } + value_list = PyList_New( count ); + if ( value_list == NULL ) { + ldap_memfree( values ); + goto error; + } + } else { + Py_INCREF(value_list); + index = PyList_Size( value_list ); + } + /* + * At this point we have our own reference on value_list + * independent on the one in attrdict, we'll release it after + * assignment. + */ + + for ( bv = values; bv->bv_val != NULL; bv++, index++ ) { + pytmp = LDAPberval_to_object( bv ); + if ( !pytmp || PyList_SetItem( value_list, index, pytmp ) ) { + ldap_memfree( values ); + Py_DECREF(value_list); + goto error; + } + } + pytmp = NULL; + + /* FIXME: deal with attrs-only */ + assert(0); + + ldap_memfree( values ); + if ( PyDict_SetItemString( attrdict, attr.bv_val, value_list ) ) { + Py_DECREF(value_list); + goto error; + } + Py_DECREF(value_list); + } + + if ( (rc = ldap_get_entry_controls( self->ldap, msg, + &controls )) != LDAP_SUCCESS ) { + goto error; + } + pytmp = LDAPControls_to_List( controls ); + ldap_controls_free( controls ); + if ( pytmp == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( msgtuple, 2, pytmp ); + + pytmp = NULL; + if ( ber != NULL ) { + ber_free( ber, 0 ); + ber = NULL; + } + } else if ( res_type == LDAP_RES_SEARCH_REFERENCE ) { + PyObject *refs_list = NULL; + char **refs = NULL; + int index; + + if ( (rc = ldap_parse_reference( self->ldap, msg, &refs, + &controls, 0 )) != LDAP_SUCCESS ) { + goto error; + } + + count = 0; + if ( refs ) { + char **p; + for ( p = refs; *p; p++ ) count++; + } + + pytmp = PyList_New( count ); + if ( !pytmp || PyDict_SetItemString( data, "referrals", pytmp ) ) { + ldap_memvfree( (void **)refs ); + ldap_controls_free( controls ); + goto error; + } + Py_DECREF( pytmp ); + refs_list = pytmp; + + pytmp = LDAPControls_to_List( controls ); + ldap_controls_free( controls ); + if ( pytmp == NULL ) { + ldap_memvfree( (void **)refs ); + goto error; + } + PyStructSequence_SET_ITEM( msgtuple, 2, pytmp ); + + for ( index = 0; refs[index]; index++ ) { + if ( (pytmp = PyUnicode_FromString( refs[index] )) == NULL ) { + ldap_memvfree( (void **)refs ); + goto error; + } + PyList_SET_ITEM( refs_list, index, pytmp ); + } + ldap_memvfree( (void **)refs ); + } else if ( res_type == LDAP_RES_INTERMEDIATE ) { + char *oid = NULL; + struct berval *value = NULL; + + if ( (rc = ldap_parse_intermediate( self->ldap, msg, &oid, + &value, &controls, 0 )) != LDAP_SUCCESS ) { + goto error; + } + /* + * Given Python 3.6 supports ordered dict, be nice and store the + * fields in order + */ + + if ( oid ) { + pytmp = PyUnicode_FromString( oid ); + ldap_memfree( oid ); + if ( !pytmp || PyDict_SetItemString( data, "name", pytmp ) ) { + ber_bvfree( value ); + ldap_controls_free( controls ); + goto error; + } + Py_DECREF( pytmp ); + } else { + pytmp = NULL; + if ( PyDict_SetItemString( data, "name", Py_None ) ) { + ber_bvfree( value ); + ldap_controls_free( controls ); + goto error; + } + } + + if ( value ) { + pytmp = LDAPberval_to_object( value ); + ber_bvfree( value ); + if ( !pytmp || PyDict_SetItemString( data, "value", pytmp ) ) { + ldap_controls_free( controls ); + goto error; + } + Py_DECREF( pytmp ); + } else { + pytmp = NULL; + if ( PyDict_SetItemString( data, "value", Py_None ) ) { + ldap_controls_free( controls ); + goto error; + } + } + + pytmp = LDAPControls_to_List( controls ); + ldap_controls_free( controls ); + if ( pytmp == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( msgtuple, 2, pytmp ); + } else { + int index, error = LDAP_SUCCESS; + char *matcheddn = NULL, *errmsg = NULL, **referrals = NULL; + PyObject *refs_list = NULL; + + if ( (rc = ldap_parse_result( self->ldap, msg, &error, &matcheddn, + &errmsg, &referrals, &controls, 0 )) != LDAP_SUCCESS ) { + goto error; + } + + pytmp = PyLong_FromLong( error ); + if ( !pytmp || PyDict_SetItemString( data, "result", pytmp ) ) { + ldap_memfree( matcheddn ); + ldap_memfree( errmsg ); + ldap_memvfree( (void **)referrals ); + ldap_controls_free( controls ); + goto error; + } + Py_DECREF( pytmp ); + + if ( matcheddn ) { + pytmp = PyUnicode_FromString( matcheddn ); + ldap_memfree( matcheddn ); + } else { + pytmp = Py_None; + Py_INCREF(pytmp); + } + if ( !pytmp || PyDict_SetItemString( data, "matcheddn", pytmp ) ) { + ldap_memfree( errmsg ); + ldap_memvfree( (void **)referrals ); + ldap_controls_free( controls ); + goto error; + } + Py_DECREF( pytmp ); + + if ( errmsg ) { + pytmp = PyUnicode_FromString( errmsg ); + ldap_memfree( errmsg ); + } else { + pytmp = Py_None; + Py_INCREF(pytmp); + } + if ( !pytmp || PyDict_SetItemString( data, "message", pytmp ) ) { + ldap_memvfree( (void **)referrals ); + ldap_controls_free( controls ); + goto error; + } + Py_DECREF( pytmp ); + + count = 0; + if ( referrals ) { + char **p; + for ( p = referrals; *p; p++ ) count++; + pytmp = PyList_New( count ); + } else { + pytmp = Py_None; + Py_INCREF(pytmp); + } + if ( !pytmp || PyDict_SetItemString( data, "referrals", pytmp ) ) { + ldap_memvfree( (void **)referrals ); + ldap_controls_free( controls ); + goto error; + } + Py_DECREF( pytmp ); + refs_list = pytmp; + + for ( index = 0; index < count; index++ ) { + if ( (pytmp = PyUnicode_FromString( referrals[index] )) == NULL ) { + ldap_memvfree( (void **)referrals ); + ldap_controls_free( controls ); + goto error; + } + PyList_SET_ITEM( refs_list, index, pytmp ); + } + ldap_memvfree( (void **)referrals ); + + pytmp = LDAPControls_to_List( controls ); + ldap_controls_free( controls ); + if ( pytmp == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( msgtuple, 2, pytmp ); + pytmp = NULL; + + if ( res_type == LDAP_RES_EXTENDED ) { + char *oid = NULL; + struct berval *value = NULL; + if ( (rc = ldap_parse_extended_result( self->ldap, msg, + &oid, &value, 0 )) != LDAP_SUCCESS ) { + goto error; + } + + if ( oid ) { + pytmp = PyUnicode_FromString( oid ); + ldap_memfree( oid ); + if ( !pytmp || PyDict_SetItemString( data, "name", pytmp ) ) { + ber_bvfree( value ); + goto error; + } + Py_DECREF( pytmp ); + } else { + pytmp = NULL; + if ( PyDict_SetItemString( data, "name", Py_None ) ) { + ber_bvfree( value ); + goto error; + } + } + + if ( value ) { + pytmp = LDAPberval_to_object( value ); + ber_bvfree( value ); + if ( !pytmp || PyDict_SetItemString( data, "value", pytmp ) ) { + goto error; + } + Py_DECREF( pytmp ); + } else { + pytmp = NULL; + if ( PyDict_SetItemString( data, "value", Py_None ) ) { + goto error; + } + } + } else if ( res_type == LDAP_RES_BIND ) { + struct berval *servercred = NULL; + if ( (rc = ldap_parse_sasl_bind_result( self->ldap, msg, + &servercred, 0 )) != LDAP_SUCCESS ) { + goto error; + } + + if ( servercred ) { + pytmp = LDAPberval_to_object( servercred ); + ber_bvfree( servercred ); + if ( !pytmp || PyDict_SetItemString( data, "servercred", pytmp ) ) { + goto error; + } + Py_DECREF( pytmp ); + } else { + pytmp = NULL; + if ( PyDict_SetItemString( data, "servercred", Py_None ) ) { + goto error; + } + } + } + } + pytmp = NULL; + } + + /* Free all messages now */ + ldap_msgfree( result ); + return retval; + +error: + if ( ber != NULL ) { + ber_free( ber, 0 ); + } + ldap_msgfree( result ); + Py_XDECREF(pytmp); + Py_XDECREF(retval); + if ( rc != LDAP_SUCCESS ) + return LDAPerr(rc); + Py_RETURN_NONE; +} + /* ldap_result4 */ static PyObject * @@ -1488,6 +1946,7 @@ static PyMethodDef methods[] = { {"delete_ext", (PyCFunction)l_ldap_delete_ext, METH_VARARGS}, {"modify_ext", (PyCFunction)l_ldap_modify_ext, METH_VARARGS}, {"rename", (PyCFunction)l_ldap_rename, METH_VARARGS}, + {"result", (PyCFunction)l_ldap_result, METH_VARARGS}, {"result4", (PyCFunction)l_ldap_result4, METH_VARARGS}, {"search_ext", (PyCFunction)l_ldap_search_ext, METH_VARARGS}, #ifdef HAVE_TLS diff --git a/Modules/constants.c b/Modules/constants.c index f0a0da94..d51f054c 100644 --- a/Modules/constants.c +++ b/Modules/constants.c @@ -194,7 +194,7 @@ LDAPerror(LDAP *l) int LDAPinit_constants(PyObject *m) { - PyObject *exc, *nobj; + PyObject *exc, *nobj, *exc_dict; struct ldap_apifeature_info info = { 1, "X_OPENLDAP_THREAD_SAFE", 0 }; int thread_safe = 0; @@ -207,6 +207,14 @@ LDAPinit_constants(PyObject *m) /* exceptions */ + exc_dict = PyDict_New(); + if ( exc_dict == NULL ) { + return -1; + } + if (PyModule_AddObject(m, "_exceptions", exc_dict) != 0) { + return -1; + } + LDAPexception_class = PyErr_NewException("ldap.LDAPError", NULL, NULL); if (LDAPexception_class == NULL) { return -1; @@ -241,6 +249,12 @@ LDAPinit_constants(PyObject *m) errobjects[LDAP_##n+LDAP_ERROR_OFFSET] = exc; \ if (PyModule_AddObject(m, #n, exc) != 0) return -1; \ Py_INCREF(exc); \ + if ( LDAP_##n > 0 ) { \ + nobj = PyLong_FromUnsignedLong( LDAP_##n ); \ + if (nobj == NULL) return -1; \ + if (PyDict_SetItem(exc_dict, nobj, exc)) { Py_DECREF(nobj); return -1; } \ + Py_DECREF(nobj); \ + } \ } while (0) #define add_int(n) do { \ diff --git a/Modules/ldapcontrol.c b/Modules/ldapcontrol.c index 4a37b614..da7a1484 100644 --- a/Modules/ldapcontrol.c +++ b/Modules/ldapcontrol.c @@ -20,6 +20,30 @@ LDAPControl_DumpList( LDAPControl** lcs ) { } } */ +PyStructSequence_Field control_fields[] = { + { + .name = "oid", + }, + { + .name = "criticality", + }, + { + .name = "value", + }, + { + .name = NULL, + } +}; + +PyStructSequence_Desc control_tuple_desc = { + .name = "_ldap._RawControl", + .doc = "LDAP Control returned from native code", + .fields = control_fields, + .n_in_sequence = 3, +}; + +PyTypeObject control_tuple_type; + /* Free a single LDAPControl object created by Tuple_to_LDAPControl */ static void @@ -165,7 +189,7 @@ LDAPControls_from_object(PyObject *list, LDAPControl ***controls_ret) PyObject * LDAPControls_to_List(LDAPControl **ldcs) { - PyObject *res = 0, *pyctrl; + PyObject *retval = NULL, *pytmp = NULL; LDAPControl **tmp = ldcs; Py_ssize_t num_ctrls = 0, i; @@ -173,22 +197,42 @@ LDAPControls_to_List(LDAPControl **ldcs) while (*tmp++) num_ctrls++; - if ((res = PyList_New(num_ctrls)) == NULL) { + if ((retval = PyList_New(num_ctrls)) == NULL) { return NULL; } for (i = 0; i < num_ctrls; i++) { - pyctrl = Py_BuildValue("sbO&", - ldcs[i]->ldctl_oid, - ldcs[i]->ldctl_iscritical, - LDAPberval_to_object, &ldcs[i]->ldctl_value); - if (pyctrl == NULL) { - Py_DECREF(res); - return NULL; + PyObject *pyctrl; + + if ( (pytmp = PyStructSequence_New( &control_tuple_type )) == NULL ) { + goto error; + } + PyList_SET_ITEM( retval, i, pytmp ); + pyctrl = pytmp; + + if ( (pytmp = PyUnicode_FromString( ldcs[i]->ldctl_oid )) == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( pyctrl, 0, pytmp ); + + if ( (pytmp = PyBool_FromLong( ldcs[i]->ldctl_iscritical )) == NULL ) { + goto error; + } + PyStructSequence_SET_ITEM( pyctrl, 1, pytmp ); + + if ( (pytmp = LDAPberval_to_object( &ldcs[i]->ldctl_value )) == NULL ) { + goto error; } - PyList_SET_ITEM(res, i, pyctrl); + PyStructSequence_SET_ITEM( pyctrl, 2, pytmp ); + pytmp = NULL; } - return res; + + return retval; + +error: + Py_XDECREF(retval); + Py_XDECREF(pytmp); + return NULL; } /* --------------- en-/decoders ------------- */ diff --git a/Modules/ldapmodule.c b/Modules/ldapmodule.c index cb3f58fb..0f837ca5 100644 --- a/Modules/ldapmodule.c +++ b/Modules/ldapmodule.c @@ -30,6 +30,41 @@ static struct PyModuleDef ldap_moduledef = { methods, /* m_methods */ }; +int +LDAPinit_types( PyObject *d ) +{ + /* PyStructSequence types */ + static struct sequence_types { + PyStructSequence_Desc *desc; + PyTypeObject *where; + } sequence_types[] = { + { + .desc = &control_tuple_desc, + .where = &control_tuple_type, + }, + { + .desc = &message_tuple_desc, + .where = &message_tuple_type, + }, + { + .desc = NULL, + } + }, *type; + + for ( type = sequence_types; type->desc; type++ ) { + /* We'd like to use PyStructSequence_NewType from Stable ABI but can't + * until Python 3.8 because of https://bugs.python.org/issue34784 */ + if ( PyStructSequence_InitType2( type->where, type->desc ) ) { + return -1; + } + if ( PyDict_SetItemString( d, type->desc->name, (PyObject *)type->where ) ) { + return -1; + } + } + + return 0; +} + /* module initialisation */ PyMODINIT_FUNC @@ -57,6 +92,9 @@ PyInit__ldap() LDAPinit_functions(d); LDAPinit_control(d); + if (LDAPinit_types(d) == -1) { + return NULL; + } /* Check for errors */ if (PyErr_Occurred()) diff --git a/Modules/pythonldap.h b/Modules/pythonldap.h index 7703af5e..71bf32f4 100644 --- a/Modules/pythonldap.h +++ b/Modules/pythonldap.h @@ -57,6 +57,12 @@ PYLDAP_FUNC(PyObject *) LDAPerror_TypeError(const char *, PyObject *); PYLDAP_FUNC(void) LDAPadd_methods(PyObject *d, PyMethodDef *methods); +PYLDAP_DATA(PyStructSequence_Desc) control_tuple_desc; +PYLDAP_DATA(PyTypeObject) control_tuple_type; + +PYLDAP_DATA(PyStructSequence_Desc) message_tuple_desc; +PYLDAP_DATA(PyTypeObject) message_tuple_type; + #define PyNone_Check(o) ((o) == Py_None) /* *** berval *** */ From 8b3446c65f6e57ef14f255f7486b370f90627130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Wed, 20 Apr 2022 14:41:35 +0100 Subject: [PATCH 3/5] wip --- Doc/spelling_wordlist.txt | 1 + Lib/ldap/connection.py | 65 +++- Lib/ldap/extop/__init__.py | 122 +++++++- Lib/ldap/ldapobject.py | 11 +- Lib/ldap/response.py | 124 ++++++-- Modules/LDAPObject.c | 10 +- Modules/constants.c | 2 +- Modules/ldapcontrol.c | 12 +- Modules/message.c | 6 +- Modules/options.c | 2 +- Modules/pythonldap.h | 2 +- Tests/t_connection.py | 615 +++++++++++++++++++++++++++++++++++++ 12 files changed, 905 insertions(+), 67 deletions(-) create mode 100644 Tests/t_connection.py diff --git a/Doc/spelling_wordlist.txt b/Doc/spelling_wordlist.txt index e2150d9a..1a3dd2c5 100644 --- a/Doc/spelling_wordlist.txt +++ b/Doc/spelling_wordlist.txt @@ -116,6 +116,7 @@ refreshDeletes refreshOnly requestName requestValue +responseName resiter respvalue ResultProcessor diff --git a/Lib/ldap/connection.py b/Lib/ldap/connection.py index d7e043ca..2f6af4ad 100644 --- a/Lib/ldap/connection.py +++ b/Lib/ldap/connection.py @@ -17,10 +17,11 @@ import ldap from ldap.controls import DecodeControlTuples, RequestControl from ldap.extop import ExtendedRequest +from ldap.extop.passwd import PasswordModifyResponse from ldap.ldapobject import SimpleLDAPObject, NO_UNIQUE_ENTRY from ldap.response import ( Response, - SearchEntry, SearchReference, SearchResult, + SearchEntry, SearchReference, IntermediateResponse, ExtendedResult, ) @@ -39,10 +40,15 @@ def __init__(self, uri: Union[LDAPUrl, str, None], **kwargs): super().__init__(uri, **kwargs) def result(self, msgid: int = ldap.RES_ANY, *, all: int = 1, - timeout: Optional[float] = None) -> Optional[list[Response]]: + timeout: Optional[float] = None, + defaultIntermediateClass: + Optional[type[IntermediateResponse]] = None, + defaultExtendedClass: Optional[type[ExtendedResult]] = None + ) -> Optional[list[Response]]: """ - result([msgid: int = RES_ANY [, all: int = 1 [, timeout : - Optional[float] = None]]]) -> Optional[list[Response]] + result([msgid: int = RES_ANY [, all: int = 1 [, + timeout: Optional[float] = None]]]) + -> Optional[list[Response]] This method is used to wait for and return the result of an operation previously initiated by one of the LDAP asynchronous @@ -94,13 +100,26 @@ def result(self, msgid: int = ldap.RES_ANY, *, all: int = 1, results = [] for msgid, msgtype, controls, data in messages: - controls = DecodeControlTuples(controls, self.resp_ctrl_classes) + if controls is not None: + controls = DecodeControlTuples(controls, self.resp_ctrl_classes) + if msgtype == ldap.RES_INTERMEDIATE: + data['defaultClass'] = defaultIntermediateClass + if msgtype == ldap.RES_EXTENDED: + data['defaultClass'] = defaultExtendedClass m = Response(msgid, msgtype, controls, **data) results.append(m) return results + def add_s(self, dn: str, + modlist: list[tuple[str, Union[bytes, list[bytes]]]], *, + ctrls: RequestControls = None) -> ldap.response.AddResult: + msgid = self.add_ext(dn, modlist, serverctrls=ctrls) + responses = self.result(msgid) + result, = responses + return result + def bind_s(self, dn: Optional[str] = None, cred: Optional[AnyStr] = None, *, method: int = ldap.AUTH_SIMPLE, @@ -126,17 +145,36 @@ def delete_s(self, dn: str, *, result, = responses return result - def extop_s(self, oid: Optional[str] = None, + def extop_s(self, name: Optional[str] = None, value: Optional[bytes] = None, *, request: Optional[ExtendedRequest] = None, - ctrls: RequestControls = None + ctrls: RequestControls = None, + defaultIntermediateClass: Optional[type[IntermediateResponse]] = None, + defaultExtendedClass: Optional[type[ExtendedResult]] = None ) -> list[Union[IntermediateResponse, ExtendedResult]]: if request is not None: - oid = request.requestName + name = request.requestName value = request.encodedRequestValue() - msgid = self.extop(oid, value, serverctrls=ctrls) - return self.result(msgid) + msgid = self.extop(name, value, serverctrls=ctrls) + return self.result(msgid, + defaultIntermediateClass=defaultIntermediateClass, + defaultExtendedClass=defaultExtendedClass) + + def modify_s(self, dn: str, + modlist: list[tuple[str, Union[bytes, list[bytes]]]], *, + ctrls: RequestControls = None) -> ldap.response.ModifyResult: + msgid = self.modify_ext(dn, modlist, serverctrls=ctrls) + responses = self.result(msgid) + result, = responses + return result + + def passwd_s(self, user: Optional[str] = None, + oldpw: Optional[bytes] = None, newpw: Optional[bytes] = None, + ctrls: RequestControls = None) -> PasswordModifyResponse: + msgid = self.passwd(user, oldpw, newpw, serverctrls=ctrls) + res, = self.result(msgid, defaultExtendedClass=PasswordModifyResponse) + return res def search_s(self, base: Optional[str] = None, scope: int = ldap.SCOPE_SUBTREE, @@ -154,8 +192,11 @@ def search_s(self, base: Optional[str] = None, attrsonly=attrsonly, serverctrls=ctrls, sizelimit=sizelimit, timeout=timelimit) result = self.result(msgid, timeout=timeout) + # FIXME: we want a better way of returning a result with multiple + # messages, always useful in searches but other operations can also + # elicit those (by way of an IntermediateResponse) result[-1].raise_for_result() - return result[:-1] + return result def search_subschemasubentry_s( self, dn: Optional[str] = None) -> Optional[str]: @@ -219,6 +260,6 @@ def find_unique_entry(self, base: Optional[str] = None, r = self.search_s(base, scope, filter, attrlist=attrlist, attrsonly=attrsonly, ctrls=ctrls, timeout=timeout, sizelimit=2) - if len(r) != 1: + if len(r) != 2: raise NO_UNIQUE_ENTRY(f'No or non-unique search result for {filter}') return r[0] diff --git a/Lib/ldap/extop/__init__.py b/Lib/ldap/extop/__init__.py index a4383f78..df2be8f1 100644 --- a/Lib/ldap/extop/__init__.py +++ b/Lib/ldap/extop/__init__.py @@ -12,6 +12,13 @@ from ldap import __version__ from ldap import KNOWN_EXTENDED_RESPONSES, KNOWN_INTERMEDIATE_RESPONSES +import ldap +import ldap.response + +from typing import Optional + +_NOTSET = object() + class ExtendedRequest: """ @@ -39,7 +46,7 @@ def encodedRequestValue(self): return self.requestValue -class ExtendedResponse: +class ExtendedResponse(ldap.response.ExtendedResult): """ Generic base class for a LDAPv3 extended operation response @@ -55,9 +62,116 @@ def __init_subclass__(cls): KNOWN_EXTENDED_RESPONSES.setdefault(cls.responseName, cls) - def __init__(self,responseName,encodedResponseValue): + @classmethod + def __convert_old_api(cls, responseName_or_msgid=_NOTSET, + encodedResponseValue_or_msgtype=_NOTSET, + controls=None, *, + result=_NOTSET, matcheddn=_NOTSET, message=_NOTSET, + referrals=_NOTSET, name=None, value=None, + defaultClass: Optional[type['ExtendedResult']] = None, + msgid=_NOTSET, msgtype=_NOTSET, + responseName=_NOTSET, encodedResponseValue=_NOTSET, + **kwargs): + """ + Implements both old and new API: + __init__(self, responseName, encodedResponseValue) + and + __init__/__new__(self, msgid, msgtype, controls=None, *, + result, matcheddn, message, referrals, + defaultClass=None, **kwargs) + """ + if responseName is not _NOTSET: + name = responseName + value = encodedResponseValue + msgid = None + msgtype = ldap.RES_EXTENDED + result = ldap.SUCCESS.errnum + elif responseName_or_msgid is not _NOTSET and \ + isinstance(responseName_or_msgid, (str, type(None))): + if responseName is not _NOTSET: + raise TypeError("responseName passed twice") + if encodedResponseValue_or_msgtype is not _NOTSET and \ + encodedResponseValue is not _NOTSET: + raise TypeError("encodedResponseValue passed twice") + name = responseName = responseName_or_msgid + value = encodedResponseValue = encodedResponseValue_or_msgtype + msgid = None + msgtype = ldap.RES_EXTENDED + result = ldap.SUCCESS.errnum + else: + responseName = name + encodedResponseValue = value + if msgid is _NOTSET: + if responseName_or_msgid is _NOTSET: + raise TypeError("msgid parameter not provided") + msgid = responseName_or_msgid + if msgtype is _NOTSET: + if encodedResponseValue_or_msgtype is _NOTSET: + raise TypeError("msgtype parameter not provided") + msgtype = encodedResponseValue_or_msgtype or ldap.RES_EXTENDED + if result is _NOTSET: + raise TypeError("result parameter not provided") + if matcheddn is _NOTSET: + raise TypeError("matcheddn parameter not provided") + if message is _NOTSET: + raise TypeError("message parameter not provided") + if referrals is _NOTSET: + raise TypeError("referrals parameter not provided") + + return ( + responseName, encodedResponseValue, + (msgid, msgtype, controls), + {'result': result, + 'matcheddn': matcheddn, + 'message': message, + 'referrals': referrals, + 'name': name, + 'value': value, + 'defaultClass': defaultClass, + **kwargs + } + ) + + def __new__(cls, *args, **kwargs): + """ + Has to support both old and new API: + __new__(cls, responseName: Optional[str], + encodedResponseValue: Optional[bytes]) + and + __new__(cls, msgid: int, msgtype: int, controls: Controls = None, *, + result: int, matcheddn: str, message: str, referrals: List[str], + defaultClass: Optional[type[ExtendedResponse]] = None, + **kwargs) + + The old API is deprecated and will be removed in 4.0. + """ + # TODO: retire polymorhpism when old API is removed (4.0?) + _, _, args, kwargs = __class__.__convert_old_api(*args, **kwargs) + + return super().__new__(cls, *args, **kwargs) + + def __init__(self, *args, **kwargs): + """ + Supports both old and new API: + __init__(self, responseName: Optional[str], + encodedResponseValue: Optional[bytes]) + and + __init__(self, msgid: int, msgtype: int, controls: Controls = None, *, + result: int, matcheddn: str, message: str, referrals: List[str], + defaultClass: Optional[type[ExtendedResponse]] = None, + **kwargs) + + The old API is deprecated and will be removed in 4.0. + """ + # TODO: retire polymorhpism when old API is removed (4.0?) + responseName, encodedResponseValue, _, _ = \ + __class__.__convert_old_api(*args, **kwargs) + self.responseName = responseName - self.responseValue = self.decodeResponseValue(encodedResponseValue) + if encodedResponseValue is not None: + self.responseValue = self.decodeResponseValue(encodedResponseValue) + else: + self.responseValue = None def __repr__(self): return f'{self.__class__.__name__}({self.responseName},{self.responseValue})' @@ -70,7 +184,7 @@ def decodeResponseValue(self,value): return value -class IntermediateResponse: +class IntermediateResponse(ldap.response.IntermediateResponse): """ Generic base class for a LDAPv3 intermediate response message diff --git a/Lib/ldap/ldapobject.py b/Lib/ldap/ldapobject.py index 7a9c17f6..a8abeeb9 100644 --- a/Lib/ldap/ldapobject.py +++ b/Lib/ldap/ldapobject.py @@ -380,14 +380,17 @@ def extop_result(self,msgid=ldap.RES_ANY,all=1,timeout=None): def extop_s(self,extreq,serverctrls=None,clientctrls=None,extop_resp_class=None): msgid = self.extop(extreq,serverctrls,clientctrls) - res = self.extop_result(msgid,all=1,timeout=self.timeout) + resulttype,_,msgid,respctrls,respoid,respvalue = self.extop_result(msgid,all=1,timeout=self.timeout) + extop_resp_class = extop_resp_class or KNOWN_EXTENDED_RESPONSES.get(respoid) if extop_resp_class: - respoid,respvalue = res if extop_resp_class.responseName!=respoid: raise ldap.PROTOCOL_ERROR(f"Wrong OID in extended response! Expected {extop_resp_class.responseName}, got {respoid}") - return extop_resp_class(extop_resp_class.responseName,respvalue) + return extop_resp_class(msgid, resulttype, respctrls, + result=0, matcheddn=None, + message=None, referrals=None, + name=respoid, value=respvalue) else: - return res + return respoid, respvalue def modify_ext(self,dn,modlist,serverctrls=None,clientctrls=None): """ diff --git a/Lib/ldap/response.py b/Lib/ldap/response.py index 1e75c4ad..a9221791 100644 --- a/Lib/ldap/response.py +++ b/Lib/ldap/response.py @@ -29,7 +29,6 @@ import ldap from ldap.controls import ResponseControl -from ldap.extop import ExtendedResponse, Intermediate _SUCCESS_CODES = [ @@ -43,7 +42,7 @@ class Response: msgid: int msgtype: int - controls: list[ResponseControl] + controls: Optional[list[ResponseControl]] __subclasses: dict[int, type] = {} @@ -65,12 +64,19 @@ def __new__(cls, msgid, msgtype, controls=None, **kwargs): if c: return c.__new__(c, msgid, msgtype, controls, **kwargs) - instance = super().__new__(cls) + instance = super().__new__(cls, **kwargs) instance.msgid = msgid instance.msgtype = msgtype instance.controls = controls return instance + def __repr__(self): + optional = "" + if self.controls is not None: + optional += f", controls={self.controls}" + return (f"{self.__class__.__name__}(msgid={self.msgid}, " + f"msgtype={self.msgtype}{optional})") + class Result(Response): result: int @@ -78,7 +84,7 @@ class Result(Response): message: str referrals: Optional[list[str]] - def __new__(cls, msgid, msgtype, controls, + def __new__(cls, msgid, msgtype, controls=None, *, result, matcheddn, message, referrals, **kwargs): instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) @@ -95,8 +101,13 @@ def raise_for_result(self) -> 'Result': raise ldap._exceptions.get(self.result, ldap.LDAPError)(self) def __repr__(self): + optional = "" + if self.controls is not None: + optional = f", controls={self.controls}" + if self.message: + optional = f", message={self.message!r}" return (f"{self.__class__.__name__}" - f"(msgid={self.msgid}, result={self.result})") + f"(msgid={self.msgid}, result={self.result}{optional})") class SearchEntry(Response): @@ -105,7 +116,8 @@ class SearchEntry(Response): dn: str attrs: dict[str, Optional[list[bytes]]] - def __new__(cls, msgid, msgtype, controls, dn, attrs, **kwargs): + def __new__(cls, msgid, msgtype, controls=None, *, + dn: str, attrs: dict[str, Optional[list[bytes]]], **kwargs): instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) instance.dn = dn @@ -119,7 +131,8 @@ class SearchReference(Response): referrals: list[str] - def __new__(cls, msgid, msgtype, controls, referrals, **kwargs): + def __new__(cls, msgid, msgtype, controls=None, *, + referrals, **kwargs): instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) instance.referrals = referrals @@ -134,28 +147,43 @@ class SearchResult(Result): class IntermediateResponse(Response): msgtype = ldap.RES_INTERMEDIATE - oid: Optional[str] + name: Optional[str] value: Optional[bytes] - __subclasses: dict[str, type] = {} - - def __new__(cls, msgid, msgtype, controls=None, name=None, - value=None, *, defaultClass: Optional[Intermediate] = None, + def __new__(cls, msgid, msgtype, controls=None, *, + name=None, value=None, + defaultClass: Optional[type['IntermediateResponse']] = None, **kwargs): if cls is not __class__: - instance = super().__new__(cls, ) + instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) + instance.name = name + instance.value = value return instance - c = __class__.__subclasses.get(msgtype) + c = ldap.KNOWN_INTERMEDIATE_RESPONSES.get(name, defaultClass) if c: - return c.__new__(c, msgid, msgtype, controls, **kwargs) + instance = c.__new__(c, msgid, msgtype, controls, + name=name, value=value, **kwargs) + if hasattr(instance, 'decode'): + instance.decode(value) + return instance - instance = super().__new__(cls) - instance.msgid = msgid - instance.msgtype = msgtype - instance.controls = controls + instance = super().__new__(cls, msgid, msgtype, controls, **kwargs) + instance.name = name + instance.value = value return instance + def __repr__(self): + optional = "" + if self.name is not None: + optional += f", name={self.name}" + if self.value is not None: + optional += f", value={self.value}" + if self.controls is not None: + optional += f", controls={self.controls}" + return (f"{self.__class__.__name__}" + f"(msgid={self.msgid}{optional})") + class BindResult(Result): msgtype = ldap.RES_BIND @@ -193,24 +221,52 @@ def __bool__(self) -> bool: class ExtendedResult(Result): msgtype = ldap.RES_EXTENDED - oid: Optional[str] + responseName: Optional[str] value: Optional[bytes] - # TODO: how to subclass these dynamically? (UnsolicitedResponse, ...), - # is it just with __new__? + def __new__(cls, msgid, msgtype, controls=None, *, + result, matcheddn, message, referrals, + name=None, value=None, + defaultClass: Optional[type['ExtendedResult']] = None, + **kwargs): + if cls is not __class__: + instance = super().__new__(cls, msgid, msgtype, controls, + result=result, matcheddn=matcheddn, + message=message, referrals=referrals) + instance.name = name + instance.value = value + return instance -class UnsolicitedResponse(ExtendedResult): - msgid = ldap.RES_UNSOLICITED + c = ldap.KNOWN_EXTENDED_RESPONSES.get(name, defaultClass) + if not c and msgid == ldap.RES_UNSOLICITED: + c = UnsolicitedNotification - __subclasses: dict[str, type] = {} + if c: + return c.__new__(c, msgid, msgtype, controls, + result=result, matcheddn=matcheddn, + message=message, referrals=referrals, + name=name, value=value, **kwargs) + + instance = super().__new__(cls, msgid, msgtype, controls, + result=result, matcheddn=matcheddn, + message=message, referrals=referrals) + instance.name = name + instance.value = value + return instance + + def __repr__(self): + optional = "" + if self.name is not None: + optional += f", name={self.name}" + if self.value is not None: + optional += f", value={self.value}" + if self.message: + optional = f", message={self.message!r}" + if self.controls is not None: + optional += f", controls={self.controls}" + return (f"{self.__class__.__name__}" + f"(msgid={self.msgid}, result={self.result}{optional})") - def __new__(cls, msgid, msgtype, controls=None, *, - name, value=None, **kwargs): - if cls is __class__: - c = __class__.__subclasses.get(msgtype) - if c: - return c.__new__(c, msgid, msgtype, controls, - name=name, value=value, **kwargs) - return super().__new__(cls, msgid, msgtype, controls, - name=name, value=value, **kwargs) +class UnsolicitedNotification(ExtendedResult): + msgid = ldap.RES_UNSOLICITED diff --git a/Modules/LDAPObject.c b/Modules/LDAPObject.c index 779e175f..7c3ade7c 100644 --- a/Modules/LDAPObject.c +++ b/Modules/LDAPObject.c @@ -1225,7 +1225,7 @@ l_ldap_result(LDAPObject *self, PyObject *args) &controls )) != LDAP_SUCCESS ) { goto error; } - pytmp = LDAPControls_to_List( controls ); + pytmp = LDAPControls_to_List( controls, 0 ); ldap_controls_free( controls ); if ( pytmp == NULL ) { goto error; @@ -1262,7 +1262,7 @@ l_ldap_result(LDAPObject *self, PyObject *args) Py_DECREF( pytmp ); refs_list = pytmp; - pytmp = LDAPControls_to_List( controls ); + pytmp = LDAPControls_to_List( controls, 0 ); ldap_controls_free( controls ); if ( pytmp == NULL ) { ldap_memvfree( (void **)refs ); @@ -1325,7 +1325,7 @@ l_ldap_result(LDAPObject *self, PyObject *args) } } - pytmp = LDAPControls_to_List( controls ); + pytmp = LDAPControls_to_List( controls, 0 ); ldap_controls_free( controls ); if ( pytmp == NULL ) { goto error; @@ -1407,7 +1407,7 @@ l_ldap_result(LDAPObject *self, PyObject *args) } ldap_memvfree( (void **)referrals ); - pytmp = LDAPControls_to_List( controls ); + pytmp = LDAPControls_to_List( controls, 0 ); ldap_controls_free( controls ); if ( pytmp == NULL ) { goto error; @@ -1594,7 +1594,7 @@ l_ldap_result4(LDAPObject *self, PyObject *args) return LDAPraise_for_message(self->ldap, msg); } - if (!(pyctrls = LDAPControls_to_List(serverctrls))) { + if (!(pyctrls = LDAPControls_to_List(serverctrls, 1))) { int err = LDAP_NO_MEMORY; LDAP_BEGIN_ALLOW_THREADS(self); diff --git a/Modules/constants.c b/Modules/constants.c index d51f054c..87811def 100644 --- a/Modules/constants.c +++ b/Modules/constants.c @@ -135,7 +135,7 @@ LDAPraise_for_message(LDAP *l, LDAPMessage *m) Py_XDECREF(pyerrno); } - if (!(pyctrls = LDAPControls_to_List(serverctrls))) { + if (!(pyctrls = LDAPControls_to_List(serverctrls, 1))) { int err = LDAP_NO_MEMORY; ldap_set_option(l, LDAP_OPT_ERROR_NUMBER, &err); diff --git a/Modules/ldapcontrol.c b/Modules/ldapcontrol.c index da7a1484..1bf0fdaf 100644 --- a/Modules/ldapcontrol.c +++ b/Modules/ldapcontrol.c @@ -149,6 +149,11 @@ LDAPControls_from_object(PyObject *list, LDAPControl ***controls_ret) LDAPControl *ldc; PyObject *item; + if (PyNone_Check(list)) { + *controls_ret = NULL; + return 0; + } + if (!PySequence_Check(list)) { LDAPerror_TypeError("LDAPControls_from_object(): expected a list", list); @@ -187,15 +192,18 @@ LDAPControls_from_object(PyObject *list, LDAPControl ***controls_ret) } PyObject * -LDAPControls_to_List(LDAPControl **ldcs) +LDAPControls_to_List(LDAPControl **ldcs, int require_list) { PyObject *retval = NULL, *pytmp = NULL; LDAPControl **tmp = ldcs; Py_ssize_t num_ctrls = 0, i; - if (tmp) + if (tmp) { while (*tmp++) num_ctrls++; + } else if (!require_list) { + Py_RETURN_NONE; + } if ((retval = PyList_New(num_ctrls)) == NULL) { return NULL; diff --git a/Modules/message.c b/Modules/message.c index f1403237..c658feaf 100644 --- a/Modules/message.c +++ b/Modules/message.c @@ -69,7 +69,7 @@ LDAPmessage_to_python(LDAP *ld, LDAPMessage *m, int add_ctrls, } /* convert serverctrls to list of tuples */ - if (!(pyctrls = LDAPControls_to_List(serverctrls))) { + if (!(pyctrls = LDAPControls_to_List(serverctrls, 1))) { int err = LDAP_NO_MEMORY; ldap_set_option(ld, LDAP_OPT_ERROR_NUMBER, &err); @@ -200,7 +200,7 @@ LDAPmessage_to_python(LDAP *ld, LDAPMessage *m, int add_ctrls, return LDAPerror(ld); } /* convert serverctrls to list of tuples */ - if (!(pyctrls = LDAPControls_to_List(serverctrls))) { + if (!(pyctrls = LDAPControls_to_List(serverctrls, 1))) { int err = LDAP_NO_MEMORY; ldap_set_option(ld, LDAP_OPT_ERROR_NUMBER, &err); @@ -254,7 +254,7 @@ LDAPmessage_to_python(LDAP *ld, LDAPMessage *m, int add_ctrls, return LDAPerror(ld); } /* convert serverctrls to list of tuples */ - if (!(pyctrls = LDAPControls_to_List(serverctrls))) { + if (!(pyctrls = LDAPControls_to_List(serverctrls, 1))) { int err = LDAP_NO_MEMORY; ldap_set_option(ld, LDAP_OPT_ERROR_NUMBER, &err); diff --git a/Modules/options.c b/Modules/options.c index 4577b075..1e3f39fa 100644 --- a/Modules/options.c +++ b/Modules/options.c @@ -481,7 +481,7 @@ LDAP_get_option(LDAPObject *self, int option) if (res != LDAP_OPT_SUCCESS) return option_error(res, "ldap_get_option"); - v = LDAPControls_to_List(lcs); + v = LDAPControls_to_List(lcs, 1); ldap_controls_free(lcs); return v; diff --git a/Modules/pythonldap.h b/Modules/pythonldap.h index 71bf32f4..7f152402 100644 --- a/Modules/pythonldap.h +++ b/Modules/pythonldap.h @@ -92,7 +92,7 @@ PYLDAP_FUNC(void) LDAPinit_functions(PyObject *); PYLDAP_FUNC(void) LDAPinit_control(PyObject *d); PYLDAP_FUNC(void) LDAPControl_List_DEL(LDAPControl **); PYLDAP_FUNC(int) LDAPControls_from_object(PyObject *, LDAPControl ***); -PYLDAP_FUNC(PyObject *) LDAPControls_to_List(LDAPControl **ldcs); +PYLDAP_FUNC(PyObject *) LDAPControls_to_List(LDAPControl **ldcs, int require_list); /* *** ldapobject *** */ typedef struct { diff --git a/Tests/t_connection.py b/Tests/t_connection.py new file mode 100644 index 00000000..38135084 --- /dev/null +++ b/Tests/t_connection.py @@ -0,0 +1,615 @@ +""" +Automatic tests for python-ldap's module ldap.ldapobject + +See https://www.python-ldap.org/ for details. +""" +import errno +import linecache +import os +import socket +import unittest +import pickle + +# Switch off processing .ldaprc or ldap.conf before importing _ldap +os.environ['LDAPNOINIT'] = '1' + +import ldap +import ldap.response +from ldap.connection import Connection + +from slapdtest import SlapdTestCase +from slapdtest import requires_ldapi, requires_sasl, requires_tls +from slapdtest import requires_init_fd + + +LDIF_TEMPLATE = """dn: %(suffix)s +objectClass: dcObject +objectClass: organization +dc: %(dc)s +o: %(dc)s + +dn: %(rootdn)s +objectClass: applicationProcess +objectClass: simpleSecurityObject +cn: %(rootcn)s +userPassword: %(rootpw)s + +dn: cn=user1,%(suffix)s +objectClass: applicationProcess +objectClass: simpleSecurityObject +cn: user1 +userPassword: user1_pw + +dn: cn=Foo1,%(suffix)s +objectClass: organizationalRole +cn: Foo1 + +dn: cn=Foo2,%(suffix)s +objectClass: organizationalRole +cn: Foo2 + +dn: cn=Foo3,%(suffix)s +objectClass: organizationalRole +cn: Foo3 + +dn: ou=Container,%(suffix)s +objectClass: organizationalUnit +ou: Container + +dn: cn=Foo4,ou=Container,%(suffix)s +objectClass: organizationalRole +cn: Foo4 + +""" + +SCHEMA_TEMPLATE = """dn: cn=mySchema,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: mySchema +olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.1 NAME 'myAttribute' + DESC 'fobar attribute' + EQUALITY caseExactMatch + ORDERING caseExactOrderingMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE + USAGE userApplications + X-ORIGIN 'foobar' ) +olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.2 NAME 'myClass' + DESC 'foobar objectclass' + SUP top + STRUCTURAL + MUST myAttribute + X-ORIGIN 'foobar' )""" + + +class Test00_Connection(SlapdTestCase): + """ + test LDAP search operations + """ + + ldap_object_class = Connection + + @classmethod + def setUpClass(cls): + super().setUpClass() + # insert some Foo* objects via ldapadd + cls.server.ldapadd( + LDIF_TEMPLATE % { + 'suffix':cls.server.suffix, + 'rootdn':cls.server.root_dn, + 'rootcn':cls.server.root_cn, + 'rootpw':cls.server.root_pw, + 'dc': cls.server.suffix.split(',')[0][3:], + } + ) + + def setUp(self): + try: + self._ldap_conn + except AttributeError: + # open local LDAP connection + self._ldap_conn = self._open_ldap_conn(bytes_mode=False) + + def tearDown(self): + del self._ldap_conn + + def reset_connection(self): + try: + del self._ldap_conn + except AttributeError: + pass + + self._ldap_conn = self._open_ldap_conn(bytes_mode=False) + + def test_typechecks(self): + base = self.server.suffix + l = self._ldap_conn + + with self.assertRaises(TypeError) as e: + l.search_s( + base.encode('utf-8'), ldap.SCOPE_SUBTREE, '(cn=Foo*)', ['*'] + ) + # Python 3.4.x does not include 'search_ext()' in message + self.assertEqual( + "search_ext() argument 1 must be str, not bytes", + str(e.exception) + ) + + with self.assertRaises(TypeError) as e: + l.search_s( + base, ldap.SCOPE_SUBTREE, b'(cn=Foo*)', ['*'] + ) + self.assertEqual( + "search_ext() argument 3 must be str, not bytes", + str(e.exception) + ) + + with self.assertRaises(TypeError) as e: + l.search_s( + base, ldap.SCOPE_SUBTREE, '(cn=Foo*)', [b'*'] + ) + self.assertEqual( + ('attrs_from_List(): expected string in list', b'*'), + e.exception.args + ) + + def test_search_keys_are_text(self): + base = self.server.suffix + l = self._ldap_conn + responses = l.search_s(base, ldap.SCOPE_SUBTREE, '(cn=Foo*)', ['*']) + entries, result = responses[:-1], responses[-1] + entries = sorted((e.dn, e.attrs) for e in entries) + dn, fields = entries[0] + self.assertEqual(dn, 'cn=Foo1,%s' % base) + self.assertEqual(type(dn), str) + for key, values in fields.items(): + self.assertEqual(type(key), str) + for value in values: + self.assertEqual(type(value), bytes) + + def test_search_accepts_unicode_dn(self): + base = self.server.suffix + l = self._ldap_conn + + with self.assertRaises(ldap.NO_SUCH_OBJECT): + result = l.search_s("CN=abc\U0001f498def", ldap.SCOPE_SUBTREE) + + def test_filterstr_accepts_unicode(self): + l = self._ldap_conn + base = self.server.suffix + responses = l.search_s(base, ldap.SCOPE_SUBTREE, '(cn=abc\U0001f498def)', ['*']) + entries, result = responses[:-1], responses[-1] + self.assertEqual(entries, []) + self.assertIsInstance(result, ldap.response.SearchResult) + + def test_attrlist_accepts_unicode(self): + base = self.server.suffix + responses = self._ldap_conn.search_s( + base, ldap.SCOPE_SUBTREE, + '(cn=Foo*)', ['abc', 'abc\U0001f498def']) + entries, result = responses[:-1], responses[-1] + + for entry in entries: + self.assertIsInstance(entry.dn, str) + self.assertEqual(entry.attrs, {}) + + def test001_search_subtree(self): + responses = self._ldap_conn.search_s( + self.server.suffix, + ldap.SCOPE_SUBTREE, + '(cn=Foo*)', + attrlist=['*'], + ) + entries, result = responses[:-1], responses[-1] + entries = sorted((e.dn, e.attrs) for e in entries) + self.assertEqual( + entries, + [ + ( + 'cn=Foo1,'+self.server.suffix, + {'cn': [b'Foo1'], 'objectClass': [b'organizationalRole']} + ), + ( + 'cn=Foo2,'+self.server.suffix, + {'cn': [b'Foo2'], 'objectClass': [b'organizationalRole']} + ), + ( + 'cn=Foo3,'+self.server.suffix, + {'cn': [b'Foo3'], 'objectClass': [b'organizationalRole']} + ), + ( + 'cn=Foo4,ou=Container,'+self.server.suffix, + {'cn': [b'Foo4'], 'objectClass': [b'organizationalRole']} + ), + ] + ) + + def test002_search_onelevel(self): + responses = self._ldap_conn.search_s( + self.server.suffix, + ldap.SCOPE_ONELEVEL, + '(cn=Foo*)', + ['*'], + ) + entries, result = responses[:-1], responses[-1] + entries = sorted((e.dn, e.attrs) for e in entries) + self.assertEqual( + entries, + [ + ( + 'cn=Foo1,'+self.server.suffix, + {'cn': [b'Foo1'], 'objectClass': [b'organizationalRole']} + ), + ( + 'cn=Foo2,'+self.server.suffix, + {'cn': [b'Foo2'], 'objectClass': [b'organizationalRole']} + ), + ( + 'cn=Foo3,'+self.server.suffix, + {'cn': [b'Foo3'], 'objectClass': [b'organizationalRole']} + ), + ] + ) + + def test003_search_oneattr(self): + responses = self._ldap_conn.search_s( + self.server.suffix, + ldap.SCOPE_SUBTREE, + '(cn=Foo4)', + ['cn'], + ) + entries, result = responses[:-1], responses[-1] + entries = sorted((e.dn, e.attrs) for e in entries) + self.assertEqual( + entries, + [('cn=Foo4,ou=Container,'+self.server.suffix, {'cn': [b'Foo4']})] + ) + + def test_find_unique_entry(self): + entry = self._ldap_conn.find_unique_entry( + self.server.suffix, + ldap.SCOPE_SUBTREE, + '(cn=Foo4)', + ['cn'], + ) + self.assertEqual( + (entry.dn, entry.attrs), + ('cn=Foo4,ou=Container,'+self.server.suffix, {'cn': [b'Foo4']}) + ) + with self.assertRaises(ldap.SIZELIMIT_EXCEEDED): + # > 2 entries returned + self._ldap_conn.find_unique_entry( + self.server.suffix, + ldap.SCOPE_ONELEVEL, + '(cn=Foo*)', + ['*'], + ) + with self.assertRaises(ldap.NO_UNIQUE_ENTRY): + # 0 entries returned + self._ldap_conn.find_unique_entry( + self.server.suffix, + ldap.SCOPE_ONELEVEL, + '(cn=Bar*)', + ['*'], + ) + + def test_search_subschema(self): + l = self._ldap_conn + dn = l.search_subschemasubentry_s() + self.assertIsInstance(dn, str) + self.assertEqual(dn, "cn=Subschema") + subschema = l.read_subschemasubentry_s(dn) + self.assertIsInstance(subschema, dict) + self.assertEqual( + sorted(subschema), + [ + 'attributeTypes', + 'ldapSyntaxes', + 'matchingRuleUse', + 'matchingRules', + 'objectClasses' + ] + ) + + def test004_enotconn(self): + l = self.ldap_object_class('ldap://127.0.0.1:42') + try: + m = l.simple_bind_s("", "") + r = l.result(m, ldap.MSG_ALL, self.timeout) + except ldap.SERVER_DOWN as ldap_err: + errno_val = ldap_err.args[0]['errno'] + if errno_val != errno.ENOTCONN: + self.fail("expected errno=%d, got %d" + % (errno.ENOTCONN, errno_val)) + info = ldap_err.args[0]['info'] + expected_info = os.strerror(errno.ENOTCONN) + if info != expected_info: + self.fail(f"expected info={expected_info!r}, got {info!r}") + else: + self.fail("expected SERVER_DOWN, got %r" % r) + + def test005_invalid_credentials(self): + l = self.ldap_object_class(self.server.ldap_uri) + # search with invalid filter + with self.assertRaises(ldap.INVALID_CREDENTIALS): + m = l.simple_bind(self.server.root_dn, self.server.root_pw+'wrong') + r, = l.result(m, all=ldap.MSG_ALL) + r.raise_for_result() + + @requires_sasl() + @requires_ldapi() + def test006_sasl_external_bind_s(self): + l = self.ldap_object_class(self.server.ldapi_uri) + l.sasl_external_bind_s() + self.assertEqual(l.whoami_s(), 'dn:'+self.server.root_dn.lower()) + authz_id = 'dn:cn=Foo2,%s' % (self.server.suffix) + l = self.ldap_object_class(self.server.ldapi_uri) + l.sasl_external_bind_s(authz_id=authz_id) + self.assertEqual(l.whoami_s(), authz_id.lower()) + + @requires_sasl() + @requires_ldapi() + def test006_sasl_options(self): + l = self.ldap_object_class(self.server.ldapi_uri) + + minssf = l.get_option(ldap.OPT_X_SASL_SSF_MIN) + self.assertGreaterEqual(minssf, 0) + self.assertLessEqual(minssf, 256) + maxssf = l.get_option(ldap.OPT_X_SASL_SSF_MAX) + self.assertGreaterEqual(maxssf, 0) + # libldap sets SSF_MAX to INT_MAX + self.assertLessEqual(maxssf, 2**31 - 1) + + l.set_option(ldap.OPT_X_SASL_SSF_MIN, 56) + l.set_option(ldap.OPT_X_SASL_SSF_MAX, 256) + self.assertEqual(l.get_option(ldap.OPT_X_SASL_SSF_MIN), 56) + self.assertEqual(l.get_option(ldap.OPT_X_SASL_SSF_MAX), 256) + + l.sasl_external_bind_s() + with self.assertRaisesRegex(ValueError, "write-only option"): + l.get_option(ldap.OPT_X_SASL_SSF_EXTERNAL) + l.set_option(ldap.OPT_X_SASL_SSF_EXTERNAL, 256) + self.assertEqual(l.whoami_s(), 'dn:' + self.server.root_dn.lower()) + + def test007_timeout(self): + l = self.ldap_object_class(self.server.ldap_uri) + m = l.search_ext(self.server.suffix, ldap.SCOPE_SUBTREE, '(objectClass=*)') + l.abandon(m) + with self.assertRaises(ldap.TIMEOUT): + l.result(m, timeout=0.001) + + def assertIsSubclass(self, cls, other): + self.assertTrue( + issubclass(cls, other), + cls.__mro__ + ) + + def test_simple_bind_noarg(self): + l = self.ldap_object_class(self.server.ldap_uri) + l.simple_bind_s() + self.assertEqual(l.whoami_s(), '') + l = self.ldap_object_class(self.server.ldap_uri) + l.simple_bind_s(None, None) + self.assertEqual(l.whoami_s(), '') + + def _check_byteswarning(self, warning, expected_message): + self.assertIs(warning.category, ldap.LDAPBytesWarning) + self.assertIn(expected_message, str(warning.message)) + + def _normalize(filename): + # Python 2 likes to report the ".pyc" file in warnings, + # tracebacks or __file__. + # Use the corresponding ".py" in that case. + if filename.endswith('.pyc'): + return filename[:-1] + return filename + + # Assert warning points to a line marked CORRECT LINE in this file + self.assertEquals(_normalize(warning.filename), _normalize(__file__)) + self.assertIn( + 'CORRECT LINE', + linecache.getline(warning.filename, warning.lineno) + ) + + @requires_tls() + def test_multiple_starttls(self): + # Test for openldap does not re-register nss shutdown callbacks + # after nss_Shutdown is called + # https://github.com/python-ldap/python-ldap/issues/60 + # https://bugzilla.redhat.com/show_bug.cgi?id=1520990 + for _ in range(10): + l = self.ldap_object_class(self.server.ldap_uri) + l.set_option(ldap.OPT_X_TLS_CACERTFILE, self.server.cafile) + l.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + l.start_tls_s() + l.simple_bind_s(self.server.root_dn, self.server.root_pw) + self.assertEqual(l.whoami_s(), 'dn:' + self.server.root_dn) + + def test_dse(self): + dse = self._ldap_conn.read_rootdse_s() + self.assertIsInstance(dse, dict) + self.assertEqual(dse['supportedLDAPVersion'], [b'3']) + keys = set(dse) + # SASL info may be missing in restricted build environments + keys.discard('supportedSASLMechanisms') + self.assertEqual( + keys, + {'configContext', 'entryDN', 'namingContexts', 'objectClass', + 'structuralObjectClass', 'subschemaSubentry', + 'supportedControl', 'supportedExtension', 'supportedFeatures', + 'supportedLDAPVersion'} + ) + self.assertEqual( + self._ldap_conn.get_naming_contexts(), + [self.server.suffix.encode('utf-8')] + ) + + def test_compare_s_true(self): + base = self.server.suffix + l = self._ldap_conn + result = l.compare_s('cn=Foo1,%s' % base, 'cn', b'Foo1') + self.assertIs(result, True) + + def test_compare_s_false(self): + base = self.server.suffix + l = self._ldap_conn + result = l.compare_s('cn=Foo1,%s' % base, 'cn', b'Foo2') + self.assertIs(result, False) + + def test_compare_s_notfound(self): + base = self.server.suffix + l = self._ldap_conn + with self.assertRaises(ldap.NO_SUCH_OBJECT): + result = l.compare_s('cn=invalid,%s' % base, 'cn', b'Foo2') + + def test_compare_s_invalidattr(self): + base = self.server.suffix + l = self._ldap_conn + with self.assertRaises(ldap.UNDEFINED_TYPE): + result = l.compare_s('cn=Foo1,%s' % base, 'invalidattr', b'invalid') + + def test_result_compare_true(self): + base = self.server.suffix + l = self._ldap_conn + msgid = l.compare('cn=Foo1,%s' % base, 'cn', b'Foo1') + responses = l.result() + result, = responses + self.assertEqual(result.msgid, msgid) + self.assertIsInstance(result, ldap.response.CompareResult) + result.raise_for_result() + self.assertEqual(result.result, ldap.COMPARE_TRUE.errnum) + self.assertTrue(result) + + def test_result_compare_false(self): + base = self.server.suffix + l = self._ldap_conn + msgid = l.compare('cn=Foo1,%s' % base, 'cn', b'Foo2') + responses = l.result() + result, = responses + self.assertEqual(result.msgid, msgid) + self.assertIsInstance(result, ldap.response.CompareResult) + result.raise_for_result() + self.assertEqual(result.result, ldap.COMPARE_FALSE.errnum) + self.assertFalse(result) + + def test_result_compare_notfound(self): + base = self.server.suffix + l = self._ldap_conn + msgid = l.compare('cn=invalid,%s' % base, 'cn', b'Foo2') + responses = l.result() + result, = responses + self.assertEqual(result.msgid, msgid) + self.assertIsInstance(result, ldap.response.CompareResult) + self.assertEqual(result.result, ldap.NO_SUCH_OBJECT.errnum) + with self.assertRaises(ldap.NO_SUCH_OBJECT): + result.raise_for_result() + + with self.assertRaises(ldap.NO_SUCH_OBJECT): + if result: + pass + + def test_result_compare_invalidattr(self): + base = self.server.suffix + l = self._ldap_conn + msgid = l.compare('cn=Foo1,%s' % base, 'invalidattr', b'invalid') + responses = l.result() + result, = responses + + self.assertEqual(result.msgid, msgid) + self.assertIsInstance(result, ldap.response.CompareResult) + self.assertEqual(result.result, ldap.UNDEFINED_TYPE.errnum) + with self.assertRaises(ldap.UNDEFINED_TYPE): + result.raise_for_result() + + with self.assertRaises(ldap.UNDEFINED_TYPE): + if result: + pass + + def test_async_search_no_such_object_exception_contains_message_id(self): + msgid = self._ldap_conn.search("CN=XXX", ldap.SCOPE_SUBTREE) + with self.assertRaises(ldap.NO_SUCH_OBJECT) as cm: + r, = self._ldap_conn.result() + r.raise_for_result() + self.assertEqual(cm.exception.args[0].msgid, msgid) + + def test_passwd_s(self): + l = self._ldap_conn + + # first, create a user to change password on + dn = "cn=PasswordTest," + self.server.suffix + result = l.add_s( + dn, + [ + ('objectClass', b'person'), + ('sn', b'PasswordTest'), + ('cn', b'PasswordTest'), + ('userPassword', b'initial'), + ] + ).raise_for_result() + self.assertIsInstance(result, ldap.response.AddResult) + self.assertEqual(result.msgtype, ldap.RES_ADD) + self.assertIsInstance(result.msgid, int) + self.assertEqual(result.controls, None) + + # try changing password with a wrong old-pw + with self.assertRaises(ldap.UNWILLING_TO_PERFORM): + l.passwd_s(dn, "bogus", "ignored").raise_for_result() + + # have the server generate a new random pw + res = l.passwd_s(dn, "initial", newpw=None).raise_for_result() + self.assertEqual(res.name, None) + + password = res.genPasswd + self.assertIsInstance(password, bytes) + + # try changing password back + res = l.passwd_s(dn, password, "initial").raise_for_result() + self.assertEqual(res.name, None) + self.assertEqual(res.value, None) + + l.delete_s(dn) + + def test_slapadd(self): + with self.assertRaises(ldap.INVALID_DN_SYNTAX): + self._ldap_conn.add_s( + "myAttribute=foobar,ou=Container,%s" % self.server.suffix, [ + ("objectClass", b'myClass'), + ("myAttribute", b'foobar'), + ]).raise_for_result() + + self.server.slapadd(SCHEMA_TEMPLATE, ["-n0"]) + self.server.restart() + self.reset_connection() + + self._ldap_conn.add_s( + "myAttribute=foobar,ou=Container,%s" % self.server.suffix, [ + ("objectClass", b'myClass'), + ("myAttribute", b'foobar'), + ]).raise_for_result() + + +@requires_init_fd() +class Test01_ConnectionWithFileno(Test00_Connection): + def _open_ldap_conn(self, who=None, cred=None, **kwargs): + if hasattr(self, '_sock'): + raise RuntimeError("socket already connected") + self._sock = socket.create_connection( + (self.server.hostname, self.server.port) + ) + return super()._open_ldap_conn( + who=who, cred=cred, fileno=self._sock.fileno(), **kwargs + ) + + def tearDown(self): + self._sock.close() + del self._sock + super().tearDown() + + def reset_connection(self): + self._sock.close() + del self._sock + super().reset_connection() + + +if __name__ == '__main__': + unittest.main() From d6e8889911a15d9317ca222c89412da508a05d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Tue, 25 Jun 2024 13:43:42 +0100 Subject: [PATCH 4/5] TMP: rich support --- Lib/ldap/response.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Lib/ldap/response.py b/Lib/ldap/response.py index a9221791..4ea1a8a1 100644 --- a/Lib/ldap/response.py +++ b/Lib/ldap/response.py @@ -77,6 +77,10 @@ def __repr__(self): return (f"{self.__class__.__name__}(msgid={self.msgid}, " f"msgtype={self.msgtype}{optional})") + def __rich_repr__(self): + yield "msgid", self.msgid + yield "controls", self.controls, None + class Result(Response): result: int @@ -109,6 +113,13 @@ def __repr__(self): return (f"{self.__class__.__name__}" f"(msgid={self.msgid}, result={self.result}{optional})") + def __rich_repr__(self): + super().__rich_repr__() + yield "result", self.result + yield "matcheddn", self.matcheddn, "" + yield "message", self.message, "" + yield "referrals", self.referrals, None + class SearchEntry(Response): msgtype = ldap.RES_SEARCH_ENTRY @@ -125,6 +136,11 @@ def __new__(cls, msgid, msgtype, controls=None, *, return instance + def __rich_repr__(self): + super().__rich_repr__() + yield "dn", self.dn + yield "attrs", self.attrs + class SearchReference(Response): msgtype = ldap.RES_SEARCH_REFERENCE @@ -139,6 +155,10 @@ def __new__(cls, msgid, msgtype, controls=None, *, return instance + def __rich_repr__(self): + super().__rich_repr__() + yield "referrals", self.referrals + class SearchResult(Result): msgtype = ldap.RES_SEARCH_RESULT @@ -184,12 +204,23 @@ def __repr__(self): return (f"{self.__class__.__name__}" f"(msgid={self.msgid}{optional})") + def __rich_repr__(self): + # No super(), we put our values between msgid and controls + yield "msgid", self.msgid + yield "name", self.name, None + yield "value", self.value, None + yield "controls", self.controls, None + class BindResult(Result): msgtype = ldap.RES_BIND servercreds: Optional[bytes] + def __rich_repr__(self): + super().__rich_repr__() + yield "servercreds", self.servercreds, None + class ModifyResult(Result): msgtype = ldap.RES_MODIFY @@ -267,6 +298,13 @@ def __repr__(self): return (f"{self.__class__.__name__}" f"(msgid={self.msgid}, result={self.result}{optional})") + def __rich_repr__(self): + # No super(), we put our values between msgid and controls + yield "msgid", self.msgid + yield "name", self.name, None + yield "value", self.value, None + yield "controls", self.controls, None + class UnsolicitedNotification(ExtendedResult): msgid = ldap.RES_UNSOLICITED From 12c3b61f037c3e3582db100595220cb8ead50f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kuzn=C3=ADk?= Date: Thu, 12 Dec 2024 12:55:59 +0000 Subject: [PATCH 5/5] Syncrepl session handling --- Lib/ldap/controls/syncrepl.py | 245 ++++++++++++++++++++++ Lib/ldap/extop/syncrepl.py | 250 ++++++++++++++++++++++ Lib/ldap/syncrepl.py | 377 ++-------------------------------- 3 files changed, 517 insertions(+), 355 deletions(-) create mode 100644 Lib/ldap/controls/syncrepl.py create mode 100644 Lib/ldap/extop/syncrepl.py diff --git a/Lib/ldap/controls/syncrepl.py b/Lib/ldap/controls/syncrepl.py new file mode 100644 index 00000000..7037c2bf --- /dev/null +++ b/Lib/ldap/controls/syncrepl.py @@ -0,0 +1,245 @@ +""" +ldap.controls.syncrepl - classes for the Content Synchronization Operation +(a.k.a. syncrepl) controls (see RFC 4533) + +See https://www.python-ldap.org/ for project details. +""" + +__all__ = [ + 'SyncRequestControl', + 'SyncStateControl', 'SyncDoneControl', +] + +from pyasn1.type import tag, namedtype, namedval, univ, constraint +from pyasn1.codec.ber import encoder, decoder +from uuid import UUID + +import ldap.controls +from ldap.controls import RequestControl, ResponseControl + + +class SyncUUID(univ.OctetString): + """ + syncUUID ::= OCTET STRING (SIZE(16)) + """ + subtypeSpec = constraint.ValueSizeConstraint(16, 16) + + +class SyncCookie(univ.OctetString): + """ + syncCookie ::= OCTET STRING + """ + + +class SyncRequestMode(univ.Enumerated): + """ + mode ENUMERATED { + -- 0 unused + refreshOnly (1), + -- 2 reserved + refreshAndPersist (3) + }, + """ + namedValues = namedval.NamedValues( + ('refreshOnly', 1), + ('refreshAndPersist', 3) + ) + subtypeSpec = univ.Enumerated.subtypeSpec + \ + constraint.SingleValueConstraint(1, 3) + + +class SyncRequestValue(univ.Sequence): + """ + syncRequestValue ::= SEQUENCE { + mode ENUMERATED { + -- 0 unused + refreshOnly (1), + -- 2 reserved + refreshAndPersist (3) + }, + cookie syncCookie OPTIONAL, + reloadHint BOOLEAN DEFAULT FALSE + } + """ + componentType = namedtype.NamedTypes( + namedtype.NamedType('mode', SyncRequestMode()), + namedtype.OptionalNamedType('cookie', SyncCookie()), + namedtype.DefaultedNamedType('reloadHint', univ.Boolean(False)) + ) + + +class SyncRequestControl(RequestControl): + """ + The Sync Request Control is an LDAP Control [RFC4511] where the + controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.1 and the + controlValue, an OCTET STRING, contains a BER-encoded + syncRequestValue. The criticality field is either TRUE or FALSE. + [..] + The Sync Request Control is only applicable to the SearchRequest + Message. + """ + controlType = '1.3.6.1.4.1.4203.1.9.1.1' + + def __init__(self, criticality=1, cookie=None, mode='refreshOnly', + reloadHint=False): + self.criticality = criticality + self.cookie = cookie + self.mode = mode + self.reloadHint = reloadHint + + def encodeControlValue(self): + rcv = SyncRequestValue() + rcv.setComponentByName('mode', SyncRequestMode(self.mode)) + if self.cookie is not None: + rcv.setComponentByName('cookie', SyncCookie(self.cookie)) + if self.reloadHint is not None: + rcv.setComponentByName('reloadHint', univ.Boolean(self.reloadHint)) + return encoder.encode(rcv) + + def __repr__(self): + return '{}(cookie={!r}, mode={!r}, reloadHint={!r})'.format( + self.__class__.__name__, + self.cookie, + self.mode, + self.reloadHint + ) + + def __rich_repr__(self): + yield 'criticality', self.criticality, 1 + yield 'cookie', self.cookie, None + yield 'mode', self.mode + yield 'reloadHint', self.reloadHint, False + + +class SyncStateOp(univ.Enumerated): + """ + state ENUMERATED { + present (0), + add (1), + modify (2), + delete (3) + }, + """ + namedValues = namedval.NamedValues( + ('present', 0), + ('add', 1), + ('modify', 2), + ('delete', 3) + ) + subtypeSpec = univ.Enumerated.subtypeSpec + \ + constraint.SingleValueConstraint(0, 1, 2, 3) + + +class SyncStateValue(univ.Sequence): + """ + syncStateValue ::= SEQUENCE { + state ENUMERATED { + present (0), + add (1), + modify (2), + delete (3) + }, + entryUUID syncUUID, + cookie syncCookie OPTIONAL + } + """ + componentType = namedtype.NamedTypes( + namedtype.NamedType('state', SyncStateOp()), + namedtype.NamedType('entryUUID', SyncUUID()), + namedtype.OptionalNamedType('cookie', SyncCookie()) + ) + + +class SyncStateControl(ResponseControl): + """ + The Sync State Control is an LDAP Control [RFC4511] where the + controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.2 and the + controlValue, an OCTET STRING, contains a BER-encoded SyncStateValue. + The criticality is FALSE. + [..] + The Sync State Control is only applicable to SearchResultEntry and + SearchResultReference Messages. + """ + controlType = '1.3.6.1.4.1.4203.1.9.1.2' + + def decodeControlValue(self, encodedControlValue): + d = decoder.decode(encodedControlValue, asn1Spec=SyncStateValue()) + state = d[0].getComponentByName('state') + uuid = UUID(bytes=bytes(d[0].getComponentByName('entryUUID'))) + cookie = d[0].getComponentByName('cookie') + if cookie is not None and cookie.hasValue(): + self.cookie = bytes(cookie) + else: + self.cookie = None + self.state = state.prettyPrint() + self.entryUUID = str(uuid) + + def __repr__(self): + optional = '' + if self.cookie is not None: + optional += ', cookie={!r}'.format(self.cookie) + return '{}(state={!r}, entryUUID={!r}{})'.format( + self.__class__.__name__, + self.state, + self.entryUUID, + optional, + ) + + def __rich_repr__(self): + yield 'state', self.state + yield 'entryUUID', self.entryUUID + yield 'cookie', self.cookie, None + + +class SyncDoneValue(univ.Sequence): + """ + syncDoneValue ::= SEQUENCE { + cookie syncCookie OPTIONAL, + refreshDeletes BOOLEAN DEFAULT FALSE + } + """ + componentType = namedtype.NamedTypes( + namedtype.OptionalNamedType('cookie', SyncCookie()), + namedtype.DefaultedNamedType('refreshDeletes', univ.Boolean(False)) + ) + + +class SyncDoneControl(ResponseControl): + """ + The Sync Done Control is an LDAP Control [RFC4511] where the + controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.3 and the + controlValue contains a BER-encoded syncDoneValue. The criticality + is FALSE (and hence absent). + [..] + The Sync Done Control is only applicable to the SearchResultDone + Message. + """ + controlType = '1.3.6.1.4.1.4203.1.9.1.3' + + def decodeControlValue(self, encodedControlValue): + d = decoder.decode(encodedControlValue, asn1Spec=SyncDoneValue()) + cookie = d[0].getComponentByName('cookie') + if cookie.hasValue(): + self.cookie = bytes(cookie) + else: + self.cookie = None + refresh_deletes = d[0].getComponentByName('refreshDeletes') + if refresh_deletes.hasValue(): + self.refreshDeletes = bool(refresh_deletes) + else: + self.refreshDeletes = None + + def __repr__(self): + optional = [] + if self.refreshDeletes is not None: + optional.append('refreshDeletes={!r}'.format(self.refreshDeletes)) + if self.cookie is not None: + optional.append('cookie={!r}'.format(self.cookie)) + return '{}({})'.format( + self.__class__.__name__, + ', '.join(optional) + ) + + def __rich_repr__(self): + yield 'refreshDeletes', self.refreshDeletes, None + yield 'cookie', self.cookie, None diff --git a/Lib/ldap/extop/syncrepl.py b/Lib/ldap/extop/syncrepl.py new file mode 100644 index 00000000..ee54096f --- /dev/null +++ b/Lib/ldap/extop/syncrepl.py @@ -0,0 +1,250 @@ +""" +ldap.extop.syncrepl - classes for the Read Entry controls (see RFC 4533) +ldap.extop.syncrepl - Classes for Dynamic Entries extended operations +(see RFC 4533) + +See https://www.python-ldap.org/ for details. +""" + +from typing import Optional, Sequence + +from pyasn1.type import tag, namedtype, namedval, univ, constraint +from pyasn1.codec.ber import encoder, decoder + +from ldap.extop import ExtendedRequest, ExtendedResponse, IntermediateResponse +from ldap.controls.syncrepl import ( + SyncCookie, SyncUUID, +) + + +class RefreshDelete(univ.Sequence): + """ + refreshDelete [1] SEQUENCE { + cookie syncCookie OPTIONAL, + refreshDone BOOLEAN DEFAULT TRUE + }, + """ + componentType = namedtype.NamedTypes( + namedtype.OptionalNamedType('cookie', SyncCookie()), + namedtype.DefaultedNamedType('refreshDone', univ.Boolean(True)) + ) + + +class RefreshPresent(univ.Sequence): + """ + refreshPresent [2] SEQUENCE { + cookie syncCookie OPTIONAL, + refreshDone BOOLEAN DEFAULT TRUE + }, + """ + componentType = namedtype.NamedTypes( + namedtype.OptionalNamedType('cookie', SyncCookie()), + namedtype.DefaultedNamedType('refreshDone', univ.Boolean(True)) + ) + + +class SyncUUIDs(univ.SetOf): + """ + syncUUIDs SET OF syncUUID + """ + componentType = SyncUUID() + + +class SyncIdSet(univ.Sequence): + """ + syncIdSet [3] SEQUENCE { + cookie syncCookie OPTIONAL, + refreshDeletes BOOLEAN DEFAULT FALSE, + syncUUIDs SET OF syncUUID + } + """ + componentType = namedtype.NamedTypes( + namedtype.OptionalNamedType('cookie', SyncCookie()), + namedtype.DefaultedNamedType('refreshDeletes', univ.Boolean(False)), + namedtype.NamedType('syncUUIDs', SyncUUIDs()) + ) + + +class SyncInfoValue(univ.Choice): + """ + syncInfoValue ::= CHOICE { + newcookie [0] syncCookie, + refreshDelete [1] SEQUENCE { + cookie syncCookie OPTIONAL, + refreshDone BOOLEAN DEFAULT TRUE + }, + refreshPresent [2] SEQUENCE { + cookie syncCookie OPTIONAL, + refreshDone BOOLEAN DEFAULT TRUE + }, + syncIdSet [3] SEQUENCE { + cookie syncCookie OPTIONAL, + refreshDeletes BOOLEAN DEFAULT FALSE, + syncUUIDs SET OF syncUUID + } + } + """ + componentType = namedtype.NamedTypes( + namedtype.NamedType( + 'newcookie', + SyncCookie().subtype( + implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 0) + ) + ), + namedtype.NamedType( + 'refreshDelete', + RefreshDelete().subtype( + implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 1) + ) + ), + namedtype.NamedType( + 'refreshPresent', + RefreshPresent().subtype( + implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 2) + ) + ), + namedtype.NamedType( + 'syncIdSet', + SyncIdSet().subtype( + implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 3) + ) + ) + ) + + +class SyncInfoMessage(IntermediateResponse): + """ + The Sync Info Message is an LDAP Intermediate Response Message + [RFC4511] where responseName is the object identifier + 1.3.6.1.4.1.4203.1.9.1.4 and responseValue contains a BER-encoded + syncInfoValue. The criticality is FALSE (and hence absent). + """ + responseName = '1.3.6.1.4.1.4203.1.9.1.4' + + def __new__(cls, msgid, msgtype, controls=None, *, + name=None, value=None, + **kwargs): + if cls is not __class__: + return super().__new__(cls, msgid, msgtype, controls, + name=name, value=value) + syncinfo, _ = decoder.decode(value, asn1Spec=SyncInfoValue()) + choice = syncinfo.getName() + if choice == 'newcookie': + child = SyncInfoNewCookie + elif choice == 'refreshDelete': + child = SyncInfoRefreshDelete + elif choice == 'refreshPresent': + child = SyncInfoRefreshPresent + elif choice == 'syncIdSet': + child = SyncInfoIDSet + else: + raise ValueError + return child.__new__(child, msgid, msgtype, controls, + name=name, value=value) + + def decode(self, value: bytes): + self.syncinfo, _ = decoder.decode( + value, + asn1Spec=SyncInfoValue(), + ) + + +class SyncInfoNewCookie(SyncInfoMessage): + cookie: bytes + + def decode(self, value: bytes): + super().decode(value) + self.cookie = bytes(self.syncinfo.getComponent()) + + def __repr__(self): + return '{}(cookie={!r})'.format( + self.__class__.__name__, + self.cookie, + ) + + def __rich_repr__(self): + yield "cookie", self.cookie + + +class SyncInfoRefreshDelete(SyncInfoMessage): + cookie: Optional[bytes] + refreshDone: bool + + def decode(self, value: bytes): + super().decode(value) + component = self.syncinfo.getComponent() + self.cookie = None + cookie = component['cookie'] + if cookie.isValue: + self.cookie = bytes(cookie) + self.refreshDone = bool(component['refreshDone']) + + def __repr__(self): + return '{}(cookie={!r}, refreshDone={!r})'.format( + self.__class__.__name__, + self.cookie, + self.refreshDone, + ) + + def __rich_repr__(self): + yield "cookie", self.cookie, None + yield "refreshDone", self.refreshDone + + +class SyncInfoRefreshPresent(SyncInfoMessage): + cookie: Optional[bytes] + refreshDone: bool + + def decode(self, value: bytes): + super().decode(value) + component = self.syncinfo.getComponent() + self.cookie = None + cookie = component['cookie'] + if cookie.isValue: + self.cookie = bytes(cookie) + self.refreshDone = bool(component['refreshDone']) + + def __repr__(self): + return '{}(cookie={!r}, refreshDone={!r})'.format( + self.__class__.__name__, + self.cookie, + self.refreshDone, + ) + + def __rich_repr__(self): + yield "cookie", self.cookie, None + yield "refreshDone", self.refreshDone + + +class SyncInfoIDSet(SyncInfoMessage): + cookie: Optional[bytes] + refreshDeletes: bool + syncUUIDs: Sequence[str] + + def decode(self, value: bytes): + super().decode(value) + component = self.syncinfo.getComponent() + self.cookie = None + cookie = component['cookie'] + if cookie.isValue: + self.cookie = bytes(cookie) + self.refreshDeletes = bool(component['refreshDeletes']) + + uuids = [] + for syncuuid in component['syncUUIDs']: + uuid = UUID(bytes=bytes(syncuuid)) + uuids.append(str(uuid)) + self.syncUUIDs = uuids + + def __repr__(self): + return '{}(cookie={!r}, refreshDeletes={!r}, syncUUIDs={!r})'.format( + self.__class__.__name__, + self.cookie, + self.refreshDeletes, + self.syncUUIDs, + ) + + def __rich_repr__(self): + yield "cookie", self.cookie, None + yield "refreshDeletes", self.refreshDeletes + yield "syncUUIDs", self.syncUUIDs diff --git a/Lib/ldap/syncrepl.py b/Lib/ldap/syncrepl.py index c59641e1..04f0a040 100644 --- a/Lib/ldap/syncrepl.py +++ b/Lib/ldap/syncrepl.py @@ -4,343 +4,18 @@ See https://www.python-ldap.org/ for project details. """ -from uuid import UUID - -# Imports from pyasn1 -from pyasn1.type import tag, namedtype, namedval, univ, constraint -from pyasn1.codec.ber import encoder, decoder - from ldap.pkginfo import __version__, __author__, __license__ -from ldap.controls import RequestControl, ResponseControl from ldap import RES_SEARCH_RESULT, RES_SEARCH_ENTRY, RES_INTERMEDIATE +from ldap.controls.syncrepl import ( + SyncUUID, SyncCookie, + SyncRequestControl, SyncStateControl, SyncDoneControl, +) __all__ = [ 'SyncreplConsumer', ] -class SyncUUID(univ.OctetString): - """ - syncUUID ::= OCTET STRING (SIZE(16)) - """ - subtypeSpec = constraint.ValueSizeConstraint(16, 16) - - -class SyncCookie(univ.OctetString): - """ - syncCookie ::= OCTET STRING - """ - - -class SyncRequestMode(univ.Enumerated): - """ - mode ENUMERATED { - -- 0 unused - refreshOnly (1), - -- 2 reserved - refreshAndPersist (3) - }, - """ - namedValues = namedval.NamedValues( - ('refreshOnly', 1), - ('refreshAndPersist', 3) - ) - subtypeSpec = univ.Enumerated.subtypeSpec + constraint.SingleValueConstraint(1, 3) - - -class SyncRequestValue(univ.Sequence): - """ - syncRequestValue ::= SEQUENCE { - mode ENUMERATED { - -- 0 unused - refreshOnly (1), - -- 2 reserved - refreshAndPersist (3) - }, - cookie syncCookie OPTIONAL, - reloadHint BOOLEAN DEFAULT FALSE - } - """ - componentType = namedtype.NamedTypes( - namedtype.NamedType('mode', SyncRequestMode()), - namedtype.OptionalNamedType('cookie', SyncCookie()), - namedtype.DefaultedNamedType('reloadHint', univ.Boolean(False)) - ) - - -class SyncRequestControl(RequestControl): - """ - The Sync Request Control is an LDAP Control [RFC4511] where the - controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.1 and the - controlValue, an OCTET STRING, contains a BER-encoded - syncRequestValue. The criticality field is either TRUE or FALSE. - [..] - The Sync Request Control is only applicable to the SearchRequest - Message. - """ - controlType = '1.3.6.1.4.1.4203.1.9.1.1' - - def __init__(self, criticality=1, cookie=None, mode='refreshOnly', reloadHint=False): - self.criticality = criticality - self.cookie = cookie - self.mode = mode - self.reloadHint = reloadHint - - def encodeControlValue(self): - rcv = SyncRequestValue() - rcv.setComponentByName('mode', SyncRequestMode(self.mode)) - if self.cookie is not None: - rcv.setComponentByName('cookie', SyncCookie(self.cookie)) - if self.reloadHint: - rcv.setComponentByName('reloadHint', univ.Boolean(self.reloadHint)) - return encoder.encode(rcv) - - -class SyncStateOp(univ.Enumerated): - """ - state ENUMERATED { - present (0), - add (1), - modify (2), - delete (3) - }, - """ - namedValues = namedval.NamedValues( - ('present', 0), - ('add', 1), - ('modify', 2), - ('delete', 3) - ) - subtypeSpec = univ.Enumerated.subtypeSpec + constraint.SingleValueConstraint(0, 1, 2, 3) - - -class SyncStateValue(univ.Sequence): - """ - syncStateValue ::= SEQUENCE { - state ENUMERATED { - present (0), - add (1), - modify (2), - delete (3) - }, - entryUUID syncUUID, - cookie syncCookie OPTIONAL - } - """ - componentType = namedtype.NamedTypes( - namedtype.NamedType('state', SyncStateOp()), - namedtype.NamedType('entryUUID', SyncUUID()), - namedtype.OptionalNamedType('cookie', SyncCookie()) - ) - - -class SyncStateControl(ResponseControl): - """ - The Sync State Control is an LDAP Control [RFC4511] where the - controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.2 and the - controlValue, an OCTET STRING, contains a BER-encoded SyncStateValue. - The criticality is FALSE. - [..] - The Sync State Control is only applicable to SearchResultEntry and - SearchResultReference Messages. - """ - controlType = '1.3.6.1.4.1.4203.1.9.1.2' - opnames = ('present', 'add', 'modify', 'delete') - - def decodeControlValue(self, encodedControlValue): - d = decoder.decode(encodedControlValue, asn1Spec=SyncStateValue()) - state = d[0].getComponentByName('state') - uuid = UUID(bytes=bytes(d[0].getComponentByName('entryUUID'))) - cookie = d[0].getComponentByName('cookie') - if cookie is not None and cookie.hasValue(): - self.cookie = str(cookie) - else: - self.cookie = None - self.state = self.__class__.opnames[int(state)] - self.entryUUID = str(uuid) - - -class SyncDoneValue(univ.Sequence): - """ - syncDoneValue ::= SEQUENCE { - cookie syncCookie OPTIONAL, - refreshDeletes BOOLEAN DEFAULT FALSE - } - """ - componentType = namedtype.NamedTypes( - namedtype.OptionalNamedType('cookie', SyncCookie()), - namedtype.DefaultedNamedType('refreshDeletes', univ.Boolean(False)) - ) - - -class SyncDoneControl(ResponseControl): - """ - The Sync Done Control is an LDAP Control [RFC4511] where the - controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.3 and the - controlValue contains a BER-encoded syncDoneValue. The criticality - is FALSE (and hence absent). - [..] - The Sync Done Control is only applicable to the SearchResultDone - Message. - """ - controlType = '1.3.6.1.4.1.4203.1.9.1.3' - - def decodeControlValue(self, encodedControlValue): - d = decoder.decode(encodedControlValue, asn1Spec=SyncDoneValue()) - cookie = d[0].getComponentByName('cookie') - if cookie.hasValue(): - self.cookie = str(cookie) - else: - self.cookie = None - refresh_deletes = d[0].getComponentByName('refreshDeletes') - if refresh_deletes.hasValue(): - self.refreshDeletes = bool(refresh_deletes) - else: - self.refreshDeletes = None - - -class RefreshDelete(univ.Sequence): - """ - refreshDelete [1] SEQUENCE { - cookie syncCookie OPTIONAL, - refreshDone BOOLEAN DEFAULT TRUE - }, - """ - componentType = namedtype.NamedTypes( - namedtype.OptionalNamedType('cookie', SyncCookie()), - namedtype.DefaultedNamedType('refreshDone', univ.Boolean(True)) - ) - - -class RefreshPresent(univ.Sequence): - """ - refreshPresent [2] SEQUENCE { - cookie syncCookie OPTIONAL, - refreshDone BOOLEAN DEFAULT TRUE - }, - """ - componentType = namedtype.NamedTypes( - namedtype.OptionalNamedType('cookie', SyncCookie()), - namedtype.DefaultedNamedType('refreshDone', univ.Boolean(True)) - ) - - -class SyncUUIDs(univ.SetOf): - """ - syncUUIDs SET OF syncUUID - """ - componentType = SyncUUID() - - -class SyncIdSet(univ.Sequence): - """ - syncIdSet [3] SEQUENCE { - cookie syncCookie OPTIONAL, - refreshDeletes BOOLEAN DEFAULT FALSE, - syncUUIDs SET OF syncUUID - } - """ - componentType = namedtype.NamedTypes( - namedtype.OptionalNamedType('cookie', SyncCookie()), - namedtype.DefaultedNamedType('refreshDeletes', univ.Boolean(False)), - namedtype.NamedType('syncUUIDs', SyncUUIDs()) - ) - - -class SyncInfoValue(univ.Choice): - """ - syncInfoValue ::= CHOICE { - newcookie [0] syncCookie, - refreshDelete [1] SEQUENCE { - cookie syncCookie OPTIONAL, - refreshDone BOOLEAN DEFAULT TRUE - }, - refreshPresent [2] SEQUENCE { - cookie syncCookie OPTIONAL, - refreshDone BOOLEAN DEFAULT TRUE - }, - syncIdSet [3] SEQUENCE { - cookie syncCookie OPTIONAL, - refreshDeletes BOOLEAN DEFAULT FALSE, - syncUUIDs SET OF syncUUID - } - } - """ - componentType = namedtype.NamedTypes( - namedtype.NamedType( - 'newcookie', - SyncCookie().subtype( - implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 0) - ) - ), - namedtype.NamedType( - 'refreshDelete', - RefreshDelete().subtype( - implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 1) - ) - ), - namedtype.NamedType( - 'refreshPresent', - RefreshPresent().subtype( - implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 2) - ) - ), - namedtype.NamedType( - 'syncIdSet', - SyncIdSet().subtype( - implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 3) - ) - ) - ) - - -class SyncInfoMessage: - """ - The Sync Info Message is an LDAP Intermediate Response Message - [RFC4511] where responseName is the object identifier - 1.3.6.1.4.1.4203.1.9.1.4 and responseValue contains a BER-encoded - syncInfoValue. The criticality is FALSE (and hence absent). - """ - responseName = '1.3.6.1.4.1.4203.1.9.1.4' - - def __init__(self, encodedMessage): - d = decoder.decode(encodedMessage, asn1Spec=SyncInfoValue()) - self.newcookie = None - self.refreshDelete = None - self.refreshPresent = None - self.syncIdSet = None - - # Due to the way pyasn1 works, refreshDelete and refreshPresent are both - # valid in the components as they are fully populated defaults. We must - # get the component directly from the message, not by iteration. - attr = d[0].getName() - comp = d[0].getComponent() - - if comp is not None and comp.hasValue(): - if attr == 'newcookie': - self.newcookie = str(comp) - return - - val = {} - - cookie = comp.getComponentByName('cookie') - if cookie.hasValue(): - val['cookie'] = str(cookie) - - if attr.startswith('refresh'): - val['refreshDone'] = bool(comp.getComponentByName('refreshDone')) - elif attr == 'syncIdSet': - uuids = [] - ids = comp.getComponentByName('syncUUIDs') - for i in range(len(ids)): - uuid = UUID(bytes=bytes(ids.getComponentByPosition(i))) - uuids.append(str(uuid)) - val['syncUUIDs'] = uuids - val['refreshDeletes'] = bool(comp.getComponentByName('refreshDeletes')) - - setattr(self, attr, val) - - class SyncreplConsumer: """ SyncreplConsumer - LDAP syncrepl consumer object. @@ -422,8 +97,9 @@ def syncrepl_poll(self, msgid=-1, timeout=None, all=0): for m in msg: dn, attrs, ctrls = m for c in ctrls: - if c.__class__.__name__ != 'SyncStateControl': + if not isinstance(c, SyncStateControl): continue + if c.state == 'present': self.syncrepl_present([c.entryUUID]) elif c.state == 'delete': @@ -432,40 +108,31 @@ def syncrepl_poll(self, msgid=-1, timeout=None, all=0): self.syncrepl_entry(dn, attrs, c.entryUUID) if self.__refreshDone is False: self.syncrepl_present([c.entryUUID]) + if c.cookie is not None: self.syncrepl_set_cookie(c.cookie) break elif type == RES_INTERMEDIATE: - # Intermediate message. If it is a SyncInfoMessage, parse it + # Intermediate message, process any that are SyncInfoMessage for m in msg: - rname, resp, ctrls = m - if rname != SyncInfoMessage.responseName: - continue - sim = SyncInfoMessage(resp) - if sim.newcookie is not None: - self.syncrepl_set_cookie(sim.newcookie) - elif sim.refreshPresent is not None: - self.syncrepl_present(None, refreshDeletes=False) - if 'cookie' in sim.refreshPresent: - self.syncrepl_set_cookie(sim.refreshPresent['cookie']) - if sim.refreshPresent['refreshDone']: - self.__refreshDone = True - self.syncrepl_refreshdone() - elif sim.refreshDelete is not None: - self.syncrepl_present(None, refreshDeletes=True) - if 'cookie' in sim.refreshDelete: - self.syncrepl_set_cookie(sim.refreshDelete['cookie']) - if sim.refreshDelete['refreshDone']: + if isinstance(m, SyncInfoNewCookie): + self.syncrepl_set_cookie(m.cookie) + elif isinstance(m, (SyncInfoRefreshPresent, SyncInfoRefreshDelete)): + refreshDeletes = isinstance(m, SyncInfoRefreshDelete) + self.syncrepl_present(None, refreshDeletes=refreshDeletes) + if m.cookie is not None: + self.syncrepl_set_cookie(m.cookie) + if m.refreshDone: self.__refreshDone = True self.syncrepl_refreshdone() - elif sim.syncIdSet is not None: - if sim.syncIdSet['refreshDeletes'] is True: - self.syncrepl_delete(sim.syncIdSet['syncUUIDs']) + elif isinstance(m, SyncInfoIDSet): + if m.refreshDeletes: + self.syncrepl_delete(m.syncUUIDs) else: - self.syncrepl_present(sim.syncIdSet['syncUUIDs']) - if 'cookie' in sim.syncIdSet: - self.syncrepl_set_cookie(sim.syncIdSet['cookie']) + self.syncrepl_present(m.syncUUIDs) + if m.cookie is not None: + self.syncrepl_set_cookie(m.cookie) if all == 0: return True