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

1""" 

2Input validators for type-safe validation. 

3 

4Provides validation functions for common input types like email, 

5project names, URLs, etc. All validators raise ValueError on invalid input. 

6""" 

7 

8import re 

9from urllib.parse import SplitResult, unquote, urlsplit 

10 

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) 

38 

39 

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) 

45 

46 

47def _validate_type( 

48 value: object, 

49 expected_type: type | tuple[type, ...], 

50 name: str, 

51) -> None: 

52 """Validate input type. 

53 

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. 

58 

59 Raises: 

60 TypeError: If value is not of the expected type. 

61 

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") 

66 

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) 

71 

72 

73def _check_project_name_length(name: str, max_length: int) -> None: 

74 """Check project name length. 

75 

76 Args: 

77 name: The project name. 

78 max_length: Maximum allowed length. 

79 

80 Raises: 

81 ValueError: If length is invalid. 

82 

83 """ 

84 if not name: 

85 msg = "Project name cannot be empty" 

86 raise ValueError(msg) 

87 

88 if len(name) > max_length: 

89 msg = f"Project name exceeds maximum length of {max_length}" 

90 raise ValueError(msg) 

91 

92 

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" 

101 

102 

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 ) 

111 

112 

113def _check_project_name_chars( 

114 name: str, 

115 allow_hyphen: bool, 

116 allow_underscore: bool, 

117) -> None: 

118 """Check project name characters. 

119 

120 Args: 

121 name: The project name. 

122 allow_hyphen: Whether to allow hyphens. 

123 allow_underscore: Whether to allow underscores. 

124 

125 Raises: 

126 ValueError: If name contains invalid characters. 

127 

128 """ 

129 pattern = _build_project_name_pattern(allow_hyphen, allow_underscore) 

130 

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) 

137 

138 

139def _check_project_name_reserved(name: str) -> None: 

140 """Check if project name is reserved. 

141 

142 Args: 

143 name: The project name. 

144 

145 Raises: 

146 ValueError: If name is reserved. 

147 

148 """ 

149 if name.lower() in PROJECT_NAME_RESERVED: 

150 msg = f"Project name '{name}' is reserved" 

151 raise ValueError(msg) 

152 

153 

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. 

162 

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. 

168 

169 Returns: 

170 The validated project name. 

171 

172 Raises: 

173 ValueError: If the name is invalid. 

174 

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 

180 

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) 

189 

190 return name 

191 

192 

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) 

199 

200 if "\x00" in version or not version.isprintable(): 

201 msg = "Version contains invalid characters" 

202 raise ValueError(msg) 

203 

204 

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) 

210 

211 pattern = r"^\d+\.\d+\Z" 

212 

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) 

216 

217 

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) 

222 

223 

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 

231 

232 if major != PYTHON_MAJOR_VERSION: 

233 msg = f"Only Python 3.x is supported, got {major}.x" 

234 raise ValueError(msg) 

235 

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) 

242 

243 

244def validate_python_version(version: str) -> str: 

245 """Validate Python version string. 

246 

247 Args: 

248 version: Version string like "3.12" or "3.10". 

249 

250 Returns: 

251 The validated version string. 

252 

253 Raises: 

254 ValueError: If version format is invalid or unsupported. 

255 

256 """ 

257 _validate_type(version, str, "Version") 

258 _check_version_format(version) 

259 _check_version_numbers(version) 

260 return version 

261 

262 

263_CONTROL_CHARS_RE = re.compile(r"[\x00-\x20\x7f]") 

264 

265 

266def _has_control_chars(text: str) -> bool: 

267 """Check if string contains control characters.""" 

268 return bool(_CONTROL_CHARS_RE.search(text)) 

269 

270 

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) 

278 

279 

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) 

285 

286 if len(email) > MAX_EMAIL_LOCAL_LENGTH + 1 + MAX_EMAIL_DOMAIN_LENGTH: 

287 msg = "Email length exceeds maximum allowed" 

288 raise ValueError(msg) 

289 

290 if _has_invalid_chars(email): 

291 msg = "Email contains invalid characters" 

292 raise ValueError(msg) 

293 

294 

295def _check_email_format(email: str) -> None: 

296 """Check email format and basic constraints.""" 

297 _check_email_basics(email) 

298 

299 # RFC 5322 compliant pattern (simplified) 

300 pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\Z" 

301 

302 if not re.match(pattern, email): 

303 msg = f"Invalid email format: {email}" 

304 raise ValueError(msg) 

305 

306 

307def _check_email_parts(email: str) -> None: 

308 """Check local and domain parts of the email.""" 

309 local, domain = email.rsplit("@", 1) 

310 

311 if len(local) > MAX_EMAIL_LOCAL_LENGTH: 

312 msg = f"Email local part exceeds {MAX_EMAIL_LOCAL_LENGTH} characters" 

313 raise ValueError(msg) 

314 

315 if len(domain) > MAX_EMAIL_DOMAIN_LENGTH: 

316 msg = f"Email domain exceeds {MAX_EMAIL_DOMAIN_LENGTH} characters" 

317 raise ValueError(msg) 

318 

319 

320def validate_email(email: str) -> str: 

321 """Validate email address format. 

322 

323 Uses a reasonable regex pattern that covers most valid emails 

324 without being overly strict. 

325 

326 Args: 

327 email: The email address to validate. 

328 

329 Returns: 

330 The validated email address. 

331 

332 Raises: 

333 ValueError: If email format is invalid. 

334 

335 """ 

336 _validate_type(email, str, "Email") 

337 _check_email_format(email) 

338 _check_email_parts(email) 

339 return email 

340 

341 

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) 

347 

348 if len(url) > MAX_URL_LENGTH: 

349 msg = f"URL length exceeds maximum allowed length of {MAX_URL_LENGTH}" 

350 raise ValueError(msg) 

351 

352 

353def _has_invalid_url_chars(url: str) -> bool: 

354 return _has_invalid_chars(url) 

355 

356 

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) 

367 

368 

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) 

374 

375 

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) 

381 

382 

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) 

391 

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) 

395 

396 

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) 

404 

405 

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) 

413 

414 if not parsed.hostname: 

415 msg = "URL must have a domain" 

416 raise ValueError(msg) 

417 

418 if require_tld: 

419 _check_tld(parsed.hostname) 

420 

421 

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. 

429 

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. 

434 

435 Returns: 

436 The validated URL. 

437 

438 Raises: 

439 ValueError: If URL format is invalid. 

440 

441 """ 

442 _check_url_basics(url) 

443 

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 

450 

451 _check_url_domain(parsed, allowed_schemes, require_tld) 

452 

453 return url