encode-toolkit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ """Input validation utilities for security.
2
+
3
+ Centralizes all input validation to prevent injection, SSRF, and path traversal.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ # ENCODE accession format: ENC + 2-4 letter type + 3-8 alphanumeric
11
+ ACCESSION_RE = re.compile(r"^ENC[A-Z]{2,4}[A-Z0-9]{3,8}$")
12
+
13
+ # Date format: YYYY-MM-DD
14
+ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
15
+
16
+ # ENCODE API relative path: /type/id/ or /type/
17
+ ENCODE_PATH_RE = re.compile(r"^/[a-zA-Z0-9][a-zA-Z0-9/_@.+-]*/?$")
18
+
19
+ # Lucene special characters that need escaping
20
+ _LUCENE_SPECIAL = re.compile(r'([+\-&|!(){}\[\]^"~*?:\\/])')
21
+
22
+ # Valid organize_by values
23
+ VALID_ORGANIZE_BY = frozenset({"flat", "experiment", "format", "experiment_format"})
24
+
25
+ # Valid export formats
26
+ VALID_EXPORT_FORMATS = frozenset({"json", "bibtex", "ris"})
27
+
28
+ # Allowed download hosts
29
+ ALLOWED_DOWNLOAD_HOSTS = frozenset(
30
+ {
31
+ "www.encodeproject.org",
32
+ "encodeproject.org",
33
+ "encode-public.s3.amazonaws.com",
34
+ }
35
+ )
36
+
37
+ # Allowed redirect hosts (includes S3 regions used by ENCODE)
38
+ ALLOWED_REDIRECT_HOSTS = frozenset(
39
+ {
40
+ "www.encodeproject.org",
41
+ "encodeproject.org",
42
+ "encode-public.s3.amazonaws.com",
43
+ "encode-files.s3.amazonaws.com",
44
+ "s3.amazonaws.com",
45
+ "encode-public.s3.us-west-2.amazonaws.com",
46
+ "encode-files.s3.us-west-2.amazonaws.com",
47
+ }
48
+ )
49
+
50
+ # Maximum limit for API queries
51
+ MAX_QUERY_LIMIT = 1000
52
+
53
+
54
+ # Valid external reference types for cross-server integration
55
+ VALID_REFERENCE_TYPES = frozenset(
56
+ {
57
+ "pmid",
58
+ "doi",
59
+ "nct_id",
60
+ "preprint_doi",
61
+ "geo_accession",
62
+ "other",
63
+ }
64
+ )
65
+
66
+ # Valid data export formats
67
+ VALID_DATA_EXPORT_FORMATS = frozenset({"csv", "tsv", "json"})
68
+
69
+
70
+ def validate_accession(accession: str) -> str:
71
+ """Validate ENCODE accession format. Raises ValueError if invalid."""
72
+ if not ACCESSION_RE.match(accession):
73
+ raise ValueError(
74
+ f"Invalid ENCODE accession format: {accession!r}. "
75
+ "Expected format like ENCSR133RZO (experiments) or ENCFF635JIA (files). "
76
+ "Accessions start with ENC followed by 2-4 uppercase letters "
77
+ "and 3-8 alphanumeric characters."
78
+ )
79
+ return accession
80
+
81
+
82
+ def validate_date(date_str: str) -> str:
83
+ """Validate date format (YYYY-MM-DD) and calendar correctness.
84
+
85
+ Raises ValueError if format is wrong or date doesn't exist
86
+ (e.g., 2024-02-30, 2024-13-01).
87
+ """
88
+ if not DATE_RE.match(date_str):
89
+ raise ValueError(f"Invalid date format: {date_str!r}. Use YYYY-MM-DD.")
90
+ # Validate that the date is a real calendar date
91
+ import datetime
92
+
93
+ try:
94
+ datetime.date.fromisoformat(date_str)
95
+ except ValueError:
96
+ raise ValueError(f"Invalid calendar date: {date_str!r}. Date does not exist.")
97
+ return date_str
98
+
99
+
100
+ def validate_encode_path(path: str) -> str:
101
+ """Validate that a path is a relative ENCODE API path, never a full URL."""
102
+ if path.startswith("http://") or path.startswith("https://"):
103
+ raise ValueError(f"Refusing to follow absolute URL from API response: {path!r}")
104
+ if not ENCODE_PATH_RE.match(path):
105
+ raise ValueError(f"Invalid ENCODE API path: {path!r}")
106
+ return path
107
+
108
+
109
+ def escape_lucene(value: str) -> str:
110
+ """Escape Lucene special characters in a query value."""
111
+ return _LUCENE_SPECIAL.sub(r"\\\1", value)
112
+
113
+
114
+ def validate_organize_by(value: str) -> str:
115
+ """Validate organize_by parameter."""
116
+ if value not in VALID_ORGANIZE_BY:
117
+ raise ValueError(f"organize_by must be one of {sorted(VALID_ORGANIZE_BY)}, got {value!r}")
118
+ return value
119
+
120
+
121
+ def validate_export_format(value: str) -> str:
122
+ """Validate export format parameter."""
123
+ if value not in VALID_EXPORT_FORMATS:
124
+ raise ValueError(f"export_format must be one of {sorted(VALID_EXPORT_FORMATS)}, got {value!r}")
125
+ return value
126
+
127
+
128
+ def validate_download_url(url: str) -> str:
129
+ """Validate that a download URL points to an allowed host."""
130
+ from urllib.parse import urlparse
131
+
132
+ parsed = urlparse(url)
133
+ if parsed.scheme and parsed.scheme != "https":
134
+ raise ValueError(f"Only HTTPS downloads allowed, got: {parsed.scheme}")
135
+ if parsed.netloc and parsed.netloc not in ALLOWED_DOWNLOAD_HOSTS:
136
+ raise ValueError(f"Download host not allowed: {parsed.netloc}")
137
+ return url
138
+
139
+
140
+ def safe_path_component(value: str, max_len: int = 64) -> str:
141
+ """Sanitize a string for use as a filesystem path component."""
142
+ cleaned = re.sub(r"[^a-zA-Z0-9._-]", "_", value)[:max_len]
143
+ if not cleaned or cleaned in (".", ".."):
144
+ raise ValueError(f"Unsafe path component: {value!r}")
145
+ return cleaned
146
+
147
+
148
+ def clamp_limit(limit: int) -> int:
149
+ """Clamp a query limit to a safe maximum."""
150
+ return max(1, min(limit, MAX_QUERY_LIMIT))
151
+
152
+
153
+ def escape_like(value: str) -> str:
154
+ """Escape SQL LIKE wildcard characters."""
155
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
156
+
157
+
158
+ def validate_reference_type(value: str) -> str:
159
+ """Validate external reference type for cross-server linking."""
160
+ if value not in VALID_REFERENCE_TYPES:
161
+ raise ValueError(
162
+ f"reference_type must be one of {sorted(VALID_REFERENCE_TYPES)}, got {value!r}. "
163
+ "Use 'pmid' for PubMed IDs, 'doi' for DOIs, 'nct_id' for ClinicalTrials.gov, "
164
+ "'preprint_doi' for bioRxiv/medRxiv, 'geo_accession' for GEO, or 'other'."
165
+ )
166
+ return value
167
+
168
+
169
+ def validate_data_export_format(value: str) -> str:
170
+ """Validate data export format parameter."""
171
+ if value not in VALID_DATA_EXPORT_FORMATS:
172
+ raise ValueError(
173
+ f"format must be one of {sorted(VALID_DATA_EXPORT_FORMATS)}, got {value!r}. "
174
+ "Use 'csv' for spreadsheets, 'tsv' for tab-separated, or 'json' for programmatic use."
175
+ )
176
+ return value
177
+
178
+
179
+ # Pre-built case-insensitive lookup maps (keyed by tuple for stable identity)
180
+ _LOWER_MAPS: dict[tuple[str, ...], dict[str, str]] = {}
181
+
182
+
183
+ def _get_lower_map(valid_values: list[str]) -> dict[str, str]:
184
+ """Get or build a cached lowercase→original map for a constants list."""
185
+ key = tuple(valid_values)
186
+ if key not in _LOWER_MAPS:
187
+ _LOWER_MAPS[key] = {v.lower(): v for v in valid_values}
188
+ return _LOWER_MAPS[key]
189
+
190
+
191
+ def check_filter_value(value: str, valid_values: list[str], filter_name: str) -> str | None:
192
+ """Check if a filter value is in the known valid values.
193
+
194
+ Returns a warning message if the value doesn't match any known value,
195
+ or None if it's valid. Does NOT raise — unknown values may be valid
196
+ if ENCODE has added new values since the constants were last updated.
197
+ """
198
+ if not isinstance(value, str) or not value:
199
+ return None
200
+ if value in valid_values:
201
+ return None
202
+ # Case-insensitive check using cached map
203
+ lower_map = _get_lower_map(valid_values)
204
+ lower_value = value.lower()
205
+ if lower_value in lower_map:
206
+ correct = lower_map[lower_value]
207
+ return (
208
+ f"Filter '{filter_name}' value '{value}' has wrong case. "
209
+ f"ENCODE API is case-sensitive — use '{correct}' instead."
210
+ )
211
+ return (
212
+ f"Filter '{filter_name}' value '{value}' not found in known ENCODE values. "
213
+ f"This may cause empty results. Check encode_get_metadata for valid values."
214
+ )
215
+
216
+
217
+ def validate_redirect_url(url: str) -> str:
218
+ """Validate that a redirect URL points to an allowed ENCODE/S3 host."""
219
+ from urllib.parse import urlparse
220
+
221
+ parsed = urlparse(url)
222
+ if parsed.scheme and parsed.scheme != "https":
223
+ raise ValueError(f"Only HTTPS redirects allowed, got: {parsed.scheme}")
224
+ if parsed.netloc and parsed.netloc not in ALLOWED_REDIRECT_HOSTS:
225
+ raise ValueError(f"Redirect host not allowed: {parsed.netloc}")
226
+ return url
@@ -0,0 +1 @@
1
+ """ENCODE Project MCP server."""
@@ -0,0 +1,5 @@
1
+ """Allow running the server as: python -m encode_connector.server"""
2
+
3
+ from encode_connector.server.main import main
4
+
5
+ main()