Coverage for src/taipanstack/security/validators.py: 100%
170 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +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 _validate_type(
41 value: object,
42 expected_type: type | tuple[type, ...],
43 name: str,
44) -> None:
45 """Validate input type.
47 Args:
48 value: The value to check.
49 expected_type: The expected type(s).
50 name: Name of the variable for the error message.
52 Raises:
53 TypeError: If value is not of the expected type.
55 """
56 # Explicitly reject bools if expecting an int (but not if expecting bool)
57 if expected_type is int and isinstance(value, bool):
58 raise TypeError(f"{name} must be int, got bool")
60 if not isinstance(value, expected_type):
61 type_name = (
62 expected_type.__name__
63 if isinstance(expected_type, type)
64 else " | ".join(t.__name__ for t in expected_type)
65 )
66 msg = f"{name} must be {type_name}, got {type(value).__name__}"
67 raise TypeError(msg)
70def _check_project_name_length(name: str, max_length: int) -> None:
71 """Check project name length.
73 Args:
74 name: The project name.
75 max_length: Maximum allowed length.
77 Raises:
78 ValueError: If length is invalid.
80 """
81 if not name:
82 msg = "Project name cannot be empty"
83 raise ValueError(msg)
85 if len(name) > max_length:
86 msg = f"Project name exceeds maximum length of {max_length}"
87 raise ValueError(msg)
90def _build_project_name_pattern(allow_hyphen: bool, allow_underscore: bool) -> str:
91 """Build the regex pattern for allowed characters."""
92 allowed = r"a-zA-Z0-9"
93 if allow_hyphen:
94 allowed += r"-"
95 if allow_underscore:
96 allowed += r"_"
97 return rf"^[a-zA-Z][{allowed}]*\Z"
100def _build_invalid_chars_msg(allow_hyphen: bool, allow_underscore: bool) -> str:
101 """Build the error message for invalid characters."""
102 hyphen_msg = ", hyphens" if allow_hyphen else ""
103 underscore_msg = ", underscores" if allow_underscore else ""
104 return (
105 f"Project name contains invalid characters. "
106 f"Allowed: letters, numbers{hyphen_msg}{underscore_msg}"
107 )
110def _check_project_name_chars(
111 name: str,
112 allow_hyphen: bool,
113 allow_underscore: bool,
114) -> None:
115 """Check project name characters.
117 Args:
118 name: The project name.
119 allow_hyphen: Whether to allow hyphens.
120 allow_underscore: Whether to allow underscores.
122 Raises:
123 ValueError: If name contains invalid characters.
125 """
126 pattern = _build_project_name_pattern(allow_hyphen, allow_underscore)
128 if not re.match(pattern, name):
129 if not name or not name[0].isalpha():
130 msg = "Project name must start with a letter"
131 raise ValueError(msg)
132 msg = _build_invalid_chars_msg(allow_hyphen, allow_underscore)
133 raise ValueError(msg)
136def _check_project_name_reserved(name: str) -> None:
137 """Check if project name is reserved.
139 Args:
140 name: The project name.
142 Raises:
143 ValueError: If name is reserved.
145 """
146 if name.lower() in PROJECT_NAME_RESERVED:
147 msg = f"Project name '{name}' is reserved"
148 raise ValueError(msg)
151def validate_project_name(
152 name: str,
153 *,
154 max_length: int = 100,
155 allow_hyphen: bool = True,
156 allow_underscore: bool = True,
157) -> str:
158 """Validate a project name.
160 Args:
161 name: The project name to validate.
162 max_length: Maximum allowed length.
163 allow_hyphen: Allow hyphens in name.
164 allow_underscore: Allow underscores in name.
166 Returns:
167 The validated project name.
169 Raises:
170 ValueError: If the name is invalid.
172 Example:
173 >>> validate_project_name("my_project")
174 'my_project'
175 >>> validate_project_name("123project")
176 ValueError: Project name must start with a letter
178 """
179 _validate_type(name, str, "Project name")
180 _validate_type(max_length, int, "max_length")
181 _validate_type(allow_hyphen, bool, "allow_hyphen")
182 _validate_type(allow_underscore, bool, "allow_underscore")
183 _check_project_name_length(name, max_length)
184 _check_project_name_chars(name, allow_hyphen, allow_underscore)
185 _check_project_name_reserved(name)
187 return name
190def _check_version_string_safety(version: str) -> None:
191 """Check the basic formatting and safety of a version string."""
192 # Prevent DoS from massive integer string conversion limit in Python
193 if len(version) > MAX_PYTHON_VERSION_LENGTH:
194 msg = "Version string exceeds maximum length"
195 raise ValueError(msg)
197 if "\x00" in version or not version.isprintable():
198 msg = "Version contains invalid characters"
199 raise ValueError(msg)
202def _check_version_regex_pattern(version: str) -> None:
203 """Check the regex pattern of a version string."""
204 if not version.isascii():
205 msg = f"Invalid version format: '{version}'. Use 'X.Y' format (e.g., '3.12')"
206 raise ValueError(msg)
208 pattern = r"^\d+\.\d+\Z"
210 if not re.match(pattern, version):
211 msg = f"Invalid version format: '{version}'. Use 'X.Y' format (e.g., '3.12')"
212 raise ValueError(msg)
215def _check_version_format(version: str) -> None:
216 """Check the basic formatting and safety of a version string."""
217 _check_version_string_safety(version)
218 _check_version_regex_pattern(version)
221def _check_version_numbers(version: str) -> None:
222 """Check the major and minor version numbers."""
223 try:
224 major, minor = map(int, version.split("."))
225 except ValueError as e:
226 msg = f"Invalid version numbers in '{version}'"
227 raise ValueError(msg) from e
229 if major != PYTHON_MAJOR_VERSION:
230 msg = f"Only Python 3.x is supported, got {major}.x"
231 raise ValueError(msg)
233 if minor < MIN_PYTHON_MINOR_VERSION:
234 msg = (
235 f"Python 3.{minor} is not supported. "
236 f"Minimum is 3.{MIN_PYTHON_MINOR_VERSION}"
237 )
238 raise ValueError(msg)
241def validate_python_version(version: str) -> str:
242 """Validate Python version string.
244 Args:
245 version: Version string like "3.12" or "3.10".
247 Returns:
248 The validated version string.
250 Raises:
251 ValueError: If version format is invalid or unsupported.
253 """
254 _validate_type(version, str, "Version")
255 _check_version_format(version)
256 _check_version_numbers(version)
257 return version
260def _check_email_basics(email: str) -> None:
261 """Check basic email constraints like empty, length and invalid characters."""
262 if not email:
263 msg = "Email cannot be empty"
264 raise ValueError(msg)
266 if len(email) > MAX_EMAIL_LOCAL_LENGTH + 1 + MAX_EMAIL_DOMAIN_LENGTH:
267 msg = "Email length exceeds maximum allowed"
268 raise ValueError(msg)
270 if "\x00" in email or not email.isprintable():
271 msg = "Email contains invalid characters"
272 raise ValueError(msg)
275def _check_email_format(email: str) -> None:
276 """Check email format and basic constraints."""
277 _check_email_basics(email)
279 # RFC 5322 compliant pattern (simplified)
280 pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\Z"
282 if not re.match(pattern, email):
283 msg = f"Invalid email format: {email}"
284 raise ValueError(msg)
287def _check_email_parts(email: str) -> None:
288 """Check local and domain parts of the email."""
289 local, domain = email.rsplit("@", 1)
291 if len(local) > MAX_EMAIL_LOCAL_LENGTH:
292 msg = f"Email local part exceeds {MAX_EMAIL_LOCAL_LENGTH} characters"
293 raise ValueError(msg)
295 if len(domain) > MAX_EMAIL_DOMAIN_LENGTH:
296 msg = f"Email domain exceeds {MAX_EMAIL_DOMAIN_LENGTH} characters"
297 raise ValueError(msg)
300def validate_email(email: str) -> str:
301 """Validate email address format.
303 Uses a reasonable regex pattern that covers most valid emails
304 without being overly strict.
306 Args:
307 email: The email address to validate.
309 Returns:
310 The validated email address.
312 Raises:
313 ValueError: If email format is invalid.
315 """
316 _validate_type(email, str, "Email")
317 _check_email_format(email)
318 _check_email_parts(email)
319 return email
322def _check_url_length(url: str) -> None:
323 """Check URL length constraints."""
324 if not url:
325 msg = "URL cannot be empty"
326 raise ValueError(msg)
328 if len(url) > MAX_URL_LENGTH:
329 msg = f"URL length exceeds maximum allowed length of {MAX_URL_LENGTH}"
330 raise ValueError(msg)
333def _has_invalid_url_chars(url: str) -> bool:
334 if any(c <= "\x20" or c == "\x7f" for c in url):
335 return True
336 return bool("\x00" in url or not url.isprintable())
339def _check_url_characters(url: str) -> None:
340 """Check URL character constraints."""
341 if _has_invalid_url_chars(url) or _has_invalid_url_chars(unquote(url)):
342 msg = "URL contains invalid characters"
343 raise ValueError(msg)
346def _check_url_basics(url: str) -> None:
347 """Check basic URL constraints like empty, length and invalid characters."""
348 _validate_type(url, str, "URL")
349 _check_url_length(url)
350 _check_url_characters(url)
353def _check_scheme(
354 parsed: SplitResult,
355 allowed_schemes: tuple[str, ...],
356) -> None:
357 """Validate the URL scheme."""
358 if not parsed.scheme:
359 msg = "URL must have a scheme (e.g., https://)"
360 raise ValueError(msg)
362 if parsed.scheme not in allowed_schemes:
363 msg = f"URL scheme '{parsed.scheme}' is not allowed. Allowed: {allowed_schemes}"
364 raise ValueError(msg)
367def _check_tld(domain: str) -> None:
368 """Validate that the domain has a TLD."""
369 has_no_tld = "." not in domain or domain.endswith(".")
370 is_localhost = domain.lower() in LOCALHOST_DOMAINS
371 if has_no_tld and not is_localhost:
372 msg = f"URL domain must have a TLD: {domain}"
373 raise ValueError(msg)
376def _check_url_domain(
377 parsed: SplitResult,
378 allowed_schemes: tuple[str, ...],
379 require_tld: bool,
380) -> None:
381 """Validate URL scheme and domain."""
382 _check_scheme(parsed, allowed_schemes)
384 if not parsed.hostname:
385 msg = "URL must have a domain"
386 raise ValueError(msg)
388 if require_tld:
389 _check_tld(parsed.hostname)
392def validate_url(
393 url: str,
394 *,
395 allowed_schemes: tuple[str, ...] = ("http", "https"),
396 require_tld: bool = True,
397) -> str:
398 """Validate URL format and scheme.
400 Args:
401 url: The URL to validate.
402 allowed_schemes: Tuple of allowed URL schemes.
403 require_tld: Whether to require a TLD in the domain.
405 Returns:
406 The validated URL.
408 Raises:
409 ValueError: If URL format is invalid.
411 """
412 _check_url_basics(url)
414 try:
415 parsed = urlsplit(url)
416 _ = parsed.port
417 except ValueError as e:
418 msg = f"Invalid URL format: {e}"
419 raise ValueError(msg) from e
421 _check_url_domain(parsed, allowed_schemes, require_tld)
423 return url