nexus-certify 0.1.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.
- nexus_certify-0.1.0.dist-info/METADATA +128 -0
- nexus_certify-0.1.0.dist-info/RECORD +46 -0
- nexus_certify-0.1.0.dist-info/WHEEL +5 -0
- nexus_certify-0.1.0.dist-info/entry_points.txt +2 -0
- nexus_certify-0.1.0.dist-info/top_level.txt +1 -0
- product/__init__.py +1 -0
- product/acquisition/__init__.py +29 -0
- product/acquisition/github.py +400 -0
- product/adapters/__init__.py +1 -0
- product/adapters/changeset_certification_v2.py +1006 -0
- product/adapters/generic_verification.py +429 -0
- product/adapters/github.py +274 -0
- product/adapters/legacy.py +34 -0
- product/adapters/trusted.py +593 -0
- product/benchmark/__init__.py +1500 -0
- product/benchmark/tg7_shadow.py +70 -0
- product/benchmark/tg7_shadow_kernel.py +999 -0
- product/benchmark/tg9_value.py +1217 -0
- product/certification/__init__.py +83 -0
- product/certification/receipt.py +520 -0
- product/clients/__init__.py +15 -0
- product/clients/cli.py +421 -0
- product/clients/github_action.py +312 -0
- product/clients/local_golden_path.py +835 -0
- product/clients/mcp.py +159 -0
- product/completion/__init__.py +550 -0
- product/evidence/__init__.py +909 -0
- product/evidence/code_integrity.py +824 -0
- product/evidence/ingestion.py +2472 -0
- product/execution/__init__.py +39 -0
- product/execution/profiles/__init__.py +1 -0
- product/execution/profiles/python-oci-pytest-v1.json +1 -0
- product/execution/profiles/python-oci-pytest-v1.lock +1 -0
- product/execution/python_runner.py +732 -0
- product/kernel/__init__.py +176 -0
- product/ledger.py +1068 -0
- product/protocol/__init__.py +17 -0
- product/protocol/compatibility_gate.py +163 -0
- product/protocol/compatibility_gate_kernel.py +1205 -0
- product/protocol/generic_verification.py +866 -0
- product/runtime/__init__.py +61 -0
- product/runtime/auth.py +205 -0
- product/runtime/http.py +379 -0
- product/runtime/schemas.py +374 -0
- product/runtime/service.py +763 -0
- product/verification/__init__.py +207 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexus-certify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nexus Evidence Trust Core and Completion Core
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
9
|
+
Requires-Dist: PyGithub>=2.1.0
|
|
10
|
+
|
|
11
|
+
# Nexus Core
|
|
12
|
+
|
|
13
|
+
Nexus Core is the standalone Evidence-to-Claim Completion Certification implementation extracted from Nexus-new.
|
|
14
|
+
|
|
15
|
+
Its truth authorities are Evidence Trust Core and Completion Core.
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
- **Evidence Trust Core**: Ingestion, normalization, verification, and tamper detection of execution evidence.
|
|
20
|
+
- **Completion Core**: ChangeSet certification, deterministic verification reduction, and disposition enforcement.
|
|
21
|
+
|
|
22
|
+
It provides the standalone `nexus-certify` CLI and HTTP/deterministic runtime interfaces.
|
|
23
|
+
|
|
24
|
+
## Local Golden Path
|
|
25
|
+
|
|
26
|
+
An external Python Git repository can verify a committed or dirty change without
|
|
27
|
+
running the HTTP service or hand-authoring protocol JSON:
|
|
28
|
+
|
|
29
|
+
The public distribution identity is `nexus-certify`. Until the release gate
|
|
30
|
+
publishes a versioned artifact, install the candidate source or locally built wheel
|
|
31
|
+
in a development environment. After an authorized public release, the supported
|
|
32
|
+
registry install command is `python -m pip install nexus-certify`.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
nexus-certify init --base-ref main --allow 'src/**' --allow 'tests/**' \
|
|
36
|
+
--verifier python -m pytest -q
|
|
37
|
+
nexus-certify doctor
|
|
38
|
+
nexus-certify check
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The local shell derives real Git object identities and the canonical manifest,
|
|
42
|
+
runs the configured verifier, delegates the verdict to the existing generic Core
|
|
43
|
+
adapter, and persists a verification receipt under `.nexus-core/receipts/`.
|
|
44
|
+
`VERIFIED` remains distinct from `CERTIFIED`; the local receipt is not a
|
|
45
|
+
Certification receipt or merge/release authority.
|
|
46
|
+
|
|
47
|
+
See [Local Golden Path Contract](docs/LOCAL_GOLDEN_PATH.md) for the exact config,
|
|
48
|
+
negative controls, receipt validation contract, and the not-yet-executed G4
|
|
49
|
+
fresh-environment canary contract.
|
|
50
|
+
|
|
51
|
+
For trusted same-repository GitHub `push` and `pull_request` events on self-hosted
|
|
52
|
+
runners, see the [G2 GitHub Repository Golden Path](docs/GITHUB_REPOSITORY_CHECK.md).
|
|
53
|
+
Fork PRs and GitHub-hosted runners are explicitly unsupported by that path.
|
|
54
|
+
|
|
55
|
+
## Generic ChangeSet Verification (experimental)
|
|
56
|
+
|
|
57
|
+
The loopback HTTP runtime also exposes a transport-neutral, non-GitHub verification seam for bounded consumers such as DevSpace or Open SWE:
|
|
58
|
+
|
|
59
|
+
- `GET /v1/protocol/generic-verification` — authenticated protocol descriptor, JSON schemas, canonicalization rules, schema-bundle hash, and cross-language conformance vectors.
|
|
60
|
+
- `POST /v1/changesets/verify` — deterministic `AcceptanceContract + ChangeSet + VerificationPlan + EvidenceBundle` verification. Certification is optional and occurs only when the caller supplies explicit policy facts.
|
|
61
|
+
|
|
62
|
+
Generic revision identities are typed as `git-commit:<40-lowercase-hex>` or `git-tree:<40-lowercase-hex>`. An uncommitted managed change may therefore be verified against a deterministic Git tree without creating a commit first. The generic `diff_hash` binds a canonical source-tree-to-target-tree manifest instead of pretty-patch formatting.
|
|
63
|
+
|
|
64
|
+
`verification=VERIFIED` does not imply `CERTIFIED`, Candidate acceptance, merge authorization, release, deployment, or production readiness. This interface does not select execution lanes, workers, models, routes, or workspaces.
|
|
65
|
+
|
|
66
|
+
## Completion Evidence Applicability and Freshness Contract
|
|
67
|
+
|
|
68
|
+
`product.completion` is an additive Completion Core capability that binds a
|
|
69
|
+
completion claim to the exact source/artifact state it claims, then decides
|
|
70
|
+
whether verification evidence actually applies to that final state. It is
|
|
71
|
+
deterministic, model-independent, and advisory: the completion claim's
|
|
72
|
+
`asserted_by` identity never confers authority, and certification remains with
|
|
73
|
+
`product.kernel.certify`.
|
|
74
|
+
|
|
75
|
+
It derives three explicit semantic states:
|
|
76
|
+
|
|
77
|
+
- `CLAIMS_COMPLETE` — a completion claim is present, revision-bound to the
|
|
78
|
+
change set's `target_revision`, and covers every changed non-deleted path
|
|
79
|
+
with a claimed final content hash.
|
|
80
|
+
- `VERIFICATION_APPLIES` — every required verifier has an evidence observation
|
|
81
|
+
that is bound to a changed path, observes the claimed final content hash
|
|
82
|
+
(freshness by content-hash comparison), and reports `PASS`.
|
|
83
|
+
- `CLAIMS_VERIFIED` — the conjunction of the two states above with the existing
|
|
84
|
+
deterministic `verify()` reduction. A claim assertion can never substitute
|
|
85
|
+
for a physical observation.
|
|
86
|
+
|
|
87
|
+
Per-verifier `EvidenceDisposition` values (`ACCEPTED`, `REJECTED_STALE`,
|
|
88
|
+
`REJECTED_IRRELEVANT`, `REJECTED_MISSING`, `REJECTED_CONTRADICTORY`,
|
|
89
|
+
`REJECTED_FAILED`) let Completion Core explain exactly which evidence was
|
|
90
|
+
accepted, rejected, stale, missing, or contradictory. Original API:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from product.completion import analyze_completion_evidence, validate_completion_evidence_analysis
|
|
94
|
+
|
|
95
|
+
analysis = analyze_completion_evidence(claim, contract, change_set, plan, evidence)
|
|
96
|
+
assert analysis.claims_verified # all three states
|
|
97
|
+
assert validate_completion_evidence_analysis(analysis, claim, contract, change_set, plan, evidence)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Freshness and execution ordering are derived from content hash comparison
|
|
101
|
+
rather than wall-clock timestamps: an observation whose artifact content hash
|
|
102
|
+
differs from the claimed final content hash predates a later mutation and is
|
|
103
|
+
stale unless the hashes are equal (equivalence proven by the trusted hash
|
|
104
|
+
machinery). See `tests/product/test_completion_evidence_applicability.py` for
|
|
105
|
+
the `modify -> test PASS -> modify again` and irrelevant-verification
|
|
106
|
+
regressions.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
uv sync
|
|
112
|
+
uv run pytest -q tests/product
|
|
113
|
+
uv run pytest -q tests/benchmark
|
|
114
|
+
uv run ruff check product tests
|
|
115
|
+
uv run nexus-certify --help
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Compatibility and Coexistence Boundary
|
|
119
|
+
|
|
120
|
+
`nexus-core` and the current `nexus-legacy` package in `Nexus-new` have distinct package and console-script ownership:
|
|
121
|
+
|
|
122
|
+
- Nexus Core is distributed as `nexus-certify` while continuing to own the
|
|
123
|
+
internal `product` Python package and `nexus-certify` console script.
|
|
124
|
+
- `nexus-legacy` owns the `nexus` and `scripts` packages and the `nexus` console script.
|
|
125
|
+
|
|
126
|
+
The current package definitions therefore no longer have the historical `product` namespace / `nexus-certify` console-script collision described by the previous README.
|
|
127
|
+
|
|
128
|
+
Use separate virtual environments for normal development and testing because the repositories have different dependency sets and operational roles. That isolation is development hygiene, not a requirement caused by the retired namespace/script collision.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
product/__init__.py,sha256=xvXwzejqwfCdfJTb_oGKMg2Ypn7RwXDfT0f-jtqwOSI,52
|
|
2
|
+
product/ledger.py,sha256=yIcHlyjIlJxV3apS_s8HASr3FhbGs20rVyUYo_8O4xM,40419
|
|
3
|
+
product/acquisition/__init__.py,sha256=jrzd7v51bgT6RAE2HblvT1E_eKYGh9H5FWADXZKxrUY,842
|
|
4
|
+
product/acquisition/github.py,sha256=E3R9P0KGCF47eSo84unWebv2pGiSZMltE-2O8naS3Vo,15655
|
|
5
|
+
product/adapters/__init__.py,sha256=1DkEAIRjlVpe_Fmxu9ErQ2I_anEzCVChFuWL5mQnKtM,39
|
|
6
|
+
product/adapters/changeset_certification_v2.py,sha256=dRsTXxnwj6-8LaC0oI0RtEkv1aLLX5O87dgMwZk4ugM,36207
|
|
7
|
+
product/adapters/generic_verification.py,sha256=lheYMpkFG1h9BpZSglaApvkZeXWMinxBkZgb02Y4YTc,16249
|
|
8
|
+
product/adapters/github.py,sha256=J-8DrnrGXVIwvTkvHZEQ3Zx2xi3Pw1QuNJJc8IqpVv4,10347
|
|
9
|
+
product/adapters/legacy.py,sha256=QPZL4vC7f6kSGZtDSjRdo3cheXikDrRxcJY4jrKRUn8,1171
|
|
10
|
+
product/adapters/trusted.py,sha256=GsrcoY-6fgsScgnoM4o4kQJV2v6-dqA5Rsngr2rfQDA,21442
|
|
11
|
+
product/benchmark/__init__.py,sha256=wHHb44gGX-f8EPOta1kf8vaElE1v58n_ZxzIHXvKH6Q,53846
|
|
12
|
+
product/benchmark/tg7_shadow.py,sha256=gOZsEx1HKEQ1OrjgtGnvE9NHgZ2iCM0KB5wTtCETmMM,2103
|
|
13
|
+
product/benchmark/tg7_shadow_kernel.py,sha256=MHEhcqRuxjLMPMjFoQDdyEGkofULYtasrAXGVvAVB2U,41400
|
|
14
|
+
product/benchmark/tg9_value.py,sha256=BuBj91yUTE4g-8aeE0eOTPWKfW47r4A2pn0ykTznykg,45949
|
|
15
|
+
product/certification/__init__.py,sha256=Yw0_X06jZCb1tZqw5DrppvkBmseAJl--tDPm5xhjGhY,2867
|
|
16
|
+
product/certification/receipt.py,sha256=w4XabPii3lp_WnpPmZW_HWNvIF19icV2Uux1x7MmsxQ,19369
|
|
17
|
+
product/clients/__init__.py,sha256=6W9lqbdUiKzHIxy0CiIWA6dkZg9IYVM_vR9F3Mg0oCs,566
|
|
18
|
+
product/clients/cli.py,sha256=yB5G44JMO36aMEJQNSs4gewlvp1qDRfoqsm1vVNeIDg,14650
|
|
19
|
+
product/clients/github_action.py,sha256=Z-kSnn5wVmLwEFbWB8uI94w-omwdxgI6ijRWubxnP78,11621
|
|
20
|
+
product/clients/local_golden_path.py,sha256=s0s1_whFeSpRobJdwSmlMwy-5Amc78m9lvwUYx4NOdE,30499
|
|
21
|
+
product/clients/mcp.py,sha256=RF5SiXgeTKelLv8Fj-oPUVkINR-ia1yoZfGHB29uS3o,5494
|
|
22
|
+
product/completion/__init__.py,sha256=MVnB9S7AYfV8QXQ5Fb0ciLIQuv-sg3Ha7mTnKRw-_TU,23118
|
|
23
|
+
product/evidence/__init__.py,sha256=sC0WF34FAH80ZTPAbTp9v-xTPk3lzjCZdEVVwoHUL_s,32660
|
|
24
|
+
product/evidence/code_integrity.py,sha256=i_ThdcTonfpAAKaJRWXiwFneVEkYndRuxxRGpMSEkts,27894
|
|
25
|
+
product/evidence/ingestion.py,sha256=0QkQrnpXDCftGysp8nSBew0cpnXgHQAc_Iyo2QoRoDU,96210
|
|
26
|
+
product/execution/__init__.py,sha256=FNLS3FhyNqz5WCUSbp3J0iqwX2nsCxypdnRyA9-DzP0,1015
|
|
27
|
+
product/execution/python_runner.py,sha256=D7VOqvvhL8KhkhIANk9S2TnQE2tQXUrXBzPv5pHLCMA,28918
|
|
28
|
+
product/execution/profiles/__init__.py,sha256=ocOHYa4W4wgIYxU4QYizCVBohSb2IDHvhaqrcQim1mc,57
|
|
29
|
+
product/execution/profiles/python-oci-pytest-v1.json,sha256=qj49XuuZ4SJy6Cwi5vLGeFA3Bsrp-RdYMHAwf5e-jaY,517
|
|
30
|
+
product/execution/profiles/python-oci-pytest-v1.lock,sha256=5gPRsKszIciB84lrKUb2Rq2-EmL1uWSL2VV-cmILXZ8,1513
|
|
31
|
+
product/kernel/__init__.py,sha256=zzX4Sy-6xuScQ1VKk6yfrT6nwgDMTb9h574gHYiVZiE,5336
|
|
32
|
+
product/protocol/__init__.py,sha256=-t-ooUzNHtCXoosaR_WflCHVihdbfXIvsxsi8Ldl3qI,696
|
|
33
|
+
product/protocol/compatibility_gate.py,sha256=8hLjxR0f2e9Ft5W2kQKEJPXZ3nnAOURagY7Sva627no,5579
|
|
34
|
+
product/protocol/compatibility_gate_kernel.py,sha256=W-lKXq5kVoiJii4a7UmJItA4ictKgHglxYrdmnxgHtI,40525
|
|
35
|
+
product/protocol/generic_verification.py,sha256=dCZfMpcGGNK99Vj8oH5G7WiEZi_TEvnctGJVR-T2III,31271
|
|
36
|
+
product/runtime/__init__.py,sha256=UWP4zFdcVpvmr8fDs_uAV_qnOjnyWgsOFYLbOcnSslo,1512
|
|
37
|
+
product/runtime/auth.py,sha256=yjXe0qyn7nT23q8lwFZCT31KOfnsb3hkLrHnAuGijKc,6349
|
|
38
|
+
product/runtime/http.py,sha256=KM7XO80tyUMRvSGPEEpUk18chJsapcep-Q6hbpC2m4s,12947
|
|
39
|
+
product/runtime/schemas.py,sha256=p8XCWMSBXzVJYXGortyMj14tnhc9XaTSh2sdpq636bo,12913
|
|
40
|
+
product/runtime/service.py,sha256=Wj-xZYAVXwzyGP7Z-bTnKSHZR2eClbJC54K2qVYmgC4,30570
|
|
41
|
+
product/verification/__init__.py,sha256=Bwle0QbMHveecHwjCjyFsOzOdypC9N5vWuvWQjA_fv8,8125
|
|
42
|
+
nexus_certify-0.1.0.dist-info/METADATA,sha256=tyCv7OZwhWbQDxEmXPbsJk76IJWSZvaM5dMaaoZWqZ8,6579
|
|
43
|
+
nexus_certify-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
44
|
+
nexus_certify-0.1.0.dist-info/entry_points.txt,sha256=QxDwcmncZyvXBHYhJKpnA-Ki0Xbf6s0hxYXW6mbJva0,59
|
|
45
|
+
nexus_certify-0.1.0.dist-info/top_level.txt,sha256=FrxW_86o3e_ivgRQnhtrp6bWvCG39Mq7aKsxCtzKFTQ,8
|
|
46
|
+
nexus_certify-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
product
|
product/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure completion-certification product kernel."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Credential-free acquisition ports for externally owned source systems.
|
|
2
|
+
|
|
3
|
+
Acquisition is deliberately separate from :mod:`product.adapters`: ports are
|
|
4
|
+
injected by the controller and return already-authorized, read-only data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .github import (
|
|
8
|
+
AcquisitionError,
|
|
9
|
+
GitHubAcquisitionSnapshot,
|
|
10
|
+
GitHubPullRequestAcquisition,
|
|
11
|
+
GitHubPullRequestLocator,
|
|
12
|
+
GitHubReadPort,
|
|
13
|
+
acquire_github_pull_request,
|
|
14
|
+
acquire_pull_request,
|
|
15
|
+
load_github_acquisition_snapshot,
|
|
16
|
+
serialize_github_acquisition_snapshot,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AcquisitionError",
|
|
21
|
+
"GitHubAcquisitionSnapshot",
|
|
22
|
+
"GitHubPullRequestAcquisition",
|
|
23
|
+
"GitHubPullRequestLocator",
|
|
24
|
+
"GitHubReadPort",
|
|
25
|
+
"acquire_github_pull_request",
|
|
26
|
+
"acquire_pull_request",
|
|
27
|
+
"load_github_acquisition_snapshot",
|
|
28
|
+
"serialize_github_acquisition_snapshot",
|
|
29
|
+
]
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Injected, read-only GitHub pull-request acquisition.
|
|
2
|
+
|
|
3
|
+
The port intentionally has no credential parameter and this module has no
|
|
4
|
+
network client. A controller-owned implementation performs authentication
|
|
5
|
+
outside the product package and supplies one complete response per read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Mapping, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
_SHA40 = re.compile(r"[0-9a-f]{40}\Z")
|
|
18
|
+
_SHA256 = re.compile(r"sha256:[0-9a-f]{64}\Z")
|
|
19
|
+
_NAME = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})?\Z")
|
|
20
|
+
_UTC_RFC3339 = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\Z")
|
|
21
|
+
_REQUIRED = frozenset({
|
|
22
|
+
"repository_owner",
|
|
23
|
+
"repository_name",
|
|
24
|
+
"pr_number",
|
|
25
|
+
"base_sha",
|
|
26
|
+
"head_sha",
|
|
27
|
+
"base_tree_sha",
|
|
28
|
+
"head_tree_sha",
|
|
29
|
+
"merge_base_policy",
|
|
30
|
+
"diff_bytes",
|
|
31
|
+
"diff_hash",
|
|
32
|
+
"changed_paths",
|
|
33
|
+
"deleted_paths",
|
|
34
|
+
"checks",
|
|
35
|
+
"pagination_complete",
|
|
36
|
+
"observed_at",
|
|
37
|
+
"freshness_cas",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AcquisitionError(ValueError):
|
|
42
|
+
"""A response cannot be admitted as an immutable acquisition snapshot."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PermissionDenied(AcquisitionError):
|
|
46
|
+
"""The injected port could not read the requested PR."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AcquisitionDriftError(AcquisitionError):
|
|
50
|
+
"""The two independent reads do not describe the same PR identity."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class GitHubPullRequestLocator:
|
|
55
|
+
repository_owner: str
|
|
56
|
+
repository_name: str
|
|
57
|
+
pr_number: int
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
for field in ("repository_owner", "repository_name"):
|
|
61
|
+
value = getattr(self, field)
|
|
62
|
+
if (
|
|
63
|
+
type(value) is not str
|
|
64
|
+
or not value
|
|
65
|
+
or value != value.strip()
|
|
66
|
+
or _NAME.fullmatch(value) is None
|
|
67
|
+
):
|
|
68
|
+
raise AcquisitionError(f"{field} is not a normalized GitHub name")
|
|
69
|
+
if type(self.pr_number) is not int or self.pr_number <= 0:
|
|
70
|
+
raise AcquisitionError("pr_number must be a positive exact int")
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def locator_hash(self) -> str:
|
|
74
|
+
value = [self.repository_owner.lower(), self.repository_name.lower(), self.pr_number]
|
|
75
|
+
return (
|
|
76
|
+
"sha256:"
|
|
77
|
+
+ hashlib.sha256(json.dumps(value, separators=(",", ":")).encode()).hexdigest()
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@runtime_checkable
|
|
82
|
+
class GitHubReadPort(Protocol):
|
|
83
|
+
"""Controller-provided authenticated read seam; credentials never cross it."""
|
|
84
|
+
|
|
85
|
+
def read_pull_request(self, locator: GitHubPullRequestLocator) -> Mapping[str, object]: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sha(value: object, field: str) -> str:
|
|
89
|
+
if type(value) is not str or _SHA40.fullmatch(value) is None:
|
|
90
|
+
raise AcquisitionError(f"{field} must be lowercase 40-hex SHA")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _hash(value: object, field: str) -> str:
|
|
95
|
+
if type(value) is not str or _SHA256.fullmatch(value) is None:
|
|
96
|
+
raise AcquisitionError(f"{field} must be sha256:<64 lowercase hex>")
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _paths(value: object, field: str, *, allow_empty: bool = True) -> tuple[str, ...]:
|
|
101
|
+
if not isinstance(value, (list, tuple)):
|
|
102
|
+
raise AcquisitionError(f"{field} must be a path list")
|
|
103
|
+
result = tuple(str(x) for x in value)
|
|
104
|
+
if not allow_empty and not result:
|
|
105
|
+
raise AcquisitionError(f"{field} must be non-empty")
|
|
106
|
+
if len(result) != len(set(result)):
|
|
107
|
+
raise AcquisitionError(f"{field} contains duplicate paths")
|
|
108
|
+
for path in result:
|
|
109
|
+
if (
|
|
110
|
+
type(path) is not str
|
|
111
|
+
or not path
|
|
112
|
+
or path != path.strip()
|
|
113
|
+
or path.startswith("/")
|
|
114
|
+
or "\\" in path
|
|
115
|
+
or any(part in {"", ".", ".."} for part in path.split("/"))
|
|
116
|
+
):
|
|
117
|
+
raise AcquisitionError(f"{field} contains an invalid relative path")
|
|
118
|
+
return tuple(sorted(result))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _canonical(value: object) -> str:
|
|
122
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _freshness_cas_for(
|
|
126
|
+
owner: str,
|
|
127
|
+
repository: str,
|
|
128
|
+
pr_number: int,
|
|
129
|
+
base_sha: str,
|
|
130
|
+
head_sha: str,
|
|
131
|
+
base_tree_sha: str,
|
|
132
|
+
head_tree_sha: str,
|
|
133
|
+
merge_base_policy: str,
|
|
134
|
+
diff_hash: str,
|
|
135
|
+
changed_paths: tuple[str, ...],
|
|
136
|
+
deleted_paths: tuple[str, ...],
|
|
137
|
+
checks: tuple[tuple[str, str], ...],
|
|
138
|
+
) -> str:
|
|
139
|
+
subject = {
|
|
140
|
+
"repository_owner": owner.lower(),
|
|
141
|
+
"repository_name": repository.lower(),
|
|
142
|
+
"pr_number": pr_number,
|
|
143
|
+
"base_sha": base_sha,
|
|
144
|
+
"head_sha": head_sha,
|
|
145
|
+
"base_tree_sha": base_tree_sha,
|
|
146
|
+
"head_tree_sha": head_tree_sha,
|
|
147
|
+
"merge_base_policy": merge_base_policy,
|
|
148
|
+
"diff_hash": diff_hash,
|
|
149
|
+
"changed_paths": list(changed_paths),
|
|
150
|
+
"deleted_paths": list(deleted_paths),
|
|
151
|
+
"checks": [list(item) for item in checks],
|
|
152
|
+
}
|
|
153
|
+
return "sha256:" + hashlib.sha256(_canonical(subject).encode()).hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class GitHubAcquisitionSnapshot:
|
|
158
|
+
repository_owner: str
|
|
159
|
+
repository_name: str
|
|
160
|
+
pr_number: int
|
|
161
|
+
base_sha: str
|
|
162
|
+
head_sha: str
|
|
163
|
+
base_tree_sha: str
|
|
164
|
+
head_tree_sha: str
|
|
165
|
+
merge_base_policy: str
|
|
166
|
+
diff_bytes: bytes
|
|
167
|
+
diff_hash: str
|
|
168
|
+
changed_paths: tuple[str, ...]
|
|
169
|
+
deleted_paths: tuple[str, ...]
|
|
170
|
+
checks: tuple[tuple[str, str], ...]
|
|
171
|
+
pagination_complete: bool
|
|
172
|
+
observed_at: str
|
|
173
|
+
freshness_cas: str
|
|
174
|
+
locator_hash: str
|
|
175
|
+
|
|
176
|
+
def __post_init__(self) -> None:
|
|
177
|
+
locator = GitHubPullRequestLocator(
|
|
178
|
+
self.repository_owner, self.repository_name, self.pr_number
|
|
179
|
+
)
|
|
180
|
+
for field in ("base_sha", "head_sha", "base_tree_sha", "head_tree_sha"):
|
|
181
|
+
_sha(getattr(self, field), field)
|
|
182
|
+
if self.base_sha == self.head_sha:
|
|
183
|
+
raise AcquisitionError("base_sha and head_sha must differ")
|
|
184
|
+
if self.merge_base_policy != "base_sha_exact":
|
|
185
|
+
raise AcquisitionError("merge_base_policy must be base_sha_exact")
|
|
186
|
+
if type(self.diff_bytes) is not bytes:
|
|
187
|
+
raise AcquisitionError("diff_bytes must be immutable bytes")
|
|
188
|
+
expected = "sha256:" + hashlib.sha256(self.diff_bytes).hexdigest()
|
|
189
|
+
if self.diff_hash != expected:
|
|
190
|
+
raise AcquisitionError("diff_hash does not match diff_bytes")
|
|
191
|
+
_hash(self.diff_hash, "diff_hash")
|
|
192
|
+
if self.changed_paths != tuple(sorted(self.changed_paths)):
|
|
193
|
+
raise AcquisitionError("changed_paths must be sorted")
|
|
194
|
+
if self.deleted_paths != tuple(sorted(self.deleted_paths)):
|
|
195
|
+
raise AcquisitionError("deleted_paths must be sorted")
|
|
196
|
+
_paths(self.changed_paths, "changed_paths", allow_empty=False)
|
|
197
|
+
_paths(self.deleted_paths, "deleted_paths")
|
|
198
|
+
if not set(self.deleted_paths).issubset(set(self.changed_paths)):
|
|
199
|
+
raise AcquisitionError("deleted_paths must be a subset of changed_paths")
|
|
200
|
+
if type(self.checks) is not tuple or any(
|
|
201
|
+
type(x) is not tuple or len(x) != 2 for x in self.checks
|
|
202
|
+
):
|
|
203
|
+
raise AcquisitionError("checks must be (identity, digest) pairs")
|
|
204
|
+
if self.checks != tuple(sorted(self.checks)) or len({x[0] for x in self.checks}) != len(
|
|
205
|
+
self.checks
|
|
206
|
+
):
|
|
207
|
+
raise AcquisitionError("checks must be sorted and have unique identities")
|
|
208
|
+
for identity, digest in self.checks:
|
|
209
|
+
if (
|
|
210
|
+
type(identity) is not str
|
|
211
|
+
or not identity
|
|
212
|
+
or identity != identity.strip()
|
|
213
|
+
or "\x00" in identity
|
|
214
|
+
):
|
|
215
|
+
raise AcquisitionError("check identities must be normalized")
|
|
216
|
+
_hash(digest, "check digest")
|
|
217
|
+
if type(self.pagination_complete) is not bool or not self.pagination_complete:
|
|
218
|
+
raise AcquisitionError("pagination_complete must be true")
|
|
219
|
+
if type(self.observed_at) is not str or _UTC_RFC3339.fullmatch(self.observed_at) is None:
|
|
220
|
+
raise AcquisitionError("observed_at must be canonical UTC RFC3339 ending in Z")
|
|
221
|
+
try:
|
|
222
|
+
parsed = datetime.fromisoformat(self.observed_at[:-1])
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
raise AcquisitionError("observed_at must be a valid UTC RFC3339 timestamp") from exc
|
|
225
|
+
if parsed.tzinfo is not None:
|
|
226
|
+
raise AcquisitionError("observed_at must use canonical Z timezone")
|
|
227
|
+
expected_cas = _freshness_cas_for(
|
|
228
|
+
self.repository_owner,
|
|
229
|
+
self.repository_name,
|
|
230
|
+
self.pr_number,
|
|
231
|
+
self.base_sha,
|
|
232
|
+
self.head_sha,
|
|
233
|
+
self.base_tree_sha,
|
|
234
|
+
self.head_tree_sha,
|
|
235
|
+
self.merge_base_policy,
|
|
236
|
+
self.diff_hash,
|
|
237
|
+
self.changed_paths,
|
|
238
|
+
self.deleted_paths,
|
|
239
|
+
self.checks,
|
|
240
|
+
)
|
|
241
|
+
if self.freshness_cas != expected_cas:
|
|
242
|
+
raise AcquisitionError("freshness_cas does not match snapshot subject")
|
|
243
|
+
if self.locator_hash != locator.locator_hash:
|
|
244
|
+
raise AcquisitionError("locator_hash does not match locator")
|
|
245
|
+
|
|
246
|
+
def to_dict(self) -> dict[str, object]:
|
|
247
|
+
return {
|
|
248
|
+
"repository_owner": self.repository_owner,
|
|
249
|
+
"repository_name": self.repository_name,
|
|
250
|
+
"pr_number": self.pr_number,
|
|
251
|
+
"base_sha": self.base_sha,
|
|
252
|
+
"head_sha": self.head_sha,
|
|
253
|
+
"base_tree_sha": self.base_tree_sha,
|
|
254
|
+
"head_tree_sha": self.head_tree_sha,
|
|
255
|
+
"merge_base_policy": self.merge_base_policy,
|
|
256
|
+
"diff_bytes": self.diff_bytes.hex(),
|
|
257
|
+
"diff_hash": self.diff_hash,
|
|
258
|
+
"changed_paths": list(self.changed_paths),
|
|
259
|
+
"deleted_paths": list(self.deleted_paths),
|
|
260
|
+
"checks": [list(x) for x in self.checks],
|
|
261
|
+
"pagination_complete": self.pagination_complete,
|
|
262
|
+
"observed_at": self.observed_at,
|
|
263
|
+
"freshness_cas": self.freshness_cas,
|
|
264
|
+
"locator_hash": self.locator_hash,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse(
|
|
269
|
+
raw: Mapping[str, object], locator: GitHubPullRequestLocator
|
|
270
|
+
) -> GitHubAcquisitionSnapshot:
|
|
271
|
+
if not isinstance(raw, Mapping) or set(raw) != _REQUIRED:
|
|
272
|
+
raise AcquisitionError("response has an incomplete or substituted schema")
|
|
273
|
+
values = dict(raw)
|
|
274
|
+
if (
|
|
275
|
+
values["repository_owner"] != locator.repository_owner
|
|
276
|
+
or values["repository_name"] != locator.repository_name
|
|
277
|
+
or values["pr_number"] != locator.pr_number
|
|
278
|
+
):
|
|
279
|
+
raise AcquisitionDriftError("response locator differs from requested locator")
|
|
280
|
+
diff_bytes = values["diff_bytes"]
|
|
281
|
+
if not isinstance(diff_bytes, bytes):
|
|
282
|
+
raise AcquisitionError("diff_bytes must be bytes from the read port")
|
|
283
|
+
checks = values["checks"]
|
|
284
|
+
if not isinstance(checks, (list, tuple)):
|
|
285
|
+
raise AcquisitionError("checks must be a list")
|
|
286
|
+
checks_tuple: tuple[tuple[str, str], ...] = tuple(
|
|
287
|
+
(str(x[0]), str(x[1]))
|
|
288
|
+
for x in checks
|
|
289
|
+
if isinstance(x, (list, tuple)) and len(x) == 2
|
|
290
|
+
)
|
|
291
|
+
changed_paths = values["changed_paths"]
|
|
292
|
+
changed_tuple = tuple(str(x) for x in changed_paths) if isinstance(changed_paths, (list, tuple)) else ()
|
|
293
|
+
deleted_paths = values["deleted_paths"]
|
|
294
|
+
deleted_tuple = tuple(str(x) for x in deleted_paths) if isinstance(deleted_paths, (list, tuple)) else ()
|
|
295
|
+
raw_pr = values["pr_number"]
|
|
296
|
+
pr_num = raw_pr if isinstance(raw_pr, int) else int(str(raw_pr))
|
|
297
|
+
return GitHubAcquisitionSnapshot(
|
|
298
|
+
repository_owner=str(values["repository_owner"]),
|
|
299
|
+
repository_name=str(values["repository_name"]),
|
|
300
|
+
pr_number=pr_num,
|
|
301
|
+
base_sha=str(values["base_sha"]),
|
|
302
|
+
head_sha=str(values["head_sha"]),
|
|
303
|
+
base_tree_sha=str(values["base_tree_sha"]),
|
|
304
|
+
head_tree_sha=str(values["head_tree_sha"]),
|
|
305
|
+
merge_base_policy=str(values["merge_base_policy"]),
|
|
306
|
+
diff_bytes=diff_bytes,
|
|
307
|
+
diff_hash=str(values["diff_hash"]),
|
|
308
|
+
changed_paths=changed_tuple,
|
|
309
|
+
deleted_paths=deleted_tuple,
|
|
310
|
+
checks=checks_tuple,
|
|
311
|
+
pagination_complete=bool(values["pagination_complete"]),
|
|
312
|
+
observed_at=str(values["observed_at"]),
|
|
313
|
+
freshness_cas=str(values["freshness_cas"]),
|
|
314
|
+
locator_hash=locator.locator_hash,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def acquire_github_pull_request(
|
|
319
|
+
port: GitHubReadPort, locator: GitHubPullRequestLocator
|
|
320
|
+
) -> GitHubAcquisitionSnapshot:
|
|
321
|
+
"""Read twice and admit only convergent, complete, immutable identity."""
|
|
322
|
+
if not isinstance(locator, GitHubPullRequestLocator):
|
|
323
|
+
raise TypeError("locator must be GitHubPullRequestLocator")
|
|
324
|
+
try:
|
|
325
|
+
first = port.read_pull_request(locator)
|
|
326
|
+
second = port.read_pull_request(locator)
|
|
327
|
+
except PermissionError as exc:
|
|
328
|
+
raise PermissionDenied("GitHub read permission denied") from exc
|
|
329
|
+
left = _parse(first, locator)
|
|
330
|
+
right = _parse(second, locator)
|
|
331
|
+
if left != right:
|
|
332
|
+
raise AcquisitionDriftError("independent GitHub reads disagree")
|
|
333
|
+
return left
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def serialize_github_acquisition_snapshot(snapshot: GitHubAcquisitionSnapshot) -> dict[str, object]:
|
|
337
|
+
if type(snapshot) is not GitHubAcquisitionSnapshot:
|
|
338
|
+
raise TypeError("snapshot must be GitHubAcquisitionSnapshot")
|
|
339
|
+
return snapshot.to_dict()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def load_github_acquisition_snapshot(payload: Mapping[str, object]) -> GitHubAcquisitionSnapshot:
|
|
343
|
+
if not isinstance(payload, Mapping):
|
|
344
|
+
raise TypeError("payload must be a mapping")
|
|
345
|
+
values = dict(payload)
|
|
346
|
+
try:
|
|
347
|
+
raw_diff = values["diff_bytes"]
|
|
348
|
+
diff_bytes = bytes.fromhex(str(raw_diff))
|
|
349
|
+
raw_checks = values["checks"]
|
|
350
|
+
checks_tuple: tuple[tuple[str, str], ...] = tuple(
|
|
351
|
+
(str(x[0]), str(x[1]))
|
|
352
|
+
for x in raw_checks
|
|
353
|
+
if isinstance(x, (list, tuple)) and len(x) == 2
|
|
354
|
+
) if isinstance(raw_checks, (list, tuple)) else ()
|
|
355
|
+
raw_changed = values["changed_paths"]
|
|
356
|
+
changed_tuple = tuple(str(x) for x in raw_changed) if isinstance(raw_changed, (list, tuple)) else ()
|
|
357
|
+
raw_deleted = values["deleted_paths"]
|
|
358
|
+
deleted_tuple = tuple(str(x) for x in raw_deleted) if isinstance(raw_deleted, (list, tuple)) else ()
|
|
359
|
+
raw_pr = values["pr_number"]
|
|
360
|
+
pr_num = raw_pr if isinstance(raw_pr, int) else int(str(raw_pr))
|
|
361
|
+
return GitHubAcquisitionSnapshot(
|
|
362
|
+
repository_owner=str(values["repository_owner"]),
|
|
363
|
+
repository_name=str(values["repository_name"]),
|
|
364
|
+
pr_number=pr_num,
|
|
365
|
+
base_sha=str(values["base_sha"]),
|
|
366
|
+
head_sha=str(values["head_sha"]),
|
|
367
|
+
base_tree_sha=str(values["base_tree_sha"]),
|
|
368
|
+
head_tree_sha=str(values["head_tree_sha"]),
|
|
369
|
+
merge_base_policy=str(values["merge_base_policy"]),
|
|
370
|
+
diff_bytes=diff_bytes,
|
|
371
|
+
diff_hash=str(values["diff_hash"]),
|
|
372
|
+
changed_paths=changed_tuple,
|
|
373
|
+
deleted_paths=deleted_tuple,
|
|
374
|
+
checks=checks_tuple,
|
|
375
|
+
pagination_complete=bool(values["pagination_complete"]),
|
|
376
|
+
observed_at=str(values["observed_at"]),
|
|
377
|
+
freshness_cas=str(values["freshness_cas"]),
|
|
378
|
+
locator_hash=str(values["locator_hash"]),
|
|
379
|
+
)
|
|
380
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
381
|
+
raise AcquisitionError("malformed serialized acquisition snapshot") from exc
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
GitHubPullRequestAcquisition = GitHubAcquisitionSnapshot
|
|
385
|
+
acquire_pull_request = acquire_github_pull_request
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
__all__ = [
|
|
389
|
+
"AcquisitionError",
|
|
390
|
+
"AcquisitionDriftError",
|
|
391
|
+
"GitHubAcquisitionSnapshot",
|
|
392
|
+
"GitHubPullRequestAcquisition",
|
|
393
|
+
"GitHubPullRequestLocator",
|
|
394
|
+
"GitHubReadPort",
|
|
395
|
+
"PermissionDenied",
|
|
396
|
+
"acquire_github_pull_request",
|
|
397
|
+
"acquire_pull_request",
|
|
398
|
+
"load_github_acquisition_snapshot",
|
|
399
|
+
"serialize_github_acquisition_snapshot",
|
|
400
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""External compatibility adapters."""
|