mxready 1.0.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.
- mxready/__init__.py +1 -0
- mxready/api/__init__.py +4 -0
- mxready/api/middleware.py +149 -0
- mxready/api/rules.py +10 -0
- mxready/api/scans.py +164 -0
- mxready/api/workspace.py +156 -0
- mxready/app.py +179 -0
- mxready/cli/__init__.py +1 -0
- mxready/cli/build_verification_bundle.py +45 -0
- mxready/cli/generate_public_report.py +140 -0
- mxready/cli/scan_batch.py +368 -0
- mxready/cli/scan_repository.py +212 -0
- mxready/cli/serve.py +55 -0
- mxready/config.py +117 -0
- mxready/errors.py +11 -0
- mxready/logging_setup.py +88 -0
- mxready/mcp/__init__.py +5 -0
- mxready/mcp/server.py +433 -0
- mxready/models.py +382 -0
- mxready/persistence/__init__.py +1 -0
- mxready/persistence/sqlite.py +581 -0
- mxready/py.typed +0 -0
- mxready/reporting/__init__.py +4 -0
- mxready/reporting/badge.py +40 -0
- mxready/reporting/comparison.py +33 -0
- mxready/reporting/markdown.py +157 -0
- mxready/reporting/sarif.py +94 -0
- mxready/reporting/terminal.py +75 -0
- mxready/repository/__init__.py +14 -0
- mxready/repository/git_client.py +275 -0
- mxready/repository/github_archive.py +566 -0
- mxready/repository/identity.py +140 -0
- mxready/resources/__init__.py +19 -0
- mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
- mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
- mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
- mxready/resources/frontend/index.html +17 -0
- mxready/resources/rules/v1/core.yml +634 -0
- mxready/resources/rules/v1/manifest.yml +4 -0
- mxready/resources/schemas/verification-result-v1.json +259 -0
- mxready/scanning/__init__.py +17 -0
- mxready/scanning/analyzer.py +183 -0
- mxready/scanning/facts.py +596 -0
- mxready/scanning/indexer.py +299 -0
- mxready/scanning/path_context.py +174 -0
- mxready/scanning/python_source.py +67 -0
- mxready/scanning/rule_engine.py +595 -0
- mxready/scanning/rule_loader.py +282 -0
- mxready/scanning/toolchain.py +100 -0
- mxready/services/__init__.py +3 -0
- mxready/services/scans.py +239 -0
- mxready/services/worker.py +38 -0
- mxready/verification/__init__.py +11 -0
- mxready/verification/bundle.py +137 -0
- mxready/verification/validation.py +101 -0
- mxready-1.0.0.dist-info/METADATA +526 -0
- mxready-1.0.0.dist-info/RECORD +71 -0
- mxready-1.0.0.dist-info/WHEEL +5 -0
- mxready-1.0.0.dist-info/entry_points.txt +8 -0
- mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
- mxready-1.0.0.dist-info/top_level.txt +2 -0
- mxready_runner/__init__.py +1 -0
- mxready_runner/__main__.py +3 -0
- mxready_runner/cli.py +88 -0
- mxready_runner/contracts.py +26 -0
- mxready_runner/execute.py +185 -0
- mxready_runner/inspect.py +79 -0
- mxready_runner/process.py +103 -0
- mxready_runner/py.typed +0 -0
- mxready_runner/redact.py +36 -0
- mxready_runner/schema.py +326 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import struct
|
|
8
|
+
import time
|
|
9
|
+
import zipfile
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from http.client import HTTPException, HTTPSConnection
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from queue import Empty, Queue
|
|
15
|
+
from threading import Lock, Thread
|
|
16
|
+
from urllib.parse import quote, urlsplit
|
|
17
|
+
|
|
18
|
+
from mxready.errors import MxReadyError
|
|
19
|
+
from mxready.repository.git_client import RepositoryLimits
|
|
20
|
+
from mxready.repository.identity import (
|
|
21
|
+
RepositoryIdentity,
|
|
22
|
+
validate_git_ref,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_COMMIT_LENGTH = 40
|
|
26
|
+
_METADATA_LIMIT_BYTES = 1_048_576
|
|
27
|
+
_COPY_CHUNK_BYTES = 65_536
|
|
28
|
+
_CENTRAL_DIRECTORY_SIGNATURE = b"PK\x01\x02"
|
|
29
|
+
_END_OF_CENTRAL_DIRECTORY_SIGNATURE = b"PK\x05\x06"
|
|
30
|
+
_END_OF_CENTRAL_DIRECTORY_SIZE = 22
|
|
31
|
+
_MAX_ZIP_COMMENT_BYTES = 65_535
|
|
32
|
+
_ZIP64_COUNT = 0xFFFF
|
|
33
|
+
_ZIP64_SIZE = 0xFFFFFFFF
|
|
34
|
+
_ALLOWED_HTTP_HOSTS = {"api.github.com", "codeload.github.com"}
|
|
35
|
+
|
|
36
|
+
HttpReader = Callable[..., bytes]
|
|
37
|
+
ConnectionFactory = Callable[[str, float], HTTPSConnection]
|
|
38
|
+
Clock = Callable[[], float]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GitHubArchiveClient:
|
|
42
|
+
"""Fetch a commit-pinned GitHub source archive without running repository code."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
http_reader: HttpReader | None = None,
|
|
48
|
+
limits: RepositoryLimits | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._http_reader = http_reader or read_bounded_url
|
|
51
|
+
self.limits = limits or RepositoryLimits()
|
|
52
|
+
|
|
53
|
+
def clone(
|
|
54
|
+
self,
|
|
55
|
+
identity: RepositoryIdentity,
|
|
56
|
+
requested_ref: str | None,
|
|
57
|
+
destination: Path,
|
|
58
|
+
) -> str:
|
|
59
|
+
if identity.provider != "github":
|
|
60
|
+
raise MxReadyError(
|
|
61
|
+
"ARCHIVE_FALLBACK_UNAVAILABLE",
|
|
62
|
+
"The bounded archive fallback is available only for GitHub repositories.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
reference = validate_git_ref(requested_ref)
|
|
66
|
+
commit = self._resolve_commit(identity, reference)
|
|
67
|
+
archive_url = f"https://codeload.github.com/{identity.owner}/{identity.name}/zip/{commit}"
|
|
68
|
+
archive_bytes = self._http_reader(
|
|
69
|
+
archive_url,
|
|
70
|
+
timeout=self.limits.clone_timeout_seconds,
|
|
71
|
+
max_bytes=self.limits.max_bytes,
|
|
72
|
+
)
|
|
73
|
+
self._extract_archive(archive_bytes, Path(destination))
|
|
74
|
+
return commit
|
|
75
|
+
|
|
76
|
+
def _resolve_commit(
|
|
77
|
+
self,
|
|
78
|
+
identity: RepositoryIdentity,
|
|
79
|
+
requested_ref: str | None,
|
|
80
|
+
) -> str:
|
|
81
|
+
reference = quote(requested_ref or "HEAD", safe="")
|
|
82
|
+
metadata_url = (
|
|
83
|
+
f"https://api.github.com/repos/{identity.owner}/{identity.name}/commits/{reference}"
|
|
84
|
+
)
|
|
85
|
+
metadata = self._http_reader(
|
|
86
|
+
metadata_url,
|
|
87
|
+
timeout=self.limits.clone_timeout_seconds,
|
|
88
|
+
max_bytes=_METADATA_LIMIT_BYTES,
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
payload = json.loads(metadata)
|
|
92
|
+
commit = payload["sha"].lower()
|
|
93
|
+
except (
|
|
94
|
+
UnicodeDecodeError,
|
|
95
|
+
json.JSONDecodeError,
|
|
96
|
+
KeyError,
|
|
97
|
+
AttributeError,
|
|
98
|
+
# 顶层不是对象时 `payload["sha"]` 抛 TypeError(列表/字符串索引);
|
|
99
|
+
# 这里的目的是把"形状不对"统一收敛成可重试的错误码,而不是让它冒成 500。
|
|
100
|
+
TypeError,
|
|
101
|
+
) as error:
|
|
102
|
+
raise MxReadyError(
|
|
103
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
104
|
+
"GitHub returned invalid commit metadata.",
|
|
105
|
+
) from error
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
not isinstance(commit, str)
|
|
109
|
+
or len(commit) != _COMMIT_LENGTH
|
|
110
|
+
or any(character not in "0123456789abcdef" for character in commit)
|
|
111
|
+
):
|
|
112
|
+
raise MxReadyError(
|
|
113
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
114
|
+
"GitHub returned an invalid commit identifier.",
|
|
115
|
+
)
|
|
116
|
+
if (
|
|
117
|
+
requested_ref is not None
|
|
118
|
+
and len(requested_ref) == _COMMIT_LENGTH
|
|
119
|
+
and all(character in "0123456789abcdefABCDEF" for character in requested_ref)
|
|
120
|
+
and commit != requested_ref.lower()
|
|
121
|
+
):
|
|
122
|
+
raise MxReadyError(
|
|
123
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
124
|
+
"GitHub resolved a different commit than the one requested.",
|
|
125
|
+
)
|
|
126
|
+
return commit
|
|
127
|
+
|
|
128
|
+
def _extract_archive(self, archive_bytes: bytes, destination: Path) -> None:
|
|
129
|
+
staging = destination.with_name(f".{destination.name}.mxready-partial")
|
|
130
|
+
if destination.exists() or staging.exists():
|
|
131
|
+
raise MxReadyError(
|
|
132
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
133
|
+
"Archive extraction destination already exists.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
staging_created = False
|
|
137
|
+
try:
|
|
138
|
+
declared_members = _validate_central_directory(
|
|
139
|
+
archive_bytes,
|
|
140
|
+
max_members=self.limits.max_files,
|
|
141
|
+
)
|
|
142
|
+
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
|
143
|
+
archive_members = archive.infolist()
|
|
144
|
+
if len(archive_members) != declared_members:
|
|
145
|
+
raise MxReadyError(
|
|
146
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
147
|
+
"Repository archive member count is inconsistent.",
|
|
148
|
+
)
|
|
149
|
+
members = self._validate_members(archive_members)
|
|
150
|
+
staging.mkdir(parents=True)
|
|
151
|
+
staging_created = True
|
|
152
|
+
for info, relative_parts in members:
|
|
153
|
+
target = staging.joinpath(*relative_parts)
|
|
154
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
self._extract_file(archive, info, target)
|
|
156
|
+
staging.replace(destination)
|
|
157
|
+
except MxReadyError:
|
|
158
|
+
if staging_created:
|
|
159
|
+
_remove_owned_staging(staging)
|
|
160
|
+
raise
|
|
161
|
+
except (OSError, RuntimeError, zipfile.BadZipFile, zipfile.LargeZipFile) as error:
|
|
162
|
+
if staging_created:
|
|
163
|
+
_remove_owned_staging(staging)
|
|
164
|
+
raise MxReadyError(
|
|
165
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
166
|
+
"GitHub returned an archive that could not be safely extracted.",
|
|
167
|
+
) from error
|
|
168
|
+
|
|
169
|
+
def _validate_members(
|
|
170
|
+
self,
|
|
171
|
+
archive_members: list[zipfile.ZipInfo],
|
|
172
|
+
) -> list[tuple[zipfile.ZipInfo, tuple[str, ...]]]:
|
|
173
|
+
roots: set[str] = set()
|
|
174
|
+
seen_paths: set[str] = set()
|
|
175
|
+
regular_files: list[tuple[zipfile.ZipInfo, tuple[str, ...]]] = []
|
|
176
|
+
total_bytes = 0
|
|
177
|
+
|
|
178
|
+
if len(archive_members) > self.limits.max_files:
|
|
179
|
+
raise MxReadyError(
|
|
180
|
+
"TOO_MANY_FILES",
|
|
181
|
+
f"Repository archive contains more than {self.limits.max_files} entries.",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
for info in archive_members:
|
|
185
|
+
parts = _safe_member_parts(info.filename)
|
|
186
|
+
roots.add(parts[0])
|
|
187
|
+
if len(parts) == 1:
|
|
188
|
+
if not info.is_dir():
|
|
189
|
+
raise _invalid_archive_path()
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
relative_parts = tuple(parts[1:])
|
|
193
|
+
normalized = "/".join(relative_parts).casefold()
|
|
194
|
+
mode = (info.external_attr >> 16) & 0xFFFF
|
|
195
|
+
|
|
196
|
+
if info.is_dir():
|
|
197
|
+
continue
|
|
198
|
+
if info.flag_bits & 0x1:
|
|
199
|
+
raise MxReadyError(
|
|
200
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
201
|
+
"Encrypted repository archive entries are not supported.",
|
|
202
|
+
)
|
|
203
|
+
file_type = stat.S_IFMT(mode)
|
|
204
|
+
if file_type == stat.S_IFLNK:
|
|
205
|
+
continue
|
|
206
|
+
if file_type not in {0, stat.S_IFREG}:
|
|
207
|
+
raise MxReadyError(
|
|
208
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
209
|
+
"Repository archive contains an unsupported file type.",
|
|
210
|
+
)
|
|
211
|
+
if normalized in seen_paths:
|
|
212
|
+
raise MxReadyError(
|
|
213
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
214
|
+
"Repository archive contains duplicate file paths.",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
seen_paths.add(normalized)
|
|
218
|
+
if len(regular_files) + 1 > self.limits.max_files:
|
|
219
|
+
raise MxReadyError(
|
|
220
|
+
"TOO_MANY_FILES",
|
|
221
|
+
f"Repository contains more than {self.limits.max_files} files.",
|
|
222
|
+
)
|
|
223
|
+
total_bytes += info.file_size
|
|
224
|
+
if total_bytes > self.limits.max_bytes:
|
|
225
|
+
raise MxReadyError(
|
|
226
|
+
"REPOSITORY_TOO_LARGE",
|
|
227
|
+
f"Repository content exceeds {self.limits.max_bytes} bytes.",
|
|
228
|
+
)
|
|
229
|
+
regular_files.append((info, relative_parts))
|
|
230
|
+
|
|
231
|
+
if len(roots) != 1:
|
|
232
|
+
raise MxReadyError(
|
|
233
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
234
|
+
"Repository archive must contain exactly one top-level directory.",
|
|
235
|
+
)
|
|
236
|
+
return regular_files
|
|
237
|
+
|
|
238
|
+
def _extract_file(
|
|
239
|
+
self,
|
|
240
|
+
archive: zipfile.ZipFile,
|
|
241
|
+
info: zipfile.ZipInfo,
|
|
242
|
+
target: Path,
|
|
243
|
+
) -> None:
|
|
244
|
+
written = 0
|
|
245
|
+
with archive.open(info, mode="r") as source, target.open("xb") as output:
|
|
246
|
+
while True:
|
|
247
|
+
chunk = source.read(_COPY_CHUNK_BYTES)
|
|
248
|
+
if not chunk:
|
|
249
|
+
break
|
|
250
|
+
written += len(chunk)
|
|
251
|
+
if written > info.file_size or written > self.limits.max_bytes:
|
|
252
|
+
raise MxReadyError(
|
|
253
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
254
|
+
"Repository archive expanded beyond its declared size.",
|
|
255
|
+
)
|
|
256
|
+
output.write(chunk)
|
|
257
|
+
if written != info.file_size:
|
|
258
|
+
raise MxReadyError(
|
|
259
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
260
|
+
"Repository archive file size did not match its declaration.",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def read_bounded_url(
|
|
265
|
+
url: str,
|
|
266
|
+
*,
|
|
267
|
+
timeout: float,
|
|
268
|
+
max_bytes: int,
|
|
269
|
+
connection_factory: ConnectionFactory | None = None,
|
|
270
|
+
clock: Clock = time.monotonic,
|
|
271
|
+
) -> bytes:
|
|
272
|
+
parsed = urlsplit(url)
|
|
273
|
+
host = parsed.hostname
|
|
274
|
+
if (
|
|
275
|
+
parsed.scheme != "https"
|
|
276
|
+
or not isinstance(host, str)
|
|
277
|
+
or host not in _ALLOWED_HTTP_HOSTS
|
|
278
|
+
or parsed.username is not None
|
|
279
|
+
or parsed.password is not None
|
|
280
|
+
or parsed.port is not None
|
|
281
|
+
or parsed.fragment
|
|
282
|
+
or parsed.netloc != host
|
|
283
|
+
):
|
|
284
|
+
raise MxReadyError(
|
|
285
|
+
"ARCHIVE_FETCH_FAILED",
|
|
286
|
+
"Archive request target is not allowed.",
|
|
287
|
+
)
|
|
288
|
+
if timeout <= 0 or max_bytes <= 0:
|
|
289
|
+
raise ValueError("HTTP limits must be positive.")
|
|
290
|
+
|
|
291
|
+
deadline = clock() + timeout
|
|
292
|
+
factory = connection_factory or _create_https_connection
|
|
293
|
+
target = parsed.path or "/"
|
|
294
|
+
if parsed.query:
|
|
295
|
+
target = f"{target}?{parsed.query}"
|
|
296
|
+
|
|
297
|
+
outcomes: Queue[bytes | Exception] = Queue(maxsize=1)
|
|
298
|
+
connection_lock = Lock()
|
|
299
|
+
active_connections: list[HTTPSConnection] = []
|
|
300
|
+
|
|
301
|
+
def observe_connection(connection: HTTPSConnection | None) -> None:
|
|
302
|
+
with connection_lock:
|
|
303
|
+
active_connections.clear()
|
|
304
|
+
if connection is not None:
|
|
305
|
+
active_connections.append(connection)
|
|
306
|
+
|
|
307
|
+
def perform_request() -> None:
|
|
308
|
+
try:
|
|
309
|
+
outcome: bytes | Exception = _read_bounded_connection(
|
|
310
|
+
host,
|
|
311
|
+
target,
|
|
312
|
+
deadline=deadline,
|
|
313
|
+
max_bytes=max_bytes,
|
|
314
|
+
connection_factory=factory,
|
|
315
|
+
clock=clock,
|
|
316
|
+
connection_observer=observe_connection,
|
|
317
|
+
)
|
|
318
|
+
except Exception as error:
|
|
319
|
+
outcome = error
|
|
320
|
+
outcomes.put(outcome)
|
|
321
|
+
|
|
322
|
+
worker = Thread(
|
|
323
|
+
target=perform_request,
|
|
324
|
+
name="mxready-github-archive-fetch",
|
|
325
|
+
daemon=True,
|
|
326
|
+
)
|
|
327
|
+
worker.start()
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
outcome = outcomes.get(timeout=_remaining_seconds(deadline, clock))
|
|
331
|
+
except Empty as error:
|
|
332
|
+
with connection_lock:
|
|
333
|
+
connection = active_connections[0] if active_connections else None
|
|
334
|
+
if connection is not None:
|
|
335
|
+
_close_connection(connection)
|
|
336
|
+
raise _archive_deadline_exceeded() from error
|
|
337
|
+
|
|
338
|
+
_remaining_seconds(deadline, clock)
|
|
339
|
+
if isinstance(outcome, Exception):
|
|
340
|
+
raise outcome
|
|
341
|
+
return outcome
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _read_bounded_connection(
|
|
345
|
+
host: str,
|
|
346
|
+
target: str,
|
|
347
|
+
*,
|
|
348
|
+
deadline: float,
|
|
349
|
+
max_bytes: int,
|
|
350
|
+
connection_factory: ConnectionFactory,
|
|
351
|
+
clock: Clock,
|
|
352
|
+
connection_observer: Callable[[HTTPSConnection | None], None],
|
|
353
|
+
) -> bytes:
|
|
354
|
+
connection: HTTPSConnection | None = None
|
|
355
|
+
try:
|
|
356
|
+
connection = connection_factory(host, _remaining_seconds(deadline, clock))
|
|
357
|
+
connection_observer(connection)
|
|
358
|
+
_set_connection_timeout(connection, _remaining_seconds(deadline, clock))
|
|
359
|
+
connection.request(
|
|
360
|
+
"GET",
|
|
361
|
+
target,
|
|
362
|
+
headers={
|
|
363
|
+
"Accept": "application/vnd.github+json",
|
|
364
|
+
"User-Agent": "MXReady/0.1",
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
_set_connection_timeout(connection, _remaining_seconds(deadline, clock))
|
|
368
|
+
response = connection.getresponse()
|
|
369
|
+
_remaining_seconds(deadline, clock)
|
|
370
|
+
|
|
371
|
+
if response.status == 404:
|
|
372
|
+
raise MxReadyError(
|
|
373
|
+
"REPOSITORY_NOT_FOUND",
|
|
374
|
+
"GitHub repository or requested reference was not found.",
|
|
375
|
+
)
|
|
376
|
+
if not 200 <= response.status < 300:
|
|
377
|
+
raise MxReadyError(
|
|
378
|
+
"ARCHIVE_FETCH_FAILED",
|
|
379
|
+
"GitHub refused the bounded archive request.",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
content_length = response.headers.get("Content-Length")
|
|
383
|
+
if content_length is not None and int(content_length) > max_bytes:
|
|
384
|
+
raise _repository_download_too_large(max_bytes)
|
|
385
|
+
|
|
386
|
+
chunks: list[bytes] = []
|
|
387
|
+
downloaded = 0
|
|
388
|
+
while True:
|
|
389
|
+
remaining_time = _remaining_seconds(deadline, clock)
|
|
390
|
+
_set_connection_timeout(connection, remaining_time)
|
|
391
|
+
read_size = min(_COPY_CHUNK_BYTES, max_bytes + 1 - downloaded)
|
|
392
|
+
chunk = response.read(read_size)
|
|
393
|
+
_remaining_seconds(deadline, clock)
|
|
394
|
+
if not chunk:
|
|
395
|
+
break
|
|
396
|
+
downloaded += len(chunk)
|
|
397
|
+
if downloaded > max_bytes:
|
|
398
|
+
raise _repository_download_too_large(max_bytes)
|
|
399
|
+
chunks.append(chunk)
|
|
400
|
+
except MxReadyError:
|
|
401
|
+
raise
|
|
402
|
+
except (HTTPException, OSError, TimeoutError, ValueError) as error:
|
|
403
|
+
raise MxReadyError(
|
|
404
|
+
"ARCHIVE_FETCH_FAILED",
|
|
405
|
+
"GitHub archive retrieval failed within the configured safety limit.",
|
|
406
|
+
) from error
|
|
407
|
+
finally:
|
|
408
|
+
if connection is not None:
|
|
409
|
+
_close_connection(connection)
|
|
410
|
+
connection_observer(None)
|
|
411
|
+
|
|
412
|
+
return b"".join(chunks)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _create_https_connection(host: str, timeout: float) -> HTTPSConnection:
|
|
416
|
+
return HTTPSConnection(host, timeout=timeout)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _set_connection_timeout(connection: HTTPSConnection, timeout: float) -> None:
|
|
420
|
+
connection.timeout = timeout
|
|
421
|
+
if connection.sock is not None:
|
|
422
|
+
connection.sock.settimeout(timeout)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _close_connection(connection: HTTPSConnection) -> None:
|
|
426
|
+
with suppress(OSError):
|
|
427
|
+
connection.close()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _remaining_seconds(deadline: float, clock: Clock) -> float:
|
|
431
|
+
remaining = deadline - clock()
|
|
432
|
+
if remaining <= 0:
|
|
433
|
+
raise _archive_deadline_exceeded()
|
|
434
|
+
return remaining
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _archive_deadline_exceeded() -> MxReadyError:
|
|
438
|
+
return MxReadyError(
|
|
439
|
+
"ARCHIVE_FETCH_FAILED",
|
|
440
|
+
"GitHub archive retrieval exceeded the total time limit.",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _validate_central_directory(archive_bytes: bytes, *, max_members: int) -> int:
|
|
445
|
+
record = _find_end_of_central_directory(archive_bytes)
|
|
446
|
+
(
|
|
447
|
+
_,
|
|
448
|
+
disk_number,
|
|
449
|
+
directory_disk,
|
|
450
|
+
disk_entries,
|
|
451
|
+
total_entries,
|
|
452
|
+
directory_size,
|
|
453
|
+
directory_offset,
|
|
454
|
+
_,
|
|
455
|
+
) = record
|
|
456
|
+
if disk_number != 0 or directory_disk != 0 or disk_entries != total_entries:
|
|
457
|
+
raise _invalid_archive_structure()
|
|
458
|
+
if total_entries == _ZIP64_COUNT:
|
|
459
|
+
raise MxReadyError(
|
|
460
|
+
"TOO_MANY_FILES",
|
|
461
|
+
f"Repository archive contains more than {max_members} entries.",
|
|
462
|
+
)
|
|
463
|
+
if directory_size == _ZIP64_SIZE or directory_offset == _ZIP64_SIZE:
|
|
464
|
+
raise _invalid_archive_structure()
|
|
465
|
+
if total_entries > max_members:
|
|
466
|
+
raise MxReadyError(
|
|
467
|
+
"TOO_MANY_FILES",
|
|
468
|
+
f"Repository archive contains more than {max_members} entries.",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
directory_end = directory_offset + directory_size
|
|
472
|
+
if directory_end > len(archive_bytes):
|
|
473
|
+
raise _invalid_archive_structure()
|
|
474
|
+
|
|
475
|
+
position = directory_offset
|
|
476
|
+
counted = 0
|
|
477
|
+
while position < directory_end:
|
|
478
|
+
if archive_bytes[position : position + 4] != _CENTRAL_DIRECTORY_SIGNATURE:
|
|
479
|
+
raise _invalid_archive_structure()
|
|
480
|
+
if position + 46 > directory_end:
|
|
481
|
+
raise _invalid_archive_structure()
|
|
482
|
+
filename_length, extra_length, comment_length = struct.unpack_from(
|
|
483
|
+
"<HHH",
|
|
484
|
+
archive_bytes,
|
|
485
|
+
position + 28,
|
|
486
|
+
)
|
|
487
|
+
entry_size = 46 + filename_length + extra_length + comment_length
|
|
488
|
+
position += entry_size
|
|
489
|
+
counted += 1
|
|
490
|
+
if counted > max_members:
|
|
491
|
+
raise MxReadyError(
|
|
492
|
+
"TOO_MANY_FILES",
|
|
493
|
+
f"Repository archive contains more than {max_members} entries.",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if position != directory_end or counted != total_entries:
|
|
497
|
+
raise _invalid_archive_structure()
|
|
498
|
+
return counted
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _find_end_of_central_directory(archive_bytes: bytes) -> tuple:
|
|
502
|
+
search_start = max(
|
|
503
|
+
0,
|
|
504
|
+
len(archive_bytes) - _END_OF_CENTRAL_DIRECTORY_SIZE - _MAX_ZIP_COMMENT_BYTES,
|
|
505
|
+
)
|
|
506
|
+
search_end = len(archive_bytes)
|
|
507
|
+
|
|
508
|
+
while search_end >= search_start:
|
|
509
|
+
offset = archive_bytes.rfind(
|
|
510
|
+
_END_OF_CENTRAL_DIRECTORY_SIGNATURE,
|
|
511
|
+
search_start,
|
|
512
|
+
search_end,
|
|
513
|
+
)
|
|
514
|
+
if offset < 0:
|
|
515
|
+
break
|
|
516
|
+
if offset + _END_OF_CENTRAL_DIRECTORY_SIZE <= len(archive_bytes):
|
|
517
|
+
record = struct.unpack_from("<4s4H2LH", archive_bytes, offset)
|
|
518
|
+
comment_length = record[-1]
|
|
519
|
+
if offset + _END_OF_CENTRAL_DIRECTORY_SIZE + comment_length == len(archive_bytes):
|
|
520
|
+
return record
|
|
521
|
+
search_end = offset
|
|
522
|
+
raise _invalid_archive_structure()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _invalid_archive_structure() -> MxReadyError:
|
|
526
|
+
return MxReadyError(
|
|
527
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
528
|
+
"Repository archive central directory is invalid.",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _remove_owned_staging(staging: Path) -> None:
|
|
533
|
+
if staging.exists():
|
|
534
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _safe_member_parts(name: str) -> list[str]:
|
|
538
|
+
if (
|
|
539
|
+
not name
|
|
540
|
+
or name.startswith("/")
|
|
541
|
+
or "\\" in name
|
|
542
|
+
or "\x00" in name
|
|
543
|
+
or any(ord(character) < 32 for character in name)
|
|
544
|
+
):
|
|
545
|
+
raise _invalid_archive_path()
|
|
546
|
+
|
|
547
|
+
parts = name.split("/")
|
|
548
|
+
if parts[-1] == "":
|
|
549
|
+
parts.pop()
|
|
550
|
+
if not parts or any(part in {"", ".", ".."} or ":" in part for part in parts):
|
|
551
|
+
raise _invalid_archive_path()
|
|
552
|
+
return parts
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _invalid_archive_path() -> MxReadyError:
|
|
556
|
+
return MxReadyError(
|
|
557
|
+
"INVALID_REPOSITORY_ARCHIVE",
|
|
558
|
+
"Repository archive contains an unsafe path.",
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _repository_download_too_large(max_bytes: int) -> MxReadyError:
|
|
563
|
+
return MxReadyError(
|
|
564
|
+
"REPOSITORY_TOO_LARGE",
|
|
565
|
+
f"Repository download exceeds {max_bytes} bytes.",
|
|
566
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from mxready.errors import MxReadyError
|
|
9
|
+
|
|
10
|
+
RepositoryProvider = str
|
|
11
|
+
|
|
12
|
+
_DEFAULT_PROVIDERS: dict[str, str] = {
|
|
13
|
+
"github.com": "github",
|
|
14
|
+
"gitee.com": "gitee",
|
|
15
|
+
}
|
|
16
|
+
_PATH_SEGMENT = re.compile(r"[A-Za-z0-9_.-]+")
|
|
17
|
+
_GIT_REF = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_providers(allowed_hosts: Sequence[str]) -> dict[str, str]:
|
|
21
|
+
"""把白名单主机名映射为 provider 标识,保持默认主机的简短名称。"""
|
|
22
|
+
providers: dict[str, str] = {}
|
|
23
|
+
for host in allowed_hosts:
|
|
24
|
+
host = host.strip()
|
|
25
|
+
if not host:
|
|
26
|
+
continue
|
|
27
|
+
providers[host] = _DEFAULT_PROVIDERS.get(host, host)
|
|
28
|
+
return providers
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class RepositoryIdentity:
|
|
33
|
+
provider: RepositoryProvider
|
|
34
|
+
owner: str
|
|
35
|
+
name: str
|
|
36
|
+
clone_url: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_repository_url(
|
|
40
|
+
value: str,
|
|
41
|
+
providers: Mapping[str, str] | None = None,
|
|
42
|
+
) -> RepositoryIdentity:
|
|
43
|
+
"""Parse a public repository URL into a safe identity.
|
|
44
|
+
|
|
45
|
+
`providers` 缺省使用内置白名单(github.com / gitee.com),
|
|
46
|
+
内部部署时可传入自定义白名单(见 `build_providers`)。
|
|
47
|
+
"""
|
|
48
|
+
resolved_providers = _DEFAULT_PROVIDERS if providers is None else dict(providers)
|
|
49
|
+
if (
|
|
50
|
+
not isinstance(value, str)
|
|
51
|
+
or not value
|
|
52
|
+
or value != value.strip()
|
|
53
|
+
or not value.startswith("https://")
|
|
54
|
+
or any(ord(character) < 32 for character in value)
|
|
55
|
+
):
|
|
56
|
+
raise _invalid_repository_url()
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
parsed = urlsplit(value)
|
|
60
|
+
port = parsed.port
|
|
61
|
+
except ValueError as error:
|
|
62
|
+
raise _invalid_repository_url() from error
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
parsed.scheme != "https"
|
|
66
|
+
or parsed.username is not None
|
|
67
|
+
or parsed.password is not None
|
|
68
|
+
or port is not None
|
|
69
|
+
or parsed.query
|
|
70
|
+
or parsed.fragment
|
|
71
|
+
):
|
|
72
|
+
raise _invalid_repository_url()
|
|
73
|
+
|
|
74
|
+
host = parsed.hostname
|
|
75
|
+
if host not in resolved_providers:
|
|
76
|
+
raise MxReadyError(
|
|
77
|
+
"UNSUPPORTED_REPOSITORY_HOST",
|
|
78
|
+
"目前只支持 GitHub 和 Gitee 的公开仓库。",
|
|
79
|
+
)
|
|
80
|
+
if parsed.netloc != host:
|
|
81
|
+
raise _invalid_repository_url()
|
|
82
|
+
|
|
83
|
+
path = parsed.path
|
|
84
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
85
|
+
raise _invalid_repository_url()
|
|
86
|
+
path = path[1:]
|
|
87
|
+
if path.endswith("/"):
|
|
88
|
+
path = path[:-1]
|
|
89
|
+
parts = path.split("/")
|
|
90
|
+
if len(parts) != 2:
|
|
91
|
+
raise _invalid_repository_url()
|
|
92
|
+
|
|
93
|
+
owner, repository = parts
|
|
94
|
+
name = repository.removesuffix(".git")
|
|
95
|
+
if (
|
|
96
|
+
not _is_valid_path_segment(owner)
|
|
97
|
+
or not _is_valid_path_segment(name)
|
|
98
|
+
or repository != name
|
|
99
|
+
and repository != f"{name}.git"
|
|
100
|
+
):
|
|
101
|
+
raise _invalid_repository_url()
|
|
102
|
+
|
|
103
|
+
return RepositoryIdentity(
|
|
104
|
+
provider=resolved_providers[host],
|
|
105
|
+
owner=owner,
|
|
106
|
+
name=name,
|
|
107
|
+
clone_url=f"https://{host}/{owner}/{name}.git",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_git_ref(value: str | None) -> str | None:
|
|
112
|
+
"""Accept only bounded branch, tag, or hexadecimal commit references."""
|
|
113
|
+
if value is None:
|
|
114
|
+
return None
|
|
115
|
+
if (
|
|
116
|
+
not isinstance(value, str)
|
|
117
|
+
or not 1 <= len(value) <= 200
|
|
118
|
+
or _GIT_REF.fullmatch(value) is None
|
|
119
|
+
or ".." in value
|
|
120
|
+
or "@{" in value
|
|
121
|
+
or "\\" in value
|
|
122
|
+
or value.startswith("-")
|
|
123
|
+
or value.endswith("/")
|
|
124
|
+
):
|
|
125
|
+
raise MxReadyError(
|
|
126
|
+
"INVALID_GIT_REF",
|
|
127
|
+
"Git 分支、标签或提交编号格式无效。",
|
|
128
|
+
)
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_valid_path_segment(value: str) -> bool:
|
|
133
|
+
return value not in {".", ".."} and _PATH_SEGMENT.fullmatch(value) is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _invalid_repository_url() -> MxReadyError:
|
|
137
|
+
return MxReadyError(
|
|
138
|
+
"INVALID_REPOSITORY_URL",
|
|
139
|
+
"请输入完整的 GitHub 或 Gitee 公开仓库 HTTPS 地址。",
|
|
140
|
+
)
|