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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any