php-eesyldap/src/Filter.php
2023-03-13 02:13:21 +01:00

646 lines
19 KiB
PHP

<?php
namespace EesyLDAP;
use EesyLDAP\Filter\CombineException;
use EesyLDAP\Filter\FilterException;
use EesyLDAP\Filter\ParserException;
/**
* LDAP Filter String abstraction class
*
* Note: originally based on Net_LDAP2_Filter implementation.
* @see https://github.com/pear/Net_LDAP2
*
* @property-read string|null $attr_name
* @property-read string|null $attribute
* @property-read string|null $op
* @property-read string|null $operator
* @property-read string|null $pattern
* @property-read string|null $log_op
* @property-read string|null $logical_operator
* @property-read array<Filter> $sub_filters
*/
class Filter {
/*
*************************************************************************************************
* Leaf mode
*************************************************************************************************
*/
/**
* The attribute name
* @var string|null
*/
private $attr_name;
/**
* The operator
* @var string|null
*/
private $op;
/**
* The attribute value pattern
* @var string|null
*/
private $pattern;
/*
*************************************************************************************************
* Combine mode
*************************************************************************************************
*/
/**
* The logical operator
* @var string|null
*/
private $log_op;
/**
* The LDAP sub-filters objects
* @var array<Filter>|null
*/
private $sub_filters;
/*
*************************************************************************************************
* Some constants
*************************************************************************************************
*/
/**
* List of possible logical operators
* @var array<string>
*/
private static $log_ops = array('&', '|', '!');
/**
* List of logical operators aliases
* @var array<string,string>
*/
private static $log_op_aliases = array('and' => '&', 'or' => '|', 'not' => '!');
/**
* The NOT logical operator
* @var string
*/
private static $not_op = '!';
/**
* List of possible operators
* @var array<string>
*/
private static $ops = array('=', '<', '>', '<=', '>=', '~=');
/**
* List of operators aliases
* @var array<string,string>
*/
private static $op_aliases = array(
'equals' => '=',
'==' => '=',
'lower' => '<',
'greater' => '>',
'lower_or_equals' => '<=',
'greater_or_equals' => '>=',
'approx' => '~=',
);
/**
* Characters to escape in a LDAP filter string
* @var array<string,string>
*/
private static $espace_chars = array (
'\\' => '\5c', // \
'(' => '\28', // (
')' => '\29', // )
'*' => '\2a', // *
"\u{0000}" => '\00', // NUL
);
/**
* Regex to match on OID
* @var string
*/
private static $oid_regex = '/^[0-9]+(\.[0-9]+)+$/';
/**
* Regex to match attribute name
* @var string
*/
private static $attr_name_regex = '/^(?P<name>[a-zA-Z][a-zA-Z0-9\-]*)(;(?P<option>[a-zA-Z0-9\-]+))*$/';
/**
* Regex to match extensible attribute name
* @throws FilterException
* @var string
*/
private static $ext_attr_name_regex = '/^(?P<name>[a-zA-Z0-9\-]+)?\:((?P<dn>dn|DN)\:)?((?P<matching_rule>[a-zA-Z]+|[0-9]+(\.[0-9]+)*)\:)?$/';
/**
* Constructor
* @param array<string|Filter|array<string|Filter>|bool> $args
* @return void
*/
public function __construct(...$args) {
// Handle espace optional argument
$escape = true;
if ($args && is_bool(end($args)))
$escape = array_pop($args);
if (!$args)
throw new FilterException('Invalid constructor arguments: no argument provided!');
// One logical operator followed by some filters
if (self :: is_log_op($args[0])) {
// @phpstan-ignore-next-line
$this -> log_op = self :: unalias_log_op(array_shift($args));
// Convert args as filters
$this -> sub_filters = array();
foreach ($args as $arg) {
if (!is_array($arg))
$arg = array($arg);
foreach ($arg as $a) {
if (is_string($a))
$a = self :: parse($a);
if (!$a instanceof Filter)
throw new FilterException(
'Invalid constructor arguments: logical operator must be followed by Filter object, '.
'filter string or array of Filter or filter string.'
);
$this -> sub_filters[] = $a;
}
}
// Check number of filters against logical operator
if (self :: is_not_op($this -> log_op) && count($this -> sub_filters) != 1)
throw new FilterException(
'Invalid constructor arguments: NOT operator must be followed by exactly one filter');
return;
}
// array('attribute', 'operator', 'pattern')
if (count($args) == 3) {
if (
self :: is_attr_name($args[0])
&& (self :: is_op($args[1]) || in_array($args[1], array('begins', 'contains', 'ends')))
&& is_string($args[2])
) {
$this -> attr_name = $args[0];
$this -> pattern = $escape?self :: escape($args[2]):$args[2];
switch ($args[1]) {
case 'begins':
$this -> op = '=';
$this -> pattern = sprintf('%s*', $this -> pattern);
break;
case 'contains':
$this -> op = '=';
$this -> pattern = sprintf('*%s*', $this -> pattern);
break;
case 'ends':
$this -> op = '=';
$this -> pattern = sprintf('*%s', $this -> pattern);
break;
default:
// @phpstan-ignore-next-line
$this -> op = self :: unalias_op($args[1]);
}
return;
}
}
// array('attribute', 'present|any')
if (count($args) == 2) {
if (
self :: is_attr_name($args[0])
&& in_array($args[1], array('present', 'any'))
) {
$this -> attr_name = $args[0];
$this -> op = '=';
$this -> pattern = '*';
return;
}
}
throw new FilterException(
'Invalid constructor arguments provided to construct a Filter object');
}
/**
* Get filter property
* @param string $key
* @return mixed
*/
public function __get($key) {
switch ($key) {
case 'attr_name':
case 'attribute':
return isset($this -> attr_name)?$this -> attr_name:null;
case 'op':
case 'operator':
return isset($this -> op)?$this -> op:null;
case 'pattern':
return isset($this -> pattern)?$this -> pattern:null;
case 'log_op':
case 'logical_operator':
return isset($this -> log_op)?$this -> log_op:null;
case 'sub_filters':
return isset($this -> sub_filters)?$this -> sub_filters:array();
}
throw new FilterException("Invalid property '$key' requested");
}
/**
* Get operators
* @return array<string>
*/
public static function operators() {
return self :: $ops;
}
/**
* Get operator aliases
* @return array<string,string>
*/
public static function operator_aliases() {
return self :: $op_aliases;
}
/**
* Get logical operators
* @return array<string>
*/
public static function logical_operators() {
return self :: $log_ops;
}
/**
* Get logical operator aliases
* @return array<string,string>
*/
public static function logical_operator_aliases() {
return self :: $log_op_aliases;
}
/**
* Check filter is a leaf one
* @phpstan-assert-if-true string $this->attr_name
* @phpstan-assert-if-true string $this->op
* @phpstan-assert-if-true string $this->pattern
* @return bool
*/
public function is_leaf() {
return isset($this -> attr_name) && isset($this -> op) && isset($this -> pattern);
}
/**
* Check filter is a combine one
* @phpstan-assert-if-true string $this->log_op
* @phpstan-assert-if-true non-empty-array<Filter> $this->sub_filters
* @return bool
*/
public function is_combine() {
return (
isset($this -> log_op) && isset($this -> sub_filters) && is_array($this -> sub_filters)
&& $this -> sub_filters
);
}
/**
* Return LDAP filter as a string
* @param bool $pretty Enable pretty format: as line return and tabulations to easily distinguate
* logical groups.
* @param int $level Level of iteration (internally use in pretty format mode)
* @return string
*/
public function as_string($pretty=false, $level=0) {
// Leaf filter
if ($this -> is_leaf()) {
if ($pretty)
return (
str_repeat("\t", $level).
sprintf("(%s%s%s)", $this -> attr_name, $this -> op, $this -> pattern).
($level?"\n":"")
);
return sprintf("(%s%s%s)", $this -> attr_name, $this -> op, $this -> pattern);
}
// Combine filter
if ($this -> is_combine()) {
$return = '';
foreach ($this->sub_filters as $filter)
$return .= $filter -> as_string($pretty, $level+1);
if ($pretty)
return (
str_repeat("\t", $level).
"(".$this -> log_op."\n".
$return.
str_repeat("\t", $level).")".
($level?"\n":"")
);
return sprintf("(%s%s)", $this -> log_op, $return);
}
// May never occured
throw new FilterException('Filter is not leaf nor combine one?!');
}
/**
* Combine LDAP filter objects or strings using a logical operator
*
* @param string $log_op The locical operator. May be "and", "or", "not" or the subsequent logical
* equivalents "&", "|", "!"
* @param array<string|Filter|array<string|Filter>> $args LDAP filters to combine: could be
* LDAP objects, LDAP strings or array of
* LDAP objects or LDAP strings.
* @throws CombineException
* @return Filter
* @static
*/
public static function combine($log_op, ...$args) {
// Unalias & check logical operator
$log_op = self :: unalias_log_op($log_op);
if (!$log_op) throw new CombineException('Invalid logical operator provided!');
// Convert args as filters
$filters = array();
foreach ($args as $arg) {
if (!is_array($arg))
$arg = array($arg);
foreach ($arg as $a) {
if (is_string($a))
$a = self :: parse($a);
if (!$a instanceof Filter)
throw new CombineException(
'Invalid filter provided: must be a Filter object or a LDAP filter string!'
);
$filters[] = $a;
}
}
// Check number of filters against logical operator
if (self :: is_not_op($log_op)) {
if (count($filters) != 1)
throw new CombineException('NOT operator accept only one filter!');
}
return new Filter($log_op, ...$filters);
}
/**
* Parse an LDAP filter string
* @param string $value The LDAP filter string to parse
* @throws ParserException
* @static
* @return Filter
*/
public static function parse($value) {
// Only handle filter enclosed with brackets
if (!preg_match('/^\((.+?)\)$/', $value, $matches))
return self :: parse("($value)");
// Check for right bracket syntax: count of unescaped opening
// brackets must match count of unescaped closing brackets.
// At this stage we may have:
// 1. one filter component with already removed outer brackets
// 2. one or more subfilter components
$c_openbracks = preg_match_all('/(?<!\\\\)\(/' , $matches[1], $notrelevant);
$c_closebracks = preg_match_all('/(?<!\\\\)\)/' , $matches[1], $notrelevant);
if ($c_openbracks != $c_closebracks) {
throw new ParserException(
"Filter parsing error: invalid filter syntax - opening brackets do not match close ".
"brackets!"
);
}
if (in_array(substr($matches[1], 0, 1), array('!', '|', '&'))) {
// Subfilter processing: pass subfilters to parse() and combine
// the objects using the logical operator detected
// we have now something like "&(...)(...)(...)" but at least one part ("!(...)").
// Each subfilter could be an arbitary complex subfilter.
// extract logical operator and filter arguments
$log_op = substr($matches[1], 0, 1);
$remaining_component = substr($matches[1], 1);
// split $remaining_component into individual subfilters
// we cannot use split() for this, because we do not know the
// complexiness of the subfilter. Thus, we look trough the filter
// string and just recognize ending filters at the first level.
// We record the index number of the char and use that information
// later to split the string.
$sub_index_pos = array();
$prev_char = ''; // previous character looked at
$level = 0; // denotes the current bracket level we are,
// >1 is too deep, 1 is ok, 0 is outside any
// subcomponent
for ($curpos = 0; $curpos < strlen($remaining_component); $curpos++) {
$cur_char = substr($remaining_component, $curpos, 1);
// rise/lower bracket level
if ($cur_char == '(' && $prev_char != '\\') {
$level++;
} elseif ($cur_char == ')' && $prev_char != '\\') {
$level--;
}
if ($cur_char == '(' && $prev_char == ')' && $level == 1) {
array_push($sub_index_pos, $curpos); // mark the position for splitting
}
$prev_char = $cur_char;
}
// now perform the splits. To get also the last part, we
// need to add the "END" index to the split array
array_push($sub_index_pos, strlen($remaining_component));
$subfilters = array();
$oldpos = 0;
foreach ($sub_index_pos as $s_pos) {
$str_part = substr($remaining_component, $oldpos, $s_pos - $oldpos);
array_push($subfilters, $str_part);
$oldpos = $s_pos;
}
// some error checking...
if (count($subfilters) > 1) {
// several subfilters found
if ($log_op == "!") {
throw new ParserException(
"Filter parsing error: invalid filter syntax - NOT operator detected but ".
"several arguments given!"
);
}
}
// Now parse the subfilters into objects and combine them using the operator
$subfilters_o = array();
foreach ($subfilters as $subfilter) {
array_push($subfilters_o, self :: parse($subfilter));
}
return self :: combine($log_op, $subfilters_o);
}
// This is one leaf filter component, do some syntax checks, then escape and build filter_o
// $matches[1] should be now something like "foo=bar"
// detect multiple leaf components
// [TODO] Maybe this will make problems with filters containing brackets inside the value
if (stristr($matches[1], ')') || stristr($matches[1], '(')) {
throw new ParserException(
"Filter parsing error: invalid filter syntax - multiple leaf components ".
"detected!"
);
}
$filter_parts = self :: split_attr_string($matches[1]);
if (!is_array($filter_parts) || count($filter_parts) != 3) {
throw new ParserException(
"Filter parsing error: invalid filter syntax - unknown matching rule used");
}
$filter_parts[] = false; // Disable escaping
return new Filter(...$filter_parts);
}
/**
* Splits an attribute=value syntax into an array
* @param string $value
* @static
* @return array<int,string>|false
*/
public static function split_attr_string($value) {
$splited_value = preg_split(
'/(?<!\\\\)('.implode('|', self :: $ops).')/',
$value,
2,
PREG_SPLIT_DELIM_CAPTURE
);
if (!is_array($splited_value) || count($splited_value) != 3)
return false;
return $splited_value;
}
/**
* Check if it's an operator
* @param mixed $value The value to check
* @param bool $allow_alias Set if operator alias is allowed (optional, default: true)
* @static
* @return bool
* @phpstan-assert-if-true string $value
*/
public static function is_op($value, $allow_alias=true) {
if (is_string($value) && in_array($value, self :: $ops))
return true;
if ($allow_alias && is_string($value) && array_key_exists($value, self :: $op_aliases))
return true;
return false;
}
/**
* Check if it's an logical operator
* @param mixed $value The value to check
* @param bool $allow_alias Set if logical operator alias is allowed (optional, default: true)
* @static
* @return bool
* @phpstan-assert-if-true string $value
*/
public static function is_log_op($value, $allow_alias=true) {
if (is_string($value) && in_array($value, self :: $log_ops))
return true;
if ($allow_alias && is_string($value) && array_key_exists($value, self :: $log_op_aliases))
return true;
return false;
}
/**
* Resolve operator alias
* @param mixed $value
* @static
* @return string|false The real operator or false if invalid value specified
*/
public static function unalias_op($value) {
if (is_string($value) && array_key_exists($value, self :: $op_aliases))
return self :: $op_aliases[$value];
if (self :: is_op($value, false))
return $value;
return false;
}
/**
* Resolve logical operator alias
* @param mixed $value
* @static
* @return string|false The real operator or false if invalid value specified
*/
public static function unalias_log_op($value) {
if (is_string($value) && array_key_exists($value, self :: $log_op_aliases))
return self :: $log_op_aliases[$value];
if (self :: is_log_op($value, false))
return $value;
return false;
}
/**
* Check if it's the NOT logical operator
* @param mixed $value The value to check
* @static
* @return bool
* @phpstan-assert-if-true string $value
*/
public static function is_not_op($value) {
return $value == self :: $not_op;
}
/**
* Check if it's a valid attribute name filter part
* @param mixed $value The value to check
* @phpstan-assert-if-true string $value
* @param bool $allow_extended Allow extended attribute name filter part (optional, default: true)
* @static
* @return bool
*/
public static function is_attr_name($value, $allow_extended=true) {
if (!is_string($value))
return false;
if (preg_match(self :: $attr_name_regex, $value))
return true;
if (preg_match(self :: $oid_regex, $value))
return true;
if ($allow_extended && preg_match(self :: $ext_attr_name_regex, $value))
return true;
return false;
}
/**
* Escape a string for LDAP filter pattern
* @param string $value
* @static
* @return string
*/
public static function escape($value) {
return str_replace(array_keys(self :: $espace_chars), array_values(self :: $espace_chars), $value);
}
/**
* Unescape a string from LDAP filter pattern
* @param string $value
* @static
* @return string
*/
public static function unescape($value) {
return str_replace(array_values(self :: $espace_chars), array_keys(self :: $espace_chars), $value);
}
}