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,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
from mxready.models import Finding, FindingOccurrence, ScanReport, Severity
|
|
7
|
+
|
|
8
|
+
_SEVERITY_HEADINGS = {
|
|
9
|
+
Severity.BLOCKER: "阻塞项",
|
|
10
|
+
Severity.WARNING: "警告",
|
|
11
|
+
Severity.INFO: "提示",
|
|
12
|
+
}
|
|
13
|
+
_MARKDOWN_CONTROL_CHARACTERS = "\\`*_{}[]()#+-.!|>"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_markdown(report: ScanReport) -> str:
|
|
17
|
+
"""Render a deterministic, portable report with untrusted text escaped."""
|
|
18
|
+
lines = [
|
|
19
|
+
f"# MXReady 适配体检报告:{_markdown_text(report.repository.name)}",
|
|
20
|
+
"",
|
|
21
|
+
"## 扫描元数据",
|
|
22
|
+
"",
|
|
23
|
+
"| 字段 | 值 |",
|
|
24
|
+
"| --- | --- |",
|
|
25
|
+
f"| 仓库 | {_table_cell(report.repository.url)} |",
|
|
26
|
+
f"| 提交 | `{report.repository.commit}` |",
|
|
27
|
+
f"| MXReady 版本 | {_table_cell(report.tool_version)} |",
|
|
28
|
+
f"| 规则集版本 | {_table_cell(report.ruleset_version)} |",
|
|
29
|
+
f"| 规则集摘要 | {_table_cell(report.ruleset_digest or '未记录')} |",
|
|
30
|
+
f"| 扩展规则版本 | {_table_cell(report.extra_ruleset_version or '无')} |",
|
|
31
|
+
f"| 分析配置 | {_table_cell(str(report.analysis_config))} |",
|
|
32
|
+
f"| 扫描时间 | {_table_cell(report.scanned_at.isoformat())} |",
|
|
33
|
+
f"| 静态状态 | `{report.static_status.value}` |",
|
|
34
|
+
f"| 硬件验证状态 | `{report.verification_status.value}` |",
|
|
35
|
+
"",
|
|
36
|
+
"## 结果摘要",
|
|
37
|
+
"",
|
|
38
|
+
"| 阻塞项 | 警告 | 提示 | 总计 |",
|
|
39
|
+
"| ---: | ---: | ---: | ---: |",
|
|
40
|
+
(
|
|
41
|
+
f"| {report.summary.blocker_count} | {report.summary.warning_count} "
|
|
42
|
+
f"| {report.summary.info_count} | {report.summary.total_count} |"
|
|
43
|
+
),
|
|
44
|
+
"",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
lines.extend(["## 扫描范围", ""])
|
|
48
|
+
if report.coverage:
|
|
49
|
+
c = report.coverage
|
|
50
|
+
lines.extend(
|
|
51
|
+
[
|
|
52
|
+
f"分析文件:{c.analyzed_files};字节:{c.analyzed_bytes};排除目录:{c.excluded_directories}(未遍历内容)。",
|
|
53
|
+
f"文件类型:{_markdown_text(str(c.files_by_type))}",
|
|
54
|
+
f"跳过原因:{_markdown_text(str(c.skipped_by_reason))}",
|
|
55
|
+
"",
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
lines.extend(["旧报告未记录扫描范围,请重新扫描。", ""])
|
|
60
|
+
lines.extend(["仅表示支持的文件类型与限制内的扫描范围,不是完整仓库覆盖率。", ""])
|
|
61
|
+
grouped: defaultdict[Severity, list[Finding]] = defaultdict(list)
|
|
62
|
+
for finding in report.findings:
|
|
63
|
+
grouped[finding.severity].append(finding)
|
|
64
|
+
|
|
65
|
+
for severity in (Severity.BLOCKER, Severity.WARNING, Severity.INFO):
|
|
66
|
+
lines.extend([f"## {_SEVERITY_HEADINGS[severity]}", ""])
|
|
67
|
+
findings = grouped[severity]
|
|
68
|
+
if not findings:
|
|
69
|
+
lines.extend(["无。", ""])
|
|
70
|
+
continue
|
|
71
|
+
for finding in findings:
|
|
72
|
+
lines.extend(_render_finding(finding))
|
|
73
|
+
|
|
74
|
+
lines.extend(["## 迁移清单", ""])
|
|
75
|
+
if report.migration_checklist:
|
|
76
|
+
for item in report.migration_checklist:
|
|
77
|
+
files = "、".join(_inline_code(path) for path in item.affected_files)
|
|
78
|
+
lines.append(
|
|
79
|
+
f"- [ ] `{item.rule_id}` {_markdown_text(item.title)}:"
|
|
80
|
+
f"{_markdown_text(item.action)}"
|
|
81
|
+
)
|
|
82
|
+
lines.append(f" - 涉及文件:{files or '无'}")
|
|
83
|
+
else:
|
|
84
|
+
lines.append("当前没有由阻塞项或警告生成的迁移任务。")
|
|
85
|
+
lines.append("")
|
|
86
|
+
|
|
87
|
+
lines.extend(["## 分析警告", ""])
|
|
88
|
+
if report.analysis_warnings:
|
|
89
|
+
for warning in report.analysis_warnings:
|
|
90
|
+
location = f"({_inline_code(warning.relative_path)})" if warning.relative_path else ""
|
|
91
|
+
lines.append(f"- `{warning.code}`{location}:{_markdown_text(warning.message)}")
|
|
92
|
+
else:
|
|
93
|
+
lines.append("无。")
|
|
94
|
+
lines.extend(
|
|
95
|
+
[
|
|
96
|
+
"",
|
|
97
|
+
"## 说明",
|
|
98
|
+
"",
|
|
99
|
+
(
|
|
100
|
+
"静态扫描不能替代真实沐曦 GPU 上的构建、功能和性能验证,"
|
|
101
|
+
"也不能作为项目安全性或完整兼容性的证明。"
|
|
102
|
+
),
|
|
103
|
+
"",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_finding(finding: Finding) -> list[str]:
|
|
110
|
+
lines = [
|
|
111
|
+
f"### `{finding.rule_id}` — {_markdown_text(finding.title)}",
|
|
112
|
+
"",
|
|
113
|
+
(
|
|
114
|
+
f"- 位置:{_inline_code(finding.relative_path)},"
|
|
115
|
+
f"第 {finding.line_start}–{finding.line_end} 行"
|
|
116
|
+
),
|
|
117
|
+
f"- 分类:`{_markdown_text(finding.category)}`",
|
|
118
|
+
f"- 说明:{_markdown_text(finding.message)}",
|
|
119
|
+
f"- 建议:{_markdown_text(finding.recommendation)}",
|
|
120
|
+
"- 证据:",
|
|
121
|
+
"",
|
|
122
|
+
]
|
|
123
|
+
for occurrence in finding.occurrences or [
|
|
124
|
+
FindingOccurrence(
|
|
125
|
+
line_start=finding.line_start, line_end=finding.line_end, evidence=finding.evidence
|
|
126
|
+
)
|
|
127
|
+
]:
|
|
128
|
+
lines.extend([f"- 第 {occurrence.line_start}–{occurrence.line_end} 行", ""])
|
|
129
|
+
lines.extend(
|
|
130
|
+
f" {html.escape(line, quote=False)}"
|
|
131
|
+
for line in occurrence.evidence.splitlines() or [""]
|
|
132
|
+
)
|
|
133
|
+
lines.append("")
|
|
134
|
+
if not finding.occurrences and finding.count > 1:
|
|
135
|
+
lines.append("旧报告仅保留聚合证据,无法恢复所有命中位置。")
|
|
136
|
+
if finding.references:
|
|
137
|
+
lines.extend(["", "- 参考:"])
|
|
138
|
+
for reference in finding.references:
|
|
139
|
+
lines.append(f" - [{_markdown_text(reference.title)}]({reference.url})")
|
|
140
|
+
lines.extend(["", ""])
|
|
141
|
+
return lines
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _markdown_text(value: str) -> str:
|
|
145
|
+
escaped = html.escape(value, quote=False)
|
|
146
|
+
for character in _MARKDOWN_CONTROL_CHARACTERS:
|
|
147
|
+
escaped = escaped.replace(character, f"\\{character}")
|
|
148
|
+
return escaped.replace("\r", " ").replace("\n", " ")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _table_cell(value: str) -> str:
|
|
152
|
+
return _markdown_text(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _inline_code(value: str) -> str:
|
|
156
|
+
escaped = html.escape(value, quote=False).replace("`", "`")
|
|
157
|
+
return f"`{escaped.replace(chr(10), ' ').replace(chr(13), ' ')}`"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Portable SARIF 2.1.0 export of static findings (never hardware certification)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from mxready.models import FindingOccurrence, ScanReport, Severity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_sarif(report: ScanReport) -> str:
|
|
13
|
+
rules: dict[str, Any] = {}
|
|
14
|
+
results = []
|
|
15
|
+
levels = {Severity.BLOCKER: "error", Severity.WARNING: "warning", Severity.INFO: "note"}
|
|
16
|
+
for finding in report.findings:
|
|
17
|
+
rules.setdefault(
|
|
18
|
+
finding.rule_id,
|
|
19
|
+
{
|
|
20
|
+
"id": finding.rule_id,
|
|
21
|
+
"shortDescription": {"text": finding.title},
|
|
22
|
+
"help": {"text": finding.recommendation},
|
|
23
|
+
"properties": {"ruleVersion": finding.rule_version},
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
occurrences = finding.occurrences or [
|
|
27
|
+
FindingOccurrence(
|
|
28
|
+
line_start=finding.line_start,
|
|
29
|
+
line_end=finding.line_end,
|
|
30
|
+
evidence=finding.evidence,
|
|
31
|
+
)
|
|
32
|
+
]
|
|
33
|
+
for occurrence in occurrences:
|
|
34
|
+
results.append(
|
|
35
|
+
{
|
|
36
|
+
"ruleId": finding.rule_id,
|
|
37
|
+
"level": levels[finding.severity],
|
|
38
|
+
"message": {"text": finding.message + "\n" + finding.recommendation},
|
|
39
|
+
"locations": [
|
|
40
|
+
{
|
|
41
|
+
"physicalLocation": {
|
|
42
|
+
"artifactLocation": {
|
|
43
|
+
"uri": quote(finding.relative_path, safe="/"),
|
|
44
|
+
"uriBaseId": "%SRCROOT%",
|
|
45
|
+
},
|
|
46
|
+
"region": {
|
|
47
|
+
"startLine": occurrence.line_start,
|
|
48
|
+
"endLine": occurrence.line_end,
|
|
49
|
+
"snippet": {"text": occurrence.evidence},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"properties": {
|
|
55
|
+
"aggregateCount": finding.count,
|
|
56
|
+
"occurrencesComplete": len(finding.occurrences) == finding.count,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return (
|
|
61
|
+
json.dumps(
|
|
62
|
+
{
|
|
63
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
64
|
+
"version": "2.1.0",
|
|
65
|
+
"runs": [
|
|
66
|
+
{
|
|
67
|
+
"tool": {
|
|
68
|
+
"driver": {
|
|
69
|
+
"name": "MXReady",
|
|
70
|
+
"version": report.tool_version,
|
|
71
|
+
"rules": list(rules.values()),
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"versionControlProvenance": [
|
|
75
|
+
{
|
|
76
|
+
"repositoryUri": report.repository.url,
|
|
77
|
+
"revisionId": report.repository.commit,
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"results": results,
|
|
81
|
+
"properties": {
|
|
82
|
+
"scanId": str(report.scan_id),
|
|
83
|
+
"coverage": report.coverage.model_dump() if report.coverage else None,
|
|
84
|
+
"analysisWarnings": [w.model_dump() for w in report.analysis_warnings],
|
|
85
|
+
"scope": "Static analysis only; hardware verification is separate.",
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
ensure_ascii=False,
|
|
91
|
+
indent=2,
|
|
92
|
+
)
|
|
93
|
+
+ "\n"
|
|
94
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""终端极简结果摘要:一行状态 + 前若干条命中 + 报告文件位置。
|
|
2
|
+
|
|
3
|
+
中文输出只用 GBK 可编码字符(中文、…、—、·、ASCII);``english=True`` 时
|
|
4
|
+
输出纯 ASCII,供 cp1252 等无法编码中文的旧代码页回退使用。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mxready.models import ScanReport, Severity
|
|
10
|
+
|
|
11
|
+
_MAX_LISTED_FINDINGS = 5
|
|
12
|
+
_SEVERITY_LABELS = {
|
|
13
|
+
Severity.BLOCKER: "阻断",
|
|
14
|
+
Severity.WARNING: "警告",
|
|
15
|
+
Severity.INFO: "提示",
|
|
16
|
+
}
|
|
17
|
+
_SEVERITY_LABELS_EN = {
|
|
18
|
+
Severity.BLOCKER: "BLOCKER",
|
|
19
|
+
Severity.WARNING: "WARNING",
|
|
20
|
+
Severity.INFO: "INFO",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_terminal_summary(
|
|
25
|
+
report: ScanReport,
|
|
26
|
+
*,
|
|
27
|
+
output_note: str | None = None,
|
|
28
|
+
max_listed: int = _MAX_LISTED_FINDINGS,
|
|
29
|
+
english: bool = False,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""渲染适合直接阅读的极简检查结果;命中按严重程度排序,只列前若干条。
|
|
32
|
+
|
|
33
|
+
英文模式省略规则标题,避免中文标题在 cp1252 等旧代码页中无法输出。
|
|
34
|
+
"""
|
|
35
|
+
summary = report.summary
|
|
36
|
+
if english:
|
|
37
|
+
labels = _SEVERITY_LABELS_EN
|
|
38
|
+
lines = [
|
|
39
|
+
f"MXReady: {report.static_status.value} | "
|
|
40
|
+
f"blockers {summary.blocker_count}, "
|
|
41
|
+
f"warnings {summary.warning_count}, "
|
|
42
|
+
f"info {summary.info_count}"
|
|
43
|
+
]
|
|
44
|
+
else:
|
|
45
|
+
labels = _SEVERITY_LABELS
|
|
46
|
+
lines = [
|
|
47
|
+
f"MXReady: {report.static_status.value} | "
|
|
48
|
+
f"阻断 {summary.blocker_count} · "
|
|
49
|
+
f"警告 {summary.warning_count} · "
|
|
50
|
+
f"提示 {summary.info_count}"
|
|
51
|
+
]
|
|
52
|
+
if report.findings:
|
|
53
|
+
for finding in report.findings[:max_listed]:
|
|
54
|
+
count = f" x{finding.count}" if finding.count > 1 else ""
|
|
55
|
+
if english:
|
|
56
|
+
lines.append(
|
|
57
|
+
f" {labels[finding.severity]} {finding.rule_id}"
|
|
58
|
+
f" ({finding.relative_path}:{finding.line_start}{count})"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
lines.append(
|
|
62
|
+
f" {labels[finding.severity]} {finding.rule_id}"
|
|
63
|
+
f" {finding.title} ({finding.relative_path}:{finding.line_start}{count})"
|
|
64
|
+
)
|
|
65
|
+
hidden = len(report.findings) - max_listed
|
|
66
|
+
if hidden > 0:
|
|
67
|
+
if english:
|
|
68
|
+
lines.append(f" ... {hidden} more in the report file")
|
|
69
|
+
else:
|
|
70
|
+
lines.append(f" … 其余 {hidden} 项见报告文件")
|
|
71
|
+
else:
|
|
72
|
+
lines.append(" No rule matches" if english else " 未命中任何规则")
|
|
73
|
+
if output_note:
|
|
74
|
+
lines.append(f"Report: {output_note}" if english else f"报告: {output_note}")
|
|
75
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from mxready.repository.git_client import GitClient, RepositoryLimits
|
|
2
|
+
from mxready.repository.identity import (
|
|
3
|
+
RepositoryIdentity,
|
|
4
|
+
parse_repository_url,
|
|
5
|
+
validate_git_ref,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"GitClient",
|
|
10
|
+
"RepositoryIdentity",
|
|
11
|
+
"RepositoryLimits",
|
|
12
|
+
"parse_repository_url",
|
|
13
|
+
"validate_git_ref",
|
|
14
|
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from subprocess import CompletedProcess, TimeoutExpired
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from mxready.errors import MxReadyError
|
|
13
|
+
from mxready.repository.identity import RepositoryIdentity, validate_git_ref
|
|
14
|
+
|
|
15
|
+
_COMMIT = re.compile(r"[0-9a-fA-F]{40}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandRunner(Protocol):
|
|
19
|
+
def __call__(
|
|
20
|
+
self,
|
|
21
|
+
args: Sequence[str],
|
|
22
|
+
*,
|
|
23
|
+
cwd: Path | None,
|
|
24
|
+
env: Mapping[str, str],
|
|
25
|
+
timeout: float,
|
|
26
|
+
) -> CompletedProcess[str]: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class RepositoryLimits:
|
|
31
|
+
clone_timeout_seconds: float = 60
|
|
32
|
+
max_bytes: int = 52_428_800
|
|
33
|
+
max_files: int = 10_000
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if self.clone_timeout_seconds <= 0 or self.max_bytes <= 0 or self.max_files <= 0:
|
|
37
|
+
raise ValueError("repository limits must be positive")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_command(
|
|
41
|
+
args: Sequence[str],
|
|
42
|
+
*,
|
|
43
|
+
cwd: Path | None,
|
|
44
|
+
env: Mapping[str, str],
|
|
45
|
+
timeout: float,
|
|
46
|
+
) -> CompletedProcess[str]:
|
|
47
|
+
return subprocess.run(
|
|
48
|
+
list(args),
|
|
49
|
+
cwd=cwd,
|
|
50
|
+
env=dict(env),
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
errors="replace",
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GitClient:
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
command_runner: CommandRunner = run_command,
|
|
65
|
+
limits: RepositoryLimits | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._command_runner = command_runner
|
|
68
|
+
self.limits = limits or RepositoryLimits()
|
|
69
|
+
|
|
70
|
+
def clone(
|
|
71
|
+
self,
|
|
72
|
+
identity: RepositoryIdentity,
|
|
73
|
+
requested_ref: str | None,
|
|
74
|
+
destination: Path,
|
|
75
|
+
) -> str:
|
|
76
|
+
reference = validate_git_ref(requested_ref)
|
|
77
|
+
destination = Path(destination)
|
|
78
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
environment = self._git_environment()
|
|
80
|
+
|
|
81
|
+
if reference is not None and _COMMIT.fullmatch(reference):
|
|
82
|
+
self._clone_commit(identity, reference, destination, environment)
|
|
83
|
+
else:
|
|
84
|
+
self._clone_branch_or_default(
|
|
85
|
+
identity,
|
|
86
|
+
reference,
|
|
87
|
+
destination,
|
|
88
|
+
environment,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self._enforce_repository_limits(destination)
|
|
92
|
+
result = self._run(
|
|
93
|
+
["git", "rev-parse", "HEAD"],
|
|
94
|
+
cwd=destination,
|
|
95
|
+
environment=environment,
|
|
96
|
+
)
|
|
97
|
+
commit = result.stdout.strip().lower()
|
|
98
|
+
if _COMMIT.fullmatch(commit) is None:
|
|
99
|
+
raise MxReadyError(
|
|
100
|
+
"SCAN_INTERNAL_ERROR",
|
|
101
|
+
"Git 未能返回有效的提交编号,请稍后重试。",
|
|
102
|
+
)
|
|
103
|
+
return commit
|
|
104
|
+
|
|
105
|
+
def _clone_branch_or_default(
|
|
106
|
+
self,
|
|
107
|
+
identity: RepositoryIdentity,
|
|
108
|
+
reference: str | None,
|
|
109
|
+
destination: Path,
|
|
110
|
+
environment: Mapping[str, str],
|
|
111
|
+
) -> None:
|
|
112
|
+
args = [
|
|
113
|
+
"git",
|
|
114
|
+
"clone",
|
|
115
|
+
"--depth",
|
|
116
|
+
"1",
|
|
117
|
+
"--single-branch",
|
|
118
|
+
"--no-tags",
|
|
119
|
+
]
|
|
120
|
+
if reference is not None:
|
|
121
|
+
args.extend(["--branch", reference])
|
|
122
|
+
args.extend([identity.clone_url, str(destination)])
|
|
123
|
+
self._run(args, cwd=destination.parent, environment=environment)
|
|
124
|
+
|
|
125
|
+
def _clone_commit(
|
|
126
|
+
self,
|
|
127
|
+
identity: RepositoryIdentity,
|
|
128
|
+
reference: str,
|
|
129
|
+
destination: Path,
|
|
130
|
+
environment: Mapping[str, str],
|
|
131
|
+
) -> None:
|
|
132
|
+
self._run(
|
|
133
|
+
["git", "init", str(destination)],
|
|
134
|
+
cwd=destination.parent,
|
|
135
|
+
environment=environment,
|
|
136
|
+
)
|
|
137
|
+
self._run(
|
|
138
|
+
["git", "remote", "add", "origin", identity.clone_url],
|
|
139
|
+
cwd=destination,
|
|
140
|
+
environment=environment,
|
|
141
|
+
)
|
|
142
|
+
self._run(
|
|
143
|
+
[
|
|
144
|
+
"git",
|
|
145
|
+
"fetch",
|
|
146
|
+
"--depth",
|
|
147
|
+
"1",
|
|
148
|
+
"--no-tags",
|
|
149
|
+
"origin",
|
|
150
|
+
reference,
|
|
151
|
+
],
|
|
152
|
+
cwd=destination,
|
|
153
|
+
environment=environment,
|
|
154
|
+
)
|
|
155
|
+
self._run(
|
|
156
|
+
["git", "checkout", "--detach", "FETCH_HEAD"],
|
|
157
|
+
cwd=destination,
|
|
158
|
+
environment=environment,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _run(
|
|
162
|
+
self,
|
|
163
|
+
args: Sequence[str],
|
|
164
|
+
*,
|
|
165
|
+
cwd: Path,
|
|
166
|
+
environment: Mapping[str, str],
|
|
167
|
+
) -> CompletedProcess[str]:
|
|
168
|
+
try:
|
|
169
|
+
result = self._command_runner(
|
|
170
|
+
args,
|
|
171
|
+
cwd=cwd,
|
|
172
|
+
env=environment,
|
|
173
|
+
timeout=self.limits.clone_timeout_seconds,
|
|
174
|
+
)
|
|
175
|
+
except TimeoutExpired as error:
|
|
176
|
+
raise MxReadyError(
|
|
177
|
+
"CLONE_TIMEOUT",
|
|
178
|
+
"仓库获取超时,请确认仓库可公开访问或稍后重试。",
|
|
179
|
+
) from error
|
|
180
|
+
except OSError as error:
|
|
181
|
+
raise MxReadyError(
|
|
182
|
+
"SCAN_INTERNAL_ERROR",
|
|
183
|
+
"无法启动 Git,请检查服务环境后重试。",
|
|
184
|
+
) from error
|
|
185
|
+
|
|
186
|
+
if result.returncode != 0:
|
|
187
|
+
raise self._map_git_failure(result.stderr)
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
def _enforce_repository_limits(self, destination: Path) -> None:
|
|
191
|
+
total_bytes = 0
|
|
192
|
+
total_files = 0
|
|
193
|
+
pending = [destination]
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
while pending:
|
|
197
|
+
directory = pending.pop()
|
|
198
|
+
with os.scandir(directory) as entries:
|
|
199
|
+
for entry in entries:
|
|
200
|
+
if entry.is_symlink():
|
|
201
|
+
continue
|
|
202
|
+
if entry.is_dir(follow_symlinks=False):
|
|
203
|
+
pending.append(Path(entry.path))
|
|
204
|
+
continue
|
|
205
|
+
if not entry.is_file(follow_symlinks=False):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
total_files += 1
|
|
209
|
+
if total_files > self.limits.max_files:
|
|
210
|
+
raise MxReadyError(
|
|
211
|
+
"TOO_MANY_FILES",
|
|
212
|
+
f"仓库文件数超过 {self.limits.max_files} 个限制。",
|
|
213
|
+
)
|
|
214
|
+
total_bytes += entry.stat(follow_symlinks=False).st_size
|
|
215
|
+
if total_bytes > self.limits.max_bytes:
|
|
216
|
+
raise MxReadyError(
|
|
217
|
+
"REPOSITORY_TOO_LARGE",
|
|
218
|
+
f"仓库内容超过 {self.limits.max_bytes} 字节限制。",
|
|
219
|
+
)
|
|
220
|
+
except MxReadyError:
|
|
221
|
+
raise
|
|
222
|
+
except OSError as error:
|
|
223
|
+
raise MxReadyError(
|
|
224
|
+
"SCAN_INTERNAL_ERROR",
|
|
225
|
+
"无法安全读取克隆后的仓库。",
|
|
226
|
+
) from error
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _git_environment() -> dict[str, str]:
|
|
230
|
+
environment = os.environ.copy()
|
|
231
|
+
environment.update(
|
|
232
|
+
{
|
|
233
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
234
|
+
"GIT_LFS_SKIP_SMUDGE": "1",
|
|
235
|
+
"GIT_CONFIG_COUNT": "1",
|
|
236
|
+
"GIT_CONFIG_KEY_0": "credential.helper",
|
|
237
|
+
"GIT_CONFIG_VALUE_0": "",
|
|
238
|
+
"LANG": "C.UTF-8",
|
|
239
|
+
"LC_ALL": "C.UTF-8",
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
return environment
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _map_git_failure(stderr: str) -> MxReadyError:
|
|
246
|
+
normalized = stderr.casefold()
|
|
247
|
+
access_markers = (
|
|
248
|
+
"authentication failed",
|
|
249
|
+
"access denied",
|
|
250
|
+
"permission denied",
|
|
251
|
+
"could not read username",
|
|
252
|
+
"terminal prompts disabled",
|
|
253
|
+
)
|
|
254
|
+
not_found_markers = (
|
|
255
|
+
"repository not found",
|
|
256
|
+
"does not exist",
|
|
257
|
+
"couldn't find remote ref",
|
|
258
|
+
"could not find remote ref",
|
|
259
|
+
"not found",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if any(marker in normalized for marker in access_markers):
|
|
263
|
+
return MxReadyError(
|
|
264
|
+
"REPOSITORY_ACCESS_DENIED",
|
|
265
|
+
"仓库拒绝访问;MXReady 的 MVP 只支持无需登录的公开仓库。",
|
|
266
|
+
)
|
|
267
|
+
if any(marker in normalized for marker in not_found_markers):
|
|
268
|
+
return MxReadyError(
|
|
269
|
+
"REPOSITORY_NOT_FOUND",
|
|
270
|
+
"未找到仓库或指定分支、标签、提交,请检查输入。",
|
|
271
|
+
)
|
|
272
|
+
return MxReadyError(
|
|
273
|
+
"SCAN_INTERNAL_ERROR",
|
|
274
|
+
"Git 获取仓库失败,请稍后重试。",
|
|
275
|
+
)
|