1<?php
2/* vim: set expandtab tabstop=4 shiftwidth=4: */
3/**
4* File containing the Net_LDAP2_Filter interface class.
5*
6* PHP version 5
7*
8* @category  Net
9* @package   Net_LDAP2
10* @author    Benedikt Hallinger <beni@php.net>
11* @copyright 2009 Benedikt Hallinger
12* @license   http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3
13* @version   SVN: $Id$
14* @link      http://pear.php.net/package/Net_LDAP2/
15*/
16
17/**
18* Includes
19*/
20require_once 'PEAR.php';
21require_once 'Net/LDAP2/Util.php';
22require_once 'Net/LDAP2/Entry.php';
23
24/**
25* Object representation of a part of a LDAP filter.
26*
27* This Class is not completely compatible to the PERL interface!
28*
29* The purpose of this class is, that users can easily build LDAP filters
30* without having to worry about right escaping etc.
31* A Filter is built using several independent filter objects
32* which are combined afterwards. This object works in two
33* modes, depending how the object is created.
34* If the object is created using the {@link create()} method, then this is a leaf-object.
35* If the object is created using the {@link combine()} method, then this is a container object.
36*
37* LDAP filters are defined in RFC-2254 and can be found under
38* {@link http://www.ietf.org/rfc/rfc2254.txt}
39*
40* Here a quick copy&paste example:
41* <code>
42* $filter0 = Net_LDAP2_Filter::create('stars', 'equals', '***');
43* $filter_not0 = Net_LDAP2_Filter::combine('not', $filter0);
44*
45* $filter1 = Net_LDAP2_Filter::create('gn', 'begins', 'bar');
46* $filter2 = Net_LDAP2_Filter::create('gn', 'ends', 'baz');
47* $filter_comp = Net_LDAP2_Filter::combine('or',array($filter_not0, $filter1, $filter2));
48*
49* echo $filter_comp->asString();
50* // This will output: (|(!(stars=\0x5c0x2a\0x5c0x2a\0x5c0x2a))(gn=bar*)(gn=*baz))
51* // The stars in $filter0 are treaten as real stars unless you disable escaping.
52* </code>
53*
54* @category Net
55* @package  Net_LDAP2
56* @author   Benedikt Hallinger <beni@php.net>
57* @license  http://www.gnu.org/copyleft/lesser.html LGPL
58* @link     http://pear.php.net/package/Net_LDAP2/
59*/
60class Net_LDAP2_Filter extends PEAR
61{
62    /**
63    * Storage for combination of filters
64    *
65    * This variable holds a array of filter objects
66    * that should be combined by this filter object.
67    *
68    * @access protected
69    * @var array
70    */
71    protected $_subfilters = array();
72
73    /**
74    * Match of this filter
75    *
76    * If this is a leaf filter, then a matching rule is stored,
77    * if it is a container, then it is a logical operator
78    *
79    * @access protected
80    * @var string
81    */
82    protected $_match;
83
84    /**
85    * Single filter
86    *
87    * If we operate in leaf filter mode,
88    * then the constructing method stores
89    * the filter representation here
90    *
91    * @acces private
92    * @var string
93    */
94    protected $_filter;
95
96    /**
97    * Create a new Net_LDAP2_Filter object and parse $filter.
98    *
99    * This is for PERL Net::LDAP interface.
100    * Construction of Net_LDAP2_Filter objects should happen through either
101    * {@link create()} or {@link combine()} which give you more control.
102    * However, you may use the perl iterface if you already have generated filters.
103    *
104    * @param string $filter LDAP filter string
105    *
106    * @see parse()
107    */
108    public function __construct($filter = false)
109    {
110        // The optional parameter must remain here, because otherwise create() crashes
111        if (false !== $filter) {
112            $filter_o = self::parse($filter);
113            if (PEAR::isError($filter_o)) {
114                $this->_filter = $filter_o; // assign error, so asString() can report it
115            } else {
116                $this->_filter = $filter_o->asString();
117            }
118        }
119    }
120
121    /**
122    * Constructor of a new part of a LDAP filter.
123    *
124    * The following matching rules exists:
125    *    - equals:         One of the attributes values is exactly $value
126    *                      Please note that case sensitiviness is depends on the
127    *                      attributes syntax configured in the server.
128    *    - begins:         One of the attributes values must begin with $value
129    *    - ends:           One of the attributes values must end with $value
130    *    - contains:       One of the attributes values must contain $value
131    *    - present | any:  The attribute can contain any value but must be existent
132    *    - greater:        The attributes value is greater than $value
133    *    - less:           The attributes value is less than $value
134    *    - greaterOrEqual: The attributes value is greater or equal than $value
135    *    - lessOrEqual:    The attributes value is less or equal than $value
136    *    - approx:         One of the attributes values is similar to $value
137    *
138    * Negation ("not") can be done by prepending the above operators with the
139    * "not" or "!" keyword, see example below.
140    *
141    * If $escape is set to true (default) then $value will be escaped
142    * properly. If it is set to false then $value will be treaten as raw filter value string.
143    * You should escape yourself using {@link Net_LDAP2_Util::escape_filter_value()}!
144    *
145    * Examples:
146    * <code>
147    *   // This will find entries that contain an attribute "sn" that ends with "foobar":
148    *   $filter = Net_LDAP2_Filter::create('sn', 'ends', 'foobar');
149    *
150    *   // This will find entries that contain an attribute "sn" that has any value set:
151    *   $filter = Net_LDAP2_Filter::create('sn', 'any');
152    *
153    *   // This will build a negated equals filter:
154    *   $filter = Net_LDAP2_Filter::create('sn', 'not equals', 'foobar');
155    * </code>
156    *
157    * @param string  $attr_name Name of the attribute the filter should apply to
158    * @param string  $match     Matching rule (equals, begins, ends, contains, greater, less, greaterOrEqual, lessOrEqual, approx, any)
159    * @param string  $value     (optional) if given, then this is used as a filter
160    * @param boolean $escape    Should $value be escaped? (default: yes, see {@link Net_LDAP2_Util::escape_filter_value()} for detailed information)
161    *
162    * @return Net_LDAP2_Filter|Net_LDAP2_Error
163    */
164    public static function create($attr_name, $match, $value = '', $escape = true)
165    {
166        $leaf_filter = new Net_LDAP2_Filter();
167        if ($escape) {
168            $array = Net_LDAP2_Util::escape_filter_value(array($value));
169            $value = $array[0];
170        }
171
172        $match = strtolower($match);
173
174        // detect negation
175        $neg_matches   = array();
176        $negate_filter = false;
177        if (preg_match('/^(?:not|!)[\s_-](.+)/', $match, $neg_matches)) {
178            $negate_filter = true;
179            $match         = $neg_matches[1];
180        }
181
182        // build basic filter
183        switch ($match) {
184        case 'equals':
185        case '=':
186        case '==':
187            $leaf_filter->_filter = '(' . $attr_name . '=' . $value . ')';
188            break;
189        case 'begins':
190            $leaf_filter->_filter = '(' . $attr_name . '=' . $value . '*)';
191            break;
192        case 'ends':
193            $leaf_filter->_filter = '(' . $attr_name . '=*' . $value . ')';
194            break;
195        case 'contains':
196            $leaf_filter->_filter = '(' . $attr_name . '=*' . $value . '*)';
197            break;
198        case 'greater':
199        case '>':
200            $leaf_filter->_filter = '(' . $attr_name . '>' . $value . ')';
201            break;
202        case 'less':
203        case '<':
204            $leaf_filter->_filter = '(' . $attr_name . '<' . $value . ')';
205            break;
206        case 'greaterorequal':
207        case '>=':
208            $leaf_filter->_filter = '(' . $attr_name . '>=' . $value . ')';
209            break;
210        case 'lessorequal':
211        case '<=':
212            $leaf_filter->_filter = '(' . $attr_name . '<=' . $value . ')';
213            break;
214        case 'approx':
215        case '~=':
216            $leaf_filter->_filter = '(' . $attr_name . '~=' . $value . ')';
217            break;
218        case 'any':
219        case 'present': // alias that may improve user code readability
220            $leaf_filter->_filter = '(' . $attr_name . '=*)';
221            break;
222        default:
223            return PEAR::raiseError('Net_LDAP2_Filter create error: matching rule "' . $match . '" not known!');
224        }
225
226        // negate if requested
227        if ($negate_filter) {
228           $leaf_filter = Net_LDAP2_Filter::combine('!', $leaf_filter);
229        }
230
231        return $leaf_filter;
232    }
233
234    /**
235    * Combine two or more filter objects using a logical operator
236    *
237    * This static method combines two or more filter objects and returns one single
238    * filter object that contains all the others.
239    * Call this method statically: $filter = Net_LDAP2_Filter::combine('or', array($filter1, $filter2))
240    * If the array contains filter strings instead of filter objects, we will try to parse them.
241    *
242    * @param string                 $log_op  The locical operator. May be "and", "or", "not" or the subsequent logical equivalents "&", "|", "!"
243    * @param array|Net_LDAP2_Filter $filters array with Net_LDAP2_Filter objects
244    *
245    * @return Net_LDAP2_Filter|Net_LDAP2_Error
246    * @static
247    */
248    public static function &combine($log_op, $filters)
249    {
250        if (PEAR::isError($filters)) {
251            return $filters;
252        }
253
254        // substitude named operators to logical operators
255        if ($log_op == 'and') $log_op = '&';
256        if ($log_op == 'or')  $log_op = '|';
257        if ($log_op == 'not') $log_op = '!';
258
259        // tests for sane operation
260        if ($log_op == '!') {
261            // Not-combination, here we only accept one filter object or filter string
262            if ($filters instanceof Net_LDAP2_Filter) {
263                $filters = array($filters); // force array
264            } elseif (is_string($filters)) {
265                $filter_o = self::parse($filters);
266                if (PEAR::isError($filter_o)) {
267                    $err = PEAR::raiseError('Net_LDAP2_Filter combine error: '.$filter_o->getMessage());
268                    return $err;
269                } else {
270                    $filters = array($filter_o);
271                }
272            } elseif (is_array($filters)) {
273                if (count($filters) != 1) {
274                    $err = PEAR::raiseError('Net_LDAP2_Filter combine error: operator is "not" but $filter is an array!');
275                    return $err;
276                } elseif (!($filters[0] instanceof Net_LDAP2_Filter)) {
277                     $err = PEAR::raiseError('Net_LDAP2_Filter combine error: operator is "not" but $filter is not a valid Net_LDAP2_Filter nor a filter string!');
278                     return $err;
279                }
280            } else {
281                $err = PEAR::raiseError('Net_LDAP2_Filter combine error: operator is "not" but $filter is not a valid Net_LDAP2_Filter nor a filter string!');
282                return $err;
283            }
284        } elseif ($log_op == '&' || $log_op == '|') {
285            if (!is_array($filters) || count($filters) < 2) {
286                $err = PEAR::raiseError('Net_LDAP2_Filter combine error: parameter $filters is not an array or contains less than two Net_LDAP2_Filter objects!');
287                return $err;
288            }
289        } else {
290            $err = PEAR::raiseError('Net_LDAP2_Filter combine error: logical operator is not known!');
291            return $err;
292        }
293
294        $combined_filter = new Net_LDAP2_Filter();
295        foreach ($filters as $key => $testfilter) {     // check for errors
296            if (PEAR::isError($testfilter)) {
297                return $testfilter;
298            } elseif (is_string($testfilter)) {
299                // string found, try to parse into an filter object
300                $filter_o = self::parse($testfilter);
301                if (PEAR::isError($filter_o)) {
302                    return $filter_o;
303                } else {
304                    $filters[$key] = $filter_o;
305                }
306            } elseif (!$testfilter instanceof Net_LDAP2_Filter) {
307                $err = PEAR::raiseError('Net_LDAP2_Filter combine error: invalid object passed in array $filters!');
308                return $err;
309            }
310        }
311
312        $combined_filter->_subfilters = $filters;
313        $combined_filter->_match      = $log_op;
314        return $combined_filter;
315    }
316
317    /**
318    * Parse FILTER into a Net_LDAP2_Filter object
319    *
320    * This parses an filter string into Net_LDAP2_Filter objects.
321    *
322    * @param string $FILTER The filter string
323    *
324    * @access static
325    * @return Net_LDAP2_Filter|Net_LDAP2_Error
326    * @todo Leaf-mode: Do we need to escape at all? what about *-chars?check for the need of encoding values, tackle problems (see code comments)
327    */
328    public static function parse($FILTER)
329    {
330        if (preg_match('/^\((.+?)\)$/', $FILTER, $matches)) {
331            // Check for right bracket syntax: count of unescaped opening
332            // brackets must match count of unescaped closing brackets.
333            // At this stage we may have:
334            //   1. one filter component with already removed outer brackets
335            //   2. one or more subfilter components
336            $c_openbracks  = preg_match_all('/(?<!\\\\)\(/' , $matches[1], $notrelevant);
337            $c_closebracks = preg_match_all('/(?<!\\\\)\)/' , $matches[1], $notrelevant);
338            if ($c_openbracks != $c_closebracks) {
339                return PEAR::raiseError("Filter parsing error: invalid filter syntax - opening brackets do not match close brackets!");
340            }
341
342            if (in_array(substr($matches[1], 0, 1), array('!', '|', '&'))) {
343                // Subfilter processing: pass subfilters to parse() and combine
344                // the objects using the logical operator detected
345                // we have now something like "&(...)(...)(...)" but at least one part ("!(...)").
346                // Each subfilter could be an arbitary complex subfilter.
347
348                // extract logical operator and filter arguments
349                $log_op              = substr($matches[1], 0, 1);
350                $remaining_component = substr($matches[1], 1);
351
352                // split $remaining_component into individual subfilters
353                // we cannot use split() for this, because we do not know the
354                // complexiness of the subfilter. Thus, we look trough the filter
355                // string and just recognize ending filters at the first level.
356                // We record the index number of the char and use that information
357                // later to split the string.
358                $sub_index_pos = array();
359                $prev_char     = ''; // previous character looked at
360                $level         = 0;  // denotes the current bracket level we are,
361                                     //   >1 is too deep, 1 is ok, 0 is outside any
362                                     //   subcomponent
363                for ($curpos = 0; $curpos < strlen($remaining_component); $curpos++) {
364                    $cur_char = substr($remaining_component, $curpos, 1);
365
366                    // rise/lower bracket level
367                    if ($cur_char == '(' && $prev_char != '\\') {
368                        $level++;
369                    } elseif  ($cur_char == ')' && $prev_char != '\\') {
370                        $level--;
371                    }
372
373                    if ($cur_char == '(' && $prev_char == ')' && $level == 1) {
374                        array_push($sub_index_pos, $curpos); // mark the position for splitting
375                    }
376                    $prev_char = $cur_char;
377                }
378
379                // now perform the splits. To get also the last part, we
380                // need to add the "END" index to the split array
381                array_push($sub_index_pos, strlen($remaining_component));
382                $subfilters = array();
383                $oldpos = 0;
384                foreach ($sub_index_pos as $s_pos) {
385                    $str_part = substr($remaining_component, $oldpos, $s_pos - $oldpos);
386                    array_push($subfilters, $str_part);
387                    $oldpos = $s_pos;
388                }
389
390                // some error checking...
391                if (count($subfilters) == 1) {
392                    // only one subfilter found
393                } elseif (count($subfilters) > 1) {
394                    // several subfilters found
395                    if ($log_op == "!") {
396                        return PEAR::raiseError("Filter parsing error: invalid filter syntax - NOT operator detected but several arguments given!");
397                    }
398                } else {
399                    // this should not happen unless the user specified a wrong filter
400                    return PEAR::raiseError("Filter parsing error: invalid filter syntax - got operator '$log_op' but no argument!");
401                }
402
403                // Now parse the subfilters into objects and combine them using the operator
404                $subfilters_o = array();
405                foreach ($subfilters as $s_s) {
406                    $o = self::parse($s_s);
407                    if (PEAR::isError($o)) {
408                        return $o;
409                    } else {
410                        array_push($subfilters_o, self::parse($s_s));
411                    }
412                }
413
414                $filter_o = self::combine($log_op, $subfilters_o);
415                return $filter_o;
416
417            } else {
418                // This is one leaf filter component, do some syntax checks, then escape and build filter_o
419                // $matches[1] should be now something like "foo=bar"
420
421                // detect multiple leaf components
422                // [TODO] Maybe this will make problems with filters containing brackets inside the value
423                if (stristr($matches[1], ')(')) {
424                    return PEAR::raiseError("Filter parsing error: invalid filter syntax - multiple leaf components detected!");
425                } else {
426                    $filter_parts = Net_LDAP2_Util::split_attribute_string($matches[1], true, true);
427                    if (count($filter_parts) != 3) {
428                        return PEAR::raiseError("Filter parsing error: invalid filter syntax - unknown matching rule used");
429                    } else {
430                        $filter_o          = new Net_LDAP2_Filter();
431                        // [TODO]: Do we need to escape at all? what about *-chars user provide and that should remain special?
432                        //         I think, those prevent escaping! We need to check against PERL Net::LDAP!
433                        // $value_arr         = Net_LDAP2_Util::escape_filter_value(array($filter_parts[2]));
434                        // $value             = $value_arr[0];
435                        $value             = $filter_parts[2];
436                        $filter_o->_filter = '('.$filter_parts[0].$filter_parts[1].$value.')';
437                        return $filter_o;
438                    }
439                }
440            }
441        } else {
442               // ERROR: Filter components must be enclosed in round brackets
443               return PEAR::raiseError("Filter parsing error: invalid filter syntax - filter components must be enclosed in round brackets");
444        }
445    }
446
447    /**
448    * Get the string representation of this filter
449    *
450    * This method runs through all filter objects and creates
451    * the string representation of the filter. If this
452    * filter object is a leaf filter, then it will return
453    * the string representation of this filter.
454    *
455    * @return string|Net_LDAP2_Error
456    */
457    public function asString()
458    {
459        if ($this->isLeaf()) {
460            $return = $this->_filter;
461        } else {
462            $return = '';
463            foreach ($this->_subfilters as $filter) {
464                $return = $return.$filter->asString();
465            }
466            $return = '(' . $this->_match . $return . ')';
467        }
468        return $return;
469    }
470
471    /**
472    * Alias for perl interface as_string()
473    *
474    * @see asString()
475    * @return string|Net_LDAP2_Error
476    */
477    public function as_string()
478    {
479        return $this->asString();
480    }
481
482    /**
483    * Print the text representation of the filter to FH, or the currently selected output handle if FH is not given
484    *
485    * This method is only for compatibility to the perl interface.
486    * However, the original method was called "print" but due to PHP language restrictions,
487    * we can't have a print() method.
488    *
489    * @param resource $FH (optional) A filehandle resource
490    *
491    * @return true|Net_LDAP2_Error
492    */
493    public function printMe($FH = false)
494    {
495        if (!is_resource($FH)) {
496            if (PEAR::isError($FH)) {
497                return $FH;
498            }
499            $filter_str = $this->asString();
500            if (PEAR::isError($filter_str)) {
501                return $filter_str;
502            } else {
503                print($filter_str);
504            }
505        } else {
506            $filter_str = $this->asString();
507            if (PEAR::isError($filter_str)) {
508                return $filter_str;
509            } else {
510                $res = @fwrite($FH, $this->asString());
511                if ($res == false) {
512                    return PEAR::raiseError("Unable to write filter string to filehandle \$FH!");
513                }
514            }
515        }
516        return true;
517    }
518
519    /**
520    * This can be used to escape a string to provide a valid LDAP-Filter.
521    *
522    * LDAP will only recognise certain characters as the
523    * character istself if they are properly escaped. This is
524    * what this method does.
525    * The method can be called statically, so you can use it outside
526    * for your own purposes (eg for escaping only parts of strings)
527    *
528    * In fact, this is just a shorthand to {@link Net_LDAP2_Util::escape_filter_value()}.
529    * For upward compatibiliy reasons you are strongly encouraged to use the escape
530    * methods provided by the Net_LDAP2_Util class.
531    *
532    * @param string $value Any string who should be escaped
533    *
534    * @static
535    * @return string         The string $string, but escaped
536    * @deprecated  Do not use this method anymore, instead use Net_LDAP2_Util::escape_filter_value() directly
537    */
538    public static function escape($value)
539    {
540        $return = Net_LDAP2_Util::escape_filter_value(array($value));
541        return $return[0];
542    }
543
544    /**
545    * Is this a container or a leaf filter object?
546    *
547    * @access protected
548    * @return boolean
549    */
550    protected function isLeaf()
551    {
552        if (count($this->_subfilters) > 0) {
553            return false; // Container!
554        } else {
555            return true; // Leaf!
556        }
557    }
558
559    /**
560    * Filter entries using this filter or see if a filter matches
561    *
562    * @todo Currently slow and naive implementation with preg_match, could be optimized (esp. begins, ends filters etc)
563    * @todo Currently only "="-based matches (equals, begins, ends, contains, any) implemented; Implement all the stuff!
564    * @todo Implement expert code with schema checks in case $entry is connected to a directory
565    * @param array|Net_LDAP2_Entry The entry (or array with entries) to check
566    * @param array                 If given, the array will be appended with entries who matched the filter. Return value is true if any entry matched.
567    * @return int|Net_LDAP2_Error Returns the number of matched entries or error
568    */
569    function matches(&$entries, &$results=array()) {
570        $numOfMatches = 0;
571
572        if (!is_array($entries)) {
573            $all_entries = array(&$entries);
574        } else {
575            $all_entries = &$entries;
576        }
577
578        foreach ($all_entries as $entry) {
579            // look at the current entry and see if filter matches
580
581            $entry_matched = false;
582            // if this is not a single component, do calculate all subfilters,
583            // then assert the partial results with the given combination modifier
584            if (!$this->isLeaf()) {
585
586                // get partial results from subfilters
587                $partial_results = array();
588                foreach ($this->_subfilters as $filter) {
589                    $partial_results[] = $filter->matches($entry);
590                }
591
592                // evaluate partial results using this filters combination rule
593                switch ($this->_match) {
594                    case '!':
595                        // result is the neagtive result of the assertion
596                        $entry_matched = !$partial_results[0];
597                    break;
598
599                    case '&':
600                        // all partial results have to be boolean-true
601                        $entry_matched = !in_array(false, $partial_results);
602                    break;
603
604                    case '|':
605                        // at least one partial result has to be true
606                        $entry_matched = in_array(true, $partial_results);
607                    break;
608                }
609
610            } else {
611                // Leaf filter: assert given entry
612                // [TODO]: Could be optimized to avoid preg_match especially with "ends", "begins" etc
613
614                // Translate the LDAP-match to some preg_match expression and evaluate it
615                list($attribute, $match, $assertValue) = $this->getComponents();
616                switch ($match) {
617                    case '=':
618                        $regexp = '/^'.str_replace('*', '.*', $assertValue).'$/i'; // not case sensitive unless specified by schema
619                        $entry_matched = $entry->pregMatch($regexp, $attribute);
620                    break;
621
622                    // -------------------------------------
623                    // [TODO]: implement <, >, <=, >= and =~
624                    // -------------------------------------
625
626                    default:
627                        $err = PEAR::raiseError("Net_LDAP2_Filter match error: unsupported match rule '$match'!");
628                        return $err;
629                }
630
631            }
632
633            // process filter matching result
634            if ($entry_matched) {
635                $numOfMatches++;
636                $results[] = $entry;
637            }
638
639        }
640
641        return $numOfMatches;
642    }
643
644
645    /**
646    * Retrieve this leaf-filters attribute, match and value component.
647    *
648    * For leaf filters, this returns array(attr, match, value).
649    * Match is be the logical operator, not the text representation,
650    * eg "=" instead of "equals". Note that some operators are really
651    * a combination of operator+value with wildcard, like
652    * "begins": That will return "=" with the value "value*"!
653    *
654    * For non-leaf filters this will drop an error.
655    *
656    * @todo $this->_match is not always available and thus not usable here; it would be great if it would set in the factory methods and constructor.
657    * @return array|Net_LDAP2_Error
658    */
659    function getComponents() {
660        if ($this->isLeaf()) {
661            $raw_filter = preg_replace('/^\(|\)$/', '', $this->_filter);
662            $parts = Net_LDAP2_Util::split_attribute_string($raw_filter, true, true);
663            if (count($parts) != 3) {
664                return PEAR::raiseError("Net_LDAP2_Filter getComponents() error: invalid filter syntax - unknown matching rule used");
665            } else {
666                return $parts;
667            }
668        } else {
669            return PEAR::raiseError('Net_LDAP2_Filter getComponents() call is invalid for non-leaf filters!');
670        }
671    }
672
673
674}
675?>
676