diff --git a/ipaclient/setup.py b/ipaclient/setup.py index 8eb9aeedc..06d1567f3 100644 --- a/ipaclient/setup.py +++ b/ipaclient/setup.py @@ -64,6 +64,7 @@ if __name__ == '__main__': "install": ["ipaplatform"], "otptoken_yubikey": ["python-yubico", "pyusb"], "csrgen": ["cffi", "jinja2"], + "ldap": ["python-ldap"], # ipapython.ipaldap }, zip_safe=False, ) diff --git a/ipapython/dn.py b/ipapython/dn.py index 6747a0ece..145f33a87 100644 --- a/ipapython/dn.py +++ b/ipapython/dn.py @@ -423,10 +423,16 @@ import sys import functools import cryptography.x509 -from ldap.dn import str2dn, dn2str -from ldap import DECODING_ERROR import six +try: + from ldap import DECODING_ERROR +except ImportError: + from ipapython.dn_ctypes import str2dn, dn2str, DECODING_ERROR +else: + from ldap.dn import str2dn, dn2str + + if six.PY3: unicode = str diff --git a/ipapython/dn_ctypes.py b/ipapython/dn_ctypes.py new file mode 100644 index 000000000..8417dc534 --- /dev/null +++ b/ipapython/dn_ctypes.py @@ -0,0 +1,165 @@ +# +# Copyright (C) 2019 FreeIPA Contributors see COPYING for license +# +"""ctypes wrapper for libldap_str2dn +""" +from __future__ import absolute_import + +import ctypes +import ctypes.util + +import six + +__all__ = ("str2dn", "dn2str", "DECODING_ERROR", "LDAPError") + +# load reentrant libldap +ldap_r_lib = ctypes.util.find_library("ldap_r") +if ldap_r_lib is None: + raise ImportError("libldap_r shared library missing") +try: + lib = ctypes.CDLL(ldap_r_lib) +except OSError as e: + raise ImportError(str(e)) + +# constants +LDAP_AVA_FREE_ATTR = 0x0010 +LDAP_AVA_FREE_VALUE = 0x0020 +LDAP_DECODING_ERROR = -4 + +# mask for AVA flags +AVA_MASK = ~(LDAP_AVA_FREE_ATTR | LDAP_AVA_FREE_VALUE) + + +class berval(ctypes.Structure): + __slots__ = () + _fields_ = [("bv_len", ctypes.c_ulong), ("bv_value", ctypes.c_char_p)] + + def __bytes__(self): + buf = ctypes.create_string_buffer(self.bv_value, self.bv_len) + return buf.raw + + def __str__(self): + return self.__bytes__().decode("utf-8") + + if six.PY2: + __unicode__ = __str__ + __str__ = __bytes__ + + +class LDAPAVA(ctypes.Structure): + __slots__ = () + _fields_ = [ + ("la_attr", berval), + ("la_value", berval), + ("la_flags", ctypes.c_uint16), + ] + + +# typedef LDAPAVA** LDAPRDN; +LDAPRDN = ctypes.POINTER(ctypes.POINTER(LDAPAVA)) +# typedef LDAPRDN* LDAPDN; +LDAPDN = ctypes.POINTER(LDAPRDN) + + +def errcheck(result, func, arguments): + if result != 0: + if result == LDAP_DECODING_ERROR: + raise DECODING_ERROR + else: + msg = ldap_err2string(result) + raise LDAPError(msg.decode("utf-8")) + return result + + +ldap_str2dn = lib.ldap_str2dn +ldap_str2dn.argtypes = ( + ctypes.c_char_p, + ctypes.POINTER(LDAPDN), + ctypes.c_uint16, +) +ldap_str2dn.restype = ctypes.c_int16 +ldap_str2dn.errcheck = errcheck + +ldap_dnfree = lib.ldap_dnfree +ldap_dnfree.argtypes = (LDAPDN,) +ldap_dnfree.restype = None + +ldap_err2string = lib.ldap_err2string +ldap_err2string.argtypes = (ctypes.c_int16,) +ldap_err2string.restype = ctypes.c_char_p + + +class LDAPError(Exception): + pass + + +class DECODING_ERROR(LDAPError): + pass + + +# RFC 4514, 2.4 +_ESCAPE_CHARS = {'"', "+", ",", ";", "<", ">", "'", "\x00"} + + +def _escape_dn(dn): + if not dn: + return "" + result = [] + # a space or number sign occurring at the beginning of the string + if dn[0] in {"#", " "}: + result.append("\\") + for c in dn: + if c in _ESCAPE_CHARS: + result.append("\\") + result.append(c) + # a space character occurring at the end of the string + if len(dn) > 1 and result[-1] == " ": + # insert before last entry + result.insert(-1, "\\") + return "".join(result) + + +def dn2str(dn): + return ",".join( + "+".join( + "=".join((attr, _escape_dn(value))) for attr, value, _flag in rdn + ) + for rdn in dn + ) + + +def str2dn(dn, flags=0): + if dn is None: + return [] + if isinstance(dn, six.text_type): + dn = dn.encode("utf-8") + + ldapdn = LDAPDN() + try: + ldap_str2dn(dn, ctypes.byref(ldapdn), flags) + + result = [] + if not ldapdn: + # empty DN, str2dn("") == [] + return result + + for rdn in ldapdn: + if not rdn: + break + avas = [] + for ava_p in rdn: + if not ava_p: + break + ava = ava_p[0] + avas.append( + ( + six.text_type(ava.la_attr), + six.text_type(ava.la_value), + ava.la_flags & AVA_MASK, + ) + ) + result.append(avas) + + return result + finally: + ldap_dnfree(ldapdn) diff --git a/ipapython/setup.py b/ipapython/setup.py index 8bba2fe4f..228e2040e 100644 --- a/ipapython/setup.py +++ b/ipapython/setup.py @@ -43,11 +43,11 @@ if __name__ == '__main__': # "ipalib", # circular dependency "ipaplatform", "netaddr", - "python-ldap", "six", ], extras_require={ "install": ["dbus-python"], # for certmonger + "ldap": ["python-ldap"], # ipapython.ipaldap # CheckedIPAddress.get_matching_interface "netifaces": ["netifaces"], }, diff --git a/ipatests/setup.py b/ipatests/setup.py index cd498b787..f7e3806ae 100644 --- a/ipatests/setup.py +++ b/ipatests/setup.py @@ -70,14 +70,13 @@ if __name__ == '__main__': "polib", "pytest", "pytest_multihost", - "python-ldap", "six", ], extras_require={ "integration": ["dbus-python", "pyyaml", "ipaserver"], "ipaserver": ["ipaserver"], "webui": ["selenium", "pyyaml", "ipaserver"], - "xmlrpc": ["ipaserver"], + "xmlrpc": ["ipaserver", "python-ldap"], ":python_version<'3'": ["mock"], } ) diff --git a/ipatests/test_ipapython/test_dn.py b/ipatests/test_ipapython/test_dn.py index 17187e48f..dac8a465f 100644 --- a/ipatests/test_ipapython/test_dn.py +++ b/ipatests/test_ipapython/test_dn.py @@ -1,3 +1,4 @@ + import contextlib import unittest import pytest @@ -5,7 +6,9 @@ import pytest from cryptography import x509 import six -from ipapython.dn import DN, RDN, AVA +from ipapython.dn import DN, RDN, AVA, str2dn, dn2str, DECODING_ERROR +from ipapython import dn_ctypes + if six.PY3: unicode = str @@ -1345,5 +1348,58 @@ class TestInternationalization(unittest.TestCase): self.assertEqual(str(dn1), b'cn=' + self.arabic_hello_utf8) +# 1: LDAP_AVA_STRING +# 4: LDAP_AVA_NONPRINTABLE +@pytest.mark.parametrize( + 'dnstring,expected', + [ + ('', []), + ('cn=bob', [[('cn', 'bob', 1)]]), + ('cn=Bob', [[('cn', 'Bob', 1)]]), + (u'cn=b\xf6b', [[('cn', u'b\xf6b', 4)]]), + ('cn=bob,sn=builder', [[('cn', 'bob', 1)], [('sn', 'builder', 1)]]), + (u'cn=b\xf6b,sn=builder', [ + [('cn', u'b\xf6b', 4)], [('sn', 'builder', 1)] + ]), + ('cn=bob+sn=builder', [[('cn', 'bob', 1), ('sn', 'builder', 1)]]), + ('dc=ipa,dc=example', [[('dc', 'ipa', 1)], [('dc', 'example', 1)]]), + ('cn=R\\,W privilege', [[('cn', 'R,W privilege', 1)]]), + ] +) +def test_str2dn2str(dnstring, expected): + dn = str2dn(dnstring) + assert dn == expected + assert dn2str(dn) == dnstring + assert dn_ctypes.str2dn(dnstring) == dn + assert dn_ctypes.dn2str(dn) == dnstring + + +@pytest.mark.parametrize( + 'dnstring', + [ + 'cn', + 'cn=foo,', + 'cn=foo+bar', + ] +) +def test_str2dn_errors(dnstring): + with pytest.raises(DECODING_ERROR): + str2dn(dnstring) + with pytest.raises(dn_ctypes.DECODING_ERROR): + dn_ctypes.str2dn(dnstring) + + +def test_dn2str_special(): + dnstring = 'cn=R\\2cW privilege' + dnstring2 = 'cn=R\\,W privilege' + expected = [[('cn', 'R,W privilege', 1)]] + + dn = str2dn(dnstring) + assert dn == expected + assert dn2str(dn) == dnstring2 + assert dn_ctypes.str2dn(dnstring) == dn + assert dn_ctypes.dn2str(dn) == dnstring2 + + if __name__ == '__main__': unittest.main() diff --git a/ipatests/util.py b/ipatests/util.py index 1dbf7c4b3..2b724e770 100644 --- a/ipatests/util.py +++ b/ipatests/util.py @@ -35,19 +35,14 @@ from contextlib import contextmanager from pprint import pformat import six -import ldap -import ldap.sasl -import ldap.modlist import ipalib from ipalib import api from ipalib.plugable import Plugin from ipalib.request import context from ipapython.dn import DN -from ipapython.ipaldap import ldap_initialize from ipapython.ipautil import run - try: # not available with client-only wheel packages from ipalib.install.kinit import kinit_keytab, kinit_password @@ -60,6 +55,15 @@ try: except ImportError: paths = None +try: + # not available with optional python-ldap + import ldap +except ImportError: + pass +else: + import ldap.sasl + import ldap.modlist + from ipapython.ipaldap import ldap_initialize if six.PY3: unicode = str diff --git a/tox.ini b/tox.ini index c4d231e90..1905be652 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,7 @@ commands= [testenv:pylint3] basepython=python3 deps= - ipaclient[csrgen,otptoken_yubikey] + ipaclient[csrgen,otptoken_yubikey,ldap] pylint commands= {envpython} -m pylint \