Make python-ldap optional for PyPI packages

python-ldap is a Python package with heavy C extensions. In order to
build python-ldap, not only OpenLDAP development headers are necessary,
but also OpenSSL, Cyrus SASL, and MIT KRB5 development headers.

A fully functional ipaclient doesn't need an LDAP driver. It talks JSON
RPC over HTTPS to a server. python-ldap is only used by ipapython.dn.DN
to convert a string to a DN with ldap_str2dn(). The function is simple
and can be wrapped with ctypes in a bunch of lines.

Related: https://pagure.io/freeipa/issue/6468
Signed-off-by: Christian Heimes <cheimes@redhat.com>
Reviewed-By: Alexander Bokovoy <abokovoy@redhat.com>
This commit is contained in:
Christian Heimes 2019-04-08 08:05:52 +02:00
parent c314411130
commit 2a459ce0f2
8 changed files with 243 additions and 12 deletions

View File

@ -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,
)

View File

@ -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

165
ipapython/dn_ctypes.py Normal file
View File

@ -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)

View File

@ -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"],
},

View File

@ -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"],
}
)

View File

@ -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()

View File

@ -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

View File

@ -22,7 +22,7 @@ commands=
[testenv:pylint3]
basepython=python3
deps=
ipaclient[csrgen,otptoken_yubikey]
ipaclient[csrgen,otptoken_yubikey,ldap]
pylint
commands=
{envpython} -m pylint \