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.
Files changed (71) hide show
  1. mxready/__init__.py +1 -0
  2. mxready/api/__init__.py +4 -0
  3. mxready/api/middleware.py +149 -0
  4. mxready/api/rules.py +10 -0
  5. mxready/api/scans.py +164 -0
  6. mxready/api/workspace.py +156 -0
  7. mxready/app.py +179 -0
  8. mxready/cli/__init__.py +1 -0
  9. mxready/cli/build_verification_bundle.py +45 -0
  10. mxready/cli/generate_public_report.py +140 -0
  11. mxready/cli/scan_batch.py +368 -0
  12. mxready/cli/scan_repository.py +212 -0
  13. mxready/cli/serve.py +55 -0
  14. mxready/config.py +117 -0
  15. mxready/errors.py +11 -0
  16. mxready/logging_setup.py +88 -0
  17. mxready/mcp/__init__.py +5 -0
  18. mxready/mcp/server.py +433 -0
  19. mxready/models.py +382 -0
  20. mxready/persistence/__init__.py +1 -0
  21. mxready/persistence/sqlite.py +581 -0
  22. mxready/py.typed +0 -0
  23. mxready/reporting/__init__.py +4 -0
  24. mxready/reporting/badge.py +40 -0
  25. mxready/reporting/comparison.py +33 -0
  26. mxready/reporting/markdown.py +157 -0
  27. mxready/reporting/sarif.py +94 -0
  28. mxready/reporting/terminal.py +75 -0
  29. mxready/repository/__init__.py +14 -0
  30. mxready/repository/git_client.py +275 -0
  31. mxready/repository/github_archive.py +566 -0
  32. mxready/repository/identity.py +140 -0
  33. mxready/resources/__init__.py +19 -0
  34. mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
  35. mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
  36. mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
  37. mxready/resources/frontend/index.html +17 -0
  38. mxready/resources/rules/v1/core.yml +634 -0
  39. mxready/resources/rules/v1/manifest.yml +4 -0
  40. mxready/resources/schemas/verification-result-v1.json +259 -0
  41. mxready/scanning/__init__.py +17 -0
  42. mxready/scanning/analyzer.py +183 -0
  43. mxready/scanning/facts.py +596 -0
  44. mxready/scanning/indexer.py +299 -0
  45. mxready/scanning/path_context.py +174 -0
  46. mxready/scanning/python_source.py +67 -0
  47. mxready/scanning/rule_engine.py +595 -0
  48. mxready/scanning/rule_loader.py +282 -0
  49. mxready/scanning/toolchain.py +100 -0
  50. mxready/services/__init__.py +3 -0
  51. mxready/services/scans.py +239 -0
  52. mxready/services/worker.py +38 -0
  53. mxready/verification/__init__.py +11 -0
  54. mxready/verification/bundle.py +137 -0
  55. mxready/verification/validation.py +101 -0
  56. mxready-1.0.0.dist-info/METADATA +526 -0
  57. mxready-1.0.0.dist-info/RECORD +71 -0
  58. mxready-1.0.0.dist-info/WHEEL +5 -0
  59. mxready-1.0.0.dist-info/entry_points.txt +8 -0
  60. mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
  61. mxready-1.0.0.dist-info/top_level.txt +2 -0
  62. mxready_runner/__init__.py +1 -0
  63. mxready_runner/__main__.py +3 -0
  64. mxready_runner/cli.py +88 -0
  65. mxready_runner/contracts.py +26 -0
  66. mxready_runner/execute.py +185 -0
  67. mxready_runner/inspect.py +79 -0
  68. mxready_runner/process.py +103 -0
  69. mxready_runner/py.typed +0 -0
  70. mxready_runner/redact.py +36 -0
  71. 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
+ )