Coverage for src/taipanstack/security/validators.py: 100%
190 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
1"""
2Input validators for type-safe validation.
4Provides validation functions for common input types like email,
5project names, URLs, etc. All validators raise ValueError on invalid input.
6"""
8import re
9from urllib.parse import SplitResult, unquote, urlsplit
11# Constants to avoid magic values (PLR2004)
12PYTHON_MAJOR_VERSION = 3
13MIN_PYTHON_MINOR_VERSION = 10
14MAX_PYTHON_VERSION_LENGTH = 20
15MAX_EMAIL_LOCAL_LENGTH = 64
16MAX_EMAIL_DOMAIN_LENGTH = 255
17MAX_URL_LENGTH = 2048
18MAX_ENV_VAR_LENGTH = 256
19LOCALHOST_DOMAINS = ("localhost", "127.0.0.1", "::1")
20PROJECT_NAME_RESERVED = frozenset(
21 {
22 "test",
23 "tests",
24 "src",
25 "lib",
26 "bin",
27 "build",
28 "dist",
29 "setup",
30 "config",
31 "settings",
32 "core",
33 "main",
34 "app",
35 "site-packages",
36 },
37)
40def _get_type_name(expected_type: type | tuple[type, ...]) -> str:
41 """Get the string representation of expected type(s)."""
42 if isinstance(expected_type, type): # type: ignore[misc]
43 return expected_type.__name__
44 return " | ".join(t.__name__ for t in expected_type)
47def _validate_type(
48 value: object,
49 expected_type: type | tuple[type, ...],
50 name: str,
51) -> None:
52 """Validate input type.
54 Args:
55 value: The value to check.
56 expected_type: The expected type(s).
57 name: Name of the variable for the error message.
59 Raises:
60 TypeError: If value is not of the expected type.
62 """
63 # Explicitly reject bools if expecting an int (but not if expecting bool)
64 if expected_type is int and isinstance(value, bool):
65 raise TypeError(f"{name} must be int, got bool")
67 if not isinstance(value, expected_type):
68 type_name = _get_type_name(expected_type)
69 msg = f"{name} must be {type_name}, got {type(value).__name__}"
70 raise TypeError(msg)
73def _check_project_name_length(name: str, max_length: int) -> None:
74 """Check project name length.
76 Args:
77 name: The project name.
78 max_length: Maximum allowed length.
80 Raises:
81 ValueError: If length is invalid.
83 """
84 if not name:
85 msg = "Project name cannot be empty"
86 raise ValueError(msg)
88 if len(name) > max_length:
89 msg = f"Project name exceeds maximum length of {max_length}"
90 raise ValueError(msg)
93def _build_project_name_pattern(allow_hyphen: bool, allow_underscore: bool) -> str:
94 """Build the regex pattern for allowed characters."""
95 allowed = r"a-zA-Z0-9"
96 if allow_hyphen:
97 allowed += r"-"
98 if allow_underscore:
99 allowed += r"_"
100 return rf"^[a-zA-Z][{allowed}]*\Z"
103def _build_invalid_chars_msg(allow_hyphen: bool, allow_underscore: bool) -> str:
104 """Build the error message for invalid characters."""
105 hyphen_msg = ", hyphens" if allow_hyphen else ""
106 underscore_msg = ", underscores" if allow_underscore else ""
107 return (
108 f"Project name contains invalid characters. "
109 f"Allowed: letters, numbers{hyphen_msg}{underscore_msg}"
110 )
113def _check_project_name_chars(
114 name: str,
115 allow_hyphen: bool,
116 allow_underscore: bool,
117) -> None:
118 """Check project name characters.
120 Args:
121 name: The project name.
122 allow_hyphen: Whether to allow hyphens.
123 allow_underscore: Whether to allow underscores.
125 Raises:
126 ValueError: If name contains invalid characters.
128 """
129 pattern = _build_project_name_pattern(allow_hyphen, allow_underscore)
131 if not re.match(pattern, name):
132 if not name or not name[0].isalpha():
133 msg = "Project name must start with a letter"
134 raise ValueError(msg)
135 msg = _build_invalid_chars_msg(allow_hyphen, allow_underscore)
136 raise ValueError(msg)
139def _check_project_name_reserved(name: str) -> None:
140 """Check if project name is reserved.
142 Args:
143 name: The project name.
145 Raises:
146 ValueError: If name is reserved.
148 """
149 if name.lower() in PROJECT_NAME_RESERVED:
150 msg = f"Project name '{name}' is reserved"
151 raise ValueError(msg)
154def validate_project_name(
155 name: str,
156 *,
157 max_length: int = 100,
158 allow_hyphen: bool = True,
159 allow_underscore: bool = True,
160) -> str:
161 """Validate a project name.
163 Args:
164 name: The project name to validate.
165 max_length: Maximum allowed length.
166 allow_hyphen: Allow hyphens in name.
167 allow_underscore: Allow underscores in name.
169 Returns:
170 The validated project name.
172 Raises:
173 ValueError: If the name is invalid.
175 Example:
176 >>> validate_project_name("my_project")
177 'my_project'
178 >>> validate_project_name("123project")
179 ValueError: Project name must start with a letter
181 """
182 _validate_type(name, str, "Project name")
183 _validate_type(max_length, int, "max_length")
184 _validate_type(allow_hyphen, bool, "allow_hyphen")
185 _validate_type(allow_underscore, bool, "allow_underscore")
186 _check_project_name_length(name, max_length)
187 _check_project_name_chars(name, allow_hyphen, allow_underscore)
188 _check_project_name_reserved(name)
190 return name
193def _check_version_string_safety(version: str) -> None:
194 """Check the basic formatting and safety of a version string."""
195 # Prevent DoS from massive integer string conversion limit in Python
196 if len(version) > MAX_PYTHON_VERSION_LENGTH:
197 msg = "Version string exceeds maximum length"
198 raise ValueError(msg)
200 if "\x00" in version or not version.isprintable():
201 msg = "Version contains invalid characters"
202 raise ValueError(msg)
205def _check_version_regex_pattern(version: str) -> None:
206 """Check the regex pattern of a version string."""
207 if not version.isascii():
208 msg = f"Invalid version format: '{version}'. Use 'X.Y' format (e.g., '3.12')"
209 raise ValueError(msg)
211 pattern = r"^\d+\.\d+\Z"
213 if not re.match(pattern, version):
214 msg = f"Invalid version format: '{version}'. Use 'X.Y' format (e.g., '3.12')"
215 raise ValueError(msg)
218def _check_version_format(version: str) -> None:
219 """Check the basic formatting and safety of a version string."""
220 _check_version_string_safety(version)
221 _check_version_regex_pattern(version)
224def _check_version_numbers(version: str) -> None:
225 """Check the major and minor version numbers."""
226 try:
227 major, minor = map(int, version.split("."))
228 except ValueError as e:
229 msg = f"Invalid version numbers in '{version}'"
230 raise ValueError(msg) from e
232 if major != PYTHON_MAJOR_VERSION:
233 msg = f"Only Python 3.x is supported, got {major}.x"
234 raise ValueError(msg)
236 if minor < MIN_PYTHON_MINOR_VERSION:
237 msg = (
238 f"Python 3.{minor} is not supported. "
239 f"Minimum is 3.{MIN_PYTHON_MINOR_VERSION}"
240 )
241 raise ValueError(msg)
244def validate_python_version(version: str) -> str:
245 """Validate Python version string.
247 Args:
248 version: Version string like "3.12" or "3.10".
250 Returns:
251 The validated version string.
253 Raises:
254 ValueError: If version format is invalid or unsupported.
256 """
257 _validate_type(version, str, "Version")
258 _check_version_format(version)
259 _check_version_numbers(version)
260 return version
263_CONTROL_CHARS_RE = re.compile(r"[\x00-\x20\x7f]")
266def _has_control_chars(text: str) -> bool:
267 """Check if string contains control characters."""
268 return bool(_CONTROL_CHARS_RE.search(text))
271def _has_invalid_chars(text: str) -> bool:
272 """Check if string contains any invalid characters."""
273 if "\x00" in text:
274 return True
275 if not text.isprintable():
276 return True
277 return _has_control_chars(text)
280def _check_email_basics(email: str) -> None:
281 """Check basic email constraints like empty, length and invalid characters."""
282 if not email:
283 msg = "Email cannot be empty"
284 raise ValueError(msg)
286 if len(email) > MAX_EMAIL_LOCAL_LENGTH + 1 + MAX_EMAIL_DOMAIN_LENGTH:
287 msg = "Email length exceeds maximum allowed"
288 raise ValueError(msg)
290 if _has_invalid_chars(email):
291 msg = "Email contains invalid characters"
292 raise ValueError(msg)
295def _check_email_format(email: str) -> None:
296 """Check email format and basic constraints."""
297 _check_email_basics(email)
299 # RFC 5322 compliant pattern (simplified)
300 pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\Z"
302 if not re.match(pattern, email):
303 msg = f"Invalid email format: {email}"
304 raise ValueError(msg)
307def _check_email_parts(email: str) -> None:
308 """Check local and domain parts of the email."""
309 local, domain = email.rsplit("@", 1)
311 if len(local) > MAX_EMAIL_LOCAL_LENGTH:
312 msg = f"Email local part exceeds {MAX_EMAIL_LOCAL_LENGTH} characters"
313 raise ValueError(msg)
315 if len(domain) > MAX_EMAIL_DOMAIN_LENGTH:
316 msg = f"Email domain exceeds {MAX_EMAIL_DOMAIN_LENGTH} characters"
317 raise ValueError(msg)
320def validate_email(email: str) -> str:
321 """Validate email address format.
323 Uses a reasonable regex pattern that covers most valid emails
324 without being overly strict.
326 Args:
327 email: The email address to validate.
329 Returns:
330 The validated email address.
332 Raises:
333 ValueError: If email format is invalid.
335 """
336 _validate_type(email, str, "Email")
337 _check_email_format(email)
338 _check_email_parts(email)
339 return email
342def _check_url_length(url: str) -> None:
343 """Check URL length constraints."""
344 if not url:
345 msg = "URL cannot be empty"
346 raise ValueError(msg)
348 if len(url) > MAX_URL_LENGTH:
349 msg = f"URL length exceeds maximum allowed length of {MAX_URL_LENGTH}"
350 raise ValueError(msg)
353def _has_invalid_url_chars(url: str) -> bool:
354 return _has_invalid_chars(url)
357def _fully_unquote_url(url: str) -> str:
358 """Recursively unquote URL to catch nested invalid characters."""
359 current = url
360 for _ in range(10): # limit to prevent DoS
361 unquoted = unquote(current)
362 if unquoted == current:
363 return current
364 current = unquoted
365 msg = "URL exceeds maximum nested encoding limit"
366 raise ValueError(msg)
369def _check_url_characters(url: str) -> None:
370 """Check URL character constraints."""
371 if _has_invalid_url_chars(url) or _has_invalid_url_chars(_fully_unquote_url(url)):
372 msg = "URL contains invalid characters"
373 raise ValueError(msg)
376def _check_url_basics(url: str) -> None:
377 """Check basic URL constraints like empty, length and invalid characters."""
378 _validate_type(url, str, "URL")
379 _check_url_length(url)
380 _check_url_characters(url)
383def _check_scheme(
384 parsed: SplitResult,
385 allowed_schemes: tuple[str, ...],
386) -> None:
387 """Validate the URL scheme."""
388 if not parsed.scheme:
389 msg = "URL must have a scheme (e.g., https://)"
390 raise ValueError(msg)
392 if parsed.scheme not in allowed_schemes:
393 msg = f"URL scheme '{parsed.scheme}' is not allowed. Allowed: {allowed_schemes}"
394 raise ValueError(msg)
397def _check_tld(domain: str) -> None:
398 """Validate that the domain has a TLD."""
399 has_no_tld = "." not in domain or domain.endswith(".")
400 is_localhost = domain.lower() in LOCALHOST_DOMAINS
401 if has_no_tld and not is_localhost:
402 msg = f"URL domain must have a TLD: {domain}"
403 raise ValueError(msg)
406def _check_url_domain(
407 parsed: SplitResult,
408 allowed_schemes: tuple[str, ...],
409 require_tld: bool,
410) -> None:
411 """Validate URL scheme and domain."""
412 _check_scheme(parsed, allowed_schemes)
414 if not parsed.hostname:
415 msg = "URL must have a domain"
416 raise ValueError(msg)
418 if require_tld:
419 _check_tld(parsed.hostname)
422def validate_url(
423 url: str,
424 *,
425 allowed_schemes: tuple[str, ...] = ("http", "https"),
426 require_tld: bool = True,
427) -> str:
428 """Validate URL format and scheme.
430 Args:
431 url: The URL to validate.
432 allowed_schemes: Tuple of allowed URL schemes.
433 require_tld: Whether to require a TLD in the domain.
435 Returns:
436 The validated URL.
438 Raises:
439 ValueError: If URL format is invalid.
441 """
442 _check_url_basics(url)
444 try:
445 parsed = urlsplit(url)
446 _ = parsed.port
447 except ValueError as e:
448 msg = f"Invalid URL format: {e}"
449 raise ValueError(msg) from e
451 _check_url_domain(parsed, allowed_schemes, require_tld)
453 return url