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

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 _validate_type( 

41 value: object, 

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

43 name: str, 

44) -> None: 

45 """Validate input type. 

46 

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. 

51 

52 Raises: 

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

54 

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

59 

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) 

68 

69 

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

71 """Check project name length. 

72 

73 Args: 

74 name: The project name. 

75 max_length: Maximum allowed length. 

76 

77 Raises: 

78 ValueError: If length is invalid. 

79 

80 """ 

81 if not name: 

82 msg = "Project name cannot be empty" 

83 raise ValueError(msg) 

84 

85 if len(name) > max_length: 

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

87 raise ValueError(msg) 

88 

89 

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" 

98 

99 

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 ) 

108 

109 

110def _check_project_name_chars( 

111 name: str, 

112 allow_hyphen: bool, 

113 allow_underscore: bool, 

114) -> None: 

115 """Check project name characters. 

116 

117 Args: 

118 name: The project name. 

119 allow_hyphen: Whether to allow hyphens. 

120 allow_underscore: Whether to allow underscores. 

121 

122 Raises: 

123 ValueError: If name contains invalid characters. 

124 

125 """ 

126 pattern = _build_project_name_pattern(allow_hyphen, allow_underscore) 

127 

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) 

134 

135 

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

137 """Check if project name is reserved. 

138 

139 Args: 

140 name: The project name. 

141 

142 Raises: 

143 ValueError: If name is reserved. 

144 

145 """ 

146 if name.lower() in PROJECT_NAME_RESERVED: 

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

148 raise ValueError(msg) 

149 

150 

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. 

159 

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. 

165 

166 Returns: 

167 The validated project name. 

168 

169 Raises: 

170 ValueError: If the name is invalid. 

171 

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 

177 

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) 

186 

187 return name 

188 

189 

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) 

196 

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

198 msg = "Version contains invalid characters" 

199 raise ValueError(msg) 

200 

201 

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) 

207 

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

209 

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) 

213 

214 

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) 

219 

220 

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 

228 

229 if major != PYTHON_MAJOR_VERSION: 

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

231 raise ValueError(msg) 

232 

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) 

239 

240 

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

242 """Validate Python version string. 

243 

244 Args: 

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

246 

247 Returns: 

248 The validated version string. 

249 

250 Raises: 

251 ValueError: If version format is invalid or unsupported. 

252 

253 """ 

254 _validate_type(version, str, "Version") 

255 _check_version_format(version) 

256 _check_version_numbers(version) 

257 return version 

258 

259 

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) 

265 

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

267 msg = "Email length exceeds maximum allowed" 

268 raise ValueError(msg) 

269 

270 if "\x00" in email or not email.isprintable(): 

271 msg = "Email contains invalid characters" 

272 raise ValueError(msg) 

273 

274 

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

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

277 _check_email_basics(email) 

278 

279 # RFC 5322 compliant pattern (simplified) 

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

281 

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

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

284 raise ValueError(msg) 

285 

286 

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

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

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

290 

291 if len(local) > MAX_EMAIL_LOCAL_LENGTH: 

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

293 raise ValueError(msg) 

294 

295 if len(domain) > MAX_EMAIL_DOMAIN_LENGTH: 

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

297 raise ValueError(msg) 

298 

299 

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

301 """Validate email address format. 

302 

303 Uses a reasonable regex pattern that covers most valid emails 

304 without being overly strict. 

305 

306 Args: 

307 email: The email address to validate. 

308 

309 Returns: 

310 The validated email address. 

311 

312 Raises: 

313 ValueError: If email format is invalid. 

314 

315 """ 

316 _validate_type(email, str, "Email") 

317 _check_email_format(email) 

318 _check_email_parts(email) 

319 return email 

320 

321 

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) 

327 

328 if len(url) > MAX_URL_LENGTH: 

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

330 raise ValueError(msg) 

331 

332 

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

337 

338 

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) 

344 

345 

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) 

351 

352 

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) 

361 

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) 

365 

366 

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) 

374 

375 

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) 

383 

384 if not parsed.hostname: 

385 msg = "URL must have a domain" 

386 raise ValueError(msg) 

387 

388 if require_tld: 

389 _check_tld(parsed.hostname) 

390 

391 

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. 

399 

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. 

404 

405 Returns: 

406 The validated URL. 

407 

408 Raises: 

409 ValueError: If URL format is invalid. 

410 

411 """ 

412 _check_url_basics(url) 

413 

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 

420 

421 _check_url_domain(parsed, allowed_schemes, require_tld) 

422 

423 return url