<?php
/**
 * Turba directory driver implementation for PHP's LDAP extension.
 *
 * Copyright 2010-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file LICENSE for license information (ASL).  If you did
 * did not receive this file, see http://www.horde.org/licenses/apache.
 *
 * @author   Chuck Hagenbuch <chuck@horde.org>
 * @author   Jon Parise <jon@csh.rit.edu>
 * @category Horde
 * @license  http://www.horde.org/licenses/apache ASL
 * @package  Turba
 */
class Turba_Driver_Ldap extends Turba_Driver
{
    /**
     * Handle for the current LDAP connection.
     *
     * @var resource
     */
    protected $_ds = 0;

    /**
     * Cache _getSyntax() calls.
     *
     * @var array
     */
    protected $_syntaxCache = array();

    /**
     * Constructs a new Turba LDAP driver object.
     *
     * @param string $name   The source name
     * @param array $params  Hash containing additional configuration parameters.
     *
     * @return Turba_Driver_Ldap
     */
    public function __construct($name = '', array $params = array())
    {
        if (!Horde_Util::extensionExists('ldap')) {
            throw new Turba_Exception(_("LDAP support is required but the LDAP module is not available or not loaded."));
        }

        $params = array_merge(array(
            'charset' => '',
            'deref' => LDAP_DEREF_NEVER,
            'multiple_entry_separator' => ', ',
            'port' => 389,
            'root' => '',
            'scope' => 'sub',
            'server' => 'localhost'
        ), $params);

        parent::__construct($name, $params);
    }

    /**
     * Initiate LDAP connection.
     *
     * Not done in __construct(), only when a read or write action is
     * necessary.
     */
    protected function _connect()
    {
        if ($this->_ds) {
            return;
        }

        if (!($this->_ds = @ldap_connect($this->_params['server'], $this->_params['port']))) {
            throw new Turba_Exception(_("Connection failure"));
        }

        /* Set the LDAP protocol version. */
        if (!empty($this->_params['version'])) {
            @ldap_set_option($this->_ds, LDAP_OPT_PROTOCOL_VERSION, $this->_params['version']);
        }

        /* Set the LDAP deref option for dereferencing aliases. */
        if (!empty($this->_params['deref'])) {
            @ldap_set_option($this->_ds, LDAP_OPT_DEREF, $this->_params['deref']);
        }

        /* Set the LDAP referrals. */
        if (isset($this->_params['referrals'])) {
            @ldap_set_option($this->_ds, LDAP_OPT_REFERRALS, $this->_params['referrals']);
        }

        /* Start TLS if we're using it. */
        if (!empty($this->_params['tls']) &&
            !@ldap_start_tls($this->_ds)) {
            throw new Turba_Exception(sprintf(_("STARTTLS failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
        }

        /* Bind to the server. */
        if (isset($this->_params['bind_dn']) &&
            isset($this->_params['bind_password'])) {
            $error = !@ldap_bind($this->_ds, $this->_params['bind_dn'], $this->_params['bind_password']);
        } else {
            $error = !(@ldap_bind($this->_ds));
        }

        if ($error) {
            throw new Turba_Exception(sprintf(_("Bind failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
        }
    }

    /**
     * Extends parent function to build composed fields needed for the dn
     * based on the contents of $this->map.
     *
     * @param array $hash  Hash using Turba keys.
     *
     * @return array  Translated version of $hash.
     */
    public function toDriverKeys(array $hash)
    {
        // First check for combined fields in the dn-fields and add them.
        if (is_array($this->_params['dn'])) {
            foreach ($this->_params['dn'] as $param) {
                foreach ($this->map as $turbaname => $ldapname) {
                    if ((is_array($ldapname)) &&
                        (isset($ldapname['attribute'])) &&
                        ($ldapname['attribute'] == $param)) {
                        $fieldarray = array();
                        foreach ($ldapname['fields'] as $mapfield) {
                            $fieldarray[] = isset($hash[$mapfield])
                                ? $hash[$mapfield]
                                : '';
                        }
                        $hash[$turbaname] = Turba::formatCompositeField($ldapname['format'], $fieldarray);
                    }
                }
            }
        }

        // Now convert the turba-fieldnames to ldap-fieldnames
        return parent::toDriverKeys($hash);
    }

    /**
     * Searches the LDAP directory with the given criteria and returns
     * a filtered list of results. If no criteria are specified, all
     * records are returned.
     *
     * @param array $criteria    Array containing the search criteria.
     * @param array $fields      List of fields to return.
     * @param array $blobFields  Fields that contain binary data.
     *
     * @return array  Hash containing the search results.
     * @throws Turba_Exception
     */
    protected function _search(array $criteria, array $fields, array $blobFields = array(), $count_only = false)
    {
        $this->_connect();

        /* Build the LDAP filter. */
        $filter = '';
        if (count($criteria)) {
            foreach ($criteria as $key => $vals) {
                if ($key == 'OR') {
                    $filter .= '(|' . $this->_buildSearchQuery($vals) . ')';
                } elseif ($key == 'AND') {
                    $filter .= '(&' . $this->_buildSearchQuery($vals) . ')';
                }
            }
        } elseif (!empty($this->_params['objectclass'])) {
            /* Filter on objectclass. */
            $filter = Horde_Ldap_Filter::build(array('objectclass' => $this->_params['objectclass']), 'or');
        }

        /* Add source-wide filters, which are _always_ AND-ed. */
        if (!empty($this->_params['filter'])) {
            $filter = '(&' . '(' . $this->_params['filter'] . ')' . $filter . ')';
        }

        /* Four11 (at least) doesn't seem to return 'cn' if you don't
         * ask for 'sn' as well. Add 'sn' implicitly. */
        $attr = $fields;
        if (!in_array('sn', $attr)) {
            $attr[] = 'sn';
        }

        /* Add a sizelimit, if specified. Default is 0, which means no
         * limit.  Note: You cannot override a server-side limit with
         * this. */
        $sizelimit = 0;
        if (!empty($this->_params['sizelimit'])) {
            $sizelimit = $this->_params['sizelimit'];
        }

        /* Log the query at a DEBUG log level. */
        Horde::log(sprintf(
            'LDAP query by Turba_Driver_ldap::_search(): user = %s, root = %s (%s); filter = "%s"; attributes = "%s"; deref = "%s"  ; sizelimit = %d',
            $GLOBALS['registry']->getAuth(),
            $this->_params['root'],
            $this->_params['server'],
            $filter,
            implode(', ', $attr),
            $this->_params['deref'],
            $sizelimit
        ), 'DEBUG');

        /* Send the query to the LDAP server and fetch the matching
         * entries. */
        $func = ($this->_params['scope'] == 'one')
            ? 'ldap_list'
            : 'ldap_search';

        if (!($res = @$func($this->_ds, $this->_params['root'], $filter, $attr, 0, $sizelimit))) {
            throw new Turba_Exception(sprintf(_("Query failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
        }

        return $count_only ? count($this->_getResults($fields, $res)) : $this->_getResults($fields, $res);
    }

    /**
     * Reads the LDAP directory for a given element and returns the results.
     *
     * @param string $key        The primary key field to use.
     * @param mixed $ids         The ids of the contacts to load.
     * @param string $owner      Only return contacts owned by this user.
     * @param array $fields      List of fields to return.
     * @param array $blobFields  Array of fields containing binary data.
     * @param array $dateFields  Array of fields containing date data.
     *                           @since 4.2.0
     *
     * @return array  Hash containing the search results.
     * @throws Horde_Exception_NotFound
     */
    protected function _read(
        $key, $ids, $owner, array $fields,
        array $blobFields = array(),
        array $dateFields = array()
    )
    {
        /* Only DN. */
        if ($key != 'dn') {
            return array();
        }

        $this->_connect();

        if (empty($this->_params['objectclass'])) {
            $filter = 'objectclass=*';
        } else {
            $filter = (string)Horde_Ldap_Filter::build(array('objectclass' => $this->_params['objectclass']), 'or');
        }

        /* Four11 (at least) doesn't seem to return 'cn' if you don't
         * ask for 'sn' as well. Add 'sn' implicitly. */
        $attr = $fields;
        if (!in_array('sn', $attr)) {
            $attr[] = 'sn';
        }

        /* Handle a request for multiple records. */
        if (is_array($ids) && !empty($ids)) {
            $results = array();
            foreach ($ids as $d) {
                $res = @ldap_read($this->_ds, Horde_String::convertCharset($d, 'UTF-8', $this->_params['charset']), $filter, $attr);
                if ($res) {
                    $results = array_merge($results, $this->_getResults($fields, $res));
                } else {
                    throw new Horde_Exception_NotFound(sprintf(_("Read failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
                }
            }

            return $results;
        }

        $res = @ldap_read($this->_ds, Horde_String::convertCharset($this->_params['root'], 'UTF-8', $this->_params['charset']), $filter, $attr);
        if (!$res) {
            throw new Horde_Exception_NotFound(sprintf(_("Read failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
        }

        return $this->_getResults($fields, $res);
    }

    /**
     * Adds the specified contact to the addressbook.
     *
     * @param array $attributes  The attribute values of the contact.
     * @param array $blob_fields  Fields that represent binary data.
     * @param array $date_fields  Fields that represent dates. @since 4.2.0
     *
     * @throws Turba_Exception
     */
    protected function _add(array $attributes, array $blob_fields = array(), array $date_fields = array())
    {
        if (empty($attributes['dn'])) {
            throw new Turba_Exception('Tried to add an object with no dn: [' . serialize($attributes) . '].');
        }
        if (empty($this->_params['objectclass'])) {
            throw new Turba_Exception('Tried to add an object with no objectclass: [' . serialize($attributes) . '].');
        }

        $this->_connect();

        /* Take the DN out of the attributes array. */
        $dn = $attributes['dn'];
        unset($attributes['dn']);

        /* Put the objectClass into the attributes array. */
        if (!is_array($this->_params['objectclass'])) {
            $attributes['objectclass'] = $this->_params['objectclass'];
        } else {
            $i = 0;
            foreach ($this->_params['objectclass'] as $objectclass) {
                $attributes['objectclass'][$i++] = $objectclass;
            }
        }

        /* Don't add empty attributes. */
        $attributes = array_filter($attributes, array($this, '_emptyAttributeFilter'));

        /* If a required attribute doesn't exist, add a dummy
         * value. */
        if (!empty($this->_params['checkrequired'])) {
            $required = $this->_checkRequiredAttributes($this->_params['objectclass']);

            foreach ($required as $v) {
                if (!isset($attributes[$v])) {
                    $attributes[$v] = $this->_params['checkrequired_string'];
                }
            }
        }

        $this->_encodeAttributes($attributes);

        if (!@ldap_add($this->_ds, Horde_String::convertCharset($dn, 'UTF-8', $this->_params['charset']), $attributes)) {
            throw new Turba_Exception('Failed to add an object: [' . ldap_errno($this->_ds) . '] "' . ldap_error($this->_ds) . '" DN: ' . $dn . ' (attributes: [' . serialize($attributes) . '])');
        }
    }

    /**
     * TODO
     *
     * @return boolean  TODO
     */
    protected function _canAdd()
    {
        return true;
    }

    /**
     * Deletes the specified entry from the LDAP directory.
     *
     * @param string $object_key
     * @param string $object_id
     *
     * @throws Turba_Exception
     */
    protected function _delete($object_key, $object_id)
    {
        if ($object_key != 'dn') {
            throw new Turba_Exception(_("Invalid key specified."));
        }

        $this->_connect();

        if (!@ldap_delete($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']))) {
            throw new Turba_Exception(sprintf(_("Delete failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
        }
    }

    /**
     * Modifies the specified entry in the LDAP directory.
     *
     * @param Turba_Object $object  The object we wish to save.
     *
     * @return string  The object id, possibly updated.
     * @throw Turba_Exception
     */
    protected function _save(Turba_Object $object)
    {
        $this->_connect();

        $object_keys = $this->toDriverKeys(array('__key' => $object->getValue('__key')));
        $object_id = reset($object_keys);
        $object_key = key($object_keys);
        $attributes = $this->toDriverKeys($object->getAttributes());

        /* Get the old entry so that we can access the old
         * values. These are needed so that we can delete any
         * attributes that have been removed by using ldap_mod_del. */
        if (empty($this->_params['objectclass'])) {
            $filter = 'objectclass=*';
        } else {
            $filter = (string)Horde_Ldap_Filter::build(array('objectclass' => $this->_params['objectclass']), 'or');
        }
        $oldres = @ldap_read($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']), $filter, array_merge(array_keys($attributes), array('objectclass')));
        $info = ldap_get_attributes($this->_ds, ldap_first_entry($this->_ds, $oldres));

        if ($this->_params['version'] == 3 &&
            Horde_String::lower(str_replace(array(',', '"'), array('\\2C', ''), $this->_makeKey($attributes))) !=
            Horde_String::lower(str_replace(',', '\\2C', $object_id))) {
            /* Need to rename the object. */
            $newrdn = $this->_makeRDN($attributes);
            if ($newrdn == '') {
                throw new Turba_Exception(_("Missing DN in LDAP source configuration."));
            }

            if (ldap_rename($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']),
                            Horde_String::convertCharset($newrdn, 'UTF-8', $this->_params['charset']), $this->_params['root'], true)) {
                $object_id = $newrdn . ',' . $this->_params['root'];
            } else {
                throw new Turba_Exception(sprintf(_("Failed to change name: (%s) %s; Old DN = %s, New DN = %s, Root = %s"), ldap_errno($this->_ds), ldap_error($this->_ds), $object_id, $newrdn, $this->_params['root']));
            }
        }

        /* Work only with lowercase keys. */
        $info = array_change_key_case($info, CASE_LOWER);
        $attributes = array_change_key_case($attributes, CASE_LOWER);

        foreach ($info as $key => $var) {
            $oldval = null;

            /* Check to see if the old value and the new value are
             * different and that the new value is empty. If so then
             * we use ldap_mod_del to delete the attribute. */
            if (isset($attributes[$key]) &&
                ($var[0] != $attributes[$key]) &&
                $attributes[$key] == '') {

                $oldval[$key] = $var[0];
                if (!@ldap_mod_del($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']), $oldval)) {
                    throw new Turba_Exception(sprintf(_("Modify failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
                }
                unset($attributes[$key]);
            } elseif (isset($attributes[$key]) &&
                      $var[0] == $attributes[$key]) {
                /* Drop unchanged elements from list of attributes to write. */
                unset($attributes[$key]);
            }
        }

        unset($attributes[Horde_String::lower($object_key)]);
        $this->_encodeAttributes($attributes);
        $attributes = array_filter($attributes, array($this, '_emptyAttributeFilter'));

        /* Modify objectclasses only if they really changed. */
        $oldClasses = array_map(array('Horde_String', 'lower'), $info['objectclass']);
        array_shift($oldClasses);
        $attributes['objectclass'] = array_unique(array_map('strtolower', array_merge($info['objectclass'], $this->_params['objectclass'])));
        unset($attributes['objectclass']['count']);
        $attributes['objectclass'] = array_values($attributes['objectclass']);

        /* Do not handle object classes unless they have changed. */
        if ((!array_diff($oldClasses, $attributes['objectclass']))) {
            unset($attributes['objectclass']);
        }
        if (!@ldap_modify($this->_ds, Horde_String::convertCharset($object_id, 'UTF-8', $this->_params['charset']), $attributes)) {
            throw new Turba_Exception(sprintf(_("Modify failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
        }

        return $object_id;
    }

    /**
     * Build a RDN based on a set of attributes and what attributes
     * make a RDN for the current source.
     *
     * @param array $attributes The attributes (in driver keys) of the
     *                          object being added.
     *
     * @return string  The RDN for the new object.
     */
    protected function _makeRDN(array $attributes)
    {
        if (!is_array($this->_params['dn'])) {
            return '';
        }

        $pairs = array();
        foreach ($this->_params['dn'] as $param) {
            if (isset($attributes[$param])) {
                $pairs[] = array($param, $attributes[$param]);
            }
        }

        return Horde_Ldap::quoteDN($pairs);
    }

    /**
     * Build a DN based on a set of attributes and what attributes
     * make a DN for the current source.
     *
     * @param array $attributes The attributes (in driver keys) of the
     *                          object being added.
     *
     * @return string  The DN for the new object.
     */
    protected function _makeKey(array $attributes)
    {
        return $this->_makeRDN($attributes) . ',' . $this->_params['root'];
    }

    /**
     * Build a piece of a search query.
     *
     * @param array  $criteria  The array of criteria.
     *
     * @return string  An LDAP query fragment.
     */
    protected function _buildSearchQuery(array $criteria)
    {
        $clause = '';

        foreach ($criteria as $key => $vals) {
            if (!empty($vals['OR']) || $key === 'OR') {
                $clause .= '(|' . $this->_buildSearchQuery($vals) . ')';
            } elseif (!empty($vals['AND'])) {
                $clause .= '(&' . $this->_buildSearchQuery($vals) . ')';
            } else {
                if (isset($vals['field'])) {
                    $rhs = Horde_String::convertCharset($vals['test'], 'UTF-8', $this->_params['charset']);
                    $clause .= Horde_Ldap::buildClause($vals['field'], $vals['op'], $rhs, array('begin' => !empty($vals['begin'])));
                } else {
                    foreach ($vals as $test) {
                        if (!empty($test['OR'])) {
                            $clause .= '(|' . $this->_buildSearchQuery($test) . ')';
                        } elseif (!empty($test['AND'])) {
                            $clause .= '(&' . $this->_buildSearchQuery($test) . ')';
                        } else {
                            $rhs = Horde_String::convertCharset($test['test'], 'UTF-8', $this->_params['charset']);
                            $clause .= Horde_Ldap::buildClause($test['field'], $test['op'], $rhs, array('begin' => !empty($vals['begin'])));
                        }
                    }
                }
            }
        }

        return $clause;
    }

    /**
     * Get some results from a result identifier and clean them up.
     *
     * @param array    $fields  List of fields to return.
     * @param resource $res     Result identifier.
     *
     * @return array  Hash containing the results.
     * @throws Horde_Exception_NotFound
     */
    protected function _getResults(array $fields, $res)
    {
        $entries = @ldap_get_entries($this->_ds, $res);
        if ($entries === false) {
            throw new Horde_Exception_NotFound(sprintf(_("Read failed: (%s) %s"), ldap_errno($this->_ds), ldap_error($this->_ds)));
        }

        /* Return only the requested fields (from $fields, above). */
        $results = array();
        for ($i = 0; $i < $entries['count']; ++$i) {
            $entry = $entries[$i];
            $result = array();

            foreach ($fields as $field) {
                $field_l = Horde_String::lower($field);
                if ($field == 'dn') {
                    $result[$field] = Horde_String::convertCharset($entry[$field_l], $this->_params['charset'], 'UTF-8');
                } else {
                    $result[$field] = '';
                    if (!empty($entry[$field_l])) {
                        for ($j = 0; $j < $entry[$field_l]['count']; $j++) {
                            if (!empty($result[$field])) {
                                $result[$field] .= $this->_params['multiple_entry_separator'];
                            }
                            $result[$field] .= Horde_String::convertCharset($entry[$field_l][$j], $this->_params['charset'], 'UTF-8');
                        }

                        /* If schema checking is enabled check the
                         * backend syntax. */
                        if (!empty($this->_params['checksyntax'])) {
                            $postal = $this->_isPostalAddress($field_l);
                        } else {
                            /* Otherwise rely on the attribute mapping
                             * in attributes.php. */
                            $attr = array_search($field_l, $this->map);
                            $postal = (!empty($attr) && !empty($GLOBALS['attributes'][$attr]) &&
                                       $GLOBALS['attributes'][$attr]['type'] == 'address');
                        }
                        if ($postal) {
                            $result[$field] = str_replace('$', "\r\n", $result[$field]);
                        }
                    }
                }
            }

            $results[] = $result;
        }

        return $results;
    }

    /**
     * Remove empty attributes from attributes array.
     *
     * @param mixed $val  Value from attributes array.
     *
     * @return boolean  Boolean used by array_filter.
     */
    protected function _emptyAttributeFilter($var)
    {
        if (!is_array($var)) {
            return ($var != '');
        }

        if (!count($var)) {
            return false;
        }

        foreach ($var as $v) {
            if ($v == '') {
                return false;
            }
        }

        return true;
    }

    /**
     * Format and encode attributes including postal addresses,
     * character set encoding, etc.
     *
     * @param array $attributes  The attributes array.
     */
    protected function _encodeAttributes(&$attributes)
    {
        foreach ($attributes as $key => $val) {
            /* If schema checking is enabled check the backend syntax. */
            if (!empty($this->_params['checksyntax'])) {
                $postal = $this->_isPostalAddress($key);
            } else {
                /* Otherwise rely on the attribute mapping in
                 * attributes.php. */
                $attr = array_search($key, $this->map);
                $postal = (!empty($attr) && !empty($val) && !empty($GLOBALS['attributes'][$attr]) &&
                           $GLOBALS['attributes'][$attr]['type'] == 'address');
            }
            if ($postal) {
                /* Correctly store postal addresses. */
                $val = str_replace(array("\r\n", "\r", "\n"), '$', $val);
            }

            if (!is_array($val)) {
                $attributes[$key] = Horde_String::convertCharset($val, 'UTF-8', $this->_params['charset']);
            }
        }
    }

    /**
     * Returns a list of required attributes.
     *
     * @param array $objectclasses  List of objectclasses that should be
     *                              checked for required attributes.
     *
     * @return array  List of attribute names of the specified objectclasses
     *                that have been configured as being required.
     * @throws Turba_Exception
     */
    protected function _checkRequiredAttributes(array $objectclasses)
    {
        $ldap = new Horde_Ldap($this->_convertParameters($this->_params));
        $schema = $ldap->schema();

        $retval = array();
        foreach ($objectclasses as $oc) {
            if (Horde_String::lower($oc) == 'top') {
                continue;
            }

            $required = $schema->must($oc, true);
            if (is_array($required)) {
                foreach ($required as $v) {
                    if ($this->_isString($v)) {
                        $retval[] = Horde_String::lower($v);
                    }
                }
            }
        }

        return $retval;
    }

    /**
     * Checks if an attribute refers to a string.
     *
     * @param string $attribute  An attribute name.
     *
     * @return boolean  True if the specified attribute refers to a string.
     */
    protected function _isString($attribute)
    {
        $syntax = $this->_getSyntax($attribute);

        /* Syntaxes we want to allow, i.e. no integers.
         * Syntaxes have the form:
         * 1.3.6.1.4.1.1466.115.121.1.$n{$y}
         * ... where $n is the integer used below and $y is a sizelimit. */
        $okSyntax = array(
            44 => 1, /* Printable string. */
            41 => 1, /* Postal address. */
            39 => 1, /* Other mailbox. */
            34 => 1, /* Name and optional UID. */
            26 => 1, /* IA5 string. */
            15 => 1, /* Directory string. */
        );

        return (preg_match('/^(.*)\.(\d+)\{\d+\}$/', $syntax, $matches) &&
                ($matches[1] == "1.3.6.1.4.1.1466.115.121.1") &&
                isset($okSyntax[$matches[2]]));
    }

    /**
     * Checks if an attribute refers to a Postal Address.
     *
     * @param string $attribute  An attribute name.
     *
     * @return boolean  True if the specified attribute refers to a Postal
     *                  Address.
     */
    protected function _isPostalAddress($attribute)
    {
        /* LDAP postal address syntax is
         * 1.3.6.1.4.1.1466.115.121.1.41 */
        return ($this->_getSyntax($attribute) == '1.3.6.1.4.1.1466.115.121.1.41');
    }

    /**
     * Returns the syntax of an attribute, if necessary recursively.
     *
     * @param string $att  Attribute name.
     *
     * @return string  Attribute syntax.
     * @throws Turba_Exception
     */
    protected function _getSyntax($att)
    {
        $ldap = new Horde_Ldap($this->_convertParameters($this->_params));
        $schema = $ldap->schema();

        if (!isset($this->_syntaxCache[$att])) {
            $attv = $schema->get('attribute', $att);
            $this->_syntaxCache[$att] = isset($attv['syntax'])
                ? $attv['syntax']
                : $this->_getSyntax($attv['sup'][0]);
        }

        return $this->_syntaxCache[$att];
    }

    /**
     * Converts Turba connection parameter so Horde_Ldap parameters.
     *
     * @param array $in  Turba parameters.
     *
     * @return array  Horde_Ldap parameters.
     */
    protected function _convertParameters(array $in)
    {
        $map = array(
            'server' => 'hostspec',
            'port' => 'port',
            'tls' => 'tls',
            'version' => 'version',
            'root' => 'basedn',
            'bind_dn' => 'binddn',
            'bind_password' => 'bindpw',
            // can both be specified in Turba but only one in Horde_Ldap.
            //'objectclass',
            //'filter' => 'filter',
            'scope' => 'scope',
            // charset is always utf-8
            //'charset',
            // Not yet implemented.
            //'deref',
            //'referrals',
            //'sizelimit',
            //'dn',
        );
        $out = array();
        foreach ($in as $key => $value) {
            if (isset($map[$key])) {
                $out[$map[$key]] = $value;
            }
        }
        return $out;
    }
}
