File size: 5,120 Bytes
bfcc872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"""Input validation with proper error raising."""
import re
from typing import Optional
from .errors import ValidationError


def validate_grant_id(gid: str) -> str:
    """
    Validate and normalize grant ID.

    Args:
        gid: Grant ID in any format ("2315", "competition-2315", etc.)

    Returns:
        Normalized ID (just the numeric part without prefix)

    Raises:
        ValidationError: If ID format is invalid

    Example:
        >>> validate_grant_id("2315")
        '2315'
        >>> validate_grant_id("competition-2315")
        '2315'
        >>> validate_grant_id("invalid")
        ValidationError: Invalid grant ID format: 'invalid'
    """
    if not gid:
        raise ValidationError("Grant ID cannot be empty")

    gid = str(gid).strip()

    # Extract numeric part
    match = re.match(r'^(?:comp(?:etition)?-|grant-)?(\d{3,7})$', gid, re.I)
    if not match:
        raise ValidationError(
            f"Invalid grant ID format: '{gid}'. "
            f"Expected: '2315' or 'competition-2315'"
        )

    numeric_id = match.group(1)
    return numeric_id


def validate_url(url: str, *, allowed_hosts: Optional[set] = None) -> str:
    """
    Validate URL and check against allowlist.

    Args:
        url: URL to validate
        allowed_hosts: Optional set of allowed hostnames

    Returns:
        Validated URL (unchanged)

    Raises:
        ValidationError: If URL is invalid or not allowed
    """
    from urllib.parse import urlparse

    if not url:
        raise ValidationError("URL cannot be empty")

    url = str(url).strip()

    if not url.startswith(('http://', 'https://')):
        raise ValidationError(
            f"URL must start with http:// or https://: {url}"
        )

    try:
        parsed = urlparse(url)
    except Exception as e:
        raise ValidationError(f"Malformed URL: {url}") from e

    if not parsed.netloc:
        raise ValidationError(f"URL has no hostname: {url}")

    if allowed_hosts and parsed.netloc not in allowed_hosts:
        allowed_preview = ', '.join(list(allowed_hosts)[:3])
        raise ValidationError(
            f"URL host '{parsed.netloc}' not in allowlist. "
            f"Allowed: {allowed_preview}..."
        )

    return url


def sanitize_filename(name: str, max_length: int = 200) -> str:
    """
    Sanitize filename to prevent path traversal.

    Args:
        name: Original filename
        max_length: Maximum length

    Returns:
        Safe filename

    Example:
        >>> sanitize_filename("../../../etc/passwd")
        'etc_passwd'
    """
    if not name:
        raise ValidationError("Filename cannot be empty")

    # Remove path separators and dangerous chars
    safe = re.sub(r'[^\w\-.]', '_', str(name))
    safe = safe.strip('._')

    if not safe:
        raise ValidationError(f"Filename '{name}' produces empty result after sanitization")

    return safe[:max_length]


def validate_search_query(query: str, max_length: int = 500) -> str:
    """
    Validate search query.

    Args:
        query: Search query string
        max_length: Maximum allowed length

    Returns:
        Validated query (stripped)

    Raises:
        ValidationError: If query is empty or too long
    """
    if not query:
        raise ValidationError("Search query cannot be empty")

    query = str(query).strip()

    if not query:
        raise ValidationError("Search query cannot be whitespace only")

    if len(query) > max_length:
        raise ValidationError(
            f"Search query too long ({len(query)} chars, max {max_length})"
        )

    return query


def validate_positive_int(value: any, name: str = "value") -> int:
    """
    Validate positive integer.

    Args:
        value: Value to validate
        name: Name of parameter (for error messages)

    Returns:
        Validated integer

    Raises:
        ValidationError: If not a positive integer
    """
    try:
        val = int(value)
    except (TypeError, ValueError) as e:
        raise ValidationError(f"{name} must be an integer, got {type(value).__name__}") from e

    if val <= 0:
        raise ValidationError(f"{name} must be positive, got {val}")

    return val


def validate_date_string(date_str: str, name: str = "date") -> str:
    """
    Validate ISO date string (YYYY-MM-DD).

    Args:
        date_str: Date string to validate
        name: Name of parameter (for error messages)

    Returns:
        Validated date string

    Raises:
        ValidationError: If not valid ISO date format
    """
    if not date_str:
        raise ValidationError(f"{name} cannot be empty")

    date_str = str(date_str).strip()

    # Check format
    if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
        raise ValidationError(
            f"{name} must be in YYYY-MM-DD format, got: {date_str}"
        )

    # Validate actual date (catches invalid like 2025-13-45)
    try:
        from datetime import datetime
        datetime.strptime(date_str, '%Y-%m-%d')
    except ValueError as e:
        raise ValidationError(f"Invalid date: {date_str}") from e

    return date_str