safeshield 1.2.2__py3-none-any.whl → 1.4.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.
validator/rules/files.py CHANGED
@@ -1,7 +1,8 @@
1
- from .base import ValidationRule
1
+ from .base import Rule
2
2
  from typing import Any, Dict, List, Optional, Set, Union, Tuple, Type
3
+ import mimetypes
3
4
 
4
- class FileRule(ValidationRule):
5
+ class FileRule(Rule):
5
6
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
6
7
  # 1. Cek file object framework (Flask/Werkzeug/FastAPI)
7
8
  if (hasattr(value, 'filename') and
@@ -28,80 +29,173 @@ class FileRule(ValidationRule):
28
29
  return False
29
30
 
30
31
  def message(self, field: str, params: List[str]) -> str:
31
- return f"The {field} must be a valid file"
32
+ return f"The :attribute must be a valid file"
32
33
 
33
- class DimensionsRule(ValidationRule):
34
+ class DimensionsRule(Rule):
35
+ def __init__(self, *params):
36
+ super().__init__(*params)
37
+
38
+ self._constraints = {
39
+ 'min_width': 0,
40
+ 'min_height': 0,
41
+ 'max_width': float('inf'),
42
+ 'max_height': float('inf'),
43
+ 'width': None,
44
+ 'height': None,
45
+ 'ratio': None
46
+ }
47
+
48
+ def maxWidth(self, value: int) -> 'DimensionsRule':
49
+ self._constraints['width'] = None
50
+ self._constraints['max_width'] = value
51
+ return self
52
+
53
+ def maxHeight(self, value: int) -> 'DimensionsRule':
54
+ self._constraints['height'] = None
55
+ self._constraints['max_height'] = value
56
+ return self
57
+
58
+ def ratio(self, value: float) -> 'DimensionsRule':
59
+ self._constraints['ratio'] = value
60
+ return self
61
+
62
+ def minWidth(self, value: int) -> 'DimensionsRule':
63
+ self._constraints['width'] = None
64
+ self._constraints['min_width'] = value
65
+ return self
66
+
67
+ def minHeight(self, value: int) -> 'DimensionsRule':
68
+ self._constraints['height'] = None
69
+ self._constraints['min_height'] = value
70
+ return self
71
+
72
+ def _validate_constraint_logic(self):
73
+ if self._constraints['width'] is not None:
74
+ if (self._constraints['min_width'] > 0 or
75
+ self._constraints['max_width'] < float('inf')):
76
+ raise ValueError("Cannot specify both exact width and min/max width")
77
+
78
+ if self._constraints['max_width'] < self._constraints['min_width']:
79
+ raise ValueError(f"max_width ({self._constraints['max_width']}) "
80
+ f"cannot be less than min_width ({self._constraints['min_width']})")
81
+
82
+ if self._constraints['height'] is not None:
83
+ if (self._constraints['min_height'] > 0 or
84
+ self._constraints['max_height'] < float('inf')):
85
+ raise ValueError("Cannot specify both exact height and min/max height")
86
+
87
+ if self._constraints['max_height'] < self._constraints['min_height']:
88
+ raise ValueError(f"max_height ({self._constraints['max_height']}) "
89
+ f"cannot be less than min_height ({self._constraints['min_height']})")
90
+
34
91
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
92
+ self._validate_constraint_logic()
35
93
  try:
36
- from PIL import Image
37
- import io
38
-
39
- # 1. Parse dimension rules
40
- min_width = min_height = 0
41
- max_width = max_height = float('inf')
42
-
94
+ # 2. Parse parameters
43
95
  for param in params:
44
- try:
45
- if param.startswith('min:'):
46
- min_width, min_height = map(int, param[4:].split('x'))
47
- elif param.startswith('max:'):
48
- max_width, max_height = map(int, param[4:].split('x'))
49
- else:
50
- exact_width, exact_height = map(int, param.split('x'))
51
- min_width = max_width = exact_width
52
- min_height = max_height = exact_height
53
- except (ValueError, IndexError):
54
- continue
55
-
56
- # 2. Load image with proper handling
57
- img = None
58
- try:
59
- # Case 1: File-like object (Flask/Werkzeug/FastAPI)
60
- if hasattr(value, 'read'):
61
- value.seek(0) # Important for rewinding
62
- img = Image.open(value)
63
- value.seek(0) # Reset after reading
64
-
65
- # Case 2: Bytes data
66
- elif isinstance(value, bytes):
67
- img = Image.open(io.BytesIO(value))
96
+ # Format Laravel: min_width=100
97
+ if '=' in param:
98
+ key, val = param.split('=', 1)
99
+ if key in self._constraints:
100
+ try:
101
+ self._constraints[key] = float(val)
102
+ except ValueError:
103
+ continue
68
104
 
69
- # Case 3: File path (string)
70
- elif isinstance(value, str) and value.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
71
- img = Image.open(value)
105
+ # Format legacy: 100x200
106
+ elif 'x' in param:
107
+ try:
108
+ width, height = map(int, param.split('x'))
109
+ self._constraints['width'] = width
110
+ self._constraints['height'] = height
111
+ except ValueError:
112
+ continue
72
113
 
73
- if img is None:
74
- return False
75
-
76
- # 3. Validate dimensions
77
- width, height = img.size
78
- return (min_width <= width <= max_width and
79
- min_height <= height <= max_height)
114
+ # Format ratio: 3/2
115
+ elif '/' in param:
116
+ try:
117
+ self._constraints['ratio'] = param
118
+ except ValueError:
119
+ continue
80
120
 
81
- except (IOError, OSError, AttributeError) as e:
82
- print(f"Image loading failed: {str(e)}")
121
+ # 3. Load image
122
+ img = self._load_image(value)
123
+ if not img:
83
124
  return False
84
125
 
126
+ width, height = img.size
127
+ actual_ratio = round(width / height, 2)
128
+
129
+ # 4. Validate _constraints
130
+ checks = [
131
+ (self._constraints['width'] is None or width == self._constraints['width']),
132
+ (self._constraints['height'] is None or height == self._constraints['height']),
133
+ width >= self._constraints['min_width'],
134
+ height >= self._constraints['min_height'],
135
+ width <= self._constraints['max_width'],
136
+ height <= self._constraints['max_height'],
137
+ self._check_ratio(self._constraints['ratio'], actual_ratio)
138
+ ]
139
+
140
+ return all(checks)
141
+
85
142
  except Exception as e:
86
- print(f"Unexpected error in dimension validation: {str(e)}")
143
+ print(f"Dimension validation error: {str(e)}")
87
144
  return False
88
-
145
+
146
+ def _load_image(self, value):
147
+ """Helper to load image from different sources"""
148
+ try:
149
+ from PIL import Image
150
+ import io
151
+
152
+ if hasattr(value, 'read'): # File-like object
153
+ value.seek(0)
154
+ img = Image.open(value)
155
+ value.seek(0)
156
+ return img
157
+ elif isinstance(value, bytes): # Bytes
158
+ return Image.open(io.BytesIO(value))
159
+ elif isinstance(value, str): # File path
160
+ if value.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
161
+ return Image.open(value)
162
+ return None
163
+ except:
164
+ return None
165
+
166
+ def _check_ratio(self, ratio_constraint, actual_ratio):
167
+ """Validate aspect ratio"""
168
+ if not ratio_constraint:
169
+ return True
170
+
171
+ try:
172
+ numerator, denominator = map(float, ratio_constraint.split('/'))
173
+ expected_ratio = round(numerator / denominator, 2)
174
+ return actual_ratio == expected_ratio
175
+ except:
176
+ return False
177
+
89
178
  def message(self, field: str, params: List[str]) -> str:
90
- min_rules = [p for p in params if p.startswith('min:')]
91
- max_rules = [p for p in params if p.startswith('max:')]
92
- exact_rules = [p for p in params if ':' not in p]
179
+ constraints = []
93
180
 
94
- messages = []
95
- if exact_rules:
96
- messages.append(f"exactly {exact_rules[0]}")
97
- if min_rules:
98
- messages.append(f"minimum {min_rules[0][4:]}")
99
- if max_rules:
100
- messages.append(f"maximum {max_rules[0][4:]}")
101
-
102
- return f"Image {field} dimensions must be {' and '.join(messages)}"
181
+ if self._constraints['width'] is not None:
182
+ constraints.append(f"width={self._constraints['width']}")
183
+ if self._constraints['height'] is not None:
184
+ constraints.append(f"height={self._constraints['height']}")
185
+ if self._constraints['min_width'] > 0:
186
+ constraints.append(f"min_width={self._constraints['min_width']}")
187
+ if self._constraints['max_width'] < float('inf'):
188
+ constraints.append(f"max_width={self._constraints['max_width']}")
189
+ if self._constraints['min_height'] > 0:
190
+ constraints.append(f"min_height={self._constraints['min_height']}")
191
+ if self._constraints['max_height'] < float('inf'):
192
+ constraints.append(f"max_height={self._constraints['max_height']}")
193
+ if self._constraints['ratio'] is not None:
194
+ constraints.append(f"ratio={self._constraints['ratio']:.2f}")
195
+
196
+ return f"The :attribute image must satisfy: {', '.join(constraints)}"
103
197
 
104
- class ExtensionsRule(ValidationRule):
198
+ class ExtensionsRule(Rule):
105
199
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
106
200
  if not params:
107
201
  return False
@@ -118,9 +212,9 @@ class ExtensionsRule(ValidationRule):
118
212
  return ext in [e.lower().strip() for e in params]
119
213
 
120
214
  def message(self, field: str, params: List[str]) -> str:
121
- return f"File {field} must have one of these extensions: {', '.join(params)}"
215
+ return f"File :attribute must have one of these extensions: {', '.join(params)}"
122
216
 
123
- class ImageRule(ValidationRule):
217
+ class ImageRule(Rule):
124
218
  VALID_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'}
125
219
  VALID_MIME_TYPES = {
126
220
  'image/jpeg', 'image/png',
@@ -146,9 +240,9 @@ class ImageRule(ValidationRule):
146
240
  return True
147
241
 
148
242
  def message(self, field: str, params: List[str]) -> str:
149
- return f"The {field} must be a valid image file (JPEG, PNG, GIF, BMP, or WebP)"
243
+ return f"The :attribute must be a valid image file (JPEG, PNG, GIF, BMP, or WebP)"
150
244
 
151
- class MimeTypesRule(ValidationRule):
245
+ class MimeTypesRule(Rule):
152
246
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
153
247
  if not params:
154
248
  return False
@@ -164,4 +258,22 @@ class MimeTypesRule(ValidationRule):
164
258
  return False
165
259
 
166
260
  def message(self, field: str, params: List[str]) -> str:
167
- return f"File {field} must be one of these types: {', '.join(params)}"
261
+ return f"File :attribute must be one of these types: {', '.join(params)}"
262
+
263
+ class MimeTypeByExtensionRule(Rule):
264
+ _name = 'mimes'
265
+
266
+ def validate(self, field: str, value: Any, params: List[str]) -> bool:
267
+ if value is None or not hasattr(value, 'filename'):
268
+ return False
269
+
270
+ mimetypes.init()
271
+
272
+ extension = value.filename.split('.')[-1].lower()
273
+
274
+ mime_type = mimetypes.guess_type(f"file.{extension}")[0]
275
+
276
+ return mime_type in params if mime_type else False
277
+
278
+ def message(self, field: str, params: List[str]) -> str:
279
+ return f"The :attribute must be one of these types: {', '.join(params)}"
validator/rules/format.py CHANGED
@@ -1,25 +1,129 @@
1
- from .base import ValidationRule
1
+ from .base import Rule
2
2
  from typing import Any, Dict, List, Optional, Set, Union, Tuple, Type
3
3
  import re
4
- import zoneinfo
5
4
  import ipaddress
6
5
  import json
7
6
  import uuid
7
+ import dns.resolver
8
+ import idna
8
9
 
9
10
  # =============================================
10
11
  # FORMAT VALIDATION RULES
11
12
  # =============================================
12
13
 
13
- class EmailRule(ValidationRule):
14
+ class EmailRule(Rule):
14
15
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
16
+ """
17
+ Available params:
18
+ - rfc: RFCValidation (default)
19
+ - strict: NoRFCWarningsValidation
20
+ - dns: DNSCheckValidation
21
+ - spoof: SpoofCheckValidation
22
+ - filter: FilterEmailValidation
23
+ - filter_unicode: FilterEmailValidation::unicode()
24
+ """
25
+
15
26
  if not isinstance(value, str):
16
27
  return False
17
- return bool(re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", value))
18
-
28
+
29
+ # Default to RFC validation if no params specified
30
+ validation_types = params if params else ['rfc']
31
+
32
+ # Apply all requested validations
33
+ for validation in validation_types:
34
+ if validation == 'rfc' and not self._validate_rfc(value):
35
+ return False
36
+ elif validation == 'strict' and not self._validate_strict(value):
37
+ return False
38
+ elif validation == 'dns' and not self._validate_dns(value):
39
+ return False
40
+ elif validation == 'spoof' and not self._validate_spoof(value):
41
+ return False
42
+ elif validation == 'filter' and not self._validate_filter(value):
43
+ return False
44
+ elif validation == 'filter_unicode' and not self._validate_filter_unicode(value):
45
+ return False
46
+
47
+ return True
48
+
49
+ def _validate_rfc(self, email: str) -> bool:
50
+ """Strict RFC-compliant email validation (single-line version)"""
51
+ return bool(re.match(
52
+ r"^(?!(\.|\.\.))[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@(?=.{1,255}$)[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$",
53
+ email
54
+ ))
55
+
56
+ def _validate_strict(self, email: str) -> bool:
57
+ """Strict RFC validation (no warnings)"""
58
+ if not self._validate_rfc(email):
59
+ return False
60
+
61
+ # No leading/trailing dots
62
+ if email.startswith('.') or email.endswith('.'):
63
+ return False
64
+
65
+ # No consecutive dots
66
+ if '..' in email.split('@')[0]:
67
+ return False
68
+
69
+ return True
70
+
71
+ def _validate_dns(self, email: str) -> bool:
72
+ """DNS MX record validation"""
73
+ try:
74
+ domain = email.split('@')[-1]
75
+ return bool(dns.resolver.resolve(domain, 'MX'))
76
+ except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN,
77
+ dns.resolver.NoNameservers, dns.exception.DNSException):
78
+ return False
79
+
80
+ def _validate_spoof(self, email: str) -> bool:
81
+ """Spoof/homograph detection"""
82
+ try:
83
+ domain_part = email.split('@')[-1]
84
+ ascii_domain = idna.encode(domain_part).decode('ascii')
85
+
86
+ # Check for homographs
87
+ if domain_part != ascii_domain:
88
+ return False
89
+
90
+ # Check for deceptive characters
91
+ return not any(ord(char) > 127 for char in email)
92
+ except idna.IDNAError:
93
+ return False
94
+
95
+ def _validate_filter(self, email: str) -> bool:
96
+ """PHP filter_var compatible validation"""
97
+ return bool(re.match(
98
+ r"^(?!(\.|\.\.))[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$",
99
+ email
100
+ ))
101
+
102
+ def _validate_filter_unicode(self, email: str) -> bool:
103
+ """PHP filter_var with Unicode support"""
104
+ return bool(re.match(
105
+ r"^(?!(\.|\.\.))[\w.!#$%&'*+/=?^_`{|}~-]+@[\w-]+(?:\.[\w-]+)*$",
106
+ email,
107
+ re.UNICODE
108
+ ))
109
+
19
110
  def message(self, field: str, params: List[str]) -> str:
20
- return f"The :name must be a valid email address."
111
+ base_msg = "The :attribute must be a valid email address"
112
+
113
+ if 'strict' in params:
114
+ base_msg += " (strict RFC compliance)"
115
+ if 'dns' in params:
116
+ base_msg += " with valid DNS records"
117
+ if 'spoof' in params:
118
+ base_msg += " without spoofed characters"
119
+ if 'filter' in params:
120
+ base_msg += " (PHP filter compatible)"
121
+ if 'filter_unicode' in params:
122
+ base_msg += " (Unicode allowed)"
123
+
124
+ return f"{base_msg}."
21
125
 
22
- class UrlRule(ValidationRule):
126
+ class UrlRule(Rule):
23
127
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
24
128
  if not isinstance(value, str):
25
129
  return False
@@ -32,9 +136,9 @@ class UrlRule(ValidationRule):
32
136
  ))
33
137
 
34
138
  def message(self, field: str, params: List[str]) -> str:
35
- return f"The :name must be a valid URL."
139
+ return f"The :attribute must be a valid URL."
36
140
 
37
- class JsonRule(ValidationRule):
141
+ class JsonRule(Rule):
38
142
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
39
143
  if not isinstance(value, str):
40
144
  return False
@@ -45,9 +149,9 @@ class JsonRule(ValidationRule):
45
149
  return False
46
150
 
47
151
  def message(self, field: str, params: List[str]) -> str:
48
- return f"The :name must be a valid JSON string."
152
+ return f"The :attribute must be a valid JSON string."
49
153
 
50
- class UuidRule(ValidationRule):
154
+ class UuidRule(Rule):
51
155
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
52
156
  if not isinstance(value, str):
53
157
  return False
@@ -58,18 +162,18 @@ class UuidRule(ValidationRule):
58
162
  return False
59
163
 
60
164
  def message(self, field: str, params: List[str]) -> str:
61
- return f"The :name must be a valid UUID."
165
+ return f"The :attribute must be a valid UUID."
62
166
 
63
- class UlidRule(ValidationRule):
167
+ class UlidRule(Rule):
64
168
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
65
169
  if not isinstance(value, str):
66
170
  return False
67
171
  return bool(re.match(r'^[0-9A-HJKMNP-TV-Z]{26}$', value))
68
172
 
69
173
  def message(self, field: str, params: List[str]) -> str:
70
- return f"The :name must be a valid ULID."
174
+ return f"The :attribute must be a valid ULID."
71
175
 
72
- class IpRule(ValidationRule):
176
+ class IpRule(Rule):
73
177
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
74
178
  if not isinstance(value, str):
75
179
  return False
@@ -80,26 +184,13 @@ class IpRule(ValidationRule):
80
184
  return False
81
185
 
82
186
  def message(self, field: str, params: List[str]) -> str:
83
- return f"The :name must be a valid IP address."
84
-
85
- class TimezoneRule(ValidationRule):
86
- def validate(self, field: str, value: Any, params: List[str]) -> bool:
87
- if not isinstance(value, str):
88
- return False
89
- try:
90
- zoneinfo.ZoneInfo(value)
91
- return True
92
- except zoneinfo.ZoneInfoNotFoundError:
93
- return False
94
-
95
- def message(self, field: str, params: List[str]) -> str:
96
- return f"The :name must be a valid timezone."
187
+ return f"The :attribute must be a valid IP address."
97
188
 
98
- class HexRule(ValidationRule):
189
+ class HexRule(Rule):
99
190
  def validate(self, field: str, value: Any, params: List[str]) -> bool:
100
191
  if not isinstance(value, str):
101
192
  return False
102
193
  return bool(re.match(r'^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', value))
103
194
 
104
195
  def message(self, field: str, params: List[str]) -> str:
105
- return f"The :name must be a valid hexadecimal color code."
196
+ return f"The :attribute must be a valid hexadecimal color code."