<?php
/**
 * Utility Class for Horde_Ldap
 *
 * This class servers some functionality to the other classes of Horde_Ldap but
 * most of the methods can be used separately as well.
 *
 * Copyright 2009 Benedikt Hallinger
 * Copyright 2010-2017 Horde LLC (http://www.horde.org/)
 *
 * @category  Horde
 * @package   Ldap
 * @author    Benedikt Hallinger <beni@php.net>
 * @author    Jan Schneider <jan@horde.org>
 * @license   http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0
 */
class Horde_Ldap_Util
{
    /**
     * Explodes the given DN into its elements
     *
     * {@link http://www.ietf.org/rfc/rfc2253.txt RFC 2253} says, a
     * Distinguished Name is a sequence of Relative Distinguished Names (RDNs),
     * which themselves are sets of Attributes. For each RDN a array is
     * constructed where the RDN part is stored.
     *
     * For example, the DN 'OU=Sales+CN=J. Smith,DC=example,DC=net' is exploded
     * to:
     * <code>
     * array(array('OU=Sales', 'CN=J. Smith'),
     *       'DC=example',
     *       'DC=net')
     * </code>
     *
     * [NOT IMPLEMENTED] DNs might also contain values, which are the bytes of
     * the BER encoding of the X.500 AttributeValue rather than some LDAP
     * string syntax. These values are hex-encoded and prefixed with a #. To
     * distinguish such BER values, explodeDN uses references to the
     * actual values, e.g. '1.3.6.1.4.1.1466.0=#04024869,DC=example,DC=com' is
     * exploded to:
     * <code>
     * array(array('1.3.6.1.4.1.1466.0' => "\004\002Hi"),
     *       array('DC' => 'example',
     *       array('DC' => 'com'))
     * <code>
     * See {@link http://www.vijaymukhi.com/vmis/berldap.htm} for more
     * information on BER.
     *
     * It also performs the following operations on the given DN:
     * - Unescape "\" followed by ",", "+", """, "\", "<", ">", ";", "#", "=",
     *   " ", or a hexpair and strings beginning with "#".
     * - Removes the leading 'OID.' characters if the type is an OID instead of
     *   a name.
     * - If an RDN contains multiple parts, the parts are re-ordered so that
     *   the attribute type names are in alphabetical order.
     *
     * $options is a list of name/value pairs, valid options are:
     * - casefold:   Controls case folding of attribute types names.
     *               Attribute values are not affected by this option.
     *               The default is to uppercase. Valid values are:
     *               - lower: Lowercase attribute types names.
     *               - upper: Uppercase attribute type names. This is the
     *                        default.
     *               - none:  Do not change attribute type names.
     * - reverse:    If true, the RDN sequence is reversed.
     * - onlyvalues: If true, then only attributes values are returned ('foo'
     *               instead of 'cn=foo')
     *
     * @todo implement BER
     * @todo replace preg_replace() callbacks.
     *
     * @param string $dn      The DN that should be exploded.
     * @param array  $options Options to use.
     *
     * @return array   Parts of the exploded DN.
     */
    public static function explodeDN($dn, array $options = array())
    {
        $options = array_merge(
            array(
                'casefold' => 'upper',
                'onlyvalues' => false,
                'reverse' => false,
            ),
            $options
        );

        // Escaping of DN and stripping of "OID.".
        $dn = self::canonicalDN($dn, array('casefold' => $options['casefold']));

        // Splitting the DN.
        $dn_array = preg_split('/(?<!\\\\),/', $dn);

        // Clear wrong splitting (possibly we have split too much).
        // Not clear, if this is neccessary here:
        //$dn_array = self::_correctDNSplitting($dn_array, ',');

        $callback_upper = function($value) {
            return Horde_String::upper($value[1]);
        };
        $callback_lower = function($value) {
            return Horde_String::lower($value[1]);
        };

        // Construct subarrays for multivalued RDNs and unescape DN value, also
        // convert to output format and apply casefolding.
        foreach ($dn_array as $key => $value) {
            $value_u = self::unescapeDNValue($value);
            $rdns    = self::splitRDNMultivalue($value_u[0]);
            // TODO: nuke code duplication
            if (count($rdns) > 1) {
                // Multivalued RDN!
                foreach ($rdns as $subrdn_k => $subrdn_v) {
                    // Casefolding.
                    if ($options['casefold'] == 'upper') {
                        $subrdn_v = preg_replace_callback('/^(\w+=)/',
                                                          $callback_upper,
                                                          $subrdn_v);
                    }
                    if ($options['casefold'] == 'lower') {
                        $subrdn_v = preg_replace_callback('/^(\w+=)/',
                                                          $callback_lower,
                                                          $subrdn_v);
                    }

                    if ($options['onlyvalues']) {
                        preg_match('/(.+?)(?<!\\\\)=(.+)/', $subrdn_v, $matches);
                        $rdn_val         = $matches[2];
                        $unescaped       = self::unescapeDNValue($rdn_val);
                        $rdns[$subrdn_k] = $unescaped[0];
                    } else {
                        $unescaped = self::unescapeDNValue($subrdn_v);
                        $rdns[$subrdn_k] = $unescaped[0];
                    }
                }

                $dn_array[$key] = $rdns;
            } else {
                // Singlevalued RDN.
                // Casefolding.
                if ($options['casefold'] == 'upper') {
                    $value = preg_replace_callback('/^(\w+=)/',
                                                   $callback_upper,
                                                   $value);
                }
                if ($options['casefold'] == 'lower') {
                    $value = preg_replace_callback('/^(\w+=)/',
                                                   $callback_lower,
                                                   $value);
                }

                if ($options['onlyvalues']) {
                    preg_match('/(.+?)(?<!\\\\)=(.+)/', $value, $matches);
                    $dn_val         = $matches[2];
                    $unescaped      = self::unescapeDNValue($dn_val);
                    $dn_array[$key] = $unescaped[0];
                } else {
                    $unescaped = self::unescapeDNValue($value);
                    $dn_array[$key] = $unescaped[0];
                }
            }
        }

        if ($options['reverse']) {
            return array_reverse($dn_array);
        }

        return $dn_array;
    }

    /**
     * Escapes DN values according to RFC 2253.
     *
     * Escapes the given VALUES according to RFC 2253 so that they can be
     * safely used in LDAP DNs.  The characters ",", "+", """, "\", "<", ">",
     * ";", "#", "=" with a special meaning in RFC 2252 are preceeded by ba
     * backslash. Control characters with an ASCII code < 32 are represented as
     * \hexpair.  Finally all leading and trailing spaces are converted to
     * sequences of \20.
     *
     * @param string|array $values  DN values that should be escaped.
     *
     * @return array  The escaped values.
     */
    public static function escapeDNValue($values)
    {
        // Parameter validation.
        if (!is_array($values)) {
            $values = array($values);
        }

        foreach ($values as $key => $val) {
            // Escaping of filter meta characters.
            $val = addcslashes($val, '\\,+"<>;#=');

            // ASCII < 32 escaping.
            $val = self::asc2hex32($val);

            // Convert all leading and trailing spaces to sequences of \20.
            if (preg_match('/^(\s*)(.+?)(\s*)$/', $val, $matches)) {
                $val = str_repeat('\20', strlen($matches[1])) . $matches[2] . str_repeat('\20', strlen($matches[3]));
            }

            if (null === $val) {
                // Apply escaped "null" if string is empty.
                $val = '\0';
            }

            $values[$key] = $val;
        }

        return $values;
    }

    /**
     * Unescapes DN values according to RFC 2253.
     *
     * Reverts the conversion done by escapeDNValue().
     *
     * Any escape sequence starting with a baskslash - hexpair or special
     * character - will be transformed back to the corresponding character.
     *
     * @param array $values  DN values.
     *
     * @return array  Unescaped DN values.
     */
    public static function unescapeDNValue($values)
    {
        // Parameter validation.
        if (!is_array($values)) {
            $values = array($values);
        }

        foreach ($values as $key => $val) {
            // Strip slashes from special chars.
            $val = str_replace(
                array('\\\\', '\,', '\+', '\"', '\<', '\>', '\;', '\#', '\='),
                array('\\', ',', '+', '"', '<', '>', ';', '#', '='),
                $val);

            // Translate hex code into ascii.
            $values[$key] = self::hex2asc($val);
        }

        return $values;
    }

    /**
     * Converts a DN into a canonical form.
     *
     * DN can either be a string or an array as returned by explodeDN(),
     * which is useful when constructing a DN.  The DN array may have be
     * indexed (each array value is a OCL=VALUE pair) or associative (array key
     * is OCL and value is VALUE).
     *
     * It performs the following operations on the given DN:
     * - Removes the leading 'OID.' characters if the type is an OID instead of
     *   a name.
     * - Escapes all RFC 2253 special characters (",", "+", """, "\", "<", ">",
     *   ";", "#", "="), slashes ("/"), and any other character where the ASCII
     *   code is < 32 as \hexpair.
     * - Converts all leading and trailing spaces in values to be \20.
     * - If an RDN contains multiple parts, the parts are re-ordered so that
     *   the attribute type names are in alphabetical order.
     *
     * $options is a list of name/value pairs, valid options are:
     *
     * - casefold:  Controls case folding of attribute type names. Attribute
     *              values are not affected by this option. The default is to
     *              uppercase. Valid values are:
     *              - lower: Lowercase attribute type names.
     *              - upper: Uppercase attribute type names.
     *              - none:  Do not change attribute type names.
     * - reverse:   If true, the RDN sequence is reversed.
     * - separator: Separator to use between RDNs. Defaults to comma (',').
     *
     * The empty string "" is a valid DN, so be sure not to do a "$can_dn ==
     * false" test, because an empty string evaluates to false. Use the "==="
     * operator instead.
     *
     * @param array|string $dn      The DN.
     * @param array        $options Options to use.
     *
     * @return boolean|string The canonical DN or false if the DN is not valid.
     */
    public static function canonicalDN($dn, array $options = array())
    {
        if ($dn === '') {
            // Empty DN is valid.
            return $dn;
        }

        // Options check.
        $options = array_merge(
            array(
                'casefold' => 'upper',
                'reverse' => false,
                'separator' => ',',
            ),
            $options
        );

        if (!is_array($dn)) {
            // It is not clear to me if the perl implementation splits by the
            // user defined separator or if it just uses this separator to
            // construct the new DN.
            $dn = preg_split('/(?<!\\\\)' . preg_quote($options['separator']) . '/', $dn);

            // Clear wrong splitting (possibly we have split too much).
            $dn = self::_correctDNSplitting($dn, $options['separator']);
        } else {
            // Is array, check if the array is indexed or associative.
            $assoc = false;
            foreach ($dn as $dn_key => $dn_part) {
                if (!is_int($dn_key)) {
                    $assoc = true;
                    break;
                }
            }

            // Convert to indexed, if associative array detected.
            if ($assoc) {
                $newdn = array();
                foreach ($dn as $dn_key => $dn_part) {
                    if (is_array($dn_part)) {
                        // We assume here that the RDN parts are also
                        // associative.
                        ksort($dn_part, SORT_STRING);
                        // Copy array as-is, so we can resolve it later.
                        $newdn[] = $dn_part;
                    } else {
                        $newdn[] = $dn_key . '=' . $dn_part;
                    }
                }
                $dn =& $newdn;
            }
        }

        // Escaping and casefolding.
        foreach ($dn as $pos => $dnval) {
            if (is_array($dnval)) {
                // Subarray detected, this means most probably that we had a
                // multivalued DN part, which must be resolved.
                $dnval_new = '';
                foreach ($dnval as $subkey => $subval) {
                    // Build RDN part.
                    if (!is_int($subkey)) {
                        $subval = $subkey . '=' . $subval;
                    }
                    $subval_processed = self::canonicalDN($subval, $options);
                    if (false === $subval_processed) {
                        return false;
                    }
                    $dnval_new .= $subval_processed . '+';
                }
                // Store RDN part, strip last plus.
                $dn[$pos] = substr($dnval_new, 0, -1);
            } else {
                // Try to split multivalued RDNs into array.
                $rdns = self::splitRDNMultivalue($dnval);
                if (count($rdns) > 1) {
                    // Multivalued RDN was detected. The RDN value is expected
                    // to be correctly split by splitRDNMultivalue(). It's time
                    // to sort the RDN and build the DN.
                    $rdn_string = '';
                    // Sort RDN keys alphabetically.
                    sort($rdns, SORT_STRING);
                    foreach ($rdns as $rdn) {
                        $subval_processed = self::canonicalDN($rdn, $options);
                        if (false === $subval_processed) {
                            return false;
                        }
                        $rdn_string .= $subval_processed . '+';
                    }

                    // Store RDN part, strip last plus.
                    $dn[$pos] = substr($rdn_string, 0, -1);
                } else {
                    // No multivalued RDN. Split at first unescaped "=".
                    $dn_comp = self::splitAttributeString($rdns[0]);
                    if (count($dn_comp) != 2) {
                        throw new Horde_Ldap_Exception('Invalid RDN: ' . $rdns[0]);
                    }
                    // Trim left whitespaces because of "cn=foo, l=bar" syntax
                    // (whitespace after comma).
                    $ocl = ltrim($dn_comp[0]);
                    $val = $dn_comp[1];

                    // Strip 'OID.', otherwise apply casefolding and escaping.
                    if (substr(Horde_String::lower($ocl), 0, 4) == 'oid.') {
                        $ocl = substr($ocl, 4);
                    } else {
                        if ($options['casefold'] == 'upper') {
                            $ocl = Horde_String::upper($ocl);
                        }
                        if ($options['casefold'] == 'lower') {
                            $ocl = Horde_String::lower($ocl);
                        }
                        $ocl = self::escapeDNValue(array($ocl));
                        $ocl = $ocl[0];
                    }

                    // Escaping of DN value.
                    // TODO: if the value is already correctly escaped, we get
                    //       double escaping.
                    $val = self::escapeDNValue(array($val));
                    $val = str_replace('/', '\/', $val[0]);

                    $dn[$pos] = $ocl . '=' . $val;
                }
            }
        }

        if ($options['reverse']) {
            $dn = array_reverse($dn);
        }

        return implode($options['separator'], $dn);
    }

    /**
     * Escapes the given values according to RFC 2254 so that they can be
     * safely used in LDAP filters.
     *
     * Any control characters with an ACII code < 32 as well as the characters
     * with special meaning in LDAP filters "*", "(", ")", and "\" (the
     * backslash) are converted into the representation of a backslash followed
     * by two hex digits representing the hexadecimal value of the character.
     *
     * @param array $values Values to escape.
     *
     * @return array Escaped values.
     */
    public static function escapeFilterValue($values)
    {
        // Parameter validation.
        if (!is_array($values)) {
            $values = array($values);
        }

        foreach ($values as $key => $val) {
            // Escaping of filter meta characters.
            $val = str_replace(array('\\', '*', '(', ')'),
                               array('\5c', '\2a', '\28', '\29'),
                               $val);

            // ASCII < 32 escaping.
            $val = self::asc2hex32($val);

            if (null === $val) {
                // Apply escaped "null" if string is empty.
                $val = '\0';
            }

            $values[$key] = $val;
        }

        return $values;
    }

    /**
     * Unescapes the given values according to RFC 2254.
     *
     * Reverses the conversion done by {@link escapeFilterValue()}.
     *
     * Converts any sequences of a backslash followed by two hex digits into
     * the corresponding character.
     *
     * @param array $values Values to unescape.
     *
     * @return array Unescaped values.
     */
    public static function unescapeFilterValue($values = array())
    {
        // Parameter validation.
        if (!is_array($values)) {
            $values = array($values);
        }

        foreach ($values as $key => $value) {
            // Translate hex code into ascii.
            $values[$key] = self::hex2asc($value);
        }

        return $values;
    }

    /**
     * Converts all ASCII chars < 32 to "\HEX".
     *
     * @param string $string String to convert.
     *
     * @return string Hexadecimal representation of $string.
     */
    public static function asc2hex32($string)
    {
        for ($i = 0, $len = strlen($string); $i < $len; $i++) {
            $char = substr($string, $i, 1);
            if (ord($char) < 32) {
                $hex = dechex(ord($char));
                if (strlen($hex) == 1) {
                    $hex = '0' . $hex;
                }
                $string = str_replace($char, '\\' . $hex, $string);
            }
        }
        return $string;
    }

    /**
     * Converts all hexadecimal expressions ("\HEX") to their original ASCII
     * characters.
     *
     * @author beni@php.net, heavily based on work from DavidSmith@byu.net
     *
     * @param string $string String to convert.
     *
     * @return string ASCII representation of $string.
     */
    public static function hex2asc($string)
    {
        return preg_replace_callback(
            '/\\\([0-9A-Fa-f]{2})/',
            function($hex) {
                return chr(hexdec($hex[1]));
            },
            $string);
    }

    /**
     * Splits a multivalued RDN value into an array.
     *
     * A RDN can contain multiple values, spearated by a plus sign. This method
     * returns each separate ocl=value pair of the RDN part.
     *
     * If no multivalued RDN is detected, an array containing only the original
     * RDN part is returned.
     *
     * For example, the multivalued RDN 'OU=Sales+CN=J. Smith' is exploded to:
     * <kbd>array([0] => 'OU=Sales', [1] => 'CN=J. Smith')</kbd>
     *
     * The method tries to be smart if it encounters unescaped "+" characters,
     * but may fail, so better ensure escaped "+" in attribute names and
     * values.
     *
     * [BUG] If you have a multivalued RDN with unescaped plus characters and
     *       there is a unescaped plus sign at the end of an value followed by
     *       an attribute name containing an unescaped plus, then you will get
     *       wrong splitting:
     *         $rdn = 'OU=Sales+C+N=J. Smith';
     *       returns:
     *         array('OU=Sales+C', 'N=J. Smith');
     *       The "C+" is treaten as the value of the first pair instead of as
     *       the attribute name of the second pair. To prevent this, escape
     *       correctly.
     *
     * @param string $rdn Part of a (multivalued) escaped RDN (e.g. ou=foo or
     *                    ou=foo+cn=bar)
     *
     * @return array The components of the multivalued RDN.
     */
    public static function splitRDNMultivalue($rdn)
    {
        $rdns = preg_split('/(?<!\\\\)\+/', $rdn);
        $rdns = self::_correctDNSplitting($rdns, '+');
        return array_values($rdns);
    }

    /**
     * Splits a attribute=value syntax into an array.
     *
     * The split will occur at the first unescaped '=' character.
     *
     * @param string $attr An attribute-value string.
     *
     * @return array Indexed array: 0=attribute name, 1=attribute value.
     */
    public static function splitAttributeString($attr)
    {
        return preg_split('/(?<!\\\\)=/', $attr, 2);
    }

    /**
     * Corrects splitting of DN parts.
     *
     * @param array $dn        Raw DN array.
     * @param array $separator Separator that was used when splitting.
     *
     * @return array Corrected array.
     */
    protected static function _correctDNSplitting($dn = array(),
                                                  $separator = ',')
    {
        foreach ($dn as $key => $dn_value) {
            // Refresh value (foreach caches!)
            $dn_value = $dn[$key];
            // If $dn_value is not in attr=value format, we had an unescaped
            // separator character inside the attr name or the value. We assume
            // that it was the attribute value.

            // TODO: To solve this, we might ask the schema. The
            //       Horde_Ldap_Util class must remain independent from the
            //       other classes or connections though.
            if (!preg_match('/.+(?<!\\\\)=.+/', $dn_value)) {
                unset($dn[$key]);
                if (array_key_exists($key - 1, $dn)) {
                    // Append to previous attribute value.
                    $dn[$key - 1] = $dn[$key - 1] . $separator . $dn_value;
                } elseif (array_key_exists($key + 1, $dn)) {
                    // First element: prepend to next attribute name.
                    $dn[$key + 1] = $dn_value . $separator . $dn[$key + 1];
                } else {
                    $dn[$key] = $dn_value;
                }
            }
        }
        return array_values($dn);
    }
}
