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.
Files changed (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. 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