tg-bot-plugin-contract-core 0.1.1__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.
- tg_bot_plugin_contract_core/__init__.py +39 -0
- tg_bot_plugin_contract_core/core.py +760 -0
- tg_bot_plugin_contract_core/errors.py +11 -0
- tg_bot_plugin_contract_core/models.py +102 -0
- tg_bot_plugin_contract_core-0.1.1.dist-info/METADATA +114 -0
- tg_bot_plugin_contract_core-0.1.1.dist-info/RECORD +7 -0
- tg_bot_plugin_contract_core-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""TG-BOT 插件 bundle 的共享正式合同辅助接口。"""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
compute_artifact_digest,
|
|
5
|
+
compute_package_sha256,
|
|
6
|
+
inspect_bundle,
|
|
7
|
+
match_signer_identity,
|
|
8
|
+
validate_bundle_same_profile,
|
|
9
|
+
validate_bundle_target_profile,
|
|
10
|
+
verify_bundle,
|
|
11
|
+
write_reproducible_bundle,
|
|
12
|
+
)
|
|
13
|
+
from .errors import ContractCoreError
|
|
14
|
+
from .models import (
|
|
15
|
+
ArtifactDescriptor,
|
|
16
|
+
BundleInspectionResult,
|
|
17
|
+
BundleVerificationResult,
|
|
18
|
+
PluginManifest,
|
|
19
|
+
SignerIdentity,
|
|
20
|
+
SignerIdentityMatchResult,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ArtifactDescriptor",
|
|
25
|
+
"BundleInspectionResult",
|
|
26
|
+
"BundleVerificationResult",
|
|
27
|
+
"ContractCoreError",
|
|
28
|
+
"PluginManifest",
|
|
29
|
+
"SignerIdentity",
|
|
30
|
+
"SignerIdentityMatchResult",
|
|
31
|
+
"compute_artifact_digest",
|
|
32
|
+
"compute_package_sha256",
|
|
33
|
+
"inspect_bundle",
|
|
34
|
+
"match_signer_identity",
|
|
35
|
+
"validate_bundle_same_profile",
|
|
36
|
+
"validate_bundle_target_profile",
|
|
37
|
+
"verify_bundle",
|
|
38
|
+
"write_reproducible_bundle",
|
|
39
|
+
]
|
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""TG-BOT 插件 manifest、bundle、digest 与 signer 的核心辅助实现。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib.machinery
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Any, Mapping, Sequence
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
from .errors import ContractCoreError
|
|
17
|
+
from .models import (
|
|
18
|
+
ArtifactDescriptor,
|
|
19
|
+
BundleInspectionResult,
|
|
20
|
+
BundleVerificationResult,
|
|
21
|
+
PluginManifest,
|
|
22
|
+
SignerIdentity,
|
|
23
|
+
SignerIdentityMatchResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
MANIFEST_FILE_NAME = "manifest.json"
|
|
27
|
+
SIGNATURE_BUNDLE_PATH = "META-INF/sigstore.bundle.json"
|
|
28
|
+
SBOM_PATH = "META-INF/sbom.json"
|
|
29
|
+
SUPPORTED_BUNDLE_SUFFIX = ".tgpkg"
|
|
30
|
+
FIXED_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
|
31
|
+
ZIP_FILE_MODE = 0o100644
|
|
32
|
+
ZIP_DIR_MODE = 0o40755
|
|
33
|
+
DEFLATE_COMPRESSLEVEL = 9
|
|
34
|
+
|
|
35
|
+
_MANIFEST_FIELDS = {
|
|
36
|
+
"plugin_id",
|
|
37
|
+
"plugin_version",
|
|
38
|
+
"name",
|
|
39
|
+
"description",
|
|
40
|
+
"category",
|
|
41
|
+
"runtime_profile_id",
|
|
42
|
+
"artifact_digest",
|
|
43
|
+
"entrypoint",
|
|
44
|
+
"config_schema",
|
|
45
|
+
"interaction_schema",
|
|
46
|
+
"declared_scopes",
|
|
47
|
+
"declared_capabilities",
|
|
48
|
+
}
|
|
49
|
+
_ENTRYPOINT_FIELDS = {"module_path", "symbol"}
|
|
50
|
+
_IGNORED_DIGEST_FILE_NAMES = {MANIFEST_FILE_NAME, ".DS_Store"}
|
|
51
|
+
_IGNORED_DIGEST_PARTS = {"__pycache__"}
|
|
52
|
+
_IGNORED_DIGEST_SUFFIXES = {".pyc", SUPPORTED_BUNDLE_SUFFIX}
|
|
53
|
+
_STORED_SUFFIXES = {
|
|
54
|
+
".7z",
|
|
55
|
+
".bz2",
|
|
56
|
+
".dll",
|
|
57
|
+
".dylib",
|
|
58
|
+
".gif",
|
|
59
|
+
".gz",
|
|
60
|
+
".ico",
|
|
61
|
+
".jar",
|
|
62
|
+
".jpeg",
|
|
63
|
+
".jpg",
|
|
64
|
+
".mp3",
|
|
65
|
+
".mp4",
|
|
66
|
+
".pdf",
|
|
67
|
+
".png",
|
|
68
|
+
".pyd",
|
|
69
|
+
".so",
|
|
70
|
+
".tgz",
|
|
71
|
+
".tgpkg",
|
|
72
|
+
".webp",
|
|
73
|
+
".woff",
|
|
74
|
+
".woff2",
|
|
75
|
+
".xz",
|
|
76
|
+
".zip",
|
|
77
|
+
}
|
|
78
|
+
_TRUSTED_SIGNER_RULE_FIELDS = {
|
|
79
|
+
"rule_id",
|
|
80
|
+
"issuer",
|
|
81
|
+
"repository_owner",
|
|
82
|
+
"repository_name",
|
|
83
|
+
"workflow_ref",
|
|
84
|
+
"reusable_workflow_ref",
|
|
85
|
+
"ref_pattern",
|
|
86
|
+
"status",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stable_json_dumps(payload: Any) -> str:
|
|
91
|
+
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _clone_json(value: Any) -> Any:
|
|
95
|
+
return json.loads(stable_json_dumps(value))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _normalize_json_object(raw: Any, *, field_name: str) -> dict[str, Any]:
|
|
99
|
+
if raw in (None, ""):
|
|
100
|
+
return {}
|
|
101
|
+
if isinstance(raw, dict):
|
|
102
|
+
return _clone_json(raw)
|
|
103
|
+
raise ContractCoreError("manifest_invalid", f"{field_name} must be an object")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _normalize_json_array(raw: Any, *, field_name: str) -> list[Any]:
|
|
107
|
+
if raw in (None, ""):
|
|
108
|
+
return []
|
|
109
|
+
if isinstance(raw, list):
|
|
110
|
+
return _clone_json(raw)
|
|
111
|
+
raise ContractCoreError("manifest_invalid", f"{field_name} must be an array")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _normalize_manifest_payload(
|
|
115
|
+
payload: Mapping[str, Any],
|
|
116
|
+
*,
|
|
117
|
+
require_artifact_digest: bool,
|
|
118
|
+
) -> PluginManifest:
|
|
119
|
+
if not isinstance(payload, Mapping):
|
|
120
|
+
raise ContractCoreError("manifest_invalid", "manifest root must be an object")
|
|
121
|
+
payload_dict = dict(payload)
|
|
122
|
+
|
|
123
|
+
unknown_fields = sorted(set(payload_dict) - _MANIFEST_FIELDS)
|
|
124
|
+
if unknown_fields:
|
|
125
|
+
raise ContractCoreError(
|
|
126
|
+
"manifest_unknown_field",
|
|
127
|
+
f"manifest contains unsupported fields: {', '.join(unknown_fields)}",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
plugin_id = str(payload_dict.get("plugin_id") or "").strip()
|
|
131
|
+
plugin_version = str(payload_dict.get("plugin_version") or "").strip()
|
|
132
|
+
name = str(payload_dict.get("name") or "").strip()
|
|
133
|
+
description = str(payload_dict.get("description") or "").strip()
|
|
134
|
+
category = str(payload_dict.get("category") or "").strip()
|
|
135
|
+
runtime_profile_id = str(payload_dict.get("runtime_profile_id") or "").strip()
|
|
136
|
+
artifact_digest = str(payload_dict.get("artifact_digest") or "").strip()
|
|
137
|
+
|
|
138
|
+
entrypoint_raw = payload_dict.get("entrypoint")
|
|
139
|
+
if not isinstance(entrypoint_raw, Mapping):
|
|
140
|
+
raise ContractCoreError("manifest_invalid", "entrypoint must be an object")
|
|
141
|
+
entrypoint_dict = dict(entrypoint_raw)
|
|
142
|
+
unknown_entrypoint_fields = sorted(set(entrypoint_dict) - _ENTRYPOINT_FIELDS)
|
|
143
|
+
if unknown_entrypoint_fields:
|
|
144
|
+
raise ContractCoreError(
|
|
145
|
+
"manifest_unknown_field",
|
|
146
|
+
f"entrypoint contains unsupported fields: {', '.join(unknown_entrypoint_fields)}",
|
|
147
|
+
)
|
|
148
|
+
module_path = str(entrypoint_dict.get("module_path") or "").strip()
|
|
149
|
+
symbol = str(entrypoint_dict.get("symbol") or "").strip()
|
|
150
|
+
|
|
151
|
+
if not plugin_id:
|
|
152
|
+
raise ContractCoreError("manifest_invalid", "plugin_id is required")
|
|
153
|
+
if not plugin_version:
|
|
154
|
+
raise ContractCoreError("manifest_invalid", f"plugin {plugin_id} missing plugin_version")
|
|
155
|
+
if not name:
|
|
156
|
+
raise ContractCoreError("manifest_invalid", f"plugin {plugin_id} missing name")
|
|
157
|
+
if not description:
|
|
158
|
+
raise ContractCoreError("manifest_invalid", f"plugin {plugin_id} missing description")
|
|
159
|
+
if not category:
|
|
160
|
+
raise ContractCoreError("manifest_invalid", f"plugin {plugin_id} missing category")
|
|
161
|
+
if not runtime_profile_id:
|
|
162
|
+
raise ContractCoreError("manifest_invalid", f"plugin {plugin_id} missing runtime_profile_id")
|
|
163
|
+
if require_artifact_digest and not artifact_digest:
|
|
164
|
+
raise ContractCoreError("manifest_invalid", f"plugin {plugin_id} missing artifact_digest")
|
|
165
|
+
if not module_path or not symbol:
|
|
166
|
+
raise ContractCoreError(
|
|
167
|
+
"manifest_invalid",
|
|
168
|
+
f"plugin {plugin_id} missing entrypoint.module_path or entrypoint.symbol",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
config_schema = _normalize_json_object(payload_dict.get("config_schema"), field_name="config_schema")
|
|
172
|
+
interaction_schema = _normalize_json_object(payload_dict.get("interaction_schema"), field_name="interaction_schema")
|
|
173
|
+
declared_scopes = _normalize_json_array(payload_dict.get("declared_scopes"), field_name="declared_scopes")
|
|
174
|
+
declared_capabilities = _normalize_json_array(
|
|
175
|
+
payload_dict.get("declared_capabilities"),
|
|
176
|
+
field_name="declared_capabilities",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
normalized_payload = _clone_json(payload_dict)
|
|
180
|
+
normalized_payload["plugin_id"] = plugin_id
|
|
181
|
+
normalized_payload["plugin_version"] = plugin_version
|
|
182
|
+
normalized_payload["name"] = name
|
|
183
|
+
normalized_payload["description"] = description
|
|
184
|
+
normalized_payload["category"] = category
|
|
185
|
+
normalized_payload["runtime_profile_id"] = runtime_profile_id
|
|
186
|
+
normalized_payload["artifact_digest"] = artifact_digest
|
|
187
|
+
normalized_payload["entrypoint"] = {"module_path": module_path, "symbol": symbol}
|
|
188
|
+
normalized_payload["config_schema"] = config_schema
|
|
189
|
+
normalized_payload["interaction_schema"] = interaction_schema
|
|
190
|
+
normalized_payload["declared_scopes"] = declared_scopes
|
|
191
|
+
normalized_payload["declared_capabilities"] = declared_capabilities
|
|
192
|
+
|
|
193
|
+
return PluginManifest(
|
|
194
|
+
plugin_id=plugin_id,
|
|
195
|
+
plugin_version=plugin_version,
|
|
196
|
+
name=name,
|
|
197
|
+
description=description,
|
|
198
|
+
category=category,
|
|
199
|
+
runtime_profile_id=runtime_profile_id,
|
|
200
|
+
artifact_digest=artifact_digest,
|
|
201
|
+
entrypoint={"module_path": module_path, "symbol": symbol},
|
|
202
|
+
config_schema=config_schema,
|
|
203
|
+
interaction_schema=interaction_schema,
|
|
204
|
+
declared_scopes=declared_scopes,
|
|
205
|
+
declared_capabilities=declared_capabilities,
|
|
206
|
+
raw=normalized_payload,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _coerce_manifest(payload: Mapping[str, Any] | PluginManifest, *, require_artifact_digest: bool) -> PluginManifest:
|
|
211
|
+
if isinstance(payload, PluginManifest):
|
|
212
|
+
manifest = payload
|
|
213
|
+
else:
|
|
214
|
+
manifest = _normalize_manifest_payload(payload, require_artifact_digest=require_artifact_digest)
|
|
215
|
+
if require_artifact_digest and not manifest.artifact_digest:
|
|
216
|
+
raise ContractCoreError("manifest_invalid", f"plugin {manifest.plugin_id} missing artifact_digest")
|
|
217
|
+
return manifest
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _ensure_directory(path_like: str | os.PathLike[str]) -> Path:
|
|
221
|
+
path = Path(path_like).expanduser().resolve()
|
|
222
|
+
if not path.exists():
|
|
223
|
+
raise ContractCoreError("path_missing", f"path not found: {path}")
|
|
224
|
+
if not path.is_dir():
|
|
225
|
+
raise ContractCoreError("path_invalid", f"path is not a directory: {path}")
|
|
226
|
+
return path
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _ensure_bundle_path(path_like: str | os.PathLike[str]) -> Path:
|
|
230
|
+
path = Path(path_like).expanduser().resolve()
|
|
231
|
+
if not path.is_file():
|
|
232
|
+
raise ContractCoreError("bundle_missing", f"bundle not found: {path}")
|
|
233
|
+
if path.suffix != SUPPORTED_BUNDLE_SUFFIX:
|
|
234
|
+
raise ContractCoreError("bundle_format_invalid", f"unsupported bundle format: {path.name}")
|
|
235
|
+
return path
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _file_sha256(path: Path) -> str:
|
|
239
|
+
digest = hashlib.sha256()
|
|
240
|
+
with path.open("rb") as handle:
|
|
241
|
+
while True:
|
|
242
|
+
chunk = handle.read(64 * 1024)
|
|
243
|
+
if not chunk:
|
|
244
|
+
break
|
|
245
|
+
digest.update(chunk)
|
|
246
|
+
return digest.hexdigest()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _should_exclude_from_artifact_digest(path: Path, plugin_dir: Path) -> bool:
|
|
250
|
+
if not path.is_file():
|
|
251
|
+
return True
|
|
252
|
+
relpath = path.relative_to(plugin_dir)
|
|
253
|
+
if any(part in _IGNORED_DIGEST_PARTS for part in relpath.parts):
|
|
254
|
+
return True
|
|
255
|
+
if path.name in _IGNORED_DIGEST_FILE_NAMES:
|
|
256
|
+
return True
|
|
257
|
+
if path.suffix in _IGNORED_DIGEST_SUFFIXES:
|
|
258
|
+
return True
|
|
259
|
+
relpath_posix = relpath.as_posix()
|
|
260
|
+
if relpath_posix in {SIGNATURE_BUNDLE_PATH, SBOM_PATH}:
|
|
261
|
+
return True
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _build_artifact_descriptor(plugin_dir: Path, manifest_payload: Mapping[str, Any] | PluginManifest) -> ArtifactDescriptor:
|
|
266
|
+
manifest = _coerce_manifest(manifest_payload, require_artifact_digest=False)
|
|
267
|
+
manifest_normalized = _clone_json(manifest.raw)
|
|
268
|
+
manifest_normalized.pop("artifact_digest", None)
|
|
269
|
+
|
|
270
|
+
files: list[dict[str, Any]] = []
|
|
271
|
+
for path in sorted(plugin_dir.rglob("*"), key=lambda candidate: candidate.relative_to(plugin_dir).as_posix()):
|
|
272
|
+
if _should_exclude_from_artifact_digest(path, plugin_dir):
|
|
273
|
+
continue
|
|
274
|
+
relpath = path.relative_to(plugin_dir).as_posix()
|
|
275
|
+
files.append(
|
|
276
|
+
{
|
|
277
|
+
"path": relpath,
|
|
278
|
+
"sha256": _file_sha256(path),
|
|
279
|
+
"size": path.stat().st_size,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return ArtifactDescriptor(
|
|
284
|
+
manifest_normalized=manifest_normalized,
|
|
285
|
+
files=tuple(files),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def compute_artifact_digest(
|
|
290
|
+
plugin_dir: str | os.PathLike[str],
|
|
291
|
+
manifest_payload: Mapping[str, Any] | PluginManifest,
|
|
292
|
+
) -> str:
|
|
293
|
+
plugin_root = _ensure_directory(plugin_dir)
|
|
294
|
+
descriptor = _build_artifact_descriptor(plugin_root, manifest_payload)
|
|
295
|
+
return hashlib.sha256(stable_json_dumps({
|
|
296
|
+
"manifest_normalized": descriptor.manifest_normalized,
|
|
297
|
+
"files": list(descriptor.files),
|
|
298
|
+
}).encode("utf-8")).hexdigest()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def compute_package_sha256(bundle_path: str | os.PathLike[str]) -> str:
|
|
302
|
+
return _file_sha256(_ensure_bundle_path(bundle_path))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _normalize_archive_member(name: str) -> PurePosixPath:
|
|
306
|
+
relpath = PurePosixPath(str(name or "").strip())
|
|
307
|
+
if not relpath.parts:
|
|
308
|
+
raise ContractCoreError("bundle_entry_invalid", "bundle contains an empty archive member")
|
|
309
|
+
if relpath.is_absolute() or ".." in relpath.parts:
|
|
310
|
+
raise ContractCoreError("bundle_entry_invalid", f"bundle contains unsafe archive member: {name}")
|
|
311
|
+
return relpath
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _extract_bundle(bundle_path: Path, target_dir: Path) -> tuple[str, ...]:
|
|
315
|
+
archive_entries: list[str] = []
|
|
316
|
+
with zipfile.ZipFile(bundle_path) as archive:
|
|
317
|
+
for info in archive.infolist():
|
|
318
|
+
relpath = _normalize_archive_member(info.filename)
|
|
319
|
+
normalized_name = relpath.as_posix()
|
|
320
|
+
archive_entries.append(normalized_name)
|
|
321
|
+
destination = target_dir / normalized_name
|
|
322
|
+
if info.is_dir():
|
|
323
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
continue
|
|
325
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
with archive.open(info, "r") as source, destination.open("wb") as sink:
|
|
327
|
+
sink.write(source.read())
|
|
328
|
+
return tuple(sorted(archive_entries))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _resolve_entrypoint_relpath(plugin_dir: Path, module_path: str) -> str:
|
|
332
|
+
normalized = str(module_path or "").strip().replace("\\", "/").replace(".", "/").strip("/")
|
|
333
|
+
candidates: list[Path] = []
|
|
334
|
+
compiled_candidates: list[Path] = []
|
|
335
|
+
if normalized in {"", "__init__"}:
|
|
336
|
+
candidates.append(plugin_dir / "__init__.py")
|
|
337
|
+
compiled_candidates.extend(sorted(plugin_dir.glob("__init__.*")))
|
|
338
|
+
else:
|
|
339
|
+
normalized_path = Path(normalized)
|
|
340
|
+
direct_module = plugin_dir / normalized_path
|
|
341
|
+
package_module = plugin_dir / normalized_path / "__init__"
|
|
342
|
+
candidates.append(direct_module.with_suffix(".py"))
|
|
343
|
+
candidates.append(package_module.with_suffix(".py"))
|
|
344
|
+
compiled_candidates.extend(sorted(direct_module.parent.glob(f"{direct_module.name}.*")))
|
|
345
|
+
compiled_candidates.extend(sorted(package_module.parent.glob(f"{package_module.name}.*")))
|
|
346
|
+
for suffix in importlib.machinery.EXTENSION_SUFFIXES:
|
|
347
|
+
if normalized in {"", "__init__"}:
|
|
348
|
+
candidates.append(plugin_dir / f"__init__{suffix}")
|
|
349
|
+
else:
|
|
350
|
+
candidates.append(plugin_dir / f"{normalized}{suffix}")
|
|
351
|
+
candidates.append(plugin_dir / normalized / f"__init__{suffix}")
|
|
352
|
+
for candidate in candidates:
|
|
353
|
+
if candidate.is_file():
|
|
354
|
+
return candidate.relative_to(plugin_dir).as_posix()
|
|
355
|
+
for candidate in compiled_candidates:
|
|
356
|
+
if candidate.is_file():
|
|
357
|
+
return candidate.relative_to(plugin_dir).as_posix()
|
|
358
|
+
raise ContractCoreError(
|
|
359
|
+
"entrypoint_missing",
|
|
360
|
+
f"entrypoint module not found for module_path={module_path}",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def inspect_bundle(bundle_path: str | os.PathLike[str]) -> BundleInspectionResult:
|
|
365
|
+
resolved_bundle_path = _ensure_bundle_path(bundle_path)
|
|
366
|
+
package_sha256 = compute_package_sha256(resolved_bundle_path)
|
|
367
|
+
|
|
368
|
+
with tempfile.TemporaryDirectory(prefix="tg-bot-plugin-contract-core-") as temp_dir_str:
|
|
369
|
+
extracted_root = Path(temp_dir_str)
|
|
370
|
+
archive_entries = _extract_bundle(resolved_bundle_path, extracted_root)
|
|
371
|
+
manifest_path = extracted_root / MANIFEST_FILE_NAME
|
|
372
|
+
if not manifest_path.is_file():
|
|
373
|
+
raise ContractCoreError("manifest_missing", "bundle missing manifest.json at archive root")
|
|
374
|
+
try:
|
|
375
|
+
manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
376
|
+
except json.JSONDecodeError as exc:
|
|
377
|
+
raise ContractCoreError("manifest_invalid", f"manifest json invalid: {manifest_path}") from exc
|
|
378
|
+
manifest = _coerce_manifest(manifest_payload, require_artifact_digest=True)
|
|
379
|
+
artifact_descriptor = _build_artifact_descriptor(extracted_root, manifest)
|
|
380
|
+
computed_digest = compute_artifact_digest(extracted_root, manifest)
|
|
381
|
+
if computed_digest != manifest.artifact_digest:
|
|
382
|
+
raise ContractCoreError(
|
|
383
|
+
"artifact_digest_mismatch",
|
|
384
|
+
"artifact_digest mismatch between manifest and extracted runtime",
|
|
385
|
+
)
|
|
386
|
+
entrypoint_relpath = _resolve_entrypoint_relpath(extracted_root, manifest.entrypoint["module_path"])
|
|
387
|
+
signature_bundle_present = (extracted_root / SIGNATURE_BUNDLE_PATH).is_file()
|
|
388
|
+
return BundleInspectionResult(
|
|
389
|
+
bundle_path=resolved_bundle_path,
|
|
390
|
+
bundle_name=resolved_bundle_path.name,
|
|
391
|
+
plugin_id=manifest.plugin_id,
|
|
392
|
+
plugin_version=manifest.plugin_version,
|
|
393
|
+
runtime_profile_id=manifest.runtime_profile_id,
|
|
394
|
+
artifact_digest=manifest.artifact_digest,
|
|
395
|
+
package_sha256=package_sha256,
|
|
396
|
+
manifest=manifest,
|
|
397
|
+
artifact_descriptor=artifact_descriptor,
|
|
398
|
+
archive_entries=archive_entries,
|
|
399
|
+
entrypoint_relpath=entrypoint_relpath,
|
|
400
|
+
signature_bundle_path=SIGNATURE_BUNDLE_PATH if signature_bundle_present else "",
|
|
401
|
+
signature_bundle_present=signature_bundle_present,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _read_archive_member(bundle_path: Path, member_name: str) -> bytes:
|
|
406
|
+
normalized_name = _normalize_archive_member(member_name).as_posix()
|
|
407
|
+
with zipfile.ZipFile(bundle_path) as archive:
|
|
408
|
+
try:
|
|
409
|
+
with archive.open(normalized_name, "r") as handle:
|
|
410
|
+
return handle.read()
|
|
411
|
+
except KeyError as exc:
|
|
412
|
+
raise ContractCoreError(
|
|
413
|
+
"bundle_entry_missing",
|
|
414
|
+
f"bundle missing archive member: {normalized_name}",
|
|
415
|
+
) from exc
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _certificate_extension_text(
|
|
419
|
+
certificate: Any,
|
|
420
|
+
oid_value: str,
|
|
421
|
+
*,
|
|
422
|
+
der_encoded_utf8: bool,
|
|
423
|
+
required: bool = True,
|
|
424
|
+
) -> str:
|
|
425
|
+
try:
|
|
426
|
+
from cryptography.x509.oid import ObjectIdentifier
|
|
427
|
+
except ImportError as exc:
|
|
428
|
+
raise ContractCoreError("sigstore_dependency_missing", "cryptography is required for signer extraction") from exc
|
|
429
|
+
try:
|
|
430
|
+
extension = certificate.extensions.get_extension_for_oid(ObjectIdentifier(oid_value)).value
|
|
431
|
+
except Exception:
|
|
432
|
+
if required:
|
|
433
|
+
raise ContractCoreError("signer_identity_missing", f"certificate missing extension {oid_value}")
|
|
434
|
+
return ""
|
|
435
|
+
raw_value = extension.value
|
|
436
|
+
if der_encoded_utf8:
|
|
437
|
+
try:
|
|
438
|
+
from pyasn1.codec.der.decoder import decode as der_decode
|
|
439
|
+
from pyasn1.type.char import UTF8String
|
|
440
|
+
except ImportError as exc:
|
|
441
|
+
raise ContractCoreError("sigstore_dependency_missing", "pyasn1 is required for signer extraction") from exc
|
|
442
|
+
decoded = der_decode(raw_value, asn1Spec=UTF8String())[0]
|
|
443
|
+
return str(decoded).strip()
|
|
444
|
+
return raw_value.decode().strip()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _normalize_github_locator(value: str) -> str:
|
|
448
|
+
normalized = str(value or "").strip()
|
|
449
|
+
if not normalized:
|
|
450
|
+
return ""
|
|
451
|
+
if normalized.startswith("https://github.com/"):
|
|
452
|
+
return normalized.removeprefix("https://github.com/").strip("/")
|
|
453
|
+
if normalized.startswith("http://github.com/"):
|
|
454
|
+
return normalized.removeprefix("http://github.com/").strip("/")
|
|
455
|
+
return normalized.strip("/")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _parse_repository_owner_name(value: str) -> tuple[str, str]:
|
|
459
|
+
normalized = str(value or "").strip()
|
|
460
|
+
if not normalized:
|
|
461
|
+
return "", ""
|
|
462
|
+
if normalized.startswith("http://") or normalized.startswith("https://"):
|
|
463
|
+
parsed = urlparse(normalized)
|
|
464
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
465
|
+
else:
|
|
466
|
+
parts = [part for part in normalized.strip("/").split("/") if part]
|
|
467
|
+
if len(parts) < 2:
|
|
468
|
+
return "", ""
|
|
469
|
+
return parts[0], parts[1]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _extract_signer_identity(bundle: Any) -> SignerIdentity:
|
|
473
|
+
certificate = bundle.signing_certificate
|
|
474
|
+
issuer = _certificate_extension_text(certificate, "1.3.6.1.4.1.57264.1.8", der_encoded_utf8=True)
|
|
475
|
+
repository_uri = _certificate_extension_text(
|
|
476
|
+
certificate,
|
|
477
|
+
"1.3.6.1.4.1.57264.1.12",
|
|
478
|
+
der_encoded_utf8=True,
|
|
479
|
+
required=False,
|
|
480
|
+
)
|
|
481
|
+
workflow_repository = _certificate_extension_text(
|
|
482
|
+
certificate,
|
|
483
|
+
"1.3.6.1.4.1.57264.1.5",
|
|
484
|
+
der_encoded_utf8=False,
|
|
485
|
+
required=False,
|
|
486
|
+
)
|
|
487
|
+
repository_owner, repository_name = _parse_repository_owner_name(repository_uri or workflow_repository)
|
|
488
|
+
workflow_ref = _certificate_extension_text(
|
|
489
|
+
certificate,
|
|
490
|
+
"1.3.6.1.4.1.57264.1.6",
|
|
491
|
+
der_encoded_utf8=False,
|
|
492
|
+
required=False,
|
|
493
|
+
)
|
|
494
|
+
reusable_workflow_ref = _normalize_github_locator(
|
|
495
|
+
_certificate_extension_text(
|
|
496
|
+
certificate,
|
|
497
|
+
"1.3.6.1.4.1.57264.1.18",
|
|
498
|
+
der_encoded_utf8=True,
|
|
499
|
+
required=False,
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
ref = _certificate_extension_text(
|
|
503
|
+
certificate,
|
|
504
|
+
"1.3.6.1.4.1.57264.1.14",
|
|
505
|
+
der_encoded_utf8=True,
|
|
506
|
+
required=False,
|
|
507
|
+
)
|
|
508
|
+
return SignerIdentity(
|
|
509
|
+
issuer=issuer,
|
|
510
|
+
repository_owner=repository_owner,
|
|
511
|
+
repository_name=repository_name,
|
|
512
|
+
workflow_ref=workflow_ref,
|
|
513
|
+
reusable_workflow_ref=reusable_workflow_ref,
|
|
514
|
+
ref=ref,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _verify_sigstore_bundle(
|
|
519
|
+
bundle_path: Path,
|
|
520
|
+
*,
|
|
521
|
+
signature_bundle_raw: bytes,
|
|
522
|
+
offline: bool,
|
|
523
|
+
) -> SignerIdentity:
|
|
524
|
+
try:
|
|
525
|
+
from sigstore.models import Bundle as SigstoreBundle
|
|
526
|
+
from sigstore.verify import Verifier
|
|
527
|
+
from sigstore.verify.policy import UnsafeNoOp
|
|
528
|
+
except ImportError as exc:
|
|
529
|
+
raise ContractCoreError(
|
|
530
|
+
"sigstore_dependency_missing",
|
|
531
|
+
"sigstore dependencies are required for bundle verification",
|
|
532
|
+
) from exc
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
sigstore_bundle = SigstoreBundle.from_json(signature_bundle_raw)
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
raise ContractCoreError("signature_bundle_invalid", f"failed to parse sigstore bundle: {exc}") from exc
|
|
538
|
+
|
|
539
|
+
artifact_bytes = bundle_path.read_bytes()
|
|
540
|
+
try:
|
|
541
|
+
verifier = Verifier.production(offline=offline)
|
|
542
|
+
verifier.verify_artifact(artifact_bytes, sigstore_bundle, UnsafeNoOp())
|
|
543
|
+
except Exception as exc:
|
|
544
|
+
raise ContractCoreError("signature_invalid", f"sigstore verification failed: {exc}") from exc
|
|
545
|
+
|
|
546
|
+
return _extract_signer_identity(sigstore_bundle)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _coerce_inspection(bundle: str | os.PathLike[str] | BundleInspectionResult) -> BundleInspectionResult:
|
|
550
|
+
if isinstance(bundle, BundleInspectionResult):
|
|
551
|
+
return bundle
|
|
552
|
+
return inspect_bundle(bundle)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def verify_bundle(
|
|
556
|
+
bundle: str | os.PathLike[str] | BundleInspectionResult,
|
|
557
|
+
*,
|
|
558
|
+
allow_unsigned_dev: bool = False,
|
|
559
|
+
offline: bool = False,
|
|
560
|
+
) -> BundleVerificationResult:
|
|
561
|
+
inspection = _coerce_inspection(bundle)
|
|
562
|
+
if not inspection.signature_bundle_present:
|
|
563
|
+
if allow_unsigned_dev:
|
|
564
|
+
return BundleVerificationResult(
|
|
565
|
+
inspection=inspection,
|
|
566
|
+
signer_identity=None,
|
|
567
|
+
signature_verification_status="unsigned_dev",
|
|
568
|
+
)
|
|
569
|
+
raise ContractCoreError(
|
|
570
|
+
"signature_bundle_missing",
|
|
571
|
+
f"bundle missing {SIGNATURE_BUNDLE_PATH}",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
signature_bundle_raw = _read_archive_member(inspection.bundle_path, inspection.signature_bundle_path)
|
|
575
|
+
signer_identity = _verify_sigstore_bundle(
|
|
576
|
+
inspection.bundle_path,
|
|
577
|
+
signature_bundle_raw=signature_bundle_raw,
|
|
578
|
+
offline=offline,
|
|
579
|
+
)
|
|
580
|
+
return BundleVerificationResult(
|
|
581
|
+
inspection=inspection,
|
|
582
|
+
signer_identity=signer_identity,
|
|
583
|
+
signature_verification_status="verified",
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _normalize_trusted_signer_rules(trusted_signer_rules: Sequence[Mapping[str, Any]]) -> list[dict[str, str]]:
|
|
588
|
+
normalized_rules: list[dict[str, str]] = []
|
|
589
|
+
for index, rule in enumerate(trusted_signer_rules):
|
|
590
|
+
if not isinstance(rule, Mapping):
|
|
591
|
+
raise ContractCoreError(
|
|
592
|
+
"trusted_signer_rule_invalid",
|
|
593
|
+
f"trusted signer rule #{index} must be an object",
|
|
594
|
+
)
|
|
595
|
+
rule_dict = {str(key): value for key, value in dict(rule).items()}
|
|
596
|
+
unknown_fields = sorted(set(rule_dict) - _TRUSTED_SIGNER_RULE_FIELDS)
|
|
597
|
+
if unknown_fields:
|
|
598
|
+
raise ContractCoreError(
|
|
599
|
+
"trusted_signer_rule_invalid",
|
|
600
|
+
f"trusted signer rule #{index} contains unsupported fields: {', '.join(unknown_fields)}",
|
|
601
|
+
)
|
|
602
|
+
missing_fields = sorted(field for field in _TRUSTED_SIGNER_RULE_FIELDS if field not in rule_dict)
|
|
603
|
+
if missing_fields:
|
|
604
|
+
raise ContractCoreError(
|
|
605
|
+
"trusted_signer_rule_invalid",
|
|
606
|
+
f"trusted signer rule #{index} missing fields: {', '.join(missing_fields)}",
|
|
607
|
+
)
|
|
608
|
+
normalized_rule = {field: str(rule_dict.get(field) or "").strip() for field in _TRUSTED_SIGNER_RULE_FIELDS}
|
|
609
|
+
empty_fields = [field for field, value in normalized_rule.items() if not value]
|
|
610
|
+
if empty_fields:
|
|
611
|
+
raise ContractCoreError(
|
|
612
|
+
"trusted_signer_rule_invalid",
|
|
613
|
+
f"trusted signer rule #{index} has empty fields: {', '.join(sorted(empty_fields))}",
|
|
614
|
+
)
|
|
615
|
+
if normalized_rule["status"] not in {"active", "disabled"}:
|
|
616
|
+
raise ContractCoreError(
|
|
617
|
+
"trusted_signer_rule_invalid",
|
|
618
|
+
f"trusted signer rule #{index} has invalid status: {normalized_rule['status']}",
|
|
619
|
+
)
|
|
620
|
+
if not normalized_rule["ref_pattern"].startswith("refs/tags/"):
|
|
621
|
+
raise ContractCoreError(
|
|
622
|
+
"trusted_signer_rule_invalid",
|
|
623
|
+
f"trusted signer rule #{index} must use a tag ref pattern",
|
|
624
|
+
)
|
|
625
|
+
normalized_rules.append(normalized_rule)
|
|
626
|
+
return normalized_rules
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _coerce_signer_identity(
|
|
630
|
+
signer_identity: SignerIdentity | BundleVerificationResult | None,
|
|
631
|
+
) -> SignerIdentity | None:
|
|
632
|
+
if isinstance(signer_identity, BundleVerificationResult):
|
|
633
|
+
return signer_identity.signer_identity
|
|
634
|
+
return signer_identity
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def match_signer_identity(
|
|
638
|
+
signer_identity: SignerIdentity | BundleVerificationResult | None,
|
|
639
|
+
trusted_signer_rules: Sequence[Mapping[str, Any]],
|
|
640
|
+
) -> SignerIdentityMatchResult:
|
|
641
|
+
identity = _coerce_signer_identity(signer_identity)
|
|
642
|
+
if identity is None:
|
|
643
|
+
return SignerIdentityMatchResult(
|
|
644
|
+
signer_identity=None,
|
|
645
|
+
matched=False,
|
|
646
|
+
matched_rule_id="",
|
|
647
|
+
signer_identity_text="",
|
|
648
|
+
reason="signer_identity_missing",
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
normalized_rules = _normalize_trusted_signer_rules(trusted_signer_rules)
|
|
652
|
+
signer_identity_text = identity.persistent_value()
|
|
653
|
+
for rule in normalized_rules:
|
|
654
|
+
if rule["status"] != "active":
|
|
655
|
+
continue
|
|
656
|
+
if identity.issuer != rule["issuer"]:
|
|
657
|
+
continue
|
|
658
|
+
if identity.repository_owner != rule["repository_owner"]:
|
|
659
|
+
continue
|
|
660
|
+
if identity.repository_name != rule["repository_name"]:
|
|
661
|
+
continue
|
|
662
|
+
if identity.workflow_ref != rule["workflow_ref"]:
|
|
663
|
+
continue
|
|
664
|
+
if identity.reusable_workflow_ref != rule["reusable_workflow_ref"]:
|
|
665
|
+
continue
|
|
666
|
+
if not fnmatch.fnmatch(identity.ref, rule["ref_pattern"]):
|
|
667
|
+
continue
|
|
668
|
+
return SignerIdentityMatchResult(
|
|
669
|
+
signer_identity=identity,
|
|
670
|
+
matched=True,
|
|
671
|
+
matched_rule_id=rule["rule_id"],
|
|
672
|
+
signer_identity_text=signer_identity_text,
|
|
673
|
+
reason="matched",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
return SignerIdentityMatchResult(
|
|
677
|
+
signer_identity=identity,
|
|
678
|
+
matched=False,
|
|
679
|
+
matched_rule_id="",
|
|
680
|
+
signer_identity_text=signer_identity_text,
|
|
681
|
+
reason="signer_identity_not_trusted",
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def validate_bundle_same_profile(
|
|
686
|
+
bundle: str | os.PathLike[str] | BundleInspectionResult,
|
|
687
|
+
*,
|
|
688
|
+
runtime_profile_id: str,
|
|
689
|
+
allow_unsigned_dev: bool = False,
|
|
690
|
+
offline: bool = False,
|
|
691
|
+
) -> BundleVerificationResult:
|
|
692
|
+
result = verify_bundle(
|
|
693
|
+
bundle,
|
|
694
|
+
allow_unsigned_dev=allow_unsigned_dev,
|
|
695
|
+
offline=offline,
|
|
696
|
+
)
|
|
697
|
+
expected_runtime_profile_id = str(runtime_profile_id or "").strip()
|
|
698
|
+
if not expected_runtime_profile_id:
|
|
699
|
+
raise ContractCoreError("runtime_profile_missing", "runtime_profile_id is required")
|
|
700
|
+
if result.runtime_profile_id != expected_runtime_profile_id:
|
|
701
|
+
raise ContractCoreError(
|
|
702
|
+
"runtime_profile_mismatch",
|
|
703
|
+
f"bundle runtime profile {result.runtime_profile_id} does not match {expected_runtime_profile_id}",
|
|
704
|
+
)
|
|
705
|
+
return result
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def validate_bundle_target_profile(
|
|
709
|
+
bundle: str | os.PathLike[str] | BundleInspectionResult,
|
|
710
|
+
*,
|
|
711
|
+
target_runtime_profile_id: str,
|
|
712
|
+
allow_unsigned_dev: bool = False,
|
|
713
|
+
offline: bool = False,
|
|
714
|
+
) -> BundleVerificationResult:
|
|
715
|
+
expected_runtime_profile_id = str(target_runtime_profile_id or "").strip()
|
|
716
|
+
if not expected_runtime_profile_id:
|
|
717
|
+
raise ContractCoreError("runtime_profile_missing", "target_runtime_profile_id is required")
|
|
718
|
+
return validate_bundle_same_profile(
|
|
719
|
+
bundle,
|
|
720
|
+
runtime_profile_id=expected_runtime_profile_id,
|
|
721
|
+
allow_unsigned_dev=allow_unsigned_dev,
|
|
722
|
+
offline=offline,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _zip_compression_for_path(path: Path) -> int:
|
|
727
|
+
if path.suffix.lower() in _STORED_SUFFIXES:
|
|
728
|
+
return zipfile.ZIP_STORED
|
|
729
|
+
return zipfile.ZIP_DEFLATED
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _zip_external_attr(mode: int) -> int:
|
|
733
|
+
return mode << 16
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def write_reproducible_bundle(
|
|
737
|
+
source_dir: str | os.PathLike[str],
|
|
738
|
+
bundle_path: str | os.PathLike[str],
|
|
739
|
+
) -> Path:
|
|
740
|
+
source_root = _ensure_directory(source_dir)
|
|
741
|
+
target_bundle_path = Path(bundle_path).expanduser().resolve()
|
|
742
|
+
target_bundle_path.parent.mkdir(parents=True, exist_ok=True)
|
|
743
|
+
with zipfile.ZipFile(target_bundle_path, mode="w") as archive:
|
|
744
|
+
for path in sorted(source_root.rglob("*"), key=lambda current: current.relative_to(source_root).as_posix()):
|
|
745
|
+
relpath = path.relative_to(source_root)
|
|
746
|
+
if path.is_symlink():
|
|
747
|
+
raise ContractCoreError("bundle_entry_invalid", f"symlink entries are not supported: {relpath.as_posix()}")
|
|
748
|
+
if path.is_dir():
|
|
749
|
+
continue
|
|
750
|
+
zip_info = zipfile.ZipInfo(relpath.as_posix(), date_time=FIXED_ZIP_TIMESTAMP)
|
|
751
|
+
zip_info.create_system = 3
|
|
752
|
+
zip_info.external_attr = _zip_external_attr(ZIP_FILE_MODE)
|
|
753
|
+
compression = _zip_compression_for_path(path)
|
|
754
|
+
zip_info.compress_type = compression
|
|
755
|
+
file_bytes = path.read_bytes()
|
|
756
|
+
if compression == zipfile.ZIP_DEFLATED:
|
|
757
|
+
archive.writestr(zip_info, file_bytes, compress_type=compression, compresslevel=DEFLATE_COMPRESSLEVEL)
|
|
758
|
+
else:
|
|
759
|
+
archive.writestr(zip_info, file_bytes, compress_type=compression)
|
|
760
|
+
return target_bundle_path
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""TG-BOT 插件合同校验使用的错误类型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContractCoreError(ValueError):
|
|
7
|
+
"""带稳定机器可读错误码的结构化校验异常。"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, code: str, message: str) -> None:
|
|
10
|
+
self.code = str(code or "").strip() or "contract_core_error"
|
|
11
|
+
super().__init__(message)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""TG-BOT 插件合同辅助层对外暴露的数据模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class PluginManifest:
|
|
12
|
+
plugin_id: str
|
|
13
|
+
plugin_version: str
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
category: str
|
|
17
|
+
runtime_profile_id: str
|
|
18
|
+
artifact_digest: str
|
|
19
|
+
entrypoint: dict[str, str]
|
|
20
|
+
config_schema: dict[str, Any]
|
|
21
|
+
interaction_schema: dict[str, Any]
|
|
22
|
+
declared_scopes: list[Any]
|
|
23
|
+
declared_capabilities: list[Any]
|
|
24
|
+
raw: dict[str, Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ArtifactDescriptor:
|
|
29
|
+
manifest_normalized: dict[str, Any]
|
|
30
|
+
files: tuple[dict[str, Any], ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class SignerIdentity:
|
|
35
|
+
issuer: str
|
|
36
|
+
repository_owner: str
|
|
37
|
+
repository_name: str
|
|
38
|
+
workflow_ref: str
|
|
39
|
+
reusable_workflow_ref: str
|
|
40
|
+
ref: str
|
|
41
|
+
|
|
42
|
+
def persistent_value(self) -> str:
|
|
43
|
+
return (
|
|
44
|
+
f"issuer={self.issuer},"
|
|
45
|
+
f"repo={self.repository_owner}/{self.repository_name},"
|
|
46
|
+
f"workflow_ref={self.workflow_ref},"
|
|
47
|
+
f"reusable_workflow_ref={self.reusable_workflow_ref},"
|
|
48
|
+
f"ref={self.ref}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class BundleInspectionResult:
|
|
54
|
+
bundle_path: Path
|
|
55
|
+
bundle_name: str
|
|
56
|
+
plugin_id: str
|
|
57
|
+
plugin_version: str
|
|
58
|
+
runtime_profile_id: str
|
|
59
|
+
artifact_digest: str
|
|
60
|
+
package_sha256: str
|
|
61
|
+
manifest: PluginManifest
|
|
62
|
+
artifact_descriptor: ArtifactDescriptor
|
|
63
|
+
archive_entries: tuple[str, ...]
|
|
64
|
+
entrypoint_relpath: str
|
|
65
|
+
signature_bundle_path: str
|
|
66
|
+
signature_bundle_present: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class BundleVerificationResult:
|
|
71
|
+
inspection: BundleInspectionResult
|
|
72
|
+
signer_identity: SignerIdentity | None
|
|
73
|
+
signature_verification_status: str
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def plugin_id(self) -> str:
|
|
77
|
+
return self.inspection.plugin_id
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def plugin_version(self) -> str:
|
|
81
|
+
return self.inspection.plugin_version
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def runtime_profile_id(self) -> str:
|
|
85
|
+
return self.inspection.runtime_profile_id
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def artifact_digest(self) -> str:
|
|
89
|
+
return self.inspection.artifact_digest
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def package_sha256(self) -> str:
|
|
93
|
+
return self.inspection.package_sha256
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class SignerIdentityMatchResult:
|
|
98
|
+
signer_identity: SignerIdentity | None
|
|
99
|
+
matched: bool
|
|
100
|
+
matched_rule_id: str
|
|
101
|
+
signer_identity_text: str
|
|
102
|
+
reason: str
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-bot-plugin-contract-core
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: TG-BOT 插件的共享 manifest、bundle、digest 与 signer 校验合同层。
|
|
5
|
+
Author: Fire Dragons
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: sigstore>=4.2.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# tg-bot-plugin-contract-core
|
|
14
|
+
|
|
15
|
+
`tg-bot-plugin-contract-core` 是 TG-BOT 插件 `manifest` 与 `.tgpkg` bundle 的共享正式合同层。
|
|
16
|
+
|
|
17
|
+
它提供:
|
|
18
|
+
|
|
19
|
+
- manifest 规范化与合同校验
|
|
20
|
+
- `artifact_digest` 计算
|
|
21
|
+
- 通过 `package_sha256` 计算 canonical `.tgpkg` 字节身份
|
|
22
|
+
- 可复现 bundle 写入
|
|
23
|
+
- bundle 结构检查
|
|
24
|
+
- Sigstore bundle 验签
|
|
25
|
+
- signer identity 提取与 trusted signer 规则匹配
|
|
26
|
+
|
|
27
|
+
它有意不提供:
|
|
28
|
+
|
|
29
|
+
- TG-BOT runtime 加载或插件类实例化
|
|
30
|
+
- trusted signer 文件发现或配置加载
|
|
31
|
+
- 超出 bundle 与 signer 合同范围的平台策略决策
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install tg-bot-plugin-contract-core
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 发布与验证
|
|
40
|
+
|
|
41
|
+
- `pull_request` / `main` 分支会执行:
|
|
42
|
+
- `pytest`
|
|
43
|
+
- wheel / sdist 构建
|
|
44
|
+
- wheel 安装 smoke test
|
|
45
|
+
- `tag push v*` 会额外执行:
|
|
46
|
+
- `tag == project.version` 校验
|
|
47
|
+
- PyPI 发布
|
|
48
|
+
- GitHub Release 附件上传
|
|
49
|
+
|
|
50
|
+
## 公开 API
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from tg_bot_plugin_contract_core import (
|
|
54
|
+
compute_artifact_digest,
|
|
55
|
+
compute_package_sha256,
|
|
56
|
+
inspect_bundle,
|
|
57
|
+
match_signer_identity,
|
|
58
|
+
validate_bundle_same_profile,
|
|
59
|
+
validate_bundle_target_profile,
|
|
60
|
+
verify_bundle,
|
|
61
|
+
write_reproducible_bundle,
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 使用示例
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
|
|
70
|
+
from tg_bot_plugin_contract_core import (
|
|
71
|
+
compute_artifact_digest,
|
|
72
|
+
inspect_bundle,
|
|
73
|
+
match_signer_identity,
|
|
74
|
+
verify_bundle,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
plugin_dir = Path("plugin-staging")
|
|
78
|
+
manifest_payload = {
|
|
79
|
+
"plugin_id": "demo",
|
|
80
|
+
"plugin_version": "1.0.0",
|
|
81
|
+
"name": "demo",
|
|
82
|
+
"description": "demo plugin",
|
|
83
|
+
"category": "utility",
|
|
84
|
+
"runtime_profile_id": "cpython-3.13-linux-x86_64-gnu",
|
|
85
|
+
"artifact_digest": "",
|
|
86
|
+
"entrypoint": {"module_path": "__init__", "symbol": "DemoPlugin"},
|
|
87
|
+
"config_schema": {},
|
|
88
|
+
"interaction_schema": {},
|
|
89
|
+
"declared_scopes": [],
|
|
90
|
+
"declared_capabilities": [],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
artifact_digest = compute_artifact_digest(plugin_dir, manifest_payload)
|
|
94
|
+
inspection = inspect_bundle("dist/plugins/demo/demo-1.0.0-cpython-3.13-linux-x86_64-gnu.tgpkg")
|
|
95
|
+
verification = verify_bundle(inspection, allow_unsigned_dev=False)
|
|
96
|
+
match_result = match_signer_identity(verification, trusted_signer_rules=[
|
|
97
|
+
{
|
|
98
|
+
"rule_id": "github-release-main",
|
|
99
|
+
"issuer": "https://token.actions.githubusercontent.com",
|
|
100
|
+
"repository_owner": "Fire-Dragons",
|
|
101
|
+
"repository_name": "demo",
|
|
102
|
+
"workflow_ref": "Fire-Dragons/demo/.github/workflows/release.yml@refs/heads/main",
|
|
103
|
+
"reusable_workflow_ref": "Fire-Dragons/tg-bot-plugin-buildkit/.github/workflows/release-plugin.yml@refs/tags/v1",
|
|
104
|
+
"ref_pattern": "refs/tags/v*",
|
|
105
|
+
"status": "active",
|
|
106
|
+
}
|
|
107
|
+
])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 说明
|
|
111
|
+
|
|
112
|
+
- `verify_bundle()` 会校验 Sigstore 签名材料,并从签名证书中提取 signer identity。
|
|
113
|
+
- `match_signer_identity()` 会基于已解析的 trusted signer 规则执行平台无关的匹配。
|
|
114
|
+
- `allow_unsigned_dev=True` 仅用于受控的本地开发链路。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tg_bot_plugin_contract_core/__init__.py,sha256=0B_E_uDK4rzQ-kNNMu23UiKXpHxgVnoTxrURci97UC4,971
|
|
2
|
+
tg_bot_plugin_contract_core/core.py,sha256=_YXVb7Bn9eHtwHVSW-R1w0GGsI5p_yAIVGNsI6Hn0bU,29399
|
|
3
|
+
tg_bot_plugin_contract_core/errors.py,sha256=qX57soRAjO-3DDOp1yu8Bhypwei8G-qVa7yeTBAofxY,360
|
|
4
|
+
tg_bot_plugin_contract_core/models.py,sha256=7jUDGRjgtbmrJmSclZuXlzhEE3fpW2H92O8jlaX2xa4,2561
|
|
5
|
+
tg_bot_plugin_contract_core-0.1.1.dist-info/METADATA,sha256=EYvgqAU2379X1J4ole-lB5_RvAUyovOpPaVXpDh1dwY,3293
|
|
6
|
+
tg_bot_plugin_contract_core-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
tg_bot_plugin_contract_core-0.1.1.dist-info/RECORD,,
|