<?php
/**
 * The main Horde_Ldap class.
 *
 * Copyright 2003-2007 Tarjej Huse, Jan Wagner, Del Elson, Benedikt Hallinger
 * Copyright 2009-2017 Horde LLC (http://www.horde.org/)
 *
 * @package   Ldap
 * @author    Tarjej Huse <tarjei@bergfald.no>
 * @author    Jan Wagner <wagner@netsols.de>
 * @author    Del <del@babel.com.au>
 * @author    Benedikt Hallinger <beni@php.net>
 * @author    Ben Klang <ben@alkaloid.net>
 * @author    Chuck Hagenbuch <chuck@horde.org>
 * @author    Jan Schneider <jan@horde.org>
 * @license   http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3
 */
class Horde_Ldap
{
    /**
     * Class configuration array.
     *
     * - hostspec:       The LDAP host to connect to (may be an array of
     *                   several hosts to try).
     * - port:           The server port.
     * - version:        LDAP version (defaults to 3).
     * - tls:            When set, ldap_start_tls() is run after connecting.
     * - binddn:         The DN to bind as when searching.
     * - bindpw:         Password to use when searching LDAP.
     * - basedn:         LDAP base.
     * - options:        Hash of LDAP options to set.
     * - filter:         Default search filter.
     * - scope:          Default search scope.
     * - user:           Configuration parameters for {@link findUserDN()},
     *                   must contain 'uid', and may contain 'basedn'
     *                   entries.
     * - timeout:        Connection timeout in seconds (defaults to 5).
     * - auto_reconnect: If true, the class will automatically
     *                   attempt to reconnect to the LDAP server in certain
     *                   failure conditions when attempting a search, or other
     *                   LDAP operations.  Defaults to false.  Note that if you
     *                   set this to true, calls to search() may block
     *                   indefinitely if there is a catastrophic server failure.
     * - min_backoff:    Minimum reconnection delay period (in seconds).
     * - current_backof: Initial reconnection delay period (in seconds).
     * - max_backoff:    Maximum reconnection delay period (in seconds).
     * - cache:          A Horde_Cache instance for caching schema requests.
     *
     * @var array
     */
    protected $_config = array(
        'hostspec'        => 'localhost',
        'port'            => 389,
        'version'         => 3,
        'tls'             => false,
        'binddn'          => '',
        'bindpw'          => '',
        'basedn'          => '',
        'options'         => array(),
        'filter'          => '(objectClass=*)',
        'scope'           => 'sub',
        'user'            => array(),
        'timeout'         => 5,
        'auto_reconnect'  => false,
        'min_backoff'     => 1,
        'current_backoff' => 1,
        'max_backoff'     => 32,
        'cache'           => false,
        'cache_root_dse'  => false,
        'cachettl'        => 3600);

    /**
     * List of hosts we try to establish a connection to.
     *
     * @var array
     */
    protected $_hostList = array();

    /**
     * List of hosts that are known to be down.
     *
     * @var array
     */
    protected $_downHostList = array();

    /**
     * LDAP resource link.
     *
     * @var resource
     */
    protected $_link;

    /**
     * Schema object.
     *
     * @see schema()
     * @var Horde_Ldap_Schema
     */
    protected $_schema;

    /**
     * Cache for attribute encoding checks.
     *
     * @var array Hash with attribute names as key and boolean value
     *            to determine whether they should be utf8 encoded or not.
     */
    protected $_schemaAttrs = array();

    /**
     * Cache for rootDSE objects.
     *
     * Hash with requested rootDSE attr names as key and rootDSE
     * object as value.
     *
     * Since the RootDSE object itself may request a rootDSE object,
     * {@link rootDSE()} caches successful requests.
     * Internally, Horde_Ldap needs several lookups to this object, so
     * caching increases performance significally.
     *
     * @var array
     */
    protected $_rootDSE = array();

    /**
     * Constructor.
     *
     * @see $_config
     *
     * @param array $config Configuration array.
     */
    public function __construct($config = array())
    {
        if (!Horde_Util::loadExtension('ldap')) {
            throw new Horde_Ldap_Exception('No PHP LDAP extension');
        }
        $this->setConfig($config);
        $this->bind();
    }

    /**
     * Destructor.
     */
    public function __destruct()
    {
        $this->disconnect();
    }

    /**
     * Sets the internal configuration array.
     *
     * @param array $config Configuration hash.
     */
    protected function setConfig($config)
    {
        /* Parameter check -- probably should raise an error here if
         * config is not an array. */
        if (!is_array($config)) {
            return;
        }

        foreach ($config as $k => $v) {
            if (isset($this->_config[$k])) {
                $this->_config[$k] = $v;
            }
        }

        /* Ensure the host list is an array. */
        if (is_array($this->_config['hostspec'])) {
            $this->_hostList = $this->_config['hostspec'];
        } else {
            if (strlen($this->_config['hostspec'])) {
                $this->_hostList = array($this->_config['hostspec']);
            } else {
                $this->_hostList = array();
                /* This will cause an error in _connect(), so
                 * the user is notified about the failure. */
            }
        }

        /* Reset the down host list, which seems like a sensible thing
         * to do if the config is being reset for some reason. */
        $this->_downHostList = array();
    }

    /**
     * Bind or rebind to the LDAP server.
     *
     * This function binds with the given DN and password to the
     * server. In case no connection has been made yet, it will be
     * started and STARTTLS issued if appropiate.
     *
     * The internal bind configuration is not being updated, so if you
     * call bind() without parameters, you can rebind with the
     * credentials provided at first connecting to the server.
     *
     * @param string $dn       DN for binding.
     * @param string $password Password for binding.
     *
     * @throws Horde_Ldap_Exception
     */
    public function bind($dn = null, $password = null)
    {
        /* Fetch current bind credentials. */
        if (is_null($dn)) {
            $dn = $this->_config['binddn'];
        }
        if (is_null($password)) {
            $password = $this->_config['bindpw'];
        }

        /* Connect first, if we haven't so far.  This will also bind
         * us to the server. */
        if (!$this->_link) {
            /* Store old credentials so we can revert them later, then
             * overwrite config with new bind credentials. */
            $olddn = $this->_config['binddn'];
            $oldpw = $this->_config['bindpw'];

            /* Overwrite bind credentials in config so
             * _connect() knows about them. */
            $this->_config['binddn'] = $dn;
            $this->_config['bindpw'] = $password;

            /* Try to connect with provided credentials. */
            $msg = $this->_connect();

            /* Reset to previous config. */
            $this->_config['binddn'] = $olddn;
            $this->_config['bindpw'] = $oldpw;
            return;
        }

        /* Do the requested bind as we are asked to bind manually. */
        if (empty($dn)) {
            /* Anonymous bind. */
            $msg = @ldap_bind($this->_link);
        } else {
            /* Privileged bind. */
            $msg = @ldap_bind($this->_link, $dn, $password);
        }
        if (!$msg) {
            throw new Horde_Ldap_Exception('Bind failed: ' . @ldap_error($this->_link),
                                           @ldap_errno($this->_link));
        }
    }

    /**
     * Connects to the LDAP server.
     *
     * This function connects to the LDAP server specified in the
     * configuration, binds and set up the LDAP protocol as needed.
     *
     * @throws Horde_Ldap_Exception
     */
    protected function _connect()
    {
        /* Connecting is briefly described in RFC1777. Basicly it works like
         * this:
         *  1. set up TCP connection
         *  2. secure that connection if neccessary
         *  3a. setVersion to tell server which version we want to speak
         *  3b. perform bind
         *  3c. setVersion to tell server which version we want to speak
         *      together with a test for supported versions
         *  4. set additional protocol options */

        /* Return if we are already connected. */
        if ($this->_link) {
            return;
        }

        /* Connnect to the LDAP server if we are not connected.  Note that
         * ldap_connect() may return a link value even if no connection is
         * made.  We need to do at least one anonymous bind to ensure that a
         * connection is actually valid.
         *
         * See: http://www.php.net/manual/en/function.ldap-connect.php */

        /* Default error message in case all connection attempts fail but no
         * message is set. */
        $current_error = new Horde_Ldap_Exception('Unknown connection error');

        /* Catch empty $_hostList arrays. */
        if (!is_array($this->_hostList) || !count($this->_hostList)) {
            throw new Horde_Ldap_Exception('No servers configured');
        }

        /* Cycle through the host list. */
        foreach ($this->_hostList as $host) {
            /* Ensure we have a valid string for host name. */
            if (is_array($host)) {
                $current_error = new Horde_Ldap_Exception('No Servers configured');
                continue;
            }

            /* Skip this host if it is known to be down. */
            if (in_array($host, $this->_downHostList)) {
                continue;
            }

            /* Record the host that we are actually connecting to in case we
             * need it later. */
            $this->_config['hostspec'] = $host;

            /* The ldap extension doesn't allow to provide connection timeouts
             * and seems to default to 2 minutes. Open a socket manually
             * instead to ping the server. */
            $failed = true;
            $url = @parse_url($host);
            $sockhost = !empty($url['host']) ? $url['host'] : $host;
            if ($fp = @fsockopen($sockhost, $this->_config['port'], $errno, $errstr, $this->_config['timeout'])) {
                $failed = false;
                fclose($fp);
            }

            /* Attempt a connection. */
            if (!$failed) {
                $this->_link = @ldap_connect($host, $this->_config['port']);
            }
            if (!$this->_link) {
                $current_error = new Horde_Ldap_Exception('Could not connect to ' .  $host . ':' . $this->_config['port']);
                $this->_downHostList[] = $host;
                continue;
            }

            /* If we're supposed to use TLS, do so before we try to bind, as
             * some strict servers only allow binding via secure
             * connections. */
            if ($this->_config['tls']) {
                try {
                    $this->startTLS();
                } catch (Horde_Ldap_Exception $e) {
                    $current_error           = $e;
                    $this->_link             = false;
                    $this->_downHostList[] = $host;
                    continue;
                }
            }

            /* Try to set the configured LDAP version on the connection if LDAP
             * server needs that before binding (eg OpenLDAP).
             * This could be necessary since RFC 1777 states that the protocol
             * version has to be set at the bind request.
             * We use force here which means that the test in the rootDSE is
             * skipped; this is neccessary, because some strict LDAP servers
             * only allow to read the LDAP rootDSE (which tells us the
             * supported protocol versions) with authenticated clients.
             * This may fail in which case we try again after binding.
             * In this case, most probably the bind() or setVersion() call
             * below will also fail, providing error messages. */
            $version_set = false;
            $this->setVersion(0, true);

            /* Attempt to bind to the server. If we have credentials
             * configured, we try to use them, otherwise it's an anonymous
             * bind.
             * As stated by RFC 1777, the bind request should be the first
             * operation to be performed after the connection is established.
             * This may give an protocol error if the server does not support
             * v2 binds and the above call to setVersion() failed.
             * If the above call failed, we try an v2 bind here and set the
             * version afterwards (with checking to the rootDSE). */
            try {
                $this->bind();
            } catch (Exception $e) {
                /* The bind failed, discard link and save error msg.
                 * Then record the host as down and try next one. */
                if ($this->errorName($e->getCode()) == 'LDAP_PROTOCOL_ERROR' &&
                    !$version_set) {
                    /* Provide a finer grained error message if protocol error
                     * arises because of invalid version. */
                    $e = new Horde_Ldap_Exception($e->getMessage() . ' (could not set LDAP protocol version to ' . $this->_config['version'].')', $e->getCode());
                }
                $this->_link             = false;
                $current_error           = $e;
                $this->_downHostList[] = $host;
                continue;
            }

            /* Set desired LDAP version if not successfully set before.
             * Here, a check against the rootDSE is performed, so we get a
             * error message if the server does not support the version.
             * The rootDSE entry should tell us which LDAP versions are
             * supported. However, some strict LDAP servers only allow
             * bound users to read the rootDSE. */
            if (!$version_set) {
                try {
                    $this->setVersion();
                } catch (Exception $e) {
                    $current_error           = $e;
                    $this->_link             = false;
                    $this->_downHostList[] = $host;
                    continue;
                }
            }

            /* Set LDAP parameters, now that we know we have a valid
             * connection. */
            if (isset($this->_config['options']) &&
                is_array($this->_config['options']) &&
                count($this->_config['options'])) {
                foreach ($this->_config['options'] as $opt => $val) {
                    try {
                        $this->setOption($opt, $val);
                    } catch (Exception $e) {
                        $current_error           = $e;
                        $this->_link             = false;
                        $this->_downHostList[] = $host;
                        continue 2;
                    }
                }
            }

            /* At this stage we have connected, bound, and set up options, so
             * we have a known good LDAP server.  Time to go home. */
            return;
        }

        /* All connection attempts have failed, return the last error. */
        throw $current_error;
    }

    /**
     * Reconnects to the LDAP server.
     *
     * In case the connection to the LDAP service has dropped out for some
     * reason, this function will reconnect, and re-bind if a bind has been
     * attempted in the past.  It is probably most useful when the server list
     * provided to the new() or _connect() function is an array rather than a
     * single host name, because in that case it will be able to connect to a
     * failover or secondary server in case the primary server goes down.
     *
     * This method just tries to re-establish the current connection.  It will
     * sleep for the current backoff period (seconds) before attempting the
     * connect, and if the connection fails it will double the backoff period,
     * but not try again.  If you want to ensure a reconnection during a
     * transient period of server downtime then you need to call this function
     * in a loop.
     *
     * @throws Horde_Ldap_Exception
     */
    protected function _reconnect()
    {
        /* Return if we are already connected. */
        if ($this->_link) {
            return;
        }

        /* Sleep for a backoff period in seconds. */
        sleep($this->_config['current_backoff']);

        /* Retry all available connections. */
        $this->_downHostList = array();

        try {
            $this->_connect();
        } catch (Horde_Ldap_Exception $e) {
            $this->_config['current_backoff'] *= 2;
            if ($this->_config['current_backoff'] > $this->_config['max_backoff']) {
                $this->_config['current_backoff'] = $this->_config['max_backoff'];
            }
            throw $e;
        }

        /* Now we should be able to safely (re-)bind. */
        try {
            $this->bind();
        } catch (Exception $e) {
            $this->_config['current_backoff'] *= 2;
            if ($this->_config['current_backoff'] > $this->_config['max_backoff']) {
                $this->_config['current_backoff'] = $this->_config['max_backoff'];
            }

            /* $this->_config['hostspec'] should have had the last connected
             * host stored in it by _connect().  Since we are unable to
             * bind to that host we can safely assume that it is down or has
             * some other problem. */
            $this->_downHostList[] = $this->_config['hostspec'];
            throw $e;
        }

        /* At this stage we have connected, bound, and set up options, so we
         * have a known good LDAP server. Time to go home. */
        $this->_config['current_backoff'] = $this->_config['min_backoff'];
    }

    /**
     * Closes the LDAP connection.
     */
    public function disconnect()
    {
        @ldap_close($this->_link);
    }

    /**
     * Starts an encrypted session.
     *
     * @throws Horde_Ldap_Exception
     */
    public function startTLS()
    {
        /* First try STARTTLS blindly, some servers don't even allow to receive
         * the rootDSE without TLS. */
        if (@ldap_start_tls($this->_link)) {
            return;
        }

        /* Keep original error. */
        $error = 'TLS not started: ' . @ldap_error($this->_link);
        $errno = @ldap_errno($this->_link);

        /* Test to see if the server supports TLS at all.
         * This is done via testing the extensions offered by the server.
         * The OID 1.3.6.1.4.1.1466.20037 tells whether TLS is supported. */
        try {
            $rootDSE = $this->rootDSE();
        } catch (Exception $e) {
            throw new Horde_Ldap_Exception('Unable to start TLS and unable to fetch rootDSE entry to see if TLS is supported: ' . $e->getMessage(), $e->getCode());
        }

        try {
            $supported_extensions = $rootDSE->getValue('supportedExtension');
        } catch (Exception $e) {
            throw new Horde_Ldap_Exception('Unable to start TLS and unable to fetch rootDSE attribute "supportedExtension" to see if TLS is supoported: ' . $e->getMessage(), $e->getCode());
        }

        if (!in_array('1.3.6.1.4.1.1466.20037', $supported_extensions)) {
            throw new Horde_Ldap_Exception('Server reports that it does not support TLS');
        }

        throw new Horde_Ldap_Exception($error, $errno);
    }

    /**
     * Adds a new entry to the directory.
     *
     * This also links the entry to the connection used for the add, if it was
     * a fresh entry.
     *
     * @see HordeLdap_Entry::createFresh()
     *
     * @param Horde_Ldap_Entry $entry An LDAP entry.
     *
     * @throws Horde_Ldap_Exception
     */
    public function add(Horde_Ldap_Entry $entry)
    {
        /* Continue attempting the add operation in a loop until we get a
         * success, a definitive failure, or the world ends. */
        while (true) {
            $link = $this->getLink();
            if ($link === false) {
                /* We do not have a successful connection yet.  The call to
                 * getLink() would have kept trying if we wanted one. */
                throw new Horde_Ldap_Exception('Could not add entry ' . $entry->dn() . ' no valid LDAP connection could be found.');
            }

            if (@ldap_add($link, $entry->dn(), $entry->getValues())) {
                /* Entry successfully added, we should update its Horde_Ldap
                 * reference in case it is not set so far (fresh entry). */
                try {
                    $entry->getLDAP();
                } catch (Horde_Ldap_Exception $e) {
                    $entry->setLDAP($this);
                }
                /* Store that the entry is present inside the directory. */
                $entry->markAsNew(false);
                return;
            }

            /* We have a failure.  What kind?  We may be able to reconnect and
             * try again. */
            $error_code = @ldap_errno($link);
            if ($this->errorName($error_code) != 'LDAP_OPERATIONS_ERROR' |
                !$this->_config['auto_reconnect']) {
                /* Errors other than the above are just passed back to the user
                 * so he may react upon them. */
                throw new Horde_Ldap_Exception('Could not add entry ' . $entry->dn() . ': ' . ldap_err2str($error_code), $error_code);
            }

            /* The server has disconnected before trying the operation.  We
             * should try again, possibly with a different server. */
            $this->_link = false;
            $this->_reconnect();
        }
    }

    /**
     * Deletes an entry from the directory.
     *
     * @param string|Horde_Ldap_Entry $dn        DN string or Horde_Ldap_Entry.
     * @param boolean                 $recursive Should we delete all children
     *                                           recursivelx as well?
     * @throws Horde_Ldap_Exception
     */
    public function delete($dn, $recursive = false)
    {
        if ($dn instanceof Horde_Ldap_Entry) {
             $dn = $dn->dn();
        }
        if (!is_string($dn)) {
            throw new Horde_Ldap_Exception('Parameter is not a string nor an entry object!');
        }

        /* Recursive delete searches for children and calls delete for them. */
        if ($recursive) {
            $result = @ldap_list($this->_link, $dn, '(objectClass=*)', array(null), 0, 0);
            if ($result && @ldap_count_entries($this->_link, $result)) {
                for ($subentry = @ldap_first_entry($this->_link, $result);
                     $subentry;
                     $subentry = @ldap_next_entry($this->_link, $subentry)) {
                    $this->delete(@ldap_get_dn($this->_link, $subentry), true);
                }
            }
        }

        /* Continue the delete operation in a loop until we get a success, or a
         * definitive failure. */
        while (true) {
            $link = $this->getLink();
            if (!$link) {
                /* We do not have a successful connection yet.  The call to
                 * getLink() would have kept trying if we wanted one. */
                throw new Horde_Ldap_Exception('Could not add entry ' . $dn . ' no valid LDAP connection could be found.');
            }

            $s = @ldap_delete($link, $dn);
            if ($s) {
                /* Entry successfully deleted. */
                return;
            }

            /* We have a failure.  What kind? We may be able to reconnect and
             * try again. */
            $error_code = @ldap_errno($link);
            if ($this->errorName($error_code) == 'LDAP_OPERATIONS_ERROR' &&
                $this->_config['auto_reconnect']) {
                /* The server has disconnected before trying the operation.  We
                 * should try again, possibly with a different server. */
                $this->_link = false;
                $this->_reconnect();
            } elseif ($this->errorName($error_code) == 'LDAP_NOT_ALLOWED_ON_NONLEAF') {
                /* Subentries present, server refused to delete.
                 * Deleting subentries is the clients responsibility, but since
                 * the user may not know of the subentries, we do not force
                 * that here but instead notify the developer so he may take
                 * actions himself. */
                throw new Horde_Ldap_Exception('Could not delete entry ' . $dn . ' because of subentries. Use the recursive parameter to delete them.', $error_code);
            } else {
                /* Errors other than the above catched are just passed back to
                 * the user so he may react upon them. */
                throw new Horde_Ldap_Exception('Could not delete entry ' . $dn . ': ' . ldap_err2str($error_code), $error_code);
            }
        }
    }

    /**
     * Modifies an LDAP entry on the server.
     *
     * The $params argument is an array of actions and should be something like
     * this:
     * <code>
     * array('add' => array('attribute1' => array('val1', 'val2'),
     *                      'attribute2' => array('val1')),
     *       'delete' => array('attribute1'),
     *       'replace' => array('attribute1' => array('val1')),
     *       'changes' => array('add' => ...,
     *                          'replace' => ...,
     *                          'delete' => array('attribute1', 'attribute2' => array('val1')))
     * </code>
     *
     * The order of execution is as following:
     *   1. adds from 'add' array
     *   2. deletes from 'delete' array
     *   3. replaces from 'replace' array
     *   4. changes (add, replace, delete) in order of appearance
     *
     * The function calls the corresponding functions of an Horde_Ldap_Entry
     * object. A detailed description of array structures can be found there.
     *
     * Unlike the modification methods provided by the Horde_Ldap_Entry object,
     * this method will instantly carry out an update() after each operation,
     * thus modifying "directly" on the server.
     *
     * @see Horde_Ldap_Entry::add()
     * @see Horde_Ldap_Entry::delete()
     * @see Horde_Ldap_Entry::replace()
     *
     * @param string|Horde_Ldap_Entry $entry DN string or Horde_Ldap_Entry.
     * @param array                   $parms Array of changes
     *
     * @throws Horde_Ldap_Exception
     */
    public function modify($entry, $parms = array())
    {
        if (is_string($entry)) {
            $entry = $this->getEntry($entry);
        }
        if (!($entry instanceof Horde_Ldap_Entry)) {
            throw new Horde_Ldap_Exception('Parameter is not a string nor an entry object!');
        }

        if ($unknown = array_diff(array_keys($parms), array('add', 'delete', 'replace', 'changes'))) {
            throw new Horde_Ldap_Exception('Unknown modify action(s): ' . implode(', ', $unknown));
        }

        /* Perform changes mentioned separately. */
        foreach (array('add', 'delete', 'replace') as $action) {
            if (!isset($parms[$action])) {
                continue;
            }
            $entry->$action($parms[$action]);
            $entry->setLDAP($this);

            /* Because the ldap_*() functions are called inside
             * Horde_Ldap_Entry::update(), we have to trap the error codes
             * issued from that if we want to support reconnection. */
            while (true) {
                try {
                    $entry->update();
                    break;
                } catch (Exception $e) {
                    /* We have a failure.  What kind?  We may be able to
                     * reconnect and try again. */
                    if ($this->errorName($e->getCode()) != 'LDAP_OPERATIONS_ERROR' ||
                        !$this->_config['auto_reconnect']) {
                        /* Errors other than the above catched are just passed
                         * back to the user so he may react upon them. */
                        throw new Horde_Ldap_Exception('Could not modify entry: ' . $e->getMessage());
                    }
                    /* The server has disconnected before trying the operation.
                     * We should try again, possibly with a different
                     * server. */
                    $this->_link = false;
                    $this->_reconnect();
                }
            }
        }

        if (!isset($parms['changes']) || !is_array($parms['changes'])) {
            return;
        }

        /* Perform combined changes in 'changes' array. */
        foreach ($parms['changes'] as $action => $value) {
            $this->modify($entry, array($action => $value));
        }
    }

    /**
     * Runs an LDAP search query.
     *
     * $base and $filter may be ommitted. The one from config will then be
     * used. $base is either a DN-string or an Horde_Ldap_Entry object in which
     * case its DN will be used.
     *
     * $params may contain:
     * - scope: The scope which will be used for searching, defaults to 'sub':
     *          - base: Just one entry
     *          - sub: The whole tree
     *          - one: Immediately below $base
     * - sizelimit: Limit the number of entries returned
     *              (default: 0 = unlimited)
     * - timelimit: Limit the time spent for searching (default: 0 = unlimited)
     * - attrsonly: If true, the search will only return the attribute names
     * - attributes: Array of attribute names, which the entry should contain.
     *               It is good practice to limit this to just the ones you
     *               need.
     *
     * You cannot override server side limitations to sizelimit and timelimit:
     * You can always only lower a given limit.
     *
     * @todo implement search controls (sorting etc)
     *
     * @param string|Horde_Ldap_Entry  $base   LDAP searchbase.
     * @param string|Horde_Ldap_Filter $filter LDAP search filter.
     * @param array                    $params Array of options.
     *
     * @return Horde_Ldap_Search  The search result.
     * @throws Horde_Ldap_Exception
     */
    public function search($base = null, $filter = null, $params = array())
    {
        if (is_null($base)) {
            $base = $this->_config['basedn'];
        }
        if ($base instanceof Horde_Ldap_Entry) {
            /* Fetch DN of entry, making searchbase relative to the entry. */
            $base = $base->dn();
        }
        if (is_null($filter)) {
            $filter = $this->_config['filter'];
        }
        if ($filter instanceof Horde_Ldap_Filter) {
            /* Convert Horde_Ldap_Filter to string representation. */
            $filter = (string)$filter;
        }

        /* Setting search parameters.  */
        $sizelimit  = isset($params['sizelimit']) ? $params['sizelimit'] : 0;
        $timelimit  = isset($params['timelimit']) ? $params['timelimit'] : 0;
        $attrsonly  = isset($params['attrsonly']) ? $params['attrsonly'] : 0;
        $attributes = isset($params['attributes']) ? $params['attributes'] : array();

        /* Ensure $attributes to be an array in case only one attribute name
         * was given as string. */
        if (!is_array($attributes)) {
            $attributes = array($attributes);
        }

        /* Reorganize the $attributes array index keys sometimes there are
         * problems with not consecutive indexes. */
        $attributes = array_values($attributes);

        /* Scoping makes searches faster! */
        $scope = isset($params['scope'])
            ? $params['scope']
            : $this->_config['scope'];

        switch ($scope) {
        case 'one':
            $search_function = 'ldap_list';
            break;
        case 'base':
            $search_function = 'ldap_read';
            break;
        default:
            $search_function = 'ldap_search';
        }

        /* Continue attempting the search operation until we get a success or a
         * definitive failure. */
        while (true) {
            $link = $this->getLink();
            $search = @call_user_func($search_function,
                                      $link,
                                      $base,
                                      $filter,
                                      $attributes,
                                      $attrsonly,
                                      $sizelimit,
                                      $timelimit);

            if ($errno = @ldap_errno($link)) {
                $err = $this->errorName($errno);
                if ($err == 'LDAP_NO_SUCH_OBJECT' ||
                    $err == 'LDAP_SIZELIMIT_EXCEEDED') {
                    return new Horde_Ldap_Search($search, $this, $attributes);
                }
                if ($err == 'LDAP_FILTER_ERROR') {
                    /* Bad search filter. */
                    throw new Horde_Ldap_Exception(ldap_err2str($errno) . ' ($filter)', $errno);
                }
                if ($err == 'LDAP_OPERATIONS_ERROR' &&
                    $this->_config['auto_reconnect']) {
                    $this->_link = false;
                    $this->_reconnect();
                } else {
                    $msg = "\nParameters:\nBase: $base\nFilter: $filter\nScope: $scope";
                    throw new Horde_Ldap_Exception(ldap_err2str($errno) . $msg, $errno);
                }
            } else {
                return new Horde_Ldap_Search($search, $this, $attributes);
            }
        }
    }

    /**
     * Returns the DN of a user.
     *
     * The purpose is to quickly find the full DN of a user so it can be used
     * to re-bind as this user. This method requires the 'user' configuration
     * parameter to be set.
     *
     * @param string $user  The user to find.
     *
     * @return string  The user's full DN.
     * @throws Horde_Ldap_Exception
     * @throws Horde_Exception_NotFound
     */
    public function findUserDN($user)
    {
        $filter = Horde_Ldap_Filter::combine(
            'and',
            array(Horde_Ldap_Filter::build($this->_config['user']),
                  Horde_Ldap_Filter::create($this->_config['user']['uid'], 'equals', $user)));
        $search = $this->search(
            isset($this->_config['user']['basedn'])
                ? $this->_config['user']['basedn']
                : null,
            $filter,
            array('attributes' => array($this->_config['user']['uid'])));
        if (!$search->count()) {
            throw new Horde_Exception_NotFound('DN for user ' . $user . ' not found');
        }
        $entry = $search->shiftEntry();
        return $entry->currentDN();
    }

    /**
     * Sets an LDAP option.
     *
     * @param string $option Option to set.
     * @param mixed  $value  Value to set option to.
     *
     * @throws Horde_Ldap_Exception
     */
    public function setOption($option, $value)
    {
        if (!$this->_link) {
            throw new Horde_Ldap_Exception('Could not set LDAP option: No LDAP connection');
        }
        if (!defined($option)) {
            throw new Horde_Ldap_Exception('Unkown option requested');
        }
        if (@ldap_set_option($this->_link, constant($option), $value)) {
            return;
        }
        $err = @ldap_errno($this->_link);
        if ($err) {
            throw new Horde_Ldap_Exception(ldap_err2str($err), $err);
        }
        throw new Horde_Ldap_Exception('Unknown error');
    }

    /**
     * Returns an LDAP option value.
     *
     * @param string $option Option to get.
     *
     * @return Horde_Ldap_Error|string Horde_Ldap_Error or option value
     * @throws Horde_Ldap_Exception
     */
    public function getOption($option)
    {
        if (!$this->_link) {
            throw new Horde_Ldap_Exception('No LDAP connection');
        }
        if (!defined($option)) {
            throw new Horde_Ldap_Exception('Unkown option requested');
        }
        if (@ldap_get_option($this->_link, constant($option), $value)) {
            return $value;
        }
        $err = @ldap_errno($this->_link);
        if ($err) {
            throw new Horde_Ldap_Exception(ldap_err2str($err), $err);
        }
        throw new Horde_Ldap_Exception('Unknown error');
    }

    /**
     * Returns the LDAP protocol version that is used on the connection.
     *
     * A lot of LDAP functionality is defined by what protocol version
     * the LDAP server speaks. This might be 2 or 3.
     *
     * @return integer  The protocol version.
     */
    public function getVersion()
    {
        if ($this->_link) {
            $version = $this->getOption('LDAP_OPT_PROTOCOL_VERSION');
        } else {
            $version = $this->_config['version'];
        }
        return $version;
    }

    /**
     * Sets the LDAP protocol version that is used on the connection.
     *
     * @todo Checking via the rootDSE takes much time - why? fetching
     *       and instanciation is quick!
     *
     * @param integer $version LDAP version that should be used.
     * @param boolean $force   If set to true, the check against the rootDSE
     *                         will be skipped.
     *
     * @throws Horde_Ldap_Exception
     */
    public function setVersion($version = 0, $force = false)
    {
        if (!$version) {
            $version = $this->_config['version'];
        }

        /* Check to see if the server supports this version first.
         *
         * TODO: Why is this so horribly slow? $this->rootDSE() is very fast,
         * as well as Horde_Ldap_RootDse(). Seems like a problem at copying the
         * object inside PHP??  Additionally, this is not always
         * reproducable... */
        if (!$force) {
            try {
                $rootDSE = $this->rootDSE();
                $supported_versions = $rootDSE->getValue('supportedLDAPVersion');
                if (is_string($supported_versions)) {
                    $supported_versions = array($supported_versions);
                }
                $check_ok = in_array($version, $supported_versions);
            } catch (Horde_Ldap_Exception $e) {
                /* If we don't get a root DSE, this is probably a v2 server. */
                $check_ok = $version < 3;
            }
        }
        $check_ok = true;

        if ($force || $check_ok) {
            return $this->setOption('LDAP_OPT_PROTOCOL_VERSION', $version);
        }
        throw new Horde_Ldap_Exception('LDAP Server does not support protocol version ' . $version);
    }


    /**
     * Returns whether a DN exists in the directory.
     *
     * @param string|Horde_Ldap_Entry $dn The DN of the object to test.
     *
     * @return boolean  True if the DN exists.
     * @throws Horde_Ldap_Exception
     */
    public function exists($dn)
    {
        if ($dn instanceof Horde_Ldap_Entry) {
             $dn = $dn->dn();
        }
        if (!is_string($dn)) {
            throw new Horde_Ldap_Exception('Parameter $dn is not a string nor an entry object!');
        }

        /* Make dn relative to parent. */
        $options = array('casefold' => 'none');
        $base = Horde_Ldap_Util::explodeDN($dn, $options);
        $entry_rdn = '(&('
            . Horde_Ldap_Util::canonicalDN(
                array_shift($base),
                array_merge($options, array('separator' => ')('))
            )
            . '))';
        $base = Horde_Ldap_Util::canonicalDN($base, $options);

        $result = @ldap_list($this->_link, $base, $entry_rdn, array('dn'), 1, 1);
        if ($result && @ldap_count_entries($this->_link, $result)) {
            return true;
        }
        if ($this->errorName(@ldap_errno($this->_link)) == 'LDAP_NO_SUCH_OBJECT') {
            return false;
        }
        if (@ldap_errno($this->_link)) {
            throw new Horde_Ldap_Exception(@ldap_error($this->_link), @ldap_errno($this->_link));
        }
        return false;
    }


    /**
     * Returns a specific entry based on the DN.
     *
     * @todo Maybe a check against the schema should be done to be
     *       sure the attribute type exists.
     *
     * @param string $dn   DN of the entry that should be fetched.
     * @param array  $attributes Array of Attributes to select. If ommitted, all
     *                     attributes are fetched.
     *
     * @return Horde_Ldap_Entry  A Horde_Ldap_Entry object.
     * @throws Horde_Ldap_Exception
     * @throws Horde_Exception_NotFound
     */
    public function getEntry($dn, $attributes = array())
    {
        if (!is_array($attributes)) {
            $attributes = array($attributes);
        }
        $result = $this->search($dn, '(objectClass=*)',
                                array('scope' => 'base', 'attributes' => $attributes));
        if (!$result->count()) {
            throw new Horde_Exception_NotFound(sprintf('Could not fetch entry %s: no entry found', $dn));
        }
        $entry = $result->shiftEntry();
        if (!$entry) {
            throw new Horde_Ldap_Exception('Could not fetch entry (error retrieving entry from search result)');
        }
        return $entry;
    }

    /**
     * Renames or moves an entry.
     *
     * This method will instantly carry out an update() after the
     * move, so the entry is moved instantly.
     *
     * You can pass an optional Horde_Ldap object. In this case, a
     * cross directory move will be performed which deletes the entry
     * in the source (THIS) directory and adds it in the directory
     * $target_ldap.
     *
     * A cross directory move will switch the entry's internal LDAP
     * reference so updates to the entry will go to the new directory.
     *
     * If you want to do a cross directory move, you need to pass an
     * Horde_Ldap_Entry object, otherwise the attributes will be
     * empty.
     *
     * @param string|Horde_Ldap_Entry $entry       An LDAP entry.
     * @param string                  $newdn       The new location.
     * @param Horde_Ldap              $target_ldap Target directory for cross
     *                                             server move.
     *
     * @throws Horde_Ldap_Exception
     */
    public function move($entry, $newdn, $target_ldap = null)
    {
        if (is_string($entry)) {
            if ($target_ldap && $target_ldap !== $this) {
                throw new Horde_Ldap_Exception('Unable to perform cross directory move: operation requires a Horde_Ldap_Entry object');
            }
            $entry = $this->getEntry($entry);
        }
        if (!$entry instanceof Horde_Ldap_Entry) {
            throw new Horde_Ldap_Exception('Parameter $entry is expected to be a Horde_Ldap_Entry object! (If DN was passed, conversion failed)');
        }
        if ($target_ldap && !($target_ldap instanceof Horde_Ldap)) {
            throw new Horde_Ldap_Exception('Parameter $target_ldap is expected to be a Horde_Ldap object!');
        }

        if (!$target_ldap || $target_ldap === $this) {
            /* Local move. */
            $entry->dn($newdn);
            $entry->setLDAP($this);
            $entry->update();
            return;
        }

        /* Cross directory move. */
        if ($target_ldap->exists($newdn)) {
            throw new Horde_Ldap_Exception('Unable to perform cross directory move: entry does exist in target directory');
        }
        $entry->dn($newdn);
        try {
            $target_ldap->add($entry);
        } catch (Exception $e) {
            throw new Horde_Ldap_Exception('Unable to perform cross directory move: ' . $e->getMessage() . ' in target directory');
        }

        try {
            $this->delete($entry->currentDN());
        } catch (Exception $e) {
            try {
                $add_error_string = '';
                /* Undo add. */
                $target_ldap->delete($entry);
            } catch (Exception $e) {
                $add_error_string = ' Additionally, the deletion (undo add) of $entry in target directory failed.';
            }
            throw new Horde_Ldap_Exception('Unable to perform cross directory move: ' . $e->getMessage() . ' in source directory.' . $add_error_string);
        }
        $entry->setLDAP($target_ldap);
    }

    /**
     * Copies an entry to a new location.
     *
     * The entry will be immediately copied. Only attributes you have
     * selected will be copied.
     *
     * @param Horde_Ldap_Entry $entry An LDAP entry.
     * @param string           $newdn New FQF-DN of the entry.
     *
     * @return Horde_Ldap_Entry  The copied entry.
     * @throws Horde_Ldap_Exception
     */
    public function copy($entry, $newdn)
    {
        if (!$entry instanceof Horde_Ldap_Entry) {
            throw new Horde_Ldap_Exception('Parameter $entry is expected to be a Horde_Ldap_Entry object');
        }

        $newentry = Horde_Ldap_Entry::createFresh($newdn, $entry->getValues());
        $this->add($newentry);

        return $newentry;
    }


    /**
     * Returns the string for an LDAP errorcode.
     *
     * Made to be able to make better errorhandling.  Function based
     * on DB::errorMessage().
     *
     * Hint: The best description of the errorcodes is found here:
     * http://www.directory-info.com/Ldap/LDAPErrorCodes.html
     *
     * @param integer $errorcode An error code.
     *
     * @return string The description for the error.
     */
    public static function errorName($errorcode)
    {
        $errorMessages = array(
            0x00 => 'LDAP_SUCCESS',
            0x01 => 'LDAP_OPERATIONS_ERROR',
            0x02 => 'LDAP_PROTOCOL_ERROR',
            0x03 => 'LDAP_TIMELIMIT_EXCEEDED',
            0x04 => 'LDAP_SIZELIMIT_EXCEEDED',
            0x05 => 'LDAP_COMPARE_FALSE',
            0x06 => 'LDAP_COMPARE_TRUE',
            0x07 => 'LDAP_AUTH_METHOD_NOT_SUPPORTED',
            0x08 => 'LDAP_STRONG_AUTH_REQUIRED',
            0x09 => 'LDAP_PARTIAL_RESULTS',
            0x0a => 'LDAP_REFERRAL',
            0x0b => 'LDAP_ADMINLIMIT_EXCEEDED',
            0x0c => 'LDAP_UNAVAILABLE_CRITICAL_EXTENSION',
            0x0d => 'LDAP_CONFIDENTIALITY_REQUIRED',
            0x0e => 'LDAP_SASL_BIND_INPROGRESS',
            0x10 => 'LDAP_NO_SUCH_ATTRIBUTE',
            0x11 => 'LDAP_UNDEFINED_TYPE',
            0x12 => 'LDAP_INAPPROPRIATE_MATCHING',
            0x13 => 'LDAP_CONSTRAINT_VIOLATION',
            0x14 => 'LDAP_TYPE_OR_VALUE_EXISTS',
            0x15 => 'LDAP_INVALID_SYNTAX',
            0x20 => 'LDAP_NO_SUCH_OBJECT',
            0x21 => 'LDAP_ALIAS_PROBLEM',
            0x22 => 'LDAP_INVALID_DN_SYNTAX',
            0x23 => 'LDAP_IS_LEAF',
            0x24 => 'LDAP_ALIAS_DEREF_PROBLEM',
            0x30 => 'LDAP_INAPPROPRIATE_AUTH',
            0x31 => 'LDAP_INVALID_CREDENTIALS',
            0x32 => 'LDAP_INSUFFICIENT_ACCESS',
            0x33 => 'LDAP_BUSY',
            0x34 => 'LDAP_UNAVAILABLE',
            0x35 => 'LDAP_UNWILLING_TO_PERFORM',
            0x36 => 'LDAP_LOOP_DETECT',
            0x3C => 'LDAP_SORT_CONTROL_MISSING',
            0x3D => 'LDAP_INDEX_RANGE_ERROR',
            0x40 => 'LDAP_NAMING_VIOLATION',
            0x41 => 'LDAP_OBJECT_CLASS_VIOLATION',
            0x42 => 'LDAP_NOT_ALLOWED_ON_NONLEAF',
            0x43 => 'LDAP_NOT_ALLOWED_ON_RDN',
            0x44 => 'LDAP_ALREADY_EXISTS',
            0x45 => 'LDAP_NO_OBJECT_CLASS_MODS',
            0x46 => 'LDAP_RESULTS_TOO_LARGE',
            0x47 => 'LDAP_AFFECTS_MULTIPLE_DSAS',
            0x50 => 'LDAP_OTHER',
            0x51 => 'LDAP_SERVER_DOWN',
            0x52 => 'LDAP_LOCAL_ERROR',
            0x53 => 'LDAP_ENCODING_ERROR',
            0x54 => 'LDAP_DECODING_ERROR',
            0x55 => 'LDAP_TIMEOUT',
            0x56 => 'LDAP_AUTH_UNKNOWN',
            0x57 => 'LDAP_FILTER_ERROR',
            0x58 => 'LDAP_USER_CANCELLED',
            0x59 => 'LDAP_PARAM_ERROR',
            0x5a => 'LDAP_NO_MEMORY',
            0x5b => 'LDAP_CONNECT_ERROR',
            0x5c => 'LDAP_NOT_SUPPORTED',
            0x5d => 'LDAP_CONTROL_NOT_FOUND',
            0x5e => 'LDAP_NO_RESULTS_RETURNED',
            0x5f => 'LDAP_MORE_RESULTS_TO_RETURN',
            0x60 => 'LDAP_CLIENT_LOOP',
            0x61 => 'LDAP_REFERRAL_LIMIT_EXCEEDED',
            1000 => 'Unknown Error');

         return isset($errorMessages[$errorcode]) ?
            $errorMessages[$errorcode] :
            'Unknown Error (' . $errorcode . ')';
    }

    /**
     * Returns a rootDSE object
     *
     * This either fetches a fresh rootDSE object or returns it from
     * the internal cache for performance reasons, if possible.
     *
     * @param array $attrs Array of attributes to search for.
     *
     * @return Horde_Ldap_RootDse Horde_Ldap_RootDse object
     * @throws Horde_Ldap_Exception
     */
    public function rootDSE(array $attrs = array())
    {
        /* If a cache object is registered, we use that to fetch a rootDSE
         * object. */
        $key = 'Horde_Ldap_RootDse_'
            . md5(serialize(array(
                $this->_config['hostspec'], $this->_config['port'], $attrs
            )));
        if (empty($this->_rootDSE[$key]) &&
            $this->_config['cache'] &&
            $this->_config['cache_root_dse']) {
            $entry = $this->_config['cache']->get(
                $key, $this->_config['cachettl']
            );
            if ($entry) {
                $this->_rootDSE[$key] = @unserialize($entry);
            }
        }

        /* See if we need to fetch a fresh object, or if we already
         * requested this object with the same attributes. */
        if (empty($this->_rootDSE[$key])) {
            $this->_rootDSE[$key] = new Horde_Ldap_RootDse($this, $attrs);

            /* If caching is active, advise the cache to store the object. */
            if ($this->_config['cache'] && $this->_config['cache_root_dse']) {
                $this->_config['cache']->set(
                    $key,
                    serialize($this->_rootDSE[$key]),
                    $this->_config['cachettl']
                );
            }
        }

        return $this->_rootDSE[$key];
    }

    /**
     * Returns a schema object
     *
     * @param string $dn Subschema entry dn.
     *
     * @return Horde_Ldap_Schema  Horde_Ldap_Schema object
     * @throws Horde_Ldap_Exception
     */
    public function schema($dn = null)
    {
        /* If a schema caching object is registered, we use that to fetch a
         * schema object. */
        $key = 'Horde_Ldap_Schema_' . md5(serialize(array($this->_config['hostspec'], $this->_config['port'], $dn)));
        if (!$this->_schema && $this->_config['cache']) {
            $schema = $this->_config['cache']->get($key, $this->_config['cachettl']);
            if ($schema) {
                $this->_schema = @unserialize($schema);
            }
        }

        /* Fetch schema, if not tried before and no cached version available.
         * If we are already fetching the schema, we will skip fetching. */
        if (!$this->_schema) {
            /* Store a temporary error message so subsequent calls to schema()
             * can detect that we are fetching the schema already. Otherwise we
             * will get an infinite loop at Horde_Ldap_Schema. */
            $this->_schema = new Horde_Ldap_Exception('Schema not initialized');
            $this->_schema = new Horde_Ldap_Schema($this, $dn);

            /* If schema caching is active, advise the cache to store the
             * schema. */
            if ($this->_config['cache']) {
                $this->_config['cache']->set($key, serialize($this->_schema), $this->_config['cachettl']);
            }
        }

        if ($this->_schema instanceof Horde_Ldap_Exception) {
            throw $this->_schema;
        }

        return $this->_schema;
    }

    /**
     * Checks if PHP's LDAP extension is loaded.
     *
     * If it is not loaded, it tries to load it manually using PHP's dl().
     * It knows both windows-dll and *nix-so.
     *
     * @throws Horde_Ldap_Exception
     */
    public static function checkLDAPExtension()
    {
        if (!extension_loaded('ldap') && !@dl('ldap.' . PHP_SHLIB_SUFFIX)) {
            throw new Horde_Ldap_Exception('Unable to locate PHP LDAP extension. Please install it before using the Horde_Ldap package.');
        }
    }

    /**
     * @todo Remove this and expect all data to be UTF-8.
     *
     * Encodes given attributes to UTF8 if needed by schema.
     *
     * This function takes attributes in an array and then checks
     * against the schema if they need UTF8 encoding. If that is the
     * case, they will be encoded. An encoded array will be returned
     * and can be used for adding or modifying.
     *
     * $attributes is expected to be an array with keys describing
     * the attribute names and the values as the value of this attribute:
     * <code>$attributes = array('cn' => 'foo', 'attr2' => array('mv1', 'mv2'));</code>
     *
     * @param array $attributes An array of attributes.
     *
     * @return array|Horde_Ldap_Error An array of UTF8 encoded attributes or an error.
     */
    public function utf8Encode($attributes)
    {
        return $this->utf8($attributes, 'utf8_encode');
    }

    /**
     * @todo Remove this and expect all data to be UTF-8.
     *
     * Decodes the given attribute values if needed by schema
     *
     * $attributes is expected to be an array with keys describing
     * the attribute names and the values as the value of this attribute:
     * <code>$attributes = array('cn' => 'foo', 'attr2' => array('mv1', 'mv2'));</code>
     *
     * @param array $attributes Array of attributes
     *
     * @access public
     * @see utf8Encode()
     * @return array|Horde_Ldap_Error Array with decoded attribute values or Error
     */
    public function utf8Decode($attributes)
    {
        return $this->utf8($attributes, 'utf8_decode');
    }

    /**
     * @todo Remove this and expect all data to be UTF-8.
     *
     * Encodes or decodes attribute values if needed
     *
     * @param array $attributes Array of attributes
     * @param array $function   Function to apply to attribute values
     *
     * @access protected
     * @return array Array of attributes with function applied to values.
     */
    protected function utf8($attributes, $function)
    {
        if (!is_array($attributes) || array_key_exists(0, $attributes)) {
            throw new Horde_Ldap_Exception('Parameter $attributes is expected to be an associative array');
        }

        if (!$this->_schema) {
            $this->_schema = $this->schema();
        }

        if (!$this->_link || !function_exists($function)) {
            return $attributes;
        }

        if (is_array($attributes) && count($attributes) > 0) {

            foreach ($attributes as $k => $v) {

                if (!isset($this->_schemaAttrs[$k])) {

                    try {
                        $attr = $this->_schema->get('attribute', $k);
                    } catch (Exception $e) {
                        continue;
                    }

                    if (false !== strpos($attr['syntax'], '1.3.6.1.4.1.1466.115.121.1.15')) {
                        $encode = true;
                    } else {
                        $encode = false;
                    }
                    $this->_schemaAttrs[$k] = $encode;

                } else {
                    $encode = $this->_schemaAttrs[$k];
                }

                if ($encode) {
                    if (is_array($v)) {
                        foreach ($v as $ak => $av) {
                            $v[$ak] = call_user_func($function, $av);
                        }
                    } else {
                        $v = call_user_func($function, $v);
                    }
                }
                $attributes[$k] = $v;
            }
        }
        return $attributes;
    }

    /**
     * Returns the LDAP link resource.
     *
     * It will loop attempting to re-establish the connection if the
     * connection attempt fails and auto_reconnect has been turned on
     * (see the _config array documentation).
     *
     * @return resource LDAP link.
     */
    public function getLink()
    {
        if ($this->_config['auto_reconnect']) {
            while (true) {
                /* Return the link handle if we are already connected.
                 * Otherwise try to reconnect. */
                if ($this->_link) {
                    return $this->_link;
                }
                $this->_reconnect();
            }
        }
        return $this->_link;
    }

    /**
     * Builds an LDAP search filter fragment.
     *
     * @param string $lhs    The attribute to test.
     * @param string $op     The operator.
     * @param string $rhs    The comparison value.
     * @param array $params  Any additional parameters for the operator.
     *
     * @return string  The LDAP search fragment.
     */
    public static function buildClause($lhs, $op, $rhs, $params = array())
    {
        switch ($op) {
        case 'LIKE':
            if (empty($rhs)) {
                return '(' . $lhs . '=*)';
            }
            if (!empty($params['begin'])) {
                return sprintf('(|(%s=%s*)(%s=* %s*))', $lhs, self::quote($rhs), $lhs, self::quote($rhs));
            }
            if (!empty($params['approximate'])) {
                return sprintf('(%s~=%s)', $lhs, self::quote($rhs));
            }
            return sprintf('(%s=*%s*)', $lhs, self::quote($rhs));

        default:
            return sprintf('(%s%s%s)', $lhs, $op, self::quote($rhs));
        }
    }


    /**
     * Escapes characters with special meaning in LDAP searches.
     *
     * @param string $clause  The string to escape.
     *
     * @return string  The escaped string.
     */
    public static function quote($clause)
    {
        return str_replace(array('\\',   '(',  ')',  '*',  "\0"),
                           array('\\5c', '\(', '\)', '\*', "\\00"),
                           $clause);
    }

    /**
     * Takes an array of DN elements and properly quotes it according to RFC
     * 1485.
     *
     * @param array $parts  An array of tuples containing the attribute
     *                      name and that attribute's value which make
     *                      up the DN. Example:
     *                      <code>
     *                      $parts = array(
     *                          array('cn', 'John Smith'),
     *                          array('dc', 'example'),
     *                          array('dc', 'com')
     *                      );
     *                      </code>
     *                      Nested arrays are supported since 2.1.0, to form
     *                      multi-valued RDNs. Example:
     *                      <code>
     *                      $parts = array(
     *                          array(
     *                              array('cn', 'John'),
     *                              array('sn', 'Smith'),
     *                              array('o', 'Acme Inc.'),
     *                          ),
     *                          array('dc', 'example'),
     *                          array('dc', 'com')
     *                      );
     *                      </code>
     *                      which will result in
     *                      cn=John+sn=Smith+o=Acme Inc.,dc=example,dc=com
     *
     * @return string  The properly quoted string DN.
     */
    public static function quoteDN($parts)
    {
        return implode(',', array_map('self::_quoteRDNs', $parts));
    }

    /**
     * Takes a single or a list of RDN arrays with an attribute name and value
     * and properly quotes it according to RFC 1485.
     *
     * @param array $attribute  A tuple or array of tuples containing the
     *                          attribute name and that attribute's value which
     *                          make up the RDN.
     *
     * @return string  The properly quoted string RDN.
     */
    protected static function _quoteRDNs($attribute)
    {
        if (is_array($attribute[0])) {
            return implode(
                '+',
                array_map('self::_quoteRDN', $attribute)
            );
        } else {
            return self::_quoteRDN($attribute);
        }
    }

    /**
     * Takes an RDN array with an attribute name and value and properly quotes
     * it according to RFC 1485.
     *
     * @param array $attribute  A tuple containing the attribute name and that
     *                          attribute's value which make up the RDN.
     *
     * @return string  The properly quoted string RDN.
     */
    protected static function _quoteRDN($attribute)
    {
        $rdn = $attribute[0] . '=';

        // See if we need to quote the value.
        if (preg_match('/^\s|\s$|\s\s|[,+="\r\n<>#;]/', $attribute[1])) {
            $rdn .= '"' . str_replace('"', '\\"', $attribute[1]) . '"';
        } else {
            $rdn .= $attribute[1];
        }

        return $rdn;
    }
}
