dissect.util 3.23.dev7__tar.gz → 3.23.dev8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dissect.util might be problematic. Click here for more details.

Files changed (88) hide show
  1. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/PKG-INFO +1 -1
  2. dissect_util-3.23.dev8/dissect/util/ldap.py +237 -0
  3. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect.util.egg-info/PKG-INFO +1 -1
  4. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect.util.egg-info/SOURCES.txt +2 -0
  5. dissect_util-3.23.dev8/tests/test_ldap.py +652 -0
  6. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/.devcontainer/devcontainer.json +0 -0
  7. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/COPYRIGHT +0 -0
  8. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/LICENSE +0 -0
  9. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/MANIFEST.in +0 -0
  10. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/README.md +0 -0
  11. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/__init__.py +0 -0
  12. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_build.py +0 -0
  13. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native/__init__.pyi +0 -0
  14. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native/compression/__init__.pyi +0 -0
  15. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native/compression/lz4.pyi +0 -0
  16. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native/compression/lzo.pyi +0 -0
  17. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native/hash/__init__.py +0 -0
  18. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native/hash/crc32c.py +0 -0
  19. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/Cargo.lock +0 -0
  20. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/Cargo.toml +0 -0
  21. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/src/compression/lz4.rs +0 -0
  22. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/src/compression/lzo.rs +0 -0
  23. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/src/compression.rs +0 -0
  24. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/src/hash/crc32c.rs +0 -0
  25. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/src/hash.rs +0 -0
  26. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/_native.src/src/lib.rs +0 -0
  27. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/__init__.py +0 -0
  28. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lz4.py +0 -0
  29. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lzbitmap.py +0 -0
  30. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lzfse.py +0 -0
  31. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lznt1.py +0 -0
  32. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lzo.py +0 -0
  33. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lzvn.py +0 -0
  34. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lzxpress.py +0 -0
  35. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/lzxpress_huffman.py +0 -0
  36. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/sevenbit.py +0 -0
  37. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/compression/xz.py +0 -0
  38. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/cpio.py +0 -0
  39. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/encoding/__init__.py +0 -0
  40. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/encoding/surrogateescape.py +0 -0
  41. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/exceptions.py +0 -0
  42. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/feature.py +0 -0
  43. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/hash/__init__.py +0 -0
  44. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/hash/crc32c.py +0 -0
  45. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/hash/jenkins.py +0 -0
  46. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/plist.py +0 -0
  47. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/sid.py +0 -0
  48. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/stream.py +0 -0
  49. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/tools/__init__.py +0 -0
  50. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/tools/dump_nskeyedarchiver.py +0 -0
  51. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/ts.py +0 -0
  52. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect/util/xmemoryview.py +0 -0
  53. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect.util.egg-info/dependency_links.txt +0 -0
  54. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect.util.egg-info/entry_points.txt +0 -0
  55. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/dissect.util.egg-info/top_level.txt +0 -0
  56. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/pyproject.toml +0 -0
  57. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/setup.cfg +0 -0
  58. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/__init__.py +0 -0
  59. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_data/bin.cpio.gz +0 -0
  60. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_data/crc.cpio.gz +0 -0
  61. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_data/hpbin.cpio.gz +0 -0
  62. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_data/hpodc.cpio.gz +0 -0
  63. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_data/newc.cpio.gz +0 -0
  64. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_data/odc.cpio.gz +0 -0
  65. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_docs/Makefile +0 -0
  66. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_docs/conf.py +0 -0
  67. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/_docs/index.rst +0 -0
  68. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/__init__.py +0 -0
  69. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lz4.py +0 -0
  70. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lzbitmap.py +0 -0
  71. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lzfse.py +0 -0
  72. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lznt1.py +0 -0
  73. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lzo.py +0 -0
  74. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lzvn.py +0 -0
  75. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lzxpress.py +0 -0
  76. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_lzxpress_huffman.py +0 -0
  77. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_sevenbit.py +0 -0
  78. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/compression/test_xz.py +0 -0
  79. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/conftest.py +0 -0
  80. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_cpio.py +0 -0
  81. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_feature.py +0 -0
  82. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_hash.py +0 -0
  83. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_plist.py +0 -0
  84. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_sid.py +0 -0
  85. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_stream.py +0 -0
  86. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_ts.py +0 -0
  87. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tests/test_xmemoryview.py +0 -0
  88. {dissect_util-3.23.dev7 → dissect_util-3.23.dev8}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dissect.util
3
- Version: 3.23.dev7
3
+ Version: 3.23.dev8
4
4
  Summary: A Dissect module implementing various utility functions for the other Dissect modules
5
5
  Author-email: Dissect Team <dissect@fox-it.com>
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import operator
4
+ import re
5
+ from enum import Enum
6
+
7
+ from dissect.util.exceptions import Error
8
+
9
+
10
+ class InvalidQueryError(Error):
11
+ pass
12
+
13
+
14
+ class LogicalOperator(Enum):
15
+ AND = "&"
16
+ OR = "|"
17
+ NOT = "!"
18
+
19
+
20
+ _LOGICAL_OPERATORS = tuple(op.value for op in LogicalOperator)
21
+
22
+
23
+ class ComparisonOperator(Enum):
24
+ GE = ">="
25
+ LE = "<="
26
+ GT = ">"
27
+ LT = "<"
28
+ EQ = "="
29
+ APPROX = "~="
30
+ BIT = ":="
31
+ EXTENDED = ":"
32
+
33
+
34
+ _NORMAL_COMPARISON_OPERATORS = [op for op in ComparisonOperator if op != ComparisonOperator.EXTENDED]
35
+ _SORTED_COMPARISON_OPERATORS = sorted(_NORMAL_COMPARISON_OPERATORS, key=lambda op: len(op.value), reverse=True)
36
+
37
+ _RE_EXTENDED = re.compile(r"(.+?):(.+?):=(.+)?")
38
+
39
+
40
+ class SearchFilter:
41
+ """Represents an LDAP search filter (simple or nested).
42
+
43
+ Args:
44
+ query: The LDAP search filter string.
45
+ """
46
+
47
+ def __init__(self, query: str) -> None:
48
+ self.query: str = query
49
+
50
+ self.children: list[SearchFilter] = []
51
+ self.operator: LogicalOperator | ComparisonOperator | None = None
52
+ self.attribute: str | None = None
53
+ self.value: str | None = None
54
+ self._extended_rule: str | None = None
55
+
56
+ _validate_syntax(query)
57
+
58
+ if query[1:-1].startswith(_LOGICAL_OPERATORS):
59
+ self._parse_nested()
60
+ else:
61
+ self._parse_simple()
62
+
63
+ def __repr__(self) -> str:
64
+ if self.is_nested():
65
+ return f"<SearchFilter nested operator={self.operator.value!r} children={self.children}>"
66
+ return f"<SearchFilter attribute={self.attribute!r} operator={self.operator.value!r} value={self.value}>"
67
+
68
+ @classmethod
69
+ def parse(cls, query: str, optimize: bool = True) -> SearchFilter:
70
+ """Parse an LDAP query into a filter object, with optional optimization."""
71
+ result = cls(query)
72
+ if optimize:
73
+ return optimize_ldap_query(result)[0]
74
+ return result
75
+
76
+ def is_nested(self) -> bool:
77
+ """Return whether the filter is nested (i.e., contains logical operators and child filters)."""
78
+ return isinstance(self.operator, LogicalOperator)
79
+
80
+ def format(self) -> str:
81
+ """Format the search filter back into an LDAP query string."""
82
+ if self.is_nested():
83
+ childs = "".join([child.format() for child in self.children])
84
+ return f"({self.operator.value}{childs})"
85
+
86
+ if self.operator == ComparisonOperator.EXTENDED:
87
+ return f"({self.attribute}:{self._extended_rule}:={self.value})"
88
+
89
+ return f"({self.attribute}{self.operator.value}{self.value})"
90
+
91
+ def _parse_simple(self) -> None:
92
+ """Parse simple filter."""
93
+ query = self.query[1:-1]
94
+
95
+ # Check for extended matching rules first
96
+ if ":" in query and (match := _RE_EXTENDED.match(query)):
97
+ self.operator = ComparisonOperator.EXTENDED
98
+ self.attribute, self._extended_rule, self.value = match.groups()
99
+ return
100
+
101
+ # Regular operator parsing
102
+ test = query
103
+ operators: list[ComparisonOperator] = []
104
+ for op in _SORTED_COMPARISON_OPERATORS:
105
+ if op.value not in test:
106
+ continue
107
+
108
+ if test.count(op.value) > 1:
109
+ raise InvalidQueryError(f"Comparison operator {op.value} found multiple times in query: {self.query}")
110
+
111
+ operators.append(op)
112
+ test = test.replace(op.value, "")
113
+
114
+ if len(operators) == 0:
115
+ raise InvalidQueryError(
116
+ f"No comparison operator found in query: {self.query}. "
117
+ f"Expected one of {[op.value for op in _NORMAL_COMPARISON_OPERATORS]}."
118
+ )
119
+
120
+ if len(operators) > 1:
121
+ raise InvalidQueryError(
122
+ f"Multiple comparison operators found in query: {self.query} -> {[o.value for o in operators]} "
123
+ f"Expected only one of {[op.value for op in _NORMAL_COMPARISON_OPERATORS]}."
124
+ )
125
+
126
+ self.operator = operators[0]
127
+ self.attribute, _, self.value = query.partition(self.operator.value)
128
+
129
+ def _parse_nested(self) -> None:
130
+ """Parse nested filter."""
131
+ query = self.query[1:-1]
132
+ self.operator = LogicalOperator(query[0])
133
+
134
+ start = 1
135
+ while start < len(query):
136
+ end = start + 1
137
+ depth = 1
138
+
139
+ while end < len(query) and depth > 0:
140
+ if query[end] == "(":
141
+ depth += 1
142
+ elif query[end] == ")":
143
+ depth -= 1
144
+ end += 1
145
+
146
+ self.children.append(SearchFilter(query[start:end]))
147
+ start = end
148
+
149
+
150
+ _ATTRIBUTE_WEIGHTS = {
151
+ "objectGUID": 1,
152
+ "distinguishedName": 1,
153
+ "sAMAccountName": 2,
154
+ "userPrincipalName": 2,
155
+ "mail": 2,
156
+ "sAMAccountType": 3,
157
+ "servicePrincipalName": 3,
158
+ "userAccountControl": 4,
159
+ "memberOf": 5,
160
+ "member": 5,
161
+ "pwdLastSet": 5,
162
+ "primaryGroupID": 6,
163
+ "whenCreated": 6,
164
+ "ou": 6,
165
+ "lastLogonTimestamp": 6,
166
+ "cn": 7,
167
+ "givenName": 7,
168
+ "name": 7,
169
+ "telephoneNumber": 7,
170
+ "objectCategory": 8,
171
+ "description": 9,
172
+ "objectClass": 10,
173
+ }
174
+
175
+
176
+ def optimize_ldap_query(query: SearchFilter) -> tuple[SearchFilter, int]:
177
+ """Optimize an LDAP query in-place.
178
+
179
+ Removes redundant conditions and sorts filters and conditions based on how specific they are.
180
+
181
+ Args:
182
+ query: The LDAP query to optimize.
183
+
184
+ Returns:
185
+ A tuple containing the optimized LDAP query and its weight.
186
+ """
187
+ # Simplify single-child AND/OR
188
+ if query.is_nested() and len(query.children) == 1 and query.operator in (LogicalOperator.AND, LogicalOperator.OR):
189
+ return optimize_ldap_query(query.children[0])
190
+
191
+ # Sort nested children by weight
192
+ if query.is_nested() and len(query.children) > 1:
193
+ children = sorted((optimize_ldap_query(child) for child in query.children), key=operator.itemgetter(1))
194
+
195
+ query.children = [child for child, _ in children]
196
+ query.query = query.format()
197
+
198
+ return query, max(weight for _, weight in children)
199
+
200
+ # Handle NOT
201
+ if query.is_nested() and len(query.children) == 1 and query.operator == LogicalOperator.NOT:
202
+ child, weight = optimize_ldap_query(query.children[0])
203
+
204
+ query.children[0] = child
205
+ query.query = query.format()
206
+
207
+ return query, weight
208
+
209
+ # Base case: simple filter
210
+ if not query.is_nested():
211
+ return query, _ATTRIBUTE_WEIGHTS.get(query.attribute, max(_ATTRIBUTE_WEIGHTS.values()))
212
+
213
+ return query, max(_ATTRIBUTE_WEIGHTS.values())
214
+
215
+
216
+ def _validate_syntax(query: str) -> None:
217
+ """Validate basic LDAP query syntax.
218
+
219
+ Args:
220
+ query: The LDAP query to validate.
221
+ """
222
+ if not query:
223
+ raise InvalidQueryError("Empty query")
224
+
225
+ if not query.startswith("(") or not query.endswith(")"):
226
+ raise InvalidQueryError(f"Query must be wrapped in parentheses: {query}")
227
+
228
+ if query.count("(") != query.count(")"):
229
+ raise InvalidQueryError(f"Unbalanced parentheses in query: {query}")
230
+
231
+ # Check for empty parentheses
232
+ if "()" in query:
233
+ raise InvalidQueryError(f"Empty parentheses found in query: {query}")
234
+
235
+ # Check for queries that start with double opening parentheses
236
+ if query.startswith("(("):
237
+ raise InvalidQueryError(f"Invalid query structure: {query}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dissect.util
3
- Version: 3.23.dev7
3
+ Version: 3.23.dev8
4
4
  Summary: A Dissect module implementing various utility functions for the other Dissect modules
5
5
  Author-email: Dissect Team <dissect@fox-it.com>
6
6
  License-Expression: Apache-2.0
@@ -15,6 +15,7 @@ dissect/util/_build.py
15
15
  dissect/util/cpio.py
16
16
  dissect/util/exceptions.py
17
17
  dissect/util/feature.py
18
+ dissect/util/ldap.py
18
19
  dissect/util/plist.py
19
20
  dissect/util/sid.py
20
21
  dissect/util/stream.py
@@ -57,6 +58,7 @@ tests/conftest.py
57
58
  tests/test_cpio.py
58
59
  tests/test_feature.py
59
60
  tests/test_hash.py
61
+ tests/test_ldap.py
60
62
  tests/test_plist.py
61
63
  tests/test_sid.py
62
64
  tests/test_stream.py
@@ -0,0 +1,652 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from dissect.util.ldap import (
6
+ _ATTRIBUTE_WEIGHTS,
7
+ ComparisonOperator,
8
+ InvalidQueryError,
9
+ LogicalOperator,
10
+ SearchFilter,
11
+ optimize_ldap_query,
12
+ )
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ ("query", "match"),
17
+ [
18
+ ("(&(objectClass=user)(name=Henk)))", "Unbalanced parentheses"),
19
+ ("(objectClass=user)(name=Henk)", "Comparison operator = found multiple times in query"),
20
+ ("", "Empty query"),
21
+ ("name=test", "Query must be wrapped in parentheses"),
22
+ ("()", "Empty parentheses found"),
23
+ ("((test))", "Invalid query structure"),
24
+ ],
25
+ )
26
+ def test_invalid_query(query: str, match: str) -> None:
27
+ """Test against various invalid LDAP queries."""
28
+ with pytest.raises(InvalidQueryError, match=match):
29
+ SearchFilter.parse(query)
30
+
31
+
32
+ @pytest.mark.parametrize(
33
+ ("query", "attribute", "value"),
34
+ [
35
+ ("(name=Henk*)", "name", "Henk*"),
36
+ ("(name=*son)", "name", "*son"),
37
+ ("(givenName=*Jan*)", "givenName", "*Jan*"),
38
+ ("(mail=*@*.com)", "mail", "*@*.com"),
39
+ ],
40
+ )
41
+ def test_wildcards(query: str, attribute: str, value: str) -> None:
42
+ """Test wildcard support in various positions."""
43
+ parsed = SearchFilter.parse(query, optimize=False)
44
+ assert parsed.attribute == attribute
45
+ assert parsed.value == value
46
+
47
+
48
+ def test_wildcards_nested() -> None:
49
+ """Test wildcards in nested filters."""
50
+ query = "(&(objectClass=user)(name=*Henk*))"
51
+ parsed = SearchFilter.parse(query, optimize=False)
52
+ assert parsed.children[1].attribute == "name"
53
+ assert parsed.children[1].value == "*Henk*"
54
+
55
+
56
+ def test_parsing_and_no_optimization() -> None:
57
+ """Test parsing without optimization."""
58
+ query = "(&(objectClass=user)(|(name=Henk)(name=Jan)))"
59
+ parsed = SearchFilter.parse(query, optimize=False)
60
+ assert parsed.query == query, "Query was optimized"
61
+
62
+ assert len(parsed.children) == 2
63
+ assert parsed.operator == LogicalOperator.AND
64
+ assert isinstance(parsed.children[0], SearchFilter)
65
+ assert parsed.children[0].attribute == "objectClass"
66
+ assert parsed.children[0].value == "user"
67
+ assert parsed.children[0].operator.value == "="
68
+
69
+ assert isinstance(parsed.children[1], SearchFilter)
70
+ assert parsed.children[1].operator == LogicalOperator.OR
71
+ assert parsed.children[1].query == "(|(name=Henk)(name=Jan))"
72
+ assert parsed.children[1].children[0].attribute == "name"
73
+ assert parsed.children[1].children[0].operator.value == "="
74
+ assert parsed.children[1].children[0].value == "Henk"
75
+ assert parsed.children[1].children[1].attribute == "name"
76
+ assert parsed.children[1].children[1].operator.value == "="
77
+ assert parsed.children[1].children[1].value == "Jan"
78
+
79
+
80
+ def test_parsing_and_suboptimal() -> None:
81
+ """Test parsing with optimization."""
82
+ query = "(&(objectClass=user)(|(name=Henk)(name=Jan)))"
83
+ parsed = SearchFilter.parse(query)
84
+ assert parsed.query != query, "Query was not optimized"
85
+
86
+ assert len(parsed.children) == 2
87
+ assert parsed.operator == LogicalOperator.AND
88
+ assert isinstance(parsed.children[1], SearchFilter)
89
+ assert parsed.children[1].attribute == "objectClass"
90
+ assert parsed.children[1].operator.value == "="
91
+ assert parsed.children[1].value == "user"
92
+
93
+ assert isinstance(parsed.children[0], SearchFilter)
94
+ assert parsed.children[0].operator == LogicalOperator.OR
95
+ assert parsed.children[0].query == "(|(name=Henk)(name=Jan))"
96
+ assert parsed.children[0].children[0].attribute == "name"
97
+ assert parsed.children[0].children[0].operator.value == "="
98
+ assert parsed.children[0].children[0].value == "Henk"
99
+ assert parsed.children[0].children[1].attribute == "name"
100
+ assert parsed.children[0].children[1].operator.value == "="
101
+ assert parsed.children[0].children[1].value == "Jan"
102
+
103
+
104
+ def test_parsing_or() -> None:
105
+ """Test parsing OR filters."""
106
+ query = "(|(name=Henk)(name=Jan))"
107
+ parsed = SearchFilter.parse(query)
108
+ assert parsed.query == query, "Query was optimized"
109
+
110
+ assert len(parsed.children) == 2
111
+ assert parsed.operator == LogicalOperator.OR
112
+ assert isinstance(parsed.children[0], SearchFilter)
113
+ assert parsed.children[0].attribute == "name"
114
+ assert parsed.children[0].operator.value == "="
115
+ assert parsed.children[0].value == "Henk"
116
+ assert isinstance(parsed.children[1], SearchFilter)
117
+ assert parsed.children[1].attribute == "name"
118
+ assert parsed.children[1].operator.value == "="
119
+ assert parsed.children[1].value == "Jan"
120
+
121
+
122
+ def test_parsing_or_2() -> None:
123
+ """Test parsing OR filters with AND parent."""
124
+ query = "(&(|(name=Henk)(name=Jan))(surname=de Vries))"
125
+ parsed = SearchFilter.parse(query)
126
+ assert parsed.query == query, "Query was optimized"
127
+
128
+ assert len(parsed.children) == 2
129
+ assert parsed.operator == LogicalOperator.AND
130
+ assert isinstance(parsed.children[0], SearchFilter)
131
+ assert parsed.children[0].query == "(|(name=Henk)(name=Jan))"
132
+ assert len(parsed.children[0].children) == 2
133
+ assert isinstance(parsed.children[0].children[0], SearchFilter)
134
+ assert parsed.children[0].operator == LogicalOperator.OR
135
+ assert parsed.children[0].children[0].attribute == "name"
136
+ assert parsed.children[0].children[0].operator.value == "="
137
+ assert parsed.children[0].children[0].value == "Henk"
138
+ assert isinstance(parsed.children[0].children[1], SearchFilter)
139
+ assert parsed.children[0].children[1].attribute == "name"
140
+ assert parsed.children[0].children[1].operator.value == "="
141
+ assert parsed.children[0].children[1].value == "Jan"
142
+ assert isinstance(parsed.children[1], SearchFilter)
143
+ assert parsed.children[1].attribute == "surname"
144
+ assert parsed.children[1].operator.value == "="
145
+ assert parsed.children[1].value == "de Vries"
146
+
147
+
148
+ def test_parsing_nested_and_or() -> None:
149
+ """Test parsing nested AND/OR filters."""
150
+ query = (
151
+ "(|(objectClass=container)(objectClass=organizationalUnit)"
152
+ "(sAMAccountType>=805306369)(objectClass=group)(&(objectCategory=person)(objectClass=user)))"
153
+ )
154
+ parsed = SearchFilter.parse(query)
155
+ assert parsed.query != query, "Query was not optimized"
156
+
157
+ assert len(parsed.children) == 5
158
+
159
+ assert isinstance(parsed.children[0], SearchFilter)
160
+ assert parsed.children[0].query == "(sAMAccountType>=805306369)"
161
+ assert parsed.children[0].attribute == "sAMAccountType"
162
+ assert parsed.children[0].operator.value == ">="
163
+ assert parsed.children[0].value == "805306369"
164
+
165
+ assert isinstance(parsed.children[2], SearchFilter)
166
+ assert parsed.children[2].query == "(objectClass=organizationalUnit)"
167
+ assert parsed.children[2].attribute == "objectClass"
168
+ assert parsed.children[2].operator.value == "="
169
+ assert parsed.children[2].value == "organizationalUnit"
170
+
171
+ assert isinstance(parsed.children[1], SearchFilter)
172
+ assert parsed.children[1].query == "(objectClass=container)"
173
+ assert parsed.children[1].attribute == "objectClass"
174
+ assert parsed.children[1].operator.value == "="
175
+ assert parsed.children[1].value == "container"
176
+
177
+ assert isinstance(parsed.children[3], SearchFilter)
178
+ assert parsed.children[3].query == "(objectClass=group)"
179
+ assert parsed.children[3].attribute == "objectClass"
180
+ assert parsed.children[3].operator.value == "="
181
+ assert parsed.children[3].value == "group"
182
+
183
+ assert isinstance(parsed.children[4], SearchFilter)
184
+ assert parsed.children[4].query == "(&(objectCategory=person)(objectClass=user))"
185
+ assert parsed.children[4].operator == LogicalOperator.AND
186
+ assert len(parsed.children[4].children) == 2
187
+ assert isinstance(parsed.children[4].children[0], SearchFilter)
188
+ assert parsed.children[4].children[0].query == "(objectCategory=person)"
189
+ assert parsed.children[4].children[0].attribute == "objectCategory"
190
+ assert parsed.children[4].children[0].operator.value == "="
191
+ assert parsed.children[4].children[0].value == "person"
192
+ assert isinstance(parsed.children[4].children[1], SearchFilter)
193
+ assert parsed.children[4].children[1].query == "(objectClass=user)"
194
+ assert parsed.children[4].children[1].attribute == "objectClass"
195
+ assert parsed.children[4].children[1].operator.value == "="
196
+ assert parsed.children[4].children[1].value == "user"
197
+
198
+
199
+ def test_parsing_single_not() -> None:
200
+ """Test parsing single NOT filter."""
201
+ query = "(!(sAMAccountType=805306369))"
202
+ parsed = SearchFilter.parse(query)
203
+ assert parsed.query == query, "Query was optimized"
204
+
205
+ assert len(parsed.children) == 1
206
+ assert parsed.operator == LogicalOperator.NOT
207
+ assert isinstance(parsed.children[0], SearchFilter)
208
+ assert parsed.children[0].attribute == "sAMAccountType"
209
+ assert parsed.children[0].operator.value == "="
210
+ assert parsed.children[0].value == "805306369"
211
+
212
+
213
+ def test_parsing_single_filter_in_condition() -> None:
214
+ """Test parsing single filter in AND condition."""
215
+ query = "(&(objectClass=user))"
216
+ parsed = SearchFilter.parse(query)
217
+ assert parsed.query != query, "Query was not optimized"
218
+
219
+ assert parsed.query == "(objectClass=user)"
220
+
221
+
222
+ @pytest.mark.parametrize(
223
+ ("query", "attribute", "operator", "value"),
224
+ [
225
+ pytest.param("(age>=18)", "age", ComparisonOperator.GE, "18", id=">="),
226
+ pytest.param("(age<=30)", "age", ComparisonOperator.LE, "30", id="<="),
227
+ pytest.param("(priority>5)", "priority", ComparisonOperator.GT, "5", id=">"),
228
+ pytest.param("(score<100)", "score", ComparisonOperator.LT, "100", id="<"),
229
+ pytest.param("(name=Henk)", "name", ComparisonOperator.EQ, "Henk", id="="),
230
+ pytest.param("(name~=Henk)", "name", ComparisonOperator.APPROX, "Henk", id="~="),
231
+ pytest.param("(userAccountControl:=2)", "userAccountControl", ComparisonOperator.BIT, "2", id=":="),
232
+ ],
233
+ )
234
+ def test_comparison_operators(query: str, attribute: str, operator: ComparisonOperator, value: str) -> None:
235
+ """Test various comparison operators."""
236
+ parsed = SearchFilter.parse(query, optimize=False)
237
+
238
+ assert not parsed.is_nested()
239
+ assert parsed.attribute == attribute
240
+ assert parsed.operator == operator
241
+ assert parsed.value == value
242
+
243
+
244
+ def test_all_comparison_operators_in_nested_filter() -> None:
245
+ """Test all comparison operators in nested filter."""
246
+ query = "(&(name=Henk)(age>=18)(score<=100)(priority>5)(weight<75)(description~=admin)(flags:=1024))"
247
+ parsed = SearchFilter.parse(query, optimize=False)
248
+
249
+ assert parsed.is_nested()
250
+ assert parsed.operator == LogicalOperator.AND
251
+ assert len(parsed.children) == 7
252
+
253
+ # name = Henk
254
+ assert parsed.children[0].attribute == "name"
255
+ assert parsed.children[0].operator.value == "="
256
+ assert parsed.children[0].value == "Henk"
257
+
258
+ # age >= 18
259
+ assert parsed.children[1].attribute == "age"
260
+ assert parsed.children[1].operator.value == ">="
261
+ assert parsed.children[1].value == "18"
262
+
263
+ # score <= 100
264
+ assert parsed.children[2].attribute == "score"
265
+ assert parsed.children[2].operator.value == "<="
266
+ assert parsed.children[2].value == "100"
267
+
268
+ # priority > 5
269
+ assert parsed.children[3].attribute == "priority"
270
+ assert parsed.children[3].operator.value == ">"
271
+ assert parsed.children[3].value == "5"
272
+
273
+ # weight < 75
274
+ assert parsed.children[4].attribute == "weight"
275
+ assert parsed.children[4].operator.value == "<"
276
+ assert parsed.children[4].value == "75"
277
+
278
+ # description ~= admin
279
+ assert parsed.children[5].attribute == "description"
280
+ assert parsed.children[5].operator.value == "~="
281
+ assert parsed.children[5].value == "admin"
282
+
283
+ # flags := 1024
284
+ assert parsed.children[6].attribute == "flags"
285
+ assert parsed.children[6].operator.value == ":="
286
+ assert parsed.children[6].value == "1024"
287
+
288
+
289
+ def test_nested_not_with_different_comparison_operators() -> None:
290
+ """Test NOT with various comparison operators."""
291
+ query = "(!(|(age<18)(score>=90)(userAccountControl:=512)))"
292
+ parsed = SearchFilter.parse(query, optimize=False)
293
+
294
+ assert parsed.is_nested()
295
+ assert parsed.operator == LogicalOperator.NOT
296
+ assert len(parsed.children) == 1
297
+
298
+ # Inner OR filter
299
+ inner_filter = parsed.children[0]
300
+ assert inner_filter.operator == LogicalOperator.OR
301
+ assert len(inner_filter.children) == 3
302
+
303
+ # age < 18
304
+ assert inner_filter.children[0].attribute == "age"
305
+ assert inner_filter.children[0].operator.value == "<"
306
+ assert inner_filter.children[0].value == "18"
307
+
308
+ # score >= 90
309
+ assert inner_filter.children[1].attribute == "score"
310
+ assert inner_filter.children[1].operator.value == ">="
311
+ assert inner_filter.children[1].value == "90"
312
+
313
+ # userAccountControl := 512
314
+ assert inner_filter.children[2].attribute == "userAccountControl"
315
+ assert inner_filter.children[2].operator.value == ":="
316
+ assert inner_filter.children[2].value == "512"
317
+
318
+
319
+ def test_complex_nested_with_all_operators() -> None:
320
+ """Test complex nested filters with multiple operators."""
321
+ query = "(&(objectClass=person)(!(department~=temp))(|(age>=21)(|(salary>9000)(title<=manager))))"
322
+ parsed = SearchFilter.parse(query, optimize=False)
323
+
324
+ assert parsed.is_nested()
325
+ assert parsed.operator == LogicalOperator.AND
326
+ assert len(parsed.children) == 3
327
+
328
+ # First: (objectClass=person)
329
+ assert parsed.children[0].attribute == "objectClass"
330
+ assert parsed.children[0].operator.value == "="
331
+ assert parsed.children[0].value == "person"
332
+
333
+ # Second: (!(department~=temp))
334
+ not_filter = parsed.children[1]
335
+ assert not_filter.operator == LogicalOperator.NOT
336
+ assert not_filter.children[0].attribute == "department"
337
+ assert not_filter.children[0].operator.value == "~="
338
+ assert not_filter.children[0].value == "temp"
339
+
340
+ # Third: (|(age>=21)(|(salary>50000)(title<=manager)))
341
+ or_filter = parsed.children[2]
342
+ assert or_filter.operator == LogicalOperator.OR
343
+ assert len(or_filter.children) == 2
344
+
345
+ # age >= 21
346
+ assert or_filter.children[0].attribute == "age"
347
+ assert or_filter.children[0].operator.value == ">="
348
+ assert or_filter.children[0].value == "21"
349
+
350
+ # Inner OR: (|(salary>50000)(title<=manager))
351
+ inner_or = or_filter.children[1]
352
+ assert inner_or.operator == LogicalOperator.OR
353
+ assert len(inner_or.children) == 2
354
+
355
+ # salary > 50000
356
+ assert inner_or.children[0].attribute == "salary"
357
+ assert inner_or.children[0].operator.value == ">"
358
+ assert inner_or.children[0].value == "9000"
359
+
360
+ # title <= manager (this tests string values with comparison operators)
361
+ assert inner_or.children[1].attribute == "title"
362
+ assert inner_or.children[1].operator.value == "<="
363
+ assert inner_or.children[1].value == "manager"
364
+
365
+
366
+ def test_presence_filters() -> None:
367
+ """Test presence filters."""
368
+ # Presence filter - attribute exists
369
+ query = "(mailNickName=*)"
370
+ parsed = SearchFilter.parse(query, optimize=False)
371
+
372
+ assert not parsed.is_nested()
373
+ assert parsed.attribute == "mailNickName"
374
+ assert parsed.operator.value == "="
375
+ assert parsed.value == "*"
376
+
377
+
378
+ def test_absence_filters() -> None:
379
+ """Test absence filters."""
380
+ # Absence filter - attribute does not exist
381
+ query = "(!(proxyAddresses=*))"
382
+ parsed = SearchFilter.parse(query, optimize=False)
383
+
384
+ assert parsed.is_nested()
385
+ assert parsed.operator == LogicalOperator.NOT
386
+ assert len(parsed.children) == 1
387
+
388
+ inner = parsed.children[0]
389
+ assert inner.attribute == "proxyAddresses"
390
+ assert inner.operator.value == "="
391
+ assert inner.value == "*"
392
+
393
+
394
+ @pytest.mark.parametrize(
395
+ ("query", "attribute", "value"),
396
+ [
397
+ ("(name=Henk*)", "name", "Henk*"),
398
+ ("(name=*Henk)", "name", "*Henk"),
399
+ ("(name=*Jo*hn*)", "name", "*Jo*hn*"),
400
+ ("(email=*@domain.com)", "email", "*@domain.com"),
401
+ ],
402
+ )
403
+ def test_wildcard_with_different_operators(query: str, attribute: str, value: str) -> None:
404
+ """Test wildcards with = operator. Wildcards should work with = operator in all positions."""
405
+ parsed = SearchFilter.parse(query, optimize=False)
406
+
407
+ assert not parsed.is_nested()
408
+ assert parsed.attribute == attribute
409
+ assert parsed.operator.value == "="
410
+ assert parsed.value == value
411
+
412
+
413
+ @pytest.mark.parametrize(
414
+ ("expected", "attribute", "op", "value"),
415
+ [
416
+ pytest.param("(name=value)", "name", "=", "value", id="="),
417
+ pytest.param("(age>=18)", "age", ">=", "18", id=">="),
418
+ pytest.param("(score<=100)", "score", "<=", "100", id="<="),
419
+ pytest.param("(priority>5)", "priority", ">", "5", id=">"),
420
+ pytest.param("(weight<75)", "weight", "<", "75", id="<"),
421
+ pytest.param("(desc~=admin)", "desc", "~=", "admin", id="!="),
422
+ pytest.param("(flags:=1024)", "flags", ":=", "1024", id=":="),
423
+ ],
424
+ )
425
+ def test_format_with_all_operators(expected: str, attribute: str, op: str, value: str) -> None:
426
+ """Test format() method with all operators."""
427
+ query = f"({attribute}{op}{value})"
428
+ parsed = SearchFilter.parse(query, optimize=False)
429
+ assert parsed.format() == expected, f"Failed for {query}"
430
+
431
+
432
+ def test_error_no_comparison_operator() -> None:
433
+ """Test error for missing comparison operator."""
434
+ with pytest.raises(InvalidQueryError, match="No comparison operator found in query: .+ Expected one of .*"):
435
+ SearchFilter.parse("(attributename)", optimize=False)
436
+
437
+
438
+ def test_error_multiple_comparison_operators() -> None:
439
+ """Test error for multiple comparison operators."""
440
+ # This creates a query with both >= and <= operators
441
+ with pytest.raises(InvalidQueryError, match="Multiple comparison operators found"):
442
+ SearchFilter.parse("(attribute>=value<=other)", optimize=False)
443
+
444
+
445
+ def test_repr_nested_filter() -> None:
446
+ """Test ``__repr__`` for nested filters."""
447
+ query = "(&(name=Henk)(age>=18))"
448
+ parsed = SearchFilter.parse(query, optimize=False)
449
+
450
+ # Test repr of the main nested filter
451
+ assert (
452
+ repr(parsed)
453
+ == "<SearchFilter nested operator='&' children=[<SearchFilter attribute='name' operator='=' value=Henk>, <SearchFilter attribute='age' operator='>=' value=18>]>" # noqa: E501
454
+ )
455
+
456
+
457
+ def test_repr_simple_filter() -> None:
458
+ """Test ``__repr__`` for simple filters."""
459
+ query = "(name=Henk)"
460
+ parsed = SearchFilter.parse(query, optimize=False)
461
+
462
+ assert repr(parsed) == "<SearchFilter attribute='name' operator='=' value=Henk>"
463
+
464
+
465
+ def test_optimizer_fallback_case() -> None:
466
+ """Test optimizer fallback case."""
467
+ # Create a nested filter that doesn't match other optimization cases
468
+
469
+ obj = SearchFilter.__new__(SearchFilter)
470
+ obj.query = "(&)"
471
+ obj.children = []
472
+ obj.operator = LogicalOperator.AND
473
+ obj.attribute = None
474
+ obj.value = None
475
+
476
+ # This should hit the fallback case
477
+ _, weight = optimize_ldap_query(obj)
478
+ assert weight == max(_ATTRIBUTE_WEIGHTS.values())
479
+
480
+
481
+ def test_extended_matching_rules() -> None:
482
+ """Test extended matching rules."""
483
+ # BIT_AND rule (1.2.840.113556.1.4.803)
484
+ query = "(groupType:1.2.840.113556.1.4.803:=8)"
485
+ parsed = SearchFilter.parse(query, optimize=False)
486
+
487
+ assert not parsed.is_nested()
488
+ assert parsed.attribute == "groupType"
489
+ assert parsed.operator == ComparisonOperator.EXTENDED
490
+ assert parsed.value == "8"
491
+ assert parsed._extended_rule == "1.2.840.113556.1.4.803"
492
+
493
+ # Test formatting of extended matching rules
494
+ assert parsed.format() == query
495
+
496
+ # BIT_OR rule (1.2.840.113556.1.4.804)
497
+ query = "(userAccountControl:1.2.840.113556.1.4.804:=65568)"
498
+ parsed = SearchFilter.parse(query, optimize=False)
499
+
500
+ assert not parsed.is_nested()
501
+ assert parsed.attribute == "userAccountControl"
502
+ assert parsed.operator == ComparisonOperator.EXTENDED
503
+ assert parsed.value == "65568"
504
+ assert parsed._extended_rule == "1.2.840.113556.1.4.804"
505
+
506
+ # Test formatting of extended matching rules
507
+ assert parsed.format() == query
508
+
509
+
510
+ def test_extended_matching_in_nested_query() -> None:
511
+ """Test extended matching in nested queries."""
512
+ query = "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))"
513
+ parsed = SearchFilter.parse(query, optimize=False)
514
+
515
+ assert parsed.is_nested()
516
+ assert parsed.operator == LogicalOperator.AND
517
+ assert len(parsed.children) == 2
518
+
519
+ # First child - regular filter
520
+ assert parsed.children[0].attribute == "objectClass"
521
+ assert parsed.children[0].operator.value == "="
522
+ assert parsed.children[0].value == "group"
523
+
524
+ # Second child - extended matching rule
525
+ assert parsed.children[1].attribute == "groupType"
526
+ assert parsed.children[1].operator == ComparisonOperator.EXTENDED
527
+ assert parsed.children[1].value == "2147483648"
528
+ assert parsed.children[1]._extended_rule == "1.2.840.113556.1.4.803"
529
+
530
+
531
+ def test_not_operator_optimization() -> None:
532
+ """Test that NOT operators properly optimize their children."""
533
+ # Create a NOT filter with a redundant single-child AND that should be optimized
534
+ query = "(!(&(objectClass=user)))"
535
+
536
+ # Parse with optimization enabled
537
+ parsed_optimized = SearchFilter.parse(query, optimize=True)
538
+
539
+ # Parse without optimization to compare
540
+ parsed_unoptimized = SearchFilter.parse(query, optimize=False)
541
+
542
+ # The optimized version should have simplified the inner (&(objectClass=user)) to just (objectClass=user)
543
+ assert parsed_optimized.operator == LogicalOperator.NOT
544
+ assert len(parsed_optimized.children) == 1
545
+
546
+ # The child should be a simple filter (not nested) after optimization
547
+ inner_child = parsed_optimized.children[0]
548
+ assert not inner_child.is_nested()
549
+ assert inner_child.attribute == "objectClass"
550
+ assert inner_child.value == "user"
551
+
552
+ # Verify the query strings are different (optimization occurred)
553
+ assert parsed_optimized.query != parsed_unoptimized.query
554
+ assert parsed_optimized.query == "(!(objectClass=user))"
555
+
556
+ # Test with a more complex case: NOT with nested AND containing OR
557
+ complex_query = "(!(&(|(name=Henk)(name=Jan))))"
558
+ complex_parsed = SearchFilter.parse(complex_query, optimize=True)
559
+
560
+ # Should optimize to: (!(|(name=Henk)(name=Jan)))
561
+ assert complex_parsed.operator == LogicalOperator.NOT
562
+ inner = complex_parsed.children[0]
563
+ assert inner.operator == LogicalOperator.OR
564
+ assert len(inner.children) == 2
565
+ assert complex_parsed.query == "(!(|(name=Henk)(name=Jan)))"
566
+
567
+
568
+ def test_real_world_examples() -> None:
569
+ """Test real-world LDAP filter examples."""
570
+ query1 = "(&(objectclass=user)(displayName=Henk))"
571
+ parsed = SearchFilter.parse(query1, optimize=False)
572
+ assert parsed.operator == LogicalOperator.AND
573
+ assert len(parsed.children) == 2
574
+
575
+ query2 = "(!(objectClass=group))"
576
+ parsed = SearchFilter.parse(query2, optimize=False)
577
+ assert parsed.operator == LogicalOperator.NOT
578
+ assert parsed.children[0].attribute == "objectClass"
579
+ assert parsed.children[0].value == "group"
580
+
581
+ query = "(mail=*@henk.nl)"
582
+ parsed = SearchFilter.parse(query, optimize=False)
583
+ assert parsed.attribute == "mail"
584
+ assert parsed.value == "*@henk.nl"
585
+
586
+ query = "(mdbStorageQuota>=100000)"
587
+ parsed = SearchFilter.parse(query, optimize=False)
588
+ assert parsed.attribute == "mdbStorageQuota"
589
+ assert parsed.operator.value == ">="
590
+ assert parsed.value == "100000"
591
+
592
+ query = "(displayName~=Henk)"
593
+ parsed = SearchFilter.parse(query, optimize=False)
594
+ assert parsed.attribute == "displayName"
595
+ assert parsed.operator.value == "~="
596
+ assert parsed.value == "Henk"
597
+
598
+
599
+ def test_anr_style_filters() -> None:
600
+ """Test ANR-style filters."""
601
+ # Simple ANR-style filter (though ANR itself is server-side)
602
+ query1 = "(anr=Henk)"
603
+ parsed = SearchFilter.parse(query1, optimize=False)
604
+ assert parsed.attribute == "anr"
605
+ assert parsed.value == "Henk"
606
+
607
+ # ANR with partial match
608
+ query2 = "(anr=Henk*)"
609
+ parsed = SearchFilter.parse(query2, optimize=False)
610
+ assert parsed.attribute == "anr"
611
+ assert parsed.value == "Henk*"
612
+
613
+
614
+ def test_complex_real_world_filter() -> None:
615
+ """Test complex real-world filter."""
616
+ # Complex filter for finding specific user accounts
617
+ query = (
618
+ "(&"
619
+ "(objectClass=user)"
620
+ "(!(userAccountControl:1.2.840.113556.1.4.803:=2))"
621
+ "(|(sAMAccountName=admin*)(mail=*@admin.*))"
622
+ "(pwdLastSet>=0)"
623
+ "(!(description=*))"
624
+ ")"
625
+ )
626
+
627
+ parsed = SearchFilter.parse(query, optimize=False)
628
+ assert parsed.operator == LogicalOperator.AND
629
+ assert len(parsed.children) == 5
630
+
631
+ # Check that all components are parsed correctly
632
+ # objectClass=user
633
+ assert any(child.attribute == "objectClass" and child.value == "user" for child in parsed.children)
634
+
635
+ # Extended matching rule for userAccountControl
636
+ bit_filter = next(
637
+ (
638
+ child
639
+ for child in parsed.children
640
+ if child.operator == LogicalOperator.NOT and child.children[0].operator == ComparisonOperator.EXTENDED
641
+ ),
642
+ None,
643
+ )
644
+ assert bit_filter is not None
645
+ assert bit_filter.children[0].attribute == "userAccountControl"
646
+
647
+ # OR condition with wildcards
648
+ or_filter = next((child for child in parsed.children if child.operator == LogicalOperator.OR), None)
649
+ assert or_filter is not None
650
+ assert len(or_filter.children) == 2
651
+ assert "admin*" in [child.value for child in or_filter.children]
652
+ assert "*@admin.*" in [child.value for child in or_filter.children]