switcher-client 0.1.2__py3-none-any.whl
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.
- switcher_client/__init__.py +11 -0
- switcher_client/client.py +262 -0
- switcher_client/errors/__init__.py +44 -0
- switcher_client/lib/__init__.py +7 -0
- switcher_client/lib/compat.py +8 -0
- switcher_client/lib/globals/__init__.py +10 -0
- switcher_client/lib/globals/global_auth.py +28 -0
- switcher_client/lib/globals/global_context.py +95 -0
- switcher_client/lib/globals/global_retry.py +16 -0
- switcher_client/lib/globals/global_snapshot.py +33 -0
- switcher_client/lib/remote.py +153 -0
- switcher_client/lib/remote_auth.py +74 -0
- switcher_client/lib/resolver.py +99 -0
- switcher_client/lib/snapshot.py +214 -0
- switcher_client/lib/snapshot_auto_updater.py +63 -0
- switcher_client/lib/snapshot_loader.py +77 -0
- switcher_client/lib/snapshot_watcher.py +67 -0
- switcher_client/lib/types.py +169 -0
- switcher_client/lib/utils/__init__.py +21 -0
- switcher_client/lib/utils/date_moment.py +26 -0
- switcher_client/lib/utils/execution_logger.py +96 -0
- switcher_client/lib/utils/ipcidr.py +46 -0
- switcher_client/lib/utils/payload_reader.py +43 -0
- switcher_client/lib/utils/timed_match/__init__.py +3 -0
- switcher_client/lib/utils/timed_match/timed_match.py +246 -0
- switcher_client/lib/utils/timed_match/worker.py +100 -0
- switcher_client/switcher.py +188 -0
- switcher_client/switcher_data.py +112 -0
- switcher_client/version.py +1 -0
- switcher_client-0.1.2.dist-info/METADATA +402 -0
- switcher_client-0.1.2.dist-info/RECORD +34 -0
- switcher_client-0.1.2.dist-info/WHEEL +5 -0
- switcher_client-0.1.2.dist-info/licenses/LICENSE +21 -0
- switcher_client-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
class ResultDetail:
|
|
5
|
+
"""
|
|
6
|
+
ResultDetail holds the result of a criteria check, including the boolean result,
|
|
7
|
+
reason for the result, and any additional metadata.
|
|
8
|
+
It provides static methods to create instances for success, disabled, or custom results.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, result: bool, reason: Optional[str], metadata: Optional[dict] = None):
|
|
11
|
+
self.result = result
|
|
12
|
+
self.reason = reason
|
|
13
|
+
self.metadata = metadata
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def create(result: bool, reason: Optional[str], metadata: Optional[dict] = None) -> 'ResultDetail':
|
|
17
|
+
return ResultDetail(result=result, reason=reason, metadata=metadata)
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def disabled(reason: str, metadata: Optional[dict] = None) -> 'ResultDetail':
|
|
21
|
+
return ResultDetail(result=False, reason=reason, metadata=metadata)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def success(reason: str = "Success", metadata: Optional[dict] = None) -> 'ResultDetail':
|
|
25
|
+
return ResultDetail(result=True, reason=reason, metadata=metadata)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
'result': self.result,
|
|
30
|
+
'reason': self.reason,
|
|
31
|
+
'metadata': self.metadata
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Domain:
|
|
36
|
+
""" Domain represents the top-level structure of the snapshot, containing groups and their configurations. """
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.name: str
|
|
39
|
+
self.version: int = 0
|
|
40
|
+
self.activated: bool
|
|
41
|
+
self.group: list[Group]
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Group:
|
|
45
|
+
""" Group represents a collection of configurations within a domain. """
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self.name: str
|
|
48
|
+
self.activated: bool
|
|
49
|
+
self.config: list[Config]
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Config:
|
|
53
|
+
""" Config represents a specific configuration within a group. """
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.key: str
|
|
56
|
+
self.activated: bool
|
|
57
|
+
self.strategies: Optional[list[StrategyConfig]] = None
|
|
58
|
+
self.relay: Optional[Relay] = None
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class StrategyConfig:
|
|
62
|
+
""" StrategyConfig represents a specific strategy within a configuration. """
|
|
63
|
+
def __init__(self):
|
|
64
|
+
self.strategy: str
|
|
65
|
+
self.activated: bool
|
|
66
|
+
self.operation: str
|
|
67
|
+
self.values: list[str]
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Entry:
|
|
71
|
+
""" Entry represents an input entry for strategy evaluation, containing the strategy type and input value. """
|
|
72
|
+
# pylint: disable=redefined-builtin
|
|
73
|
+
def __init__(self, strategy: str, input: str):
|
|
74
|
+
self.strategy = strategy
|
|
75
|
+
self.input = input
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
return {
|
|
79
|
+
'strategy': self.strategy,
|
|
80
|
+
'input': self.input
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Relay:
|
|
85
|
+
""" Relay represents a relay configuration within a configuration. """
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self.type: str
|
|
88
|
+
self.activated: bool
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Snapshot:
|
|
92
|
+
"""
|
|
93
|
+
Snapshot represents the entire snapshot data structure,
|
|
94
|
+
including the domain and its nested groups and configurations.
|
|
95
|
+
"""
|
|
96
|
+
def __init__(self, json_data: dict):
|
|
97
|
+
self._original_data = json_data
|
|
98
|
+
self.domain = self._parse_domain(json_data)
|
|
99
|
+
|
|
100
|
+
def _parse_domain(self, domain_data: dict) -> Domain:
|
|
101
|
+
""" Parse domain data from JSON """
|
|
102
|
+
|
|
103
|
+
domain = Domain()
|
|
104
|
+
domain.name = domain_data.get('name', '')
|
|
105
|
+
domain.activated = domain_data.get('activated', False)
|
|
106
|
+
domain.version = domain_data.get('version', 0)
|
|
107
|
+
|
|
108
|
+
if 'group' in domain_data and domain_data['group']:
|
|
109
|
+
domain.group = []
|
|
110
|
+
for group_data in domain_data['group']:
|
|
111
|
+
domain.group.append(self._parse_group(group_data))
|
|
112
|
+
|
|
113
|
+
return domain
|
|
114
|
+
|
|
115
|
+
def _parse_group(self, group_data: dict) -> Group:
|
|
116
|
+
""" Parse group data from JSON """
|
|
117
|
+
|
|
118
|
+
group = Group()
|
|
119
|
+
group.name = group_data.get('name', '')
|
|
120
|
+
group.activated = group_data.get('activated', False)
|
|
121
|
+
|
|
122
|
+
if 'config' in group_data and group_data['config']:
|
|
123
|
+
group.config = []
|
|
124
|
+
for config_data in group_data['config']:
|
|
125
|
+
group.config.append(self._parse_config(config_data))
|
|
126
|
+
|
|
127
|
+
return group
|
|
128
|
+
|
|
129
|
+
def _parse_config(self, config_data: dict) -> Config:
|
|
130
|
+
""" Parse config data from JSON """
|
|
131
|
+
|
|
132
|
+
config = Config()
|
|
133
|
+
config.key = config_data.get('key', '')
|
|
134
|
+
config.activated = config_data.get('activated', False)
|
|
135
|
+
|
|
136
|
+
if 'strategies' in config_data and config_data['strategies']:
|
|
137
|
+
config.strategies = []
|
|
138
|
+
for strategy_data in config_data['strategies']:
|
|
139
|
+
config.strategies.append(self._parse_strategy(strategy_data))
|
|
140
|
+
|
|
141
|
+
if 'relay' in config_data and config_data['relay']:
|
|
142
|
+
config.relay = self._parse_relay(config_data['relay'])
|
|
143
|
+
|
|
144
|
+
return config
|
|
145
|
+
|
|
146
|
+
def _parse_strategy(self, strategy_data: dict) -> StrategyConfig:
|
|
147
|
+
""" Parse strategy data from JSON """
|
|
148
|
+
|
|
149
|
+
strategy = StrategyConfig()
|
|
150
|
+
strategy.strategy = strategy_data.get('strategy', '')
|
|
151
|
+
strategy.activated = strategy_data.get('activated', False)
|
|
152
|
+
strategy.operation = strategy_data.get('operation', '')
|
|
153
|
+
strategy.values = strategy_data.get('values', [])
|
|
154
|
+
|
|
155
|
+
return strategy
|
|
156
|
+
|
|
157
|
+
def _parse_relay(self, relay_data: dict) -> Relay:
|
|
158
|
+
""" Parse relay data from JSON """
|
|
159
|
+
|
|
160
|
+
relay = Relay()
|
|
161
|
+
relay.type = relay_data.get('type', '')
|
|
162
|
+
relay.activated = relay_data.get('activated', False)
|
|
163
|
+
|
|
164
|
+
return relay
|
|
165
|
+
|
|
166
|
+
def to_dict(self) -> dict:
|
|
167
|
+
""" Convert Snapshot back to dictionary format for JSON serialization """
|
|
168
|
+
|
|
169
|
+
return {'domain': self._original_data}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from switcher_client.lib.types import Entry
|
|
2
|
+
from switcher_client.lib.utils.execution_logger import ExecutionLogger
|
|
3
|
+
|
|
4
|
+
def get(value, default_value):
|
|
5
|
+
""" Return value if not None, otherwise return default_value """
|
|
6
|
+
return value if value is not None else default_value
|
|
7
|
+
|
|
8
|
+
# pylint: disable=redefined-builtin
|
|
9
|
+
def get_entry(input: list) -> list[Entry]:
|
|
10
|
+
""" Prepare entry dictionary from input strategy handling """
|
|
11
|
+
entry: list[Entry] = []
|
|
12
|
+
for strategy_type, input_value in input:
|
|
13
|
+
entry.append(Entry(strategy_type, input_value))
|
|
14
|
+
|
|
15
|
+
return entry
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
'ExecutionLogger',
|
|
19
|
+
'get_entry',
|
|
20
|
+
'get',
|
|
21
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
class DateMoment:
|
|
4
|
+
"""
|
|
5
|
+
A utility class for date and time manipulation, similar to moment.js functionality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, date: datetime):
|
|
9
|
+
self.date = date
|
|
10
|
+
|
|
11
|
+
def get_date(self) -> datetime:
|
|
12
|
+
return self.date
|
|
13
|
+
|
|
14
|
+
def add(self, amount: int, unit: str) -> 'DateMoment':
|
|
15
|
+
unit_lower = unit.lower()
|
|
16
|
+
|
|
17
|
+
if unit_lower == 's':
|
|
18
|
+
self.date += timedelta(seconds=amount)
|
|
19
|
+
elif unit_lower == 'm':
|
|
20
|
+
self.date += timedelta(minutes=amount)
|
|
21
|
+
elif unit_lower == 'h':
|
|
22
|
+
self.date += timedelta(hours=amount)
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError(f"Unit {unit} not compatible - try [s, m or h]")
|
|
25
|
+
|
|
26
|
+
return self
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# pylint: disable=redefined-builtin
|
|
2
|
+
from typing import Optional, Callable, List
|
|
3
|
+
|
|
4
|
+
from switcher_client.lib.types import ResultDetail
|
|
5
|
+
|
|
6
|
+
# Global logger storage
|
|
7
|
+
_logger: List['ExecutionLogger'] = []
|
|
8
|
+
|
|
9
|
+
class ExecutionLogger:
|
|
10
|
+
"""It keeps track of latest execution results."""
|
|
11
|
+
|
|
12
|
+
_callback_error: Optional[Callable[[Exception], None]] = None
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.key: Optional[str] = None
|
|
16
|
+
self.input: Optional[List[List[str]]] = None
|
|
17
|
+
self.response: ResultDetail = ResultDetail(result=False, reason=None, metadata=None)
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def add(response: ResultDetail, key: str, input: Optional[List[List[str]]] = None) -> None:
|
|
21
|
+
"""Add new execution result"""
|
|
22
|
+
# Remove existing execution with same key and input
|
|
23
|
+
for index, log in enumerate(_logger):
|
|
24
|
+
if ExecutionLogger._has_execution(log, key, input):
|
|
25
|
+
_logger.pop(index)
|
|
26
|
+
break
|
|
27
|
+
|
|
28
|
+
# Create new execution log entry
|
|
29
|
+
new_log = ExecutionLogger()
|
|
30
|
+
new_log.key = key
|
|
31
|
+
new_log.input = input
|
|
32
|
+
new_log.response = ResultDetail(
|
|
33
|
+
result=response.result,
|
|
34
|
+
reason=response.reason,
|
|
35
|
+
metadata={**(response.metadata or {}), 'cached': True}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_logger.append(new_log)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def get_execution(key: str, input: Optional[List[List[str]]] = None) -> 'ExecutionLogger':
|
|
42
|
+
"""Retrieve a specific result given a key and an input"""
|
|
43
|
+
for log in _logger:
|
|
44
|
+
if ExecutionLogger._has_execution(log, key, input):
|
|
45
|
+
return log
|
|
46
|
+
|
|
47
|
+
return ExecutionLogger()
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def get_by_key(key: str) -> List['ExecutionLogger']:
|
|
51
|
+
"""Retrieve results given a switcher key"""
|
|
52
|
+
return [log for log in _logger if log.key == key]
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def clear_logger() -> None:
|
|
56
|
+
"""Clear all results"""
|
|
57
|
+
_logger.clear()
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def callback_error(cls) -> Optional[Callable[[Exception], None]]:
|
|
61
|
+
"""Returns the subscribed error callback."""
|
|
62
|
+
return cls._callback_error
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def subscribe_notify_error(callback: Callable[[Exception], None]) -> None:
|
|
66
|
+
"""Subscribe to notify when an asynchronous error is thrown."""
|
|
67
|
+
ExecutionLogger._callback_error = callback
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _has_execution(log: 'ExecutionLogger', key: str, input: Optional[List[List[str]]]) -> bool:
|
|
71
|
+
"""Check if log matches the given key and input"""
|
|
72
|
+
return log.key == key and ExecutionLogger._check_strategy_inputs(log.input, input)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _check_strategy_inputs(logger_inputs: Optional[List[List[str]]], inputs: Optional[List[List[str]]]) -> bool:
|
|
76
|
+
"""Check if strategy inputs match between logger and current inputs"""
|
|
77
|
+
if not logger_inputs:
|
|
78
|
+
return not inputs or len(inputs) == 0
|
|
79
|
+
|
|
80
|
+
if not inputs:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
for strategy_input in logger_inputs:
|
|
84
|
+
if len(strategy_input) >= 2:
|
|
85
|
+
strategy, input_value = strategy_input[0], strategy_input[1]
|
|
86
|
+
# Find matching strategy and input in current inputs
|
|
87
|
+
found = any(
|
|
88
|
+
len(current_input) >= 2 and
|
|
89
|
+
current_input[0] == strategy and
|
|
90
|
+
current_input[1] == input_value
|
|
91
|
+
for current_input in inputs
|
|
92
|
+
)
|
|
93
|
+
if not found:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
return True
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# pylint: disable=too-few-public-methods
|
|
2
|
+
class IPCIDR:
|
|
3
|
+
"""Class for checking if an IP address is within a CIDR range."""
|
|
4
|
+
|
|
5
|
+
def __init__(self, cidr: str) -> None:
|
|
6
|
+
"""
|
|
7
|
+
Initialize IPCIDR with a CIDR string.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
cidr: CIDR notation string (e.g., '192.168.1.0/24')
|
|
11
|
+
"""
|
|
12
|
+
self.cidr = cidr
|
|
13
|
+
|
|
14
|
+
def _ip4_to_int(self, ip: str) -> int:
|
|
15
|
+
"""
|
|
16
|
+
Convert an IPv4 address string to a 32-bit integer.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
ip: IPv4 address string (e.g., '192.168.1.1')
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
32-bit integer representation of the IP address
|
|
23
|
+
"""
|
|
24
|
+
octets = ip.split('.')
|
|
25
|
+
result = 0
|
|
26
|
+
for octet in octets:
|
|
27
|
+
result = (result << 8) + int(octet)
|
|
28
|
+
return result & 0xFFFFFFFF # Ensure 32-bit unsigned integer
|
|
29
|
+
|
|
30
|
+
def is_ip4_in_cidr(self, ip: str) -> bool:
|
|
31
|
+
"""
|
|
32
|
+
Check if an IPv4 address is within the CIDR range.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
ip: IPv4 address string to check
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True if the IP is within the CIDR range, False otherwise
|
|
39
|
+
"""
|
|
40
|
+
cidr_parts = self.cidr.split('/')
|
|
41
|
+
range_ip = cidr_parts[0]
|
|
42
|
+
bits = int(cidr_parts[1]) if len(cidr_parts) > 1 else 32
|
|
43
|
+
|
|
44
|
+
mask = ~(2 ** (32 - bits) - 1) & 0xFFFFFFFF
|
|
45
|
+
|
|
46
|
+
return (self._ip4_to_int(ip) & mask) == (self._ip4_to_int(range_ip) & mask)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Union
|
|
4
|
+
|
|
5
|
+
def payload_reader(payload: Any) -> List[str]:
|
|
6
|
+
"""Extract all field keys from a JSON payload structure.
|
|
7
|
+
|
|
8
|
+
This function recursively traverses a JSON structure
|
|
9
|
+
and returns all possible field paths in dot notation.
|
|
10
|
+
"""
|
|
11
|
+
# Handle string payload - if it's a string, try to parse it as JSON
|
|
12
|
+
if isinstance(payload, str):
|
|
13
|
+
parsed = parse_json(payload)
|
|
14
|
+
if parsed is not None:
|
|
15
|
+
payload = parsed
|
|
16
|
+
|
|
17
|
+
# If payload is a list/array, recursively process each element
|
|
18
|
+
if isinstance(payload, list):
|
|
19
|
+
result = []
|
|
20
|
+
for item in payload:
|
|
21
|
+
result.extend(payload_reader(item))
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
# If payload is not a dict, return empty list (primitive values don't have keys)
|
|
25
|
+
if not isinstance(payload, dict):
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
# Process dictionary keys
|
|
29
|
+
result = []
|
|
30
|
+
|
|
31
|
+
for field in payload.keys():
|
|
32
|
+
result.append(field)
|
|
33
|
+
nested_fields = payload_reader(payload[field])
|
|
34
|
+
for nested_field in nested_fields:
|
|
35
|
+
result.append(f"{field}.{nested_field}")
|
|
36
|
+
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
def parse_json(json_str: str) -> Union[Any, None]:
|
|
40
|
+
try:
|
|
41
|
+
return json.loads(json_str)
|
|
42
|
+
except (json.JSONDecodeError, TypeError):
|
|
43
|
+
return None
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import multiprocessing
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from typing import List, Optional, Any
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from switcher_client.lib.globals.global_context import DEFAULT_REGEX_MAX_BLACKLISTED, DEFAULT_REGEX_MAX_TIME_LIMIT
|
|
8
|
+
from switcher_client.lib.utils.timed_match.worker import TaskType, WorkerResult, WorkerTask, persistent_regex_worker
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Blacklist:
|
|
12
|
+
"""Represents a blacklisted regex pattern and input combination."""
|
|
13
|
+
patterns: List[str]
|
|
14
|
+
input_value: str
|
|
15
|
+
|
|
16
|
+
class TimedMatch:
|
|
17
|
+
"""
|
|
18
|
+
This class provides regex match operations with timeout-based ReDoS protection.
|
|
19
|
+
|
|
20
|
+
Operations are executed in isolated processes with configurable timeouts.
|
|
21
|
+
Processes that exceed the timeout are terminated, preventing ReDoS attacks.
|
|
22
|
+
Failed operations are cached in a blacklist to avoid repeated resource usage.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_blacklisted: List[Blacklist] = []
|
|
26
|
+
_max_blacklisted: int = DEFAULT_REGEX_MAX_BLACKLISTED
|
|
27
|
+
_max_time_limit: float = DEFAULT_REGEX_MAX_TIME_LIMIT / 1000.0 # Convert to seconds
|
|
28
|
+
|
|
29
|
+
# Persistent worker management
|
|
30
|
+
_worker_process: Optional[multiprocessing.Process] = None
|
|
31
|
+
_task_queue: Optional[multiprocessing.Queue] = None
|
|
32
|
+
_result_queue: Optional[multiprocessing.Queue] = None
|
|
33
|
+
_worker_ctx: Optional[Any] = None
|
|
34
|
+
_task_counter: int = 0
|
|
35
|
+
_worker_needs_restart: bool = False
|
|
36
|
+
_old_workers_to_cleanup: List[multiprocessing.Process] = []
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def initialize_worker(cls) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Initialize the persistent worker process for regex matching.
|
|
42
|
+
|
|
43
|
+
Creates a new worker process with communication queues. If a worker
|
|
44
|
+
already exists, it will be terminated before creating a new one.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if worker was successfully initialized, False otherwise
|
|
48
|
+
"""
|
|
49
|
+
# Terminate existing worker if any
|
|
50
|
+
cls.terminate_worker()
|
|
51
|
+
|
|
52
|
+
# Create multiprocessing context
|
|
53
|
+
cls._worker_ctx = multiprocessing.get_context('spawn')
|
|
54
|
+
|
|
55
|
+
# Create communication queues
|
|
56
|
+
cls._task_queue = cls._worker_ctx.Queue()
|
|
57
|
+
cls._result_queue = cls._worker_ctx.Queue()
|
|
58
|
+
|
|
59
|
+
# Create and start worker process
|
|
60
|
+
cls._worker_process = cls._worker_ctx.Process(
|
|
61
|
+
target=persistent_regex_worker,
|
|
62
|
+
args=(cls._task_queue, cls._result_queue)
|
|
63
|
+
)
|
|
64
|
+
if cls._worker_process:
|
|
65
|
+
cls._worker_process.start()
|
|
66
|
+
|
|
67
|
+
# Reset task counter
|
|
68
|
+
cls._task_counter = 0
|
|
69
|
+
|
|
70
|
+
return cls._worker_process is not None and cls._worker_process.is_alive()
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def terminate_worker(cls) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Terminate all worker processes (current and old ones).
|
|
76
|
+
|
|
77
|
+
Sends a shutdown signal to workers and forcefully terminates them if needed.
|
|
78
|
+
Cleans up all worker-related resources.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
# Terminate current worker
|
|
82
|
+
if cls._worker_process and cls._worker_process.is_alive():
|
|
83
|
+
cls._graceful_shutdown()
|
|
84
|
+
|
|
85
|
+
# Terminate all old workers waiting for cleanup
|
|
86
|
+
cls._terminate_all_old_workers()
|
|
87
|
+
finally:
|
|
88
|
+
cls._cleanup_resources()
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def _graceful_shutdown(cls) -> None:
|
|
92
|
+
"""Attempt graceful shutdown of worker process."""
|
|
93
|
+
if cls._task_queue:
|
|
94
|
+
shutdown_task = WorkerTask(
|
|
95
|
+
task_type=TaskType.SHUTDOWN,
|
|
96
|
+
task_id=f"shutdown_{time.time()}"
|
|
97
|
+
)
|
|
98
|
+
cls._task_queue.put(shutdown_task, timeout=1.0)
|
|
99
|
+
if cls._worker_process:
|
|
100
|
+
cls._worker_process.join(timeout=2.0)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def _cleanup_resources(cls) -> None:
|
|
104
|
+
"""Clean up all worker-related resources."""
|
|
105
|
+
cls._worker_process = None
|
|
106
|
+
cls._task_queue = None
|
|
107
|
+
cls._result_queue = None
|
|
108
|
+
cls._worker_ctx = None
|
|
109
|
+
cls._task_counter = 0
|
|
110
|
+
cls._worker_needs_restart = False
|
|
111
|
+
cls._old_workers_to_cleanup.clear()
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def try_match(cls, patterns: List[str], input_value: str, use_fullmatch: bool = False) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Executes regex matching operation with timeout protection.
|
|
117
|
+
|
|
118
|
+
The operation runs in an isolated process with timeout protection to prevent
|
|
119
|
+
runaway regex operations that could lead to ReDoS attacks.
|
|
120
|
+
|
|
121
|
+
Failed operations (timeouts, errors) are automatically added to a blacklist
|
|
122
|
+
to prevent repeated attempts with the same problematic patterns.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
patterns: Array of regular expression patterns to test against the input
|
|
126
|
+
input_value: The input string to match against the regex patterns
|
|
127
|
+
use_fullmatch: If True, uses re.fullmatch; if False, uses re.search
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if any of the regex patterns match the input, false otherwise
|
|
131
|
+
"""
|
|
132
|
+
if cls._is_blacklisted(patterns, input_value):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
return cls._safe_match(patterns, input_value, use_fullmatch)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _safe_match(cls, patterns: List[str], input_value: str, use_fullmatch: bool) -> bool:
|
|
139
|
+
""" Run regex match with timeout protection using persistent worker."""
|
|
140
|
+
task_id = cls._create_and_send_task(patterns, input_value, use_fullmatch)
|
|
141
|
+
return cls._wait_for_result(task_id, patterns, input_value)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def _create_and_send_task(cls, patterns: List[str], input_value: str, use_fullmatch: bool) -> str:
|
|
145
|
+
"""Create task and send to worker."""
|
|
146
|
+
cls._task_counter += 1
|
|
147
|
+
task_id = f"task_{cls._task_counter}_{time.time()}"
|
|
148
|
+
|
|
149
|
+
task = WorkerTask(
|
|
150
|
+
task_type=TaskType.MATCH,
|
|
151
|
+
patterns=patterns,
|
|
152
|
+
input_value=input_value,
|
|
153
|
+
use_fullmatch=use_fullmatch,
|
|
154
|
+
task_id=task_id
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if cls._task_queue:
|
|
158
|
+
cls._task_queue.put(task, timeout=1.0)
|
|
159
|
+
return task_id
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def _wait_for_result(cls, task_id: str, patterns: List[str], input_value: str) -> bool:
|
|
163
|
+
"""Wait for result from worker with timeout."""
|
|
164
|
+
start_time = time.time()
|
|
165
|
+
while time.time() - start_time < cls._max_time_limit:
|
|
166
|
+
try:
|
|
167
|
+
if cls._result_queue:
|
|
168
|
+
result = cls._result_queue.get(timeout=0.1)
|
|
169
|
+
if result.task_id == task_id:
|
|
170
|
+
return cls._process_worker_result(result, patterns, input_value)
|
|
171
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# Timeout occurred - start new worker immediately and defer cleanup of old one
|
|
175
|
+
cls._replace_worker_immediately()
|
|
176
|
+
cls._add_to_blacklist(patterns, input_value)
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def _process_worker_result(cls, result: WorkerResult, patterns: List[str], input_value: str) -> bool:
|
|
181
|
+
"""Process result from worker."""
|
|
182
|
+
if result.success:
|
|
183
|
+
return result.result if result.result is not None else False
|
|
184
|
+
cls._add_to_blacklist(patterns, input_value)
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def _is_blacklisted(cls, patterns: List[str], input_value: str) -> bool:
|
|
189
|
+
for blacklisted in cls._blacklisted:
|
|
190
|
+
# Check if input can contain same segment that could fail matching
|
|
191
|
+
if blacklisted.input_value in input_value or input_value in blacklisted.input_value:
|
|
192
|
+
# Check if any of the patterns match (regex order should not affect)
|
|
193
|
+
matching_patterns = [p for p in patterns if p in blacklisted.patterns]
|
|
194
|
+
if matching_patterns:
|
|
195
|
+
return True
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def _add_to_blacklist(cls, patterns: List[str], input_value: str) -> None:
|
|
200
|
+
# Maintain blacklist size limit
|
|
201
|
+
if len(cls._blacklisted) >= cls._max_blacklisted:
|
|
202
|
+
cls._blacklisted.pop(0) # Remove oldest entry
|
|
203
|
+
|
|
204
|
+
cls._blacklisted.append(Blacklist(
|
|
205
|
+
patterns=patterns.copy(),
|
|
206
|
+
input_value=input_value
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def _replace_worker_immediately(cls) -> None:
|
|
211
|
+
"""Replace worker immediately without waiting for cleanup."""
|
|
212
|
+
# Move current worker to cleanup list if it exists
|
|
213
|
+
if cls._worker_process:
|
|
214
|
+
cls._old_workers_to_cleanup.append(cls._worker_process)
|
|
215
|
+
|
|
216
|
+
# Clear current worker references (but don't cleanup yet)
|
|
217
|
+
cls._worker_process = None
|
|
218
|
+
cls._task_queue = None
|
|
219
|
+
cls._result_queue = None
|
|
220
|
+
cls._worker_ctx = None
|
|
221
|
+
cls._task_counter = 0
|
|
222
|
+
|
|
223
|
+
# Initialize new worker immediately
|
|
224
|
+
cls.initialize_worker()
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def _terminate_all_old_workers(cls) -> None:
|
|
228
|
+
"""Forcefully terminate all old workers synchronously."""
|
|
229
|
+
for worker in cls._old_workers_to_cleanup[:]:
|
|
230
|
+
if worker and worker.is_alive():
|
|
231
|
+
worker.terminate()
|
|
232
|
+
worker.join(timeout=1.0)
|
|
233
|
+
|
|
234
|
+
cls._old_workers_to_cleanup.clear()
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def clear_blacklist(cls) -> None:
|
|
238
|
+
cls._blacklisted.clear()
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def set_max_blacklisted(cls, value: int) -> None:
|
|
242
|
+
cls._max_blacklisted = value
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def set_max_time_limit(cls, value: int) -> None:
|
|
246
|
+
cls._max_time_limit = value / 1000.0 # Convert to seconds
|