1<?php
2/**
3 * Object representation of a part of a LDAP filter.
4 *
5 * The purpose of this class is to easily build LDAP filters without having to
6 * worry about correct escaping etc.
7 *
8 * A filter is built using several independent filter objects which are
9 * combined afterwards. This object works in two modes, depending how the
10 * object is created.
11 *
12 * If the object is created using the {@link create()} method, then this is a
13 * leaf-object. If the object is created using the {@link combine()} method,
14 * then this is a container object.
15 *
16 * LDAP filters are defined in RFC 2254.
17 *
18 * @see http://www.ietf.org/rfc/rfc2254.txt
19 *
20 * A short example:
21 * <code>
22 * $filter0     = Horde_Ldap_Filter::create('stars', 'equals', '***');
23 * $filter_not0 = Horde_Ldap_Filter::combine('not', $filter0);
24 *
25 * $filter1     = Horde_Ldap_Filter::create('gn', 'begins', 'bar');
26 * $filter2     = Horde_Ldap_Filter::create('gn', 'ends', 'baz');
27 * $filter_comp = Horde_Ldap_Filter::combine('or', array($filter_not0, $filter1, $filter2));
28 *
29 * echo (string)$filter_comp;
30 * // This will output: (|(!(stars=\0x5c0x2a\0x5c0x2a\0x5c0x2a))(gn=bar*)(gn=*baz))
31 * // The stars in $filter0 are treaten as real stars unless you disable escaping.
32 * </code>
33 *
34 * Copyright 2009 Benedikt Hallinger
35 * Copyright 2010-2017 Horde LLC (http://www.horde.org/)
36 *
37 * @category  Horde
38 * @package   Ldap
39 * @author    Benedikt Hallinger <beni@php.net>
40 * @author    Jan Schneider <jan@horde.org>
41 * @license   http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0
42 */
43class Horde_Ldap_Filter
44{
45    /**
46     * Storage for combination of filters.
47     *
48     * This variable holds a array of filter objects that should be combined by
49     * this filter object.
50     *
51     * @var array
52     */
53    protected $_filters = array();
54
55    /**
56     * Operator for sub-filters.
57     *
58     * @var string
59     */
60    protected $_operator;
61
62    /**
63     * Single filter.
64     *
65     * If this is a leaf filter, the filter representation is store here.
66     *
67     * @var string
68     */
69    protected $_filter;
70
71    /**
72     * Constructor.
73     *
74     * Construction of Horde_Ldap_Filter objects should happen through either
75     * {@link create()} or {@link combine()} which give you more control.
76     * However, you may use the constructor if you already have generated
77     * filters.
78     *
79     * @param array $params List of object parameters
80     */
81    protected function __construct(array $params)
82    {
83        foreach ($params as $param => $value) {
84            if (in_array($param, array('filter', 'filters', 'operator'))) {
85                $this->{'_' . $param} = $value;
86            }
87        }
88    }
89
90    /**
91     * Creates a new part of an LDAP filter.
92     *
93     * The following matching rules exists:
94     * - equals:         One of the attributes values is exactly $value.
95     *                   Please note that case sensitiviness depends on the
96     *                   attributes syntax configured in the server.
97     * - begins:         One of the attributes values must begin with $value.
98     * - ends:           One of the attributes values must end with $value.
99     * - contains:       One of the attributes values must contain $value.
100     * - present | any:  The attribute can contain any value but must exist.
101     * - greater:        The attributes value is greater than $value.
102     * - less:           The attributes value is less than $value.
103     * - greaterOrEqual: The attributes value is greater or equal than $value.
104     * - lessOrEqual:    The attributes value is less or equal than $value.
105     * - approx:         One of the attributes values is similar to $value.
106     *
107     * If $escape is set to true then $value will be escaped. If set to false
108     * then $value will be treaten as a raw filter value string.  You should
109     * then escape it yourself using {@link
110     * Horde_Ldap_Util::escapeFilterValue()}.
111     *
112     * Examples:
113     * <code>
114     * // This will find entries that contain an attribute "sn" that ends with
115     * // "foobar":
116     * $filter = Horde_Ldap_Filter::create('sn', 'ends', 'foobar');
117     *
118     * // This will find entries that contain an attribute "sn" that has any
119     * // value set:
120     * $filter = Horde_Ldap_Filter::create('sn', 'any');
121     * </code>
122     *
123     * @param string  $attribute Name of the attribute the filter should apply
124     *                           to.
125     * @param string  $match     Matching rule (equals, begins, ends, contains,
126     *                           greater, less, greaterOrEqual, lessOrEqual,
127     *                           approx, any).
128     * @param string  $value     If given, then this is used as a filter value.
129     * @param boolean $escape    Should $value be escaped?
130     *
131     * @return Horde_Ldap_Filter
132     * @throws Horde_Ldap_Exception
133     */
134    public static function create($attribute, $match, $value = '',
135                                  $escape = true)
136    {
137        if ($escape) {
138            $array = Horde_Ldap_Util::escapeFilterValue(array($value));
139            $value = $array[0];
140        }
141
142        switch (Horde_String::lower($match)) {
143        case 'equals':
144        case '=':
145            $filter = '(' . $attribute . '=' . $value . ')';
146            break;
147        case 'begins':
148            $filter = '(' . $attribute . '=' . $value . '*)';
149            break;
150        case 'ends':
151            $filter = '(' . $attribute . '=*' . $value . ')';
152            break;
153        case 'contains':
154            $filter = '(' . $attribute . '=*' . $value . '*)';
155            break;
156        case 'greater':
157        case '>':
158            $filter = '(' . $attribute . '>' . $value . ')';
159            break;
160        case 'less':
161        case '<':
162            $filter = '(' . $attribute . '<' . $value . ')';
163            break;
164        case 'greaterorequal':
165        case '>=':
166            $filter = '(' . $attribute . '>=' . $value . ')';
167            break;
168        case 'lessorequal':
169        case '<=':
170            $filter = '(' . $attribute . '<=' . $value . ')';
171            break;
172        case 'approx':
173        case '~=':
174            $filter = '(' . $attribute . '~=' . $value . ')';
175            break;
176        case 'any':
177        case 'present':
178            $filter = '(' . $attribute . '=*)';
179            break;
180        default:
181            throw new Horde_Ldap_Exception('Matching rule "' . $match . '" unknown');
182        }
183
184        return new Horde_Ldap_Filter(array('filter' => $filter));
185
186    }
187
188    /**
189     * Combines two or more filter objects using a logical operator.
190     *
191     * Example:
192     * <code>
193     * $filter = Horde_Ldap_Filter::combine('or', array($filter1, $filter2));
194     * </code>
195     *
196     * If the array contains filter strings instead of filter objects, they
197     * will be parsed.
198     *
199     * @param string $operator
200     *     The logical operator, either "and", "or", "not" or the logical
201     *     equivalents "&", "|", "!".
202     * @param array|Horde_Ldap_Filter|string $filters
203     *     Array with Horde_Ldap_Filter objects and/or strings or a single
204     *     filter when using the "not" operator.
205     *
206     * @return Horde_Ldap_Filter
207     * @throws Horde_Ldap_Exception
208     */
209    public static function combine($operator, $filters)
210    {
211        // Substitute named operators with logical operators.
212        switch ($operator) {
213        case 'and':
214            $operator = '&';
215            break;
216        case 'or':
217            $operator = '|';
218            break;
219        case 'not':
220            $operator = '!';
221            break;
222        }
223
224        // Tests for sane operation.
225        switch ($operator) {
226        case '!':
227            // Not-combination, here we only accept one filter object or filter
228            // string.
229            if ($filters instanceof Horde_Ldap_Filter) {
230                $filters = array($filters); // force array
231            } elseif (is_string($filters)) {
232                $filters = array(self::parse($filters));
233            } elseif (is_array($filters)) {
234                throw new Horde_Ldap_Exception('Operator is "not" but $filter is an array');
235            } else {
236                throw new Horde_Ldap_Exception('Operator is "not" but $filter is not a valid Horde_Ldap_Filter nor a filter string');
237            }
238            break;
239
240        case '&':
241        case '|':
242            if (!is_array($filters) || count($filters) < 2) {
243                throw new Horde_Ldap_Exception('Parameter $filters is not an array or contains less than two Horde_Ldap_Filter objects');
244            }
245            break;
246
247        default:
248            throw new Horde_Ldap_Exception('Logical operator is unknown');
249        }
250
251        foreach ($filters as $key => $testfilter) {
252            // Check for errors.
253            if (is_string($testfilter)) {
254                // String found, try to parse into an filter object.
255                $filters[$key] = self::parse($testfilter);
256            } elseif (!($testfilter instanceof Horde_Ldap_Filter)) {
257                throw new Horde_Ldap_Exception('Invalid object passed in array $filters!');
258            }
259        }
260
261        return new Horde_Ldap_Filter(array('filters' => $filters,
262                                           'operator' => $operator));
263    }
264
265    /**
266     * Builds a filter (commonly for objectClass attributes) from different
267     * configuration options.
268     *
269     * @param array $params    Hash with configuration options that build the
270     *                         search filter. Possible hash keys:
271     *                         - 'filter': An LDAP filter string.
272     *                         - 'objectclass' (string): An objectClass name.
273     *                         - 'objectclass' (array): A list of objectClass
274     *                                                  names.
275     * @param string $operator How to combine mutliple 'objectclass' entries.
276     *                         'and' or 'or'.
277     *
278     * @return Horde_Ldap_Filter  A filter matching the specified criteria.
279     * @throws Horde_Ldap_Exception
280     */
281    public static function build(array $params, $operator = 'and')
282    {
283        if (!empty($params['filter'])) {
284            return self::parse($params['filter']);
285        }
286        if (!is_array($params['objectclass'])) {
287            return self::create('objectclass', 'equals', $params['objectclass']);
288        }
289        $filters = array();
290        foreach ($params['objectclass'] as $objectclass) {
291            $filters[] = self::create('objectclass', 'equals', $objectclass);
292        }
293        if (count($filters) == 1) {
294            return $filters[0];
295        }
296        return self::combine($operator, $filters);
297    }
298
299    /**
300     * Parses a string into a Horde_Ldap_Filter object.
301     *
302     * @todo Leaf-mode: Do we need to escape at all? what about *-chars? Check
303     * for the need of encoding values, tackle problems (see code comments).
304     *
305     * @param string $filter An LDAP filter string.
306     *
307     * @return Horde_Ldap_Filter
308     * @throws Horde_Ldap_Exception
309     */
310    public static function parse($filter)
311    {
312        if (!preg_match('/^\((.+?)\)$/', $filter, $matches)) {
313            throw new Horde_Ldap_Exception('Invalid filter syntax, filter components must be enclosed in round brackets');
314        }
315
316        if (in_array(substr($matches[1], 0, 1), array('!', '|', '&'))) {
317            return self::_parseCombination($matches[1]);
318        } else {
319            return self::_parseLeaf($matches[1]);
320        }
321    }
322
323    /**
324     * Parses combined subfilter strings.
325     *
326     * Passes subfilters to parse() and combines the objects using the logical
327     * operator detected.  Each subfilter could be an arbitary complex
328     * subfilter.
329     *
330     * @param string $filter An LDAP filter string.
331     *
332     * @return Horde_Ldap_Filter
333     * @throws Horde_Ldap_Exception
334     */
335    protected static function _parseCombination($filter)
336    {
337        // Extract logical operator and filter arguments.
338        $operator = substr($filter, 0, 1);
339        $filter = substr($filter, 1);
340
341        // Split $filter into individual subfilters. We cannot use split() for
342        // this, because we do not know the complexiness of the
343        // subfilter. Thus, we look trough the filter string and just recognize
344        // ending filters at the first level. We record the index number of the
345        // char and use that information later to split the string.
346        $sub_index_pos = array();
347        // Previous character looked at.
348        $prev_char = '';
349        // Denotes the current bracket level we are, >1 is too deep, 1 is ok, 0
350        // is outside any subcomponent.
351        $level = 0;
352        for ($curpos = 0, $len = strlen($filter); $curpos < $len; $curpos++) {
353            $cur_char = $filter[$curpos];
354
355            // Rise/lower bracket level.
356            if ($cur_char == '(' && $prev_char != '\\') {
357                $level++;
358            } elseif ($cur_char == ')' && $prev_char != '\\') {
359                $level--;
360            }
361
362            if ($cur_char == '(' && $prev_char == ')' && $level == 1) {
363                // Mark the position for splitting.
364                $sub_index_pos[] = $curpos;
365            }
366            $prev_char = $cur_char;
367        }
368
369        // Now perform the splits. To get the last part too, we need to add the
370        // "END" index to the split array.
371        $sub_index_pos[] = strlen($filter);
372        $subfilters = array();
373        $oldpos = 0;
374        foreach ($sub_index_pos as $s_pos) {
375            $str_part = substr($filter, $oldpos, $s_pos - $oldpos);
376            $subfilters[] = $str_part;
377            $oldpos = $s_pos;
378        }
379
380        if (count($subfilters) > 1) {
381            // Several subfilters found.
382            if ($operator == '!') {
383                throw new Horde_Ldap_Exception('Invalid filter syntax: NOT operator detected but several arguments given');
384            }
385        } elseif (!count($subfilters)) {
386            // This should not happen unless the user specified a wrong filter.
387            throw new Horde_Ldap_Exception('Invalid filter syntax: got operator ' . $operator . ' but no argument');
388        }
389
390        // Now parse the subfilters into objects and combine them using the
391        // operator.
392        $subfilters_o = array();
393        foreach ($subfilters as $s_s) {
394            $subfilters_o[] = self::parse($s_s);
395        }
396        if (count($subfilters_o) == 1) {
397            $subfilters_o = $subfilters_o[0];
398        }
399
400        return self::combine($operator, $subfilters_o);
401    }
402
403    /**
404     * Parses a single leaf component.
405     *
406     * @param string $filter An LDAP filter string.
407     *
408     * @return Horde_Ldap_Filter
409     * @throws Horde_Ldap_Exception
410     */
411    protected static function _parseLeaf($filter)
412    {
413        // Detect multiple leaf components.
414        // [TODO] Maybe this will make problems with filters containing
415        // brackets inside the value.
416        if (strpos($filter, ')(')) {
417            throw new Horde_Ldap_Exception('Invalid filter syntax: multiple leaf components detected');
418        }
419
420        $filter_parts = preg_split('/(?<!\\\\)(=|=~|>|<|>=|<=)/', $filter, 2, PREG_SPLIT_DELIM_CAPTURE);
421        if (count($filter_parts) != 3) {
422            throw new Horde_Ldap_Exception('Invalid filter syntax: unknown matching rule used');
423        }
424
425        // [TODO]: Do we need to escape at all? what about *-chars user provide
426        //         and that should remain special?  I think, those prevent
427        //         escaping! We need to check against PERL Net::LDAP!
428        // $value_arr = Horde_Ldap_Util::escapeFilterValue(array($filter_parts[2]));
429        // $value     = $value_arr[0];
430
431        return new Horde_Ldap_Filter(array('filter' => '(' . $filter_parts[0] . $filter_parts[1] . $filter_parts[2] . ')'));
432    }
433
434    /**
435     * Returns the string representation of this filter.
436     *
437     * This method runs through all filter objects and creates the string
438     * representation of the filter.
439     *
440     * @return string
441     */
442    public function __toString()
443    {
444        if (!count($this->_filters)) {
445            return $this->_filter;
446        }
447
448        $return = '';
449        foreach ($this->_filters as $filter) {
450            $return .= (string)$filter;
451        }
452
453        return '(' . $this->_operator . $return . ')';
454    }
455}
456