patchground 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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Installed pack lifecycle: add, list, update and remove, plus re-exported resolution APIs.
|
|
2
|
+
|
|
3
|
+
Installation only fetches, copies and structurally validates; it never builds or runs. Content is
|
|
4
|
+
captured once (hashed while copied), validated in that captured copy and published atomically into
|
|
5
|
+
the immutable cache, then the catalog is replaced atomically. A failure at any point leaves the
|
|
6
|
+
previous catalog and active version untouched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from patchground.packs.execution import (
|
|
18
|
+
Inspection,
|
|
19
|
+
InstalledChallengeSummary,
|
|
20
|
+
InstalledDiscoveryResult,
|
|
21
|
+
discover_installed,
|
|
22
|
+
inspect_pack,
|
|
23
|
+
make_review,
|
|
24
|
+
prepare_execution,
|
|
25
|
+
resolve_challenge,
|
|
26
|
+
show_reference,
|
|
27
|
+
)
|
|
28
|
+
from patchground.packs.models import (
|
|
29
|
+
ConsentRequired,
|
|
30
|
+
PackError,
|
|
31
|
+
PackInfo,
|
|
32
|
+
PackListing,
|
|
33
|
+
PackProblem,
|
|
34
|
+
PackRemoval,
|
|
35
|
+
PackUpdateResult,
|
|
36
|
+
SourceSnapshot,
|
|
37
|
+
)
|
|
38
|
+
from patchground.packs.source import fetch_pack
|
|
39
|
+
from patchground.packs.store import (
|
|
40
|
+
Capture,
|
|
41
|
+
Entry,
|
|
42
|
+
PublishedStateError,
|
|
43
|
+
State,
|
|
44
|
+
Version,
|
|
45
|
+
cache_path,
|
|
46
|
+
cache_problem,
|
|
47
|
+
capture_tree,
|
|
48
|
+
ensure_dirs,
|
|
49
|
+
hold_stage,
|
|
50
|
+
identity_of,
|
|
51
|
+
load_state,
|
|
52
|
+
locked,
|
|
53
|
+
make_identity,
|
|
54
|
+
packs_dir,
|
|
55
|
+
remove_tree,
|
|
56
|
+
save_state,
|
|
57
|
+
state_root,
|
|
58
|
+
)
|
|
59
|
+
from patchground.safeio import open_directory
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"InstalledChallengeSummary",
|
|
63
|
+
"InstalledDiscoveryResult",
|
|
64
|
+
"add_pack",
|
|
65
|
+
"discover_installed",
|
|
66
|
+
"list_packs",
|
|
67
|
+
"prepare_execution",
|
|
68
|
+
"remove_pack",
|
|
69
|
+
"resolve_challenge",
|
|
70
|
+
"show_reference",
|
|
71
|
+
"update_pack",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Staged:
|
|
76
|
+
"""A fetched revision captured into a private stage and validated, not yet published."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
snap: SourceSnapshot,
|
|
81
|
+
path: Path,
|
|
82
|
+
identity: tuple[int, int],
|
|
83
|
+
capture: Capture,
|
|
84
|
+
inspection: Inspection,
|
|
85
|
+
):
|
|
86
|
+
self.snap = snap
|
|
87
|
+
self.path = path
|
|
88
|
+
self.filesystem_identity = identity
|
|
89
|
+
self.capture = capture
|
|
90
|
+
self.inspection = inspection
|
|
91
|
+
self.name = inspection.manifest.name
|
|
92
|
+
self.version = Version(
|
|
93
|
+
version=inspection.manifest.version,
|
|
94
|
+
commit=snap.commit,
|
|
95
|
+
digest=capture.digest,
|
|
96
|
+
identity=make_identity(
|
|
97
|
+
self.name,
|
|
98
|
+
inspection.manifest.version,
|
|
99
|
+
snap.source_kind,
|
|
100
|
+
snap.source,
|
|
101
|
+
snap.commit,
|
|
102
|
+
capture.digest,
|
|
103
|
+
),
|
|
104
|
+
challenges=tuple(s.model_copy(update={"path": s.id}) for s in inspection.summaries),
|
|
105
|
+
folders=capture.folder_digests(tuple(s.id for s in inspection.summaries)),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def _stage(source: str | Path, ref: str, root: Path) -> Iterator[_Staged]:
|
|
111
|
+
with fetch_pack(source, ref) as snap:
|
|
112
|
+
ensure_dirs(root)
|
|
113
|
+
path = Path(tempfile.mkdtemp(prefix=".stage-", dir=packs_dir(root))).resolve()
|
|
114
|
+
# The descriptor stays open until cleanup is done, so the identity cannot be recycled.
|
|
115
|
+
with hold_stage(path) as identity:
|
|
116
|
+
try:
|
|
117
|
+
capture = capture_tree(snap.path.resolve(), path, seal=True)
|
|
118
|
+
yield _Staged(snap, path, identity, capture, inspect_pack(path))
|
|
119
|
+
finally:
|
|
120
|
+
# Only the directory created here: a replacement at this path is not ours to delete.
|
|
121
|
+
remove_tree(path, expect=identity)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _publish(root: Path, staged: _Staged, entry: Entry | None) -> bool:
|
|
125
|
+
"""Place the staged tree in the cache (caller holds the lock); True when newly created.
|
|
126
|
+
|
|
127
|
+
An existing destination is never deleted or replaced. It is accepted only when its actual
|
|
128
|
+
bytes match what would be published (registered or not, e.g. after an interrupted add);
|
|
129
|
+
anything else fails closed.
|
|
130
|
+
"""
|
|
131
|
+
final = cache_path(root, staged.name, staged.snap.commit)
|
|
132
|
+
if final.exists() or final.is_symlink():
|
|
133
|
+
known = entry.versions.get(staged.snap.commit) if entry is not None else None
|
|
134
|
+
if known is not None and cache_problem(root, staged.name, known) is not None:
|
|
135
|
+
raise PackError("registered pack cache failed integrity verification")
|
|
136
|
+
try:
|
|
137
|
+
actual = capture_tree(final, None, seal=False).digest
|
|
138
|
+
except PackError:
|
|
139
|
+
actual = None
|
|
140
|
+
if actual != staged.version.digest or (known and known.digest != actual):
|
|
141
|
+
raise PackError("pack cache destination exists with different content; not replaced")
|
|
142
|
+
return False
|
|
143
|
+
try:
|
|
144
|
+
# The stage must still be exactly what was validated, not something swapped in since.
|
|
145
|
+
if capture_tree(staged.path, None, seal=False).digest != staged.version.digest:
|
|
146
|
+
raise PackError("staged pack changed before publication")
|
|
147
|
+
staged.path.rename(final)
|
|
148
|
+
with open_directory(final.parent, error=PackError, label="pack cache") as directory:
|
|
149
|
+
os.fsync(directory)
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise PackError("pack could not be published") from exc
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _info(root: Path, name: str, entry: Entry, commit: str) -> PackInfo:
|
|
156
|
+
ver = entry.versions[commit]
|
|
157
|
+
return PackInfo(
|
|
158
|
+
pack=identity_of(name, entry, ver),
|
|
159
|
+
path=cache_path(root, name, commit),
|
|
160
|
+
ref=entry.ref,
|
|
161
|
+
consented=ver.consented,
|
|
162
|
+
active=commit == entry.active,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def add_pack(source: str | Path, *, ref: str = "HEAD", root: Path | None = None) -> PackInfo:
|
|
167
|
+
"""Install a pack at an exact commit. Idempotent; never replaces an existing installation."""
|
|
168
|
+
base = state_root(root)
|
|
169
|
+
with _stage(source, ref, base) as staged, locked(base):
|
|
170
|
+
state = load_state(base)
|
|
171
|
+
entry = state.packs.get(staged.name)
|
|
172
|
+
if entry is not None:
|
|
173
|
+
if entry.source != staged.snap.source:
|
|
174
|
+
raise PackError(f"pack '{staged.name}' is already installed from another source")
|
|
175
|
+
if staged.snap.commit not in entry.versions:
|
|
176
|
+
raise PackError(
|
|
177
|
+
f"pack '{staged.name}' is already installed at another revision; "
|
|
178
|
+
"use pack update"
|
|
179
|
+
)
|
|
180
|
+
_publish(base, staged, entry) # verifies identical content
|
|
181
|
+
return _info(base, staged.name, entry, staged.snap.commit)
|
|
182
|
+
created = _publish(base, staged, None)
|
|
183
|
+
new = Entry(
|
|
184
|
+
source=staged.snap.source,
|
|
185
|
+
source_kind=staged.snap.source_kind,
|
|
186
|
+
ref=ref,
|
|
187
|
+
active=staged.snap.commit,
|
|
188
|
+
versions={staged.snap.commit: staged.version},
|
|
189
|
+
)
|
|
190
|
+
updated = State(packs={**state.packs, staged.name: new})
|
|
191
|
+
try:
|
|
192
|
+
save_state(base, updated)
|
|
193
|
+
except PackError as exc:
|
|
194
|
+
if created and not isinstance(exc, PublishedStateError):
|
|
195
|
+
remove_tree(
|
|
196
|
+
cache_path(base, staged.name, staged.snap.commit),
|
|
197
|
+
expect=staged.filesystem_identity,
|
|
198
|
+
)
|
|
199
|
+
raise
|
|
200
|
+
return _info(base, staged.name, new, staged.snap.commit)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def list_packs(*, root: Path | None = None) -> PackListing:
|
|
204
|
+
"""Every registered cached version. Reads only; missing state is an empty listing."""
|
|
205
|
+
base = state_root(root)
|
|
206
|
+
packs: list[PackInfo] = []
|
|
207
|
+
invalid: list[PackProblem] = []
|
|
208
|
+
for name, entry in sorted(load_state(base).packs.items()):
|
|
209
|
+
for commit in entry.versions:
|
|
210
|
+
problem = cache_problem(base, name, entry.versions[commit])
|
|
211
|
+
if problem is not None:
|
|
212
|
+
invalid.append(PackProblem(name=f"{name}@{commit[:12]}", reason=problem))
|
|
213
|
+
continue
|
|
214
|
+
packs.append(_info(base, name, entry, commit))
|
|
215
|
+
return PackListing(packs=tuple(packs), invalid=tuple(invalid))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def update_pack(
|
|
219
|
+
name: str,
|
|
220
|
+
*,
|
|
221
|
+
ref: str | None = None,
|
|
222
|
+
yes: bool = False,
|
|
223
|
+
expected_identity: str | None = None,
|
|
224
|
+
root: Path | None = None,
|
|
225
|
+
) -> PackUpdateResult:
|
|
226
|
+
"""Activate a new pinned revision of ``name`` only with explicit approval.
|
|
227
|
+
|
|
228
|
+
Without approval ``ConsentRequired`` carries the change review and nothing changes. Older
|
|
229
|
+
registered versions stay in the cache.
|
|
230
|
+
"""
|
|
231
|
+
base = state_root(root)
|
|
232
|
+
entry = load_state(base).packs.get(name)
|
|
233
|
+
if entry is None:
|
|
234
|
+
raise PackError("pack is not installed")
|
|
235
|
+
start = entry.active
|
|
236
|
+
with _stage(entry.source, ref or entry.ref, base) as staged, locked(base):
|
|
237
|
+
state = load_state(base)
|
|
238
|
+
entry = state.packs.get(name)
|
|
239
|
+
if entry is None or entry.active != start:
|
|
240
|
+
raise PackError(f"pack '{name}' changed concurrently")
|
|
241
|
+
if staged.name != name or staged.snap.source != entry.source:
|
|
242
|
+
raise PackError("fetched pack does not match the installed pack")
|
|
243
|
+
commit = staged.snap.commit
|
|
244
|
+
pack = identity_of(name, entry, staged.version)
|
|
245
|
+
if expected_identity is not None and expected_identity != pack.identity:
|
|
246
|
+
raise PackError("pack identity differs from the reviewed version")
|
|
247
|
+
if commit == entry.active:
|
|
248
|
+
_publish(base, staged, entry)
|
|
249
|
+
if ref is not None and ref != entry.ref:
|
|
250
|
+
updated = entry.model_copy(update={"ref": ref})
|
|
251
|
+
save_state(base, State(packs={**state.packs, name: updated}))
|
|
252
|
+
entry = updated
|
|
253
|
+
return PackUpdateResult(
|
|
254
|
+
current=_info(base, name, entry, commit), previous_commit=commit, changed=False
|
|
255
|
+
)
|
|
256
|
+
previous = entry.versions[entry.active]
|
|
257
|
+
review = make_review(pack, staged.inspection.recipes, previous, staged.version.folders)
|
|
258
|
+
if not yes:
|
|
259
|
+
raise ConsentRequired(review)
|
|
260
|
+
created = _publish(base, staged, entry)
|
|
261
|
+
version = staged.version.model_copy(update={"consented": True})
|
|
262
|
+
updated = entry.model_copy(
|
|
263
|
+
update={
|
|
264
|
+
"ref": ref or entry.ref,
|
|
265
|
+
"active": commit,
|
|
266
|
+
"versions": {**entry.versions, commit: version},
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
new_state = State(packs={**state.packs, name: updated})
|
|
270
|
+
try:
|
|
271
|
+
save_state(base, new_state)
|
|
272
|
+
except PackError as exc:
|
|
273
|
+
if created and not isinstance(exc, PublishedStateError):
|
|
274
|
+
remove_tree(cache_path(base, name, commit), expect=staged.filesystem_identity)
|
|
275
|
+
raise
|
|
276
|
+
return PackUpdateResult(
|
|
277
|
+
current=_info(base, name, updated, commit),
|
|
278
|
+
previous_commit=previous.commit,
|
|
279
|
+
changed=True,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def remove_pack(
|
|
284
|
+
name: str,
|
|
285
|
+
*,
|
|
286
|
+
yes: bool = False,
|
|
287
|
+
expected_identity: str | None = None,
|
|
288
|
+
root: Path | None = None,
|
|
289
|
+
) -> PackRemoval:
|
|
290
|
+
"""Remove every cached version and consent of ``name``; sessions are never touched."""
|
|
291
|
+
if not yes:
|
|
292
|
+
raise PackError("removing a pack requires confirmation")
|
|
293
|
+
base = state_root(root)
|
|
294
|
+
with locked(base):
|
|
295
|
+
state = load_state(base)
|
|
296
|
+
entry = state.packs.get(name)
|
|
297
|
+
if entry is None:
|
|
298
|
+
raise PackError("pack is not installed")
|
|
299
|
+
if (
|
|
300
|
+
expected_identity is not None
|
|
301
|
+
and expected_identity != entry.versions[entry.active].identity
|
|
302
|
+
):
|
|
303
|
+
raise PackError("pack identity differs from the confirmed version")
|
|
304
|
+
remaining = {n: e for n, e in state.packs.items() if n != name}
|
|
305
|
+
save_state(base, State(packs=remaining))
|
|
306
|
+
# Unregistered from here on: no rollback is possible or attempted, only honest reporting.
|
|
307
|
+
failed = 0
|
|
308
|
+
for commit in entry.versions:
|
|
309
|
+
try:
|
|
310
|
+
remove_tree(cache_path(base, name, commit))
|
|
311
|
+
except PackError:
|
|
312
|
+
failed += 1
|
|
313
|
+
if failed:
|
|
314
|
+
raise PackError(
|
|
315
|
+
f"pack '{name}' was unregistered, but {failed} of {len(entry.versions)} cached "
|
|
316
|
+
"versions could not be removed and remain on disk unregistered"
|
|
317
|
+
)
|
|
318
|
+
return PackRemoval(name=name, removed_versions=len(entry.versions))
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Import raw Git objects through isolated metadata, never a source checkout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import contextmanager, suppress
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
18
|
+
|
|
19
|
+
from patchground import safeio
|
|
20
|
+
from patchground.packs.models import PackError, SourceSnapshot
|
|
21
|
+
|
|
22
|
+
MAX_BYTES = 100 * 1024 * 1024
|
|
23
|
+
MAX_FILES = 10_000
|
|
24
|
+
TIMEOUT = 300
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run_process(
|
|
28
|
+
command: list[str], *, cwd: Path, env: dict[str, str], timeout: float
|
|
29
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
30
|
+
"""Cancel Git and its HTTPS helpers together on timeout or interruption."""
|
|
31
|
+
with subprocess.Popen(
|
|
32
|
+
command,
|
|
33
|
+
cwd=cwd,
|
|
34
|
+
env=env,
|
|
35
|
+
stdout=subprocess.PIPE,
|
|
36
|
+
stderr=subprocess.DEVNULL,
|
|
37
|
+
start_new_session=True,
|
|
38
|
+
) as process:
|
|
39
|
+
try:
|
|
40
|
+
stdout, _ = process.communicate(timeout=timeout)
|
|
41
|
+
except BaseException:
|
|
42
|
+
with suppress(ProcessLookupError):
|
|
43
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
44
|
+
process.communicate()
|
|
45
|
+
raise
|
|
46
|
+
return subprocess.CompletedProcess(command, process.returncode, stdout)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _source(source: str | Path) -> tuple[str, Literal["https", "local"], Path | None]:
|
|
50
|
+
value = str(source)
|
|
51
|
+
if "://" in value or value.startswith(("git@", "ssh:", "file:")):
|
|
52
|
+
try:
|
|
53
|
+
url = urlsplit(value)
|
|
54
|
+
if (
|
|
55
|
+
url.scheme != "https"
|
|
56
|
+
or not url.hostname
|
|
57
|
+
or url.username is not None
|
|
58
|
+
or url.password is not None
|
|
59
|
+
or url.query
|
|
60
|
+
or url.fragment
|
|
61
|
+
or "%" in url.netloc
|
|
62
|
+
or any(c.isspace() for c in value)
|
|
63
|
+
):
|
|
64
|
+
raise ValueError
|
|
65
|
+
port = url.port
|
|
66
|
+
host = url.hostname.lower()
|
|
67
|
+
if ":" in host:
|
|
68
|
+
host = f"[{host}]"
|
|
69
|
+
netloc = host + (f":{port}" if port and port != 443 else "")
|
|
70
|
+
return urlunsplit(("https", netloc, url.path.rstrip("/"), "", "")), "https", None
|
|
71
|
+
except ValueError:
|
|
72
|
+
raise PackError("pack source must be public HTTPS without credentials") from None
|
|
73
|
+
path = Path(value).absolute()
|
|
74
|
+
safeio.no_links(path, error=PackError, label="pack source")
|
|
75
|
+
return str(path.resolve()), "local", path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _copy_tree(parent: int, name: str, destination: Path, deadline: float) -> None:
|
|
79
|
+
if time.monotonic() > deadline:
|
|
80
|
+
raise PackError("pack transport timed out")
|
|
81
|
+
with safeio.open_entry(parent, name, error=PackError, label="Git data") as fd:
|
|
82
|
+
mode = os.fstat(fd).st_mode
|
|
83
|
+
if stat.S_ISDIR(mode):
|
|
84
|
+
destination.mkdir()
|
|
85
|
+
for child in os.listdir(fd): # noqa: PTH208 - descriptor-pinned listing
|
|
86
|
+
# Alternates and commit graphs are unnecessary and may reference outside data.
|
|
87
|
+
if destination.name == "objects" and child == "info":
|
|
88
|
+
continue
|
|
89
|
+
_copy_tree(fd, child, destination / child, deadline)
|
|
90
|
+
else:
|
|
91
|
+
with destination.open("xb") as output:
|
|
92
|
+
while data := os.read(fd, 1024 * 1024):
|
|
93
|
+
if time.monotonic() > deadline:
|
|
94
|
+
raise PackError("pack transport timed out")
|
|
95
|
+
output.write(data)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@contextmanager
|
|
99
|
+
def fetch_pack(source: str | Path, ref: str = "HEAD") -> Iterator[SourceSnapshot]:
|
|
100
|
+
"""Yield a private, temporary exact-commit regular-file tree with provenance."""
|
|
101
|
+
try:
|
|
102
|
+
canonical, kind, local = _source(source)
|
|
103
|
+
if not ref or ref.startswith("-") or any(c in ref for c in "\x00\r\n"):
|
|
104
|
+
raise PackError("invalid pack revision")
|
|
105
|
+
git = safeio.resolve_git(*((local,) if local else ()), error=PackError)
|
|
106
|
+
deadline = time.monotonic() + TIMEOUT
|
|
107
|
+
with tempfile.TemporaryDirectory(prefix="patchground-source-") as temporary:
|
|
108
|
+
root = Path(temporary)
|
|
109
|
+
metadata = root / "repo"
|
|
110
|
+
home = root / "home"
|
|
111
|
+
home.mkdir()
|
|
112
|
+
env = {
|
|
113
|
+
"PATH": str(Path(git).parent) + os.pathsep + os.defpath,
|
|
114
|
+
"HOME": str(home),
|
|
115
|
+
"XDG_CONFIG_HOME": str(home),
|
|
116
|
+
"LC_ALL": "C",
|
|
117
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
118
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
119
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
120
|
+
"GIT_NO_REPLACE_OBJECTS": "1",
|
|
121
|
+
"GIT_ALLOW_PROTOCOL": "https",
|
|
122
|
+
"GIT_ATTR_NOSYSTEM": "1",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def run(*args: str) -> bytes:
|
|
126
|
+
remaining = deadline - time.monotonic()
|
|
127
|
+
if remaining <= 0:
|
|
128
|
+
raise PackError("pack transport timed out")
|
|
129
|
+
result = _run_process(
|
|
130
|
+
[
|
|
131
|
+
git,
|
|
132
|
+
"-c",
|
|
133
|
+
"core.hooksPath=/dev/null",
|
|
134
|
+
"-c",
|
|
135
|
+
"credential.helper=",
|
|
136
|
+
"-c",
|
|
137
|
+
"http.followRedirects=false",
|
|
138
|
+
"-c",
|
|
139
|
+
"gc.auto=0",
|
|
140
|
+
"-c",
|
|
141
|
+
"maintenance.auto=false",
|
|
142
|
+
"--git-dir",
|
|
143
|
+
str(metadata),
|
|
144
|
+
*args,
|
|
145
|
+
],
|
|
146
|
+
cwd=root,
|
|
147
|
+
env=env,
|
|
148
|
+
timeout=remaining,
|
|
149
|
+
)
|
|
150
|
+
if result.returncode:
|
|
151
|
+
raise PackError("cannot read pack source or revision")
|
|
152
|
+
return result.stdout
|
|
153
|
+
|
|
154
|
+
if local is not None:
|
|
155
|
+
safeio.safe_path(local, directory=True, error=PackError, label="pack source")
|
|
156
|
+
source_git = local / ".git" if (local / ".git").exists() else local
|
|
157
|
+
with safeio.open_directory(source_git, error=PackError, label="Git data") as fd:
|
|
158
|
+
object_format = "sha1"
|
|
159
|
+
with safeio.open_entry(fd, "config", error=PackError) as config:
|
|
160
|
+
config_data = os.read(config, 1024 * 1024 + 1)
|
|
161
|
+
if len(config_data) > 1024 * 1024:
|
|
162
|
+
raise PackError("Git configuration is too large")
|
|
163
|
+
if re.search(rb"^\s*objectformat\s*=\s*sha256\s*$", config_data, re.M | re.I):
|
|
164
|
+
object_format = "sha256"
|
|
165
|
+
run("init", "--bare", f"--object-format={object_format}", "--template=")
|
|
166
|
+
for name in ("objects", "refs", "HEAD", "packed-refs", "shallow"):
|
|
167
|
+
if name not in os.listdir(fd): # noqa: PTH208 - descriptor-pinned listing
|
|
168
|
+
continue
|
|
169
|
+
target = metadata / name
|
|
170
|
+
if target.is_dir():
|
|
171
|
+
shutil.rmtree(target)
|
|
172
|
+
elif target.exists():
|
|
173
|
+
target.unlink()
|
|
174
|
+
_copy_tree(fd, name, target, deadline)
|
|
175
|
+
revision = ref
|
|
176
|
+
else:
|
|
177
|
+
run("init", "--bare", "--template=")
|
|
178
|
+
run("fetch", "--no-tags", "--no-recurse-submodules", "--depth=1", canonical, ref)
|
|
179
|
+
revision = "FETCH_HEAD"
|
|
180
|
+
commit = run(
|
|
181
|
+
"rev-parse", "--verify", "--end-of-options", revision + "^{commit}"
|
|
182
|
+
).strip()
|
|
183
|
+
if not re.fullmatch(rb"(?:[0-9a-f]{40}|[0-9a-f]{64})", commit):
|
|
184
|
+
raise PackError("invalid pack commit")
|
|
185
|
+
# Git normally trusts loose-object names; verify hashes before provenance is asserted.
|
|
186
|
+
run("fsck", "--full", "--no-reflogs", "--no-dangling", commit.decode())
|
|
187
|
+
entries = run("ls-tree", "-r", "-l", "-z", commit.decode()).split(b"\0")
|
|
188
|
+
files: list[tuple[Path, str, int, bool]] = []
|
|
189
|
+
total = 0
|
|
190
|
+
for entry in entries:
|
|
191
|
+
if not entry:
|
|
192
|
+
continue
|
|
193
|
+
header, raw_path = entry.split(b"\t", 1)
|
|
194
|
+
mode, obj_type, oid, size = header.split()
|
|
195
|
+
parts = raw_path.decode("utf-8").split("/")
|
|
196
|
+
if (
|
|
197
|
+
mode not in (b"100644", b"100755")
|
|
198
|
+
or obj_type != b"blob"
|
|
199
|
+
or any(
|
|
200
|
+
p in ("", ".", "..") or p.lower() == ".git" or "\\" in p or ":" in p
|
|
201
|
+
for p in parts
|
|
202
|
+
)
|
|
203
|
+
):
|
|
204
|
+
raise PackError("pack contains an unsupported or unsafe Git entry")
|
|
205
|
+
length = int(size)
|
|
206
|
+
total += length
|
|
207
|
+
files.append((Path(*parts), oid.decode(), length, mode == b"100755"))
|
|
208
|
+
if total > MAX_BYTES or len(files) > MAX_FILES:
|
|
209
|
+
raise PackError("pack exceeds exported size or file limit")
|
|
210
|
+
tree = root / "tree"
|
|
211
|
+
tree.mkdir()
|
|
212
|
+
for relative, blob_id, length, executable in files:
|
|
213
|
+
data = run("cat-file", "blob", blob_id)
|
|
214
|
+
if len(data) != length:
|
|
215
|
+
raise PackError("pack blob changed during retrieval")
|
|
216
|
+
if data.startswith(b"version https://git-lfs.github.com/spec/v1"):
|
|
217
|
+
raise PackError("Git LFS content is unsupported")
|
|
218
|
+
target = tree / relative
|
|
219
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
target.write_bytes(data)
|
|
221
|
+
target.chmod(0o755 if executable else 0o644)
|
|
222
|
+
yield SourceSnapshot(
|
|
223
|
+
path=tree, source=canonical, source_kind=kind, commit=commit.decode()
|
|
224
|
+
)
|
|
225
|
+
except PackError:
|
|
226
|
+
raise
|
|
227
|
+
except subprocess.TimeoutExpired:
|
|
228
|
+
raise PackError("pack transport timed out") from None
|
|
229
|
+
except (OSError, ValueError, UnicodeError, RuntimeError):
|
|
230
|
+
raise PackError("cannot safely retrieve pack source") from None
|